Skip to content

feat: split-screen multi-task view (finished, reconciled with the #263 viewer model) - #276

Open
kovtcharov wants to merge 3 commits into
mainfrom
feat/split-screen-multi-task-view
Open

kovtcharov wants to merge 3 commits into
mainfrom
feat/split-screen-multi-task-view

Conversation

@kovtcharov

@kovtcharov kovtcharov commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Summary

VSCode-style split screen for task terminals: split a pane right or down, open a different task in each pane, drag the dividers, and every pane streams its own live terminal at once. The default layout is still a single pane, and it looks exactly as it did before. The layout persists across reloads.

This PR lands the split-screen work built in August (tasks #67#72), which was never PR'd. The first commit is that snapshot, rebased onto current main. The second commit finishes it: it reconciles the feature with the multi-client viewer model from #263 and fixes the bugs that only showed up in a real browser.

What it does

  • Panes. Split right (Ctrl/Cmd+\) or down (Ctrl/Cmd+Shift+\) from each pane's header. Close a pane with Ctrl/Cmd+Alt+W; the task keeps running. Ctrl/Cmd+Alt+←/→ cycles focus. The limit is 6 panes.
  • Choosing tasks. Clicking a task in the sidebar opens it in the focused pane. If the task is already open in another pane, that pane gets focus instead. You can also drag a task from the sidebar onto any pane. A task lives in at most one pane, because a PTY has one size.
  • Focus follows the pane. The focused pane's task becomes the selected task, so the File Explorer, Supervisor and sidebar highlight follow what you're looking at.
  • Backend: a visible-task set replaces the single active task. Previously, selecting a task silenced every other task's PTY output and freed its scrollback after 30s. Now each client declares its panes with task:setVisible, and the server streams the union across clients. Each client is capped at 8 tasks, and a client's set is released when it disconnects. A phone's one-task declaration can't blank a desktop's panes.
  • Rendering. Leaves render as flat, absolutely positioned siblings. Restructuring the tree only changes styles, so it never remounts a live xterm (this was the Claudia Manager: Comprehensive Planning Documentation #72 fix). A persisted layout is validated on load; a corrupt layout falls back to one pane.
  • test-cli. --test-split <ids> asserts that N visible tasks all stream concurrently.

Inherited vs. finished

State I found. Task #67's last session reported the work complete on unit tests: 24/24 backend and 126/126 frontend split tests, with #68 (backend set) and #72 (remount fix) integrated. It had never been shown in a browser (the session said so explicitly), never been rebased or PR'd, and it predates #263. I diffed #70 (claudia/task-587e65ae) and #71 (claudia/task-c943b7da) against the integration branch. Both are superseded: #71's layout tests exist in expanded form, and #70's single-set backend was replaced by the per-client union. Nothing unique was lost.

What I took "complete" to mean:

  1. The feature is rebased onto main and coexists correctly with feat(viewers): per-task terminal ownership so multiple clients stop fighting over PTY size #263's per-task terminal ownership.
  2. In a real browser: each pane renders its own terminal, input reaches the right task, resizing one pane doesn't garble another, and panes survive a reload.
  3. Unit tests and all three CI coverage gates pass on all three OSes.

Finished in this PR (commit 2):

Viewer model (#263) vs. split screen. #263 assumed one terminal per client: ViewerRegistry.focus() released the task a client had been looking at. With panes, mounting pane B released pane A. A second client's plain resize could then claim A and shrink the PTY under a desktop that was actively showing it. Pane A also reported 0 viewers.

  • A client that declares its panes via task:setVisible now keeps ownership of every pane and counts as a viewer of each.
  • A task leaves a client's view only when a later setVisible omits it (the pane closed or switched task), or when the client disconnects.
  • Clients that never declare a set (older frontends, scripts) keep the single-focus behaviour unchanged.
  • The server feeds setVisible into the registry and re-broadcasts task:viewers.

Bugs found by browser verification:

  • Regaining ownership left the PTY at the other client's size (a feat(viewers): per-task terminal ownership so multiple clients stop fighting over PTY size #263 bug, but a common path in split view). On takeover, TerminalView refits. The "ignore ≤2-column changes" filter compared against the size sent before following, and swallowed the resize. It now explicitly re-sends its size.
  • Reload wiped every pane. The "clear panes whose task no longer exists" effect ran before init had delivered the task list. It's now gated on a new taskStore.hasTaskList flag. Deleting the last task still clears its pane.
  • A pane grown by a divider drag stopped following output. The viewport sat about 10 rows above the newest lines. The refit now pins a terminal that was tailing. It checks what's visible (the DOM viewport) as well as xterm's buffer geometry, which lags async writes, and keeps it pinned for a short settle window.
  • Narrow panes clipped the split/close buttons off the header and collapsed the title. Each pane is now a CSS size container, so the header compacts on the pane's width, not the viewport's.
  • OpenCode adapters' setTaskActive was still exclusive, which would silence all but one OpenCode pane. It's now additive, and TaskSpawner tells the adapter when a task leaves the visible set.

Tests added:

  • ViewerRegistry: 10 split cases.
  • WS: split-ownership end-to-end.
  • TerminalView: 5 (regain re-send, refit pin, no yank when scrolled up, DOM vs. lagging geometry, settle window).
  • App: a reload case.
  • A new PaneHost suite (14).
  • A vitest Mock typing fix in the SplitContainer test.

How it interacts with the #263 viewer model

Ownership is still per task and exclusive, and only the owner's task:resize reaches the PTY. What changed is the unit of "what a client is showing": it's now the set a split-aware client declares, not only its last focus.

In practice:

  • One browser with 3 panes owns all 3 and resizes each independently.
  • A second client that opens one of those tasks takes ownership of that task only. The desktop pane shows the "viewing at C×R" follower badge; its other panes are unaffected.
  • Clicking back into the pane reclaims it, and the PTY returns to the desktop pane's size.

Both of these were verified in the browser (screenshots 06 and 07 below).

Rebase conflicts and resolutions

The snapshot's own work was replayed with git rebase --onto origin/main d37a6c1.

Conflicts:

App.tsx, TerminalView.tsx (paneControls alongside #263's ownership code), useWebSocket.ts, WorkspacePanel.tsx and shared/src/index.ts auto-merged. I then reviewed each by hand. A second rebase onto the latest main (#256, #259, #272, #275) was clean.

Verification

Re-verified with auth on (after #261). The branch was rebased onto main containing #261, #259, #255 and #272. The whole browser run below was then repeated against a backend where every /api route and WebSocket upgrade requires the token. There was no bypass of any kind: the built SPA got its token through its own bootstrapAuth (the loopback /api/auth/local), and the second client bootstrapped its own. 60/60 checks passed. On top of the checks listed further down, the auth-on run confirmed:

  • an unauthenticated GET /api/tasks and an unauthenticated WS upgrade are both refused with 401
  • every browser socket (desktop, second client, gate client) carries token=
  • task:setVisible, task:focus, task:resize, task:select and task:input from every pane ride those authenticated sockets
  • every /api request presents x-claudia-token
  • the AuthTokenGate path works: with the loopback grant refused, the app shows the gate and opens no socket; pasting the token connects it
  • test-cli --test-split passes against the auth-on sandbox, with all 3 tasks streaming concurrently

Getting that last one to pass needed a fix in this PR: test-cli's token bootstrap ignored --url and asked :4001 for its token. Commit 3 fixes that, and ws-visible-tasks.test.ts now presents the token and asserts that an untokened socket cannot reach task:setVisible.

Unit tests (local, Windows, on the final rebase onto main at #249):

  • backend: 2307 passed, 278 skipped. The skips are the fake-CLI integration suites, which only run on Linux/macOS, including the new WS split-ownership test.
  • frontend: 1255 passed.
  • tsc --noEmit is clean for backend, frontend and electron.

Coverage:

  • New-file floor ✓ (4 new source files ≥ 60%).
  • Frontend package lines: 75.6%, up from a 72.5% baseline.
  • The repo-wide ratchet is only meaningful on the Linux CI leg, where the gate runs. Locally, Windows skips the backend integration suites.

Browser verification. I ran a fully sandboxed stack on Windows. The #254 Playwright harness isn't on main: it relies on a bash fake CLI and on VITE_CLAUDIA_BACKEND_PORT, which main doesn't have. So I used the same approach, adapted:

  • Backend: the built backend (node backend/dist/index.js) on :4811. CLAUDIA_DATA_DIR, HOME, USERPROFILE and APPDATA all pointed under ~/.claudia-split-e2e, and CLAUDIA_SHARED_MCP=0.
  • Fake CLI: a Node fake Claude CLI, installed where the Windows resolver looks (%APPDATA%\npm\node_modules\@anthropic-ai\claude-code\cli.js). It prints a ticker with its real PTY size and a SIGWINCH -> CxR line on every resize. That way each pane's rendered text proves which task it shows and what size that task's PTY actually is.
  • Frontend: the built frontend on :5811, with the bundle's baked 4001 rewritten to 4811 and then asserted absent.
  • Port guards (two layers):
    • A --require guard in the sandbox's Node processes refused any outbound socket to 4001/5173. It logged zero attempts. This matters because several backend paths hardcode localhost:4001.
    • An in-page guard refused WebSocket/fetch/XHR to 4001/5173.
  • Browser: Chromium via Playwright, with WebGL disabled so terminal text is readable from the DOM.

53/53 checks passed on the final rebased build:

Area Checked
Default a single pane that behaves like the old view
Concurrency with ALPHA | BRAVO split, both tick counters advance at the same time; each pane contains only its own task's lines; task:setVisible declares both
Input routing text sent from BRAVO's input bar reached only BRAVO's process; keystrokes typed into ALPHA's xterm reached only ALPHA
No remount ALPHA's scrollback survived two splits in the same xterm instance
Resize isolation dragging the BRAVO/CHARLIE divider sent SIGWINCH to only those two PTYs, and ALPHA stayed 56×43; after both divider drags, every PTY reports exactly the size its own pane requested; no pane has broken or foreign lines
Narrow panes at 314px, the title is still visible and the split/close controls sit fully inside the pane
#263 ownership a second client opening ALPHA turns only the desktop's ALPHA pane into a follower; clicking back reclaims it and restores the PTY size
Reload identical panes, tasks and geometry; all three streaming at the right sizes
Close pane leaves 2 panes; the visible set drops the closed task; the task keeps running
Hygiene the browser only ever connected to :4811; no console or page errors

Screenshots (fake tasks in the sandbox):

Three panes: ALPHA | BRAVO over CHARLIE. Each pane streams its own task, and each task's reported PTY size matches its pane.
three panes

After both divider drags: each PTY was resized independently. The narrow ALPHA pane keeps its title and controls.
after divider drags

A second client took ALPHA. The desktop's ALPHA pane shows the #263 "viewing at 38×27" follower badge, while BRAVO and CHARLIE stay owned.
second client, desktop

After a reload, the same layout comes back and all panes are live.
after reload

More: two panes · input routing · second client's view · after closing a pane · AuthTokenGate (loopback grant refused). All images were re-taken on the auth-on run. The images live on the pr-assets/split-screen-multi-task-view branch, which contains screenshots only and can be deleted after merge.

Known limitations / follow-ups

  • Product decisions from feat: checkpoints, voice tools, and major refactoring #67's scoping were never explicitly confirmed:
    • the pane cap of 6 (MAX_PANES), with a backend cap of 8 per client
    • one shared sidebar rather than per-pane workspaces
    • mobile stays single-terminal (it declares exactly one visible task)
  • One task per pane. Opening a task that's already in another pane focuses that pane; it doesn't duplicate the task.
  • Two clients on the same task still follow feat(viewers): per-task terminal ownership so multiple clients stop fighting over PTY size #263: one owner, and the other renders at the owner's size.
  • Load isn't characterised. Each pane is its own xterm with its own history replay, and a reload with N panes sends N task:select requests. Verified with 3 panes of light output; CPU with 6 busy panes hasn't been measured.
  • Not exercised in the browser:
    • keyboard shortcuts and dragging from the sidebar (both unit-tested in App and PaneHost)
    • the OpenCode multi-pane fix (no OpenCode binary; reasoned from the adapter code, with no dedicated test)
  • No Playwright spec is committed. The split scenario should move into e2e/tests once test: Playwright browser E2E suite on a fully sandboxed stack #254 lands.
  • Pre-existing, not changed here: llm-service, the learnings embeddings and the tunnel manager hardcode localhost:4001 regardless of CLAUDIA_BACKEND_PORT. A sandboxed or second instance can reach the live one through them. I found this while sandboxing.

Test plan

  • npm test -w backend / npm test -w frontend, plus tsc --noEmit for both
  • npm run coverage: new-file floor ✓ (the ratchet is evaluated on Linux CI)
  • Sandboxed browser verification: 53/53 checks
  • CI green on ubuntu / windows / macos

kovtcharov and others added 3 commits September 10, 2026 19:20
…ask set)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…odel

Completes the inherited split-screen snapshot and reconciles it with the
multi-client viewer model (#263), which assumed one terminal per client.

Viewer model (#263) x split screen
- ViewerRegistry: a client that declares its panes with task:setVisible keeps
  ownership of every pane and is counted as a viewer of each. Previously
  mounting pane B released pane A, and a second client's bare resize on A
  claimed it, shrinking a terminal the desktop was actively showing. Clients
  that never declare a set keep the single-focus behaviour.
- server: task:setVisible feeds the registry and re-broadcasts task:viewers.
- TerminalView: regaining ownership now re-sends the terminal size explicitly;
  the small-change filter compared against the pre-follow size and swallowed
  it, leaving the PTY at the other client's dimensions.

Split-screen fixes found in browser verification
- Reload wiped every pane: the prune-missing-tasks effect ran before init
  delivered the task list. Gated on a new taskStore.hasTaskList flag.
- A pane grown by a divider drag stopped following output; the refit now keeps
  a tailing terminal pinned (DOM + buffer bottom check, short pin window).
- Narrow panes clipped the split/close controls and collapsed the title; the
  pane is now a size container and the header compacts on the pane's width.
- OpenCode adapters no longer make setTaskActive exclusive, which silenced all
  but one OpenCode pane.

Tests: ViewerRegistry split cases, WS split-ownership case, TerminalView
regain/refit/pin cases, App reload case; SplitContainer mock typing fix.
…t test

After #261 every WebSocket upgrade needs the API token.

- test-cli bootstrapped its token from CLAUDIA_BACKEND_URL or :4001 before
  argv was parsed, ignoring --url. `--url http://127.0.0.1:4811` therefore
  fetched a DIFFERENT instance's token and presented it to :4811, which
  refused the upgrade (401). The bootstrap now asks the backend named by
  --url, falling back to CLAUDIA_BACKEND_URL, then the default.
- ws-visible-tasks.test.ts presents getAuthToken(base), and gains a case
  proving an untokened socket cannot reach task:setVisible at all.
@kovtcharov
kovtcharov force-pushed the feat/split-screen-multi-task-view branch from 8986e62 to d733507 Compare September 11, 2026 02:22
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