From 9cc7bd200e1bf800901a88e1e08b821721507032 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 17 Sep 2026 18:06:21 -0700 Subject: [PATCH 1/4] Start the refill for a minimized last pane in its cwd Minimizing the last terminal in a Workspace auto-spawns a replacement; it now inherits the minimized Surface's local cwd, as a split does. The local-cwd derivation shared by splits, dor split/ensure, and the refill moves into one helper. Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/layout.md | 2 +- lib/src/components/Wall.test.tsx | 22 ++++++++++++++++++++++ lib/src/components/Wall.tsx | 29 ++++++++++++++++++++++------- scripts/spec-word-budgets.json | 2 +- 4 files changed, 46 insertions(+), 9 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index a82c3e2c1..5a56d1b1e 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -497,7 +497,7 @@ Shell-selection replacement shows a short fixed-position notice over the resulti ### Auto-spawn refill -A store commit that empties the tree (last pane killed or minimized) triggers the "always keep one pane visible" auto-spawn: a Wall effect subscribed to the store spawns one leaf into the emptied tree (`lib/src/components/Wall.tsx`), **re-entrantly on the same commit chain**, so the refill appears with no separate delay (rationale). It spawns with the current default shell selection, matching manual splits. +A store commit that empties the tree (last pane killed or minimized) triggers the "always keep one pane visible" auto-spawn: a Wall effect subscribed to the store spawns one leaf into the emptied tree (`lib/src/components/Wall.tsx`), **re-entrantly on the same commit chain**, so the refill appears with no separate delay (rationale). It spawns with the current default shell selection, matching manual splits. **A refill after a minimize inherits that Surface's local cwd**, as a split does. **The refill adopts the replacement (`selectPane`) only when the current selection points at nothing real** — null (the kill tail cleared it after a selected last-pane kill) or dangling (still naming the just-removed pane). **A valid selection is left alone** — the just-created door on the minimize path, or a live pane after an unselected kill — because the auto-spawn exists to keep a pane visible, not to steal selection. diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index e244fdd5c..da9ba29ba 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -1086,6 +1086,28 @@ describe('Wall on the Lath engine', () => { expect(leafCount()).toBe(1); }); + it('starts the refill for a minimized last pane in that pane\'s cwd', async () => { + terminalRegistry.seedTerminalManualCwd('pane-a', '/repo'); + await act(async () => { + root.render(); + }); + await flush(); + + const leafA = container.querySelector('[data-lath-leaf="pane-a"]')!; + await act(async () => { leafA.querySelector('[aria-label="Minimize"]')!.click(); }); + await flush(); + + const refillId = container.querySelector('[data-lath-leaf]')?.getAttribute('data-lath-leaf'); + try { + expect(refillId).toBeTruthy(); + expect(refillId).not.toBe('pane-a'); + expect(pendingShellOpts.get(refillId!)?.cwd).toBe('/repo'); + } finally { + if (refillId) pendingShellOpts.delete(refillId); + act(() => terminalRegistry.removeTerminalPaneState('pane-a')); + } + }); + it('retires the old ref when shell selection replaces an untouched pane', async () => { await act(async () => { root.render(); diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 1aebb8424..805b3c1fc 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -203,6 +203,13 @@ function createSurfaceRefRegistry( return { refs, nextIndex: Math.max(persistedNext, max + 1) }; } +/** The cwd a new local shell may inherit from `id`. A remote cwd (OSC 7 over ssh) + * names a path on the remote host, not one the local shell can chdir to. */ +function inheritableCwd(id: string): string | undefined { + const cwd = getTerminalPaneState(id).cwd; + return cwd && !cwd.isRemote ? cwd.path : undefined; +} + function compareBySurfaceRef(a: DorSurface, b: DorSurface): number { return (surfaceRefNumber(a.ref) ?? Number.MAX_SAFE_INTEGER) - (surfaceRefNumber(b.ref) ?? Number.MAX_SAFE_INTEGER); @@ -413,6 +420,9 @@ export function Wall({ const [selectedId, setSelectedId] = useState(null); const [selectedType, setSelectedType] = useState('pane'); const lastPaneIdRef = useRef(null); + /** The Surface a minimize is detaching, set only across its `doorLeaf` commit, + * so a refill that commit triggers starts in its cwd. */ + const minimizingIdRef = useRef(null); const doorKillReturnRef = useRef<{ id: string; neighbors: string[] } | null>(null); const windowFocused = useWindowFocused(); @@ -866,7 +876,13 @@ export function Wall({ if (!meta) return; // May auto-spawn if this was the last leaf. `doorLeaf` retains the leaf's meta in // the store (it keeps changing while minimized). - const { token } = lath.store.doorLeaf(id, { park: shouldParkOnMinimize(meta) }); + minimizingIdRef.current = id; + let token: RestoreToken | null; + try { + ({ token } = lath.store.doorLeaf(id, { park: shouldParkOnMinimize(meta) })); + } finally { + minimizingIdRef.current = null; + } if (!token) return; clearSessionAttention(id); // The runtime Door is identity + the core restore payload only @@ -1050,7 +1066,9 @@ export function Wall({ const id = generatePaneId(); surfaceRefForId(id); const defaults = getDefaultShellOpts(); - if (defaults?.shell) setPendingShellOpts(id, { shell: defaults.shell, args: defaults.args }); + // The last pane minimized: its replacement starts where it was. + const cwd = minimizingIdRef.current ? inheritableCwd(minimizingIdRef.current) : undefined; + if (defaults?.shell || cwd) setPendingShellOpts(id, { shell: defaults?.shell, args: defaults?.args, cwd }); lath.store.setEnterHint(id, 'top-left'); // grows from the top-left as the killed pane shrank to the bottom-right lath.store.addLeaf(id, terminalLeafMeta(), null); // becomes the root // Adopt selection only when it points at nothing real: null, or dangling (a @@ -1369,8 +1387,7 @@ export function Wall({ const defaults = getDefaultShellOpts(); // An explicit cwd (dor ensure --cwd, defaulting to the caller's directory) // wins; otherwise inherit the reference pane's local cwd as dor split does. - const sourceCwd = getTerminalPaneState(referenceId).cwd; - const inheritedCwd = cwd ?? (sourceCwd && !sourceCwd.isRemote ? sourceCwd.path : undefined); + const inheritedCwd = cwd ?? inheritableCwd(referenceId); if (deferTerminal) { // No pending shell opts at all: the terminal must not spawn when the leaf @@ -1856,9 +1873,7 @@ export function Wall({ const ref = id && nav.hasPane(id) ? id : null; // Carry the currently selected shell into every manual split. const defaults = getDefaultShellOpts(); - // Remote cwds (OSC 7 over ssh) name a path on the remote host, not one the local shell can chdir to. - const sourceCwd = ref ? getTerminalPaneState(ref).cwd : null; - const inheritedCwd = sourceCwd && !sourceCwd.isRemote ? sourceCwd.path : undefined; + const inheritedCwd = ref ? inheritableCwd(ref) : undefined; if (defaults?.shell || inheritedCwd) { setPendingShellOpts(newId, { shell: defaults?.shell, args: defaults?.args, cwd: inheritedCwd }); } diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 06c1630c9..16f7cca5d 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -10,7 +10,7 @@ "docs/specs/dor-tool.md": 4050, "docs/specs/glossary.md": 3000, "docs/specs/hosted.md": 1050, - "docs/specs/layout.md": 10000, + "docs/specs/layout.md": 10050, "docs/specs/mobile-terminal-ui.md": 1950, "docs/specs/mouse-and-clipboard.md": 3800, "docs/specs/notepad.md": 4000, From 8db895ca5aa0a523a9c97d1c10f52ec0e971195f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 17 Sep 2026 18:07:37 -0700 Subject: [PATCH 2/4] Start the refill for a killed last pane in its cwd too A kill disposes the Session's state before removing the leaf, so the cwd is read first and handed to the refill across the removal commit, the same way minimize hands it across doorLeaf. Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/layout.md | 2 +- lib/src/components/Wall.test.tsx | 6 ++++-- lib/src/components/Wall.tsx | 23 +++++++++++++++-------- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 5a56d1b1e..29b439de8 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -497,7 +497,7 @@ Shell-selection replacement shows a short fixed-position notice over the resulti ### Auto-spawn refill -A store commit that empties the tree (last pane killed or minimized) triggers the "always keep one pane visible" auto-spawn: a Wall effect subscribed to the store spawns one leaf into the emptied tree (`lib/src/components/Wall.tsx`), **re-entrantly on the same commit chain**, so the refill appears with no separate delay (rationale). It spawns with the current default shell selection, matching manual splits. **A refill after a minimize inherits that Surface's local cwd**, as a split does. +A store commit that empties the tree (last pane killed or minimized) triggers the "always keep one pane visible" auto-spawn: a Wall effect subscribed to the store spawns one leaf into the emptied tree (`lib/src/components/Wall.tsx`), **re-entrantly on the same commit chain**, so the refill appears with no separate delay (rationale). It spawns with the current default shell selection, matching manual splits. **The refill inherits the departing pane's local cwd**, as a split does. **The refill adopts the replacement (`selectPane`) only when the current selection points at nothing real** — null (the kill tail cleared it after a selected last-pane kill) or dangling (still naming the just-removed pane). **A valid selection is left alone** — the just-created door on the minimize path, or a live pane after an unselected kill — because the auto-spawn exists to keep a pane visible, not to steal selection. diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index da9ba29ba..01fafc856 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -1086,7 +1086,9 @@ describe('Wall on the Lath engine', () => { expect(leafCount()).toBe(1); }); - it('starts the refill for a minimized last pane in that pane\'s cwd', async () => { + it.each(['Minimize', 'Kill'])('starts the refill after %s of the last pane in that pane\'s cwd', async (control) => { + // An untouched pane closes at once, with no confirm overlay. + vi.spyOn(terminalRegistry, 'isUntouched').mockReturnValue(true); terminalRegistry.seedTerminalManualCwd('pane-a', '/repo'); await act(async () => { root.render(); @@ -1094,7 +1096,7 @@ describe('Wall on the Lath engine', () => { await flush(); const leafA = container.querySelector('[data-lath-leaf="pane-a"]')!; - await act(async () => { leafA.querySelector('[aria-label="Minimize"]')!.click(); }); + await act(async () => { leafA.querySelector(`[aria-label="${control}"]`)!.click(); }); await flush(); const refillId = container.querySelector('[data-lath-leaf]')?.getAttribute('data-lath-leaf'); diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 805b3c1fc..ad453fb22 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -420,9 +420,9 @@ export function Wall({ const [selectedId, setSelectedId] = useState(null); const [selectedType, setSelectedType] = useState('pane'); const lastPaneIdRef = useRef(null); - /** The Surface a minimize is detaching, set only across its `doorLeaf` commit, - * so a refill that commit triggers starts in its cwd. */ - const minimizingIdRef = useRef(null); + /** The local cwd of the pane a kill or minimize is detaching, set only across + * that commit, so a refill it triggers starts there. */ + const departingCwdRef = useRef(undefined); const doorKillReturnRef = useRef<{ id: string; neighbors: string[] } | null>(null); const windowFocused = useWindowFocused(); @@ -734,12 +734,19 @@ export function Wall({ const exitMs = closingWorkspaceRef.current && workspaceIsCollapsed(effectiveWorkspaceId) ? 0 : lath.exitMs; setTimeout(() => { if (!lath.store.has(id)) return; // superseded meanwhile (e.g. replaced) + // Read before disposal drops the Session's state. + const cwd = inheritableCwd(id); disposeSession(id); // Live re-read at removal time: only a kill of the still-selected pane moves // selection; navigating away mid-fade is honored. Removing the last leaf // empties the tree and the auto-spawn effect fills it. const wasSelectedPane = selectedTypeRef.current === 'pane' && selectedIdRef.current === id; - lath.store.removeLeaf(id); + departingCwdRef.current = cwd; + try { + lath.store.removeLeaf(id); + } finally { + departingCwdRef.current = undefined; + } // Forget the ref only now — while the pane is fading it is still in // `listPanes()`, so an earlier delete would let a `dor` projection re-mint a // fresh ref for the dying pane. @@ -876,12 +883,12 @@ export function Wall({ if (!meta) return; // May auto-spawn if this was the last leaf. `doorLeaf` retains the leaf's meta in // the store (it keeps changing while minimized). - minimizingIdRef.current = id; + departingCwdRef.current = inheritableCwd(id); let token: RestoreToken | null; try { ({ token } = lath.store.doorLeaf(id, { park: shouldParkOnMinimize(meta) })); } finally { - minimizingIdRef.current = null; + departingCwdRef.current = undefined; } if (!token) return; clearSessionAttention(id); @@ -1066,8 +1073,8 @@ export function Wall({ const id = generatePaneId(); surfaceRefForId(id); const defaults = getDefaultShellOpts(); - // The last pane minimized: its replacement starts where it was. - const cwd = minimizingIdRef.current ? inheritableCwd(minimizingIdRef.current) : undefined; + // The last pane killed or minimized: its replacement starts where it was. + const cwd = departingCwdRef.current; if (defaults?.shell || cwd) setPendingShellOpts(id, { shell: defaults?.shell, args: defaults?.args, cwd }); lath.store.setEnterHint(id, 'top-left'); // grows from the top-left as the killed pane shrank to the bottom-right lath.store.addLeaf(id, terminalLeafMeta(), null); // becomes the root From 6419f95af53218849e3de6ca2cc55640e7202757 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 17 Sep 2026 18:12:47 -0700 Subject: [PATCH 3/4] Hand the refill its departed pane's id rather than its cwd The store subscription already knows which leaf a tree-emptying commit took, so the refill reads that pane's cwd itself; a kill now disposes the Session after removing its leaf so the state is still there to read. This drops the ref handed across both commits. The local-cwd rule moves into the terminal state store, shared with Helper terminals, and the default-shell staging the refill and both split paths repeated becomes one helper. Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/layout.md | 2 +- lib/src/components/Wall.test.tsx | 33 +++++++-------- lib/src/components/Wall.tsx | 66 ++++++++++------------------- lib/src/lib/helper-terminal.ts | 4 +- lib/src/lib/terminal-registry.ts | 1 + lib/src/lib/terminal-state-store.ts | 7 +++ scripts/spec-word-budgets.json | 2 +- 7 files changed, 50 insertions(+), 65 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 29b439de8..455e80db3 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -497,7 +497,7 @@ Shell-selection replacement shows a short fixed-position notice over the resulti ### Auto-spawn refill -A store commit that empties the tree (last pane killed or minimized) triggers the "always keep one pane visible" auto-spawn: a Wall effect subscribed to the store spawns one leaf into the emptied tree (`lib/src/components/Wall.tsx`), **re-entrantly on the same commit chain**, so the refill appears with no separate delay (rationale). It spawns with the current default shell selection, matching manual splits. **The refill inherits the departing pane's local cwd**, as a split does. +A store commit that empties the tree (last pane killed or minimized) triggers the "always keep one pane visible" auto-spawn: a Wall effect subscribed to the store spawns one leaf into the emptied tree (`lib/src/components/Wall.tsx`), **re-entrantly on the same commit chain**, so the refill appears with no separate delay (rationale). Like a split, it takes the default shell selection and **the departing pane's local cwd**. **The refill adopts the replacement (`selectPane`) only when the current selection points at nothing real** — null (the kill tail cleared it after a selected last-pane kill) or dangling (still naming the just-removed pane). **A valid selection is left alone** — the just-created door on the minimize path, or a live pane after an unselected kill — because the auto-spawn exists to keep a pane visible, not to steal selection. diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index 01fafc856..4f89ae3c4 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -1086,18 +1086,17 @@ describe('Wall on the Lath engine', () => { expect(leafCount()).toBe(1); }); - it.each(['Minimize', 'Kill'])('starts the refill after %s of the last pane in that pane\'s cwd', async (control) => { - // An untouched pane closes at once, with no confirm overlay. - vi.spyOn(terminalRegistry, 'isUntouched').mockReturnValue(true); + it.each(['Minimize', 'Kill'] as const)('starts the refill after %s of the last pane in that pane\'s cwd', async (control) => { + // The stubbed pane has no registry entry to tear down, so disposal is made to + // drop the pane state as the real teardown does. + vi.spyOn(terminalRegistry, 'disposeSession').mockImplementation((id) => terminalRegistry.removeTerminalPaneState(id)); terminalRegistry.seedTerminalManualCwd('pane-a', '/repo'); await act(async () => { root.render(); }); await flush(); - const leafA = container.querySelector('[data-lath-leaf="pane-a"]')!; - await act(async () => { leafA.querySelector(`[aria-label="${control}"]`)!.click(); }); - await flush(); + await clickHeaderControl('pane-a', control); const refillId = container.querySelector('[data-lath-leaf]')?.getAttribute('data-lath-leaf'); try { @@ -3165,15 +3164,15 @@ describe('Wall on the Lath engine', () => { await flush(); } - /** The pane header's Kill button — a user-visible closure, which does prompt. - * `isUntouched` short-circuits the kill confirmation so this is one click. */ - async function clickHeaderKill(paneId: string): Promise { + /** A pane header control; Kill is a user-visible closure, which does prompt. + * `isUntouched` short-circuits the kill confirmation so a kill is one click. */ + async function clickHeaderControl(paneId: string, label: 'Kill' | 'Minimize'): Promise { const untouched = vi.spyOn(terminalRegistry, 'isUntouched').mockReturnValue(true); try { const button = container.querySelector( - `[data-lath-leaf="${paneId}"] button[aria-label="Kill"]`, + `[data-lath-leaf="${paneId}"] button[aria-label="${label}"]`, ); - expect(button, `no Kill button on ${paneId}`).not.toBeNull(); + expect(button, `no ${label} button on ${paneId}`).not.toBeNull(); await act(async () => { button!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); @@ -3361,7 +3360,7 @@ describe('Wall on the Lath engine', () => { const { dispose, setBusy } = spyOnHelper(); vi.spyOn(fake.notepadArchive, 'save').mockRejectedValue(new Error('disk full')); await renderNotedPane(); - await clickHeaderKill('pane-a'); + await clickHeaderControl('pane-a', 'Kill'); expect(archiveFailureModal()).not.toBeNull(); setBusy(true); await clickButton('Close anyway'); @@ -3397,7 +3396,7 @@ describe('Wall on the Lath engine', () => { await flush(); act(() => { addPlainNote('pane-a', 'keep me'); }); - await clickHeaderKill('pane-a'); + await clickHeaderControl('pane-a', 'Kill'); expect(container.querySelector('[data-lath-leaf="pane-a"]')).not.toBeNull(); expect(getNotes('pane-a')).toHaveLength(1); @@ -3432,7 +3431,7 @@ describe('Wall on the Lath engine', () => { }); await flush(); act(() => { addPlainNote('pane-a', 'keep me'); }); - await clickHeaderKill('pane-a'); + await clickHeaderControl('pane-a', 'Kill'); await clickButton('Keep open'); @@ -3448,7 +3447,7 @@ describe('Wall on the Lath engine', () => { }); await flush(); act(() => { addPlainNote('pane-a', 'expendable'); }); - await clickHeaderKill('pane-a'); + await clickHeaderControl('pane-a', 'Kill'); await clickButton('Close anyway'); @@ -3477,8 +3476,8 @@ describe('Wall on the Lath engine', () => { addPlainNote('pane-b', 'from b'); }); - await clickHeaderKill('pane-a'); - await clickHeaderKill('pane-b'); + await clickHeaderControl('pane-a', 'Kill'); + await clickHeaderControl('pane-b', 'Kill'); // A's prompt is the one on screen; B's is behind it. expect(archiveFailureModal()?.textContent).toContain('a could not be written'); diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index ad453fb22..123da0df9 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -45,6 +45,7 @@ import { toggleSessionTodo, setPendingShellOpts, getDefaultShellOpts, + getInheritableCwd, getTerminalPaneState, getTerminalPaneStateSnapshot, getActivitySnapshot, @@ -203,11 +204,11 @@ function createSurfaceRefRegistry( return { refs, nextIndex: Math.max(persistedNext, max + 1) }; } -/** The cwd a new local shell may inherit from `id`. A remote cwd (OSC 7 over ssh) - * names a path on the remote host, not one the local shell can chdir to. */ -function inheritableCwd(id: string): string | undefined { - const cwd = getTerminalPaneState(id).cwd; - return cwd && !cwd.isRemote ? cwd.path : undefined; +/** Stage `id`'s shell as the current default selection, started in `cwd`. Stages + * nothing when neither is set, leaving the host's own default shell and cwd. */ +function stageDefaultShell(id: string, cwd: string | undefined): void { + const defaults = getDefaultShellOpts(); + if (defaults?.shell || cwd) setPendingShellOpts(id, { shell: defaults?.shell, args: defaults?.args, cwd }); } function compareBySurfaceRef(a: DorSurface, b: DorSurface): number { @@ -420,9 +421,6 @@ export function Wall({ const [selectedId, setSelectedId] = useState(null); const [selectedType, setSelectedType] = useState('pane'); const lastPaneIdRef = useRef(null); - /** The local cwd of the pane a kill or minimize is detaching, set only across - * that commit, so a refill it triggers starts there. */ - const departingCwdRef = useRef(undefined); const doorKillReturnRef = useRef<{ id: string; neighbors: string[] } | null>(null); const windowFocused = useWindowFocused(); @@ -734,19 +732,13 @@ export function Wall({ const exitMs = closingWorkspaceRef.current && workspaceIsCollapsed(effectiveWorkspaceId) ? 0 : lath.exitMs; setTimeout(() => { if (!lath.store.has(id)) return; // superseded meanwhile (e.g. replaced) - // Read before disposal drops the Session's state. - const cwd = inheritableCwd(id); - disposeSession(id); // Live re-read at removal time: only a kill of the still-selected pane moves // selection; navigating away mid-fade is honored. Removing the last leaf // empties the tree and the auto-spawn effect fills it. const wasSelectedPane = selectedTypeRef.current === 'pane' && selectedIdRef.current === id; - departingCwdRef.current = cwd; - try { - lath.store.removeLeaf(id); - } finally { - departingCwdRef.current = undefined; - } + lath.store.removeLeaf(id); + // Dispose only after the removal: a refill it triggers reads this pane's cwd. + disposeSession(id); // Forget the ref only now — while the pane is fading it is still in // `listPanes()`, so an earlier delete would let a `dor` projection re-mint a // fresh ref for the dying pane. @@ -883,13 +875,7 @@ export function Wall({ if (!meta) return; // May auto-spawn if this was the last leaf. `doorLeaf` retains the leaf's meta in // the store (it keeps changing while minimized). - departingCwdRef.current = inheritableCwd(id); - let token: RestoreToken | null; - try { - ({ token } = lath.store.doorLeaf(id, { park: shouldParkOnMinimize(meta) })); - } finally { - departingCwdRef.current = undefined; - } + const { token } = lath.store.doorLeaf(id, { park: shouldParkOnMinimize(meta) }); if (!token) return; clearSessionAttention(id); // The runtime Door is identity + the core restore payload only @@ -1067,15 +1053,13 @@ export function Wall({ ); /** Restore the Wall's "always one pane" rule after a commit empties the tree - * (last pane killed or minimized). A no-op while the tree is non-empty. */ - const refillEmptyTree = useCallback(() => { + * (last pane killed or minimized), starting in `departedId`'s cwd. A no-op + * while the tree is non-empty. */ + const refillEmptyTree = useCallback((departedId?: string) => { if (lath.store.getSnapshot().tree.root !== null) return; const id = generatePaneId(); surfaceRefForId(id); - const defaults = getDefaultShellOpts(); - // The last pane killed or minimized: its replacement starts where it was. - const cwd = departingCwdRef.current; - if (defaults?.shell || cwd) setPendingShellOpts(id, { shell: defaults?.shell, args: defaults?.args, cwd }); + stageDefaultShell(id, departedId ? getInheritableCwd(departedId) : undefined); lath.store.setEnterHint(id, 'top-left'); // grows from the top-left as the killed pane shrank to the bottom-right lath.store.addLeaf(id, terminalLeafMeta(), null); // becomes the root // Adopt selection only when it points at nothing real: null, or dangling (a @@ -1111,7 +1095,9 @@ export function Wall({ publishMembership(); } if (closingWorkspaceRef.current) return; - refillEmptyTree(); + // A commit that empties the tree took every pre-commit leaf with it. + const [departedId] = prevIds; + refillEmptyTree(departedId); }); }, [lath, fireEvent, refillEmptyTree, publishMembership]); @@ -1391,15 +1377,15 @@ export function Wall({ } const newId = generatePaneId(); - const defaults = getDefaultShellOpts(); // An explicit cwd (dor ensure --cwd, defaulting to the caller's directory) // wins; otherwise inherit the reference pane's local cwd as dor split does. - const inheritedCwd = cwd ?? inheritableCwd(referenceId); + const inheritedCwd = cwd ?? getInheritableCwd(referenceId); if (deferTerminal) { // No pending shell opts at all: the terminal must not spawn when the leaf // mounts, and must not inherit a cwd it will never use. } else if (command) { + const defaults = getDefaultShellOpts(); // Spawn a real interactive shell and type the command into it once it // reaches a prompt (see typeCommandWhenPromptReady in the lifecycle), rather // than launching `shell -c command`. A `-c` invocation has no prompt behind @@ -1418,12 +1404,8 @@ export function Wall({ command, ...(requireIntegration ? { requireIntegration: true } : {}), }); - } else if (defaults?.shell || inheritedCwd) { - setPendingShellOpts(newId, { - shell: defaults?.shell, - args: defaults?.args, - cwd: inheritedCwd, - }); + } else { + stageDefaultShell(newId, inheritedCwd); } if (referenceDoor) { @@ -1879,11 +1861,7 @@ export function Wall({ surfaceRefForId(newId); const ref = id && nav.hasPane(id) ? id : null; // Carry the currently selected shell into every manual split. - const defaults = getDefaultShellOpts(); - const inheritedCwd = ref ? inheritableCwd(ref) : undefined; - if (defaults?.shell || inheritedCwd) { - setPendingShellOpts(newId, { shell: defaults?.shell, args: defaults?.args, cwd: inheritedCwd }); - } + stageDefaultShell(newId, ref ? getInheritableCwd(ref) : undefined); const panes = lath.listPanes(); const refId = ref ?? (panes.length > 0 ? panes[panes.length - 1].id : null); const edge: Edge = direction === 'right' ? 'right' : 'bottom'; diff --git a/lib/src/lib/helper-terminal.ts b/lib/src/lib/helper-terminal.ts index 0557d4ed0..194106352 100644 --- a/lib/src/lib/helper-terminal.ts +++ b/lib/src/lib/helper-terminal.ts @@ -2,7 +2,7 @@ import { getPlatform } from './platform'; import { registry } from './terminal-store'; import { disposeSession, getOrCreateTerminal, parkElement, setPendingShellOpts } from './terminal-lifecycle'; import { getDefaultShellOpts } from './shell-defaults'; -import { getTerminalPaneState, isPaneOscDriven, seedLaunchedCommand } from './terminal-state-store'; +import { getInheritableCwd, getTerminalPaneState, isPaneOscDriven, seedLaunchedCommand } from './terminal-state-store'; import { DEFAULT_HELPER_COMMAND, type HelperIdentity } from './terminal-context-types'; export type HelperStatus = 'waiting' | 'running' | 'completed' | 'preserved' | 'off' | 'unsupported' | 'exited'; @@ -160,7 +160,7 @@ export async function openHelper(parentId: string): Promise { const command = settings.command ?? DEFAULT_HELPER_COMMAND; const helper: HelperTerminal = { id, parentId, command, status: command ? 'waiting' : 'off' }; helpers.set(parentId, helper); - setPendingShellOpts(id, { ...getDefaultShellOpts(), cwd: cwd && !cwd.isRemote ? cwd.path : undefined, helper: { parentId, command } }); + setPendingShellOpts(id, { ...getDefaultShellOpts(), cwd: getInheritableCwd(parentId), helper: { parentId, command } }); getOrCreateTerminal(id); parkElement(id); notifyHelpers(); diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index bb1a5c908..d69cd127a 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -103,6 +103,7 @@ export { ensureTerminalPaneState, fillTerminalProcessCwd, getRunningCommandArgv0, + getInheritableCwd, getTerminalPaneState, getTerminalPaneStateSnapshot, isPaneOscDriven, diff --git a/lib/src/lib/terminal-state-store.ts b/lib/src/lib/terminal-state-store.ts index 1ee1be979..a569bd330 100644 --- a/lib/src/lib/terminal-state-store.ts +++ b/lib/src/lib/terminal-state-store.ts @@ -65,6 +65,13 @@ export function getTerminalPaneState(id: string): TerminalPaneState { return paneStates.get(id) ?? createTerminalPaneState(); } +/** The cwd a new local shell may inherit from `id`. A remote cwd (OSC 7 over ssh) + * names a path on the remote host, not one the local shell can chdir to. */ +export function getInheritableCwd(id: string): string | undefined { + const cwd = paneStates.get(id)?.cwd; + return cwd && !cwd.isRemote ? cwd.path : undefined; +} + /** * The bare program name of the pane's foreground command, or null when the pane * is at a prompt (or its shell reported no command line). This is the key the diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 16f7cca5d..06c1630c9 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -10,7 +10,7 @@ "docs/specs/dor-tool.md": 4050, "docs/specs/glossary.md": 3000, "docs/specs/hosted.md": 1050, - "docs/specs/layout.md": 10050, + "docs/specs/layout.md": 10000, "docs/specs/mobile-terminal-ui.md": 1950, "docs/specs/mouse-and-clipboard.md": 3800, "docs/specs/notepad.md": 4000, From 86f6329b04e7b1c98f719131d92e069c252f8fb6 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 17 Sep 2026 18:25:33 -0700 Subject: [PATCH 4/4] Record a Helper's launch cwd as the one it spawned in The Helper's spawn already dropped a remote parent cwd, but the launched command's record still took the unfiltered ssh path, so the helper claimed a local cwd it was not in. Both now use the same inheritable cwd. The layout spec names getInheritableCwd as the split's cwd read. Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/layout.md | 2 +- lib/src/lib/helper-terminal.test.ts | 12 +++++++++++- lib/src/lib/helper-terminal.ts | 8 +++++--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 455e80db3..7ed4f0ff7 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -279,7 +279,7 @@ That order is load-bearing twice: a rename input suppresses the pane shortcuts b A split from an existing pane (`|`/`%`/`-`/`"` or the header split buttons) spawns the new pane with its source pane's last-known cwd, then selects it and enters passthrough; host New Terminal actions share that focus tail (rationale). Focus-neutral control-plane creation (`dor split -- …`, `dor ensure`, `dor iframe`, `dor ab`) keeps its documented background behavior. -The source cwd is read from `getTerminalPaneState(sourceId).cwd`. **Never inherit a remote cwd** (`isRemote === true`, e.g. an OSC 7 path reported over ssh) — it is not a usable local spawn cwd. The host default applies when the source cwd is unknown, remote, or absent (initial pane creation). The inherited cwd rides `setPendingShellOpts` alongside the inherited shell selection, consumed by `getOrCreateTerminal` on the next `platform.spawnPty`. +The source cwd is read from `getInheritableCwd(sourceId)`. **Never inherit a remote cwd** (`isRemote === true`, e.g. an OSC 7 path reported over ssh) — it is not a usable local spawn cwd. The host default applies when the source cwd is unknown, remote, or absent (initial pane creation). The inherited cwd rides `setPendingShellOpts` alongside the inherited shell selection, consumed by `getOrCreateTerminal` on the next `platform.spawnPty`. ### Kill confirmation diff --git a/lib/src/lib/helper-terminal.test.ts b/lib/src/lib/helper-terminal.test.ts index bf49f2ff8..80f9eee4d 100644 --- a/lib/src/lib/helper-terminal.test.ts +++ b/lib/src/lib/helper-terminal.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { registry, pendingShellOpts, type TerminalEntry } from './terminal-store'; -import { applyTerminalSemanticEvents, resetTerminalPaneState, removeTerminalPaneState } from './terminal-state-store'; +import { applyTerminalSemanticEvents, getTerminalPaneState, resetTerminalPaneState, removeTerminalPaneState } from './terminal-state-store'; import { beginPromotion, cancelPromotion, closeHelperParent, disposeHelper, finishPromotion, getHelper, helperHasWork, openHelper, restoreHelper, setHelperVisible, subscribeHelpers } from './helper-terminal'; const host = vi.hoisted(() => ({ writePty: vi.fn(), terminalContext: vi.fn() })); @@ -137,6 +137,16 @@ describe('helper lifecycle', () => { prompt(first.id); await vi.advanceTimersByTimeAsync(500); expect(first.status).toBe('completed'); expect(host.writePty).toHaveBeenCalledTimes(1); }); + it.each([ + ['a local parent cwd', false, '/work'], + ['no cwd from a remote parent', true, undefined], + ])('spawns and records %s', async (_label, isRemote, expected) => { + resetTerminalPaneState('parent', { cwd: { path: '/work', pathKind: 'posix', isRemote, source: 'osc7', updatedAt: 0 } }); + const helper = await openHelper('parent'); + expect(pendingShellOpts.get(helper.id)?.cwd).toBe(expected); + prompt(helper.id); await vi.advanceTimersByTimeAsync(100); + expect(getTerminalPaneState(helper.id).cwd?.path).toBe(expected); + }); it('user input during startup cancels autorun and remains preserved at idle', async () => { const helper = await openHelper('parent'); registry.get(helper.id)!.untouched = false; prompt(helper.id); await vi.advanceTimersByTimeAsync(100); diff --git a/lib/src/lib/helper-terminal.ts b/lib/src/lib/helper-terminal.ts index 194106352..a7b411d2d 100644 --- a/lib/src/lib/helper-terminal.ts +++ b/lib/src/lib/helper-terminal.ts @@ -156,15 +156,17 @@ export async function openHelper(parentId: string): Promise { const settings = await platform.terminalContext({ op: 'settings' }); if (!parentIsOpen(parentId)) throw new Error('The parent terminal has closed'); const id = `helper-${crypto.randomUUID()}`; - const cwd = getTerminalPaneState(parentId).cwd; + // One cwd for the spawn and the launched command's record, so a remote parent + // leaves the helper in the host default rather than claiming the ssh path. + const cwd = getInheritableCwd(parentId); const command = settings.command ?? DEFAULT_HELPER_COMMAND; const helper: HelperTerminal = { id, parentId, command, status: command ? 'waiting' : 'off' }; helpers.set(parentId, helper); - setPendingShellOpts(id, { ...getDefaultShellOpts(), cwd: getInheritableCwd(parentId), helper: { parentId, command } }); + setPendingShellOpts(id, { ...getDefaultShellOpts(), cwd, helper: { parentId, command } }); getOrCreateTerminal(id); parkElement(id); notifyHelpers(); - watchHelper(helper, cwd?.path); + watchHelper(helper, cwd); return helper; })(); pending.set(parentId, operation);