mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): surface needsRestart badge on live UI surfaces (#1853)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user