Polish agent runtime cards (#1327)

This commit is contained in:
klopez4212
2026-06-29 15:58:23 +01:00
committed by GitHub
parent 4d6196934e
commit a3c4f3f625
26 changed files with 870 additions and 495 deletions

View File

@@ -23,7 +23,7 @@ const rules = [
// glyph is a fixed display size sized to its avatar box (not readable message
// text), so it stays as the lone documented `text-[6rem]` literal.
const overrides = new Set([
"src/features/settings/ui/ProfileSettingsCard.tsx:572",
"src/features/settings/ui/ProfileSettingsCard.tsx:584",
"src/features/onboarding/ui/AvatarStep.tsx:89",
]);

View File

@@ -50,7 +50,7 @@ const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[BuiltInPersona {
"Orchard", "Buzz",
],
model: None,
runtime: None,
runtime: Some("goose"),
}];
const RETIRED_PERSONAS: &[(&str, &str)] = &[

View File

@@ -34,6 +34,9 @@ fn merge_personas_adds_missing_built_ins() {
assert_eq!(records.len(), BUILT_IN_PERSONAS.len());
assert!(records.iter().all(|record| record.is_builtin));
assert!(records.iter().all(|record| record.is_active));
assert!(records
.iter()
.any(|record| record.id == "builtin:fizz" && record.runtime.as_deref() == Some("goose")));
let display_names: Vec<&str> = records
.iter()
.map(|record| record.display_name.as_str())

View File

@@ -361,6 +361,17 @@ export function useStartManagedAgentMutation() {
return useMutation({
mutationFn: (pubkey: string) => startManagedAgent(pubkey),
onSuccess: (updated) => {
queryClient.setQueryData<ManagedAgent[]>(
managedAgentsQueryKey,
(current) => {
if (!current) return current;
return current.map((agent) =>
agent.pubkey === updated.pubkey ? updated : agent,
);
},
);
},
onSettled: () => {
invalidateManagedAgentQueriesInBackground(queryClient);
},

View File

@@ -7,27 +7,23 @@ import { IdentityInitialsAvatar } from "./IdentityInitialsAvatar";
type AgentIdentityCardProps = {
actions?: ReactNode;
ariaLabel: string;
avatar?: ReactNode;
avatarUrl?: string | null;
dataTestId: string;
label: string;
errorLabel?: string | null;
modelControl?: ReactNode;
modelLabel: string;
modelLabel?: string | null;
onClick: () => void;
status?: ReactNode;
};
export function AgentIdentityCard({
actions,
ariaLabel,
avatar,
avatarUrl,
dataTestId,
errorLabel,
label,
modelControl,
modelLabel,
onClick,
status,
}: AgentIdentityCardProps) {
const trimmedAvatarUrl = avatarUrl?.trim() || null;
@@ -40,50 +36,43 @@ export function AgentIdentityCard({
>
<button
aria-label={ariaLabel}
className="flex h-full w-full min-w-0 flex-col items-center justify-center gap-5 px-4 pb-12 text-center focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
className="absolute inset-0 z-10 rounded-xl focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onClick={onClick}
type="button"
>
/>
<div className="pointer-events-none relative z-20 flex h-full w-full min-w-0 flex-col items-center justify-center gap-5 px-4 pb-12 text-center">
<div className="flex h-24 w-24 items-center justify-center">
{trimmedAvatarUrl ? (
<ProfileAvatar
avatarUrl={trimmedAvatarUrl}
className="h-full w-full border-[3px] border-background bg-muted shadow-sm"
iconClassName="h-8 w-8"
label={label}
/>
) : (
<IdentityInitialsAvatar label={label} size={96} />
)}
{avatar ??
(trimmedAvatarUrl ? (
<ProfileAvatar
avatarUrl={trimmedAvatarUrl}
className="h-full w-full border-[3px] border-background bg-muted shadow-none"
iconClassName="h-8 w-8"
label={label}
/>
) : (
<IdentityInitialsAvatar
className="shadow-none"
label={label}
size={96}
/>
))}
</div>
</button>
</div>
{actions ? (
<div className="absolute top-3 right-3 z-40">{actions}</div>
) : null}
{status ? (
<div className="absolute top-3 left-3 z-30 flex max-w-[calc(100%-4rem)] flex-wrap items-center gap-1.5">
{status}
</div>
) : null}
<div className="absolute right-3 bottom-3 left-3 z-30 flex min-w-0 flex-col gap-0.5 text-left text-sm leading-5">
<div className="pointer-events-none absolute right-3 bottom-3 left-3 z-30 flex min-w-0 flex-col gap-0.5 text-left text-sm leading-5">
<span className="min-w-0 truncate font-semibold text-foreground tracking-normal">
{label}
</span>
{modelControl ?? (
{modelLabel ? (
<span className="min-w-0 truncate font-normal text-secondary-foreground/75">
{modelLabel}
</span>
)}
{errorLabel ? (
<span
className="min-w-0 truncate text-2xs font-medium text-destructive"
title={errorLabel}
>
{errorLabel}
</span>
) : null}
</div>
</div>

View File

@@ -0,0 +1,193 @@
import { CircleAlert, Play } from "lucide-react";
import { useReducedMotion } from "motion/react";
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
import {
type AvatarBadgeCurve,
MaskedAvatarBadgeFrame,
STATUS_DOT_MASK_CURVE,
} from "@/features/profile/ui/MaskedAvatarBadgeFrame";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { cn } from "@/shared/lib/cn";
import { Spinner } from "@/shared/ui/spinner";
import { IdentityInitialsAvatar } from "./IdentityInitialsAvatar";
type AgentRuntimeAvatarControlProps = {
activeTestId: string;
avatarUrl?: string | null;
errorLabel?: string | null;
errorTestId?: string;
isActive: boolean;
isStarting: boolean;
label: string;
startTestId: string;
onOpenError?: () => void;
onStart: () => void;
};
const TAILWIND_SPACING = {
"1": 4,
"2": 8,
"2.5": 10,
"6": 24,
"11": 44,
"24": 96,
} as const;
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 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) {
return {
cutout: {
cx: getBadgeCenter(ACTION_BADGE_SIZE, offset),
cy: getBadgeCenter(ACTION_BADGE_SIZE, offset),
r: ACTION_BADGE_SIZE / 2,
},
shell: {
bottom: -offset,
height: ACTION_BADGE_SIZE,
right: -offset,
width: ACTION_BADGE_SIZE,
},
} as const;
}
function getActiveBadge(inset: number) {
return {
cutout: {
cx: getBadgeCenter(ACTIVE_BADGE_SIZE, -inset),
cy: getBadgeCenter(ACTIVE_BADGE_SIZE, -inset),
r: (ACTIVE_BADGE_SIZE / 2) * PROFILE_STATUS_CUTOUT_RATIO,
},
shell: {
bottom: inset,
height: ACTIVE_BADGE_SIZE,
right: inset,
width: ACTIVE_BADGE_SIZE,
},
} as const;
}
const ACTION_MASK_CURVE = {
avatarRoundingAngle: 0.16,
cutoutRoundingLength: ACTION_BADGE_SIZE * 0.18,
cutoutRoundingMinAngle: 0.34,
cutoutRoundingMaxAngle: 0.52,
handleDistanceRatio: 0.58,
handleLengthRatio: 0.26,
} satisfies AvatarBadgeCurve;
const ACTION_BADGE = getActionBadge(ACTION_BADGE_OFFSET);
const ACTIVE_BADGE = getActiveBadge(ACTIVE_BADGE_INSET);
const MASK_TRANSITION = {
duration: 0.22,
ease: [0.23, 1, 0.32, 1],
} as const;
export function AgentRuntimeAvatarControl({
activeTestId,
avatarUrl,
errorLabel,
errorTestId,
isActive,
isStarting,
label,
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 errorActionLabel = `${label} has a runtime error. Open runtime details.`;
const transition = shouldReduceMotion ? { duration: 0 } : MASK_TRANSITION;
const badge = isActive ? ACTIVE_BADGE : ACTION_BADGE;
return (
<MaskedAvatarBadgeFrame
badge={
<span className="grid h-full w-full place-items-center">
{isActive ? (
<span
aria-label={`${label} is running`}
className="flex h-6 w-6 items-center justify-center 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",
hasError
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
: "bg-primary text-primary-foreground hover:bg-primary/90",
)}
data-testid={hasError ? errorTestId : startTestId}
disabled={isStarting}
onClick={(event) => {
event.stopPropagation();
if (hasError) {
onOpenError?.();
return;
}
onStart();
}}
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>
</button>
)}
</span>
}
badgeBox={badge.shell}
className="h-24 w-24"
curve={isActive ? STATUS_DOT_MASK_CURVE : ACTION_MASK_CURVE}
cutout={badge.cutout}
maskTransition={transition}
size={AGENT_AVATAR_SIZE}
>
{trimmedAvatarUrl ? (
<ProfileAvatar
avatarUrl={trimmedAvatarUrl}
className="h-full w-full bg-muted shadow-none"
iconClassName="h-8 w-8"
label={label}
/>
) : (
<IdentityInitialsAvatar
className="border-0 shadow-none"
label={label}
size={AGENT_AVATAR_SIZE}
/>
)}
</MaskedAvatarBadgeFrame>
);
}

View File

@@ -14,7 +14,10 @@ import {
} from "@/features/profile/ui/UserProfilePanelUtils";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { AgentPersona } from "@/shared/api/types";
import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext";
import {
type ProfilePanelOpenOptions,
ProfilePanelProvider,
} from "@/shared/context/ProfilePanelContext";
import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState";
import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
@@ -64,11 +67,11 @@ export function AgentsScreen() {
const { goChannel } = useAppNavigation();
const handleOpenProfilePanel = React.useCallback(
(pubkey: string) => {
(pubkey: string, options?: ProfilePanelOpenOptions) => {
applyPatch({
profile: pubkey,
profilePersona: null,
profileTab: null,
profileTab: options?.tab === "info" ? null : (options?.tab ?? null),
profileView: null,
});
},

View File

@@ -59,15 +59,13 @@ export function AgentsView() {
return (
<>
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 pb-4 pt-4 sm:px-6">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
<div className="flex flex-col gap-6">
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-7 sm:px-6 sm:py-8">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8">
<div className="flex flex-col gap-8">
<UnifiedAgentsSection
actionErrorMessage={agents.actionErrorMessage}
actionNoticeMessage={agents.actionNoticeMessage}
agents={agents.managedAgents}
channelIdToName={agents.channelIdToName}
channelsByPubkey={agents.channelsByPubkey}
agentsError={
agents.managedAgentsQuery.error instanceof Error
? agents.managedAgentsQuery.error
@@ -75,9 +73,8 @@ export function AgentsView() {
}
isActionPending={isActionPending}
isAgentsLoading={agents.managedAgentsQuery.isLoading}
personaLabelsById={personas.personaLabelsById}
presenceLoaded={agents.managedPresenceQuery.isSuccess}
presenceLookup={agents.managedPresenceQuery.data ?? {}}
startingAgentPubkey={agents.startingAgentPubkey}
startingPersonaId={agents.startingPersonaId}
onBulkRemoveStopped={() => {
void agents.handleBulkRemoveStopped();
}}
@@ -87,12 +84,18 @@ export function AgentsView() {
onCreateAgent={() => {
agents.setIsCreateOpen(true);
}}
onOpenAgentProfile={(pubkey) => {
openProfilePanel?.(pubkey);
onOpenAgentProfile={(pubkey, options) => {
openProfilePanel?.(pubkey, options);
}}
onOpenPersonaProfile={(persona) => {
openPersonaProfilePanel?.(persona);
}}
onStartAgent={(pubkey) => {
void agents.handleStart(pubkey);
}}
onStartPersona={(persona) => {
void agents.handleStartPersona(persona);
}}
// Persona props
canChooseCatalog={personas.catalogPersonas.length > 0}
personas={personas.libraryPersonas}

View File

@@ -5,10 +5,12 @@ import {
useUpdateManagedAgentMutation,
} from "@/features/agents/hooks";
import type {
AgentModelsResponse,
ManagedAgent,
RespondToMode,
UpdateManagedAgentInput,
} from "@/shared/api/types";
import { getAgentModels } from "@/shared/api/tauri";
import { Button } from "@/shared/ui/button";
import {
Dialog,
@@ -17,6 +19,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import {
CreateAgentBasicsFields,
CreateAgentRuntimeFields,
@@ -24,6 +27,9 @@ import {
import { EnvVarsEditor, type EnvVarsValue } from "./EnvVarsEditor";
import { CreateAgentRespondToField } from "./RespondToField";
const AUTO_MODEL_VALUE = "__auto_model__";
const CUSTOM_MODEL_VALUE = "__custom_model__";
export function EditAgentDialog({
agent,
open,
@@ -59,6 +65,11 @@ export function EditAgentDialog({
const [systemPrompt, setSystemPrompt] = React.useState(
agent.systemPrompt ?? "",
);
const [model, setModel] = React.useState(agent.model ?? "");
const [modelsData, setModelsData] =
React.useState<AgentModelsResponse | null>(null);
const [modelsLoading, setModelsLoading] = React.useState(false);
const [modelsError, setModelsError] = React.useState<string | null>(null);
const [envVars, setEnvVars] = React.useState<EnvVarsValue>(agent.envVars);
const personasQuery = usePersonasQuery();
const linkedPersona = React.useMemo(
@@ -96,6 +107,7 @@ export function EditAgentDialog({
setTurnTimeoutSeconds(String(agent.turnTimeoutSeconds));
setParallelism(String(agent.parallelism));
setSystemPrompt(agent.systemPrompt ?? "");
setModel(agent.model ?? "");
setEnvVars(agent.envVars);
setRespondTo(agent.respondTo);
setRespondToAllowlist(agent.respondToAllowlist);
@@ -103,6 +115,40 @@ export function EditAgentDialog({
}
}, [open, agent.pubkey]);
React.useEffect(() => {
if (!open) {
return;
}
let cancelled = false;
setModelsData(null);
setModelsError(null);
setModelsLoading(true);
getAgentModels(agent.pubkey)
.then((data) => {
if (!cancelled) {
setModelsData(data);
}
})
.catch((error) => {
if (!cancelled) {
setModelsError(
error instanceof Error ? error.message : String(error),
);
}
})
.finally(() => {
if (!cancelled) {
setModelsLoading(false);
}
});
return () => {
cancelled = true;
};
}, [open, agent.pubkey]);
function handleOpenChange(next: boolean) {
onOpenChange(next);
}
@@ -138,6 +184,7 @@ export function EditAgentDialog({
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0);
const normalizedModel = model.trim() || null;
// Harness pin resolution. The backend treats an empty string as the
// "inherit from persona" sentinel (clears the override) and any concrete
@@ -188,6 +235,10 @@ export function EditAgentDialog({
(systemPrompt.trim() || null) !== agent.systemPrompt
? systemPrompt.trim() || null
: undefined,
model:
normalizedModel !== (agent.model ?? null)
? normalizedModel
: undefined,
envVars: envVarsChanged(envVars, agent.envVars) ? envVars : undefined,
respondTo: respondTo !== agent.respondTo ? respondTo : undefined,
// The allowlist is preserved across mode toggles in local UI state
@@ -237,6 +288,15 @@ export function EditAgentDialog({
onModeChange={setRespondTo}
/>
<EditAgentModelField
disabled={updateMutation.isPending}
model={model}
modelsData={modelsData}
modelsError={modelsError}
modelsLoading={modelsLoading}
onModelChange={setModel}
/>
{linkedPersona ? (
<div className="space-y-1.5">
<label
@@ -330,6 +390,98 @@ export function EditAgentDialog({
);
}
function EditAgentModelField({
disabled,
model,
modelsData,
modelsError,
modelsLoading,
onModelChange,
}: {
disabled: boolean;
model: string;
modelsData: AgentModelsResponse | null;
modelsError: string | null;
modelsLoading: boolean;
onModelChange: (value: string) => void;
}) {
const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false);
const trimmedModel = model.trim();
const modelOptions = modelsData?.models ?? [];
const selectedKnownModel = modelOptions.some(
(option) => option.id === trimmedModel,
);
const hasCustomModel = trimmedModel.length > 0 && !selectedKnownModel;
const showCustomModelInput = isCustomModelEditing || hasCustomModel;
const modelSelectValue = showCustomModelInput
? CUSTOM_MODEL_VALUE
: trimmedModel.length === 0
? AUTO_MODEL_VALUE
: selectedKnownModel
? trimmedModel
: CUSTOM_MODEL_VALUE;
const supportsSwitching = modelsData?.supportsSwitching === true;
const selectDisabled = disabled || modelsLoading || !supportsSwitching;
const defaultModelLabel = modelsData?.agentDefaultModel
? `Default model (${modelsData.agentDefaultModel})`
: "Default model";
return (
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="agent-model">
Model
</label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs disabled:cursor-not-allowed disabled:opacity-60"
disabled={selectDisabled}
id="agent-model"
onChange={(event) => {
const nextValue = event.target.value;
if (nextValue === AUTO_MODEL_VALUE) {
setIsCustomModelEditing(false);
onModelChange("");
return;
}
if (nextValue === CUSTOM_MODEL_VALUE) {
setIsCustomModelEditing(true);
return;
}
setIsCustomModelEditing(false);
onModelChange(nextValue);
}}
value={modelSelectValue}
>
<option value={AUTO_MODEL_VALUE}>{defaultModelLabel}</option>
{modelOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.name ?? option.id}
</option>
))}
<option value={CUSTOM_MODEL_VALUE}>Custom model...</option>
</select>
{showCustomModelInput && supportsSwitching ? (
<Input
aria-label="Custom model ID"
autoCorrect="off"
disabled={disabled}
onChange={(event) => onModelChange(event.target.value)}
placeholder="Custom model ID"
value={model}
/>
) : null}
<p className="text-xs text-muted-foreground">
{modelsLoading
? "Loading models..."
: modelsError
? `Could not load models: ${modelsError}`
: supportsSwitching
? "Saved changes take effect on the next start."
: "This runtime does not report switchable models."}
</p>
</div>
);
}
function envVarsChanged(
a: Record<string, string>,
b: Record<string, string>,

View File

@@ -1,159 +0,0 @@
import { ChevronDown } from "lucide-react";
import { Spinner } from "@/shared/ui/spinner";
import React from "react";
import type { AgentModelsResponse, ManagedAgent } from "@/shared/api/types";
import { getAgentModels, updateManagedAgent } from "@/shared/api/tauri";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
export function ModelPicker({
agent,
onModelChanged,
}: {
agent: ManagedAgent;
onModelChanged?: () => void;
}) {
const [modelsData, setModelsData] =
React.useState<AgentModelsResponse | null>(null);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [saving, setSaving] = React.useState(false);
const [needsRestart, setNeedsRestart] = React.useState(false);
const [hasRequestedModels, setHasRequestedModels] = React.useState(false);
const fetchModels = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await getAgentModels(agent.pubkey);
setModelsData(data);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [agent.pubkey]);
const handleOpenChange = React.useCallback(
(open: boolean) => {
if (!open || loading || modelsData) {
return;
}
setHasRequestedModels(true);
void fetchModels();
},
[fetchModels, loading, modelsData],
);
const currentValue = agent.model ?? modelsData?.agentDefaultModel ?? "";
const displayLabel =
agent.model ??
(modelsData?.agentDefaultModel
? `${modelsData.agentDefaultModel} (default)`
: hasRequestedModels && loading
? "Loading..."
: "Auto");
const handleModelChange = async (modelId: string) => {
setSaving(true);
try {
await updateManagedAgent({
pubkey: agent.pubkey,
model: modelId === modelsData?.agentDefaultModel ? null : modelId,
});
if (agent.status === "running" || agent.status === "deployed") {
setNeedsRestart(true);
}
onModelChanged?.();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
};
return (
<span className="inline-flex items-center gap-1.5">
<DropdownMenu modal={false} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<Button
className="h-7 max-w-full justify-start gap-1.5 rounded-full border border-border/50 bg-muted/45 px-2.5 text-xs font-medium text-foreground shadow-none hover:bg-muted/70"
disabled={saving}
size="sm"
type="button"
variant="ghost"
>
<span className="truncate">{displayLabel}</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="max-h-64 min-w-48 overflow-y-auto"
onCloseAutoFocus={(event) => event.preventDefault()}
>
{loading ? (
<div className="flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground">
<Spinner className="h-4 w-4 border-2" />
Loading models...
</div>
) : error ? (
<div className="space-y-2 px-3 py-2 text-sm">
<p className="text-destructive">Failed to load models.</p>
<button
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
onClick={() => {
setHasRequestedModels(true);
void fetchModels();
}}
type="button"
>
Retry
</button>
</div>
) : !modelsData ? (
<div className="px-3 py-2 text-sm text-muted-foreground">
Open to load available models.
</div>
) : !modelsData.supportsSwitching ? (
<div className="px-3 py-2 text-sm text-muted-foreground">
{agent.model ? (
<>
<p className="font-medium text-foreground">{agent.model}</p>
<p className="mt-0.5 text-xs">
This runtime does not support switching models.
</p>
</>
) : (
"This agent uses the runtime's default model."
)}
</div>
) : (
<DropdownMenuRadioGroup
onValueChange={handleModelChange}
value={currentValue}
>
{modelsData.models.map((model) => (
<DropdownMenuRadioItem key={model.id} value={model.id}>
{model.name ?? model.id}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
)}
</DropdownMenuContent>
</DropdownMenu>
{needsRestart ? (
<span className="text-2xs text-warning">restart to apply</span>
) : null}
</span>
);
}

View File

@@ -94,7 +94,7 @@ export function TeamsSection({
className={`${TEAM_CARD_COLUMN_CLASS} flex items-center justify-between gap-3`}
>
<div>
<h3 className="text-sm font-semibold tracking-tight">My teams</h3>
<h3 className="text-sm font-semibold tracking-tight">Teams</h3>
<p className="text-sm text-secondary-foreground/75">
Saved groups from My Agents that you can add to a channel together.
</p>

View File

@@ -7,21 +7,14 @@ import {
Trash2,
} from "lucide-react";
import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore";
import { formatAgentModelLabel } from "@/features/agents/lib/formatAgentModelLabel";
import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError";
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge";
import { ModelPicker } from "@/features/agents/ui/ModelPicker";
import { useUserProfileQuery } from "@/features/profile/hooks";
import type {
AgentPersona,
ManagedAgent,
PresenceLookup,
} from "@/shared/api/types";
import type { AgentPersona, ManagedAgent } from "@/shared/api/types";
import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext";
import { useFeedbackToasts } from "@/shared/hooks/useToastEffect";
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
@@ -32,6 +25,7 @@ import {
} from "@/shared/ui/dropdown-menu";
import { IdentityCardSkeleton } from "@/shared/ui/identity-card-skeleton";
import { AgentIdentityCard } from "./AgentIdentityCard";
import { AgentRuntimeAvatarControl } from "./AgentRuntimeAvatarControl";
import { CreateIdentityCard } from "./CreateIdentityCard";
import { buildUnifiedGroups, pickProfileAgent } from "./unifiedAgentGroups";
@@ -39,19 +33,21 @@ type UnifiedAgentsSectionProps = {
actionErrorMessage: string | null;
actionNoticeMessage: string | null;
agents: ManagedAgent[];
channelIdToName: Record<string, string>;
channelsByPubkey: Record<string, { id: string; name: string }[]>;
agentsError: Error | null;
isActionPending: boolean;
isAgentsLoading: boolean;
personaLabelsById: Record<string, string>;
presenceLoaded: boolean;
presenceLookup: PresenceLookup;
startingAgentPubkey: string | null;
startingPersonaId: string | null;
onBulkRemoveStopped: () => void;
onBulkStopRunning: () => void;
onCreateAgent: () => void;
onOpenAgentProfile: (pubkey: string) => void;
onOpenAgentProfile: (
pubkey: string,
options?: ProfilePanelOpenOptions,
) => void;
onOpenPersonaProfile: (persona: AgentPersona) => void;
onStartAgent: (pubkey: string) => void;
onStartPersona: (persona: AgentPersona) => void;
canChooseCatalog: boolean;
personas: AgentPersona[];
personasError: Error | null;
@@ -75,13 +71,15 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
agentsError,
isActionPending,
isAgentsLoading,
presenceLoaded,
presenceLookup,
startingAgentPubkey,
startingPersonaId,
onBulkRemoveStopped,
onBulkStopRunning,
onCreateAgent,
onOpenAgentProfile,
onOpenPersonaProfile,
onStartAgent,
onStartPersona,
canChooseCatalog,
personas,
personasError,
@@ -175,10 +173,12 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
agent={profileAgent}
key={group.persona.id}
persona={group.persona}
presenceLoaded={presenceLoaded}
presenceLookup={presenceLookup}
startingAgentPubkey={startingAgentPubkey}
startingPersonaId={startingPersonaId}
onOpenAgentProfile={onOpenAgentProfile}
onOpenPersonaProfile={onOpenPersonaProfile}
onStartAgent={onStartAgent}
onStartPersona={onStartPersona}
/>
);
})}
@@ -198,10 +198,10 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
collapsed={collapsed}
groupKey="__additional_persona_agents__"
label="Additional agent instances"
presenceLoaded={presenceLoaded}
presenceLookup={presenceLookup}
startingAgentPubkey={startingAgentPubkey}
onToggle={toggle}
onOpenAgentProfile={onOpenAgentProfile}
onStartAgent={onStartAgent}
/>
) : null}
{unknown.length > 0 ? (
@@ -210,10 +210,10 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
collapsed={collapsed}
groupKey="__unknown__"
label="Unknown Persona"
presenceLoaded={presenceLoaded}
presenceLookup={presenceLookup}
startingAgentPubkey={startingAgentPubkey}
onToggle={toggle}
onOpenAgentProfile={onOpenAgentProfile}
onStartAgent={onStartAgent}
/>
) : null}
{ungrouped.length > 0 ? (
@@ -222,35 +222,15 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
collapsed={collapsed}
groupKey="__ungrouped__"
label="Custom agents"
presenceLoaded={presenceLoaded}
presenceLookup={presenceLookup}
startingAgentPubkey={startingAgentPubkey}
onToggle={toggle}
onOpenAgentProfile={onOpenAgentProfile}
onStartAgent={onStartAgent}
/>
) : null}
</div>
) : null}
{!isLoading && stoppedCount > 0 ? (
<div
className={`${AGENT_CARD_COLUMN_CLASS} flex items-center justify-between rounded-xl border border-border/60 bg-muted/30 px-4 py-2.5`}
>
<p className="text-sm text-muted-foreground">
{stoppedCount} stopped {stoppedCount === 1 ? "agent" : "agents"}
</p>
<Button
className="text-destructive"
disabled={isActionPending}
onClick={onBulkRemoveStopped}
size="sm"
variant="ghost"
>
<Trash2 className="mr-1.5 h-4 w-4" />
Remove stopped
</Button>
</div>
) : null}
{agentsError ? (
<p
className={`${AGENT_CARD_COLUMN_CLASS} rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive`}
@@ -272,20 +252,28 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
function AgentPersonaCard({
agent,
persona,
presenceLoaded,
presenceLookup,
startingAgentPubkey,
startingPersonaId,
onOpenAgentProfile,
onOpenPersonaProfile,
onStartAgent,
onStartPersona,
}: {
agent: ManagedAgent | undefined;
persona: AgentPersona;
presenceLoaded: boolean;
presenceLookup: PresenceLookup;
onOpenAgentProfile: (pubkey: string) => void;
startingAgentPubkey: string | null;
startingPersonaId: string | null;
onOpenAgentProfile: (
pubkey: string,
options?: ProfilePanelOpenOptions,
) => void;
onOpenPersonaProfile: (persona: AgentPersona) => void;
onStartAgent: (pubkey: string) => void;
onStartPersona: (persona: AgentPersona) => void;
}) {
const title = persona.displayName;
const modelLabel = formatAgentModelLabel(agent?.model ?? persona.model);
const isActive = agent ? isManagedAgentActive(agent) : false;
const profileQuery = useUserProfileQuery(agent?.pubkey);
const avatarUrl = agent
? firstAvatarUrl(profileQuery.data?.avatarUrl, persona.avatarUrl)
@@ -293,92 +281,106 @@ function AgentPersonaCard({
const friendlyError = agent
? friendlyAgentLastError(agent.lastError)?.copy
: null;
const opensRuntimeTab = Boolean(agent && friendlyError && !isActive);
return (
<AgentIdentityCard
ariaLabel={`${title} agent profile`}
avatar={
agent ? (
<AgentRuntimeAvatarControl
activeTestId={`agent-runtime-active-${agent.pubkey}`}
avatarUrl={avatarUrl}
errorLabel={friendlyError}
errorTestId={`agent-runtime-error-${agent.pubkey}`}
isActive={isActive}
isStarting={startingAgentPubkey === agent.pubkey}
label={title}
startTestId={`agent-runtime-start-${agent.pubkey}`}
onOpenError={() => {
onOpenAgentProfile(agent.pubkey, { tab: "runtime" });
}}
onStart={() => onStartAgent(agent.pubkey)}
/>
) : (
<AgentRuntimeAvatarControl
activeTestId={`persona-runtime-active-${persona.id}`}
avatarUrl={avatarUrl}
isActive={false}
isStarting={startingPersonaId === persona.id}
label={title}
startTestId={`persona-runtime-start-${persona.id}`}
onStart={() => onStartPersona(persona)}
/>
)
}
avatarUrl={avatarUrl}
dataTestId={`persona-agent-row-${persona.id}`}
errorLabel={friendlyError}
label={title}
modelControl={agent ? <ModelPicker agent={agent} /> : undefined}
modelLabel={modelLabel}
modelLabel={agent && isActive ? modelLabel : null}
onClick={() => {
if (agent) {
onOpenAgentProfile(agent.pubkey);
onOpenAgentProfile(
agent.pubkey,
opensRuntimeTab ? { tab: "runtime" } : undefined,
);
return;
}
onOpenPersonaProfile(persona);
}}
status={
agent ? (
<AgentCardStatus
agent={agent}
presenceLoaded={presenceLoaded}
presenceLookup={presenceLookup}
/>
) : null
}
/>
);
}
function StandaloneAgentCard({
agent,
presenceLoaded,
presenceLookup,
startingAgentPubkey,
onOpenAgentProfile,
onStartAgent,
}: {
agent: ManagedAgent;
presenceLoaded: boolean;
presenceLookup: PresenceLookup;
onOpenAgentProfile: (pubkey: string) => void;
startingAgentPubkey: string | null;
onOpenAgentProfile: (
pubkey: string,
options?: ProfilePanelOpenOptions,
) => void;
onStartAgent: (pubkey: string) => void;
}) {
const title = agent.name;
const profileQuery = useUserProfileQuery(agent.pubkey);
const friendlyError = friendlyAgentLastError(agent.lastError)?.copy;
const isActive = isManagedAgentActive(agent);
const opensRuntimeTab = Boolean(friendlyError && !isActive);
return (
<AgentIdentityCard
ariaLabel={`${title} agent profile`}
avatarUrl={profileQuery.data?.avatarUrl}
dataTestId={`managed-agent-${agent.pubkey}`}
errorLabel={friendlyError}
label={title}
modelControl={<ModelPicker agent={agent} />}
modelLabel={formatAgentModelLabel(agent.model)}
onClick={() => {
onOpenAgentProfile(agent.pubkey);
}}
status={
<AgentCardStatus
agent={agent}
presenceLoaded={presenceLoaded}
presenceLookup={presenceLookup}
avatar={
<AgentRuntimeAvatarControl
activeTestId={`agent-runtime-active-${agent.pubkey}`}
avatarUrl={profileQuery.data?.avatarUrl}
errorLabel={friendlyError}
errorTestId={`agent-runtime-error-${agent.pubkey}`}
isActive={isActive}
isStarting={startingAgentPubkey === agent.pubkey}
label={title}
startTestId={`agent-runtime-start-${agent.pubkey}`}
onOpenError={() => {
onOpenAgentProfile(agent.pubkey, { tab: "runtime" });
}}
onStart={() => onStartAgent(agent.pubkey)}
/>
}
/>
);
}
function AgentCardStatus({
agent,
presenceLoaded,
presenceLookup,
}: {
agent: ManagedAgent;
presenceLoaded: boolean;
presenceLookup: PresenceLookup;
}) {
const activeTurns = useActiveAgentTurns(agent.pubkey);
const presenceStatus = presenceLookup[normalizePubkey(agent.pubkey)];
return (
<AgentStatusBadge
isWorking={activeTurns.length > 0}
presenceLoaded={presenceLoaded}
presenceStatus={presenceStatus}
status={agent.status}
avatarUrl={profileQuery.data?.avatarUrl}
dataTestId={`managed-agent-${agent.pubkey}`}
label={title}
modelLabel={isActive ? formatAgentModelLabel(agent.model) : null}
onClick={() => {
onOpenAgentProfile(
agent.pubkey,
opensRuntimeTab ? { tab: "runtime" } : undefined,
);
}}
/>
);
}
@@ -417,7 +419,7 @@ function SectionHeader({
className={`${AGENT_CARD_COLUMN_CLASS} flex items-center justify-between gap-3`}
>
<div>
<h3 className="text-sm font-semibold tracking-tight">Your Agents</h3>
<h3 className="text-sm font-semibold tracking-tight">Agents</h3>
<p className="text-sm text-secondary-foreground/75">
Agents in this workspace.
</p>
@@ -545,19 +547,22 @@ function CollapsibleAgentGroup({
label,
agents,
collapsed,
presenceLoaded,
presenceLookup,
startingAgentPubkey,
onToggle,
onOpenAgentProfile,
onStartAgent,
}: {
groupKey: string;
label: string;
agents: ManagedAgent[];
collapsed: ReadonlySet<string>;
presenceLoaded: boolean;
presenceLookup: PresenceLookup;
startingAgentPubkey: string | null;
onToggle: (key: string) => void;
onOpenAgentProfile: (pubkey: string) => void;
onOpenAgentProfile: (
pubkey: string,
options?: ProfilePanelOpenOptions,
) => void;
onStartAgent: (pubkey: string) => void;
}) {
const isCollapsed = collapsed.has(groupKey);
return (
@@ -581,9 +586,9 @@ function CollapsibleAgentGroup({
<StandaloneAgentCard
agent={agent}
key={agent.pubkey}
presenceLoaded={presenceLoaded}
presenceLookup={presenceLookup}
startingAgentPubkey={startingAgentPubkey}
onOpenAgentProfile={onOpenAgentProfile}
onStartAgent={onStartAgent}
/>
))}
</div>

View File

@@ -2,6 +2,8 @@ import * as React from "react";
import {
type AttachManagedAgentToChannelResult,
useAvailableAcpRuntimes,
useCreateManagedAgentMutation,
useManagedAgentLogQuery,
useManagedAgentsQuery,
useRelayAgentsQuery,
@@ -15,7 +17,11 @@ import { usePresenceQuery } from "@/features/presence/hooks";
import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore";
import { useActiveAgentTurnsBridge } from "@/features/agents/activeAgentTurnsStore";
import type {
AcpRuntime,
AcpRuntimeCatalogEntry,
AgentPersona,
Channel,
CreateManagedAgentInput,
CreateManagedAgentResponse,
ManagedAgent,
} from "@/shared/api/types";
@@ -27,6 +33,7 @@ import {
startManagedAgentWithRules,
stopManagedAgentWithRules,
} from "../lib/managedAgentControlActions";
import { resolvePersonaRuntime } from "../lib/resolvePersonaRuntime";
export function useManagedAgentActions() {
const relayAgentsQuery = useRelayAgentsQuery();
@@ -36,6 +43,8 @@ export function useManagedAgentActions() {
const startMutation = useStartManagedAgentMutation();
const stopMutation = useStopManagedAgentMutation();
const deleteMutation = useDeleteManagedAgentMutation();
const createAgentMutation = useCreateManagedAgentMutation();
const availableRuntimesQuery = useAvailableAcpRuntimes();
const startOnLaunchMutation = useSetManagedAgentStartOnAppLaunchMutation();
const [isCreateOpen, setIsCreateOpen] = React.useState(false);
const [agentToAddToChannel, setAgentToAddToChannel] =
@@ -159,6 +168,79 @@ export function useManagedAgentActions() {
}
}
async function getAvailableRuntimesForStart() {
if (availableRuntimesQuery.isFetched) {
return availableRuntimesQuery.data ?? [];
}
const result = await availableRuntimesQuery.refetch();
return filterAvailableRuntimes(result.data);
}
async function handleStartPersona(persona: AgentPersona) {
clearFeedback();
try {
const runtimes = await getAvailableRuntimesForStart();
const defaultRuntime = runtimes[0] ?? null;
const { runtime, warnings, isOverridden } = resolvePersonaRuntime(
persona.runtime,
runtimes,
defaultRuntime,
);
if (!runtime) {
throw new Error("No available runtime found for this agent.");
}
if (isOverridden) {
throw new Error(
warnings[0] ??
"This agent's configured runtime is not available. Install the runtime or edit the agent before starting it.",
);
}
const input: CreateManagedAgentInput = {
name: persona.displayName,
acpCommand: "buzz-acp",
agentCommand: runtime.command,
agentArgs: runtime.defaultArgs,
mcpCommand: runtime.mcpCommand ?? "",
personaId: persona.id,
systemPrompt: persona.systemPrompt,
avatarUrl: persona.avatarUrl ?? undefined,
model: persona.model ?? undefined,
envVars: persona.envVars,
spawnAfterCreate: true,
startOnAppLaunch: true,
backend: { type: "local" },
harnessOverride: !persona.runtime || persona.runtime === runtime.id,
};
const created = await createAgentMutation.mutateAsync(input);
setCreatedAgent(created);
const notices = [...warnings];
if (created.spawnError) {
setActionErrorMessage(created.spawnError);
} else {
notices.push(`Started ${created.agent.name}.`);
}
if (created.profileSyncError) {
notices.push(created.profileSyncError);
}
if (notices.length > 0) {
setActionNoticeMessage(notices.join(" "));
}
void managedAgentsQuery.refetch();
void relayAgentsQuery.refetch();
} catch (error) {
setActionErrorMessage(
error instanceof Error ? error.message : "Failed to start agent.",
);
}
}
async function getChannelsForAction() {
if (channelsQuery.data) {
return channelsQuery.data;
@@ -345,10 +427,20 @@ export function useManagedAgentActions() {
}
const isPending =
createAgentMutation.isPending ||
startMutation.isPending ||
stopMutation.isPending ||
startOnLaunchMutation.isPending ||
deleteMutation.isPending;
const startingAgentPubkey =
startMutation.isPending && typeof startMutation.variables === "string"
? startMutation.variables
: null;
const startingPersonaId =
createAgentMutation.isPending &&
typeof createAgentMutation.variables?.personaId === "string"
? createAgentMutation.variables.personaId
: null;
return {
relayAgentsQuery,
@@ -372,7 +464,10 @@ export function useManagedAgentActions() {
setActionNoticeMessage,
actionErrorMessage,
setActionErrorMessage,
startingAgentPubkey,
startingPersonaId,
handleStart,
handleStartPersona,
handleStop,
handleDelete,
handleToggleStartOnAppLaunch,
@@ -383,3 +478,11 @@ export function useManagedAgentActions() {
refetchRelayAgents: () => void relayAgentsQuery.refetch(),
};
}
function filterAvailableRuntimes(
runtimes: readonly AcpRuntimeCatalogEntry[] | undefined,
): AcpRuntime[] {
return (runtimes ?? []).filter(
(runtime): runtime is AcpRuntime => runtime.availability === "available",
);
}

View File

@@ -1,9 +1,4 @@
import type * as React from "react";
import type { useChannelFind } from "@/features/search/useChannelFind";
import type {
ProfilePanelTab,
ProfilePanelView,
} from "@/features/profile/ui/UserProfilePanel";
import type { BotActivityAgent } from "@/features/channels/ui/BotActivityBar";
import type { ChannelAgentSessionAgent } from "@/features/channels/ui/useChannelAgentSessions";
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
@@ -11,8 +6,12 @@ import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import type { TimelineMessage } from "@/features/messages/types";
import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { useChannelFind } from "@/features/search/useChannelFind";
import type {
ProfilePanelTab,
ProfilePanelView,
} from "@/features/profile/ui/UserProfilePanel";
import type { Channel } from "@/shared/api/types";
export type ChannelPaneProps = {
activeChannel: Channel | null;
activityAgents?: BotActivityAgent[];

View File

@@ -1,6 +1,7 @@
import * as React from "react";
import { Camera, Link2, Upload, X } from "lucide-react";
import { MaskedAvatarBadgeFrame } from "@/features/profile/ui/MaskedAvatarBadgeFrame";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { useAvatarUpload } from "@/features/profile/useAvatarUpload";
import { Input } from "@/shared/ui/input";
@@ -76,13 +77,27 @@ export function AvatarUpload({
<p className="text-sm font-medium">Add a profile photo</p>
<div className="flex items-center gap-4">
<div className="relative h-20 w-20 shrink-0">
<ProfileAvatar
avatarUrl={avatarUrl || null}
className="h-full w-full text-xl"
iconClassName="h-6 w-6"
label={previewName}
testId={`${testIdPrefix}-preview`}
/>
<MaskedAvatarBadgeFrame
badge={
showClear ? null : (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Camera className="h-4 w-4" />
</div>
)
}
badgeBox={{ bottom: -4, height: 32, right: -4, width: 32 }}
className="h-20 w-20"
cutout={{ cx: 68, cy: 68, r: 20 }}
size={80}
>
<ProfileAvatar
avatarUrl={avatarUrl || null}
className="h-full w-full text-xl"
iconClassName="h-6 w-6"
label={previewName}
testId={`${testIdPrefix}-preview`}
/>
</MaskedAvatarBadgeFrame>
{showClear && onClear ? (
<button
className="absolute -right-1 -top-1 flex h-6 w-6 items-center justify-center rounded-full border border-background bg-destructive text-destructive-foreground shadow-xs transition-colors hover:bg-destructive/80"
@@ -93,11 +108,7 @@ export function AvatarUpload({
>
<X className="h-4 w-4" />
</button>
) : (
<div className="absolute -bottom-1 -right-1 flex h-8 w-8 items-center justify-center rounded-full border border-background bg-primary text-primary-foreground shadow-xs">
<Camera className="h-4 w-4" />
</div>
)}
) : null}
</div>
<button
className={`flex flex-1 cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed bg-transparent px-4 py-5 transition-colors ${

View File

@@ -38,6 +38,10 @@ import {
ProfileRuntimeTabContent,
ProfileTabBar,
} from "@/features/profile/ui/UserProfilePanelTabs";
import {
MaskedAvatarBadgeFrame,
STATUS_DOT_MASK_CURVE,
} from "@/features/profile/ui/MaskedAvatarBadgeFrame";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
import { BotIdenticon } from "@/features/messages/ui/BotIdenticon";
@@ -108,6 +112,21 @@ export type ProfileSummaryViewProps = {
type RuntimeTabStatus = "running" | "stopped" | "error";
const PROFILE_HERO_SPACING = {
"0": 0,
"6": 24,
} as const;
const PROFILE_HERO_PRESENCE_BADGE = {
cutout: { cx: 68, cy: 68, r: 15 },
shell: {
bottom: PROFILE_HERO_SPACING["0"],
height: PROFILE_HERO_SPACING["6"],
right: PROFILE_HERO_SPACING["0"],
width: PROFILE_HERO_SPACING["6"],
},
} as const;
function resolveRuntimeTabStatus({
diagnosticsError,
managedAgent,
@@ -464,28 +483,41 @@ function ProfileHero({
profile: ProfileSummaryViewProps["profile"];
userStatus: ProfileSummaryViewProps["userStatus"];
}) {
const presenceDotClassName = isBot ? "h-4.5 w-4.5" : "h-3.5 w-3.5";
return (
<div className="flex flex-col items-center gap-3 text-center">
<div className="relative">
<MaskedAvatarBadgeFrame
badge={
presenceStatus ? (
<span
aria-label={getPresenceLabel(presenceStatus)}
className="flex h-6 w-6 items-center justify-center rounded-full"
data-testid="user-profile-presence-badge"
role="img"
>
<PresenceDot
className={presenceDotClassName}
status={presenceStatus}
/>
</span>
) : null
}
badgeBox={PROFILE_HERO_PRESENCE_BADGE.shell}
className="h-20 w-20"
curve={STATUS_DOT_MASK_CURVE}
cutout={PROFILE_HERO_PRESENCE_BADGE.cutout}
size={80}
>
<ProfileAvatar
avatarUrl={profile?.avatarUrl ?? null}
className="h-20 w-20 text-xl"
className="h-full w-full text-xl"
iconClassName="h-8 w-8"
label={displayName}
plain
testId="user-profile-avatar"
/>
{presenceStatus ? (
<span
aria-label={getPresenceLabel(presenceStatus)}
className="absolute bottom-0 right-0 flex h-6 w-6 items-center justify-center rounded-full bg-background"
data-testid="user-profile-presence-badge"
role="img"
>
<PresenceDot className="h-3.5 w-3.5" status={presenceStatus} />
</span>
) : null}
</div>
</MaskedAvatarBadgeFrame>
<div className="flex flex-col items-center gap-1">
<div className="flex items-center justify-center gap-2">

View File

@@ -12,6 +12,7 @@ import {
useProfileQuery,
useUpdateProfileMutation,
} from "@/features/profile/hooks";
import { MaskedAvatarBadgeFrame } from "@/features/profile/ui/MaskedAvatarBadgeFrame";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import {
ProfileAvatarEditor,
@@ -166,7 +167,6 @@ export function ProfileSettingsCard({
const isEditingProfileMetadataRef = React.useRef(false);
const avatarEditorOpenFrameRef = React.useRef<number | null>(null);
const avatarEditorFinishTimeoutRef = React.useRef<number | null>(null);
const avatarEditClipId = React.useId().replace(/:/g, "");
isEditingProfileMetadataRef.current = isEditingProfileMetadata;
React.useEffect(() => {
@@ -308,23 +308,13 @@ export function ProfileSettingsCard({
? { duration: 0 }
: AVATAR_EDITOR_LAYOUT_TRANSITION;
const avatarEditShellClassName = cn(
"absolute right-0 bottom-0 z-10 flex h-[54px] w-[54px] items-center justify-center rounded-full bg-background opacity-100 transition-[opacity,scale,transform] duration-150 ease-out",
"flex h-[54px] w-[54px] items-center justify-center rounded-full opacity-100 transition-[opacity,scale,transform] duration-150 ease-out",
isAvatarEditorOpen
? "pointer-events-none scale-[0.94] opacity-0"
: "scale-100 opacity-100",
);
const avatarEditButtonClassName = cn(
"flex h-11 w-11 items-center justify-center rounded-full bg-sidebar-active text-sidebar-active-foreground shadow-lg transition-[background-color,opacity,scale,transform] duration-150 ease-out hover:scale-[1.04] hover:bg-sidebar-active focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-default disabled:opacity-90 disabled:hover:scale-100",
);
const avatarClipStyle = React.useMemo<React.CSSProperties | undefined>(
() =>
!isAvatarEditorOpen
? {
clipPath: `url(#${avatarEditClipId})`,
transform: "translateZ(0)",
}
: undefined,
[avatarEditClipId, isAvatarEditorOpen],
"flex h-11 w-11 items-center justify-center rounded-full bg-sidebar-active text-sidebar-active-foreground transition-[background-color,opacity,scale,transform] duration-150 ease-out hover:scale-[1.04] hover:bg-sidebar-active focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-default disabled:opacity-90 disabled:hover:scale-100",
);
const clearAvatarEditorFinishTimeout = React.useCallback(() => {
if (avatarEditorFinishTimeoutRef.current === null) {
@@ -525,102 +515,92 @@ export function ProfileSettingsCard({
className="relative h-48 w-48"
data-testid="profile-avatar-clip-frame"
>
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full"
fill="none"
height="192"
viewBox="0 0 192 192"
width="192"
xmlns="http://www.w3.org/2000/svg"
>
<clipPath
clipPathUnits="userSpaceOnUse"
id={avatarEditClipId}
>
<path
clipRule="evenodd"
d="M100.734 83.3298C102.415 84.1574 104.616 83.8757 105.495 82.2207C109.647 74.3981 112 65.4738 112 56C112 25.0721 86.9279 0 56 0C25.0721 0 0 25.0721 0 56C0 86.9279 25.0721 112 56 112C65.4738 112 74.3981 109.647 82.2207 105.495C83.8757 104.616 84.1574 102.415 83.3298 100.734C82.4783 99.0047 82 97.0582 82 95C82 87.8203 87.8203 82 95 82C97.0582 82 99.0047 82.4783 100.734 83.3298Z"
fillRule="evenodd"
transform="translate(-34.5 -34.5) scale(2.1)"
/>
</clipPath>
</svg>
<div
className="relative h-full w-full"
data-testid="profile-avatar-preview-clip"
style={avatarClipStyle}
>
<div
className="pointer-events-none absolute inset-0 z-10"
data-testid="profile-avatar-animated-preview-slot"
ref={setAnimatedPreviewEl}
/>
{shouldShowAnimatedPreview ? null : emojiAvatarPreview ? (
<div
aria-label={`${resolvedName} avatar`}
className="relative flex h-full w-full shrink-0 items-center justify-center overflow-hidden rounded-full shadow-xs"
data-testid="profile-avatar-preview"
role="img"
style={{
backgroundColor: emojiAvatarPreview.color,
}}
>
<span
className={cn(
"buzz-avatar-emoji-glyph flex h-full w-full items-center justify-center text-[6rem] leading-[6.25rem]",
avatarSquishKey > 0 && "buzz-avatar-squish",
)}
data-testid="profile-avatar-preview-emoji"
key={avatarSquishKey}
<MaskedAvatarBadgeFrame
badge={
isAvatarEditorOpen ? null : (
<div
className={avatarEditShellClassName}
data-testid="profile-avatar-edit-shell"
>
{emojiAvatarPreview.emoji}
</span>
</div>
) : (
<ProfileAvatar
avatarUrl={avatarUrlDraft || null}
className="h-full w-full rounded-full text-5xl"
iconClassName="h-14 w-14"
label={resolvedName}
testId="profile-avatar-preview"
/>
)}
</div>
<div
className={avatarEditShellClassName}
data-testid="profile-avatar-edit-shell"
<button
aria-expanded={isAvatarEditorOpen}
aria-label={
isAvatarEditorSaving
? "Saving profile photo"
: "Edit profile photo"
}
className={avatarEditButtonClassName}
data-testid="profile-avatar-edit"
disabled={isAvatarEditorSaving}
onClick={openAvatarEditor}
title={
isAvatarEditorSaving
? "Saving profile photo"
: "Edit profile photo"
}
type="button"
>
{isAvatarEditorSaving && !isAvatarEditorOpen ? (
<Spinner
aria-label="Saving avatar"
className="h-4 w-4 border-2"
/>
) : (
<Pencil className="h-4 w-4" />
)}
</button>
</div>
)
}
badgeBox={{
bottom: 0,
height: 54,
right: 0,
width: 54,
}}
className="h-48 w-48"
clipTestId="profile-avatar-preview-clip"
cutout={{ cx: 165, cy: 165, r: 30 }}
size={192}
>
<button
aria-expanded={isAvatarEditorOpen}
aria-label={
isAvatarEditorSaving
? "Saving profile photo"
: "Edit profile photo"
}
className={avatarEditButtonClassName}
data-testid="profile-avatar-edit"
disabled={isAvatarEditorSaving}
onClick={openAvatarEditor}
title={
isAvatarEditorSaving
? "Saving profile photo"
: "Edit profile photo"
}
type="button"
>
{isAvatarEditorSaving && !isAvatarEditorOpen ? (
<Spinner
aria-label="Saving avatar"
className="h-4 w-4 border-2"
/>
<div className="relative h-full w-full">
<div
className="pointer-events-none absolute inset-0 z-10"
data-testid="profile-avatar-animated-preview-slot"
ref={setAnimatedPreviewEl}
/>
{shouldShowAnimatedPreview ? null : emojiAvatarPreview ? (
<div
aria-label={`${resolvedName} avatar`}
className="relative flex h-full w-full shrink-0 items-center justify-center overflow-hidden rounded-full shadow-xs"
data-testid="profile-avatar-preview"
role="img"
style={{
backgroundColor: emojiAvatarPreview.color,
}}
>
<span
className={cn(
"buzz-avatar-emoji-glyph flex h-full w-full items-center justify-center text-[6rem] leading-[6.25rem]",
avatarSquishKey > 0 && "buzz-avatar-squish",
)}
data-testid="profile-avatar-preview-emoji"
key={avatarSquishKey}
>
{emojiAvatarPreview.emoji}
</span>
</div>
) : (
<Pencil className="h-4 w-4" />
<ProfileAvatar
avatarUrl={avatarUrlDraft || null}
className="h-full w-full rounded-full text-5xl"
iconClassName="h-14 w-14"
label={resolvedName}
testId="profile-avatar-preview"
/>
)}
</button>
</div>
</div>
</MaskedAvatarBadgeFrame>
</div>
<AnimatePresence initial={false} mode="wait">

View File

@@ -4,6 +4,10 @@ import { getPresenceLabel } from "@/features/presence/lib/presence";
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
import { useSelfProfileCache } from "@/features/profile/hooks";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import {
MaskedAvatarBadgeFrame,
STATUS_DOT_MASK_CURVE,
} from "@/features/profile/ui/MaskedAvatarBadgeFrame";
import { ProfilePopover } from "@/features/profile/ui/ProfilePopover";
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
import type { Workspace } from "@/features/workspaces/types";
@@ -102,22 +106,32 @@ export function SidebarProfileCard({
}}
type="button"
>
<ProfileAvatar
avatarDataUrl={selfProfileCache?.avatarDataUrl ?? null}
avatarUrl={profile?.avatarUrl ?? null}
className="h-8 w-8 text-xs"
iconClassName="h-4 w-4"
label={resolvedDisplayName}
testId="sidebar-profile-avatar"
/>
<span
aria-label={getPresenceLabel(selfPresenceStatus)}
className="absolute -bottom-0.5 -right-0.5 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-sidebar"
data-testid="self-presence-badge"
role="img"
<MaskedAvatarBadgeFrame
badge={
<span
aria-label={getPresenceLabel(selfPresenceStatus)}
className="flex h-3.5 w-3.5 items-center justify-center rounded-full"
data-testid="self-presence-badge"
role="img"
>
<PresenceDot className="h-2 w-2" status={selfPresenceStatus} />
</span>
}
badgeBox={{ bottom: -2, height: 14, right: -2, width: 14 }}
className="h-8 w-8"
curve={STATUS_DOT_MASK_CURVE}
cutout={{ cx: 28, cy: 28, r: 7.5 }}
size={32}
>
<PresenceDot className="h-2 w-2" status={selfPresenceStatus} />
</span>
<ProfileAvatar
avatarDataUrl={selfProfileCache?.avatarDataUrl ?? null}
avatarUrl={profile?.avatarUrl ?? null}
className="h-full w-full text-xs"
iconClassName="h-4 w-4"
label={resolvedDisplayName}
testId="sidebar-profile-avatar"
/>
</MaskedAvatarBadgeFrame>
</button>
<div className="min-w-0 flex-1">

View File

@@ -364,9 +364,10 @@ export type CreateManagedAgentInput = {
acpCommand?: string;
agentCommand?: string;
/**
* True when `agentCommand` is a runtime command the user deliberately picked
* for a linked persona. Lets the backend preserve real selections, including
* installed aliases, while still ignoring missing-runtime fallbacks.
* True when `agentCommand` is a runtime command the caller deliberately wants
* to preserve instead of inheriting the linked persona command. This covers
* deploy-dialog runtime selections and discovered or installed aliases for the
* same persona runtime id, while still ignoring missing-runtime fallbacks.
*/
harnessOverride?: boolean;
agentArgs?: string[];

View File

@@ -2,8 +2,14 @@ import * as React from "react";
import type { AgentPersona } from "@/shared/api/types";
export type ProfilePanelOpenOptions = {
tab?: "info" | "runtime" | "channels" | "memories";
};
type ProfilePanelContextValue = {
openProfilePanel: ((pubkey: string) => void) | null;
openProfilePanel:
| ((pubkey: string, options?: ProfilePanelOpenOptions) => void)
| null;
openPersonaProfilePanel: ((persona: AgentPersona) => void) | null;
};
@@ -18,7 +24,10 @@ export function ProfilePanelProvider({
onOpenPersonaProfilePanel,
}: {
children: React.ReactNode;
onOpenProfilePanel: (pubkey: string) => void;
onOpenProfilePanel: (
pubkey: string,
options?: ProfilePanelOpenOptions,
) => void;
onOpenPersonaProfilePanel?: (persona: AgentPersona) => void;
}) {
const value = React.useMemo(

View File

@@ -44,6 +44,7 @@ type MockManagedAgentSeed = {
channelNames?: string[];
channelIds?: string[];
backend?: RawManagedAgent["backend"];
lastError?: string | null;
respondTo?: RawManagedAgent["respond_to"];
respondToAllowlist?: string[];
};
@@ -451,6 +452,10 @@ type RawPersona = {
display_name: string;
avatar_url: string | null;
system_prompt: string;
runtime?: string | null;
model?: string | null;
provider?: string | null;
name_pool?: string[];
is_builtin: boolean;
is_active: boolean;
env_vars?: Record<string, string>;
@@ -1030,7 +1035,7 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent {
last_started_at: status === "running" ? now : null,
last_stopped_at: status === "stopped" ? now : null,
last_exit_code: null,
last_error: null,
last_error: seed.lastError ?? null,
log_path: `/tmp/mock-agent-${seed.pubkey}.log`,
start_on_app_launch: true,
backend: seed.backend ?? { type: "local" },
@@ -1115,6 +1120,8 @@ function resetMockPersonas(config?: E2eConfig) {
display_name: "Fizz",
avatar_url: null,
system_prompt: "You are Fizz.",
runtime: "goose",
model: null,
is_builtin: true,
is_active: activePersonaIds.has("builtin:fizz"),
created_at: now,

View File

@@ -16,6 +16,9 @@ export default {
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
spacing: {
4.5: "1.125rem",
},
fontFamily: {
sans: [
'"Inter Variable"',

View File

@@ -59,6 +59,16 @@ async function seedTurns(
}, turns);
}
async function openAgentProfile(
page: import("@playwright/test").Page,
pubkey: string,
) {
await page.getByTestId(`managed-agent-${pubkey}`).click();
const panel = page.getByTestId("user-profile-panel");
await expect(panel).toBeVisible({ timeout: 5_000 });
return panel;
}
test.describe("active turn badge resilience", () => {
test.use({ viewport: { width: 1280, height: 720 } });
@@ -107,19 +117,20 @@ test.describe("active turn badge resilience", () => {
},
]);
const paulRow = page.getByTestId(`managed-agent-${AGENT_PAUL}`);
const duncanRow = page.getByTestId(`managed-agent-${AGENT_DUNCAN}`);
await expect(paulRow).toContainText("Working", { timeout: 5_000 });
await expect(duncanRow).toContainText("Working", { timeout: 5_000 });
const paulPanel = await openAgentProfile(page, AGENT_PAUL);
await expect(paulPanel).toContainText("Working in #general", {
timeout: 5_000,
});
await expect(paulPanel).toContainText("Working in #engineering");
// Simulate the all-at-once relay drop: no further frames, advance the clock
// past both thresholds. This fires several real prune ticks; shouldPausePrune
// sees every turn's lastActivityAt stuck at T0 (gap > 20s) and pauses the
// prune, so the badges survive. Under the pre-fix code every badge would be
// gone after the first tick past 25s.
// prune, so the active-turn-driven working badges survive. Under the
// pre-fix code every badge would be gone after the first tick past 25s.
await page.clock.fastForward(FRAME_GAP_MS);
await expect(paulRow).toContainText("Working");
await expect(duncanRow).toContainText("Working");
await expect(paulPanel).toContainText("Working in #general");
await expect(paulPanel).toContainText("Working in #engineering");
});
});

View File

@@ -331,7 +331,9 @@ test("saved relay-mesh agents restart via the backend serve-target preflight", a
const row = page.getByTestId(`managed-agent-${pubkey}`);
await expect(row).toContainText("Saved relay mesh agent");
await expect(row).toContainText("running");
await expect(
page.getByTestId(`agent-runtime-active-${pubkey}`),
).toBeVisible();
await page.getByRole("button", { name: "Done" }).click();
await expect(page.getByRole("dialog", { name: "Agent created" })).toHaveCount(
0,
@@ -341,7 +343,7 @@ test("saved relay-mesh agents restart via the backend serve-target preflight", a
await expect
.poll(async () => await commands(page))
.toContain("stop_managed_agent");
await expect(row).toContainText("stopped");
await expect(page.getByTestId(`agent-runtime-start-${pubkey}`)).toBeVisible();
// With a live serve target for the model, manual restart goes through:
// the backend preflight re-resolves the target and the agent starts.
@@ -349,10 +351,12 @@ test("saved relay-mesh agents restart via the backend serve-target preflight", a
await expect
.poll(async () => await commands(page))
.toContain("start_managed_agent");
await expect(row).toContainText("running");
await expect(
page.getByTestId(`agent-runtime-active-${pubkey}`),
).toBeVisible();
await triggerManagedAgentPrimaryAction(page, pubkey);
await expect(row).toContainText("stopped");
await expect(page.getByTestId(`agent-runtime-start-${pubkey}`)).toBeVisible();
// Without a live serve target, the backend preflight rejects the start
// with an actionable error, surfaced as a toast; the agent stays stopped.
@@ -364,7 +368,7 @@ test("saved relay-mesh agents restart via the backend serve-target preflight", a
.locator("[data-sonner-toast]")
.filter({ hasText: "no live serve target is available" }),
).toBeVisible();
await expect(row).toContainText("stopped");
await expect(page.getByTestId(`agent-runtime-start-${pubkey}`)).toBeVisible();
await expect(
page.evaluate(async (agentPubkey) => {
@@ -383,5 +387,5 @@ test("saved relay-mesh agents restart via the backend serve-target preflight", a
}
}, pubkey),
).resolves.toContain("no live serve target is available");
await expect(row).toContainText("stopped");
await expect(page.getByTestId(`agent-runtime-start-${pubkey}`)).toBeVisible();
});

View File

@@ -301,7 +301,7 @@ test("nests the avatar edit button in a clipped notch", async ({ page }) => {
await expect(page.getByTestId("profile-avatar-preview-clip")).toHaveCSS(
"clip-path",
/url/,
/polygon/,
);
const editShell = page.getByTestId("profile-avatar-edit-shell");
await expect(editShell).toHaveCSS("height", "54px");

View File

@@ -52,6 +52,7 @@ type MockManagedAgentSeed = {
backend?:
| { type: "local" }
| { type: "provider"; id: string; config: Record<string, unknown> };
lastError?: string | null;
respondTo?: "owner-only" | "allowlist" | "anyone";
respondToAllowlist?: string[];
};