From 7b02a518417bdd39cf23b926ebb90ac4f469efd1 Mon Sep 17 00:00:00 2001
From: Jake Howell
Date: Sat, 11 Apr 2026 15:10:03 +1000
Subject: [PATCH] feat: refactor `` error state (#24233)
This pull-request addresses a few design things within the `` 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.
---
site/src/modules/resources/AgentRow.tsx | 70 ++-
.../WorkspaceStatusIndicator.tsx | 6 +-
site/src/modules/workspaces/health.test.ts | 437 ++++--------------
site/src/modules/workspaces/health.ts | 129 ++----
...ert.stories.tsx => AgentAlert.stories.tsx} | 10 +-
.../{WorkspaceAlert.tsx => AgentAlert.tsx} | 22 +-
site/src/pages/WorkspacePage/Workspace.tsx | 11 -
.../WorkspaceNotifications.stories.tsx | 6 +-
.../WorkspaceNotifications.tsx | 11 +-
9 files changed, 202 insertions(+), 500 deletions(-)
rename site/src/pages/WorkspacePage/{WorkspaceAlert.stories.tsx => AgentAlert.stories.tsx} (79%)
rename site/src/pages/WorkspacePage/{WorkspaceAlert.tsx => AgentAlert.tsx} (56%)
diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx
index 19fabffe3f..ea78e5d6ad 100644
--- a/site/src/modules/resources/AgentRow.tsx
+++ b/site/src/modules/resources/AgentRow.tsx
@@ -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 = ({
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 = ({
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 = ({
agent,
Boolean(hasDevcontainerErrors || shouldShowWildcardWarning),
);
-
const [selectedLogTab, setSelectedLogTab] = useState("all");
const sourceLogTabs = agent.log_sources
.filter((logSource) => {
@@ -459,20 +468,37 @@ export const AgentRow: FC = ({
- {hasStartupFeatures && (
-
-
-
-
-
-
+
+
+
+
+
+
+ {healthIssues.length > 0 && (
+
+ {healthIssues.map((issue) => (
+
+ ))}
+
+ )}
+ {hasStartupFeatures && hasAnyLogs && (
= ({
-
-
-
- )}
+ )}
+
+
+
);
};
diff --git a/site/src/modules/workspaces/WorkspaceStatusIndicator/WorkspaceStatusIndicator.tsx b/site/src/modules/workspaces/WorkspaceStatusIndicator/WorkspaceStatusIndicator.tsx
index 0885253833..186296c514 100644
--- a/site/src/modules/workspaces/WorkspaceStatusIndicator/WorkspaceStatusIndicator.tsx
+++ b/site/src/modules/workspaces/WorkspaceStatusIndicator/WorkspaceStatusIndicator.tsx
@@ -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 = ({
{children}
- {getAgentHealthIssue(workspace).detail}
+
+ One or more workspace agents need attention. Expand an agent's logs for
+ details.
+
);
};
diff --git a/site/src/modules/workspaces/health.test.ts b/site/src/modules/workspaces/health.test.ts
index 357a6ccadb..8800cb8791 100644
--- a/site/src/modules/workspaces/health.test.ts
+++ b/site/src/modules/workspaces/health.test.ts
@@ -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" }),
+ );
});
});
diff --git a/site/src/modules/workspaces/health.ts b/site/src/modules/workspaces/health.ts
index 88d8ff43b7..e1faa72597 100644
--- a/site/src/modules/workspaces/health.ts
+++ b/site/src/modules/workspaces/health.ts
@@ -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();
- 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;
}
diff --git a/site/src/pages/WorkspacePage/WorkspaceAlert.stories.tsx b/site/src/pages/WorkspacePage/AgentAlert.stories.tsx
similarity index 79%
rename from site/src/pages/WorkspacePage/WorkspaceAlert.stories.tsx
rename to site/src/pages/WorkspacePage/AgentAlert.stories.tsx
index 4ca68e98e6..464e7cb8ab 100644
--- a/site/src/pages/WorkspacePage/WorkspaceAlert.stories.tsx
+++ b/site/src/pages/WorkspacePage/AgentAlert.stories.tsx
@@ -1,9 +1,9 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
-import { WorkspaceAlert } from "./WorkspaceAlert";
+import { AgentAlert } from "./AgentAlert";
-const meta: Meta = {
- title: "pages/WorkspacePage/WorkspaceAlert",
- component: WorkspaceAlert,
+const meta: Meta = {
+ title: "pages/WorkspacePage/AgentAlert",
+ component: AgentAlert,
args: {
title: "Something went wrong",
detail:
@@ -13,7 +13,7 @@ const meta: Meta = {
};
export default meta;
-type Story = StoryObj;
+type Story = StoryObj;
export const WarningProminent: Story = {
args: {
diff --git a/site/src/pages/WorkspacePage/WorkspaceAlert.tsx b/site/src/pages/WorkspacePage/AgentAlert.tsx
similarity index 56%
rename from site/src/pages/WorkspacePage/WorkspaceAlert.tsx
rename to site/src/pages/WorkspacePage/AgentAlert.tsx
index 0343309856..3d40843c02 100644
--- a/site/src/pages/WorkspacePage/WorkspaceAlert.tsx
+++ b/site/src/pages/WorkspacePage/AgentAlert.tsx
@@ -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 = ({
+export const AgentAlert: FC = ({
title,
detail,
severity,
@@ -21,14 +21,14 @@ export const WorkspaceAlert: FC = ({
{title}
- {detail}
-
- {troubleshootingURL && (
-
+
{detail}
+ {troubleshootingURL && (
+
+
+
+ )}
);
diff --git a/site/src/pages/WorkspacePage/Workspace.tsx b/site/src/pages/WorkspacePage/Workspace.tsx
index 8a3e6c8d55..f1ca8fdb66 100644
--- a/site/src/pages/WorkspacePage/Workspace.tsx
+++ b/site/src/pages/WorkspacePage/Workspace.tsx
@@ -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 = ({
(workspace.latest_build.matched_provisioners?.available ?? 1) > 0;
const shouldShowProvisionerAlert =
workspacePending && !haveBuildLogs && !provisionersHealthy && !isRestarting;
- const troubleshootingURL = findTroubleshootingURL(workspace.latest_build);
return (
@@ -195,13 +191,6 @@ export const Workspace: FC = ({
)}
- {!workspace.health.healthy && (
-
- )}
-
{transitionStats !== undefined && (
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,
),
);
});
diff --git a/site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.tsx b/site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.tsx
index bc34eb6c97..019a656667 100644
--- a/site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.tsx
+++ b/site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.tsx
@@ -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 = ({
) {
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>;
-export const findTroubleshootingURL = (
+const findTroubleshootingURL = (
workspaceBuild: WorkspaceBuild,
): string | undefined => {
for (const resource of workspaceBuild.resources) {