refactor: render operator chat with AI primitives
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user