Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit a95bf52

Browse files
fix(site): address R3 review feedback (shared status, mutation lift, stories, nits)
- Extract getWorkspaceStatus() into StatusIcon.tsx to deduplicate the ~25-line status computation between WorkspacePill and AgentChatPageView (C022) - Lift useMutation to WorkspacePill parent so isPending persists across dropdown open/close cycles (C023) - Add WithStoppedWorkspace story asserting disabled items (C024) - Fix chevron comment direction (C025) - Remove unnecessary ?. on non-optional agent prop (C026) - Fix double 'open' in toast message — label is now 'VS Code' with 'Open in' prefix in JSX (C027)
1 parent 19ba116 commit a95bf52

4 files changed

Lines changed: 135 additions & 78 deletions

File tree

site/src/pages/AgentsPage/AgentChatPageView.tsx

Lines changed: 4 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import type * as TypesGen from "#/api/typesGenerated";
1414
import type { ChatDiffStatus, ChatMessagePart } from "#/api/typesGenerated";
1515
import { cn } from "#/utils/cn";
1616
import { pageTitle } from "#/utils/page";
17-
import { getDisplayWorkspaceStatus } from "#/utils/workspace";
1817
import {
1918
AgentChatInput,
2019
type ChatMessageInputRef,
@@ -33,7 +32,7 @@ import { ChatTopBar } from "./components/ChatTopBar";
3332
import { GitPanel } from "./components/GitPanel/GitPanel";
3433
import { RightPanel } from "./components/RightPanel/RightPanel";
3534
import { SidebarTabView } from "./components/Sidebar/SidebarTabView";
36-
import { StatusIcon } from "./components/StatusIcon";
35+
import { getWorkspaceStatus, StatusIcon } from "./components/StatusIcon";
3736
import { TerminalPanel } from "./components/TerminalPanel";
3837
import { ChatWorkspaceContext } from "./context/ChatWorkspaceContext";
3938
import { chatWidthClass, useChatFullWidth } from "./hooks/useChatFullWidth";
@@ -270,31 +269,10 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
270269
const attachedWorkspace = (() => {
271270
if (!workspace || !workspaceRoute) return undefined;
272271

273-
let { type, text } = getDisplayWorkspaceStatus(
274-
workspace.latest_build.status,
275-
workspace.latest_build.job,
272+
const { effectiveType, statusLabel } = getWorkspaceStatus(
273+
workspace,
274+
workspaceAgent,
276275
);
277-
278-
const agentPreparing =
279-
workspace.latest_build.status === "running" &&
280-
(workspaceAgent?.lifecycle_state === "created" ||
281-
workspaceAgent?.lifecycle_state === "starting");
282-
const agentStartupFailed =
283-
workspace.latest_build.status === "running" &&
284-
(workspaceAgent?.lifecycle_state === "start_error" ||
285-
workspaceAgent?.lifecycle_state === "start_timeout");
286-
if (agentPreparing) {
287-
type = "active";
288-
text = "Preparing";
289-
} else if (agentStartupFailed) {
290-
type = "warning";
291-
text = "Startup failed";
292-
}
293-
294-
const effectiveType = workspace.health.healthy ? type : "warning";
295-
const statusLabel = workspace.health.healthy
296-
? `Workspace ${text.toLowerCase()}`
297-
: `Workspace ${text.toLowerCase()} (unhealthy)`;
298276
const statusIcon = <StatusIcon type={effectiveType} />;
299277
return {
300278
name: workspace.name,

site/src/pages/AgentsPage/components/StatusIcon.tsx

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import {
55
MonitorXIcon,
66
} from "lucide-react";
77
import type { FC } from "react";
8-
import type { DisplayWorkspaceStatusType } from "#/utils/workspace";
8+
import type { Workspace, WorkspaceAgent } from "#/api/typesGenerated";
9+
import {
10+
type DisplayWorkspaceStatusType,
11+
getDisplayWorkspaceStatus,
12+
} from "#/utils/workspace";
913

1014
const iconMap: Record<
1115
DisplayWorkspaceStatusType,
@@ -26,3 +30,35 @@ export const StatusIcon: FC<{
2630
const Icon = iconMap[type];
2731
return <Icon className={className} />;
2832
};
33+
34+
export function getWorkspaceStatus(
35+
workspace: Workspace,
36+
agent?: WorkspaceAgent | null,
37+
): { effectiveType: DisplayWorkspaceStatusType; statusLabel: string } {
38+
let { type, text } = getDisplayWorkspaceStatus(
39+
workspace.latest_build.status,
40+
workspace.latest_build.job,
41+
);
42+
43+
const agentPreparing =
44+
workspace.latest_build.status === "running" &&
45+
(agent?.lifecycle_state === "created" ||
46+
agent?.lifecycle_state === "starting");
47+
const agentStartupFailed =
48+
workspace.latest_build.status === "running" &&
49+
(agent?.lifecycle_state === "start_error" ||
50+
agent?.lifecycle_state === "start_timeout");
51+
if (agentPreparing) {
52+
type = "active";
53+
text = "Preparing";
54+
} else if (agentStartupFailed) {
55+
type = "warning";
56+
text = "Startup failed";
57+
}
58+
59+
const effectiveType = workspace.health.healthy ? type : "warning";
60+
const statusLabel = workspace.health.healthy
61+
? `Workspace ${text.toLowerCase()}`
62+
: `Workspace ${text.toLowerCase()} (unhealthy)`;
63+
return { effectiveType, statusLabel };
64+
}

site/src/pages/AgentsPage/components/WorkspacePill.stories.tsx

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import type { Meta, StoryObj } from "@storybook/react-vite";
22
import { expect, userEvent, waitFor, within } from "storybook/test";
33
import type { WorkspaceApp } from "#/api/typesGenerated";
4-
import { MockWorkspace, MockWorkspaceAgent } from "#/testHelpers/entities";
4+
import {
5+
MockStoppedWorkspace,
6+
MockWorkspace,
7+
MockWorkspaceAgent,
8+
} from "#/testHelpers/entities";
59
import { withProxyProvider } from "#/testHelpers/storybook";
610
import { WorkspacePill } from "./WorkspacePill";
711

@@ -232,3 +236,41 @@ export const WithHiddenApp: Story = {
232236
});
233237
},
234238
};
239+
240+
/** Stopped workspace — VS Code and terminal items should be disabled. */
241+
export const WithStoppedWorkspace: Story = {
242+
args: {
243+
...defaultProps,
244+
workspace: MockStoppedWorkspace,
245+
agent: agentWithApps,
246+
},
247+
play: async ({ canvasElement }) => {
248+
const canvas = within(canvasElement);
249+
const pill = canvas.getByText(MockStoppedWorkspace.name);
250+
await userEvent.click(pill);
251+
252+
await waitFor(() => {
253+
const body = within(document.body);
254+
255+
// VS Code items should be present but disabled.
256+
const vscodeItem = body
257+
.getByText("Open in VS Code")
258+
.closest("[role=menuitem]");
259+
expect(vscodeItem).toHaveAttribute("aria-disabled", "true");
260+
261+
const vscodeInsidersItem = body
262+
.getByText("Open in VS Code Insiders")
263+
.closest("[role=menuitem]");
264+
expect(vscodeInsidersItem).toHaveAttribute("aria-disabled", "true");
265+
266+
// Terminal item should be disabled.
267+
const terminalItem = body
268+
.getByText("Open Terminal")
269+
.closest("[role=menuitem]");
270+
expect(terminalItem).toHaveAttribute("aria-disabled", "true");
271+
272+
// View Workspace link should still be accessible.
273+
expect(body.getByText("View Workspace")).toBeInTheDocument();
274+
});
275+
},
276+
};

site/src/pages/AgentsPage/components/WorkspacePill.tsx

Lines changed: 51 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,7 @@ import {
3939
} from "#/modules/apps/apps";
4040
import { useAppLink } from "#/modules/apps/useAppLink";
4141
import { cn } from "#/utils/cn";
42-
import { getDisplayWorkspaceStatus } from "#/utils/workspace";
43-
import { StatusIcon } from "./StatusIcon";
42+
import { getWorkspaceStatus, StatusIcon } from "./StatusIcon";
4443

4544
interface WorkspacePillProps {
4645
workspace: Workspace;
@@ -62,31 +61,11 @@ export const WorkspacePill: FC<WorkspacePillProps> = ({
6261
const isRunning = workspace.latest_build.status === "running";
6362
const route = `/@${workspace.owner_name}/${workspace.name}`;
6463

65-
let { type, text } = getDisplayWorkspaceStatus(
66-
workspace.latest_build.status,
67-
workspace.latest_build.job,
68-
);
69-
70-
const agentPreparing =
71-
workspace.latest_build.status === "running" &&
72-
(agent?.lifecycle_state === "created" ||
73-
agent?.lifecycle_state === "starting");
74-
const agentStartupFailed =
75-
workspace.latest_build.status === "running" &&
76-
(agent?.lifecycle_state === "start_error" ||
77-
agent?.lifecycle_state === "start_timeout");
78-
if (agentPreparing) {
79-
type = "active";
80-
text = "Preparing";
81-
} else if (agentStartupFailed) {
82-
type = "warning";
83-
text = "Startup failed";
84-
}
64+
const { effectiveType, statusLabel } = getWorkspaceStatus(workspace, agent);
8565

86-
const effectiveType = workspace.health.healthy ? type : "warning";
87-
const statusLabel = workspace.health.healthy
88-
? `Workspace ${text.toLowerCase()}`
89-
: `Workspace ${text.toLowerCase()} (unhealthy)`;
66+
const { mutate: generateKey, isPending: isGeneratingKey } = useMutation({
67+
mutationFn: () => API.getApiKey(),
68+
});
9069

9170
const builtinApps = new Set(agent.display_apps);
9271
const hasVSCode = builtinApps.has("vscode");
@@ -120,8 +99,8 @@ export const WorkspacePill: FC<WorkspacePillProps> = ({
12099
>
121100
<StatusIcon type={effectiveType} /> {workspace.name}
122101
{/* The menu opens upward (side="top"), so the chevron
123-
points toward the menu when closed (default) and
124-
away when open (rotate-180). */}
102+
points away from the menu when closed (default) and
103+
toward it when open (rotate-180). */}
125104
<ChevronDownIcon
126105
className={cn(
127106
"size-3 opacity-60 transition-transform",
@@ -141,23 +120,27 @@ export const WorkspacePill: FC<WorkspacePillProps> = ({
141120
{hasVSCode && (
142121
<VSCodeMenuItem
143122
variant="vscode"
144-
label="Open in VS Code"
123+
label="VS Code"
145124
workspace={workspace}
146125
agent={agent}
147126
chatId={chatId}
148127
folder={folder}
149128
isRunning={isRunning}
129+
generateKey={generateKey}
130+
isGeneratingKey={isGeneratingKey}
150131
/>
151132
)}
152133
{hasVSCodeInsiders && (
153134
<VSCodeMenuItem
154135
variant="vscode-insiders"
155-
label="Open in VS Code Insiders"
136+
label="VS Code Insiders"
156137
workspace={workspace}
157138
agent={agent}
158139
chatId={chatId}
159140
folder={folder}
160141
isRunning={isRunning}
142+
generateKey={generateKey}
143+
isGeneratingKey={isGeneratingKey}
161144
/>
162145
)}
163146
{userApps.map((app) => (
@@ -197,32 +180,50 @@ const VSCodeMenuItem: FC<{
197180
chatId: string;
198181
folder?: string;
199182
isRunning: boolean;
200-
}> = ({ variant, label, workspace, agent, chatId, folder, isRunning }) => {
201-
const { mutate: generateKey, isPending } = useMutation({
202-
mutationFn: () => API.getApiKey(),
203-
onSuccess: ({ key }) => {
204-
location.href = getVSCodeHref(variant, {
205-
owner: workspace.owner_name,
206-
workspace: workspace.name,
207-
token: key,
208-
agent: agent.name,
209-
folder: folder ?? agent.expanded_directory,
210-
chatId,
211-
});
212-
},
213-
onError: () => {
214-
toast.error(`Failed to open ${label}.`);
183+
generateKey: (
184+
variables: undefined,
185+
options: {
186+
onSuccess: (data: { key: string }) => void;
187+
onError: () => void;
215188
},
216-
});
217-
189+
) => void;
190+
isGeneratingKey: boolean;
191+
}> = ({
192+
variant,
193+
label,
194+
workspace,
195+
agent,
196+
chatId,
197+
folder,
198+
isRunning,
199+
generateKey,
200+
isGeneratingKey,
201+
}) => {
218202
const handleClick = () => {
219-
generateKey();
203+
generateKey(undefined, {
204+
onSuccess: ({ key }) => {
205+
location.href = getVSCodeHref(variant, {
206+
owner: workspace.owner_name,
207+
workspace: workspace.name,
208+
token: key,
209+
agent: agent.name,
210+
folder: folder ?? agent.expanded_directory,
211+
chatId,
212+
});
213+
},
214+
onError: () => {
215+
toast.error(`Failed to open ${label}.`);
216+
},
217+
});
220218
};
221219

222220
return (
223-
<DropdownMenuItem onSelect={handleClick} disabled={isPending || !isRunning}>
221+
<DropdownMenuItem
222+
onSelect={handleClick}
223+
disabled={isGeneratingKey || !isRunning}
224+
>
224225
<ExternalLinkIcon className="size-3.5" />
225-
{label}
226+
Open in {label}
226227
</DropdownMenuItem>
227228
);
228229
};

0 commit comments

Comments
 (0)