diff --git a/docs/specs/transport.md b/docs/specs/transport.md
index 996efc65d..ce35aee9d 100644
--- a/docs/specs/transport.md
+++ b/docs/specs/transport.md
@@ -205,7 +205,7 @@ Carriers: the `boot_reserve_ids` and `surface_reserve_ids` invokes (standalone);
**The Window wrapping lives at the standalone adapter boundary, never in the shared save/restore code**, which still operates on a bare `PersistedSession` per Workspace (`docs/specs/standalone.md` → Persistence). **A Window-persisting adapter answers through `getWindowState` / `saveWindowState`, and answers nothing on the bare-Session `getState` / `saveState` pair** — its blob is a Window and every shared reader of `getState` wants a Session.
-**A save probes every non-browser pane's cwd in one host round trip where the adapter offers `getCwds`, and every save landing in one microtask shares that round trip**, falling back to one `getCwd` per id (rationale). **A flush may say `probeCwd: false`** and keep each pane's previously persisted cwd — the post-kill quit flush (`docs/specs/standalone.md` → "Quit flow").
+**A save probes every non-browser pane's cwd in one host round trip where the adapter offers `getCwds`, and every save landing in one microtask shares that round trip**, falling back to one `getCwd` per id (rationale). **Must retain the latest request's options when coalescing queued saves.** **A flush may say `probeCwd: false`** and keep each pane's previously persisted cwd — the post-kill quit flush (`docs/specs/standalone.md` → "Quit flow").
**`PersistedSession.version` is 4 and `PersistedWindow.version` 2; a reader must discard any other version as a fresh start, never migrate it.** Every writer stamps both, pinned by `standalone/scripts/persisted-format.json`; the standalone Rust host's discard: `docs/specs/standalone.md` → Persistence.
diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx
index a99fc7669..1d3f8456d 100644
--- a/lib/src/components/Wall.test.tsx
+++ b/lib/src/components/Wall.test.tsx
@@ -398,6 +398,39 @@ describe('Wall on the Lath engine', () => {
expect(previousWorkspaceSession(DEFAULT_WORKSPACE_ID)?.panes.find(p => p.id === 'surface:a')?.cwd).toBe('/fresh');
});
+ it('honors a no-probe flush queued behind a probing save', async () => {
+ await act(async () => root.render());
+ await flush();
+ const handle = getWallHandle(DEFAULT_WORKSPACE_ID)!;
+ await act(async () => { await handle.flushPersistence(); });
+ let resolveCwd!: (cwd: string) => void;
+ const probe = vi.spyOn(fake, 'getCwd').mockResolvedValue('/unexpected')
+ .mockImplementationOnce(() => new Promise((resolve) => { resolveCwd = resolve; }));
+ await act(async () => {
+ const first = handle.flushPersistence();
+ const last = handle.flushPersistence({ probeCwd: false });
+ resolveCwd('/retained');
+ await Promise.all([first, last]);
+ });
+ expect(probe).toHaveBeenCalledTimes(1);
+ expect((fake.getState() as PersistedSession).panes[0].cwd).toBe('/retained');
+ });
+
+ it('probes for a normal flush queued behind a no-probe save', async () => {
+ await act(async () => root.render());
+ await flush();
+ const handle = getWallHandle(DEFAULT_WORKSPACE_ID)!;
+ await act(async () => { await handle.flushPersistence(); });
+ const probe = vi.spyOn(fake, 'getCwd').mockResolvedValue('/fresh');
+ await act(async () => {
+ const first = handle.flushPersistence({ probeCwd: false });
+ const last = handle.flushPersistence();
+ await Promise.all([first, last]);
+ });
+ expect(probe).toHaveBeenCalledTimes(1);
+ expect((fake.getState() as PersistedSession).panes[0].cwd).toBe('/fresh');
+ });
+
it('manual keyboard splits enter passthrough on the new pane immediately', async () => {
const onEvent = vi.fn();
await act(async () => {
diff --git a/lib/src/components/wall/use-session-persistence.ts b/lib/src/components/wall/use-session-persistence.ts
index fa04941cb..6144ecea9 100644
--- a/lib/src/components/wall/use-session-persistence.ts
+++ b/lib/src/components/wall/use-session-persistence.ts
@@ -84,7 +84,7 @@ export function useSessionPersistence({
}), [effectiveWorkspaceId]);
const sessionSaveTimerRef = useRef | null>(null);
const sessionSavePromiseRef = useRef | null>(null);
- const pendingSaveNeededRef = useRef(false);
+ const pendingSaveRef = useRef<{ options?: SaveOptions } | null>(null);
// See session-dirty.ts for the conservative-under-races generation model.
const trackerRef = useRef(createSessionDirtyTracker());
// This Workspace's own previous record, which is where a dead PTY's retained
@@ -179,27 +179,26 @@ export function useSessionPersistence({
const persistSessionNow = useCallback(async (options?: SaveOptions): Promise => {
const runSave = (): Promise => {
- pendingSaveNeededRef.current = false;
+ const request = pendingSaveRef.current;
+ pendingSaveRef.current = null;
// Clear dirty only on a fulfilled write (.then, not .finally).
const token = trackerRef.current.beginSave();
- const savePromise = doSave(options)
+ const savePromise = doSave(request?.options)
.then(() => {
trackerRef.current.completeSave(token);
})
.finally(() => {
if (sessionSavePromiseRef.current === savePromise) {
- sessionSavePromiseRef.current = pendingSaveNeededRef.current ? runSave() : null;
+ sessionSavePromiseRef.current = pendingSaveRef.current ? runSave() : null;
}
});
sessionSavePromiseRef.current = savePromise;
return savePromise;
};
- if (sessionSavePromiseRef.current) {
- pendingSaveNeededRef.current = true;
- } else {
- runSave();
- }
+ // Coalesce requests, retaining the latest options even when a save is busy.
+ pendingSaveRef.current = { options };
+ if (!sessionSavePromiseRef.current) runSave();
// Await until the pipeline idles so the resolution covers the LATEST queued
// save, not just the one in flight. Swallow a per-save rejection here: a
// failed save still chains its queued follow-up (via `.finally`), so