Skip to content

feat(web): two-pane thread split view with cross-environment support - #163

Merged
pandec merged 5 commits into
devfrom
feat/thread-split-view
Aug 22, 2026
Merged

feat(web): two-pane thread split view with cross-environment support#163
pandec merged 5 commits into
devfrom
feat/thread-split-view

Conversation

@pandec

@pandec pandec commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Problem

Working across several threads (often on several machines) means constantly flipping the single chat view back and forth. There was no way to watch or steer two threads at once.

What this adds

An optional second thread pane on web and desktop. The routed thread stays the primary pane; the secondary pane is session-local state showing any other server thread, including one from a different environment. Entry points: a titlebar button and a ⌘K "Open thread in split view..." submenu (reusing the palette's thread items). The secondary pane has switch/close controls, a threadPane.focusOther keybinding (default mod+) jumps between panes, and a static ring marks the active pane. The tree shape is constant whether the split is open or closed, so toggling it never remounts the primary ChatView, and both panes share one diff worker pool.

Making two ChatViews coexist required fixing everything that assumed one:

  • Every window-level shortcut listener (ChatView keydown, permission-prompt number keys, composer stash, model-jump, stash menu, slash pickers, panel launcher letters, editor shortcut, image dialog, preview bus) gates on the active pane via a splitMounted store flag that tracks what is actually rendered, so a hidden split (narrow viewport, settings route) never deadens the visible pane.
  • thread.archive and the preview-toggle when context follow the active pane's thread.
  • Each open terminal has exactly one owning pane; the right panel switches to its existing sheet mode while split so panes keep a usable chat column; DiffPanel takes an explicit threadRef instead of reading the route.
  • The secondary pane never navigates the router: a deleted thread shows an in-pane state, and thread-creating actions (new thread, implement plan) decline with a toast there.
  • Cross-environment keying fixes, since duplicate ids across environments are realistic with cloned state: palette project metadata and thread item values, ChatMarkdown editor dispatch, per-pane availableEditors, branch-mismatch dismissal keys, script-preference storage, steer-pending leases (now refcounted per thread), basename-lookup cancellation, drag-mention payload scope, and toast filtering that admits the secondary pane's thread.

Behavior notes

  • Per-project last-invoked-script preferences and dismissed branch-mismatch banners reset once (their storage keys became environment-scoped; the old values were ambiguous).
  • A thread on a non-primary environment now offers that server's editors instead of the primary's, split or not.
  • threadSteerPending (client-runtime) now holds one lease per thread instead of a single global lease. On mobile, a thread kept mounted under the navigation stack retains its steer markers instead of losing them to the foreground thread.

Verification

vp check and vp run typecheck pass (only pre-existing warnings/suggestions). Focused tests: new suites for the split store and palette items (mutation-checked), refcount tests for steer-pending (mutation-checked), scoped-key coverage in the palette logic and basename-lookup suites — 135 tests green. An Opus review pass over the full diff drove three fixes: the splitMounted source of truth, the constant tree shape, and pane-aware archive targeting.

Implemented by Claude Fable 5 in Claude Code; steer-pending refcount by gpt-5.6-sol; reviewed by Claude Opus 5.

pandec added 2 commits August 22, 2026 21:38
Adds an optional second thread pane on web/desktop: fork-owned
components/thread-split module (pane store, layout, secondary host,
controls, palette picker items), a threadPane.focusOther keybinding,
active-pane gating for every window-level shortcut listener, pane-scoped
terminal ownership, sheet-mode panels while split, and cross-environment
keying fixes (palette metadata, editor dispatch, branch-mismatch and
script-preference keys, steer-pending leases, basename lookups, drag
payload scope, toast filtering).

Implemented by Claude Fable 5 in Claude Code; steer-pending refcount by
gpt-5.6-sol.
Focus now follows pointer activation across panes, so a preventDefault
click can no longer leave terminal shortcuts acting on the wrong pane's
terminal, and terminal ownership swaps in the same commit the secondary
thread is picked or dropped. Dropped the hoisted diff worker pool: the
pool is a module singleton, and mounting it at the route level spawned
workers on the landing screen. The resize handle caches its bounds at
pointerdown instead of forcing layout per move, and the split sheet no
longer blurs the other pane. A disconnected or deleted environment gets
a pane-local notice instead of a blank pane, the open-in-split picker
waits for thread shells to hydrate, a lone image dialog keeps Escape
from the inactive pane, and editors come only from the pane's own
server.

Review by gpt-5.6-sol and Claude Opus 5; fixes by Claude Fable 5 in
Claude Code.
@pandec

pandec commented Aug 22, 2026

Copy link
Copy Markdown
Owner Author

Second review round (gpt-5.6-sol on behavior/races, Claude Opus 5 on architecture/perf/UX) landed in 92ca7be. Fixed:

  • Pointer activation now re-syncs DOM focus across panes. A preventDefault click could flip the active pane while focus (and getTerminalFocusOwner) stayed in the other pane's terminal, so terminal shortcuts acted on the wrong terminal.
  • Terminal ownership swaps in the same commit the secondary thread is picked or dropped. The mounted-gated key left a frame where both panes attached the same terminal.
  • Removed the hoisted diff worker pool and reverted DiffWorkerPoolProvider. The pool is a module singleton, so the hoist shared nothing, and mounting it at the route level spawned 2-6 workers plus the highlighter on the landing screen.
  • The resize handle caches container bounds at pointerdown. Reading getBoundingClientRect per move forced a layout of both panes on every pointermove. pointercancel also clears the dragging highlight now.
  • While split, the right-panel sheet renders a transparent backdrop, so the other pane stays legible and un-blurred. The sheet stays modal; a click outside dismisses it.
  • A disconnected or deleted environment shows a pane-local notice with Choose thread / Close split instead of a blank pane forever.
  • The open-in-split intent waits for thread shells to hydrate (same shape as new-thread-in) before deciding there is nothing to offer, so a cold-load click no longer gets a false 'no other threads' toast.
  • A lone expanded-image dialog keeps Escape and arrows even when its pane is inactive (it covers the window, so gating it off stranded a keyboard-undismissable modal). Two open dialogs still route keys to the active pane only.
  • availableEditors falls back to an empty list instead of the primary server's list while a pane's config is unresolved.
  • Small ones: stored split ratio rejects an empty string (Number('') === 0 clamped to the minimum), ASCII ellipsis in the pane notice.

Declined, with reasons:

  • Right-panel remount on split toggle (opus, high): real, but the fix is restructuring ChatView so one RightPanelTabs survives the inline/sheet flip, which is a large upstream edit for a cost paid once per deliberate toggle. Documented in the layout comment instead of the previous over-claim.
  • Secondary terminal surface lost on split close (sol): the surface lives in the unmounting subtree, so any adoption still recreates it. The genuine fix is the already-deferred persistent terminal host.
  • Splitting the environment-rekey churn into its own PR (opus, fork policy): the rekeys only matter together with the split shipping, and two stacked PRs to a private dev branch buy little; keeping one revert unit.
  • Pruning the basename-lookup sequence map and drag-over-time scope feedback: both deliberate (predicate idempotency; dataTransfer.getData is unreadable during dragover by spec).

pandec added 2 commits August 22, 2026 22:23
…er project

Verified in a live web pass: picking a thread for the split left the
primary pane active because the palette's close-time focus restore
always targeted the app-root composer. The palette now returns focus
(and saved-prompt inserts) to the active pane's composer, and a
one-shot pane-focus intent bounces stray overlay restores into the
pane the user just opened. Sidebar navigation hands shortcut ownership
back to the primary pane, splitMounted updates in a layout effect so a
collapsing split cannot strand shortcuts for a frame, closing the
split focuses the surviving pane, and the focus fallback chain no
longer stops at an element that refused focus. Mention drags now carry
env:project scope, so a file dragged from one project cannot land as a
wrong-relative mention in another project on the same server.

Findings by gpt-5.6-sol; fixes by Claude Fable 5 in Claude Code.
Cloned state can repeat a bare thread id across environments; the
scroll, checkpoint-revert, env-mode, and composer-refocus resets keyed
on the bare id would skip when navigating between two such threads.

Finding by gpt-5.6-sol; fix by Claude Fable 5 in Claude Code.
@pandec

pandec commented Aug 22, 2026

Copy link
Copy Markdown
Owner Author

Third round: an integrated web pass (test-t3-app, isolated home seeded from ~/.t3/dev) plus a second sol audit against 92ca7be. Landed in a80a322 and 714b490.

The browser pass reproduced the biggest one immediately: after picking a thread for the split, the pane opened with the correct thread but the primary pane stayed active — the palette's close-time finalFocus always targets the app-root composer, whose focus event re-activated the primary pane. Same mechanism reversed the palette's 'Focus other split pane' action and sent saved-prompt Mod+Enter inserts to the wrong composer. Fixes:

  • finalFocus and saved-prompt inserts route through the active pane's composer (activeThreadPaneComposerHandle), with a one-shot pane-focus intent that bounces any stray overlay restore into the pane the user just opened (a real pointerdown cancels it).
  • Sidebar/palette navigation retargets the primary pane, so shortcut ownership (archive, preview) now follows it instead of staying with the secondary pane.
  • splitMounted updates in a layout effect — no commit-to-effect window where the only visible pane rejects shortcuts.
  • Closing the split focuses the surviving pane instead of dropping focus to <body>; the pane focus fallback verifies focus actually moved before stopping.
  • Mention drags now carry env:projectId scope (comparator degrades to env-only when either side lacks a project), so a file dragged from project A can't land as a wrong-relative mention in project B on the same server.
  • ChatView's per-thread view-state resets (scroll, checkpoint revert, env mode, composer refocus) key on the scoped thread key — cloned state can repeat a bare thread id across environments.

Verified live: pick → secondary pane active and focused; mod+\ jumps both ways; sidebar navigation returns ownership to primary; navigating onto the secondary's thread folds the split; close control hands focus to primary; the in-split panel sheet shows no blur or dim over the other pane; index and pull-requests routes render normally under the new grid wrapper.

Known and accepted: with zero server threads (fresh environment, unsent draft), the split button opens the plain palette without the 'no other threads' notice — the latched intent clears when the palette closes.

The route-change ownership reset used a component ref, so a thread
picked while the chat layout was unmounted (from Settings) went
unnoticed, and both remounting ChatViews raced their composer
autofocus — whichever frame fired last stole ownership. The last seen
route key now lives at module level and the mount autofocus only runs
in the active pane. The palette's close-time focus also claims the
active secondary pane when it shows a composer-less notice, the
saved-prompt footer hint uses the same composer resolution as the
insert, and a server thread's mention scope comes from the thread's
own projectId instead of degrading to env-only while the project
entity loads.

Findings by gpt-5.6-sol; fixes by Claude Fable 5 in Claude Code.
@pandec

pandec commented Aug 22, 2026

Copy link
Copy Markdown
Owner Author

Final round: a sol pass over just the round-3 delta found three edge cases, fixed in 2414165 and re-verified in the live web session.

  • A thread picked while the chat layout was unmounted (Settings detour) went unnoticed by the ownership reset, and both remounting ChatViews raced their composer autofocus — the secondary pane could keep shortcut ownership of a thread the user had navigated away from. The last seen route key now lives at module level and mount autofocus only runs in the active pane. Reproduced and confirmed fixed in the browser.
  • The palette's close-time focus now claims the active secondary pane even when it shows a composer-less notice (unavailable/disconnected states), instead of silently handing ownership to the primary pane; the saved-prompt footer hint uses the same composer resolution as the insert action.
  • A server thread's mention scope comes from the thread's own projectId, so it no longer degrades to env-only (briefly accepting cross-project drops) while the project entity loads.

Gates green: vp check 0 errors, typecheck 0 errors, focused suites pass. Merging.

@pandec
pandec merged commit e259144 into dev Aug 22, 2026
1 check passed
@pandec
pandec deleted the feat/thread-split-view branch August 22, 2026 20:45
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.

1 participant