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.