feat: render presentation chat with assistant ui

This commit is contained in:
lda
2026-07-10 06:33:42 +07:00 Verified
parent 143f11f74c
commit 2d67e26b5e
6 changed files with 533 additions and 31 deletions
@@ -0,0 +1,113 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AgentMessage } from "../../demo/agent/events.js";
import { AssistantOperatorThread } from "./AssistantOperatorThread.js";
afterEach(() => cleanup());
describe("AssistantOperatorThread", () => {
it("renders text interleaved with a collapsed tool call", async () => {
const user = userEvent.setup();
const messages: ReadonlyArray<AgentMessage> = [
{
id: "assistant-1",
role: "assistant",
parts: [
{ type: "text", text: "I will inspect the run." },
{
type: "tool-call",
call: { id: "call-1", name: "readRunTrace", input: { run_id: "run_1" } },
},
{ type: "text", text: "The trace is inspectable." },
],
},
];
render(<AssistantOperatorThread mode="dock" messages={messages} />);
expect(screen.getByRole("log", { name: /operator conversation/i })).toBeInTheDocument();
expect(screen.getByText("I will inspect the run.")).toBeInTheDocument();
expect(screen.getByText("The trace is inspectable.")).toBeInTheDocument();
const tool = screen.getByRole("button", { name: /readRunTrace/i });
expect(screen.queryByText(/run_1/)).not.toBeInTheDocument();
await user.click(tool);
expect(screen.getByText(/run_1/)).toBeInTheDocument();
});
it("renders grouped consecutive tool calls", () => {
const messages: ReadonlyArray<AgentMessage> = [
{
id: "assistant-tools",
role: "assistant",
parts: [
{ type: "tool-call", call: { id: "call-1", name: "readRunTrace", input: {} } },
{ type: "tool-call", call: { id: "call-2", name: "inspectDeployment", input: { deployment_id: "demo" } } },
],
},
];
render(<AssistantOperatorThread mode="dock" messages={messages} />);
expect(screen.getByText(/2 tools/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /readRunTrace/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /inspectDeployment/i })).toBeInTheDocument();
});
it("renders schema approval through the existing approval surface", async () => {
const user = userEvent.setup();
const submit = vi.fn();
const cancel = vi.fn();
const messages: ReadonlyArray<AgentMessage> = [
{
id: "approval",
role: "assistant",
parts: [
{
type: "approval-request",
callId: "call-approval",
name: "resumeIssueReview",
prompt: "Submit resume request?",
contract: {
kind: "issue_review",
outcomes: ["submitted", "cancelled"],
resumeSchema: { type: "object" },
resumePayloadPreview: { selected_issue_ids: ["risk-1"] },
runId: "run_recorded_lda_report",
},
},
],
},
];
render(
<AssistantOperatorThread
mode="dock"
messages={messages}
submitApproval={submit}
cancelApproval={cancel}
/>,
);
expect(screen.getByRole("group", { name: /issue review resume/i })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Submit" }));
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(submit).toHaveBeenCalledOnce();
expect(cancel).toHaveBeenCalledOnce();
});
it("renders a chat-owned run action", async () => {
const user = userEvent.setup();
const run = vi.fn();
render(
<AssistantOperatorThread
mode="dock"
messages={[]}
runAction={{ label: "Run prepared workflow", disabled: false, run }}
/>,
);
await user.click(screen.getByRole("button", { name: /run prepared workflow/i }));
expect(run).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,263 @@
import { useCallback, useMemo, useState } from "react";
import {
AssistantRuntimeProvider,
MessagePrimitive,
ThreadPrimitive,
useExternalStoreRuntime,
type AppendMessage,
} from "@assistant-ui/react";
import type { AgentMessage } from "../../demo/agent/events.js";
import { SchemaApprovalSurface } from "../approval/SchemaApprovalSurface.js";
import { projectAgentMessagesForAssistant, type AssistantProjectedMessage } from "./assistantRuntimeProjection.js";
type AssistantOperatorThreadProps = {
readonly mode: "hidden" | "full" | "rail" | "dock";
readonly messages: ReadonlyArray<AgentMessage>;
readonly runAction?: { readonly label: string; readonly disabled: boolean; readonly run: () => void } | undefined;
readonly submitApproval?: (() => void) | undefined;
readonly cancelApproval?: (() => void) | undefined;
readonly ariaLabel?: string | undefined;
};
const formatJson = (value: unknown): string => {
if (typeof value === "string") return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
};
const ToolCard = ({
toolName,
args,
result,
isError,
}: {
readonly toolName: string;
readonly args?: unknown;
readonly result?: unknown;
readonly isError?: boolean;
}) => {
const [open, setOpen] = useState(false);
return (
<div className="assistant-tool-card" data-open={open ? "true" : "false"}>
<button
type="button"
className="assistant-tool-card__trigger"
aria-expanded={open}
onClick={() => setOpen((c) => !c)}
>
<span className="assistant-tool-card__label">Used tool: <b>{toolName}</b></span>
</button>
{open ? (
<div className="assistant-tool-card__body">
{args !== undefined ? (
<pre className="assistant-tool-card__io" aria-label="tool input">{formatJson(args)}</pre>
) : null}
{result !== undefined ? (
<div className="assistant-tool-card__result" data-error={isError ? "true" : undefined}>
<span>{isError ? "error" : "success"}</span>
<pre className="assistant-tool-card__io" aria-label="tool output">{formatJson(result)}</pre>
</div>
) : null}
</div>
) : null}
</div>
);
};
const ToolGroupCard = ({
toolCount,
defaultOpen = true,
children,
}: {
readonly toolCount: number;
readonly defaultOpen?: boolean;
readonly children: React.ReactNode;
}) => {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="assistant-tool-group" data-open={open ? "true" : "false"}>
<button
type="button"
className="assistant-tool-group__trigger"
aria-expanded={open}
onClick={() => setOpen((c) => !c)}
>
<span>{toolCount} tools</span>
</button>
{open ? <div className="assistant-tool-group__body">{children}</div> : null}
</div>
);
};
type ContentPart =
| { readonly type: "text"; readonly text: string }
| { readonly type: "tool-call"; readonly toolName: string; readonly args?: unknown; readonly result?: unknown; readonly isError?: boolean };
const renderContentPart = (
part: ContentPart,
submitApproval?: (() => void) | undefined,
cancelApproval?: (() => void) | undefined,
): React.ReactNode => {
if (part.type === "text") {
return <MessagePrimitive.Content />;
}
const toolName = part.toolName;
const args = part.args as Record<string, unknown> | undefined;
const contract = args?.contract as
| {
readonly kind: string;
readonly outcomes: readonly string[];
readonly resumeSchema: unknown;
readonly resumePayloadPreview: unknown;
readonly runId: string;
}
| undefined;
if (toolName === "resumeIssueReview" && contract) {
return (
<div className="assistant-tool-approval">
<SchemaApprovalSurface
title={`${contract.kind.replaceAll("_", " ")} resume`}
schema={contract.resumeSchema}
payload={contract.resumePayloadPreview}
outcomes={contract.outcomes}
runId={contract.runId}
onSubmit={submitApproval}
onCancel={cancelApproval}
/>
</div>
);
}
return <ToolCard toolName={toolName} args={part.args} result={part.result} isError={part.isError ?? false} />;
};
const AssistantMessageBody = ({
parts,
submitApproval,
cancelApproval,
}: {
readonly parts: readonly ContentPart[];
readonly submitApproval?: (() => void) | undefined;
readonly cancelApproval?: (() => void) | undefined;
}) => {
const textParts = parts.filter((p) => p.type === "text");
const toolParts = parts.filter((p) => p.type === "tool-call");
if (toolParts.length === 0) {
return <MessagePrimitive.Content />;
}
if (toolParts.length === 1) {
const firstToolIndex = parts.findIndex((p) => p.type === "tool-call");
const beforeText = parts.slice(0, firstToolIndex).filter((p) => p.type === "text");
const afterText = parts.slice(firstToolIndex + 1).filter((p) => p.type === "text");
const tool = toolParts[0]!;
return (
<>
{beforeText.map((p, i) => <p key={`before-${i}`} style={{ whiteSpace: "pre-line" }}>{p.text}</p>)}
{renderContentPart(tool, submitApproval, cancelApproval)}
{afterText.map((p, i) => <p key={`after-${i}`} style={{ whiteSpace: "pre-line" }}>{p.text}</p>)}
</>
);
}
const firstToolIndex = parts.findIndex((p) => p.type === "tool-call");
const beforeText = parts.slice(0, firstToolIndex).filter((p) => p.type === "text");
return (
<>
{beforeText.map((p, i) => <p key={`before-${i}`} style={{ whiteSpace: "pre-line" }}>{p.text}</p>)}
<ToolGroupCard toolCount={toolParts.length}>
{toolParts.map((tool, i) => (
<div key={i}>
{renderContentPart(tool, submitApproval, cancelApproval)}
</div>
))}
</ToolGroupCard>
</>
);
};
const ThreadBody = ({
submitApproval,
cancelApproval,
}: {
readonly submitApproval?: (() => void) | undefined;
readonly cancelApproval?: (() => void) | undefined;
}) => (
<ThreadPrimitive.Root className="assistant-thread">
<ThreadPrimitive.Viewport className="assistant-thread__viewport">
<ThreadPrimitive.Messages>
{({ message }) => {
const content = (message as unknown as { content?: unknown }).content;
const parts: ContentPart[] = Array.isArray(content)
? (content as ContentPart[])
: typeof content === "string"
? [{ type: "text" as const, text: content }]
: [];
return (
<MessagePrimitive.Root
className="assistant-message"
data-role={message.role}
>
<AssistantMessageBody
parts={parts}
submitApproval={submitApproval}
cancelApproval={cancelApproval}
/>
</MessagePrimitive.Root>
);
}}
</ThreadPrimitive.Messages>
</ThreadPrimitive.Viewport>
</ThreadPrimitive.Root>
);
export const AssistantOperatorThread = ({
mode,
messages,
runAction,
submitApproval,
cancelApproval,
ariaLabel = "operator conversation",
}: AssistantOperatorThreadProps) => {
const projected = useMemo(() => projectAgentMessagesForAssistant(messages), [messages]);
const [localMessages, setLocalMessages] = useState<AssistantProjectedMessage[]>([]);
const runtimeMessages = projected.length > 0 ? projected : localMessages;
const onNew = useCallback(async (message: AppendMessage) => {
const text = message.content.find((part) => part.type === "text")?.text ?? "";
setLocalMessages((current) => [
...current,
{
id: `local-${current.length + 1}`,
role: "user" as const,
content: [{ type: "text" as const, text }],
},
]);
}, []);
const runtime = useExternalStoreRuntime({
messages: runtimeMessages,
setMessages: (msgs) => setLocalMessages([...msgs]),
onNew,
convertMessage: (message) => message,
isRunning: false,
});
return (
<section className="assistant-operator-thread" data-mode={mode} role="log" aria-label={ariaLabel}>
{runAction ? (
<div className="assistant-operator-thread__action">
<button type="button" disabled={runAction.disabled} onClick={runAction.run}>
{runAction.label}
</button>
</div>
) : null}
<AssistantRuntimeProvider runtime={runtime}>
<ThreadBody submitApproval={submitApproval} cancelApproval={cancelApproval} />
</AssistantRuntimeProvider>
</section>
);
};
@@ -36,7 +36,7 @@ describe("assistantRuntimeProjection", () => {
]);
});
it("projects tool results as tool-role messages", () => {
it("projects tool results as text summary", () => {
const messages: ReadonlyArray<AgentMessage> = [
{
id: "tool-result-message",
@@ -59,15 +59,9 @@ describe("assistantRuntimeProjection", () => {
expect(projected[0]).toMatchObject({
id: "tool-result-message",
role: "tool",
role: "assistant",
content: [
{
type: "tool-result",
toolCallId: "call-1",
toolName: "readRunTrace",
result: { frames: 3 },
isError: false,
},
{ type: "text", text: "Result for readRunTrace: success" },
],
});
});
@@ -8,27 +8,22 @@ export type AssistantToolRenderPayload = {
readonly isError?: boolean;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- JSON-serializable args for assistant-ui compatibility
type JsonArgs = Record<string, any>;
type AssistantContentPart =
| { readonly type: "text"; readonly text: string }
| {
readonly type: "tool-call";
readonly toolCallId: string;
readonly toolCallId?: string;
readonly toolName: string;
readonly args: unknown;
}
| {
readonly type: "tool-result";
readonly toolCallId: string;
readonly toolName: string;
readonly result: unknown;
readonly isError: boolean;
readonly args?: JsonArgs;
};
export type AssistantProjectedMessage = {
readonly id: string;
readonly role: "user" | "assistant" | "tool";
readonly role: "user" | "assistant";
readonly content: ReadonlyArray<AssistantContentPart>;
readonly metadata?: { readonly unstable_state?: string | undefined };
};
const projectPart = (part: AgentMessagePart): AssistantContentPart[] => {
@@ -40,22 +35,19 @@ const projectPart = (part: AgentMessagePart): AssistantContentPart[] => {
type: "tool-call",
toolCallId: part.call.id,
toolName: part.call.name,
args: part.call.input,
args: part.call.input as JsonArgs,
}];
case "tool-result":
return [{
type: "tool-result",
toolCallId: part.result.callId,
toolName: part.result.name,
result: part.result.output,
isError: part.result.status === "failure",
type: "text",
text: `Result for ${part.result.name}: ${part.result.status === "failure" ? "error" : "success"}`,
}];
case "presentation-action":
return [{
type: "tool-call",
toolCallId: `presentation-${part.action.type}`,
toolName: `presentation.${part.action.type}`,
args: part.action,
args: part.action as JsonArgs,
}];
case "approval-request":
return [
@@ -67,7 +59,7 @@ const projectPart = (part: AgentMessagePart): AssistantContentPart[] => {
args: {
prompt: part.prompt,
contract: part.contract,
},
} as JsonArgs,
},
];
case "error":
@@ -76,9 +68,6 @@ const projectPart = (part: AgentMessagePart): AssistantContentPart[] => {
};
const messageRoleFor = (message: AgentMessage): AssistantProjectedMessage["role"] => {
const onlyToolResults = message.parts.length > 0
&& message.parts.every((part) => part.type === "tool-result");
if (onlyToolResults) return "tool";
return message.role === "user" ? "user" : "assistant";
};
@@ -1638,6 +1638,145 @@
opacity: 0.55;
}
.assistant-operator-thread {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 0.55rem;
min-height: 0;
}
.assistant-operator-thread[data-mode="hidden"] {
display: none;
}
.assistant-operator-thread__action button {
width: 100%;
border: 1px solid var(--accent-cyan);
border-radius: 0.65rem;
background: color-mix(in oklch, var(--accent-cyan) 13%, var(--stage-surface));
color: var(--text-primary);
padding: 0.55rem 0.7rem;
font: 700 0.8rem/1 var(--font-interface);
}
.assistant-thread,
.assistant-thread__viewport {
min-height: 0;
height: 100%;
}
.assistant-thread__viewport {
overflow: auto;
scrollbar-width: none;
}
.assistant-thread__viewport::-webkit-scrollbar {
display: none;
}
.assistant-message {
display: grid;
gap: 0.45rem;
margin-bottom: 0.65rem;
}
.assistant-tool-card {
border: 1px solid var(--stage-line);
border-radius: 0.65rem;
background: var(--stage-inset);
overflow: hidden;
}
.assistant-tool-card__trigger {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.45rem 0.65rem;
background: none;
border: none;
color: var(--text-primary);
font: 600 0.78rem/1 var(--font-interface);
cursor: pointer;
text-align: left;
}
.assistant-tool-card__trigger:hover {
background: color-mix(in oklch, var(--accent-cyan) 8%, transparent);
}
.assistant-tool-card__body {
padding: 0 0.65rem 0.55rem;
}
.assistant-tool-card__io {
background: var(--stage-surface);
border: 1px solid var(--stage-line);
border-radius: 0.5rem;
padding: 0.45rem 0.55rem;
font: 0.72rem/1.45 var(--font-mono);
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-all;
overflow-x: auto;
}
.assistant-tool-card__result {
margin-top: 0.35rem;
font: 600 0.7rem/1 var(--font-interface);
}
.assistant-tool-card__result span {
color: var(--accent-cyan);
}
.assistant-tool-card__result[data-error="true"] span {
color: var(--accent-error);
}
.assistant-tool-group {
border: 1px solid var(--stage-line);
border-radius: 0.65rem;
background: var(--stage-inset);
overflow: hidden;
}
.assistant-tool-group__trigger {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.45rem 0.65rem;
background: none;
border: none;
color: var(--text-secondary);
font: 600 0.78rem/1 var(--font-interface);
cursor: pointer;
}
.assistant-tool-group__trigger:hover {
background: color-mix(in oklch, var(--accent-cyan) 8%, transparent);
color: var(--text-primary);
}
.assistant-tool-group__body {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0 0.65rem 0.55rem;
}
.assistant-tool-group__body .assistant-tool-card {
border-color: var(--stage-line);
}
.assistant-tool-approval {
border: 1px solid color-mix(in oklch, var(--accent-cyan) 50%, var(--stage-line));
border-radius: 0.75rem;
background: var(--stage-inset);
padding: 0.65rem;
}
.operator-chat .ai-chat-conversation {
display: flex;
min-height: 0;
+4
View File
@@ -28,3 +28,7 @@ if (!globalThis.DOMRect) {
}),
} as unknown as typeof DOMRect;
}
if (!HTMLDivElement.prototype.scrollTo) {
HTMLDivElement.prototype.scrollTo = () => {};
}