cc-haha/desktop/src/components/markdown/MarkdownRenderer.tsx
程序员阿江(Relakkes) 91b4d16dc5 Restore sub-agent context in the desktop chat transcript
The desktop chat view flattened Agent tool activity, which made sub-agent work
hard to follow and separated key evidence from the main conversation. This
change threads parent tool linkage through the server bridge and desktop store,
renders dispatched sub-agents as grouped cards with nested tool activity, and
moves long final outputs into a markdown preview dialog so the main transcript
stays readable on narrow layouts.

Constraint: Existing sessions and live websocket events both needed to preserve parent-child relationships
Rejected: Add brand-new subagent websocket event types | unnecessary protocol expansion when parent linkage already existed upstream
Rejected: Inline full sub-agent markdown in the card body | too cramped for narrow desktop chat layouts
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Keep Agent card summaries compact; route long-form sub-agent output through the preview dialog unless the main chat layout is widened substantially
Tested: cd desktop && bun run test -- MessageList.test.tsx chatStore.test.ts
Tested: cd desktop && bun run lint
Tested: bun test src/server/__tests__/sessions.test.ts -t should\ reconstruct\ parent\ agent\ tool\ linkage\ from\ parentUuid\ chains
Not-tested: Full end-to-end visual verification against live CLI sessions with sub-agent text/thinking nested inline
2026-04-09 00:04:40 +08:00

131 lines
4.0 KiB
TypeScript

import { useMemo, useCallback } from 'react'
import { marked, type Tokens } from 'marked'
import { CodeViewer } from '../chat/CodeViewer'
type Props = {
content: string
}
type CodeBlock = {
id: string
code: string
language: string | undefined
}
const renderer = new marked.Renderer()
let pendingCodeBlocks: CodeBlock[] = []
renderer.code = function ({ text, lang }: Tokens.Code) {
const id = `cb-${pendingCodeBlocks.length}`
pendingCodeBlocks.push({ id, code: text, language: lang || undefined })
return `<div data-codeblock-id="${id}"></div>`
}
marked.setOptions({
breaks: true,
gfm: true,
})
marked.use({ renderer })
function parseMarkdown(content: string): { html: string; codeBlocks: CodeBlock[] } {
pendingCodeBlocks = []
const html = marked.parse(content) as string
const codeBlocks = [...pendingCodeBlocks]
pendingCodeBlocks = []
return { html, codeBlocks }
}
export function MarkdownRenderer({ content }: Props) {
const { html, codeBlocks } = useMemo(() => parseMarkdown(content), [content])
const parts = useMemo(() => {
if (codeBlocks.length === 0) {
return [{ type: 'html' as const, content: html }]
}
const result: Array<{ type: 'html'; content: string } | { type: 'code'; block: CodeBlock }> = []
let remaining = html
for (const block of codeBlocks) {
const marker = `<div data-codeblock-id="${block.id}"></div>`
const idx = remaining.indexOf(marker)
if (idx === -1) continue
const before = remaining.slice(0, idx)
if (before) {
result.push({ type: 'html', content: before })
}
result.push({ type: 'code', block })
remaining = remaining.slice(idx + marker.length)
}
if (remaining) {
result.push({ type: 'html', content: remaining })
}
return result
}, [html, codeBlocks])
const handleClick = useCallback(async (event: React.MouseEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement | null
const button = target?.closest<HTMLButtonElement>('[data-copy-code]')
if (!button) return
const text = button.getAttribute('data-copy-code')
if (!text) return
try {
await navigator.clipboard.writeText(text)
const original = button.textContent
button.textContent = 'Copied'
window.setTimeout(() => {
button.textContent = original
}, 1500)
} catch {
// Ignore clipboard errors
}
}, [])
const proseClasses = `prose prose-sm max-w-none text-[var(--color-text-primary)]
prose-headings:text-[var(--color-text-primary)] prose-headings:font-semibold
prose-p:my-2 prose-p:leading-relaxed
prose-p:break-words
prose-code:text-[13px] prose-code:font-[var(--font-mono)] prose-code:bg-[var(--color-surface-info)] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded
prose-pre:!bg-transparent prose-pre:!p-0 prose-pre:!shadow-none
prose-a:text-[var(--color-text-accent)] prose-a:no-underline hover:prose-a:underline
prose-strong:text-[var(--color-text-primary)]
prose-ul:my-2 prose-ol:my-2
prose-li:my-0.5
prose-table:w-full prose-table:table-auto prose-table:text-sm
prose-th:bg-[var(--color-surface-info)] prose-th:px-3 prose-th:py-2 prose-th:whitespace-normal prose-th:break-words prose-th:align-top
prose-td:px-3 prose-td:py-2 prose-td:border-[var(--color-border)] prose-td:whitespace-normal prose-td:break-words prose-td:align-top`
if (codeBlocks.length === 0) {
return (
<div
className={proseClasses}
dangerouslySetInnerHTML={{ __html: html }}
onClick={handleClick}
/>
)
}
return (
<div className={proseClasses} onClick={handleClick}>
{parts.map((part, i) =>
part.type === 'html' ? (
<div key={i} dangerouslySetInnerHTML={{ __html: part.content }} />
) : (
<div key={part.block.id} className="my-4">
<CodeViewer
code={part.block.code}
language={part.block.language}
/>
</div>
)
)}
</div>
)
}