fix: align assistant ui presentation chat

This commit is contained in:
lda
2026-07-10 08:11:28 +07:00 Verified
parent df026b553a
commit abea869481
10 changed files with 331 additions and 236 deletions
+19 -1
View File
@@ -7,7 +7,10 @@ export type WorkflowToolName =
export type PresentationToolName = export type PresentationToolName =
| "selectWorkflowNode" | "selectWorkflowNode"
| "focusOperation" | "focusOperation"
| "showTraceFrame"; | "showTraceFrame"
| "fetchData"
| "transformPayload"
| "writeOutput";
export type AgentToolName = WorkflowToolName | PresentationToolName; export type AgentToolName = WorkflowToolName | PresentationToolName;
@@ -53,6 +56,21 @@ export const AGENT_TOOLS = {
kind: "presentation", kind: "presentation",
description: "Focus a trace frame in the presentation.", description: "Focus a trace frame in the presentation.",
}, },
fetchData: {
name: "fetchData",
kind: "presentation",
description: "Presentation-only placeholder for fetching data in a one-off tool loop.",
},
transformPayload: {
name: "transformPayload",
kind: "presentation",
description: "Presentation-only placeholder for transforming data in a one-off tool loop.",
},
writeOutput: {
name: "writeOutput",
kind: "presentation",
description: "Presentation-only placeholder for writing output in a one-off tool loop.",
},
} satisfies Record<AgentToolName, AgentToolDescriptor>; } satisfies Record<AgentToolName, AgentToolDescriptor>;
export const isAllowedAgentToolName = (name: string): name is AgentToolName => export const isAllowedAgentToolName = (name: string): name is AgentToolName =>
@@ -74,7 +74,7 @@ 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.getByRole("button", { name: /selectWorkflowNode/i })).toBeInTheDocument(); expect(screen.getAllByRole("button", { name: /selectWorkflowNode/i }).length).toBeGreaterThan(0);
}); });
it("renders tool calls as open tool cards", async () => { it("renders tool calls as open tool cards", async () => {
@@ -151,16 +151,16 @@ describe("OperatorChat", () => {
}, },
]; ];
render(<OperatorChat state={initialPresentationState} messages={messages} />); const { container } = 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();
expect(screen.getByText("Submit resume request?")).toBeInTheDocument(); expect(screen.getByText("Submit resume request?")).toBeInTheDocument();
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();
expect(screen.getByLabelText("tool input")).toBeInTheDocument(); expect(container.querySelector('[data-slot="tool-fallback-args"]')).toBeInTheDocument();
await user.click(tool); await user.click(tool);
expect(screen.queryByLabelText("tool input")).not.toBeInTheDocument(); expect(container.querySelector('[data-slot="tool-fallback-args"]')).not.toBeInTheDocument();
}); });
it("renders error and presentation action parts", () => { it("renders error and presentation action parts", () => {
@@ -24,9 +24,10 @@ describe("AssistantOperatorThread", () => {
}, },
]; ];
render(<AssistantOperatorThread mode="dock" messages={messages} />); const { container } = render(<AssistantOperatorThread mode="dock" messages={messages} />);
expect(screen.getByRole("log", { name: /operator conversation/i })).toBeInTheDocument(); expect(screen.getByRole("log", { name: /operator conversation/i })).toBeInTheDocument();
expect(container.querySelector('[data-slot="tool-fallback-root"]')).toBeInTheDocument();
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 });
@@ -48,9 +49,10 @@ describe("AssistantOperatorThread", () => {
}, },
]; ];
render(<AssistantOperatorThread mode="dock" messages={messages} />); const { container } = render(<AssistantOperatorThread mode="dock" messages={messages} />);
expect(screen.getByText(/2 tools/i)).toBeInTheDocument(); expect(container.querySelector('[data-slot="tool-group-root"]')).toBeInTheDocument();
expect(screen.getByText(/2 tool calls/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /readRunTrace/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /readRunTrace/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /inspectDeployment/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /inspectDeployment/i })).toBeInTheDocument();
}); });
@@ -111,4 +113,29 @@ describe("AssistantOperatorThread", () => {
await user.click(screen.getByRole("button", { name: /run prepared workflow/i })); await user.click(screen.getByRole("button", { name: /run prepared workflow/i }));
expect(run).toHaveBeenCalledOnce(); expect(run).toHaveBeenCalledOnce();
}); });
it("renders structured tool results through the generated fallback result slot", () => {
const messages: ReadonlyArray<AgentMessage> = [
{
id: "assistant-result",
role: "assistant",
parts: [
{
type: "tool-result",
result: {
callId: "call-1",
name: "readRunTrace",
status: "success",
output: { frames: 3 },
},
},
],
},
];
const { container } = render(<AssistantOperatorThread mode="dock" messages={messages} />);
expect(container.querySelector('[data-slot="tool-fallback-result"]')).toBeInTheDocument();
expect(screen.getByText(/"frames": 3/)).toBeInTheDocument();
});
}); });
@@ -1,7 +1,24 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, type ReactNode } from "react";
import type { ToolCallMessagePartStatus } from "@assistant-ui/react";
import {
ToolFallbackArgs,
ToolFallbackContent,
ToolFallbackError,
ToolFallbackResult,
ToolFallbackRoot,
ToolFallbackTrigger,
} from "../../components/assistant-ui/tool-fallback.js";
import {
ToolGroupContent,
ToolGroupRoot,
ToolGroupTrigger,
} from "../../components/assistant-ui/tool-group.js";
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 AssistantContentPart,
} from "./assistantRuntimeProjection.js";
type AssistantOperatorThreadProps = { type AssistantOperatorThreadProps = {
readonly mode: "hidden" | "full" | "rail" | "dock"; readonly mode: "hidden" | "full" | "rail" | "dock";
@@ -21,102 +38,97 @@ const formatJson = (value: unknown): string => {
} }
}; };
const ToolCard = ({ type ToolRenderPart = Extract<AssistantContentPart, { readonly type: "tool-call" | "tool-result" }>;
toolName,
args, type ApprovalContract = {
defaultOpen = true, readonly kind: string;
}: { readonly outcomes: readonly string[];
readonly toolName: string; readonly resumeSchema: unknown;
readonly args?: unknown; readonly resumePayloadPreview: unknown;
readonly defaultOpen?: boolean; readonly runId: string | null;
}) => {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="assistant-tool-card" data-open={open ? "true" : "false"}>
<button
type="button"
className="assistant-tool-card__trigger"
aria-expanded={open}
onClick={() => setOpen((c) => !c)}
>
<span className="assistant-tool-card__label">{toolName}</span>
</button>
{open ? (
<div className="assistant-tool-card__body">
{args !== undefined ? (
<pre className="assistant-tool-card__io" aria-label="tool input">{formatJson(args)}</pre>
) : null}
</div>
) : null}
</div>
);
}; };
const ToolGroupCard = ({ const isRecord = (value: unknown): value is Record<string, unknown> =>
toolCount, typeof value === "object" && value !== null;
defaultOpen = true,
children, const approvalContractFromArgs = (args: unknown): ApprovalContract | undefined => {
}: { if (!isRecord(args) || !isRecord(args.contract)) return undefined;
readonly toolCount: number; const contract = args.contract;
readonly defaultOpen?: boolean; if (
readonly children: React.ReactNode; typeof contract.kind !== "string" ||
}) => { !Array.isArray(contract.outcomes) ||
const [open, setOpen] = useState(defaultOpen); !contract.outcomes.every((outcome) => typeof outcome === "string") ||
return ( !("resumeSchema" in contract) ||
<div className="assistant-tool-group" data-open={open ? "true" : "false"}> !("resumePayloadPreview" in contract) ||
<button !(typeof contract.runId === "string" || contract.runId === null)
type="button" ) {
className="assistant-tool-group__trigger" return undefined;
aria-expanded={open} }
onClick={() => setOpen((c) => !c)} return {
> kind: contract.kind,
<span>{toolCount} tools</span> outcomes: contract.outcomes,
</button> resumeSchema: contract.resumeSchema,
{open ? <div className="assistant-tool-group__body">{children}</div> : null} resumePayloadPreview: contract.resumePayloadPreview,
</div> runId: contract.runId,
); };
}; };
type ContentPart = const statusForToolPart = (part: ToolRenderPart): ToolCallMessagePartStatus | undefined => {
| { readonly type: "text"; readonly text: string } if (part.type !== "tool-result") return undefined;
| { readonly type: "tool-call"; readonly toolName: string; readonly args?: unknown; readonly result?: unknown; readonly isError?: boolean }; if (part.status === "success") return { type: "complete" };
return { type: "incomplete", reason: "error", error: part.result };
};
const resultForToolPart = (part: ToolRenderPart): unknown =>
part.type === "tool-result" ? part.result : undefined;
const renderContentPart = ( const renderContentPart = (
part: ContentPart, part: AssistantContentPart,
submitApproval?: (() => void) | undefined, submitApproval?: (() => void) | undefined,
cancelApproval?: (() => void) | undefined, cancelApproval?: (() => void) | undefined,
defaultOpen?: boolean, defaultOpen?: boolean,
): React.ReactNode => { ): ReactNode => {
if (part.type === "text") { if (part.type === "text") {
return <p style={{ whiteSpace: "pre-line" }}>{part.text}</p>; return <p style={{ whiteSpace: "pre-line" }}>{part.text}</p>;
} }
const status = statusForToolPart(part);
const toolName = part.toolName; const toolName = part.toolName;
const args = part.args as Record<string, unknown> | undefined; const args = part.type === "tool-call" ? part.args : undefined;
const contract = args?.contract as const contract = part.type === "tool-call" ? approvalContractFromArgs(args) : undefined;
| { const argsText = args !== undefined ? formatJson(args) : undefined;
readonly kind: string; const result = resultForToolPart(part);
readonly outcomes: readonly string[];
readonly resumeSchema: unknown;
readonly resumePayloadPreview: unknown;
readonly runId: string;
}
| undefined;
if (toolName === "resumeIssueReview" && contract) { if (toolName === "resumeIssueReview" && contract) {
return ( return (
<div className="assistant-tool-approval"> <ToolFallbackRoot defaultOpen={defaultOpen ?? true}>
<SchemaApprovalSurface <ToolFallbackTrigger toolName={toolName} status={{ type: "requires-action", reason: "interrupt" }} />
title={`${contract.kind.replaceAll("_", " ")} resume`} <ToolFallbackContent>
schema={contract.resumeSchema} {argsText !== undefined ? <ToolFallbackArgs argsText={argsText} /> : null}
payload={contract.resumePayloadPreview} <div className="assistant-tool-approval">
outcomes={contract.outcomes} <SchemaApprovalSurface
runId={contract.runId} title={`${contract.kind.replaceAll("_", " ")} resume`}
onSubmit={submitApproval} schema={contract.resumeSchema}
onCancel={cancelApproval} payload={contract.resumePayloadPreview}
/> outcomes={contract.outcomes}
</div> runId={contract.runId}
onSubmit={submitApproval}
onCancel={cancelApproval}
/>
</div>
</ToolFallbackContent>
</ToolFallbackRoot>
); );
} }
return <ToolCard toolName={toolName} args={part.args} {...(defaultOpen !== undefined ? { defaultOpen } : {})} />; return (
<ToolFallbackRoot defaultOpen={defaultOpen ?? true}>
<ToolFallbackTrigger toolName={toolName} {...(status !== undefined ? { status } : {})} />
<ToolFallbackContent>
<ToolFallbackError {...(status !== undefined ? { status } : {})} />
{argsText !== undefined ? <ToolFallbackArgs argsText={argsText} /> : null}
<ToolFallbackResult result={result} />
</ToolFallbackContent>
</ToolFallbackRoot>
);
}; };
const AssistantMessageBody = ({ const AssistantMessageBody = ({
@@ -124,51 +136,56 @@ const AssistantMessageBody = ({
submitApproval, submitApproval,
cancelApproval, cancelApproval,
}: { }: {
readonly parts: readonly ContentPart[]; readonly parts: readonly AssistantContentPart[];
readonly submitApproval?: (() => void) | undefined; readonly submitApproval?: (() => void) | undefined;
readonly cancelApproval?: (() => void) | undefined; readonly cancelApproval?: (() => void) | undefined;
}) => { }) => {
const toolParts = parts.filter((p) => p.type === "tool-call"); const rendered: ReactNode[] = [];
let index = 0;
if (toolParts.length === 0) { while (index < parts.length) {
return ( const part = parts[index]!;
<> if (part.type === "text") {
{parts.filter((p) => p.type === "text").map((p, i) => ( rendered.push(
<p key={`text-${i}`} style={{ whiteSpace: "pre-line" }}>{p.text}</p> <p key={`text-${index}`} style={{ whiteSpace: "pre-line" }}>{part.text}</p>,
))} );
</> index += 1;
continue;
}
const toolRunStart = index;
const toolRun: ToolRenderPart[] = [];
while (index < parts.length && parts[index]!.type !== "text") {
toolRun.push(parts[index]! as ToolRenderPart);
index += 1;
}
if (toolRun.length === 1) {
rendered.push(
<div key={`tool-${toolRunStart}`}>
{renderContentPart(toolRun[0]!, submitApproval, cancelApproval)}
</div>,
);
continue;
}
rendered.push(
<ToolGroupRoot key={`tool-group-${toolRunStart}`} defaultOpen>
<ToolGroupTrigger count={toolRun.length} />
<ToolGroupContent>
{toolRun.map((tool, toolIndex) => (
<div key={`${tool.type}-${tool.toolName}-${tool.toolCallId ?? "no-id"}-${toolIndex}`}>
{renderContentPart(tool, submitApproval, cancelApproval, false)}
</div>
))}
</ToolGroupContent>
</ToolGroupRoot>,
); );
} }
if (toolParts.length === 1) {
const firstToolIndex = parts.findIndex((p) => p.type === "tool-call");
const beforeText = parts.slice(0, firstToolIndex).filter((p) => p.type === "text");
const afterText = parts.slice(firstToolIndex + 1).filter((p) => p.type === "text");
const tool = toolParts[0]!;
return (
<>
{beforeText.map((p, i) => <p key={`before-${i}`} style={{ whiteSpace: "pre-line" }}>{p.text}</p>)}
{renderContentPart(tool, submitApproval, cancelApproval)}
{afterText.map((p, i) => <p key={`after-${i}`} style={{ whiteSpace: "pre-line" }}>{p.text}</p>)}
</>
);
}
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 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>)} {rendered}
<ToolGroupCard toolCount={toolParts.length}>
{toolParts.map((tool, i) => (
<div key={i}>
{renderContentPart(tool, submitApproval, cancelApproval, false)}
</div>
))}
</ToolGroupCard>
{afterText.map((p, i) => <p key={`after-${i}`} style={{ whiteSpace: "pre-line" }}>{p.text}</p>)}
</> </>
); );
}; };
@@ -178,7 +195,7 @@ const MessageBubble = ({
children, children,
}: { }: {
readonly role: "user" | "assistant"; readonly role: "user" | "assistant";
readonly children: React.ReactNode; readonly children: ReactNode;
}) => ( }) => (
<div className="assistant-message" data-role={role}> <div className="assistant-message" data-role={role}>
{role === "user" ? ( {role === "user" ? (
@@ -209,17 +226,10 @@ export const AssistantOperatorThread = ({
<div className="assistant-thread"> <div className="assistant-thread">
<div className="assistant-thread__viewport"> <div className="assistant-thread__viewport">
{projected.map((message) => { {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") { if (message.role === "user") {
return ( return (
<MessageBubble key={message.id} role="user"> <MessageBubble key={message.id} role="user">
{parts.filter((p) => p.type === "text").map((p, i) => ( {message.content.filter((p) => p.type === "text").map((p, i) => (
<p key={`text-${i}`} style={{ whiteSpace: "pre-line" }}>{p.text}</p> <p key={`text-${i}`} style={{ whiteSpace: "pre-line" }}>{p.text}</p>
))} ))}
</MessageBubble> </MessageBubble>
@@ -229,7 +239,7 @@ export const AssistantOperatorThread = ({
return ( return (
<MessageBubble key={message.id} role="assistant"> <MessageBubble key={message.id} role="assistant">
<AssistantMessageBody <AssistantMessageBody
parts={parts} parts={message.content}
submitApproval={submitApproval} submitApproval={submitApproval}
cancelApproval={cancelApproval} cancelApproval={cancelApproval}
/> />
@@ -36,7 +36,7 @@ describe("assistantRuntimeProjection", () => {
]); ]);
}); });
it("projects tool results as text summary", () => { it("projects tool results as structured result parts", () => {
const messages: ReadonlyArray<AgentMessage> = [ const messages: ReadonlyArray<AgentMessage> = [
{ {
id: "tool-result-message", id: "tool-result-message",
@@ -57,13 +57,15 @@ describe("assistantRuntimeProjection", () => {
const projected = projectAgentMessagesForAssistant(messages); const projected = projectAgentMessagesForAssistant(messages);
expect(projected[0]).toMatchObject({ expect(projected[0]?.content).toEqual([
id: "tool-result-message", {
role: "assistant", type: "tool-result",
content: [ toolCallId: "call-1",
{ type: "text", text: "Result for readRunTrace: success" }, toolName: "readRunTrace",
], status: "success",
}); result: { frames: 3 },
},
]);
}); });
it("projects approval requests as human tool calls with contract metadata", () => { it("projects approval requests as human tool calls with contract metadata", () => {
@@ -8,16 +8,20 @@ export type AssistantToolRenderPayload = {
readonly isError?: boolean; readonly isError?: boolean;
}; };
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- JSON-serializable args for assistant-ui compatibility export type AssistantContentPart =
type JsonArgs = Record<string, any>;
type AssistantContentPart =
| { readonly type: "text"; readonly text: string } | { readonly type: "text"; readonly text: string }
| { | {
readonly type: "tool-call"; readonly type: "tool-call";
readonly toolCallId?: string; readonly toolCallId?: string;
readonly toolName: string; readonly toolName: string;
readonly args?: JsonArgs; readonly args?: unknown;
}
| {
readonly type: "tool-result";
readonly toolCallId: string;
readonly toolName: string;
readonly status: "success" | "failure";
readonly result: unknown;
}; };
export type AssistantProjectedMessage = { export type AssistantProjectedMessage = {
@@ -35,19 +39,22 @@ const projectPart = (part: AgentMessagePart): AssistantContentPart[] => {
type: "tool-call", type: "tool-call",
toolCallId: part.call.id, toolCallId: part.call.id,
toolName: part.call.name, toolName: part.call.name,
args: part.call.input as JsonArgs, args: part.call.input,
}]; }];
case "tool-result": case "tool-result":
return [{ return [{
type: "text", type: "tool-result",
text: `Result for ${part.result.name}: ${part.result.status === "failure" ? "error" : "success"}`, toolCallId: part.result.callId,
toolName: part.result.name,
status: part.result.status,
result: part.result.output,
}]; }];
case "presentation-action": case "presentation-action":
return [{ return [{
type: "tool-call", type: "tool-call",
toolCallId: `presentation-${part.action.type}`, toolCallId: `presentation-${part.action.type}`,
toolName: `presentation.${part.action.type}`, toolName: `presentation.${part.action.type}`,
args: part.action as JsonArgs, args: part.action,
}]; }];
case "approval-request": case "approval-request":
return [ return [
@@ -59,7 +66,7 @@ const projectPart = (part: AgentMessagePart): AssistantContentPart[] => {
args: { args: {
prompt: part.prompt, prompt: part.prompt,
contract: part.contract, contract: part.contract,
} as JsonArgs, },
}, },
]; ];
case "error": case "error":
@@ -15,7 +15,7 @@ describe("ProblemLoopScene", () => {
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();
const toolButtons = within(transcript).getAllByRole("button", { name: /workflow.run_once/i }); const toolButtons = within(transcript).getAllByRole("button", { name: /fetchData|transformPayload|writeOutput/i });
expect(toolButtons).toHaveLength(3); expect(toolButtons).toHaveLength(3);
expect(within(transcript).getByText("Done. But none of this is recorded in a durable workflow.")).toBeInTheDocument(); expect(within(transcript).getByText("Done. But none of this is recorded in a durable workflow.")).toBeInTheDocument();
}); });
@@ -1,5 +1,4 @@
import type { AgentMessage } from "../../demo/agent/events.js"; import type { AgentMessage } from "../../demo/agent/events.js";
import type { AgentToolName } from "../../demo/agent/tools.js";
import { AssistantOperatorThread } from "../chat/AssistantOperatorThread.js"; import { AssistantOperatorThread } from "../chat/AssistantOperatorThread.js";
import { StageCaption } from "../StageCaption.js"; import { StageCaption } from "../StageCaption.js";
import type { SceneBeatDefinition, SceneDefinition } from "../storyboard.js"; import type { SceneBeatDefinition, SceneDefinition } from "../storyboard.js";
@@ -20,7 +19,7 @@ const oneOffToolLoopMessages: ReadonlyArray<AgentMessage> = [
type: "tool-call", type: "tool-call",
call: { call: {
id: "scene-2-tool-1", id: "scene-2-tool-1",
name: "workflow.run_once" as AgentToolName, name: "fetchData",
input: { source: "api", endpoint: "/tasks" }, input: { source: "api", endpoint: "/tasks" },
}, },
}, },
@@ -28,7 +27,7 @@ const oneOffToolLoopMessages: ReadonlyArray<AgentMessage> = [
type: "tool-call", type: "tool-call",
call: { call: {
id: "scene-2-tool-2", id: "scene-2-tool-2",
name: "workflow.run_once" as AgentToolName, name: "transformPayload",
input: { format: "json", validate: true }, input: { format: "json", validate: true },
}, },
}, },
@@ -36,7 +35,7 @@ const oneOffToolLoopMessages: ReadonlyArray<AgentMessage> = [
type: "tool-call", type: "tool-call",
call: { call: {
id: "scene-2-tool-3", id: "scene-2-tool-3",
name: "workflow.run_once" as AgentToolName, name: "writeOutput",
input: { destination: "file", path: "/tmp/result.json" }, input: { destination: "file", path: "/tmp/result.json" },
}, },
}, },
@@ -70,7 +69,7 @@ export const ProblemLoopScene = ({ scene, beat }: ProblemLoopSceneProps) => {
<h2>Chat + tool loop</h2> <h2>Chat + tool loop</h2>
<p>Good at getting through one request.</p> <p>Good at getting through one request.</p>
</header> </header>
<div aria-label="one-off assistant transcript" role="group"> <div className="problem-chat-card__transcript" aria-label="one-off assistant transcript" role="group">
<AssistantOperatorThread mode="dock" messages={oneOffToolLoopMessages} ariaLabel="one-off assistant transcript" /> <AssistantOperatorThread mode="dock" messages={oneOffToolLoopMessages} ariaLabel="one-off assistant transcript" />
</div> </div>
<p className="problem-artifact-note">The useful work lives in the conversation history.</p> <p className="problem-artifact-note">The useful work lives in the conversation history.</p>
@@ -1406,9 +1406,10 @@
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
display: grid; display: grid;
grid-template-columns: minmax(24rem, 0.95fr) auto minmax(26rem, 1.05fr); grid-template-columns: minmax(24rem, 0.92fr) auto minmax(26rem, 1.08fr);
align-items: stretch; align-items: stretch;
gap: 1rem; gap: 1rem;
overflow: hidden;
} }
.problem-chat-card, .problem-chat-card,
@@ -1416,13 +1417,14 @@
min-width: 0; min-width: 0;
border: 1px solid color-mix(in oklch, var(--stage-line) 62%, transparent); border: 1px solid color-mix(in oklch, var(--stage-line) 62%, transparent);
border-radius: 0.95rem; border-radius: 0.95rem;
padding: 0.85rem; padding: 0.75rem;
overflow: hidden;
} }
.problem-chat-card { .problem-chat-card {
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr) auto; grid-template-rows: auto minmax(0, 1fr) auto;
gap: 0.65rem; gap: 0.45rem;
background: background:
linear-gradient(180deg, color-mix(in oklch, var(--stage-inset) 94%, black), var(--stage-inset)); linear-gradient(180deg, color-mix(in oklch, var(--stage-inset) 94%, black), var(--stage-inset));
} }
@@ -1456,14 +1458,23 @@
.problem-artifact-header h2 { .problem-artifact-header h2 {
margin: 0; margin: 0;
color: var(--text-primary); color: var(--text-primary);
font: 800 1.45rem/0.98 var(--font-interface); font: 800 1.32rem/0.98 var(--font-interface);
} }
.problem-artifact-header p, .problem-artifact-header p,
.problem-artifact-note { .problem-artifact-note {
margin: 0; margin: 0;
color: var(--text-secondary); color: var(--text-secondary);
font: 0.86rem/1.3 var(--font-interface); font: 0.8rem/1.25 var(--font-interface);
}
.problem-chat-card .problem-artifact-note {
display: none;
}
.problem-chat-card__transcript {
min-height: 0;
overflow: hidden;
} }
.problem-blueprint .concept-rail { .problem-blueprint .concept-rail {
@@ -1476,7 +1487,7 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
justify-items: center; justify-items: center;
text-align: center; text-align: center;
padding: 0.72rem 0.45rem; padding: 0.55rem 0.4rem;
} }
.problem-blueprint .concept-node__copy span, .problem-blueprint .concept-node__copy span,
@@ -1599,9 +1610,15 @@
.assistant-operator-thread { .assistant-operator-thread {
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto;
gap: 0.55rem; gap: 0.55rem;
min-height: 0; min-height: 0;
height: 100%;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--text-primary);
} }
.assistant-operator-thread[data-mode="hidden"] { .assistant-operator-thread[data-mode="hidden"] {
@@ -1624,9 +1641,17 @@
height: 100%; height: 100%;
} }
.assistant-thread {
overflow: hidden;
border: 1px solid color-mix(in oklch, var(--stage-line) 70%, transparent);
border-radius: 0.72rem;
background: color-mix(in oklch, var(--stage-inset) 86%, black);
}
.assistant-thread__viewport { .assistant-thread__viewport {
overflow: auto; overflow: auto;
scrollbar-width: none; scrollbar-width: none;
padding: 0.7rem;
} }
.assistant-thread__viewport::-webkit-scrollbar { .assistant-thread__viewport::-webkit-scrollbar {
@@ -1635,8 +1660,8 @@
.assistant-message { .assistant-message {
display: grid; display: grid;
gap: 0.45rem; gap: 0.36rem;
margin-bottom: 0.65rem; margin-bottom: 0.5rem;
} }
.assistant-message[data-role="user"] { .assistant-message[data-role="user"] {
@@ -1644,96 +1669,101 @@
} }
.assistant-message__user-bubble { .assistant-message__user-bubble {
background: color-mix(in oklch, var(--accent-cyan) 14%, var(--stage-surface)); background: color-mix(in oklch, var(--accent-cyan) 24%, var(--stage-inset));
border: 1px solid color-mix(in oklch, var(--accent-cyan) 30%, var(--stage-line)); border: 1px solid color-mix(in oklch, var(--accent-cyan) 44%, var(--stage-line));
border-radius: 0.65rem; border-radius: 0.55rem;
padding: 0.55rem 0.75rem; padding: 0.48rem 0.65rem;
max-width: 80%; max-width: 76%;
color: var(--text-primary); color: var(--text-primary);
font: 0.82rem/1.5 var(--font-interface); font: 0.76rem/1.35 var(--font-interface);
} }
.assistant-message[data-role="assistant"] { .assistant-message[data-role="assistant"] {
justify-items: start; justify-items: start;
color: var(--text-primary);
} }
.assistant-tool-card { /*
border: 1px solid var(--stage-line); assistant-ui ships utility-class components. Bridge their stable data-slot
border-radius: 0.65rem; hooks into the presentation tokens instead of editing generated files.
background: var(--stage-inset); */
.assistant-operator-thread [data-slot="tool-group-root"],
.assistant-operator-thread [data-slot="tool-fallback-root"] {
width: min(100%, 30rem);
border: 1px solid color-mix(in oklch, var(--stage-line) 72%, transparent);
border-radius: 0.55rem;
background: color-mix(in oklch, var(--stage-surface) 58%, var(--stage-inset));
color: var(--text-primary);
overflow: hidden; overflow: hidden;
} }
.assistant-tool-card__trigger { .assistant-operator-thread [data-slot="tool-group-root"] {
display: flex; padding-block: 0;
align-items: center; }
gap: 0.4rem;
.assistant-operator-thread [data-slot="tool-group-trigger"],
.assistant-operator-thread [data-slot="tool-fallback-trigger"] {
width: 100%; width: 100%;
padding: 0.45rem 0.65rem; min-height: 0;
background: none; gap: 0.32rem;
border: none; padding: 0.3rem 0.48rem;
border: 0;
background: transparent;
color: var(--text-secondary);
font: 700 0.61rem/1 var(--font-evidence);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.assistant-operator-thread [data-slot="tool-group-trigger"]:hover,
.assistant-operator-thread [data-slot="tool-fallback-trigger"]:hover {
background: color-mix(in oklch, var(--accent-cyan) 9%, transparent);
color: var(--text-primary); color: var(--text-primary);
font: 600 0.78rem/1 var(--font-interface);
cursor: pointer;
text-align: left;
} }
.assistant-tool-card__trigger:hover { .assistant-operator-thread [data-slot="tool-fallback-trigger-icon"],
background: color-mix(in oklch, var(--accent-cyan) 8%, transparent); .assistant-operator-thread [data-slot="tool-fallback-trigger-chevron"],
.assistant-operator-thread [data-slot="tool-group-trigger-chevron"] {
width: 0.68rem;
height: 0.68rem;
color: var(--accent-cyan);
} }
.assistant-tool-card__body { .assistant-operator-thread [data-slot="tool-group-content"] > div,
padding: 0 0.65rem 0.55rem; .assistant-operator-thread [data-slot="tool-fallback-content"] > div {
gap: 0.22rem;
padding: 0.3rem 0.48rem 0.42rem;
border-top: 1px solid color-mix(in oklch, var(--stage-line) 55%, transparent);
} }
.assistant-tool-card__io { .assistant-operator-thread [data-slot="tool-group-content"] [data-slot="tool-fallback-root"] {
background: var(--stage-surface); width: fit-content;
border: 1px solid var(--stage-line); max-width: 100%;
border-radius: 0.5rem; background: color-mix(in oklch, var(--stage-inset) 86%, black);
border-color: color-mix(in oklch, var(--stage-line) 60%, transparent);
}
.assistant-operator-thread [data-slot="tool-fallback-args-value"],
.assistant-operator-thread [data-slot="tool-fallback-result-content"] {
max-height: 6.5rem;
overflow: auto;
border: 1px solid color-mix(in oklch, var(--stage-line) 55%, transparent);
border-radius: 0.45rem;
background: color-mix(in oklch, var(--stage-inset) 72%, black);
color: var(--text-secondary);
padding: 0.45rem 0.55rem; padding: 0.45rem 0.55rem;
font: 0.72rem/1.45 var(--font-mono); font: 0.68rem/1.38 var(--font-mono);
color: var(--text-secondary);
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; overflow-wrap: anywhere;
overflow-x: auto;
} }
.assistant-tool-group { .assistant-operator-thread [data-slot="tool-fallback-result-header"],
border: 1px solid var(--stage-line); .assistant-operator-thread [data-slot="tool-fallback-error-header"] {
border-radius: 0.65rem; margin: 0;
background: var(--stage-inset); color: var(--text-muted);
overflow: hidden; font: 700 0.66rem/1 var(--font-evidence);
} letter-spacing: 0.04em;
text-transform: uppercase;
.assistant-tool-group__trigger {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.45rem 0.65rem;
background: none;
border: none;
color: var(--text-secondary);
font: 600 0.78rem/1 var(--font-interface);
cursor: pointer;
}
.assistant-tool-group__trigger:hover {
background: color-mix(in oklch, var(--accent-cyan) 8%, transparent);
color: var(--text-primary);
}
.assistant-tool-group__body {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0 0.65rem 0.55rem;
max-height: 14rem;
overflow: hidden;
}
.assistant-tool-group__body .assistant-tool-card {
border-color: var(--stage-line);
} }
.assistant-tool-approval { .assistant-tool-approval {
+2
View File
@@ -1,3 +1,5 @@
@import "tw-shimmer";
:root { :root {
--color-paper: #faf8f5; --color-paper: #faf8f5;
--color-ink: #1a1a1a; --color-ink: #1a1a1a;