Wire @crema/agent-ui: ToolCallCard + AgentAvatar with activity

The /ai surface now renders agent-ui primitives instead of homegrown
tool/typing widgets:

- AgentAvatar with activity (thinking / working / waiting / speaking /
  idle) replaces TypingIndicator. Pulses while the model is generating,
  shows "waiting" while a write is held for confirmation, "working" while
  a confirmed write is executing, "speaking" once tokens are streaming.
- ToolCallCard renders each native tool_call with typed status (pending
  / running / success / error). Built from the assistant message's
  toolCalls plus the matching tool result message. Tool messages no
  longer render standalone — absorbed into their parent assistant turn.
- Empty assistant bubbles (no prose, only tool_calls) collapse so the
  ToolCallCards carry the visual weight.

Wiring: add @crema/agent-ui path entry to tsconfig and @source line to
app.css. Sibling lib-agent-ui must be cloned next to arcadia-admin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jules
2026-05-01 20:25:36 +10:00
parent e5cd85fff3
commit 1b2e85cdad
3 changed files with 123 additions and 21 deletions

View File

@@ -17,6 +17,7 @@
@source "../../lib-search-ui/src"; @source "../../lib-search-ui/src";
@source "../../lib-feedback-ui/src"; @source "../../lib-feedback-ui/src";
@source "../../lib-auth-ui/src"; @source "../../lib-auth-ui/src";
@source "../../lib-agent-ui/src";
/* CREMA:SOURCES */ /* CREMA:SOURCES */
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));

View File

@@ -62,7 +62,13 @@ import { addLibraryItem } from "~/lib/library"
import { Avatar, AvatarFallback } from "~/components/ui/avatar" import { Avatar, AvatarFallback } from "~/components/ui/avatar"
import { pageTitle } from "~/lib/page-meta" import { pageTitle } from "~/lib/page-meta"
import { useArcadiaClient } from "@crema/arcadia-client" import { useArcadiaClient } from "@crema/arcadia-client"
import type { ToolCall } from "@crema/llm-ui" import type { Message as LLMMessage, ToolCall } from "@crema/llm-ui"
import {
AgentAvatar,
ToolCallCard,
type ToolCall as AgentToolCall,
type ToolCallStatus,
} from "@crema/agent-ui"
import { import {
buildDenialMessages, buildDenialMessages,
classifyCalls, classifyCalls,
@@ -523,30 +529,60 @@ function ChatSurface({
) : ( ) : (
<div className="flex-1 px-4 py-6 sm:px-6"> <div className="flex-1 px-4 py-6 sm:px-6">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6"> <div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
{messages {messages.map((m, i) => {
.filter((m) => m.role !== "system") if (m.role === "system" || m.role === "tool") return null
.map((m, i) => ( const calls =
m.role === "assistant" && m.toolCalls
? m.toolCalls.map((tc) =>
buildAgentToolCall(tc, messages, isStreaming, !!pendingConfirm),
)
: []
const isWritePending =
pendingConfirm?.afterIndex === i ? pendingConfirm.writes : null
return (
<div key={i} className="contents"> <div key={i} className="contents">
<MessageRow <MessageRow
role={m.role as "user" | "assistant" | "tool"} role={m.role as "user" | "assistant"}
content={m.content} content={m.content}
toolCalls={m.toolCalls} toolCalls={m.toolCalls}
/> />
{pendingConfirm?.afterIndex === i && ( {calls.length > 0 && (
<div className="self-start flex w-full max-w-[80ch] flex-col gap-2">
{calls.map((c) => (
<ToolCallCard key={c.id} call={c} defaultExpanded={c.status !== "success"} />
))}
</div>
)}
{isWritePending && (
<ConfirmCard <ConfirmCard
calls={pendingConfirm.writes} calls={isWritePending}
onConfirm={onConfirmWrites} onConfirm={onConfirmWrites}
onDeny={onDenyWrites} onDeny={onDenyWrites}
busy={confirmBusy} busy={confirmBusy}
/> />
)} )}
</div> </div>
))} )
{isStreaming && messages.at(-1)?.role !== "assistant" && ( })}
{(() => {
const activity = deriveAgentActivity({
isStreaming,
lastMessage: messages.at(-1),
pendingConfirm: !!pendingConfirm,
confirmBusy,
})
if (activity === "idle") return null
return (
<div className="self-start"> <div className="self-start">
<TypingIndicator /> <AgentAvatar
name={activeAgent?.name ?? "Atlas"}
activity={activity}
initials={activeAgent ? agentInitials(activeAgent.name) : "AT"}
showLabel
/>
</div> </div>
)} )
})()}
<div <div
ref={endRef} ref={endRef}
aria-hidden="true" aria-hidden="true"
@@ -609,22 +645,82 @@ function ChatSurface({
) )
} }
function deriveAgentActivity({
isStreaming,
lastMessage,
pendingConfirm,
confirmBusy,
}: {
isStreaming: boolean
lastMessage: LLMMessage | undefined
pendingConfirm: boolean
confirmBusy: boolean
}): "idle" | "thinking" | "working" | "waiting" | "speaking" {
if (confirmBusy) return "working"
if (pendingConfirm) return "waiting"
if (!isStreaming) return "idle"
if (!lastMessage || lastMessage.role !== "assistant") return "thinking"
if (lastMessage.content.trim().length > 0) return "speaking"
return "thinking"
}
function buildAgentToolCall(
tc: ToolCall,
allMessages: LLMMessage[],
isStreaming: boolean,
pendingConfirm: boolean,
): AgentToolCall {
const result = allMessages.find(
(m) => m.role === "tool" && m.toolCallId === tc.id,
)
let parsedArgs: Record<string, unknown> | undefined
try {
parsedArgs = tc.arguments ? JSON.parse(tc.arguments) : undefined
} catch {
parsedArgs = undefined
}
if (result) {
let parsedResult: unknown = result.content
let errorMsg: string | undefined
try {
const obj = JSON.parse(result.content) as Record<string, unknown>
if (obj && typeof obj === "object" && typeof obj.error === "string") {
errorMsg = obj.error
} else {
parsedResult = obj
}
} catch {
// leave as raw text
}
return {
id: tc.id,
name: tc.name,
status: errorMsg ? "error" : "success",
args: parsedArgs,
result: errorMsg ? undefined : parsedResult,
error: errorMsg,
}
}
let status: ToolCallStatus = "running"
if (pendingConfirm) status = "pending"
else if (!isStreaming) status = "running"
return {
id: tc.id,
name: tc.name,
status,
args: parsedArgs,
}
}
function MessageRow({ function MessageRow({
role, role,
content, content,
toolCalls, toolCalls,
}: { }: {
role: "user" | "assistant" | "tool" role: "user" | "assistant"
content: string content: string
toolCalls?: ToolCall[] toolCalls?: ToolCall[]
}) { }) {
if (role === "tool") {
return (
<div className="self-start max-w-[80ch]">
<MessageBody content={content} isToolResult />
</div>
)
}
if (role === "user") { if (role === "user") {
return ( return (
<div className="self-end"> <div className="self-end">
@@ -640,6 +736,9 @@ function MessageRow({
</div> </div>
) )
} }
// Assistant messages with only tool_calls and no prose render nothing here —
// the ToolCallCards beneath them carry the visual weight.
if (!content.trim()) return null
return ( return (
<div className="self-start max-w-[80ch]"> <div className="self-start max-w-[80ch]">
<MessageBody content={content} toolCalls={toolCalls} /> <MessageBody content={content} toolCalls={toolCalls} />

View File

@@ -38,6 +38,8 @@
"@crema/feedback-ui/*": ["../lib-feedback-ui/src/*"], "@crema/feedback-ui/*": ["../lib-feedback-ui/src/*"],
"@crema/auth-ui": ["../lib-auth-ui/src/index.tsx"], "@crema/auth-ui": ["../lib-auth-ui/src/index.tsx"],
"@crema/auth-ui/*": ["../lib-auth-ui/src/*"], "@crema/auth-ui/*": ["../lib-auth-ui/src/*"],
"@crema/agent-ui": ["../lib-agent-ui/src/index.tsx"],
"@crema/agent-ui/*": ["../lib-agent-ui/src/*"],
"// CREMA:PATHS": [""], "// CREMA:PATHS": [""],
"react": ["./node_modules/@types/react"], "react": ["./node_modules/@types/react"],
"react/*": ["./node_modules/@types/react/*"], "react/*": ["./node_modules/@types/react/*"],