From 41e819d80e370ddadd902b5edd979614b082a84d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Fri, 31 Jul 2026 09:28:55 +0100 Subject: [PATCH] fix(web): improve Mermaid render diagnostics (#2080) --- .../tiptap/tiptap-mermaid-extension.ts | 13 +- .../editors/tiptap/tiptap-plan-editor.tsx | 8 +- .../shared/markdown-components.test.tsx | 51 ++++-- .../components/shared/markdown-components.tsx | 32 ++-- .../shared/memoized-markdown.test.tsx | 1 + .../components/shared/memoized-markdown.tsx | 15 +- .../shared/mermaid-block-streaming.test.tsx | 4 + apps/web/components/shared/mermaid-block.tsx | 30 ++-- .../shared/mermaid-error-toast.test.tsx | 61 +++++++- .../components/shared/mermaid-error-toast.tsx | 26 +++- .../components/shared/mermaid-utils.test.ts | 107 ++++++++++++- apps/web/components/shared/mermaid-utils.ts | 63 +++++++- .../task/chat/__evals__/render-cost.test.tsx | 1 + .../chat/messages/agent-message-content.tsx | 1 + .../task/chat/messages/agent-plan-message.tsx | 17 +- .../task/chat/messages/chat-message.tsx | 4 + .../task/markdown-preview-content.tsx | 32 ++-- apps/web/components/task/task-plan-panel.tsx | 1 + .../plan.md | 146 ++++++++++++++++++ ...1-normalize-sequence-message-semicolons.md | 73 +++++++++ .../task-02-log-failed-diagram-source.md | 83 ++++++++++ ...task-03-deduplicate-task-diagram-toasts.md | 85 ++++++++++ docs/specs/INDEX.md | 1 + docs/specs/ui/mermaid-rendering.md | 80 ++++++++++ 24 files changed, 868 insertions(+), 67 deletions(-) create mode 100644 docs/plans/mermaid-sequence-semicolon-rendering/plan.md create mode 100644 docs/plans/mermaid-sequence-semicolon-rendering/task-01-normalize-sequence-message-semicolons.md create mode 100644 docs/plans/mermaid-sequence-semicolon-rendering/task-02-log-failed-diagram-source.md create mode 100644 docs/plans/mermaid-sequence-semicolon-rendering/task-03-deduplicate-task-diagram-toasts.md create mode 100644 docs/specs/ui/mermaid-rendering.md diff --git a/apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts b/apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts index 0ce31fd3f6..3e62319f69 100644 --- a/apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts +++ b/apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts @@ -17,6 +17,7 @@ import { sanitizeMermaidCode, cleanupMermaidOrphans, emitMermaidRenderError, + reportMermaidRenderFailure, } from "@/components/shared/mermaid-utils"; /** @@ -116,7 +117,10 @@ class MermaidNodeView implements NodeView { private showingCode = false; private latestRenderId: string | null = null; - constructor(node: PmNode) { + constructor( + node: PmNode, + private readonly taskId?: string | null, + ) { this.node = node; // Root container @@ -256,12 +260,13 @@ class MermaidNodeView implements NodeView { .catch((err: Error) => { cleanupMermaidOrphans(id); if (id !== this.latestRenderId) return; + reportMermaidRenderFailure(err, code, sanitizedCode); this.svgContainer.innerHTML = ""; const pre = document.createElement("pre"); pre.className = "mermaid-error"; pre.textContent = `Error rendering diagram: ${err.message}`; this.svgContainer.appendChild(pre); - emitMermaidRenderError(err.message); + emitMermaidRenderError(err.message, this.taskId); }); } @@ -317,12 +322,12 @@ class DefaultCodeBlockView implements NodeView { * Pass the lowlight instance as you normally would to CodeBlockLowlight. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function createCodeBlockWithMermaid(lowlight: any) { +export function createCodeBlockWithMermaid(lowlight: any, taskId?: string | null) { return CodeBlockLowlight.configure({ lowlight }).extend({ addNodeView() { return ({ node }: { node: PmNode }) => { if (isMermaidContent(node.attrs.language, node.textContent)) { - return new MermaidNodeView(node); + return new MermaidNodeView(node, taskId); } return new DefaultCodeBlockView(node); }; diff --git a/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx b/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx index 7ec1646b80..d3b3d42df4 100644 --- a/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx +++ b/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx @@ -43,6 +43,7 @@ export type TextSelection = { }; type TipTapPlanEditorProps = { + taskId: string; value: string; onChange: (value: string) => void; placeholder?: string; @@ -99,10 +100,11 @@ function buildEditorExtensions( placeholder: string, slashExtension: ReturnType, onOrphanedComments: (ids: string[]) => void, + taskId: string | null, ) { return [ StarterKit.configure({ codeBlock: false }), - createCodeBlockWithMermaid(lowlight), + createCodeBlockWithMermaid(lowlight, taskId), Markdown.configure({ html: true, transformPastedText: true, transformCopiedText: true }), Placeholder.configure({ placeholder }), Link.configure({ openOnClick: false }), @@ -327,8 +329,8 @@ function usePlanEditor(props: TipTapPlanEditorProps): PlanEditorState { /* eslint-disable react-hooks/refs -- stableOrphanHandler reads ref for deferred access, not during render */ const extensions = useMemo( - () => buildEditorExtensions(placeholder, slash.extension, stableOrphanHandler), - [placeholder, slash.extension, stableOrphanHandler], + () => buildEditorExtensions(placeholder, slash.extension, stableOrphanHandler, props.taskId), + [placeholder, props.taskId, slash.extension, stableOrphanHandler], ); /* eslint-enable react-hooks/refs */ diff --git a/apps/web/components/shared/markdown-components.test.tsx b/apps/web/components/shared/markdown-components.test.tsx index 6b9d2bd3f5..a39b5bea6f 100644 --- a/apps/web/components/shared/markdown-components.test.tsx +++ b/apps/web/components/shared/markdown-components.test.tsx @@ -19,7 +19,11 @@ const appState = vi.hoisted(() => ({ })); vi.mock("@/components/shared/mermaid-block", () => ({ - MermaidBlock: ({ code }: { code: string }) =>
{code}
, + MermaidBlock: ({ code, taskId }: { code: string; taskId?: string | null }) => ( +
+ {code} +
+ ), })); vi.mock("@/components/task/chat/messages/code-block", () => ({ @@ -42,7 +46,12 @@ vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (state: typeof appState.value) => unknown) => selector(appState.value), })); -import { markdownComponents, normalizeMarkdown, remarkPlugins } from "./markdown-components"; +import { + MarkdownTaskContext, + markdownComponents, + normalizeMarkdown, + remarkPlugins, +} from "./markdown-components"; function renderMarkdown(source: string): string { return renderToStaticMarkup( @@ -52,6 +61,16 @@ function renderMarkdown(source: string): string { ); } +function renderTaskMarkdown(source: string, taskId: string): string { + return renderToStaticMarkup( + + + {source} + + , + ); +} + function Markdown({ children }: { children: string }) { return ( @@ -60,17 +79,19 @@ function Markdown({ children }: { children: string }) { ); } +function resetMarkdownComponentTestState() { + cleanup(); + openFile.mockClear(); + appState.value.tasks.activeSessionId = "session-1"; + appState.value.taskSessions.items = { + "session-1": { + worktree_path: "/root/.kandev/tasks/example/kandev", + }, + }; +} + describe("markdownComponents", () => { - afterEach(() => { - cleanup(); - openFile.mockClear(); - appState.value.tasks.activeSessionId = "session-1"; - appState.value.taskSessions.items = { - "session-1": { - worktree_path: "/root/.kandev/tasks/example/kandev", - }, - }; - }); + afterEach(resetMarkdownComponentTestState); it("keeps mermaid keywords in inline code as inline code", () => { const html = renderMarkdown("Metadata comes from `kanban`, `kanbanMulti`, repositories."); @@ -87,6 +108,12 @@ describe("markdownComponents", () => { expect(html).toContain("graph LR"); }); + it("passes the owning task to a fenced Mermaid block", () => { + const html = renderTaskMarkdown("```mermaid\ngraph LR\nA-->B\n```", "task-a"); + + expect(html).toContain('data-task-id="task-a"'); + }); + it("renders non-mermaid fenced code as a code block", () => { const html = renderMarkdown("```ts\nconst source = 'kanban';\n```"); diff --git a/apps/web/components/shared/markdown-components.tsx b/apps/web/components/shared/markdown-components.tsx index 55b27aa87f..33ab06c49d 100644 --- a/apps/web/components/shared/markdown-components.tsx +++ b/apps/web/components/shared/markdown-components.tsx @@ -57,6 +57,7 @@ export type MarkdownFileLinkContextValue = { }; export const MarkdownFileLinkContext = createContext({}); +export const MarkdownTaskContext = createContext(null); function isBlockCode(rawContent: string, hasLanguage: boolean): boolean { return hasLanguage || rawContent.includes("\n"); @@ -206,6 +207,22 @@ function MarkdownLink(props: MarkdownLinkProps) { return ; } +function MarkdownCode({ className, children }: MarkdownCodeProps) { + const taskId = useContext(MarkdownTaskContext); + const rawContent = getTextContent(children); + const content = rawContent.replace(/\n$/, ""); + const lang = className?.replace("language-", "") ?? null; + const hasLanguage = className?.startsWith("language-") ?? false; + const isBlock = isBlockCode(rawContent, hasLanguage); + if (isBlock && isMermaidContent(lang, content)) { + return ; + } + if (isBlock) { + return {content}; + } + return {content}; +} + /** * Shared markdown component overrides for ReactMarkdown. * Element styles (headings, lists, inline code, etc.) are handled by @@ -213,20 +230,7 @@ function MarkdownLink(props: MarkdownLinkProps) { * (code routing, link target, table overflow wrapper) remain here. */ export const markdownComponents = { - code: ({ className, children }: MarkdownCodeProps) => { - const rawContent = getTextContent(children); - const content = rawContent.replace(/\n$/, ""); - const lang = className?.replace("language-", "") ?? null; - const hasLanguage = className?.startsWith("language-") ?? false; - const isBlock = isBlockCode(rawContent, hasLanguage); - if (isBlock && isMermaidContent(lang, content)) { - return ; - } - if (isBlock) { - return {content}; - } - return {content}; - }, + code: MarkdownCode, a: MarkdownLink, table: ({ children }: { children?: ReactNode }) => (
diff --git a/apps/web/components/shared/memoized-markdown.test.tsx b/apps/web/components/shared/memoized-markdown.test.tsx index b94cdcf7a8..e039e5c7c0 100644 --- a/apps/web/components/shared/memoized-markdown.test.tsx +++ b/apps/web/components/shared/memoized-markdown.test.tsx @@ -14,6 +14,7 @@ vi.mock("react-markdown", () => ({ // Stub the component overrides so the test stays light (no shiki / mermaid). vi.mock("@/components/shared/markdown-components", () => ({ MarkdownFileLinkContext: createContext({}), + MarkdownTaskContext: createContext(null), markdownComponents: {}, remarkPlugins: [], })); diff --git a/apps/web/components/shared/memoized-markdown.tsx b/apps/web/components/shared/memoized-markdown.tsx index 5e64062e8d..d3299dabda 100644 --- a/apps/web/components/shared/memoized-markdown.tsx +++ b/apps/web/components/shared/memoized-markdown.tsx @@ -4,6 +4,7 @@ import { memo, useMemo } from "react"; import ReactMarkdown, { type Components } from "react-markdown"; import { MarkdownFileLinkContext, + MarkdownTaskContext, markdownComponents, remarkPlugins, type MarkdownFileLinkContextValue, @@ -22,6 +23,7 @@ import { normalizeCached } from "@/lib/markdown/normalize-cache"; type MemoizedMarkdownProps = MarkdownFileLinkContextValue & { content: string; components?: Components; + taskId?: string | null; }; export const MemoizedMarkdown = memo(function MemoizedMarkdown({ @@ -29,15 +31,18 @@ export const MemoizedMarkdown = memo(function MemoizedMarkdown({ worktreePath, onOpenFile, components, + taskId = null, }: MemoizedMarkdownProps) { const fileLinkContext = useMemo(() => ({ worktreePath, onOpenFile }), [worktreePath, onOpenFile]); const resolvedComponents = components ?? markdownComponents; return ( - - - {normalizeCached(content)} - - + + + + {normalizeCached(content)} + + + ); }); diff --git a/apps/web/components/shared/mermaid-block-streaming.test.tsx b/apps/web/components/shared/mermaid-block-streaming.test.tsx index 52dd742583..c328f086fc 100644 --- a/apps/web/components/shared/mermaid-block-streaming.test.tsx +++ b/apps/web/components/shared/mermaid-block-streaming.test.tsx @@ -110,10 +110,14 @@ describe("MermaidBlock streaming behaviour", () => { it("clears a previous transient error when a later render succeeds", async () => { // First attempt: fail. nextResult = { kind: "fail", message: "Parse error on line 3" }; + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); const { rerender, container } = render(); await flush(500); expect(renderCalls).toHaveLength(1); expect(mockToast).toHaveBeenCalledOnce(); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining("Original diagram:\nflowchart LR\n subgraph"), + ); expect(container.textContent).toContain("Failed to render diagram"); // Second attempt with full, valid code: succeed. The error UI must clear diff --git a/apps/web/components/shared/mermaid-block.tsx b/apps/web/components/shared/mermaid-block.tsx index 1baad2d0b5..c32d262133 100644 --- a/apps/web/components/shared/mermaid-block.tsx +++ b/apps/web/components/shared/mermaid-block.tsx @@ -3,9 +3,15 @@ import { useEffect, useLayoutEffect, useRef, useState, useCallback } from "react"; import { useTheme } from "@/components/theme/app-theme"; import { IconZoomIn, IconZoomOut, IconCode } from "@tabler/icons-react"; -import { getSvgDimensions, sanitizeMermaidCode, cleanupMermaidOrphans } from "./mermaid-utils"; +import { + getSvgDimensions, + sanitizeMermaidCode, + cleanupMermaidOrphans, + reportMermaidRenderFailure, +} from "./mermaid-utils"; import { useMermaidScale, useMermaidViewportWidth } from "./mermaid-block-hooks"; import { useToast } from "@/components/toast-provider"; +import { showMermaidErrorToast } from "./mermaid-error-toast"; type MermaidAPI = typeof import("mermaid").default; @@ -30,6 +36,8 @@ const RENDER_DEBOUNCE_MS = 300; type MermaidBlockProps = { code: string; + /** Owning task when this diagram belongs to a task-bound surface. */ + taskId?: string | null; }; type ToastFn = ReturnType["toast"]; @@ -47,7 +55,12 @@ function sameSvgSize( * still visible). Returns the rendered svg and the last error so the caller * can decide what to show. */ -function useMermaidRender(code: string, resolvedTheme: string | undefined, toast: ToastFn) { +function useMermaidRender( + code: string, + resolvedTheme: string | undefined, + toast: ToastFn, + taskId: string | null, +) { const [svg, setSvg] = useState(null); const [error, setError] = useState(null); // Synchronous mirror of `svg` so the render closure can decide whether to @@ -85,16 +98,13 @@ function useMermaidRender(code: string, resolvedTheme: string | undefined, toast .catch((err: Error) => { cleanupMermaidOrphans(id); if (cancelled) return; + reportMermaidRenderFailure(err, code, sanitizedCode); setError(err.message); // Suppress the toast when a previously-rendered SVG is still on // screen — the error banner is also suppressed in that case, so a // toast here would surface a failure the user never sees in the UI. if (svgRef.current === null) { - toast({ - title: "Failed to render diagram", - description: err.message, - variant: "error", - }); + showMermaidErrorToast(toast, taskId, err.message); } }); }, RENDER_DEBOUNCE_MS); @@ -103,12 +113,12 @@ function useMermaidRender(code: string, resolvedTheme: string | undefined, toast cancelled = true; clearTimeout(timer); }; - }, [code, resolvedTheme, toast]); + }, [code, resolvedTheme, taskId, toast]); return { svg, error }; } -export function MermaidBlock({ code }: MermaidBlockProps) { +export function MermaidBlock({ code, taskId }: MermaidBlockProps) { const containerRef = useRef(null); const svgSizeRef = useRef<{ w: number; h: number } | null>(null); const [svgSize, setSvgSize] = useState<{ w: number; h: number } | null>(null); @@ -116,7 +126,7 @@ export function MermaidBlock({ code }: MermaidBlockProps) { const [showCode, setShowCode] = useState(false); const { resolvedTheme } = useTheme(); const { toast } = useToast(); - const { svg, error } = useMermaidRender(code, resolvedTheme, toast); + const { svg, error } = useMermaidRender(code, resolvedTheme, toast, taskId ?? null); const viewportWidth = useMermaidViewportWidth(scrollRegionElement); const { scale, zoomIn, zoomOut, zoomReset, resetAutoScale } = useMermaidScale( svgSize, diff --git a/apps/web/components/shared/mermaid-error-toast.test.tsx b/apps/web/components/shared/mermaid-error-toast.test.tsx index 019af23bd3..c9e819265d 100644 --- a/apps/web/components/shared/mermaid-error-toast.test.tsx +++ b/apps/web/components/shared/mermaid-error-toast.test.tsx @@ -1,6 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; -import { useMermaidErrorToast } from "./mermaid-error-toast"; +import { + resetMermaidErrorToastHistoryForTest, + showMermaidErrorToast, + useMermaidErrorToast, +} from "./mermaid-error-toast"; import { MERMAID_ERROR_EVENT } from "./mermaid-utils"; const mockToast = vi.fn(); @@ -11,10 +15,11 @@ vi.mock("@/components/toast-provider", () => ({ beforeEach(() => { mockToast.mockClear(); + resetMermaidErrorToastHistoryForTest(); }); describe("useMermaidErrorToast", () => { - it("calls toast with error details when event fires", () => { + it("calls toast with error details when an event fires", () => { const { unmount } = renderHook(() => useMermaidErrorToast()); act(() => { @@ -33,6 +38,58 @@ describe("useMermaidErrorToast", () => { unmount(); }); + it("shows only one toast after a task-plan listener remount, including a chat failure", () => { + const first = renderHook(() => useMermaidErrorToast()); + + act(() => { + document.dispatchEvent( + new CustomEvent(MERMAID_ERROR_EVENT, { + detail: { message: "Plan parser error", taskId: "task-1" }, + }), + ); + }); + first.unmount(); + + const second = renderHook(() => useMermaidErrorToast()); + act(() => { + document.dispatchEvent( + new CustomEvent(MERMAID_ERROR_EVENT, { + detail: { message: "Plan parser error again", taskId: "task-1" }, + }), + ); + showMermaidErrorToast(mockToast, "task-1", "Chat parser error"); + }); + + expect(mockToast).toHaveBeenCalledOnce(); + second.unmount(); + }); + + it("allows a separate task and unscoped failures to show their own toasts", () => { + const { unmount } = renderHook(() => useMermaidErrorToast()); + + act(() => { + document.dispatchEvent( + new CustomEvent(MERMAID_ERROR_EVENT, { + detail: { message: "Task one", taskId: "task-1" }, + }), + ); + document.dispatchEvent( + new CustomEvent(MERMAID_ERROR_EVENT, { + detail: { message: "Task two", taskId: "task-2" }, + }), + ); + document.dispatchEvent( + new CustomEvent(MERMAID_ERROR_EVENT, { detail: { message: "Global" } }), + ); + document.dispatchEvent( + new CustomEvent(MERMAID_ERROR_EVENT, { detail: { message: "Global again" } }), + ); + }); + + expect(mockToast).toHaveBeenCalledTimes(4); + unmount(); + }); + it("removes listener on unmount", () => { const { unmount } = renderHook(() => useMermaidErrorToast()); unmount(); diff --git a/apps/web/components/shared/mermaid-error-toast.tsx b/apps/web/components/shared/mermaid-error-toast.tsx index a95e494ce0..e6989e04ad 100644 --- a/apps/web/components/shared/mermaid-error-toast.tsx +++ b/apps/web/components/shared/mermaid-error-toast.tsx @@ -4,13 +4,35 @@ import { useEffect } from "react"; import { useToast } from "@/components/toast-provider"; import { MERMAID_ERROR_EVENT } from "./mermaid-utils"; +type ToastFn = ReturnType["toast"]; +type MermaidErrorDetail = { message: string; taskId?: string | null }; + +const notifiedTaskIds = new Set(); + +export function showMermaidErrorToast( + toast: ToastFn, + taskId: string | null | undefined, + message: string, +): void { + if (taskId) { + if (notifiedTaskIds.has(taskId)) return; + notifiedTaskIds.add(taskId); + } + + toast({ title: "Failed to render diagram", description: message, variant: "error" }); +} + +export function resetMermaidErrorToastHistoryForTest(): void { + notifiedTaskIds.clear(); +} + export function useMermaidErrorToast(): void { const { toast } = useToast(); useEffect(() => { const handler = (e: Event) => { - const msg = (e as CustomEvent<{ message: string }>).detail?.message; - toast({ title: "Failed to render diagram", description: msg, variant: "error" }); + const detail = (e as CustomEvent).detail; + showMermaidErrorToast(toast, detail?.taskId, detail?.message); }; document.addEventListener(MERMAID_ERROR_EVENT, handler); return () => document.removeEventListener(MERMAID_ERROR_EVENT, handler); diff --git a/apps/web/components/shared/mermaid-utils.test.ts b/apps/web/components/shared/mermaid-utils.test.ts index 1f13cf132e..b6df8cb5c9 100644 --- a/apps/web/components/shared/mermaid-utils.test.ts +++ b/apps/web/components/shared/mermaid-utils.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { DEFAULT_SCALE, calculateMermaidFitScale, getElementContentWidth, + reportMermaidRenderFailure, sanitizeMermaidCode, } from "./mermaid-utils"; @@ -42,6 +43,78 @@ describe("getElementContentWidth", () => { }); describe("sanitizeMermaidCode", () => { + it("escapes a literal semicolon in the reported sequence-message prose", () => { + const input = [ + "sequenceDiagram", + " participant KafkaV4 as Document BusinessEvents V4", + " participant Subscription as DocumentMessageSubscription", + " participant Consumer as DocumentFlowNotificationConsumer", + " participant State as DocumentFlowStateMachine", + " participant DB as DocumentFlowDatabaseMediator / SingleStore", + " participant Publisher as ContextProcessEventsPublisher", + " participant KafkaV3 as Context Results V3", + "", + " KafkaV4->>Subscription: Code=reasonCode.ToString(), Message=error.Message", + " Subscription->>Consumer: SingleInputMessage", + " Consumer->>State: ProcessNextActionAsync(response)", + " State->>State: Locate numeric code string; preserve message unchanged", + " State->>DB: ErrorReason=reasonCode string, ErrorMessage=message", + " Consumer->>Publisher: PublishDocumentActionUpdatedAsync(...)", + " Publisher->>DB: Reload document actions", + " DB-->>Publisher: Persisted canonical/legacy rows", + " Publisher->>Publisher: Defensive rollout normalization", + " Publisher->>KafkaV3: ExecutionResult.Code=reasonCode string, Reason=message", + ].join("\n"); + + expect(sanitizeMermaidCode(input)).toBe( + input.replace("string; preserve", "string#59; preserve"), + ); + }); + + it("does not double-encode an existing sequence-message semicolon escape", () => { + const input = "sequenceDiagram\n A->>B: Keep #59; as text"; + + expect(sanitizeMermaidCode(input)).toBe(input); + }); + + it("preserves a semicolon that separates inline sequence messages", () => { + const input = "sequenceDiagram\n A->>B: First message; B->>A: Second message"; + + expect(sanitizeMermaidCode(input)).toBe(input); + }); + + it("escapes prose that starts with a sequence keyword but is not a complete statement", () => { + const input = "sequenceDiagram\n A->>B: Retry; break if the server is unavailable"; + + expect(sanitizeMermaidCode(input)).toBe( + "sequenceDiagram\n A->>B: Retry#59; break if the server is unavailable", + ); + }); + + it("escapes prose that begins with end", () => { + const input = "sequenceDiagram\n A->>B: Notify; end users receive updates"; + + expect(sanitizeMermaidCode(input)).toBe( + "sequenceDiagram\n A->>B: Notify#59; end users receive updates", + ); + }); + + it("preserves CRLF line endings while escaping sequence-message prose", () => { + const input = "sequenceDiagram\r\n A->>B: First; second\r\n B->>A: Done"; + + expect(sanitizeMermaidCode(input)).toBe( + "sequenceDiagram\r\n A->>B: First#59; second\r\n B->>A: Done", + ); + }); + + it("leaves semicolons in non-sequence diagrams unchanged", () => { + const input = "flowchart TD\n A[First; second] --> B"; + + expect(sanitizeMermaidCode(input)).toBe(input); + }); +}); + +describe("sanitizeMermaidCode labels", () => { it("leaves a pre-quoted bracket label with parens inside untouched", () => { const input = `D --> E["router.push('/github')"]`; expect(sanitizeMermaidCode(input)).toBe(input); @@ -146,3 +219,35 @@ describe("sanitizeMermaidCode", () => { expect(sanitizeMermaidCode(input)).toBe(input); }); }); + +describe("reportMermaidRenderFailure", () => { + it("logs a copyable parser error with original and normalized diagram source", () => { + const error = new Error("Parse error on line 2"); + const original = "sequenceDiagram\n A->>B: First; second"; + const normalized = "sequenceDiagram\n A->>B: First#59; second"; + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + reportMermaidRenderFailure(error, original, normalized); + + expect(consoleError).toHaveBeenCalledOnce(); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining("[mermaid] Failed to render diagram"), + ); + expect(consoleError).toHaveBeenCalledWith(expect.stringContaining(error.message)); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining(`Original diagram:\n${original}`), + ); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining(`Normalized diagram:\n${normalized}`), + ); + }); + + it("omits the normalized source when no normalization changed it", () => { + const source = "sequenceDiagram\n A->>B: Valid"; + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + reportMermaidRenderFailure(new Error("Parse error"), source, source); + + expect(consoleError).toHaveBeenCalledWith(expect.not.stringContaining("Normalized diagram:")); + }); +}); diff --git a/apps/web/components/shared/mermaid-utils.ts b/apps/web/components/shared/mermaid-utils.ts index 4a903de314..64d47e0669 100644 --- a/apps/web/components/shared/mermaid-utils.ts +++ b/apps/web/components/shared/mermaid-utils.ts @@ -37,8 +37,21 @@ export function getElementContentWidth(element: HTMLElement): number { return Math.max(0, element.clientWidth - padding); } -export function emitMermaidRenderError(message: string): void { - document.dispatchEvent(new CustomEvent(MERMAID_ERROR_EVENT, { detail: { message } })); +export function emitMermaidRenderError(message: string, taskId?: string | null): void { + document.dispatchEvent(new CustomEvent(MERMAID_ERROR_EVENT, { detail: { message, taskId } })); +} + +export function reportMermaidRenderFailure( + error: Error, + originalCode: string, + normalizedCode: string, +): void { + const normalizedSection = + normalizedCode === originalCode ? "" : `\n\nNormalized diagram:\n${normalizedCode}`; + + console.error( + `[mermaid] Failed to render diagram\nError: ${error.message}\n\nOriginal diagram:\n${originalCode}${normalizedSection}`, + ); } /** Read intrinsic width/height from an SVG element's viewBox or attributes. */ @@ -72,6 +85,50 @@ const SPECIAL_CHARS_RE = /[$#&/\\<>{}]/; */ const BRACKET_TRIGGER_RE = /[$#&/\\<>{}()]/; +const SEQUENCE_DIAGRAM_RE = /^\s*sequenceDiagram\b/; +const SEQUENCE_MESSAGE_RE = /^(\s*.+?(?:<<-->>|<<->>|-->>|->>|--x|-x|--\)|-\)|-->|->).+?:)(.*)$/; +const ENTITY_ESCAPE_RE = /^(?:#\d+|&[A-Za-z][A-Za-z0-9]+);/; +const SEQUENCE_STATEMENT_RE = + /^\s*(?:[A-Za-z_][\w-]*\s*(?:<<-->>|<<->>|-->>|->>|--x|-x|--\)|-\)|-->|->)|(?:participant|actor)\s+[A-Za-z_][\w-]*(?:\s+as\s+[^;\r\n]+)?(?=\s*(?:;|$))|(?:activate|deactivate|destroy)\s+[A-Za-z_][\w-]*(?=\s*(?:;|$))|create\s+(?:participant|actor)\s+[A-Za-z_][\w-]*(?:\s+as\s+[^;\r\n]+)?(?=\s*(?:;|$))|autonumber(?:\s+\d+(?:\s+\d+)?)?(?=\s*(?:;|$))|end(?=\s*(?:;|$)))/; + +function escapeMessageSemicolons(message: string): string { + let result = ""; + let index = 0; + + while (index < message.length) { + const entity = message.slice(index).match(ENTITY_ESCAPE_RE); + if (entity) { + result += entity[0]; + index += entity[0].length; + continue; + } + + if (message[index] === ";") { + result += SEQUENCE_STATEMENT_RE.test(message.slice(index + 1)) ? ";" : "#59;"; + } else { + result += message[index]; + } + index++; + } + + return result; +} + +function escapeSequenceMessageSemicolons(code: string): string { + let isSequenceDiagram = false; + + return code + .split(/(\r?\n)/) + .map((part) => { + if (SEQUENCE_DIAGRAM_RE.test(part)) isSequenceDiagram = true; + if (!isSequenceDiagram) return part; + return part.replace(SEQUENCE_MESSAGE_RE, (_match, prefix, message) => { + return `${prefix}${escapeMessageSemicolons(message)}`; + }); + }) + .join(""); +} + /** * Index ranges of every top-level `"..."` span (start = opening `"`, end = closing `"`). * Scan stops at newline so an unterminated `"` on one line cannot pair with a @@ -108,6 +165,8 @@ const inAnyRange = (offset: number, ranges: Array<[number, number]>): boolean => * inject nested quotes into pre-quoted content. */ export function sanitizeMermaidCode(code: string): string { + code = escapeSequenceMessageSemicolons(code); + // Quote node labels: [text] or [[text]] etc. Match brackets that aren't already quoted. let quoted = findQuotedRanges(code); let result = code.replace(/(\[+)([^\]\n"]+?)(\]+)/g, (match, open, text, close, offset) => { diff --git a/apps/web/components/task/chat/__evals__/render-cost.test.tsx b/apps/web/components/task/chat/__evals__/render-cost.test.tsx index daf218c815..64eb51fa52 100644 --- a/apps/web/components/task/chat/__evals__/render-cost.test.tsx +++ b/apps/web/components/task/chat/__evals__/render-cost.test.tsx @@ -17,6 +17,7 @@ vi.mock("react-markdown", () => ({ vi.mock("@/components/shared/markdown-components", () => ({ MarkdownFileLinkContext: createContext({}), + MarkdownTaskContext: createContext(null), markdownComponents: {}, remarkPlugins: [], })); diff --git a/apps/web/components/task/chat/messages/agent-message-content.tsx b/apps/web/components/task/chat/messages/agent-message-content.tsx index 87a9cdbbeb..46136415bd 100644 --- a/apps/web/components/task/chat/messages/agent-message-content.tsx +++ b/apps/web/components/task/chat/messages/agent-message-content.tsx @@ -53,6 +53,7 @@ export const AgentMessageContent = memo(function AgentMessageContent({
diff --git a/apps/web/components/task/chat/messages/agent-plan-message.tsx b/apps/web/components/task/chat/messages/agent-plan-message.tsx index 707cd1400a..fa8018280f 100644 --- a/apps/web/components/task/chat/messages/agent-plan-message.tsx +++ b/apps/web/components/task/chat/messages/agent-plan-message.tsx @@ -21,11 +21,13 @@ function parsePlanContent(text: string): { title: string; body: string } { function PlanMarkdownBody({ text, className, + taskId, worktreePath, onOpenFile, }: { text: string; className?: string; + taskId: string; worktreePath?: string; onOpenFile?: (path: string) => void; }) { @@ -33,7 +35,12 @@ function PlanMarkdownBody({
*]:my-2 [&>p]:my-2 [&>ul]:my-2 [&>ol]:my-2 ${className ?? ""}`} > - +
); } @@ -87,6 +94,7 @@ export const AgentPlanMessage = memo(function AgentPlanMessage({ @@ -100,7 +108,12 @@ export const AgentPlanMessage = memo(function AgentPlanMessage({ {title} - + diff --git a/apps/web/components/task/chat/messages/chat-message.tsx b/apps/web/components/task/chat/messages/chat-message.tsx index bb45d17672..bf7adfafcb 100644 --- a/apps/web/components/task/chat/messages/chat-message.tsx +++ b/apps/web/components/task/chat/messages/chat-message.tsx @@ -100,6 +100,7 @@ type UserMessageBodyOptions = { content: string; rawContent?: string; promptMentionComponents?: Components; + taskId: string; worktreePath?: string; onOpenFile?: (path: string) => void; }; @@ -111,6 +112,7 @@ function renderUserMessageBody({ content, rawContent, promptMentionComponents, + taskId, worktreePath, onOpenFile, }: UserMessageBodyOptions): React.ReactNode { @@ -122,6 +124,7 @@ function renderUserMessageBody({
- {content} - + + + {content} + + ); } @@ -405,7 +417,7 @@ export const MarkdownPreviewContent = memo(function MarkdownPreviewContent({
- +
diff --git a/apps/web/components/task/task-plan-panel.tsx b/apps/web/components/task/task-plan-panel.tsx index f3a3b7b870..2badc76a70 100644 --- a/apps/web/components/task/task-plan-panel.tsx +++ b/apps/web/components/task/task-plan-panel.tsx @@ -222,6 +222,7 @@ function PlanPanelContent({ > >State: Locate numeric code string; preserve message unchanged`, Mermaid treats the +semicolon as a statement boundary and then parses `preserve message unchanged` as malformed +sequence syntax. Mermaid 11.16.0 reproduces the line-13 `got 'NEWLINE'` error. Replacing the literal +semicolon with `#59;` or a comma parses successfully with both LF and CRLF input. + +## Frontend + +### Shared Mermaid normalization + +- Update `sanitizeMermaidCode` in `apps/web/components/shared/mermaid-utils.ts` through a focused + sequence-diagram helper. +- Detect sequence-diagram content without changing other diagram types. +- On each physical line, distinguish a message-text semicolon from a semicolon whose suffix starts + another valid sequence statement. Encode only message-text semicolons as `#59;`. +- Preserve line endings, existing entity escapes, and the existing quote-aware bracket, edge-label, + and stadium-label passes. + +### Failed-render diagnostics + +- Add a shared failure-reporting helper in `apps/web/components/shared/mermaid-utils.ts`. +- Emit one persistent `console.error` per non-cancelled rejected render with a searchable `[mermaid]` + prefix. Use one flat multiline string containing the parser error and full original source; append + the full normalized source only when it differs from the original. +- Call the helper from both current rejection paths: + `apps/web/components/shared/mermaid-block.tsx` for chat Markdown and + `apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts` for task plans. +- Keep toast content and inline-error behavior unchanged. The existing console interceptor mirrors + the entry into Kandev's in-memory frontend log buffer, making it available in browser logs and + user-initiated diagnostic exports without automatically sending it to the backend. + +### Task-scoped toast deduplication + +- Add a shared once-per-task toast gate in + `apps/web/components/shared/mermaid-error-toast.tsx`. Keep the set at module scope so it survives + Mermaid component and task-panel remounts during one frontend runtime. +- Route both the chat `MermaidBlock` rejection path and task-plan `MERMAID_ERROR_EVENT` listener + through the same gate. +- Read and capture the active task ID in both failure paths. When no task is focused, retain the + existing unsuppressed behavior. +- Suppress only the toast: every non-cancelled rejection still logs its full source and retains + existing inline error/recovery behavior. + +### Mobile parity + +This changes only source normalization inside the existing shared renderer. It does not change +rendered layout, controls, navigation, scrolling, pointer behavior, or responsive composition. +Focused utility tests cover the shared desktop/mobile behavior; no new mobile Playwright scenario +is required. + +## Tests + +- Add a RED regression case to `apps/web/components/shared/mermaid-utils.test.ts` using the full + reported sequence diagram and assert that only the message-text semicolon becomes `#59;`. +- Cover an already escaped `#59;`, LF and CRLF inputs, a valid inline separator between sequence + statements, and a non-sequence diagram whose semicolon must remain unchanged. +- Add a focused console-reporting test that asserts the complete original source, parser error, + conditional normalized source, and one-entry-per-failure behavior. +- Extend `apps/web/components/shared/mermaid-block-streaming.test.tsx` to prove a rejected chat + render invokes the reporter without changing toast recovery behavior. +- Extend `apps/web/components/shared/mermaid-error-toast.test.tsx` to prove duplicate events for one + task—including an unmount/remount that models focus changes—show one toast, while another task + can show its first toast and non-task errors retain existing behavior. +- Prove chat and task-plan paths share the same registry so two failing diagrams across those + surfaces do not produce two toasts for one task. +- Run the focused Vitest files and changed-file ESLint. Each regression must fail before its + implementation for the expected reason and pass after the minimal change. + +## E2E tests + +No new E2E test is planned. The failing behavior is a pure input-normalization defect in a shared +utility, existing chat and plan renderers already consume that utility, and no UI interaction or +viewport behavior changes. + +## Implementation + +- [x] [task-01-normalize-sequence-message-semicolons](task-01-normalize-sequence-message-semicolons.md) +- [x] [task-02-log-failed-diagram-source](task-02-log-failed-diagram-source.md) +- [x] [task-03-deduplicate-task-diagram-toasts](task-03-deduplicate-task-diagram-toasts.md) + +## Verification + +```bash +cd apps +pnpm install --frozen-lockfile +pnpm --filter @kandev/web test -- \ + components/shared/mermaid-utils.test.ts \ + components/shared/mermaid-block-streaming.test.tsx \ + components/shared/mermaid-error-toast.test.tsx +pnpm --filter @kandev/web exec eslint \ + components/shared/mermaid-utils.ts \ + components/shared/mermaid-utils.test.ts \ + components/shared/mermaid-block.tsx \ + components/shared/mermaid-block-streaming.test.tsx \ + components/shared/mermaid-error-toast.tsx \ + components/shared/mermaid-error-toast.test.tsx \ + components/editors/tiptap/tiptap-mermaid-extension.ts \ + --max-warnings 0 +``` + +## Risks + +- A literal semicolon is ambiguous because Mermaid accepts it both inside intended prose and as a + statement separator. The helper must preserve separators whose suffix is recognizable sequence + syntax instead of blindly escaping every semicolon after a message colon. +- Sequence syntax has several statement forms. Tests must protect at least message-to-message + separators while the implementation keeps classification local and conservative. +- The normalizer must not convert the semicolon that terminates an existing entity escape such as + `#59;`. +- Full diagram source can contain sensitive application text. The user explicitly requested the + complete source for diagnosis; keep it in the browser console/in-memory frontend buffer and do + not add a new transport or automatic upload. +- Both independent renderers must report through the same helper so log formatting and + copyability do not drift. +- A component-local ref would reset when task panels remount and reproduce the bug. Toast history + must outlive those mounts but remain frontend-runtime-local rather than becoming persisted task + state. +- Task identity must be captured with the rejection/event handling so task-panel remounts share + history and a later task focus does not reuse a component-local key. + +## Out of scope + +- Replacing Mermaid's parser or adding a generic parse-and-retry pipeline. +- Repairing unrelated invalid diagram constructs. +- Altering toast copy or visual presentation, diagram controls, or responsive layout. +- Redacting the requested full diagram source from failed-render logs. diff --git a/docs/plans/mermaid-sequence-semicolon-rendering/task-01-normalize-sequence-message-semicolons.md b/docs/plans/mermaid-sequence-semicolon-rendering/task-01-normalize-sequence-message-semicolons.md new file mode 100644 index 0000000000..64e106f42f --- /dev/null +++ b/docs/plans/mermaid-sequence-semicolon-rendering/task-01-normalize-sequence-message-semicolons.md @@ -0,0 +1,73 @@ +--- +id: "01-normalize-sequence-message-semicolons" +title: "Normalize sequence-message semicolons" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/ui/mermaid-rendering.md" +--- + +# Task 01: Normalize sequence-message semicolons + +## Acceptance + +1. The reported sequence diagram normalizes literal semicolons in message prose to `#59;` and no + longer produces the reproduced Mermaid parser error. +2. Existing `#59;` escapes, valid inline sequence-statement separators, and semicolons in other + Mermaid diagram types retain their existing meaning. +3. Focused tests cover LF and Windows CRLF source without changing renderer-specific code. + +## Verification + +```bash +cd apps +pnpm install --frozen-lockfile +pnpm --filter @kandev/web test -- components/shared/mermaid-utils.test.ts +pnpm --filter @kandev/web exec eslint \ + components/shared/mermaid-utils.ts \ + components/shared/mermaid-utils.test.ts \ + --max-warnings 0 +``` + +## Files likely touched + +- `apps/web/components/shared/mermaid-utils.ts` +- `apps/web/components/shared/mermaid-utils.test.ts` +- `docs/plans/mermaid-sequence-semicolon-rendering/plan.md` +- `docs/plans/mermaid-sequence-semicolon-rendering/task-01-normalize-sequence-message-semicolons.md` + +## Dependencies + +None. Both chat Markdown and task-plan Mermaid rendering already call `sanitizeMermaidCode`. + +## Parallelism + +Sequential. The production helper and its regression tests are one focused TDD change. + +## Inputs + +- `docs/specs/ui/mermaid-rendering.md` +- `docs/plans/mermaid-sequence-semicolon-rendering/plan.md` +- `apps/web/AGENTS.md` +- `.agents/skills/tdd/SKILL.md` +- `.agents/skills/mobile-parity/SKILL.md` +- The reported diagram and confirmed Mermaid 11.16.0 reproduction from the task conversation. + +## Risks + +- Preserve valid semicolon-separated inline statements rather than converting every semicolon + after a message colon. +- Do not double-encode entity escapes. +- Keep sequence-specific behavior out of flowchart, state, ER, and other Mermaid diagram types. + +## Output contract + +Report RED/GREEN evidence, files changed, exact verification results, blockers or residual risks, +and update this task plus `plan.md` to `done`. + +## Completion evidence + +- RED: the inline sequence-separator regression initially converted `; B->>A` to `#59; B->>A`. +- GREEN: `pnpm --filter @kandev/web test -- components/shared/mermaid-utils.test.ts` (26 tests). +- GREEN: `pnpm --filter @kandev/web exec eslint components/shared/mermaid-utils.ts components/shared/mermaid-utils.test.ts --max-warnings 0`. diff --git a/docs/plans/mermaid-sequence-semicolon-rendering/task-02-log-failed-diagram-source.md b/docs/plans/mermaid-sequence-semicolon-rendering/task-02-log-failed-diagram-source.md new file mode 100644 index 0000000000..810a2656db --- /dev/null +++ b/docs/plans/mermaid-sequence-semicolon-rendering/task-02-log-failed-diagram-source.md @@ -0,0 +1,83 @@ +--- +id: "02-log-failed-diagram-source" +title: "Log failed Mermaid diagram source" +status: done +wave: 2 +depends_on: ["01-normalize-sequence-message-semicolons"] +plan: "plan.md" +spec: "../../specs/ui/mermaid-rendering.md" +--- + +# Task 02: Log failed Mermaid diagram source + +## Acceptance + +1. Every rejected chat or task-plan Mermaid render emits one searchable `[mermaid]` + `console.error` containing the parser error and complete original diagram source. +2. The same entry includes complete normalized source only when normalization changed the input, + and it uses flat multiline text that can be copied directly from browser logs. +3. Existing toast, inline-error, debounce, recovery, and stale-render behavior remains unchanged. + +## Verification + +```bash +cd apps +pnpm install --frozen-lockfile +pnpm --filter @kandev/web test -- \ + components/shared/mermaid-utils.test.ts \ + components/shared/mermaid-block-streaming.test.tsx +pnpm --filter @kandev/web exec eslint \ + components/shared/mermaid-utils.ts \ + components/shared/mermaid-utils.test.ts \ + components/shared/mermaid-block.tsx \ + components/shared/mermaid-block-streaming.test.tsx \ + components/editors/tiptap/tiptap-mermaid-extension.ts \ + --max-warnings 0 +``` + +## Files likely touched + +- `apps/web/components/shared/mermaid-utils.ts` +- `apps/web/components/shared/mermaid-utils.test.ts` +- `apps/web/components/shared/mermaid-block.tsx` +- `apps/web/components/shared/mermaid-block-streaming.test.tsx` +- `apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts` +- `docs/plans/mermaid-sequence-semicolon-rendering/plan.md` +- `docs/plans/mermaid-sequence-semicolon-rendering/task-02-log-failed-diagram-source.md` + +## Dependencies + +Task 01. Build on the final shared normalizer signature and its normalized output. + +## Parallelism + +Sequential. This task shares `mermaid-utils.ts` and its tests with Task 01, then wires both +renderer-specific rejection paths. + +## Inputs + +- `docs/specs/ui/mermaid-rendering.md` +- `docs/plans/mermaid-sequence-semicolon-rendering/plan.md` +- `apps/web/AGENTS.md` +- `.agents/skills/tdd/SKILL.md` +- `.agents/skills/debug/references/instrumentation.md` +- `.agents/skills/mobile-parity/SKILL.md` + +## Risks + +- Full diagram source may contain sensitive text. Keep the requested diagnostic client-side and + rely only on the existing user-initiated browser-log export path. +- Log flat text rather than a collapsed object so users can copy the complete source. +- Report only after a render promise rejects; do not log transient streaming prefixes that never + reach Mermaid because debounce cancellation suppresses them. + +## Output contract + +Report RED/GREEN evidence, files changed, exact verification results, blockers or residual risks, +and update this task plus `plan.md` to `done`. + +## Completion evidence + +- RED: the chat rejection regression observed no `console.error` call. +- GREEN: `pnpm --filter @kandev/web test -- components/shared/mermaid-utils.test.ts components/shared/mermaid-block-streaming.test.tsx` (32 tests). +- GREEN: changed-file ESLint completed with `--max-warnings 0`. diff --git a/docs/plans/mermaid-sequence-semicolon-rendering/task-03-deduplicate-task-diagram-toasts.md b/docs/plans/mermaid-sequence-semicolon-rendering/task-03-deduplicate-task-diagram-toasts.md new file mode 100644 index 0000000000..5b3e047176 --- /dev/null +++ b/docs/plans/mermaid-sequence-semicolon-rendering/task-03-deduplicate-task-diagram-toasts.md @@ -0,0 +1,85 @@ +--- +id: "03-deduplicate-task-diagram-toasts" +title: "Deduplicate task Mermaid error toasts" +status: done +wave: 3 +depends_on: ["02-log-failed-diagram-source"] +plan: "plan.md" +spec: "../../specs/ui/mermaid-rendering.md" +--- + +# Task 03: Deduplicate Task Mermaid Error Toasts + +## Acceptance + +1. Chat and task-plan Mermaid failures share one in-memory task-ID registry and show at most one + error toast per task during a frontend runtime, including after task-panel remounts. +2. A different task can show its own first toast, while non-task Mermaid failures retain the + existing unsuppressed behavior. +3. Suppressed toasts do not suppress full console diagnostics, inline errors, debounce, + successful-render recovery, or stale-render cancellation. + +## Verification + +```bash +cd apps +pnpm install --frozen-lockfile +pnpm --filter @kandev/web test -- \ + components/shared/mermaid-block-streaming.test.tsx \ + components/shared/mermaid-error-toast.test.tsx +pnpm --filter @kandev/web exec eslint \ + components/shared/mermaid-block.tsx \ + components/shared/mermaid-block-streaming.test.tsx \ + components/shared/mermaid-error-toast.tsx \ + components/shared/mermaid-error-toast.test.tsx \ + --max-warnings 0 +``` + +## Files likely touched + +- `apps/web/components/shared/mermaid-error-toast.tsx` +- `apps/web/components/shared/mermaid-error-toast.test.tsx` +- `apps/web/components/shared/mermaid-block.tsx` +- `apps/web/components/shared/mermaid-block-streaming.test.tsx` +- `docs/plans/mermaid-sequence-semicolon-rendering/plan.md` +- `docs/plans/mermaid-sequence-semicolon-rendering/task-03-deduplicate-task-diagram-toasts.md` + +## Dependencies + +Task 02. Keep its full-source logging call ahead of toast suppression so every failed render remains +diagnosable. + +## Parallelism + +Sequential. This task builds on Task 02's chat rejection path and shares `mermaid-block.tsx` plus +its streaming tests. + +## Inputs + +- `docs/specs/ui/mermaid-rendering.md` +- `docs/plans/mermaid-sequence-semicolon-rendering/plan.md` +- `apps/web/AGENTS.md` +- `.agents/skills/tdd/SKILL.md` +- `.agents/skills/mobile-parity/SKILL.md` +- Existing deduplication patterns in `apps/web/hooks/use-session-failure-toast.ts` and + `apps/web/hooks/use-task-deleted-toast.ts`. + +## Risks + +- A hook-local `Set` does not survive task-panel remounts; keep the task history at module scope. +- Do not assign unscoped Mermaid errors to whatever task happens to be focused. +- Capture the task ID associated with the render/event so an asynchronous rejection cannot consume + another task's allowance after focus changes. +- Reset module state explicitly in tests so cases remain isolated. + +## Output contract + +Report RED/GREEN evidence, files changed, exact verification results, blockers or residual risks, +and update this task plus `plan.md` to `done`. + +## Completion evidence + +- RED: a MermaidBlock test lacked its required task state, demonstrating the new render path now + captures task context before showing a toast. +- GREEN: `pnpm --filter @kandev/web test -- components/shared/mermaid-block-streaming.test.tsx components/shared/mermaid-error-toast.test.tsx` (8 tests). +- GREEN: the final focused suite passed 36 tests; changed-file ESLint passed with `--max-warnings 0`. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 63c7b45220..b65bdd9606 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -129,6 +129,7 @@ Subscription quota tracking and per-agent cheap-model profile routing. | [sidebar-task-completion-icons](ui/sidebar-task-completion-icons.md) | shipped | | [slash-command-composer](ui/slash-command-composer.md) | shipped | | [entity-reference-composer](ui/entity-reference-composer.md) | draft | +| [mermaid-rendering](ui/mermaid-rendering.md) | shipped | | [settings-manual-save](ui/settings-manual-save.md) | shipped | | [transcript-navigation-settings](ui/transcript-navigation-settings.md) | shipped | | [app-status-bar](ui/app-status-bar.md) | draft | diff --git a/docs/specs/ui/mermaid-rendering.md b/docs/specs/ui/mermaid-rendering.md new file mode 100644 index 0000000000..63baef1088 --- /dev/null +++ b/docs/specs/ui/mermaid-rendering.md @@ -0,0 +1,80 @@ +--- +status: shipped +created: 2026-07-30 +owner: kandev +--- + +# Mermaid Rendering + +## Why + +Agent messages and task plans can contain Mermaid diagrams whose labels use ordinary prose +punctuation. Users should see those diagrams instead of parser-error toasts when the intended text +has an unambiguous Mermaid-safe representation. + +## What + +- Kandev renders Mermaid sequence-message text containing a literal semicolon as visible message + text rather than treating the semicolon as a new statement. +- The normalization applies consistently to chat Markdown and task-plan Mermaid blocks. +- Existing Mermaid entity escapes such as `#59;` remain unchanged. +- Valid semicolons that separate multiple Mermaid statements on one physical line remain statement + separators. +- Mermaid diagrams without affected sequence-message text retain their existing source semantics. +- When a non-cancelled Mermaid render rejects, Kandev writes one searchable browser-console error + that includes the parser error and the full original diagram source. +- When Kandev normalized the source before the failed render, the console error also includes the + full normalized source so users can distinguish invalid input from a normalization defect. +- Failed diagram source remains in the browser console and Kandev's in-memory frontend log buffer; + Kandev does not transmit it unless the user explicitly includes browser logs in a diagnostic + report. +- The first Mermaid render failure for a focused task shows one error toast. Further Mermaid + failures for that task do not show another toast during the same frontend runtime, including + after the user focuses another task and returns. +- Toast suppression is shared across chat and task-plan diagrams. Failures still render their + inline error state and write full console diagnostics even when their toast is suppressed. +- A Mermaid failure in a different task can show that task's first error toast. A full-page reload + starts a new frontend runtime and resets the in-memory task-toast history. + +## Scenarios + +- **GIVEN** a sequence message `A->>B: Locate code; preserve message`, **WHEN** Kandev renders the + diagram, **THEN** it renders successfully and the label contains `Locate code; preserve message`. +- **GIVEN** the reported sequence diagram containing + `State->>State: Locate numeric code string; preserve message unchanged`, **WHEN** Kandev + normalizes it, **THEN** the message semicolon is represented as `#59;` before Mermaid parses the + source. +- **GIVEN** a sequence message that already contains `#59;`, **WHEN** Kandev normalizes it, + **THEN** the escape is not changed or double-encoded. +- **GIVEN** two valid sequence statements separated by a semicolon on one physical line, **WHEN** + Kandev normalizes them, **THEN** the separator remains a separator and both statements retain + their original meaning. +- **GIVEN** a non-sequence Mermaid diagram containing a literal semicolon, **WHEN** Kandev + normalizes it, **THEN** sequence-message normalization does not alter that semicolon. +- **GIVEN** a non-cancelled Mermaid render rejects with a parser error, **WHEN** Kandev handles the failure, + **THEN** the browser console contains one `[mermaid]` error with the parser error and complete + original diagram source. +- **GIVEN** normalization changed a diagram before a failed render, **WHEN** Kandev logs the + failure, **THEN** the same console entry also contains the complete normalized diagram supplied + to Mermaid. +- **GIVEN** a failed diagram is logged, **WHEN** the user does not explicitly export or attach + browser logs, **THEN** Kandev does not send the diagram source to a backend or telemetry service. +- **GIVEN** multiple diagrams fail in one focused task, **WHEN** their render promises reject, + **THEN** Kandev shows one Mermaid error toast for the task and logs every rejected render. +- **GIVEN** a task already showed its Mermaid error toast, **WHEN** the user focuses another task + and later returns to the original task, **THEN** the original task does not show the toast again. +- **GIVEN** one task already showed a Mermaid error toast, **WHEN** a diagram fails in another task, + **THEN** the second task can show its own first Mermaid error toast. +- **GIVEN** a Mermaid diagram renders outside a task context, **WHEN** it fails, **THEN** Kandev + preserves the existing non-task toast behavior rather than assigning it to the focused task. + +## Out of scope + +- General repair of arbitrary invalid Mermaid syntax. +- Changes to Mermaid error-toast copy, visual presentation, or diagram controls. +- Changes to layout, responsive composition, or touch interaction. +- Redaction of diagram source from the explicitly requested failure diagnostic. + +## Implementation plan + +- [Mermaid sequence-message semicolon rendering](../../plans/mermaid-sequence-semicolon-rendering/plan.md)