# 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`, or `badge` as the goal. Only add components required by assistant-ui chat/tool rendering. - Keep `SchemaApprovalSurface` as 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.ts` - `cn()` helper for registry components. - Modify `web/apps/console/tsconfig.json` - Add `baseUrl` and `@/*` alias for registry imports. - 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. - Create `web/apps/console/src/presentation/chat/assistantRuntimeProjection.ts` - Converts existing `AgentMessage` values into assistant-ui `ThreadMessageLike` messages. - 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` / `ToolFallback` style components against projected messages. - 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 `ChatPrimitives` with `AssistantOperatorThread`. - Modify `web/apps/console/src/presentation/OperatorChat.test.tsx` - Update expectations from `.ai-chat-*` classes to assistant surface roles and tool groups. - 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. - 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`. - [ ] **Step 1: Add assistant-ui and registry helper dependencies** Run from repository root: ```bash 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.json` gains those dependencies. - `web/pnpm-lock.yaml` updates. - [ ] **Step 2: Add shadcn/assistant-ui registry config** Create `web/apps/console/components.json`: ```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 `cn` helper** Create `web/apps/console/src/lib/utils.ts`: ```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`: ```json { "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: ```ts import { fileURLToPath, URL } from "node:url"; ``` Add `resolve` inside `defineConfig({ ... })`: ```ts 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`: ```bash 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.tsx` or `src/components/ui/collapsible.tsx`; keep generated files that are imported by `tool-group.tsx` or `tool-fallback.tsx`. - Do not install `thread`, `thread-list`, `assistant-modal`, or AI SDK runtime components in this slice. - [ ] **Step 7: Run setup verification** Run: ```bash pnpm --dir web --filter @lda/console typecheck ``` Expected: PASS. - [ ] **Step 8: Commit setup** ```bash 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: - `AgentMessage` and `AgentMessagePart` from `web/apps/console/src/demo/agent/events.ts`. - Produces: - `projectAgentMessagesForAssistant(messages: ReadonlyArray): 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`: ```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 = [ { 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 = [ { 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 = [ { 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 = [ { 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: ```bash 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`: ```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, ): 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: ```ts export type AssistantProjectedMessage = { readonly id: string; readonly role: "user" | "assistant" | "tool"; readonly content: ReadonlyArray; 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: ```bash pnpm --dir web --filter @lda/console test -- src/presentation/chat/assistantRuntimeProjection.test.ts ``` Expected: PASS. - [ ] **Step 5: Commit projection** ```bash 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` - `SchemaApprovalSurface` from `../approval/SchemaApprovalSurface.js`. - Produces: - `AssistantOperatorThread(props)` React component. - Props: ```ts type AssistantOperatorThreadProps = { readonly mode: "hidden" | "full" | "rail" | "dock"; readonly messages: ReadonlyArray; 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`: ```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 = [ { 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(); 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 = [ { 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(); 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 = [ { 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( , ); 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( , ); await user.click(screen.getByRole("button", { name: /run prepared workflow/i })); expect(run).toHaveBeenCalledOnce(); }); }); ``` - [ ] **Step 2: Run tests and confirm failure** Run: ```bash 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`, and `groupPartByType` from `@assistant-ui/react`. - Use `useExternalStoreRuntime` from `@assistant-ui/react`. - Use local `ToolGroupRoot`, `ToolGroupTrigger`, `ToolGroupContent`, and `ToolFallback` from generated assistant-ui components. - Do not wire a live backend. `onNew` should append user text to local state only and must not call network. - Render approval tool calls with `SchemaApprovalSurface` when tool name is `resumeIssueReview` and `args.contract` exists. - Render other tool calls with `ToolFallback`. - Keep `mode` as `data-mode` for existing presentation layout. Use this implementation shape: ```tsx 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; 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; }) => ( {({ part, children }) => { switch (part.type) { case "group-tool": return ( {children} ); case "text": return ; 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 (
); } return part.toolUI ?? ; } default: return null; } }}
); const ThreadBody = ({ submitApproval, cancelApproval, }: { readonly submitApproval?: (() => void) | undefined; readonly cancelApproval?: (() => void) | undefined; }) => ( {({ message }) => ( )} ); export const AssistantOperatorThread = ({ mode, messages, runAction, submitApproval, cancelApproval, }: AssistantOperatorThreadProps) => { const projected = useMemo(() => projectAgentMessagesForAssistant(messages), [messages]); const [localMessages, setLocalMessages] = useState([]); 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 (
{runAction ? (
) : null}
); }; ``` 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`: ```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: ```bash pnpm --dir web --filter @lda/console test -- src/presentation/chat/AssistantOperatorThread.test.tsx ``` Expected: PASS. - [ ] **Step 6: Run typecheck** Run: ```bash pnpm --dir web --filter @lda/console typecheck ``` Expected: PASS. - [ ] **Step 7: Commit assistant thread** ```bash 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: - `AssistantOperatorThread` from Task 3. - Existing `TimelineAgentController`. - Existing fallback message construction. - Produces: - Same `OperatorChat` public props. - Assistant-ui-backed internal renderer. - [ ] **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: ```tsx it("renders through the assistant-ui operator thread", () => { render(); 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: ```bash 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: ```ts import { Conversation, ConversationContent, Message, MessageContent, MessageResponse, PromptAction, Tool, ToolInput, ToolOutput, } from "./chat/ChatPrimitives.js"; import { projectAgentMessage, type ProjectedChatPart } from "./chat/agentChatProjection.js"; ``` Add: ```ts import { AssistantOperatorThread } from "./chat/AssistantOperatorThread.js"; ``` Delete `renderProjectedPart`. In the returned `