From a8957cfc845327ed632ec1dc34763c6891e12265 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 16 Sep 2026 10:47:33 -0700 Subject: [PATCH 1/7] Show Tool-reported unsaved changes on Panes and Doors --- docs/specs/dor-tool.md | 31 +++++-- docs/specs/layout.md | 4 +- docs/specs/terminal-escapes.md | 3 +- docs/specs/transport.md | 4 +- docs/testing/dor-tool-dirty.md | 43 ++++++++++ lib/src/components/Baseboard.test.tsx | 62 ++++++++++++++ lib/src/components/Baseboard.tsx | 7 +- lib/src/components/Door.test.tsx | 12 +++ lib/src/components/Door.tsx | 9 +- lib/src/components/ToolDirtyIndicator.tsx | 14 ++++ lib/src/components/Wall.tsx | 2 +- .../wall/SurfacePaneHeader.test.tsx | 55 +++++++++++- lib/src/components/wall/SurfacePaneHeader.tsx | 13 ++- .../components/wall/TerminalPaneHeader.tsx | 8 +- lib/src/components/wall/use-dor-control.ts | 3 + lib/src/components/wall/workspace-transfer.ts | 5 +- lib/src/host/remote/sidecar-entry.test.ts | 9 ++ lib/src/lib/platform/fake-adapter.test.ts | 11 +++ lib/src/lib/platform/vscode-adapter.test.ts | 19 +++++ lib/src/lib/platform/vscode-adapter.ts | 4 + lib/src/lib/terminal-protocol.ts | 10 ++- lib/src/lib/terminal-state-store.ts | 6 ++ lib/src/lib/tool-dirty-store.ts | 31 +++++++ lib/src/lib/tool-state.test.ts | 83 +++++++++++++++++++ lib/src/lib/tool-state.ts | 16 ++++ .../stories/BrowserChromeHeader.stories.tsx | 14 ++++ lib/src/stories/Door.stories.tsx | 5 ++ scripts/dor-tool-qc/server.mjs | 11 ++- scripts/loopback-lint.mjs | 3 + scripts/spec-word-budgets.json | 6 +- .../src/browser-sidecar-adapter.test.ts | 22 +++++ standalone/src/browser-sidecar-adapter.ts | 2 + standalone/src/tauri-adapter.test.ts | 22 +++++ standalone/src/tauri-adapter.ts | 2 + standalone/src/workspace-move.test.ts | 15 +++- standalone/src/workspace-move.ts | 2 + vscode-ext/src/message-router.ts | 13 ++- vscode-ext/src/message-types.ts | 1 + vscode-ext/test/message-router.test.ts | 15 ++++ 39 files changed, 565 insertions(+), 32 deletions(-) create mode 100644 docs/testing/dor-tool-dirty.md create mode 100644 lib/src/components/ToolDirtyIndicator.tsx create mode 100644 lib/src/lib/tool-dirty-store.ts create mode 100644 lib/src/lib/tool-state.test.ts create mode 100644 lib/src/lib/tool-state.ts diff --git a/docs/specs/dor-tool.md b/docs/specs/dor-tool.md index 95c40abd5..f76637304 100644 --- a/docs/specs/dor-tool.md +++ b/docs/specs/dor-tool.md @@ -196,17 +196,36 @@ Source of truth: `toolTakesOverCaller` / `toolRerunsInCaller` / `callerStillPlac ## OSC 367 -**Must consume OSC 367 at the PTY owner's parser**, including malformed and unknown verbs, and emit no reply. `serve` is the only implemented verb. The escape registry is `docs/specs/terminal-escapes.md`. +**Must consume OSC 367 at the PTY owner's parser**, including malformed and unknown verbs, and emit no reply. `serve` and `state` are implemented verbs. The escape registry is `docs/specs/terminal-escapes.md`. -- **Must sanitize and bound the payload before retaining it.** `ToolAnnounce` and `parseToolAnnounce` own the field shapes and validation limits. +- **Must sanitize and bound the payload before retaining it.** `ToolAnnounce` / `parseToolAnnounce` and `ToolState` / `parseToolState` own the field shapes and validation limits. - **Must treat an optional serve `path` as a path/query on the discovered port, never as another authority.** Accept at most 2,048 characters starting with one `/`, with no backslash, ASCII whitespace/control, or DEL; invalid paths are ignored and the default is `/`. The port still must belong to the designated Session's process tree. Live binding memory includes the path; durable saves omit it. -- **Must forward parsed announcements and command-start resets in stream order to the owning renderer.** A start clears the previous command's announcement; a later serve in the same chunk survives. Standalone uses `terminal:protocolEvents`; VS Code uses nullable `terminal:toolAnnounce` scoped to the owning webview, with null clearing the hint. The fake adapter applies locally. -- **Must reconstruct announcements and resets from raw replay without emitting replies**, preserving transferred announcements when since-mark replay has no command start, and clear the renderer record on Session disposal. Ordinary terminal announcements stay inert. +- **Must forward parsed announcements, state reports, and command-start resets in stream order to the owning renderer.** A start clears the previous command's announcement and unsaved state; later reports in that chunk survive. Standalone uses `terminal:protocolEvents`; VS Code uses nullable `terminal:toolAnnounce` and `terminal:toolState` scoped to the owning webview, with null clearing the corresponding record. The fake adapter applies locally. +- **Must reconstruct announcements, state, and resets from raw replay without emitting replies**, preserving transferred announcements when since-mark replay has no command start, and clear the renderer record on Session disposal. Ordinary terminal announcements stay inert. - Reserved: **Must retain `name`, `dehydrate`, and `persist` as inert parsed fields**, serving the announced-name and D1/D2 items under [Future](#future). Neither `persist: never` nor a `dehydrate` verb changes current persistence. -- Reserved: **Never assign a third OSC 367 verb**; `dehydrate` belongs to D2 under [Future](#future), while existing title/progress protocols keep those roles. +- Reserved: **Must reserve `dehydrate` for D2 under [Future](#future)**; existing title/progress protocols keep those roles. Source of truth: `TerminalProtocolParser` / `collectTerminalProtocolAlerts` in `lib/src/lib/terminal-protocol.ts`; `parseToolAnnounce` in `lib/src/lib/tool-announce.ts`; `recordToolAnnounce` in `lib/src/lib/tool-announce-store.ts`; `createOwnerPtyStream` in `vscode-ext/src/message-router.ts`; `ownerStream` in `lib/src/host/remote/sidecar-entry.ts`. Tests: `lib/src/lib/tool-announce.test.ts`, `standalone/scripts/dev-agent-browser-announce.test.mjs`. +## Unsaved changes + +**Must accept a Tool's `OSC 367;state;{"v":1,"dirty":true}` report as unsaved state**, with `false` reporting clean. Require version 1 and a boolean; malformed, oversized, and unknown-version reports leave the last state unchanged. State reports never change serving hints, Tool identity, or designation; ordinary terminal reports have no dirty UI. The parser consumes them without replying. + +**Must distinguish unreported state from clean.** Start unknown, update immediately on valid reports, and return to unknown on command start, explicit restart, or Session disposal. Command completion is not a save: retain its last report until reset. Serve announcements never clear unsaved state. + +**Must retain unsaved state through minimize/reattach, renderer changes, and live Workspace transfer**, and reconstruct it in stream order during raw replay. Never write it to durable session metadata; a cold-started Tool reports its own new state. Layout owns the Pane and Door indicator under `docs/specs/layout.md` → Pane header. + +**Must treat this state as indication only.** It neither writes files nor acknowledges a save, changes kill/close behavior, or authorizes automatic reaping. Save coordination and close protection are under [Future](#future). + +A Tool writes reports to its terminal output, for example: + +```sh +printf '\033]367;state;{"v":1,"dirty":true}\033\\' +printf '\033]367;state;{"v":1,"dirty":false}\033\\' +``` + +Source of truth: `parseToolState` in `lib/src/lib/tool-state.ts`; `getToolDirty` / `recordToolDirty` / `recordToolStates` in `lib/src/lib/tool-dirty-store.ts`; `ToolDirtyIndicator` in `lib/src/components/ToolDirtyIndicator.tsx`. Tests: `lib/src/lib/tool-state.test.ts`, `lib/src/components/wall/SurfacePaneHeader.test.tsx`, `lib/src/components/Baseboard.test.tsx`. + ## Security The Tool-specific local boundaries are `docs/specs/security-local.md` → Dor Tool configuration. Browser content follows `docs/specs/security-local.md` → Browser panes. Serving authority follows [Serving](#serving); approval workflow follows [Trust](#trust). @@ -233,6 +252,8 @@ Source of truth: `PersistedToolMetadata` in `lib/src/lib/session-types.ts`; `sav **Scope: dor-tools** — remaining design, in implementation order. +- **Save coordination and close protection.** Optional save requests with completion/failure and protection against clearing newer edits; dirty-aware Pane/Workspace/app closure. Establish an explicit safe-to-stop contract before automatic reaping; clean or unknown state alone is insufficient. + - **D1 — reaping without cooperation.** Idle-threshold reap + rehydrate-from-args + `persist: "never"`: every stateless tool, no new API, no Windows question. diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 2240874dd..b0e6a612a 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -46,11 +46,13 @@ Panes are separated by a 7px gap (`PANE_GUTTER_PX`), odd so the 1px selection ri ### Pane header +**Must keep a Tool's unsaved-change dot visible in its Pane header and minimized Door**, across header widths and terminal/browser faces. Use inherited foreground, without animation; label and tooltip it “Unsaved changes”. Show it only for an explicit dirty report; never hide it inside browser overflow controls or replace Kill. State semantics belong to `docs/specs/dor-tool.md` → Unsaved changes. + A 30px header doubling as a drag handle: **a `pointerdown` past a 5px threshold begins a Lath pane drag**; below the threshold the header's own click behavior stands. It uses `cursor-grab` / `active:cursor-grabbing`, `select-none`, the shared terminal top radius from `lib/src/components/design.tsx`, and the `--color-header-active-*` / `--color-header-inactive-*` token pairs (VSCode file-tree list colors). **Must use browser chrome for a serving Tool, with a Terminal Context disclosure for its serving terminal.** Tool composition belongs to `docs/specs/dor-tool.md` → Lifecycle. -**Must size browser chrome by available pane width, excluding Tool context.** Hide inline split/zoom below 420px and navigation below 360px. Below 180px, move browser controls into a keyboard-accessible, viewport-clamped popover; minimize/kill remain inline until 72px, then join the popover. A filled notepad glyph and note count identify saved notes on its trigger. Long keys and connection labels yield before controls. (rationale) +**Must size browser chrome by available pane width, excluding Tool context.** Hide inline split/zoom below 420px and navigation below 360px. Below 180px, move browser controls into a keyboard-accessible, viewport-clamped popover; minimize/kill remain inline until 72px (80px with the unsaved-change dot), then join the popover. A filled notepad glyph and note count identify saved notes on its trigger. Long keys and connection labels yield before controls. (rationale) Source of truth: `SurfacePaneHeader` in `lib/src/components/wall/SurfacePaneHeader.tsx`; tests: `lib/src/components/wall/SurfacePaneHeader.test.tsx`; stories: `lib/src/stories/BrowserChromeHeader.stories.tsx`. diff --git a/docs/specs/terminal-escapes.md b/docs/specs/terminal-escapes.md index 0575646d6..cf2c0df84 100644 --- a/docs/specs/terminal-escapes.md +++ b/docs/specs/terminal-escapes.md @@ -62,7 +62,8 @@ Replay (`pty:replay`) is the raw stream requiring re-parse: **the webview runs a | `OSC 633 ; P ; Cwd= ST` | CWD (VS Code) | [terminal-state.md](terminal-state.md#supported-osc-inputs) | | `OSC 777 ; notify ; ; <body> ST` | rxvt/WezTerm notification | [alert.md](alert.md#terminal-reports) | | `OSC 367 ; serve ; <json> ST` | Dor Tool announcement: selects a bound port and optional same-origin path, plus a reserved name and runtime re-key | [dor-tool.md](dor-tool.md#osc-367) | -| `OSC 367 ; <any other verb> ST` | Reserved for the staged `dehydrate` verb; consumed and ignored. | [dor-tool.md](dor-tool.md#osc-367) | +| `OSC 367 ; state ; <json> ST` | Tool-reported unsaved state | [dor-tool.md](dor-tool.md#unsaved-changes) | +| `OSC 367 ; <any other verb> ST` | Includes the reserved `dehydrate` verb; consumed and ignored. | [dor-tool.md](dor-tool.md#osc-367) | | `OSC 1337 ; CurrentDir=<cwd> ST` | CWD (iTerm2 compatibility) | [terminal-state.md](terminal-state.md#supported-osc-inputs) | | `OSC 1337 ; File=...:<data> ST` / `MultipartFile=...` / `FilePart=...` / `FileEnd` | iTerm2 inline image protocol (IIP); passed through to ImageAddon. | [Inline graphics](#inline-graphics) | | `OSC 1337 ; ReportCellSize ST` | iTerm2 cell-size query; passed through and answered by the owner's ImageAddon. | [Inline graphics](#inline-graphics) | diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 0dab27ba3..e400c5991 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -214,7 +214,7 @@ Transport constraints: | Host → webview | `pty:openPorts` | `ports: OpenPort[]` (`{ protocol, family, address, port, pid, processName }`), de-duplicated by `(family, address, port)`, sorted by port then address. Empty when the PTY is gone or enumeration fails. | | Host → webview | `pty:data` | PTY output after state-driving supported OSCs are parsed/stripped; `OSC 8` and ImageAddon's inline-image `OSC 1337` forms are preserved for xterm.js, routed only to the owning router. **Carries an optional `textData`** (string-control payloads removed, for the prompt heuristic), **omitted when it would equal `data`**. | | Host → webview | `terminal:semanticEvents` | Normalized CWD / prompt-command / title events the owner's parser derived, in stream order. | -| Host → webview | `terminal:protocolEvents` | Standalone notification/progress delivery and ordered Tool announcements/resets (`docs/specs/dor-tool.md` → OSC 367). VS Code keeps its `AlertManager` in the extension host. | +| Host → webview | `terminal:protocolEvents` | Standalone notification/progress delivery and ordered Tool announcements/state/resets (`docs/specs/dor-tool.md` → OSC 367). VS Code keeps its `AlertManager` in the extension host. | | Webview → host | `dormouse:themeColors` (VS Code) / `pty_theme_colors` (standalone) | Resolved foreground / background / cursor, so the owner's parser can answer OSC 10/11/12. | | Host → webview | `pty:replay` | Buffered raw output since spawn; the webview runs a one-shot parser over it, the only re-parse there is. | | Host → webview | `dormouse:newTerminal` | May carry `shell`, `args`, display `name`, `replaceUntouched`, `announce`. The webview replaces the selected untouched terminal in place only when `replaceUntouched` is true, otherwise spawns a new pane. | @@ -372,6 +372,6 @@ Source of truth: `TerminalContextRequest` in `lib/src/lib/terminal-context-types ## Tool transport -**Must forward parsed OSC 367 announcements from the PTY owner to its renderer**, without reparsing live display bytes or answering from a viewer (`docs/specs/dor-tool.md` → OSC 367). Tool persistence follows `docs/specs/dor-tool.md` → Persistence and hosts. +**Must forward parsed OSC 367 announcements and state from the PTY owner to its renderer**, without reparsing live display bytes or answering from a viewer (`docs/specs/dor-tool.md` → OSC 367). Tool persistence follows `docs/specs/dor-tool.md` → Persistence and hosts. Source of truth: `ExtensionMessage` in `vscode-ext/src/message-types.ts`; `ownerStream` in `lib/src/host/remote/sidecar-entry.ts`. diff --git a/docs/testing/dor-tool-dirty.md b/docs/testing/dor-tool-dirty.md new file mode 100644 index 000000000..9b551ed2c --- /dev/null +++ b/docs/testing/dor-tool-dirty.md @@ -0,0 +1,43 @@ +# Tool dirty-indication QC + +Branch: `dor-tool-dirty`, stacked on `dor-tool-qc`. + +## Scope + +Tools report unsaved state with `OSC 367;state;{"v":1,"dirty":true|false}`. +This pass adds indication; save coordination and close protection remain future work. + +## Plan + +Run source-mutating root tests before starting innerdogfood. Start the real +sidecar/staged CLI in a visible `dor ensure` pane, with an isolated XDG config. +Use `scripts/dor-tool-qc/server.mjs --announce --dirty-controls` as a user Tool. +All credentials and captures stay in the ignored `standalone/src-tauri/target/` +fixture directory and credentials are deleted at shutdown. + +| Case | Expected | Result | +| --- | --- | --- | +| Unknown and clean | No dot; neither invents an unsaved report | Pass live: initial unknown and explicit clean both show no dot; tri-state distinction is unit-tested. | +| Dirty / clean updates | Dot updates immediately, accessible name and tooltip “Unsaved changes” | Pass live: 6px dot appears/clears with Tool output; role, label and tooltip verified. | +| Ordinary terminal output | State report never creates Tool identity or dirty chrome | Pass live: identical state output in the plain terminal creates no indicator. | +| Serving | State updates leave URL and browser process intact | Pass live: state-only signal retains page location and entered browser text. | +| Narrow pane | Dirty dot remains outside overflow controls and Kill stays usable | Pass live at 103px: dot remains 6px and outside popup; Kill available inside popup. | +| Minimize / reattach | Door shows the dot; updates while minimized; restored Pane agrees | Pass live: Door updates while minimized; reattached Pane retains state and page input. | +| Command exit / restart | Last dirty report remains on terminal face; new command returns to unknown | Pass live: dirty remains after Ctrl-C on terminal face; keyed restart keeps Surface and clears old state. | +| Live reload / transfer | Ordered replay and volatile transfer retain state; no durable dirty field | Live reload passed; automated tests cover both true/false Workspace transfer, since-mark replay and durable omission. Native-window transfer not exercised live. | +| Malformed output | Invalid type/version, oversized payload, or unknown verb cannot clear state | Pass automated: strict parsing and ordered stream tests retain state on invalid output. | + +## Results + +- Full `pnpm test` passes, including 3,569 lib tests, 228 standalone frontend tests, and 180 VS Code tests; the root lint/self-tests pass too. +- Library production TypeScript/CSS build and host typechecks pass. +- Protocol/lifecycle review found no actionable issues. +- Local screenshots: `dirty-pane.png` and `dirty-narrow.png` in the ignored fixture directory. `live.json` and `after-restart.json` capture the same Tool identity across its restart. +- The root loopback lint caught the earlier QC server once tracked. Its explicit allowlist entry documents that it is an unshipped generated-content fixture with no file, credential, or command API. + +Native host rendering, cold restart, and cross-window transfer were not exercised through this browser harness. Automated coverage checks transfer and reset semantics. + +## Cleanup + +The fixture Tool was closed and its listener PID verified exited. The harness +was stopped and private CLI credentials deleted. diff --git a/lib/src/components/Baseboard.test.tsx b/lib/src/components/Baseboard.test.tsx index d95771ec0..73774ede5 100644 --- a/lib/src/components/Baseboard.test.tsx +++ b/lib/src/components/Baseboard.test.tsx @@ -13,6 +13,7 @@ vi.mock('../lib/platform', () => ({ getPlatformOrNull: () => ({ alertPublishSettings: vi.fn(), notepadArchive: {} }), })); +import { recordToolDirty } from '../lib/tool-dirty-store'; import { Baseboard } from './Baseboard'; import { installLocalStorageStub } from '../lib/test-local-storage'; import { applyAlertSettingsFromHost, DEFAULT_ALERT_SETTINGS, getAlertSettings } from '../lib/alert-settings'; @@ -369,3 +370,64 @@ describe('Baseboard Door notepad', () => { expect(onReattach).not.toHaveBeenCalled(); }); }); + + +describe('Baseboard Tool unsaved changes', () => { + it('refits Door overflow when a live dirty report changes a measured width', () => { + vi.stubGlobal('ResizeObserver', class { + constructor(private callback: ResizeObserverCallback) {} + observe(target: HTMLElement) { + const width = target.classList.contains('h-7') ? 244 : 72; + this.callback([{ target, contentRect: { width } } as ResizeObserverEntry], this as unknown as ResizeObserver); + } + disconnect() {} + }); + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) { + if (this.getAttribute('role') !== 'group') return 16; + return this.querySelector('[aria-label="Unsaved changes"]') ? 94 : 80; + }); + const id = 'dirty-door-fit'; + try { + act(() => root.render(<Baseboard items={[ + { id, kind: 'tool', title: 'Editor' }, + { id: 'other-door', kind: 'terminal', title: 'Shell' }, + ]} onReattach={() => {}} />)); + expect(container.querySelectorAll('[data-door-id]')).toHaveLength(2); + act(() => recordToolDirty(id, true)); + expect(container.querySelectorAll('[data-door-id]')).toHaveLength(1); + act(() => recordToolDirty(id, false)); + expect(container.querySelectorAll('[data-door-id]')).toHaveLength(2); + } finally { + act(() => recordToolDirty(id, null)); + } + }); + + it('updates a terminal-faced Tool Door and suppresses reports for other kinds', () => { + const id = 'dirty-door'; + const renderKind = (kind: 'tool' | 'terminal' | 'browser') => act(() => root.render( + <Baseboard items={[{ id, kind, title: 'Editor' }]} onReattach={() => {}} />, + )); + const visibleDot = () => container.querySelector(`[data-door-id="${id}"] [aria-label="Unsaved changes"]`); + try { + renderKind('tool'); + expect(visibleDot()).toBeNull(); + act(() => recordToolDirty(id, true)); + expect(visibleDot()).not.toBeNull(); + // The hidden measurement pass and the visible Door must grow together. + expect(container.querySelectorAll('[role="img"][aria-label="Unsaved changes"]')).toHaveLength(2); + expect(container.querySelector(`[data-door-id="${id}"]`)?.getAttribute('aria-label')).toContain('Unsaved changes'); + renderKind('terminal'); + expect(visibleDot()).toBeNull(); + renderKind('browser'); + expect(visibleDot()).toBeNull(); + renderKind('tool'); + expect(visibleDot()).not.toBeNull(); + act(() => recordToolDirty(id, false)); + expect(visibleDot()).toBeNull(); + act(() => recordToolDirty(id, null)); + expect(visibleDot()).toBeNull(); + } finally { + act(() => recordToolDirty(id, null)); + } + }); +}); diff --git a/lib/src/components/Baseboard.tsx b/lib/src/components/Baseboard.tsx index 5c3623812..5cd2c375f 100644 --- a/lib/src/components/Baseboard.tsx +++ b/lib/src/components/Baseboard.tsx @@ -1,3 +1,4 @@ +import { getToolDirty, subscribeToToolDirty } from '../lib/tool-dirty-store'; import { setWorkspaceAlertDelivery } from '../lib/workspace-store'; import { useWorkspaceAlertPolicy } from './wall/use-workspace-alert-policy'; import { useCallback, useRef, useState, useMemo, useLayoutEffect, useContext, useSyncExternalStore, type ReactNode } from 'react'; @@ -70,6 +71,9 @@ export function Baseboard({ items, onReattach, notice, onDoorDragStart }: Basebo // props component and never asks the platform anything. const notepadNotes = useSyncExternalStore(subscribeToNotepad, getNotepadSnapshot); const notepadAvailable = hasNotepadArchive(); + // A stable primitive snapshot also invalidates the hidden Door width pass. + const dirtyTools = useSyncExternalStore(subscribeToToolDirty, + () => items.map(item => item.kind === 'tool' && getToolDirty(item.id) === true ? '1' : '0').join('')); const appTitleForPane = useMemo( () => buildAppTitleResolver(terminalStates, activityStates), [terminalStates, activityStates], @@ -157,7 +161,7 @@ export function Baseboard({ items, onReattach, notice, onDoorDragStart }: Basebo if (arrowMeasureEl.current) { layoutMetrics.current.arrowWidth = arrowMeasureEl.current.offsetWidth; } - }, [items, activityStates, speechStates, terminalStates, notepadNotes]); + }, [items, activityStates, speechStates, terminalStates, notepadNotes, dirtyTools]); // Reset startIndex when the set of door items changes (not just count) const itemKey = useMemo(() => items.map(i => i.id).join('\0'), [items]); @@ -244,6 +248,7 @@ export function Baseboard({ items, onReattach, notice, onDoorDragStart }: Basebo ? deriveSurfaceLabel(terminalStates.get(item.id) ?? createTerminalPaneState(), appTitleForPane, item.title) : item.title, browserDisplay: item.browserDisplay, + toolDirty: item.kind === 'tool' && getToolDirty(item.id) === true, status: activity.status, ringSeq: activity.ringSeq, todo: activity.todo, diff --git a/lib/src/components/Door.test.tsx b/lib/src/components/Door.test.tsx index ee0490c0d..62c9e25ae 100644 --- a/lib/src/components/Door.test.tsx +++ b/lib/src/components/Door.test.tsx @@ -131,3 +131,15 @@ describe('Door notepad button', () => { expect(onDragPress).toHaveBeenCalledTimes(1); }); }); + + +describe('Door unsaved changes', () => { + it.each(['speaking', 'spoken'] as const)('keeps the dirty dot beside notes and %s state', speechState => { + act(() => root.render(<Door doorId="dirty" title="Editor" ringSeq={1} toolDirty + speechState={speechState} noteCount={2} todo status="ALERT_RINGING" />)); + const door = container.querySelector('[data-door-id="dirty"]')!; + expect(door.querySelector('[role="img"][aria-label="Unsaved changes"]')).not.toBeNull(); + expect(door.querySelector('[data-door-notepad-for="dirty"]')).not.toBeNull(); + expect(door.getAttribute('aria-label')).toBe(`Editor, ${speechState}, Unsaved changes`); + }); +}); diff --git a/lib/src/components/Door.tsx b/lib/src/components/Door.tsx index 304be60f1..03757c96d 100644 --- a/lib/src/components/Door.tsx +++ b/lib/src/components/Door.tsx @@ -1,4 +1,5 @@ import { useRef, type PointerEvent as ReactPointerEvent } from 'react'; +import { ToolDirtyIndicator } from './ToolDirtyIndicator'; import { clsx } from 'clsx'; import { NotepadIcon, SpeakerHighIcon } from '@phosphor-icons/react'; import type { AlertSpeechState, SessionStatus, TodoState } from '../lib/terminal-registry'; @@ -21,6 +22,8 @@ export interface DoorProps { * Chrome). Door draws the glyph pair and names it, so the visible and * accessible meanings cannot drift apart. */ browserDisplay?: BrowserDisplayMode; + /** Set only for a Tool whose last report says it has unsaved changes. */ + toolDirty?: boolean; status?: SessionStatus; /** `ActivityState.ringSeq`; a change replays the ringing burst. */ ringSeq: number; @@ -52,6 +55,7 @@ export function Door({ doorId, title, browserDisplay, + toolDirty = false, status = 'WATCHING_DISABLED', ringSeq, todo = false, @@ -67,7 +71,7 @@ export function Door({ const speaking = speechState === 'speaking'; const spoken = speechState === 'spoken'; const detail = browserDisplay ? BROWSER_DISPLAY_LABEL[browserDisplay] : undefined; - const nameParts = [title, detail, speechState].filter(Boolean); + const nameParts = [title, detail, speechState, toolDirty ? 'Unsaved changes' : undefined].filter(Boolean); const doorRef = useRef<HTMLDivElement>(null); const showNotepad = noteCount > 0; @@ -98,7 +102,7 @@ export function Door({ )} onPointerDown={onPointerDown} title={nameParts.join(' — ')} - aria-label={detail || speechState ? nameParts.join(', ') : undefined} + aria-label={detail || speechState || toolDirty ? nameParts.join(', ') : undefined} data-alert-speech-state={speechState} > <button @@ -109,6 +113,7 @@ export function Door({ )} onClick={onClick} > + <ToolDirtyIndicator dirty={toolDirty} /> {browserDisplay && <BrowserDisplayIcon mode={browserDisplay} size={12} />} <span className="min-w-0 flex-1 truncate"> {title} diff --git a/lib/src/components/ToolDirtyIndicator.tsx b/lib/src/components/ToolDirtyIndicator.tsx new file mode 100644 index 000000000..c56383f06 --- /dev/null +++ b/lib/src/components/ToolDirtyIndicator.tsx @@ -0,0 +1,14 @@ +import { useSyncExternalStore } from 'react'; +import { getToolDirty, subscribeToToolDirty } from '../lib/tool-dirty-store'; + +export function useToolDirty(surfaceId: string): boolean | null { + return useSyncExternalStore(subscribeToToolDirty, () => getToolDirty(surfaceId)); +} + +/** A Tool's last reported unsaved changes, independent of Activity alarms. */ +export function ToolDirtyIndicator({ dirty }: { dirty: boolean | null }) { + return dirty === true ? ( + <span role="img" aria-label="Unsaved changes" title="Unsaved changes" + className="size-1.5 shrink-0 rounded-full bg-current" /> + ) : null; +} diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 51326adca..b47765776 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -461,7 +461,7 @@ export function Wall({ const meta = lath.store.getSnapshot().leafMeta; return doorsRef.current.map((door) => { const leaf = meta.get(door.id); - return `${leaf?.title ?? ''}\u0001${browserDisplayModeFromParams(leaf?.params) ?? ''}`; + return `${leaf?.title ?? ''}\u0001${surfaceKindFromParams(leaf?.params)}\u0001${browserDisplayModeFromParams(leaf?.params) ?? ''}`; }).join('\u0000'); }); // The Baseboard's chips: the runtime Doors plus the store's current fallback title diff --git a/lib/src/components/wall/SurfacePaneHeader.test.tsx b/lib/src/components/wall/SurfacePaneHeader.test.tsx index 845e6bb3e..6ef03bf33 100644 --- a/lib/src/components/wall/SurfacePaneHeader.test.tsx +++ b/lib/src/components/wall/SurfacePaneHeader.test.tsx @@ -5,6 +5,7 @@ import { act, StrictMode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { PaneProps } from './pane-props'; +import { recordToolDirty } from '../../lib/tool-dirty-store'; import { SurfacePaneHeader } from './SurfacePaneHeader'; import { ToolPaneHeader } from './ToolPaneHeader'; import { FakePtyAdapter } from '../../lib/platform/fake-adapter'; @@ -54,7 +55,7 @@ beforeEach(() => { vi.stubGlobal('ResizeObserver', class { constructor(private callback: ResizeObserverCallback) {} observe(target: Element) { - resizeHeader = width => this.callback([{ target, borderBoxSize: [{ inlineSize: width }] } as unknown as ResizeObserverEntry], this as unknown as ResizeObserver); + resizeHeader = width => this.callback([{ target, borderBoxSize: [{ inlineSize: width }], contentRect: { width } } as unknown as ResizeObserverEntry], this as unknown as ResizeObserver); resizeHeader(620); } disconnect() {} @@ -92,6 +93,58 @@ function renderHeader( } describe('SurfacePaneHeader — browser chrome', () => { + it.each([ + ['terminal', {}], + ['port conflict', { toolPortConflict: [3000, 4000] }], + ['browser', { url: CHROME.url }], + ])('shows live unsaved changes on the Tool %s face at narrow widths', (_face, params) => { + const id = 'dirty-tool-header'; + const registration = register(id); + try { + renderHeader({ ...headerProps(id, 'Tool'), params: { surfaceType: 'tool', ...params } }, stubActions(), { tool: true }); + act(() => resizeHeader(100)); + const indicator = () => container.querySelector('[role="img"][aria-label="Unsaved changes"]'); + expect(indicator()).toBeNull(); + act(() => recordToolDirty(id, true)); + expect(indicator()).not.toBeNull(); + expect(container.querySelector('[aria-label="Kill"]')).not.toBeNull(); + act(() => addPlainNote(id, 'Keep this note')); + expect(indicator()).not.toBeNull(); + if ('url' in params) { + // A 103px Tool has only 79px of browser chrome. The dirty dot remains + // outside the menu; essential controls join the menu before overflowing. + act(() => resizeHeader(79)); + expect(indicator()).not.toBeNull(); + expect(container.querySelector('[aria-label="Kill"]')).toBeNull(); + act(() => container.querySelector<HTMLButtonElement>('[aria-label="Browser controls, 1 note"]')!.click()); + expect(document.querySelector('[role="dialog"] [aria-label="Kill"]')).not.toBeNull(); + expect(container.contains(indicator())).toBe(true); + } + act(() => recordToolDirty(id, false)); + expect(indicator()).toBeNull(); + act(() => recordToolDirty(id, true)); + expect(indicator()).not.toBeNull(); + act(() => recordToolDirty(id, null)); + expect(indicator()).toBeNull(); + } finally { + registration.dispose(); + act(() => recordToolDirty(id, null)); + } + }); + + it.each(['terminal', 'browser'] as const)('ignores dirty reports on an ordinary %s', kind => { + const id = 'dirty-non-tool-header'; + const registration = register(id); + try { + act(() => recordToolDirty(id, true)); + renderHeader({ ...headerProps(id, 'Ordinary'), params: { surfaceType: kind, url: CHROME.url } }, stubActions(), { tool: kind === 'terminal' }); + expect(container.querySelector('[aria-label="Unsaved changes"]')).toBeNull(); + } finally { + registration.dispose(); + act(() => recordToolDirty(id, null)); + } + }); + it('adapts to pane resizes in a wide window and keeps compact controls keyboard reachable', async () => { const registration = register('pane-resize', { ...CHROME, key: 'a'.repeat(300) }); const actions = stubActions(); diff --git a/lib/src/components/wall/SurfacePaneHeader.tsx b/lib/src/components/wall/SurfacePaneHeader.tsx index 0597e27ce..7bc204b52 100644 --- a/lib/src/components/wall/SurfacePaneHeader.tsx +++ b/lib/src/components/wall/SurfacePaneHeader.tsx @@ -16,6 +16,8 @@ import { SplitVerticalIcon, XIcon, } from '@phosphor-icons/react'; +import { ToolDirtyIndicator, useToolDirty } from '../ToolDirtyIndicator'; +import { isToolParams } from './browser-surface'; import { HeaderActionButton } from '../HeaderActionButton'; import { HEADER_PALETTE_TRANSITION_CLASS, POPUP_SURFACE_CLASS, paneZoomButtonClass, TERMINAL_TOP_RADIUS_CLASS } from '../design'; import { NotepadHeaderButton } from './NotepadHeaderButton'; @@ -39,7 +41,9 @@ import { useDialogKeyboardOwner, } from './wall-context'; -export function SurfacePaneHeader({ id, title }: PaneProps) { +export function SurfacePaneHeader({ id, title, params }: PaneProps) { + const reportedDirty = useToolDirty(id); + const dirty = isToolParams(params) && reportedDirty === true; const mode = useContext(ModeContext); const selectedId = useContext(SelectedIdContext); const windowFocused = useContext(WindowFocusedContext); @@ -90,6 +94,8 @@ export function SurfacePaneHeader({ id, title }: PaneProps) { const overflowRef = useRef<HTMLButtonElement>(null); const [width, setWidth] = useState(Number.POSITIVE_INFINITY); const compact = width < 180; + // A dirty dot and its compact gap take 8px beside the persistent controls. + const inlinePaneActions = width >= 72 + (dirty ? 8 : 0); const [menuAnchor, setMenuAnchor] = useState<DOMRect | null>(null); const noteCount = useNoteCount(id); const overflowLabel = `Browser controls${noteCount ? `, ${noteCount} ${noteCount === 1 ? 'note' : 'notes'}` : ''}`; @@ -262,6 +268,7 @@ export function SurfacePaneHeader({ id, title }: PaneProps) { className={`flex h-full min-w-0 flex-1 cursor-grab items-center ${compact ? 'gap-0.5 px-1' : 'gap-1.5 pl-2 pr-[5px]'} ${TERMINAL_TOP_RADIUS_CLASS} text-sm leading-none font-mono select-none active:cursor-grabbing ${HEADER_PALETTE_TRANSITION_CLASS} ${isActiveHeader ? 'bg-header-active-bg text-header-active-fg' : 'bg-header-inactive-bg text-header-inactive-fg'}`} onMouseDown={() => actions.onClickPanel(id)} > + <ToolDirtyIndicator dirty={dirty} /> {compact ? ( <button ref={overflowRef} type="button" aria-label={overflowLabel} aria-haspopup="dialog" aria-expanded={menuAnchor !== null} @@ -272,10 +279,10 @@ export function SurfacePaneHeader({ id, title }: PaneProps) { {noteCount ? <NotepadIcon size={14} weight="fill" /> : <DotsThreeIcon size={14} />} </button> ) : browserControls} - {width >= 72 && paneActions} + {inlinePaneActions && paneActions} {compact && menuAnchor && <BrowserHeaderPopover anchor={menuAnchor} onClose={closeMenu}> {browserControls} - {width < 72 && paneActions} + {!inlinePaneActions && paneActions} </BrowserHeaderPopover>} </div> ); diff --git a/lib/src/components/wall/TerminalPaneHeader.tsx b/lib/src/components/wall/TerminalPaneHeader.tsx index 3c20239ca..95b150252 100644 --- a/lib/src/components/wall/TerminalPaneHeader.tsx +++ b/lib/src/components/wall/TerminalPaneHeader.tsx @@ -12,6 +12,8 @@ import { SplitVerticalIcon, XIcon, } from '@phosphor-icons/react'; +import { ToolDirtyIndicator, useToolDirty } from '../ToolDirtyIndicator'; +import { isToolParams } from './browser-surface'; import { HeaderActionButton } from '../HeaderActionButton'; import { HEADER_PALETTE_TRANSITION_CLASS, paneZoomButtonClass, POPUP_SURFACE_CLASS, TERMINAL_TOP_RADIUS_CLASS, TODO_PILL_TRACKING_CLASS } from '../design'; import { AlertBell } from '../AlertBell'; @@ -55,7 +57,7 @@ import { const tabVariant = tv({ // The active/inactive palette swap crossfades in step with the focus ring's // travel (HEADER_PALETTE_TRANSITION_CLASS); children inherit via `text-inherit`. - base: `flex h-full w-full cursor-grab items-center gap-1.5 ${TERMINAL_TOP_RADIUS_CLASS} pl-2 pr-[5px] text-sm leading-none font-mono select-none active:cursor-grabbing ${HEADER_PALETTE_TRANSITION_CLASS}`, + base: `flex h-full min-w-0 flex-1 cursor-grab items-center gap-1.5 ${TERMINAL_TOP_RADIUS_CLASS} pl-2 pr-[5px] text-sm leading-none font-mono select-none active:cursor-grabbing ${HEADER_PALETTE_TRANSITION_CLASS}`, variants: { state: { active: 'bg-header-active-bg text-header-active-fg', @@ -80,7 +82,8 @@ function alertButtonLabelsFor(status: SessionStatus, argv0: string | null): { ar const TODO_PREVIEW_GAP = 6; const TODO_PREVIEW_MARGIN = 8; -export function TerminalPaneHeader({ id, title }: PaneProps) { +export function TerminalPaneHeader({ id, title, params }: PaneProps) { + const dirty = useToolDirty(id); const mode = useContext(ModeContext); const selectedId = useContext(SelectedIdContext); const renamingId = useContext(RenamingIdContext); @@ -199,6 +202,7 @@ export function TerminalPaneHeader({ id, title }: PaneProps) { context.open(id, { origin: { x: e.clientX, y: e.clientY } }); }} > + {isToolParams(params) && <ToolDirtyIndicator dirty={dirty} />} <div className="flex flex-1 min-w-0 items-center gap-1.5 overflow-hidden"> {isRenaming ? ( <InlineEditInput diff --git a/lib/src/components/wall/use-dor-control.ts b/lib/src/components/wall/use-dor-control.ts index 6f4b41751..1e2a98fa7 100644 --- a/lib/src/components/wall/use-dor-control.ts +++ b/lib/src/components/wall/use-dor-control.ts @@ -1,3 +1,4 @@ +import { recordToolDirty } from '../../lib/tool-dirty-store'; import { createSerialQueue } from '../../host/remote/serial-queue'; import { useCallback, type MutableRefObject } from 'react'; import { sessionForKey } from 'dor-lib-common/agent-browser'; @@ -388,6 +389,7 @@ export async function restartSurfaceInPlace( if (signal?.aborted || interrupted === 'aborted') return RESTART_CANCELLED; if (interrupted === 'timeout') return { ok: false, message: 'did not return to a prompt after interrupt' }; const previousRun = getTerminalPaneState(id).lastCommand?.id ?? null; + recordToolDirty(id, null); platform.writePty(id, `${command}\r`); const restarted = await waitForTerminalState( id, @@ -435,6 +437,7 @@ async function runToolInCallerPane( // Whatever this Session announced under its previous command is not this run's: // a stale OSC 367 would hand the tool that port, or re-key it. clearToolAnnounce(id); + recordToolDirty(id, null); if (tool.become) { // A rename the user made outlives the transformation; an untouched fallback // title becomes the tool's, as a spawned one would be. diff --git a/lib/src/components/wall/workspace-transfer.ts b/lib/src/components/wall/workspace-transfer.ts index af159f79e..4962aa5aa 100644 --- a/lib/src/components/wall/workspace-transfer.ts +++ b/lib/src/components/wall/workspace-transfer.ts @@ -1,3 +1,4 @@ +import { getToolDirty } from '../../lib/tool-dirty-store'; import type { TransferredTools } from './tool-transfer'; import { getToolAnnounce } from '../../lib/tool-announce-store'; import type { ToolAnnounce } from '../../lib/tool-announce'; @@ -139,6 +140,7 @@ export interface TransferredTerminal { * Window no longer held. */ serialized: string; toolAnnounce?: ToolAnnounce; + toolDirty?: boolean; /** Grid at serialization, applied before replay and before destination fitting. */ grid?: TerminalGrid; semanticState?: TransferredTerminalState; @@ -177,7 +179,8 @@ export async function captureTransferContent( const grid = terminal ? { cols: terminal.cols, rows: terminal.rows } : undefined; const mark = marks.get(id); const toolAnnounce = getToolAnnounce(id); - terminals[id] = { serialized, ...(toolAnnounce ? { toolAnnounce } : {}), ...(grid ? { grid, semanticState: snapshotTerminalState(id) } : {}), ...(mark === undefined ? {} : { mark }) }; + const toolDirty = getToolDirty(id); + terminals[id] = { serialized, ...(toolDirty === null ? {} : { toolDirty }), ...(toolAnnounce ? { toolAnnounce } : {}), ...(grid ? { grid, semanticState: snapshotTerminalState(id) } : {}), ...(mark === undefined ? {} : { mark }) }; } return { terminals }; } diff --git a/lib/src/host/remote/sidecar-entry.test.ts b/lib/src/host/remote/sidecar-entry.test.ts index 120418266..a5d6a0035 100644 --- a/lib/src/host/remote/sidecar-entry.test.ts +++ b/lib/src/host/remote/sidecar-entry.test.ts @@ -487,6 +487,15 @@ describe('the webview’s half of the parse', () => { ]); }); + it('forwards dirty state and command resets separately from serve metadata', () => { + bridge.onPtyEvent('data', { id: 'pty-1', data: '\x1b]367;state;{"v":1,"dirty":true}\x07\x1b]633;C\x07\x1b]367;state;{"v":1,"dirty":false}\x07' }); + expect(emitted<{ events: unknown[] }>('terminal:protocolEvents')[0]?.events).toEqual([ + { kind: 'toolState', state: { dirty: true } }, + { kind: 'semantic', event: { type: 'commandStart', source: 'osc633_boundaries' } }, + { kind: 'toolState', state: { dirty: false } }, + ]); + }); + it('preserves command-start resets between forwarded Tool announcements', () => { bridge.onPtyEvent('data', { id: 'pty-1', data: '\x1b]367;serve;{"port":6006}\x07\x1b]633;C\x07\x1b]367;serve;{"port":6007}\x07' }); expect(emitted<{ events: unknown[] }>('terminal:protocolEvents')[0]?.events).toEqual([ diff --git a/lib/src/lib/platform/fake-adapter.test.ts b/lib/src/lib/platform/fake-adapter.test.ts index 80b3003cd..ed009b170 100644 --- a/lib/src/lib/platform/fake-adapter.test.ts +++ b/lib/src/lib/platform/fake-adapter.test.ts @@ -1,3 +1,4 @@ +import { getToolDirty, recordToolDirty } from '../tool-dirty-store'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { FakePtyAdapter, type FakeScenario } from './fake-adapter'; import { ITERM2_DEVICE_ATTRIBUTES_RESPONSE } from '../terminal-protocol'; @@ -24,6 +25,16 @@ describe('FakePtyAdapter', () => { // --- Core (Story 11.1) --- + it('records ordered dirty reports from ordinary PTYs without inferring clean on exit', () => { + const { adapter } = createAdapter(); + adapter.spawnPty('dirty-fake'); + adapter.writePty('dirty-fake', '\x1b]633;C\x07\x1b]367;state;{"v":1,"dirty":false}\x07'); + expect(getToolDirty('dirty-fake')).toBe(false); + adapter.writePty('dirty-fake', '\x1b]367;state;{"v":1,"dirty":true}\x07\x1b]633;D;0\x07'); + expect(getToolDirty('dirty-fake')).toBe(true); + recordToolDirty('dirty-fake', null); + }); + it('init resolves without error', async () => { const { adapter } = createAdapter(); await expect(adapter.init()).resolves.toBeUndefined(); diff --git a/lib/src/lib/platform/vscode-adapter.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts index 292939fdd..45b9725a9 100644 --- a/lib/src/lib/platform/vscode-adapter.test.ts +++ b/lib/src/lib/platform/vscode-adapter.test.ts @@ -1,3 +1,4 @@ +import { getToolDirty, recordToolDirty } from '../tool-dirty-store'; import { getToolAnnounce, resetToolAnnounces } from '../tool-announce-store'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -224,6 +225,24 @@ describe('VSCodeAdapter PTY exit handling', () => { expect(snapshots).toEqual([['claude', 'npm']]); }); + it('receives dirty state/reset messages and retains ordered replay reports through the semantic batch', () => { + new VSCodeAdapter(); + const id = 'dirty-vscode'; + windowTarget.dispatchEvent(hostMessage({ type: 'terminal:toolState', id, dirty: true })); + expect(getToolDirty(id)).toBe(true); + windowTarget.dispatchEvent(hostMessage({ type: 'terminal:toolState', id, dirty: null })); + expect(getToolDirty(id)).toBeNull(); + const before = postMessage.mock.calls.length; + windowTarget.dispatchEvent(hostMessage({ type: 'pty:replay', id, data: '\x1b]633;C\x07\x1b]367;state;{"v":1,"dirty":false}\x07\x1b]633;D;0\x07' })); + expect(getToolDirty(id)).toBe(false); + expect(postMessage.mock.calls).toHaveLength(before); + windowTarget.dispatchEvent(hostMessage({ type: 'pty:replay', id, data: 'tail without state' })); + expect(getToolDirty(id)).toBe(false); + windowTarget.dispatchEvent(hostMessage({ type: 'pty:replay', id, data: '\x1b]633;C\x07' })); + expect(getToolDirty(id)).toBeNull(); + recordToolDirty(id, null); + }); + it('receives owner-parsed Tool announcements and reconstructs them on replay', () => { resetToolAnnounces(); const adapter = new VSCodeAdapter(); diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index fb50780f2..ca9ff52c2 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -1,3 +1,4 @@ +import { recordToolStates, recordToolDirty } from '../tool-dirty-store'; import { recordToolAnnounce, recordToolAnnounces } from '../tool-announce-store'; import type { HelperIdentity, TerminalContextRequest, TerminalContextInfo } from '../terminal-context-types'; import type { AgentBrowserCommandResult, AgentBrowserEditOp, AgentBrowserEditResult, AgentBrowserOpenResult, AgentBrowserPopResult, AgentBrowserScreenshotResult, AgentBrowserStreamStatusResult, AlertStateDetail, IframeProxyResult, OpenPort, PlatformAdapter, PtyDataDetail, PtyInfo, BurrowLink, ToolControlResult, ToolHostRequest } from './types'; @@ -180,10 +181,13 @@ export class VSCodeAdapter implements PlatformAdapter { const parser = new TerminalProtocolParser(themeColorProvider); const parsed = parser.process(msg.data); recordToolAnnounces(msg.id, parsed.events); + recordToolStates(msg.id, parsed.events); applyTerminalSemanticEvents(msg.id, collectTerminalSemanticEvents(parsed.events)); for (const handler of this.replayHandlers) { handler({ id: msg.id, data: parsed.visibleData }); } + } else if (msg.type === 'terminal:toolState') { + recordToolDirty(msg.id, msg.dirty); } else if (msg.type === 'terminal:toolAnnounce') { recordToolAnnounce(msg.id, msg.announce); } else if (msg.type === 'terminal:semanticEvents') { diff --git a/lib/src/lib/terminal-protocol.ts b/lib/src/lib/terminal-protocol.ts index 10cab6ac3..fa4862f87 100644 --- a/lib/src/lib/terminal-protocol.ts +++ b/lib/src/lib/terminal-protocol.ts @@ -1,3 +1,5 @@ +import { parseToolState, type ToolState } from './tool-state'; +import { recordToolStates } from './tool-dirty-store'; import type { ActivityNotification, ProtocolProgressUpdate } from './alert-manager'; import { parseColor } from './css-color'; import { sanitizeText, truncateText } from './osc-sanitize'; @@ -24,6 +26,7 @@ import { export type TerminalProtocolEvent = | { kind: 'notification'; notification: ActivityNotification } | { kind: 'toolAnnounce'; announce: ToolAnnounce } + | { kind: 'toolState'; state: ToolState } | { kind: 'progress'; progress: ProtocolProgressUpdate } | { kind: 'response'; data: string } | { kind: 'semantic'; event: TerminalSemanticEvent }; @@ -297,7 +300,9 @@ export class TerminalProtocolParser { // must not print itself into the user's scrollback. if (content === '367' || content.startsWith('367;')) { const announce = content.startsWith('367;') ? parseToolAnnounce(content.slice('367;'.length)) : null; - return announce ? [{ kind: 'toolAnnounce', announce }] : []; + if (announce) return [{ kind: 'toolAnnounce', announce }]; + const state = content.startsWith('367;') ? parseToolState(content.slice('367;'.length)) : null; + return state ? [{ kind: 'toolState', state }] : []; } const colorResponse = this.parseColorQuery(content); if (colorResponse) return colorResponse; @@ -434,6 +439,7 @@ export function applyTerminalProtocolEvents( events: TerminalProtocolEvent[], ): void { recordToolAnnounces(id, events); + recordToolStates(id, events); for (const event of events) { if (event.kind === 'notification') { sink.notifyFromProtocol(id, event.notification); @@ -452,7 +458,7 @@ export function applyTerminalProtocolEvents( export function collectTerminalProtocolAlerts( events: TerminalProtocolEvent[], ): TerminalProtocolEvent[] { - return events.filter((event) => event.kind === 'notification' || event.kind === 'progress' || event.kind === 'toolAnnounce' + return events.filter((event) => event.kind === 'notification' || event.kind === 'progress' || event.kind === 'toolAnnounce' || event.kind === 'toolState' || (event.kind === 'semantic' && event.event.type === 'commandStart')); } diff --git a/lib/src/lib/terminal-state-store.ts b/lib/src/lib/terminal-state-store.ts index 9403046f6..4955f4024 100644 --- a/lib/src/lib/terminal-state-store.ts +++ b/lib/src/lib/terminal-state-store.ts @@ -1,3 +1,4 @@ +import { recordToolDirty } from './tool-dirty-store'; import { registry } from './terminal-store'; import { commandArgv0, @@ -122,6 +123,7 @@ function clearPaneScratch(id: string): void { } export function resetTerminalPaneState(id: string, initial?: Partial<TerminalPaneState>): void { + recordToolDirty(id, null); clearPaneScratch(id); paneStates.set(id, createTerminalPaneState(initial)); notifyTerminalPaneStateListeners(id); @@ -144,6 +146,7 @@ export function restoreTransferredTerminalState(id: string, state: TransferredTe } export function removeTerminalPaneState(id: string): void { + recordToolDirty(id, null); clearPaneScratch(id); if (!paneStates.delete(id)) return; notifyTerminalPaneStateListeners(id); @@ -155,6 +158,9 @@ export function applyTerminalSemanticEvents( options?: { keystrokeHeuristic?: boolean }, ): void { if (events.length === 0) return; + // OSC starts reset in protocol stream order, before any following state report. + // Only synthetic starts originate here without that ordered protocol event. + if (events.some(event => event.type === 'commandStart' && event.source === 'user_input')) recordToolDirty(id, null); // `keystrokeHeuristic` marks the fallback's own synthesized markers, which must // not promote the pane — that would retire the very path emitting them. if (!options?.keystrokeHeuristic && !oscDrivenPanes.has(id) && events.some(isOscDrivenBoundary)) { diff --git a/lib/src/lib/tool-dirty-store.ts b/lib/src/lib/tool-dirty-store.ts new file mode 100644 index 000000000..b22a9da3d --- /dev/null +++ b/lib/src/lib/tool-dirty-store.ts @@ -0,0 +1,31 @@ +import type { TerminalProtocolEvent } from './terminal-protocol'; + +const dirty = new Map<string, boolean>(); +const listeners = new Set<() => void>(); + +/** Unknown is not clean. Reports from ordinary terminals remain inert until + * a Tool-designated Surface consumes this runtime-only state. */ +export function getToolDirty(id: string): boolean | null { + return dirty.get(id) ?? null; +} + +export function recordToolDirty(id: string, value: boolean | null): void { + if (getToolDirty(id) === value) return; + if (value === null) dirty.delete(id); + else dirty.set(id, value); + for (const listener of listeners) listener(); +} + +export function subscribeToToolDirty(listener: () => void): () => void { + listeners.add(listener); + return () => { listeners.delete(listener); }; +} + +/** Preserve stream order across live events and raw replay. A finish is not a + * save; only a new command or explicit report replaces the previous state. */ +export function recordToolStates(id: string, events: readonly TerminalProtocolEvent[]): void { + for (const event of events) { + if (event.kind === 'toolState') recordToolDirty(id, event.state.dirty); + else if (event.kind === 'semantic' && event.event.type === 'commandStart') recordToolDirty(id, null); + } +} diff --git a/lib/src/lib/tool-state.test.ts b/lib/src/lib/tool-state.test.ts new file mode 100644 index 000000000..0e36efc7e --- /dev/null +++ b/lib/src/lib/tool-state.test.ts @@ -0,0 +1,83 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { parseToolState } from './tool-state'; +import { getToolDirty, recordToolDirty, recordToolStates, subscribeToToolDirty } from './tool-dirty-store'; +import { getToolAnnounce, recordToolAnnounces, resetToolAnnounces } from './tool-announce-store'; +import { applyTerminalProtocolEvents, collectTerminalProtocolAlerts, collectTerminalProtocolResponses, collectTerminalSemanticEvents, TerminalProtocolParser } from './terminal-protocol'; +import { applyTerminalSemanticEvents, removeTerminalPaneState, resetTerminalPaneState } from './terminal-state-store'; + +const id = 'dirty-state-test'; +const state = (dirty: boolean) => `\x1b]367;state;${JSON.stringify({ v: 1, dirty })}\x07`; +const start = '\x1b]633;C\x07'; +const serve = '\x1b]367;serve;{"port":6006}\x07'; +const sink = { notifyFromProtocol() {}, updateProtocolProgress() {} }; +afterEach(() => { removeTerminalPaneState(id); resetToolAnnounces(); }); + +it.each([true, false])('parses strict v1 dirty=%s without a response, including split/ST output', dirty => { + expect(parseToolState(`state;${JSON.stringify({ v: 1, dirty })}`)).toEqual({ dirty }); + const parser = new TerminalProtocolParser(); + expect(parser.process('before\x1b]367;state;{"v":1,"dirty":').visibleData).toBe('before'); + const parsed = parser.process(`${dirty}}\x1b\\after`); + expect(parsed.visibleData).toBe('after'); + expect(parsed.events).toEqual([{ kind: 'toolState', state: { dirty } }]); + expect(collectTerminalProtocolResponses(parsed.events)).toEqual([]); +}); + +it.each([null, [], {}, { dirty: true }, { v: 2, dirty: true }, { v: '1', dirty: true }, + { v: 1 }, { v: 1, dirty: 'false' }, { v: 1, dirty: 0 }, { v: 1, dirty: null }])('ignores invalid state payload %j without assuming clean', value => { + recordToolDirty(id, true); + const parsed = new TerminalProtocolParser().process(`before\x1b]367;state;${JSON.stringify(value)}\x07after`); + applyTerminalProtocolEvents(sink, id, parsed.events); + expect(parsed.visibleData).toBe('beforeafter'); + expect(parsed.events).toEqual([]); + expect(getToolDirty(id)).toBe(true); +}); + +it('bounds malformed/oversized payloads before JSON parsing', () => { + for (const value of ['state;{', `state;${' '.repeat(4096)}{"v":1,"dirty":false}`, 'serve;{"v":1,"dirty":true}']) { + expect(parseToolState(value)).toBeNull(); + } +}); + +it.each(['live', 'forwarded', 'replay'] as const)('preserves serve/state/start order and retains exit reports through %s', mode => { + const feed = (data: string) => { + const events = new TerminalProtocolParser().process(data).events; + if (mode === 'replay') { recordToolAnnounces(id, events); recordToolStates(id, events); } + else applyTerminalProtocolEvents(sink, id, mode === 'forwarded' ? collectTerminalProtocolAlerts(events) : events); + // Adapters deliver semantic batches after protocol reports. This must not + // erase a state that followed the start inside the same read. + applyTerminalSemanticEvents(id, collectTerminalSemanticEvents(events)); + }; + feed(state(true) + start + serve + state(false)); + expect(getToolDirty(id)).toBe(false); + expect(getToolAnnounce(id)?.port).toBe(6006); + feed(state(true) + serve + '\x1b]633;D;0\x07'); + expect(getToolDirty(id)).toBe(true); + expect(getToolAnnounce(id)?.port).toBe(6006); + feed('since-mark bytes with no state'); + expect(getToolDirty(id)).toBe(true); + feed(start); + expect(getToolDirty(id)).toBeNull(); +}); + +it('distinguishes clean from unknown, notifies changes only, and resets on synthetic starts/disposal', () => { + const listener = vi.fn(); + const stop = subscribeToToolDirty(listener); + expect(getToolDirty(id)).toBeNull(); + recordToolDirty(id, false); + recordToolDirty(id, false); + expect(getToolDirty(id)).toBe(false); + expect(listener).toHaveBeenCalledTimes(1); + recordToolDirty(id, true); + applyTerminalSemanticEvents(id, [{ type: 'commandStart', source: 'user_input' }]); + expect(getToolDirty(id)).toBeNull(); + recordToolDirty(id, true); + resetTerminalPaneState(id); + expect(getToolDirty(id)).toBeNull(); + recordToolDirty(id, false); + removeTerminalPaneState(id); + expect(getToolDirty(id)).toBeNull(); + stop(); + listener.mockClear(); + recordToolDirty(id, true); + expect(listener).not.toHaveBeenCalled(); +}); diff --git a/lib/src/lib/tool-state.ts b/lib/src/lib/tool-state.ts new file mode 100644 index 000000000..8592ff23a --- /dev/null +++ b/lib/src/lib/tool-state.ts @@ -0,0 +1,16 @@ +import { isRecord } from './is-record'; + +export interface ToolState { dirty: boolean } + +/** OSC 367 state is independent of serving metadata. Reject unsupported + * versions and non-booleans rather than treating absent/invalid data as clean. */ +export function parseToolState(content: string): ToolState | null { + if (!content.startsWith('state;')) return null; + const raw = content.slice('state;'.length); + if (!raw || raw.length > 4096) return null; + try { + const value: unknown = JSON.parse(raw); + return isRecord(value) && value.v === 1 && typeof value.dirty === 'boolean' + ? { dirty: value.dirty } : null; + } catch { return null; } +} diff --git a/lib/src/stories/BrowserChromeHeader.stories.tsx b/lib/src/stories/BrowserChromeHeader.stories.tsx index 4d0b8a05c..2b7888f1e 100644 --- a/lib/src/stories/BrowserChromeHeader.stories.tsx +++ b/lib/src/stories/BrowserChromeHeader.stories.tsx @@ -8,6 +8,7 @@ import { type WallActions, } from '../components/wall/wall-context'; import { SurfacePaneHeader } from '../components/wall/SurfacePaneHeader'; +import { recordToolDirty } from '../lib/tool-dirty-store'; import { ToolPaneHeader } from '../components/wall/ToolPaneHeader'; import { PANE_HEADER_HEIGHT_PX } from '../components/design'; import { @@ -73,6 +74,7 @@ interface StoryArgs { width: number; /** Include the Tool Terminal Context button beside the browser header. */ tool: boolean; + dirty: 'unknown' | 'clean' | 'dirty'; /** Whether the surface is the selected/active pane (header highlight). */ selected: boolean; } @@ -82,6 +84,10 @@ function BrowserChromeStory(args: StoryArgs) { // collide on one registry id. const surfaceId = useId(); const registrationRef = useRef<ScreenRegistration | null>(null); + useEffect(() => { + recordToolDirty(surfaceId, args.dirty === 'unknown' ? null : args.dirty === 'dirty'); + return () => recordToolDirty(surfaceId, null); + }, [surfaceId, args.dirty]); const screenSnapshot: ScreenSnapshot = useMemo(() => ({ state: args.state, @@ -185,6 +191,7 @@ const meta: Meta<typeof BrowserChromeStory> = { width: { control: { type: 'range', min: 80, max: 900, step: 10 } }, selected: { control: 'boolean' }, tool: { control: 'boolean' }, + dirty: { control: 'inline-radio', options: ['unknown', 'clean', 'dirty'] }, }, args: { renderMode: 'ab-screencast', @@ -197,6 +204,7 @@ const meta: Meta<typeof BrowserChromeStory> = { hostCapable: true, width: 620, tool: false, + dirty: 'unknown', selected: true, }, }; @@ -255,3 +263,9 @@ export const TinyBrowser: Story = { export const SmallestTool: Story = { args: { width: 80, tool: true }, }; + + +export const DirtyTool: Story = { args: { tool: true, dirty: 'dirty' } }; +export const CleanTool: Story = { args: { tool: true, dirty: 'clean' } }; +export const UnknownTool: Story = { args: { tool: true, dirty: 'unknown' } }; +export const NarrowDirtyTool: Story = { args: { tool: true, dirty: 'dirty', width: 103 } }; diff --git a/lib/src/stories/Door.stories.tsx b/lib/src/stories/Door.stories.tsx index e2845be9e..36ee385fe 100644 --- a/lib/src/stories/Door.stories.tsx +++ b/lib/src/stories/Door.stories.tsx @@ -111,3 +111,8 @@ export const NotepadPopover: StoryObj<typeof NotedDoorStory> = { args: { noteCount: 2 }, play: openDoorNotepad, }; + + +export const DirtyTool: Story = { + args: { title: 'Editor', toolDirty: true, noteCount: 2, speechState: 'spoken' }, +}; diff --git a/scripts/dor-tool-qc/server.mjs b/scripts/dor-tool-qc/server.mjs index fc67ad6df..a28926a28 100644 --- a/scripts/dor-tool-qc/server.mjs +++ b/scripts/dor-tool-qc/server.mjs @@ -8,11 +8,19 @@ const option = (name, fallback) => { const label = option('--label', 'Tool QC'); const count = Number(option('--ports', '1')); const escape = text => text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('"', '"'); +const dirtyControls = argv.includes('--dirty-controls'); +let dirty = false; +const reportDirty = value => { + dirty = value; + process.stdout.write(`\x1b]367;state;${JSON.stringify({ v: 1, dirty })}\x1b\\`); +}; +const controls = dirtyControls ? '<form method="post" action="/dirty"><button>Report unsaved changes</button></form><form method="post" action="/clean"><button>Report clean</button></form>' : ''; const servers = []; for (let i = 0; i < count; i++) { const server = http.createServer((req, res) => { + if (dirtyControls && req.method === 'POST' && (req.url === '/dirty' || req.url === '/clean')) reportDirty(req.url === '/dirty'); res.setHeader('content-type', 'text/html; charset=utf-8'); - res.end(`<html><head><title>${escape(label)}

${escape(label)}

${escape(req.url)}

${escape(JSON.stringify(argv))}
`); + res.end(`${escape(label)}

${escape(label)}

${escape(req.url)}

${escape(JSON.stringify(argv))}
${controls}`); }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); servers.push(server); @@ -22,6 +30,7 @@ const announce = () => process.stdout.write(`\x1b]367;serve;${JSON.stringify({ v console.log(JSON.stringify({ event: 'qc-start', pid: process.pid, ports, argv })); if (argv.includes('--announce')) announce(); process.on('SIGUSR1', announce); +if (dirtyControls) process.on('SIGUSR2', () => reportDirty(!dirty)); for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => { for (const server of servers) server.close(); process.exit(0); diff --git a/scripts/loopback-lint.mjs b/scripts/loopback-lint.mjs index 8bb63ba46..cf87231c3 100644 --- a/scripts/loopback-lint.mjs +++ b/scripts/loopback-lint.mjs @@ -70,6 +70,9 @@ const ROOT = fileURLToPath(new URL('..', import.meta.url)); * review; forgetting the guard entirely does not. */ const ALLOWED = { + 'scripts/dor-tool-qc/server.mjs': + 'Unshipped innerdogfood fixture serving generated test content only; no file, ' + + 'credential, or command API. Dirty/clean buttons only emit fixture state reports.', 'vscode-ext/src/agent-browser-host.ts': 'The stream relay authenticates with a single-use 64-hex token (60s TTL, ' + 'pinned to one target port) and drops Origin rather than rewriting it, so ' diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 10011195b..0bd99109d 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -7,9 +7,9 @@ "docs/specs/deploy.md": 1900, "docs/specs/dor-browser.md": 4700, "docs/specs/dor-cli.md": 6000, - "docs/specs/dor-tool.md": 3750, + "docs/specs/dor-tool.md": 4000, "docs/specs/glossary.md": 3000, - "docs/specs/layout.md": 8900, + "docs/specs/layout.md": 9000, "docs/specs/mobile-terminal-ui.md": 1950, "docs/specs/mouse-and-clipboard.md": 3800, "docs/specs/notepad.md": 4000, @@ -26,7 +26,7 @@ "docs/specs/shortcuts.md": 1050, "docs/specs/standalone.md": 10250, "docs/specs/terminal-context.md": 1050, - "docs/specs/terminal-escapes.md": 3800, + "docs/specs/terminal-escapes.md": 3850, "docs/specs/terminal-state.md": 2400, "docs/specs/theme.md": 2150, "docs/specs/tiling-engine.md": 4500, diff --git a/standalone/src/browser-sidecar-adapter.test.ts b/standalone/src/browser-sidecar-adapter.test.ts index 1a1a024b5..bc18e5937 100644 --- a/standalone/src/browser-sidecar-adapter.test.ts +++ b/standalone/src/browser-sidecar-adapter.test.ts @@ -1,3 +1,4 @@ +import { getToolDirty, recordToolDirty } from 'dormouse-lib/lib/tool-dirty-store'; import { describe, expect, it, vi } from "vitest"; import type { AlertStateDetail, PlatformAdapter, PtyDataDetail } from "dormouse-lib/lib/platform/types"; import { getTerminalPaneState } from "dormouse-lib/lib/terminal-state-store"; @@ -136,6 +137,27 @@ describe("BrowserSidecarAdapter terminal stream", () => { }; } + it("applies dirty live/replay reports in order and preserves explicit clean across exit", async () => { + const { deliver } = await listening(); + const id = 'dirty-stream'; + deliver('terminal:protocolEvents', { id, events: [ + { kind: 'toolState', state: { dirty: true } }, + { kind: 'semantic', event: { type: 'commandStart', source: 'osc633_boundaries' } }, + { kind: 'toolState', state: { dirty: false } }, + ] }); + deliver('terminal:semanticEvents', { id, events: [{ type: 'commandStart', source: 'osc633_boundaries' }] }); + expect(getToolDirty(id)).toBe(false); + deliver('pty:exit', { id, exitCode: 0 }); + expect(getToolDirty(id)).toBe(false); + deliver('pty:replay', { id, data: '\x1b]633;C\x07\x1b]367;state;{"v":1,"dirty":true}\x07' }); + expect(getToolDirty(id)).toBe(true); + deliver('pty:replay', { id, data: 'since-mark output without a new command' }); + expect(getToolDirty(id)).toBe(true); + deliver('pty:replay', { id, data: '\x1b]633;C\x07' }); + expect(getToolDirty(id)).toBeNull(); + recordToolDirty(id, null); + }); + it("forwards the projection pair it was handed, parsing nothing again", async () => { const { adapter, send, deliver } = await listening(); const seen: PtyDataDetail[] = []; diff --git a/standalone/src/browser-sidecar-adapter.ts b/standalone/src/browser-sidecar-adapter.ts index 85a1cdbe3..a37d38a70 100644 --- a/standalone/src/browser-sidecar-adapter.ts +++ b/standalone/src/browser-sidecar-adapter.ts @@ -1,3 +1,4 @@ +import { recordToolStates } from '../../lib/src/lib/tool-dirty-store'; import { recordToolAnnounces } from '../../lib/src/lib/tool-announce-store'; import type { AlertRuntimeSnapshot } from 'dormouse-lib/lib/alert-manager'; import type { HelperIdentity, TerminalContextRequest, TerminalContextInfo } from '../../lib/src/lib/terminal-context-types'; @@ -454,6 +455,7 @@ export class BrowserSidecarAdapter implements PlatformAdapter { const { id, data: text, requestId } = data as PtyReplayDetail; const parsed = new TerminalProtocolParser(themeColorProvider).process(text); recordToolAnnounces(id, parsed.events); + recordToolStates(id, parsed.events); applyTerminalSemanticEvents(id, this.alertManager.applyReplay(id, requestId, parsed)); for (const handler of this.replayHandlers) handler({ id, data: parsed.visibleData, requestId }); } else if (event === BURROW_RESULT_EVENT) { diff --git a/standalone/src/tauri-adapter.test.ts b/standalone/src/tauri-adapter.test.ts index 16f14ae72..1939822a0 100644 --- a/standalone/src/tauri-adapter.test.ts +++ b/standalone/src/tauri-adapter.test.ts @@ -1,3 +1,4 @@ +import { getToolDirty, recordToolDirty } from 'dormouse-lib/lib/tool-dirty-store'; import { describe, expect, it, vi } from "vitest"; // The in-process session-flush handshake and drain wrappers on TauriAdapter are @@ -400,6 +401,27 @@ describe("TauriAdapter terminal stream", () => { }; } + it("applies dirty live/replay reports in order and preserves explicit clean across exit", async () => { + const { deliver } = await listening(); + const id = 'dirty-stream'; + deliver('terminal:protocolEvents', { id, events: [ + { kind: 'toolState', state: { dirty: true } }, + { kind: 'semantic', event: { type: 'commandStart', source: 'osc633_boundaries' } }, + { kind: 'toolState', state: { dirty: false } }, + ] }); + deliver('terminal:semanticEvents', { id, events: [{ type: 'commandStart', source: 'osc633_boundaries' }] }); + expect(getToolDirty(id)).toBe(false); + deliver('pty:exit', { id, exitCode: 0 }); + expect(getToolDirty(id)).toBe(false); + deliver('pty:replay', { id, data: '\x1b]633;C\x07\x1b]367;state;{"v":1,"dirty":true}\x07' }); + expect(getToolDirty(id)).toBe(true); + deliver('pty:replay', { id, data: 'since-mark output without a new command' }); + expect(getToolDirty(id)).toBe(true); + deliver('pty:replay', { id, data: '\x1b]633;C\x07' }); + expect(getToolDirty(id)).toBeNull(); + recordToolDirty(id, null); + }); + it("forwards the projection pair it was handed, parsing nothing again", async () => { const { adapter, deliver, invoke } = await listening(); const seen: PtyDataDetail[] = []; diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 1878d66f6..9090e9e29 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -1,3 +1,4 @@ +import { recordToolStates } from '../../lib/src/lib/tool-dirty-store'; import { recordToolAnnounces } from '../../lib/src/lib/tool-announce-store'; import type { AlertRuntimeSnapshot } from 'dormouse-lib/lib/alert-manager'; import type { HelperIdentity, TerminalContextRequest, TerminalContextInfo } from '../../lib/src/lib/terminal-context-types'; @@ -207,6 +208,7 @@ export class TauriAdapter implements PlatformAdapter { const { id, data, requestId } = event.payload; const parsed = new TerminalProtocolParser(themeColorProvider).process(data); recordToolAnnounces(id, parsed.events); + recordToolStates(id, parsed.events); applyTerminalSemanticEvents(id, this.alertManager.applyReplay(id, requestId, parsed)); // A listed exited buffer can contain a command-start with no finish. // Apply its exit after rebuilding the replay's watch, for either target diff --git a/standalone/src/workspace-move.test.ts b/standalone/src/workspace-move.test.ts index a84e87ae4..591ba8f93 100644 --- a/standalone/src/workspace-move.test.ts +++ b/standalone/src/workspace-move.test.ts @@ -1,3 +1,4 @@ +import { getToolDirty, recordToolDirty } from 'dormouse-lib/lib/tool-dirty-store'; import { getToolAnnounce, resetToolAnnounces } from 'dormouse-lib/lib/tool-announce-store'; import { applyTerminalSemanticEvents, snapshotTerminalState, removeTerminalPaneState, countRunningSessionsIn, getTerminalPaneState, isPaneOscDriven } from 'dormouse-lib/lib/terminal-state-store'; // @vitest-environment jsdom @@ -1024,7 +1025,7 @@ describe("workspaceDropTarget", () => { }); -it('restores volatile Tool browser state without publishing it in the durable Workspace', async () => { +it.each([true, false])('restores volatile Tool browser/dirty state (%s) without publishing it in the durable Workspace', async dirty => { resetToolAnnounces(); const move = payload(); const stable = { surfaceType: 'tool', command: 'pnpm storybook', toolRender: 'ab-screencast', toolPort: 'announced' }; @@ -1035,23 +1036,29 @@ it('restores volatile Tool browser state without publishing it in the durable Wo }; const browser = { ...stable, url: 'http://localhost:6006/edited', session: 'browser-to-keep', renderMode: 'ab-screencast', toolAnnouncedPort: 6006 }; const announce = { port: 6006, name: null, key: null, dehydrate: false, persist: null }; - arrivals = [Object.assign(move, { tools: { 'pane-a': browser }, terminals: { 'pane-a': { serialized: '', toolAnnounce: announce } } })]; + arrivals = [Object.assign(move, { tools: { 'pane-a': browser }, terminals: { 'pane-a': { serialized: '', toolAnnounce: announce, toolDirty: dirty } } })]; const plans = await bootFromTearOut(fakePlatform()); expect(plans?.[WORKSPACE_ID].restoredLathLayout).toMatchObject({ leafMeta: { 'pane-a': { params: browser } } }); expect(getToolAnnounce('pane-a')).toEqual(announce); + expect(getToolDirty('pane-a')).toBe(dirty); + expect(JSON.stringify(move.workspace.session)).not.toContain('toolDirty'); + recordToolDirty('pane-a', null); expect(move.workspace.session.lathLayout).toMatchObject({ leafMeta: { 'pane-a': { params: stable } } }); expect(JSON.stringify(move.workspace.session)).not.toContain('browser-to-keep'); resetToolAnnounces(); }); -it('sends Tool browser bindings only with volatile transfer content', async () => { +it('sends Tool browser bindings and explicit clean only with volatile transfer content', async () => { + recordToolDirty('pane-a', false); initWorkspaceMoves(fakePlatform([], { marks: { 'pane-a': 42 } })); const move = { ...prepared(), tools: { 'pane-a': { surfaceType: 'tool', session: 'browser-to-keep' } } }; registerWallHandle(stubWallHandle(WORKSPACE_ID, { prepareWorkspaceTransfer: async () => move })); void transferWorkspaceTo(WORKSPACE_ID, 'ws-2'); await contentSent(); const [, args] = mocks.invoke.mock.calls.find(([cmd]) => cmd === 'transfer_workspace_content')!; - expect(args).toMatchObject({ content: { tools: move.tools } }); + expect(args).toMatchObject({ content: { tools: move.tools, terminals: { 'pane-a': { toolDirty: false } } } }); const [, persisted] = mocks.invoke.mock.calls.find(([cmd]) => cmd === 'transfer_workspace')!; expect(JSON.stringify(persisted)).not.toContain('browser-to-keep'); + expect(JSON.stringify(persisted)).not.toContain('toolDirty'); + recordToolDirty('pane-a', null); }); diff --git a/standalone/src/workspace-move.ts b/standalone/src/workspace-move.ts index 36da82190..bc73b037a 100644 --- a/standalone/src/workspace-move.ts +++ b/standalone/src/workspace-move.ts @@ -1,3 +1,4 @@ +import { recordToolDirty } from "dormouse-lib/lib/tool-dirty-store"; import { restoreToolParams } from 'dormouse-lib/components/wall/tool-transfer'; import { recordToolAnnounce } from 'dormouse-lib/lib/tool-announce-store'; import { pauseAlertDelivery, resumeAlertDelivery, snapshotAlertDelivery, restoreAlertDelivery, forgetAlertDelivery } from 'dormouse-lib/lib/alert-delivery-state'; @@ -427,6 +428,7 @@ async function planArrival( if (terminal.alertDelivery) restoreAlertDelivery(id, terminal.alertDelivery); if (terminal.semanticState) restoreTransferredTerminalState(id, terminal.semanticState); if (terminal.toolAnnounce) recordToolAnnounce(id, terminal.toolAnnounce); + recordToolDirty(id, typeof terminal.toolDirty === 'boolean' ? terminal.toolDirty : null); } const live = await collectLivePtys(platform, { // The token rides through Rust to the sidecar's `list` and comes back on the diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 494402ef1..3ba9d6756 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -210,6 +210,7 @@ type ProcessedExitListener = (id: string, exitCode: number) => void; const processedExitListeners = new Set(); type SemanticEventsListener = (id: string, events: TerminalSemanticEvent[]) => void; const semanticEventsListeners = new Set(); +const toolStateListeners = new Set<(id: string, dirty: boolean | null) => void>(); const toolAnnounceListeners = new Set<(id: string, announce: ToolAnnounce | null) => void>(); export function onProcessedPtyData(listener: ProcessedDataListener): () => void { @@ -293,11 +294,14 @@ function createOwnerPtyStream(id: string): ProcessedPtyStream { // this process cannot reach, so the router withholds them and forwards // them to the webviews instead — and only the rare chunk that carries one // pays for the filtered copy. - const hasAnnounce = events.some(event => event.kind === 'toolAnnounce'); - applyTerminalProtocolEvents(alertManager, id, hasAnnounce ? events.filter(event => event.kind !== 'toolAnnounce') : events); + const hasToolEvent = events.some(event => event.kind === 'toolAnnounce' || event.kind === 'toolState'); + applyTerminalProtocolEvents(alertManager, id, hasToolEvent ? events.filter(event => event.kind !== 'toolAnnounce' && event.kind !== 'toolState') : events); // A null announcement retires the previous command's hint in the owning // webview. Keep starts and serves in parse order, including one chunk. for (const event of events) { + if (event.kind === 'toolState' || (event.kind === 'semantic' && event.event.type === 'commandStart')) { + for (const listener of toolStateListeners) listener(id, event.kind === 'toolState' ? event.state.dirty : null); + } if (event.kind === 'toolAnnounce' || (event.kind === 'semantic' && event.event.type === 'commandStart')) { const announce = event.kind === 'toolAnnounce' ? event.announce : null; for (const listener of toolAnnounceListeners) listener(id, announce); @@ -561,6 +565,10 @@ export function attachRouter( if (ownedPtyIds.has(id)) post({ type: 'terminal:toolAnnounce', id, announce } satisfies ExtensionMessage); }; toolAnnounceListeners.add(onToolAnnounce); + const onToolState = (id: string, dirty: boolean | null) => { + if (ownedPtyIds.has(id)) post({ type: 'terminal:toolState', id, dirty } satisfies ExtensionMessage); + }; + toolStateListeners.add(onToolState); const removeSemanticListener = onTerminalSemanticEvents((id, events) => { if (!ownedPtyIds.has(id)) return; post({ type: 'terminal:semanticEvents', id, events } satisfies ExtensionMessage); @@ -580,6 +588,7 @@ export function attachRouter( removeProcessedListener(); removeSemanticListener(); toolAnnounceListeners.delete(onToolAnnounce); + toolStateListeners.delete(onToolState); removeExitListener(); removeAlertListener(); }; diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index 8c7d8bd58..a9e148ad4 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -95,6 +95,7 @@ export type ExtensionMessage = | { type: 'pty:data'; id: string; data: string; textData?: string } | { type: 'pty:exit'; id: string; exitCode: number } | { type: 'terminal:toolAnnounce'; id: string; announce: ToolAnnounce | null } + | { type: 'terminal:toolState'; id: string; dirty: boolean | null } | { type: 'terminal:semanticEvents'; id: string; events: TerminalSemanticEvent[] } | { type: 'pty:list'; ptys: PtyInfo[] } | { type: 'pty:replay'; id: string; data: string } diff --git a/vscode-ext/test/message-router.test.ts b/vscode-ext/test/message-router.test.ts index e070df4f4..001c536c6 100644 --- a/vscode-ext/test/message-router.test.ts +++ b/vscode-ext/test/message-router.test.ts @@ -136,6 +136,21 @@ it('reports rejected helper creation as an exited terminal', () => { } finally { disposable.dispose(); } }); +it('forwards dirty reports and command resets in order only to the PTY owner', () => { + const owner = fakeWebview(); + const other = fakeWebview(); + const first = router.attachRouter(owner.channel, {}); + const second = router.attachRouter(other.channel, {}); + try { + owner.send({ type: 'dormouse:init' }); + other.send({ type: 'dormouse:init' }); + owner.send({ type: 'pty:spawn', id: 'dirty-owner', options: { cwd: '/repo' } }); + ptys.callbacks!.onData('dirty-owner', '\x1b]367;state;{"v":1,"dirty":true}\x07\x1b]633;C\x07\x1b]367;state;{"v":1,"dirty":false}\x07\x1b]367;serve;{"port":6006}\x07\x1b]633;D;0\x07'); + expect(owner.posted.filter(message => message.type === 'terminal:toolState').map(message => message.dirty)).toEqual([true, null, false]); + expect(other.posted.filter(message => message.type === 'terminal:toolState')).toEqual([]); + } finally { first.dispose(); second.dispose(); } +}); + it('forwards command-start resets and Tool announcements in stream order only to the owning webview', () => { const owner = fakeWebview(); const other = fakeWebview(); From 88a19abbe59991232564cbd8291ac1198f55160b Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 16 Sep 2026 16:41:29 -0700 Subject: [PATCH 2/7] Simplify the Tool dirty-indication plumbing One recorder, `recordToolEvents`, feeds both Tool stores in stream order from every replay seam; OSC 367 verbs share one bounded payload parser. Dirty resets leave the generic pane-state store: disposal clears beside the announce store in teardown and discarded arrivals, synthetic starts reset at their two producers. `useToolDirty` owns the Tool gate and returns the boolean both headers need; ordinary panes never subscribe. Baseboard reads a copy-on-write snapshot like its sibling stores. Tests use the new `resetToolDirty` seam; the duplicate Chromatic story is gone. Co-Authored-By: Claude Fable 5.1 --- docs/specs/dor-tool.md | 10 +-- docs/specs/layout.md | 2 +- lib/src/components/Baseboard.test.tsx | 64 +++++++++---------- lib/src/components/Baseboard.tsx | 8 +-- lib/src/components/Door.tsx | 5 +- lib/src/components/ToolDirtyIndicator.tsx | 16 +++-- .../wall/SurfacePaneHeader.test.tsx | 36 +++++++---- lib/src/components/wall/SurfacePaneHeader.tsx | 4 +- .../components/wall/TerminalPaneHeader.tsx | 5 +- .../components/wall/use-tool-serving.test.tsx | 7 +- lib/src/lib/platform/fake-adapter.test.ts | 4 +- lib/src/lib/platform/vscode-adapter.test.ts | 4 +- lib/src/lib/platform/vscode-adapter.ts | 8 +-- lib/src/lib/terminal-lifecycle.ts | 2 + lib/src/lib/terminal-protocol.ts | 16 ++--- lib/src/lib/terminal-state-store.ts | 8 +-- lib/src/lib/tool-announce-store.ts | 15 +---- lib/src/lib/tool-announce.test.ts | 5 +- lib/src/lib/tool-announce.ts | 33 +++++----- lib/src/lib/tool-dirty-store.ts | 33 +++++++--- lib/src/lib/tool-events.ts | 22 +++++++ lib/src/lib/tool-state.test.ts | 20 +++--- lib/src/lib/tool-state.ts | 12 +--- .../stories/BrowserChromeHeader.stories.tsx | 4 +- .../src/browser-sidecar-adapter.test.ts | 7 +- standalone/src/browser-sidecar-adapter.ts | 6 +- standalone/src/tauri-adapter.test.ts | 7 +- standalone/src/tauri-adapter.ts | 6 +- standalone/src/workspace-move.test.ts | 5 +- standalone/src/workspace-move.ts | 5 +- vscode-ext/src/message-router.ts | 18 +++--- 31 files changed, 214 insertions(+), 183 deletions(-) create mode 100644 lib/src/lib/tool-events.ts diff --git a/docs/specs/dor-tool.md b/docs/specs/dor-tool.md index 4c88bef57..be813d591 100644 --- a/docs/specs/dor-tool.md +++ b/docs/specs/dor-tool.md @@ -204,17 +204,17 @@ Source of truth: `toolTakesOverCaller` / `toolRerunsInCaller` / `callerStillPlac - **Must forward parsed announcements, state reports, and command-start resets in stream order to the owning renderer.** A start clears the previous command's announcement and unsaved state; later reports in that chunk survive. Standalone uses `terminal:protocolEvents`; VS Code uses nullable `terminal:toolAnnounce` and `terminal:toolState` scoped to the owning webview, with null clearing the corresponding record. The fake adapter applies locally. - **Must reconstruct announcements, state, and resets from raw replay without emitting replies**, preserving transferred announcements when since-mark replay has no command start, and clear the renderer record on Session disposal. Ordinary terminal announcements stay inert. - Reserved: **Must retain `name`, `dehydrate`, and `persist` as inert parsed fields**, serving the announced-name and D1/D2 items under [Future](#future). Neither `persist: never` nor a `dehydrate` verb changes current persistence. -- Reserved: **Must reserve `dehydrate` for D2 under [Future](#future)**; existing title/progress protocols keep those roles. +- Reserved: **Never assign an OSC 367 verb beyond `serve`, `state`, and `dehydrate`**; `dehydrate` belongs to D2 under [Future](#future), while existing title/progress protocols keep those roles. -Source of truth: `TerminalProtocolParser` / `collectTerminalProtocolAlerts` in `lib/src/lib/terminal-protocol.ts`; `parseToolAnnounce` in `lib/src/lib/tool-announce.ts`; `recordToolAnnounce` in `lib/src/lib/tool-announce-store.ts`; `createOwnerPtyStream` in `vscode-ext/src/message-router.ts`; `ownerStream` in `lib/src/host/remote/sidecar-entry.ts`. Tests: `lib/src/lib/tool-announce.test.ts`, `standalone/scripts/dev-agent-browser-announce.test.mjs`. +Source of truth: `TerminalProtocolParser` / `collectTerminalProtocolAlerts` in `lib/src/lib/terminal-protocol.ts`; `parseToolAnnounce` in `lib/src/lib/tool-announce.ts`; `recordToolAnnounce` in `lib/src/lib/tool-announce-store.ts`; `recordToolEvents` in `lib/src/lib/tool-events.ts`; `createOwnerPtyStream` in `vscode-ext/src/message-router.ts`; `ownerStream` in `lib/src/host/remote/sidecar-entry.ts`. Tests: `lib/src/lib/tool-announce.test.ts`, `standalone/scripts/dev-agent-browser-announce.test.mjs`. ## Unsaved changes -**Must accept a Tool's `OSC 367;state;{"v":1,"dirty":true}` report as unsaved state**, with `false` reporting clean. Require version 1 and a boolean; malformed, oversized, and unknown-version reports leave the last state unchanged. State reports never change serving hints, Tool identity, or designation; ordinary terminal reports have no dirty UI. The parser consumes them without replying. +**Must accept a Tool's `OSC 367;state;{"v":1,"dirty":true}` report as unsaved state**, with `false` reporting clean. Require version 1 and a boolean; malformed, oversized, and unknown-version reports leave the last state unchanged. State reports never change serving hints, Tool identity, or designation; ordinary terminal reports have no dirty UI. **Must distinguish unreported state from clean.** Start unknown, update immediately on valid reports, and return to unknown on command start, explicit restart, or Session disposal. Command completion is not a save: retain its last report until reset. Serve announcements never clear unsaved state. -**Must retain unsaved state through minimize/reattach, renderer changes, and live Workspace transfer**, and reconstruct it in stream order during raw replay. Never write it to durable session metadata; a cold-started Tool reports its own new state. Layout owns the Pane and Door indicator under `docs/specs/layout.md` → Pane header. +**Must retain unsaved state through minimize/reattach, renderer changes, and live Workspace transfer.** Never write it to durable session metadata; a cold-started Tool reports its own new state. Layout owns the Pane and Door indicator under `docs/specs/layout.md` → Pane header. **Must treat this state as indication only.** It neither writes files nor acknowledges a save, changes kill/close behavior, or authorizes automatic reaping. Save coordination and close protection are under [Future](#future). @@ -225,7 +225,7 @@ printf '\033]367;state;{"v":1,"dirty":true}\033\\' printf '\033]367;state;{"v":1,"dirty":false}\033\\' ``` -Source of truth: `parseToolState` in `lib/src/lib/tool-state.ts`; `getToolDirty` / `recordToolDirty` / `recordToolStates` in `lib/src/lib/tool-dirty-store.ts`; `ToolDirtyIndicator` in `lib/src/components/ToolDirtyIndicator.tsx`. Tests: `lib/src/lib/tool-state.test.ts`, `lib/src/components/wall/SurfacePaneHeader.test.tsx`, `lib/src/components/Baseboard.test.tsx`. +Source of truth: `parseToolState` in `lib/src/lib/tool-state.ts`; `getToolDirty` / `recordToolDirty` in `lib/src/lib/tool-dirty-store.ts`; `recordToolEvents` in `lib/src/lib/tool-events.ts`; `ToolDirtyIndicator` in `lib/src/components/ToolDirtyIndicator.tsx`. Tests: `lib/src/lib/tool-state.test.ts`, `lib/src/components/wall/SurfacePaneHeader.test.tsx`, `lib/src/components/Baseboard.test.tsx`. ## Security diff --git a/docs/specs/layout.md b/docs/specs/layout.md index e2b943899..f4b70feb9 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -46,7 +46,7 @@ Panes are separated by a 7px gap (`PANE_GUTTER_PX`), odd so the 1px selection ri ### Pane header -**Must keep a Tool's unsaved-change dot visible in its Pane header and minimized Door**, across header widths and terminal/browser faces. Use inherited foreground, without animation; label and tooltip it “Unsaved changes”. Show it only for an explicit dirty report; never hide it inside browser overflow controls or replace Kill. State semantics belong to `docs/specs/dor-tool.md` → Unsaved changes. +**Must keep a Tool's unsaved-change dot visible in its Pane header and minimized Door**, across header widths and terminal/browser faces. Use inherited foreground, without animation; label and tooltip it “Unsaved changes”. Never hide it inside browser overflow controls or replace Kill. State semantics belong to `docs/specs/dor-tool.md` → Unsaved changes. A 30px header doubling as a drag handle: **a `pointerdown` past a 5px threshold begins a Lath pane drag**; below the threshold the header's own click behavior stands. It uses `cursor-grab` / `active:cursor-grabbing`, `select-none`, the shared terminal top radius from `lib/src/components/design.tsx`, and the `--color-header-active-*` / `--color-header-inactive-*` token pairs (VSCode file-tree list colors). diff --git a/lib/src/components/Baseboard.test.tsx b/lib/src/components/Baseboard.test.tsx index 73774ede5..0ff081743 100644 --- a/lib/src/components/Baseboard.test.tsx +++ b/lib/src/components/Baseboard.test.tsx @@ -13,7 +13,7 @@ vi.mock('../lib/platform', () => ({ getPlatformOrNull: () => ({ alertPublishSettings: vi.fn(), notepadArchive: {} }), })); -import { recordToolDirty } from '../lib/tool-dirty-store'; +import { recordToolDirty, resetToolDirty } from '../lib/tool-dirty-store'; import { Baseboard } from './Baseboard'; import { installLocalStorageStub } from '../lib/test-local-storage'; import { applyAlertSettingsFromHost, DEFAULT_ALERT_SETTINGS, getAlertSettings } from '../lib/alert-settings'; @@ -373,6 +373,8 @@ describe('Baseboard Door notepad', () => { describe('Baseboard Tool unsaved changes', () => { + afterEach(() => act(() => resetToolDirty())); + it('refits Door overflow when a live dirty report changes a measured width', () => { vi.stubGlobal('ResizeObserver', class { constructor(private callback: ResizeObserverCallback) {} @@ -387,19 +389,15 @@ describe('Baseboard Tool unsaved changes', () => { return this.querySelector('[aria-label="Unsaved changes"]') ? 94 : 80; }); const id = 'dirty-door-fit'; - try { - act(() => root.render( {}} />)); - expect(container.querySelectorAll('[data-door-id]')).toHaveLength(2); - act(() => recordToolDirty(id, true)); - expect(container.querySelectorAll('[data-door-id]')).toHaveLength(1); - act(() => recordToolDirty(id, false)); - expect(container.querySelectorAll('[data-door-id]')).toHaveLength(2); - } finally { - act(() => recordToolDirty(id, null)); - } + act(() => root.render( {}} />)); + expect(container.querySelectorAll('[data-door-id]')).toHaveLength(2); + act(() => recordToolDirty(id, true)); + expect(container.querySelectorAll('[data-door-id]')).toHaveLength(1); + act(() => recordToolDirty(id, false)); + expect(container.querySelectorAll('[data-door-id]')).toHaveLength(2); }); it('updates a terminal-faced Tool Door and suppresses reports for other kinds', () => { @@ -408,26 +406,22 @@ describe('Baseboard Tool unsaved changes', () => { {}} />, )); const visibleDot = () => container.querySelector(`[data-door-id="${id}"] [aria-label="Unsaved changes"]`); - try { - renderKind('tool'); - expect(visibleDot()).toBeNull(); - act(() => recordToolDirty(id, true)); - expect(visibleDot()).not.toBeNull(); - // The hidden measurement pass and the visible Door must grow together. - expect(container.querySelectorAll('[role="img"][aria-label="Unsaved changes"]')).toHaveLength(2); - expect(container.querySelector(`[data-door-id="${id}"]`)?.getAttribute('aria-label')).toContain('Unsaved changes'); - renderKind('terminal'); - expect(visibleDot()).toBeNull(); - renderKind('browser'); - expect(visibleDot()).toBeNull(); - renderKind('tool'); - expect(visibleDot()).not.toBeNull(); - act(() => recordToolDirty(id, false)); - expect(visibleDot()).toBeNull(); - act(() => recordToolDirty(id, null)); - expect(visibleDot()).toBeNull(); - } finally { - act(() => recordToolDirty(id, null)); - } + renderKind('tool'); + expect(visibleDot()).toBeNull(); + act(() => recordToolDirty(id, true)); + expect(visibleDot()).not.toBeNull(); + // The hidden measurement pass and the visible Door must grow together. + expect(container.querySelectorAll('[role="img"][aria-label="Unsaved changes"]')).toHaveLength(2); + expect(container.querySelector(`[data-door-id="${id}"]`)?.getAttribute('aria-label')).toContain('Unsaved changes'); + renderKind('terminal'); + expect(visibleDot()).toBeNull(); + renderKind('browser'); + expect(visibleDot()).toBeNull(); + renderKind('tool'); + expect(visibleDot()).not.toBeNull(); + act(() => recordToolDirty(id, false)); + expect(visibleDot()).toBeNull(); + act(() => recordToolDirty(id, null)); + expect(visibleDot()).toBeNull(); }); }); diff --git a/lib/src/components/Baseboard.tsx b/lib/src/components/Baseboard.tsx index 5cd2c375f..d7f144e97 100644 --- a/lib/src/components/Baseboard.tsx +++ b/lib/src/components/Baseboard.tsx @@ -1,4 +1,4 @@ -import { getToolDirty, subscribeToToolDirty } from '../lib/tool-dirty-store'; +import { getToolDirtySnapshot, subscribeToToolDirty } from '../lib/tool-dirty-store'; import { setWorkspaceAlertDelivery } from '../lib/workspace-store'; import { useWorkspaceAlertPolicy } from './wall/use-workspace-alert-policy'; import { useCallback, useRef, useState, useMemo, useLayoutEffect, useContext, useSyncExternalStore, type ReactNode } from 'react'; @@ -71,9 +71,7 @@ export function Baseboard({ items, onReattach, notice, onDoorDragStart }: Basebo // props component and never asks the platform anything. const notepadNotes = useSyncExternalStore(subscribeToNotepad, getNotepadSnapshot); const notepadAvailable = hasNotepadArchive(); - // A stable primitive snapshot also invalidates the hidden Door width pass. - const dirtyTools = useSyncExternalStore(subscribeToToolDirty, - () => items.map(item => item.kind === 'tool' && getToolDirty(item.id) === true ? '1' : '0').join('')); + const dirtyTools = useSyncExternalStore(subscribeToToolDirty, getToolDirtySnapshot); const appTitleForPane = useMemo( () => buildAppTitleResolver(terminalStates, activityStates), [terminalStates, activityStates], @@ -248,7 +246,7 @@ export function Baseboard({ items, onReattach, notice, onDoorDragStart }: Basebo ? deriveSurfaceLabel(terminalStates.get(item.id) ?? createTerminalPaneState(), appTitleForPane, item.title) : item.title, browserDisplay: item.browserDisplay, - toolDirty: item.kind === 'tool' && getToolDirty(item.id) === true, + toolDirty: item.kind === 'tool' && dirtyTools.get(item.id) === true, status: activity.status, ringSeq: activity.ringSeq, todo: activity.todo, diff --git a/lib/src/components/Door.tsx b/lib/src/components/Door.tsx index 03757c96d..0b732884b 100644 --- a/lib/src/components/Door.tsx +++ b/lib/src/components/Door.tsx @@ -71,7 +71,8 @@ export function Door({ const speaking = speechState === 'speaking'; const spoken = speechState === 'spoken'; const detail = browserDisplay ? BROWSER_DISPLAY_LABEL[browserDisplay] : undefined; - const nameParts = [title, detail, speechState, toolDirty ? 'Unsaved changes' : undefined].filter(Boolean); + const extras = [detail, speechState, toolDirty && 'Unsaved changes'].filter(Boolean); + const nameParts = [title, ...extras]; const doorRef = useRef(null); const showNotepad = noteCount > 0; @@ -102,7 +103,7 @@ export function Door({ )} onPointerDown={onPointerDown} title={nameParts.join(' — ')} - aria-label={detail || speechState || toolDirty ? nameParts.join(', ') : undefined} + aria-label={extras.length ? nameParts.join(', ') : undefined} data-alert-speech-state={speechState} >