0547488f13
- Chat: an Agent step shows its task, status and tool calls and opens its conversation in place; Windows paths (E:\…, C:/…) in replies open as files - Prompt: attach a file on this computer by path (native picker), clearer too-large messages - Artifacts: edit title/description and a skill's files (new snapshot), Reinstall, install history, Hub-only rows, skills installed into ~/.claude/skills and ~/.agents/skills - Files: batch upload of small files in one extract - AI Tools: npm install falls back to --prefix ~/.local when the global prefix is not writable
277 lines
10 KiB
TypeScript
277 lines
10 KiB
TypeScript
/**
|
||
* @file AgentDetails.tsx
|
||
* @description What the chat shows of a subagent, like Claude Desktop: an
|
||
* Agent/Task call's facts (type, model, status, time, tokens, tool calls),
|
||
* the task it was given and the report it returned — read off the call, its
|
||
* result (`tool_result.agent`) and, for a background agent, the
|
||
* `<task-notification>` that closed it — and, one click further, the
|
||
* subagent's own conversation as a small chat right there in the step, every
|
||
* step of it. The chat's owner (ConversationView) supplies the links through
|
||
* `AgentContext`: which agent transcript a call launched, the notifications
|
||
* seen, and how to render one agent's conversation (a nested
|
||
* ConversationView — passed in, so this module never imports it back).
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
import { createContext, useContext, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { CheckCircle2, ChevronRight, CircleSlash, Loader2, XCircle } from "lucide-react";
|
||
import type { TranscriptContent, TranscriptMessage } from "../../lib/types";
|
||
import { fmt, formatModelName, formatMs } from "../../lib/format";
|
||
import { MarkdownContent } from "./MarkdownContent";
|
||
|
||
export type AgentStatus = "running" | "done" | "error" | "stopped";
|
||
|
||
/** One subagent as the chat knows it from the call that launched it. */
|
||
export interface AgentRef {
|
||
/** The Agent/Task `tool_use` id. */
|
||
toolUseId: string;
|
||
/** Its own transcript's `agent_id`; null while nothing names it yet. */
|
||
agentId: string | null;
|
||
type: string;
|
||
description: string;
|
||
model: string | null;
|
||
status: AgentStatus;
|
||
background: boolean;
|
||
prompt: string | null;
|
||
report: string | null;
|
||
durationMs: number | null;
|
||
tokens: number | null;
|
||
toolUses: number | null;
|
||
}
|
||
|
||
/** What a `<task-notification>` said about a background agent. */
|
||
export interface AgentNotice {
|
||
status: string;
|
||
result: string | null;
|
||
durationMs: number | null;
|
||
tokens: number | null;
|
||
toolUses: number | null;
|
||
}
|
||
|
||
export interface AgentLinks {
|
||
/** tool_use id → agent transcript id, from the session's transcript list. */
|
||
ids: Map<string, string>;
|
||
/** tool_use id → the notification that closed that background agent. */
|
||
notices: Map<string, AgentNotice>;
|
||
/** The session runs in a pane: a call with no result yet still works. */
|
||
live: boolean;
|
||
/** One agent's own conversation, as a chat that fills its box. */
|
||
render?: (agent: AgentRef) => React.ReactNode;
|
||
}
|
||
|
||
export const AgentContext = createContext<AgentLinks>({
|
||
ids: new Map(),
|
||
notices: new Map(),
|
||
live: false,
|
||
});
|
||
|
||
const AGENT_TOOLS = new Set(["Agent", "Task"]);
|
||
export const isAgentCall = (b: TranscriptContent) =>
|
||
b.type === "tool_use" && AGENT_TOOLS.has(b.name ?? "");
|
||
|
||
// The result names the agent's own transcript (`agent-<id>.jsonl`).
|
||
const AGENT_ID = /agentId:\s*([\w-]+)/;
|
||
const USAGE = /<usage>([\s\S]*?)<\/usage>/;
|
||
|
||
const tag = (text: string, name: string) =>
|
||
text.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`))?.[1]?.trim() ?? null;
|
||
|
||
/** The time, tokens and tool calls of a `<usage>` block, in either form
|
||
* Claude Code writes it: `<total_tokens>N</…>` tags or `total_tokens: N`. */
|
||
export function parseUsage(text: string): Pick<AgentNotice, "durationMs" | "tokens" | "toolUses"> {
|
||
const body = text.match(USAGE)?.[1] ?? "";
|
||
const num = (...names: string[]) => {
|
||
for (const n of names) {
|
||
const v = body.match(new RegExp(`${n}(?:>|:\\s*)(\\d+)`))?.[1];
|
||
if (v) return Number(v);
|
||
}
|
||
return null;
|
||
};
|
||
return {
|
||
durationMs: num("duration_ms"),
|
||
tokens: num("subagent_tokens", "total_tokens"),
|
||
toolUses: num("tool_uses"),
|
||
};
|
||
}
|
||
|
||
/** Each `<task-notification>` in the transcript, by the call it closes. */
|
||
export function agentNotices(messages: TranscriptMessage[]): Map<string, AgentNotice> {
|
||
const out = new Map<string, AgentNotice>();
|
||
for (const msg of messages) {
|
||
if (msg.type !== "user") continue;
|
||
for (const c of msg.content) {
|
||
if (c.type !== "text" || !c.text?.includes("<task-notification>")) continue;
|
||
for (const part of c.text.split("<task-notification>").slice(1)) {
|
||
const id = tag(part, "tool-use-id");
|
||
if (!id) continue;
|
||
out.set(id, {
|
||
status: tag(part, "status") ?? "completed",
|
||
result: tag(part, "result"),
|
||
...parseUsage(part),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** A report without the footer Claude Code appends for the orchestrator. */
|
||
function stripFooter(text: string): string {
|
||
return text
|
||
.replace(USAGE, "")
|
||
.replace(/^agentId:.*$/m, "")
|
||
.trim();
|
||
}
|
||
|
||
/** Everything the chat can say about the subagent `block` launched. */
|
||
export function agentRef(
|
||
block: TranscriptContent,
|
||
result: TranscriptContent | null | undefined,
|
||
links: AgentLinks
|
||
): AgentRef {
|
||
const raw = block.input as Record<string, unknown> | undefined;
|
||
const input = raw && !("_truncated" in raw) ? raw : {};
|
||
const str = (v: unknown) => (typeof v === "string" && v ? v : null);
|
||
const id = block.id ?? "";
|
||
const facts = result?.agent;
|
||
const output = result?.output ?? "";
|
||
const background = facts?.status === "async_launched" || output.includes("Async agent launched");
|
||
const notice = background ? links.notices.get(id) : undefined;
|
||
const usage = !background && output ? parseUsage(output) : null;
|
||
|
||
let status: AgentStatus;
|
||
if (result?.is_error) status = "error";
|
||
else if (!result || (background && !notice)) status = links.live ? "running" : "stopped";
|
||
else if (notice && notice.status !== "completed")
|
||
status = notice.status === "failed" ? "error" : "stopped";
|
||
else status = "done";
|
||
|
||
return {
|
||
toolUseId: id,
|
||
agentId: facts?.id ?? output.match(AGENT_ID)?.[1] ?? links.ids.get(id) ?? null,
|
||
type: str(input.subagent_type) ?? "Agent",
|
||
description: str(input.description) ?? "",
|
||
model: facts?.model ?? str(input.model),
|
||
status,
|
||
background,
|
||
prompt: str(input.prompt),
|
||
report: background ? (notice?.result ?? null) : output ? stripFooter(output) || null : null,
|
||
durationMs: facts?.duration_ms ?? notice?.durationMs ?? usage?.durationMs ?? null,
|
||
tokens: facts?.tokens ?? notice?.tokens ?? usage?.tokens ?? null,
|
||
toolUses: facts?.tool_uses ?? notice?.toolUses ?? usage?.toolUses ?? null,
|
||
};
|
||
}
|
||
|
||
const STATUS_ICON: Record<AgentStatus, React.ReactNode> = {
|
||
running: <Loader2 className="h-3.5 w-3.5 flex-shrink-0 animate-spin text-accent" />,
|
||
done: <CheckCircle2 className="h-3.5 w-3.5 flex-shrink-0 text-status-success" />,
|
||
error: <XCircle className="h-3.5 w-3.5 flex-shrink-0 text-status-danger" />,
|
||
stopped: <CircleSlash className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />,
|
||
};
|
||
|
||
/** One muted line: its status as an icon, then type · model · time ·
|
||
* tokens · tool calls, as far as they are known. */
|
||
export function AgentFacts({ agent }: { agent: AgentRef }) {
|
||
const { t } = useTranslation("sessions");
|
||
const parts = [
|
||
agent.type,
|
||
agent.model && formatModelName(agent.model),
|
||
agent.background && t("conversation.agent.background"),
|
||
agent.durationMs != null && formatMs(agent.durationMs),
|
||
agent.tokens != null && t("conversation.agentContext", { tokens: fmt(agent.tokens) }),
|
||
agent.toolUses != null && t("conversation.agent.toolUses", { count: agent.toolUses }),
|
||
].filter(Boolean);
|
||
return (
|
||
<div
|
||
data-testid="agent-facts"
|
||
title={t(`conversation.agent.status.${agent.status}`)}
|
||
className="flex min-w-0 items-center gap-1.5 text-xs text-fg-muted"
|
||
>
|
||
{STATUS_ICON[agent.status]}
|
||
<span className="sr-only">{t(`conversation.agent.status.${agent.status}`)}</span>
|
||
<span className="truncate">{parts.join(" · ")}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const linkClass =
|
||
"inline-flex items-center gap-0.5 text-xs text-fg-muted transition-colors hover:text-fg-secondary";
|
||
|
||
/** The subagent's own conversation, a thread hung under its step by a line
|
||
* on the left — as tall as it is, up to 24rem, then it scrolls. */
|
||
export function AgentChatBox({ agent }: { agent: AgentRef }) {
|
||
const links = useContext(AgentContext);
|
||
return (
|
||
<div data-testid="agent-chat" className="mt-2 border-l-2 border-border pl-3">
|
||
{links.render?.(agent)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** An Agent/Task step opened in the chat: one line of facts, its report as
|
||
* prose, and "View conversation ›", which puts the agent's own thread (its
|
||
* task, every step, its reply) in the report's place. With no transcript
|
||
* to show, the task it was given opens on its own instead. */
|
||
export function AgentStepBody({
|
||
block,
|
||
result,
|
||
}: {
|
||
block: TranscriptContent;
|
||
result: TranscriptContent | null;
|
||
}) {
|
||
const { t } = useTranslation("sessions");
|
||
const links = useContext(AgentContext);
|
||
const agent = agentRef(block, result, links);
|
||
const canOpen = !!agent.agentId && !!links.render;
|
||
const [thread, setThread] = useState(false);
|
||
const [task, setTask] = useState(false);
|
||
return (
|
||
<div data-testid="agent-step" className="space-y-1.5">
|
||
<AgentFacts agent={agent} />
|
||
{thread ? (
|
||
<AgentChatBox agent={agent} />
|
||
) : (
|
||
<>
|
||
{!canOpen && agent.prompt && task && (
|
||
<div className="max-h-60 overflow-auto border-l-2 border-border pl-3 text-fg-secondary">
|
||
<MarkdownContent text={agent.prompt} dense />
|
||
</div>
|
||
)}
|
||
{agent.report && (
|
||
<div
|
||
className={`max-h-60 overflow-auto ${agent.status === "error" ? "text-status-danger" : "text-fg-secondary"}`}
|
||
>
|
||
<MarkdownContent text={agent.report} dense />
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
{canOpen ? (
|
||
<button
|
||
type="button"
|
||
data-testid="agent-open"
|
||
aria-expanded={thread}
|
||
onClick={() => setThread(!thread)}
|
||
className={linkClass}
|
||
>
|
||
{t(thread ? "conversation.agent.hide" : "conversation.agent.open")}
|
||
<ChevronRight className={`h-3 w-3 transition-transform ${thread ? "-rotate-90" : ""}`} />
|
||
</button>
|
||
) : (
|
||
agent.prompt && (
|
||
<button
|
||
type="button"
|
||
aria-expanded={task}
|
||
onClick={() => setTask(!task)}
|
||
className={linkClass}
|
||
>
|
||
{t("conversation.agent.prompt")}
|
||
<ChevronRight className={`h-3 w-3 transition-transform ${task ? "rotate-90" : ""}`} />
|
||
</button>
|
||
)
|
||
)}
|
||
</div>
|
||
);
|
||
}
|