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:
klopez4212
2026-08-06 16:21:35 +01:00
committed by GitHub
parent 9213090f60
commit 6ca9641a95
10 changed files with 679 additions and 208 deletions

View File

@@ -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(

View File

@@ -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",
};
/**

View File

@@ -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}
>

View File

@@ -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);
}}

View File

@@ -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}
/>
))}

View File

@@ -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,

View File

@@ -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}

View File

@@ -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"
/>

View File

@@ -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,
}) => {

View File

@@ -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 ────────────────────────────