46 KiB
Assistant UI Chat Surface Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the hand-rolled presentation chat/tool-call visuals with assistant-ui/shadcn-sourced chat and tool-call components while keeping deterministic replay truthful.
Architecture: Adopt @assistant-ui/react and assistant-ui registry components only for chat/message/tool rendering. Install the registry components through shadcn using the official https://r.assistant-ui.com/... URLs. Keep TimelineAgentController, presentation scene state, graph, lifecycle, approval facts, evidence, and workflow run facts as the source of truth. Scene 2 should reuse the same assistant/tool transcript renderer so the deck no longer has two different fake chat languages.
Tech Stack: React 19, TypeScript, Vite, pnpm workspace, Tailwind 4, shadcn registry-style source components, @assistant-ui/react, existing Vitest + Testing Library + Playwright smoke.
Global Constraints
- Do not add
@assistant-ui/react-ai-sdk, assistant-cloud, CopilotKit, or live LLM backend code in this slice. - Do not claim the demo is a live autonomous agent building workflows. This slice is deterministic replay rendered through assistant-style components.
- Do not change workflow RPC behavior,
useDemoTimeline, graph execution, interrupt facts, or/console. - Do not migrate the whole app to shadcn.
- Do not add generic shadcn
button,card, orbadgeas the goal. Only add components required by assistant-ui chat/tool rendering. - Keep
SchemaApprovalSurfaceas the approval body for typed resume decisions. - Keep existing presentation hash routes.
- Keep Scene 2 right side as reusable automation, but replace the left side with the shared assistant transcript surface.
- Add screenshot gates for Scene 2 and demo chat scenes at
1280x720.
File Structure
- Create
web/apps/console/components.json- shadcn/assistant-ui registry configuration for the Vite app.
- Create
web/apps/console/src/lib/utils.tscn()helper for registry components.
- Modify
web/apps/console/tsconfig.json- Add
baseUrland@/*alias for registry imports.
- Add
- Modify
web/apps/console/vite.config.ts- Add matching Vite alias.
- Add assistant-ui registry components under
web/apps/console/src/components/assistant-ui/- Expected components:
tool-fallback.tsx,tool-group.tsx. - If the CLI also adds small local UI helpers under
src/components/ui/, keep only files referenced by the installed assistant-ui components.
- Expected components:
- Create
web/apps/console/src/presentation/chat/assistantRuntimeProjection.ts- Converts existing
AgentMessagevalues into assistant-uiThreadMessageLikemessages.
- Converts existing
- Create
web/apps/console/src/presentation/chat/assistantRuntimeProjection.test.ts- Pins text, tool-call, tool-result, presentation-action, approval-request conversion.
- Create
web/apps/console/src/presentation/chat/AssistantOperatorThread.tsx- Renders assistant-ui primitives and local
ToolGroup/ToolFallbackstyle components against projected messages.
- Renders assistant-ui primitives and local
- Create
web/apps/console/src/presentation/chat/AssistantOperatorThread.test.tsx- Pins grouped/collapsed tools, approval rendering, prompt action, and fallback messages.
- Modify
web/apps/console/src/presentation/OperatorChat.tsx- Replace direct use of
ChatPrimitiveswithAssistantOperatorThread.
- Replace direct use of
- Modify
web/apps/console/src/presentation/OperatorChat.test.tsx- Update expectations from
.ai-chat-*classes to assistant surface roles and tool groups.
- Update expectations from
- Modify
web/apps/console/src/presentation/opening/ProblemLoopScene.tsx- Replace left-side bespoke transcript with the shared assistant transcript surface in read-only mode.
- Modify
web/apps/console/src/presentation/opening/ProblemLoopScene.test.tsx- Pin that Scene 2 uses assistant transcript/tool rendering and preserves simple reusable automation vocabulary.
- Modify
web/apps/console/src/presentation/presentation.css- Remove or neutralize stale
.ai-chat-*and bespoke Scene 2 transcript rules after the new assistant surface is active.
- Remove or neutralize stale
- Modify
docs/current_roadmap.md- Mark assistant UI chat surface completed after implementation.
- Move this plan to
docs/historical/superpowers/plans/after completion.
Task 1: Configure Assistant UI And shadcn Registry Boundary
Files:
- Create:
web/apps/console/components.json - Create:
web/apps/console/src/lib/utils.ts - Modify:
web/apps/console/package.json - Modify:
web/pnpm-lock.yaml - Modify:
web/apps/console/tsconfig.json - Modify:
web/apps/console/vite.config.ts - Expected generated files:
web/apps/console/src/components/assistant-ui/tool-fallback.tsx,web/apps/console/src/components/assistant-ui/tool-group.tsx
Interfaces:
-
Produces:
cn(...inputs: ClassValue[]): string- Working
@/*import alias for TypeScript and Vite. @assistant-ui/react,class-variance-authority,tw-shimmer, and registry-required UI dependencies.- Local assistant-ui source components for tool fallback/group rendering.
-
Consumes:
- Existing Vite app root:
web/apps/console. - Existing CSS entry:
src/styles/global.css.
- Existing Vite app root:
-
Step 1: Add assistant-ui and registry helper dependencies
Run from repository root:
pnpm --dir web --filter @lda/console add @assistant-ui/react class-variance-authority tw-shimmer clsx tailwind-merge lucide-react
Expected:
-
web/apps/console/package.jsongains those dependencies. -
web/pnpm-lock.yamlupdates. -
Step 2: Add shadcn/assistant-ui registry config
Create web/apps/console/components.json:
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/global.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
- Step 3: Add local
cnhelper
Create web/apps/console/src/lib/utils.ts:
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export const cn = (...inputs: ClassValue[]): string => twMerge(clsx(inputs));
- Step 4: Add TypeScript alias
Modify web/apps/console/tsconfig.json to include baseUrl and paths:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "Preserve",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"noEmit": true,
"types": ["node", "vite/client", "vitest/globals"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
- Step 5: Add Vite alias
Modify web/apps/console/vite.config.ts.
Add imports:
import { fileURLToPath, URL } from "node:url";
Add resolve inside defineConfig({ ... }):
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
Keep the existing plugins, server, and test blocks unchanged.
- Step 6: Install assistant-ui tool components from the shadcn registry
Run from web/apps/console:
pnpm dlx shadcn@latest add https://r.assistant-ui.com/tool-group.json https://r.assistant-ui.com/tool-fallback.json
Expected:
-
Files are added under
src/components/assistant-ui/. -
If files are generated elsewhere, move them to
src/components/assistant-ui/and update their imports to use@/lib/utils. -
The command may also add shadcn support components such as
src/components/ui/button.tsxorsrc/components/ui/collapsible.tsx; keep generated files that are imported bytool-group.tsxortool-fallback.tsx. -
Do not install
thread,thread-list,assistant-modal, or AI SDK runtime components in this slice. -
Step 7: Run setup verification
Run:
pnpm --dir web --filter @lda/console typecheck
Expected: PASS.
- Step 8: Commit setup
git add web/apps/console/package.json web/pnpm-lock.yaml web/apps/console/components.json web/apps/console/tsconfig.json web/apps/console/vite.config.ts web/apps/console/src/lib/utils.ts web/apps/console/src/components/assistant-ui
git commit -m "feat: add assistant ui chat component boundary"
Task 2: Project Existing Agent Messages Into Assistant UI Messages
Files:
- Create:
web/apps/console/src/presentation/chat/assistantRuntimeProjection.ts - Create:
web/apps/console/src/presentation/chat/assistantRuntimeProjection.test.ts
Interfaces:
-
Consumes:
AgentMessageandAgentMessagePartfromweb/apps/console/src/demo/agent/events.ts.
-
Produces:
projectAgentMessagesForAssistant(messages: ReadonlyArray<AgentMessage>): ThreadMessageLike[]type AssistantToolRenderPayload- Deterministic mapping for text/tool-call/tool-result/presentation-action/approval-request/error.
-
Step 1: Write failing projection tests
Create web/apps/console/src/presentation/chat/assistantRuntimeProjection.test.ts:
import { describe, expect, it } from "vitest";
import type { AgentMessage } from "../../demo/agent/events.js";
import { projectAgentMessagesForAssistant } from "./assistantRuntimeProjection.js";
describe("assistantRuntimeProjection", () => {
it("projects text and tool call parts into assistant-ui content parts", () => {
const messages: ReadonlyArray<AgentMessage> = [
{
id: "assistant-1",
role: "assistant",
parts: [
{ type: "text", text: "I will inspect the run." },
{
type: "tool-call",
call: { id: "call-1", name: "readRunTrace", input: { run_id: "run_1" } },
},
],
},
];
const projected = projectAgentMessagesForAssistant(messages);
expect(projected).toHaveLength(1);
expect(projected[0]).toMatchObject({
id: "assistant-1",
role: "assistant",
});
expect(projected[0]?.content).toEqual([
{ type: "text", text: "I will inspect the run." },
{
type: "tool-call",
toolCallId: "call-1",
toolName: "readRunTrace",
args: { run_id: "run_1" },
},
]);
});
it("projects tool results as tool-role messages", () => {
const messages: ReadonlyArray<AgentMessage> = [
{
id: "tool-result-message",
role: "assistant",
parts: [
{
type: "tool-result",
result: {
callId: "call-1",
name: "readRunTrace",
status: "success",
output: { frames: 3 },
},
},
],
},
];
const projected = projectAgentMessagesForAssistant(messages);
expect(projected[0]).toMatchObject({
id: "tool-result-message",
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call-1",
toolName: "readRunTrace",
result: { frames: 3 },
isError: false,
},
],
});
});
it("projects approval requests as human tool calls with contract metadata", () => {
const messages: ReadonlyArray<AgentMessage> = [
{
id: "approval",
role: "assistant",
parts: [
{
type: "approval-request",
callId: "call-approval",
name: "resumeIssueReview",
prompt: "Submit resume request?",
contract: {
kind: "issue_review",
outcomes: ["submitted", "cancelled"],
resumeSchema: { type: "object" },
resumePayloadPreview: { selected_issue_ids: ["risk-1"] },
runId: "run_recorded_lda_report",
},
},
],
},
];
const projected = projectAgentMessagesForAssistant(messages);
expect(projected[0]?.content).toEqual([
{ type: "text", text: "Submit resume request?" },
{
type: "tool-call",
toolCallId: "call-approval",
toolName: "resumeIssueReview",
args: {
prompt: "Submit resume request?",
contract: {
kind: "issue_review",
outcomes: ["submitted", "cancelled"],
resumeSchema: { type: "object" },
resumePayloadPreview: { selected_issue_ids: ["risk-1"] },
runId: "run_recorded_lda_report",
},
},
},
]);
});
it("keeps presentation actions and errors visible as text/tool evidence", () => {
const messages: ReadonlyArray<AgentMessage> = [
{
id: "mixed",
role: "assistant",
parts: [
{ type: "presentation-action", action: { type: "selectWorkflowNode", nodeId: "review_issues" } },
{ type: "error", message: "provider failed" },
],
},
];
const projected = projectAgentMessagesForAssistant(messages);
expect(projected[0]?.content).toEqual([
{
type: "tool-call",
toolCallId: "presentation-selectWorkflowNode",
toolName: "presentation.selectWorkflowNode",
args: { type: "selectWorkflowNode", nodeId: "review_issues" },
},
{ type: "text", text: "provider failed" },
]);
});
});
- Step 2: Run projection tests and confirm failure
Run:
pnpm --dir web --filter @lda/console test -- src/presentation/chat/assistantRuntimeProjection.test.ts
Expected: FAIL because assistantRuntimeProjection.ts does not exist.
- Step 3: Implement projection
Create web/apps/console/src/presentation/chat/assistantRuntimeProjection.ts:
import type { ThreadMessageLike } from "@assistant-ui/react";
import type { AgentMessage, AgentMessagePart } from "../../demo/agent/events.js";
export type AssistantToolRenderPayload = {
readonly toolCallId: string;
readonly toolName: string;
readonly args?: unknown;
readonly result?: unknown;
readonly isError?: boolean;
};
type AssistantContentPart =
| { readonly type: "text"; readonly text: string }
| {
readonly type: "tool-call";
readonly toolCallId: string;
readonly toolName: string;
readonly args: unknown;
}
| {
readonly type: "tool-result";
readonly toolCallId: string;
readonly toolName: string;
readonly result: unknown;
readonly isError: boolean;
};
const WORKFLOW_START_TOOL = "startPreparedReportRun";
const projectPart = (part: AgentMessagePart): AssistantContentPart[] => {
switch (part.type) {
case "text":
return [{ type: "text", text: part.text }];
case "tool-call":
return [{
type: "tool-call",
toolCallId: part.call.id,
toolName: part.call.name,
args: part.call.input,
}];
case "tool-result":
return [{
type: "tool-result",
toolCallId: part.result.callId,
toolName: part.result.name,
result: part.result.output,
isError: part.result.status === "failure",
}];
case "presentation-action":
return [{
type: "tool-call",
toolCallId: `presentation-${part.action.type}`,
toolName: `presentation.${part.action.type}`,
args: part.action,
}];
case "approval-request":
return [
{ type: "text", text: part.prompt },
{
type: "tool-call",
toolCallId: part.callId,
toolName: part.name,
args: {
prompt: part.prompt,
contract: part.contract,
},
},
];
case "error":
return [{ type: "text", text: part.message }];
}
};
const messageRoleFor = (message: AgentMessage): ThreadMessageLike["role"] => {
const onlyToolResults = message.parts.length > 0
&& message.parts.every((part) => part.type === "tool-result");
if (onlyToolResults) return "tool";
return message.role === "user" ? "user" : "assistant";
};
export const projectAgentMessagesForAssistant = (
messages: ReadonlyArray<AgentMessage>,
): ThreadMessageLike[] =>
messages.map((message) => {
const content = message.parts.flatMap(projectPart);
return {
id: message.id,
role: messageRoleFor(message),
content,
metadata: {
unstable_state: message.parts.some((part) =>
part.type === "tool-call" && part.call.name === WORKFLOW_START_TOOL
) ? "workflow-handoff" : undefined,
},
} satisfies ThreadMessageLike;
});
If TypeScript rejects ThreadMessageLike["role"] or the content part shapes because assistant-ui uses a narrower exported type, replace the explicit ThreadMessageLike return type with:
export type AssistantProjectedMessage = {
readonly id: string;
readonly role: "user" | "assistant" | "tool";
readonly content: ReadonlyArray<AssistantContentPart>;
readonly metadata?: { readonly unstable_state?: string | undefined };
};
and export projectAgentMessagesForAssistant(...): AssistantProjectedMessage[]. Then Task 3 must use convertMessage to cast into assistant-ui at the runtime boundary with a code comment explaining the library type gap.
- Step 4: Run projection tests
Run:
pnpm --dir web --filter @lda/console test -- src/presentation/chat/assistantRuntimeProjection.test.ts
Expected: PASS.
- Step 5: Commit projection
git add web/apps/console/src/presentation/chat/assistantRuntimeProjection.ts web/apps/console/src/presentation/chat/assistantRuntimeProjection.test.ts
git commit -m "feat: project agent messages for assistant ui"
Task 3: Build AssistantOperatorThread
Files:
- Create:
web/apps/console/src/presentation/chat/AssistantOperatorThread.tsx - Create:
web/apps/console/src/presentation/chat/AssistantOperatorThread.test.tsx - Modify:
web/apps/console/src/presentation/presentation.css
Interfaces:
-
Consumes:
projectAgentMessagesForAssistant(messages)from Task 2.- Local assistant-ui registry components:
@/components/assistant-ui/tool-fallback@/components/assistant-ui/tool-group
SchemaApprovalSurfacefrom../approval/SchemaApprovalSurface.js.
-
Produces:
AssistantOperatorThread(props)React component.- Props:
type AssistantOperatorThreadProps = { readonly mode: "hidden" | "full" | "rail" | "dock"; readonly messages: ReadonlyArray<AgentMessage>; readonly runAction?: { readonly label: string; readonly disabled: boolean; readonly run: () => void } | undefined; readonly submitApproval?: (() => void) | undefined; readonly cancelApproval?: (() => void) | undefined; };
-
Step 1: Write failing component tests
Create web/apps/console/src/presentation/chat/AssistantOperatorThread.test.tsx:
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AgentMessage } from "../../demo/agent/events.js";
import { AssistantOperatorThread } from "./AssistantOperatorThread.js";
afterEach(() => cleanup());
describe("AssistantOperatorThread", () => {
it("renders text interleaved with a collapsed tool call", async () => {
const user = userEvent.setup();
const messages: ReadonlyArray<AgentMessage> = [
{
id: "assistant-1",
role: "assistant",
parts: [
{ type: "text", text: "I will inspect the run." },
{
type: "tool-call",
call: { id: "call-1", name: "readRunTrace", input: { run_id: "run_1" } },
},
{ type: "text", text: "The trace is inspectable." },
],
},
];
render(<AssistantOperatorThread mode="dock" messages={messages} />);
expect(screen.getByRole("log", { name: /operator conversation/i })).toBeInTheDocument();
expect(screen.getByText("I will inspect the run.")).toBeInTheDocument();
expect(screen.getByText("The trace is inspectable.")).toBeInTheDocument();
const tool = screen.getByRole("button", { name: /readRunTrace/i });
expect(screen.queryByText(/run_1/)).not.toBeInTheDocument();
await user.click(tool);
expect(screen.getByText(/run_1/)).toBeInTheDocument();
});
it("renders grouped consecutive tool calls", () => {
const messages: ReadonlyArray<AgentMessage> = [
{
id: "assistant-tools",
role: "assistant",
parts: [
{ type: "tool-call", call: { id: "call-1", name: "workflow.sources.list", input: {} } },
{ type: "tool-call", call: { id: "call-2", name: "workflow.deployments.inspect", input: { deployment_id: "demo" } } },
],
},
];
render(<AssistantOperatorThread mode="dock" messages={messages} />);
expect(screen.getByText(/2 tools/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /workflow.sources.list/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /workflow.deployments.inspect/i })).toBeInTheDocument();
});
it("renders schema approval through the existing approval surface", async () => {
const user = userEvent.setup();
const submit = vi.fn();
const cancel = vi.fn();
const messages: ReadonlyArray<AgentMessage> = [
{
id: "approval",
role: "assistant",
parts: [
{
type: "approval-request",
callId: "call-approval",
name: "resumeIssueReview",
prompt: "Submit resume request?",
contract: {
kind: "issue_review",
outcomes: ["submitted", "cancelled"],
resumeSchema: { type: "object" },
resumePayloadPreview: { selected_issue_ids: ["risk-1"] },
runId: "run_recorded_lda_report",
},
},
],
},
];
render(
<AssistantOperatorThread
mode="dock"
messages={messages}
submitApproval={submit}
cancelApproval={cancel}
/>,
);
expect(screen.getByRole("group", { name: /issue review resume/i })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Submit" }));
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(submit).toHaveBeenCalledOnce();
expect(cancel).toHaveBeenCalledOnce();
});
it("renders a chat-owned run action", async () => {
const user = userEvent.setup();
const run = vi.fn();
render(
<AssistantOperatorThread
mode="dock"
messages={[]}
runAction={{ label: "Run prepared workflow", disabled: false, run }}
/>,
);
await user.click(screen.getByRole("button", { name: /run prepared workflow/i }));
expect(run).toHaveBeenCalledOnce();
});
});
- Step 2: Run tests and confirm failure
Run:
pnpm --dir web --filter @lda/console test -- src/presentation/chat/AssistantOperatorThread.test.tsx
Expected: FAIL because AssistantOperatorThread.tsx does not exist.
- Step 3: Implement AssistantOperatorThread
Create web/apps/console/src/presentation/chat/AssistantOperatorThread.tsx.
Implementation requirements:
- Use
AssistantRuntimeProvider,ThreadPrimitive,MessagePrimitive, andgroupPartByTypefrom@assistant-ui/react. - Use
useExternalStoreRuntimefrom@assistant-ui/react. - Use local
ToolGroupRoot,ToolGroupTrigger,ToolGroupContent, andToolFallbackfrom generated assistant-ui components. - Do not wire a live backend.
onNewshould append user text to local state only and must not call network. - Render approval tool calls with
SchemaApprovalSurfacewhen tool name isresumeIssueReviewandargs.contractexists. - Render other tool calls with
ToolFallback. - Keep
modeasdata-modefor existing presentation layout.
Use this implementation shape:
import { useCallback, useMemo, useState } from "react";
import {
AssistantRuntimeProvider,
MessagePrimitive,
ThreadPrimitive,
groupPartByType,
useExternalStoreRuntime,
type AppendMessage,
type ThreadMessageLike,
} from "@assistant-ui/react";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import {
ToolGroupContent,
ToolGroupRoot,
ToolGroupTrigger,
} from "@/components/assistant-ui/tool-group";
import type { AgentMessage } from "../../demo/agent/events.js";
import { SchemaApprovalSurface } from "../approval/SchemaApprovalSurface.js";
import { projectAgentMessagesForAssistant } from "./assistantRuntimeProjection.js";
type AssistantOperatorThreadProps = {
readonly mode: "hidden" | "full" | "rail" | "dock";
readonly messages: ReadonlyArray<AgentMessage>;
readonly runAction?: { readonly label: string; readonly disabled: boolean; readonly run: () => void } | undefined;
readonly submitApproval?: (() => void) | undefined;
readonly cancelApproval?: (() => void) | undefined;
};
const textFromAppendMessage = (message: AppendMessage): string =>
message.content.find((part) => part.type === "text")?.text ?? "";
const AssistantMessageParts = ({
submitApproval,
cancelApproval,
}: {
readonly submitApproval?: (() => void) | undefined;
readonly cancelApproval?: (() => void) | undefined;
}) => (
<MessagePrimitive.GroupedParts
groupBy={groupPartByType({
"tool-call": ["group-tool"],
})}
>
{({ part, children }) => {
switch (part.type) {
case "group-tool":
return (
<ToolGroupRoot>
<ToolGroupTrigger
count={part.indices.length}
active={part.status.type === "running"}
/>
<ToolGroupContent>{children}</ToolGroupContent>
</ToolGroupRoot>
);
case "text":
return <MessagePrimitive.Content />;
case "tool-call": {
const args = part.args as { readonly contract?: unknown; readonly prompt?: string } | undefined;
const contract = args?.contract as
| {
readonly kind: string;
readonly outcomes: readonly string[];
readonly resumeSchema: unknown;
readonly resumePayloadPreview: unknown;
readonly runId: string;
}
| undefined;
if (part.toolName === "resumeIssueReview" && contract) {
return (
<div className="assistant-tool-approval">
<SchemaApprovalSurface
title={`${contract.kind.replaceAll("_", " ")} resume`}
schema={contract.resumeSchema}
payload={contract.resumePayloadPreview}
outcomes={contract.outcomes}
runId={contract.runId}
onSubmit={submitApproval}
onCancel={cancelApproval}
/>
</div>
);
}
return part.toolUI ?? <ToolFallback {...part} />;
}
default:
return null;
}
}}
</MessagePrimitive.GroupedParts>
);
const ThreadBody = ({
submitApproval,
cancelApproval,
}: {
readonly submitApproval?: (() => void) | undefined;
readonly cancelApproval?: (() => void) | undefined;
}) => (
<ThreadPrimitive.Root className="assistant-thread">
<ThreadPrimitive.Viewport className="assistant-thread__viewport">
<ThreadPrimitive.Messages>
{({ message }) => (
<MessagePrimitive.Root
className="assistant-message"
data-role={message.role}
>
<AssistantMessageParts
submitApproval={submitApproval}
cancelApproval={cancelApproval}
/>
</MessagePrimitive.Root>
)}
</ThreadPrimitive.Messages>
</ThreadPrimitive.Viewport>
</ThreadPrimitive.Root>
);
export const AssistantOperatorThread = ({
mode,
messages,
runAction,
submitApproval,
cancelApproval,
}: AssistantOperatorThreadProps) => {
const projected = useMemo(() => projectAgentMessagesForAssistant(messages), [messages]);
const [localMessages, setLocalMessages] = useState<ThreadMessageLike[]>([]);
const runtimeMessages = projected.length > 0 ? projected : localMessages;
const onNew = useCallback((message: AppendMessage) => {
const text = textFromAppendMessage(message);
setLocalMessages((current) => [
...current,
{
id: `local-${current.length + 1}`,
role: "user",
content: [{ type: "text", text }],
},
]);
}, []);
const runtime = useExternalStoreRuntime({
messages: runtimeMessages,
setMessages: setLocalMessages,
onNew,
convertMessage: (message) => message,
isRunning: false,
});
return (
<section className="assistant-operator-thread" data-mode={mode} role="log" aria-label="operator conversation">
{runAction ? (
<div className="assistant-operator-thread__action">
<button type="button" disabled={runAction.disabled} onClick={runAction.run}>
{runAction.label}
</button>
</div>
) : null}
<AssistantRuntimeProvider runtime={runtime}>
<ThreadBody submitApproval={submitApproval} cancelApproval={cancelApproval} />
</AssistantRuntimeProvider>
</section>
);
};
If assistant-ui exports use different names for MessagePrimitive.Content or useExternalStoreRuntime, use the names from installed TypeScript errors and add a comment at the import explaining the version-specific API. Do not replace this with the old ChatPrimitives.
- Step 4: Add minimal assistant thread CSS
Add to web/apps/console/src/presentation/presentation.css:
.assistant-operator-thread {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 0.55rem;
min-height: 0;
}
.assistant-operator-thread[data-mode="hidden"] {
display: none;
}
.assistant-operator-thread__action button {
width: 100%;
border: 1px solid var(--accent-cyan);
border-radius: 0.65rem;
background: color-mix(in oklch, var(--accent-cyan) 13%, var(--stage-surface));
color: var(--text-primary);
padding: 0.55rem 0.7rem;
font: 700 0.8rem/1 var(--font-interface);
}
.assistant-thread,
.assistant-thread__viewport {
min-height: 0;
height: 100%;
}
.assistant-thread__viewport {
overflow: auto;
scrollbar-width: none;
}
.assistant-thread__viewport::-webkit-scrollbar {
display: none;
}
.assistant-message {
display: grid;
gap: 0.45rem;
margin-bottom: 0.65rem;
}
.assistant-tool-approval {
border: 1px solid color-mix(in oklch, var(--accent-cyan) 50%, var(--stage-line));
border-radius: 0.75rem;
background: var(--stage-inset);
padding: 0.65rem;
}
- Step 5: Run AssistantOperatorThread tests
Run:
pnpm --dir web --filter @lda/console test -- src/presentation/chat/AssistantOperatorThread.test.tsx
Expected: PASS.
- Step 6: Run typecheck
Run:
pnpm --dir web --filter @lda/console typecheck
Expected: PASS.
- Step 7: Commit assistant thread
git add web/apps/console/src/presentation/chat/AssistantOperatorThread.tsx web/apps/console/src/presentation/chat/AssistantOperatorThread.test.tsx web/apps/console/src/presentation/presentation.css
git commit -m "feat: render presentation chat with assistant ui"
Task 4: Replace OperatorChat Internals
Files:
- Modify:
web/apps/console/src/presentation/OperatorChat.tsx - Modify:
web/apps/console/src/presentation/OperatorChat.test.tsx
Interfaces:
-
Consumes:
AssistantOperatorThreadfrom Task 3.- Existing
TimelineAgentController. - Existing fallback message construction.
-
Produces:
- Same
OperatorChatpublic props. - Assistant-ui-backed internal renderer.
- Same
-
Step 1: Update OperatorChat tests for assistant surface
Modify web/apps/console/src/presentation/OperatorChat.test.tsx:
Replace assertions that depend on .ai-chat-message, .ai-chat-tool, or old ChatPrimitives class names with role/text assertions.
Add:
it("renders through the assistant-ui operator thread", () => {
render(<OperatorChat state={initialPresentationState} />);
expect(screen.getByRole("log", { name: /operator conversation/i }))
.toHaveClass("assistant-operator-thread");
});
Keep existing behavioral tests for:
-
fallback messages
-
standard agent message parts
-
collapsed tool calls
-
schema approval submit/cancel
-
timeline-agent run button
-
presentation action and error parts
-
Step 2: Run OperatorChat tests and confirm failure
Run:
pnpm --dir web --filter @lda/console test -- src/presentation/OperatorChat.test.tsx
Expected: FAIL because OperatorChat still renders old ChatPrimitives.
- Step 3: Replace OperatorChat internals
Modify web/apps/console/src/presentation/OperatorChat.tsx.
Remove imports from:
import {
Conversation,
ConversationContent,
Message,
MessageContent,
MessageResponse,
PromptAction,
Tool,
ToolInput,
ToolOutput,
} from "./chat/ChatPrimitives.js";
import { projectAgentMessage, type ProjectedChatPart } from "./chat/agentChatProjection.js";
Add:
import { AssistantOperatorThread } from "./chat/AssistantOperatorThread.js";
Delete renderProjectedPart.
In the returned <aside>, replace the old Conversation tree with:
<AssistantOperatorThread
mode={composition.chatMode}
messages={visibleMessages}
runAction={timelineAgent ? {
label: timelineAgent.runLabel,
disabled: !timelineAgent.canRun,
run: () => void timelineAgent.runPreparedWorkflow(),
} : undefined}
submitApproval={submit}
cancelApproval={cancel}
/>
Keep the outer <aside className="operator-chat" ...> and its data attributes unchanged so presentation layout remains stable.
- Step 4: Run OperatorChat tests
Run:
pnpm --dir web --filter @lda/console test -- src/presentation/OperatorChat.test.tsx src/presentation/chat/AssistantOperatorThread.test.tsx
Expected: PASS.
- Step 5: Remove stale direct chat primitive usage from OperatorChat only
Run:
rg -n 'ChatPrimitives|projectAgentMessage|ProjectedChatPart|ai-chat-' web/apps/console/src/presentation/OperatorChat.tsx web/apps/console/src/presentation
Expected:
-
No
ChatPrimitivesimport inOperatorChat.tsx. -
Existing
ChatPrimitives.tsxand its tests may remain temporarily for rollback and comparison. -
.ai-chat-*CSS may remain only if tests still coverChatPrimitives; it must not be used byOperatorChat. -
Step 6: Commit OperatorChat migration
git add web/apps/console/src/presentation/OperatorChat.tsx web/apps/console/src/presentation/OperatorChat.test.tsx
git commit -m "refactor: use assistant ui for operator chat"
Task 5: Reuse Assistant Transcript In Scene 2
Files:
- Modify:
web/apps/console/src/presentation/opening/ProblemLoopScene.tsx - Modify:
web/apps/console/src/presentation/opening/ProblemLoopScene.test.tsx - Modify:
web/apps/console/src/presentation/presentation.css
Interfaces:
-
Consumes:
AssistantOperatorThreadfrom Task 3.
-
Produces:
- Scene 2 left side uses the same assistant UI transcript renderer as demo chat.
- Scene 2 no longer maintains custom
.problem-chat-transcriptrows.
-
Step 1: Update Scene 2 tests
Modify web/apps/console/src/presentation/opening/ProblemLoopScene.test.tsx.
Replace the transcript test with:
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")!} />);
const transcript = screen.getByRole("log", { name: /one-off assistant transcript/i });
expect(transcript).toHaveClass("assistant-operator-thread");
expect(within(transcript).getByText("Can you finish this workspace task?")).toBeInTheDocument();
expect(within(transcript).getByRole("button", { name: /workspace.run_once/i })).toBeInTheDocument();
expect(within(transcript).getByText("Reports success, but leaves no reusable workflow behind.")).toBeInTheDocument();
await user.click(within(transcript).getByRole("button", { name: /workspace.run_once/i }));
expect(within(transcript).getByText(/ephemeral/i)).toBeInTheDocument();
});
Add userEvent import:
import userEvent from "@testing-library/user-event";
Keep the durable blueprint and forbidden vocabulary tests.
- Step 2: Run Scene 2 tests and confirm failure
Run:
pnpm --dir web --filter @lda/console test -- src/presentation/opening/ProblemLoopScene.test.tsx
Expected: FAIL because Scene 2 still uses custom transcript markup.
- Step 3: Replace left side with AssistantOperatorThread
Modify web/apps/console/src/presentation/opening/ProblemLoopScene.tsx.
Add import:
import { AssistantOperatorThread } from "../chat/AssistantOperatorThread.js";
import type { AgentMessage } from "../../demo/agent/events.js";
Replace toolLoopTurns with:
const oneOffToolLoopMessages: ReadonlyArray<AgentMessage> = [
{
id: "scene-2-user",
role: "user",
parts: [{ type: "text", text: "Can you finish this workspace task?" }],
},
{
id: "scene-2-assistant",
role: "assistant",
parts: [
{ type: "text", text: "I can solve the immediate request." },
{
type: "tool-call",
call: {
id: "scene-2-tool",
name: "workspace.run_once",
input: { persistence: "ephemeral", reusable_workflow: false },
},
},
{ type: "text", text: "Reports success, but leaves no reusable workflow behind." },
],
},
];
Replace the left <article className="problem-chat-card" ...> body with:
<article
className="problem-chat-card"
data-problem-active={automationBeat ? "false" : "true"}
aria-label="one-off chat and tool loop"
>
<header className="problem-artifact-header">
<span>One-off</span>
<h2>Chat + tool loop</h2>
<p>Good at getting through one request.</p>
</header>
<AssistantOperatorThread
mode="dock"
messages={oneOffToolLoopMessages}
/>
<p className="problem-artifact-note">The useful work lives in the conversation history.</p>
</article>
Change the assistant thread aria-label in this context by wrapping it:
<div aria-label="one-off assistant transcript" role="group">
<AssistantOperatorThread mode="dock" messages={oneOffToolLoopMessages} />
</div>
If tests require the role="log" itself to have the custom label, add an optional ariaLabel?: string prop to AssistantOperatorThread, default it to "operator conversation", and pass ariaLabel="one-off assistant transcript".
- Step 4: Remove obsolete Scene 2 transcript CSS
Remove these selectors from web/apps/console/src/presentation/presentation.css:
.problem-chat-transcript
.problem-chat-turn
.problem-chat-turn[data-turn-kind="tool"]
.problem-chat-turn[data-turn-kind="observation"]
.problem-chat-turn__label
.problem-chat-turn p
Keep .problem-chat-card and .problem-blueprint.
- Step 5: Run Scene 2 tests
Run:
pnpm --dir web --filter @lda/console test -- src/presentation/opening/ProblemLoopScene.test.tsx src/presentation/chat/AssistantOperatorThread.test.tsx
Expected: PASS.
- Step 6: Commit Scene 2 assistant transcript
git add web/apps/console/src/presentation/opening/ProblemLoopScene.tsx web/apps/console/src/presentation/opening/ProblemLoopScene.test.tsx web/apps/console/src/presentation/presentation.css web/apps/console/src/presentation/chat/AssistantOperatorThread.tsx
git commit -m "refactor: use assistant transcript in Scene 2"
Task 6: Remove Or Quarantine Old ChatPrimitives
Files:
- Modify or delete:
web/apps/console/src/presentation/chat/ChatPrimitives.tsx - Modify or delete:
web/apps/console/src/presentation/chat/ChatPrimitives.test.tsx - Modify or delete:
web/apps/console/src/presentation/chat/agentChatProjection.ts - Modify or delete:
web/apps/console/src/presentation/chat/agentChatProjection.test.ts - Modify:
web/apps/console/src/presentation/presentation.css
Interfaces:
-
Consumes:
- Completed OperatorChat and Scene 2 migrations.
-
Produces:
- No active use of old hand-rolled chat primitives.
-
Step 1: Find remaining old primitive usage
Run:
rg -n 'ChatPrimitives|agentChatProjection|ConversationContent|MessageResponse|ai-chat-' web/apps/console/src/presentation
Expected:
-
Only old primitive files/tests and CSS rules reference these names.
-
If production components still import them, finish Task 4/5 first.
-
Step 2: Delete old primitive files if unused
If Step 1 shows no production imports, delete:
git rm web/apps/console/src/presentation/chat/ChatPrimitives.tsx
git rm web/apps/console/src/presentation/chat/ChatPrimitives.test.tsx
git rm web/apps/console/src/presentation/chat/agentChatProjection.ts
git rm web/apps/console/src/presentation/chat/agentChatProjection.test.ts
- Step 3: Remove stale CSS
Remove .ai-chat-* rules from web/apps/console/src/presentation/presentation.css if no production component uses those classes.
Run:
rg -n 'ai-chat-' web/apps/console/src/presentation
Expected: no matches after removal.
- Step 4: Run chat/presentation tests
Run:
pnpm --dir web --filter @lda/console test -- src/presentation/chat src/presentation/OperatorChat.test.tsx src/presentation/opening/ProblemLoopScene.test.tsx
Expected: PASS.
- Step 5: Commit cleanup
git add web/apps/console/src/presentation/presentation.css
git rm --cached --ignore-unmatch web/apps/console/src/presentation/chat/ChatPrimitives.tsx web/apps/console/src/presentation/chat/ChatPrimitives.test.tsx web/apps/console/src/presentation/chat/agentChatProjection.ts web/apps/console/src/presentation/chat/agentChatProjection.test.ts
git add -u web/apps/console/src/presentation/chat
git commit -m "refactor: remove hand rolled presentation chat primitives"
If git rm --cached reports no files because Step 2 already removed them, run:
git add -u web/apps/console/src/presentation/chat web/apps/console/src/presentation/presentation.css
git commit -m "refactor: remove hand rolled presentation chat primitives"
Task 7: Verification, Screenshots, Docs, And Archive
Files:
- Modify:
docs/current_roadmap.md - Move:
docs/superpowers/plans/2026-07-10-assistant-ui-chat-surface.mdtodocs/historical/superpowers/plans/2026-07-10-assistant-ui-chat-surface.md
Interfaces:
-
Consumes:
- Assistant UI chat surface from Tasks 1-6.
-
Produces:
- Verified screenshots and roadmap entry.
-
Step 1: Run full presentation tests
Run:
pnpm --dir web --filter @lda/console test -- src/presentation
Expected: PASS.
- Step 2: Run typecheck
Run:
pnpm --dir web --filter @lda/console typecheck
Expected: PASS.
- Step 3: Run build
Run:
pnpm --dir web --filter @lda/console build
Expected: PASS. The existing Vite chunk-size warning is acceptable.
- Step 4: Capture screenshots
Run with dev server at http://127.0.0.1:5173:
pnpm dlx playwright screenshot --viewport-size="1280,720" "http://127.0.0.1:5173/present#scene/problem/direct-actions" web/apps/console/.visual-smoke/assistant-ui-scene-2-actions.png
pnpm dlx playwright screenshot --viewport-size="1280,720" "http://127.0.0.1:5173/present#scene/problem/missing-contracts" web/apps/console/.visual-smoke/assistant-ui-scene-2-automation.png
pnpm dlx playwright screenshot --viewport-size="1280,720" "http://127.0.0.1:5173/present#scene/typed-human-boundary/approval" web/apps/console/.visual-smoke/assistant-ui-approval-chat.png
pnpm dlx playwright screenshot --viewport-size="1280,720" "http://127.0.0.1:5173/present#scene/resume-output-evidence/trace" web/apps/console/.visual-smoke/assistant-ui-trace-chat.png
Expected:
-
Scene 2 left side looks like a modern assistant transcript with a tool call.
-
Demo chat uses the same assistant-style tool rendering.
-
Approval still renders
SchemaApprovalSurface. -
No live LLM or AI SDK copy appears.
-
Step 5: Update roadmap
Modify docs/current_roadmap.md under the presentation wishlist / recommended visual slices:
- Completed: presentation chat and Scene 2 now use assistant-ui/shadcn-sourced
message and tool-call surfaces while preserving deterministic replay truth.
Live AI SDK integration remains outside this slice. Implementation:
[`assistant UI chat surface`](historical/superpowers/plans/2026-07-10-assistant-ui-chat-surface.md).
- Step 6: Archive this plan
Run:
git mv docs/superpowers/plans/2026-07-10-assistant-ui-chat-surface.md docs/historical/superpowers/plans/2026-07-10-assistant-ui-chat-surface.md
- Step 7: Commit docs/archive
git add docs/current_roadmap.md docs/historical/superpowers/plans/2026-07-10-assistant-ui-chat-surface.md
git commit -m "docs: complete assistant ui chat surface"
Self-Review Checklist
- Spec coverage:
- assistant-ui dependency and runtime boundary: Task 1 and Task 3.
- shadcn/registry source components: Task 1.
- OperatorChat overhaul: Task 4.
- Scene 2 assistant transcript: Task 5.
- Approval remains factual and schema-backed: Task 3 and Task 4.
- No live LLM backend: Global constraints and no AI SDK dependency.
- Old hand-rolled primitives removed or quarantined: Task 6.
- Screenshots for Scene 2 and demo chat: Task 7.
- Placeholder scan:
- No unresolved placeholder markers or vague implementation steps.
- Type consistency:
AssistantOperatorThreadPropsis defined in Task 3 and consumed in Tasks 4-5.projectAgentMessagesForAssistantis defined in Task 2 and consumed in Task 3.ToolFallbackandToolGroupimports use the@/components/assistant-ui/*alias established in Task 1.
- Truth boundary:
- The UI may look like an assistant/tool flow, but the slice still renders deterministic replay. No copy says the agent live-builds the workflow.