Skip to content

feat(chat): add multi-chat workspaces and workspace tabs - #4372

Draft
azizmejri1 wants to merge 4 commits into
dyad-sh:mainfrom
azizmejri1:feat/multi-chat-workspace-tabs
Draft

feat(chat): add multi-chat workspaces and workspace tabs#4372
azizmejri1 wants to merge 4 commits into
dyad-sh:mainfrom
azizmejri1:feat/multi-chat-workspace-tabs

Conversation

@azizmejri1

@azizmejri1 azizmejri1 commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a per-app multi-chat workspace that lets users view and interact with several chats at once while preserving the existing standalone-chat experience. Workspace membership remains explicit and window-session scoped, and this review revision makes focus changes presentation-safe and covers the real multi-pane workflow.

  • Workspace focus changes route shared actions without replaying single-chat file, preview, panel, or scroll presentation state; targeted scroll restoration is scoped to the pane that owns the chat.
  • The id-less /chat route keeps rendering its shell while app/chat redirects settle, and route-provided app identity now self-heals before redirecting.
  • Closing or transferring tabs prunes workspace membership, including rollback when a cross-window transfer cannot be confirmed.
  • Multi-pane drafts, scroll positions, and sends are exercised through the renderer+IPC hybrid harness, while Playwright covers add β†’ focus β†’ remove in the packaged Electron app.
  • Workspace panes continue sharing the app preview intentionally; pane-local chat state remains keyed by chat ID.

How to use

  1. Open the chats you want to work with.
  2. Right-click a chat tab and select Add to workspace.
  3. Repeat for other chats belonging to the same app.
  4. Click the colored Workspace tab with the pane-layout icon.
  5. Click or focus a pane to make it the focused chat.
  6. Select Open focused chat to open that chat on its standalone page. The workspace remains unchanged.
  7. Use Remove from workspace to remove a chat from the layout without closing or deleting it.

Behavior

  • Workspace panes use a usable minimum height, and the workspace scrolls when all panes cannot fit in the available chat-panel height.
  • Each pane retains its own messages, draft, token estimate, scroll position, streaming state, and chat actions.
  • The focused pane has a highlighted border and receives shared chat actions; programmatic descendant focus does not change the focused route.
  • Clicking a normal chat tab opens its standalone page and does not modify workspace membership.
  • Chats are added only through Add to workspace.
  • Removing a chat from the workspace does not close its tab or delete the chat.
  • Workspace membership is stored separately for each app and renderer-window session; invalid chats and stale window-session data are automatically pruned.
  • The app preview remains shared across all chats in the workspace.
  • Workspace labels, controls, and accessibility text are localized across all supported languages.

Review in cubic

@dyad-assistant dyad-assistant Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude review: 6 inline finding(s).

Comment thread src/pages/chat.tsx
Comment thread src/components/chat/ChatTabs.tsx Outdated
Comment thread src/pages/chat.tsx Outdated
Comment thread src/atoms/chatWorkspaceAtoms.ts
Comment thread src/i18n/locales/en/chat.json Outdated
Comment thread src/pages/chat.tsx Outdated
@dyad-assistant

Copy link
Copy Markdown
Contributor

πŸ” Dyadbot Code Review Summary

Verdict: β›” NO - Do NOT merge
Recommendation: auto-fix

The pane-scoping refactor (ChatPaneContext, usePaneChatId, chat-keyed useStreamChat/usePlan/useIntegrationContinue) is well executed and is the right foundation β€” drafts, messages, terminals and stream state were already chat-keyed, so the migration is mostly mechanical and low-risk. Localization covers all five locales, and the new atoms are unit-tested.

The blocker is not in the workspace feature itself but in a regression it introduces to the single-chat path: ChatPanel is now rendered only for chat IDs present in the useChats(selectedAppId) result, so the chat panel renders nothing while that query is cold or stale.

Issues Summary

Severity File Issue
πŸ”΄ HIGH src/pages/chat.tsx:69 Chat panel renders nothing until the chats query contains the route chat
🟑 MEDIUM src/components/chat/ChatTabs.tsx:1284 Workspace tabs take visible tab slots ahead of chat tabs
🟑 MEDIUM src/pages/chat.tsx:333 Workspace grid has no minimum pane size and clips overflow
🟑 MEDIUM src/atoms/chatWorkspaceAtoms.ts:38 Persisted workspace state is unversioned and unvalidated
🟑 MEDIUM src/i18n/locales/en/chat.json:380 Workspace count strings lack plural forms
🟑 MEDIUM src/pages/chat.tsx:371 Remove-from-workspace control disappears from the focused pane
🟒 Low Priority Notes (5 items)
  • Closing a tab silently drops workspace membership - The tab-close handler calls hideChatFromWorkspace for every closed record. Reopening the tab (useReopenClosedTab) does not restore the pane, so decluttering the tab strip silently destroys workspace layout. Worth confirming this is intended, and at minimum documenting it. (src/components/chat/ChatTabs.tsx)
  • hideChatFromWorkspace writes entries for apps that never had a workspace - updateWorkspace always produces {[appId]: {visibleChatIds}}, so closing any tab persists an empty record to localStorage. Harmless for rendering (empty workspaces are filtered out) but the stored object grows without bound. (src/atoms/chatWorkspaceAtoms.ts)
  • No way to dismiss a Workspace tab - The workspace tab has no close affordance and no context menu; removing every member chat one at a time is the only way to get rid of it. (src/components/chat/ChatTabs.tsx)
  • Untranslated pane fallback label - `Chat ${workspaceChatId}` is hardcoded English for untitled chats, while the surrounding labels are localized (the repo already has a newChat key). (src/pages/chat.tsx)
  • No component/E2E coverage for the workspace UI - The added tests cover the atoms, getChatWorkspaceTabs, and usePaneChatId, but nothing renders the grid, focus transitions, or the remove flow. The plan file in this PR lists component and Playwright coverage as required. (plans/multi-chat-workspace.md)

Notes on confidence: the diff is complete (not truncated), but the workspace UI was reviewed statically β€” I did not run the app, so the pane-sizing and tab-capacity findings are reasoned from the CSS and arithmetic rather than observed.


Generated by Dyadbot persona-based code review

@github-actions github-actions Bot added the needs-human:review-issue ai agent flagged an issue that requires human review label Aug 25, 2026
@azizmejri1

Copy link
Copy Markdown
Collaborator Author

πŸ€– Claude Code Review Summary

PR Confidence: 4/5

All trusted review findings are fixed and explicitly resolved, and targeted tests, lint, type checks, and the production build pass; confidence is held below 5 because the multi-pane UI flow was not exercised by a dedicated E2E test in this run.

Unresolved Threads

No unresolved threads

Resolved Threads

Issue Rationale Link
Single-chat route could render blank while chat summaries load Standalone mode now renders the route chat immediately and leaves loading/error handling to ChatPanel; covered by a regression test. View
Workspace tabs crowded regular and active chat tabs into overflow Visible workspace tabs are capped, at least one regular-chat slot is reserved, and partitioning immediately keeps the selected chat visible. View
Workspace panes could shrink below usable height with clipped content Grid rows now have a 320px minimum and the workspace gains a hover scrollbar when more rows are needed. View
Malformed or future persisted workspace state could crash consumers Persistence now writes a versioned envelope and validates, normalizes, deduplicates, or safely resets data on read. View
Workspace counts lacked locale-aware singular/plural forms All three count-bearing keys now provide _one and _other variants in all five locales. View
Focused pane lost its remove control Every pane in a multi-chat workspace keeps the control, and removing the focused pane navigates to a deterministic remaining chat. View
Product Principle Suggestions

No suggestions; the review findings were concrete correctness, resilience, accessibility-copy, and usability issues rather than ambiguous product trade-offs.


πŸ€– Generated by Claude Code

@dyad-assistant dyad-assistant Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude review: 6 inline finding(s).

Comment thread src/pages/chat.tsx Outdated
Comment thread src/components/ChatPanel.tsx
Comment thread src/pages/chat.tsx
Comment thread src/components/chat/ChatInput.tsx
Comment thread src/atoms/chatWorkspaceAtoms.ts Outdated
Comment thread src/components/chat/ChatTabs.tsx
@dyad-assistant

Copy link
Copy Markdown
Contributor

πŸ” Dyadbot Code Review Summary

Verdict: β›” NO - Do NOT merge
Recommendation: auto-fix

This is a well-structured feature: the identity scoping (ChatPaneContext + usePaneChatId, chat-keyed useStreamChat/usePlan/useIntegrationContinue) is done carefully, membership is validated and version-gated on read, all five locales were updated together, and the pure helpers (getChatWorkspaceTabs, partitionChatsByVisibleCount, getVisibleWorkspaceTabCount, workspace atoms) come with unit tests. No IPC, main-process, database, or schema changes are involved, so the Electron boundary, DyadError, and Drizzle-migration concerns do not apply here.

The blocking problem is the one the PR's own plan flagged as the top risk: mounting several ChatPanel subtrees exposes singleton state that was not migrated. The pane focus handler treats any focus event β€” including programmatic autoFocus from a background pane β€” as user intent and rewrites the focused chat and the route, so a background agent asking a question can pull the user out of the pane they are typing in. Related leaks remain in closeTerminal's global document.querySelector and in TokenBar's read of the focused-chat draft atom.

Coverage is also thinner than the change warrants: there are unit tests for the atoms and tab helpers, but no hybrid/integration or E2E test mounting two panes and proving focus, draft, and action isolation, which is what the plan's Phase 1/3 called for and what would have caught the issues above.

The diff was complete (not truncated), so confidence in the file-level findings is good; the focus-hijack and terminal-focus findings rely on reading unchanged code in ChatPanel.tsx, ChatHeader.tsx, and QuestionnaireInput.tsx, which I verified against the checked-out tree.

Issues Summary

Severity File Issue
πŸ”΄ HIGH src/pages/chat.tsx:383 Programmatic focus in a background pane steals the focused chat
🟑 MEDIUM src/components/ChatPanel.tsx:408 Terminal close focuses the first pane's button via a global query
🟑 MEDIUM src/pages/chat.tsx:73 Workspace route renders an empty chat panel while chats load
🟑 MEDIUM src/components/chat/ChatInput.tsx:177 TokenBar in every pane counts the focused chat's draft
🟑 MEDIUM src/atoms/chatWorkspaceAtoms.ts:150 Workspace membership is shared across Electron windows
🟑 MEDIUM src/components/chat/ChatTabs.tsx:439 Selected tab is hoisted to the front, reordering the tab strip
🟒 Low Priority Notes (6 items)
  • Untranslated pane label - The pane fallback label is a hardcoded `Chat ${workspaceChatId}`, while tabs use t("newChat") for untitled chats. It feeds aria-label and removeFromWorkspaceNamed, so screen readers announce English text in every locale. (src/pages/chat.tsx)
  • Duplicate data-testid values across panes - toggle-terminal-button, toggle-preview-panel-button, and terminal-drawer are now rendered once per pane. Playwright's strict-mode locators and getByTestId will fail as soon as an E2E test enters a multi-pane workspace. Consider suffixing pane-scoped test ids with the chat id, as the new chat-workspace-pane-${id} already does. (src/components/chat/ChatHeader.tsx)
  • No multi-pane integration/E2E coverage - The plan asked for hybrid tests with two mounted pane providers and one broad Playwright journey; the PR adds only atom/helper/hook unit tests. A two-pane hybrid test asserting draft, stream, and focus isolation would have caught the focus and TokenBar issues. (src/atoms/chatWorkspaceAtoms.test.ts)
  • Empty workspace records accumulate - hideChatFromWorkspaceAtom writes { visibleChatIds: [] } rather than deleting the app key, and normalizeChatWorkspaceByAppId preserves empty entries, so the persisted blob grows one entry per app ever touched. (src/atoms/chatWorkspaceAtoms.ts)
  • Route search typed via cast - useRouterState result is cast with as { appId?: number; workspace?: boolean } instead of being derived from chatSearchSchema, so a future schema rename will not be caught by npm run ts. (src/components/chat/ChatTabs.tsx)
  • Focusing a pane skips tab-selection bookkeeping - focusChat navigates directly instead of going through selectChatWithPresentation, so clearNotification is not called; a pane you focus keeps its unread indicator. (src/pages/chat.tsx)

Generated by Dyadbot persona-based code review

@azizmejri1

Copy link
Copy Markdown
Collaborator Author

πŸ€– Claude Code Review Summary

PR Confidence: 4/5

All trusted review findings are fixed and explicitly resolved, and targeted tests, lint, type checks, and the production build pass; confidence is held below 5 because the multi-pane UI flow was not exercised by a dedicated E2E test in this run.

Unresolved Threads

No unresolved threads

Resolved Threads

Issue Rationale Link
Single-chat route could render blank while chat summaries load Standalone mode now renders the route chat immediately and leaves loading/error handling to ChatPanel; covered by a regression test. View
Workspace tabs crowded regular and active chat tabs into overflow Visible workspace tabs are capped and at least one regular-chat slot is reserved; overflow selections enter the last visible slot without destabilizing earlier positions. View
Workspace panes could shrink below usable height with clipped content Grid rows now have a 320px minimum and the workspace gains a hover scrollbar when more rows are needed. View
Malformed or future persisted workspace state could crash consumers Persistence now writes a versioned envelope and validates, normalizes, deduplicates, or safely resets data on read. View
Workspace counts lacked locale-aware singular/plural forms All three count-bearing keys now provide _one and _other variants in all five locales. View
Focused pane lost its remove control Every pane in a multi-chat workspace keeps the control, and removing the focused pane navigates to a deterministic remaining chat. View
Programmatic or cross-pane focus could steal the selected route Pane selection now requires explicit pointer, keyboard-navigation, or activation intent; terminal focus restoration and mentions-menu lookup are scoped to the owning pane. Focus intent, pane-local DOM
Workspace route could render blank while chats load or after deletion The route chat renders during loading; after reconciliation, the route recovers to a valid workspace/standalone chat or app details. View
Background pane token bars counted the focused pane draft TokenBar now selects input from chatInputValuesByIdAtom using its explicit pane chat ID. View
Workspace membership leaked across Electron windows Storage is now keyed by active window session, initialized after renderer bootstrap, isolated from cross-window events, and pruned with stale sessions. View
Overflow selection reordered the tab strip from the front Selected overflow tabs now replace only the last visible slot while preserving the earlier tab order. View
Product Principle Suggestions

No suggestions; the review findings were concrete correctness, resilience, accessibility-copy, and usability issues rather than ambiguous product trade-offs.


πŸ€– Generated by Claude Code

@dyad-assistant dyad-assistant Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude review: 5 inline finding(s).

Comment thread src/pages/chat.tsx
Comment thread src/pages/chat.tsx Outdated
Comment thread src/pages/chat.tsx
Comment thread src/components/chat/ChatTabs.tsx
Comment thread src/pages/chat.tsx Outdated
@dyad-assistant

Copy link
Copy Markdown
Contributor

πŸ” Dyadbot Code Review Summary

Verdict: β›” NO - Do NOT merge
Recommendation: human-review

The pane-scoping work is the strong part of this PR: ChatPaneContext plus the usePaneChatId() migration removes most implicit selectedChatIdAtom reads from the chat subtree, useStreamChat/usePlan/useIntegrationContinue now accept explicit chat ids, and the terminal-focus and mentions-menu document.querySelector lookups were correctly replaced with pane-owned refs. Persistence is versioned, window-session keyed, normalized on read, and pruned on startup, consistent with the existing chat-tab session storage. No IPC surface, schema, or main-process code changed, so the Electron boundary and Drizzle migration rules are not affected, and all five locales received the new keys.

The blocker is that focusing a pane still drives the global single-chat presentation transition, which is built on document-level DOM lookups and shared preview/panel atoms. In a multi-pane workspace that transition targets the wrong pane, so clicking a background pane scrolls a different pane and can pop the shared preview open. Two related lifecycle and rendering gaps and the absence of any test that mounts the workspace are noted below.

The diff was complete (no truncated patches), so confidence in the diff-level findings is good. Verification of the HIGH finding is based on reading the current ChatTabs.tsx presentation transition on main, which this PR does not modify.

Issues Summary

Severity File Issue
πŸ”΄ HIGH src/pages/chat.tsx:260 Pane focus runs the single-chat presentation transition and scroll-jacks other panes
🟑 MEDIUM src/pages/chat.tsx:389 Chat area renders nothing when the route has no chat id
🟑 MEDIUM src/pages/chat.tsx:110 New app-id guard can strand the id-less /chat route
🟑 MEDIUM src/components/chat/ChatTabs.tsx:1569 Cross-window tab transfer leaves the chat in the workspace
🟑 MEDIUM src/pages/chat.tsx:397 No integration or E2E coverage for the workspace UI
🟒 Low Priority Notes (6 items)
  • Pane focus skips notification clearing - focusChat bypasses selectChatWithPresentation, so clearNotification(chatId) never runs for the pane you focus and its tab keeps an unread badge. (src/pages/chat.tsx)
  • Duplicated singleton UI per pane - Every pane renders its own NotificationBanner, SupabaseLegacyKeyBanner, FreeAgentQuotaBanner, and header, and duplicates data-testid="toggle-terminal-button" / messages-list. App-level notices appear N times and any future Playwright getByTestId on those ids will hit a strict-mode violation in workspace view. (src/components/ChatPanel.tsx)
  • Version pane follows focus - showVersionPane = isFocused && isVersionPaneOpen means opening version history in one pane and then clicking another moves the version surface to the new pane while the original pane snaps back to its chat. (src/components/ChatPanel.tsx)
  • Single-chat workspace has no in-view exit - Both "Open focused chat" and the per-pane remove button are gated on isMultiChatWorkspace, so a workspace holding exactly one chat offers no way out or removal from the workspace surface itself. (src/pages/chat.tsx)
  • Tab-ordering change ships unexplained - applySelectionToOrderedChatIds now inserts the selected chat at the last visible slot instead of the front, changing persisted tab order for all users including those who never use workspaces; the PR description does not mention it. (src/components/chat/ChatTabs.tsx)
  • Empty workspace entries persist - hideChatFromWorkspace leaves { visibleChatIds: [] } in the stored map forever; harmless today since getChatWorkspaceTabs filters empties, but the persisted blob grows with every app touched. (src/atoms/chatWorkspaceAtoms.ts)

Generated by Dyadbot persona-based code review

- 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
@azizmejri1

Copy link
Copy Markdown
Collaborator Author

πŸ€– Claude Code Review Summary

PR Confidence: 5/5

All trusted review findings were fixed, explicitly resolved, and covered by passing unit, hybrid integration, packaged-build, and targeted Playwright checks.

Unresolved Threads

No unresolved threads

Resolved Threads

Issue Rationale Link
Workspace focus replayed single-chat presentation state Intra-workspace focus navigation now skips shared presentation transitions, while scroll capture/restore targets the owning pane. View
Id-less /chat rendered a blank panel The chat shell now renders with an undefined chat ID while redirects or loading settle. View
Route app mismatch could strand /chat?appId=… Route-provided app identity now synchronizes even when the route has no chat ID, allowing the redirect to self-heal. View
Cross-window transfer retained workspace membership Source-window workspace membership is pruned during transfer removal and restored if confirmation rolls back. View
Workspace UI lacked integration and E2E coverage Added a two-pane hybrid isolation test plus a packaged Playwright add β†’ focus β†’ remove workflow. View
Product Principle Suggestions

No suggestions


πŸ€– Generated by Claude Code

@dyad-assistant dyad-assistant Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude review: 6 inline finding(s).

Comment thread src/pages/chat.tsx
}, [chatId, setSelectedChatId]);

useEffect(() => {
if (selectedAppId === null || loading) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 MEDIUM

Failed chats query silently wipes persisted workspace membership

The prune effect only guards on loading, which is TanStack Query v5's isPending && isFetching. If ipc.chat.getChats fails on a cold load (no cached data), the query settles into the error state with loading === false and data === undefined, so useChats returns chats: [] and validChatIds is empty. pruneChatWorkspace then clears every visible chat id for the selected app, and because chatWorkspaceByAppIdAtom is an atomWithStorage, that empty state is immediately written to localStorage. The user's workspace is permanently lost from a transient IPC failure, with no error surfaced.

πŸ’‘ Suggestion: Skip pruning unless the chats query actually succeeded (e.g. return the query's isSuccess/isError from useChats and require isSuccess before calling pruneChatWorkspace).

Comment thread src/pages/chat.tsx
className={cn(
"min-h-0 flex-1 overflow-hidden",
isMultiChatWorkspace &&
"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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 MEDIUM

Workspace mounts every member pane with no cap or compaction

The grid renders one full ChatPanel per workspace member with auto-fit columns and overflow-auto, and nothing limits membership: showChatInWorkspaceAtom appends unconditionally and the tab context menu lets a user add every open chat. Each pane mounts a Virtuoso message list, issues its own ipc.chat.getChat fetch, and subscribes to its own stream state, so a user who adds 10-20 chats gets 10-20 live chat panels at once. The plan committed in this PR lists compact/minimized presentation for excess panes as MVP scope and requires profiling at 4/6/10 chats before scalability claims; neither compaction nor any pane budget landed here.

πŸ’‘ Suggestion: Add a pane budget (render full panels up to N and a compact/minimized row beyond it), or at minimum cap workspace membership and surface why further adds are refused.

</div>
<ChatPaneProvider chatId={chatId}>
<div className="relative flex h-full flex-col overflow-hidden">
<ChatHeader

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 MEDIUM

Panes show no chat title and duplicate app-level header chrome

ChatHeader renders no chat title - it renders app-level controls (New chat, version history, terminal toggle, preview toggle) plus the branch and uncommitted-files banners. In a multi-pane workspace every pane therefore looks identical apart from message content: the only chat identification is the invisible aria-label on the wrapping <section>, so a sighted user comparing two chats side by side cannot tell which pane is which. At the same time the app-scoped 'New chat' button, version-history button, branch warning, and uncommitted-files banner are repeated once per pane, and clicking 'New chat' in a background pane navigates the whole view away.

πŸ’‘ Suggestion: Render a pane-local title bar with the chat title (and status) when the panel is in workspace mode, and hoist or hide the app-scoped header controls and banners for non-focused panes.

Comment thread src/pages/chat.tsx
})}
</span>
</div>
{isMultiChatWorkspace && (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 MEDIUM

Single-chat workspace hides both exit and remove affordances

isMultiChatWorkspace requires workspaceChatIds.length > 1, and it gates both the 'Open focused chat' button and the per-pane onRemoveFromWorkspace X. A workspace that contains exactly one chat (the state right after the first 'Add to workspace', or after removing down to the last pane) therefore renders a header strip that says '1 chat in workspace' with no control at all: no way to leave the workspace route and no way to remove the last member from within the view. The only escape is clicking an unrelated chat tab and then right-clicking the chat tab to remove it.

πŸ’‘ Suggestion: Gate 'Open focused chat' on isWorkspaceView rather than isMultiChatWorkspace, and keep the pane remove action available for the last remaining pane (or add an explicit 'Close workspace' action on the workspace tab).

@@ -1424,6 +1678,13 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) {
})
.filter((record): record is ClosedTabRecord => record !== null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 MEDIUM

Closing a tab drops workspace membership that reopen never restores

The close path calls hideChatFromWorkspace for every closed record, but reopenClosedTab only restores the tab - it never calls showChatInWorkspace. A user who closes a tab that was part of a workspace and then uses 'Reopen closed tab' gets the chat back but silently loses its workspace membership and its position in the pane order, with no indication that closing the tab also changed the workspace. This is inconsistent with the PR's stated model that tab ownership and workspace membership are separate concepts.

πŸ’‘ Suggestion: Record workspace membership (and index) in the ClosedTabRecord and re-apply it in reopenClosedTab, the same way the cross-window transfer path already captures wasInWorkspace for rollback.

appId: number,
update: (chatIds: number[]) => number[],
): ChatWorkspaceByAppId {
const visibleChatIds = update(workspaces[appId]?.visibleChatIds ?? []);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 MEDIUM

hideChatFromWorkspace always writes, creating empty stored entries

updateWorkspace unconditionally returns a new object with [appId]: { visibleChatIds }, so hideChatFromWorkspaceAtom writes a fresh atom value even when the chat was never in a workspace. Because it is called for every closed tab and every cross-window transfer, ordinary tab closing creates { appId: { visibleChatIds: [] } } entries for apps that never had a workspace, persists a JSON localStorage write each time (setItem keeps empty arrays through normalizeChatWorkspaceByAppId), and re-renders every chatWorkspaceByAppIdAtom consumer including the tab strip and all mounted panes. The empty entries accumulate and are never garbage collected.

πŸ’‘ Suggestion: Return the previous workspaces object unchanged when the update is a no-op, and drop app entries whose visibleChatIds becomes empty.

@dyad-assistant

Copy link
Copy Markdown
Contributor

πŸ” Dyadbot Code Review Summary

Verdict: πŸ€” NOT SURE - Potential issues
Recommendation: ready

The identity-scoping half of this PR is solid: ChatPaneContext + usePaneChatId() replaces the implicit selectedChatIdAtom reads across the pane subtree, useStreamChat/usePlan/useIntegrationContinue gained explicit chat-id overrides, TokenBar now reads the per-chat draft, and the two document.querySelector singletons (terminal toggle focus, Lexical mentions menu) were correctly converted to ref/container-scoped lookups. Scroll restoration is now scoped by [data-chat-id], workspace membership is versioned and window-session keyed, pruning is wired into close/transfer/route paths, and all five locales were updated. Unit, hybrid-integration, and E2E coverage is present and reasonable.

No merge-blocking defects were found. The issues below are lifecycle/resource/UX gaps in the new workspace surface rather than correctness bugs in the scoping work. Notably, the committed plan (plans/multi-chat-workspace.md) lists compaction/minimize/maximize, column control, narrow-window switcher, keyboard reorder, and live regions as MVP scope; this PR ships a narrower subset, which is where most of the findings below come from.

The diff was provided in full (patchTruncated: false for every file, diffTruncated: false), so confidence is not limited by truncation. I did not run the test suite.

Issues Summary

Severity File Issue
🟑 MEDIUM src/pages/chat.tsx:93 Failed chats query silently wipes persisted workspace membership
🟑 MEDIUM src/pages/chat.tsx:385 Workspace mounts every member pane with no cap or compaction
🟑 MEDIUM src/components/ChatPanel.tsx:410 Panes show no chat title and duplicate app-level header chrome
🟑 MEDIUM src/pages/chat.tsx:362 Single-chat workspace hides both exit and remove affordances
🟑 MEDIUM src/components/chat/ChatTabs.tsx:1680 Closing a tab drops workspace membership that reopen never restores
🟑 MEDIUM src/atoms/chatWorkspaceAtoms.ts:193 hideChatFromWorkspace always writes, creating empty stored entries
🟒 Low Priority Notes (7 items)
  • Version pane inside a grid tile - showVersionPane = isFocused && isVersionPaneOpen renders VersionPane inside the focused pane, which can be as narrow as 320px in a multi-pane grid. ChatHeader still receives the unscoped isVersionPaneOpen, so background panes also suppress their branch/uncommitted banners while the focused pane's version history is open. (src/components/ChatPanel.tsx)
  • Transfer rollback loses pane order - The cross-window drag rollback restores membership with showChatInWorkspace, which appends to the end, so a failed transfer silently reorders the workspace. (src/components/chat/ChatTabs.tsx)
  • Displaced selected tab is not draggable - partitionChatsByVisibleCount can render the selected overflow chat in the last visible slot, but reorderVisibleChatIds still slices orderedChatIds.slice(0, visibleTabCount), so dragging that tab is a no-op until the ordering effect catches up. (src/components/chat/ChatTabs.tsx)
  • Unrelated tab-ordering change bundled in - applySelectionToOrderedChatIds now splices the selected chat into the last visible slot instead of the front. This changes long-standing single-chat tab behavior and is independent of the workspace feature; worth calling out in the PR description. (src/components/chat/ChatTabs.tsx)
  • At most one workspace tab is ever visible - getVisibleWorkspaceTabCount hard-caps visible workspace tabs at 1, so adding a chat to a second app's workspace can produce a tab that only exists in the overflow menu, with no feedback that anything happened. (src/components/chat/ChatTabs.tsx)
  • Tab keydown intent is global - keyboardFocusIntentRef is set by any Tab keydown on window, including Tab pressed inside an editor that consumes it, so a subsequent programmatic focus can still be treated as keyboard navigation. (src/pages/chat.tsx)
  • Plan doc ships ahead of the implementation - plans/multi-chat-workspace.md is committed with every checkbox unchecked and describes minimize/maximize, column selection, responsive pane switcher, keyboard reorder, and live-region announcements that this PR does not implement. Consider trimming it to the delivered MVP or noting the deferred phases. (plans/multi-chat-workspace.md)

Generated by Dyadbot persona-based code review

@github-actions

Copy link
Copy Markdown
Contributor

🎭 Playwright Test Results

❌ Some tests failed

OS Passed Failed Flaky Skipped
🍎 macOS 287 7 1 12

Summary: 287 passed, 7 failed, 1 flaky, 12 skipped

Failed Tests

🍎 macOS

  • chat_history.spec.ts > should open, navigate, and select from history menu
    • Error: expect(locator).not.toBeVisible() failed
  • local_agent_advanced.spec.ts > local-agent - mention apps
    • Error: expect(string).toMatchSnapshot(expected) failed
  • local_agent_auto.spec.ts > local-agent - auto model
    • Error: expect(string).toMatchSnapshot(expected) failed
  • local_agent_explore_code.spec.ts > local-agent - sub-agent tools replace root explore_code
    • Error: expect(string).toMatchSnapshot(expected) failed
  • mention_files.spec.ts > mention file
    • TimeoutError: locator.click: Timeout 30000ms exceeded.
  • plan_mode.spec.ts > plan mode - add and review plan annotations
    • TimeoutError: locator.click: Timeout 30000ms exceeded.
  • plan_mode.spec.ts > plan mode - view plan button opens preview panel when collapsed
    • TimeoutError: locator.click: Timeout 30000ms exceeded.

πŸ“‹ Re-run Failing Tests (macOS)

Copy and paste to re-run all failing spec files locally:

npm run e2e \
  e2e-tests/chat_history.spec.ts \
  e2e-tests/local_agent_advanced.spec.ts \
  e2e-tests/local_agent_auto.spec.ts \
  e2e-tests/local_agent_explore_code.spec.ts \
  e2e-tests/mention_files.spec.ts \
  e2e-tests/plan_mode.spec.ts

⚠️ Flaky Tests

🍎 macOS

  • setup_flow.spec.ts > Setup Flow > OpenRouter API key setup switches the pending first prompt from Build to Basic Agent (passed after 1 retry)

πŸ“Š View full report

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-human:review-issue ai agent flagged an issue that requires human review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant