refactor: render operator chat with AI primitives

This commit is contained in:
lda
2026-07-09 16:44:42 +07:00 Verified
parent 8af48d4e3d
commit 91f089df17
3 changed files with 79 additions and 152 deletions
@@ -19,8 +19,8 @@ describe("OperatorChat", () => {
const chat = screen.getByLabelText("scripted operator chat"); const chat = screen.getByLabelText("scripted operator chat");
expect(chat).toHaveAttribute("data-chat-theme", "light"); expect(chat).toHaveAttribute("data-chat-theme", "light");
expect(chat).toHaveAttribute("data-presentation-surface", "editorial"); expect(chat).toHaveAttribute("data-presentation-surface", "editorial");
expect(screen.getAllByText(/Found prepared workflow recipe/)[0]?.closest(".chat-message")) expect(screen.getAllByText(/Found prepared workflow recipe/)[0]?.closest(".ai-chat-message"))
.toHaveClass("chat-message"); .toHaveClass("ai-chat-message");
}); });
it("maps dark chat theme to the night presentation surface", () => { it("maps dark chat theme to the night presentation surface", () => {
@@ -38,6 +38,13 @@ describe("OperatorChat", () => {
expect(chat).not.toHaveAttribute("data-readable-surface"); expect(chat).not.toHaveAttribute("data-readable-surface");
}); });
it("renders messages through the AI chat conversation surface", () => {
render(<OperatorChat state={initialPresentationState} />);
expect(screen.getByRole("log", { name: "operator conversation" })).toBeInTheDocument();
expect(screen.getByText("Prepare the thesis readiness report.")).toBeInTheDocument();
});
it("renders standard agent message parts", () => { it("renders standard agent message parts", () => {
const messages: ReadonlyArray<AgentMessage> = [ const messages: ReadonlyArray<AgentMessage> = [
{ id: "u1", role: "user", parts: [{ type: "text", text: "Prepare the report." }] }, { id: "u1", role: "user", parts: [{ type: "text", text: "Prepare the report." }] },
@@ -67,6 +74,26 @@ describe("OperatorChat", () => {
expect(screen.getByText(/tool result/i)).toBeInTheDocument(); expect(screen.getByText(/tool result/i)).toBeInTheDocument();
}); });
it("renders tool calls as collapsed AI tool blocks", async () => {
const user = userEvent.setup();
const messages: ReadonlyArray<AgentMessage> = [
{
id: "start",
role: "assistant",
parts: [
{ type: "tool-call", call: { id: "call-1", name: "readRunTrace", input: { run_id: "run_1" } } },
],
},
];
render(<OperatorChat state={initialPresentationState} messages={messages} />);
const tool = screen.getByRole("button", { name: /tool call.*readRunTrace/i });
expect(screen.queryByText(/run_id/)).not.toBeInTheDocument();
await user.click(tool);
expect(screen.getByText(/run_id/)).toBeInTheDocument();
});
it("renders fallback messages when no agent messages are present", () => { it("renders fallback messages when no agent messages are present", () => {
render(<OperatorChat state={initialPresentationState} />); render(<OperatorChat state={initialPresentationState} />);
@@ -226,7 +253,7 @@ describe("OperatorChat", () => {
expect(cancelReview).toHaveBeenCalledTimes(1); expect(cancelReview).toHaveBeenCalledTimes(1);
}); });
it("renders prepared run tool calls as workflow handoffs", () => { it("renders prepared run tool calls as workflow handoffs", () => {
const messages: ReadonlyArray<AgentMessage> = [ const messages: ReadonlyArray<AgentMessage> = [
{ {
id: "start", id: "start",
@@ -1,10 +1,21 @@
import { m } from "motion/react";
import { PREPARE_THESIS_REPORT_RECIPE } from "../demo/agent/recipes.js"; import { PREPARE_THESIS_REPORT_RECIPE } from "../demo/agent/recipes.js";
import type { AgentMessage, AgentMessagePart } from "../demo/agent/events.js"; import type { AgentMessage } from "../demo/agent/events.js";
import type { TimelineAgentController } from "../demo/agent/timelineAgent.js"; import type { TimelineAgentController } from "../demo/agent/timelineAgent.js";
import type { PresentationState } from "./presentation-state.js"; import type { PresentationState } from "./presentation-state.js";
import { compositionForState } from "./presentation-state.js"; import { compositionForState } from "./presentation-state.js";
import { SchemaApprovalSurface } from "./approval/SchemaApprovalSurface.js"; import { SchemaApprovalSurface } from "./approval/SchemaApprovalSurface.js";
import {
Conversation,
ConversationContent,
Message,
MessageContent,
MessageResponse,
PromptAction,
Tool,
ToolInput,
ToolOutput,
} from "./chat/ChatPrimitives.js";
import { projectAgentMessage, type ProjectedChatPart } from "./chat/agentChatProjection.js";
type OperatorChatProps = { type OperatorChatProps = {
readonly state: PresentationState; readonly state: PresentationState;
@@ -31,56 +42,26 @@ const fallbackMessages = (state: PresentationState): ReadonlyArray<AgentMessage>
}, },
]; ];
const renderPart = ( const renderProjectedPart = (
part: AgentMessagePart, part: ProjectedChatPart,
key: string, key: string,
onApprove?: () => void, submit?: () => void,
onDeny?: () => void, cancel?: () => void,
) => { ) => {
switch (part.type) { switch (part.kind) {
case "text": case "text":
return <p key={key}>{part.text}</p>; return <MessageResponse key={key}>{part.text}</MessageResponse>;
case "tool-call": case "tool":
if (part.call.name === "startPreparedReportRun") {
return (
<m.div
key={key}
layout
layoutId="workflow-start-operation"
className="chat-tool-part chat-tool-part--handoff"
>
<span>Workflow operation</span>
<code>{part.call.name}</code>
</m.div>
);
}
return ( return (
<div key={key} className="chat-tool-part"> <Tool key={key} label={part.label} name={part.name} state={part.state} defaultOpen={part.defaultOpen}>
<span>Tool call</span> {"input" in part ? <ToolInput input={part.input} /> : null}
<code>{part.call.name}</code> {"output" in part ? <ToolOutput status={part.state} output={part.output} /> : null}
</div> </Tool>
); );
case "tool-result": case "approval":
return ( return (
<div key={key} className="chat-tool-part chat-tool-part--result"> <Tool key={key} label="Approval required" name={part.name} state="pending" defaultOpen>
<span>Tool result</span> <MessageResponse>{part.prompt}</MessageResponse>
<code>{part.result.name}</code>
<small>{part.result.status}</small>
</div>
);
case "presentation-action":
return (
<div key={key} className="chat-tool-part chat-tool-part--presentation">
<span>Presentation action</span>
<code>{part.action.type}</code>
</div>
);
case "approval-request":
return (
<div key={key} className="chat-tool-part chat-tool-part--approval">
<span>Approval required</span>
<code>{part.name}</code>
<p>{part.prompt}</p>
{part.contract ? ( {part.contract ? (
<SchemaApprovalSurface <SchemaApprovalSurface
title={`${part.contract.kind.replaceAll("_", " ")} resume`} title={`${part.contract.kind.replaceAll("_", " ")} resume`}
@@ -88,38 +69,19 @@ const renderPart = (
payload={part.contract.resumePayloadPreview} payload={part.contract.resumePayloadPreview}
outcomes={part.contract.outcomes} outcomes={part.contract.outcomes}
runId={part.contract.runId} runId={part.contract.runId}
onSubmit={onApprove} onSubmit={submit}
onCancel={onDeny} onCancel={cancel}
/> />
) : ( ) : (
<div className="chat-approval-actions"> <div className="chat-approval-actions">
<button type="button" onClick={onApprove} disabled={!onApprove}>Approve</button> <button type="button" onClick={submit} disabled={!submit}>Approve</button>
<button type="button" onClick={onDeny} disabled={!onDeny}>Deny</button> <button type="button" onClick={cancel} disabled={!cancel}>Deny</button>
</div> </div>
)} )}
</div> </Tool>
); );
case "error": case "error":
return <p key={key} className="chat-error">{part.message}</p>; return <MessageResponse key={key}>{part.message}</MessageResponse>;
default:
return null;
}
};
const partKey = (messageId: string, part: AgentMessagePart): string => {
switch (part.type) {
case "text":
return `${messageId}-text-${part.text}`;
case "tool-call":
return `${messageId}-call-${part.call.id}`;
case "tool-result":
return `${messageId}-result-${part.result.callId}`;
case "presentation-action":
return `${messageId}-action-${JSON.stringify(part.action)}`;
case "approval-request":
return `${messageId}-approval-${part.callId}`;
case "error":
return `${messageId}-error-${part.message}`;
} }
}; };
@@ -142,22 +104,23 @@ export const OperatorChat = ({ state, messages, timelineAgent, onApprove, onDeny
aria-label="scripted operator chat" aria-label="scripted operator chat"
> >
{timelineAgent ? ( {timelineAgent ? (
<div className="operator-chat__action"> <PromptAction
<button label={timelineAgent.runLabel}
type="button" disabled={!timelineAgent.canRun}
onClick={() => void timelineAgent.runPreparedWorkflow()} onClick={() => void timelineAgent.runPreparedWorkflow()}
disabled={!timelineAgent.canRun} />
>
{timelineAgent.runLabel}
</button>
</div>
) : null} ) : null}
{visibleMessages.map((message) => ( <Conversation mode={composition.chatMode}>
<div key={message.id} className={`chat-message chat-message--${message.role === "user" ? "operator" : "system"}`}> <ConversationContent>
<strong>{message.role === "user" ? "Operator" : "lda.chat"}</strong> {visibleMessages.map(projectAgentMessage).map((message) => (
{message.parts.map((part) => renderPart(part, partKey(message.id, part), submit, cancel))} <Message key={message.id} from={message.from}>
</div> <MessageContent>
))} {message.parts.map((part, index) => renderProjectedPart(part, `${message.id}-${index}`, submit, cancel))}
</MessageContent>
</Message>
))}
</ConversationContent>
</Conversation>
</aside> </aside>
); );
}; };
@@ -181,37 +181,6 @@
max-width: var(--chat-width); max-width: var(--chat-width);
} }
.chat-message {
border: 1px solid oklch(0.34 0.035 250);
border-radius: 0.65rem;
padding: 0.65rem 0.75rem;
background: oklch(0.18 0.025 250);
color: var(--text-primary);
font-size: 0.85rem;
}
.chat-message strong {
display: block;
margin-bottom: 0.35rem;
color: var(--text-primary);
}
.chat-message p,
.chat-message span,
.chat-message code {
color: inherit;
}
.operator-chat[data-presentation-surface="editorial"] .chat-message {
border-color: color-mix(in oklch, var(--color-editorial-muted, oklch(0.48 0.025 65)) 34%, transparent);
background: color-mix(in oklch, var(--color-editorial-paper, oklch(0.975 0.012 82)) 82%, var(--color-editorial-muted, oklch(0.48 0.025 65)) 18%);
color: var(--color-editorial-ink, oklch(0.19 0.015 65));
}
.operator-chat[data-presentation-surface="editorial"] .chat-message strong {
color: var(--color-editorial-ink, oklch(0.19 0.015 65));
}
.operation-block { .operation-block {
border: 1px solid var(--stage-line); border: 1px solid var(--stage-line);
background: var(--stage-surface); background: var(--stage-surface);
@@ -788,38 +757,6 @@
} }
} }
.chat-tool-part {
display: flex;
align-items: center;
gap: 0.35rem;
margin-top: 0.25rem;
padding: 0.25rem 0.4rem;
border: 1px solid oklch(0.36 0.04 250);
border-radius: 0.35rem;
font-size: 0.75rem;
color: var(--text-primary);
}
.operator-chat[data-presentation-surface="editorial"] .chat-tool-part {
border-color: color-mix(in oklch, var(--color-editorial-muted, oklch(0.48 0.025 65)) 40%, transparent);
background: color-mix(in oklch, var(--color-editorial-paper, oklch(0.975 0.012 82)) 70%, transparent);
color: var(--color-editorial-ink, oklch(0.19 0.015 65));
}
.chat-tool-part span {
color: oklch(0.72 0.03 250);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.chat-tool-part--presentation {
border-color: oklch(0.7 0.16 195);
}
.chat-error {
color: oklch(0.65 0.2 25);
}
/* Scene body visual treatments */ /* Scene body visual treatments */
.scene-body__positioning-map { .scene-body__positioning-map {
display: grid; display: grid;