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");
|
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(".ai-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,11 +36,18 @@ 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", () => {
|
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} />);
|
render(<OperatorChat state={initialPresentationState} />);
|
||||||
|
|
||||||
expect(screen.getByRole("log", { name: "operator conversation" })).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Prepare the thesis readiness report.")).toBeInTheDocument();
|
expect(screen.getByText("Prepare the thesis readiness report.")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Found prepared workflow recipe/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders standard agent message parts", () => {
|
it("renders standard agent message parts", () => {
|
||||||
@@ -69,12 +74,10 @@ describe("OperatorChat", () => {
|
|||||||
|
|
||||||
expect(screen.getByText("Prepare the report.")).toBeInTheDocument();
|
expect(screen.getByText("Prepare the report.")).toBeInTheDocument();
|
||||||
expect(screen.getByText("I will use the prepared recipe.")).toBeInTheDocument();
|
expect(screen.getByText("I will use the prepared recipe.")).toBeInTheDocument();
|
||||||
expect(screen.getByText(/tool call/i)).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /selectWorkflowNode/i })).toBeInTheDocument();
|
||||||
expect(screen.getAllByText(/selectWorkflowNode/i).length).toBe(2);
|
|
||||||
expect(screen.getByText(/tool result/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 user = userEvent.setup();
|
||||||
const messages: ReadonlyArray<AgentMessage> = [
|
const messages: ReadonlyArray<AgentMessage> = [
|
||||||
{
|
{
|
||||||
@@ -88,17 +91,10 @@ describe("OperatorChat", () => {
|
|||||||
|
|
||||||
render(<OperatorChat state={initialPresentationState} messages={messages} />);
|
render(<OperatorChat state={initialPresentationState} messages={messages} />);
|
||||||
|
|
||||||
const tool = screen.getByRole("button", { name: /tool call.*readRunTrace/i });
|
const tool = screen.getByRole("button", { name: /readRunTrace/i });
|
||||||
expect(screen.queryByText(/run_id/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/run_1/)).not.toBeInTheDocument();
|
||||||
await user.click(tool);
|
await user.click(tool);
|
||||||
expect(screen.getByText(/run_id/)).toBeInTheDocument();
|
expect(screen.getByText(/run_1/)).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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders schema approval surface inside chat approval request", async () => {
|
it("renders schema approval surface inside chat approval request", async () => {
|
||||||
@@ -137,10 +133,8 @@ describe("OperatorChat", () => {
|
|||||||
expect(onDeny).toHaveBeenCalledTimes(1);
|
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 user = userEvent.setup();
|
||||||
const onApprove = vi.fn();
|
|
||||||
const onDeny = vi.fn();
|
|
||||||
const messages: ReadonlyArray<AgentMessage> = [
|
const messages: ReadonlyArray<AgentMessage> = [
|
||||||
{
|
{
|
||||||
id: "approval",
|
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();
|
expect(screen.queryByRole("group", { name: /issue review resume/i })).not.toBeInTheDocument();
|
||||||
await user.click(screen.getByRole("button", { name: "Approve" }));
|
expect(screen.getByText("Submit resume request?")).toBeInTheDocument();
|
||||||
await user.click(screen.getByRole("button", { name: "Deny" }));
|
const tool = screen.getByRole("button", { name: /resumeIssueReview/i });
|
||||||
expect(onApprove).toHaveBeenCalledTimes(1);
|
expect(screen.queryByRole("button", { name: /Approve/i })).not.toBeInTheDocument();
|
||||||
expect(onDeny).toHaveBeenCalledTimes(1);
|
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", () => {
|
it("renders error and presentation action parts", () => {
|
||||||
@@ -179,9 +175,8 @@ describe("OperatorChat", () => {
|
|||||||
|
|
||||||
render(<OperatorChat state={initialPresentationState} messages={messages} />);
|
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.getByText("provider failed")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /presentation.selectWorkflowNode/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a chat-owned run prepared workflow action", async () => {
|
it("shows a chat-owned run prepared workflow action", async () => {
|
||||||
@@ -269,8 +264,7 @@ describe("OperatorChat", () => {
|
|||||||
|
|
||||||
render(<OperatorChat state={initialPresentationState} messages={messages} />);
|
render(<OperatorChat state={initialPresentationState} messages={messages} />);
|
||||||
|
|
||||||
expect(screen.getByText("Workflow operation")).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /startPreparedReportRun/i })).toBeInTheDocument();
|
||||||
expect(screen.getByText("startPreparedReportRun")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables schema approval buttons when approval actions are unavailable", () => {
|
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 { 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 { AssistantOperatorThread } from "./chat/AssistantOperatorThread.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;
|
||||||
@@ -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) => {
|
export const OperatorChat = ({ state, messages, timelineAgent, onApprove, onDeny }: OperatorChatProps) => {
|
||||||
const visibleMessages = messages && messages.length > 0
|
const visibleMessages = messages && messages.length > 0
|
||||||
? messages
|
? messages
|
||||||
@@ -103,24 +48,17 @@ export const OperatorChat = ({ state, messages, timelineAgent, onApprove, onDeny
|
|||||||
data-presentation-surface={presentationSurface}
|
data-presentation-surface={presentationSurface}
|
||||||
aria-label="scripted operator chat"
|
aria-label="scripted operator chat"
|
||||||
>
|
>
|
||||||
{timelineAgent ? (
|
<AssistantOperatorThread
|
||||||
<PromptAction
|
mode={composition.chatMode}
|
||||||
label={timelineAgent.runLabel}
|
messages={visibleMessages}
|
||||||
disabled={!timelineAgent.canRun}
|
runAction={timelineAgent ? {
|
||||||
onClick={() => void timelineAgent.runPreparedWorkflow()}
|
label: timelineAgent.runLabel,
|
||||||
|
disabled: !timelineAgent.canRun,
|
||||||
|
run: () => void timelineAgent.runPreparedWorkflow(),
|
||||||
|
} : undefined}
|
||||||
|
submitApproval={submit}
|
||||||
|
cancelApproval={cancel}
|
||||||
/>
|
/>
|
||||||
) : 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>
|
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user