Unverified 提交 139b3ffe authored 作者: keppo-bot[bot]'s avatar keppo-bot[bot] 提交者: GitHub

feat: add "Group tabs by app" context menu option (#3150)

## Summary - Adds a new right-click context menu option on chat tabs that reorders tabs so they are grouped by app - Within each app group the original relative order is preserved, and app groups appear in the order their first tab was encountered - The option is disabled when all open tabs belong to the same app - Includes i18n support (en, pt-BR, zh-CN) and unit tests Closes #3126 ## Test plan - Right-click on a chat tab and verify the "Group tabs by app" option appears - With tabs from multiple apps open, click the option and verify tabs are reordered by app grouping - With tabs from only one app, verify the option is disabled - Run `npm test` — all 1003 tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/3150" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> Co-authored-by: 's avatarWill Chen <7344640+wwwillchen@users.noreply.github.com> Co-authored-by: 's avatarClaude <noreply@anthropic.com>
上级 7ca7b0c1
...@@ -15,15 +15,16 @@ import { ...@@ -15,15 +15,16 @@ import {
getOrderedRecentChatIds, getOrderedRecentChatIds,
getVisibleTabCapacity, getVisibleTabCapacity,
getFallbackChatIdAfterClose, getFallbackChatIdAfterClose,
groupChatIdsByApp,
partitionChatsByVisibleCount, partitionChatsByVisibleCount,
reorderVisibleChatIds, reorderVisibleChatIds,
} from "@/components/chat/ChatTabs"; } from "@/components/chat/ChatTabs";
import type { ChatSummary } from "@/lib/schemas"; import type { ChatSummary } from "@/lib/schemas";
function chat(id: number): ChatSummary { function chat(id: number, appId = 1): ChatSummary {
return { return {
id, id,
appId: 1, appId,
title: `Chat ${id}`, title: `Chat ${id}`,
createdAt: new Date(), createdAt: new Date(),
}; };
...@@ -197,3 +198,42 @@ describe("close multiple tabs", () => { ...@@ -197,3 +198,42 @@ describe("close multiple tabs", () => {
expect(store.get(recentViewedChatIdsAtom)).toEqual([1, 2, 3]); expect(store.get(recentViewedChatIdsAtom)).toEqual([1, 2, 3]);
}); });
}); });
describe("groupChatIdsByApp", () => {
function toMap(chats: ChatSummary[]): Map<number, ChatSummary> {
return new Map(chats.map((c) => [c.id, c]));
}
it("groups interleaved apps while preserving within-group order", () => {
// app1: chats 1, 3, 5 | app2: chats 2, 4
const chats = [chat(1, 1), chat(2, 2), chat(3, 1), chat(4, 2), chat(5, 1)];
const result = groupChatIdsByApp([1, 2, 3, 4, 5], toMap(chats));
// app1 group first (seen first at index 0), then app2
expect(result).toEqual([1, 3, 5, 2, 4]);
});
it("returns same order when all tabs belong to one app", () => {
const chats = [chat(1, 1), chat(2, 1), chat(3, 1)];
const result = groupChatIdsByApp([1, 2, 3], toMap(chats));
expect(result).toEqual([1, 2, 3]);
});
it("handles empty input", () => {
expect(groupChatIdsByApp([], new Map())).toEqual([]);
});
it("orders app groups by first appearance", () => {
// app3 appears first, then app1, then app2
const chats = [chat(10, 3), chat(20, 1), chat(30, 2), chat(40, 3)];
const result = groupChatIdsByApp([10, 20, 30, 40], toMap(chats));
expect(result).toEqual([10, 40, 20, 30]);
});
it("handles chat IDs missing from chatsById gracefully", () => {
const chats = [chat(1, 1), chat(3, 2)];
// chatId 2 is not in the map — should be placed in fallback group (-1)
const result = groupChatIdsByApp([1, 2, 3], toMap(chats));
// app1 first (chat 1), then unknown (chat 2), then app2 (chat 3)
expect(result).toEqual([1, 2, 3]);
});
});
...@@ -170,6 +170,31 @@ export function partitionChatsByVisibleCount( ...@@ -170,6 +170,31 @@ export function partitionChatsByVisibleCount(
}; };
} }
/**
* Reorders chat IDs so that tabs for the same app are grouped together.
* Within each app group the original relative order is preserved.
* App groups are ordered by the position of their first chat in the input.
*/
export function groupChatIdsByApp(
orderedChatIds: number[],
chatsById: Map<number, ChatSummary>,
): number[] {
// Build groups keyed by appId, preserving encounter order via a Map.
const groups = new Map<number, number[]>();
for (const chatId of orderedChatIds) {
const chat = chatsById.get(chatId);
const appId = chat?.appId ?? -1;
let group = groups.get(appId);
if (!group) {
group = [];
groups.set(appId, group);
}
group.push(chatId);
}
// Flatten groups (Map preserves insertion order → first-seen app comes first).
return Array.from(groups.values()).flat();
}
export function getFallbackChatIdAfterClose( export function getFallbackChatIdAfterClose(
tabs: ChatSummary[], tabs: ChatSummary[],
closedChatId: number, closedChatId: number,
...@@ -481,6 +506,24 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) { ...@@ -481,6 +506,24 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) {
closeTabsAndClearNotifications(idsToClose, fallback); closeTabsAndClearNotifications(idsToClose, fallback);
}; };
const handleGroupByApp = () => {
const grouped = groupChatIdsByApp(orderedChatIds, chatsById);
if (!isSameIdOrder(orderedChatIds, grouped)) {
setRecentViewedChatIds(grouped);
}
};
// Check whether tabs span more than one app (used to enable/disable grouping)
const hasMultipleApps = useMemo(() => {
const appIds = new Set<number>();
for (const chatId of orderedChatIds) {
const chat = chatsById.get(chatId);
if (chat) appIds.add(chat.appId);
if (appIds.size > 1) return true;
}
return false;
}, [orderedChatIds, chatsById]);
if (orderedChats.length === 0) return null; if (orderedChats.length === 0) return null;
return ( return (
...@@ -656,6 +699,13 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) { ...@@ -656,6 +699,13 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) {
> >
{t("closeTabsToRight")} {t("closeTabsToRight")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onClick={handleGroupByApp}
disabled={!hasMultipleApps}
>
{t("groupTabsByApp")}
</ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
); );
......
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
"closeTab": "Close", "closeTab": "Close",
"closeOtherTabs": "Close other tabs", "closeOtherTabs": "Close other tabs",
"closeTabsToRight": "Close tabs to the right", "closeTabsToRight": "Close tabs to the right",
"groupTabsByApp": "Group tabs by app",
"chatInProgress": "Chat in progress", "chatInProgress": "Chat in progress",
"newActivity": "New activity", "newActivity": "New activity",
"openOverflowTabs": "Open more tabs ({{count}})", "openOverflowTabs": "Open more tabs ({{count}})",
......
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
"closeTab": "Fechar", "closeTab": "Fechar",
"closeOtherTabs": "Fechar outras abas", "closeOtherTabs": "Fechar outras abas",
"closeTabsToRight": "Fechar abas à direita", "closeTabsToRight": "Fechar abas à direita",
"groupTabsByApp": "Agrupar abas por app",
"chatInProgress": "Chat em andamento", "chatInProgress": "Chat em andamento",
"newActivity": "Nova atividade", "newActivity": "Nova atividade",
"openOverflowTabs": "Abrir mais abas ({{count}})", "openOverflowTabs": "Abrir mais abas ({{count}})",
......
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
"closeTab": "关闭", "closeTab": "关闭",
"closeOtherTabs": "关闭其他标签页", "closeOtherTabs": "关闭其他标签页",
"closeTabsToRight": "关闭右侧标签页", "closeTabsToRight": "关闭右侧标签页",
"groupTabsByApp": "按应用分组标签页",
"chatInProgress": "聊天进行中", "chatInProgress": "聊天进行中",
"newActivity": "新活动", "newActivity": "新活动",
"openOverflowTabs": "打开更多标签页({{count}})", "openOverflowTabs": "打开更多标签页({{count}})",
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论