refactor: polish assistant-ui chat surface and rename tools
- Remove useExternalStoreRuntime (caused duplicate ID crashes on re-render) - Render messages directly from projected data for read-only transcripts - ToolCard open by default for single tools, collapsed inside ToolGroupCard - Remove result bubble from ToolCard (was unstyled) - Move run button below thread (chat-style layout) - Add user/assistant visual distinction (right-aligned cyan bubble for user) - Rename startPreparedReportRun -> startRun - Add 3 workflow.run_once tool calls to Scene 2 - Add overflow handling with max-height + hidden overflow - Update all tests for new behavior
This commit is contained in:
@@ -18,19 +18,19 @@ describe("agent events", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("creates tool call, tool result, and presentation action parts", () => {
|
it("creates tool call, tool result, and presentation action parts", () => {
|
||||||
expect(agentToolCallPart("c1", "startPreparedReportRun", { deploymentId: "lda_report_case_study.default" })).toEqual({
|
expect(agentToolCallPart("c1", "startRun", { deploymentId: "lda_report_case_study.default" })).toEqual({
|
||||||
type: "tool-call",
|
type: "tool-call",
|
||||||
call: {
|
call: {
|
||||||
id: "c1",
|
id: "c1",
|
||||||
name: "startPreparedReportRun",
|
name: "startRun",
|
||||||
input: { deploymentId: "lda_report_case_study.default" },
|
input: { deploymentId: "lda_report_case_study.default" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(agentToolResultPart("c1", "startPreparedReportRun", "success", { runId: "run_1" })).toEqual({
|
expect(agentToolResultPart("c1", "startRun", "success", { runId: "run_1" })).toEqual({
|
||||||
type: "tool-result",
|
type: "tool-result",
|
||||||
result: {
|
result: {
|
||||||
callId: "c1",
|
callId: "c1",
|
||||||
name: "startPreparedReportRun",
|
name: "startRun",
|
||||||
status: "success",
|
status: "success",
|
||||||
output: { runId: "run_1" },
|
output: { runId: "run_1" },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ describe("prepared recipe driver", () => {
|
|||||||
const messages = await collect(runPreparedRecipeReplay(recording, signal, async () => ({ approved: true, comment: "test" })));
|
const messages = await collect(runPreparedRecipeReplay(recording, signal, async () => ({ approved: true, comment: "test" })));
|
||||||
expect(messages[0]?.role).toBe("user");
|
expect(messages[0]?.role).toBe("user");
|
||||||
expect(messages.some((message) =>
|
expect(messages.some((message) =>
|
||||||
message.parts.some((part) => part.type === "tool-call" && part.call.name === "startPreparedReportRun"),
|
message.parts.some((part) => part.type === "tool-call" && part.call.name === "startRun"),
|
||||||
)).toBe(true);
|
)).toBe(true);
|
||||||
expect(messages.some((message) =>
|
expect(messages.some((message) =>
|
||||||
message.parts.some((part) => part.type === "presentation-action" && part.action.type === "selectWorkflowNode"),
|
message.parts.some((part) => part.type === "presentation-action" && part.action.type === "selectWorkflowNode"),
|
||||||
@@ -35,7 +35,7 @@ describe("prepared recipe driver", () => {
|
|||||||
);
|
);
|
||||||
expect(toolNames).toEqual([
|
expect(toolNames).toEqual([
|
||||||
"inspectDeployment",
|
"inspectDeployment",
|
||||||
"startPreparedReportRun",
|
"startRun",
|
||||||
"selectWorkflowNode",
|
"selectWorkflowNode",
|
||||||
"resumeIssueReview",
|
"resumeIssueReview",
|
||||||
"readRunTrace",
|
"readRunTrace",
|
||||||
@@ -92,7 +92,7 @@ describe("prepared recipe driver", () => {
|
|||||||
);
|
);
|
||||||
expect(toolCalls).toEqual([
|
expect(toolCalls).toEqual([
|
||||||
"inspectDeployment",
|
"inspectDeployment",
|
||||||
"startPreparedReportRun",
|
"startRun",
|
||||||
"selectWorkflowNode",
|
"selectWorkflowNode",
|
||||||
"resumeIssueReview",
|
"resumeIssueReview",
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export async function* runPreparedRecipeReplay(
|
|||||||
case "inspectDeployment":
|
case "inspectDeployment":
|
||||||
yield emitToolStep(step.id, step.toolName, { deploymentId }, { deploymentId });
|
yield emitToolStep(step.id, step.toolName, { deploymentId }, { deploymentId });
|
||||||
break;
|
break;
|
||||||
case "startPreparedReportRun":
|
case "startRun":
|
||||||
yield emitToolStep(step.id, step.toolName, { deploymentId }, {
|
yield emitToolStep(step.id, step.toolName, { deploymentId }, {
|
||||||
runId,
|
runId,
|
||||||
eventId: runStart?.id ?? null,
|
eventId: runStart?.id ?? null,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { LDA_REPORT_DEPLOYMENT_ID } from "../ldaReportDemoConfig.js";
|
|||||||
import type { PresentationToolName, WorkflowToolName } from "./tools.js";
|
import type { PresentationToolName, WorkflowToolName } from "./tools.js";
|
||||||
|
|
||||||
export type RecipeTool =
|
export type RecipeTool =
|
||||||
| Extract<WorkflowToolName, "inspectDeployment" | "startPreparedReportRun" | "resumeIssueReview" | "readRunTrace">
|
| Extract<WorkflowToolName, "inspectDeployment" | "startRun" | "resumeIssueReview" | "readRunTrace">
|
||||||
| Extract<PresentationToolName, "selectWorkflowNode">;
|
| Extract<PresentationToolName, "selectWorkflowNode">;
|
||||||
|
|
||||||
type SelectWorkflowNodeStep = {
|
type SelectWorkflowNodeStep = {
|
||||||
@@ -47,7 +47,7 @@ export const PREPARE_THESIS_REPORT_RECIPE: PreparedRecipe = {
|
|||||||
{
|
{
|
||||||
id: "start-run",
|
id: "start-run",
|
||||||
narration: "I will start the prepared workflow run.",
|
narration: "I will start the prepared workflow run.",
|
||||||
toolName: "startPreparedReportRun",
|
toolName: "startRun",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "focus-interrupt",
|
id: "focus-interrupt",
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export const useTimelineAgent = (
|
|||||||
setMessages((current) => appendToolMessage(
|
setMessages((current) => appendToolMessage(
|
||||||
current,
|
current,
|
||||||
"timeline-agent-start",
|
"timeline-agent-start",
|
||||||
"startPreparedReportRun",
|
"startRun",
|
||||||
{ mode: modeLabel },
|
{ mode: modeLabel },
|
||||||
{ phase: "started" },
|
{ phase: "started" },
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { AGENT_TOOLS, isAllowedAgentToolName } from "./tools.js";
|
|||||||
describe("agent tools", () => {
|
describe("agent tools", () => {
|
||||||
it("separates workflow tools from presentation tools", () => {
|
it("separates workflow tools from presentation tools", () => {
|
||||||
expect(AGENT_TOOLS.inspectDeployment.kind).toBe("workflow");
|
expect(AGENT_TOOLS.inspectDeployment.kind).toBe("workflow");
|
||||||
expect(AGENT_TOOLS.startPreparedReportRun.kind).toBe("workflow");
|
expect(AGENT_TOOLS.startRun.kind).toBe("workflow");
|
||||||
expect(AGENT_TOOLS.resumeIssueReview.kind).toBe("workflow");
|
expect(AGENT_TOOLS.resumeIssueReview.kind).toBe("workflow");
|
||||||
expect(AGENT_TOOLS.readRunTrace.kind).toBe("workflow");
|
expect(AGENT_TOOLS.readRunTrace.kind).toBe("workflow");
|
||||||
expect(AGENT_TOOLS.selectWorkflowNode.kind).toBe("presentation");
|
expect(AGENT_TOOLS.selectWorkflowNode.kind).toBe("presentation");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export type WorkflowToolName =
|
export type WorkflowToolName =
|
||||||
| "inspectDeployment"
|
| "inspectDeployment"
|
||||||
| "startPreparedReportRun"
|
| "startRun"
|
||||||
| "resumeIssueReview"
|
| "resumeIssueReview"
|
||||||
| "readRunTrace";
|
| "readRunTrace";
|
||||||
|
|
||||||
@@ -23,8 +23,8 @@ export const AGENT_TOOLS = {
|
|||||||
kind: "workflow",
|
kind: "workflow",
|
||||||
description: "Inspect the prepared report deployment.",
|
description: "Inspect the prepared report deployment.",
|
||||||
},
|
},
|
||||||
startPreparedReportRun: {
|
startRun: {
|
||||||
name: "startPreparedReportRun",
|
name: "startRun",
|
||||||
kind: "workflow",
|
kind: "workflow",
|
||||||
description: "Start the prepared report workflow run.",
|
description: "Start the prepared report workflow run.",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ describe("OperatorChat", () => {
|
|||||||
expect(screen.getByRole("button", { name: /selectWorkflowNode/i })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /selectWorkflowNode/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders tool calls as collapsed tool cards", async () => {
|
it("renders tool calls as open tool cards", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const messages: ReadonlyArray<AgentMessage> = [
|
const messages: ReadonlyArray<AgentMessage> = [
|
||||||
{
|
{
|
||||||
@@ -92,9 +92,10 @@ describe("OperatorChat", () => {
|
|||||||
render(<OperatorChat state={initialPresentationState} messages={messages} />);
|
render(<OperatorChat state={initialPresentationState} messages={messages} />);
|
||||||
|
|
||||||
const tool = screen.getByRole("button", { name: /readRunTrace/i });
|
const tool = screen.getByRole("button", { name: /readRunTrace/i });
|
||||||
expect(screen.queryByText(/run_1/)).not.toBeInTheDocument();
|
expect(tool).toHaveAttribute("aria-expanded", "true");
|
||||||
await user.click(tool);
|
|
||||||
expect(screen.getByText(/run_1/)).toBeInTheDocument();
|
expect(screen.getByText(/run_1/)).toBeInTheDocument();
|
||||||
|
await user.click(tool);
|
||||||
|
expect(screen.queryByText(/run_1/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders schema approval surface inside chat approval request", async () => {
|
it("renders schema approval surface inside chat approval request", async () => {
|
||||||
@@ -157,8 +158,9 @@ describe("OperatorChat", () => {
|
|||||||
const tool = screen.getByRole("button", { name: /resumeIssueReview/i });
|
const tool = screen.getByRole("button", { name: /resumeIssueReview/i });
|
||||||
expect(screen.queryByRole("button", { name: /Approve/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: /Approve/i })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: /Deny/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: /Deny/i })).not.toBeInTheDocument();
|
||||||
await user.click(tool);
|
|
||||||
expect(screen.getByLabelText("tool input")).toBeInTheDocument();
|
expect(screen.getByLabelText("tool input")).toBeInTheDocument();
|
||||||
|
await user.click(tool);
|
||||||
|
expect(screen.queryByLabelText("tool input")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders error and presentation action parts", () => {
|
it("renders error and presentation action parts", () => {
|
||||||
@@ -256,7 +258,7 @@ describe("OperatorChat", () => {
|
|||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
type: "tool-call",
|
type: "tool-call",
|
||||||
call: { id: "call-1", name: "startPreparedReportRun", input: { deploymentId: "demo" } },
|
call: { id: "call-1", name: "startRun", input: { deploymentId: "demo" } },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -264,7 +266,7 @@ describe("OperatorChat", () => {
|
|||||||
|
|
||||||
render(<OperatorChat state={initialPresentationState} messages={messages} />);
|
render(<OperatorChat state={initialPresentationState} messages={messages} />);
|
||||||
|
|
||||||
expect(screen.getByRole("button", { name: /startPreparedReportRun/i })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /startRun/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables schema approval buttons when approval actions are unavailable", () => {
|
it("disables schema approval buttons when approval actions are unavailable", () => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { AssistantOperatorThread } from "./AssistantOperatorThread.js";
|
|||||||
afterEach(() => cleanup());
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
describe("AssistantOperatorThread", () => {
|
describe("AssistantOperatorThread", () => {
|
||||||
it("renders text interleaved with a collapsed tool call", async () => {
|
it("renders text interleaved with an open tool call", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const messages: ReadonlyArray<AgentMessage> = [
|
const messages: ReadonlyArray<AgentMessage> = [
|
||||||
{
|
{
|
||||||
@@ -30,9 +30,10 @@ describe("AssistantOperatorThread", () => {
|
|||||||
expect(screen.getByText("I will inspect the run.")).toBeInTheDocument();
|
expect(screen.getByText("I will inspect the run.")).toBeInTheDocument();
|
||||||
expect(screen.getByText("The trace is inspectable.")).toBeInTheDocument();
|
expect(screen.getByText("The trace is inspectable.")).toBeInTheDocument();
|
||||||
const tool = screen.getByRole("button", { name: /readRunTrace/i });
|
const tool = screen.getByRole("button", { name: /readRunTrace/i });
|
||||||
expect(screen.queryByText(/run_1/)).not.toBeInTheDocument();
|
expect(tool).toHaveAttribute("aria-expanded", "true");
|
||||||
await user.click(tool);
|
|
||||||
expect(screen.getByText(/run_1/)).toBeInTheDocument();
|
expect(screen.getByText(/run_1/)).toBeInTheDocument();
|
||||||
|
await user.click(tool);
|
||||||
|
expect(screen.queryByText(/run_1/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders grouped consecutive tool calls", () => {
|
it("renders grouped consecutive tool calls", () => {
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
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 type { AgentMessage } from "../../demo/agent/events.js";
|
||||||
import { SchemaApprovalSurface } from "../approval/SchemaApprovalSurface.js";
|
import { SchemaApprovalSurface } from "../approval/SchemaApprovalSurface.js";
|
||||||
import { projectAgentMessagesForAssistant, type AssistantProjectedMessage } from "./assistantRuntimeProjection.js";
|
import { projectAgentMessagesForAssistant, type AssistantProjectedMessage } from "./assistantRuntimeProjection.js";
|
||||||
@@ -31,15 +24,13 @@ const formatJson = (value: unknown): string => {
|
|||||||
const ToolCard = ({
|
const ToolCard = ({
|
||||||
toolName,
|
toolName,
|
||||||
args,
|
args,
|
||||||
result,
|
defaultOpen = true,
|
||||||
isError,
|
|
||||||
}: {
|
}: {
|
||||||
readonly toolName: string;
|
readonly toolName: string;
|
||||||
readonly args?: unknown;
|
readonly args?: unknown;
|
||||||
readonly result?: unknown;
|
readonly defaultOpen?: boolean;
|
||||||
readonly isError?: boolean;
|
|
||||||
}) => {
|
}) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
return (
|
return (
|
||||||
<div className="assistant-tool-card" data-open={open ? "true" : "false"}>
|
<div className="assistant-tool-card" data-open={open ? "true" : "false"}>
|
||||||
<button
|
<button
|
||||||
@@ -48,19 +39,13 @@ const ToolCard = ({
|
|||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
onClick={() => setOpen((c) => !c)}
|
onClick={() => setOpen((c) => !c)}
|
||||||
>
|
>
|
||||||
<span className="assistant-tool-card__label">Used tool: <b>{toolName}</b></span>
|
<span className="assistant-tool-card__label">{toolName}</span>
|
||||||
</button>
|
</button>
|
||||||
{open ? (
|
{open ? (
|
||||||
<div className="assistant-tool-card__body">
|
<div className="assistant-tool-card__body">
|
||||||
{args !== undefined ? (
|
{args !== undefined ? (
|
||||||
<pre className="assistant-tool-card__io" aria-label="tool input">{formatJson(args)}</pre>
|
<pre className="assistant-tool-card__io" aria-label="tool input">{formatJson(args)}</pre>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -100,9 +85,10 @@ const renderContentPart = (
|
|||||||
part: ContentPart,
|
part: ContentPart,
|
||||||
submitApproval?: (() => void) | undefined,
|
submitApproval?: (() => void) | undefined,
|
||||||
cancelApproval?: (() => void) | undefined,
|
cancelApproval?: (() => void) | undefined,
|
||||||
|
defaultOpen?: boolean,
|
||||||
): React.ReactNode => {
|
): React.ReactNode => {
|
||||||
if (part.type === "text") {
|
if (part.type === "text") {
|
||||||
return <MessagePrimitive.Content />;
|
return <p style={{ whiteSpace: "pre-line" }}>{part.text}</p>;
|
||||||
}
|
}
|
||||||
const toolName = part.toolName;
|
const toolName = part.toolName;
|
||||||
const args = part.args as Record<string, unknown> | undefined;
|
const args = part.args as Record<string, unknown> | undefined;
|
||||||
@@ -130,7 +116,7 @@ const renderContentPart = (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <ToolCard toolName={toolName} args={part.args} result={part.result} isError={part.isError ?? false} />;
|
return <ToolCard toolName={toolName} args={part.args} {...(defaultOpen !== undefined ? { defaultOpen } : {})} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AssistantMessageBody = ({
|
const AssistantMessageBody = ({
|
||||||
@@ -142,11 +128,16 @@ const AssistantMessageBody = ({
|
|||||||
readonly submitApproval?: (() => void) | undefined;
|
readonly submitApproval?: (() => void) | undefined;
|
||||||
readonly cancelApproval?: (() => void) | undefined;
|
readonly cancelApproval?: (() => void) | undefined;
|
||||||
}) => {
|
}) => {
|
||||||
const textParts = parts.filter((p) => p.type === "text");
|
|
||||||
const toolParts = parts.filter((p) => p.type === "tool-call");
|
const toolParts = parts.filter((p) => p.type === "tool-call");
|
||||||
|
|
||||||
if (toolParts.length === 0) {
|
if (toolParts.length === 0) {
|
||||||
return <MessagePrimitive.Content />;
|
return (
|
||||||
|
<>
|
||||||
|
{parts.filter((p) => p.type === "text").map((p, i) => (
|
||||||
|
<p key={`text-${i}`} style={{ whiteSpace: "pre-line" }}>{p.text}</p>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toolParts.length === 1) {
|
if (toolParts.length === 1) {
|
||||||
@@ -164,54 +155,38 @@ const AssistantMessageBody = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const firstToolIndex = parts.findIndex((p) => p.type === "tool-call");
|
const firstToolIndex = parts.findIndex((p) => p.type === "tool-call");
|
||||||
|
const lastToolIndex = parts.findLastIndex((p) => p.type === "tool-call");
|
||||||
const beforeText = parts.slice(0, firstToolIndex).filter((p) => p.type === "text");
|
const beforeText = parts.slice(0, firstToolIndex).filter((p) => p.type === "text");
|
||||||
|
const afterText = parts.slice(lastToolIndex + 1).filter((p) => p.type === "text");
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{beforeText.map((p, i) => <p key={`before-${i}`} style={{ whiteSpace: "pre-line" }}>{p.text}</p>)}
|
{beforeText.map((p, i) => <p key={`before-${i}`} style={{ whiteSpace: "pre-line" }}>{p.text}</p>)}
|
||||||
<ToolGroupCard toolCount={toolParts.length}>
|
<ToolGroupCard toolCount={toolParts.length}>
|
||||||
{toolParts.map((tool, i) => (
|
{toolParts.map((tool, i) => (
|
||||||
<div key={i}>
|
<div key={i}>
|
||||||
{renderContentPart(tool, submitApproval, cancelApproval)}
|
{renderContentPart(tool, submitApproval, cancelApproval, false)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</ToolGroupCard>
|
</ToolGroupCard>
|
||||||
|
{afterText.map((p, i) => <p key={`after-${i}`} style={{ whiteSpace: "pre-line" }}>{p.text}</p>)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ThreadBody = ({
|
const MessageBubble = ({
|
||||||
submitApproval,
|
role,
|
||||||
cancelApproval,
|
children,
|
||||||
}: {
|
}: {
|
||||||
readonly submitApproval?: (() => void) | undefined;
|
readonly role: "user" | "assistant";
|
||||||
readonly cancelApproval?: (() => void) | undefined;
|
readonly children: React.ReactNode;
|
||||||
}) => (
|
}) => (
|
||||||
<ThreadPrimitive.Root className="assistant-thread">
|
<div className="assistant-message" data-role={role}>
|
||||||
<ThreadPrimitive.Viewport className="assistant-thread__viewport">
|
{role === "user" ? (
|
||||||
<ThreadPrimitive.Messages>
|
<div className="assistant-message__user-bubble">{children}</div>
|
||||||
{({ message }) => {
|
) : (
|
||||||
const content = (message as unknown as { content?: unknown }).content;
|
children
|
||||||
const parts: ContentPart[] = Array.isArray(content)
|
)}
|
||||||
? (content as ContentPart[])
|
</div>
|
||||||
: 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 = ({
|
export const AssistantOperatorThread = ({
|
||||||
@@ -223,41 +198,53 @@ export const AssistantOperatorThread = ({
|
|||||||
ariaLabel = "operator conversation",
|
ariaLabel = "operator conversation",
|
||||||
}: AssistantOperatorThreadProps) => {
|
}: AssistantOperatorThreadProps) => {
|
||||||
const projected = useMemo(() => projectAgentMessagesForAssistant(messages), [messages]);
|
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 handleRun = useCallback(() => {
|
||||||
const text = message.content.find((part) => part.type === "text")?.text ?? "";
|
if (!runAction || runAction.disabled) return;
|
||||||
setLocalMessages((current) => [
|
runAction.run();
|
||||||
...current,
|
}, [runAction]);
|
||||||
{
|
|
||||||
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 (
|
return (
|
||||||
<section className="assistant-operator-thread" data-mode={mode} role="log" aria-label={ariaLabel}>
|
<section className="assistant-operator-thread" data-mode={mode} role="log" aria-label={ariaLabel}>
|
||||||
|
<div className="assistant-thread">
|
||||||
|
<div className="assistant-thread__viewport">
|
||||||
|
{projected.map((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 }]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (message.role === "user") {
|
||||||
|
return (
|
||||||
|
<MessageBubble key={message.id} role="user">
|
||||||
|
{parts.filter((p) => p.type === "text").map((p, i) => (
|
||||||
|
<p key={`text-${i}`} style={{ whiteSpace: "pre-line" }}>{p.text}</p>
|
||||||
|
))}
|
||||||
|
</MessageBubble>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessageBubble key={message.id} role="assistant">
|
||||||
|
<AssistantMessageBody
|
||||||
|
parts={parts}
|
||||||
|
submitApproval={submitApproval}
|
||||||
|
cancelApproval={cancelApproval}
|
||||||
|
/>
|
||||||
|
</MessageBubble>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{runAction ? (
|
{runAction ? (
|
||||||
<div className="assistant-operator-thread__action">
|
<div className="assistant-operator-thread__action">
|
||||||
<button type="button" disabled={runAction.disabled} onClick={runAction.run}>
|
<button type="button" disabled={runAction.disabled} onClick={handleRun}>
|
||||||
{runAction.label}
|
{runAction.label}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<AssistantRuntimeProvider runtime={runtime}>
|
|
||||||
<ThreadBody submitApproval={submitApproval} cancelApproval={cancelApproval} />
|
|
||||||
</AssistantRuntimeProvider>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,17 +10,14 @@ afterEach(() => cleanup());
|
|||||||
|
|
||||||
describe("ProblemLoopScene", () => {
|
describe("ProblemLoopScene", () => {
|
||||||
it("uses the assistant transcript surface for the direct-action side", async () => {
|
it("uses the assistant transcript surface for the direct-action side", async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<ProblemLoopScene scene={problemScene} beat={findBeat("problem", "direct-actions")!} />);
|
render(<ProblemLoopScene scene={problemScene} beat={findBeat("problem", "direct-actions")!} />);
|
||||||
|
|
||||||
const transcript = screen.getByRole("log", { name: /one-off assistant transcript/i });
|
const transcript = screen.getByRole("log", { name: /one-off assistant transcript/i });
|
||||||
expect(transcript).toHaveClass("assistant-operator-thread");
|
expect(transcript).toHaveClass("assistant-operator-thread");
|
||||||
expect(within(transcript).getByText("Can you finish this workspace task?")).toBeInTheDocument();
|
expect(within(transcript).getByText("Can you finish this workspace task?")).toBeInTheDocument();
|
||||||
expect(within(transcript).getByRole("button", { name: /workspace.run_once/i })).toBeInTheDocument();
|
const toolButtons = within(transcript).getAllByRole("button", { name: /workflow.run_once/i });
|
||||||
expect(within(transcript).getByText("Reports success, but leaves no reusable workflow behind.")).toBeInTheDocument();
|
expect(toolButtons).toHaveLength(3);
|
||||||
|
expect(within(transcript).getByText("Done. But none of this is recorded in a durable workflow.")).toBeInTheDocument();
|
||||||
await user.click(within(transcript).getByRole("button", { name: /workspace.run_once/i }));
|
|
||||||
expect(within(transcript).getByText(/ephemeral/i)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders reusable automation as a durable workflow blueprint", () => {
|
it("renders reusable automation as a durable workflow blueprint", () => {
|
||||||
|
|||||||
@@ -19,12 +19,28 @@ const oneOffToolLoopMessages: ReadonlyArray<AgentMessage> = [
|
|||||||
{
|
{
|
||||||
type: "tool-call",
|
type: "tool-call",
|
||||||
call: {
|
call: {
|
||||||
id: "scene-2-tool",
|
id: "scene-2-tool-1",
|
||||||
name: "workspace.run_once" as AgentToolName,
|
name: "workflow.run_once" as AgentToolName,
|
||||||
input: { persistence: "ephemeral", reusable_workflow: false },
|
input: { source: "api", endpoint: "/tasks" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ type: "text", text: "Reports success, but leaves no reusable workflow behind." },
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
call: {
|
||||||
|
id: "scene-2-tool-2",
|
||||||
|
name: "workflow.run_once" as AgentToolName,
|
||||||
|
input: { format: "json", validate: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
call: {
|
||||||
|
id: "scene-2-tool-3",
|
||||||
|
name: "workflow.run_once" as AgentToolName,
|
||||||
|
input: { destination: "file", path: "/tmp/result.json" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "text", text: "Done. But none of this is recorded in a durable workflow." },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1639,6 +1639,24 @@
|
|||||||
margin-bottom: 0.65rem;
|
margin-bottom: 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.assistant-message[data-role="user"] {
|
||||||
|
justify-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-message__user-bubble {
|
||||||
|
background: color-mix(in oklch, var(--accent-cyan) 14%, var(--stage-surface));
|
||||||
|
border: 1px solid color-mix(in oklch, var(--accent-cyan) 30%, var(--stage-line));
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
max-width: 80%;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font: 0.82rem/1.5 var(--font-interface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-message[data-role="assistant"] {
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
.assistant-tool-card {
|
.assistant-tool-card {
|
||||||
border: 1px solid var(--stage-line);
|
border: 1px solid var(--stage-line);
|
||||||
border-radius: 0.65rem;
|
border-radius: 0.65rem;
|
||||||
@@ -1680,19 +1698,6 @@
|
|||||||
overflow-x: auto;
|
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 {
|
.assistant-tool-group {
|
||||||
border: 1px solid var(--stage-line);
|
border: 1px solid var(--stage-line);
|
||||||
border-radius: 0.65rem;
|
border-radius: 0.65rem;
|
||||||
@@ -1723,6 +1728,8 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
padding: 0 0.65rem 0.55rem;
|
padding: 0 0.65rem 0.55rem;
|
||||||
|
max-height: 14rem;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-tool-group__body .assistant-tool-card {
|
.assistant-tool-group__body .assistant-tool-card {
|
||||||
|
|||||||
Reference in New Issue
Block a user