Workspaces 1/10: Workspace strip, hidden-Workspace minimize - #614
Workspaces 1/10: Workspace strip, hidden-Workspace minimize#614nedtwigg wants to merge 14 commits into
Conversation
Stage A needs three module-global registries before any Wall can be Workspace-aware: which Surfaces belong to which Workspace (the piece `computeWorkspaceUnion` was missing, since the Activity store is window-wide), the per-Workspace `PersistedSession` collector that PR B will attach a writer to, and the imperative handle a mounted Wall exposes to the strip, the persistence owner, and the `dor` router. `workspace-store` gains the reorder verb and the positional `workspace:<n>` ref mapping both the strip and the router resolve through; `terminal-state-store` gains the id-scoped running count the Workspace close confirmation asks of one Workspace. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
A Wall may now be one of several mounted at once. Everything that assumed it was the page's only Wall is scoped: the notepad meta-resolver is a set whose first non-null answer is the owning Wall's; window input, host New Terminal, the blur that clears attention, and the modal hosts all defer to the visible Workspace; `useSurfaceVisibility` idles a hidden Workspace's screencasts; and the GL context claim is deferred to a Workspace's first activation so the budget scales with visited Workspaces. The per-Wall `dormouse:control-request` listener is replaced by one window listener in `dor-control-router.ts`, which picks the answering Wall: an explicit `workspace:<n>`, else the caller's own Workspace, else the active one. Every Wall registers a handle — a bare one under `DEFAULT_WORKSPACE_ID` — so the single-Wall hosts are unchanged. `saveSession` gains a sink so a Workspace's record is compared against and published beside its own previous record rather than the Window's active one; the aggregator's writer is still uninstalled. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
`WorkspaceWindow` renders every Workspace's Wall into one grid cell, so switching flips `active` rather than changing any Wall's box — no re-seed, no remount, and no xterm refit. Only the boot Workspace receives the restored record; every Workspace created later takes Lath's fresh branch and spawns one default-shell pane. The Window, not each Wall, answers the host's session-flush request: the adapter completes on the first notification, so a per-Wall answer would let a quit proceed once one Workspace had written. `App` gains `multiWorkspace`, which only the standalone host sets; VS Code and the website playground keep mounting a bare Wall. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
The strip replaces the placeholder button that opened the tracking issue. It is store-driven end to end — Workspaces, membership, Activity — so it renders in the AppBar, outside every Wall: click activates, double-click renames, middle-click or the × closes, + creates, and a drag past the shared threshold reorders live with Escape restoring the original index. A hidden Workspace's tab carries the union's TODO pill and bell; the visible one shows none, since its panes already say it. `WorkspaceUnion` gains `ringSeq` so a new ring replays the tab's burst while returning to the Workspace does not. The rename editor and the close confirmation sit outside every Wall, where `stopPropagation` cannot reach the Wall's capture-phase window listener, so they take a reference-counted chrome keyboard lease instead. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
tmux's window bindings, minus the one this Wall already spends: `c` creates, `n`/`p` cycle, `1`-`9` select, `&` closes, `$` renames (tmux's `,` is pane rename here). The branch sits after the dialog gate and before the pane shortcuts, and is inert without the Window's verbs, so a bare Wall leaves every one of those keys unbound. A modified key is never claimed, so Cmd+C stays a clipboard chord. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
Strip states that only exist in composition — the active tab's treatment, a hidden Workspace's indicators, the rename editor, tab overflow, the close confirmation — plus the app bar carrying it and one Window story with two real Walls, so a switch is snapshotted rather than described. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
A press on a tab captured the pointer immediately, and a captured pointer retargets the following `click` to the capture element — so every plain tab press was swallowed and no tab could be activated by mouse. Capture now waits for the drag threshold, which is the only point that needs it. The close confirmation anchored to the 24px tab, leaving the dialog clipped off the top of the window; it anchors to the Workspace's own Wall instead, which is the same box for every Workspace. The active tab took the wall's background but kept the app bar's foreground, so its name was invisible under a light theme; it takes both halves of the palette now, and the tab bell takes the header's alarm color rather than the Door's, since that is the surface it sits on. The attention count leaves the active tab's label too, matching its indicators. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
Every spec that described this feature as staged now describes what it is: one mounted Wall per Workspace sharing a grid cell, a switch that is a prop flip, the routing rule that picks the answering Wall, the strip and its gestures, the command-mode bindings, and the closure path that takes each member Surface through the coordinator. The built halves are deleted from `## Future`, which is now three items — persistence, multiple windows, and the `dor workspace` verbs. `WorkspaceUnion` gains `ringSeq`, alert.md loses its Reserved line for inactive Workspaces, glossary I8 loses its Reserved marker and its verbs gain real effects, and dor-cli.md states that `workspace:<n>` is positional and Surface targets resolve inside the answering Workspace. Evidence from the dogfood pass moves to layout.rationale.md. Word budgets ratcheted for the seven specs that grew. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
Quality-only pass over Stage A: same behavior, fewer moving parts. - Wall handle is one stable object mutated in place; drop `serialize`, `focusSelected`, `SessionPersistenceHandle.buildSession`, and un-export `buildPersistedSession`. `focusSelected(focused)` now covers both branches of the activation effect. - One membership expression (`memberSurfaceIds` / `publishMembership`) behind the two publish effects, `ownsSurface`, and `closeAll`; `closeAll` waits on a Lath store subscription instead of a 10 ms poll. - `ownsHostFlush` derives from `workspaceId`; `SaveSink.previous`/`publish` are required; the `onPtyData` dirty trigger is ownership-filtered like `handlePtyExit`. - One WebGL claim site: `mountElement` loses its `claimWebgl` option and `TerminalPane` keeps the single activation effect. - `dor` refs: `use-dor-control` reads `WINDOW_REF` directly, `workspaceRefFor` is total, and the router uses `isWindowRef` with one unknown-workspace branch. - `WallBootProps` is declared once and spread by `App` and `WorkspaceWindow`. - The chrome keyboard lease moves under `components/wall/` and reuses `createDialogKeyboardCoordinator`; the strip holds it via `useDialogKeyboardOwner`. The kill-confirm key rule is one shared predicate, so the strip's confirmation matches the pane's (Caps Lock, stray key). - `WorkspaceCommands` and the strip intent bus are replaced by direct store calls plus `workspace-ui-store`; the close/rename verbs move to `wall/workspace-lifecycle.ts`. `workspaceIdForSurface` is deleted. - Strip renders each union in the loop, keeps unchanged union objects, memoizes the tab, uses a stable `registerElement`, anchors the confirmation to `[data-workspace-content]`, and reads the PR-C drag hooks through a ref. Two fixes with user-visible consequences, each pinned by a test: - The agent-browser window key forwarder now honors Workspace visibility, so a hidden Workspace left in passthrough on a browser pane stops swallowing every keystroke. - `useAlertSpeech`, `useDynamicPalette`, and the dev-server port correlation are window singletons again: N Walls no longer speak each ring N times, fight over the document palette, or clobber and endlessly re-poll each other's port resolutions. Test and story reuse: `stubWallHandle`, `mountWallHarness`, `ensureResizeObserver`, and a `primedWorkspaces` Storybook parameter replacing the three stories' hand-rolled priming (verified by a Storybook build plus screenshots of the strip, AppBar, and Window stories). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
The close verb could empty a Wall the store then refused to remove, and a hidden Wall could still answer window input. The Stage A review findings, each with a test: - One close at a time for the Window, with the count re-checked after the confirmation; a `closeWorkspace` that refuses anyway hands the Wall back its auto-spawn through the new `WallHandle.cancelClose`. - `closeAll` re-reads membership until nothing new turns up, refuses the Surface-creating `dor` verbs while it walks, and refuses on its exit deadline instead of reporting clean over a live Surface (`awaitWallEmpty`). - The kill confirmation, the refused-archive prompt, and a terminal's selection popup render only in the visible Workspace, so their key traps answer nothing behind a switch; the staged state survives it. - The strip's drag latch is one-shot, consumed by the click that follows a release, and the pointer capture lands on the tab rather than on React's root container. - Session persistence filters PTY traffic on Wall membership (Doors included), so a minimized Session's untouched flip is persisted. - The `dor` router types container refs before use, answers a handler that throws or rejects, and retries a bounded number of macrotasks across the gap between `createWorkspace()` and the new Wall registering. - Four hand-rolled refcounts collapse onto `createRefCount`, whose release is idempotent; membership is published only when leaves change, and the strip's union cache evicts closed Workspaces. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe
Resolves the six conflicts with #611 (parked browsers unbounded, terminal fitting waits for painted layout) and #612 (TerminalWebglRenderer owns a mount's GL context; unmount/park/dispose release it): - terminal-lifecycle.ts: mountElement takes main's renderer mount; the stack's claimWebglRenderer and its registry export are deleted, since #612 removed webglAttempted and the deferred claim it guarded. - TerminalPane.tsx: main's fit lifecycle; the stack's activation effect that called claimWebglRenderer is dropped with it. - layout.md: Renderer section is main's; the Workspaces bullet and the rationale entry for the deferred GL claim go with the mechanism. - glossary.md: Doored from main (no eviction), Hidden from the stack. - tiling-engine.md: main's never-evict rule plus the stack's hidden Workspace clause; both the three-gate visibility and terminal-sizing bullets. - spec-word-budgets.json: standalone and transport ratcheted for text both sides added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkPyEFCxiPo5UFeju5Ya9u
A Wall under visibility:hidden still intersects, so xterm kept rasterizing every output frame for every hidden pane and held its GL context. Route a hidden Workspace's terminals through the minimize primitives instead: TerminalPane skips mountElement while its Workspace is inactive and its cleanup runs unmountElement on deactivation, which releases the renderer (#612). Activation remounts and fits through the layout gate, so an unchanged grid sends no PTY resize (#611). The Session, PTY, buffers, and notepad pins survive on the registry entry; browser Surfaces keep their live documents as before. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkPyEFCxiPo5UFeju5Ya9u
Deploying mouseterm with
|
| Latest commit: |
61ac0be
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://60cc490d.mouseterm.pages.dev |
| Branch Preview URL: | https://workspaces-window.mouseterm.pages.dev |
dormouse-bot
left a comment
There was a problem hiding this comment.
Feedback on work in progress — not a merge verdict. Mark the PR ready when you want the full review.
Four of the five inline findings are reachable today; the fifth is a spec line that now contradicts itself. The highest-impact one is the stranded renamingId: it is a one-gesture path to a session where command mode never dispatches again.
One observation with no inline home, because the code it concerns has never executed: the SaveSink path is unreachable while PERSIST_SESSION = false in both standalone adapters, so two of its seams are unexercised. publishedRef starts null and is only ever written by publish, so SaveSink.previous's own claim — "This Workspace's last persisted record; the previous-pane map reads a dead PTY's retained cwd out of it" — is false for the first save of every boot; the old readPersistedSession(platform.getState()) read the restored record on every save including the first, so a restored pane whose PTY is dead would keep its retained cwd and alert where the sink path drops both. And closeWorkspaceWithSurfaces calls forgetWorkspaceSession(id) synchronously, but the Wall unmounts a render later and its persistence cleanup ends with void persistSessionNow(), which republishes through the sink — re-inserting the entry that was just forgotten. getWindowSnapshot() filters against the Workspace store so the emitted snapshot stays right, but sessions grows one PersistedSession per closed Workspace and the writer, once installed, gets a spurious call per close. Both are worth settling before the writer lands rather than after, since neither will show up as a failing test until it does.
…keys Review findings on the Workspace strip and its close verb: - `closeWorkspaceWithSurfaces` now clears `renamingId` and `pendingClose` with the Workspace. Renaming a tab and then middle-clicking it closed left the rename editor's chrome keyboard lease held forever (the input unmounts without a `blur`), which silenced command mode in every Wall. - The AppBar carries `data-tauri-drag-region` on a dedicated `min-w-8` spacer after the strip instead of the strip's `flex-1` wrapper, so the window stays draggable once tabs fill the bar. - A press inside the open rename editor no longer starts a reorder drag: `InlineEditInput` stops `mousedown` but not `pointerdown`, so a text selection reordered the Workspace and committed the half-selected draft. - A bare Shift or Meta no longer dismisses the close confirmation, matching the pane kill, where `handleDualTap` consumes both before the confirmation sees them. - Glossary I4's trailing clause now agrees with the exception it opens. Each behavioral rule is pinned in `WorkspaceStrip.test.tsx` or `workspace-lifecycle.test.ts`; layout.md's budget is ratcheted for the three added rules. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RChsJ5rMUMyfu22UZDfUus
dormouse-bot
left a comment
There was a problem hiding this comment.
Feedback on work in progress — not a merge verdict. Mark the PR ready when you want the full review.
All five earlier findings are closed out: the close verb now clears the lease, the spacer carries data-tauri-drag-region with a floor, the rename editor no longer starts a reorder, bare Shift/Meta no longer answer the confirmation, and I4 reads consistently. I re-derived the handleDualTap claim the new comment makes — Meta and Shift are the only keys it consumes, so Control and Alt still dismiss both confirmations, and the filter is faithful.
One new finding on the increment, inline: the clear is unscoped, so closing one Workspace drops another's open rename editor or on-screen confirmation.
dormouse-bot
left a comment
There was a problem hiding this comment.
Feedback on work in progress — not a merge verdict. Mark the PR ready when you want the full review.
The scoped clear closes out the previous round's finding, and the new test pins it against the unconditional form. One finding on the increment, inline: layout.md's rule narrowed with the code, but the clause explaining it and the test it names still describe the rule before the narrowing.
| - Each Wall keeps its own mode and selection across switches: deactivating blurs its selected pane, activating focuses it a frame later, since focus into a hidden subtree is a no-op. | ||
|
|
||
| Source of truth: `createWorkspace` / `setWorkspaces` / `closeWorkspace` / `renameWorkspace` / `setActiveWorkspace` in `lib/src/lib/workspace-store.ts`; `WORKSPACES_FLAG_KEY` in `lib/src/lib/feature-flags.ts`; `loadSessionState` / `saveSessionState` in `lib/src/lib/window-persistence.ts`; `PERSIST_SESSION` in `standalone/src/tauri-adapter.ts` and `standalone/src/browser-sidecar-adapter.ts`. | ||
| **Create** adds a Workspace named `Workspace N`, makes it active, and gives its Wall no restored record, so Lath's fresh branch spawns one default-shell pane. **Close** confirms first when the Workspace holds touched Surfaces or running work, reusing the kill-confirm letter and key rule over the Window's content area (**a bare `Shift` or `Meta` is not an answer**, as for a pane kill), then routes every member Surface through the closure coordinator; **the last remaining Workspace cannot be closed** — there is always one active Workspace, as there is always one visible pane (corner case #5). **One close runs at a time for the whole Window**, with the count re-checked after the confirmation, so two of them cannot empty two Walls between them; **a close the store then refuses hands the Wall back its auto-spawn** rather than leaving it mounted and empty. **Rename** edits the Workspace `name` only — no Surface title, and not the per-pane inline rename. **Reorder** moves a tab in the strip and renumbers the positional `workspace:<n>` refs with it; **a press inside the open rename editor never starts a reorder**. **Must drop only the closing Workspace’s rename editor and pending confirmation**, or a stale `renamingId` holds the chrome keyboard lease for the session (`WorkspaceStrip.test.tsx`). **Every Workspace verb runs outside the strip**, which renders the rename editor and confirmation from a store, so a tab gesture and a command-mode key take one path. |
There was a problem hiding this comment.
The narrowing landed in the code, but this line's why-clause and its cited test still describe the rule before it was narrowed.
a stale renamingId holds the chrome keyboard lease for the session is the consequence of not dropping — it says nothing about what goes wrong if a close drops more than its own, which is now the emphasized half of the rule. And the pin is WorkspaceStrip.test.tsx, whose releases the rename lease when the tab being renamed is middle-clicked closed covers only the same-Workspace case; the sibling-preservation half is pinned by workspace-lifecycle.test.ts's new preserves another Workspace's rename and close confirmation when closing a sibling, which this line doesn't name. A maintainer who later widens the clear back to the unconditional form reads a rule whose stated consequence it still satisfies and whose named test still passes.
Moving only out of the bold predicate and into a trailing and no other's keeps one bolded imperative with one why-clause, and names both pins (8393/8400 words — no re-ratchet):
| **Create** adds a Workspace named `Workspace N`, makes it active, and gives its Wall no restored record, so Lath's fresh branch spawns one default-shell pane. **Close** confirms first when the Workspace holds touched Surfaces or running work, reusing the kill-confirm letter and key rule over the Window's content area (**a bare `Shift` or `Meta` is not an answer**, as for a pane kill), then routes every member Surface through the closure coordinator; **the last remaining Workspace cannot be closed** — there is always one active Workspace, as there is always one visible pane (corner case #5). **One close runs at a time for the whole Window**, with the count re-checked after the confirmation, so two of them cannot empty two Walls between them; **a close the store then refuses hands the Wall back its auto-spawn** rather than leaving it mounted and empty. **Rename** edits the Workspace `name` only — no Surface title, and not the per-pane inline rename. **Reorder** moves a tab in the strip and renumbers the positional `workspace:<n>` refs with it; **a press inside the open rename editor never starts a reorder**. **Must drop only the closing Workspace’s rename editor and pending confirmation**, or a stale `renamingId` holds the chrome keyboard lease for the session (`WorkspaceStrip.test.tsx`). **Every Workspace verb runs outside the strip**, which renders the rename editor and confirmation from a store, so a tab gesture and a command-mode key take one path. | |
| **Create** adds a Workspace named `Workspace N`, makes it active, and gives its Wall no restored record, so Lath's fresh branch spawns one default-shell pane. **Close** confirms first when the Workspace holds touched Surfaces or running work, reusing the kill-confirm letter and key rule over the Window's content area (**a bare `Shift` or `Meta` is not an answer**, as for a pane kill), then routes every member Surface through the closure coordinator; **the last remaining Workspace cannot be closed** — there is always one active Workspace, as there is always one visible pane (corner case #5). **One close runs at a time for the whole Window**, with the count re-checked after the confirmation, so two of them cannot empty two Walls between them; **a close the store then refuses hands the Wall back its auto-spawn** rather than leaving it mounted and empty. **Rename** edits the Workspace `name` only — no Surface title, and not the per-pane inline rename. **Reorder** moves a tab in the strip and renumbers the positional `workspace:<n>` refs with it; **a press inside the open rename editor never starts a reorder**. **Must drop the closing Workspace’s rename editor and pending confirmation, and no other’s** — a stale `renamingId` holds the chrome keyboard lease for the session (`WorkspaceStrip.test.tsx`, `workspace-lifecycle.test.ts`). **Every Workspace verb runs outside the strip**, which renders the rename editor and confirmation from a store, so a tab gesture and a command-mode key take one path. |
First stage of the Workspaces stack: the Workspace membership, session, and Wall-handle registries; one Wall per Workspace composed in one grid cell; the strip in the standalone app bar; command-mode shortcuts; stories.
Merged over
origin/main(#611, #612). A hidden Workspace's terminals now go through the minimize primitives — detached and GL-released on deactivation, remounted through the fit gate on activation with no PTY resize at an unchanged grid — since avisibility:hiddenbox still intersects and xterm kept rasterizing.Stack: 1/10 →
workspaces-persist→workspaces-multiwindow→workspaces-dor→workspaces-harden→workspaces-registry→workspaces-durability→workspaces-fidelity→workspaces-move-verb→workspaces-harness.Not verified in this PR: the WKWebView checks (context release in Safari Web Inspector, one-frame blank on switch-back, switch latency into many panes) — #612 was validated only in Chromium.
🤖 Generated with Claude Code
https://claude.ai/code/session_01PkPyEFCxiPo5UFeju5Ya9u