Skip to content

Workspaces 1/10: Workspace strip, hidden-Workspace minimize - #614

Draft
nedtwigg wants to merge 14 commits into
mainfrom
workspaces-window
Draft

Workspaces 1/10: Workspace strip, hidden-Workspace minimize#614
nedtwigg wants to merge 14 commits into
mainfrom
workspaces-window

Conversation

@nedtwigg

Copy link
Copy Markdown
Member

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 a visibility:hidden box still intersects and xterm kept rasterizing.

Stack: 1/10 → workspaces-persistworkspaces-multiwindowworkspaces-dorworkspaces-hardenworkspaces-registryworkspaces-durabilityworkspaces-fidelityworkspaces-move-verbworkspaces-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

nedtwigg and others added 12 commits September 9, 2026 18:14
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
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 10, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 61ac0be
Status: ✅  Deploy successful!
Preview URL: https://60cc490d.mouseterm.pages.dev
Branch Preview URL: https://workspaces-window.mouseterm.pages.dev

View logs

@nedtwigg
nedtwigg added this pull request to stack #624 September 10, 2026 21:44

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread lib/src/components/wall/workspace-lifecycle.ts
Comment thread standalone/src/AppBar.tsx Outdated
Comment thread lib/src/components/WorkspaceStrip.tsx
Comment thread lib/src/components/WorkspaceStrip.tsx
Comment thread docs/specs/glossary.md Outdated
…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 dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread lib/src/components/wall/workspace-lifecycle.ts Outdated
Comment thread lib/src/components/wall/workspace-lifecycle.ts Outdated

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread docs/specs/layout.md
- 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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):

Suggested change
**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.

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