mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Refine agent runtime controls (#5026)
## Summary - replace ambiguous avatar play controls with centered Start and Restart pills - preserve avatar clipping while smoothly morphing actions into the running status dot - use accessible warning contrast and real restart behavior without a duplicate status badge ## Validation - `just ci` - focused Playwright coverage for morphing, shared geometry, and light/dark contrast Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -44,7 +44,7 @@ export function getManagedAgentPrimaryActionLabel(agent: ManagedAgent) {
|
||||
return "Stop";
|
||||
}
|
||||
|
||||
return agent.status === "stopped" ? "Respawn" : "Spawn";
|
||||
return agent.status === "stopped" ? "Restart Agent" : "Start Agent";
|
||||
}
|
||||
|
||||
export function resolveManagedAgentChannelId(
|
||||
|
||||
@@ -57,9 +57,9 @@ export const MANAGED_AGENT_PAIR_ACTION_LABELS: Record<
|
||||
ManagedAgentPairAction,
|
||||
string
|
||||
> = {
|
||||
start: "Start",
|
||||
stop: "Stop",
|
||||
restart: "Restart",
|
||||
start: "Start Agent",
|
||||
stop: "Stop Agent",
|
||||
restart: "Restart Agent",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { CircleAlert, Play } from "lucide-react";
|
||||
import { CircleAlert } from "lucide-react";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
|
||||
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
|
||||
import {
|
||||
type AvatarBadgeCurve,
|
||||
MaskedAvatarBadgeFrame,
|
||||
@@ -18,8 +17,10 @@ type AgentRuntimeAvatarControlProps = {
|
||||
errorLabel?: string | null;
|
||||
errorTestId?: string;
|
||||
isActive: boolean;
|
||||
isRestarting?: boolean;
|
||||
isStarting: boolean;
|
||||
label: string;
|
||||
requiresRestart?: boolean;
|
||||
startTestId: string;
|
||||
onOpenError?: () => void;
|
||||
onStart: () => void;
|
||||
@@ -29,6 +30,7 @@ const TAILWIND_SPACING = {
|
||||
"1": 4,
|
||||
"2": 8,
|
||||
"2.5": 10,
|
||||
"3.5": 14,
|
||||
"6": 24,
|
||||
"11": 44,
|
||||
"24": 96,
|
||||
@@ -36,44 +38,58 @@ const TAILWIND_SPACING = {
|
||||
|
||||
const AGENT_AVATAR_SIZE = TAILWIND_SPACING["24"];
|
||||
const ACTION_BADGE_SIZE = TAILWIND_SPACING["11"];
|
||||
const ACTIVE_BADGE_SIZE = TAILWIND_SPACING["6"];
|
||||
const ACTION_BADGE_OFFSET = TAILWIND_SPACING["2.5"];
|
||||
const ACTION_BUTTON_HEIGHT = 36;
|
||||
const START_ACTION_BADGE_WIDTH = 56;
|
||||
const RESTART_ACTION_BADGE_WIDTH = 72;
|
||||
const ACTIVE_BADGE_CUTOUT_SIZE = TAILWIND_SPACING["6"];
|
||||
const ACTIVE_DOT_SIZE = 18;
|
||||
const ACTION_BADGE_OFFSET = TAILWIND_SPACING["3.5"] + TAILWIND_SPACING["1"];
|
||||
const ACTIVE_BADGE_INSET = TAILWIND_SPACING["1"];
|
||||
const ACTIVE_DOT_CLASS_NAME = "h-4.5 w-4.5";
|
||||
const PROFILE_STATUS_CUTOUT_RATIO = 1.25;
|
||||
|
||||
function getBadgeCenter(badgeSize: number, outwardOffset: number) {
|
||||
return AGENT_AVATAR_SIZE + outwardOffset - badgeSize / 2;
|
||||
}
|
||||
|
||||
function getActionBadge(offset: number) {
|
||||
function getActionBadge(width: number, height: number, offset: number) {
|
||||
const centerY = getBadgeCenter(ACTION_BADGE_SIZE, offset);
|
||||
const clearance = (ACTION_BADGE_SIZE - height) / 2;
|
||||
|
||||
return {
|
||||
cutout: {
|
||||
cx: getBadgeCenter(ACTION_BADGE_SIZE, offset),
|
||||
cy: getBadgeCenter(ACTION_BADGE_SIZE, offset),
|
||||
// Keep the cutout on the avatar edge so the mask has the same soft,
|
||||
// two-point join as the status dot. Unlike the status dot, center the
|
||||
// primary action horizontally to make its purpose easier to spot.
|
||||
cx: AGENT_AVATAR_SIZE / 2,
|
||||
cy: centerY,
|
||||
r: ACTION_BADGE_SIZE / 2,
|
||||
},
|
||||
shell: {
|
||||
bottom: -offset,
|
||||
height: ACTION_BADGE_SIZE,
|
||||
right: -offset,
|
||||
width: ACTION_BADGE_SIZE,
|
||||
bottom: AGENT_AVATAR_SIZE - centerY - height / 2,
|
||||
height,
|
||||
right: (AGENT_AVATAR_SIZE - width) / 2,
|
||||
width,
|
||||
},
|
||||
// Carry the vertical clearance around the end caps horizontally too, so
|
||||
// the avatar gap stays even around the pill.
|
||||
cutoutWidth: width + clearance * 2,
|
||||
} as const;
|
||||
}
|
||||
|
||||
function getActiveBadge(inset: number) {
|
||||
const center = getBadgeCenter(ACTIVE_BADGE_CUTOUT_SIZE, -inset);
|
||||
|
||||
return {
|
||||
cutout: {
|
||||
cx: getBadgeCenter(ACTIVE_BADGE_SIZE, -inset),
|
||||
cy: getBadgeCenter(ACTIVE_BADGE_SIZE, -inset),
|
||||
r: (ACTIVE_BADGE_SIZE / 2) * PROFILE_STATUS_CUTOUT_RATIO,
|
||||
cx: center,
|
||||
cy: center,
|
||||
r: (ACTIVE_BADGE_CUTOUT_SIZE / 2) * PROFILE_STATUS_CUTOUT_RATIO,
|
||||
},
|
||||
shell: {
|
||||
bottom: inset,
|
||||
height: ACTIVE_BADGE_SIZE,
|
||||
right: inset,
|
||||
width: ACTIVE_BADGE_SIZE,
|
||||
bottom: AGENT_AVATAR_SIZE - center - ACTIVE_DOT_SIZE / 2,
|
||||
height: ACTIVE_DOT_SIZE,
|
||||
right: AGENT_AVATAR_SIZE - center - ACTIVE_DOT_SIZE / 2,
|
||||
width: ACTIVE_DOT_SIZE,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
@@ -87,12 +103,26 @@ const ACTION_MASK_CURVE = {
|
||||
handleLengthRatio: 0.26,
|
||||
} satisfies AvatarBadgeCurve;
|
||||
|
||||
const ACTION_BADGE = getActionBadge(ACTION_BADGE_OFFSET);
|
||||
const START_ACTION_BADGE = getActionBadge(
|
||||
START_ACTION_BADGE_WIDTH,
|
||||
ACTION_BUTTON_HEIGHT,
|
||||
ACTION_BADGE_OFFSET,
|
||||
);
|
||||
const RESTART_ACTION_BADGE = getActionBadge(
|
||||
RESTART_ACTION_BADGE_WIDTH,
|
||||
ACTION_BUTTON_HEIGHT,
|
||||
ACTION_BADGE_OFFSET,
|
||||
);
|
||||
const ERROR_BADGE = getActionBadge(
|
||||
ACTION_BADGE_SIZE,
|
||||
ACTION_BUTTON_HEIGHT,
|
||||
ACTION_BADGE_OFFSET,
|
||||
);
|
||||
const ACTIVE_BADGE = getActiveBadge(ACTIVE_BADGE_INSET);
|
||||
|
||||
const MASK_TRANSITION = {
|
||||
duration: 0.22,
|
||||
ease: [0.23, 1, 0.32, 1],
|
||||
duration: 0.3,
|
||||
ease: [0.4, 0, 0.2, 1],
|
||||
} as const;
|
||||
|
||||
export function AgentRuntimeAvatarControl({
|
||||
@@ -101,45 +131,66 @@ export function AgentRuntimeAvatarControl({
|
||||
errorLabel,
|
||||
errorTestId,
|
||||
isActive,
|
||||
isRestarting = false,
|
||||
isStarting,
|
||||
label,
|
||||
requiresRestart = false,
|
||||
startTestId,
|
||||
onOpenError,
|
||||
onStart,
|
||||
}: AgentRuntimeAvatarControlProps) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const trimmedAvatarUrl = avatarUrl?.trim() || null;
|
||||
const actionLabel = isStarting ? `Starting ${label}` : `Start ${label}`;
|
||||
const hasError = !isActive && !isStarting && Boolean(errorLabel);
|
||||
const isRestartAction = requiresRestart || isRestarting;
|
||||
const actionLabel = isRestarting
|
||||
? "Restarting Agent"
|
||||
: isStarting
|
||||
? "Starting Agent"
|
||||
: isRestartAction
|
||||
? "Restart Agent"
|
||||
: "Start Agent";
|
||||
const actionText = isRestartAction ? "Restart" : "Start";
|
||||
const isPending = isStarting || isRestarting;
|
||||
const showRunningDot = isActive && !isRestartAction;
|
||||
const hasError = !isActive && !isPending && Boolean(errorLabel);
|
||||
const errorActionLabel = `${label} has a runtime error. Open runtime details.`;
|
||||
const transition = shouldReduceMotion ? { duration: 0 } : MASK_TRANSITION;
|
||||
const badge = isActive ? ACTIVE_BADGE : ACTION_BADGE;
|
||||
const actionBadge = isRestartAction
|
||||
? RESTART_ACTION_BADGE
|
||||
: START_ACTION_BADGE;
|
||||
const badge = showRunningDot
|
||||
? ACTIVE_BADGE
|
||||
: hasError
|
||||
? ERROR_BADGE
|
||||
: actionBadge;
|
||||
const actionCutoutWidth =
|
||||
showRunningDot || hasError ? undefined : actionBadge.cutoutWidth;
|
||||
|
||||
return (
|
||||
<MaskedAvatarBadgeFrame
|
||||
badge={
|
||||
<span className="grid h-full w-full place-items-center">
|
||||
{isActive ? (
|
||||
{showRunningDot ? (
|
||||
<span
|
||||
aria-label={`${label} is running`}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full"
|
||||
className="h-full w-full rounded-full"
|
||||
data-testid={activeTestId}
|
||||
role="img"
|
||||
title={`${label} is running`}
|
||||
>
|
||||
<PresenceDot className={ACTIVE_DOT_CLASS_NAME} status="online" />
|
||||
</span>
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
aria-label={hasError ? errorActionLabel : actionLabel}
|
||||
className={cn(
|
||||
"pointer-events-auto flex h-9 w-9 items-center justify-center rounded-full transition-colors duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-default disabled:opacity-90",
|
||||
"pointer-events-auto flex h-full w-full items-center justify-center rounded-full px-2.5 text-xs font-semibold leading-none transition-colors duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-default disabled:opacity-90",
|
||||
hasError
|
||||
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
: isRestartAction
|
||||
? "bg-transparent text-amber-800 hover:bg-amber-500/10 dark:text-amber-400"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
)}
|
||||
data-testid={hasError ? errorTestId : startTestId}
|
||||
disabled={isStarting}
|
||||
disabled={isPending}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (hasError) {
|
||||
@@ -151,26 +202,36 @@ export function AgentRuntimeAvatarControl({
|
||||
title={hasError ? errorLabel || errorActionLabel : actionLabel}
|
||||
type="button"
|
||||
>
|
||||
<span className="grid h-4 w-4 place-items-center">
|
||||
{isStarting ? (
|
||||
<Spinner
|
||||
aria-label={actionLabel}
|
||||
className="h-4 w-4 border-2"
|
||||
/>
|
||||
) : hasError ? (
|
||||
<CircleAlert className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4 fill-current" />
|
||||
)}
|
||||
</span>
|
||||
{isPending ? (
|
||||
<Spinner
|
||||
aria-label={actionLabel}
|
||||
className="h-4 w-4 border-2"
|
||||
/>
|
||||
) : hasError ? (
|
||||
<CircleAlert className="h-4 w-4" />
|
||||
) : (
|
||||
actionText
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
badgeBox={badge.shell}
|
||||
badgeClassName={cn(
|
||||
"transition-colors ease-in-out",
|
||||
shouldReduceMotion ? "duration-0" : "duration-300",
|
||||
showRunningDot
|
||||
? "bg-emerald-500"
|
||||
: hasError
|
||||
? "bg-destructive"
|
||||
: isRestartAction
|
||||
? "bg-amber-500/15"
|
||||
: "bg-primary",
|
||||
)}
|
||||
className="h-24 w-24"
|
||||
curve={isActive ? STATUS_DOT_MASK_CURVE : ACTION_MASK_CURVE}
|
||||
curve={showRunningDot ? STATUS_DOT_MASK_CURVE : ACTION_MASK_CURVE}
|
||||
cutout={badge.cutout}
|
||||
cutoutWidth={actionCutoutWidth}
|
||||
maskTransition={transition}
|
||||
size={AGENT_AVATAR_SIZE}
|
||||
>
|
||||
|
||||
@@ -225,6 +225,7 @@ export function AgentsView() {
|
||||
isActionPending={isActionPending}
|
||||
isAgentsLoading={agents.managedAgentsQuery.isLoading}
|
||||
startingAgentPubkey={agents.startingAgentPubkey}
|
||||
restartingAgentPubkey={agents.restartingAgentPubkey}
|
||||
startingPersonaIds={agents.startingPersonaIds}
|
||||
onOpenAgentProfile={(pubkey, options) => {
|
||||
openProfilePanel?.(pubkey, options);
|
||||
@@ -235,6 +236,9 @@ export function AgentsView() {
|
||||
onStartAgent={(pubkey) => {
|
||||
void agents.handleStart(pubkey);
|
||||
}}
|
||||
onRestartAgent={(pubkey) => {
|
||||
void agents.handleRestart(pubkey);
|
||||
}}
|
||||
onStartPersona={(persona) => {
|
||||
void agents.handleStartPersona(persona);
|
||||
}}
|
||||
|
||||
@@ -10,7 +10,6 @@ import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelConte
|
||||
import { useFeedbackToasts } from "@/shared/hooks/useToastEffect";
|
||||
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { RestartDiffBadge } from "./RestartDiffBadge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -32,6 +31,7 @@ type UnifiedAgentsSectionProps = {
|
||||
agentsError: Error | null;
|
||||
isActionPending: boolean;
|
||||
isAgentsLoading: boolean;
|
||||
restartingAgentPubkey: string | null;
|
||||
startingAgentPubkey: string | null;
|
||||
startingPersonaIds: ReadonlySet<string>;
|
||||
onOpenAgentProfile: (
|
||||
@@ -39,6 +39,7 @@ type UnifiedAgentsSectionProps = {
|
||||
options?: ProfilePanelOpenOptions,
|
||||
) => void;
|
||||
onOpenPersonaProfile: (persona: AgentPersona) => void;
|
||||
onRestartAgent: (pubkey: string) => void;
|
||||
onStartAgent: (pubkey: string) => void;
|
||||
onStartPersona: (persona: AgentPersona) => void;
|
||||
personas: AgentPersona[];
|
||||
@@ -75,10 +76,12 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
agentsError,
|
||||
isActionPending,
|
||||
isAgentsLoading,
|
||||
restartingAgentPubkey,
|
||||
startingAgentPubkey,
|
||||
startingPersonaIds,
|
||||
onOpenAgentProfile,
|
||||
onOpenPersonaProfile,
|
||||
onRestartAgent,
|
||||
onStartAgent,
|
||||
onStartPersona,
|
||||
personas,
|
||||
@@ -175,10 +178,12 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
defaultModel={defaultModel}
|
||||
key={group.persona.id}
|
||||
persona={group.persona}
|
||||
restartingAgentPubkey={restartingAgentPubkey}
|
||||
startingAgentPubkey={startingAgentPubkey}
|
||||
startingPersonaIds={startingPersonaIds}
|
||||
onOpenAgentProfile={onOpenAgentProfile}
|
||||
onOpenPersonaProfile={onOpenPersonaProfile}
|
||||
onRestartAgent={onRestartAgent}
|
||||
onStartAgent={onStartAgent}
|
||||
onStartPersona={onStartPersona}
|
||||
/>
|
||||
@@ -199,9 +204,11 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
defaultModel={defaultModel}
|
||||
groupKey="__unknown__"
|
||||
label="Unknown agents"
|
||||
restartingAgentPubkey={restartingAgentPubkey}
|
||||
startingAgentPubkey={startingAgentPubkey}
|
||||
onToggle={toggle}
|
||||
onOpenAgentProfile={onOpenAgentProfile}
|
||||
onRestartAgent={onRestartAgent}
|
||||
onStartAgent={onStartAgent}
|
||||
/>
|
||||
) : null}
|
||||
@@ -212,9 +219,11 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
defaultModel={defaultModel}
|
||||
groupKey="__ungrouped__"
|
||||
label="Custom agents"
|
||||
restartingAgentPubkey={restartingAgentPubkey}
|
||||
startingAgentPubkey={startingAgentPubkey}
|
||||
onToggle={toggle}
|
||||
onOpenAgentProfile={onOpenAgentProfile}
|
||||
onRestartAgent={onRestartAgent}
|
||||
onStartAgent={onStartAgent}
|
||||
/>
|
||||
) : null}
|
||||
@@ -244,10 +253,12 @@ function AgentPersonaCard({
|
||||
agent,
|
||||
defaultModel,
|
||||
persona,
|
||||
restartingAgentPubkey,
|
||||
startingAgentPubkey,
|
||||
startingPersonaIds,
|
||||
onOpenAgentProfile,
|
||||
onOpenPersonaProfile,
|
||||
onRestartAgent,
|
||||
onStartAgent,
|
||||
onStartPersona,
|
||||
}: {
|
||||
@@ -258,6 +269,7 @@ function AgentPersonaCard({
|
||||
agent: ManagedAgent | undefined;
|
||||
defaultModel: string;
|
||||
persona: AgentPersona;
|
||||
restartingAgentPubkey: string | null;
|
||||
startingAgentPubkey: string | null;
|
||||
startingPersonaIds: ReadonlySet<string>;
|
||||
onOpenAgentProfile: (
|
||||
@@ -265,6 +277,7 @@ function AgentPersonaCard({
|
||||
options?: ProfilePanelOpenOptions,
|
||||
) => void;
|
||||
onOpenPersonaProfile: (persona: AgentPersona) => void;
|
||||
onRestartAgent: (pubkey: string) => void;
|
||||
onStartAgent: (pubkey: string) => void;
|
||||
onStartPersona: (persona: AgentPersona) => void;
|
||||
}) {
|
||||
@@ -299,13 +312,19 @@ function AgentPersonaCard({
|
||||
errorLabel={friendlyError}
|
||||
errorTestId={`agent-runtime-error-${agent.pubkey}`}
|
||||
isActive={isActive}
|
||||
isRestarting={restartingAgentPubkey === agent.pubkey}
|
||||
isStarting={startingAgentPubkey === agent.pubkey}
|
||||
label={title}
|
||||
requiresRestart={agent.needsRestart}
|
||||
startTestId={`agent-runtime-start-${agent.pubkey}`}
|
||||
onOpenError={() => {
|
||||
onOpenAgentProfile(agent.pubkey, { tab: "runtime" });
|
||||
}}
|
||||
onStart={() => onStartAgent(agent.pubkey)}
|
||||
onStart={() =>
|
||||
agent.needsRestart
|
||||
? onRestartAgent(agent.pubkey)
|
||||
: onStartAgent(agent.pubkey)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<AgentRuntimeAvatarControl
|
||||
@@ -339,11 +358,6 @@ function AgentPersonaCard({
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Configuration missing
|
||||
</Badge>
|
||||
) : agent?.needsRestart ? (
|
||||
<RestartDiffBadge
|
||||
autoRestartEnabled={agent.autoRestartOnConfigChange}
|
||||
restartDiff={agent.restartDiff}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
@@ -353,17 +367,21 @@ function AgentPersonaCard({
|
||||
function StandaloneAgentCard({
|
||||
agent,
|
||||
defaultModel,
|
||||
restartingAgentPubkey,
|
||||
startingAgentPubkey,
|
||||
onOpenAgentProfile,
|
||||
onRestartAgent,
|
||||
onStartAgent,
|
||||
}: {
|
||||
agent: ManagedAgent;
|
||||
defaultModel: string;
|
||||
restartingAgentPubkey: string | null;
|
||||
startingAgentPubkey: string | null;
|
||||
onOpenAgentProfile: (
|
||||
pubkey: string,
|
||||
options?: ProfilePanelOpenOptions,
|
||||
) => void;
|
||||
onRestartAgent: (pubkey: string) => void;
|
||||
onStartAgent: (pubkey: string) => void;
|
||||
}) {
|
||||
const title = agent.name;
|
||||
@@ -385,13 +403,19 @@ function StandaloneAgentCard({
|
||||
errorLabel={friendlyError}
|
||||
errorTestId={`agent-runtime-error-${agent.pubkey}`}
|
||||
isActive={isActive}
|
||||
isRestarting={restartingAgentPubkey === agent.pubkey}
|
||||
isStarting={startingAgentPubkey === agent.pubkey}
|
||||
label={title}
|
||||
requiresRestart={agent.needsRestart}
|
||||
startTestId={`agent-runtime-start-${agent.pubkey}`}
|
||||
onOpenError={() => {
|
||||
onOpenAgentProfile(agent.pubkey, { tab: "runtime" });
|
||||
}}
|
||||
onStart={() => onStartAgent(agent.pubkey)}
|
||||
onStart={() =>
|
||||
agent.needsRestart
|
||||
? onRestartAgent(agent.pubkey)
|
||||
: onStartAgent(agent.pubkey)
|
||||
}
|
||||
/>
|
||||
}
|
||||
avatarUrl={profileQuery.data?.avatarUrl}
|
||||
@@ -414,11 +438,6 @@ function StandaloneAgentCard({
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Configuration missing
|
||||
</Badge>
|
||||
) : agent.needsRestart ? (
|
||||
<RestartDiffBadge
|
||||
autoRestartEnabled={agent.autoRestartOnConfigChange}
|
||||
restartDiff={agent.restartDiff}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
@@ -498,9 +517,11 @@ function CollapsibleAgentGroup({
|
||||
agents,
|
||||
collapsed,
|
||||
defaultModel,
|
||||
restartingAgentPubkey,
|
||||
startingAgentPubkey,
|
||||
onToggle,
|
||||
onOpenAgentProfile,
|
||||
onRestartAgent,
|
||||
onStartAgent,
|
||||
}: {
|
||||
groupKey: string;
|
||||
@@ -508,12 +529,14 @@ function CollapsibleAgentGroup({
|
||||
agents: ManagedAgent[];
|
||||
collapsed: ReadonlySet<string>;
|
||||
defaultModel: string;
|
||||
restartingAgentPubkey: string | null;
|
||||
startingAgentPubkey: string | null;
|
||||
onToggle: (key: string) => void;
|
||||
onOpenAgentProfile: (
|
||||
pubkey: string,
|
||||
options?: ProfilePanelOpenOptions,
|
||||
) => void;
|
||||
onRestartAgent: (pubkey: string) => void;
|
||||
onStartAgent: (pubkey: string) => void;
|
||||
}) {
|
||||
const isCollapsed = collapsed.has(groupKey);
|
||||
@@ -539,8 +562,10 @@ function CollapsibleAgentGroup({
|
||||
agent={agent}
|
||||
defaultModel={defaultModel}
|
||||
key={agent.pubkey}
|
||||
restartingAgentPubkey={restartingAgentPubkey}
|
||||
startingAgentPubkey={startingAgentPubkey}
|
||||
onOpenAgentProfile={onOpenAgentProfile}
|
||||
onRestartAgent={onRestartAgent}
|
||||
onStartAgent={onStartAgent}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import {
|
||||
deleteManagedAgentWithRules,
|
||||
isManagedAgentActive,
|
||||
respawnManagedAgentWithRules,
|
||||
startManagedAgentWithRules,
|
||||
stopManagedAgentWithRules,
|
||||
} from "../lib/managedAgentControlActions";
|
||||
@@ -57,6 +58,9 @@ export function useManagedAgentActions() {
|
||||
ReadonlySet<string>
|
||||
>(() => new Set());
|
||||
const startingPersonaIdsRef = React.useRef(new Set<string>());
|
||||
const [restartingAgentPubkey, setRestartingAgentPubkey] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [logAgentPubkey, setLogAgentPubkey] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
@@ -174,6 +178,30 @@ export function useManagedAgentActions() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRestart(pubkey: string) {
|
||||
if (restartingAgentPubkey) return;
|
||||
clearFeedback();
|
||||
setRestartingAgentPubkey(pubkey);
|
||||
try {
|
||||
const agent = managedAgents.find(
|
||||
(candidate) => candidate.pubkey === pubkey,
|
||||
);
|
||||
if (!agent) return;
|
||||
await respawnManagedAgentWithRules({
|
||||
agent,
|
||||
startManagedAgent: startMutation.mutateAsync,
|
||||
stopManagedAgent: stopMutation.mutateAsync,
|
||||
onStopped: () => clearActiveTurnsForAgentOnStop(agent.pubkey),
|
||||
});
|
||||
} catch (error) {
|
||||
setActionErrorMessage(
|
||||
error instanceof Error ? error.message : "Failed to restart agent.",
|
||||
);
|
||||
} finally {
|
||||
setRestartingAgentPubkey(null);
|
||||
}
|
||||
}
|
||||
|
||||
function setPersonaStartPending(personaId: string, pending: boolean) {
|
||||
const next = new Set(startingPersonaIdsRef.current);
|
||||
if (pending) {
|
||||
@@ -387,6 +415,7 @@ export function useManagedAgentActions() {
|
||||
}
|
||||
|
||||
const isPending =
|
||||
restartingAgentPubkey !== null ||
|
||||
createAgentMutation.isPending ||
|
||||
startMutation.isPending ||
|
||||
stopMutation.isPending ||
|
||||
@@ -420,8 +449,10 @@ export function useManagedAgentActions() {
|
||||
actionErrorMessage,
|
||||
setActionErrorMessage,
|
||||
startingAgentPubkey,
|
||||
restartingAgentPubkey,
|
||||
startingPersonaIds,
|
||||
handleStart,
|
||||
handleRestart,
|
||||
handleStartPersona,
|
||||
handleStop,
|
||||
handleDelete,
|
||||
|
||||
@@ -43,12 +43,14 @@ type BadgeMotionTarget = {
|
||||
type MaskedAvatarBadgeFrameProps = {
|
||||
badge?: React.ReactNode;
|
||||
badgeBox?: AvatarBadgeBox;
|
||||
badgeClassName?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
clipTestId?: string;
|
||||
cornerRadius?: number;
|
||||
curve?: AvatarBadgeCurve;
|
||||
cutout?: AvatarBadgeCircle;
|
||||
cutoutWidth?: number;
|
||||
maskMode?: "clip-path" | "radial";
|
||||
maskTransition?: React.ComponentProps<typeof motion.path>["transition"];
|
||||
size: number;
|
||||
@@ -323,6 +325,30 @@ function sampleArc(
|
||||
);
|
||||
}
|
||||
|
||||
function sampleStableOuterBoundary(
|
||||
avatar: AvatarBadgeCircle,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
direction: 1 | -1,
|
||||
largeArc: boolean,
|
||||
segments: number,
|
||||
) {
|
||||
const outerBoundary = { ...avatar, r: avatar.r * 4 };
|
||||
|
||||
return [
|
||||
getPointOnCircle(outerBoundary, startAngle),
|
||||
...sampleArc(
|
||||
outerBoundary,
|
||||
startAngle,
|
||||
endAngle,
|
||||
direction,
|
||||
largeArc,
|
||||
segments - 2,
|
||||
),
|
||||
getPointOnCircle(avatar, endAngle),
|
||||
];
|
||||
}
|
||||
|
||||
function toPolygonPoint(point: Point, size: number) {
|
||||
return `${toPercent(point.x / size)} ${toPercent(point.y / size)}`;
|
||||
}
|
||||
@@ -331,6 +357,7 @@ function getRoundedAvatarMaskPolygon(
|
||||
size: number,
|
||||
cutout: AvatarBadgeCircle,
|
||||
curve?: AvatarBadgeCurve,
|
||||
stabilizeOuterBoundary = false,
|
||||
) {
|
||||
const {
|
||||
avatar,
|
||||
@@ -361,14 +388,23 @@ function getRoundedAvatarMaskPolygon(
|
||||
avatarUpper,
|
||||
12,
|
||||
),
|
||||
...sampleArc(
|
||||
avatar,
|
||||
getAngle(avatar, avatarUpper),
|
||||
getAngle(avatar, avatarLower),
|
||||
-1,
|
||||
true,
|
||||
96,
|
||||
),
|
||||
...(stabilizeOuterBoundary
|
||||
? sampleStableOuterBoundary(
|
||||
avatar,
|
||||
getAngle(avatar, avatarUpper),
|
||||
getAngle(avatar, avatarLower),
|
||||
-1,
|
||||
true,
|
||||
96,
|
||||
)
|
||||
: sampleArc(
|
||||
avatar,
|
||||
getAngle(avatar, avatarUpper),
|
||||
getAngle(avatar, avatarLower),
|
||||
-1,
|
||||
true,
|
||||
96,
|
||||
)),
|
||||
...sampleCubic(
|
||||
avatarLower,
|
||||
getControlPoint(avatarLower, lowerAvatarTangent, lowerHandleLength),
|
||||
@@ -389,6 +425,141 @@ function getRoundedAvatarMaskPolygon(
|
||||
return `polygon(${points.map((point) => toPolygonPoint(point, size)).join(", ")})`;
|
||||
}
|
||||
|
||||
function getRoundedAvatarCapsuleMaskPolygon(
|
||||
size: number,
|
||||
cutout: AvatarBadgeCircle,
|
||||
cutoutWidth: number,
|
||||
curve?: AvatarBadgeCurve,
|
||||
stabilizeOuterBoundary = false,
|
||||
) {
|
||||
const resolvedCurve = { ...DEFAULT_AVATAR_BADGE_CURVE, ...curve };
|
||||
const avatar = {
|
||||
cx: size / 2,
|
||||
cy: size / 2,
|
||||
r: size / 2,
|
||||
};
|
||||
const straightHalfWidth = Math.max(0, cutoutWidth / 2 - cutout.r);
|
||||
const leftCap = {
|
||||
cx: cutout.cx - straightHalfWidth,
|
||||
cy: cutout.cy,
|
||||
r: cutout.r,
|
||||
};
|
||||
const rightCap = {
|
||||
cx: cutout.cx + straightHalfWidth,
|
||||
cy: cutout.cy,
|
||||
r: cutout.r,
|
||||
};
|
||||
const leftIntersection = getCircleIntersections(avatar, leftCap).reduce(
|
||||
(leftmost, point) => (point.x < leftmost.x ? point : leftmost),
|
||||
);
|
||||
const rightIntersection = getCircleIntersections(avatar, rightCap).reduce(
|
||||
(rightmost, point) => (point.x > rightmost.x ? point : rightmost),
|
||||
);
|
||||
const cutoutRoundingAngle = Math.min(
|
||||
resolvedCurve.cutoutRoundingMaxAngle,
|
||||
Math.max(
|
||||
resolvedCurve.cutoutRoundingMinAngle,
|
||||
resolvedCurve.cutoutRoundingLength / cutout.r,
|
||||
),
|
||||
);
|
||||
const avatarLeft = getPointOnCircle(
|
||||
avatar,
|
||||
getAngle(avatar, leftIntersection) + resolvedCurve.avatarRoundingAngle,
|
||||
);
|
||||
const avatarRight = getPointOnCircle(
|
||||
avatar,
|
||||
getAngle(avatar, rightIntersection) - resolvedCurve.avatarRoundingAngle,
|
||||
);
|
||||
const cutoutLeft = getPointOnCircle(
|
||||
leftCap,
|
||||
getAngle(leftCap, leftIntersection) + cutoutRoundingAngle,
|
||||
);
|
||||
const cutoutRight = getPointOnCircle(
|
||||
rightCap,
|
||||
getAngle(rightCap, rightIntersection) - cutoutRoundingAngle,
|
||||
);
|
||||
const leftHandleLength = Math.min(
|
||||
cutout.r * resolvedCurve.handleLengthRatio,
|
||||
getDistance(cutoutLeft, avatarLeft) * resolvedCurve.handleDistanceRatio,
|
||||
);
|
||||
const rightHandleLength = Math.min(
|
||||
cutout.r * resolvedCurve.handleLengthRatio,
|
||||
getDistance(avatarRight, cutoutRight) * resolvedCurve.handleDistanceRatio,
|
||||
);
|
||||
const cutoutLeftTangent = getTangent(getAngle(leftCap, cutoutLeft), -1);
|
||||
const avatarLeftTangent = getTangent(getAngle(avatar, avatarLeft), 1);
|
||||
const avatarRightTangent = getTangent(getAngle(avatar, avatarRight), 1);
|
||||
const cutoutRightTangent = getTangent(getAngle(rightCap, cutoutRight), -1);
|
||||
const points = [
|
||||
cutoutLeft,
|
||||
...sampleCubic(
|
||||
cutoutLeft,
|
||||
getControlPoint(cutoutLeft, cutoutLeftTangent, leftHandleLength),
|
||||
getControlPoint(avatarLeft, avatarLeftTangent, -leftHandleLength),
|
||||
avatarLeft,
|
||||
12,
|
||||
),
|
||||
...(stabilizeOuterBoundary
|
||||
? sampleStableOuterBoundary(
|
||||
avatar,
|
||||
getAngle(avatar, avatarLeft),
|
||||
getAngle(avatar, avatarRight),
|
||||
1,
|
||||
true,
|
||||
96,
|
||||
)
|
||||
: sampleArc(
|
||||
avatar,
|
||||
getAngle(avatar, avatarLeft),
|
||||
getAngle(avatar, avatarRight),
|
||||
1,
|
||||
true,
|
||||
96,
|
||||
)),
|
||||
...sampleCubic(
|
||||
avatarRight,
|
||||
getControlPoint(avatarRight, avatarRightTangent, rightHandleLength),
|
||||
getControlPoint(cutoutRight, cutoutRightTangent, -rightHandleLength),
|
||||
cutoutRight,
|
||||
12,
|
||||
),
|
||||
...sampleArc(
|
||||
rightCap,
|
||||
getAngle(rightCap, cutoutRight),
|
||||
-Math.PI / 2,
|
||||
-1,
|
||||
false,
|
||||
12,
|
||||
),
|
||||
{ x: leftCap.cx, y: cutout.cy - cutout.r },
|
||||
...sampleArc(
|
||||
leftCap,
|
||||
-Math.PI / 2,
|
||||
getAngle(leftCap, cutoutLeft),
|
||||
-1,
|
||||
false,
|
||||
11,
|
||||
),
|
||||
];
|
||||
|
||||
// Keep the capsule contour aligned with the circular status cutout's point
|
||||
// order. Matching like-for-like edges prevents the polygon from folding
|
||||
// across the avatar while Motion interpolates between the two shapes.
|
||||
const joinSegments = 12;
|
||||
const outerSegments = 96;
|
||||
const outerEndIndex = joinSegments + outerSegments;
|
||||
const rightJoinEndIndex = joinSegments * 2 + outerSegments;
|
||||
const alignedPoints = [
|
||||
points[rightJoinEndIndex],
|
||||
...points.slice(outerEndIndex, rightJoinEndIndex).reverse(),
|
||||
...points.slice(joinSegments, outerEndIndex).reverse(),
|
||||
...points.slice(0, joinSegments).reverse(),
|
||||
...points.slice(rightJoinEndIndex + 1).reverse(),
|
||||
];
|
||||
|
||||
return `polygon(${alignedPoints.map((point) => toPolygonPoint(point, size)).join(", ")})`;
|
||||
}
|
||||
|
||||
function getRoundedSquareMaskPolygon(
|
||||
size: number,
|
||||
cornerRadius: number,
|
||||
@@ -488,20 +659,36 @@ function getRoundedSquareMaskPolygon(
|
||||
export function MaskedAvatarBadgeFrame({
|
||||
badge,
|
||||
badgeBox,
|
||||
badgeClassName,
|
||||
children,
|
||||
className,
|
||||
clipTestId,
|
||||
cornerRadius,
|
||||
curve,
|
||||
cutout,
|
||||
cutoutWidth,
|
||||
maskMode = "clip-path",
|
||||
maskTransition,
|
||||
size,
|
||||
}: MaskedAvatarBadgeFrameProps) {
|
||||
const shouldMask = Boolean(badge && badgeBox && cutout);
|
||||
const stabilizeOuterBoundary = Boolean(maskTransition);
|
||||
const maskPolygon = cutout
|
||||
? cornerRadius === undefined
|
||||
? getRoundedAvatarMaskPolygon(size, cutout, curve)
|
||||
? cutoutWidth && cutoutWidth > cutout.r * 2
|
||||
? getRoundedAvatarCapsuleMaskPolygon(
|
||||
size,
|
||||
cutout,
|
||||
cutoutWidth,
|
||||
curve,
|
||||
stabilizeOuterBoundary,
|
||||
)
|
||||
: getRoundedAvatarMaskPolygon(
|
||||
size,
|
||||
cutout,
|
||||
curve,
|
||||
stabilizeOuterBoundary,
|
||||
)
|
||||
: getRoundedSquareMaskPolygon(size, cornerRadius, cutout, curve)
|
||||
: undefined;
|
||||
const radialMask =
|
||||
@@ -539,10 +726,18 @@ export function MaskedAvatarBadgeFrame({
|
||||
data-testid={clipTestId}
|
||||
initial={false}
|
||||
style={{
|
||||
WebkitClipPath: radialMask ? undefined : maskPolygon,
|
||||
// WebKit otherwise applies the prefixed path immediately while the
|
||||
// unprefixed path is still animating, which briefly tears the avatar.
|
||||
WebkitClipPath:
|
||||
radialMask || maskTransition ? undefined : maskPolygon,
|
||||
WebkitMaskImage: radialMask,
|
||||
backfaceVisibility:
|
||||
maskTransition && !radialMask ? "hidden" : undefined,
|
||||
clipPath: radialMask ? undefined : maskPolygon,
|
||||
maskImage: radialMask,
|
||||
transform:
|
||||
maskTransition && !radialMask ? "translateZ(0)" : undefined,
|
||||
willChange: maskTransition && !radialMask ? "clip-path" : undefined,
|
||||
}}
|
||||
transition={maskTransition}
|
||||
>
|
||||
@@ -551,7 +746,10 @@ export function MaskedAvatarBadgeFrame({
|
||||
|
||||
<motion.span
|
||||
animate={maskTransition ? badgeMotionTarget : undefined}
|
||||
className="absolute z-20 flex items-center justify-center rounded-full"
|
||||
className={cn(
|
||||
"absolute z-20 flex items-center justify-center rounded-full",
|
||||
badgeClassName,
|
||||
)}
|
||||
initial={false}
|
||||
style={badgeStyle}
|
||||
transition={maskTransition}
|
||||
|
||||
@@ -108,7 +108,7 @@ export function ProfilePrimaryActions({
|
||||
<ProfileQuickAction
|
||||
disabled={agentActionDisabled}
|
||||
icon={RefreshCw}
|
||||
label="Restart"
|
||||
label="Restart Agent"
|
||||
onClick={onAgentRestart}
|
||||
testId="user-profile-agent-restart"
|
||||
/>
|
||||
@@ -143,7 +143,7 @@ export function ProfilePersonaPrimaryActions({
|
||||
<ProfileQuickAction
|
||||
disabled={disabled}
|
||||
icon={Play}
|
||||
label="Start agent"
|
||||
label="Start Agent"
|
||||
onClick={onStartAgent}
|
||||
testId="user-profile-start-agent"
|
||||
/>
|
||||
|
||||
@@ -2437,6 +2437,100 @@ test("personas referenced by teams cannot be deleted", async ({ page }) => {
|
||||
);
|
||||
});
|
||||
|
||||
test("start pill morphs into the running dot without remounting the avatar", async ({
|
||||
page,
|
||||
}) => {
|
||||
const personaId = "custom:motion-auditor";
|
||||
const pubkey = "ab".repeat(32);
|
||||
const activeDotSize = 18;
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await installMockBridge(page, {
|
||||
personas: [
|
||||
{
|
||||
avatarUrl: emojiAvatarDataUrl("✨", "#7657FF"),
|
||||
displayName: "Motion Auditor",
|
||||
id: personaId,
|
||||
systemPrompt: "You audit motion continuity.",
|
||||
},
|
||||
],
|
||||
managedAgents: [
|
||||
{
|
||||
name: "Motion Auditor",
|
||||
personaId,
|
||||
pubkey,
|
||||
status: "stopped",
|
||||
},
|
||||
],
|
||||
});
|
||||
await gotoApp(page);
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
|
||||
const card = page.getByTestId(`persona-agent-row-${personaId}`);
|
||||
const startButton = page.getByTestId(`agent-runtime-start-${pubkey}`);
|
||||
const badge = startButton.locator("xpath=../..");
|
||||
const initialAvatar = await card
|
||||
.getByAltText("Motion Auditor avatar")
|
||||
.elementHandle();
|
||||
expect(initialAvatar).not.toBeNull();
|
||||
|
||||
const samplesPromise = badge.evaluate(async (element) => {
|
||||
const samples: Array<{
|
||||
backgroundColor: string;
|
||||
height: number;
|
||||
width: number;
|
||||
}> = [];
|
||||
const startedAt = performance.now();
|
||||
|
||||
while (performance.now() - startedAt < 440) {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
samples.push({
|
||||
backgroundColor: getComputedStyle(element).backgroundColor,
|
||||
height: bounds.height,
|
||||
width: bounds.width,
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => resolve()),
|
||||
);
|
||||
}
|
||||
|
||||
return samples;
|
||||
});
|
||||
|
||||
await page.waitForTimeout(32);
|
||||
await startButton.click();
|
||||
await expect(
|
||||
page.getByTestId(`agent-runtime-active-${pubkey}`),
|
||||
).toBeVisible();
|
||||
const samples = await samplesPromise;
|
||||
const finalAvatar = await card
|
||||
.getByAltText("Motion Auditor avatar")
|
||||
.elementHandle();
|
||||
|
||||
expect(samples[0]?.width).toBeCloseTo(56, 0);
|
||||
expect(samples[0]?.height).toBeCloseTo(36, 0);
|
||||
expect(
|
||||
samples.some(
|
||||
(sample) =>
|
||||
sample.width > activeDotSize &&
|
||||
sample.width < 56 &&
|
||||
sample.height > activeDotSize &&
|
||||
sample.height < 36,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(samples.at(-1)?.width).toBeCloseTo(activeDotSize, 0);
|
||||
expect(samples.at(-1)?.height).toBeCloseTo(activeDotSize, 0);
|
||||
expect(samples.at(-1)?.backgroundColor).not.toBe(samples[0]?.backgroundColor);
|
||||
await expect(
|
||||
page.getByTestId(`agent-runtime-active-${pubkey}`).locator("xpath=../.."),
|
||||
).toHaveClass(/bg-emerald-500/);
|
||||
expect(
|
||||
await initialAvatar?.evaluate(
|
||||
(before, after) => before === after,
|
||||
finalAvatar,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("duplicate instances move from the agents gallery into the agent profile", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* overlay work).
|
||||
*
|
||||
* Exercises:
|
||||
* - Agent grid card and list-row badges at all three badge sites.
|
||||
* - Hover tooltip with itemised before→after diff (capped at 6 + "and N more").
|
||||
* - Agent grid restart actions without a duplicate status badge.
|
||||
* - Profile badge tooltip with itemised before→after diff.
|
||||
* - Runtime-tab banner with full uncapped diff list.
|
||||
* - Side-panel badge visible on the default (Info) tab — not only Runtime.
|
||||
* - DOM validity: tooltip trigger has no <button> ancestor.
|
||||
@@ -80,7 +80,7 @@ const PERSONA_AGENT = {
|
||||
restartDiff: DIFF_ENTRIES,
|
||||
};
|
||||
|
||||
/** Running agent with no config drift — badge must be absent. */
|
||||
/** Running agent with no config drift — restart action must be absent. */
|
||||
const NO_DRIFT_AGENT = {
|
||||
pubkey: TEST_IDENTITIES.tyler.pubkey,
|
||||
name: "Stable Agent",
|
||||
@@ -88,25 +88,6 @@ const NO_DRIFT_AGENT = {
|
||||
needsRestart: false,
|
||||
};
|
||||
|
||||
/** Agent with only 3 diff entries — tooltip shows all, no "and N more". */
|
||||
const SHORT_DIFF_AGENT = {
|
||||
pubkey: TEST_IDENTITIES.charlie.pubkey,
|
||||
name: "Short Diff Agent",
|
||||
status: "running" as const,
|
||||
needsRestart: true,
|
||||
restartDiff: [
|
||||
{
|
||||
field: "model",
|
||||
change: { kind: "value" as const, before: null, after: "gpt-5" },
|
||||
},
|
||||
{
|
||||
field: "some_unknown_field",
|
||||
change: { kind: "value" as const, before: "old", after: "new" },
|
||||
},
|
||||
{ field: "env.MY_TOKEN", change: { kind: "removed" as const } },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Inactive agent with a friendly error AND a restart diff. Opening its card
|
||||
* forces the panel to open on the Runtime tab (opensRuntimeTab logic).
|
||||
@@ -122,6 +103,21 @@ const INACTIVE_FRIENDLY_ERROR_AGENT = {
|
||||
lastErrorCode: -32002,
|
||||
};
|
||||
|
||||
const RESTART_AGENT = {
|
||||
pubkey: "cd".repeat(32),
|
||||
name: "Restart Agent",
|
||||
status: "running" as const,
|
||||
needsRestart: true,
|
||||
restartDiff: DIFF_ENTRIES,
|
||||
};
|
||||
|
||||
const START_AGENT = {
|
||||
pubkey: "ef".repeat(32),
|
||||
name: "Start Agent",
|
||||
status: "stopped" 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({
|
||||
@@ -133,6 +129,86 @@ async function gotoAgentsView(page: import("@playwright/test").Page) {
|
||||
});
|
||||
}
|
||||
|
||||
const WCAG_AA_NORMAL_TEXT_CONTRAST = 4.5;
|
||||
|
||||
async function renderedTextContrast(
|
||||
locator: import("@playwright/test").Locator,
|
||||
): Promise<number> {
|
||||
return locator.evaluate((element) => {
|
||||
type Rgba = { r: number; g: number; b: number; a: number };
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) throw new Error("Could not create contrast test canvas");
|
||||
|
||||
const parseColor = (color: string): Rgba => {
|
||||
context.clearRect(0, 0, 1, 1);
|
||||
context.fillStyle = color;
|
||||
context.fillRect(0, 0, 1, 1);
|
||||
const [r, g, b, alpha] = context.getImageData(0, 0, 1, 1).data;
|
||||
return { r, g, b, a: alpha / 255 };
|
||||
};
|
||||
|
||||
const composite = (foreground: Rgba, background: Rgba): Rgba => {
|
||||
const alpha = foreground.a + background.a * (1 - foreground.a);
|
||||
if (alpha === 0) return { r: 0, g: 0, b: 0, a: 0 };
|
||||
|
||||
return {
|
||||
r:
|
||||
(foreground.r * foreground.a +
|
||||
background.r * background.a * (1 - foreground.a)) /
|
||||
alpha,
|
||||
g:
|
||||
(foreground.g * foreground.a +
|
||||
background.g * background.a * (1 - foreground.a)) /
|
||||
alpha,
|
||||
b:
|
||||
(foreground.b * foreground.a +
|
||||
background.b * background.a * (1 - foreground.a)) /
|
||||
alpha,
|
||||
a: alpha,
|
||||
};
|
||||
};
|
||||
|
||||
const backgroundLayers: Rgba[] = [];
|
||||
let current: Element | null = element;
|
||||
while (current) {
|
||||
backgroundLayers.push(
|
||||
parseColor(window.getComputedStyle(current).backgroundColor),
|
||||
);
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
let renderedBackground: Rgba = { r: 255, g: 255, b: 255, a: 1 };
|
||||
for (const layer of backgroundLayers.reverse()) {
|
||||
renderedBackground = composite(layer, renderedBackground);
|
||||
}
|
||||
|
||||
const renderedForeground = composite(
|
||||
parseColor(window.getComputedStyle(element).color),
|
||||
renderedBackground,
|
||||
);
|
||||
const luminance = (color: Rgba) => {
|
||||
const channels = [color.r, color.g, color.b].map((value) => {
|
||||
const channel = value / 255;
|
||||
return channel <= 0.04045
|
||||
? channel / 12.92
|
||||
: ((channel + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
|
||||
};
|
||||
const foregroundLuminance = luminance(renderedForeground);
|
||||
const backgroundLuminance = luminance(renderedBackground);
|
||||
|
||||
return (
|
||||
(Math.max(foregroundLuminance, backgroundLuminance) + 0.05) /
|
||||
(Math.min(foregroundLuminance, backgroundLuminance) + 0.05)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("restart-diff screenshots", () => {
|
||||
test.use({ viewport: { width: 1280, height: 900 } });
|
||||
|
||||
@@ -159,17 +235,16 @@ test.describe("restart-diff screenshots", () => {
|
||||
`managed-agent-${STANDALONE_AGENT.pubkey}`,
|
||||
);
|
||||
await expect(agentCard).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
agentCard.getByText("Restart required", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(agentCard.getByText("Restart", { exact: true })).toBeVisible();
|
||||
await expect(agentCard.getByTestId("restart-diff-badge")).toHaveCount(0);
|
||||
|
||||
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 expect(stableCard.getByText("Restart", { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
await waitForAnimations(page);
|
||||
await agentCard.screenshot({
|
||||
@@ -190,8 +265,9 @@ test.describe("restart-diff screenshots", () => {
|
||||
);
|
||||
await expect(personaCard).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
personaCard.getByText("Restart required", { exact: true }),
|
||||
personaCard.getByText("Restart", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(personaCard.getByTestId("restart-diff-badge")).toHaveCount(0);
|
||||
|
||||
await waitForAnimations(page);
|
||||
await personaCard.screenshot({
|
||||
@@ -199,119 +275,101 @@ test.describe("restart-diff screenshots", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tooltip on card badge + no-button-ancestor (B4: badge is sibling of the overlay button) ─
|
||||
|
||||
test("03-card-badge-tooltip-and-no-button-ancestor", async ({ page }) => {
|
||||
test("03-running-restart-action", async ({ page }) => {
|
||||
await installMockBridge(page, {
|
||||
managedAgents: [STANDALONE_AGENT, NO_DRIFT_AGENT],
|
||||
managedAgents: [RESTART_AGENT],
|
||||
});
|
||||
|
||||
await gotoAgentsView(page);
|
||||
|
||||
const agentCard = page.getByTestId(
|
||||
`managed-agent-${STANDALONE_AGENT.pubkey}`,
|
||||
);
|
||||
const agentCard = page.getByTestId(`managed-agent-${RESTART_AGENT.pubkey}`);
|
||||
await expect(agentCard).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// B4: the tooltip trigger must have no <button> ancestor.
|
||||
// In AgentIdentityCard the badge lives in a z-30 container that is a
|
||||
// sibling of the z-10 overlay <button> — not a descendant of it.
|
||||
const badgeLocator = agentCard.getByTestId("restart-diff-badge");
|
||||
await expect(badgeLocator).toBeVisible();
|
||||
|
||||
const hasButtonAncestor = await badgeLocator.evaluate((el) => {
|
||||
let node: Element | null = el.parentElement;
|
||||
while (node) {
|
||||
if (node.tagName === "BUTTON") return true;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
expect(hasButtonAncestor).toBe(false);
|
||||
|
||||
// Hover to open tooltip
|
||||
await badgeLocator.hover();
|
||||
const tooltip = page.locator("[role=tooltip]");
|
||||
await expect(tooltip).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Tooltip is capped at 6 entries with "and 2 more" for 8-entry diff
|
||||
await expect(tooltip.getByText("Model:")).toBeVisible();
|
||||
await expect(tooltip.getByText("and 2 more")).toBeVisible();
|
||||
|
||||
// Array value rendered as JSON.stringify in the value slot
|
||||
await expect(tooltip.getByText(/\["acp"\]/)).toBeVisible();
|
||||
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/03-card-badge-tooltip.png` });
|
||||
});
|
||||
|
||||
// ── Tooltip with short diff (no truncation) ─────────────────────────────────
|
||||
|
||||
test("04-card-tooltip-short-diff-no-truncation", async ({ page }) => {
|
||||
await installMockBridge(page, {
|
||||
managedAgents: [SHORT_DIFF_AGENT],
|
||||
});
|
||||
|
||||
await gotoAgentsView(page);
|
||||
|
||||
const agentCard = page.getByTestId(
|
||||
`managed-agent-${SHORT_DIFF_AGENT.pubkey}`,
|
||||
const restartAction = page.getByTestId(
|
||||
`agent-runtime-start-${RESTART_AGENT.pubkey}`,
|
||||
);
|
||||
const badge = agentCard.getByTestId("restart-diff-badge");
|
||||
await expect(badge).toBeVisible({ timeout: 10_000 });
|
||||
await badge.hover();
|
||||
const tooltip = page.locator("[role=tooltip]");
|
||||
await expect(tooltip).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// All 3 entries visible
|
||||
await expect(tooltip.getByText("Model:")).toBeVisible();
|
||||
// Unknown field humanised generically
|
||||
await expect(tooltip.getByText("Some unknown field:")).toBeVisible();
|
||||
// No truncation line
|
||||
await expect(tooltip.getByText(/and \d+ more/)).toHaveCount(0);
|
||||
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/04-short-diff-no-truncation.png` });
|
||||
});
|
||||
|
||||
// ── Keyboard focus shows tooltip ──────────────────────────────────────────
|
||||
|
||||
test("05-card-tooltip-keyboard-focus", async ({ page }) => {
|
||||
await installMockBridge(page, {
|
||||
managedAgents: [STANDALONE_AGENT],
|
||||
});
|
||||
|
||||
await gotoAgentsView(page);
|
||||
|
||||
const badge = page
|
||||
.getByTestId(`managed-agent-${STANDALONE_AGENT.pubkey}`)
|
||||
.getByTestId("restart-diff-badge");
|
||||
await badge.focus();
|
||||
const tooltip = page.locator("[role=tooltip]");
|
||||
await expect(tooltip).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
// ── Persona card tooltip (AgentIdentityCard — pointer-events-auto) ─────────
|
||||
|
||||
test("06-persona-card-tooltip", 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(restartAction).toHaveText("Restart");
|
||||
await expect(restartAction).toHaveAttribute("aria-label", "Restart Agent");
|
||||
await expect(restartAction).toHaveClass(/bg-transparent/);
|
||||
await expect(restartAction).toHaveClass(/text-amber-800/);
|
||||
await expect(restartAction).toHaveClass(/dark:text-amber-400/);
|
||||
await expect(restartAction.locator("svg")).toHaveCount(0);
|
||||
await expect(restartAction).toHaveCSS("width", "72px");
|
||||
await expect(restartAction).toHaveCSS("height", "36px");
|
||||
await expect(agentCard.getByTestId("restart-diff-badge")).toHaveCount(0);
|
||||
await expect(page.locator("html")).toHaveClass(/light/);
|
||||
expect(await renderedTextContrast(restartAction)).toBeGreaterThanOrEqual(
|
||||
WCAG_AA_NORMAL_TEXT_CONTRAST,
|
||||
);
|
||||
const badge = personaCard.getByTestId("restart-diff-badge");
|
||||
await expect(badge).toBeVisible({ timeout: 10_000 });
|
||||
await badge.hover();
|
||||
const tooltip = page.locator("[role=tooltip]");
|
||||
await expect(tooltip).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/06-persona-card-tooltip.png` });
|
||||
await agentCard.screenshot({
|
||||
path: `${SHOTS}/03-running-restart-action.png`,
|
||||
});
|
||||
});
|
||||
|
||||
test("restart action meets WCAG AA contrast in dark mode", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("buzz-theme", "catppuccin-mocha");
|
||||
});
|
||||
await installMockBridge(page, {
|
||||
managedAgents: [RESTART_AGENT],
|
||||
});
|
||||
|
||||
await gotoAgentsView(page);
|
||||
|
||||
const restartAction = page.getByTestId(
|
||||
`agent-runtime-start-${RESTART_AGENT.pubkey}`,
|
||||
);
|
||||
await expect(restartAction).toBeVisible();
|
||||
await expect(page.locator("html")).toHaveClass(/dark/);
|
||||
expect(await renderedTextContrast(restartAction)).toBeGreaterThanOrEqual(
|
||||
WCAG_AA_NORMAL_TEXT_CONTRAST,
|
||||
);
|
||||
});
|
||||
|
||||
test("start and restart pills share geometry except for width", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
managedAgents: [START_AGENT, RESTART_AGENT],
|
||||
});
|
||||
|
||||
await gotoAgentsView(page);
|
||||
|
||||
const startAction = page.getByTestId(
|
||||
`agent-runtime-start-${START_AGENT.pubkey}`,
|
||||
);
|
||||
const restartAction = page.getByTestId(
|
||||
`agent-runtime-start-${RESTART_AGENT.pubkey}`,
|
||||
);
|
||||
await expect(startAction).toHaveText("Start");
|
||||
await expect(restartAction).toHaveText("Restart");
|
||||
|
||||
const relativeGeometry = async (locator: typeof startAction) =>
|
||||
locator.evaluate((element) => {
|
||||
const frame = element.parentElement?.parentElement?.parentElement;
|
||||
if (!frame) throw new Error("Could not resolve avatar frame");
|
||||
const bounds = element.getBoundingClientRect();
|
||||
const frameBounds = frame.getBoundingClientRect();
|
||||
return {
|
||||
centerX: bounds.x + bounds.width / 2 - frameBounds.x,
|
||||
centerY: bounds.y + bounds.height / 2 - frameBounds.y,
|
||||
height: bounds.height,
|
||||
width: bounds.width,
|
||||
};
|
||||
});
|
||||
|
||||
const [start, restart] = await Promise.all([
|
||||
relativeGeometry(startAction),
|
||||
relativeGeometry(restartAction),
|
||||
]);
|
||||
expect(start.height).toBeCloseTo(restart.height, 2);
|
||||
expect(start.centerX).toBeCloseTo(restart.centerX, 2);
|
||||
expect(start.centerY).toBeCloseTo(restart.centerY, 2);
|
||||
expect(start.width).toBeCloseTo(56, 2);
|
||||
expect(restart.width).toBeCloseTo(72, 2);
|
||||
});
|
||||
|
||||
// ── Runtime-tab banner with full uncapped diff ────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user