# ChatGPT-like Assistant Markdown 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:** Render safe, streaming-aware, ChatGPT-like Markdown for assistant messages while preserving plain user messages, optimistic rendering, retry/status behavior, and source-copy semantics. **Architecture:** Add a focused `AssistantMarkdown` client component backed by `streamdown` and the Shiki-based `@streamdown/code` plugin. The component owns the untrusted-content boundary, streaming Markdown mode, safe links, disabled images, and code-block controls; `ChatMessage` continues to own message lifecycle and delegates only non-empty assistant text. Presentation stays scoped to `.assistant-markdown` in the existing shadcn/Tailwind product layer. **Tech Stack:** Next.js 16 App Router, React 19, TypeScript, Streamdown 2.5.0, `@streamdown/code` 1.1.1, Shiki, Tailwind CSS 4, shadcn tokens, Vitest, Testing Library, Docker, minikube/Kubernetes. ## Global Constraints - Only assistant messages render Markdown. User messages remain literal plain text. - Model output is untrusted: no raw HTML execution, remote images, active previews, Mermaid, math, executable code, or unsafe URL schemes. - Only absolute `http:`/`https:` links and conservative same-document anchors may navigate. - External links open with `target="_blank"` and `rel="noopener noreferrer"`; invalid links remain readable without navigation. - Code blocks are inert text, use a dark Shiki theme, scroll horizontally inside the message, and expose copy but not download. - The existing message-level copy button must copy the original Markdown source exactly. - Empty streaming placeholder, stopped/error states, retry, attachments, downloads, persistence, API, database, Gateway, FinOps, and model adapters remain unchanged. - Incomplete Markdown must render progressively and converge to the completed semantic tree without adding a second stream buffer. - All Markdown presentation selectors are scoped below `.assistant-markdown`; no generic global heading, paragraph, table, or code styles. - Pin new runtime packages to `streamdown@2.5.0` and `@streamdown/code@1.1.1` for reproducible image builds. - Follow RED → GREEN → refactor for every behavioral task and commit only after the stated verification passes. ## File Structure | File | Responsibility | | --- | --- | | `components/chat/assistant-markdown.tsx` | New streaming renderer, safe URL policy, safe link element, image denial, code plugin/configuration. | | `components/chat/assistant-markdown.test.tsx` | Semantic Markdown, partial streaming, XSS/URL/image boundary, code copy, overflow hooks. | | `components/chat/chat-message.tsx` | Delegate non-empty assistant content to `AssistantMarkdown`; retain all lifecycle/action ownership. | | `components/chat/chat-message.test.tsx` | Integration: assistant rich rendering, user literal rendering, full-source copy, existing regressions. | | `app/globals.css` | Streamdown Tailwind source discovery and scoped ChatGPT-like typography/code/table presentation. | | `package.json` / `package-lock.json` | Exact Streamdown runtime dependencies. | | `docs/superpowers/specs/2026-08-10-chatgpt-like-assistant-markdown-design.md` | Approved behavior and security source of truth; no implementation edits expected. | --- ### Task 1: Streaming Markdown renderer and semantic contract **Files:** - Create: `components/chat/assistant-markdown.tsx` - Create: `components/chat/assistant-markdown.test.tsx` - Modify: `package.json` - Modify: `package-lock.json` **Interfaces:** ```ts type AssistantMarkdownProps = { content: string; isStreaming: boolean; }; export function AssistantMarkdown( props: AssistantMarkdownProps, ): React.JSX.Element; ``` - Consumes the original model Markdown string and the assistant message's current streaming state. - Produces semantic, inert React DOM only; it does not mutate or normalize the persisted/source string. - [ ] **Step 1: Install and pin the approved renderer packages** Run: ```bash npm install --save-exact streamdown@2.5.0 @streamdown/code@1.1.1 ``` Expected: `package.json` contains exact versions and `package-lock.json` resolves the packages without installing Mermaid, math, or preview plugins as direct dependencies. - [ ] **Step 2: Write the initial failing semantic and streaming tests** Create `components/chat/assistant-markdown.test.tsx` with a representative GFM document: ```tsx import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it } from "vitest"; import { AssistantMarkdown } from "@/components/chat/assistant-markdown"; afterEach(cleanup); describe("AssistantMarkdown", () => { it("renders supported model Markdown as semantic document content", () => { const { container } = render( Проверяем цитату. | Модель | Статус | | --- | --- | | GPT | готова | ---`} isStreaming={false} />, ); expect(screen.getByRole("heading", { level: 2, name: "Итог" })).toBeVisible(); expect(screen.getByText("важный").tagName).toBe("SPAN"); expect(container.querySelector("em")).toHaveTextContent("курсивный"); expect(container.querySelector("del")).toHaveTextContent("удалением"); expect(container.querySelector('[data-streamdown="inline-code"]')).toHaveTextContent("inline()"); expect(container.querySelector("ul")).toBeInTheDocument(); expect(container.querySelector('input[type="checkbox"]')).toBeDisabled(); expect(container.querySelector("blockquote")).toHaveTextContent("Проверяем цитату."); expect(screen.getByRole("table")).toHaveTextContent("GPT"); expect(container.querySelector("hr")).toBeInTheDocument(); }); it("renders incomplete Markdown during streaming and converges when completed", () => { const { rerender } = render( , ); expect(screen.getByText(/Ответ с/)).toBeVisible(); rerender( , ); expect(screen.getByText("важным")).toBeVisible(); expect(screen.getByText("важным").closest("span")).toHaveAttribute( "data-streamdown", "strong", ); }); }); ``` - [ ] **Step 3: Run the focused tests and verify RED** Run: ```bash npm test -- components/chat/assistant-markdown.test.tsx ``` Expected: FAIL because `components/chat/assistant-markdown.tsx` does not exist yet. The failure must be module/component absence, not test-environment setup. - [ ] **Step 4: Implement the minimal streaming renderer** Create `components/chat/assistant-markdown.tsx` as a client component. Start with a stable dark Shiki plugin and Streamdown's streaming/static modes: ```tsx "use client"; import { createCodePlugin } from "@streamdown/code"; import { Streamdown } from "streamdown"; const codePlugin = createCodePlugin({ themes: ["github-dark", "github-dark"], }); type AssistantMarkdownProps = { content: string; isStreaming: boolean; }; export function AssistantMarkdown({ content, isStreaming, }: AssistantMarkdownProps) { return ( {content} ); } ``` Do not add `animated`, Mermaid, math, CJK, custom renderers, or raw HTML plugins. - [ ] **Step 5: Run the semantic tests and verify GREEN** Run: ```bash npm test -- components/chat/assistant-markdown.test.tsx ``` Expected: all initial semantic and incomplete-streaming tests PASS. If the completed `strong` node is a Streamdown `span`, assert its semantic `data-streamdown="strong"` marker rather than coupling to internal class order. - [ ] **Step 6: Commit the renderer foundation** ```bash git add package.json package-lock.json components/chat/assistant-markdown.tsx components/chat/assistant-markdown.test.tsx git commit -m "feat: add streaming assistant markdown renderer" ``` --- ### Task 2: Fail-closed content boundary and code-copy behavior **Files:** - Modify: `components/chat/assistant-markdown.tsx` - Modify: `components/chat/assistant-markdown.test.tsx` **Interfaces:** ```ts export const safeMarkdownUrl: UrlTransform; ``` - `safeMarkdownUrl` returns the original URL only for absolute HTTP(S) URLs or conservative `#fragment` anchors. - Every other scheme, malformed URL, relative URL, and every non-`href` URL-bearing property resolves to `null`/`undefined` and is non-navigable. - [ ] **Step 1: Write failing security, link, image, and code-copy tests** Add tests which render a single adversarial document containing raw HTML, a script, event attributes, remote/data images, an HTTPS link, an anchor, a relative link, and `javascript:`, `data:`, `file:`, and `blob:` links: ```tsx it("renders untrusted model output without active HTML, images, or unsafe navigation", () => { const { container } = render( window.__owned = true ![remote](https://tracker.invalid/markdown.png) [safe](https://example.com/docs?q=1) [anchor](#details) [relative](/admin) [javascript](javascript:alert(1)) [data](data:text/html,owned) [file](file:///etc/passwd) [blob](blob:https://example.com/id)`} isStreaming={false} />, ); expect(container.querySelector("script, img, iframe, object, embed, style")).toBeNull(); expect(screen.getByRole("link", { name: "safe" })).toHaveAttribute( "href", "https://example.com/docs?q=1", ); expect(screen.getByRole("link", { name: "safe" })).toHaveAttribute("target", "_blank"); expect(screen.getByRole("link", { name: "safe" })).toHaveAttribute( "rel", expect.stringContaining("noopener"), ); expect(screen.getByRole("link", { name: "anchor" })).toHaveAttribute("href", "#details"); expect(screen.getByText("relative").closest("a")).not.toHaveAttribute("href"); expect(screen.getByText("javascript").closest("a")).not.toHaveAttribute("href"); }); ``` Add a code-block interaction test: ```tsx it("copies only inert fenced code and never offers a download", async () => { const writeText = vi.fn().mockResolvedValue(undefined); vi.spyOn(navigator.clipboard, "writeText").mockImplementation(writeText); const user = userEvent.setup(); const { container } = render( , ); expect(container.querySelector('[data-streamdown="code-block"]')).toHaveTextContent( "const answer = 42;", ); expect(container.querySelector('[data-streamdown="code-block-header"]')).toHaveTextContent( "typescript", ); expect(container.querySelector('[data-streamdown="code-block-download-button"]')).toBeNull(); await user.click(screen.getByTitle("Копировать код")); expect(writeText).toHaveBeenCalledWith("const answer = 42;\n"); }); ``` Also cover malformed/encoded schemes (`JaVaScRiPt:`, leading whitespace, and character-reference forms) and assert no resulting anchor has a navigable unsafe `href`. - [ ] **Step 2: Run the focused tests and verify RED** Run: ```bash npm test -- components/chat/assistant-markdown.test.tsx ``` Expected: FAIL because the initial renderer still uses Streamdown's broad default URL/image policy and does not yet enforce the application-specific navigation boundary. - [ ] **Step 3: Implement the explicit URL and element policy** Add a pure transform and stable component overrides: ```tsx import type { Components, UrlTransform } from "streamdown"; const SAFE_ANCHOR = /^#[A-Za-z][A-Za-z0-9:._-]*$/; export const safeMarkdownUrl: UrlTransform = (url, key) => { if (key !== "href") return null; if (url !== url.trim()) return null; if (SAFE_ANCHOR.test(url)) return url; try { const parsed = new URL(url); return parsed.protocol === "http:" || parsed.protocol === "https:" ? url : null; } catch { return null; } }; const components: Components = { a: (componentProps) => { const { node, href, ...anchorProps } = componentProps; const sameDocument = href?.startsWith("#") ?? false; void node; return ( ); }, img: () => null, }; ``` Pass the boundary to Streamdown: ```tsx {content} ``` Keep `disallowedElements`, `components`, and `urlTransform` module-level/stable. Do not enable `rehype-raw`; `skipHtml` must remain true. - [ ] **Step 4: Verify clipboard rejection is non-destructive** Add a test whose clipboard mock rejects, click the code-copy control, and assert the code remains visible and no internal error text appears in the DOM. Do not surface the rejected error or model content through diagnostics. - [ ] **Step 5: Run the focused renderer suite and verify GREEN** Run: ```bash npm test -- components/chat/assistant-markdown.test.tsx ``` Expected: all semantic, streaming, security, safe-link, image-denial, code-copy, and clipboard-rejection tests PASS. - [ ] **Step 6: Commit the security boundary** ```bash git add components/chat/assistant-markdown.tsx components/chat/assistant-markdown.test.tsx git commit -m "fix: harden assistant markdown rendering" ``` --- ### Task 3: Chat message integration and ChatGPT-like scoped presentation **Files:** - Modify: `components/chat/chat-message.tsx` - Modify: `components/chat/chat-message.test.tsx` - Modify: `app/globals.css` **Interfaces:** - `ChatMessage` passes `message.content` unchanged and `message.status === "streaming"` to `AssistantMarkdown` only for non-empty assistant responses. - User text remains inside the existing `whitespace-pre-wrap` paragraph. - Message-level copy continues to call `navigator.clipboard.writeText(message.content)`. - [ ] **Step 1: Write failing integration and regression tests** Extend `components/chat/chat-message.test.tsx` with: 1. assistant Markdown becomes semantic; 2. user Markdown stays literal and creates no heading/strong/code-block semantics; 3. message-level copy receives the exact raw Markdown source; 4. empty streaming placeholder, stopped state, retry, attachment download, and download failure continue to work; 5. assistant renderer exposes the scoped wrapper and overflow hooks. Representative assertions: ```tsx it("renders assistant Markdown but keeps the original source for full-response copy", async () => { const source = "## Ответ\n\n**Готово**\n\n```js\nconsole.log('ok')\n```"; const writeText = vi.spyOn(navigator.clipboard, "writeText"); const user = userEvent.setup(); const { container } = render( , ); expect(screen.getByRole("heading", { level: 2, name: "Ответ" })).toBeVisible(); expect(container.querySelector(".assistant-markdown")).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Копировать ответ" })); expect(writeText).toHaveBeenCalledWith(source); }); it("keeps user-entered Markdown literal", () => { const { container } = render( , ); expect(screen.getByText("# Заголовок\n\n**текст**")).toBeVisible(); expect(container.querySelector("h1, strong, [data-streamdown]")).toBeNull(); }); ``` - [ ] **Step 2: Run chat-message tests and verify RED** Run: ```bash npm test -- components/chat/chat-message.test.tsx ``` Expected: new assistant semantic/scoped assertions FAIL because `ChatMessage` still renders assistant content as a plain paragraph. Existing placeholder and attachment tests remain GREEN. - [ ] **Step 3: Delegate assistant content to the renderer** Import the component and replace only the non-empty assistant paragraph: ```tsx import { AssistantMarkdown } from "@/components/chat/assistant-markdown"; {message.status === "streaming" && !message.content ? ( {[0, 1, 2].map((index) => ( ) : ( )} ``` Keep the current placeholder markup inline if extracting it would create unnecessary churn. Do not modify the user branch or the message-level copy handler. - [ ] **Step 4: Add Tailwind source discovery and scoped product styles** At the top of `app/globals.css`, after imports, add only installed package sources: ```css @source "../node_modules/streamdown/dist/*.js"; @source "../node_modules/@streamdown/code/dist/*.js"; ``` Add a scoped presentation block after the existing chat product utilities. The final selectors must stay under `.assistant-markdown`: ```css .assistant-markdown { min-width: 0; max-width: 100%; overflow-wrap: anywhere; font-size: 0.875rem; line-height: 1.75; } .assistant-markdown :where(h1, h2, h3, h4, h5, h6) { color: var(--foreground); font-family: var(--font-sans); letter-spacing: -0.015em; } .assistant-markdown :where(p, ul, ol, blockquote, table, pre) { max-width: 100%; } .assistant-markdown [data-streamdown="inline-code"] { border: 1px solid var(--border); background: var(--muted); font-family: var(--font-mono); } .assistant-markdown [data-streamdown="code-block"] { max-width: 100%; overflow: hidden; border-color: oklch(0.36 0.012 67); background: oklch(0.19 0.009 65); color: oklch(0.94 0.007 78); } .assistant-markdown [data-streamdown="code-block-body"], .assistant-markdown [data-streamdown="table-wrapper"] { max-width: 100%; overflow-x: auto; } .assistant-markdown [data-streamdown="code-block-header"] { color: oklch(0.72 0.012 75); } .assistant-markdown [data-streamdown="blockquote"] { border-left-color: var(--border); color: var(--muted-foreground); } ``` Refine heading sizes and vertical rhythm to the approved restrained ChatGPT-like hierarchy; retain Streamdown's semantic/data attributes. Do not add page-wide overflow clipping as a substitute for containing code/tables. - [ ] **Step 5: Run focused chat rendering tests and verify GREEN** Run: ```bash npm test -- components/chat/assistant-markdown.test.tsx components/chat/chat-message.test.tsx components/chat/chat-app.test.tsx hooks/use-chat.test.tsx ``` Expected: all focused tests PASS, including optimistic user rendering, empty response placeholder, stream updates, stop/retry, both copy controls, safe links, and attachments. - [ ] **Step 6: Perform static scope and dependency audits** Run: ```bash rg -n '^\s*(p|pre|table|h[1-6]|blockquote|code)\b' app/globals.css rg -n '@streamdown/(mermaid|math)|rehype-raw|dangerouslySetInnerHTML' components app package.json npm ls streamdown @streamdown/code --depth=0 git diff --check ``` Expected: - no new unscoped generic Markdown selectors; - no active rendering plugin or raw-HTML escape hatch; - exact direct versions `streamdown@2.5.0` and `@streamdown/code@1.1.1`; - clean diff formatting. - [ ] **Step 7: Commit the UI integration** ```bash git add components/chat/chat-message.tsx components/chat/chat-message.test.tsx app/globals.css git commit -m "feat: format assistant responses like ChatGPT" ``` --- ### Task 4: Full verification, minikube rollout, and live browser smoke **Files:** - Verify: entire `corp-ui` workspace - Deploy: Kubernetes Deployment `aegida-services/ai-control-chat-ui`, container `app` - No manifest mutation expected. **Interfaces:** - Produces a versioned local image served through the existing shared ingress at `https://chat.aegida.test:8443`. - Does not change Gateway, FinOps, model registry, database, Secrets, ConfigMaps, or ingress configuration. - [ ] **Step 1: Run the full local verification matrix** Run from `corp-ui`: ```bash npm test npm run lint npx tsc --noEmit npm run build git diff --check git status --short ``` Expected: every command exits 0; all tests pass; production build emits the existing pages/API routes; the tree contains only intentionally committed work. If Turbopack cannot bind its sandbox worker port, rerun only `npm run build` with the already-established local worker permission. - [ ] **Step 2: Build a versioned minikube image** Run: ```bash CHAT_UI_IMAGE="ai-control-chat-ui:minikube-$(git rev-parse --short=7 HEAD)" minikube image build \ --build-opt build-arg=NEXT_PUBLIC_APP_URL=https://chat.aegida.test:8443 \ -t "$CHAT_UI_IMAGE" \ . ``` Expected: Docker build completes using `npm ci` and `npm run build`, and the image exists in minikube with the exact committed package lock. - [ ] **Step 3: Roll out only corp-ui** Run: ```bash kubectl -n aegida-services set image \ deployment/ai-control-chat-ui \ app="$CHAT_UI_IMAGE" kubectl -n aegida-services rollout status \ deployment/ai-control-chat-ui \ --timeout=240s kubectl -n aegida-services get pods \ -l app=ai-control-chat-ui \ -o wide ``` Expected: rollout succeeds, one new pod is Ready, and the new pod has zero restarts. Do not reapply `k8s/config.yaml` or replace existing Secrets. - [ ] **Step 4: Verify ingress health on port 8443** Run: ```bash curl -ksS --max-time 10 \ -w '\nHTTP %{http_code}\n' \ https://chat.aegida.test:8443/api/health ``` Expected: JSON health response and HTTP 200 through the existing TLS bridge/ingress. - [ ] **Step 5: Run an authenticated browser smoke against a real model** In the in-app browser at `https://chat.aegida.test:8443`: 1. sign in as the existing admin test user; 2. select an available model and send a prompt requesting a heading, paragraphs, bold/emphasis, a list, a table, an HTTPS link, inline code, and a fenced code block; 3. while streaming, verify the existing placeholder appears before first content and partial Markdown appears progressively afterward; 4. after completion, verify semantic formatting, dark highlighted code, language label, contained table/code overflow, code-specific copy, and full-response source copy; 5. verify an intentionally requested remote Markdown image does not create an `img` element or a network request; 6. verify external HTTPS links open safely and user-authored Markdown remains literal; 7. reload the conversation and confirm the persisted Markdown renders identically; 8. repeat the overflow check at a mobile viewport width and confirm there is no horizontal page scroll. - [ ] **Step 6: Record final evidence and leave the verified chat open** Report: - focused/full test counts; - lint, TypeScript, build, and diff-check exit status; - commit SHAs; - deployed image tag and image digest; - pod Ready/restart state; - ingress health status; - browser assertions for streaming, semantic formatting, security boundary, copy controls, persistence, and mobile overflow. Leave the verified `https://chat.aegida.test:8443` chat tab open for the user. --- ## Final Self-Review Checklist - [ ] Every approved element in the design spec has a behavioral test or an explicit browser verification step. - [ ] No `TODO`, placeholder assertion, skipped test, snapshot-only security claim, or unbounded raw HTML path remains. - [ ] `AssistantMarkdownProps`, Streamdown imports, `UrlTransform`, and custom component types pass `tsc --noEmit` without casts that weaken the content boundary. - [ ] Test diagnostics never print complete untrusted model output, credentials, URLs with secrets, or clipboard contents. - [ ] User messages, empty placeholder, stop/retry, attachment downloads, quota presentation, and source persistence remain behaviorally unchanged. - [ ] Only `corp-ui` is rebuilt and rolled out; all backend services and data remain untouched.