diff --git a/src/client/components/browser.tsx b/src/client/components/browser.tsx index e9c45df..b2f55c0 100644 --- a/src/client/components/browser.tsx +++ b/src/client/components/browser.tsx @@ -26,6 +26,8 @@ import { React } from '../react' import type { ConversationNodeLike, UseSessionLike } from '../services' import type { ViewKit } from '../viewkit' import { makeNodeText } from './nodes' +import { makeRichText } from './richText' +import type { RichKit, RichMode } from './richText' import type { StackedBarProps } from './stackedBar' export interface ContextBrowserProps { @@ -181,11 +183,14 @@ function ParamRow(props: { * The full body of one expanded tool row: description, a parsed parameter * table (when the schema carries one), and the raw JSON behind a toggle. * Owns its own open/closed state for the JSON so two expanded tools stay - * independent. + * independent. The description card carries the shared raw/markdown view + * toggle in its head (per-card state via `rich.useRichMode`). */ function ToolSchema(props: { description: string | undefined schema: unknown + /** Rich-text toggle kit (description card: raw
vs markdown). */
+ rich: RichKit
/** Localized labels for the card titles, empty-state line, and toggle. */
labels: {
desc: string
@@ -195,7 +200,9 @@ function ToolSchema(props: {
hide: string
}
}): ReactNS.ReactElement {
+ const { rich } = props
const [jsonOpen, setJsonOpen] = React.useState(false)
+ const [descMode, setDescMode] = rich.useRichMode()
const params = React.useMemo(() => paramsOf(props.schema), [props.schema])
// Pull `properties` + `required` out of the parameter object as plain
// values (avoids re-deriving the same shape in every row).
@@ -225,8 +232,14 @@ function ToolSchema(props: {
{props.labels.desc}
+
- {props.description}
+
) : null}
{params !== null && rows.length > 0 ? (
@@ -255,17 +268,23 @@ function ToolSchema(props: {
}
/** One raw content block (text/reasoning/tool-result/…), rendered defensively. */
-function RawBlocks(props: { blocks: readonly unknown[] }): ReactNS.ReactElement {
+function RawBlocks(props: { blocks: readonly unknown[]; mode: RichMode; rich: RichKit }): ReactNS.ReactElement {
+ const { rich } = props
return (
<>
{props.blocks.map((b, i) => {
const blk = b as { type?: string; text?: unknown; content?: unknown }
if (blk !== null && typeof blk === 'object'
&& (blk.type === 'text' || blk.type === 'reasoning') && typeof blk.text === 'string') {
- return {blk.text}
+ // Reasoning stays raw in either mode (plain model trace, not
+ // prose); text blocks follow the card's view mode.
+ if (blk.type === 'reasoning') {
+ return {blk.text}
+ }
+ return
}
if (blk !== null && typeof blk === 'object' && blk.type === 'tool-result' && Array.isArray(blk.content)) {
- return
+ return
}
return {JSON.stringify(b, null, 2)}
})}
@@ -274,23 +293,36 @@ function RawBlocks(props: { blocks: readonly unknown[] }): ReactNS.ReactElement
}
/** The actual content of one surface element, joined from the conversation snapshot. */
-function NodeContent(props: { node: SurfaceNode; conv: ConversationNodeLike | undefined; hint: string }): ReactNS.ReactElement {
- const { node, conv } = props
+function NodeContent(props: { node: SurfaceNode; conv: ConversationNodeLike | undefined; hint: string; rich: RichKit }): ReactNS.ReactElement {
+ const { node, conv, rich } = props
+ // The raw/markdown toggle serves the prose categories (user / assistant /
+ // injection bodies); tool results stay raw (structured payload, not prose).
+ const richable = node.cat === 'user' || node.cat === 'assistant' || node.cat === 'inject'
+ const wrap = (render: (mode: RichMode) => ReactNS.ReactNode): ReactNS.ReactElement =>
+ richable
+ ?
+ : {render('raw')}
+
if (conv === undefined) {
- return (
-
- {node.text !== undefined && node.text !== '' ? {node.text} : null}
+ if (node.text === undefined || node.text === '') {
+ return {props.hint}
+ }
+ return wrap(mode => (
+ <>
+
{props.hint}
-
- )
+ >
+ ))
}
if (conv.kind === 'assistant' && Array.isArray(conv.blocks)) {
- return (
-
- {conv.blocks.map((b, i) => {
+ // Captured once: the render-prop closure below cannot narrow the prop.
+ const blocks = conv.blocks
+ return wrap(mode => (
+ <>
+ {blocks.map((b, i) => {
const blk = b as { kind?: string; text?: unknown; name?: unknown; argsRaw?: unknown }
if (blk.kind === 'text' && typeof blk.text === 'string') {
- return {blk.text}
+ return
}
if (blk.kind === 'reasoning' && typeof blk.text === 'string') {
return {blk.text}
@@ -307,8 +339,8 @@ function NodeContent(props: { node: SurfaceNode; conv: ConversationNodeLike | un
}
return null
})}
-
- )
+ >
+ ))
}
if (conv.kind === 'tool-result') {
return (
@@ -321,21 +353,21 @@ function NodeContent(props: { node: SurfaceNode; conv: ConversationNodeLike | un
)
: null}
- {Array.isArray(conv.content) ? : null}
+ {Array.isArray(conv.content) ? : null}
)
}
if (conv.kind === 'compaction') {
- return (
-
+ return wrap(mode => (
+ <>
{typeof conv.summary === 'string' && conv.summary !== ''
- ? {conv.summary}
+ ?
: null}
-
- )
+ >
+ ))
}
if (Array.isArray(conv.content)) {
- return
+ return wrap(mode => )
}
return {props.hint}
}
@@ -366,6 +398,7 @@ export function makeContextBrowser(
): (props: ContextBrowserProps) => ReactNS.ReactElement {
const { t, fmt, fmtTime, catLabel } = kit
const nodeText = makeNodeText(kit)
+ const rich = makeRichText(kit)
const catColor: Record = {}
for (const c of CATS) catColor[c.key] = c.color
@@ -535,7 +568,7 @@ export function makeContextBrowser(
const system = view.header.system
if (system === undefined) return null
return elemRow('sys', null, system.replace(/\s+/g, ' ').trim().slice(0, 80), breakdown.system, undefined,
- {system})
+ } />)
}
if (c === 'tools') {
if (view.header === null) return {t(headers === null ? 'browser.noHeader' : 'browser.noEpoch')}
@@ -554,7 +587,7 @@ export function makeContextBrowser(
return view.header.tools.slice().sort((a, b) => b.tokens - a.tokens).map((tool: HeaderTool) => {
return elemRow('tool:' + tool.name, null, tool.name, tool.tokens, undefined,
-
+
)
})
}
@@ -580,6 +613,7 @@ export function makeContextBrowser(
. The mode crosses component boundaries explicitly through render
+ * props/props (no React context), and each card keeps its own state so two
+ * open details switch independently.
+ */
+
+import type * as ReactNS from 'react'
+import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
+import { React } from '../react'
+import type { ViewKit } from '../viewkit'
+
+/** Detail-body view mode: raw source text or rendered markdown. */
+export type RichMode = 'raw' | 'md'
+
+export interface RichKit {
+ /** One text block in the given mode: raw or rendered markdown. */
+ RichText: (props: {
+ text: string
+ mode: RichMode
+ /** Chrome of the raw block (defaults to the browser's `lc-br-pre`). */
+ rawClass?: string
+ /** Chrome of the markdown box (defaults to `lc-br-md`). */
+ mdClass?: string
+ }) => ReactNS.ReactElement
+ /**
+ * Detail card wrapper: renders the mode switch as a small segmented pill
+ * on the card's top-right edge and hands the current mode to its `render`
+ * prop (an explicit prop, not function-children, so the mode hand-off is
+ * visible in the props object).
+ */
+ RichCard: (props: {
+ className?: string
+ render: (mode: RichMode) => ReactNS.ReactNode
+ }) => ReactNS.ReactElement
+ /** The segmented switch alone, for cards that place it in their own head row. */
+ RichSwitch: (props: { mode: RichMode; onPick: (mode: RichMode) => void; inHead?: boolean }) => ReactNS.ReactElement
+ /** Per-card mode state for bodies that place the switch themselves. */
+ useRichMode: () => [RichMode, (mode: RichMode) => void]
+}
+
+export function makeRichText(kit: ViewKit): RichKit {
+ const { t } = kit
+
+ function useRichMode(): [RichMode, (mode: RichMode) => void] {
+ // Markdown is the default view: the detail cards hold prose (prompts,
+ // descriptions, messages), which reads better rendered; raw stays one
+ // click away for exact source inspection.
+ const [mode, setMode] = React.useState('md')
+ return [mode, setMode]
+ }
+
+ // Two-option pill mirroring the trend chart's 步骤/轮次 control: one
+ // segment per view, the active segment highlighted; each segment's
+ // tooltip spells its view out (localized).
+ function RichSwitch(props: { mode: RichMode; onPick: (mode: RichMode) => void; inHead?: boolean }): ReactNS.ReactElement {
+ const seg = (m: RichMode, label: string, tip: string) => (
+
+ )
+ return (
+
+ {seg('raw', t('rich.raw'), t('rich.toRaw'))}
+ {seg('md', t('rich.md'), t('rich.toMd'))}
+
+ )
+ }
+
+ function RichText(props: {
+ text: string
+ mode: RichMode
+ rawClass?: string
+ mdClass?: string
+ }): ReactNS.ReactElement {
+ if (props.mode === 'md') {
+ return
+ }
+ return {props.text}
+ }
+
+ function RichCard(props: {
+ className?: string
+ render: (mode: RichMode) => ReactNS.ReactNode
+ }): ReactNS.ReactElement {
+ const [mode, setMode] = useRichMode()
+ return (
+
+
+ {props.render(mode)}
+
+ )
+ }
+
+ return { RichText, RichCard, RichSwitch, useRichMode }
+}
diff --git a/src/client/i18n.ts b/src/client/i18n.ts
index 933f24d..39bb945 100644
--- a/src/client/i18n.ts
+++ b/src/client/i18n.ts
@@ -88,6 +88,12 @@ export const DICT_ZH: Record = {
'tool.paramsEmpty': '(无参数)',
'tool.jsonToggle': '查看原始 JSON',
'tool.jsonHide': '收起',
+ // Detail-card view switch (system prompt / tool description / message and
+ // injection bodies): a segmented pill — segment labels + tooltips.
+ 'rich.raw': '原文',
+ 'rich.md': 'Markdown',
+ 'rich.toMd': '按 Markdown 渲染查看',
+ 'rich.toRaw': '查看原始文本',
}
export const DICT_EN: Record = {
@@ -173,4 +179,10 @@ export const DICT_EN: Record = {
'tool.paramsEmpty': '(no parameters)',
'tool.jsonToggle': 'View Raw JSON',
'tool.jsonHide': 'Collapse',
+ // Detail-card view switch (system prompt / tool description / message and
+ // injection bodies): a segmented pill — segment labels + tooltips.
+ 'rich.raw': 'Raw',
+ 'rich.md': 'Markdown',
+ 'rich.toMd': 'View as Markdown',
+ 'rich.toRaw': 'View Raw Text',
}
diff --git a/src/client/styles.ts b/src/client/styles.ts
index 242f13b..7975225 100644
--- a/src/client/styles.ts
+++ b/src/client/styles.ts
@@ -293,4 +293,35 @@ export const STYLES = [
'.lc-ts-json { display: flex; flex-direction: column; gap: 4px; }',
'.lc-ts-json-toggle { align-self: flex-start; border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; font-size: 11px; padding: 0; cursor: pointer; }',
'.lc-ts-json-toggle:hover { color: var(--dsw-alias-label-primary); text-decoration: underline; }',
+ // ---- Raw/markdown view switch (detail cards: system prompt, tool
+ // description, user/assistant message + injection bodies). A segmented
+ // pill mirroring the trend chart's 步骤/轮次 control (`lc-gran`), riding
+ // the card's top-right corner as a self-end item of the content column
+ // (no absolute overlay, so it never covers text); the `in-head` variant
+ // sits at the right end of the tool description card's title row. The
+ // markdown box reuses the raw chrome minus the pre whitespace. ----
+ '.lc-rich-seg { align-self: flex-end; display: flex; gap: 2px; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; padding: 1px; }',
+ '.lc-rich-seg-btn { border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 1; padding: 3px 8px; border-radius: 5px; cursor: pointer; font-family: inherit; }',
+ '.lc-rich-seg-btn:hover { color: var(--dsw-alias-label-primary); }',
+ '.lc-rich-seg-on, .lc-rich-seg-on:hover { background: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-foreground); }',
+ '.lc-rich-seg-head { align-self: auto; margin-left: auto; }',
+ '.lc-br-md { padding: 4px 10px; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; color: var(--dsw-alias-label-primary); font-size: 12px; line-height: 1.55; word-break: break-word; max-height: 320px; overflow-y: auto; scrollbar-width: thin; }',
+ '.lc-ts-desc-md { padding: 4px 10px; color: var(--dsw-alias-label-primary); font-size: 12px; line-height: 1.55; word-break: break-word; max-height: 160px; overflow-y: auto; scrollbar-width: thin; }',
+ // The shared markdown renderer sizes its root/headings for chat bubbles;
+ // inside a detail card the base size sits one step ABOVE the raw text
+ // (13px vs 12px — a proportional face at 13px matches the visual weight
+ // of the raw view's 12px monospace) and the heading scale collapses to a
+ // weight-only bump (specificity beats the renderer's one-class rules).
+ // Inline code keeps the FULL size too — the renderer shrinks it to
+ // 0.875em (!important) and table code to 13px, both visibly off. Its
+ // `font` shorthand also drags in a ~22px line-height, TALLER than the
+ // 13px/1.55 paragraph line (~20px), so a code chip wrapping onto the
+ // next line sticks to the neighbor line's chip; pin the chip just under
+ // the paragraph line (1.3em + 1px padding ≈ 19px) to reopen the gap.
+ '.lc-br-md > div, .lc-ts-desc-md > div { font-size: 13px; line-height: 1.55; }',
+ '.lc-br-md div :is(h1, h2, h3, h4, h5, h6), .lc-ts-desc-md div :is(h1, h2, h3, h4, h5, h6) { font-size: 13px; font-weight: 600; margin: 10px 0 4px; }',
+ '.lc-br-md div p, .lc-ts-desc-md div p { margin: 6px 0; }',
+ '.lc-br-md div pre, .lc-ts-desc-md div pre { margin: 6px 0; }',
+ '.lc-br-md div :not(pre) > code, .lc-ts-desc-md div :not(pre) > code { font-size: 1em !important; line-height: 1.3; padding: 1px 5px; box-decoration-break: clone; }',
+ '.lc-br-md div table code, .lc-ts-desc-md div table code { font-size: inherit; }',
].join('\n')
diff --git a/tests/client.test.mjs b/tests/client.test.mjs
index cc03da3..c22d90a 100644
--- a/tests/client.test.mjs
+++ b/tests/client.test.mjs
@@ -47,10 +47,13 @@ globalThis.document = fakeDoc
// The module table answers the platform seeds this bundle uses ('react' and
// the shared primitives singleton); everything else rides ctx or is inlined.
-// Icons are stubbed as inert elements (glyph rendering is never asserted).
+// Icons are stubbed as inert elements (glyph rendering is never asserted);
+// MarkdownText resolves to a plain div carrying its source text, so the
+// raw/markdown toggle assertions can find it by class and text.
const fakePrimitives = {
IconPlusOutline16: (p) => ({ kind: 'icon', name: 'IconPlusOutline16', props: p }),
IconBranchOutline16: (p) => ({ kind: 'icon', name: 'IconBranchOutline16', props: p }),
+ MarkdownText: (p) => ({ kind: 'element', args: ['div', { className: 'lc-md-stub' }, p.text] }),
}
const require = (spec) => {
assert.ok(spec === 'react' || spec === '@deepseek-ai/dsh-client-ui-primitives',
@@ -230,7 +233,7 @@ const m2 = { exports: {} }
const pluginExports2 = factory(requireStateful, m2, globalThis.window, fakeDoc)
const DICT_FOR_TEST = { 'tab': 'Context', 'loading': '…', 'error': 'x', 'detail.step': 'Turn {t} · Step {s}', 'gran.step': 'Step', 'gran.turn': 'Turn', 'detail.turn': 'Turn {t} · {n} steps', 'detail.lastStep': 'last step', 'overview.used': 'of context used', 'overview.free': 'Free window', 'events.at': 'Turn {t} · Step {s}', 'events.range': 'Turn {t} · Step {a}→{b}', 'events.rangeTo': 'Turn {a} · Step {as} → Turn {b} · Step {bs}', 'stats.recycleSub': '{c} compactions · {p} prunes', 'tip.step': 'Turn {t} · Step {s}', 'tip.turn': 'Turn {t} · {n} steps', 'tip.total': 'total ≈ {n}', 'tip.actual': ' (actual {n})', 'trend.title': 'History', 'trend.empty': 'empty trend', 'cmd.close': 'Close', 'cat.user': 'User', 'browser.live': 'Live (next request)', 'browser.liveNow': 'Live · next request', 'browser.items': '{n} items', 'browser.noContent': 'outside the loaded window', 'browser.loading': 'loading older history', 'browser.preview': 'preview', 'browser.noHeader': 'older plugin build',
-'browser.deltaHint': 'vs previous turn', 'overview.compactReserve': 'compact reserve {pct}%', 'tool.desc': 'Description', 'tool.params': 'Parameters', 'tool.paramsEmpty': '(no parameters)', 'tool.jsonToggle': 'View Raw JSON', 'tool.jsonHide': 'Collapse' }
+'browser.deltaHint': 'vs previous turn', 'overview.compactReserve': 'compact reserve {pct}%', 'tool.desc': 'Description', 'tool.params': 'Parameters', 'tool.paramsEmpty': '(no parameters)', 'tool.jsonToggle': 'View Raw JSON', 'tool.jsonHide': 'Collapse', 'rich.raw': 'Raw', 'rich.md': 'Markdown', 'rich.toMd': 'View as Markdown', 'rich.toRaw': 'View Raw Text' }
let viewComponent = null
let modalComponent = null
let modalSource = null
@@ -913,6 +916,27 @@ tr = renderView()
assert.equal(byClass(tr, 'lc-br-content').length, 1, 'element content area open')
assert.match(textOf(byClass(tr, 'lc-br-content')[0]), /first message/, 'content falls back to the node preview')
assert.match(textOf(byClass(tr, 'lc-br-content')[0]), /outside the loaded window/, 'window note follows the fallback preview')
+// The message detail card carries the raw/markdown switch too (a segmented
+// pill like the trend chart's Step/Turn), defaulting to MARKDOWN: the
+// preview renders through MarkdownText while the window note stays.
+const msgSeg = byClass(byClass(tr, 'lc-br-content')[0], 'lc-rich-seg')[0]
+assert.ok(msgSeg, 'message detail card carries the raw/markdown switch')
+const msgSegBtns = byClass(msgSeg, 'lc-rich-seg-btn')
+assert.deepEqual(msgSegBtns.map(b => textOf(b)), ['Raw', 'Markdown'], 'one segment per view')
+assert.match(msgSegBtns[1].args[1].className, /lc-rich-seg-on/, 'markdown segment is active by default')
+assert.equal(msgSegBtns[0].args[1].title, 'View Raw Text', 'raw segment tooltip')
+assert.equal(msgSegBtns[1].args[1].title, 'View as Markdown', 'markdown segment tooltip')
+assert.equal(textOf(byClass(tr, 'lc-md-stub')[0]), 'first message', 'markdown view carries the message text')
+assert.match(textOf(byClass(tr, 'lc-br-content')[0]), /outside the loaded window/, 'window note stays in markdown mode')
+// The Raw segment restores the raw ; picking Markdown again flips back.
+msgSegBtns[0].args[1].onClick()
+tr = renderView()
+assert.equal(byClass(tr, 'lc-md-stub').length, 0, 'raw mode drops the markdown renderer')
+assert.match(textOf(byClass(tr, 'lc-br-pre')[0]), /first message/, 'raw view carries the message text')
+assert.match(byClass(tr, 'lc-rich-seg-btn')[0].args[1].className, /lc-rich-seg-on/, 'raw segment lights up')
+byClass(tr, 'lc-rich-seg-btn')[1].args[1].onClick()
+tr = renderView()
+assert.equal(byClass(tr, 'lc-md-stub').length, 1, 'switching back restores the markdown view')
// Pick step seq 2 (Turn 1 · Step 1): the reconstruction includes the archived
// node (gone 3 > 2) — the accordion resets on picking, so reopen the category.
@@ -938,6 +962,24 @@ tr = renderView()
brSlots[2][1]('sys')
tr = renderView()
assert.match(textOf(byClass(tr, 'lc-br-body')[0]), /SYSTEM-PROMPT-TEXT/, 'system section shows the full prompt')
+
+// Raw/markdown view switch on the system-prompt detail card, defaulting to
+// MARKDOWN; the Raw segment restores the raw and Markdown flips back.
+assert.ok(byClass(tr, 'lc-rich-seg').length >= 1, 'system detail card carries the raw/markdown switch')
+const sysSegBtns = () => byClass(byClass(tr, 'lc-rich-seg')[0], 'lc-rich-seg-btn')
+assert.match(sysSegBtns()[1].args[1].className, /lc-rich-seg-on/, 'markdown segment is active by default')
+const sysMd = byClass(tr, 'lc-md-stub')
+assert.equal(sysMd.length, 1, 'markdown mode renders the prompt through MarkdownText')
+assert.equal(textOf(sysMd[0]), 'SYSTEM-PROMPT-TEXT', 'markdown view carries the same source text')
+assert.equal(byClass(tr, 'lc-br-body')[0] && byClass(byClass(tr, 'lc-br-body')[0], 'lc-br-pre').length, 0,
+ 'markdown mode drops the raw ')
+sysSegBtns()[0].args[1].onClick()
+tr = renderView()
+assert.equal(byClass(tr, 'lc-md-stub').length, 0, 'raw mode drops the markdown renderer')
+assert.match(textOf(byClass(tr, 'lc-br-pre')[0]), /SYSTEM-PROMPT-TEXT/, 'raw view shows the prompt')
+sysSegBtns()[1].args[1].onClick()
+tr = renderView()
+assert.equal(byClass(tr, 'lc-md-stub').length, 1, 'switching back restores the markdown view')
brSlots[1][1]('tools')
tr = renderView()
assert.match(textOf(byClass(tr, 'lc-br-body')[0]), /bash/, 'tools section lists the schema rows')
@@ -956,7 +998,20 @@ const descCards = byClass(tr, 'lc-ts-card').filter(c => {
return head !== undefined && textOf(head).includes('Description')
})
assert.equal(descCards.length, 1, 'description is rendered inside a titled card')
-assert.match(textOf(byClass(descCards[0], 'lc-ts-desc-body')[0]), /run a command/, 'description card body carries the prose')
+// The description card head carries the same raw/markdown switch, also
+// defaulting to MARKDOWN; the Raw segment restores the raw body (and back).
+const descSeg = byClass(descCards[0], 'lc-rich-seg')[0]
+assert.ok(descSeg, 'description card head carries the raw/markdown switch')
+const descMd = byClass(descCards[0], 'lc-ts-desc-md')
+assert.equal(descMd.length, 1, 'description defaults to the markdown view')
+assert.equal(textOf(byClass(descMd[0], 'lc-md-stub')[0]), 'run a command', 'markdown view carries the description text')
+byClass(descSeg, 'lc-rich-seg-btn')[0].args[1].onClick()
+tr = renderView()
+assert.equal(byClass(tr, 'lc-ts-desc-md').length, 0, 'raw mode drops the markdown body')
+assert.match(textOf(byClass(tr, 'lc-ts-desc-body')[0]), /run a command/, 'raw view carries the prose')
+byClass(tr, 'lc-rich-seg-btn')[1].args[1].onClick()
+tr = renderView()
+assert.equal(byClass(tr, 'lc-ts-desc-md').length, 1, 'switching back restores the markdown view')
// Parsed parameter table sits above the (still-collapsed) raw JSON: one row
// per declared property, type labels carry the JSON-Schema type, required
// ones marked with ✓, descriptions shown on a second line.