Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion desktop/scripts/check-px-text.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]);

Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/src/managed_agents/personas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[BuiltInPersona {
"Orchard", "Buzz",
],
model: None,
runtime: None,
runtime: Some("goose"),
}];

const RETIRED_PERSONAS: &[(&str, &str)] = &[
Expand Down
3 changes: 3 additions & 0 deletions desktop/src-tauri/src/managed_agents/personas/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
11 changes: 11 additions & 0 deletions desktop/src/features/agents/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
Expand Down
61 changes: 25 additions & 36 deletions desktop/src/features/agents/ui/AgentIdentityCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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>
Expand Down
193 changes: 193 additions & 0 deletions desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx
Original file line number Diff line number Diff line change
@@ -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}
Comment thread
klopez4212 marked this conversation as resolved.
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>
);
}
9 changes: 6 additions & 3 deletions desktop/src/features/agents/ui/AgentsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
});
},
Expand Down
Loading
Loading