feat(desktop): surface needsRestart badge on live UI surfaces (#1853)

This commit is contained in:
Will Pfleger
2026-07-14 12:15:02 -04:00
committed by GitHub
parent 7f81d93eb1
commit b1d323c4e7
8 changed files with 260 additions and 3 deletions
+1
View File
@@ -42,6 +42,7 @@ export default defineConfig({
"**/local-archive-screenshots.spec.ts",
"**/agent-readiness-screenshots.spec.ts",
"**/agent-error-state-screenshots.spec.ts",
"**/needs-restart-screenshots.spec.ts",
"**/edit-agent.spec.ts",
"**/doctor-cta-screenshots.spec.ts",
"**/pubkey-display-screenshots.spec.ts",
@@ -13,6 +13,8 @@ type AgentIdentityCardProps = {
label: string;
modelLabel?: string | null;
onClick: () => void;
/** Optional badge rendered below the label (e.g. "Restart required"). */
statusBadge?: ReactNode;
};
export function AgentIdentityCard({
@@ -24,6 +26,7 @@ export function AgentIdentityCard({
label,
modelLabel,
onClick,
statusBadge,
}: AgentIdentityCardProps) {
const trimmedAvatarUrl = avatarUrl?.trim() || null;
@@ -74,6 +77,7 @@ export function AgentIdentityCard({
{modelLabel}
</span>
) : null}
{statusBadge}
</div>
</div>
);
@@ -1,5 +1,11 @@
import * as React from "react";
import { ChevronDown, ChevronRight, Ellipsis, OctagonX } from "lucide-react";
import {
ChevronDown,
ChevronRight,
Ellipsis,
OctagonX,
RefreshCw,
} from "lucide-react";
import { formatAgentModelLabel } from "@/features/agents/lib/formatAgentModelLabel";
import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError";
@@ -9,6 +15,7 @@ import type { AgentPersona, ManagedAgent } from "@/shared/api/types";
import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext";
import { useFeedbackToasts } from "@/shared/hooks/useToastEffect";
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
@@ -344,6 +351,14 @@ function AgentPersonaCard({
}
onOpenPersonaProfile(persona);
}}
statusBadge={
agent?.needsRestart ? (
<Badge className="gap-1" variant="warning">
<RefreshCw className="h-3 w-3" />
Restart required
</Badge>
) : null
}
/>
);
}
@@ -400,6 +415,14 @@ function StandaloneAgentCard({
opensRuntimeTab ? { tab: "runtime" } : undefined,
);
}}
statusBadge={
agent.needsRestart ? (
<Badge className="gap-1" variant="warning">
<RefreshCw className="h-3 w-3" />
Restart required
</Badge>
) : null
}
/>
);
}
@@ -398,8 +398,12 @@ export function ProfileSummaryView({
<>
<ProfileRuntimeTabContent
agentInstruction={agentInstruction}
autoRestartEnabled={
managedAgent?.autoRestartOnConfigChange ?? false
}
diagnosticsFields={diagnosticsFields}
diagnosticsSummary={diagnosticsTrailing}
needsRestart={managedAgent?.needsRestart ?? false}
onOpenDiagnostics={onOpenDiagnostics}
onOpenInstructions={onOpenInstructions}
runtimeConfigurationFields={runtimeConfigurationFields}
@@ -1,6 +1,13 @@
import * as React from "react";
import type { LucideIcon } from "lucide-react";
import { Activity, Archive, ChevronRight, Info, Wrench } from "lucide-react";
import {
Activity,
Archive,
ChevronRight,
Info,
RefreshCw,
Wrench,
} from "lucide-react";
import type { ActiveTurnSummary } from "@/features/agents/activeAgentTurnsStore";
import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel";
@@ -726,8 +733,10 @@ function ArchiveStatusTooltip() {
export function ProfileRuntimeTabContent({
agentInstruction,
autoRestartEnabled = false,
diagnosticsFields,
diagnosticsSummary,
needsRestart = false,
onOpenDiagnostics,
onOpenInstructions,
runtimeConfigurationFields,
@@ -736,8 +745,12 @@ export function ProfileRuntimeTabContent({
showInstructionBlock,
}: {
agentInstruction: string | null;
/** Whether the per-agent auto-restart toggle is ON. */
autoRestartEnabled?: boolean;
diagnosticsFields: ProfileField[];
diagnosticsSummary: React.ReactNode;
/** True when the running agent's config has drifted from what it was spawned with. */
needsRestart?: boolean;
onOpenDiagnostics: () => void;
onOpenInstructions: () => void;
runtimeConfigurationFields: ProfileField[];
@@ -766,6 +779,24 @@ export function ProfileRuntimeTabContent({
return (
<div className="space-y-2">
{needsRestart ? (
<div
className="flex items-start gap-3 rounded-2xl bg-amber-500/10 px-4 py-3"
data-testid="needs-restart-banner"
>
<RefreshCw className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
<div className="min-w-0 text-sm">
<p className="font-medium text-amber-600 dark:text-amber-400">
Restart required
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{autoRestartEnabled
? "Configuration changed since this agent started. Buzz can restart it automatically after ~3 minutes idle, or stop and respawn it to apply now."
: "Configuration changed since this agent started. Automatic restart is off for this agent \u2014 stop and respawn it to apply the changes."}
</p>
</div>
</div>
) : null}
{showInstructionBlock ? (
<div className="overflow-hidden rounded-2xl bg-muted/20">
<AgentInstructionRow
+6 -1
View File
@@ -69,6 +69,8 @@ type MockManagedAgentSeed = {
backend?: RawManagedAgent["backend"];
lastError?: string | null;
lastErrorCode?: number | null;
needsRestart?: boolean;
autoRestartOnConfigChange?: boolean;
respondTo?: RawManagedAgent["respond_to"];
respondToAllowlist?: string[];
};
@@ -544,6 +546,7 @@ type RawManagedAgent = {
last_exit_code: number | null;
last_error: string | null;
last_error_code: number | null;
needs_restart?: boolean;
log_path: string;
start_on_app_launch: boolean;
auto_restart_on_config_change?: boolean;
@@ -1204,6 +1207,7 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent {
last_exit_code: agent.last_exit_code,
last_error: agent.last_error,
last_error_code: agent.last_error_code,
needs_restart: agent.needs_restart ?? false,
log_path: agent.log_path,
start_on_app_launch: agent.start_on_app_launch,
auto_restart_on_config_change: agent.auto_restart_on_config_change ?? true,
@@ -1735,9 +1739,10 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent {
last_exit_code: null,
last_error: seed.lastError ?? null,
last_error_code: seed.lastErrorCode ?? null,
needs_restart: seed.needsRestart ?? false,
log_path: `/tmp/mock-agent-${seed.pubkey}.log`,
start_on_app_launch: true,
auto_restart_on_config_change: true,
auto_restart_on_config_change: seed.autoRestartOnConfigChange ?? true,
backend: seed.backend ?? { type: "local" },
backend_agent_id: null,
respond_to: seed.respondTo ?? "owner-only",
@@ -0,0 +1,187 @@
/**
* Screenshot spec for the needsRestart badge and banner (PR #1853).
*
* Exercises two surfaces:
* - Agent grid card: warning badge ("Restart required") on standalone and
* persona-backed cards when `needsRestart: true`, absent when false.
* - Profile panel Runtime tab: amber banner with copy branched on the
* per-agent auto-restart toggle.
*/
import { expect, test } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
const SHOTS = "test-results/pr-1853-screenshots";
const STANDALONE_AGENT = {
pubkey: TEST_IDENTITIES.alice.pubkey,
name: "Local Agent",
status: "running" as const,
needsRestart: true,
};
const PERSONA_AGENT = {
pubkey: TEST_IDENTITIES.bob.pubkey,
name: "Persona Agent",
personaId: "builtin:fizz",
status: "running" as const,
needsRestart: true,
};
/** A running agent with no config drift — badge must be absent. */
const NO_DRIFT_AGENT = {
pubkey: TEST_IDENTITIES.tyler.pubkey,
name: "Stable Agent",
status: "running" as const,
needsRestart: false,
};
async function gotoAgentsView(page: import("@playwright/test").Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("open-agents-view")).toBeVisible({
timeout: 10_000,
});
await page.getByTestId("open-agents-view").click();
await expect(page.getByTestId("agents-library-personas")).toBeVisible({
timeout: 10_000,
});
}
test.describe("needs-restart screenshots", () => {
test.use({ viewport: { width: 1280, height: 900 } });
test.beforeEach(async ({ page }) => {
page.on("pageerror", (err) => {
console.error(
"PAGE ERROR:",
err.message,
err.stack?.split("\n").slice(0, 5).join("\n"),
);
});
});
test("01-grid-standalone-restart-badge", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [STANDALONE_AGENT, NO_DRIFT_AGENT],
});
await gotoAgentsView(page);
// Drifted card shows the badge.
const agentCard = page.getByTestId(
`managed-agent-${STANDALONE_AGENT.pubkey}`,
);
await expect(agentCard).toBeVisible({ timeout: 10_000 });
await expect(
agentCard.getByText("Restart required", { exact: true }),
).toBeVisible();
// Non-drifted card does NOT show the badge.
const stableCard = page.getByTestId(
`managed-agent-${NO_DRIFT_AGENT.pubkey}`,
);
await expect(stableCard).toBeVisible({ timeout: 10_000 });
await expect(
stableCard.getByText("Restart required", { exact: true }),
).toHaveCount(0);
await waitForAnimations(page);
await agentCard.screenshot({
path: `${SHOTS}/01-grid-standalone-restart-badge.png`,
});
});
test("02-grid-persona-restart-badge", async ({ page }) => {
await installMockBridge(page, {
activePersonaIds: ["builtin:fizz"],
managedAgents: [PERSONA_AGENT],
});
await gotoAgentsView(page);
const personaCard = page.getByTestId(
`persona-agent-row-${PERSONA_AGENT.personaId}`,
);
await expect(personaCard).toBeVisible({ timeout: 10_000 });
await expect(
personaCard.getByText("Restart required", { exact: true }),
).toBeVisible();
await waitForAnimations(page);
await personaCard.screenshot({
path: `${SHOTS}/02-grid-persona-restart-badge.png`,
});
});
test("03-runtime-tab-restart-banner", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [STANDALONE_AGENT],
});
await gotoAgentsView(page);
// Click the agent card to open the profile panel.
const agentButton = page.getByRole("button", {
name: `${STANDALONE_AGENT.name} agent profile`,
});
await expect(agentButton).toBeVisible({ timeout: 10_000 });
await agentButton.click();
const panel = page.getByTestId("user-profile-panel");
await expect(panel).toBeVisible({ timeout: 10_000 });
// Switch to the Runtime tab.
await panel.getByRole("tab", { name: "Runtime" }).click();
// Wait for the restart banner to appear.
const banner = panel.getByTestId("needs-restart-banner");
await expect(banner).toBeVisible({ timeout: 10_000 });
// Auto-restart defaults ON — verify the enabled copy.
await expect(
banner.getByText("Buzz can restart it automatically"),
).toBeVisible();
await waitForAnimations(page);
await banner.screenshot({
path: `${SHOTS}/03-runtime-tab-restart-banner.png`,
});
});
test("04-runtime-tab-restart-banner-auto-off", async ({ page }) => {
const agentAutoOff = {
...STANDALONE_AGENT,
autoRestartOnConfigChange: false,
};
await installMockBridge(page, {
managedAgents: [agentAutoOff],
});
await gotoAgentsView(page);
const agentButton = page.getByRole("button", {
name: `${agentAutoOff.name} agent profile`,
});
await expect(agentButton).toBeVisible({ timeout: 10_000 });
await agentButton.click();
const panel = page.getByTestId("user-profile-panel");
await expect(panel).toBeVisible({ timeout: 10_000 });
await panel.getByRole("tab", { name: "Runtime" }).click();
const banner = panel.getByTestId("needs-restart-banner");
await expect(banner).toBeVisible({ timeout: 10_000 });
// Auto-restart OFF — verify the disabled copy.
await expect(banner.getByText("Automatic restart is off")).toBeVisible();
await waitForAnimations(page);
await banner.screenshot({
path: `${SHOTS}/04-runtime-tab-restart-banner-auto-off.png`,
});
});
});
+2
View File
@@ -54,6 +54,8 @@ type MockManagedAgentSeed = {
| { type: "provider"; id: string; config: Record<string, unknown> };
lastError?: string | null;
lastErrorCode?: number | null;
needsRestart?: boolean;
autoRestartOnConfigChange?: boolean;
respondTo?: "owner-only" | "allowlist" | "anyone";
respondToAllowlist?: string[];
};