Skip to content

Commit 7fb53b4

Browse files
committed
fix(web): persist explicit workspace target switches
1 parent fcb728e commit 7fb53b4

11 files changed

Lines changed: 327 additions & 30 deletions

File tree

packages/web/src/features/command-palette/components/command-palette.test.tsx

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import type { Workspace } from "@coder-studio/core";
2-
import { fireEvent, render, screen } from "@testing-library/react";
2+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
33
import { createStore, Provider } from "jotai";
44
import { beforeEach, describe, expect, it, vi } from "vitest";
55
import { commandPaletteOpenAtom, localeAtom } from "../../../atoms/app-ui";
6+
import { wsClientAtom } from "../../../atoms/connection";
67
import {
78
activeWorkspaceIdAtom,
89
workspaceOrderAtom,
@@ -89,6 +90,39 @@ describe("CommandPalette", () => {
8990
expect(routerMocks.navigate).toHaveBeenCalledWith("/workspace");
9091
});
9192

93+
it("persists the global last-viewed target when switching workspaces", async () => {
94+
const sendCommand = vi.fn().mockResolvedValue({
95+
workspaceId: "ws-2",
96+
updatedAt: 10,
97+
});
98+
const store = createStore();
99+
store.set(localeAtom, "en");
100+
store.set(commandPaletteOpenAtom, true);
101+
store.set(wsClientAtom, { sendCommand } as never);
102+
store.set(workspacesAtom, {
103+
"ws-1": createWorkspace("ws-1", "/tmp/one"),
104+
"ws-2": createWorkspace("ws-2", "/tmp/two"),
105+
});
106+
store.set(workspaceOrderAtom, ["ws-2", "ws-1"]);
107+
store.set(workspacesLoadStateAtom, "ready");
108+
109+
render(
110+
<Provider store={store}>
111+
<CommandPalette />
112+
</Provider>
113+
);
114+
115+
fireEvent.click(screen.getByText("Workspace: two"));
116+
117+
await waitFor(() => {
118+
expect(sendCommand).toHaveBeenCalledWith(
119+
"workspace.lastViewedTarget.set",
120+
{ workspaceId: "ws-2", sessionId: undefined },
121+
undefined
122+
);
123+
});
124+
});
125+
92126
it("renders inside shared Sheet on mobile and still filters commands", () => {
93127
viewportMocks.viewport = "mobile";
94128

packages/web/src/features/command-palette/components/command-palette.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { EmptyState, Sheet, ThemedIcon } from "../../../components/ui";
1818
import { useViewport } from "../../../hooks/use-viewport";
1919
import { useTranslation } from "../../../lib/i18n";
2020
import { formatWorkspaceLabel } from "../../notifications/format";
21+
import { useSelectWorkspaceTarget } from "../../workspace/actions/use-select-workspace-target";
2122
import {
2223
bottomPanelHeightAtom,
2324
focusModeAtom,
@@ -70,6 +71,7 @@ export function CommandPalette() {
7071
const [bottomPanelHeight, setBottomPanelHeight] = useAtom(bottomPanelHeightAtom);
7172
const activeWorkspaceId = useAtomValue(resolvedActiveWorkspaceIdAtom);
7273
const setActiveWorkspaceId = useSetAtom(activeWorkspaceIdAtom);
74+
const selectWorkspaceTarget = useSelectWorkspaceTarget();
7375
const workspaces = useAtomValue(orderedWorkspacesAtom);
7476

7577
const [searchQuery, setSearchQuery] = useState("");
@@ -90,6 +92,7 @@ export function CommandPalette() {
9092
setBottomPanelHeight,
9193
activeWorkspaceId,
9294
setActiveWorkspaceId,
95+
selectWorkspaceTarget,
9396
workspaces,
9497
locationPathname: location.pathname,
9598
navigate,
@@ -286,6 +289,7 @@ function buildCommands(context: {
286289
setBottomPanelHeight: (v: number) => void;
287290
activeWorkspaceId: string | null;
288291
setActiveWorkspaceId: (v: string | null) => void;
292+
selectWorkspaceTarget: (workspaceId: string) => Promise<unknown>;
289293
workspaces: Workspace[];
290294
locationPathname: string;
291295
navigate: (path: string) => void;
@@ -304,6 +308,7 @@ function buildCommands(context: {
304308
setBottomPanelHeight,
305309
activeWorkspaceId,
306310
setActiveWorkspaceId,
311+
selectWorkspaceTarget,
307312
workspaces,
308313
locationPathname,
309314
navigate,
@@ -405,7 +410,7 @@ function buildCommands(context: {
405410
label: `${t("workspace.title")}: ${workspaceLabel}`,
406411
description: ws.path || ws.id,
407412
action: () => {
408-
setActiveWorkspaceId(ws.id);
413+
void selectWorkspaceTarget(ws.id);
409414
if (locationPathname !== "/workspace") {
410415
navigate("/workspace");
411416
}

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
workspacesAtom,
1111
} from "../../../atoms/workspaces";
1212
import { TabList, Tabs } from "../../../components/ui";
13+
import { CommandResultError } from "../../../ws/client";
1314
import { WorkspaceTab } from "./tab";
1415

1516
const routerMocks = vi.hoisted(() => ({
@@ -154,6 +155,60 @@ describe("WorkspaceTab", () => {
154155
);
155156
});
156157

158+
it("retries persistence for the same workspace after a failed write", async () => {
159+
const workspace = createWorkspace("ws-2", "/tmp/two");
160+
const sendCommand = vi
161+
.fn()
162+
.mockRejectedValueOnce(
163+
new CommandResultError({
164+
code: "write_failed",
165+
message: "failed",
166+
})
167+
)
168+
.mockResolvedValueOnce({
169+
workspaceId: "ws-2",
170+
updatedAt: 11,
171+
});
172+
const store = createStore();
173+
174+
store.set(localeAtom, "en");
175+
store.set(wsClientAtom, { sendCommand } as never);
176+
177+
const { rerender } = renderWorkspaceTab(store, workspace, { value: "ws-1" });
178+
179+
fireEvent.click(screen.getByRole("tab", { name: /two/i }));
180+
181+
await waitFor(() => {
182+
expect(sendCommand).toHaveBeenNthCalledWith(
183+
1,
184+
"workspace.lastViewedTarget.set",
185+
{ workspaceId: "ws-2", sessionId: undefined },
186+
undefined
187+
);
188+
});
189+
190+
rerender(
191+
<Provider store={store}>
192+
<Tabs aria-label="Workspaces" onValueChange={vi.fn()} value="ws-1">
193+
<TabList className="topbar-tablist">
194+
<WorkspaceTab workspace={workspace} isActive={false} />
195+
</TabList>
196+
</Tabs>
197+
</Provider>
198+
);
199+
200+
fireEvent.click(screen.getByRole("tab", { name: /two/i }));
201+
202+
await waitFor(() => {
203+
expect(sendCommand).toHaveBeenNthCalledWith(
204+
2,
205+
"workspace.lastViewedTarget.set",
206+
{ workspaceId: "ws-2", sessionId: undefined },
207+
undefined
208+
);
209+
});
210+
});
211+
157212
it("closes the active workspace without route navigation and falls back to the next ordered workspace", async () => {
158213
const firstWorkspace = createWorkspace("ws-1", "/tmp/one");
159214
const secondWorkspace = createWorkspace("ws-2", "/tmp/two");

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

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,12 @@
66
*/
77

88
import type { Workspace } from "@coder-studio/core";
9-
import { useSetAtom } from "jotai";
109
import { X } from "lucide-react";
1110
import type { FC } from "react";
12-
import { activeWorkspaceIdAtom } from "../../../atoms/workspaces";
1311
import { Badge, IconButton, Tab, Tooltip } from "../../../components/ui";
1412
import { useTranslation } from "../../../lib/i18n";
1513
import { formatWorkspaceLabel } from "../../notifications/format";
16-
import { usePersistWorkspaceLastViewedTarget } from "../../workspace/actions/use-persist-workspace-last-viewed-target";
14+
import { useSelectWorkspaceTarget } from "../../workspace/actions/use-select-workspace-target";
1715
import { useWorkspaceCloseAction } from "../../workspace/actions/use-workspace-close-action";
1816

1917
interface WorkspaceTabProps {
@@ -32,18 +30,16 @@ interface WorkspaceTabProps {
3230
*/
3331
export const WorkspaceTab: FC<WorkspaceTabProps> = ({ workspace, isActive }) => {
3432
const t = useTranslation();
35-
const setActiveWorkspace = useSetAtom(activeWorkspaceIdAtom);
3633
const closeWorkspace = useWorkspaceCloseAction();
37-
const persistLastViewedTarget = usePersistWorkspaceLastViewedTarget();
34+
const selectWorkspaceTarget = useSelectWorkspaceTarget();
3835
const displayName = formatWorkspaceLabel(workspace) || workspace.id;
3936

4037
const handleClick = () => {
4138
if (isActive) {
4239
return;
4340
}
4441

45-
setActiveWorkspace(workspace.id);
46-
void persistLastViewedTarget({ workspaceId: workspace.id });
42+
void selectWorkspaceTarget(workspace.id);
4743
};
4844

4945
const handleClose = async (e: React.MouseEvent) => {

packages/web/src/features/topbar/index.test.tsx

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import type { Workspace } from "@coder-studio/core";
2-
import { fireEvent, render, screen } from "@testing-library/react";
2+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
33
import { createStore, Provider } from "jotai";
44
import { beforeEach, describe, expect, it, vi } from "vitest";
55
import { localeAtom } from "../../atoms/app-ui";
6+
import { wsClientAtom } from "../../atoms/connection";
67
import {
8+
activeWorkspaceIdAtom,
79
workspaceOrderAtom,
810
workspacesAtom,
911
workspacesLoadStateAtom,
@@ -39,14 +41,6 @@ vi.mock("../workspace/views/shared/workspace-launch-modal", () => ({
3941
WorkspaceLaunchModal: () => null,
4042
}));
4143

42-
vi.mock("./components/tab", () => ({
43-
WorkspaceTab: ({ workspace, isActive }: { workspace: Workspace; isActive: boolean }) => (
44-
<div data-testid="workspace-tab" data-active={String(isActive)}>
45-
{workspace.id}
46-
</div>
47-
),
48-
}));
49-
5044
function createWorkspace(id: string, path: string): Workspace {
5145
return {
5246
id,
@@ -84,14 +78,50 @@ describe("TopBar", () => {
8478
</Provider>
8579
);
8680

87-
const tabs = screen.getAllByTestId("workspace-tab");
81+
const tabs = screen.getAllByRole("tab");
8882

89-
expect(tabs.map((tab) => tab.textContent)).toEqual(["ws-b", "ws-a"]);
90-
expect(tabs[0]?.getAttribute("data-active")).toBe("true");
91-
expect(tabs[1]?.getAttribute("data-active")).toBe("false");
83+
expect(tabs.map((tab) => tab.textContent)).toEqual(["b", "a"]);
84+
expect(tabs[0]).toHaveAttribute("aria-selected", "true");
85+
expect(tabs[1]).toHaveAttribute("aria-selected", "false");
9286
expect(screen.getByRole("tablist", { name: "Workspace tabs" })).toBeInTheDocument();
9387
});
9488

89+
it("persists the global last-viewed target when keyboard navigation changes workspace tabs", async () => {
90+
const store = createStore();
91+
const sendCommand = vi.fn().mockResolvedValue({
92+
workspaceId: "ws-b",
93+
updatedAt: 10,
94+
});
95+
96+
store.set(localeAtom, "en");
97+
store.set(wsClientAtom, { sendCommand } as never);
98+
store.set(workspacesAtom, {
99+
"ws-a": createWorkspace("ws-a", "/tmp/a"),
100+
"ws-b": createWorkspace("ws-b", "/tmp/b"),
101+
});
102+
store.set(workspaceOrderAtom, ["ws-a", "ws-b"]);
103+
store.set(workspacesLoadStateAtom, "ready");
104+
store.set(activeWorkspaceIdAtom, "ws-a");
105+
106+
render(
107+
<Provider store={store}>
108+
<TopBar />
109+
</Provider>
110+
);
111+
112+
const activeTab = screen.getByRole("tab", { name: "a" });
113+
activeTab.focus();
114+
fireEvent.keyDown(activeTab, { key: "ArrowRight" });
115+
116+
await waitFor(() => {
117+
expect(sendCommand).toHaveBeenCalledWith(
118+
"workspace.lastViewedTarget.set",
119+
{ workspaceId: "ws-b", sessionId: undefined },
120+
undefined
121+
);
122+
});
123+
});
124+
95125
it("uses translated labels when locale is set to en", () => {
96126
const store = createStore();
97127
store.set(localeAtom, "en");

packages/web/src/features/topbar/index.tsx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,15 @@
44
* Main navigation bar with workspace tabs, quick actions, and settings.
55
*/
66

7-
import { useAtom, useAtomValue, useSetAtom } from "jotai";
7+
import { useAtom, useAtomValue } from "jotai";
88
import type { FC } from "react";
99
import { useState } from "react";
1010
import { useNavigate } from "react-router-dom";
1111
import { commandPaletteOpenAtom } from "../../atoms/app-ui";
12-
import {
13-
activeWorkspaceIdAtom,
14-
orderedWorkspacesAtom,
15-
resolvedActiveWorkspaceIdAtom,
16-
} from "../../atoms/workspaces";
12+
import { orderedWorkspacesAtom, resolvedActiveWorkspaceIdAtom } from "../../atoms/workspaces";
1713
import { EmptyState, IconButton, TabList, Tabs, ThemedIcon, Tooltip } from "../../components/ui";
1814
import { useTranslation } from "../../lib/i18n";
15+
import { useSelectWorkspaceTarget } from "../workspace/actions/use-select-workspace-target";
1916
import type { WorkspaceFullscreenController } from "../workspace/actions/use-workspace-fullscreen";
2017
import { sidebarCollapsedAtom, terminalPanelVisibleAtom } from "../workspace/atoms";
2118
import { WorkspaceFullscreenButton } from "../workspace/components/workspace-fullscreen-button";
@@ -54,7 +51,7 @@ export const TopBar: FC<TopBarProps> = ({ fullscreenController }) => {
5451
const workspaceList = useAtomValue(orderedWorkspacesAtom);
5552
const activeWorkspaceId = useAtomValue(resolvedActiveWorkspaceIdAtom);
5653
const selectedWorkspaceId = activeWorkspaceId ?? workspaceList[0]?.id ?? "";
57-
const setActiveWorkspace = useSetAtom(activeWorkspaceIdAtom);
54+
const selectWorkspaceTarget = useSelectWorkspaceTarget();
5855
const [commandPaletteOpen, setCommandPaletteOpen] = useAtom(commandPaletteOpenAtom);
5956
const [terminalPanelVisible, setTerminalPanelVisible] = useAtom(terminalPanelVisibleAtom);
6057
const [sidebarCollapsed, setSidebarCollapsed] = useAtom(sidebarCollapsedAtom);
@@ -77,7 +74,9 @@ export const TopBar: FC<TopBarProps> = ({ fullscreenController }) => {
7774
<Tabs
7875
aria-label={t("workspace.tabs")}
7976
className="topbar-tabs-nav"
80-
onValueChange={setActiveWorkspace}
77+
onValueChange={(workspaceId) => {
78+
void selectWorkspaceTarget(workspaceId);
79+
}}
8180
value={selectedWorkspaceId}
8281
>
8382
<TabList className="topbar-tablist">
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { act, renderHook } from "@testing-library/react";
2+
import { createStore, Provider } from "jotai";
3+
import type { ReactNode } from "react";
4+
import { describe, expect, it, vi } from "vitest";
5+
import { lastViewedTargetAtom } from "../../../atoms/app-ui";
6+
import { wsClientAtom } from "../../../atoms/connection";
7+
import { CommandResultError } from "../../../ws/client";
8+
import { usePersistWorkspaceLastViewedTarget } from "./use-persist-workspace-last-viewed-target";
9+
10+
function wrapperFor(store: ReturnType<typeof createStore>) {
11+
return function Wrapper({ children }: { children: ReactNode }) {
12+
return <Provider store={store}>{children}</Provider>;
13+
};
14+
}
15+
16+
describe("usePersistWorkspaceLastViewedTarget", () => {
17+
it("does not suppress a retry for the same target after a failed write", async () => {
18+
const store = createStore();
19+
const sendCommand = vi
20+
.fn()
21+
.mockRejectedValueOnce(
22+
new CommandResultError({
23+
code: "write_failed",
24+
message: "failed",
25+
})
26+
)
27+
.mockResolvedValueOnce({
28+
workspaceId: "ws-2",
29+
updatedAt: 11,
30+
});
31+
32+
store.set(wsClientAtom, {
33+
sendCommand,
34+
subscribe: vi.fn(() => () => {}),
35+
} as never);
36+
store.set(lastViewedTargetAtom, null);
37+
38+
const { result } = renderHook(() => usePersistWorkspaceLastViewedTarget(), {
39+
wrapper: wrapperFor(store),
40+
});
41+
42+
await act(async () => {
43+
await result.current({ workspaceId: "ws-2" });
44+
});
45+
46+
await act(async () => {
47+
await result.current({ workspaceId: "ws-2" });
48+
});
49+
50+
expect(sendCommand).toHaveBeenNthCalledWith(
51+
1,
52+
"workspace.lastViewedTarget.set",
53+
{ workspaceId: "ws-2", sessionId: undefined },
54+
undefined
55+
);
56+
expect(sendCommand).toHaveBeenNthCalledWith(
57+
2,
58+
"workspace.lastViewedTarget.set",
59+
{ workspaceId: "ws-2", sessionId: undefined },
60+
undefined
61+
);
62+
});
63+
});

0 commit comments

Comments
 (0)