feat: refactor <AgentLogs /> error state (#24233)

This pull-request addresses a few design things within the `<AgentRow
/>` element. This is a follow-on from the previous work done with
implementing tabs.

- Workspace border can no longer be red, will always be orange (this was
done in a previous PR but not stated).
- Warnings have been moved to inside the Agent Logs collapsible.
- Warning badge has been added to the Agent Logs collapsible trigger.
- Collapsible is now open by default when there is an error inside of
the agent.
- Agent disconnected is no longer prominent by default.
This commit is contained in:
Jake Howell
2026-04-11 15:10:03 +10:00
committed by GitHub
parent bd467ce443
commit 7b02a51841
9 changed files with 202 additions and 500 deletions
+48 -22
View File
@@ -5,6 +5,7 @@ import {
EllipsisIcon,
PlayIcon,
SquareCheckBigIcon,
TriangleAlertIcon,
} from "lucide-react";
import {
type FC,
@@ -25,6 +26,7 @@ import type {
} from "#/api/typesGenerated";
import { CheckIcon } from "#/components/AnimatedIcons/Check";
import { ChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown";
import { Badge } from "#/components/Badge/Badge";
import { Button } from "#/components/Button/Button";
import {
DropdownMenu,
@@ -45,6 +47,8 @@ import { useKebabMenu } from "#/components/Tabs/utils/useKebabMenu";
import { useProxy } from "#/contexts/ProxyContext";
import { useClipboard } from "#/hooks/useClipboard";
import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
import { getAgentHealthIssues } from "#/modules/workspaces/health";
import { AgentAlert } from "#/pages/WorkspacePage/AgentAlert";
import { AppStatuses } from "#/pages/WorkspacePage/AppStatuses";
import { cn } from "#/utils/cn";
import { AgentApps, organizeAgentApps } from "./AgentApps/AgentApps";
@@ -135,9 +139,12 @@ export const AgentRow: FC<AgentRowProps> = ({
const showVSCode = hasVSCodeApp && !browser_only;
const hasStartupFeatures = Boolean(agent.logs_length);
const healthIssues = getAgentHealthIssues(agent);
const hasAgentIssues = healthIssues.length > 0;
const { proxy } = useProxy();
const [showLogs, setShowLogs] = useState(
["starting", "start_timeout"].includes(agent.lifecycle_state) &&
(["starting", "start_timeout"].includes(agent.lifecycle_state) ||
hasAgentIssues) &&
hasStartupFeatures,
);
const agentLogs = useAgentLogs({ agentId: agent.id, enabled: showLogs });
@@ -146,8 +153,11 @@ export const AgentRow: FC<AgentRowProps> = ({
const [bottomOfLogs, setBottomOfLogs] = useState(true);
useEffect(() => {
setShowLogs(agent.lifecycle_state !== "ready" && hasStartupFeatures);
}, [agent.lifecycle_state, hasStartupFeatures]);
setShowLogs(
(agent.lifecycle_state !== "ready" || hasAgentIssues) &&
hasStartupFeatures,
);
}, [agent.lifecycle_state, hasAgentIssues, hasStartupFeatures]);
// This is a layout effect to remove flicker when we're scrolling to the bottom.
// biome-ignore lint/correctness/useExhaustiveDependencies: consider refactoring
@@ -208,7 +218,6 @@ export const AgentRow: FC<AgentRowProps> = ({
agent,
Boolean(hasDevcontainerErrors || shouldShowWildcardWarning),
);
const [selectedLogTab, setSelectedLogTab] = useState("all");
const sourceLogTabs = agent.log_sources
.filter((logSource) => {
@@ -459,20 +468,37 @@ export const AgentRow: FC<AgentRowProps> = ({
<AgentMetadata initialMetadata={initialMetadata} agent={agent} />
</div>
{hasStartupFeatures && (
<section className="border-0 border-t border-solid border-border">
<div className="px-4 py-2 relative">
<Button
variant="subtle"
onClick={() => setShowLogs((v) => !v)}
className="after:content-[''] after:absolute after:inset-0"
>
<ChevronDownIcon open={showLogs} />
<span>Logs</span>
</Button>
</div>
<Collapse in={showLogs}>
<div className="px-4 pb-4">
<section className="border-0 border-t border-solid border-border">
<div className="px-4 py-2 relative">
<Button
variant="subtle"
onClick={() => setShowLogs((v) => !v)}
className="after:content-[''] after:absolute after:inset-0"
>
<ChevronDownIcon open={showLogs} />
<span>Logs</span>
{healthIssues.length > 0 && (
<Badge variant="warning" size="xs" className="ml-1.5">
<TriangleAlertIcon />
<span>{healthIssues.length}</span>
</Badge>
)}
</Button>
</div>
<Collapse in={showLogs || (!hasStartupFeatures && hasAgentIssues)}>
<div className={cn("px-4", hasStartupFeatures ? "pb-4" : "py-4")}>
{healthIssues.length > 0 && (
<div className="mb-4 flex flex-col gap-3">
{healthIssues.map((issue) => (
<AgentAlert
key={`${issue.title}-${issue.detail}`}
{...issue}
troubleshootingURL={agent.troubleshooting_url}
/>
))}
</div>
)}
{hasStartupFeatures && hasAnyLogs && (
<div className="border border-solid rounded-md overflow-clip">
<Tabs
className="-mx-px -mt-px"
@@ -585,10 +611,10 @@ export const AgentRow: FC<AgentRowProps> = ({
</TabsContent>
</Tabs>
</div>
</div>
</Collapse>
</section>
)}
)}
</div>
</Collapse>
</section>
</div>
);
};
@@ -11,7 +11,6 @@ import {
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { getAgentHealthIssue } from "#/modules/workspaces/health";
import {
type DisplayWorkspaceStatusType,
getDisplayWorkspaceStatus,
@@ -68,7 +67,10 @@ export const WorkspaceStatusIndicator: FC<WorkspaceStatusIndicatorProps> = ({
{children}
</StatusIndicator>
</TooltipTrigger>
<TooltipContent>{getAgentHealthIssue(workspace).detail}</TooltipContent>
<TooltipContent>
One or more workspace agents need attention. Expand an agent's logs for
details.
</TooltipContent>
</Tooltip>
);
};
+88 -349
View File
@@ -1,16 +1,11 @@
import { describe, expect, it } from "vitest";
import type {
Workspace,
WorkspaceAgent,
WorkspaceAgentLifecycle,
WorkspaceAgentStatus,
} from "#/api/typesGenerated";
import {
MockWorkspace,
MockWorkspaceAgent,
MockWorkspaceBuild,
MockWorkspaceResource,
} from "#/testHelpers/entities";
import { getAgentHealthIssue } from "./health";
import { MockWorkspaceAgent } from "#/testHelpers/entities";
import { getAgentHealthIssues } from "./health";
interface AgentOverrides {
status?: WorkspaceAgentStatus;
@@ -18,375 +13,119 @@ interface AgentOverrides {
parent_id?: string | null;
}
/**
* Build a workspace mock with the given agent configurations and
* failing-agent count. Defaults to status "connected" and lifecycle
* "ready" so each test only needs to specify the fields it cares about.
*/
function buildWorkspace(
agents: AgentOverrides[],
failingAgentCount: number,
): Workspace {
function buildAgent(overrides: AgentOverrides): WorkspaceAgent {
return {
...MockWorkspace,
latest_build: {
...MockWorkspaceBuild,
resources: [
{
...MockWorkspaceResource,
agents: agents.map((overrides, i) => ({
...MockWorkspaceAgent,
id: `agent-${i}`,
name: `agent-${i}`,
status: overrides.status ?? "connected",
lifecycle_state: overrides.lifecycle_state ?? "ready",
parent_id: overrides.parent_id ?? null,
})),
},
],
},
health: {
healthy: failingAgentCount === 0,
failing_agents: Array.from(
{ length: failingAgentCount },
(_, i) => `agent-${i}`,
),
},
...MockWorkspaceAgent,
status: overrides.status ?? "connected",
lifecycle_state: overrides.lifecycle_state ?? "ready",
parent_id: overrides.parent_id ?? null,
};
}
describe("getAgentHealthIssue", () => {
describe("individual branches", () => {
it("returns disconnected issue for a disconnected agent", () => {
const ws = buildWorkspace(
[{ status: "disconnected", lifecycle_state: "ready" }],
1,
);
expect(getAgentHealthIssue(ws)).toEqual({
describe("getAgentHealthIssues", () => {
it("returns disconnected issue for a disconnected agent", () => {
expect(
getAgentHealthIssues(buildAgent({ status: "disconnected" })),
).toContainEqual(
expect.objectContaining({
title: "Workspace agent has disconnected",
detail:
"Check the log output for errors. If agents do not reconnect, try restarting the workspace.",
severity: "warning",
prominent: true,
});
});
prominent: false,
}),
);
});
it("returns timeout issue for a timed-out agent", () => {
const ws = buildWorkspace(
[{ status: "timeout", lifecycle_state: "ready" }],
1,
);
expect(getAgentHealthIssue(ws)).toEqual({
it("returns timeout issue for a timed-out agent", () => {
expect(
getAgentHealthIssues(buildAgent({ status: "timeout" })),
).toContainEqual(
expect.objectContaining({
title: "Agent is taking longer than expected to connect",
detail:
"Continue to wait and check the log output for errors. If agents do not connect, try restarting the workspace.",
severity: "warning",
prominent: false,
});
});
}),
);
});
it("returns shutting down issue for shutting_down lifecycle", () => {
const ws = buildWorkspace(
[{ status: "connected", lifecycle_state: "shutting_down" }],
1,
);
expect(getAgentHealthIssue(ws)).toEqual({
it("returns shutdown issue for shutdown lifecycle states", () => {
expect(
getAgentHealthIssues(buildAgent({ lifecycle_state: "shutting_down" })),
).toContainEqual(
expect.objectContaining({
title: "Workspace agent is shutting down",
detail: "The workspace is not available while agents shut down.",
severity: "info",
prominent: false,
});
});
it("returns shutting down issue for shutdown_error lifecycle", () => {
const ws = buildWorkspace(
[{ status: "connected", lifecycle_state: "shutdown_error" }],
1,
);
expect(getAgentHealthIssue(ws)).toEqual({
}),
);
expect(
getAgentHealthIssues(buildAgent({ lifecycle_state: "shutdown_error" })),
).toContainEqual(
expect.objectContaining({
title: "Workspace agent is shutting down",
detail: "The workspace is not available while agents shut down.",
severity: "info",
prominent: false,
});
});
it("returns shutting down issue for shutdown_timeout lifecycle", () => {
const ws = buildWorkspace(
[{ status: "connected", lifecycle_state: "shutdown_timeout" }],
1,
);
expect(getAgentHealthIssue(ws)).toEqual({
}),
);
expect(
getAgentHealthIssues(buildAgent({ lifecycle_state: "shutdown_timeout" })),
).toContainEqual(
expect.objectContaining({
title: "Workspace agent is shutting down",
detail: "The workspace is not available while agents shut down.",
severity: "info",
prominent: false,
});
});
}),
);
});
it("returns start error issue for start_error lifecycle", () => {
const ws = buildWorkspace(
[{ status: "connected", lifecycle_state: "start_error" }],
1,
);
expect(getAgentHealthIssue(ws)).toEqual({
it("returns startup script issues", () => {
expect(
getAgentHealthIssues(buildAgent({ lifecycle_state: "start_error" })),
).toContainEqual(
expect.objectContaining({
title: "Startup script failed",
detail:
"A startup script exited with an error. Check the agent logs for details.",
severity: "warning",
prominent: true,
});
});
it("returns start timeout issue for start_timeout lifecycle", () => {
const ws = buildWorkspace(
[{ status: "connected", lifecycle_state: "start_timeout" }],
1,
);
expect(getAgentHealthIssue(ws)).toEqual({
title: "Startup script is taking longer than expected",
detail:
"A startup script has exceeded the expected time. Check the agent logs for details.",
severity: "warning",
prominent: false,
});
});
}),
);
expect(
getAgentHealthIssues(buildAgent({ lifecycle_state: "start_timeout" })),
).toContainEqual(
expect.objectContaining({
title: "Startup script is taking longer than expected",
severity: "warning",
prominent: false,
}),
);
});
it("returns connecting issue for a connecting agent", () => {
const ws = buildWorkspace(
[{ status: "connecting", lifecycle_state: "starting" }],
1,
);
expect(getAgentHealthIssue(ws)).toEqual({
it("returns connecting issue for a connecting agent", () => {
expect(
getAgentHealthIssues(
buildAgent({ status: "connecting", lifecycle_state: "starting" }),
),
).toContainEqual(
expect.objectContaining({
title: "Workspace agent is connecting",
detail:
"The workspace agent has not connected yet. Wait for it to connect or check the logs if it does not.",
severity: "info",
prominent: false,
});
});
}),
);
});
describe("plural path", () => {
it("uses plural title when multiple agents are disconnected", () => {
const ws = buildWorkspace(
[
{ status: "disconnected", lifecycle_state: "ready" },
{ status: "disconnected", lifecycle_state: "ready" },
{ status: "disconnected", lifecycle_state: "ready" },
],
3,
);
const result = getAgentHealthIssue(ws);
expect(result.title).toBe("3 workspace agents have disconnected");
});
it("uses plural title when multiple agents time out", () => {
const ws = buildWorkspace(
[
{ status: "timeout", lifecycle_state: "ready" },
{ status: "timeout", lifecycle_state: "ready" },
],
2,
);
const result = getAgentHealthIssue(ws);
expect(result.title).toBe(
"2 agents are taking longer than expected to connect",
);
});
it("uses plural title when multiple agents have start errors", () => {
const ws = buildWorkspace(
[
{ status: "connected", lifecycle_state: "start_error" },
{ status: "connected", lifecycle_state: "start_error" },
],
2,
);
const result = getAgentHealthIssue(ws);
expect(result.title).toBe("Startup scripts failed on 2 agents");
});
it("uses plural title when multiple agents are shutting down", () => {
const ws = buildWorkspace(
[
{ status: "connected", lifecycle_state: "shutting_down" },
{ status: "connected", lifecycle_state: "shutdown_error" },
],
2,
);
const result = getAgentHealthIssue(ws);
expect(result.title).toBe("2 workspace agents are shutting down");
});
it("uses plural title when multiple agents are connecting", () => {
const ws = buildWorkspace(
[
{ status: "connecting", lifecycle_state: "starting" },
{ status: "connecting", lifecycle_state: "starting" },
],
2,
);
const result = getAgentHealthIssue(ws);
expect(result.title).toBe("2 workspace agents are connecting");
});
it("uses singular title when only one agent is failing", () => {
const ws = buildWorkspace(
[{ status: "disconnected", lifecycle_state: "ready" }],
1,
);
const result = getAgentHealthIssue(ws);
expect(result.title).toBe("Workspace agent has disconnected");
});
it("returns empty list for healthy ready connected agent", () => {
expect(
getAgentHealthIssues(
buildAgent({ status: "connected", lifecycle_state: "ready" }),
),
).toEqual([]);
});
describe("priority ordering", () => {
it("disconnected takes priority over timeout", () => {
const ws = buildWorkspace(
[
{ status: "disconnected", lifecycle_state: "ready" },
{ status: "timeout", lifecycle_state: "ready" },
],
2,
);
const result = getAgentHealthIssue(ws);
expect(result.title).toBe("2 workspace agents have disconnected");
expect(result.severity).toBe("warning");
expect(result.prominent).toBe(true);
});
it("timeout takes priority over shutdown states", () => {
const ws = buildWorkspace(
[
{ status: "timeout", lifecycle_state: "ready" },
{ status: "connected", lifecycle_state: "shutting_down" },
],
2,
);
const result = getAgentHealthIssue(ws);
expect(result.title).toBe(
"2 agents are taking longer than expected to connect",
);
expect(result.severity).toBe("warning");
expect(result.prominent).toBe(false);
});
it("shutdown states take priority over start_error", () => {
const ws = buildWorkspace(
[
{ status: "connected", lifecycle_state: "shutting_down" },
{ status: "connected", lifecycle_state: "start_error" },
],
2,
);
const result = getAgentHealthIssue(ws);
expect(result.title).toBe("2 workspace agents are shutting down");
expect(result.severity).toBe("info");
});
it("start_error takes priority over start_timeout", () => {
const ws = buildWorkspace(
[
{ status: "connected", lifecycle_state: "start_error" },
{ status: "connected", lifecycle_state: "start_timeout" },
],
2,
);
const result = getAgentHealthIssue(ws);
expect(result.title).toBe("Startup scripts failed on 2 agents");
expect(result.severity).toBe("warning");
expect(result.prominent).toBe(true);
});
it("disconnected takes priority over all lifecycle states", () => {
const ws = buildWorkspace(
[
{ status: "disconnected", lifecycle_state: "start_error" },
{ status: "connected", lifecycle_state: "shutting_down" },
{ status: "connected", lifecycle_state: "start_timeout" },
],
3,
);
const result = getAgentHealthIssue(ws);
expect(result.title).toBe("3 workspace agents have disconnected");
});
});
describe("sub-agent filtering", () => {
it("ignores a sub-agent whose status would change the result", () => {
const ws = buildWorkspace(
[
// Parent agent: still connecting.
{ status: "connecting", lifecycle_state: "starting" },
// Sub-agent: disconnected, which would be highest priority
// if not filtered out.
{
status: "disconnected",
lifecycle_state: "ready",
parent_id: "agent-0",
},
],
1,
);
const result = getAgentHealthIssue(ws);
expect(result.title).toBe("Workspace agent is connecting");
expect(result.severity).toBe("info");
});
it("ignores a sub-agent whose lifecycle would promote severity", () => {
const ws = buildWorkspace(
[
// Parent agent: soft start_timeout issue.
{ status: "connected", lifecycle_state: "start_timeout" },
// Sub-agent: start_error, which would take priority over
// start_timeout if not filtered.
{
status: "connected",
lifecycle_state: "start_error",
parent_id: "agent-0",
},
],
1,
);
const result = getAgentHealthIssue(ws);
expect(result.title).toBe(
"Startup script is taking longer than expected",
);
expect(result.prominent).toBe(false);
});
});
describe("start_timeout reachability", () => {
it("is overshadowed by start_error in a multi-agent workspace", () => {
const ws = buildWorkspace(
[
{ status: "connected", lifecycle_state: "start_timeout" },
{ status: "connected", lifecycle_state: "start_error" },
],
2,
);
const result = getAgentHealthIssue(ws);
expect(result.title).toBe("Startup scripts failed on 2 agents");
expect(result.prominent).toBe(true);
});
it("is returned when it is the sole lifecycle issue", () => {
// In a multi-agent workspace another agent may have triggered
// the unhealthy flag while this agent only has start_timeout.
const ws = buildWorkspace(
[
{ status: "connected", lifecycle_state: "start_timeout" },
{ status: "connected", lifecycle_state: "ready" },
],
1,
);
const result = getAgentHealthIssue(ws);
expect(result.title).toBe(
"Startup script is taking longer than expected",
);
expect(result.severity).toBe("warning");
expect(result.prominent).toBe(false);
});
it("returns multiple issues when multiple conditions match", () => {
const issues = getAgentHealthIssues(
buildAgent({ status: "disconnected", lifecycle_state: "start_error" }),
);
expect(issues).toContainEqual(
expect.objectContaining({ title: "Workspace agent has disconnected" }),
);
expect(issues).toContainEqual(
expect.objectContaining({ title: "Startup script failed" }),
);
});
});
+39 -90
View File
@@ -1,10 +1,10 @@
import type { Workspace, WorkspaceAgentStatus } from "#/api/typesGenerated";
import type { WorkspaceAgent } from "#/api/typesGenerated";
/**
* Canonical messages for startup and shutdown script issues.
* Used by the per-agent-row tooltips in AgentStatus; the
* start-related entries are also shared with the workspace-level
* health classification in getAgentHealthIssue.
* start-related entries are also shared with per-agent health
* classification in getAgentHealthIssues.
*/
export const agentScriptMessages = {
start_error: {
@@ -62,121 +62,70 @@ interface AgentHealthIssue {
}
/**
* Classifies the health issue affecting a workspace based on agent
* status and lifecycle state. Returns a title and detail message
* that accurately describes the root cause rather than using a
* generic "unhealthy" label.
* Classifies all health issues for an individual agent.
*/
export function getAgentHealthIssue(workspace: Workspace): AgentHealthIssue {
const failingAgentCount = workspace.health.failing_agents.length;
const statusSet = new Set<WorkspaceAgentStatus>();
let hasStartError = false;
let hasStartTimeout = false;
let hasShutdownState = false;
export function getAgentHealthIssues(
agent: WorkspaceAgent,
): AgentHealthIssue[] {
const issues: AgentHealthIssue[] = [];
for (const resource of workspace.latest_build.resources) {
for (const agent of resource.agents ?? []) {
// Skip sub-agents (devcontainer agents) to match the
// backend health calculation which excludes them.
if (agent.parent_id !== null) {
continue;
}
statusSet.add(agent.status);
if (agent.lifecycle_state === "start_error") {
hasStartError = true;
}
if (agent.lifecycle_state === "start_timeout") {
hasStartTimeout = true;
}
if (
agent.lifecycle_state === "shutting_down" ||
agent.lifecycle_state === "shutdown_error" ||
agent.lifecycle_state === "shutdown_timeout"
) {
hasShutdownState = true;
}
}
}
const plural = failingAgentCount > 1;
if (statusSet.has("disconnected")) {
return {
title: plural
? `${failingAgentCount} workspace agents have disconnected`
: agentConnectionMessages.disconnected.title,
if (agent.status === "disconnected") {
issues.push({
title: agentConnectionMessages.disconnected.title,
detail: agentConnectionMessages.disconnected.detail,
severity: "warning",
prominent: true,
};
prominent: false,
});
}
if (statusSet.has("timeout")) {
return {
title: plural
? `${failingAgentCount} agents are taking longer than expected to connect`
: agentConnectionMessages.timeout.title,
if (agent.status === "timeout") {
issues.push({
title: agentConnectionMessages.timeout.title,
detail: agentConnectionMessages.timeout.detail,
severity: "warning",
prominent: false,
};
});
}
if (hasShutdownState) {
return {
title: plural
? `${failingAgentCount} workspace agents are shutting down`
: "Workspace agent is shutting down",
if (
agent.lifecycle_state === "shutting_down" ||
agent.lifecycle_state === "shutdown_error" ||
agent.lifecycle_state === "shutdown_timeout"
) {
issues.push({
title: "Workspace agent is shutting down",
detail: "The workspace is not available while agents shut down.",
severity: "info",
prominent: false,
};
});
}
if (hasStartError) {
return {
title: plural
? `Startup scripts failed on ${failingAgentCount} agents`
: agentScriptMessages.start_error.title,
if (agent.lifecycle_state === "start_error") {
issues.push({
title: agentScriptMessages.start_error.title,
detail: agentScriptMessages.start_error.detail,
severity: "warning",
prominent: true,
};
prominent: false,
});
}
// The backend does not mark start_timeout agents as unhealthy on
// their own (it treats it as a soft issue). This branch is only
// reachable in multi-agent workspaces where a different agent
// triggered the unhealthy flag but none of the higher-priority
// branches matched.
if (hasStartTimeout) {
return {
title: plural
? `Startup scripts are taking longer than expected on ${failingAgentCount} agents`
: agentScriptMessages.start_timeout.title,
if (agent.lifecycle_state === "start_timeout") {
issues.push({
title: agentScriptMessages.start_timeout.title,
detail: agentScriptMessages.start_timeout.detail,
severity: "warning",
prominent: false,
};
});
}
if (statusSet.has("connecting")) {
return {
title: plural
? `${failingAgentCount} workspace agents are connecting`
: agentConnectionMessages.connecting.title,
if (agent.status === "connecting") {
issues.push({
title: agentConnectionMessages.connecting.title,
detail: agentConnectionMessages.connecting.detail,
severity: "info",
prominent: false,
};
});
}
return {
title: plural
? `${failingAgentCount} workspace agents are still connecting`
: "Workspace agent is still connecting",
detail: "Check the log output if the connection does not complete.",
severity: "info",
prominent: false,
};
return issues;
}
@@ -1,9 +1,9 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { WorkspaceAlert } from "./WorkspaceAlert";
import { AgentAlert } from "./AgentAlert";
const meta: Meta<typeof WorkspaceAlert> = {
title: "pages/WorkspacePage/WorkspaceAlert",
component: WorkspaceAlert,
const meta: Meta<typeof AgentAlert> = {
title: "pages/WorkspacePage/AgentAlert",
component: AgentAlert,
args: {
title: "Something went wrong",
detail:
@@ -13,7 +13,7 @@ const meta: Meta<typeof WorkspaceAlert> = {
};
export default meta;
type Story = StoryObj<typeof WorkspaceAlert>;
type Story = StoryObj<typeof AgentAlert>;
export const WarningProminent: Story = {
args: {
@@ -1,16 +1,16 @@
import type { FC } from "react";
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
import { Link } from "#/components/Link/Link";
import { Button } from "#/components/Button/Button";
interface WorkspaceAlertProps {
interface AgentAlertProps {
title: string;
detail: string;
severity: "info" | "warning";
prominent: boolean;
troubleshootingURL: string | undefined;
troubleshootingURL?: string;
}
export const WorkspaceAlert: FC<WorkspaceAlertProps> = ({
export const AgentAlert: FC<AgentAlertProps> = ({
title,
detail,
severity,
@@ -21,14 +21,14 @@ export const WorkspaceAlert: FC<WorkspaceAlertProps> = ({
<Alert severity={severity} prominent={prominent}>
<AlertTitle>{title}</AlertTitle>
<AlertDescription>
<p>{detail}</p>
<p>
{troubleshootingURL && (
<Link href={troubleshootingURL} target="_blank">
<div className="mb-2">{detail}</div>
{troubleshootingURL && (
<Button size="sm" asChild>
<a href={troubleshootingURL} target="_blank" rel="noopener">
View docs to troubleshoot
</Link>
)}
</p>
</a>
</Button>
)}
</AlertDescription>
</Alert>
);
@@ -7,21 +7,18 @@ import { SidebarIconButton } from "#/components/FullPageLayout/Sidebar";
import { useSearchParamsKey } from "#/hooks/useSearchParamsKey";
import { ProvisionerStatusAlert } from "#/modules/provisioners/ProvisionerStatusAlert";
import { AgentRow } from "#/modules/resources/AgentRow";
import { getAgentHealthIssue } from "#/modules/workspaces/health";
import { WorkspaceTimings } from "#/modules/workspaces/WorkspaceTiming/WorkspaceTimings";
import type { WorkspacePermissions } from "../../modules/workspaces/permissions";
import { HistorySidebar } from "./HistorySidebar";
import { ResourceMetadata } from "./ResourceMetadata";
import { ResourcesSidebar } from "./ResourcesSidebar";
import { resourceOptionValue, useResourcesNav } from "./useResourcesNav";
import { WorkspaceAlert } from "./WorkspaceAlert";
import { WorkspaceBuildLogsSection } from "./WorkspaceBuildLogsSection";
import {
getActiveTransitionStats,
WorkspaceBuildProgress,
} from "./WorkspaceBuildProgress";
import { WorkspaceDeletedBanner } from "./WorkspaceDeletedBanner";
import { findTroubleshootingURL } from "./WorkspaceNotifications/WorkspaceNotifications";
import { WorkspaceTopbar } from "./WorkspaceTopbar";
interface WorkspaceProps {
@@ -99,7 +96,6 @@ export const Workspace: FC<WorkspaceProps> = ({
(workspace.latest_build.matched_provisioners?.available ?? 1) > 0;
const shouldShowProvisionerAlert =
workspacePending && !haveBuildLogs && !provisionersHealthy && !isRestarting;
const troubleshootingURL = findTroubleshootingURL(workspace.latest_build);
return (
<div className="flex flex-col flex-1 min-h-0">
@@ -195,13 +191,6 @@ export const Workspace: FC<WorkspaceProps> = ({
</Alert>
)}
{!workspace.health.healthy && (
<WorkspaceAlert
{...getAgentHealthIssue(workspace)}
troubleshootingURL={troubleshootingURL}
/>
)}
{transitionStats !== undefined && (
<WorkspaceBuildProgress
workspace={workspace}
@@ -156,7 +156,7 @@ export const StartupScriptFailed: Story = {
await userEvent.hover(screen.getByTestId("warning-notifications"));
await waitFor(() =>
expect(screen.getByRole("tooltip")).toHaveTextContent(
/startup script failed/i,
/one or more workspace agents need attention/i,
),
);
});
@@ -177,7 +177,7 @@ export const AgentDisconnected: Story = {
await userEvent.hover(screen.getByTestId("warning-notifications"));
await waitFor(() =>
expect(screen.getByRole("tooltip")).toHaveTextContent(
/agent has disconnected/i,
/one or more workspace agents need attention/i,
),
);
});
@@ -198,7 +198,7 @@ export const AgentTimeout: Story = {
await userEvent.hover(screen.getByTestId("warning-notifications"));
await waitFor(() =>
expect(screen.getByRole("tooltip")).toHaveTextContent(
/taking longer than expected/i,
/one or more workspace agents need attention/i,
),
);
});
@@ -13,7 +13,6 @@ import type {
import { MemoizedInlineMarkdown } from "#/components/Markdown/InlineMarkdown";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { TemplateUpdateMessage } from "#/modules/templates/TemplateUpdateMessage";
import { getAgentHealthIssue } from "#/modules/workspaces/health";
dayjs.extend(relativeTime);
@@ -93,12 +92,10 @@ export const WorkspaceNotifications: FC<WorkspaceNotificationsProps> = ({
) {
const troubleshootingURL = findTroubleshootingURL(workspace.latest_build);
const hasActions = permissions.updateWorkspace || troubleshootingURL;
const healthIssue = getAgentHealthIssue(workspace);
notifications.push({
title: healthIssue.title,
severity: healthIssue.severity,
detail: healthIssue.detail,
title: "One or more workspace agents need attention",
severity: "warning",
detail: "Expand an agent's logs to view per-agent health details.",
actions: hasActions ? (
<>
{permissions.updateWorkspace && (
@@ -270,7 +267,7 @@ const styles = {
},
} satisfies Record<string, Interpolation<Theme>>;
export const findTroubleshootingURL = (
const findTroubleshootingURL = (
workspaceBuild: WorkspaceBuild,
): string | undefined => {
for (const resource of workspaceBuild.resources) {