From 05292d68e413770b6a0bb89fa558e9fc9a255288 Mon Sep 17 00:00:00 2001 From: Mohamed Aziz Mejri Date: Tue, 25 Aug 2026 11:35:34 +0100 Subject: [PATCH 1/4] feat(chat): add multi-chat workspaces and workspace tabs --- plans/multi-chat-workspace.md | 388 ++++++++++++++++++ src/atoms/chatWorkspaceAtoms.test.ts | 73 ++++ src/atoms/chatWorkspaceAtoms.ts | 102 +++++ src/components/ChatPanel.tsx | 237 ++++++----- src/components/chat/ChatHeader.tsx | 47 ++- src/components/chat/ChatInput.tsx | 14 +- src/components/chat/ChatMessage.tsx | 16 +- src/components/chat/ChatPaneContext.test.tsx | 36 ++ src/components/chat/ChatPaneContext.tsx | 25 ++ src/components/chat/ChatTabs.test.ts | 20 + src/components/chat/ChatTabs.tsx | 296 ++++++++++++- src/components/chat/DyadAddIntegration.tsx | 6 +- src/components/chat/DyadAppBlueprintCard.tsx | 4 +- src/components/chat/DyadExitPlan.tsx | 4 +- src/components/chat/DyadMarkdownParser.tsx | 5 +- src/components/chat/DyadOutput.tsx | 13 +- src/components/chat/DyadStepLimit.tsx | 7 +- src/components/chat/DyadWritePlan.tsx | 7 +- src/components/chat/MessagesList.tsx | 14 +- src/components/chat/QuestionnaireInput.tsx | 6 +- .../chat/SummarizeInNewChatButton.tsx | 6 +- src/components/chat/TestAssertionsInput.tsx | 9 +- src/hooks/useIntegrationContinue.ts | 5 +- src/hooks/usePlan.ts | 8 +- src/hooks/useSelectChat.test.tsx | 18 + src/hooks/useSelectChat.ts | 6 +- src/hooks/useStreamChat.ts | 8 +- src/i18n/locales/en/chat.json | 11 +- src/i18n/locales/es/chat.json | 11 +- src/i18n/locales/ko/chat.json | 11 +- src/i18n/locales/pt-BR/chat.json | 11 +- src/i18n/locales/zh-CN/chat.json | 11 +- src/pages/chat.tsx | 267 +++++++++++- src/routes/chatSearchSchema.ts | 1 + 34 files changed, 1487 insertions(+), 216 deletions(-) create mode 100644 plans/multi-chat-workspace.md create mode 100644 src/atoms/chatWorkspaceAtoms.test.ts create mode 100644 src/atoms/chatWorkspaceAtoms.ts create mode 100644 src/components/chat/ChatPaneContext.test.tsx create mode 100644 src/components/chat/ChatPaneContext.tsx diff --git a/plans/multi-chat-workspace.md b/plans/multi-chat-workspace.md new file mode 100644 index 0000000000..51356d7312 --- /dev/null +++ b/plans/multi-chat-workspace.md @@ -0,0 +1,388 @@ +# Same-App Multi-Chat Workspace Plan + +> Generated by swarm planning session on 2026-08-24 + +## Summary + +Build a multi-chat workspace that lets a user view and interact with several chats belonging to one app at the same time. The workspace will preserve the existing single-chat experience, use an unbounded pane model with an adaptive grid, keep one clearly focused chat for navigation and shared actions, and retain one shared preview for the app. + +## Problem Statement + +Users who run several chats for one app must repeatedly switch tabs to compare approaches, reference prior discussion, monitor parallel work, and respond to approvals or errors. Tab switching hides important context and makes concurrent work harder to understand. + +The feature must extend the existing chat-tab model rather than replace it. An open tab, a chat visible in the workspace, and the focused chat are separate concepts. The default path remains one chat, while advanced users can progressively add more panes. + +## Product Principles + +- **Intuitive but power-user friendly:** keep the single-chat default unchanged and disclose workspace controls through existing tab menus. +- **Transparent over magical:** make focus, streaming, approvals, queued operations, errors, and the destination of every action explicit. +- **Delightful:** preserve drafts and scroll positions, avoid disruptive reflow, and provide polished focus, maximize, and responsive transitions with reduced-motion support. + +## Goals + +- Display several chats from the currently selected app in one window. +- Allow an arbitrary number of pane memberships in the state model without promising that an arbitrary number of full chat panels will fit on screen. +- Preserve independent messages, drafts, scrolling, streams, approvals, errors, modes, and terminal state. +- Make it impossible for an action in one pane to target another pane accidentally. +- Preserve one shared app preview/runtime surface. +- Restore a valid per-app workspace while safely pruning unavailable chats. + +## Scope + +### In Scope (MVP) + +- An ordered, app-keyed collection of visible chat IDs with no schema-level pane cap. +- An equal-size responsive grid with a minimum usable pane size. +- Full panes for chats that fit; a compact/minimized presentation for excess panes. +- Add to workspace and remove from workspace actions in existing chat tab/context menus. +- A pane-local header with title, status, focus treatment, maximize/restore, minimize, and remove actions. +- One explicit focused chat. Focusing a pane updates the route, selected tab, and global focused-chat compatibility state. +- Independent pane loading, messages, scrolling, draft, streaming, approval, error, mode/model, stop/retry, and terminal behavior. +- One shared app preview outside the chat grid. +- Concurrent chat streams, with app-changing operations continuing to use main-process app-operation coordination. +- Versioned, window-local, per-app persistence for membership, order, columns, minimized chats, and maximized chat. +- Pruning for deleted, closed, transferred, unavailable, and wrong-app chats. +- Responsive fallback to one full pane plus a pane switcher/status rail when the window is too narrow. +- Keyboard navigation, accessible regions and controls, non-color focus indicators, and reduced-motion behavior. + +### Out of Scope (Follow-up) + +- Chats from different apps in one workspace. +- Separate previews or app runtimes per chat. +- Freeform canvas positioning or arbitrary nested split trees. +- Detached panes or workspace transfer between Electron windows. +- Named workspace templates or cloud synchronization. +- Sending one prompt to several chats or other aggregate agent orchestration. +- A full aggregate activity center; the MVP may show lightweight counts and pane-local statuses. +- Drag-to-dock and advanced drag reordering if keyboard/menu ordering is sufficient for the MVP. + +## User Stories + +- As a builder, I want to view a planning chat beside an implementation chat so that I can reference one while working in the other. +- As a builder running several chats, I want to see each chat's live state so that I notice completion, errors, queued work, and approvals quickly. +- As a user, I want to add chats through the familiar tab interface so that I do not have to learn a separate navigation system. +- As a user, I want each pane to retain its draft and position so that changing focus does not disrupt my work. +- As a user, I want an unmistakable focused-pane indicator so that I know where shortcuts and shared actions will apply. +- As a user, I want removing a pane to leave its chat open so that reorganizing the workspace is safe and reversible. +- As a returning user, I want each app's valid workspace restored so that I can resume work quickly. +- As a keyboard or assistive-technology user, I want to focus, reorder, maximize, minimize, and remove panes without relying on drag gestures. + +## UX Design + +### Primary Flow + +1. The user starts in the unchanged single-chat view. +2. From a chat tab or overflow context menu, the user chooses **Add to workspace**. +3. The selected chat is added to the adaptive grid without replacing or closing the focused chat. +4. The user repeats the action for additional chats from the same app. +5. Clicking or keyboard-focusing a pane makes it the focused chat and updates the URL and selected tab. +6. The user can maximize, restore, minimize, reorder, or remove a pane. **Remove from workspace** never closes or deletes the underlying chat. +7. Exiting workspace mode returns to the focused chat in the standard single-chat view while retaining the saved workspace. + +### Layout Policy + +- **1 pane:** full chat surface. +- **2 panes:** two columns where minimum width permits. +- **3 panes:** three columns on wide windows; otherwise a 2+1 grid. +- **4 panes:** 2x2 grid by default. +- **5+ panes:** responsive scrollable grid; panes that cannot meet minimum usable dimensions use compact/minimized presentation rather than becoming micro-columns. +- **Narrow windows or high zoom:** one full pane plus a pane switcher/status rail; membership is preserved. +- The underlying membership array is not capped. Performance profiling determines whether the UI should later warn or compact at a particular count. + +The MVP uses equal-size grid tiles and a simple column preference. Arbitrary resizable split trees are deferred because they add persistence, accessibility, and responsive-layout complexity before the core workflow is validated. + +### Pane States + +- **Focused:** strong non-color-only border/header marker; global shortcuts and chat-targeted shared actions resolve here. +- **Background:** fully visible without receiving global actions or stealing focus. +- **Loading:** independent skeleton; other panes remain interactive. +- **Streaming:** pane-local progress and stop control. +- **Queued/blocked:** explains that shared app work is waiting on another operation. +- **Approval required:** prominent pane-local indicator; approval affects only its originating chat. +- **Completed/unread:** unobtrusive activity indication consistent with existing tab notifications. +- **Error:** pane-local recovery and details without blanking the workspace. +- **Missing/deleted:** inline unavailable state with a safe remove action; other panes are retained. +- **Minimized:** chat title plus running, waiting, approval, error, and unread state with restore action. +- **Maximized:** one pane temporarily occupies the chat workspace; Escape and a visible control restore the grid. + +### Shared Preview + +- Keep one preview, runtime, code/file surface, and version-preview machine for the app. +- The preview remains a sibling of the entire chat workspace, not a tile duplicated per chat. +- Actions that send preview information into a chat capture the focused chat explicitly at invocation time. +- Chat-specific version or diff context identifies the focused chat in its label. +- On constrained windows, the preview overlays/maximizes or switches surfaces instead of crushing chat panes below their minimum width. + +### Accessibility + +- Label the workspace and every pane as regions, including chat name and position such as “Chat: Checkout redesign, 2 of 4.” +- Keep DOM and keyboard order aligned with visual order. +- Announce user-initiated add/remove/focus changes and important background approval/completion states through restrained live regions. +- Use text/icon plus color for focus and status. +- Give all pane controls stable chat-specific accessible names. +- Make resize controls, if added after MVP, keyboard-operable separators with values and a reset action. +- Provide menu/keyboard alternatives for every drag interaction. +- Do not move focus when a stream completes or the grid reflows. +- Honor reduced motion for tiling and maximize transitions. + +## Technical Design + +### Identity Model + +Treat the following as distinct: + +1. **Open chat IDs:** tabs owned by the current renderer window. +2. **Visible chat IDs:** chats tiled for a particular app. +3. **Focused chat ID:** the visible chat receiving navigation and global chat-targeted actions. +4. **Pane chat ID:** the `chatId` explicitly provided to one mounted chat subtree. + +A **pane** is only the visual container that displays a chat in the workspace. The MVP does not introduce a separate `paneId`: a chat can appear at most once in a workspace, so its existing `chatId` uniquely identifies both the visible tile and all chat-specific actions inside it. For example, a pane showing chat `42` provides `chatId: 42` to its entire component subtree. If a future feature allows the same chat to appear twice with different presentation state, that feature can add a separate opaque `paneId`; it is unnecessary for this plan. + +The `/chat?id=...&appId=...` route remains the primary navigation identity. `selectedChatIdAtom` remains a centrally synchronized mirror of the focused route chat for global compatibility; it no longer implies that only one chat is mounted. + +### Workspace State + +Create a focused module such as `src/atoms/chatWorkspaceAtoms.ts`: + +```ts +interface ChatWorkspaceState { + visibleChatIds: number[]; + columnCount: 1 | 2 | 3; + minimizedChatIds: number[]; + maximizedChatId: number | null; +} + +type ChatWorkspaceByAppId = Record; +``` + +Focus is authoritative in the route and is therefore not a second persisted authority. Add write-only domain operations that clone collections: + +- `showChatInWorkspace({ appId, chatId })` +- `hideChatFromWorkspace({ appId, chatId })` +- `setWorkspaceColumns({ appId, columnCount })` +- `toggleMinimizedWorkspaceChat({ appId, chatId })` +- `toggleMaximizedWorkspaceChat({ appId, chatId })` +- `reorderWorkspaceChats({ appId, orderedChatIds })` +- `pruneWorkspaceChats({ appId, validChatIds })` + +Use a separate versioned `atomWithStorage` adapter keyed by renderer window and app. Do not add workspace data to `ChatTabSession`; tab ownership/transfer and tiled presentation are different domains. Hydration intersects saved IDs with chats that still belong to the app and are available to the window, always inserting the route chat. + +React Query continues to own IPC-backed chat/app data. Jotai owns only client-side workspace presentation. + +### Pane Scoping + +Add `ChatPaneContext` with `ChatPaneProvider`, `usePaneChatId()`, and focused-pane metadata. Despite the context's name, it provides the existing chat ID, not a new pane ID. Wrap the existing single `ChatPanel` first, then migrate pane-subtree consumers away from implicit reads of `selectedChatIdAtom` and `chatInputValueAtom`. + +Provide explicit chat-keyed APIs such as: + +```ts +useChatInputValue(chatId) +useSetChatInputValue(chatId) +usePaneChatId() +``` + +All pane-local mutations—send, stop, retry, approval, draft writes, plan actions, terminal controls, tool cards, and delayed callbacks—must carry an explicit chat ID. Singleton selection is reserved for genuinely shared/global surfaces. + +### Concurrent Work + +- Allow multiple chat streams concurrently and show status independently. +- Treat the working tree, Git state, runtime, and version-preview machine as shared app resources. +- Reuse and audit existing main-process app-operation coordination for consequential operations; do not add renderer locks. +- Surface queued, blocked, or conflicting state rather than suggesting independent app sandboxes. +- Stop, retry, and approval actions always target the originating pane by explicit chat ID. + +### Components Affected + +- `src/pages/chat.tsx` — replace the single panel with a workspace and centralize route/focus/selection synchronization while retaining one shared preview. +- `src/atoms/chatWorkspaceAtoms.ts` — new app-keyed presentation state, versioned persistence, transitions, and pruning. +- `src/components/chat/ChatWorkspace.tsx` — new responsive grid, switcher, focus, maximize/minimize, and fallback states. +- `src/components/chat/ChatPaneContext.tsx` — new scoped chat identity provider and hooks. +- `src/components/ChatPanel.tsx` — require explicit pane identity and separate shared app surfaces from pane-local surfaces. +- `src/components/chat/ChatHeader.tsx` — pane-specific status and actions using scoped identity. +- `src/components/chat/ChatTabs.tsx` — add/remove workspace actions and optional visible-pane indicators without changing tab ownership semantics. +- `src/components/chat/ChatInput.tsx` and pane descendants — replace selected-chat draft/action access with explicit chat-keyed APIs. +- `src/hooks/usePlan.ts`, `src/hooks/useIntegrationContinue.ts`, and other pane-used hooks — accept or derive the scoped pane chat ID. +- `src/window_infrastructure/chat_tab_session_storage.ts` and renderer tab-transfer/deletion cleanup — audit lifecycle integration, but keep workspace persistence separate. +- Preview-panel actions — capture the focused chat at invocation time for delayed writes. + +No database migration or new external IPC endpoint is expected for the MVP. + +## Implementation Plan + +### Phase 0: Define the Interaction Contract and Inventory Identity + +- [ ] Specify open-tab, visible-pane, focused-chat, remove-pane, close-tab, and exit-workspace semantics. +- [ ] Define minimum pane dimensions and narrow-window/zoom fallback behavior. +- [ ] Audit every `selectedChatIdAtom` and `chatInputValueAtom` consumer under `ChatPanel`. +- [ ] Classify each consumer as pane-local, focused/global, or app-level. +- [ ] Inventory app-mutating paths used by concurrent chats and verify their app-operation coordination coverage. +- [ ] Define performance budgets and representative 4-, 6-, and 10-chat fixtures. + +### Phase 1: Make a Single Chat Panel Explicitly Scoped + +- [ ] Add `ChatPaneContext` and wrap the existing single panel without changing visible behavior. +- [ ] Add explicit chat-keyed draft read/write helpers. +- [ ] Migrate chat input, header, message actions, tool cards, plan/integration flows, terminals, stop/retry, approvals, and delayed callbacks to scoped identity. +- [ ] Keep route synchronization and global focused surfaces unchanged. +- [ ] Move or gate app-owned version surfaces so multiple panels cannot duplicate one app-level machine. +- [ ] Add hybrid tests with two mounted pane providers proving identity and draft/action isolation. + +### Phase 2: Add Workspace State and Adaptive Grid + +- [ ] Add pure workspace transition helpers and unit tests. +- [ ] Add versioned, window-local, app-keyed persistence separate from tab storage. +- [ ] Hydrate and prune state against the app's valid/window-available chats. +- [ ] Add `ChatWorkspace` and initialize it with the route chat. +- [ ] Add **Add to workspace** and **Remove from workspace** actions to tab/context menus. +- [ ] Implement pane focus through one action that owns route navigation and selected-chat synchronization. +- [ ] Implement equal-grid columns, compact/minimized panes, maximize/restore, and responsive single-pane switching. +- [ ] Keep the existing preview as one shared sibling of the grid. + +### Phase 3: Complete Lifecycle and Concurrent-State Correctness + +- [ ] Prune workspace panes on deletion, tab close, and window transfer. +- [ ] Select the nearest valid focus fallback when the focused pane disappears. +- [ ] Ensure app switches never leak another app's pane membership. +- [ ] Handle missing chats without discarding the remaining workspace. +- [ ] Verify simultaneous streams, stops, retries, approvals, modes, drafts, scrolling, and terminals remain chat-scoped. +- [ ] Verify shared app operations remain coordinated and expose queued/blocked/conflict state clearly. +- [ ] Ensure preview/file/console “send to chat” actions target the focused chat captured at invocation time. + +### Phase 4: Accessibility, Performance, and Polish + +- [ ] Add workspace/pane region labels, live announcements, non-color focus treatments, and chat-specific control names. +- [ ] Add focus-next/focus-previous and keyboard reorder commands exposed through menus. +- [ ] Ensure DOM order follows visual order and maximize restore works with Escape. +- [ ] Honor reduced motion and avoid focus stealing during reflow or stream completion. +- [ ] Profile 4, 6, and 10 long chats, including multiple concurrent streams. +- [ ] Tune compact/minimized thresholds based on performance and usability evidence. +- [ ] Add lightweight running/approval/error counts if pane-local status is insufficient. + +### Phase 5: Follow-up Enhancements + +- [ ] Evaluate drag reordering with keyboard parity. +- [ ] Evaluate user-resizable grid tracks before considering arbitrary nested splits. +- [ ] Evaluate a richer aggregate activity/approval center from observed usage. +- [ ] Evaluate saved workspace templates and cross-app workspaces separately. + +## Testing Strategy + +### Unit Tests + +- [ ] Add, deduplicate, remove, reorder, minimize, maximize, and normalize columns. +- [ ] Keep workspace state independent between apps. +- [ ] Reject or prune unknown and wrong-app chats. +- [ ] Select a deterministic focus fallback after removal. +- [ ] Migrate/fallback safely from invalid persisted schemas. +- [ ] Clean up deleted, closed, and transferred chat IDs. +- [ ] Prove two pane contexts resolve different chat IDs. +- [ ] Prove a delayed write captured for pane A cannot be redirected to pane B after focus changes. + +### Component Tests + +- [ ] Render multiple chat summaries/messages in the correct panes. +- [ ] Verify add, remove, focus, minimize, maximize, responsive fallback, and restore behavior. +- [ ] Verify focused styling, route updates, and global selection remain synchronized. +- [ ] Verify independent loading/error states do not block other panes. +- [ ] Verify keyboard order, accessible labels, and focus behavior. + +### Vitest Integration Tests + +- [ ] Mount multiple real `ChatPanel` trees in one renderer/Jotai store. +- [ ] Send from chat A and prove only A receives the messages. +- [ ] Stream A and B simultaneously and prove isolation. +- [ ] Stop/retry/approve in A without affecting B. +- [ ] Verify independent draft, mode, terminal, and plan/tool behavior. +- [ ] Verify preview/file/console actions target the focused pane. +- [ ] Verify app-operation conflicts are coordinated and surfaced without corrupting either pane. + +### Playwright E2E Tests + +- [ ] Run `npm run build` before E2E. +- [ ] Open at least three chats from one app in the workspace. +- [ ] Exercise independent focus, drafts, scrolling, and statuses. +- [ ] Start work in multiple chats and verify pane-local status and targeting. +- [ ] Interact with the shared preview and verify the focused chat receives chat-targeted actions. +- [ ] Delete, close, and transfer a visible chat and verify valid fallback. +- [ ] Restart and verify persisted workspace pruning/restoration. +- [ ] Capture screenshots for the perceptible UI change. + +### Standard Checks + +- [ ] `npm run fmt` +- [ ] `npm run lint` +- [ ] `npm run ts` +- [ ] Targeted unit and hybrid integration suites. +- [ ] `npm run build` followed by the targeted E2E suite. + +## Acceptance Criteria + +1. A user can add at least three chats from one app to the workspace without navigating away. +2. The state model accepts an arbitrary ordered number of panes and the UI compacts/switches rather than shrinking full panes below usable dimensions. +3. Every pane displays the correct messages, status, and title and preserves independent drafts and scroll state. +4. Sending, stopping, retrying, approving, changing mode, or toggling terminal in pane A cannot target pane B. +5. Focus is visually and accessibly unambiguous; route, selected tab, shortcuts, and shared chat-targeted actions follow it deterministically. +6. Removing a pane does not close or delete its chat. +7. One shared preview remains available and clearly belongs to the app. +8. Multiple chats may stream concurrently, while shared app mutations are coordinated and visibly queued/blocked when necessary. +9. Per-app workspace state restores after restart and invalid chats are pruned without losing valid panes. +10. Narrow windows and large text retain access to every pane through a single-pane switcher/status rail. +11. Existing single-chat, tabs, preview, close/reopen, notification, and multi-window transfer flows do not regress. +12. Performance at 4, 6, and 10 representative chats is measured before broad scalability claims are made. + +## Success Metrics + +- Adoption among users with two or more chats for the same app. +- Percentage of workspace users who add a third or later pane. +- Repeat workspace use on at least two days within fourteen days. +- Reduction in tab switches during active multi-chat sessions. +- Time from background completion/approval-needed to user focus/action. +- Workspace abandonment or reset-to-single within sixty seconds. +- Pane loading/send/restore errors and stale-ID prune frequency. +- No regression in single-chat send success or time to interactive. +- Measured memory, CPU, and interaction responsiveness at representative pane counts. + +## Risks and Mitigations + +| Risk | Likelihood | Impact | Mitigation | +| --- | --- | --- | --- | +| Hidden singleton chat dependencies target the wrong pane | High | High | Complete pane-context migration first; add multi-panel hybrid isolation tests. | +| Shared app surfaces appear pane-local | High | High | Keep one preview/runtime/version surface and label focused-chat-specific actions. | +| Concurrent chats conflict on one working tree/runtime | Medium | High | Reuse main-process app-operation coordination and expose queued/blocked ownership. | +| Many mounted panels consume excessive memory/CPU | Medium | High | Minimum pane dimensions, compact/minimized rendering, virtualization, selectors, and profiling. | +| Focus, route, and delayed callbacks race | Medium | High | One centralized focus transition and explicit chat IDs captured at invocation time. | +| Deleted/closed/transferred chats leave stale panes | Medium | High | Shared pruning transitions and lifecycle tests for every removal path. | +| Persistence conflicts with tab/window ownership | Medium | Medium | Separate versioned workspace storage; validate against current app/window chats. | +| Remove-pane and close-chat semantics confuse users | Medium | Medium | Distinct labels, pane-local remove action, and destructive confirmation only for deletion. | +| Responsive grids become unusable with many chats | Medium | Medium | Minimum dimensions, compact/minimized states, maximize, scrolling, and narrow switcher. | +| Workspace refactor regresses single-chat behavior | Medium | High | Land identity scoping first with unchanged UI and retain existing regression suites. | + +## Open Questions + +These can be validated during design/prototyping without blocking the architecture: + +1. What minimum pane dimensions produce a usable input and message-reading experience at supported zoom levels? +2. At what measured pane count should full panels automatically compact or show a performance warning? +3. Should removing a pane offer a short-lived Undo toast, or is immediate re-add through the tab menu sufficient? +4. Should the initial workspace toolbar expose a column selector, or rely entirely on auto-layout until users request control? +5. Which existing app-mutating operations lack visible queued/blocked status and need coordination UI before concurrent-chat workflows are promoted? + +## Decision Log + +- The feature is limited to chats from one app; cross-app workspaces are deferred. +- The state model is not capped at two or four panes. +- The MVP uses an adaptive equal grid, not an arbitrary split tree. +- Full-panel rendering is constrained by usable dimensions; excess chats compact/minimize rather than disappearing. +- Open tabs, visible panes, and focused chat are separate concepts. +- The MVP has no separate pane ID; each visible pane is uniquely keyed by its existing chat ID. +- Removing a pane keeps the chat tab open. +- The route owns focused navigation identity; workspace persistence does not create a competing focused-chat authority. +- Pane-local code receives explicit chat identity through context and chat-keyed helpers. +- The app has one shared preview/runtime/version surface. +- Multiple streams are allowed; app mutations remain coordinated shared operations. +- Workspace persistence is versioned, app-keyed, window-local, and separate from chat-tab ownership storage. +- Identity scoping lands before the visible multi-pane UI. + +--- + +_Generated by dyad:swarm-to-plan_ diff --git a/src/atoms/chatWorkspaceAtoms.test.ts b/src/atoms/chatWorkspaceAtoms.test.ts new file mode 100644 index 0000000000..cf30fa61ee --- /dev/null +++ b/src/atoms/chatWorkspaceAtoms.test.ts @@ -0,0 +1,73 @@ +import { createStore } from "jotai"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + chatWorkspaceByAppIdAtom, + getVisibleChatViewIds, + getVisibleWorkspaceChatIds, + hideChatFromWorkspaceAtom, + pruneChatWorkspaceAtom, + showChatInWorkspaceAtom, +} from "./chatWorkspaceAtoms"; + +describe("chat workspace atoms", () => { + const store = createStore(); + + beforeEach(() => { + store.set(chatWorkspaceByAppIdAtom, {}); + }); + + it("adds chats once and preserves their order per app", () => { + store.set(showChatInWorkspaceAtom, { appId: 1, chatId: 10 }); + store.set(showChatInWorkspaceAtom, { appId: 1, chatId: 20 }); + store.set(showChatInWorkspaceAtom, { appId: 1, chatId: 10 }); + store.set(showChatInWorkspaceAtom, { appId: 2, chatId: 30 }); + + expect(store.get(chatWorkspaceByAppIdAtom)).toEqual({ + 1: { visibleChatIds: [10, 20] }, + 2: { visibleChatIds: [30] }, + }); + }); + + it("removes and prunes unavailable chats", () => { + store.set(chatWorkspaceByAppIdAtom, { + 1: { visibleChatIds: [10, 20, 30] }, + }); + + store.set(hideChatFromWorkspaceAtom, { appId: 1, chatId: 20 }); + store.set(pruneChatWorkspaceAtom, { + appId: 1, + validChatIds: new Set([30]), + }); + + expect(store.get(chatWorkspaceByAppIdAtom)[1]?.visibleChatIds).toEqual([ + 30, + ]); + }); + + it("shows only explicit workspace members while filtering stale ids", () => { + expect( + getVisibleWorkspaceChatIds([10, 20, 99, 10], new Set([10, 20, 30])), + ).toEqual([10, 20]); + }); + + it("keeps individual chat views separate from workspace membership", () => { + const validChatIds = new Set([10, 20, 30]); + + expect( + getVisibleChatViewIds({ + workspaceChatIds: [10, 20], + focusedChatId: 30, + validChatIds, + isWorkspaceView: false, + }), + ).toEqual([30]); + expect( + getVisibleChatViewIds({ + workspaceChatIds: [10, 20], + focusedChatId: 30, + validChatIds, + isWorkspaceView: true, + }), + ).toEqual([10, 20]); + }); +}); diff --git a/src/atoms/chatWorkspaceAtoms.ts b/src/atoms/chatWorkspaceAtoms.ts new file mode 100644 index 0000000000..a19176328b --- /dev/null +++ b/src/atoms/chatWorkspaceAtoms.ts @@ -0,0 +1,102 @@ +import { atom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; + +export interface ChatWorkspaceState { + visibleChatIds: number[]; +} + +export type ChatWorkspaceByAppId = Record; + +export function getVisibleWorkspaceChatIds( + workspaceChatIds: number[], + validChatIds: Set, +): number[] { + return Array.from(new Set(workspaceChatIds)).filter((chatId) => + validChatIds.has(chatId), + ); +} + +export function getVisibleChatViewIds({ + workspaceChatIds, + focusedChatId, + validChatIds, + isWorkspaceView, +}: { + workspaceChatIds: number[]; + focusedChatId: number | undefined; + validChatIds: Set; + isWorkspaceView: boolean; +}): number[] { + if (isWorkspaceView) { + return getVisibleWorkspaceChatIds(workspaceChatIds, validChatIds); + } + return focusedChatId !== undefined && validChatIds.has(focusedChatId) + ? [focusedChatId] + : []; +} + +export const chatWorkspaceByAppIdAtom = atomWithStorage( + "chat-workspace-by-app-id", + {}, + undefined, + { getOnInit: true }, +); + +function updateWorkspace( + workspaces: ChatWorkspaceByAppId, + appId: number, + update: (chatIds: number[]) => number[], +): ChatWorkspaceByAppId { + const visibleChatIds = update(workspaces[appId]?.visibleChatIds ?? []); + return { + ...workspaces, + [appId]: { visibleChatIds }, + }; +} + +export const showChatInWorkspaceAtom = atom( + null, + (get, set, { appId, chatId }: { appId: number; chatId: number }) => { + set( + chatWorkspaceByAppIdAtom, + updateWorkspace(get(chatWorkspaceByAppIdAtom), appId, (chatIds) => + chatIds.includes(chatId) ? chatIds : [...chatIds, chatId], + ), + ); + }, +); + +export const hideChatFromWorkspaceAtom = atom( + null, + (get, set, { appId, chatId }: { appId: number; chatId: number }) => { + set( + chatWorkspaceByAppIdAtom, + updateWorkspace(get(chatWorkspaceByAppIdAtom), appId, (chatIds) => + chatIds.filter((id) => id !== chatId), + ), + ); + }, +); + +export const pruneChatWorkspaceAtom = atom( + null, + ( + get, + set, + { appId, validChatIds }: { appId: number; validChatIds: Set }, + ) => { + const workspaces = get(chatWorkspaceByAppIdAtom); + const current = workspaces[appId]?.visibleChatIds ?? []; + const visibleChatIds = current.filter((id) => validChatIds.has(id)); + if ( + visibleChatIds.length === current.length && + visibleChatIds.every((id, index) => current[index] === id) + ) { + return; + } + set(chatWorkspaceByAppIdAtom, { + ...workspaces, + [appId]: { visibleChatIds }, + }); + }, +); diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index 64207307e5..91fe03af80 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -56,17 +56,24 @@ import { useChatMessages, useChatMessagesLoaded, } from "@/hooks/useChatMessages"; +import { ChatPaneProvider } from "./chat/ChatPaneContext"; const TerminalPanel = lazy(() => import("./chat/TerminalPanel")); interface ChatPanelProps { chatId?: number; + isFocused?: boolean; + onRemoveFromWorkspace?: () => void; + removeFromWorkspaceLabel?: string; isPreviewOpen: boolean; onTogglePreview: () => void; } export function ChatPanel({ chatId, + isFocused = true, + onRemoveFromWorkspace, + removeFromWorkspaceLabel, isPreviewOpen, onTogglePreview, }: ChatPanelProps) { @@ -397,120 +404,130 @@ export function ChatPanel({ ? { duration: 0.12 } : { duration: 0.22, ease: drawerEase }; - const showTerminalDrawer = isTerminalOpen && chatId && !isVersionPaneOpen; + const showVersionPane = isFocused && isVersionPaneOpen; + const showTerminalDrawer = isTerminalOpen && chatId && !showVersionPane; return ( -
- { - if (isVersionPaneOpen) { - sendVersionPreview({ type: "CLOSE" }); - } else if (selectedAppId !== null) { - sendVersionPreview({ type: "OPEN", appId: selectedAppId }); - } - }} - /> -
- {!isVersionPaneOpen && ( -
- - {!showTerminalDrawer && ( - -
- - - {/* Scroll to bottom button */} - {showScrollButton && ( -
- - - } - > - - - {t("scrollToBottom")} - -
+ +
+ { + if (isVersionPaneOpen) { + sendVersionPreview({ type: "CLOSE" }); + } else if (selectedAppId !== null) { + sendVersionPreview({ type: "OPEN", appId: selectedAppId }); + } + }} + /> +
+ {!showVersionPane && ( +
+ + {!showTerminalDrawer && ( + +
+ + + {/* Scroll to bottom button */} + {showScrollButton && ( +
+ + + } + > + + + + {t("scrollToBottom")} + + +
+ )} +
+ {showFreeAgentQuotaBanner && ( + void setChatMode("build").catch(() => {}) + } + /> )} + + + +
+ )} +
+
+ )} + {isFocused && } +
+ + {showTerminalDrawer && ( + { + setTerminalFitSignal((value) => value + 1); + }} + > + + {t("terminal.loading")}
- {showFreeAgentQuotaBanner && ( - void setChatMode("build").catch(() => {}) - } - /> - )} - - - - - )} - -
- )} - + } + > + + +
+ )} +
- - {showTerminalDrawer && ( - { - setTerminalFitSignal((value) => value + 1); - }} - > - - {t("terminal.loading")} -
- } - > - - - - )} - -
+ ); } diff --git a/src/components/chat/ChatHeader.tsx b/src/components/chat/ChatHeader.tsx index c2192de3de..47294eb2c6 100644 --- a/src/components/chat/ChatHeader.tsx +++ b/src/components/chat/ChatHeader.tsx @@ -5,6 +5,7 @@ import { GitBranch, Info, SquareTerminal, + X, } from "lucide-react"; import { PanelRightClose } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -20,7 +21,6 @@ import { } from "../ui/tooltip"; import { ipc } from "@/ipc/types"; import { useRouter } from "@tanstack/react-router"; -import { selectedChatIdAtom } from "@/atoms/chatAtoms"; import { useSelectChat } from "@/hooks/useSelectChat"; import { useChats } from "@/hooks/useChats"; import { showError, showSuccess } from "@/lib/toast"; @@ -36,6 +36,9 @@ import { terminalOpenByChatIdAtom } from "@/atoms/terminalAtoms"; import { cn } from "@/lib/utils"; interface ChatHeaderProps { + chatId?: number; + onRemoveFromWorkspace?: () => void; + removeFromWorkspaceLabel?: string; isVersionPaneOpen: boolean; isPreviewOpen: boolean; onTogglePreview: () => void; @@ -43,6 +46,9 @@ interface ChatHeaderProps { } export function ChatHeader({ + chatId, + onRemoveFromWorkspace, + removeFromWorkspaceLabel, isVersionPaneOpen, isPreviewOpen, onTogglePreview, @@ -52,13 +58,12 @@ export function ChatHeader({ const appId = useAtomValue(selectedAppIdAtom); const { versions, loading: versionsLoading } = useVersions(appId); const { navigate } = useRouter(); - const [selectedChatId] = useAtom(selectedChatIdAtom); const [terminalOpenByChatId, setTerminalOpenByChatId] = useAtom( terminalOpenByChatIdAtom, ); const { invalidateChats } = useChats(appId); const { selectChat } = useSelectChat(); - const { isStreaming } = useStreamChat(); + const { isStreaming } = useStreamChat({ chatId }); const { branchInfo, isLoading: branchInfoLoading, @@ -77,7 +82,7 @@ export function ChatHeader({ if (appId) { refetchBranchInfo(); } - }, [appId, selectedChatId, isStreaming, refetchBranchInfo]); + }, [appId, chatId, isStreaming, refetchBranchInfo]); const handleCheckoutMainBranch = async () => { if (!appId) return; @@ -112,18 +117,18 @@ export function ChatHeader({ const isNotMainBranch = branchInfo && branchInfo.branch !== "main"; const currentBranchName = branchInfo?.branch; - const isTerminalOpen = selectedChatId - ? (terminalOpenByChatId.get(selectedChatId) ?? false) + const isTerminalOpen = chatId + ? (terminalOpenByChatId.get(chatId) ?? false) : false; - const isTerminalDisabled = !appId || !selectedChatId; + const isTerminalDisabled = !appId || !chatId; const handleToggleTerminal = () => { - if (!appId || !selectedChatId) return; + if (!appId || !chatId) return; const nextOpen = !isTerminalOpen; setTerminalOpenByChatId((prev) => { const next = new Map(prev); - next.set(selectedChatId, nextOpen); + next.set(chatId, nextOpen); return next; }); }; @@ -234,7 +239,7 @@ export function ChatHeader({ -
+
)} + {onRemoveFromWorkspace && ( + + + + } + > + + + {t("removeFromWorkspace")} + + + )}
diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx index 14c5d0f387..dba33b19dd 100644 --- a/src/components/chat/ChatInput.tsx +++ b/src/components/chat/ChatInput.tsx @@ -33,6 +33,7 @@ import { selectedChatIdAtom, agentTodosByChatIdAtom, } from "@/atoms/chatAtoms"; +import { usePaneChatId } from "./ChatPaneContext"; import { atom, useAtom, useSetAtom, useAtomValue, useStore } from "jotai"; import { useStreamChat } from "@/hooks/useStreamChat"; import { selectedAppIdAtom } from "@/atoms/appAtoms"; @@ -173,7 +174,7 @@ export function ChatInput({ chatId }: { chatId?: number }) { pauseQueue, clearPauseOnly, resumeQueue, - } = useStreamChat(); + } = useStreamChat({ chatId }); const { isCancellationRequested, requestCancellation } = useCancellationRequestLatch({ chatId, @@ -1135,7 +1136,8 @@ function SuggestionButton({ children: React.ReactNode; tooltipText: string | string[]; }) { - const { isStreaming } = useStreamChat(); + const chatId = usePaneChatId(); + const { isStreaming } = useStreamChat({ chatId }); return ( { if (!chatId) { @@ -1201,7 +1203,7 @@ function RefactorFileButton({ path }: { path: string }) { function WriteCodeProperlyButton() { const { t } = useTranslation("chat"); - const chatId = useAtomValue(selectedChatIdAtom); + const chatId = usePaneChatId(); const { streamMessage } = useStreamChat(); const onClick = () => { if (!chatId) { @@ -1293,7 +1295,7 @@ function RefreshButton() { function KeepGoingButton() { const { t } = useTranslation("chat"); const { streamMessage } = useStreamChat(); - const chatId = useAtomValue(selectedChatIdAtom); + const chatId = usePaneChatId(); const onClick = () => { if (!chatId) { console.error("No chat id found"); @@ -1314,7 +1316,7 @@ function KeepGoingButton() { function AddTypeScriptButton() { const { t } = useTranslation("chat"); const { streamMessage } = useStreamChat(); - const chatId = useAtomValue(selectedChatIdAtom); + const chatId = usePaneChatId(); const onClick = () => { if (!chatId) { console.error("No chat id found"); diff --git a/src/components/chat/ChatMessage.tsx b/src/components/chat/ChatMessage.tsx index bd7417d473..4a6aaf7f7c 100644 --- a/src/components/chat/ChatMessage.tsx +++ b/src/components/chat/ChatMessage.tsx @@ -23,7 +23,7 @@ import { formatDistanceToNow, format } from "date-fns"; import { useVersions } from "@/hooks/useVersions"; import { useAtomValue } from "jotai"; import { selectedAppIdAtom } from "@/atoms/appAtoms"; -import { selectedChatIdAtom } from "@/atoms/chatAtoms"; +import { usePaneChatId } from "./ChatPaneContext"; import { useChatStreamHasPreview } from "@/hooks/useChatStream"; import { useEffect, useMemo, useRef, useState } from "react"; import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"; @@ -106,7 +106,8 @@ const ChatMessage = ({ isLastMessage, isCancelledPrompt, }: ChatMessageProps) => { - const { isStreaming } = useStreamChat(); + const paneChatId = usePaneChatId(); + const { isStreaming } = useStreamChat({ chatId: paneChatId }); const appId = useAtomValue(selectedAppIdAtom); const { versions: liveVersions } = useVersions(appId); const { @@ -123,8 +124,7 @@ const ChatMessage = ({ // Sidecar tool-input XML preview lives outside message.content. Subscribe // to its equality-gated boolean so non-last messages only re-render on the // empty/non-empty transition, not on every preview chunk. - const selectedChatId = useAtomValue(selectedChatIdAtom); - const hasPreviewForChat = useChatStreamHasPreview(selectedChatId); + const hasPreviewForChat = useChatStreamHasPreview(paneChatId ?? null); const [showRestoreConfirm, setShowRestoreConfirm] = useState(false); const hasStreamingPreview = message.role === "assistant" && @@ -213,14 +213,14 @@ const ChatMessage = ({ !isCancelled; const handleRestoreToMessage = (restoreCodebase: boolean) => { - if (appId == null || selectedChatId == null) { + if (appId == null || paneChatId == null) { return; } setShowRestoreConfirm(false); sendPreviewEvent({ type: "RESTORE_TO_MESSAGE", appId, - chatId: selectedChatId, + chatId: paneChatId, messageId: message.id, restoreCodebase, }); @@ -405,9 +405,9 @@ const ChatMessage = ({ ) : null} - {message.role === "assistant" && selectedChatId != null && ( + {message.role === "assistant" && paneChatId != null && ( { + it("uses the pane chat id instead of the globally focused chat", () => { + const store = createStore(); + store.set(selectedChatIdAtom, 10); + + const { result } = renderHook(() => usePaneChatId(), { + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + }); + + expect(result.current).toBe(20); + }); + + it("falls back to the focused chat outside a pane", () => { + const store = createStore(); + store.set(selectedChatIdAtom, 10); + + const { result } = renderHook(() => usePaneChatId(), { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); + + expect(result.current).toBe(10); + }); +}); diff --git a/src/components/chat/ChatPaneContext.tsx b/src/components/chat/ChatPaneContext.tsx new file mode 100644 index 0000000000..3b0433dd9b --- /dev/null +++ b/src/components/chat/ChatPaneContext.tsx @@ -0,0 +1,25 @@ +import { createContext, useContext, type ReactNode } from "react"; +import { useAtomValue } from "jotai"; +import { selectedChatIdAtom } from "@/atoms/chatAtoms"; + +const ChatPaneContext = createContext(undefined); + +export function ChatPaneProvider({ + chatId, + children, +}: { + chatId: number | undefined; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function usePaneChatId(): number | undefined { + const paneChatId = useContext(ChatPaneContext); + const selectedChatId = useAtomValue(selectedChatIdAtom); + return paneChatId ?? selectedChatId ?? undefined; +} diff --git a/src/components/chat/ChatTabs.test.ts b/src/components/chat/ChatTabs.test.ts index 07d57162ba..854e494bd0 100644 --- a/src/components/chat/ChatTabs.test.ts +++ b/src/components/chat/ChatTabs.test.ts @@ -25,6 +25,7 @@ import { getVisibleTabCapacity, matchesPreNavigationPresentationCapture, getFallbackChatIdAfterClose, + getChatWorkspaceTabs, groupChatIdsByApp, partitionChatsByVisibleCount, reorderVisibleChatIds, @@ -152,10 +153,29 @@ describe("ChatTabs helpers", () => { it("reselects the active chat when navigation must return to the chat route", () => { expect(shouldSkipChatSelection(7, 7, "/chat")).toBe(true); + expect(shouldSkipChatSelection(7, 7, "/chat", true, false)).toBe(false); + expect(shouldSkipChatSelection(7, 7, "/chat", false, true)).toBe(false); + expect(shouldSkipChatSelection(7, 7, "/chat", true, true)).toBe(true); expect(shouldSkipChatSelection(7, 7, "/settings")).toBe(false); expect(shouldSkipChatSelection(7, 8, "/chat")).toBe(false); }); + it("creates workspace tabs only from explicit same-app membership", () => { + expect( + getChatWorkspaceTabs( + { + 1: { visibleChatIds: [1, 2, 1, 99] }, + 2: { visibleChatIds: [3] }, + 3: { visibleChatIds: [] }, + }, + [chat(1, 1), chat(2, 1), chat(3, 2), chat(99, 2)], + ), + ).toEqual([ + { appId: 1, chatIds: [1, 2] }, + { appId: 2, chatIds: [3] }, + ]); + }); + it("captures the outgoing chat before route-driven presentation changes", () => { expect( shouldCapturePresentationBeforeNavigation(7, "/chat", 7, "/chat", 8), diff --git a/src/components/chat/ChatTabs.tsx b/src/components/chat/ChatTabs.tsx index a2d82cea5a..b9bd34ced1 100644 --- a/src/components/chat/ChatTabs.tsx +++ b/src/components/chat/ChatTabs.tsx @@ -7,7 +7,7 @@ import { useState, } from "react"; import { useAtomValue, useSetAtom, useStore } from "jotai"; -import { Loader2, MoreHorizontal, X } from "lucide-react"; +import { Loader2, MoreHorizontal, PanelsTopLeft, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { ChatSummary } from "@/lib/schemas"; import { useNavigate, useRouter, useRouterState } from "@tanstack/react-router"; @@ -93,6 +93,12 @@ import { earlyChatTabRemovalEvents, } from "@/app_wiring/early_renderer_events"; import type { ChatTabPresentationState } from "@/window_infrastructure/types"; +import { + chatWorkspaceByAppIdAtom, + hideChatFromWorkspaceAtom, + showChatInWorkspaceAtom, + type ChatWorkspaceByAppId, +} from "@/atoms/chatWorkspaceAtoms"; const MIN_VISIBLE_TAB_WIDTH_PX = 160; const TAB_GAP_PX = 4; @@ -342,8 +348,14 @@ export function shouldSkipChatSelection( selectedChatId: number | null, nextChatId: number, pathname: string, + isWorkspaceRoute = false, + nextIsWorkspaceRoute = false, ): boolean { - return selectedChatId === nextChatId && pathname === "/chat"; + return ( + selectedChatId === nextChatId && + pathname === "/chat" && + isWorkspaceRoute === nextIsWorkspaceRoute + ); } export function shouldCapturePresentationBeforeNavigation( @@ -457,6 +469,29 @@ interface ChatTabsProps { selectedChatId: number | null; } +export interface ChatWorkspaceTab { + appId: number; + chatIds: number[]; +} + +export function getChatWorkspaceTabs( + workspaces: ChatWorkspaceByAppId, + chats: ChatSummary[], +): ChatWorkspaceTab[] { + const chatAppById = new Map(chats.map((chat) => [chat.id, chat.appId])); + + return Object.entries(workspaces) + .map(([rawAppId, workspace]) => { + const appId = Number(rawAppId); + const chatIds = Array.from(new Set(workspace.visibleChatIds)).filter( + (chatId) => chatAppById.get(chatId) === appId, + ); + return { appId, chatIds }; + }) + .filter((workspace) => workspace.chatIds.length > 0) + .sort((left, right) => left.appId - right.appId); +} + function ChatTabActivity({ chatId, notified, @@ -509,6 +544,9 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) { const hydrateChatTabSession = useSetAtom(hydrateChatTabSessionAtom); const persistChatTabSession = useSetAtom(persistChatTabSessionAtom); const setSelectedChatId = useSetAtom(selectedChatIdAtom); + const workspaces = useAtomValue(chatWorkspaceByAppIdAtom); + const showChatInWorkspace = useSetAtom(showChatInWorkspaceAtom); + const hideChatFromWorkspace = useSetAtom(hideChatFromWorkspaceAtom); const { reopenClosedTab, hasClosedTabs, lastClosedTab } = useReopenClosedTab(); const { selectChat } = useSelectChat(); @@ -517,6 +555,12 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) { const pathname = useRouterState({ select: (state) => state.location.pathname, }); + const routeSearch = useRouterState({ + select: (state) => state.location.search, + }) as { appId?: number; workspace?: boolean }; + const isWorkspaceRoute = + pathname === "/chat" && routeSearch.workspace === true; + const workspaceRouteAppId = isWorkspaceRoute ? routeSearch.appId : undefined; const locationHref = useRouterState({ select: (state) => state.location.href, }); @@ -546,6 +590,14 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) { () => new Map(chats.map((chat) => [chat.id, chat])), [chats], ); + const workspaceTabs = useMemo(() => { + const tabs = getChatWorkspaceTabs(workspaces, chats); + if (workspaceRouteAppId === undefined) return tabs; + return [ + ...tabs.filter((tab) => tab.appId === workspaceRouteAppId), + ...tabs.filter((tab) => tab.appId !== workspaceRouteAppId), + ]; + }, [chats, workspaces, workspaceRouteAppId]); const publishChatTabOwnership = useCallback(async () => { await ipc.windowInfrastructure.setChatTabOwnership( getActiveStoredChatTabs(), @@ -1214,27 +1266,58 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) { ]); const visibleTabCapacity = useMemo( - () => getVisibleTabCapacity(containerWidth, orderedChats.length), - [containerWidth, orderedChats.length], + () => + getVisibleTabCapacity( + containerWidth, + orderedChats.length + workspaceTabs.length, + ), + [containerWidth, orderedChats.length, workspaceTabs.length], ); - const visibleTabCount = + const totalVisibleTabCount = visibleTabCapacity > 0 ? visibleTabCapacity - : Math.min(orderedChats.length, DEFAULT_UNMEASURED_VISIBLE_TABS); + : Math.min( + orderedChats.length + workspaceTabs.length, + DEFAULT_UNMEASURED_VISIBLE_TABS, + ); + const visibleWorkspaceTabCount = isWorkspaceRoute + ? Math.min(workspaceTabs.length, totalVisibleTabCount) + : Math.min( + workspaceTabs.length, + Math.max(totalVisibleTabCount - (orderedChats.length > 0 ? 1 : 0), 0), + ); + const visibleWorkspaceTabs = workspaceTabs.slice(0, visibleWorkspaceTabCount); + const overflowWorkspaceTabs = workspaceTabs.slice(visibleWorkspaceTabCount); + const visibleTabCount = Math.max( + totalVisibleTabCount - visibleWorkspaceTabCount, + 0, + ); const { visibleTabs, overflowTabs } = useMemo( () => partitionChatsByVisibleCount(orderedChats, visibleTabCount), [orderedChats, visibleTabCount], ); + const overflowWorkspaceTabsForMenu = overflowWorkspaceTabs.slice( + 0, + MAX_OVERFLOW_MENU_ITEMS, + ); const overflowTabsForMenu = useMemo( - () => overflowTabs.slice(0, MAX_OVERFLOW_MENU_ITEMS), - [overflowTabs], + () => + overflowTabs.slice( + 0, + Math.max( + MAX_OVERFLOW_MENU_ITEMS - overflowWorkspaceTabsForMenu.length, + 0, + ), + ), + [overflowTabs, overflowWorkspaceTabsForMenu.length], ); + const overflowTabCount = overflowWorkspaceTabs.length + overflowTabs.length; // Re-run when orderedChats becomes non-empty so the ResizeObserver attaches // after the container div renders (it returns null when there are no chats). - const hasChats = orderedChats.length > 0; + const hasChats = orderedChats.length > 0 || workspaceTabs.length > 0; useEffect(() => { const node = containerRef.current; if (!node) return; @@ -1328,9 +1411,19 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) { ]); const selectChatWithPresentation = useCallback( - (chatId: number, appId: number) => { + (chatId: number, appId: number, options: { workspace?: boolean } = {}) => { clearNotification(chatId); - if (shouldSkipChatSelection(selectedChatId, chatId, pathname)) return; + if ( + shouldSkipChatSelection( + selectedChatId, + chatId, + pathname, + isWorkspaceRoute, + options.workspace === true, + ) + ) { + return; + } if (selectedChatId !== null) { const selectedChat = chatsById.get(selectedChatId); @@ -1351,6 +1444,7 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) { chatId, appId, preserveTabOrder: true, + workspace: options.workspace, }); const presentation = presentationByChatIdRef.current.get(chatId) ?? @@ -1363,6 +1457,7 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) { capturePresentation, chatsById, clearNotification, + isWorkspaceRoute, neutralPresentation, pathname, restorePresentation, @@ -1387,6 +1482,17 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) { selectChatWithPresentation(chat.id, chat.appId); }; + const handleWorkspaceTabClick = (workspace: ChatWorkspaceTab) => { + const nextChatId = + selectedChatId !== null && workspace.chatIds.includes(selectedChatId) + ? selectedChatId + : workspace.chatIds[0]; + if (nextChatId === undefined) return; + selectChatWithPresentation(nextChatId, workspace.appId, { + workspace: true, + }); + }; + useEffect(() => { if (!hasHydratedTabSession) return; return chatNavigationEvents.subscribe(({ chatId, appId }) => { @@ -1424,6 +1530,13 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) { }) .filter((record): record is ClosedTabRecord => record !== null); + for (const record of records) { + hideChatFromWorkspace({ + appId: record.appId, + chatId: record.chatId, + }); + } + closeMultipleTabs(records); // Switch to fallback when a fallback is provided and either there is @@ -1455,6 +1568,7 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) { [ clearNotification, closeMultipleTabs, + hideChatFromWorkspace, selectedChatId, chatsById, neutralPresentation, @@ -1531,8 +1645,88 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) { }} >
+ {visibleWorkspaceTabs.map((workspace) => { + const app = appById.get(workspace.appId); + const appName = app?.name ?? `App ${workspace.appId}`; + const isActive = + isWorkspaceRoute && workspaceRouteAppId === workspace.appId; + const workspaceAriaLabel = t("workspaceTabAria", { + appName, + count: workspace.chatIds.length, + }); + + return ( + + + } + > + + + + {workspaceAriaLabel} + + + ); + })} {visibleTabs.map((chat) => { - const isActive = selectedChatId === chat.id; + const isActive = !isWorkspaceRoute && selectedChatId === chat.id; const title = chat.title?.trim() || t("newChat"); const app = appById.get(chat.appId); const appName = app?.name ?? `App ${chat.appId}`; @@ -1543,6 +1737,8 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) { const hasTabsToRight = tabIndex !== -1 && tabIndex < orderedChatIds.length - 1; const hasOtherTabs = orderedChatIds.length > 1; + const isInWorkspace = + workspaces[chat.appId]?.visibleChatIds.includes(chat.id) ?? false; return ( @@ -1751,6 +1947,44 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) { + { + if (isInWorkspace) { + const nextWorkspaceChatId = workspaces[ + chat.appId + ]?.visibleChatIds.find((id) => id !== chat.id); + hideChatFromWorkspace({ + appId: chat.appId, + chatId: chat.id, + }); + if ( + isWorkspaceRoute && + workspaceRouteAppId === chat.appId && + selectedChatId === chat.id + ) { + if (nextWorkspaceChatId !== undefined) { + selectChatWithPresentation( + nextWorkspaceChatId, + chat.appId, + { workspace: true }, + ); + } else { + selectChatWithPresentation(chat.id, chat.appId); + } + } + } else { + showChatInWorkspace({ + appId: chat.appId, + chatId: chat.id, + }); + } + }} + > + {isInWorkspace + ? t("removeFromWorkspace") + : t("addToWorkspace")} + + {enableMultiWindow && ( <> - {overflowTabs.length > 0 && ( + {overflowTabCount > 0 && ( + {overflowWorkspaceTabsForMenu.map((workspace) => { + const appName = + appById.get(workspace.appId)?.name ?? + `App ${workspace.appId}`; + return ( + handleWorkspaceTabClick(workspace)} + className="flex items-center gap-2" + > + + + ); + })} {overflowTabsForMenu.map((chat) => { const title = chat.title?.trim() || t("newChat"); const appName = diff --git a/src/components/chat/DyadAddIntegration.tsx b/src/components/chat/DyadAddIntegration.tsx index be0633593c..8084ca646e 100644 --- a/src/components/chat/DyadAddIntegration.tsx +++ b/src/components/chat/DyadAddIntegration.tsx @@ -1,7 +1,7 @@ import React, { useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { previewModeAtom, selectedAppIdAtom } from "@/atoms/appAtoms"; -import { selectedChatIdAtom } from "@/atoms/chatAtoms"; +import { usePaneChatId } from "./ChatPaneContext"; import { integrationProviderSelectionAtom } from "@/atoms/integrationAtoms"; import { usePendingIntegrations } from "@/user_input/hooks"; import { isPreviewOpenAtom } from "@/atoms/viewAtoms"; @@ -37,7 +37,7 @@ export const DyadAddIntegration: React.FC = ({ }) => { const { t } = useTranslation("home"); const appId = useAtomValue(selectedAppIdAtom); - const chatId = useAtomValue(selectedChatIdAtom); + const chatId = usePaneChatId(); const pendingIntegrationMap = usePendingIntegrations(); const setIntegrationProviderSelection = useSetAtom( integrationProviderSelectionAtom, @@ -194,7 +194,7 @@ export const DyadAddIntegration: React.FC = ({ isSubmitting: isContinueSubmitting, handleContinue, handleSkip, - } = useIntegrationContinue(); + } = useIntegrationContinue(chatId); const handleSkipClick = () => void handleSkip(); diff --git a/src/components/chat/DyadAppBlueprintCard.tsx b/src/components/chat/DyadAppBlueprintCard.tsx index e6ff1c2487..058338ba70 100644 --- a/src/components/chat/DyadAppBlueprintCard.tsx +++ b/src/components/chat/DyadAppBlueprintCard.tsx @@ -12,7 +12,7 @@ import { } from "lucide-react"; import { useAtomValue, useSetAtom } from "jotai"; import { appBlueprintStateAtom } from "@/atoms/appBlueprintAtoms"; -import { selectedChatIdAtom } from "@/atoms/chatAtoms"; +import { usePaneChatId } from "./ChatPaneContext"; import { useStreamChat } from "@/hooks/useStreamChat"; import { selectedAppIdAtom } from "@/atoms/appAtoms"; import { useTemplates } from "@/hooks/useTemplates"; @@ -62,7 +62,7 @@ export const DyadAppBlueprintCard: React.FC = ({ node, }) => { const props = node.properties; - const chatId = useAtomValue(selectedChatIdAtom); + const chatId = usePaneChatId(); const appBlueprintState = useAtomValue(appBlueprintStateAtom); const setAppBlueprintState = useSetAtom(appBlueprintStateAtom); const selectedAppId = useAtomValue(selectedAppIdAtom); diff --git a/src/components/chat/DyadExitPlan.tsx b/src/components/chat/DyadExitPlan.tsx index d312e8c353..08b501b4eb 100644 --- a/src/components/chat/DyadExitPlan.tsx +++ b/src/components/chat/DyadExitPlan.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react"; import { useAtomValue } from "jotai"; import { AlertCircle, CheckCircle, ArrowRight } from "lucide-react"; import { planAcceptInNewChatByChatIdAtom } from "@/atoms/planAtoms"; -import { selectedChatIdAtom } from "@/atoms/chatAtoms"; +import { usePaneChatId } from "./ChatPaneContext"; import { usePlanHandoffState } from "@/plan_handoff/usePlanHandoff"; interface DyadExitPlanProps { @@ -15,7 +15,7 @@ interface DyadExitPlanProps { export const DyadExitPlan: React.FC = ({ node }) => { const { notes } = node.properties; - const chatId = useAtomValue(selectedChatIdAtom); + const chatId = usePaneChatId(); const acceptInNewChatByChatId = useAtomValue(planAcceptInNewChatByChatIdAtom); const handoffState = usePlanHandoffState(chatId); const failure = diff --git a/src/components/chat/DyadMarkdownParser.tsx b/src/components/chat/DyadMarkdownParser.tsx index 31065ee8b9..0e5060632c 100644 --- a/src/components/chat/DyadMarkdownParser.tsx +++ b/src/components/chat/DyadMarkdownParser.tsx @@ -21,8 +21,7 @@ import { DyadSearchReplace } from "./DyadSearchReplace"; import { DyadCodebaseContext } from "./DyadCodebaseContext"; import { DyadThink } from "./DyadThink"; import { CodeHighlight } from "./CodeHighlight"; -import { useAtomValue } from "jotai"; -import { selectedChatIdAtom } from "@/atoms/chatAtoms"; +import { usePaneChatId } from "./ChatPaneContext"; import { useChatStreamPreview, useChatStreamState, @@ -131,7 +130,7 @@ export const DyadMarkdownParser: React.FC = ({ messageId, showStreamingPreview = false, }) => { - const chatId = useAtomValue(selectedChatIdAtom); + const chatId = usePaneChatId(); const streamState = useChatStreamState(chatId ?? undefined) ?? { type: "idle", }; diff --git a/src/components/chat/DyadOutput.tsx b/src/components/chat/DyadOutput.tsx index 1fba28a1a8..798ec845ad 100644 --- a/src/components/chat/DyadOutput.tsx +++ b/src/components/chat/DyadOutput.tsx @@ -1,7 +1,6 @@ import React, { useState } from "react"; import { AlertTriangle, XCircle, Sparkles } from "lucide-react"; -import { useAtomValue } from "jotai"; -import { selectedChatIdAtom } from "@/atoms/chatAtoms"; +import { usePaneChatId } from "./ChatPaneContext"; import { useStreamChat } from "@/hooks/useStreamChat"; import { useChatStreamState } from "@/hooks/useChatStream"; import { isStreamActive } from "@/chat_stream/transition"; @@ -26,12 +25,12 @@ export const DyadOutput: React.FC = ({ children, }) => { const [isContentVisible, setIsContentVisible] = useState(false); - const selectedChatId = useAtomValue(selectedChatIdAtom); - const streamState = useChatStreamState(selectedChatId ?? undefined) ?? { + const paneChatId = usePaneChatId(); + const streamState = useChatStreamState(paneChatId) ?? { type: "idle", }; const isStreaming = isStreamActive(streamState); - const { streamMessage } = useStreamChat(); + const { streamMessage } = useStreamChat({ chatId: paneChatId }); // If the type is not warning, it is an error (in case LLM gives a weird "type") const isError = type !== "warning"; @@ -41,10 +40,10 @@ export const DyadOutput: React.FC = ({ const handleAIFix = (e: React.MouseEvent) => { e.stopPropagation(); - if (message && selectedChatId) { + if (message && paneChatId) { streamMessage({ prompt: `Fix the error: ${message}`, - chatId: selectedChatId, + chatId: paneChatId, }); } }; diff --git a/src/components/chat/DyadStepLimit.tsx b/src/components/chat/DyadStepLimit.tsx index 18f82d6d12..7e334b23c8 100644 --- a/src/components/chat/DyadStepLimit.tsx +++ b/src/components/chat/DyadStepLimit.tsx @@ -1,5 +1,4 @@ import React, { useState } from "react"; -import { useAtomValue } from "jotai"; import { CustomTagState } from "./stateTypes"; import { DyadCard, @@ -9,7 +8,7 @@ import { import { PauseCircle, Play, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useStreamChat } from "@/hooks/useStreamChat"; -import { selectedChatIdAtom } from "@/atoms/chatAtoms"; +import { usePaneChatId } from "./ChatPaneContext"; import { hasPendingReviewContinuation } from "@/hooks/subagentReviewContinuation"; interface DyadStepLimitProps { @@ -27,8 +26,8 @@ export function DyadStepLimit({ node, children }: DyadStepLimitProps) { const { steps = "50", limit: _limit = "50", state } = node.properties; const isFinished = state === "finished"; const content = typeof children === "string" ? children : ""; - const chatId = useAtomValue(selectedChatIdAtom); - const { streamMessage, clearPauseOnly } = useStreamChat(); + const chatId = usePaneChatId(); + const { streamMessage, clearPauseOnly } = useStreamChat({ chatId }); const [isLoading, setIsLoading] = useState(false); const handleContinue = () => { diff --git a/src/components/chat/DyadWritePlan.tsx b/src/components/chat/DyadWritePlan.tsx index 167afc65e3..880132b125 100644 --- a/src/components/chat/DyadWritePlan.tsx +++ b/src/components/chat/DyadWritePlan.tsx @@ -5,6 +5,7 @@ import { previewModeAtom } from "@/atoms/appAtoms"; import { isPreviewOpenAtom } from "@/atoms/viewAtoms"; import { CustomTagState } from "./stateTypes"; import { usePlan } from "@/hooks/usePlan"; +import { usePaneChatId } from "./ChatPaneContext"; interface DyadWritePlanProps { node: { @@ -23,11 +24,15 @@ export const DyadWritePlan: React.FC = ({ node }) => { const [showSummary, setShowSummary] = useState(false); const setPreviewMode = useSetAtom(previewModeAtom); const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom); + const chatId = usePaneChatId(); // Consider in progress if state is pending OR complete is explicitly "false" const isInProgress = state === "pending" || complete === "false"; - const { savedPlan, hasPlanInMemory } = usePlan({ enabled: !isInProgress }); + const { savedPlan, hasPlanInMemory } = usePlan({ + enabled: !isInProgress, + chatId, + }); const hasPlan = hasPlanInMemory || !!savedPlan; diff --git a/src/components/chat/MessagesList.tsx b/src/components/chat/MessagesList.tsx index 309f2c889d..d8ccad31d2 100644 --- a/src/components/chat/MessagesList.tsx +++ b/src/components/chat/MessagesList.tsx @@ -13,7 +13,7 @@ import ChatMessage from "./ChatMessage"; import { OpenRouterSetupBanner, SetupBanner } from "../SetupBanner"; import { useStreamChat } from "@/hooks/useStreamChat"; -import { selectedChatIdAtom } from "@/atoms/chatAtoms"; +import { usePaneChatId } from "./ChatPaneContext"; import { useUserInputRequests } from "@/user_input/hooks"; import { useAtomValue } from "jotai"; import { CheckCircle2, Loader2, RefreshCw, Undo } from "lucide-react"; @@ -591,13 +591,15 @@ export const MessagesList = forwardRef( previewState.session.checkedOutVersionId !== null ? previewState.session.originBranch : null; - const { streamMessage, isStreaming } = useStreamChat(); + const paneChatId = usePaneChatId(); + const { streamMessage, isStreaming } = useStreamChat({ + chatId: paneChatId, + }); const { isAnyProviderSetup, isProviderSetup } = useLanguageModelProviders(); const { settings } = useSettings(); const [isUndoLoading, setIsUndoLoading] = useState(false); const [isRetryLoading, setIsRetryLoading] = useState(false); - const selectedChatId = useAtomValue(selectedChatIdAtom); - const { chat: selectedChat } = useChatMode(selectedChatId); + const { chat: selectedChat } = useChatMode(paneChatId); // Virtualization only renders visible DOM elements, which creates issues for E2E tests: // 1. Off-screen logs don't exist in the DOM and can't be queried by test selectors @@ -686,7 +688,7 @@ export const MessagesList = forwardRef( restoreTargetBranch, sendPreviewMutation, streamMessage, - selectedChatId, + selectedChatId: paneChatId ?? null, appId, renderSetupBanner, }), @@ -703,7 +705,7 @@ export const MessagesList = forwardRef( restoreTargetBranch, sendPreviewMutation, streamMessage, - selectedChatId, + paneChatId, appId, renderSetupBanner, ], diff --git a/src/components/chat/QuestionnaireInput.tsx b/src/components/chat/QuestionnaireInput.tsx index 25b8026a0b..7ff78b111c 100644 --- a/src/components/chat/QuestionnaireInput.tsx +++ b/src/components/chat/QuestionnaireInput.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from "react"; -import { useAtomValue, useStore } from "jotai"; +import { useStore } from "jotai"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -16,7 +16,7 @@ import { LoaderCircle, X, } from "lucide-react"; -import { selectedChatIdAtom } from "@/atoms/chatAtoms"; +import { usePaneChatId } from "./ChatPaneContext"; import { getUserInputReadModel } from "@/user_input/read_model"; import { usePendingQuestionnaires } from "@/user_input/hooks"; @@ -26,7 +26,7 @@ export function QuestionnaireInput() { const store = useStore(); const userInputReadModel = getUserInputReadModel({ store }); const questionnaireMap = usePendingQuestionnaires(); - const chatId = useAtomValue(selectedChatIdAtom); + const chatId = usePaneChatId(); const questionnaire = chatId != null ? questionnaireMap.get(chatId) : undefined; diff --git a/src/components/chat/SummarizeInNewChatButton.tsx b/src/components/chat/SummarizeInNewChatButton.tsx index bd1b094f50..f4c9bd19d8 100644 --- a/src/components/chat/SummarizeInNewChatButton.tsx +++ b/src/components/chat/SummarizeInNewChatButton.tsx @@ -1,6 +1,6 @@ import { useNavigate } from "@tanstack/react-router"; import { useAtomValue } from "jotai"; -import { selectedChatIdAtom } from "@/atoms/chatAtoms"; +import { usePaneChatId } from "./ChatPaneContext"; import { selectedAppIdAtom } from "@/atoms/appAtoms"; import { useStreamChat } from "@/hooks/useStreamChat"; import { ipc } from "@/ipc/types"; @@ -9,9 +9,9 @@ import { useQueryClient } from "@tanstack/react-query"; import { queryKeys } from "@/lib/queryKeys"; export function useSummarizeInNewChat() { - const chatId = useAtomValue(selectedChatIdAtom); + const chatId = usePaneChatId(); const appId = useAtomValue(selectedAppIdAtom); - const { streamMessage } = useStreamChat(); + const { streamMessage } = useStreamChat({ chatId }); const navigate = useNavigate(); const queryClient = useQueryClient(); diff --git a/src/components/chat/TestAssertionsInput.tsx b/src/components/chat/TestAssertionsInput.tsx index 8177da2979..96510713ad 100644 --- a/src/components/chat/TestAssertionsInput.tsx +++ b/src/components/chat/TestAssertionsInput.tsx @@ -20,7 +20,8 @@ import { } from "lucide-react"; import { selectedAppIdAtom, previewModeAtom } from "@/atoms/appAtoms"; -import { chatMessagesByIdAtom, selectedChatIdAtom } from "@/atoms/chatAtoms"; +import { chatMessagesByIdAtom } from "@/atoms/chatAtoms"; +import { usePaneChatId } from "./ChatPaneContext"; import { selectedFileAtom } from "@/atoms/viewAtoms"; import { useChatStreamManager } from "@/chat_stream/ChatStreamProvider"; import { useChatMessages } from "@/hooks/useChatMessages"; @@ -124,7 +125,7 @@ interface LiveAssertionPlans { const NO_LIVE_PLANS: LiveAssertionPlans = { tag: null, pendingCount: 0 }; export function TestAssertionsInput() { - const chatId = useAtomValue(selectedChatIdAtom); + const chatId = usePaneChatId(); const messages = useChatMessages(chatId); const { tag, pendingCount } = useMemo(() => { let newest: AssertionsTagSummary | null = null; @@ -203,14 +204,14 @@ export function TestAssertionsPlanCard({ /** Unanswered plans in this chat, including this one. */ pendingCount?: number; }) { - const chatId = useAtomValue(selectedChatIdAtom); + const chatId = usePaneChatId(); const appId = useAtomValue(selectedAppIdAtom); const setMessagesById = useSetAtom(chatMessagesByIdAtom); const setSelectedFile = useSetAtom(selectedFileAtom); const setPreviewMode = useSetAtom(previewModeAtom); const queryClient = useQueryClient(); const chatStreamManager = useChatStreamManager(); - const { streamMessage } = useStreamChat(); + const { streamMessage } = useStreamChat({ chatId }); // The agent is parked on this card's request for as long as it's live, so // answering it resumes that turn. It won't be live for a card reloaded after // a restart, or one whose turn was stopped — those fall back to handing the diff --git a/src/hooks/useIntegrationContinue.ts b/src/hooks/useIntegrationContinue.ts index 659475b631..3f4c93f1fe 100644 --- a/src/hooks/useIntegrationContinue.ts +++ b/src/hooks/useIntegrationContinue.ts @@ -15,8 +15,9 @@ import { * Shared continue logic for the integration setup flow. Request lifecycle * reads and responses go through the generic user-input read-model adapter. */ -export function useIntegrationContinue() { - const chatId = useAtomValue(selectedChatIdAtom); +export function useIntegrationContinue(chatIdOverride?: number) { + const selectedChatId = useAtomValue(selectedChatIdAtom); + const chatId = chatIdOverride ?? selectedChatId ?? undefined; const selectedAppId = useAtomValue(selectedAppIdAtom); const store = useStore(); const userInputReadModel = getUserInputReadModel({ store }); diff --git a/src/hooks/usePlan.ts b/src/hooks/usePlan.ts index b13a70fe8d..28afc1dd60 100644 --- a/src/hooks/usePlan.ts +++ b/src/hooks/usePlan.ts @@ -13,8 +13,12 @@ import { usePlanDocument } from "@/hooks/usePlanDocument"; * * @param options.enabled - Extra condition to suppress the query (e.g. while plan is streaming). Defaults to true. */ -export function usePlan({ enabled = true }: { enabled?: boolean } = {}) { - const chatId = useAtomValue(selectedChatIdAtom); +export function usePlan({ + enabled = true, + chatId: chatIdOverride, +}: { enabled?: boolean; chatId?: number } = {}) { + const selectedChatId = useAtomValue(selectedChatIdAtom); + const chatId = chatIdOverride ?? selectedChatId ?? undefined; const appId = useAtomValue(selectedAppIdAtom); const planDocument = usePlanDocument(chatId); const setPlanState = useSetAtom(planStateAtom); diff --git a/src/hooks/useSelectChat.test.tsx b/src/hooks/useSelectChat.test.tsx index acf2f29a83..4f5b9e043f 100644 --- a/src/hooks/useSelectChat.test.tsx +++ b/src/hooks/useSelectChat.test.tsx @@ -60,4 +60,22 @@ describe("useSelectChat", () => { new Set([202]), ); }); + + it("opens workspace routes only when explicitly requested", () => { + const { wrapper } = makeHarness(); + const { result } = renderHook(() => useSelectChat(), { wrapper }); + + act(() => { + result.current.selectChat({ + chatId: 101, + appId: 7, + workspace: true, + }); + }); + + expect(mocks.navigate).toHaveBeenCalledWith({ + to: "/chat", + search: { id: 101, appId: 7, workspace: true }, + }); + }); }); diff --git a/src/hooks/useSelectChat.ts b/src/hooks/useSelectChat.ts index 1124482463..22b4bed413 100644 --- a/src/hooks/useSelectChat.ts +++ b/src/hooks/useSelectChat.ts @@ -27,12 +27,14 @@ export function useSelectChat() { preserveTabOrder = false, prefillInput, scrollToBottom = false, + workspace = false, }: { chatId: number; appId: number; preserveTabOrder?: boolean; prefillInput?: string; scrollToBottom?: boolean; + workspace?: boolean; }) => { if (scrollToBottom) { setScrollToBottomRequestedChatIds((prev) => { @@ -50,7 +52,9 @@ export function useSelectChat() { } const navigationResult = navigate({ to: "/chat", - search: { id: chatId, appId }, + search: workspace + ? { id: chatId, appId, workspace: true } + : { id: chatId, appId }, }); if (prefillInput !== undefined) { diff --git a/src/hooks/useStreamChat.ts b/src/hooks/useStreamChat.ts index c0abf8d607..f8b588524d 100644 --- a/src/hooks/useStreamChat.ts +++ b/src/hooks/useStreamChat.ts @@ -30,14 +30,16 @@ export function getRandomNumberId() { */ export function useStreamChat({ hasChatId = true, -}: { hasChatId?: boolean } = {}) { + chatId: chatIdOverride, +}: { hasChatId?: boolean; chatId?: number } = {}) { const chatStreamManager = useChatStreamManager(); - let chatId: number | undefined; + let routeChatId: number | undefined; if (hasChatId) { const { id } = useSearch({ from: "/chat" }); - chatId = id; + routeChatId = id; } + const chatId = chatIdOverride ?? routeChatId; const streamState = useChatStreamState(chatId); const queueRevision = streamState?.queueRevision; const queuedMessages = useMemo( diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json index 885e8d4ba0..0676a4988d 100644 --- a/src/i18n/locales/en/chat.json +++ b/src/i18n/locales/en/chat.json @@ -373,5 +373,14 @@ "guide": "Guide", "agentModeActivated": "Agent Mode Activated", "agentModeTip": "Tip: Create a new chat to give the agent a clean context for better results.", - "neverShowAgain": "Never show again" + "neverShowAgain": "Never show again", + "addToWorkspace": "Add to workspace", + "removeFromWorkspace": "Remove from workspace", + "removeFromWorkspaceNamed": "Remove {{title}} from workspace", + "workspaceChatCount": "{{count}} chats in workspace", + "workspaceTabName": "Workspace", + "workspaceTabDetails": "{{appName}} · {{count}} chats", + "workspaceTabAria": "{{appName}} workspace, {{count}} chats", + "openFocusedChat": "Open focused chat", + "focusedChatAria": "{{title}}, focused" } diff --git a/src/i18n/locales/es/chat.json b/src/i18n/locales/es/chat.json index fa376c9da6..8fd407655f 100644 --- a/src/i18n/locales/es/chat.json +++ b/src/i18n/locales/es/chat.json @@ -371,5 +371,14 @@ "guide": "Guía", "agentModeActivated": "Modo Agente Activado", "agentModeTip": "Consejo: Crea un nuevo chat para darle al agente un contexto limpio para obtener mejores resultados.", - "neverShowAgain": "No volver a mostrar" + "neverShowAgain": "No volver a mostrar", + "addToWorkspace": "Añadir al espacio de trabajo", + "removeFromWorkspace": "Quitar del espacio de trabajo", + "removeFromWorkspaceNamed": "Quitar {{title}} del espacio de trabajo", + "workspaceChatCount": "{{count}} chats en el espacio de trabajo", + "workspaceTabName": "Espacio de trabajo", + "workspaceTabDetails": "{{appName}} · {{count}} chats", + "workspaceTabAria": "Espacio de trabajo de {{appName}}, {{count}} chats", + "openFocusedChat": "Abrir el chat enfocado", + "focusedChatAria": "{{title}}, enfocado" } diff --git a/src/i18n/locales/ko/chat.json b/src/i18n/locales/ko/chat.json index f72f4f64aa..9e43fbdecc 100644 --- a/src/i18n/locales/ko/chat.json +++ b/src/i18n/locales/ko/chat.json @@ -373,5 +373,14 @@ "guide": "가이드", "agentModeActivated": "에이전트 모드 활성화됨", "agentModeTip": "팁: 새 채팅을 만들면 에이전트에게 더 나은 결과를 위한 깨끗한 컨텍스트를 제공할 수 있습니다.", - "neverShowAgain": "다시 표시 안 함" + "neverShowAgain": "다시 표시 안 함", + "addToWorkspace": "작업 공간에 추가", + "removeFromWorkspace": "작업 공간에서 제거", + "removeFromWorkspaceNamed": "작업 공간에서 {{title}} 제거", + "workspaceChatCount": "작업 공간의 채팅 {{count}}개", + "workspaceTabName": "작업 공간", + "workspaceTabDetails": "{{appName}} · 채팅 {{count}}개", + "workspaceTabAria": "채팅 {{count}}개가 있는 {{appName}} 작업 공간", + "openFocusedChat": "포커스된 채팅 열기", + "focusedChatAria": "{{title}}, 포커스됨" } diff --git a/src/i18n/locales/pt-BR/chat.json b/src/i18n/locales/pt-BR/chat.json index 19b6a52530..bfb811e85a 100644 --- a/src/i18n/locales/pt-BR/chat.json +++ b/src/i18n/locales/pt-BR/chat.json @@ -370,5 +370,14 @@ "guide": "Guia", "agentModeActivated": "Modo Agente Ativado", "agentModeTip": "Dica: Crie um novo chat para dar ao agente um contexto limpo para melhores resultados.", - "neverShowAgain": "Não mostrar novamente" + "neverShowAgain": "Não mostrar novamente", + "addToWorkspace": "Adicionar ao espaço de trabalho", + "removeFromWorkspace": "Remover do espaço de trabalho", + "removeFromWorkspaceNamed": "Remover {{title}} do espaço de trabalho", + "workspaceChatCount": "{{count}} chats no espaço de trabalho", + "workspaceTabName": "Espaço de trabalho", + "workspaceTabDetails": "{{appName}} · {{count}} chats", + "workspaceTabAria": "Espaço de trabalho de {{appName}}, {{count}} chats", + "openFocusedChat": "Abrir chat em foco", + "focusedChatAria": "{{title}}, em foco" } diff --git a/src/i18n/locales/zh-CN/chat.json b/src/i18n/locales/zh-CN/chat.json index 0a3a52a518..d67a64dcc2 100644 --- a/src/i18n/locales/zh-CN/chat.json +++ b/src/i18n/locales/zh-CN/chat.json @@ -370,5 +370,14 @@ "guide": "指南", "agentModeActivated": "Agent 模式已激活", "agentModeTip": "提示:创建新聊天以给 Agent 一个干净的上下文,获得更好的结果。", - "neverShowAgain": "不再显示" + "neverShowAgain": "不再显示", + "addToWorkspace": "添加到工作区", + "removeFromWorkspace": "从工作区移除", + "removeFromWorkspaceNamed": "从工作区移除 {{title}}", + "workspaceChatCount": "工作区中有 {{count}} 个聊天", + "workspaceTabName": "工作区", + "workspaceTabDetails": "{{appName}} · {{count}} 个聊天", + "workspaceTabAria": "{{appName}} 工作区,{{count}} 个聊天", + "openFocusedChat": "打开聚焦的聊天", + "focusedChatAria": "{{title}},已聚焦" } diff --git a/src/pages/chat.tsx b/src/pages/chat.tsx index 67c0b486dd..b1549e2c7f 100644 --- a/src/pages/chat.tsx +++ b/src/pages/chat.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from "react"; +import { useState, useRef, useEffect, useMemo } from "react"; import { PanelGroup, Panel, @@ -9,27 +9,72 @@ import { ChatPanel } from "../components/ChatPanel"; import { PreviewPanel } from "../components/preview_panel/PreviewPanel"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { cn } from "@/lib/utils"; -import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai"; import { isPreviewOpenAtom, isChatPanelHiddenAtom } from "@/atoms/viewAtoms"; import { useChats } from "@/hooks/useChats"; import { selectedAppIdAtom } from "@/atoms/appAtoms"; import { selectedChatIdAtom } from "@/atoms/chatAtoms"; import { ipc } from "@/ipc/types"; +import { + chatWorkspaceByAppIdAtom, + getVisibleChatViewIds, + getVisibleWorkspaceChatIds, + hideChatFromWorkspaceAtom, + pruneChatWorkspaceAtom, +} from "@/atoms/chatWorkspaceAtoms"; +import { Button } from "@/components/ui/button"; +import { PanelsTopLeft } from "lucide-react"; +import { useTranslation } from "react-i18next"; const DEFAULT_CHAT_PANEL_SIZE = 50; export default function ChatPage() { - const { id: chatId, appId: routeAppId } = useSearch({ from: "/chat" }); + const { t } = useTranslation("chat"); + const { + id: chatId, + appId: routeAppId, + workspace: isWorkspaceRoute = false, + } = useSearch({ from: "/chat" }); const navigate = useNavigate(); const [isPreviewOpen, setIsPreviewOpen] = useAtom(isPreviewOpenAtom); const [isChatPanelHidden, setIsChatPanelHidden] = useAtom( isChatPanelHiddenAtom, ); const setSelectedChatId = useSetAtom(selectedChatIdAtom); + const store = useStore(); const [isResizing, setIsResizing] = useState(false); const selectedAppId = useAtomValue(selectedAppIdAtom); const setSelectedAppId = useSetAtom(selectedAppIdAtom); const { chats, loading } = useChats(selectedAppId); + const workspaces = useAtomValue(chatWorkspaceByAppIdAtom); + const hideChatFromWorkspace = useSetAtom(hideChatFromWorkspaceAtom); + const pruneChatWorkspace = useSetAtom(pruneChatWorkspaceAtom); + const validChatIds = useMemo( + () => new Set(chats.map((chat) => chat.id)), + [chats], + ); + const workspaceChatIds = useMemo( + () => + getVisibleWorkspaceChatIds( + selectedAppId === null + ? [] + : (workspaces[selectedAppId]?.visibleChatIds ?? []), + validChatIds, + ), + [selectedAppId, validChatIds, workspaces], + ); + const visibleChatIds = useMemo( + () => + getVisibleChatViewIds({ + workspaceChatIds, + focusedChatId: chatId, + validChatIds, + isWorkspaceView: isWorkspaceRoute, + }), + [chatId, isWorkspaceRoute, validChatIds, workspaceChatIds], + ); + const isWorkspaceView = isWorkspaceRoute && workspaceChatIds.length > 0; + const isMultiChatWorkspace = isWorkspaceView && workspaceChatIds.length > 1; const previousSizeRef = useRef(DEFAULT_CHAT_PANEL_SIZE); const isInitialMountRef = useRef(true); const selectedAppIdRef = useRef(selectedAppId); @@ -43,6 +88,14 @@ export default function ChatPage() { setSelectedChatId(chatId ?? null); }, [chatId, setSelectedChatId]); + useEffect(() => { + if (selectedAppId === null || loading) return; + pruneChatWorkspace({ + appId: selectedAppId, + validChatIds, + }); + }, [loading, pruneChatWorkspace, selectedAppId, validChatIds]); + useEffect(() => { if (chatId || loading) { return; @@ -53,6 +106,23 @@ export default function ChatPage() { return; } + if (routeAppId && routeAppId !== selectedAppId) { + return; + } + + if (isWorkspaceRoute && workspaceChatIds.length > 0) { + navigate({ + to: "/chat", + search: { + id: workspaceChatIds[0], + appId: selectedAppId, + workspace: true, + }, + replace: true, + }); + return; + } + if (chats.length) { // Not a real navigation, just a redirect, when the user navigates to /chat // without a chatId, we redirect to the first chat @@ -70,7 +140,17 @@ export default function ChatPage() { search: { appId: selectedAppId }, replace: true, }); - }, [chatId, chats, loading, navigate, selectedAppId, setSelectedAppId]); + }, [ + chatId, + chats, + isWorkspaceRoute, + loading, + navigate, + routeAppId, + selectedAppId, + setSelectedAppId, + workspaceChatIds, + ]); useEffect(() => { if (!chatId) { @@ -108,6 +188,55 @@ export default function ChatPage() { }; }, [chatId, routeAppId, chats, setSelectedAppId]); + useEffect(() => { + if ( + !isWorkspaceRoute || + chatId === undefined || + loading || + selectedAppId === null || + (routeAppId !== undefined && routeAppId !== selectedAppId) + ) { + return; + } + + if (workspaceChatIds.includes(chatId)) { + return; + } + + const nextWorkspaceChatId = workspaceChatIds[0]; + if (nextWorkspaceChatId !== undefined) { + setSelectedChatId(nextWorkspaceChatId); + navigate({ + to: "/chat", + search: { + id: nextWorkspaceChatId, + appId: selectedAppId, + workspace: true, + }, + replace: true, + }); + return; + } + + if (validChatIds.has(chatId)) { + navigate({ + to: "/chat", + search: { id: chatId, appId: selectedAppId }, + replace: true, + }); + } + }, [ + chatId, + isWorkspaceRoute, + loading, + navigate, + routeAppId, + selectedAppId, + setSelectedChatId, + validChatIds, + workspaceChatIds, + ]); + useEffect(() => { if (isPreviewOpen) { ref.current?.expand(); @@ -118,6 +247,22 @@ export default function ChatPage() { const ref = useRef(null); const chatPanelRef = useRef(null); + const focusChat = (nextChatId: number, target: EventTarget | null) => { + if ( + target instanceof Element && + target.closest("[data-workspace-remove]") + ) { + return; + } + if (nextChatId === chatId || selectedAppId === null) return; + store.set(selectedChatIdAtom, nextChatId); + void navigate({ + to: "/chat", + search: { id: nextChatId, appId: selectedAppId, workspace: true }, + replace: true, + }); + }; + // Keep chat panel size in sync with hidden state (from toolbar button / other views) useEffect(() => { if (!chatPanelRef.current) return; @@ -149,20 +294,108 @@ export default function ChatPage() { minSize={1} className={cn(!isResizing && "transition-all duration-100 ease-in-out")} > -
+
{!isChatPanelHidden && ( - { - setIsPreviewOpen(!isPreviewOpen); - if (isPreviewOpen) { - ref.current?.collapse(); - } else { - ref.current?.expand(); - } - }} - /> + <> + {isWorkspaceView && ( +
+
+ + + {t("workspaceChatCount", { + count: workspaceChatIds.length, + })} + +
+ {isMultiChatWorkspace && ( + + )} +
+ )} +
+ {visibleChatIds.map((workspaceChatId) => { + const isFocused = workspaceChatId === chatId; + const workspaceChat = chats.find( + (chat) => chat.id === workspaceChatId, + ); + const chatLabel = + workspaceChat?.title?.trim() || `Chat ${workspaceChatId}`; + return ( +
+ focusChat(workspaceChatId, event.target) + } + onFocusCapture={(event) => + focusChat(workspaceChatId, event.target) + } + > + + hideChatFromWorkspace({ + appId: selectedAppId, + chatId: workspaceChatId, + }) + : undefined + } + removeFromWorkspaceLabel={t( + "removeFromWorkspaceNamed", + { title: chatLabel }, + )} + isPreviewOpen={isPreviewOpen} + onTogglePreview={() => { + setIsPreviewOpen(!isPreviewOpen); + if (isPreviewOpen) { + ref.current?.collapse(); + } else { + ref.current?.expand(); + } + }} + /> +
+ ); + })} +
+ )}
diff --git a/src/routes/chatSearchSchema.ts b/src/routes/chatSearchSchema.ts index 9ac780996f..23714c4ab1 100644 --- a/src/routes/chatSearchSchema.ts +++ b/src/routes/chatSearchSchema.ts @@ -8,4 +8,5 @@ import { z } from "zod"; export const chatSearchSchema = z.object({ id: z.number().optional(), appId: z.number().optional(), + workspace: z.boolean().optional(), }); From 5b734df5e51c1b32659262a87c890ac7c17b6d9a Mon Sep 17 00:00:00 2001 From: Mohamed Aziz Mejri Date: Tue, 25 Aug 2026 13:04:13 +0100 Subject: [PATCH 2/4] fix(chat): address workspace review feedback --- plans/multi-chat-workspace.md | 30 +++---- rules/jotai-state.md | 5 ++ src/atoms/chatWorkspaceAtoms.test.ts | 53 ++++++++++++ src/atoms/chatWorkspaceAtoms.ts | 119 ++++++++++++++++++++++++++- src/components/chat/ChatTabs.test.ts | 19 +++++ src/components/chat/ChatTabs.tsx | 48 +++++++++-- src/components/chat/DyadExitPlan.tsx | 2 +- src/i18n/locales/en/chat.json | 9 +- src/i18n/locales/es/chat.json | 9 +- src/i18n/locales/ko/chat.json | 9 +- src/i18n/locales/pt-BR/chat.json | 9 +- src/i18n/locales/zh-CN/chat.json | 9 +- src/pages/chat.tsx | 33 ++++++-- 13 files changed, 302 insertions(+), 52 deletions(-) diff --git a/plans/multi-chat-workspace.md b/plans/multi-chat-workspace.md index 51356d7312..75d47201fb 100644 --- a/plans/multi-chat-workspace.md +++ b/plans/multi-chat-workspace.md @@ -176,9 +176,9 @@ Add `ChatPaneContext` with `ChatPaneProvider`, `usePaneChatId()`, and focused-pa Provide explicit chat-keyed APIs such as: ```ts -useChatInputValue(chatId) -useSetChatInputValue(chatId) -usePaneChatId() +useChatInputValue(chatId); +useSetChatInputValue(chatId); +usePaneChatId(); ``` All pane-local mutations—send, stop, retry, approval, draft writes, plan actions, terminal controls, tool cards, and delayed callbacks—must carry an explicit chat ID. Singleton selection is reserved for genuinely shared/global surfaces. @@ -344,18 +344,18 @@ No database migration or new external IPC endpoint is expected for the MVP. ## Risks and Mitigations -| Risk | Likelihood | Impact | Mitigation | -| --- | --- | --- | --- | -| Hidden singleton chat dependencies target the wrong pane | High | High | Complete pane-context migration first; add multi-panel hybrid isolation tests. | -| Shared app surfaces appear pane-local | High | High | Keep one preview/runtime/version surface and label focused-chat-specific actions. | -| Concurrent chats conflict on one working tree/runtime | Medium | High | Reuse main-process app-operation coordination and expose queued/blocked ownership. | -| Many mounted panels consume excessive memory/CPU | Medium | High | Minimum pane dimensions, compact/minimized rendering, virtualization, selectors, and profiling. | -| Focus, route, and delayed callbacks race | Medium | High | One centralized focus transition and explicit chat IDs captured at invocation time. | -| Deleted/closed/transferred chats leave stale panes | Medium | High | Shared pruning transitions and lifecycle tests for every removal path. | -| Persistence conflicts with tab/window ownership | Medium | Medium | Separate versioned workspace storage; validate against current app/window chats. | -| Remove-pane and close-chat semantics confuse users | Medium | Medium | Distinct labels, pane-local remove action, and destructive confirmation only for deletion. | -| Responsive grids become unusable with many chats | Medium | Medium | Minimum dimensions, compact/minimized states, maximize, scrolling, and narrow switcher. | -| Workspace refactor regresses single-chat behavior | Medium | High | Land identity scoping first with unchanged UI and retain existing regression suites. | +| Risk | Likelihood | Impact | Mitigation | +| -------------------------------------------------------- | ---------- | ------ | ----------------------------------------------------------------------------------------------- | +| Hidden singleton chat dependencies target the wrong pane | High | High | Complete pane-context migration first; add multi-panel hybrid isolation tests. | +| Shared app surfaces appear pane-local | High | High | Keep one preview/runtime/version surface and label focused-chat-specific actions. | +| Concurrent chats conflict on one working tree/runtime | Medium | High | Reuse main-process app-operation coordination and expose queued/blocked ownership. | +| Many mounted panels consume excessive memory/CPU | Medium | High | Minimum pane dimensions, compact/minimized rendering, virtualization, selectors, and profiling. | +| Focus, route, and delayed callbacks race | Medium | High | One centralized focus transition and explicit chat IDs captured at invocation time. | +| Deleted/closed/transferred chats leave stale panes | Medium | High | Shared pruning transitions and lifecycle tests for every removal path. | +| Persistence conflicts with tab/window ownership | Medium | Medium | Separate versioned workspace storage; validate against current app/window chats. | +| Remove-pane and close-chat semantics confuse users | Medium | Medium | Distinct labels, pane-local remove action, and destructive confirmation only for deletion. | +| Responsive grids become unusable with many chats | Medium | Medium | Minimum dimensions, compact/minimized states, maximize, scrolling, and narrow switcher. | +| Workspace refactor regresses single-chat behavior | Medium | High | Land identity scoping first with unchanged UI and retain existing regression suites. | ## Open Questions diff --git a/rules/jotai-state.md b/rules/jotai-state.md index 4347c61b68..d99d56c0c1 100644 --- a/rules/jotai-state.md +++ b/rules/jotai-state.md @@ -90,6 +90,11 @@ Components should usually read `currentTestSpecsAtom` rather than repeat - Use write-only atoms or domain helper hooks for repeated mutations such as append, clear, set-for-id, or remove-for-id. +- When supplying a custom synchronous storage to `atomWithStorage`, declare its + structural `getItem`/`setItem`/`removeItem` type locally. `SyncStorage` appears + in Jotai's internal declarations but is not exported by the public + `jotai/vanilla/utils` barrel, and importing it there makes `tsgo` select the + async overload and spread `Promise` errors through atom consumers. - Keep high-frequency state, such as logs, separate from slower state so a log append does not rerender consumers of unrelated preview metadata. - Combine fields only when they form one domain concept and are updated diff --git a/src/atoms/chatWorkspaceAtoms.test.ts b/src/atoms/chatWorkspaceAtoms.test.ts index cf30fa61ee..3523d8fd49 100644 --- a/src/atoms/chatWorkspaceAtoms.test.ts +++ b/src/atoms/chatWorkspaceAtoms.test.ts @@ -2,6 +2,7 @@ import { createStore } from "jotai"; import { beforeEach, describe, expect, it } from "vitest"; import { chatWorkspaceByAppIdAtom, + createChatWorkspaceStorage, getVisibleChatViewIds, getVisibleWorkspaceChatIds, hideChatFromWorkspaceAtom, @@ -70,4 +71,56 @@ describe("chat workspace atoms", () => { }), ).toEqual([10, 20]); }); + + it("renders the route chat while the chats query is still loading", () => { + expect( + getVisibleChatViewIds({ + workspaceChatIds: [], + focusedChatId: 42, + validChatIds: new Set(), + isWorkspaceView: false, + }), + ).toEqual([42]); + }); + + it("loads only validated workspace entries from versioned storage", () => { + const values = new Map(); + values.set( + "workspace", + JSON.stringify({ + version: 1, + workspaces: { + 1: { visibleChatIds: [10, 10, -1, "bad", 20] }, + 2: null, + invalid: { visibleChatIds: [30] }, + }, + }), + ); + const storage = createChatWorkspaceStorage(() => ({ + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value), + removeItem: (key) => values.delete(key), + clear: () => values.clear(), + key: (index) => Array.from(values.keys())[index] ?? null, + get length() { + return values.size; + }, + })); + + expect(storage.getItem("workspace", {})).toEqual({ + 1: { visibleChatIds: [10, 20] }, + }); + + values.set( + "workspace", + JSON.stringify({ version: 2, workspaces: { 1: null } }), + ); + expect(storage.getItem("workspace", {})).toEqual({}); + + storage.setItem("workspace", { 3: { visibleChatIds: [30] } }); + expect(JSON.parse(values.get("workspace") ?? "null")).toEqual({ + version: 1, + workspaces: { 3: { visibleChatIds: [30] } }, + }); + }); }); diff --git a/src/atoms/chatWorkspaceAtoms.ts b/src/atoms/chatWorkspaceAtoms.ts index a19176328b..d87e53cb4f 100644 --- a/src/atoms/chatWorkspaceAtoms.ts +++ b/src/atoms/chatWorkspaceAtoms.ts @@ -7,6 +7,117 @@ export interface ChatWorkspaceState { export type ChatWorkspaceByAppId = Record; +const CHAT_WORKSPACE_STORAGE_VERSION = 1; + +interface PersistedChatWorkspaceState { + version: typeof CHAT_WORKSPACE_STORAGE_VERSION; + workspaces: ChatWorkspaceByAppId; +} + +interface ChatWorkspaceSyncStorage { + getItem: ( + key: string, + initialValue: ChatWorkspaceByAppId, + ) => ChatWorkspaceByAppId; + setItem: (key: string, newValue: ChatWorkspaceByAppId) => void; + removeItem: (key: string) => void; + subscribe: ( + key: string, + callback: (value: ChatWorkspaceByAppId) => void, + initialValue: ChatWorkspaceByAppId, + ) => (() => void) | undefined; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export function normalizeChatWorkspaceByAppId( + value: unknown, +): ChatWorkspaceByAppId { + if (!isRecord(value)) return {}; + + const normalized: ChatWorkspaceByAppId = {}; + for (const [rawAppId, rawWorkspace] of Object.entries(value)) { + const appId = Number(rawAppId); + if ( + !Number.isSafeInteger(appId) || + appId <= 0 || + !isRecord(rawWorkspace) || + !Array.isArray(rawWorkspace.visibleChatIds) + ) { + continue; + } + + const visibleChatIds = Array.from( + new Set( + rawWorkspace.visibleChatIds.filter( + (chatId): chatId is number => + typeof chatId === "number" && + Number.isSafeInteger(chatId) && + chatId > 0, + ), + ), + ); + normalized[appId] = { visibleChatIds }; + } + + return normalized; +} + +function deserializeChatWorkspaceState( + rawValue: string | null, + initialValue: ChatWorkspaceByAppId, +): ChatWorkspaceByAppId { + if (rawValue === null) return initialValue; + + try { + const persisted: unknown = JSON.parse(rawValue); + if ( + !isRecord(persisted) || + persisted.version !== CHAT_WORKSPACE_STORAGE_VERSION + ) { + return initialValue; + } + return normalizeChatWorkspaceByAppId(persisted.workspaces); + } catch { + return initialValue; + } +} + +export function createChatWorkspaceStorage( + getStorage: () => Storage | undefined = () => + typeof window === "undefined" ? undefined : window.localStorage, +): ChatWorkspaceSyncStorage { + return { + getItem(key, initialValue) { + return deserializeChatWorkspaceState( + getStorage()?.getItem(key) ?? null, + initialValue, + ); + }, + setItem(key, newValue) { + const persisted: PersistedChatWorkspaceState = { + version: CHAT_WORKSPACE_STORAGE_VERSION, + workspaces: normalizeChatWorkspaceByAppId(newValue), + }; + getStorage()?.setItem(key, JSON.stringify(persisted)); + }, + removeItem(key) { + getStorage()?.removeItem(key); + }, + subscribe(key, callback, initialValue) { + if (typeof window === "undefined") return undefined; + const onStorage = (event: StorageEvent) => { + if (event.key !== key) return; + callback(deserializeChatWorkspaceState(event.newValue, initialValue)); + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, + }; +} + export function getVisibleWorkspaceChatIds( workspaceChatIds: number[], validChatIds: Set, @@ -30,15 +141,15 @@ export function getVisibleChatViewIds({ if (isWorkspaceView) { return getVisibleWorkspaceChatIds(workspaceChatIds, validChatIds); } - return focusedChatId !== undefined && validChatIds.has(focusedChatId) - ? [focusedChatId] - : []; + return focusedChatId === undefined ? [] : [focusedChatId]; } +const chatWorkspaceStorage = createChatWorkspaceStorage(); + export const chatWorkspaceByAppIdAtom = atomWithStorage( "chat-workspace-by-app-id", {}, - undefined, + chatWorkspaceStorage, { getOnInit: true }, ); diff --git a/src/components/chat/ChatTabs.test.ts b/src/components/chat/ChatTabs.test.ts index 854e494bd0..127aa086f7 100644 --- a/src/components/chat/ChatTabs.test.ts +++ b/src/components/chat/ChatTabs.test.ts @@ -23,6 +23,7 @@ import { consumePreNavigationPresentationCapture, getOrderedRecentChatIds, getVisibleTabCapacity, + getVisibleWorkspaceTabCount, matchesPreNavigationPresentationCapture, getFallbackChatIdAfterClose, getChatWorkspaceTabs, @@ -330,11 +331,29 @@ describe("ChatTabs helpers", () => { expect(overflowTabs.map((c) => c.id)).toEqual([3, 4]); }); + it("keeps the selected chat visible while its persisted order updates", () => { + const orderedChats = [chat(1), chat(2), chat(3), chat(4)]; + const { visibleTabs, overflowTabs } = partitionChatsByVisibleCount( + orderedChats, + 2, + 4, + ); + expect(visibleTabs.map((c) => c.id)).toEqual([4, 1]); + expect(overflowTabs.map((c) => c.id)).toEqual([2, 3]); + }); + it("uses overflow-aware capacity with min width constraints", () => { // 3 tabs fit at 140px each (+ gaps), but with overflow trigger reserved only 2 fit. expect(getVisibleTabCapacity(430, 4, 140)).toBe(2); }); + it("caps visible workspaces and reserves a regular chat slot", () => { + expect(getVisibleWorkspaceTabCount(2, 2, 4)).toBe(1); + expect(getVisibleWorkspaceTabCount(4, 3, 8)).toBe(1); + expect(getVisibleWorkspaceTabCount(1, 2, 4)).toBe(0); + expect(getVisibleWorkspaceTabCount(2, 2, 0)).toBe(1); + }); + it("selects right-adjacent tab when closing active middle tab", () => { const fallback = getFallbackChatIdAfterClose( [chat(1), chat(2), chat(3)], diff --git a/src/components/chat/ChatTabs.tsx b/src/components/chat/ChatTabs.tsx index b9bd34ced1..74e672bccf 100644 --- a/src/components/chat/ChatTabs.tsx +++ b/src/components/chat/ChatTabs.tsx @@ -294,6 +294,18 @@ export function getVisibleTabCapacity( return Math.min(withOverflow, totalTabs); } +export function getVisibleWorkspaceTabCount( + totalVisibleTabCount: number, + workspaceTabCount: number, + chatTabCount: number, +): number { + return Math.min( + workspaceTabCount, + Math.max(totalVisibleTabCount - (chatTabCount > 0 ? 1 : 0), 0), + 1, + ); +} + export function applySelectionToOrderedChatIds( orderedChatIds: number[], selectedChatId: number, @@ -419,7 +431,21 @@ export function shouldRemoveTransferredChatFromRenderer( export function partitionChatsByVisibleCount( orderedChats: ChatSummary[], visibleTabCount: number, + selectedChatId?: number | null, ): { visibleTabs: ChatSummary[]; overflowTabs: ChatSummary[] } { + const selectedIndex = orderedChats.findIndex( + (chat) => chat.id === selectedChatId, + ); + if (visibleTabCount > 0 && selectedIndex >= visibleTabCount) { + const selectedChat = orderedChats[selectedIndex]; + const otherChats = orderedChats.filter( + (chat) => chat.id !== selectedChatId, + ); + return { + visibleTabs: [selectedChat, ...otherChats.slice(0, visibleTabCount - 1)], + overflowTabs: otherChats.slice(visibleTabCount - 1), + }; + } return { visibleTabs: orderedChats.slice(0, visibleTabCount), overflowTabs: orderedChats.slice(visibleTabCount), @@ -1281,12 +1307,13 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) { orderedChats.length + workspaceTabs.length, DEFAULT_UNMEASURED_VISIBLE_TABS, ); - const visibleWorkspaceTabCount = isWorkspaceRoute - ? Math.min(workspaceTabs.length, totalVisibleTabCount) - : Math.min( - workspaceTabs.length, - Math.max(totalVisibleTabCount - (orderedChats.length > 0 ? 1 : 0), 0), - ); + // Keep workspace access visible without allowing workspace tabs to crowd all + // regular chats out of the strip. The active workspace is sorted first. + const visibleWorkspaceTabCount = getVisibleWorkspaceTabCount( + totalVisibleTabCount, + workspaceTabs.length, + orderedChats.length, + ); const visibleWorkspaceTabs = workspaceTabs.slice(0, visibleWorkspaceTabCount); const overflowWorkspaceTabs = workspaceTabs.slice(visibleWorkspaceTabCount); const visibleTabCount = Math.max( @@ -1295,8 +1322,13 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) { ); const { visibleTabs, overflowTabs } = useMemo( - () => partitionChatsByVisibleCount(orderedChats, visibleTabCount), - [orderedChats, visibleTabCount], + () => + partitionChatsByVisibleCount( + orderedChats, + visibleTabCount, + selectedChatId, + ), + [orderedChats, selectedChatId, visibleTabCount], ); const overflowWorkspaceTabsForMenu = overflowWorkspaceTabs.slice( 0, diff --git a/src/components/chat/DyadExitPlan.tsx b/src/components/chat/DyadExitPlan.tsx index 08b501b4eb..7f57151bea 100644 --- a/src/components/chat/DyadExitPlan.tsx +++ b/src/components/chat/DyadExitPlan.tsx @@ -17,7 +17,7 @@ export const DyadExitPlan: React.FC = ({ node }) => { const { notes } = node.properties; const chatId = usePaneChatId(); const acceptInNewChatByChatId = useAtomValue(planAcceptInNewChatByChatIdAtom); - const handoffState = usePlanHandoffState(chatId); + const handoffState = usePlanHandoffState(chatId ?? null); const failure = handoffState.phase === "failed" ? (handoffState.failure ?? "Plan implementation could not be started.") diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json index 0676a4988d..d695e5b5bc 100644 --- a/src/i18n/locales/en/chat.json +++ b/src/i18n/locales/en/chat.json @@ -377,10 +377,13 @@ "addToWorkspace": "Add to workspace", "removeFromWorkspace": "Remove from workspace", "removeFromWorkspaceNamed": "Remove {{title}} from workspace", - "workspaceChatCount": "{{count}} chats in workspace", + "workspaceChatCount_one": "{{count}} chat in workspace", + "workspaceChatCount_other": "{{count}} chats in workspace", "workspaceTabName": "Workspace", - "workspaceTabDetails": "{{appName}} · {{count}} chats", - "workspaceTabAria": "{{appName}} workspace, {{count}} chats", + "workspaceTabDetails_one": "{{appName}} · {{count}} chat", + "workspaceTabDetails_other": "{{appName}} · {{count}} chats", + "workspaceTabAria_one": "{{appName}} workspace, {{count}} chat", + "workspaceTabAria_other": "{{appName}} workspace, {{count}} chats", "openFocusedChat": "Open focused chat", "focusedChatAria": "{{title}}, focused" } diff --git a/src/i18n/locales/es/chat.json b/src/i18n/locales/es/chat.json index 8fd407655f..77418d87dd 100644 --- a/src/i18n/locales/es/chat.json +++ b/src/i18n/locales/es/chat.json @@ -375,10 +375,13 @@ "addToWorkspace": "Añadir al espacio de trabajo", "removeFromWorkspace": "Quitar del espacio de trabajo", "removeFromWorkspaceNamed": "Quitar {{title}} del espacio de trabajo", - "workspaceChatCount": "{{count}} chats en el espacio de trabajo", + "workspaceChatCount_one": "{{count}} chat en el espacio de trabajo", + "workspaceChatCount_other": "{{count}} chats en el espacio de trabajo", "workspaceTabName": "Espacio de trabajo", - "workspaceTabDetails": "{{appName}} · {{count}} chats", - "workspaceTabAria": "Espacio de trabajo de {{appName}}, {{count}} chats", + "workspaceTabDetails_one": "{{appName}} · {{count}} chat", + "workspaceTabDetails_other": "{{appName}} · {{count}} chats", + "workspaceTabAria_one": "Espacio de trabajo de {{appName}}, {{count}} chat", + "workspaceTabAria_other": "Espacio de trabajo de {{appName}}, {{count}} chats", "openFocusedChat": "Abrir el chat enfocado", "focusedChatAria": "{{title}}, enfocado" } diff --git a/src/i18n/locales/ko/chat.json b/src/i18n/locales/ko/chat.json index 9e43fbdecc..8a2e19d969 100644 --- a/src/i18n/locales/ko/chat.json +++ b/src/i18n/locales/ko/chat.json @@ -377,10 +377,13 @@ "addToWorkspace": "작업 공간에 추가", "removeFromWorkspace": "작업 공간에서 제거", "removeFromWorkspaceNamed": "작업 공간에서 {{title}} 제거", - "workspaceChatCount": "작업 공간의 채팅 {{count}}개", + "workspaceChatCount_one": "작업 공간의 채팅 {{count}}개", + "workspaceChatCount_other": "작업 공간의 채팅 {{count}}개", "workspaceTabName": "작업 공간", - "workspaceTabDetails": "{{appName}} · 채팅 {{count}}개", - "workspaceTabAria": "채팅 {{count}}개가 있는 {{appName}} 작업 공간", + "workspaceTabDetails_one": "{{appName}} · 채팅 {{count}}개", + "workspaceTabDetails_other": "{{appName}} · 채팅 {{count}}개", + "workspaceTabAria_one": "채팅 {{count}}개가 있는 {{appName}} 작업 공간", + "workspaceTabAria_other": "채팅 {{count}}개가 있는 {{appName}} 작업 공간", "openFocusedChat": "포커스된 채팅 열기", "focusedChatAria": "{{title}}, 포커스됨" } diff --git a/src/i18n/locales/pt-BR/chat.json b/src/i18n/locales/pt-BR/chat.json index bfb811e85a..52534ace30 100644 --- a/src/i18n/locales/pt-BR/chat.json +++ b/src/i18n/locales/pt-BR/chat.json @@ -374,10 +374,13 @@ "addToWorkspace": "Adicionar ao espaço de trabalho", "removeFromWorkspace": "Remover do espaço de trabalho", "removeFromWorkspaceNamed": "Remover {{title}} do espaço de trabalho", - "workspaceChatCount": "{{count}} chats no espaço de trabalho", + "workspaceChatCount_one": "{{count}} chat no espaço de trabalho", + "workspaceChatCount_other": "{{count}} chats no espaço de trabalho", "workspaceTabName": "Espaço de trabalho", - "workspaceTabDetails": "{{appName}} · {{count}} chats", - "workspaceTabAria": "Espaço de trabalho de {{appName}}, {{count}} chats", + "workspaceTabDetails_one": "{{appName}} · {{count}} chat", + "workspaceTabDetails_other": "{{appName}} · {{count}} chats", + "workspaceTabAria_one": "Espaço de trabalho de {{appName}}, {{count}} chat", + "workspaceTabAria_other": "Espaço de trabalho de {{appName}}, {{count}} chats", "openFocusedChat": "Abrir chat em foco", "focusedChatAria": "{{title}}, em foco" } diff --git a/src/i18n/locales/zh-CN/chat.json b/src/i18n/locales/zh-CN/chat.json index d67a64dcc2..17d692e24e 100644 --- a/src/i18n/locales/zh-CN/chat.json +++ b/src/i18n/locales/zh-CN/chat.json @@ -374,10 +374,13 @@ "addToWorkspace": "添加到工作区", "removeFromWorkspace": "从工作区移除", "removeFromWorkspaceNamed": "从工作区移除 {{title}}", - "workspaceChatCount": "工作区中有 {{count}} 个聊天", + "workspaceChatCount_one": "工作区中有 {{count}} 个聊天", + "workspaceChatCount_other": "工作区中有 {{count}} 个聊天", "workspaceTabName": "工作区", - "workspaceTabDetails": "{{appName}} · {{count}} 个聊天", - "workspaceTabAria": "{{appName}} 工作区,{{count}} 个聊天", + "workspaceTabDetails_one": "{{appName}} · {{count}} 个聊天", + "workspaceTabDetails_other": "{{appName}} · {{count}} 个聊天", + "workspaceTabAria_one": "{{appName}} 工作区,{{count}} 个聊天", + "workspaceTabAria_other": "{{appName}} 工作区,{{count}} 个聊天", "openFocusedChat": "打开聚焦的聊天", "focusedChatAria": "{{title}},已聚焦" } diff --git a/src/pages/chat.tsx b/src/pages/chat.tsx index b1549e2c7f..ecb80763dd 100644 --- a/src/pages/chat.tsx +++ b/src/pages/chat.tsx @@ -263,6 +263,27 @@ export default function ChatPage() { }); }; + const removeChatFromWorkspace = (removedChatId: number) => { + if (selectedAppId === null) return; + const nextWorkspaceChatId = workspaceChatIds.find( + (id) => id !== removedChatId, + ); + hideChatFromWorkspace({ appId: selectedAppId, chatId: removedChatId }); + + if (removedChatId === chatId && nextWorkspaceChatId !== undefined) { + store.set(selectedChatIdAtom, nextWorkspaceChatId); + void navigate({ + to: "/chat", + search: { + id: nextWorkspaceChatId, + appId: selectedAppId, + workspace: true, + }, + replace: true, + }); + } + }; + // Keep chat panel size in sync with hidden state (from toolbar button / other views) useEffect(() => { if (!chatPanelRef.current) return; @@ -330,7 +351,7 @@ export default function ChatPage() { className={cn( "min-h-0 flex-1 overflow-hidden", isMultiChatWorkspace && - "grid auto-rows-[minmax(0,1fr)] grid-cols-[repeat(auto-fit,minmax(min(100%,320px),1fr))] gap-1 bg-border p-1", + "scrollbar-on-hover grid auto-rows-[minmax(320px,1fr)] grid-cols-[repeat(auto-fit,minmax(min(100%,320px),1fr))] gap-1 overflow-auto bg-border p-1", )} data-testid="chat-workspace" > @@ -367,14 +388,8 @@ export default function ChatPage() { chatId={workspaceChatId} isFocused={isFocused} onRemoveFromWorkspace={ - isMultiChatWorkspace && - !isFocused && - selectedAppId !== null - ? () => - hideChatFromWorkspace({ - appId: selectedAppId, - chatId: workspaceChatId, - }) + isMultiChatWorkspace && selectedAppId !== null + ? () => removeChatFromWorkspace(workspaceChatId) : undefined } removeFromWorkspaceLabel={t( From a964876969afa3e11f1657f6d1865410aa37c8fd Mon Sep 17 00:00:00 2001 From: Mohamed Aziz Mejri Date: Tue, 25 Aug 2026 13:43:05 +0100 Subject: [PATCH 3/4] fix(chat): isolate multi-pane workspace state --- rules/jotai-state.md | 5 ++ src/atoms/chatWorkspaceAtoms.test.ts | 80 +++++++++++++++---- src/atoms/chatWorkspaceAtoms.ts | 76 +++++++++++++----- src/components/ChatPanel.tsx | 8 +- src/components/chat/ChatHeader.tsx | 5 +- src/components/chat/ChatTabs.test.ts | 4 +- src/components/chat/ChatTabs.tsx | 19 +++-- src/components/chat/LexicalChatInput.test.tsx | 14 +++- src/components/chat/LexicalChatInput.tsx | 22 ++--- src/components/chat/TokenBar.test.tsx | 47 +++++++++++ src/components/chat/TokenBar.tsx | 7 +- src/pages/chat.tsx | 63 +++++++++++++-- src/pages/chatWorkspaceFocus.test.ts | 11 +++ src/pages/chatWorkspaceFocus.ts | 12 +++ src/renderer.tsx | 9 +++ 15 files changed, 309 insertions(+), 73 deletions(-) create mode 100644 src/components/chat/TokenBar.test.tsx create mode 100644 src/pages/chatWorkspaceFocus.test.ts create mode 100644 src/pages/chatWorkspaceFocus.ts diff --git a/rules/jotai-state.md b/rules/jotai-state.md index d99d56c0c1..7440a3bdad 100644 --- a/rules/jotai-state.md +++ b/rules/jotai-state.md @@ -54,6 +54,11 @@ notification, reopen, and tab actions must not bypass the transition. Scope delayed DOM restoration (for example scroll retries) to the selected entity and a generation token so stale callbacks cannot overwrite a later selection. +Repeated entity panes must also isolate DOM focus: restore focus through a ref +owned by the pane instead of `document.querySelector`, and do not let +programmatic descendant focus change router/global selection. Gate pane focus +transitions on explicit pointer, keyboard-navigation, or activation intent. + ## Entity Scoping When state belongs to an entity, key it by that entity id instead of using a diff --git a/src/atoms/chatWorkspaceAtoms.test.ts b/src/atoms/chatWorkspaceAtoms.test.ts index 3523d8fd49..9f67511574 100644 --- a/src/atoms/chatWorkspaceAtoms.test.ts +++ b/src/atoms/chatWorkspaceAtoms.test.ts @@ -1,22 +1,54 @@ import { createStore } from "jotai"; -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { + chatWorkspaceStorageKey, chatWorkspaceByAppIdAtom, createChatWorkspaceStorage, getVisibleChatViewIds, getVisibleWorkspaceChatIds, hideChatFromWorkspaceAtom, pruneChatWorkspaceAtom, + pruneChatWorkspaceWindowSessions, showChatInWorkspaceAtom, } from "./chatWorkspaceAtoms"; +import { + configureChatTabWindowSession, + getActiveWindowSessionId, +} from "@/window_infrastructure/chat_tab_session_storage"; +import { + PRIMARY_WINDOW_SESSION_ID, + type WindowSessionId, +} from "@/window_infrastructure/types"; + +function memoryStorage(values = new Map()): Storage { + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value), + removeItem: (key) => values.delete(key), + clear: () => values.clear(), + key: (index) => Array.from(values.keys())[index] ?? null, + get length() { + return values.size; + }, + }; +} describe("chat workspace atoms", () => { const store = createStore(); beforeEach(() => { + configureChatTabWindowSession(PRIMARY_WINDOW_SESSION_ID, { + mayMigrateLegacySession: false, + }); store.set(chatWorkspaceByAppIdAtom, {}); }); + afterEach(() => { + configureChatTabWindowSession(PRIMARY_WINDOW_SESSION_ID, { + mayMigrateLegacySession: false, + }); + }); + it("adds chats once and preserves their order per app", () => { store.set(showChatInWorkspaceAtom, { appId: 1, chatId: 10 }); store.set(showChatInWorkspaceAtom, { appId: 1, chatId: 20 }); @@ -85,8 +117,9 @@ describe("chat workspace atoms", () => { it("loads only validated workspace entries from versioned storage", () => { const values = new Map(); + const workspaceKey = chatWorkspaceStorageKey(getActiveWindowSessionId()); values.set( - "workspace", + workspaceKey, JSON.stringify({ version: 1, workspaces: { @@ -96,31 +129,50 @@ describe("chat workspace atoms", () => { }, }), ); - const storage = createChatWorkspaceStorage(() => ({ - getItem: (key) => values.get(key) ?? null, - setItem: (key, value) => values.set(key, value), - removeItem: (key) => values.delete(key), - clear: () => values.clear(), - key: (index) => Array.from(values.keys())[index] ?? null, - get length() { - return values.size; - }, - })); + const storage = createChatWorkspaceStorage(() => memoryStorage(values)); expect(storage.getItem("workspace", {})).toEqual({ 1: { visibleChatIds: [10, 20] }, }); values.set( - "workspace", + workspaceKey, JSON.stringify({ version: 2, workspaces: { 1: null } }), ); expect(storage.getItem("workspace", {})).toEqual({}); storage.setItem("workspace", { 3: { visibleChatIds: [30] } }); - expect(JSON.parse(values.get("workspace") ?? "null")).toEqual({ + expect(JSON.parse(values.get(workspaceKey) ?? "null")).toEqual({ version: 1, workspaces: { 3: { visibleChatIds: [30] } }, }); }); + + it("isolates workspace membership by window session and prunes stale keys", () => { + const values = new Map(); + const storageBackend = memoryStorage(values); + const storage = createChatWorkspaceStorage(() => storageBackend); + const firstWindow = + "10000000-0000-4000-8000-000000000001" as WindowSessionId; + const secondWindow = + "10000000-0000-4000-8000-000000000002" as WindowSessionId; + + configureChatTabWindowSession(firstWindow, { + mayMigrateLegacySession: false, + }); + storage.setItem("workspace", { 1: { visibleChatIds: [10] } }); + + configureChatTabWindowSession(secondWindow, { + mayMigrateLegacySession: false, + }); + expect(storage.getItem("workspace", {})).toEqual({}); + storage.setItem("workspace", { 2: { visibleChatIds: [20] } }); + + expect(values.has(chatWorkspaceStorageKey(firstWindow))).toBe(true); + expect(values.has(chatWorkspaceStorageKey(secondWindow))).toBe(true); + + pruneChatWorkspaceWindowSessions(storageBackend, [secondWindow]); + expect(values.has(chatWorkspaceStorageKey(firstWindow))).toBe(false); + expect(values.has(chatWorkspaceStorageKey(secondWindow))).toBe(true); + }); }); diff --git a/src/atoms/chatWorkspaceAtoms.ts b/src/atoms/chatWorkspaceAtoms.ts index d87e53cb4f..c0a0b7a4e1 100644 --- a/src/atoms/chatWorkspaceAtoms.ts +++ b/src/atoms/chatWorkspaceAtoms.ts @@ -1,5 +1,7 @@ import { atom } from "jotai"; import { atomWithStorage } from "jotai/utils"; +import { getActiveWindowSessionId } from "@/window_infrastructure/chat_tab_session_storage"; +import type { WindowSessionId } from "@/window_infrastructure/types"; export interface ChatWorkspaceState { visibleChatIds: number[]; @@ -8,6 +10,13 @@ export interface ChatWorkspaceState { export type ChatWorkspaceByAppId = Record; const CHAT_WORKSPACE_STORAGE_VERSION = 1; +export const CHAT_WORKSPACE_STORAGE_PREFIX = "chat-workspace-v1:"; + +export function chatWorkspaceStorageKey( + windowSessionId: WindowSessionId, +): string { + return `${CHAT_WORKSPACE_STORAGE_PREFIX}${windowSessionId}`; +} interface PersistedChatWorkspaceState { version: typeof CHAT_WORKSPACE_STORAGE_VERSION; @@ -21,11 +30,6 @@ interface ChatWorkspaceSyncStorage { ) => ChatWorkspaceByAppId; setItem: (key: string, newValue: ChatWorkspaceByAppId) => void; removeItem: (key: string) => void; - subscribe: ( - key: string, - callback: (value: ChatWorkspaceByAppId) => void, - initialValue: ChatWorkspaceByAppId, - ) => (() => void) | undefined; } function isRecord(value: unknown): value is Record { @@ -90,34 +94,56 @@ export function createChatWorkspaceStorage( typeof window === "undefined" ? undefined : window.localStorage, ): ChatWorkspaceSyncStorage { return { - getItem(key, initialValue) { + getItem(_key, initialValue) { return deserializeChatWorkspaceState( - getStorage()?.getItem(key) ?? null, + getStorage()?.getItem( + chatWorkspaceStorageKey(getActiveWindowSessionId()), + ) ?? null, initialValue, ); }, - setItem(key, newValue) { + setItem(_key, newValue) { const persisted: PersistedChatWorkspaceState = { version: CHAT_WORKSPACE_STORAGE_VERSION, workspaces: normalizeChatWorkspaceByAppId(newValue), }; - getStorage()?.setItem(key, JSON.stringify(persisted)); - }, - removeItem(key) { - getStorage()?.removeItem(key); + getStorage()?.setItem( + chatWorkspaceStorageKey(getActiveWindowSessionId()), + JSON.stringify(persisted), + ); }, - subscribe(key, callback, initialValue) { - if (typeof window === "undefined") return undefined; - const onStorage = (event: StorageEvent) => { - if (event.key !== key) return; - callback(deserializeChatWorkspaceState(event.newValue, initialValue)); - }; - window.addEventListener("storage", onStorage); - return () => window.removeEventListener("storage", onStorage); + removeItem(_key) { + getStorage()?.removeItem( + chatWorkspaceStorageKey(getActiveWindowSessionId()), + ); }, }; } +export function pruneChatWorkspaceWindowSessions( + storage: Storage, + restorableWindowSessionIds: readonly WindowSessionId[], +): void { + try { + const restorableKeys = new Set( + restorableWindowSessionIds.map(chatWorkspaceStorageKey), + ); + const staleKeys: string[] = []; + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if ( + key?.startsWith(CHAT_WORKSPACE_STORAGE_PREFIX) && + !restorableKeys.has(key) + ) { + staleKeys.push(key); + } + } + for (const key of staleKeys) storage.removeItem(key); + } catch (error) { + console.error("Failed to prune chat workspace window sessions", error); + } +} + export function getVisibleWorkspaceChatIds( workspaceChatIds: number[], validChatIds: Set, @@ -147,12 +173,18 @@ export function getVisibleChatViewIds({ const chatWorkspaceStorage = createChatWorkspaceStorage(); export const chatWorkspaceByAppIdAtom = atomWithStorage( - "chat-workspace-by-app-id", + "chat-workspace-by-window", {}, chatWorkspaceStorage, - { getOnInit: true }, ); +export const initializeChatWorkspaceStorageAtom = atom(null, (_get, set) => { + set( + chatWorkspaceByAppIdAtom, + chatWorkspaceStorage.getItem("chat-workspace-by-window", {}), + ); +}); + function updateWorkspace( workspaces: ChatWorkspaceByAppId, appId: number, diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index 91fe03af80..aa45c43a67 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -122,6 +122,7 @@ export function ChatPanel({ const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); + const terminalToggleButtonRef = useRef(null); // Tracks whether the user is at the bottom of the scroll container. // Uses a ref so followOutput can read it without stale closures, @@ -388,11 +389,7 @@ export function ChatPanel({ return next; }); requestAnimationFrame(() => { - document - .querySelector( - '[data-testid="toggle-terminal-button"]', - ) - ?.focus(); + terminalToggleButtonRef.current?.focus(); }); }, [chatId, setTerminalOpenByChatId]); @@ -414,6 +411,7 @@ export function ChatPanel({ chatId={chatId} onRemoveFromWorkspace={onRemoveFromWorkspace} removeFromWorkspaceLabel={removeFromWorkspaceLabel} + terminalToggleButtonRef={terminalToggleButtonRef} isVersionPaneOpen={isVersionPaneOpen} isPreviewOpen={isPreviewOpen} onTogglePreview={onTogglePreview} diff --git a/src/components/chat/ChatHeader.tsx b/src/components/chat/ChatHeader.tsx index 47294eb2c6..362ba68fbc 100644 --- a/src/components/chat/ChatHeader.tsx +++ b/src/components/chat/ChatHeader.tsx @@ -24,7 +24,7 @@ import { useRouter } from "@tanstack/react-router"; import { useSelectChat } from "@/hooks/useSelectChat"; import { useChats } from "@/hooks/useChats"; import { showError, showSuccess } from "@/lib/toast"; -import { useEffect } from "react"; +import { useEffect, type Ref } from "react"; import { useStreamChat } from "@/hooks/useStreamChat"; import { useCurrentBranch } from "@/hooks/useCurrentBranch"; import { useVersionPreview } from "@/hooks/useVersionPreview"; @@ -39,6 +39,7 @@ interface ChatHeaderProps { chatId?: number; onRemoveFromWorkspace?: () => void; removeFromWorkspaceLabel?: string; + terminalToggleButtonRef?: Ref; isVersionPaneOpen: boolean; isPreviewOpen: boolean; onTogglePreview: () => void; @@ -49,6 +50,7 @@ export function ChatHeader({ chatId, onRemoveFromWorkspace, removeFromWorkspaceLabel, + terminalToggleButtonRef, isVersionPaneOpen, isPreviewOpen, onTogglePreview, @@ -245,6 +247,7 @@ export function ChatHeader({ { it("promotes a non-visible selected tab and bumps the last visible tab", () => { const orderedIds = [4, 2, 3, 1]; const nextIds = applySelectionToOrderedChatIds(orderedIds, 1, 3); - expect(nextIds).toEqual([1, 4, 2, 3]); + expect(nextIds).toEqual([4, 2, 1, 3]); }); it("reorders only visible tabs during drag", () => { @@ -338,7 +338,7 @@ describe("ChatTabs helpers", () => { 2, 4, ); - expect(visibleTabs.map((c) => c.id)).toEqual([4, 1]); + expect(visibleTabs.map((c) => c.id)).toEqual([1, 4]); expect(overflowTabs.map((c) => c.id)).toEqual([2, 3]); }); diff --git a/src/components/chat/ChatTabs.tsx b/src/components/chat/ChatTabs.tsx index 74e672bccf..cd73aaa373 100644 --- a/src/components/chat/ChatTabs.tsx +++ b/src/components/chat/ChatTabs.tsx @@ -312,7 +312,7 @@ export function applySelectionToOrderedChatIds( visibleTabCount: number, ): number[] { const selectedIndex = orderedChatIds.indexOf(selectedChatId); - if (selectedIndex === -1) { + if (selectedIndex === -1 || visibleTabCount <= 0) { // Unknown chat ID — don't modify the order. The caller should // ensure selectedChatId is valid before invoking this function. return orderedChatIds; @@ -324,7 +324,7 @@ export function applySelectionToOrderedChatIds( const nextIds = [...orderedChatIds]; nextIds.splice(selectedIndex, 1); - nextIds.unshift(selectedChatId); + nextIds.splice(Math.max(visibleTabCount - 1, 0), 0, selectedChatId); return nextIds; } @@ -438,12 +438,17 @@ export function partitionChatsByVisibleCount( ); if (visibleTabCount > 0 && selectedIndex >= visibleTabCount) { const selectedChat = orderedChats[selectedIndex]; - const otherChats = orderedChats.filter( - (chat) => chat.id !== selectedChatId, - ); + const visibleTabs = orderedChats.slice(0, visibleTabCount); + const displacedTab = visibleTabs[visibleTabCount - 1]; + visibleTabs[visibleTabCount - 1] = selectedChat; return { - visibleTabs: [selectedChat, ...otherChats.slice(0, visibleTabCount - 1)], - overflowTabs: otherChats.slice(visibleTabCount - 1), + visibleTabs, + overflowTabs: [ + displacedTab, + ...orderedChats + .slice(visibleTabCount) + .filter((chat) => chat.id !== selectedChatId), + ], }; } return { diff --git a/src/components/chat/LexicalChatInput.test.tsx b/src/components/chat/LexicalChatInput.test.tsx index c047de3a98..7c175ace79 100644 --- a/src/components/chat/LexicalChatInput.test.tsx +++ b/src/components/chat/LexicalChatInput.test.tsx @@ -1,6 +1,6 @@ import { render, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { LexicalChatInput } from "./LexicalChatInput"; +import { hasVisibleMentionsMenu, LexicalChatInput } from "./LexicalChatInput"; vi.mock("@/hooks/useLoadApps", () => ({ useLoadApps: () => ({ apps: [] }), @@ -48,4 +48,16 @@ describe("LexicalChatInput", () => { ).not.toBeNull(); }); }); + + it("checks only the owning editor for a visible mentions menu", () => { + const firstEditor = document.createElement("div"); + const secondEditor = document.createElement("div"); + const menu = document.createElement("ul"); + menu.dataset.mentionsMenu = "true"; + menu.append(document.createElement("li")); + firstEditor.append(menu); + + expect(hasVisibleMentionsMenu(firstEditor)).toBe(true); + expect(hasVisibleMentionsMenu(secondEditor)).toBe(false); + }); }); diff --git a/src/components/chat/LexicalChatInput.tsx b/src/components/chat/LexicalChatInput.tsx index 61b948fc3f..1aa6ba68cf 100644 --- a/src/components/chat/LexicalChatInput.tsx +++ b/src/components/chat/LexicalChatInput.tsx @@ -137,9 +137,11 @@ function CustomMenu({ loading: _loading, ...props }: any) { function EnterKeyPlugin({ onSubmit, disableSendButton, + containerRef, }: { onSubmit: () => void; disableSendButton: boolean; + containerRef: React.RefObject; }) { const [editor] = useLexicalComposerContext(); @@ -147,14 +149,7 @@ function EnterKeyPlugin({ return editor.registerCommand( KEY_ENTER_COMMAND, (event: KeyboardEvent) => { - // Check if mentions menu is open by looking for our custom menu element - const mentionsMenu = document.querySelector( - '[data-mentions-menu="true"]', - ); - const hasVisibleItems = - mentionsMenu && mentionsMenu.children.length > 0; - - if (hasVisibleItems) { + if (hasVisibleMentionsMenu(containerRef.current)) { // If mentions menu is open with items, let the mentions plugin handle the Enter key return false; } @@ -168,11 +163,16 @@ function EnterKeyPlugin({ }, COMMAND_PRIORITY_HIGH, // Use higher priority to catch before mentions plugin ); - }, [editor, onSubmit, disableSendButton]); + }, [containerRef, editor, onSubmit, disableSendButton]); return null; } +export function hasVisibleMentionsMenu(container: HTMLElement | null): boolean { + const mentionsMenu = container?.querySelector('[data-mentions-menu="true"]'); + return Boolean(mentionsMenu && mentionsMenu.children.length > 0); +} + function EditableStatePlugin({ editable }: { editable: boolean }) { const [editor] = useLexicalComposerContext(); @@ -312,6 +312,7 @@ export function LexicalChatInput({ const { prompts } = usePrompts(); const { mediaApps } = useAppMediaFiles(); const historyTriggerActiveRef = useRef(false); + const editorContainerRef = useRef(null); const selectedAppId = useAtomValue(selectedAppIdAtom); const { app } = useLoadApp(selectedAppId); const appFiles = app?.files; @@ -495,7 +496,7 @@ export function LexicalChatInput({ return ( -
+
({ + useCountTokens: vi.fn(), +})); + +vi.mock("@/hooks/useCountTokens", () => ({ + useCountTokens: mocks.useCountTokens, +})); + +vi.mock("@/hooks/useSettings", () => ({ + useSettings: () => ({ settings: undefined }), +})); + +describe("TokenBar", () => { + beforeEach(() => { + mocks.useCountTokens.mockReset(); + mocks.useCountTokens.mockReturnValue({ result: null, error: null }); + }); + + it("counts the draft belonging to its pane chat", () => { + const store = createStore(); + store.set(selectedChatIdAtom, 1); + store.set( + chatInputValuesByIdAtom, + new Map([ + [1, "focused draft"], + [2, "background pane draft"], + ]), + ); + + render( + + + , + ); + + expect(mocks.useCountTokens).toHaveBeenCalledWith( + 2, + "background pane draft", + ); + }); +}); diff --git a/src/components/chat/TokenBar.tsx b/src/components/chat/TokenBar.tsx index 5e8bccbe55..08f254a522 100644 --- a/src/components/chat/TokenBar.tsx +++ b/src/components/chat/TokenBar.tsx @@ -13,8 +13,8 @@ import { AlignLeft, ExternalLink, } from "lucide-react"; -import { chatInputValueAtom } from "@/atoms/chatAtoms"; -import { useAtom } from "jotai"; +import { chatInputValuesByIdAtom } from "@/atoms/chatAtoms"; +import { useAtomValue } from "jotai"; import { useSettings } from "@/hooks/useSettings"; import { ipc } from "@/ipc/types"; @@ -23,7 +23,8 @@ interface TokenBarProps { } export function TokenBar({ chatId }: TokenBarProps) { - const [inputValue] = useAtom(chatInputValueAtom); + const inputValuesById = useAtomValue(chatInputValuesByIdAtom); + const inputValue = chatId ? (inputValuesById.get(chatId) ?? "") : ""; const { settings } = useSettings(); const { result, error } = useCountTokens(chatId ?? null, inputValue); diff --git a/src/pages/chat.tsx b/src/pages/chat.tsx index ecb80763dd..1dd990923a 100644 --- a/src/pages/chat.tsx +++ b/src/pages/chat.tsx @@ -25,6 +25,7 @@ import { import { Button } from "@/components/ui/button"; import { PanelsTopLeft } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { shouldFocusWorkspacePane } from "./chatWorkspaceFocus"; const DEFAULT_CHAT_PANEL_SIZE = 50; @@ -63,17 +64,17 @@ export default function ChatPage() { ), [selectedAppId, validChatIds, workspaces], ); + const isWorkspaceView = isWorkspaceRoute && workspaceChatIds.length > 0; const visibleChatIds = useMemo( () => getVisibleChatViewIds({ workspaceChatIds, focusedChatId: chatId, validChatIds, - isWorkspaceView: isWorkspaceRoute, + isWorkspaceView, }), - [chatId, isWorkspaceRoute, validChatIds, workspaceChatIds], + [chatId, isWorkspaceView, validChatIds, workspaceChatIds], ); - const isWorkspaceView = isWorkspaceRoute && workspaceChatIds.length > 0; const isMultiChatWorkspace = isWorkspaceView && workspaceChatIds.length > 1; const previousSizeRef = useRef(DEFAULT_CHAT_PANEL_SIZE); const isInitialMountRef = useRef(true); @@ -218,15 +219,24 @@ export default function ChatPage() { return; } - if (validChatIds.has(chatId)) { + const fallbackChatId = validChatIds.has(chatId) ? chatId : chats[0]?.id; + if (fallbackChatId !== undefined) { navigate({ to: "/chat", - search: { id: chatId, appId: selectedAppId }, + search: { id: fallbackChatId, appId: selectedAppId }, replace: true, }); + return; } + + navigate({ + to: "/app-details", + search: { appId: selectedAppId }, + replace: true, + }); }, [ chatId, + chats, isWorkspaceRoute, loading, navigate, @@ -263,6 +273,27 @@ export default function ChatPage() { }); }; + const keyboardFocusIntentRef = useRef(false); + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Tab") keyboardFocusIntentRef.current = true; + }; + const clearIntent = (event: KeyboardEvent) => { + if (event.key === "Tab") keyboardFocusIntentRef.current = false; + }; + const clearOnBlur = () => { + keyboardFocusIntentRef.current = false; + }; + window.addEventListener("keydown", onKeyDown, true); + window.addEventListener("keyup", clearIntent, true); + window.addEventListener("blur", clearOnBlur); + return () => { + window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("keyup", clearIntent, true); + window.removeEventListener("blur", clearOnBlur); + }; + }, []); + const removeChatFromWorkspace = (removedChatId: number) => { if (selectedAppId === null) return; const nextWorkspaceChatId = workspaceChatIds.find( @@ -378,11 +409,27 @@ export default function ChatPage() { isFocused ? "border-primary" : "border-transparent", )} onPointerDownCapture={(event) => + shouldFocusWorkspacePane("pointer", false) && focusChat(workspaceChatId, event.target) } - onFocusCapture={(event) => - focusChat(workspaceChatId, event.target) - } + onClickCapture={(event) => { + if ( + event.detail === 0 && + shouldFocusWorkspacePane("activation", false) + ) { + focusChat(workspaceChatId, event.target); + } + }} + onFocusCapture={(event) => { + if ( + shouldFocusWorkspacePane( + "focus", + keyboardFocusIntentRef.current, + ) + ) { + focusChat(workspaceChatId, event.target); + } + }} > { + it("ignores programmatic focus but accepts user focus intent", () => { + expect(shouldFocusWorkspacePane("focus", false)).toBe(false); + expect(shouldFocusWorkspacePane("focus", true)).toBe(true); + expect(shouldFocusWorkspacePane("pointer", false)).toBe(true); + expect(shouldFocusWorkspacePane("activation", false)).toBe(true); + }); +}); diff --git a/src/pages/chatWorkspaceFocus.ts b/src/pages/chatWorkspaceFocus.ts new file mode 100644 index 0000000000..20383c0819 --- /dev/null +++ b/src/pages/chatWorkspaceFocus.ts @@ -0,0 +1,12 @@ +export type WorkspaceFocusTrigger = "pointer" | "activation" | "focus"; + +export function shouldFocusWorkspacePane( + trigger: WorkspaceFocusTrigger, + keyboardNavigationActive: boolean, +): boolean { + return ( + trigger === "pointer" || + trigger === "activation" || + (trigger === "focus" && keyboardNavigationActive) + ); +} diff --git a/src/renderer.tsx b/src/renderer.tsx index 887425159d..f64cfcfc74 100644 --- a/src/renderer.tsx +++ b/src/renderer.tsx @@ -44,6 +44,10 @@ import { ensureRecentViewedChatIdAtom, initializeChatTabSessionStorageAtom, } from "./atoms/chatAtoms"; +import { + initializeChatWorkspaceStorageAtom, + pruneChatWorkspaceWindowSessions, +} from "./atoms/chatWorkspaceAtoms"; import { configureChatTabWindowSession, promoteMostRecentChatTabSession, @@ -254,7 +258,12 @@ function RendererServices() { window.localStorage, bootstrap.restorableWindowSessionIds, ); + pruneChatWorkspaceWindowSessions( + window.localStorage, + bootstrap.restorableWindowSessionIds, + ); store.set(initializeChatTabSessionStorageAtom); + store.set(initializeChatWorkspaceStorageAtom); } catch (error) { // Browser storage is optional presentation state. A denied or full // localStorage must not turn a successful main-process bootstrap From 47269b2418fc67847f1088b56ad1fcdbab019538 Mon Sep 17 00:00:00 2001 From: Mohamed Aziz Mejri Date: Tue, 25 Aug 2026 14:16:42 +0100 Subject: [PATCH 4/4] Address multi-chat workspace review comments - isolate workspace focus from single-chat presentation transitions - recover id-less routes and prune transferred workspace tabs - cover multi-pane isolation and the packaged workspace workflow --- e2e-tests/chat_tabs.spec.ts | 57 +++++++ rules/e2e-testing.md | 1 + src/components/chat/ChatTabs.test.ts | 88 ++++++++++- src/components/chat/ChatTabs.tsx | 123 ++++++++++++++- src/pages/chat.tsx | 157 +++++++++++-------- src/pages/chatWorkspace.integration.test.tsx | 64 ++++++++ src/testing/hybrid_chat_harness.tsx | 36 ++++- 7 files changed, 446 insertions(+), 80 deletions(-) create mode 100644 src/pages/chatWorkspace.integration.test.tsx diff --git a/e2e-tests/chat_tabs.spec.ts b/e2e-tests/chat_tabs.spec.ts index 35e1dcec44..fb81677e01 100644 --- a/e2e-tests/chat_tabs.spec.ts +++ b/e2e-tests/chat_tabs.spec.ts @@ -231,6 +231,63 @@ test("right-click context menu: Close tabs to the right", async ({ po }) => { }).toPass({ timeout: Timeout.MEDIUM }); }); +test("adds, focuses, and removes chats in a workspace", async ({ po }) => { + await po.setUp({ autoApprove: true }); + await po.importApp("minimal"); + + await po.sendPrompt("[dump] Workspace chat one"); + await po.chatActions.waitForChatCompletion(); + const firstActiveChatTab = po.page + .locator('[data-testid^="chat-tab-"][draggable="true"]') + .filter({ has: po.page.locator('button[aria-current="page"]') }); + await expect(firstActiveChatTab).toBeVisible({ timeout: Timeout.MEDIUM }); + const firstChatId = Number( + (await firstActiveChatTab.getAttribute("data-testid"))?.replace( + "chat-tab-", + "", + ), + ); + + await po.chatActions.clickNewChat(); + await po.sendPrompt("[dump] Workspace chat two"); + await po.chatActions.waitForChatCompletion(); + const secondChatTab = po.page + .locator('[data-testid^="chat-tab-"][draggable="true"]') + .filter({ has: po.page.locator('button[aria-current="page"]') }); + await expect(secondChatTab).toBeVisible({ timeout: Timeout.MEDIUM }); + const secondChatId = Number( + (await secondChatTab.getAttribute("data-testid"))?.replace("chat-tab-", ""), + ); + expect(secondChatId).not.toBe(firstChatId); + const firstChatTab = po.page.getByTestId(`chat-tab-${firstChatId}`); + const secondOwnedChatTab = po.page.getByTestId(`chat-tab-${secondChatId}`); + + await firstChatTab.click({ button: "right" }); + await po.page.getByText("Add to workspace", { exact: true }).click(); + await secondOwnedChatTab.click({ button: "right" }); + await po.page.getByText("Add to workspace", { exact: true }).click(); + + const workspaceTab = po.page.locator( + '[data-testid^="chat-workspace-tab-"] button', + ); + await expect(workspaceTab).toBeVisible({ timeout: Timeout.MEDIUM }); + await workspaceTab.click(); + + const firstPane = po.page.getByTestId(`chat-workspace-pane-${firstChatId}`); + const secondPane = po.page.getByTestId(`chat-workspace-pane-${secondChatId}`); + await expect(firstPane).toBeVisible({ timeout: Timeout.MEDIUM }); + await expect(secondPane).toBeVisible({ timeout: Timeout.MEDIUM }); + + await firstPane.click({ position: { x: 8, y: 8 } }); + await expect(firstPane).toHaveAttribute("aria-label", /, focused$/); + + await firstPane + .getByRole("button", { name: /^Remove .* from workspace$/ }) + .click(); + await expect(firstPane).toHaveCount(0); + await expect(secondPane).toBeVisible(); +}); + test("right-click context menu: Reopen closed tab", async ({ po }) => { await po.setUp({ autoApprove: true }); await po.importApp("minimal"); diff --git a/rules/e2e-testing.md b/rules/e2e-testing.md index ad95c16b9c..38729c0ce0 100644 --- a/rules/e2e-testing.md +++ b/rules/e2e-testing.md @@ -208,6 +208,7 @@ If a targeted E2E fails before launch with `ENOENT: no such file or directory, s - **After `page.reload()`**: Always add `await page.waitForLoadState("domcontentloaded")` before interacting with elements. Without this, the page may not have re-rendered yet. - **Keyboard navigation events (ArrowUp/ArrowDown)**: Add `await page.waitForTimeout(100)` between sequential keyboard presses to let the UI state settle. Rapid keypresses can cause race conditions in menu navigation. - **Navigation to tabs**: Use `await expect(link).toBeVisible({ timeout: Timeout.EXTRA_LONG })` before clicking tab links (especially in `goToAppsTab()`). Electron sidebar links can take time to render during app initialization. +- **Chat-tab test-id prefixes**: `[data-testid^="chat-tab-"]` also matches `chat-tab-drop-zone`. When a test needs actual tabs, restrict the locator to draggable elements: `[data-testid^="chat-tab-"][draggable="true"]`. - **Collapsed sidebar app/chat lists**: App and chat sub-lists may be hidden until the sidebar rail item is hovered. Use page-object helpers such as `po.appManagement.showAppList()` or `po.chatActions.clickNewChat()` instead of asserting list items are visible immediately after navigation. - **Starting a new chat during an active first turn**: Before calling `clickNewChat()`, wait until the first chat's ID is present in the URL. If the helper captures a null prior ID, the first chat's delayed navigation can satisfy its "some chat selected" assertion and leave the next prompt in the original chat. - **Imported chat tab state**: Import flows should select the newly created chat through `useSelectChat().selectChat(...)`, not direct `navigate({ to: "/chat" })`, so current-session chat tabs are seeded consistently. diff --git a/src/components/chat/ChatTabs.test.ts b/src/components/chat/ChatTabs.test.ts index 1e7500af0a..7c2621dbc7 100644 --- a/src/components/chat/ChatTabs.test.ts +++ b/src/components/chat/ChatTabs.test.ts @@ -28,6 +28,8 @@ import { getFallbackChatIdAfterClose, getChatWorkspaceTabs, groupChatIdsByApp, + isIntraWorkspaceFocusChange, + isIntraWorkspaceNavigation, partitionChatsByVisibleCount, reorderVisibleChatIds, restoreLocalStorageSnapshot, @@ -130,6 +132,8 @@ describe("ChatTabs helpers", () => { return callbacks.length; }); const wrapper = document.createElement("div"); + const pane = document.createElement("section"); + pane.dataset.chatId = "7"; wrapper.dataset.testid = "messages-list"; const viewport = document.createElement("div"); viewport.dataset.virtuosoScroller = ""; @@ -138,9 +142,10 @@ describe("ChatTabs helpers", () => { clientHeight: { value: 200 }, }); wrapper.append(viewport); - document.body.append(wrapper); + pane.append(wrapper); + document.body.append(pane); - restoreMessagesScrollTop(300, () => true); + restoreMessagesScrollTop(300, () => true, 7); callbacks.shift()?.(0); expect(viewport.scrollTop).toBe(300); @@ -149,7 +154,39 @@ describe("ChatTabs helpers", () => { callbacks.shift()?.(2); callbacks.shift()?.(3); expect(viewport.scrollTop).toBe(300); - wrapper.remove(); + pane.remove(); + }); + + it("restores scroll only in the pane that owns the chat", () => { + const callbacks: FrameRequestCallback[] = []; + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + callbacks.push(callback); + return callbacks.length; + }); + const makePane = (chatId: number, scrollTop: number) => { + const pane = document.createElement("section"); + pane.dataset.chatId = String(chatId); + const messages = document.createElement("div"); + messages.dataset.testid = "messages-list"; + Object.defineProperties(messages, { + scrollHeight: { value: 1_000 }, + clientHeight: { value: 200 }, + }); + messages.scrollTop = scrollTop; + pane.append(messages); + document.body.append(pane); + return { pane, messages }; + }; + const first = makePane(7, 100); + const second = makePane(8, 600); + + restoreMessagesScrollTop(250, () => true, 7); + callbacks.shift()?.(0); + + expect(first.messages.scrollTop).toBe(250); + expect(second.messages.scrollTop).toBe(600); + first.pane.remove(); + second.pane.remove(); }); it("reselects the active chat when navigation must return to the chat route", () => { @@ -204,6 +241,51 @@ describe("ChatTabs helpers", () => { ).toBe(false); }); + it("recognizes focus-only navigation inside one workspace", () => { + expect( + isIntraWorkspaceNavigation( + "/chat", + { appId: 1, workspace: true }, + "/chat", + { appId: 1, workspace: true }, + ), + ).toBe(true); + expect( + isIntraWorkspaceNavigation( + "/chat", + { appId: 1, workspace: true }, + "/chat", + { appId: 2, workspace: true }, + ), + ).toBe(false); + + const chatsById = new Map([ + [7, chat(7, 1)], + [8, chat(8, 1)], + [9, chat(9, 2)], + ]); + expect( + isIntraWorkspaceFocusChange({ + isWorkspaceRoute: true, + workspaceAppId: 1, + previousChatId: 7, + selectedChatId: 8, + workspaces: { 1: { visibleChatIds: [7, 8] } }, + chatsById, + }), + ).toBe(true); + expect( + isIntraWorkspaceFocusChange({ + isWorkspaceRoute: true, + workspaceAppId: 1, + previousChatId: 7, + selectedChatId: 9, + workspaces: { 1: { visibleChatIds: [7, 9] } }, + chatsById, + }), + ).toBe(false); + }); + it("does not reuse a stale capture marker for a later destination", () => { const settingsCapture = { fromChatId: 7, toChatId: null }; diff --git a/src/components/chat/ChatTabs.tsx b/src/components/chat/ChatTabs.tsx index cd73aaa373..d84c082c03 100644 --- a/src/components/chat/ChatTabs.tsx +++ b/src/components/chat/ChatTabs.tsx @@ -109,8 +109,13 @@ const CHAT_TAB_TRANSFER_MIME = "application/x-dyad-chat-tab-transfer"; const SCROLL_RESTORE_MAX_FRAMES = 120; const SCROLL_RESTORE_STABILIZATION_FRAMES = 4; -function getMessagesScrollViewport(): HTMLElement | null { - const wrapper = document.querySelector( +function getMessagesScrollViewport(chatId?: number): HTMLElement | null { + const root = + chatId === undefined + ? document + : document.querySelector(`[data-chat-id="${chatId}"]`); + if (!root) return null; + const wrapper = root.querySelector( '[data-testid="messages-list"]', ); return ( @@ -135,12 +140,13 @@ export function restoreLocalStorageSnapshot( export function restoreMessagesScrollTop( scrollTop: number, shouldContinue: () => boolean, + chatId?: number, ): void { let remainingFrames = SCROLL_RESTORE_MAX_FRAMES; let stableFrames = 0; const apply = () => { if (!shouldContinue()) return; - const viewport = getMessagesScrollViewport(); + const viewport = getMessagesScrollViewport(chatId); if (viewport) { viewport.scrollTop = scrollTop; if ( @@ -385,6 +391,56 @@ export function shouldCapturePresentationBeforeNavigation( ); } +export function isIntraWorkspaceNavigation( + fromPathname: string | undefined, + fromSearch: { appId?: unknown; workspace?: unknown }, + toPathname: string, + toSearch: { appId?: unknown; workspace?: unknown }, +): boolean { + return ( + fromPathname === "/chat" && + toPathname === "/chat" && + fromSearch.workspace === true && + toSearch.workspace === true && + typeof fromSearch.appId === "number" && + fromSearch.appId === toSearch.appId + ); +} + +export function isIntraWorkspaceFocusChange({ + isWorkspaceRoute, + workspaceAppId, + previousChatId, + selectedChatId, + workspaces, + chatsById, +}: { + isWorkspaceRoute: boolean; + workspaceAppId: number | undefined; + previousChatId: number | null; + selectedChatId: number | null; + workspaces: ChatWorkspaceByAppId; + chatsById: Map; +}): boolean { + if ( + !isWorkspaceRoute || + workspaceAppId === undefined || + previousChatId === null || + selectedChatId === null || + previousChatId === selectedChatId + ) { + return false; + } + + const workspaceChatIds = workspaces[workspaceAppId]?.visibleChatIds ?? []; + return ( + workspaceChatIds.includes(previousChatId) && + workspaceChatIds.includes(selectedChatId) && + chatsById.get(previousChatId)?.appId === workspaceAppId && + chatsById.get(selectedChatId)?.appId === workspaceAppId + ); +} + export interface PreNavigationPresentationCapture { fromChatId: number; toChatId: number | null; @@ -656,7 +712,7 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) { const capturePresentation = useCallback( (chatId: number, appId: number) => { - const messages = getMessagesScrollViewport(); + const messages = getMessagesScrollViewport(chatId); if ( pathname !== "/chat" || presentedChatIdRef.current !== chatId || @@ -725,6 +781,7 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) { scrollRestoreGeneration === scrollRestoreGenerationRef.current && (options.chatId === undefined || store.get(selectedChatIdAtom) === options.chatId), + presentationChatId, ); if (options.restoreComponents !== false) { requestAnimationFrame(() => { @@ -756,9 +813,23 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) { const presentedChatId = presentedChatIdRef.current; if (presentedChatId === null) return; const fromSearch = navigation.fromLocation?.search as - | { id?: unknown } + | { id?: unknown; appId?: unknown; workspace?: unknown } | undefined; - const toSearch = navigation.toLocation.search as { id?: unknown }; + const toSearch = navigation.toLocation.search as { + id?: unknown; + appId?: unknown; + workspace?: unknown; + }; + if ( + isIntraWorkspaceNavigation( + navigation.fromLocation?.pathname, + fromSearch ?? {}, + navigation.toLocation.pathname, + toSearch, + ) + ) { + return; + } const fromChatId = typeof fromSearch?.id === "number" ? fromSearch.id : null; const toChatId = typeof toSearch.id === "number" ? toSearch.id : null; @@ -796,6 +867,24 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) { ); if (previousChatId === selectedChatId) return; + if ( + isIntraWorkspaceFocusChange({ + isWorkspaceRoute, + workspaceAppId: workspaceRouteAppId, + previousChatId, + selectedChatId, + workspaces, + chatsById, + }) + ) { + // Workspace panes remain mounted and own their scroll/input state. A + // focus change only routes shared actions; it must not replay the + // single-chat file/preview/panel presentation transition. + scrollRestoreGenerationRef.current += 1; + presentedChatIdRef.current = selectedChatId; + return; + } + if (previousChatId !== null && !capturedBeforeNavigation) { const previousChat = chatsById.get(previousChatId); if (previousChat) { @@ -823,9 +912,12 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) { }, [ capturePresentation, chatsById, + isWorkspaceRoute, neutralPresentation, restorePresentation, selectedChatId, + workspaces, + workspaceRouteAppId, ]); const adoptCrossWindowTab = useCallback( @@ -1211,12 +1303,23 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) { ); const previousSelectedChatId = store.get(selectedChatIdAtom); const movedChat = chatsById.get(chatId); + const wasInWorkspace = movedChat + ? (store + .get(chatWorkspaceByAppIdAtom) + [movedChat.appId]?.visibleChatIds.includes(chatId) ?? false) + : false; const movedPresentation = movedChat ? capturePresentation(chatId, movedChat.appId) : null; try { const remaining = orderedChatIds.filter((id) => id !== chatId); store.set(removeTransferredChatTabAtom, chatId); + if (movedChat) { + hideChatFromWorkspace({ + appId: movedChat.appId, + chatId, + }); + } if (store.get(selectedChatIdAtom) === chatId) { const fallback = remaining[0]; const fallbackChat = fallback @@ -1261,6 +1364,12 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) { store.set(sessionOpenedChatIdsAtom, previousSession); store.set(chatInputValuesByIdAtom, previousDrafts); store.set(terminalOpenByChatIdAtom, previousTerminals); + if (wasInWorkspace && movedChat) { + showChatInWorkspace({ + appId: movedChat.appId, + chatId, + }); + } if ( previousSelectedChatId === chatId && movedChat && @@ -1288,11 +1397,13 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) { capturePresentation, chatsById, hasHydratedTabSession, + hideChatFromWorkspace, navigate, neutralPresentation, orderedChatIds, restorePresentation, selectChat, + showChatInWorkspace, store, ]); diff --git a/src/pages/chat.tsx b/src/pages/chat.tsx index 1dd990923a..4d9021995d 100644 --- a/src/pages/chat.tsx +++ b/src/pages/chat.tsx @@ -154,10 +154,6 @@ export default function ChatPage() { ]); useEffect(() => { - if (!chatId) { - return; - } - if (routeAppId) { if (routeAppId !== selectedAppIdRef.current) { selectedAppIdRef.current = routeAppId; @@ -166,6 +162,10 @@ export default function ChatPage() { return; } + if (!chatId) { + return; + } + // If chatId is already in our loaded chats list, selectedAppId is correct // for this chat (useChats filters by selectedAppId), so skip the IPC fetch. if (chats.some((c) => c.id === chatId)) { @@ -386,76 +386,97 @@ export default function ChatPage() { )} data-testid="chat-workspace" > - {visibleChatIds.map((workspaceChatId) => { - const isFocused = workspaceChatId === chatId; - const workspaceChat = chats.find( - (chat) => chat.id === workspaceChatId, - ); - const chatLabel = - workspaceChat?.title?.trim() || `Chat ${workspaceChatId}`; - return ( -
- shouldFocusWorkspacePane("pointer", false) && - focusChat(workspaceChatId, event.target) - } - onClickCapture={(event) => { - if ( - event.detail === 0 && - shouldFocusWorkspacePane("activation", false) - ) { - focusChat(workspaceChatId, event.target); + {visibleChatIds.length === 0 ? ( +
+ { + setIsPreviewOpen(!isPreviewOpen); + if (isPreviewOpen) { + ref.current?.collapse(); + } else { + ref.current?.expand(); } }} - onFocusCapture={(event) => { - if ( - shouldFocusWorkspacePane( - "focus", - keyboardFocusIntentRef.current, - ) - ) { - focusChat(workspaceChatId, event.target); + /> +
+ ) : ( + visibleChatIds.map((workspaceChatId) => { + const isFocused = workspaceChatId === chatId; + const workspaceChat = chats.find( + (chat) => chat.id === workspaceChatId, + ); + const chatLabel = + workspaceChat?.title?.trim() || `Chat ${workspaceChatId}`; + return ( +
- removeChatFromWorkspace(workspaceChatId) - : undefined - } - removeFromWorkspaceLabel={t( - "removeFromWorkspaceNamed", - { title: chatLabel }, + data-testid={`chat-workspace-pane-${workspaceChatId}`} + data-chat-id={workspaceChatId} + className={cn( + "relative min-h-0 overflow-hidden bg-background", + !isMultiChatWorkspace && "h-full", + isMultiChatWorkspace && "rounded-md border-2", + isFocused ? "border-primary" : "border-transparent", )} - isPreviewOpen={isPreviewOpen} - onTogglePreview={() => { - setIsPreviewOpen(!isPreviewOpen); - if (isPreviewOpen) { - ref.current?.collapse(); - } else { - ref.current?.expand(); + onPointerDownCapture={(event) => + shouldFocusWorkspacePane("pointer", false) && + focusChat(workspaceChatId, event.target) + } + onClickCapture={(event) => { + if ( + event.detail === 0 && + shouldFocusWorkspacePane("activation", false) + ) { + focusChat(workspaceChatId, event.target); } }} - /> -
- ); - })} + onFocusCapture={(event) => { + if ( + shouldFocusWorkspacePane( + "focus", + keyboardFocusIntentRef.current, + ) + ) { + focusChat(workspaceChatId, event.target); + } + }} + > + removeChatFromWorkspace(workspaceChatId) + : undefined + } + removeFromWorkspaceLabel={t( + "removeFromWorkspaceNamed", + { title: chatLabel }, + )} + isPreviewOpen={isPreviewOpen} + onTogglePreview={() => { + setIsPreviewOpen(!isPreviewOpen); + if (isPreviewOpen) { + ref.current?.collapse(); + } else { + ref.current?.expand(); + } + }} + /> +
+ ); + }) + )}
)} diff --git a/src/pages/chatWorkspace.integration.test.tsx b/src/pages/chatWorkspace.integration.test.tsx new file mode 100644 index 0000000000..6284404a8b --- /dev/null +++ b/src/pages/chatWorkspace.integration.test.tsx @@ -0,0 +1,64 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { screen, waitFor, within } from "@testing-library/react"; +import { + setupHybridChatHarness, + type HybridChatHarness, +} from "@/testing/hybrid_chat_harness"; +import { h } from "@/testing/hybrid.setup"; + +describe("multi-chat workspace isolation (hybrid)", () => { + let harness: HybridChatHarness; + + beforeAll(async () => { + harness = await setupHybridChatHarness({ + electronMock: h, + settings: { isTestMode: true }, + }); + }, 60_000); + + afterAll(async () => { + await harness?.dispose(); + }); + + it("isolates drafts, scroll, and sends across two mounted panes", async () => { + const firstChatId = harness.chatId; + const secondChatId = await harness.createChat(); + harness.mountSurface({ + chatId: firstChatId, + workspaceChatIds: [firstChatId, secondChatId], + }); + + const firstPane = await screen.findByTestId( + `chat-workspace-pane-${firstChatId}`, + ); + const secondPane = await screen.findByTestId( + `chat-workspace-pane-${secondChatId}`, + ); + const firstMessages = within(firstPane).getByTestId("messages-list"); + const secondMessages = within(secondPane).getByTestId("messages-list"); + firstMessages.scrollTop = 120; + secondMessages.scrollTop = 480; + + harness.setChatInputValue("first pane draft", { chatId: firstChatId }); + harness.setChatInputValue("second pane draft", { chatId: secondChatId }); + expect(harness.getChatInputValue(firstChatId)).toBe("first pane draft"); + expect(harness.getChatInputValue(secondChatId)).toBe("second pane draft"); + + const prompt = "[dump] sent only from the first workspace pane"; + const { send } = await harness.typeInChat(prompt, { + chatId: firstChatId, + }); + send(); + + await waitFor(() => { + expect(within(firstPane).getByText(prompt)).toBeTruthy(); + expect(within(secondPane).queryByText(prompt)).toBeNull(); + }); + await harness.waitForStreamEnd(firstChatId); + + expect(harness.getChatInputValue(firstChatId)).toBe(""); + expect(harness.getChatInputValue(secondChatId)).toBe("second pane draft"); + expect(firstMessages.scrollTop).toBe(120); + expect(secondMessages.scrollTop).toBe(480); + }, 60_000); +}); diff --git a/src/testing/hybrid_chat_harness.tsx b/src/testing/hybrid_chat_harness.tsx index e1f51c80a2..bc35c8d04f 100644 --- a/src/testing/hybrid_chat_harness.tsx +++ b/src/testing/hybrid_chat_harness.tsx @@ -248,6 +248,8 @@ export interface MountOptions { chatId?: number; /** App to select. Default: the harness's default app. */ appId?: number; + /** Render multiple real ChatPanels in one workspace-like grid. */ + workspaceChatIds?: number[]; /** Install the same main->renderer listeners AppRoot registers. Default true. */ wireAppEvents?: boolean; /** Render the plan preview panel alongside ChatPanel for plan-mode tests. */ @@ -791,6 +793,26 @@ export async function setupHybridChatHarness( validateSearch: chatSearchSchema, component: function HybridChatRoute() { const search = chatTestRoute.useSearch(); + if (opts.workspaceChatIds && opts.workspaceChatIds.length > 0) { + return ( +
+ {opts.workspaceChatIds.map((workspaceChatId) => ( +
+ {}} + /> +
+ ))} +
+ ); + } return ( <> => { seedChatInput(text, opts); - const sendButton = await screen.findByLabelText( - /^(sendMessage|Send message)$/, + const pane = document.querySelector( + `[data-chat-id="${opts.chatId ?? nodeHarness.chatId}"]`, ); + const sendButton = pane + ? await within(pane).findByLabelText(/^(sendMessage|Send message)$/) + : await screen.findByLabelText(/^(sendMessage|Send message)$/); await waitFor(() => { expect((sendButton as HTMLButtonElement).hasAttribute("disabled")).toBe( false, @@ -1220,7 +1245,12 @@ export async function setupHybridChatHarness( opts: MountOptions = {}, ): Promise => { seedChatInput(text, opts); - const container = screen.getByTestId("chat-input-container"); + const pane = document.querySelector( + `[data-chat-id="${opts.chatId ?? nodeHarness.chatId}"]`, + ); + const container = pane + ? within(pane).getByTestId("chat-input-container") + : screen.getByTestId("chat-input-container"); const editable = container.querySelector('[contenteditable="true"]'); if (!editable) { throw new Error(