From 6264a9c6a633b5c3a05c8cb15075ccf2655efa46 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Thu, 9 Jul 2026 12:36:43 -0400 Subject: [PATCH 1/3] fix(desktop): preserve agent timer state across workspace switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resetWorkspaceState() wiped the activeAgentTurnsStore on every workspace switch, losing the startedAt timestamps that anchor the elapsed-time badges. Switching A→B→A re-anchored timers to "just now" via resurrectTurn instead of restoring the original start. Add saveActiveAgentTurnsForWorkspace / restoreActiveAgentTurnsForWorkspace to snapshot and recover all four module maps (activeTurnsByAgent, clockOffsetByAgent, lastProcessed, terminalAtByAgent) around the reset. useWorkspaceInit saves the outgoing workspace before resetWorkspaceState() and restores the incoming workspace after applyWorkspace() resolves. lastActivityAt is refreshed on restore so turns saved 25+ s ago are not immediately pruned before new liveness events arrive. Tombstones are preserved so completed turns cannot be resurrected by a stale liveness frame arriving after the round-trip. The watermark is preserved so already-processed events are not replayed. The snapshot is consumed on first use; empty saves (no turns, no tombstones) discard prior snapshots. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../agents/activeAgentTurnsStore.test.mjs | 218 ++++++++++++++++++ .../features/agents/activeAgentTurnsStore.ts | 99 ++++++++ .../features/workspaces/useWorkspaceInit.ts | 21 +- 3 files changed, 337 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs index 75d216e2eb..2209cdd0c6 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs +++ b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs @@ -8,6 +8,8 @@ import { getActiveTurnsByChannel, resetActiveAgentTurnsStore, subscribeActiveAgentTurns, + saveActiveAgentTurnsForWorkspace, + restoreActiveAgentTurnsForWorkspace, } from "./activeAgentTurnsStore.ts"; import { injectObserverEventsForE2E, @@ -1328,3 +1330,219 @@ describe("formatElapsed", () => { assert.equal(formatElapsed(-5_000), "0s"); }); }); + +describe("workspace-switch save / restore", () => { + beforeEach(() => { + resetActiveAgentTurnsStore(); + }); + + it("restores original startedAt timestamps across a round-trip", () => { + // Simulate a turn active in workspace A with a known start timestamp. + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ + seq: 1, + turnId: "t1", + channelId: "c1", + timestamp: "2024-01-01T00:00:00Z", + }), + ]); + const anchorBefore = getActiveTurnsForAgent(AGENT)[0].anchorAt; + + // Switch away (save A, reset, apply B). + saveActiveAgentTurnsForWorkspace("ws-a"); + resetActiveAgentTurnsStore(); + assert.equal( + getActiveTurnsForAgent(AGENT).length, + 0, + "store must be empty after reset", + ); + + // Switch back (restore A). + restoreActiveAgentTurnsForWorkspace("ws-a"); + const turns = getActiveTurnsForAgent(AGENT); + assert.equal(turns.length, 1, "restored turn must reappear"); + assert.equal(turns[0].channelId, "c1"); + assert.equal( + turns[0].anchorAt, + anchorBefore, + "anchorAt must equal the pre-switch value — startedAt and offset are preserved", + ); + }); + + it("no-op restore when no snapshot exists for the workspace", () => { + // Populate the store so we can verify nothing changes. + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }), + ]); + restoreActiveAgentTurnsForWorkspace("ws-unknown"); + // Store should be unchanged — still has the turn we set up above. + assert.equal(getActiveTurnsForAgent(AGENT).length, 1); + }); + + it("empty store discards a prior snapshot rather than saving an empty one", () => { + // First round-trip: save something. + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }), + ]); + saveActiveAgentTurnsForWorkspace("ws-a"); + + // Second pass: the turns ended while we were away, so the store is empty + // when we try to save again. + resetActiveAgentTurnsStore(); + saveActiveAgentTurnsForWorkspace("ws-a"); // empty store → delete snapshot + + // Restore must be a no-op now. + restoreActiveAgentTurnsForWorkspace("ws-a"); + assert.equal( + getActiveTurnsForAgent(AGENT).length, + 0, + "restoring after an empty save must yield no turns", + ); + }); + + it("snapshot is consumed on restore — a second restore is a no-op", () => { + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }), + ]); + saveActiveAgentTurnsForWorkspace("ws-a"); + resetActiveAgentTurnsStore(); + + restoreActiveAgentTurnsForWorkspace("ws-a"); + assert.equal(getActiveTurnsForAgent(AGENT).length, 1, "first restore ok"); + + // Second restore — snapshot was consumed. + resetActiveAgentTurnsStore(); + restoreActiveAgentTurnsForWorkspace("ws-a"); + assert.equal( + getActiveTurnsForAgent(AGENT).length, + 0, + "second restore must be no-op — snapshot is consumed on first use", + ); + }); + + it("watermark is preserved — events already processed before save are not reprocessed", () => { + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ + seq: 5, + turnId: "t1", + channelId: "c1", + timestamp: "2024-01-01T00:00:00Z", + }), + ]); + saveActiveAgentTurnsForWorkspace("ws-a"); + resetActiveAgentTurnsStore(); + restoreActiveAgentTurnsForWorkspace("ws-a"); + + // Replaying an event at or below the watermark must be a no-op. + let notified = 0; + const unsub = subscribeActiveAgentTurns(() => { + notified++; + }); + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ + seq: 3, + turnId: "t2", + channelId: "c2", + timestamp: "2024-01-01T00:00:00Z", + }), + ]); + unsub(); + + assert.equal(notified, 0, "stale event after restore must not notify"); + const channels = new Set( + getActiveTurnsForAgent(AGENT).map((s) => s.channelId), + ); + assert.ok(!channels.has("c2"), "stale event must not add a turn"); + }); + + it("terminal tombstones are preserved — a stale liveness cannot resurrect a completed turn", () => { + // Complete a turn before saving. + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ + seq: 1, + turnId: "t1", + channelId: "c1", + timestamp: "2024-01-01T00:00:00Z", + }), + makeEvent({ + seq: 2, + kind: "turn_completed", + turnId: "t1", + channelId: "c1", + timestamp: "2024-01-01T00:00:10Z", + }), + ]); + saveActiveAgentTurnsForWorkspace("ws-a"); + resetActiveAgentTurnsStore(); + restoreActiveAgentTurnsForWorkspace("ws-a"); + + // A liveness stamped before the completion must not resurrect t1. + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ + seq: 3, + kind: "turn_liveness", + turnId: "t1", + channelId: "c1", + timestamp: "2024-01-01T00:00:05Z", + }), + ]); + assert.equal( + getActiveTurnsForAgent(AGENT).length, + 0, + "terminal tombstone must survive save/restore — stale liveness must not resurrect", + ); + }); + + it("notifies listeners after restore so UI re-renders with recovered turns", () => { + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }), + ]); + saveActiveAgentTurnsForWorkspace("ws-a"); + resetActiveAgentTurnsStore(); + + let notified = 0; + const unsub = subscribeActiveAgentTurns(() => { + notified++; + }); + restoreActiveAgentTurnsForWorkspace("ws-a"); + unsub(); + + assert.ok(notified > 0, "restore must notify listeners"); + }); + + it("multiple workspaces maintain independent snapshots", () => { + // Workspace A: agent in c1. + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }), + ]); + saveActiveAgentTurnsForWorkspace("ws-a"); + + // Workspace B: different agent/channel. + resetActiveAgentTurnsStore(); + syncAgentTurnsFromEvents(AGENT_2, [ + makeEvent({ seq: 1, turnId: "t2", channelId: "c2" }), + ]); + saveActiveAgentTurnsForWorkspace("ws-b"); + + // Switch to A — only A's turns must appear. + resetActiveAgentTurnsStore(); + restoreActiveAgentTurnsForWorkspace("ws-a"); + const aChannels = new Set( + getActiveTurnsForAgent(AGENT).map((s) => s.channelId), + ); + assert.ok(aChannels.has("c1"), "ws-a must restore c1"); + assert.equal( + getActiveTurnsForAgent(AGENT_2).length, + 0, + "ws-b turns must not appear in ws-a restore", + ); + + // Switch to B — only B's turns. + resetActiveAgentTurnsStore(); + restoreActiveAgentTurnsForWorkspace("ws-b"); + const bChannels = new Set( + getActiveTurnsForAgent(AGENT_2).map((s) => s.channelId), + ); + assert.ok(bChannels.has("c2"), "ws-b must restore c2"); + }); +}); diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts index 9ffd34db63..fb1f41e978 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.ts +++ b/desktop/src/features/agents/activeAgentTurnsStore.ts @@ -568,3 +568,102 @@ export function resetActiveAgentTurnsStore() { terminalAtByAgent.clear(); notifyListeners(); } + +// --------------------------------------------------------------------------- +// Workspace-switch save / restore +// --------------------------------------------------------------------------- + +type TurnsStoreSnapshot = { + turns: Map>; + offsets: Map; + watermarks: Map; + terminals: Map>; +}; + +/** Per-workspace snapshots. Keyed by workspace ID. */ +const savedByWorkspace = new Map(); + +/** + * Snapshot the current active-turns state under `workspaceId` so it can be + * restored when the user switches back. If both the turns map and the + * tombstone map are empty there is nothing worth restoring — discard any + * previously-saved snapshot instead. + * + * Deep-clones all four maps so subsequent mutations on the live maps do not + * corrupt the snapshot. + */ +export function saveActiveAgentTurnsForWorkspace(workspaceId: string): void { + if (activeTurnsByAgent.size === 0 && terminalAtByAgent.size === 0) { + savedByWorkspace.delete(workspaceId); + return; + } + + // Deep-clone activeTurnsByAgent: outer map + inner per-agent maps + turn + // objects (plain structs, no nested references beyond primitives). + const turns = new Map>(); + for (const [agentKey, agentTurns] of activeTurnsByAgent) { + const clonedAgent = new Map(); + for (const [turnId, turn] of agentTurns) { + clonedAgent.set(turnId, { ...turn }); + } + turns.set(agentKey, clonedAgent); + } + + // Shallow-clone scalar maps (primitives as values). + const offsets = new Map(clockOffsetByAgent); + const watermarks = new Map(lastProcessed); + + // Deep-clone terminalAtByAgent: outer map + inner per-agent maps. + const terminals = new Map>(); + for (const [agentKey, tombstones] of terminalAtByAgent) { + terminals.set(agentKey, new Map(tombstones)); + } + + savedByWorkspace.set(workspaceId, { turns, offsets, watermarks, terminals }); +} + +/** + * Restore a previously saved active-turns snapshot for `workspaceId` into the + * (already-reset) module maps. No-op when no snapshot exists. + * + * Refreshes `lastActivityAt` on every restored turn so the prune interval + * doesn't immediately kill turns that were saved more than 25 s ago (the prune + * threshold). New observer events arriving after restore will update + * `lastActivityAt` normally via `recordActivity`. + * + * Consumes the snapshot (deletes it from `savedByWorkspace`) — a given + * workspace's snapshot is only usable once per round-trip. + */ +export function restoreActiveAgentTurnsForWorkspace( + workspaceId: string, +): void { + const snap = savedByWorkspace.get(workspaceId); + if (!snap) return; + savedByWorkspace.delete(workspaceId); + + const now = Date.now(); + + for (const [agentKey, agentTurns] of snap.turns) { + const restored = new Map(); + for (const [turnId, turn] of agentTurns) { + restored.set(turnId, { ...turn, lastActivityAt: now }); + } + activeTurnsByAgent.set(agentKey, restored); + } + + for (const [agentKey, offset] of snap.offsets) { + clockOffsetByAgent.set(agentKey, offset); + } + + for (const [agentKey, event] of snap.watermarks) { + lastProcessed.set(agentKey, event); + } + + for (const [agentKey, tombstones] of snap.terminals) { + terminalAtByAgent.set(agentKey, new Map(tombstones)); + } + + cachedTurnSummaries.clear(); + cachedChannelTurnSummaries = null; + notifyListeners(); +} diff --git a/desktop/src/features/workspaces/useWorkspaceInit.ts b/desktop/src/features/workspaces/useWorkspaceInit.ts index d9df6113fb..80fff41bb3 100644 --- a/desktop/src/features/workspaces/useWorkspaceInit.ts +++ b/desktop/src/features/workspaces/useWorkspaceInit.ts @@ -13,7 +13,11 @@ import { initDraftStore, } from "@/features/messages/lib/useDrafts"; import { resetRenderScopedReactionHydration } from "@/features/messages/lib/renderScopedReactions"; -import { resetActiveAgentTurnsStore } from "@/features/agents/activeAgentTurnsStore"; +import { + resetActiveAgentTurnsStore, + saveActiveAgentTurnsForWorkspace, + restoreActiveAgentTurnsForWorkspace, +} from "@/features/agents/activeAgentTurnsStore"; import { resetAgentWorkingSignal } from "@/features/agents/agentWorkingSignal"; import { resetAgentObserverStore } from "@/features/agents/observerRelayStore"; import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; @@ -76,6 +80,10 @@ export function useWorkspaceInit( // On the initial mount we skip resetting singletons (they're fresh). const hasInitializedRef = useRef(false); + // Track the previously-applied workspace ID so we can save its turn state + // before resetting when the user switches to a different workspace. + const prevWorkspaceIdRef = useRef(null); + // biome-ignore lint/correctness/useExhaustiveDependencies: we intentionally depend on specific properties (id/relayUrl/token/reposDir) — depending on the whole object would trigger resets on name-only changes useEffect(() => { let cancelled = false; @@ -127,6 +135,11 @@ export function useWorkspaceInit( // On workspace switch (not initial mount), reset module singletons // so the new tree starts with a clean slate. if (hasInitializedRef.current) { + // Save the outgoing workspace's turn state before wiping the store so + // timers survive a round-trip (A → B → A keeps A's elapsed time). + if (prevWorkspaceIdRef.current) { + saveActiveAgentTurnsForWorkspace(prevWorkspaceIdRef.current); + } resetWorkspaceState(); } hasInitializedRef.current = true; @@ -170,6 +183,12 @@ export function useWorkspaceInit( if (activeWorkspace.pubkey) { initDraftStore(activeWorkspace.pubkey); } + // Restore any turn state saved for this workspace (a prior A→B round- + // trip). This runs after applyWorkspace succeeds and before the app + // renders so components see the restored timers on first render. + restoreActiveAgentTurnsForWorkspace(activeWorkspace.id); + // Prime the ref so the NEXT switch saves this workspace's state. + prevWorkspaceIdRef.current = activeWorkspace.id; setResult({ isReady: true, needsSetup: false, From 93b115ea0e1f2e1e39a6f8a92cb09d9432d5be63 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Thu, 9 Jul 2026 12:46:17 -0400 Subject: [PATCH 2/3] fix(desktop): null ref after save, collapse restore signature Two fixes from Thufir review pass 1: - useWorkspaceInit: null out prevWorkspaceIdRef.current immediately after saving the outgoing workspace's snapshot. Previously the ref stayed set to the outgoing workspace ID across a cancelled/failed incoming switch; on the next re-fire the save path would see the now-empty store and call the empty-store branch, deleting the saved snapshot. Nulling after save prevents the stale-ref overwrite. - activeAgentTurnsStore: collapse restoreActiveAgentTurnsForWorkspace signature onto one line to satisfy Biome formatter (fixes Desktop Core CI failure). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src/features/agents/activeAgentTurnsStore.ts | 4 +--- desktop/src/features/workspaces/useWorkspaceInit.ts | 4 ++++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts index fb1f41e978..3b9f6fd19c 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.ts +++ b/desktop/src/features/agents/activeAgentTurnsStore.ts @@ -634,9 +634,7 @@ export function saveActiveAgentTurnsForWorkspace(workspaceId: string): void { * Consumes the snapshot (deletes it from `savedByWorkspace`) — a given * workspace's snapshot is only usable once per round-trip. */ -export function restoreActiveAgentTurnsForWorkspace( - workspaceId: string, -): void { +export function restoreActiveAgentTurnsForWorkspace(workspaceId: string): void { const snap = savedByWorkspace.get(workspaceId); if (!snap) return; savedByWorkspace.delete(workspaceId); diff --git a/desktop/src/features/workspaces/useWorkspaceInit.ts b/desktop/src/features/workspaces/useWorkspaceInit.ts index 80fff41bb3..cd52a63e6f 100644 --- a/desktop/src/features/workspaces/useWorkspaceInit.ts +++ b/desktop/src/features/workspaces/useWorkspaceInit.ts @@ -139,6 +139,10 @@ export function useWorkspaceInit( // timers survive a round-trip (A → B → A keeps A's elapsed time). if (prevWorkspaceIdRef.current) { saveActiveAgentTurnsForWorkspace(prevWorkspaceIdRef.current); + // Null out immediately so a cancelled/failed incoming workspace + // switch doesn't re-save the now-empty store under the outgoing + // workspace ID and delete its snapshot. + prevWorkspaceIdRef.current = null; } resetWorkspaceState(); } From 82f40ad5697ad029a0b041f7795fc964afe14a9a Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Thu, 9 Jul 2026 13:07:24 -0400 Subject: [PATCH 3/3] fix(desktop): harden workspace-switch timer save/restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address five code-review findings: - Add clearSavedWorkspaceSnapshot() and wire it into removeWorkspace() alongside the existing relay-specific GC calls so snapshots for permanently-deleted workspaces don't sit in memory indefinitely. - Make restoreActiveAgentTurnsForWorkspace self-contained by clearing all four module maps before writing from the snapshot. Callers no longer need to pre-clear as a precondition; the function is now safe against the narrow async window between resetWorkspaceState() and the restore. - Add doc comment to resetActiveAgentTurnsStore clarifying that savedByWorkspace is intentionally excluded — snapshots must survive the reset that runs between save and restore. - Add no-notification assertion to the no-op restore test so a future unconditional notifyListeners() regression is caught immediately. - Refine the prevWorkspaceIdRef null-out comment to describe the actual rapid-switch (A→B→C before B resolves) scenario rather than the less-precise cancelled/failed framing. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../agents/activeAgentTurnsStore.test.mjs | 25 +++++++++++++++++ .../features/agents/activeAgentTurnsStore.ts | 28 ++++++++++++++++++- .../features/workspaces/useWorkspaceInit.ts | 6 ++-- .../src/features/workspaces/useWorkspaces.tsx | 2 ++ 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs index 2209cdd0c6..4897d73a45 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs +++ b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs @@ -10,6 +10,7 @@ import { subscribeActiveAgentTurns, saveActiveAgentTurnsForWorkspace, restoreActiveAgentTurnsForWorkspace, + clearSavedWorkspaceSnapshot, } from "./activeAgentTurnsStore.ts"; import { injectObserverEventsForE2E, @@ -1374,7 +1375,13 @@ describe("workspace-switch save / restore", () => { syncAgentTurnsFromEvents(AGENT, [ makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }), ]); + let notified = 0; + const unsub = subscribeActiveAgentTurns(() => { + notified++; + }); restoreActiveAgentTurnsForWorkspace("ws-unknown"); + unsub(); + assert.equal(notified, 0, "no-op restore must not notify listeners"); // Store should be unchanged — still has the turn we set up above. assert.equal(getActiveTurnsForAgent(AGENT).length, 1); }); @@ -1545,4 +1552,22 @@ describe("workspace-switch save / restore", () => { ); assert.ok(bChannels.has("c2"), "ws-b must restore c2"); }); + + it("clearSavedWorkspaceSnapshot discards the snapshot so restore is a no-op", () => { + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }), + ]); + saveActiveAgentTurnsForWorkspace("ws-a"); + + // Simulate workspace deletion: GC the snapshot before switching back. + clearSavedWorkspaceSnapshot("ws-a"); + + resetActiveAgentTurnsStore(); + restoreActiveAgentTurnsForWorkspace("ws-a"); + assert.equal( + getActiveTurnsForAgent(AGENT).length, + 0, + "restore must be no-op after clearSavedWorkspaceSnapshot", + ); + }); }); diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts index 3b9f6fd19c..07a6e7e01d 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.ts +++ b/desktop/src/features/agents/activeAgentTurnsStore.ts @@ -559,6 +559,11 @@ export function useActiveAgentTurnsBridge( }, [agents]); } +/** + * Clears all live turn state (active turns, offsets, watermarks, tombstones). + * Intentionally preserves `savedByWorkspace` — workspace-switch snapshots + * must survive the reset that runs between save and restore. + */ export function resetActiveAgentTurnsStore() { activeTurnsByAgent.clear(); lastProcessed.clear(); @@ -624,7 +629,13 @@ export function saveActiveAgentTurnsForWorkspace(workspaceId: string): void { /** * Restore a previously saved active-turns snapshot for `workspaceId` into the - * (already-reset) module maps. No-op when no snapshot exists. + * module maps. No-op when no snapshot exists. + * + * Clears all four module maps before writing so the function is + * self-contained — it replaces rather than merging, regardless of whether the + * caller pre-cleared. At the primary call site (`useWorkspaceInit`) the maps + * are already empty after `resetWorkspaceState()`, but this guard makes the + * contract explicit. * * Refreshes `lastActivityAt` on every restored turn so the prune interval * doesn't immediately kill turns that were saved more than 25 s ago (the prune @@ -639,6 +650,12 @@ export function restoreActiveAgentTurnsForWorkspace(workspaceId: string): void { if (!snap) return; savedByWorkspace.delete(workspaceId); + // Clear before writing so this is a replace, not a merge. + activeTurnsByAgent.clear(); + clockOffsetByAgent.clear(); + lastProcessed.clear(); + terminalAtByAgent.clear(); + const now = Date.now(); for (const [agentKey, agentTurns] of snap.turns) { @@ -665,3 +682,12 @@ export function restoreActiveAgentTurnsForWorkspace(workspaceId: string): void { cachedChannelTurnSummaries = null; notifyListeners(); } + +/** + * Discard the saved turn-state snapshot for a workspace that has been + * permanently deleted so the entry doesn't sit in memory indefinitely. + * Call this alongside the other relay-specific GC in `removeWorkspace`. + */ +export function clearSavedWorkspaceSnapshot(workspaceId: string): void { + savedByWorkspace.delete(workspaceId); +} diff --git a/desktop/src/features/workspaces/useWorkspaceInit.ts b/desktop/src/features/workspaces/useWorkspaceInit.ts index cd52a63e6f..91d01f076b 100644 --- a/desktop/src/features/workspaces/useWorkspaceInit.ts +++ b/desktop/src/features/workspaces/useWorkspaceInit.ts @@ -139,9 +139,9 @@ export function useWorkspaceInit( // timers survive a round-trip (A → B → A keeps A's elapsed time). if (prevWorkspaceIdRef.current) { saveActiveAgentTurnsForWorkspace(prevWorkspaceIdRef.current); - // Null out immediately so a cancelled/failed incoming workspace - // switch doesn't re-save the now-empty store under the outgoing - // workspace ID and delete its snapshot. + // Null out immediately so a rapid workspace switch (A→B→C before + // B's applyWorkspace resolves) doesn't re-save the now-empty + // store under the outgoing workspace ID and delete its snapshot. prevWorkspaceIdRef.current = null; } resetWorkspaceState(); diff --git a/desktop/src/features/workspaces/useWorkspaces.tsx b/desktop/src/features/workspaces/useWorkspaces.tsx index e984ea4920..e289baa179 100644 --- a/desktop/src/features/workspaces/useWorkspaces.tsx +++ b/desktop/src/features/workspaces/useWorkspaces.tsx @@ -19,6 +19,7 @@ import { import { removeSelfProfileCachesForRelay } from "@/features/profile/lib/selfProfileStorage"; import { removeChannelSnapshotForRelay } from "@/features/channels/channelSnapshot"; import { removeMessageSnapshotsForRelay } from "@/features/messages/lib/messageSnapshot"; +import { clearSavedWorkspaceSnapshot } from "@/features/agents/activeAgentTurnsStore"; export type UseWorkspacesReturn = { workspaces: Workspace[]; @@ -120,6 +121,7 @@ function useWorkspacesInternal(): UseWorkspacesReturn { removeSelfProfileCachesForRelay(removed.relayUrl); removeChannelSnapshotForRelay(removed.relayUrl); removeMessageSnapshotsForRelay(removed.relayUrl); + clearSavedWorkspaceSnapshot(id); } }