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:
@@ -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 *));
|
||||||
|
|||||||
@@ -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" && (
|
})}
|
||||||
<div className="self-start">
|
{(() => {
|
||||||
<TypingIndicator />
|
const activity = deriveAgentActivity({
|
||||||
</div>
|
isStreaming,
|
||||||
)}
|
lastMessage: messages.at(-1),
|
||||||
|
pendingConfirm: !!pendingConfirm,
|
||||||
|
confirmBusy,
|
||||||
|
})
|
||||||
|
if (activity === "idle") return null
|
||||||
|
return (
|
||||||
|
<div className="self-start">
|
||||||
|
<AgentAvatar
|
||||||
|
name={activeAgent?.name ?? "Atlas"}
|
||||||
|
activity={activity}
|
||||||
|
initials={activeAgent ? agentInitials(activeAgent.name) : "AT"}
|
||||||
|
showLabel
|
||||||
|
/>
|
||||||
|
</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} />
|
||||||
|
|||||||
@@ -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/*"],
|
||||||
|
|||||||
Reference in New Issue
Block a user