From 377d3c114aa15967a44478849ff3497450929fd1 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 6 Aug 2026 10:10:34 +0100 Subject: [PATCH] Refine agent runtime controls Signed-off-by: kenny lopez --- .../agents/lib/managedAgentControlActions.ts | 2 +- .../agents/managedAgentRuntimeStatus.ts | 6 +- .../agents/ui/AgentRuntimeAvatarControl.tsx | 153 ++++++--- desktop/src/features/agents/ui/AgentsView.tsx | 4 + .../agents/ui/UnifiedAgentsSection.tsx | 51 ++- .../agents/ui/useManagedAgentActions.ts | 31 ++ .../profile/ui/MaskedAvatarBadgeFrame.tsx | 220 ++++++++++++- .../profile/ui/UserProfilePrimaryActions.tsx | 4 +- desktop/tests/e2e/agents.spec.ts | 94 ++++++ .../e2e/needs-restart-screenshots.spec.ts | 294 +++++++++++------- 10 files changed, 665 insertions(+), 194 deletions(-) diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index dbaaaba803..50a92e4f17 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -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( diff --git a/desktop/src/features/agents/managedAgentRuntimeStatus.ts b/desktop/src/features/agents/managedAgentRuntimeStatus.ts index a9f2734f21..c3a952f7d5 100644 --- a/desktop/src/features/agents/managedAgentRuntimeStatus.ts +++ b/desktop/src/features/agents/managedAgentRuntimeStatus.ts @@ -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", }; /** diff --git a/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx b/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx index 6f34ffba79..1b3c7a0574 100644 --- a/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx +++ b/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx @@ -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 ( - {isActive ? ( + {showRunningDot ? ( - - + /> ) : ( )} } 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} > diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 720d6e62ad..f9ada91c2f 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -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); }} diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index cf39b0859e..212d9bc96e 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -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; 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; 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) + } /> ) : ( Configuration missing - ) : agent?.needsRestart ? ( - ) : 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({ Configuration missing - ) : agent.needsRestart ? ( - ) : 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; 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} /> ))} diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index e1c2e9c9fc..6068ad1639 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -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 >(() => new Set()); const startingPersonaIdsRef = React.useRef(new Set()); + const [restartingAgentPubkey, setRestartingAgentPubkey] = React.useState< + string | null + >(null); const [logAgentPubkey, setLogAgentPubkey] = React.useState( 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, diff --git a/desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx b/desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx index 294a87e5e2..ea6e234493 100644 --- a/desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx +++ b/desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx @@ -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["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({ @@ -143,7 +143,7 @@ export function ProfilePersonaPrimaryActions({ diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 3cbe097c05..befe2b5563 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -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((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, }) => { diff --git a/desktop/tests/e2e/needs-restart-screenshots.spec.ts b/desktop/tests/e2e/needs-restart-screenshots.spec.ts index 1a695844fb..508a5d91e9 100644 --- a/desktop/tests/e2e/needs-restart-screenshots.spec.ts +++ b/desktop/tests/e2e/needs-restart-screenshots.spec.ts @@ -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