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.
http:/https: links and conservative same-document anchors may navigate.target="_blank" and rel="noopener noreferrer"; invalid links remain readable without navigation..assistant-markdown; no generic global heading, paragraph, table, or code styles.streamdown@2.5.0 and @streamdown/code@1.1.1 for reproducible image builds.| 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. |
Files:
components/chat/assistant-markdown.tsxcomponents/chat/assistant-markdown.test.tsxpackage.jsonpackage-lock.jsonInterfaces:
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.
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",
);
});
});
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.
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.
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.
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"
Files:
components/chat/assistant-markdown.tsxcomponents/chat/assistant-markdown.test.tsxInterfaces:
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">

[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.
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.
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.
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.
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.
git add components/chat/assistant-markdown.tsx components/chat/assistant-markdown.test.tsx
git commit -m "fix: harden assistant markdown rendering"
Files:
components/chat/chat-message.tsxcomponents/chat/chat-message.test.tsxapp/globals.cssInterfaces:
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:
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();
});
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.
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.
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.
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.
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"
Files:
corp-ui workspaceaegida-services/ai-control-chat-ui, container appInterfaces:
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.
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.
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.
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.
In the in-app browser at https://chat.aegida.test:8443:
img element or a network request;Report:
Leave the verified https://chat.aegida.test:8443 chat tab open for the user.
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.corp-ui is rebuilt and rolled out; all backend services and data remain untouched.