mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Polish agent runtime cards (#1327)
This commit is contained in:
@@ -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",
|
||||
]);
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[BuiltInPersona {
|
||||
"Orchard", "Buzz",
|
||||
],
|
||||
model: None,
|
||||
runtime: None,
|
||||
runtime: Some("goose"),
|
||||
}];
|
||||
|
||||
const RETIRED_PERSONAS: &[(&str, &str)] = &[
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
193
desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx
Normal file
193
desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 ${
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"',
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user