Skip to content

feat: multi-session parallel tabs with instant switching and split pane#189

Open
davidliuk wants to merge 2 commits intomainfrom
feat/multi-session-tabs
Open

feat: multi-session parallel tabs with instant switching and split pane#189
davidliuk wants to merge 2 commits intomainfrom
feat/multi-session-tabs

Conversation

@davidliuk
Copy link
Copy Markdown
Collaborator

Summary

  • Instant tab switching — all ChatInterface instances stay mounted simultaneously via CSS display:none/position:absolute toggling; no unmount/remount/API reload on every tab switch
  • Multi-session tab bar (SessionTabBar) with drag-to-reorder, close button, background loading indicator, and unread dot; hides when only one tab is open
  • Split pane — right-click a tab or click the split icon to view two sessions side-by-side with a draggable divider (ratio persisted to localStorage)
  • Session isolation — localStorage keys are scoped per session (chat_messages_${project}_${sessionId}) to prevent cross-tab message bleed
  • Stable tab identity — each tab slot gets a tabKey UUID assigned once; used as React key so the ChatInterface instance survives the new-session-* → real-ID replacement without remounting
  • session-aborted guard — only the ChatInterface that owns the aborted session handles the event (prevents all mounted instances from each appending an "interrupted" message)
  • Background session status — Zustand tracks loading/unread state for non-visible tabs; shown in the tab bar

Architecture

AppContent
├── useSessionTabsStore (Zustand) ← addTab called on every session navigation
│   ├── tabs[]  activeTabId  splitMode  secondaryTabId
│   └── backgroundStatus{}  snapshots{}
└── MainContent
    ├── SessionTabBar        (reads store, renders tab chips)
    └── SplitPaneContainer   (renders ONE ChatInterface per tab, all mounted)
        ├── tab[0]: display:none
        ├── tab[1]: position:absolute; inset:0   ← active
        └── tab[2]: display:none

Test plan

  • Open multiple sessions — verify tab bar appears and switching is instant (no loading spinner)
  • Navigate to a session from the sidebar — verify it opens as a new tab
  • Close a tab — verify adjacent tab activates
  • Right-click a tab → split view opens; drag divider resizes; click split icon to exit
  • Send a message in a background tab — verify loading indicator and unread dot appear in tab bar
  • Create a new session — verify new-session-* tab is replaced with real session ID without remounting
  • Reload the page — verify split ratio is restored from localStorage

🤖 Generated with Claude Code

davidliuk and others added 2 commits April 15, 2026 21:18
… split pane

Introduce a Zustand-based session tab system that enables users to work
with multiple chat sessions in parallel:

- Session Tab Bar: draggable tabs at the top of the chat area for instant
  switching between open sessions, with provider icon, title, and status.
- Session Snapshots: save/restore full chat state (messages, scroll pos,
  loading status) in memory so tab switches are instant without re-fetching.
- Background Status Indicators: three-state model on tabs and sidebar —
  amber spinner while a session is actively processing, green dot when
  completed but unread, no icon once viewed.
- Split Pane: side-by-side view of two sessions with a resizable divider,
  toggled from the tab bar context menu.
- WebSocket Multiplexing: background sessions receive real-time updates
  (token counts, completion events) routed through the existing WS channel.

New files:
  src/types/sessionTabs.ts
  src/stores/useSessionTabsStore.ts
  src/components/chat/view/subcomponents/SessionTabBar.tsx
  src/components/chat/view/subcomponents/SplitPaneContainer.tsx

Modified files:
  AppContent.tsx — sync tab store active ID with app routing
  useChatSessionState.ts — snapshot save/restore on session switch
  useChatRealtimeHandlers.ts — background session status updates
  MainContent.tsx — mount SessionTabBar + SplitPaneContainer
  SidebarSessionItem.tsx — loading/unread indicators
  useProjectsState.ts — addTab on session select/navigate
  package.json — add zustand dependency

Made-with: Cursor
…tion

- SplitPaneContainer: keep all ChatInterface instances mounted (CSS display:none for hidden tabs) — eliminates API reload on every tab switch
- SessionTab: add stable `tabKey` UUID used as React key so instances survive session ID replacement (new-session-* → real ID)
- useSessionTabsStore.setActiveTab: always clear backgroundStatus on activation (previous conditional left stale entries)
- useChatSessionState: scope localStorage keys by session ID to prevent cross-session message bleed
- useChatRealtimeHandlers: guard session-aborted handler so only the owning instance handles it; cleanup session-scoped localStorage on claude/codex-complete
- MainContent: remove useChatTabs React-state machinery in favour of Zustand store; keep SessionTabBar + SplitPaneContainer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Zhang-Henry
Copy link
Copy Markdown
Collaborator

Review notes

CI is green and I like the "all tabs stay mounted" idea for instant switching. But there are a few things to resolve before merging — I verified each against the branch source.

1. Relationship with the already-merged #174 / #181 is unclear

The merge base of this PR is 6e5cd9e, which is after #174 (Merge pull request #174 from liuyixin-louis/feat/multi-session-tabs-v2, commit 120c12b) already landed on main.

This PR effectively replaces #174’s architecture:

  • It removes all imports/uses of ChatTabBar / useChatTabs / resolveChatTabSyncAction from MainContent.tsx.
  • It introduces a parallel implementation in SessionTabBar.tsx + SplitPaneContainer.tsx + useSessionTabsStore.ts.

But the old files (src/components/chat/view/ChatTabBar.tsx, src/hooks/useChatTabs.ts, src/components/main-content/view/chatTabSync.ts, plus src/components/chat/view/__tests__/ChatTabBar.test.tsx) are still on this branch — they become dead code after merge.

Please either:

  • delete them as part of this PR (and drop their tests), or
  • clarify whether both implementations are expected to coexist.

2. Accessibility / keyboard regressions from #181 are not carried over

On main after #181, ChatTabBar.tsx has:

  • role="tablist" on the container and role="tab" on each tab
  • Roving tabIndex (active tab gets 0, others get -1)
  • ArrowLeft / ArrowRight / Home / End navigation
  • Delete / Backspace to close the focused tab
  • Focus management via tablistRef

The new SessionTabBar.tsx in this PR has none of that — no role="tab", no tabIndex, no onKeyDown handler. grep -E 'keydown|metaKey|altKey|role="tab"' SessionTabBar.tsx returns nothing. A11y / keyboard users lose functionality that was shipped just days earlier.

3. Unrelated better-sqlite3 bump

package.json / package-lock.json bumps better-sqlite3 from ^12.2.0 to ^12.9.0. Nothing else in this PR looks related to native-module work, and this can cause Electron rebuild friction. Please split this out unless it’s intentional.

4. Multi-instance WS processing — one spot worth double-checking

With N tabs mounted, useChatRealtimeHandlers runs inside each ChatInterface instance and they all receive the same WS messages.

For messages gated by latestMessage.sessionId !== activeViewSessionId, only the owning instance acts — that’s fine. For the "global" types listed in globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created', 'session-aborted']:

  • session-aborted — correctly guarded with the new if (abortedSessionId !== thisInstanceId) break; check ✅
  • session-createdreplaceTabSessionId / onReplaceTemporarySession is gated on temporarySessionId (only the instance currently in new-session-* mode fires), looks fine ✅
  • projects_updated / taskmaster-project-updated — runs once per mounted instance, causing N duplicate downstream effects. Likely idempotent today but worth a pass to confirm no duplicate fetches/toasts.

5. localStorage key migration

The persistence key moves from chat_messages_${project} to chat_messages_${project}_${sessionId}. Old entries are still read as a fallback but never cleaned up — minor, but worth a one-time cleanup for long-time users.


Happy to re-review once #1#3 are addressed.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants