aegida-console / docs / superpowers / plans / 2026-08-10-chatgpt-like-assistant-markdown.md
2026-08-10-chatgpt-like-assistant-markdown.md
Raw

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:

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:

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:

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(
      <AssistantMarkdown
        content={`## Итог

Обычный **важный** и *курсивный* текст с ~~удалением~~ и \`inline()\`.

- первый пункт
- [x] выполнено

> Проверяем цитату.

| Модель | Статус |
| --- | --- |
| 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(
      <AssistantMarkdown content="Ответ с **важным" isStreaming />,
    );

    expect(screen.getByText(/Ответ с/)).toBeVisible();

    rerender(
      <AssistantMarkdown content="Ответ с **важным** выводом." isStreaming={false} />,
    );

    expect(screen.getByText("важным")).toBeVisible();
    expect(screen.getByText("важным").closest("span")).toHaveAttribute(
      "data-streamdown",
      "strong",
    );
  });
});
  • Step 3: Run the focused tests and verify RED

Run:

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:

"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 (
    <Streamdown
      className="assistant-markdown min-w-0"
      controls={{
        code: { copy: true, download: false },
        mermaid: false,
        table: false,
      }}
      isAnimating={isStreaming}
      lineNumbers={false}
      mode={isStreaming ? "streaming" : "static"}
      parseIncompleteMarkdown
      plugins={{ code: codePlugin }}
      translations={{
        copied: "Скопировано",
        copyCode: "Копировать код",
      }}
    >
      {content}
    </Streamdown>
  );
}

Do not add animated, Mermaid, math, CJK, custom renderers, or raw HTML plugins.

  • Step 5: Run the semantic tests and verify GREEN

Run:

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
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:

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:

it("renders untrusted model output without active HTML, images, or unsafe navigation", () => {
  const { container } = render(
    <AssistantMarkdown
      content={`<script>window.__owned = true</script>
<img src="https://tracker.invalid/pixel.png" onerror="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:

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(
    <AssistantMarkdown
      content={'```typescript\nconst answer = 42;\n```'}
      isStreaming={false}
    />,
  );

  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:

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:

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 (
      <a
        {...anchorProps}
        href={href}
        rel={sameDocument ? undefined : "noopener noreferrer"}
        target={sameDocument ? undefined : "_blank"}
      />
    );
  },
  img: () => null,
};

Pass the boundary to Streamdown:

<Streamdown
  components={components}
  controls={{
    code: { copy: true, download: false },
    mermaid: false,
    table: false,
  }}
  disallowedElements={["img"]}
  isAnimating={isStreaming}
  linkSafety={{ enabled: false }}
  lineNumbers={false}
  mode={isStreaming ? "streaming" : "static"}
  parseIncompleteMarkdown
  plugins={{ code: codePlugin }}
  skipHtml
  translations={{
    copied: "Скопировано",
    copyCode: "Копировать код",
  }}
  urlTransform={safeMarkdownUrl}
>
  {content}
</Streamdown>

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:

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
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:

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(
    <ChatMessage
      canRetry={false}
      message={{
        id: "assistant-markdown",
        role: "assistant",
        content: source,
        createdAt: "2026-08-10T10:00:00.000Z",
        status: "complete",
      }}
      onRetry={vi.fn()}
    />,
  );

  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(
    <ChatMessage
      canRetry={false}
      message={{
        id: "user-markdown",
        role: "user",
        content: "# Заголовок\n\n**текст**",
        createdAt: "2026-08-10T10:00:00.000Z",
        status: "complete",
      }}
      onRetry={vi.fn()}
    />,
  );

  expect(screen.getByText("# Заголовок\n\n**текст**")).toBeVisible();
  expect(container.querySelector("h1, strong, [data-streamdown]")).toBeNull();
});
  • Step 2: Run chat-message tests and verify RED

Run:

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:

import { AssistantMarkdown } from "@/components/chat/assistant-markdown";

{message.status === "streaming" && !message.content ? (
  <span
    aria-label="Модель формирует ответ"
    className="inline-flex items-center gap-1 py-2"
    role="status"
  >
    {[0, 1, 2].map((index) => (
      <span
        aria-hidden="true"
        className="size-1.5 animate-bounce rounded-full bg-muted-foreground motion-reduce:animate-none"
        data-testid="response-placeholder-dot"
        key={index}
        style={{ animationDelay: `${index * 120}ms` }}
      />
    ))}
  </span>
) : (
  <AssistantMarkdown
    content={message.content}
    isStreaming={message.status === "streaming"}
  />
)}

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:

@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:

.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:

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:

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

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:

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:

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:

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:

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.