Unverified 提交 4a0e3571 authored 作者: wwwillchen-bot's avatar wwwillchen-bot 提交者: GitHub

Add context menu to chat tabs with bulk close actions (#2705)

## Summary - Add right-click context menu to chat tabs with options: Close tab, Close other tabs, Close tabs to the right, Close all tabs - Track which chats were opened in the current session via `sessionOpenedChatIdsAtom` - only tabs explicitly opened this session are shown - Add `context-menu.tsx` UI component from shadcn/ui with Radix UI dependency - Add translations for context menu items (en, pt-BR, zh-CN) ## Test plan - [x] Unit tests added for `getOrderedRecentChatIds` with session filtering - [x] Unit tests added for `closeMultipleTabsAtom` - [x] E2E tests added for context menu interactions (close other tabs, close tabs to right, close all) - [ ] Manual testing: right-click a chat tab and verify context menu appears with all options - [ ] Manual testing: verify "Close other tabs" closes all tabs except the clicked one - [ ] Manual testing: verify "Close tabs to the right" closes only tabs to the right - [ ] Manual testing: verify "Close all" closes all tabs 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2705" 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 <willchen90@gmail.com> Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com>
上级 8fb9115b
...@@ -92,6 +92,7 @@ This is the only supported way to type-check the project. It uses the correct co ...@@ -92,6 +92,7 @@ This is the only supported way to type-check the project. It uses the correct co
- Favor descriptive module/function names that mirror IPC channel semantics. - Favor descriptive module/function names that mirror IPC channel semantics.
- Keep Electron security practices in mind (no `remote`, validate/lock by `appId` when mutating shared resources). - Keep Electron security practices in mind (no `remote`, validate/lock by `appId` when mutating shared resources).
- Add tests in the same folder tree when touching renderer components. - Add tests in the same folder tree when touching renderer components.
- **Always use Base UI (`@base-ui/react`) for UI primitives, never Radix UI.** This includes menus, tooltips, accordions, context menus, and other headless UI components. See [rules/base-ui-components.md](rules/base-ui-components.md) for component-specific guidance.
Use these guidelines whenever you work within this repository. Use these guidelines whenever you work within this repository.
......
...@@ -96,3 +96,115 @@ test("closing a tab removes it and selects adjacent tab", async ({ po }) => { ...@@ -96,3 +96,115 @@ test("closing a tab removes it and selects adjacent tab", async ({ po }) => {
expect(newCount).toBe(initialCount - 1); expect(newCount).toBe(initialCount - 1);
}).toPass({ timeout: Timeout.MEDIUM }); }).toPass({ timeout: Timeout.MEDIUM });
}); });
test("right-click context menu: Close other tabs", async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.importApp("minimal");
// Chat 1
await po.sendPrompt("[dump] Chat one context menu");
await po.chatActions.waitForChatCompletion();
// Chat 2
await po.chatActions.clickNewChat();
await po.sendPrompt("[dump] Chat two context menu");
await po.chatActions.waitForChatCompletion();
// Chat 3
await po.chatActions.clickNewChat();
await po.sendPrompt("[dump] Chat three context menu");
await po.chatActions.waitForChatCompletion();
// Wait for 3 tabs to appear
const closeButtons = po.page.getByLabel(/^Close tab:/);
await expect(async () => {
const count = await closeButtons.count();
expect(count).toBe(3);
}).toPass({ timeout: Timeout.MEDIUM });
// Right-click on the second tab to open context menu
const tabs = po.page.locator("div[draggable]");
await tabs.nth(1).click({ button: "right" });
// Click "Close other tabs" from context menu
await po.page.getByText("Close other tabs").click();
// After closing other tabs, only 1 tab should remain
await expect(async () => {
const newCount = await closeButtons.count();
expect(newCount).toBe(1);
}).toPass({ timeout: Timeout.MEDIUM });
});
test("right-click context menu: Close tabs to the right", async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.importApp("minimal");
// Chat 1
await po.sendPrompt("[dump] Left tab one");
await po.chatActions.waitForChatCompletion();
// Chat 2
await po.chatActions.clickNewChat();
await po.sendPrompt("[dump] Left tab two");
await po.chatActions.waitForChatCompletion();
// Chat 3
await po.chatActions.clickNewChat();
await po.sendPrompt("[dump] Right tab one");
await po.chatActions.waitForChatCompletion();
// Chat 4
await po.chatActions.clickNewChat();
await po.sendPrompt("[dump] Right tab two");
await po.chatActions.waitForChatCompletion();
// Wait for 4 tabs to appear
const closeButtons = po.page.getByLabel(/^Close tab:/);
await expect(async () => {
const count = await closeButtons.count();
expect(count).toBe(4);
}).toPass({ timeout: Timeout.MEDIUM });
// Right-click on the second tab (index 1) to open context menu
const tabs = po.page.locator("div[draggable]");
await tabs.nth(1).click({ button: "right" });
// Click "Close tabs to the right" from context menu
await po.page.getByText("Close tabs to the right").click();
// After closing tabs to the right, only 2 tabs should remain (first and second)
await expect(async () => {
const newCount = await closeButtons.count();
expect(newCount).toBe(2);
}).toPass({ timeout: Timeout.MEDIUM });
});
test("only shows tabs for chats opened in current session", async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.importApp("minimal");
// Initially no tabs should be visible (no chats opened yet in this session)
const closeButtons = po.page.getByLabel(/^Close tab:/);
// Create first chat
await po.sendPrompt("[dump] Session chat one");
await po.chatActions.waitForChatCompletion();
// Now exactly 1 tab should be visible
await expect(async () => {
const count = await closeButtons.count();
expect(count).toBe(1);
}).toPass({ timeout: Timeout.MEDIUM });
// Create second chat
await po.chatActions.clickNewChat();
await po.sendPrompt("[dump] Session chat two");
await po.chatActions.waitForChatCompletion();
// Now exactly 2 tabs should be visible
await expect(async () => {
const count = await closeButtons.count();
expect(count).toBe(2);
}).toPass({ timeout: Timeout.MEDIUM });
});
...@@ -20,7 +20,7 @@ ...@@ -20,7 +20,7 @@
"@ai-sdk/provider-utils": "^4.0.13", "@ai-sdk/provider-utils": "^4.0.13",
"@ai-sdk/xai": "^3.0.46", "@ai-sdk/xai": "^3.0.46",
"@babel/parser": "^7.28.5", "@babel/parser": "^7.28.5",
"@base-ui/react": "^1.1.0", "@base-ui/react": "^1.2.0",
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",
"@dyad-sh/supabase-management-js": "v1.0.1", "@dyad-sh/supabase-management-js": "v1.0.1",
"@flakiness/playwright": "^1.0.0", "@flakiness/playwright": "^1.0.0",
...@@ -662,9 +662,9 @@ ...@@ -662,9 +662,9 @@
} }
}, },
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.28.4", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
...@@ -716,16 +716,15 @@ ...@@ -716,16 +716,15 @@
} }
}, },
"node_modules/@base-ui/react": { "node_modules/@base-ui/react": {
"version": "1.1.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.2.0.tgz",
"integrity": "sha512-ikcJRNj1mOiF2HZ5jQHrXoVoHcNHdBU5ejJljcBl+VTLoYXR6FidjTN86GjO6hyshi6TZFuNvv0dEOgaOFv6Lw==", "integrity": "sha512-O6aEQHcm+QyGTFY28xuwRD3SEJGZOBDpyjN2WvpfWYFVhg+3zfXPysAILqtM0C1kWC82MccOE/v1j+GHXE4qIw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.6",
"@base-ui/utils": "0.2.4", "@base-ui/utils": "0.2.5",
"@floating-ui/react-dom": "^2.1.6", "@floating-ui/react-dom": "^2.1.6",
"@floating-ui/utils": "^0.2.10", "@floating-ui/utils": "^0.2.10",
"reselect": "^5.1.1",
"tabbable": "^6.4.0", "tabbable": "^6.4.0",
"use-sync-external-store": "^1.6.0" "use-sync-external-store": "^1.6.0"
}, },
...@@ -748,12 +747,12 @@ ...@@ -748,12 +747,12 @@
} }
}, },
"node_modules/@base-ui/utils": { "node_modules/@base-ui/utils": {
"version": "0.2.4", "version": "0.2.5",
"resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.2.4.tgz", "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.2.5.tgz",
"integrity": "sha512-smZwpMhjO29v+jrZusBSc5T+IJ3vBb9cjIiBjtKcvWmRj9Z4DWGVR3efr1eHR56/bqY5a4qyY9ElkOY5ljo3ng==", "integrity": "sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.6",
"@floating-ui/utils": "^0.2.10", "@floating-ui/utils": "^0.2.10",
"reselect": "^5.1.1", "reselect": "^5.1.1",
"use-sync-external-store": "^1.6.0" "use-sync-external-store": "^1.6.0"
......
...@@ -58,7 +58,7 @@ ...@@ -58,7 +58,7 @@
"@ai-sdk/provider-utils": "^4.0.13", "@ai-sdk/provider-utils": "^4.0.13",
"@ai-sdk/xai": "^3.0.46", "@ai-sdk/xai": "^3.0.46",
"@babel/parser": "^7.28.5", "@babel/parser": "^7.28.5",
"@base-ui/react": "^1.1.0", "@base-ui/react": "^1.2.0",
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",
"@dyad-sh/supabase-management-js": "v1.0.1", "@dyad-sh/supabase-management-js": "v1.0.1",
"@flakiness/playwright": "^1.0.0", "@flakiness/playwright": "^1.0.0",
......
# Base UI Component Patterns # Base UI Component Patterns
## Always Use Base UI, Never Radix UI
This project uses **Base UI** (`@base-ui/react`) for all headless UI primitives. **Do not use Radix UI** (`@radix-ui/*`) for any new components. This ensures:
- Consistent animation/transition behavior across all menus and popups
- Uniform keyboard navigation and focus management patterns
- Consistent ARIA attribute usage for accessibility
- A single set of APIs to learn and maintain
If you need a component not yet wrapped in `src/components/ui/`, build it using Base UI primitives following the existing patterns in that directory.
### Context Menu
The `ContextMenu` in `src/components/ui/context-menu.tsx` uses Base UI's native `ContextMenu` primitive (`@base-ui/react/context-menu`), which handles right-click and long-press detection automatically. Key differences from Radix's API:
- Use `onClick` instead of `onSelect` on `ContextMenuItem`
- `ContextMenuTrigger` renders a `<div>` wrapper — no `asChild` needed (use the `render` prop if you need to change the element type)
- Menu positioning at the cursor is handled natively by Base UI
```tsx
// Correct usage
<ContextMenu>
<ContextMenuTrigger>
<div>Right-click me</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => doSomething()}>Action</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
```
## TooltipTrigger render prop ## TooltipTrigger render prop
`TooltipTrigger` from `@base-ui/react/tooltip` (wrapped in `src/components/ui/tooltip.tsx`) renders a `<button>` by default. Wrapping another button-like element (`<button>`, `<Button>`, `<DropdownMenuTrigger>`, `<PopoverTrigger>`, `<MiniSelectTrigger>`, `<ToggleGroupItem>`) inside it creates invalid nested `<button>` HTML. Use the `render` prop instead: `TooltipTrigger` from `@base-ui/react/tooltip` (wrapped in `src/components/ui/tooltip.tsx`) renders a `<button>` by default. Wrapping another button-like element (`<button>`, `<Button>`, `<DropdownMenuTrigger>`, `<PopoverTrigger>`, `<MiniSelectTrigger>`, `<ToggleGroupItem>`) inside it creates invalid nested `<button>` HTML. Use the `render` prop instead:
......
...@@ -6,6 +6,9 @@ import { ...@@ -6,6 +6,9 @@ import {
pushRecentViewedChatIdAtom, pushRecentViewedChatIdAtom,
removeRecentViewedChatIdAtom, removeRecentViewedChatIdAtom,
pruneClosedChatIdsAtom, pruneClosedChatIdsAtom,
sessionOpenedChatIdsAtom,
addSessionOpenedChatIdAtom,
closeMultipleTabsAtom,
} from "@/atoms/chatAtoms"; } from "@/atoms/chatAtoms";
import { import {
applySelectionToOrderedChatIds, applySelectionToOrderedChatIds,
...@@ -27,15 +30,42 @@ function chat(id: number): ChatSummary { ...@@ -27,15 +30,42 @@ function chat(id: number): ChatSummary {
} }
describe("ChatTabs helpers", () => { describe("ChatTabs helpers", () => {
it("keeps MRU order and appends chats that were never viewed", () => { it("keeps MRU order and appends chats that were never viewed (session filter)", () => {
const chats = [chat(1), chat(2), chat(3), chat(4)]; const chats = [chat(1), chat(2), chat(3), chat(4)];
const orderedIds = getOrderedRecentChatIds([4, 2], chats); // All chats are in the session
const sessionIds = new Set([1, 2, 3, 4]);
const orderedIds = getOrderedRecentChatIds(
[4, 2],
chats,
new Set(),
sessionIds,
);
expect(orderedIds).toEqual([4, 2, 1, 3]); expect(orderedIds).toEqual([4, 2, 1, 3]);
}); });
it("only shows chats opened in current session", () => {
const chats = [chat(1), chat(2), chat(3), chat(4)];
// Only chats 1 and 3 are opened in the current session
const sessionIds = new Set([1, 3]);
const orderedIds = getOrderedRecentChatIds(
[4, 2, 3, 1],
chats,
new Set(),
sessionIds,
);
// Should only include chats 3 and 1 (in MRU order)
expect(orderedIds).toEqual([3, 1]);
});
it("skips stale chat ids that no longer exist", () => { it("skips stale chat ids that no longer exist", () => {
const chats = [chat(1), chat(3)]; const chats = [chat(1), chat(3)];
const orderedIds = getOrderedRecentChatIds([3, 999, 1], chats); const sessionIds = new Set([1, 3, 999]);
const orderedIds = getOrderedRecentChatIds(
[3, 999, 1],
chats,
new Set(),
sessionIds,
);
expect(orderedIds).toEqual([3, 1]); expect(orderedIds).toEqual([3, 1]);
}); });
...@@ -130,3 +160,40 @@ describe("recent viewed chat atoms", () => { ...@@ -130,3 +160,40 @@ describe("recent viewed chat atoms", () => {
expect(pruned.has(99)).toBe(false); expect(pruned.has(99)).toBe(false);
}); });
}); });
describe("session opened chat atoms", () => {
it("adds chat to session when opened", () => {
const store = createStore();
store.set(addSessionOpenedChatIdAtom, 1);
store.set(addSessionOpenedChatIdAtom, 2);
const sessionIds = store.get(sessionOpenedChatIdsAtom);
expect(sessionIds.has(1)).toBe(true);
expect(sessionIds.has(2)).toBe(true);
});
it("does not duplicate chat IDs in session", () => {
const store = createStore();
store.set(addSessionOpenedChatIdAtom, 1);
store.set(addSessionOpenedChatIdAtom, 1);
const sessionIds = store.get(sessionOpenedChatIdsAtom);
expect(sessionIds.size).toBe(1);
});
});
describe("close multiple tabs", () => {
it("closes multiple tabs at once", () => {
const store = createStore();
store.set(recentViewedChatIdsAtom, [1, 2, 3, 4, 5]);
store.set(closeMultipleTabsAtom, [2, 4]);
expect(store.get(recentViewedChatIdsAtom)).toEqual([1, 3, 5]);
expect(store.get(closedChatIdsAtom).has(2)).toBe(true);
expect(store.get(closedChatIdsAtom).has(4)).toBe(true);
});
it("handles empty array gracefully", () => {
const store = createStore();
store.set(recentViewedChatIdsAtom, [1, 2, 3]);
store.set(closeMultipleTabsAtom, []);
expect(store.get(recentViewedChatIdsAtom)).toEqual([1, 2, 3]);
});
});
...@@ -19,6 +19,8 @@ export const recentStreamChatIdsAtom = atom<Set<number>>(new Set<number>()); ...@@ -19,6 +19,8 @@ export const recentStreamChatIdsAtom = atom<Set<number>>(new Set<number>());
export const recentViewedChatIdsAtom = atom<number[]>([]); export const recentViewedChatIdsAtom = atom<number[]>([]);
// Track explicitly closed tabs - these should not reappear in the tab bar // Track explicitly closed tabs - these should not reappear in the tab bar
export const closedChatIdsAtom = atom<Set<number>>(new Set<number>()); export const closedChatIdsAtom = atom<Set<number>>(new Set<number>());
// Track chats opened in the current session - tabs are only shown for these
export const sessionOpenedChatIdsAtom = atom<Set<number>>(new Set<number>());
const MAX_RECENT_VIEWED_CHAT_IDS = 100; const MAX_RECENT_VIEWED_CHAT_IDS = 100;
// Helper to remove a chat ID from the closed set (used when a closed tab is re-opened) // Helper to remove a chat ID from the closed set (used when a closed tab is re-opened)
...@@ -43,21 +45,34 @@ export const setRecentViewedChatIdsAtom = atom( ...@@ -43,21 +45,34 @@ export const setRecentViewedChatIdsAtom = atom(
} }
}, },
); );
// Helper to add a chat ID to the session-opened set
function addToSessionSet(get: Getter, set: Setter, chatId: number): void {
const sessionIds = get(sessionOpenedChatIdsAtom);
if (!sessionIds.has(chatId)) {
const newSessionIds = new Set(sessionIds);
newSessionIds.add(chatId);
set(sessionOpenedChatIdsAtom, newSessionIds);
}
}
// Add a chat ID to the recent list only if it's not already present. // Add a chat ID to the recent list only if it's not already present.
// Unlike pushRecentViewedChatIdAtom, this does NOT move existing IDs to the front, // Unlike pushRecentViewedChatIdAtom, this does NOT move existing IDs to the front,
// preserving the current tab order for chats already tracked. // preserving the current tab order for chats already tracked.
// Also adds to session tracking so the tab appears in the tab bar.
export const ensureRecentViewedChatIdAtom = atom( export const ensureRecentViewedChatIdAtom = atom(
null, null,
(get, set, chatId: number) => { (get, set, chatId: number) => {
const currentIds = get(recentViewedChatIdsAtom); const currentIds = get(recentViewedChatIdsAtom);
if (currentIds.includes(chatId)) return; if (!currentIds.includes(chatId)) {
const nextIds = [chatId, ...currentIds]; const nextIds = [chatId, ...currentIds];
if (nextIds.length > MAX_RECENT_VIEWED_CHAT_IDS) { if (nextIds.length > MAX_RECENT_VIEWED_CHAT_IDS) {
nextIds.length = MAX_RECENT_VIEWED_CHAT_IDS; nextIds.length = MAX_RECENT_VIEWED_CHAT_IDS;
}
set(recentViewedChatIdsAtom, nextIds);
} }
set(recentViewedChatIdsAtom, nextIds);
// Remove from closed set when explicitly selected // Remove from closed set when explicitly selected
removeFromClosedSet(get, set, chatId); removeFromClosedSet(get, set, chatId);
// Track in session so the tab appears
addToSessionSet(get, set, chatId);
}, },
); );
export const pushRecentViewedChatIdAtom = atom( export const pushRecentViewedChatIdAtom = atom(
...@@ -71,6 +86,8 @@ export const pushRecentViewedChatIdAtom = atom( ...@@ -71,6 +86,8 @@ export const pushRecentViewedChatIdAtom = atom(
set(recentViewedChatIdsAtom, nextIds); set(recentViewedChatIdsAtom, nextIds);
// Remove from closed set when explicitly selected // Remove from closed set when explicitly selected
removeFromClosedSet(get, set, chatId); removeFromClosedSet(get, set, chatId);
// Track in session so the tab appears (fixes re-open after bulk close)
addToSessionSet(get, set, chatId);
}, },
); );
export const removeRecentViewedChatIdAtom = atom( export const removeRecentViewedChatIdAtom = atom(
...@@ -85,6 +102,8 @@ export const removeRecentViewedChatIdAtom = atom( ...@@ -85,6 +102,8 @@ export const removeRecentViewedChatIdAtom = atom(
const newClosedIds = new Set(closedIds); const newClosedIds = new Set(closedIds);
newClosedIds.add(chatId); newClosedIds.add(chatId);
set(closedChatIdsAtom, newClosedIds); set(closedChatIdsAtom, newClosedIds);
// Also remove from session tracking (consistent with closeMultipleTabsAtom)
removeFromSessionSet(get, set, [chatId]);
}, },
); );
// Prune closed chat IDs that no longer exist in the chats list // Prune closed chat IDs that no longer exist in the chats list
...@@ -106,6 +125,56 @@ export const pruneClosedChatIdsAtom = atom( ...@@ -106,6 +125,56 @@ export const pruneClosedChatIdsAtom = atom(
} }
}, },
); );
// Add a chat ID to the session-opened set (delegates to helper)
export const addSessionOpenedChatIdAtom = atom(
null,
(get, set, chatId: number) => addToSessionSet(get, set, chatId),
);
// Helper to remove chat IDs from the session-opened set
function removeFromSessionSet(
get: Getter,
set: Setter,
chatIds: number[],
): void {
const sessionIds = get(sessionOpenedChatIdsAtom);
let changed = false;
const newSessionIds = new Set(sessionIds);
for (const id of chatIds) {
if (newSessionIds.has(id)) {
newSessionIds.delete(id);
changed = true;
}
}
if (changed) {
set(sessionOpenedChatIdsAtom, newSessionIds);
}
}
// Close multiple tabs at once (for "Close other tabs" / "Close tabs to the right")
export const closeMultipleTabsAtom = atom(
null,
(get, set, chatIdsToClose: number[]) => {
if (chatIdsToClose.length === 0) return;
// Remove from recent viewed
const currentIds = get(recentViewedChatIdsAtom);
const closeSet = new Set(chatIdsToClose);
set(
recentViewedChatIdsAtom,
currentIds.filter((id) => !closeSet.has(id)),
);
// Add to closed set
const closedIds = get(closedChatIdsAtom);
const newClosedIds = new Set(closedIds);
for (const id of chatIdsToClose) {
newClosedIds.add(id);
}
set(closedChatIdsAtom, newClosedIds);
// Remove from session tracking to prevent unbounded growth
removeFromSessionSet(get, set, chatIdsToClose);
},
);
// Remove a chat ID from all tracking (used when chat is deleted) // Remove a chat ID from all tracking (used when chat is deleted)
export const removeChatIdFromAllTrackingAtom = atom( export const removeChatIdFromAllTrackingAtom = atom(
null, null,
...@@ -115,6 +184,8 @@ export const removeChatIdFromAllTrackingAtom = atom( ...@@ -115,6 +184,8 @@ export const removeChatIdFromAllTrackingAtom = atom(
get(recentViewedChatIdsAtom).filter((id) => id !== chatId), get(recentViewedChatIdsAtom).filter((id) => id !== chatId),
); );
removeFromClosedSet(get, set, chatId); removeFromClosedSet(get, set, chatId);
// Also remove from session tracking
removeFromSessionSet(get, set, [chatId]);
}, },
); );
......
...@@ -243,7 +243,7 @@ export function QuestionnaireInput() { ...@@ -243,7 +243,7 @@ export function QuestionnaireInput() {
currentQuestion.options && ( currentQuestion.options && (
<RadioGroup <RadioGroup
value={(responses[currentQuestion.id] as string) || ""} value={(responses[currentQuestion.id] as string) || ""}
onValueChange={(value: string) => { onValueChange={(value) => {
setResponses((prev) => ({ setResponses((prev) => ({
...prev, ...prev,
[currentQuestion.id]: value, [currentQuestion.id]: value,
......
import * as React from "react";
import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Context menu using Base UI ContextMenu primitives.
* Opens on right-click or long press natively.
*/
function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuTrigger({
className,
...props
}: ContextMenuPrimitive.Trigger.Props) {
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
className={className}
{...props}
/>
);
}
function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
);
}
interface ContextMenuContentProps extends ContextMenuPrimitive.Popup.Props {
sideOffset?: number;
}
function ContextMenuContent({
className,
sideOffset = 4,
...props
}: ContextMenuContentProps) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Positioner
className="isolate z-50 outline-none"
side="bottom"
align="start"
sideOffset={sideOffset}
>
<ContextMenuPrimitive.Popup
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--available-height) min-w-[8rem] origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Positioner>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
);
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: ContextMenuPrimitive.Item.Props & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-1 rounded-sm px-2 py-1 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: ContextMenuPrimitive.CheckboxItem.Props) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.CheckboxItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioGroup({
...props
}: ContextMenuPrimitive.RadioGroup.Props) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
);
}
function ContextMenuRadioItem({
className,
children,
...props
}: ContextMenuPrimitive.RadioItem.Props) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.RadioItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.RadioItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<"div"> & {
inset?: boolean;
}) {
return (
<div
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
}: ContextMenuPrimitive.Separator.Props) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) {
return (
<ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} />
);
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: ContextMenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubmenuTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
openOnHover={false}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</ContextMenuPrimitive.SubmenuTrigger>
);
}
function ContextMenuSubContent({
className,
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
...props
}: ContextMenuPrimitive.Popup.Props &
Pick<
ContextMenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<ContextMenuPrimitive.Popup
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Positioner>
</ContextMenuPrimitive.Portal>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};
...@@ -9,7 +9,10 @@ import { cn } from "@/lib/utils"; ...@@ -9,7 +9,10 @@ import { cn } from "@/lib/utils";
const RadioGroup = React.forwardRef< const RadioGroup = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentPropsWithoutRef<typeof BaseRadioGroup> & { Omit<
React.ComponentPropsWithoutRef<typeof BaseRadioGroup>,
"onValueChange"
> & {
onValueChange?: (value: string) => void; onValueChange?: (value: string) => void;
} }
>(({ className, onValueChange, ...props }, ref) => { >(({ className, onValueChange, ...props }, ref) => {
......
...@@ -2,6 +2,7 @@ import { useSetAtom } from "jotai"; ...@@ -2,6 +2,7 @@ import { useSetAtom } from "jotai";
import { import {
selectedChatIdAtom, selectedChatIdAtom,
pushRecentViewedChatIdAtom, pushRecentViewedChatIdAtom,
addSessionOpenedChatIdAtom,
} from "@/atoms/chatAtoms"; } from "@/atoms/chatAtoms";
import { selectedAppIdAtom } from "@/atoms/appAtoms"; import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
...@@ -10,6 +11,7 @@ export function useSelectChat() { ...@@ -10,6 +11,7 @@ export function useSelectChat() {
const setSelectedChatId = useSetAtom(selectedChatIdAtom); const setSelectedChatId = useSetAtom(selectedChatIdAtom);
const setSelectedAppId = useSetAtom(selectedAppIdAtom); const setSelectedAppId = useSetAtom(selectedAppIdAtom);
const pushRecentViewedChatId = useSetAtom(pushRecentViewedChatIdAtom); const pushRecentViewedChatId = useSetAtom(pushRecentViewedChatIdAtom);
const addSessionOpenedChatId = useSetAtom(addSessionOpenedChatIdAtom);
const navigate = useNavigate(); const navigate = useNavigate();
return { return {
...@@ -24,6 +26,8 @@ export function useSelectChat() { ...@@ -24,6 +26,8 @@ export function useSelectChat() {
}) => { }) => {
setSelectedChatId(chatId); setSelectedChatId(chatId);
setSelectedAppId(appId); setSelectedAppId(appId);
// Track this chat as opened in the current session
addSessionOpenedChatId(chatId);
if (!preserveTabOrder) { if (!preserveTabOrder) {
pushRecentViewedChatId(chatId); pushRecentViewedChatId(chatId);
} }
......
...@@ -3,6 +3,9 @@ ...@@ -3,6 +3,9 @@
"recentChats": "Recent Chats", "recentChats": "Recent Chats",
"searchChats": "Search chats", "searchChats": "Search chats",
"closeChatTab": "Close tab: {{title}}", "closeChatTab": "Close tab: {{title}}",
"closeTab": "Close",
"closeOtherTabs": "Close other tabs",
"closeTabsToRight": "Close tabs to the right",
"chatInProgress": "Chat in progress", "chatInProgress": "Chat in progress",
"newActivity": "New activity", "newActivity": "New activity",
"openOverflowTabs": "Open more tabs ({{count}})", "openOverflowTabs": "Open more tabs ({{count}})",
......
...@@ -3,6 +3,9 @@ ...@@ -3,6 +3,9 @@
"recentChats": "Chats Recentes", "recentChats": "Chats Recentes",
"searchChats": "Pesquisar chats", "searchChats": "Pesquisar chats",
"closeChatTab": "Fechar aba: {{title}}", "closeChatTab": "Fechar aba: {{title}}",
"closeTab": "Fechar",
"closeOtherTabs": "Fechar outras abas",
"closeTabsToRight": "Fechar abas à direita",
"chatInProgress": "Chat em andamento", "chatInProgress": "Chat em andamento",
"newActivity": "Nova atividade", "newActivity": "Nova atividade",
"openOverflowTabs": "Abrir mais abas ({{count}})", "openOverflowTabs": "Abrir mais abas ({{count}})",
......
...@@ -3,6 +3,9 @@ ...@@ -3,6 +3,9 @@
"recentChats": "最近的聊天", "recentChats": "最近的聊天",
"searchChats": "搜索聊天", "searchChats": "搜索聊天",
"closeChatTab": "关闭标签页:{{title}}", "closeChatTab": "关闭标签页:{{title}}",
"closeTab": "关闭",
"closeOtherTabs": "关闭其他标签页",
"closeTabsToRight": "关闭右侧标签页",
"chatInProgress": "聊天进行中", "chatInProgress": "聊天进行中",
"newActivity": "新活动", "newActivity": "新活动",
"openOverflowTabs": "打开更多标签页({{count}})", "openOverflowTabs": "打开更多标签页({{count}})",
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论