Skip to content

Commit b90f2b0

Browse files
committed
feat: persist last viewed workspace target
1 parent 5b9d378 commit b90f2b0

27 files changed

Lines changed: 812 additions & 31 deletions

e2e/fixtures/seed-hydrate-refresh-db.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,15 @@ try {
134134
"Unavailable"
135135
);
136136

137+
db.prepare("INSERT INTO user_settings (key, value) VALUES (?, ?)").run(
138+
"workspace.lastViewedTarget",
139+
JSON.stringify({
140+
workspaceId: WORKSPACE_ID,
141+
sessionId: INTERRUPTED_SESSION_ID,
142+
updatedAt: now,
143+
})
144+
);
145+
137146
console.log(
138147
JSON.stringify({
139148
dbPath,

e2e/specs/sessions/hydrate-refresh.spec.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -276,8 +276,8 @@ test.describe("session hydrate refresh acceptance", () => {
276276

277277
const visibleCard = page.locator(".mobile-shell .session-card.agent-pane").first();
278278
await expect(visibleCard).toBeVisible();
279-
await expect(visibleCard).toHaveAttribute("data-session-id", UNAVAILABLE_SESSION_ID);
280-
await expect(visibleCard.locator(".session-title")).toHaveText("Unavailable");
279+
await expect(visibleCard).toHaveAttribute("data-session-id", INTERRUPTED_SESSION_ID);
280+
await expect(visibleCard.locator(".session-title")).toHaveText("Resume me");
281281
await expect(visibleCard.locator(".session-state-badge")).toHaveText(ENDED_STATE_LABEL);
282282
await expect(visibleCard.getByRole("button", { name: "Expand terminal keys" })).toHaveCount(
283283
0
@@ -290,8 +290,8 @@ test.describe("session hydrate refresh acceptance", () => {
290290
});
291291
await expect(page.getByTestId("mobile-shell")).toBeVisible({ timeout: 20000 });
292292
await expect(visibleCard).toBeVisible();
293-
await expect(visibleCard).toHaveAttribute("data-session-id", UNAVAILABLE_SESSION_ID);
294-
await expect(visibleCard.locator(".session-title")).toHaveText("Unavailable");
293+
await expect(visibleCard).toHaveAttribute("data-session-id", INTERRUPTED_SESSION_ID);
294+
await expect(visibleCard.locator(".session-title")).toHaveText("Resume me");
295295
await expect(visibleCard.locator(".session-state-badge")).toHaveText(ENDED_STATE_LABEL);
296296
await expect(visibleCard.getByRole("button", { name: "Expand terminal keys" })).toHaveCount(
297297
0
@@ -301,10 +301,35 @@ test.describe("session hydrate refresh acceptance", () => {
301301
const agentSheet = page.getByRole("region", { name: "Agent Sessions sheet" });
302302
await expect(agentSheet).toBeVisible();
303303
await expect(agentSheet.getByRole("button", { name: "Resume me" })).toBeVisible();
304-
await expect(agentSheet.getByRole("button", { name: "Unavailable" })).toHaveAttribute(
304+
await expect(agentSheet.getByRole("button", { name: "Resume me" })).toHaveAttribute(
305305
"aria-pressed",
306306
"true"
307307
);
308+
309+
const secondContext = await browser.newContext({
310+
viewport: { width: 430, height: 932 },
311+
});
312+
313+
try {
314+
const secondPage = await secondContext.newPage();
315+
await secondPage.addInitScript(() => {
316+
window.localStorage.setItem("ui.locale", JSON.stringify("en"));
317+
});
318+
319+
await secondPage.goto(`${BASE_URL}/workspace`);
320+
await expect(secondPage.getByTestId("workspace-resolving-shell")).toHaveCount(0, {
321+
timeout: 20000,
322+
});
323+
await expect(secondPage.getByTestId("mobile-shell")).toBeVisible({ timeout: 20000 });
324+
325+
const secondVisibleCard = secondPage
326+
.locator(".mobile-shell .session-card.agent-pane")
327+
.first();
328+
await expect(secondVisibleCard).toHaveAttribute("data-session-id", INTERRUPTED_SESSION_ID);
329+
await expect(secondVisibleCard.locator(".session-title")).toHaveText("Resume me");
330+
} finally {
331+
await secondContext.close();
332+
}
308333
} finally {
309334
await context.close();
310335
}

e2e/specs/workspace/route-history.spec.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,9 @@ test.describe("workspace route history acceptance", () => {
145145
baseURL: BASE_URL,
146146
});
147147

148-
test("switching workspaces keeps the URL stable and does not create history entries", async ({
148+
test("switching workspaces keeps the URL stable and restores across refresh and a new browser context", async ({
149149
page,
150+
browser,
150151
}) => {
151152
await page.goto("/");
152153
await expect(page.locator(".topbar-tab")).toHaveCount(2, { timeout: 20000 });
@@ -163,10 +164,32 @@ test.describe("workspace route history acceptance", () => {
163164

164165
await expect(page.locator(".topbar-tab.active")).toContainText("older-workspace");
165166
await expect(page).toHaveURL(`${BASE_URL}/workspace`);
167+
await page.waitForTimeout(300);
166168

167169
const historyLengthAfterSwitch = await page.evaluate(() => window.history.length);
168170
expect(historyLengthAfterSwitch).toBe(historyLengthBeforeSwitch);
169171

172+
await page.reload();
173+
174+
await expect(page.getByTestId("workspace-resolving-shell")).toHaveCount(0, { timeout: 20000 });
175+
await expect(page.locator(".topbar-tab")).toHaveCount(2, { timeout: 20000 });
176+
await expect(page.locator(".topbar-tab.active")).toContainText("older-workspace");
177+
await expect(page).toHaveURL(`${BASE_URL}/workspace`);
178+
179+
const secondContext = await browser.newContext();
180+
try {
181+
const secondPage = await secondContext.newPage();
182+
await secondPage.goto(`${BASE_URL}/workspace`);
183+
await expect(secondPage.getByTestId("workspace-resolving-shell")).toHaveCount(0, {
184+
timeout: 20000,
185+
});
186+
await expect(secondPage.locator(".topbar-tab")).toHaveCount(2, { timeout: 20000 });
187+
await expect(secondPage.locator(".topbar-tab.active")).toContainText("older-workspace");
188+
await expect(secondPage).toHaveURL(`${BASE_URL}/workspace`);
189+
} finally {
190+
await secondContext.close();
191+
}
192+
170193
await page.goBack();
171194

172195
await expect(page).toHaveURL("about:blank");

packages/core/src/domain/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ export interface UiState {
3838
paneLayout?: WorkspacePaneNode;
3939
}
4040

41+
export interface WorkspaceLastViewedTarget {
42+
workspaceId: string;
43+
sessionId?: string;
44+
updatedAt: number;
45+
}
46+
4147
export interface Terminal {
4248
id: string;
4349
workspaceId: string;

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

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { dispatch } from "../ws/dispatch.js";
1010

1111
// Import command handlers to register them
1212
import "../commands/workspace.js";
13+
import "../commands/workspace-activity.js";
1314

1415
describe("Workspace Commands", () => {
1516
let db: ReturnType<typeof openDatabase>;
@@ -46,7 +47,9 @@ describe("Workspace Commands", () => {
4647
ctx = {
4748
db,
4849
workspaceMgr,
49-
sessionMgr: {},
50+
sessionMgr: {
51+
get: vi.fn(() => undefined),
52+
},
5053
terminalMgr: {},
5154
eventBus,
5255
broadcaster: { broadcast: () => {} },
@@ -202,4 +205,115 @@ describe("Workspace Commands", () => {
202205
});
203206
});
204207
});
208+
209+
describe("workspace.lastViewedTarget", () => {
210+
it("persists and returns the global workspace last-viewed target", async () => {
211+
const dir = join(tmpdir(), `workspace-target-test-${Date.now()}`);
212+
await mkdir(dir);
213+
214+
const openResult = await dispatch(
215+
{
216+
kind: "command",
217+
id: "open-workspace-target",
218+
op: "workspace.open",
219+
args: { path: dir },
220+
},
221+
ctx
222+
);
223+
224+
expect(openResult.ok).toBe(true);
225+
const workspaceId = (openResult.data as { id: string }).id;
226+
227+
const writeResult = await dispatch(
228+
{
229+
kind: "command",
230+
id: "set-last-viewed-target",
231+
op: "workspace.lastViewedTarget.set",
232+
args: {
233+
workspaceId,
234+
sessionId: "sess-123",
235+
},
236+
},
237+
ctx
238+
);
239+
240+
expect(writeResult.ok).toBe(true);
241+
expect(writeResult.data).toMatchObject({
242+
workspaceId,
243+
sessionId: undefined,
244+
});
245+
expect((writeResult.data as { updatedAt: number }).updatedAt).toEqual(expect.any(Number));
246+
247+
const readResult = await dispatch(
248+
{
249+
kind: "command",
250+
id: "get-last-viewed-target",
251+
op: "workspace.lastViewedTarget.get",
252+
args: {},
253+
},
254+
ctx
255+
);
256+
257+
expect(readResult.ok).toBe(true);
258+
expect(readResult.data).toMatchObject({
259+
workspaceId,
260+
sessionId: undefined,
261+
});
262+
expect((readResult.data as { updatedAt: number }).updatedAt).toEqual(expect.any(Number));
263+
});
264+
265+
it("drops an out-of-workspace session id while preserving the workspace target", async () => {
266+
const dir = join(tmpdir(), `workspace-target-mismatch-${Date.now()}`);
267+
await mkdir(dir);
268+
269+
const openResult = await dispatch(
270+
{
271+
kind: "command",
272+
id: "open-workspace-target-mismatch",
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+
282+
const result = await dispatch(
283+
{
284+
kind: "command",
285+
id: "set-last-viewed-target-mismatch",
286+
op: "workspace.lastViewedTarget.set",
287+
args: {
288+
workspaceId,
289+
sessionId: "sess-missing",
290+
},
291+
},
292+
ctx
293+
);
294+
295+
expect(result.ok).toBe(true);
296+
expect(result.data).toMatchObject({
297+
workspaceId,
298+
sessionId: undefined,
299+
});
300+
});
301+
302+
it("returns workspace_not_found when writing a target for a missing workspace", async () => {
303+
const result = await dispatch(
304+
{
305+
kind: "command",
306+
id: "set-last-viewed-target-missing-workspace",
307+
op: "workspace.lastViewedTarget.set",
308+
args: {
309+
workspaceId: "ws-missing",
310+
},
311+
},
312+
ctx
313+
);
314+
315+
expect(result.ok).toBe(false);
316+
expect(result.error?.code).toBe("workspace_not_found");
317+
});
318+
});
205319
});

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

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import type { WorkspaceLastViewedTarget } from "@coder-studio/core";
12
import { z } from "zod";
23
import { registerCommand } from "../ws/dispatch.js";
34

5+
const WORKSPACE_LAST_VIEWED_TARGET_KEY = "workspace.lastViewedTarget";
6+
47
registerCommand(
58
"workspace.activate",
69
z.object({
@@ -24,3 +27,57 @@ registerCommand("workspace.deactivate", z.object({}), async (_args, ctx, clientI
2427
ctx.autoFetch.unregisterViewer(clientId);
2528
return {};
2629
});
30+
31+
registerCommand("workspace.lastViewedTarget.get", z.object({}), async (_args, ctx) => {
32+
const row = ctx.db
33+
.prepare("SELECT value FROM user_settings WHERE key = ?")
34+
.get(WORKSPACE_LAST_VIEWED_TARGET_KEY) as { value: string } | undefined;
35+
36+
if (!row) {
37+
return null;
38+
}
39+
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;
46+
});
47+
48+
registerCommand(
49+
"workspace.lastViewedTarget.set",
50+
z.object({
51+
workspaceId: z.string(),
52+
sessionId: z.string().optional(),
53+
}),
54+
async (args, ctx) => {
55+
const workspace = ctx.workspaceMgr.get(args.workspaceId);
56+
if (!workspace) {
57+
throw {
58+
code: "workspace_not_found",
59+
message: `Workspace not found: ${args.workspaceId}`,
60+
};
61+
}
62+
63+
const session = args.sessionId ? ctx.sessionMgr.get(args.sessionId) : undefined;
64+
65+
const nextTarget: WorkspaceLastViewedTarget = {
66+
workspaceId: args.workspaceId,
67+
sessionId: session && session.workspaceId === args.workspaceId ? session.id : undefined,
68+
updatedAt: Date.now(),
69+
};
70+
71+
ctx.db
72+
.prepare(
73+
`
74+
INSERT INTO user_settings (key, value)
75+
VALUES (?, ?)
76+
ON CONFLICT(key) DO UPDATE SET value = excluded.value
77+
`
78+
)
79+
.run(WORKSPACE_LAST_VIEWED_TARGET_KEY, JSON.stringify(nextTarget));
80+
81+
return nextTarget;
82+
}
83+
);

packages/web/src/atoms/app-ui.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* Shared app-level UI state that is not owned by a single feature.
55
*/
66

7+
import type { WorkspaceLastViewedTarget } from "@coder-studio/core";
78
import { atom } from "jotai";
89
import { atomWithStorage, createJSONStorage } from "jotai/utils";
910
import { resolveStoredThemeId } from "../theme";
@@ -79,6 +80,15 @@ export const commandPaletteOpenAtom = atom<boolean>(false);
7980
*/
8081
export const pendingFocusSessionAtom = atom<string | null>(null);
8182

83+
/**
84+
* Server-hydrated global last-viewed workspace/session target.
85+
*
86+
* This mirrors the server-backed cross-device target and is not persisted
87+
* locally. Desktop restores only `workspaceId`; mobile can additionally use
88+
* `sessionId` during session selection.
89+
*/
90+
export const lastViewedTargetAtom = atom<WorkspaceLastViewedTarget | null>(null);
91+
8292
/**
8393
* Currently visible session in the mobile workspace shell.
8494
*

0 commit comments

Comments
 (0)