diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 0aef8cd30..5806122b1 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -184,7 +184,7 @@ VS Code mounts a bare Wall with no Workspace id, which behaves exactly as a sing A Workspace may leave the Window for another: `docs/specs/standalone.md` -> "Transfer". **Must confirm before a move that would destroy an iframe's page state**: a Workspace holding a plain iframe or serving iframe Tool, Doored ones included, asks with the Close's typed confirmation before it leaves, since that document reopens at its saved URL; agent-browser Surfaces reconnect and ask nothing (`iframeSurfaceIds` on the Wall handle). **Must show drag refusals over Window content in a dialog** until dismissal, retry, or Workspace departure; it waits behind a pending confirmation and an open rename. -**Must use `WorkspaceKillConfirm` for Workspace close, the iframe move gates, and host termination confirmations**: a bare matching letter confirms, another bare key cancels, and a modifier or chord never answers, so `Cmd+Q` still quits. **Must ignore the close confirmation's key while that Workspace transfers, and refuse renaming or pinning it.** **Must hold at most one pending Workspace confirmation** (close, cross-Window move gate, Surface move iframe consent), answered no when a newer one is raised or any close, cross-Window move, or Surface move starts, by gesture or `dor`, even one that refuses. **Must abandon superseded preparation while awaiting a Wall, window probe or editor decision**, so an older verb cannot later act or replace the newer question. A successful transfer dismisses only the departing Workspace's pending confirmation and rename UI; a failed transfer retains them. No pending kill follows a Workspace to its destination. +**Must use `WorkspaceKillConfirm` for Workspace close, the iframe move gates, and host termination confirmations**: a bare matching letter confirms, another bare key cancels, and a modifier or chord never answers, so `Cmd+Q` still quits. **Must ignore the close confirmation's key while that Workspace transfers, and refuse renaming or pinning it.** **Must hold at most one pending Workspace confirmation** (close, cross-Window move gate, Surface move iframe consent), answered no when a newer one is raised or any close, cross-Window move, or Surface move starts, by gesture or `dor`, even one that refuses. **Must abandon superseded preparation while awaiting a Wall, window probe, persistence flush or editor decision**, so an older verb cannot later act or replace the newer question. A successful transfer dismisses only the departing Workspace's pending confirmation and rename UI; a failed transfer retains them. No pending kill follows a Workspace to its destination. Persisted containers: `docs/specs/transport.md`; standalone's per-window record: `docs/specs/standalone.md` -> "Persistence". The union projection: `docs/specs/alert.md` → Workspace union. diff --git a/lib/src/components/WorkspaceWindow.test.tsx b/lib/src/components/WorkspaceWindow.test.tsx index e4c9a3a68..069cde23d 100644 --- a/lib/src/components/WorkspaceWindow.test.tsx +++ b/lib/src/components/WorkspaceWindow.test.tsx @@ -1251,6 +1251,29 @@ describe('Surface moves between Workspaces', () => { await act(async () => { settleConfirmation(getWorkspaceUiSnapshot().confirmation!, false); await pending; }); }); + it('does not let a move whose Tool started serving during its flush replace a newer close question', async () => { + const source = await iframeWalls(); + const handle = getWallHandle(source)!; + vi.spyOn(getWallHandle('workspace:3')!, 'needsCloseConfirmation').mockReturnValue(true); + // Not an iframe when the move starts; serving one by the time the flush ends. + const prepare = handle.prepareSurfaceMove; + let calls = 0; + vi.spyOn(handle, 'prepareSurfaceMove').mockImplementation(id => ({ ...prepare(id), iframe: ++calls > 1 })); + let flushed!: () => void; + vi.spyOn(handle, 'flushPersistence').mockReturnValue(new Promise(resolve => { flushed = resolve; })); + let pending!: Promise>>; + await act(async () => { pending = moveSurface('frame', request({ workspace: 'workspace:2' }), true); }); + expect(getWorkspaceUiSnapshot().confirmation).toBeNull(); + await act(async () => { requestWorkspaceClose('workspace:3'); }); + await act(async () => { await vi.waitFor(() => expect(getWorkspaceUiSnapshot().confirmation?.id).toBe('workspace:3')); }); + const close = getWorkspaceUiSnapshot().confirmation; + await act(async () => { flushed(); await Promise.resolve(); }); + expect(getWorkspaceUiSnapshot().confirmation).toBe(close); + await act(async () => { expect(await pending).toBeNull(); }); + expect(handle.ownsSurface('frame')).toBe(true); + expect(isWorkspaceTransferPending(source)).toBe(false); + }); + it('lets a second GUI iframe move supersede the first one’s consent and complete', async () => { const source = await iframeWalls(); let first!: Promise>>; diff --git a/lib/src/components/wall/surface-move.ts b/lib/src/components/wall/surface-move.ts index 479b7c284..6139cd4a8 100644 --- a/lib/src/components/wall/surface-move.ts +++ b/lib/src/components/wall/surface-move.ts @@ -57,7 +57,7 @@ function discardWorkspace(id: WorkspaceId): void { * in one synchronous step, reversible until both Walls finish. */ export async function moveSurface(id: string, request: Omit, gui = false): Promise { // A move still awaiting its consent is answered no and released here. - cancelPendingConfirmation(); + const isCurrent = cancelPendingConfirmation(); if (moving) throw new Error('Another Surface move is in progress'); const owner = wallHandleOwning(id); const destination = request.destination; @@ -83,7 +83,8 @@ export async function moveSurface(id: string, request: Omit => { if (!prepared.iframe || iframeConsented) return true; if (!gui) throw new Error(IFRAME_FLAG_REQUIRED); - if (!await consent(source.workspaceId, release)) return false; + // A verb started during the flush owns the question now; asking would answer it no. + if (!isCurrent() || !await consent(source.workspaceId, release)) return false; iframeConsented = true; prepared = source.prepareSurfaceMove(id); return true; diff --git a/lib/src/host/remote/test-burrow-link.ts b/lib/src/host/remote/test-burrow-link.ts index a916957b2..cada13061 100644 --- a/lib/src/host/remote/test-burrow-link.ts +++ b/lib/src/host/remote/test-burrow-link.ts @@ -155,9 +155,10 @@ const HOUR_MS = 60 * 60_000; * first paired an hour ago and connected now; each after it paired a day * before the last and was last seen two hours after pairing. Relative to now, * like {@link setupQrResult}, so "Last seen" reads the same in every story. + * A caller that runs before a story pins the clock (a `parameters` literal is + * built at module load) passes its pinned `now`. */ -export function pairedDevices(count: number): PairedDevice[] { - const now = Date.now(); +export function pairedDevices(count: number, now = Date.now()): PairedDevice[] { return Array.from({ length: count }, (_, index) => { const pairedAt = now - HOUR_MS - index * 24 * HOUR_MS; return { @@ -176,7 +177,10 @@ export function pairedDevices(count: number): PairedDevice[] { * one's pairing, the second's session that went direct and ended, a one-time * connection, an unpaired phone refused, and the first's session open now. */ -export function accessLogEntries(devices: PairedDevice[] = pairedDevices(2)): AccessLogEntry[] { +export function accessLogEntries( + devices: PairedDevice[] = pairedDevices(2), + now = Date.now(), +): AccessLogEntry[] { const entries: AccessLogEntry[] = []; for (const [index, { label: device, id: deviceId, pairedAt, lastSeenAt }] of devices.entries()) { entries.push({ at: pairedAt, event: 'paired', device, deviceId }); @@ -192,7 +196,6 @@ export function accessLogEntries(devices: PairedDevice[] = pairedDevices(2)): Ac { at: lastSeenAt, event: 'ended', device, deviceId, path: 'direct', durationMs: 4 * 60_000 }, ); } - const now = Date.now(); const oneTime = 'Dormouse Pocket (browser)'; entries.push( { at: now - 3 * HOUR_MS, event: 'one-time-connected', device: oneTime, path: 'direct' }, diff --git a/lib/src/stories/RemoteControlSection.stories.tsx b/lib/src/stories/RemoteControlSection.stories.tsx index 8e6dcdd7d..c465d3f92 100644 --- a/lib/src/stories/RemoteControlSection.stories.tsx +++ b/lib/src/stories/RemoteControlSection.stories.tsx @@ -39,7 +39,9 @@ function RemoteControlStory() { } /** The clock every story here reads. A setup code encodes its expiry, so a real - * clock would draw a different QR on every run. */ + * clock would draw a different QR on every run. `beforeEach` pins it only + * around render: a `parameters` literal is built at module load, under the + * real clock, so a timestamp there must use `STORY_NOW` itself. */ const STORY_NOW = Date.UTC(2026, 0, 1); const meta: Meta = { @@ -342,7 +344,7 @@ export const PairedPhonesAndAccessLog: Story = { parameters: { primedBurrow: { status: enrolledStatus({ pairedClients: 2 }), - accessLog: accessLogEntries(pairedDevices(2)), + accessLog: accessLogEntries(pairedDevices(2, STORY_NOW), STORY_NOW), }, docs: { story: { height: '620px' } }, }, @@ -703,7 +705,7 @@ export const OneTimeEndedNetworkNotAllowed: Story = { oneTime: { status: 'ended', reason: 'network-not-allowed', - refusal: { at: Date.now(), kind: 'path-refused', end: 'remote', address: '172.58.12.9', addressSource: 'observed' }, + refusal: { at: STORY_NOW, kind: 'path-refused', end: 'remote', address: '172.58.12.9', addressSource: 'observed' }, }, }, docs: { story: { height: '360px' } },