refactor: use assistant ui for operator chat
This commit is contained in:
@@ -19,8 +19,6 @@ describe("OperatorChat", () => {
|
||||
const chat = screen.getByLabelText("scripted operator chat");
|
||||
expect(chat).toHaveAttribute("data-chat-theme", "light");
|
||||
expect(chat).toHaveAttribute("data-presentation-surface", "editorial");
|
||||
expect(screen.getAllByText(/Found prepared workflow recipe/)[0]?.closest(".ai-chat-message"))
|
||||
.toHaveClass("ai-chat-message");
|
||||
});
|
||||
|
||||
it("maps dark chat theme to the night presentation surface", () => {
|
||||
@@ -38,11 +36,18 @@ describe("OperatorChat", () => {
|
||||
expect(chat).not.toHaveAttribute("data-readable-surface");
|
||||
});
|
||||
|
||||
it("renders messages through the AI chat conversation surface", () => {
|
||||
it("renders through the assistant-ui operator thread", () => {
|
||||
render(<OperatorChat state={initialPresentationState} />);
|
||||
|
||||
expect(screen.getByRole("log", { name: /operator conversation/i }))
|
||||
.toHaveClass("assistant-operator-thread");
|
||||
});
|
||||
|
||||
it("renders fallback messages when no agent messages are present", () => {
|
||||
render(<OperatorChat state={initialPresentationState} />);
|
||||
|
||||
expect(screen.getByRole("log", { name: "operator conversation" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Prepare the thesis readiness report.")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Found prepared workflow recipe/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders standard agent message parts", () => {
|
||||
@@ -69,12 +74,10 @@ describe("OperatorChat", () => {
|
||||
|
||||
expect(screen.getByText("Prepare the report.")).toBeInTheDocument();
|
||||
expect(screen.getByText("I will use the prepared recipe.")).toBeInTheDocument();
|
||||
expect(screen.getByText(/tool call/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/selectWorkflowNode/i).length).toBe(2);
|
||||
expect(screen.getByText(/tool result/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /selectWorkflowNode/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders tool calls as collapsed AI tool blocks", async () => {
|
||||
it("renders tool calls as collapsed tool cards", async () => {
|
||||
const user = userEvent.setup();
|
||||
const messages: ReadonlyArray<AgentMessage> = [
|
||||
{
|
||||
@@ -88,17 +91,10 @@ describe("OperatorChat", () => {
|
||||
|
||||
render(<OperatorChat state={initialPresentationState} messages={messages} />);
|
||||
|
||||
const tool = screen.getByRole("button", { name: /tool call.*readRunTrace/i });
|
||||
expect(screen.queryByText(/run_id/)).not.toBeInTheDocument();
|
||||
const tool = screen.getByRole("button", { name: /readRunTrace/i });
|
||||
expect(screen.queryByText(/run_1/)).not.toBeInTheDocument();
|
||||
await user.click(tool);
|
||||
expect(screen.getByText(/run_id/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders fallback messages when no agent messages are present", () => {
|
||||
render(<OperatorChat state={initialPresentationState} />);
|
||||
|
||||
expect(screen.getByText("Prepare the thesis readiness report.")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Found prepared workflow recipe/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/run_1/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders schema approval surface inside chat approval request", async () => {
|
||||
@@ -137,10 +133,8 @@ describe("OperatorChat", () => {
|
||||
expect(onDeny).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to plain approve/deny buttons when approval request has no contract", async () => {
|
||||
it("falls back to tool card display when approval request has no contract", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onApprove = vi.fn();
|
||||
const onDeny = vi.fn();
|
||||
const messages: ReadonlyArray<AgentMessage> = [
|
||||
{
|
||||
id: "approval",
|
||||
@@ -156,13 +150,15 @@ describe("OperatorChat", () => {
|
||||
},
|
||||
];
|
||||
|
||||
render(<OperatorChat state={initialPresentationState} messages={messages} onApprove={onApprove} onDeny={onDeny} />);
|
||||
render(<OperatorChat state={initialPresentationState} messages={messages} />);
|
||||
|
||||
expect(screen.queryByRole("group", { name: /issue review resume/i })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Approve" }));
|
||||
await user.click(screen.getByRole("button", { name: "Deny" }));
|
||||
expect(onApprove).toHaveBeenCalledTimes(1);
|
||||
expect(onDeny).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText("Submit resume request?")).toBeInTheDocument();
|
||||
const tool = screen.getByRole("button", { name: /resumeIssueReview/i });
|
||||
expect(screen.queryByRole("button", { name: /Approve/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Deny/i })).not.toBeInTheDocument();
|
||||
await user.click(tool);
|
||||
expect(screen.getByLabelText("tool input")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders error and presentation action parts", () => {
|
||||
@@ -179,9 +175,8 @@ describe("OperatorChat", () => {
|
||||
|
||||
render(<OperatorChat state={initialPresentationState} messages={messages} />);
|
||||
|
||||
expect(screen.getByText("Presentation action")).toBeInTheDocument();
|
||||
expect(screen.getByText("selectWorkflowNode")).toBeInTheDocument();
|
||||
expect(screen.getByText("provider failed")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /presentation.selectWorkflowNode/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a chat-owned run prepared workflow action", async () => {
|
||||
@@ -269,8 +264,7 @@ describe("OperatorChat", () => {
|
||||
|
||||
render(<OperatorChat state={initialPresentationState} messages={messages} />);
|
||||
|
||||
expect(screen.getByText("Workflow operation")).toBeInTheDocument();
|
||||
expect(screen.getByText("startPreparedReportRun")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /startPreparedReportRun/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables schema approval buttons when approval actions are unavailable", () => {
|
||||
|
||||
@@ -3,19 +3,7 @@ import type { AgentMessage } from "../demo/agent/events.js";
|
||||
import type { TimelineAgentController } from "../demo/agent/timelineAgent.js";
|
||||
import type { PresentationState } from "./presentation-state.js";
|
||||
import { compositionForState } from "./presentation-state.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";
|
||||
import { AssistantOperatorThread } from "./chat/AssistantOperatorThread.js";
|
||||
|
||||
type OperatorChatProps = {
|
||||
readonly state: PresentationState;
|
||||
@@ -42,49 +30,6 @@ const fallbackMessages = (state: PresentationState): ReadonlyArray<AgentMessage>
|
||||
},
|
||||
];
|
||||
|
||||
const renderProjectedPart = (
|
||||
part: ProjectedChatPart,
|
||||
key: string,
|
||||
submit?: () => void,
|
||||
cancel?: () => void,
|
||||
) => {
|
||||
switch (part.kind) {
|
||||
case "text":
|
||||
return <MessageResponse key={key}>{part.text}</MessageResponse>;
|
||||
case "tool":
|
||||
return (
|
||||
<Tool key={key} label={part.label} name={part.name} state={part.state} defaultOpen={part.defaultOpen}>
|
||||
{"input" in part ? <ToolInput input={part.input} /> : null}
|
||||
{"output" in part ? <ToolOutput status={part.state} output={part.output} /> : null}
|
||||
</Tool>
|
||||
);
|
||||
case "approval":
|
||||
return (
|
||||
<Tool key={key} label="Approval required" name={part.name} state="pending" defaultOpen>
|
||||
<MessageResponse>{part.prompt}</MessageResponse>
|
||||
{part.contract ? (
|
||||
<SchemaApprovalSurface
|
||||
title={`${part.contract.kind.replaceAll("_", " ")} resume`}
|
||||
schema={part.contract.resumeSchema}
|
||||
payload={part.contract.resumePayloadPreview}
|
||||
outcomes={part.contract.outcomes}
|
||||
runId={part.contract.runId}
|
||||
onSubmit={submit}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
) : (
|
||||
<div className="chat-approval-actions">
|
||||
<button type="button" onClick={submit} disabled={!submit}>Approve</button>
|
||||
<button type="button" onClick={cancel} disabled={!cancel}>Deny</button>
|
||||
</div>
|
||||
)}
|
||||
</Tool>
|
||||
);
|
||||
case "error":
|
||||
return <MessageResponse key={key}>{part.message}</MessageResponse>;
|
||||
}
|
||||
};
|
||||
|
||||
export const OperatorChat = ({ state, messages, timelineAgent, onApprove, onDeny }: OperatorChatProps) => {
|
||||
const visibleMessages = messages && messages.length > 0
|
||||
? messages
|
||||
@@ -103,24 +48,17 @@ export const OperatorChat = ({ state, messages, timelineAgent, onApprove, onDeny
|
||||
data-presentation-surface={presentationSurface}
|
||||
aria-label="scripted operator chat"
|
||||
>
|
||||
{timelineAgent ? (
|
||||
<PromptAction
|
||||
label={timelineAgent.runLabel}
|
||||
disabled={!timelineAgent.canRun}
|
||||
onClick={() => void timelineAgent.runPreparedWorkflow()}
|
||||
/>
|
||||
) : null}
|
||||
<Conversation mode={composition.chatMode}>
|
||||
<ConversationContent>
|
||||
{visibleMessages.map(projectAgentMessage).map((message) => (
|
||||
<Message key={message.id} from={message.from}>
|
||||
<MessageContent>
|
||||
{message.parts.map((part, index) => renderProjectedPart(part, `${message.id}-${index}`, submit, cancel))}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
))}
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
<AssistantOperatorThread
|
||||
mode={composition.chatMode}
|
||||
messages={visibleMessages}
|
||||
runAction={timelineAgent ? {
|
||||
label: timelineAgent.runLabel,
|
||||
disabled: !timelineAgent.canRun,
|
||||
run: () => void timelineAgent.runPreparedWorkflow(),
|
||||
} : undefined}
|
||||
submitApproval={submit}
|
||||
cancelApproval={cancel}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user