Skip to content

Commit 370518f

Browse files
committed
fix(web): resolve workspace read-side fallbacks
1 parent c58c538 commit 370518f

4 files changed

Lines changed: 178 additions & 5 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import type { Session, Workspace } from "@coder-studio/core";
2+
import { createStore } from "jotai";
3+
import { describe, expect, it } from "vitest";
4+
import { activeSessionAtom, sessionsAtom } from "./sessions";
5+
import {
6+
activeWorkspaceIdAtom,
7+
workspaceOrderAtom,
8+
workspacesAtom,
9+
workspacesLoadStateAtom,
10+
} from "./workspaces";
11+
12+
function createWorkspace(id: string): Workspace {
13+
return {
14+
id,
15+
name: id,
16+
path: `/tmp/${id}`,
17+
targetRuntime: "native",
18+
openedAt: 1,
19+
lastActiveAt: 1,
20+
uiState: {
21+
leftPanelWidth: 280,
22+
bottomPanelHeight: 200,
23+
focusMode: false,
24+
},
25+
};
26+
}
27+
28+
function createSession(id: string, workspaceId: string, state: Session["state"]): Session {
29+
return {
30+
id,
31+
workspaceId,
32+
terminalId: `${id}-terminal`,
33+
providerId: "codex",
34+
state,
35+
capability: "full",
36+
startedAt: 1,
37+
lastActiveAt: 1,
38+
};
39+
}
40+
41+
describe("activeSessionAtom", () => {
42+
it("falls back to the first ordered ready workspace when the requested workspace is missing", () => {
43+
const store = createStore();
44+
const ws1 = createWorkspace("ws-1");
45+
const ws2 = createWorkspace("ws-2");
46+
const runningSession = createSession("sess-2", ws2.id, "running");
47+
48+
store.set(workspacesAtom, {
49+
[ws1.id]: ws1,
50+
[ws2.id]: ws2,
51+
});
52+
store.set(workspaceOrderAtom, [ws2.id, ws1.id]);
53+
store.set(workspacesLoadStateAtom, "ready");
54+
store.set(activeWorkspaceIdAtom, "missing");
55+
store.set(sessionsAtom, {
56+
[runningSession.id]: runningSession,
57+
});
58+
59+
expect(store.get(activeSessionAtom)?.id).toBe(runningSession.id);
60+
});
61+
62+
it("returns null before the workspace list is ready", () => {
63+
const store = createStore();
64+
const ws1 = createWorkspace("ws-1");
65+
const runningSession = createSession("sess-1", ws1.id, "running");
66+
67+
store.set(workspacesAtom, {
68+
[ws1.id]: ws1,
69+
});
70+
store.set(workspaceOrderAtom, [ws1.id]);
71+
store.set(workspacesLoadStateAtom, "loading");
72+
store.set(activeWorkspaceIdAtom, ws1.id);
73+
store.set(sessionsAtom, {
74+
[runningSession.id]: runningSession,
75+
});
76+
77+
expect(store.get(activeSessionAtom)).toBeNull();
78+
});
79+
});

packages/web/src/atoms/sessions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import type { Session, SessionState } from "@coder-studio/core";
88
import { atom } from "jotai";
99
import { atomFamily } from "jotai-family";
10-
import { activeWorkspaceIdAtom } from "./workspaces";
10+
import { resolvedActiveWorkspaceIdAtom } from "./workspaces";
1111

1212
/**
1313
* All sessions (server state projection)
@@ -40,7 +40,7 @@ export function setActiveWorkspaceIdGetter(_getter: () => string | null): void {
4040
}
4141

4242
export const activeSessionAtom = atom((get) => {
43-
const wsId = get(activeWorkspaceIdAtom);
43+
const wsId = get(resolvedActiveWorkspaceIdAtom);
4444
if (!wsId) return null;
4545
const sessions = get(sessionsByWorkspaceAtomFamily(wsId));
4646
return sessions.find((s) => s.state === "running" || s.state === "idle") ?? null;

packages/web/src/features/workspace/views/shared/worktree-modal.test.tsx

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import { createStore, Provider } from "jotai";
44
import { afterEach, describe, expect, it, vi } from "vitest";
55
import { localeAtom } from "../../../../atoms/app-ui";
66
import { wsClientAtom } from "../../../../atoms/connection";
7-
import { activeWorkspaceIdAtom } from "../../../../atoms/workspaces";
7+
import {
8+
activeWorkspaceIdAtom,
9+
workspaceOrderAtom,
10+
workspacesAtom,
11+
workspacesLoadStateAtom,
12+
} from "../../../../atoms/workspaces";
813
import { WorktreeModal } from "./worktree-modal";
914

1015
const viewportMocks = vi.hoisted(() => ({
@@ -23,6 +28,22 @@ const worktree: WorktreeInfo = {
2328
status: "dirty",
2429
};
2530

31+
function createWorkspace(id: string) {
32+
return {
33+
id,
34+
name: id,
35+
path: `/tmp/${id}`,
36+
targetRuntime: "native" as const,
37+
openedAt: 1,
38+
lastActiveAt: 1,
39+
uiState: {
40+
leftPanelWidth: 280,
41+
bottomPanelHeight: 200,
42+
focusMode: false,
43+
},
44+
};
45+
}
46+
2647
describe("WorktreeModal", () => {
2748
afterEach(() => {
2849
viewportMocks.viewport = "desktop";
@@ -92,8 +113,14 @@ describe("WorktreeModal", () => {
92113
});
93114

94115
const store = createStore();
116+
const workspace = createWorkspace("ws-1");
95117
store.set(localeAtom, "en");
96118
store.set(activeWorkspaceIdAtom, "ws-1");
119+
store.set(workspacesAtom, {
120+
[workspace.id]: workspace,
121+
});
122+
store.set(workspaceOrderAtom, [workspace.id]);
123+
store.set(workspacesLoadStateAtom, "ready");
97124
store.set(wsClientAtom, {
98125
sendCommand,
99126
subscribe: vi.fn(() => () => {}),
@@ -119,6 +146,73 @@ describe("WorktreeModal", () => {
119146
expect(screen.getByText("Latest Commit")).toBeInTheDocument();
120147
});
121148

149+
it("does not render without an explicit workspace until the workspace list is ready", () => {
150+
const store = createStore();
151+
const workspace = createWorkspace("ws-1");
152+
153+
store.set(localeAtom, "en");
154+
store.set(activeWorkspaceIdAtom, workspace.id);
155+
store.set(workspacesAtom, {
156+
[workspace.id]: workspace,
157+
});
158+
store.set(workspaceOrderAtom, [workspace.id]);
159+
store.set(workspacesLoadStateAtom, "loading");
160+
161+
const { container } = render(
162+
<Provider store={store}>
163+
<WorktreeModal worktree={worktree} onClose={vi.fn()} />
164+
</Provider>
165+
);
166+
167+
expect(container).toBeEmptyDOMElement();
168+
});
169+
170+
it("falls back to the first ordered workspace when the requested workspace is missing", async () => {
171+
const sendCommand = vi.fn().mockResolvedValue({
172+
status: {
173+
branch: "feature/mobile-sheet",
174+
ahead: 0,
175+
behind: 0,
176+
headSha: "abc1234567890",
177+
headShortSha: "abc1234",
178+
headSubject: "Initial mobile sheet setup",
179+
staged: [],
180+
modified: [],
181+
untracked: [],
182+
deleted: [],
183+
},
184+
});
185+
186+
const store = createStore();
187+
const fallbackWorkspace = createWorkspace("ws-fallback");
188+
const otherWorkspace = createWorkspace("ws-other");
189+
store.set(localeAtom, "en");
190+
store.set(activeWorkspaceIdAtom, "ws-missing");
191+
store.set(workspacesAtom, {
192+
[fallbackWorkspace.id]: fallbackWorkspace,
193+
[otherWorkspace.id]: otherWorkspace,
194+
});
195+
store.set(workspaceOrderAtom, [fallbackWorkspace.id, otherWorkspace.id]);
196+
store.set(workspacesLoadStateAtom, "ready");
197+
store.set(wsClientAtom, {
198+
sendCommand,
199+
subscribe: vi.fn(() => () => {}),
200+
} as never);
201+
202+
render(
203+
<Provider store={store}>
204+
<WorktreeModal worktree={worktree} onClose={vi.fn()} />
205+
</Provider>
206+
);
207+
208+
await waitFor(() => {
209+
expect(sendCommand).toHaveBeenCalledWith("worktree.status", {
210+
workspaceId: fallbackWorkspace.id,
211+
worktreePath: "/tmp/coder-studio-feature",
212+
});
213+
});
214+
});
215+
122216
it("renders inside shared Sheet on mobile and still loads data when tabs change", async () => {
123217
viewportMocks.viewport = "mobile";
124218
const sendCommand = vi.fn().mockImplementation(async (op: string) => {

packages/web/src/features/workspace/views/shared/worktree-modal.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { WorktreeInfo } from "@coder-studio/core";
22
import { useAtomValue } from "jotai";
33
import { X } from "lucide-react";
4-
import { activeWorkspaceIdAtom } from "../../../../atoms/workspaces";
4+
import { resolvedActiveWorkspaceIdAtom } from "../../../../atoms/workspaces";
55
import {
66
IconButton,
77
Modal,
@@ -22,7 +22,7 @@ interface WorktreeModalProps {
2222

2323
export function WorktreeModal({ workspaceId, worktree, onClose }: WorktreeModalProps) {
2424
const isMobile = useViewport() === "mobile";
25-
const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom);
25+
const activeWorkspaceId = useAtomValue(resolvedActiveWorkspaceIdAtom);
2626
const t = useTranslation();
2727
const resolvedWorkspaceId = workspaceId ?? activeWorkspaceId;
2828

0 commit comments

Comments
 (0)