Skip to content

Commit 346452b

Browse files
committed
fix: avoid echoing server workspace views
1 parent db1c0c9 commit 346452b

4 files changed

Lines changed: 256 additions & 16 deletions

File tree

apps/web/src/features/workspace/WorkspaceScreen.tsx

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,11 @@ import {
100100
resolveTerminalRecoveryAction,
101101
} from "./workspace-recovery";
102102
import { attachWorkspaceRuntimeWithRetry } from "./runtime-attach";
103+
import {
104+
pruneWorkspaceViewBaselines,
105+
rememberWorkspaceViewBaseline,
106+
shouldPersistWorkspaceView,
107+
} from "./workspace-view-persistence";
103108
import {
104109
createInitialHistoryExpansion,
105110
groupSessionHistory,
@@ -453,7 +458,6 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }:
453458
const validatedRuntimeTargetsRef = useRef(new Set<string>());
454459
const runtimeValidationRequestIdRef = useRef(0);
455460
const persistedLayoutRef = useRef<string>("");
456-
const persistedWorkspaceViewsRef = useRef(new Map<string, string>());
457461
const agentStartupStateRef = useRef(new Map<string, {
458462
token: number;
459463
startedAt: number;
@@ -841,26 +845,21 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }:
841845
state.tabs.forEach((tab) => {
842846
if (tab.status !== "ready" || !tab.project?.path) return;
843847
if (!canMutateWorkspace(tab.controller, "switch_pane")) return;
848+
if (!shouldPersistWorkspaceView(tab)) return;
844849
const patch = {
845850
active_session_id: tab.activeSessionId,
846851
active_pane_id: tab.activePaneId,
847852
active_terminal_id: tab.activeTerminalId,
848853
pane_layout: tab.paneLayout,
849854
file_preview: tab.filePreview,
850855
};
851-
const serialized = JSON.stringify(patch);
852-
if (persistedWorkspaceViewsRef.current.get(tab.id) === serialized) return;
853-
persistedWorkspaceViewsRef.current.set(tab.id, serialized);
856+
rememberWorkspaceViewBaseline(tab);
854857
void withServiceFallback(
855858
() => updateWorkspaceView(tab.id, patch, tab.controller),
856859
null,
857860
);
858861
});
859-
Array.from(persistedWorkspaceViewsRef.current.keys()).forEach((workspaceId) => {
860-
if (!liveWorkspaceIds.has(workspaceId)) {
861-
persistedWorkspaceViewsRef.current.delete(workspaceId);
862-
}
863-
});
862+
pruneWorkspaceViewBaselines(liveWorkspaceIds);
864863
}, [bootstrapReady, state.tabs]);
865864

866865
useEffect(() => {
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { Tab } from "../../state/workbench-core.ts";
2+
import type { WorkspaceViewPatch } from "../../types/app.ts";
3+
4+
type WorkspaceViewTab = Pick<
5+
Tab,
6+
"id" | "activeSessionId" | "activePaneId" | "activeTerminalId" | "paneLayout" | "filePreview"
7+
>;
8+
9+
const persistedWorkspaceViews = new Map<string, string>();
10+
11+
export const createWorkspaceViewPatchFromTab = (
12+
tab: WorkspaceViewTab,
13+
): WorkspaceViewPatch => ({
14+
active_session_id: tab.activeSessionId,
15+
active_pane_id: tab.activePaneId,
16+
active_terminal_id: tab.activeTerminalId,
17+
pane_layout: tab.paneLayout,
18+
file_preview: tab.filePreview,
19+
});
20+
21+
export const serializeWorkspaceViewPatch = (patch: WorkspaceViewPatch) => JSON.stringify(patch);
22+
23+
const serializeWorkspaceViewTab = (tab: WorkspaceViewTab) => serializeWorkspaceViewPatch(
24+
createWorkspaceViewPatchFromTab(tab),
25+
);
26+
27+
export const rememberWorkspaceViewBaseline = (tab: WorkspaceViewTab) => {
28+
persistedWorkspaceViews.set(tab.id, serializeWorkspaceViewTab(tab));
29+
};
30+
31+
export const rememberWorkspaceViewBaselines = (tabs: WorkspaceViewTab[]) => {
32+
tabs.forEach((tab) => {
33+
rememberWorkspaceViewBaseline(tab);
34+
});
35+
};
36+
37+
export const shouldPersistWorkspaceView = (tab: WorkspaceViewTab) => (
38+
persistedWorkspaceViews.get(tab.id) !== serializeWorkspaceViewTab(tab)
39+
);
40+
41+
export const forgetWorkspaceViewBaseline = (workspaceId: string) => {
42+
persistedWorkspaceViews.delete(workspaceId);
43+
};
44+
45+
export const pruneWorkspaceViewBaselines = (workspaceIds: ReadonlySet<string>) => {
46+
Array.from(persistedWorkspaceViews.keys()).forEach((workspaceId) => {
47+
if (!workspaceIds.has(workspaceId)) {
48+
persistedWorkspaceViews.delete(workspaceId);
49+
}
50+
});
51+
};
52+
53+
export const resetWorkspaceViewBaselines = () => {
54+
persistedWorkspaceViews.clear();
55+
};

apps/web/src/shared/utils/workspace.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ import {
3434
isDraftSession,
3535
resolveVisibleStatus,
3636
} from "./session.ts";
37+
import {
38+
rememberWorkspaceViewBaseline,
39+
rememberWorkspaceViewBaselines,
40+
} from "../../features/workspace/workspace-view-persistence.ts";
3741

3842
const unique = (values: string[]) => Array.from(new Set(values.filter(Boolean)));
3943

@@ -388,7 +392,7 @@ export const buildWorkbenchStateFromBootstrap = (
388392
})
389393
.filter((tab): tab is Tab => Boolean(tab));
390394

391-
return {
395+
const nextState = {
392396
tabs,
393397
activeTabId: resolveActiveWorkspaceId(tabs, bootstrap.ui_state.active_workspace_id),
394398
layout: workbenchLayoutFromBackend(bootstrap.ui_state.layout),
@@ -398,6 +402,8 @@ export const buildWorkbenchStateFromBootstrap = (
398402
input: tabs.length === 0 ? current.overlay.input : "",
399403
},
400404
};
405+
rememberWorkspaceViewBaselines(nextState.tabs);
406+
return nextState;
401407
};
402408

403409
export const upsertWorkspaceSnapshot = (
@@ -417,7 +423,7 @@ export const upsertWorkspaceSnapshot = (
417423
}
418424
const tabs = orderTabsByUiState(Array.from(tabMap.values()), openWorkspaceIds);
419425

420-
return {
426+
const nextState = {
421427
...current,
422428
tabs,
423429
activeTabId: resolveActiveWorkspaceId(tabs, uiState?.active_workspace_id ?? nextTab.id),
@@ -428,6 +434,8 @@ export const upsertWorkspaceSnapshot = (
428434
input: "",
429435
},
430436
};
437+
rememberWorkspaceViewBaseline(nextTab);
438+
return nextState;
431439
};
432440

433441
export const applyWorkspaceRuntimeSnapshot = (
@@ -481,9 +489,10 @@ export const applyWorkspaceControllerEvent = (
481489
export const applyWorkspaceRuntimeStateEvent = (
482490
current: WorkbenchState,
483491
payload: WorkspaceRuntimeStateEvent,
484-
): WorkbenchState => ({
485-
...current,
486-
tabs: current.tabs.map((tab) => {
492+
): WorkbenchState => {
493+
const nextState = {
494+
...current,
495+
tabs: current.tabs.map((tab) => {
487496
if (tab.id !== payload.workspace_id) return tab;
488497
const nextActiveSessionId = tab.sessions.some((session) => session.id === payload.view_state.active_session_id)
489498
? payload.view_state.active_session_id
@@ -500,8 +509,14 @@ export const applyWorkspaceRuntimeStateEvent = (
500509
filePreview: normalizeFilePreview(payload.view_state.file_preview, tab.filePreview),
501510
viewingArchiveId: undefined,
502511
};
503-
}),
504-
});
512+
}),
513+
};
514+
const nextTab = nextState.tabs.find((tab) => tab.id === payload.workspace_id);
515+
if (nextTab) {
516+
rememberWorkspaceViewBaseline(nextTab);
517+
}
518+
return nextState;
519+
};
505520

506521
export const applyWorkbenchUiState = (
507522
current: WorkbenchState,
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
import {
4+
applyWorkspaceRuntimeStateEvent,
5+
buildWorkbenchStateFromBootstrap,
6+
} from "../apps/web/src/shared/utils/workspace.ts";
7+
import { createDefaultWorkbenchState } from "../apps/web/src/state/workbench-core.ts";
8+
import {
9+
resetWorkspaceViewBaselines,
10+
shouldPersistWorkspaceView,
11+
} from "../apps/web/src/features/workspace/workspace-view-persistence.ts";
12+
13+
const APP_SETTINGS = {
14+
agentProvider: "claude" as const,
15+
agentCommand: "claude",
16+
idlePolicy: {
17+
enabled: true,
18+
idleMinutes: 10,
19+
maxActive: 3,
20+
pressure: true,
21+
},
22+
completionNotifications: {
23+
enabled: true,
24+
onlyWhenBackground: true,
25+
},
26+
terminalCompatibilityMode: "standard" as const,
27+
};
28+
29+
const createWorkspaceSnapshot = (activeSessionId: string) => ({
30+
workspace: {
31+
workspace_id: "ws-1",
32+
title: "Workspace 1",
33+
project_path: "/tmp/ws-1",
34+
source_kind: "local" as const,
35+
source_value: "/tmp/ws-1",
36+
git_url: null,
37+
target: { type: "native" as const },
38+
idle_policy: {
39+
enabled: true,
40+
idle_minutes: 10,
41+
max_active: 3,
42+
pressure: true,
43+
},
44+
},
45+
sessions: [
46+
{
47+
id: 1,
48+
title: "Session 1",
49+
status: "idle" as const,
50+
mode: "branch" as const,
51+
auto_feed: true,
52+
queue: [],
53+
messages: [],
54+
stream: "",
55+
unread: 0,
56+
last_active_at: 1,
57+
claude_session_id: null,
58+
},
59+
{
60+
id: 2,
61+
title: "Session 2",
62+
status: "waiting" as const,
63+
mode: "branch" as const,
64+
auto_feed: true,
65+
queue: [],
66+
messages: [],
67+
stream: "",
68+
unread: 0,
69+
last_active_at: 2,
70+
claude_session_id: null,
71+
},
72+
],
73+
archive: [],
74+
view_state: {
75+
active_session_id: activeSessionId,
76+
active_pane_id: `pane-${activeSessionId}`,
77+
active_terminal_id: "",
78+
pane_layout: {
79+
type: "leaf" as const,
80+
id: `pane-${activeSessionId}`,
81+
sessionId: activeSessionId,
82+
},
83+
file_preview: {
84+
path: "",
85+
content: "",
86+
mode: "preview" as const,
87+
originalContent: "",
88+
modifiedContent: "",
89+
dirty: false,
90+
},
91+
},
92+
terminals: [],
93+
});
94+
95+
test.afterEach(() => {
96+
resetWorkspaceViewBaselines();
97+
});
98+
99+
test("buildWorkbenchStateFromBootstrap seeds workspace view persistence baselines for hydrated tabs", () => {
100+
const state = buildWorkbenchStateFromBootstrap(
101+
createDefaultWorkbenchState(),
102+
{
103+
ui_state: {
104+
open_workspace_ids: ["ws-1"],
105+
active_workspace_id: "ws-1",
106+
layout: {
107+
left_width: 320,
108+
right_width: 320,
109+
right_split: 64,
110+
show_code_panel: false,
111+
show_terminal_panel: false,
112+
},
113+
},
114+
workspaces: [createWorkspaceSnapshot("2")],
115+
},
116+
"en",
117+
APP_SETTINGS,
118+
);
119+
120+
const tab = state.tabs[0];
121+
assert.equal(shouldPersistWorkspaceView(tab), false);
122+
assert.equal(shouldPersistWorkspaceView({ ...tab, activeSessionId: "1" }), true);
123+
});
124+
125+
test("runtime state events refresh workspace view persistence baselines", () => {
126+
const initial = buildWorkbenchStateFromBootstrap(
127+
createDefaultWorkbenchState(),
128+
{
129+
ui_state: {
130+
open_workspace_ids: ["ws-1"],
131+
active_workspace_id: "ws-1",
132+
layout: {
133+
left_width: 320,
134+
right_width: 320,
135+
right_split: 64,
136+
show_code_panel: false,
137+
show_terminal_panel: false,
138+
},
139+
},
140+
workspaces: [createWorkspaceSnapshot("2")],
141+
},
142+
"en",
143+
APP_SETTINGS,
144+
);
145+
146+
const next = applyWorkspaceRuntimeStateEvent(initial, {
147+
workspace_id: "ws-1",
148+
view_state: {
149+
active_session_id: "1",
150+
active_pane_id: "pane-1",
151+
active_terminal_id: "",
152+
pane_layout: {
153+
type: "leaf",
154+
id: "pane-1",
155+
sessionId: "1",
156+
},
157+
file_preview: {
158+
path: "",
159+
content: "",
160+
mode: "preview",
161+
originalContent: "",
162+
modifiedContent: "",
163+
dirty: false,
164+
},
165+
},
166+
});
167+
168+
const tab = next.tabs[0];
169+
assert.equal(shouldPersistWorkspaceView(tab), false);
170+
assert.equal(shouldPersistWorkspaceView({ ...tab, activePaneId: "pane-2" }), true);
171+
});

0 commit comments

Comments
 (0)