Status: APPROVED design, pending implementation. Decisions settled with the user 2026-07-12
(chat session 9f4cd444); reference implementation studied: userW's fork
PHIIISH1988/claude-code-webui branch feat/task-centric (src/lib/task-manager.js) —
DIFFERENT model (task-driven, fixed 2×N grid + pinned Files window), so we implement the
user's session-driven spec fresh and port userW's defensive mechanisms (§6).
A per-session workspace stage. One special desktop — the dynamic desktop — sits at the
LEFT of the desktop strip, visually separated. It is a VIEW, not an owner: any window can
be a member of the dynamic view AND its normal home desktop at the same time (user decision:
"相当于一个特殊的view"). Since only one desktop is visible per tab at any moment, this needs
no DOM duplication — one window object, two stored geometries (home gridBounds + stage
bounds), applied on desktop entry/exit. Taskbar previews draw the window's rect in BOTH the
home-desktop preview and the dynamic preview.
Settings toggle desktop.dynamicEnabled (boolean, default false). Off = feature invisible.
- Exactly ONE slot (v1). Data model uses an array (
slots: [...]) for future multi-slot, but v1 renders/uses onlyslots[0]. (User: two slots have an unsolved focus-model question.) - On first entry the slot renders as a placeholder pseudo-window at the top-left grid cell.
It drags/resizes/spans grid cells exactly like a normal window (reuse window.js mechanics
on a
.window.stage-placeholderelement; excluded from sessions/layout capture as a window — its geometry IS the slot record). - Slot geometry is shared state (user decision ③): while a hero occupies the slot, moving/resizing the hero WRITES the slot record. The next hero inherits it.
- Sessions cannot be placed into the dynamic desktop directly (no drag-into from sidebar, no create-on); they only materialize via §3.
While the dynamic desktop is active, ANY action that would switch to / open a session window (sidebar card click, taskbar click, Ctrl+K palette, find/goto, inbox jump, notification click, session create, resume) is intercepted at the app focus funnel and instead:
- The session's window becomes the hero: shown on the stage at the slot geometry. If the window also lives on a normal desktop, it keeps that membership (view model) — its home gridBounds are untouched.
- The previous hero's workspace (hero + bound aux windows) is deactivated per §5.
- The new hero's recorded workspace is restored: every bound window is re-shown (if alive) or replayed from its openSpec + stage geometry (if closed), alongside the hero.
Closing the hero window returns the stage to the placeholder (its aux set is deactivated and recorded). On a NORMAL desktop all behaviors are unchanged — interception applies only while the dynamic desktop is active.
- While a hero is active on the stage, every window CREATED (any type — user decision ⑦:
file explorers, viewers, editors, browsers, task windows, …) is tagged as bound to that
hero's workspace. Bind record =
{ openSpec, stageBounds }, keyed by the hero's stable session keybackend:backendSessionId— the same replay machinery as layout restore (replayOpenSpec+_applyGridBounds). Transient windows without an openSpec (Settings) simply can't bind — acceptable. - Closing a bound window while its hero is active = unbind (removed from the set).
Dragging a bound window onto a normal-desktop preview = unbind + move thereSUPERSEDED 2.112.4 (user directive): window drags between the stage and normal desktops are blocked in BOTH directions — stage-view windows (placeholder/hero/aux/stage-created) never drag out to a desktop preview, and the stage preview is not a drop target.stage.dragToDesktopBlocked(win)is the single predicate (window.js hover detection + dm.moveWindowToDesktop guard, which also covers the right-click "Move to desktop" menu path). Real report behind it: a dragged placeholder escaped onto a normal desktop and stuck there (further defended byhealStray()- a captureState exclusion so the placeholder can never enter a desktop record).
- Windows opened while the stage shows the PLACEHOLDER (no hero) are transient: not bound, not retained across a materialization (user decision ⑤ "这些窗口都不保留") — they are closed when a hero materializes or the user leaves the dynamic desktop.
The HIDDEN (LRU) tier restores everything for free. The REPLAY tier depends on what the openSpec points at — classified at replay time:
| class | examples | replay condition | handling |
|---|---|---|---|
| durable | file viewer/editor on a real path, explorer dir, http(s) browser, task/props/usage windows, subagent viewers | target still exists (host reachable) | pre-validated via /api/file/info; probe failure (offline host) still ATTEMPTS the replay |
| derived | PDF opened from inside a zip (extract-entry temp file) | temp gone but the recipe survives | openSpec carries via: {kind:'archive-entry', archive, entry} (stamped at open); replay re-extracts a fresh temp and patches the path |
| volatile | blob:/data: browser pages (chat html preview, diagnostics), temp files with no recipe, Ctrl+G editor tmp | unrecoverable once closed | NEVER LRU-evicted (kept hidden-alive); if already gone at replay → skipped, one summary toast "{n} windows could not be restored" — never an open-but-broken viewer |
Deleted/moved real files degrade to skip+toast too (no recipe). Remote-host files ride the
same probe with ?host=.
- 2026-07-12 (2.112.6) SHARED HERO (user directive, supersedes the v1 "hero is per-tab"
decision): the 挂机/walk-over scenario — a device left idle on the stage must mirror what the
user does on another device, so walking over shows the CURRENT workspace. The active hero is
SHARED state in the stage store ('hero' → {key, openSpec}):
_publishHeroat materialization (value-compared; skipped while_applyingRemoteHero),_followRemoteHeroon remote ops (150ms debounce, deferred while the local pointer is down) and atenter()(walk-over adoption — the shared hero WINS over the tab's stale local hero); hero close publishes a clear → placeholder everywhere. Which tab is staged at all stays per-tab. Follow path: find a local window by session key, else replay the published openSpec (identity adoption then converges the fresh window onto the desktop record). - 2026-07-12 (2.112.6) harness-caught:
closeWindow's auto-focus-next skipped_hiddenByDesktopbut NOT_hiddenByStage— closing the hero focused a stage-HIDDEN previous hero → re-materialized + re-published it, yanking every staged client back. Stage-hidden windows are now invisible to ALL four visible-window filters in window.js (close auto-focus, applyLayout, overlap switcher, overlap indicators). HARNESS LESSON №2: leftover SyncStore state between suite runs masks/aliases failures — wipe stage-sync.json + layouts.json between suites; and a find()-then-focus test step needs a create fallback or it silently no-ops.
Full audit of every replayOpenSpec action (2026-07-12, user push "别止步于此"):
| action | class | replay risk | handling |
|---|---|---|---|
| attachSession | hero (never aux) | stale serverId / dead session | existing re-resolution + viewSession fallback ✓ |
| openFileExplorer | durable | dir deleted / host offline | opens + navigates; graceful in-window error, user re-navigates (live CURRENT path refreshed into the spec at record time) |
| openFile | durable/derived | temp gone / file deleted | file/info probe; archive-entry recipe re-extracts; else skip+toast |
| openEditor | durable | file deleted; unsaved edits | same probe+skip; winInfo._editorDirty() (new — CodeEditor.modified exposed) makes dirty editors VOLATILE: never LRU-evicted |
| openBrowser | durable/volatile | blob:/data: dead after session | http(s) replays (page state lost — inherent); blob/data skip+toast, never evicted |
| openDesktop | durable | VNC server restarted | reconnect handles; desktop session persists server-side ✓ |
| openTaskDetail / openTaskLog | durable | task group DELETED | pre-checked against sidebar._tasks → skip (the window would open then self-close on tasks-updated) |
| openUsage / openSettings | durable | none (server data / pure UI) | ✓ |
| openSessionProps | durable | session gone | window renders from live lists, degrades to stale info — acceptable |
| openWorkflowDetail | durable | wf snapshot/journal cleaned | GET /api/workflow probe → 404 skips |
| attachTmuxSession | hero-class (terminal → never aux) | tmux target gone | attach errors into existing handling; N/A for workspaces |
| viewSession / viewSubagent | hero-class (type chat → never aux) | JSONL gone | N/A for workspaces; NOTE: focusing a read-only view on stage materializes it as hero (harmless — a read-only hero) |
| Ctrl+G editor / Settings-style transient | unbindable (no openSpec) | — | hide/show only; never recorded (correct: they're turn-scoped) |
LRU keep-alive of the last N workspaces (setting desktop.stageKeepAlive, default 3):
- Within LRU: windows hidden via the established
visibility:hiddenpattern (scroll/terminal/ WS state preserved) — instant switch-back. - Beyond LRU: bound windows are serialized to their bind records and CLOSED. Session hero windows follow userW's hard-won rule (§6.4): never close a session window that shows any in-flight signal — streaming flag, active background tasks, active /goal, pending permission, non-empty draft — re-checked AT CLOSE TIME, never from cached state. A busy hero stays hidden regardless of LRU. Windows that ALSO live on a normal desktop are never closed by the stage — they just lose stage visibility.
- Active hero identity is per-tab (like
_activeId); bind records + slot geometry are global (layouts.json + layout-sync). Close decisions act on shared windows → only the LRU/close pass runs them, guarded by the busy re-check.
- Incoming hero sits at the BOTTOM of the stage z-order: the shared slot may have been moved/resized since a workspace was recorded, so the materializing hero must not cover that workspace's aux windows — the user rearranges from there. Input focus stays on the hero.
- Aux state restoration is maximal: the LRU-hidden tier preserves everything for free
(visibility:hidden). The REPLAY tier additionally records per-window
extras(a generic walker captures every scrollable descendant's scroll offsets, index-matched on restore) and refreshes live openSpec fields at record time (e.g. the file explorer's CURRENT path, not its creation path). Deep per-type state (wrap toggles etc.) rides the hidden tier; replay-tier fidelity can grow per-type as needed.
- Serialize hero switches (lock-and-queue, latest-wins): rapid A→B clicks running two async switch pipelines concurrently corrupted shared state ("white screen, toolbar gone"). One in-flight switch; queued taskId/sessionKey holds ONLY the latest.
- Spawn/replay dedup (
_spawningKeys+ refresh/spawn split): a chat window exists long beforeopenSpec.backendSessionIdis filled, so a reconcile pass re-running spawn creates duplicates (userW: 9 clones of one chat). Track in-flight spawn keys with a 15s hard timeout; reconcile passes must never spawn. _inProgrammaticSwitchguard: desktop-switch hooks must distinguish user switches from programmatic ones or they feed back into the switch pipeline.- Idle-close semantics: default generous (30min-class), busy-signals re-checked at fire time (see §5). The original 30s default killed the previous session on every hop and swallowed messages sent into dead sessions.
- Membership flags are SEPARATE booleans (
_hiddenByStagevs_hiddenByDesktopanalog to userW's_hiddenByTask) — never overloadwin._desktopIdwith a second meaning.
layouts.json gains stage: { slot: {gridBounds}, workspaces: { '<backend:sid>': [ {openSpec, stageBounds}, … ] }, lru: [keys…] }, broadcast via the existing layout-sync (seq/user-dirty
guards apply). Per-tab active hero is NOT synced (mirrors per-tab desktop _activeId).
Restore on boot: stage records replay lazily on first entry, not at startup.
- Mobile: feature hidden (
isMobile). - Hero window cannot join a tab group while on stage; aux windows may group among themselves (chains persist via existing layout capture).
- Dynamic desktop preview: leftmost, separated by a divider, distinct accent border; shows placeholder outline when empty.
- Ctrl+Alt+Left from the leftmost normal desktop enters the dynamic desktop.
"动态桌面" in zh; English label "Stage". Preview tooltip explains: "Sessions materialize here with their own workspace of helper windows."
- Phase A+B (committed):
src/lib/stage-manager.js(StageManager, STAGE_ID='stage'); SyncStore 'stage' registered in server.js (data/stage-sync.json; keys: slot / ws: / lru); settingsdesktop.dynamicEnabled+desktop.stageKeepAlive; app.js instantiatesthis.stage; window.js hooks — focusWindow interception (_stageBypassopt),_captureGridBounds→stage.onGeometryCaptured(slot persistence), closeWindow →stage.onWindowClosed; layout.js_doAutoSavesuppressed while stage active; desktop-manager_renderSwitcherprepends the separated Stage preview (slot outline + live rects, digest includes stage state), normal-preview click while staged routes throughstage.leave(deskId); placeholder is a REAL wm window (type 'stage-placeholder', dashed chrome, hint text, TYPE_ICONS entry); CSS + zh/ja. Enter/leave reuse dm primitives (capture→hide→retag activeId→show/replay); hero borrow keeps_stageHomeBoundsand restores on deactivation. - Phase C (committed): workspace binding — tag windows created while a hero is active
DONE: wm.createWindow tail →
stage.onWindowCreated(session-type windows never bind — they swap in as hero; empty-stage windows tagged_stageTransient);_recordActiveWorkspace()serializes{openSpec, stageBounds}per aux intows:<key>at every switch/leave/hero-close, with heroKey RE-DERIVED AT RECORD TIME (backendSessionId arrives late);_restoreWorkspace()shows live hidden members + replays missing via openSpec with_replayingKeysdedup (15s) and hides stale late-landing replays;_sweepTransient()closes empty-stage windows on materialize/leave; aux close → unbind + re-record; dm.moveWindowToDesktop →stage.unbind. - Phase D (committed): LRU eviction
_enforceLru— v1 CONSERVATIVE: only hidden AUX windows beyond keep-alive N are closed (records replay them); session windows are NEVER closed by the stage (strictly avoids userW's killed-session class). Ctrl+Alt+Left enters from the leftmost desktop / Right leaves (command-mode.js). Placeholder window controls hidden via CSS. Hero-at-bottom z placement + extras capture/restore (addenda §5b). SMOKE-VERIFIED via CDP-driven headless Chrome on an isolated worktree instance (12/12): enter/placeholder/materialize-at-slot/aux-bind/swap-hides-set/hero-close-returns-placeholder/ leave-restores-desktop; caught+fixed: onWindowCreated must check win.type==='stage-placeholder' (the _isStagePlaceholder flag is set AFTER createWindow's tail hook ran — the placeholder got tagged transient and swept on first materialization).
Known Phase A+B caveats to revisit: shouldIntercept has a dead _hiddenByStage === undefined && false clause (harmless, clean up); placeholder close button should be disabled; leave()
doesn't yet record the active workspace (Phase C); enter() while dm._restoring silently bails.
- 2026-07-12 "placeholder never moves": dragging worked; persistence didn't.
stage.init()ran beforeinitStateSync()→ the 'stage' SyncStore was never registered →StateSync.set()SILENTLY DROPS writes for unknown stores → slotBounds() always returned the default top-left. Fixed by init ordering + a lazy_sync()guard that registers the store on any access. LESSON: any new SyncStore consumer must init AFTER initStateSync, and StateSync's silent-drop semantics hide this class of bug — guard in the consumer. - 2026-07-12 (2.112.4) "回到普通桌面尺寸不还原":
leave()hid the hero with_hideStagebut never returned the borrowed geometry — the window came back to its normal desktop at the SLOT size. Fix: leave()/_deactivateHero()HAND THE HERO BACK to the desktop system — restore_stageHomeBounds→ gridBounds, clear_isStageHero/_onStage(off-stage moves must edit HOME bounds, not the slot), thendm._hideWin/target-show it like any desktop window (a bare_hideStageleft it invisible on its home desktop: the desktop show loops only clear_hiddenByDesktop).enter()re-borrows via the still-set_heroWinId(fresh home-bounds snapshot each time, so home moves made while off-stage are kept). Stage-created heroes (_desktopId === STAGE_ID) have no home and stay stage-hidden._materializeInnerclears_hiddenByDesktop(a stale flag excluded the hero from_isStageVisible). - 2026-07-12 (2.112.4) "动态桌面里的 grid 配置不保存": desktop autosave is suppressed while
staged, so the stage grid had NO persistence path. First fix (gate inside
_doAutoSave) still lost it —scheduleAutoSave's_restoringgates dropped the call before the stage gate ever ran (smoke-caught). The interception must live at the TOP ofscheduleAutoSave:if (stage.isActive) { stage.onStageLayoutChanged(); return; }(content-compared, cheap, no debounce needed). Grid stored in the stage store key 'grid';enter()applies it,leave()applies the target desktop's own grid as before. - 2026-07-12 (2.112.4) liveApply:
desktop.dynamicEnabledneeded a page refresh — the switcher digest already includedstage.enabledbut nothing kicked_renderSwitcher()on the settings change.stage.init()registers a settings listener (render kick; disabling while staged leaves first). Same class in the sidebar:sessionCard.*settings are read at CARD BUILD time — sidebar constructor now re-renders on change (schema flipped to liveApply: true). LESSON: aliveApply: trueschema flag is a CLAIM — every setting read at render/build time needs an explicit change listener that re-renders its surface. - 2026-07-12 (2.112.5) MULTI-CLIENT DATA LOSS "窗口A两个客户端都看不到了": materializing a
session with NO local window created a stage-owned hero under a FRESH winId; leaving to the
desktop that (per other clients' records) held that session's window replayed nothing
(attachSession same-session dedup) → the post-leave capture missed the recorded winId → the
broadcast CLOSED the window on every client. Fix = IDENTITY ADOPTION:
_adoptDesktopIdentityscans all desktops' saved states for the session and converges —wm.rekeyWindowonto the recorded winId + home desktop + gridBounds + isMaximized (retried at leave/_deactivateHero for late-arriving session ids, e.g. resume). TWO structural gotchas: (a) createWindow's tail focus runs materialization BEFORE app.js's desktop-tag wrapper assigns _desktopId — the adoption gate must accept!win._desktopId, and the wrapper must NOT clobber an id assigned during creation; (b) maximized heroes: borrow un-maximizes onto the slot, hand-back applies home element geometry BEFORE re-maximizing so toggleMaximize records HOME pixels as prevBounds. Verified with a two-CDP-client harness (10/10) + single-client regression (11/11). HARNESS LESSONS: background tabs throttle the 500ms autosave timer (launch chrome with --disable-background-timer-throttling …); broadcasts need REAL input per client (_userDirty). - 2026-07-12 (2.112.5) live sync layer (user: "动态桌面完全没做多客户端同步"): stage store ops
now MIRROR live — StateSync events fire for remote ops only (server excludes sender), so
init()subscribes a 'stage'/'*' listener: 'slot' re-applies to the local slot occupant, 'grid' re-applies setGrid, 'ws:' reconciles the ACTIVE hero's workspace (400ms debounce;_reconcileWorkspace= _restoreWorkspace + close local aux missing from the record, dirty editors exempt). Recording is no longer switch/leave-only:_scheduleRecord(500ms) fires on aux create/move. Which VIEW a tab shows (staged or not, which hero) stays PER-TAB by design — same philosophy as the per-tab active desktop. - 2026-09-28 (2.369.197, userW inc-muly2izg-cks3 — "在 Dynamic Desktop 里面用这个Browser,Browser 拖不出来"): the
report was a TAB drag-out INSIDE the Stage (the hero chat + its live view in one group; the owner's correction), and
the Stage was innocent — reproduced in chrome at the reporter's page: the stage's record (
_scheduleRecord), a remote_reconcileWorkspaceand a leave → re-enter trip all keep a torn-off aux apart. The failure was the tab drag's classification (split tabs v2 decided once on the first 8 px; the reporter's pull, Δ −451/+351 from the ring, was a strip reorder for the whole drag) — fixed in PURE chain-layouttabDragMode(a reorder that leaves the bar's band by > 30 px tears off). Secondary: the §4 rule "Stage↔desktop moves are blocked in BOTH directions" STAYS, but it no longer refuses in silence — PURE src/lib/stage-rules.js is the one verdict (stage.windowKind/stage.moveVerdict;dragToDesktopBlocked=!!windowKind) and its words: the preview is marked with the words beside the pointer, a refused drop puts the window back + a toast says what to do, "Move to Desktop ▸" holds one reason row,dm.moveWindowToDesktopreturns a typed refusal and speaks on a user's act. The "Open on desktop X" verb the lane first proposed was dropped by the owner (not what was asked). Gates: test-stage-rules (fast), test-stage-dragout-ui (heavy). - 2026-09-28, owner correction #2 (same incident: "他那个拖动出标签栏了,然后窗口就消失了"): the tab DID leave the bar
and the pulled-out window was INVISIBLE.
leave()hides every Stage-tagged window (a chain guest included);enter()re-showed only the hero + its BOUND aux, so a guest the stage never bound (the agent's live view is auto-opened into the chat's group even while the Stage is off screen) kept the leave-timevisibility:hidden; pointer-events:noneon its own element, invisible only while its host drew it. Fixed:_borrowHerore-shows the hero's whole group; a window leaving a chain takes its frame's visibility (tab-group_matchFrameVisibility); a tab torn out on the Stage is bound to the frame's workspace (onTornOff). Reproduced RED on 2.369.196 by replaying the ring (heavy test-stage-dragout-ui ⓪).