Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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