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
167 changes: 167 additions & 0 deletions src/features/chat/ui/AgentIdentityAvatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { useState } from "react";
import { useAgentStore } from "@/features/agents/stores/agentStore";
import { cn } from "@/shared/lib/cn";
import type { Persona } from "@/shared/types/agents";
import { AvatarVisual } from "@/shared/ui/avatar-visual";
import { useAvatarImage, useAvatarMedia } from "@/shared/hooks/useAvatarSrc";
import { AvatarStaticMedia } from "@/shared/ui/avatar-static-media";

function normalizeAgentIdentity(value: string): string {
return value
.trim()
.replace(/\\/g, "/")
.replace(/\/+$/, "")
.toLocaleLowerCase();
}

function normalizedDisplayName(value: string): string {
return value.trim().replace(/\s+/g, " ").toLocaleLowerCase();
}

export function findPersonaForAgentName(
personas: readonly Persona[],
agentName: string,
): Persona | undefined {
const normalizedIdentity = normalizeAgentIdentity(agentName);
if (!normalizedIdentity) return undefined;

const exactIdentityMatch = personas.find(
(persona) => normalizeAgentIdentity(persona.id) === normalizedIdentity,
);
if (exactIdentityMatch) return exactIdentityMatch;

const baseIdentityMatches = personas.filter((persona) => {
const normalizedId = normalizeAgentIdentity(persona.id);
const baseName = normalizedId.split("/").at(-1)?.replace(/\.md$/, "");
return baseName === normalizedIdentity;
});
if (baseIdentityMatches.length === 1) return baseIdentityMatches[0];

const normalizedName = normalizedDisplayName(agentName);
const displayMatches = personas.filter(
(persona) => normalizedDisplayName(persona.displayName) === normalizedName,
);
return displayMatches.length === 1 ? displayMatches[0] : undefined;
}

function agentInitial(agentName: string): string {
return agentName.match(/[\p{L}\p{N}]/u)?.[0]?.toLocaleUpperCase() ?? "?";
}

export function AgentIdentityAvatar({
agentName,
className,
}: {
agentName: string;
className?: string;
}) {
const persona = useAgentStore((state) =>
findPersonaForAgentName(state.personas, agentName),
);
const avatar = persona?.avatar;
const staticImage = useAvatarImage(avatar);
const media = useAvatarMedia(avatar);
const resolvedSource = staticImage ?? media?.posterSrc ?? media?.src;
const [failedSource, setFailedSource] = useState<string>();

const sourceFailed = failedSource === resolvedSource;

const fallback = (
<span
aria-hidden="true"
data-agent-avatar-fallback=""
className="flex size-full items-center justify-center rounded-full bg-muted text-[9px] font-medium text-muted-foreground"
>
{agentInitial(agentName)}
</span>
);

const avatarContent = (() => {
if (resolvedSource && sourceFailed) return fallback;
if (media?.mediaType === "video" && !staticImage && !media.posterSrc) {
return (
<AvatarStaticMedia
media={media}
alt=""
className="size-full rounded-full object-cover"
fallback={fallback}
/>
);
}
if (staticImage) {
return (
<img
src={staticImage}
alt=""
className="size-full rounded-full object-cover"
onError={() => setFailedSource(staticImage)}
/>
);
}
return (
<AvatarVisual
avatar={avatar}
alt=""
className="size-full rounded-full object-cover"
fallback={fallback}
loadingStrategy="lazy-once"
onError={() => {
if (resolvedSource) setFailedSource(resolvedSource);
}}
/>
);
})();

return (
<span
aria-hidden="true"
data-agent-identity-avatar={agentName}
className={cn(
"inline-flex size-5 shrink-0 overflow-hidden rounded-full",
className,
)}
>
{avatarContent}
</span>
);
}

const MAX_VISIBLE_AGENTS = 3;

export function ActiveAgentFacepile({
agentNames,
label,
}: {
agentNames: readonly string[];
label: string;
}) {
if (agentNames.length === 0) return null;

const visibleNames = agentNames.slice(0, MAX_VISIBLE_AGENTS);
const overflowCount = agentNames.length - visibleNames.length;

return (
<span
role="img"
aria-label={label}
data-active-agent-facepile=""
className="ml-1 inline-flex shrink-0 items-center gap-0.5"
>
{visibleNames.map((agentName) => (
<AgentIdentityAvatar
key={agentName}
agentName={agentName}
className="ring-1 ring-card"
/>
))}
{overflowCount > 0 ? (
<span
aria-hidden="true"
className="relative inline-flex size-5 items-center justify-center rounded-full bg-muted text-[10px] font-medium tabular-nums text-muted-foreground ring-1 ring-card"
>
+{overflowCount}
</span>
) : null}
</span>
);
}
116 changes: 115 additions & 1 deletion src/features/chat/ui/AgentWorkPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import type { TranscriptAgentWorkPayload } from "@/features/chat/transcript/proj
import { useTranscriptRowStateAdapter } from "@/features/chat/transcript/row-state";
import { ToolCallAdapter } from "./ToolCallAdapter";
import { VoiceSpeechStatusIndicator } from "./VoiceSpeechStatusIndicator";
import { getSubagentToolCallInfo } from "@/features/chat/lib/subagentToolCalls";
import { ActiveAgentFacepile } from "./AgentIdentityAvatar";

interface ToolTimelineItem {
kind: "tool";
Expand Down Expand Up @@ -199,6 +201,104 @@ function getActivePreviewState(
};
}

const GOOSE_TASK_ID_PATTERN = /\b\d{8}_\w+\b/g;

function taskIdsFromToolResult(item: ToolTimelineItem): string[] {
if (!item.response) return [];
let structuredText = "";
if (item.response.structuredContent !== undefined) {
try {
structuredText = JSON.stringify(item.response.structuredContent);
} catch {
// The plain result can still carry the task id.
}
}
const text = `${item.response.result} ${structuredText}`;
return [...new Set(text.match(GOOSE_TASK_ID_PATTERN) ?? [])];
}

function addUniqueAgentName(
names: string[],
seen: Set<string>,
value?: string,
) {
const name = value?.trim();
if (!name) return;
const key = name.toLocaleLowerCase();
if (seen.has(key)) return;
seen.add(key);
names.push(name);
}

function getActiveDelegatedAgentNames(
items: readonly AgentWorkTimelineItem[],
): string[] {
const activeTaskAgents = new Map<string, string>();
const activeToolAgents: string[] = [];
const seenActiveTools = new Set<string>();

for (const item of items) {
if (item.kind !== "tool" || !item.request) continue;
const status = getToolStatus(item);
const info = getSubagentToolCallInfo({
toolName: item.request.toolName,
arguments: item.request.arguments ?? {},
});
if (!info) continue;

const agentNames = info.agentNames ?? [
info.agentName ?? item.request.subagentAgentName,
];
const isRunningTool = status === "pending" || status === "in_progress";

if (info.activity === "delegating" && isRunningTool) {
for (const name of agentNames) {
addUniqueAgentName(activeToolAgents, seenActiveTools, name);
}
}

if (
item.request.toolName === "delegate" &&
item.request.arguments?.async === true &&
item.response &&
!item.response.isError
) {
const agentName = agentNames[0]?.trim();
if (agentName) {
for (const taskId of taskIdsFromToolResult(item)) {
activeTaskAgents.set(taskId, agentName);
}
}
}

if (item.request.toolName === "load" && info.taskId) {
if (
info.activity === "waiting" &&
item.response &&
!item.response.isError
) {
activeTaskAgents.delete(info.taskId);
} else if (
info.activity === "cancelling" &&
item.response &&
!item.response.isError
) {
activeTaskAgents.delete(info.taskId);
}
}
}

const names: string[] = [];
const seen = new Set<string>();
for (const name of activeTaskAgents.values()) {
addUniqueAgentName(names, seen, name);
}
for (const name of activeToolAgents) {
addUniqueAgentName(names, seen, name);
}
return names;
}

function getRailColor(
status: "thought" | "progress" | ToolCallStatus,
primary = false,
Expand Down Expand Up @@ -390,7 +490,7 @@ export function AgentWorkPanel({
payload: TranscriptAgentWorkPayload;
settleOnMount?: boolean;
}) {
const { t } = useTranslation("chat");
const { t, i18n } = useTranslation("chat");
const prefersReducedMotion = useReducedMotion();
const { markRowInteracted, pinScrollAnchor } = useTranscriptRowStateAdapter();
const items = useMemo(
Expand Down Expand Up @@ -461,6 +561,10 @@ export function AgentWorkPanel({
const hiddenItems = activePreviewState?.hiddenItems ?? [];
const hiddenStepCount = activePreviewState?.hiddenStepCount ?? 0;
const shouldShowPreviousSteps = isActiveWorkPreview && hiddenStepCount > 0;
const activeDelegatedAgentNames = useMemo(
() => getActiveDelegatedAgentNames(items),
[items],
);

return (
<Collapsible
Expand Down Expand Up @@ -559,6 +663,16 @@ export function AgentWorkPanel({
count: hiddenStepCount,
})}
</span>
<ActiveAgentFacepile
agentNames={activeDelegatedAgentNames}
label={t("agent_work.summary.activeAgents", {
count: activeDelegatedAgentNames.length,
names: new Intl.ListFormat(i18n.resolvedLanguage, {
style: "long",
type: "conjunction",
}).format(activeDelegatedAgentNames),
})}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
Expand Down
Loading