Skip to content

Commit 6725d63

Browse files
committed
fix: harden last viewed target restore
1 parent 4ef0473 commit 6725d63

13 files changed

Lines changed: 594 additions & 86 deletions

File tree

packages/server/src/__tests__/workspace-commands.test.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,11 +257,67 @@ describe("Workspace Commands", () => {
257257
expect(readResult.ok).toBe(true);
258258
expect(readResult.data).toMatchObject({
259259
workspaceId,
260-
sessionId: undefined,
261260
});
261+
expect(readResult.data).not.toHaveProperty("sessionId");
262262
expect((readResult.data as { updatedAt: number }).updatedAt).toEqual(expect.any(Number));
263263
});
264264

265+
it("preserves a session id that belongs to the workspace", async () => {
266+
const dir = join(tmpdir(), `workspace-target-session-test-${Date.now()}`);
267+
await mkdir(dir);
268+
269+
const openResult = await dispatch(
270+
{
271+
kind: "command",
272+
id: "open-workspace-target-session",
273+
op: "workspace.open",
274+
args: { path: dir },
275+
},
276+
ctx
277+
);
278+
279+
expect(openResult.ok).toBe(true);
280+
const workspaceId = (openResult.data as { id: string }).id;
281+
ctx.sessionMgr.get = vi.fn((sessionId: string) =>
282+
sessionId === "sess-123" ? { id: "sess-123", workspaceId } : undefined
283+
) as never;
284+
285+
const writeResult = await dispatch(
286+
{
287+
kind: "command",
288+
id: "set-last-viewed-target-session",
289+
op: "workspace.lastViewedTarget.set",
290+
args: {
291+
workspaceId,
292+
sessionId: "sess-123",
293+
},
294+
},
295+
ctx
296+
);
297+
298+
expect(writeResult.ok).toBe(true);
299+
expect(writeResult.data).toMatchObject({
300+
workspaceId,
301+
sessionId: "sess-123",
302+
});
303+
304+
const readResult = await dispatch(
305+
{
306+
kind: "command",
307+
id: "get-last-viewed-target-session",
308+
op: "workspace.lastViewedTarget.get",
309+
args: {},
310+
},
311+
ctx
312+
);
313+
314+
expect(readResult.ok).toBe(true);
315+
expect(readResult.data).toMatchObject({
316+
workspaceId,
317+
sessionId: "sess-123",
318+
});
319+
});
320+
265321
it("drops an out-of-workspace session id while preserving the workspace target", async () => {
266322
const dir = join(tmpdir(), `workspace-target-mismatch-${Date.now()}`);
267323
await mkdir(dir);
@@ -315,5 +371,25 @@ describe("Workspace Commands", () => {
315371
expect(result.ok).toBe(false);
316372
expect(result.error?.code).toBe("workspace_not_found");
317373
});
374+
375+
it("returns null when the stored last-viewed target is malformed", async () => {
376+
db.prepare("INSERT INTO user_settings (key, value) VALUES (?, ?)").run(
377+
"workspace.lastViewedTarget",
378+
"{not-json"
379+
);
380+
381+
const result = await dispatch(
382+
{
383+
kind: "command",
384+
id: "get-last-viewed-target-malformed",
385+
op: "workspace.lastViewedTarget.get",
386+
args: {},
387+
},
388+
ctx
389+
);
390+
391+
expect(result.ok).toBe(true);
392+
expect(result.data).toBeNull();
393+
});
318394
});
319395
});

packages/server/src/commands/workspace-activity.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,21 @@ import { z } from "zod";
33
import { registerCommand } from "../ws/dispatch.js";
44

55
const WORKSPACE_LAST_VIEWED_TARGET_KEY = "workspace.lastViewedTarget";
6+
const workspaceLastViewedTargetSchema = z.object({
7+
workspaceId: z.string(),
8+
sessionId: z.string().optional(),
9+
updatedAt: z.number(),
10+
});
11+
12+
function parseWorkspaceLastViewedTarget(value: string): WorkspaceLastViewedTarget | null {
13+
try {
14+
const parsed = JSON.parse(value);
15+
const result = workspaceLastViewedTargetSchema.safeParse(parsed);
16+
return result.success ? result.data : null;
17+
} catch {
18+
return null;
19+
}
20+
}
621

722
registerCommand(
823
"workspace.activate",
@@ -37,12 +52,7 @@ registerCommand("workspace.lastViewedTarget.get", z.object({}), async (_args, ct
3752
return null;
3853
}
3954

40-
const target = JSON.parse(row.value) as WorkspaceLastViewedTarget;
41-
return {
42-
workspaceId: target.workspaceId,
43-
sessionId: target.sessionId,
44-
updatedAt: target.updatedAt,
45-
} satisfies WorkspaceLastViewedTarget;
55+
return parseWorkspaceLastViewedTarget(row.value);
4656
});
4757

4858
registerCommand(

packages/web/src/features/agent-panes/components/session-card.test.tsx

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
22
import { createStore, Provider } from "jotai";
33
import type { ReactNode } from "react";
44
import { beforeEach, describe, expect, it, vi } from "vitest";
5-
import { pendingFocusSessionAtom } from "../../../atoms/app-ui";
5+
import { lastViewedTargetAtom, pendingFocusSessionAtom } from "../../../atoms/app-ui";
66
import { wsClientAtom } from "../../../atoms/connection";
77
import { sessionsAtom } from "../../../atoms/sessions";
88
import {
@@ -137,6 +137,11 @@ describe("SessionCard", () => {
137137
},
138138
},
139139
});
140+
store.set(lastViewedTargetAtom, {
141+
workspaceId: "ws-123",
142+
sessionId: "sess_123456",
143+
updatedAt: 10,
144+
});
140145

141146
render(
142147
<Provider store={store}>
@@ -645,6 +650,48 @@ describe("SessionCard", () => {
645650
});
646651
});
647652

653+
it("does not persist the global last-viewed target again when the active session card is clicked", () => {
654+
const sendCommand = vi.fn().mockResolvedValue(undefined);
655+
const { store } = createSessionStore(
656+
{
657+
terminalId: "term-live",
658+
state: "running",
659+
endedAt: undefined,
660+
},
661+
sendCommand
662+
);
663+
664+
store.set(workspacesAtom, {
665+
"ws-123": {
666+
id: "ws-123",
667+
path: "/tmp/ws-123",
668+
targetRuntime: "native",
669+
openedAt: Date.now() - 10_000,
670+
lastActiveAt: Date.now(),
671+
uiState: {
672+
leftPanelWidth: 320,
673+
bottomPanelHeight: 240,
674+
focusMode: false,
675+
activeSessionId: "sess_123456",
676+
},
677+
},
678+
});
679+
680+
render(
681+
<Provider store={store}>
682+
<SessionCard sessionId="sess_123456" />
683+
</Provider>
684+
);
685+
686+
fireEvent.click(document.querySelector('[data-session-id="sess_123456"]')!);
687+
688+
expect(sendCommand).not.toHaveBeenCalledWith(
689+
"workspace.lastViewedTarget.set",
690+
expect.anything(),
691+
undefined
692+
);
693+
});
694+
648695
it("does not persist activeSessionId when header action buttons are clicked", async () => {
649696
const sendCommand = vi.fn().mockResolvedValue({ ok: true });
650697
const onClose = vi.fn();

packages/web/src/features/agent-panes/views/shared/session-card.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ export const SessionCard: FC<SessionCardProps> = ({
103103
return;
104104
}
105105

106+
if (workspace?.uiState.activeSessionId === session.id) {
107+
return;
108+
}
109+
106110
void persistLastViewedTarget({
107111
workspaceId: session.workspaceId,
108112
sessionId: session.id,

packages/web/src/features/topbar/components/tab.test.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Workspace } from "@coder-studio/core";
22
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
33
import { createStore, Provider } from "jotai";
44
import { beforeEach, describe, expect, it, vi } from "vitest";
5-
import { localeAtom } from "../../../atoms/app-ui";
5+
import { lastViewedTargetAtom, localeAtom } from "../../../atoms/app-ui";
66
import { wsClientAtom } from "../../../atoms/connection";
77
import {
88
activeWorkspaceIdAtom,
@@ -130,6 +130,30 @@ describe("WorkspaceTab", () => {
130130
});
131131
});
132132

133+
it("does not persist again when the active workspace tab is clicked", () => {
134+
const workspace = createWorkspace("ws-2", "/tmp/two");
135+
const sendCommand = vi.fn();
136+
const store = createStore();
137+
138+
store.set(localeAtom, "en");
139+
store.set(wsClientAtom, { sendCommand } as never);
140+
store.set(activeWorkspaceIdAtom, "ws-2");
141+
store.set(lastViewedTargetAtom, {
142+
workspaceId: "ws-2",
143+
updatedAt: 10,
144+
});
145+
146+
renderWorkspaceTab(store, workspace, { isActive: true, value: "ws-2" });
147+
148+
fireEvent.click(screen.getByRole("tab", { name: /two/i }));
149+
150+
expect(sendCommand).not.toHaveBeenCalledWith(
151+
"workspace.lastViewedTarget.set",
152+
expect.anything(),
153+
undefined
154+
);
155+
});
156+
133157
it("closes the active workspace without route navigation and falls back to the next ordered workspace", async () => {
134158
const firstWorkspace = createWorkspace("ws-1", "/tmp/one");
135159
const secondWorkspace = createWorkspace("ws-2", "/tmp/two");

packages/web/src/features/topbar/components/tab.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ export const WorkspaceTab: FC<WorkspaceTabProps> = ({ workspace, isActive }) =>
3838
const displayName = formatWorkspaceLabel(workspace) || workspace.id;
3939

4040
const handleClick = () => {
41+
if (isActive) {
42+
return;
43+
}
44+
4145
setActiveWorkspace(workspace.id);
4246
void persistLastViewedTarget({ workspaceId: workspace.id });
4347
};

packages/web/src/features/workspace/actions/use-persist-workspace-last-viewed-target.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ interface PersistWorkspaceLastViewedTargetInput {
1111

1212
export function usePersistWorkspaceLastViewedTarget() {
1313
const dispatch = useAtomValue(dispatchCommandAtom);
14+
const lastViewedTarget = useAtomValue(lastViewedTargetAtom);
1415
const setLastViewedTarget = useSetAtom(lastViewedTargetAtom);
1516

1617
return useCallback(
@@ -19,6 +20,13 @@ export function usePersistWorkspaceLastViewedTarget() {
1920
return null;
2021
}
2122

23+
if (
24+
lastViewedTarget?.workspaceId === workspaceId &&
25+
(lastViewedTarget.sessionId ?? undefined) === sessionId
26+
) {
27+
return lastViewedTarget;
28+
}
29+
2230
const optimisticTarget: WorkspaceLastViewedTarget = {
2331
workspaceId,
2432
sessionId,
@@ -38,6 +46,6 @@ export function usePersistWorkspaceLastViewedTarget() {
3846
setLastViewedTarget(result.data);
3947
return result.data;
4048
},
41-
[dispatch, setLastViewedTarget]
49+
[dispatch, lastViewedTarget, setLastViewedTarget]
4250
);
4351
}

0 commit comments

Comments
 (0)