Merge remote-tracking branch 'origin/master' into xtr/agent-loop-message-machine
# Conflicts: # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/core-data-structures/tools.i18n.yaml # packages/core/tools/README.i18n.yaml
This commit is contained in:
@@ -44,7 +44,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
|
||||
<div className={css.root} data-streaming={streaming || undefined}>
|
||||
{blocks.map((block, i) => {
|
||||
switch (block.kind) {
|
||||
case 'text': return <MarkdownText key={i} text={block.text} />
|
||||
case 'text': return <MarkdownText key={i} text={block.text} streaming={streaming} />
|
||||
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
|
||||
// Tool-call heads render as tool rows in the chat view's grouping pass.
|
||||
case 'tool-call': return null
|
||||
|
||||
@@ -87,14 +87,9 @@ button.leading {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* The code variant's expanded body is the run_code program: monospace on the
|
||||
markdown code-block fill so the program reads as code, not prose. */
|
||||
.root[data-variant='code'] .body {
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
padding: 6px 8px;
|
||||
margin-left: 22px;
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
/* The code variant's expanded body is the run_code program, rendered through
|
||||
the shared CodeBlock (shiki-highlighted TypeScript); only indentation is
|
||||
this row's concern. */
|
||||
.codeBody {
|
||||
margin: 4px 0 4px 22px;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { CodeBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import css from './ToolRow.module.css'
|
||||
@@ -96,7 +96,9 @@ export function ToolRow({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{open && <div className={css.body}>{body}</div>}
|
||||
{open && (variant === 'code'
|
||||
? <CodeBlock code={body} lang="typescript" className={css.codeBody} />
|
||||
: <div className={css.body}>{body}</div>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// share the store seat exists for) and derives the call material from the
|
||||
// session snapshot — no data of its own.
|
||||
|
||||
import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { DetailsSlotProps } from '../contract/slots.ts'
|
||||
@@ -89,7 +90,7 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane
|
||||
{material.argsRaw !== null && (
|
||||
<section className={css.section}>
|
||||
<div className={css.sectionLabel}>Input</div>
|
||||
<pre className={css.code}>{pretty(material.argsRaw)}</pre>
|
||||
<CodeBlock code={pretty(material.argsRaw)} lang="json" />
|
||||
</section>
|
||||
)}
|
||||
<section className={css.section}>
|
||||
|
||||
@@ -152,7 +152,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
|
||||
expect(view.getByText('Tool call')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('expanding the code row reveals the program body verbatim', async () => {
|
||||
it('expanding the code row reveals the program body verbatim (shiki-tokenized)', async () => {
|
||||
const parent = 'call-64'
|
||||
const b = await bench(snapshotWith([codeResult(10, parent)], new Map()))
|
||||
const view = mountApp(b.slots)
|
||||
@@ -160,7 +160,12 @@ describe('run_code sub-calls through the real chat machinery', () => {
|
||||
const toggle = view.container.querySelector('[data-variant="code"] button[aria-expanded]')
|
||||
expect(toggle).not.toBeNull()
|
||||
fireEvent.click(toggle!)
|
||||
expect(view.getByText(/const listing = await tools\.bash/)).toBeTruthy()
|
||||
// Shiki splits the program into token spans inside one <pre class="shiki">:
|
||||
// assert the whole text and the highlighted tree rather than one node.
|
||||
const pre = view.container.querySelector('pre.shiki')
|
||||
expect(pre).not.toBeNull()
|
||||
expect(pre!.textContent).toContain('const listing = await tools.bash')
|
||||
expect(pre!.querySelectorAll('span[style]').length).toBeGreaterThan(3)
|
||||
})
|
||||
|
||||
it('an isError sub-call renders the error state dot exactly like a failed native row', async () => {
|
||||
|
||||
@@ -20,11 +20,13 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@shikijs/langs": "^4.3.1",
|
||||
"clsx": "^2.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^4.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
|
||||
@@ -17,6 +17,7 @@ export { FishLogo } from './FishLogo.tsx'
|
||||
export { BrandWordmark } from './BrandWordmark.tsx'
|
||||
export { Tooltip } from './Tooltip.tsx'
|
||||
export type { TooltipSide } from './Tooltip.tsx'
|
||||
export { CodeBlock } from './markdown/CodeBlock.tsx'
|
||||
export { JsonBlock } from './markdown/JsonBlock.tsx'
|
||||
export { MarkdownText } from './markdown/MarkdownText.tsx'
|
||||
export { MessageText } from './markdown/MessageText.tsx'
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/* One code-block geometry for highlighted and plain arms: the shiki <pre>
|
||||
and the fallback <pre> draw identically except for token colors. */
|
||||
|
||||
.block :where(pre) {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
font: var(--dsw-font-markdown-code-block);
|
||||
}
|
||||
|
||||
/* Shiki inlines its theme background var; route it to the repo token. */
|
||||
.block :where(pre.shiki) {
|
||||
background: var(--dsw-alias-markdown-code-block) !important;
|
||||
}
|
||||
|
||||
.block :where(pre) code {
|
||||
font: inherit;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.plain {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
white-space: pre;
|
||||
}
|
||||
37
packages/client/ui-primitives/src/markdown/CodeBlock.tsx
Normal file
37
packages/client/ui-primitives/src/markdown/CodeBlock.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
// CodeBlock: one code surface for every consumer — markdown fences, the
|
||||
// run_code program body, and the details panel's raw args/output — with
|
||||
// shiki highlighting for the registered grammars and an identical-geometry
|
||||
// plain fallback for everything else. Shiki emits a single <pre class="shiki">
|
||||
// tree of nested spans whose colors are --shiki-* custom properties
|
||||
// (token sheets own the values); it produces no scripts or event handlers,
|
||||
// so injecting its output is safe by construction.
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { highlightToHtml } from './highlight.ts'
|
||||
import css from './CodeBlock.module.css'
|
||||
|
||||
export interface CodeBlockProps {
|
||||
/** The source text, rendered verbatim (trailing newline trimmed for display). */
|
||||
code: string
|
||||
/** Grammar hint (markdown fence info string or a fixed caller id); unknown = plain. */
|
||||
lang?: string | undefined
|
||||
/** Extra class merged onto the wrapper (callers position; this component draws). */
|
||||
className?: string | undefined
|
||||
}
|
||||
|
||||
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
|
||||
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
|
||||
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
|
||||
if (html === undefined) {
|
||||
return (
|
||||
<div className={clsx(css.block, className)}>
|
||||
<pre className={css.plain}><code>{trimmed}</code></pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// eslint-disable-next-line react/no-danger -- shiki's output is a static
|
||||
// span tree it generated from `code` (no user HTML passes through), the
|
||||
// sanctioned innerHTML consumption path per shiki's own docs.
|
||||
return <div className={clsx(css.block, className)} dangerouslySetInnerHTML={{ __html: html }} />
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { isValidElement } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import type { Components, UrlTransform } from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { CodeBlock } from './CodeBlock.tsx'
|
||||
import css from './MarkdownText.module.css'
|
||||
|
||||
const remarkPlugins = [remarkGfm]
|
||||
@@ -22,7 +24,9 @@ function sanitizeUrl(url: string): string {
|
||||
|
||||
const safeUrl: UrlTransform = url => sanitizeUrl(url)
|
||||
|
||||
const components: Components = {
|
||||
/** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */
|
||||
function buildComponents(streaming: boolean): Components {
|
||||
return {
|
||||
a: ({ href = '', children }) => {
|
||||
const safeHref = sanitizeUrl(href)
|
||||
if (safeHref === '') return <>{children}</>
|
||||
@@ -42,19 +46,40 @@ const components: Components = {
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
),
|
||||
// Fenced blocks route through the shared CodeBlock (shiki for registered
|
||||
// grammars, identical-geometry plain fallback for unknown/absent
|
||||
// languages); inline code keeps the default <code> path (the :not(pre)
|
||||
// rule styles it). While the message streams, the fence renders the
|
||||
// plain arm — retokenizing a growing fence on every chunk is quadratic
|
||||
// main-thread work; the finalize swap highlights it once.
|
||||
pre: ({ children }) => {
|
||||
/* v8 ignore next 2 -- the markdown pipeline always hands `pre` its single `code` element; the undefined arm guards a react-markdown representation change. */
|
||||
const child = isValidElement<{ className?: string; children?: unknown }>(children) ? children : undefined
|
||||
const raw = child?.props.children
|
||||
// A fence whose content isn't one plain string (e.g. an empty fence)
|
||||
// keeps the stock <pre> rather than guessing.
|
||||
if (typeof raw !== 'string') return <pre>{children}</pre>
|
||||
const lang = /language-([\w-]+)/.exec(child?.props.className ?? '')?.[1]
|
||||
return <CodeBlock code={raw} lang={streaming ? undefined : lang} />
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const staticComponents = buildComponents(false)
|
||||
const streamingComponents = buildComponents(true)
|
||||
|
||||
/**
|
||||
* Render untrusted assistant-authored Markdown as semantic React elements.
|
||||
* @param props - Markdown source text preserved by the session projection.
|
||||
* @param props - Markdown source text preserved by the session projection;
|
||||
* `streaming` renders fences plain (highlighting lands on the finalize swap).
|
||||
* @returns A GFM document with raw HTML, relative links, unsafe protocols, and remote images disabled.
|
||||
*/
|
||||
export function MarkdownText({ text }: { text: string }) {
|
||||
export function MarkdownText({ text, streaming = false }: { text: string; streaming?: boolean }) {
|
||||
return (
|
||||
<div className={css.markdown}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
components={components}
|
||||
components={streaming ? streamingComponents : staticComponents}
|
||||
urlTransform={safeUrl}
|
||||
>
|
||||
{text}
|
||||
|
||||
82
packages/client/ui-primitives/src/markdown/highlight.ts
Normal file
82
packages/client/ui-primitives/src/markdown/highlight.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* The client's ONE syntax highlighter: a synchronous fine-grained shiki core
|
||||
* (JavaScript regex engine — no oniguruma WASM, bundle-friendly) with an
|
||||
* explicit grammar allowlist and a CSS-variables theme. Colors live in the
|
||||
* theme package's token sheets as `--shiki-*` custom properties (light and
|
||||
* dark blocks), never here — the repo's tokens-only styling rule.
|
||||
*
|
||||
* Grammars are the set the harness actually renders: TypeScript programs
|
||||
* (`run_code` bodies; TS pulls in JS via grammar embedding), shell commands,
|
||||
* and JSON payloads. An unknown or absent language falls back to plain text
|
||||
* (no highlighting, still monospace) — never an error.
|
||||
*/
|
||||
|
||||
import { createHighlighterCoreSync, createCssVariablesTheme } from 'shiki/core'
|
||||
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'
|
||||
import langTs from '@shikijs/langs/typescript'
|
||||
import langBash from '@shikijs/langs/shellscript'
|
||||
import langJson from '@shikijs/langs/json'
|
||||
import type { HighlighterCore } from 'shiki/core'
|
||||
|
||||
/**
|
||||
* Language ids (and aliases) the singleton registers; everything else renders
|
||||
* plain. A Map, not an object: fence info strings are assistant-authored, so
|
||||
* a label like `constructor` or `__proto__` must miss instead of resolving an
|
||||
* inherited property and crashing the renderer inside shiki.
|
||||
*/
|
||||
const LANG_ALIASES = new Map<string, string>([
|
||||
['typescript', 'typescript'],
|
||||
['ts', 'typescript'],
|
||||
['tsx', 'typescript'],
|
||||
['javascript', 'typescript'],
|
||||
['js', 'typescript'],
|
||||
['shellscript', 'shellscript'],
|
||||
['bash', 'shellscript'],
|
||||
['sh', 'shellscript'],
|
||||
['shell', 'shellscript'],
|
||||
['zsh', 'shellscript'],
|
||||
['json', 'json'],
|
||||
['jsonc', 'json'],
|
||||
])
|
||||
|
||||
/** All token colors resolve through `--shiki-*` custom properties (theme package sheets). */
|
||||
const cssVariablesTheme = createCssVariablesTheme({
|
||||
name: 'css-variables',
|
||||
variablePrefix: '--shiki-',
|
||||
fontStyle: true,
|
||||
})
|
||||
|
||||
let singleton: HighlighterCore | undefined
|
||||
|
||||
/** The synchronous highlighter (one instance per document); pre-warmed below, lazy as the fallback. */
|
||||
function highlighter(): HighlighterCore {
|
||||
singleton ??= createHighlighterCoreSync({
|
||||
themes: [cssVariablesTheme],
|
||||
langs: [langTs, langBash, langJson],
|
||||
engine: createJavaScriptRegexEngine({ forgiving: true }),
|
||||
})
|
||||
return singleton
|
||||
}
|
||||
|
||||
// Engine + grammar construction costs a long task (~120-175ms); building it
|
||||
// during the first finalized fence's render would jank exactly when a stream
|
||||
// completes. Warm the singleton in a deferred task at module load (= plugin
|
||||
// boot) instead; the lazy path above stays as the correctness fallback for a
|
||||
// fence that renders before the timer fires. `unref` (Node-only) keeps a
|
||||
// non-browser import from pinning the event loop.
|
||||
const warmupTimer = setTimeout(() => { highlighter() }, 0)
|
||||
;(warmupTimer as { unref?: () => void }).unref?.()
|
||||
|
||||
/**
|
||||
* Highlight `code` into shiki's HTML (a single `<pre class="shiki">` tree)
|
||||
* when `lang` maps to a registered grammar; `undefined` means the caller
|
||||
* renders its plain fallback.
|
||||
* @param code - the source text.
|
||||
* @param lang - the language hint (a markdown fence info string or a fixed caller id).
|
||||
* @returns the highlighted HTML, or `undefined` for unknown languages.
|
||||
*/
|
||||
export function highlightToHtml(code: string, lang: string | undefined): string | undefined {
|
||||
const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
|
||||
if (resolved === undefined) return undefined
|
||||
return highlighter().codeToHtml(code, { lang: resolved, theme: 'css-variables' })
|
||||
}
|
||||
53
packages/client/ui-primitives/tests/code-block.spec.tsx
Normal file
53
packages/client/ui-primitives/tests/code-block.spec.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
// @vitest-environment jsdom
|
||||
// CodeBlock + the shiki singleton: registered grammars highlight into token
|
||||
// spans colored by --shiki-* custom properties; unknown/absent languages take
|
||||
// the identical-geometry plain arm; aliases resolve; the trailing newline is
|
||||
// display-trimmed. MarkdownText's fence route is pinned in markdown.spec.tsx
|
||||
// alongside the rest of the markdown family.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach } from 'vitest'
|
||||
import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
|
||||
import { highlightToHtml } from '../src/markdown/highlight.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('highlightToHtml', () => {
|
||||
it('highlights a registered grammar into css-variables token spans', () => {
|
||||
const html = highlightToHtml('const x: number = 1', 'typescript')
|
||||
expect(html).toContain('pre class="shiki css-variables"')
|
||||
expect(html).toContain('var(--shiki-')
|
||||
})
|
||||
|
||||
it.each([['ts'], ['js'], ['bash'], ['sh'], ['jsonc']])('resolves the %s alias', (alias) => {
|
||||
expect(highlightToHtml('x', alias)).toContain('shiki')
|
||||
})
|
||||
|
||||
it('returns undefined for unknown or absent languages', () => {
|
||||
expect(highlightToHtml('x', 'cobol')).toBeUndefined()
|
||||
expect(highlightToHtml('x', undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CodeBlock', () => {
|
||||
it('renders the highlighted tree for TypeScript', () => {
|
||||
const view = render(<CodeBlock code={'const a = 1\n'} lang="ts" />)
|
||||
const pre = view.container.querySelector('pre.shiki')
|
||||
expect(pre).not.toBeNull()
|
||||
expect(pre!.textContent).toBe('const a = 1')
|
||||
expect(pre!.querySelectorAll('span[style]').length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('renders the plain arm for an unknown language with the text verbatim', () => {
|
||||
const view = render(<CodeBlock code={'IDENTIFICATION DIVISION.'} lang="cobol" />)
|
||||
expect(view.container.querySelector('pre.shiki')).toBeNull()
|
||||
expect(view.getByText('IDENTIFICATION DIVISION.')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the plain arm when no language is given', () => {
|
||||
const view = render(<CodeBlock code="plain text" />)
|
||||
expect(view.container.querySelector('pre.shiki')).toBeNull()
|
||||
expect(view.getByText('plain text')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -57,11 +57,41 @@ describe('MarkdownText', () => {
|
||||
expect(container.querySelector('table')?.textContent).toContain('alphabeta')
|
||||
expect(container.querySelector('hr')).not.toBeNull()
|
||||
expect(container.querySelector('pre code')?.textContent).toContain('const answer = 42')
|
||||
// The ts fence routed through the shared CodeBlock: shiki token spans present.
|
||||
expect(container.querySelector('pre.shiki')).not.toBeNull()
|
||||
expect(container.querySelector('br')).not.toBeNull()
|
||||
expect(screen.getByRole('link', { name: 'safe' }).getAttribute('target')).toBe('_blank')
|
||||
expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a fence labeled with an inherited object key renders plain, never crashing shiki', () => {
|
||||
for (const label of ['constructor', '__proto__', 'toString', 'hasOwnProperty']) {
|
||||
const { container, unmount } = render(<MarkdownText text={'```' + label + '\ncode body\n```'} />)
|
||||
expect(container.querySelector('pre.shiki')).toBeNull()
|
||||
expect(container.querySelector('pre code')?.textContent).toContain('code body')
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('an empty fence keeps the stock pre; a language-less fence renders the plain CodeBlock arm', () => {
|
||||
const empty = render(<MarkdownText text={'```\n```'} />)
|
||||
expect(empty.container.querySelector('pre')?.outerHTML).toBe('<pre><code></code></pre>')
|
||||
|
||||
const plain = render(<MarkdownText text={'```\nno language here\n```'} />)
|
||||
expect(plain.container.querySelector('pre.shiki')).toBeNull()
|
||||
expect(plain.container.querySelector('pre code')?.textContent).toContain('no language here')
|
||||
})
|
||||
|
||||
it('streaming renders fences plain; the finalize swap highlights them', () => {
|
||||
const fence = '```ts\nconst answer = 42\n```'
|
||||
const live = render(<MarkdownText text={fence} streaming />)
|
||||
expect(live.container.querySelector('pre.shiki')).toBeNull()
|
||||
expect(live.container.querySelector('pre code')?.textContent).toContain('const answer = 42')
|
||||
live.unmount()
|
||||
const done = render(<MarkdownText text={fence} />)
|
||||
expect(done.container.querySelector('pre.shiki')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('neutralizes raw HTML, unsafe or relative links, and remote images', () => {
|
||||
const markdown = [
|
||||
'<script>globalThis.compromised = true</script>',
|
||||
|
||||
31
packages/client/ui-theme/src/styles/shiki.css
Normal file
31
packages/client/ui-theme/src/styles/shiki.css
Normal file
@@ -0,0 +1,31 @@
|
||||
/* Syntax-highlight token palette: the values behind shiki's css-variables
|
||||
theme (--shiki-* custom properties emitted by the ui-primitives CodeBlock).
|
||||
Light values on :root, dark overrides on the body attribute — the same
|
||||
cascade as every other token sheet. Background/foreground deliberately
|
||||
alias the markdown code-block tokens so highlighted and plain blocks agree. */
|
||||
|
||||
:root {
|
||||
--shiki-foreground: var(--dsw-alias-label-primary);
|
||||
--shiki-background: var(--dsw-alias-markdown-code-block);
|
||||
--shiki-token-constant: #1c7ed6;
|
||||
--shiki-token-string: #2f9e44;
|
||||
--shiki-token-comment: #868e96;
|
||||
--shiki-token-keyword: #d6336c;
|
||||
--shiki-token-parameter: #e8590c;
|
||||
--shiki-token-function: #6741d9;
|
||||
--shiki-token-string-expression: #2b8a3e;
|
||||
--shiki-token-punctuation: #495057;
|
||||
--shiki-token-link: #1971c2;
|
||||
}
|
||||
|
||||
body[data-ds-dark-theme] {
|
||||
--shiki-token-constant: #4dabf7;
|
||||
--shiki-token-string: #69db7c;
|
||||
--shiki-token-comment: #adb5bd;
|
||||
--shiki-token-keyword: #faa2c1;
|
||||
--shiki-token-parameter: #ffa94d;
|
||||
--shiki-token-function: #b197fc;
|
||||
--shiki-token-string-expression: #8ce99a;
|
||||
--shiki-token-punctuation: #ced4da;
|
||||
--shiki-token-link: #74c0fc;
|
||||
}
|
||||
@@ -61,6 +61,17 @@
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
}
|
||||
|
||||
/* run_code sub-dispatch cells: the business tint plus an indent so the
|
||||
nesting under the parent Tool cell reads at a glance. */
|
||||
.tagSubtool {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
background: var(--dsw-alias-state-business-tertiary);
|
||||
}
|
||||
|
||||
.root[data-kind='subtool'] {
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
@@ -4,20 +4,23 @@
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import css from './TrajectoryCell.module.css'
|
||||
|
||||
/** Closed set of trajectory step kinds (call+result fold into Tool; no Think). */
|
||||
export type TrajectoryCellKind = 'user' | 'message' | 'tool'
|
||||
/** Closed set of trajectory step kinds (call+result fold into Tool; no Think;
|
||||
* subtool = one run_code sub-dispatch nested under its Tool cell). */
|
||||
export type TrajectoryCellKind = 'user' | 'message' | 'tool' | 'subtool'
|
||||
|
||||
/** Display label per kind (matches the design tags). */
|
||||
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
|
||||
user: 'User',
|
||||
message: 'Message',
|
||||
tool: 'Tool',
|
||||
subtool: 'Sub',
|
||||
}
|
||||
|
||||
const TAG_CLASS: Record<TrajectoryCellKind, string> = {
|
||||
user: css.tagUser!,
|
||||
message: css.tagMessage!,
|
||||
tool: css.tagTool!,
|
||||
subtool: css.tagSubtool!,
|
||||
}
|
||||
|
||||
export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
|
||||
|
||||
@@ -12,9 +12,10 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
|
||||
const nodes = useSession((s) => s.nodes)
|
||||
const partial = useSession((s) => s.partial)
|
||||
const runningCalls = useSession((s) => s.runningCalls)
|
||||
const codeDispatches = useSession((s) => s.codeDispatches)
|
||||
const turns = useMemo(
|
||||
() => deriveTrajectoryLayout({ nodes, partial, runningCalls }),
|
||||
[nodes, partial, runningCalls],
|
||||
() => deriveTrajectoryLayout({ nodes, partial, runningCalls, codeDispatches }),
|
||||
[nodes, partial, runningCalls, codeDispatches],
|
||||
)
|
||||
if (turns.length === 0) {
|
||||
return <div className={css.root}><p className={css.empty}>暂无轨迹数据</p></div>
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
// WaterfallView: P-I placeholder body for the waterfall tab — span stats
|
||||
// header over node-count bars per turn standing in for duration lanes (no
|
||||
// timing data yet; deviation ledger #3 defers real rendering to P-III).
|
||||
// WaterfallView: span stats header over per-turn node-count lanes (P-I
|
||||
// stand-in for duration lanes; deviation ledger #3). run_code turns
|
||||
// additionally draw TRUTHFUL sub-call lanes: the dispatch start/settle pair
|
||||
// carries per-sub-call wall time, so each sub-span's width is its real
|
||||
// duration against the parent turn's dispatch window.
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { deriveSpans } from './spans.ts'
|
||||
import { deriveSpans, deriveSubSpans } from './spans.ts'
|
||||
import { TrajectoryStatsHeader } from './TrajectoryStatsHeader.tsx'
|
||||
import css from './views.module.css'
|
||||
|
||||
/** Bar width scale: px per node, clamped so tiny windows still show a bar. */
|
||||
const PX_PER_NODE = 14
|
||||
const MIN_BAR_PX = 8
|
||||
/** Sub-span lane width budget (the parent window scales into this). */
|
||||
const SUB_LANE_PX = 220
|
||||
|
||||
/** Optional density override (test/standalone knob; the register site passes nothing). */
|
||||
export interface WaterfallExtraProps {
|
||||
@@ -21,27 +25,48 @@ export interface WaterfallExtraProps {
|
||||
export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & WaterfallExtraProps) {
|
||||
const scale = pxPerNode ?? PX_PER_NODE
|
||||
const nodes = useSession((s) => s.nodes)
|
||||
const codeDispatches = useSession((s) => s.codeDispatches)
|
||||
const spans = useMemo(() => deriveSpans(nodes), [nodes])
|
||||
const subSpans = useMemo(() => deriveSubSpans(nodes, codeDispatches), [nodes, codeDispatches])
|
||||
if (spans.length === 0) return <div className={css.root}><p className={css.empty}>暂无瀑布数据</p></div>
|
||||
return (
|
||||
<>
|
||||
<TrajectoryStatsHeader useSession={useSession} />
|
||||
<div className={css.root}>
|
||||
{spans.map((span, i) => (
|
||||
<div key={span.turn} className={css.row} style={{ paddingLeft: i * 12 }}>
|
||||
<span className={css.turnTag}>turn {span.turn}</span>
|
||||
<span
|
||||
className={css.bar}
|
||||
style={{ width: Math.max(span.nodes * scale, MIN_BAR_PX) }}
|
||||
title={`${span.nodes} nodes`}
|
||||
/>
|
||||
{span.calls > 0 && (
|
||||
<div key={span.turn}>
|
||||
<div className={css.row} style={{ paddingLeft: i * 12 }}>
|
||||
<span className={css.turnTag}>turn {span.turn}</span>
|
||||
<span
|
||||
className={`${css.bar} ${css.barCalls}`}
|
||||
style={{ width: Math.max(span.calls * scale, MIN_BAR_PX) }}
|
||||
title={`${span.calls} tool calls`}
|
||||
className={css.bar}
|
||||
style={{ width: Math.max(span.nodes * scale, MIN_BAR_PX) }}
|
||||
title={`${span.nodes} nodes`}
|
||||
/>
|
||||
)}
|
||||
{span.calls > 0 && (
|
||||
<span
|
||||
className={`${css.bar} ${css.barCalls}`}
|
||||
style={{ width: Math.max(span.calls * scale, MIN_BAR_PX) }}
|
||||
title={`${span.calls} tool calls`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{(subSpans.get(span.turn) ?? []).map((lane) => (
|
||||
<div key={lane.callId} className={css.subRow} data-subspan style={{ paddingLeft: i * 12 + 24 }}>
|
||||
<span className={css.subTag}>{lane.name}</span>
|
||||
<span
|
||||
className={`${css.bar} ${css.barSub}`}
|
||||
data-timing={lane.timing}
|
||||
style={{
|
||||
marginLeft: Math.round(lane.offsetFraction * SUB_LANE_PX),
|
||||
width: Math.max(Math.round(lane.widthFraction * SUB_LANE_PX), 4),
|
||||
}}
|
||||
title={lane.timing === 'measured'
|
||||
/* durationMs is non-null exactly when timing is measured. */
|
||||
? `${lane.name} · ${((lane.durationMs ?? 0) / 1000).toFixed(2)}s`
|
||||
: lane.timing === 'running' ? `${lane.name} · running` : `${lane.name} · duration unknown`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import type {
|
||||
AssistantMessageNode,
|
||||
CodeSubCall,
|
||||
ConversationSnapshot,
|
||||
ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -27,6 +28,8 @@ export interface TrajectoryLayoutInput {
|
||||
nodes: ConversationSnapshot['nodes']
|
||||
partial: ConversationSnapshot['partial']
|
||||
runningCalls: ConversationSnapshot['runningCalls']
|
||||
/** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */
|
||||
codeDispatches: ConversationSnapshot['codeDispatches']
|
||||
}
|
||||
|
||||
interface UsageLike {
|
||||
@@ -49,7 +52,7 @@ interface LaidCell {
|
||||
* @returns turns ordered by first appearance.
|
||||
*/
|
||||
export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
|
||||
const { nodes, partial, runningCalls } = input
|
||||
const { nodes, partial, runningCalls, codeDispatches } = input
|
||||
const resultByCall = indexResults(nodes)
|
||||
const turns = new Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>()
|
||||
let index = 0
|
||||
@@ -96,7 +99,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'assistant') {
|
||||
const laidList = expandAssistant(node, index + 1, prevAbsTime, resultByCall)
|
||||
const laidList = withSubCalls(expandAssistant(node, index + 1, prevAbsTime, resultByCall), codeDispatches)
|
||||
for (const laid of laidList) {
|
||||
if (node.step > 0) pushStep(node.turn, node.step, laid)
|
||||
else pushMessage(node.turn, laid)
|
||||
@@ -128,6 +131,10 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
timeSeconds: durationSeconds(node.time, node.callTime),
|
||||
},
|
||||
})
|
||||
for (const laid of expandSubCalls(codeDispatches.get(node.callId), index)) {
|
||||
pushStep(0, 1, laid)
|
||||
index = laid.cell.index
|
||||
}
|
||||
}
|
||||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||||
}
|
||||
@@ -161,6 +168,10 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
timeSeconds: null,
|
||||
},
|
||||
})
|
||||
for (const laid of expandSubCalls(codeDispatches.get(call.callId), index)) {
|
||||
pushStep(call.turn, call.step > 0 ? call.step : 1, laid)
|
||||
index = laid.cell.index
|
||||
}
|
||||
}
|
||||
|
||||
// Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1.
|
||||
@@ -387,6 +398,53 @@ function collectCallIds(
|
||||
return ids
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Interleave each tool cell's run_code sub-dispatch cells right after it, reindexing followers. */
|
||||
function withSubCalls(laidList: LaidCell[], codeDispatches: ConversationSnapshot['codeDispatches']): LaidCell[] {
|
||||
if (codeDispatches.size === 0) return laidList
|
||||
const out: LaidCell[] = []
|
||||
let index = laidList[0] !== undefined ? laidList[0].cell.index - 1 : 0
|
||||
for (const laid of laidList) {
|
||||
out.push({ ...laid, cell: { ...laid.cell, index: ++index } })
|
||||
if (laid.callId === undefined) continue
|
||||
for (const sub of expandSubCalls(codeDispatches.get(laid.callId), index)) {
|
||||
out.push(sub)
|
||||
index = sub.cell.index
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Sub-dispatch cells for one run_code parent, in start order (running = null duration). */
|
||||
function expandSubCalls(
|
||||
subs: readonly CodeSubCall[] | undefined,
|
||||
startIndex: number,
|
||||
): LaidCell[] {
|
||||
if (subs === undefined || subs.length === 0) return []
|
||||
const out: LaidCell[] = []
|
||||
let index = startIndex
|
||||
for (const sub of subs) {
|
||||
const settled = 'kind' in sub
|
||||
out.push({
|
||||
absTime: settled ? finiteTime(sub.callTime ?? sub.time) : finiteTime(sub.time),
|
||||
toolName: settled ? sub.call?.name ?? sub.callId : sub.name,
|
||||
callId: sub.callId,
|
||||
cell: {
|
||||
index: ++index,
|
||||
kind: 'subtool',
|
||||
text: settled
|
||||
? (sub.call !== null ? summarizeCall(sub.call.name, sub.call.argsRaw) : summarizeResult(sub))
|
||||
: summarizeCall(sub.name, sub.argsRaw),
|
||||
// PR3's start/settle pair carries per-sub-call wall time; a running
|
||||
// (unsettled) or pre-pair log entry shows the em dash.
|
||||
timeSeconds: settled ? durationSeconds(sub.time, sub.callTime) : null,
|
||||
},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function summarizeCall(name: string, argsRaw: string): string {
|
||||
const args = argsRaw.replace(/\s+/g, ' ').trim()
|
||||
if (args === '') return name
|
||||
|
||||
@@ -5,6 +5,24 @@
|
||||
*/
|
||||
import type { ConversationNode, ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** One run_code sub-dispatch lane in the waterfall: real timing off the start/settle pair. */
|
||||
export interface SubSpanLane {
|
||||
callId: string
|
||||
name: string
|
||||
/** Wall duration in ms; null unless both endpoints were observed (`timing: 'measured'`). */
|
||||
durationMs: number | null
|
||||
/**
|
||||
* Timing provenance: `measured` = start/settle pair observed; `running` =
|
||||
* start seen, settle pending; `unknown` = settle-only replay window (the
|
||||
* start fell outside), so no duration claim is possible.
|
||||
*/
|
||||
timing: 'measured' | 'running' | 'unknown'
|
||||
/** Start offset as a fraction of the parent turn's dispatch window [0, 1). */
|
||||
offsetFraction: number
|
||||
/** Width as a fraction of the window (running lanes extend to the window end). */
|
||||
widthFraction: number
|
||||
}
|
||||
|
||||
/** One turn's worth of activity, folded from the snapshot node window. */
|
||||
export interface TurnSpan {
|
||||
turn: number
|
||||
@@ -69,3 +87,61 @@ export function deriveSpanStats(spans: readonly TurnSpan[]): SpanStats {
|
||||
function hasTurn(node: ConversationNode): node is ConversationNode & { turn: number } {
|
||||
return node.kind === 'assistant' || node.kind === 'steering'
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the dispatch index into per-turn sub-span lanes with REAL timing: each
|
||||
* lane's offset/width scale against its parent turn's dispatch window (first
|
||||
* start → last settle). Running (unsettled) lanes extend to the window end
|
||||
* with a null duration.
|
||||
* @param nodes - snapshot nodes (locates each parent run_code call's turn).
|
||||
* @param codeDispatches - the snapshot's dispatch index.
|
||||
* @returns lanes keyed by turn, in start order.
|
||||
*/
|
||||
export function deriveSubSpans(
|
||||
nodes: ConversationSnapshot['nodes'],
|
||||
codeDispatches: ConversationSnapshot['codeDispatches'],
|
||||
): ReadonlyMap<number, readonly SubSpanLane[]> {
|
||||
const out = new Map<number, SubSpanLane[]>()
|
||||
if (codeDispatches.size === 0) return out
|
||||
const turnByCall = new Map<string, number>()
|
||||
let currentTurn = 0
|
||||
for (const node of nodes) {
|
||||
if (node.kind === 'assistant' || node.kind === 'steering') currentTurn = node.turn
|
||||
if (node.kind === 'tool-result') turnByCall.set(node.callId, currentTurn)
|
||||
}
|
||||
for (const [parent, subs] of codeDispatches) {
|
||||
if (subs.length === 0) continue
|
||||
const turn = turnByCall.get(parent) ?? currentTurn
|
||||
// A settle-only entry (callTime null: its start fell outside the replay
|
||||
// window) anchors the window by its settle time — a real observation —
|
||||
// but must never masquerade as a measured zero-duration span.
|
||||
const starts: number[] = []
|
||||
const ends: number[] = []
|
||||
for (const sub of subs) {
|
||||
const settled = 'kind' in sub
|
||||
const start = settled ? sub.callTime ?? sub.time : sub.time
|
||||
starts.push(start)
|
||||
ends.push(settled ? sub.time : start)
|
||||
}
|
||||
const windowStart = Math.min(...starts)
|
||||
const windowEnd = Math.max(...ends, windowStart + 1)
|
||||
const windowSpan = windowEnd - windowStart
|
||||
const lanes: SubSpanLane[] = subs.map((sub, i) => {
|
||||
const settled = 'kind' in sub
|
||||
const timing = settled ? (sub.callTime === null ? 'unknown' as const : 'measured' as const) : 'running' as const
|
||||
const start = starts[i] ?? windowStart
|
||||
const end = settled ? sub.time : windowEnd
|
||||
return {
|
||||
callId: sub.callId,
|
||||
name: settled ? sub.call?.name ?? sub.callId : sub.name,
|
||||
durationMs: timing === 'measured' ? Math.max(0, end - start) : null,
|
||||
timing,
|
||||
offsetFraction: (start - windowStart) / windowSpan,
|
||||
widthFraction: Math.max((end - start) / windowSpan, 0.02),
|
||||
}
|
||||
})
|
||||
const existing = out.get(turn) ?? []
|
||||
out.set(turn, [...existing, ...lanes])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -45,3 +45,38 @@
|
||||
color: var(--dsw-alias-label-caption);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* run_code sub-span lanes: one row per sub-dispatch under its turn row,
|
||||
offset/width scaled to the dispatch window (real wall time). A running
|
||||
lane pulses via reduced opacity until its settle arrives. */
|
||||
.subRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.subTag {
|
||||
flex: none;
|
||||
width: 88px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
.barSub {
|
||||
height: 8px;
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.barSub[data-timing='running'] {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* Settle-only replay entries: no measured span — hollow, not a solid bar. */
|
||||
.barSub[data-timing='unknown'] {
|
||||
background: transparent;
|
||||
border: 1px dashed var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('deriveTrajectoryLayout', () => {
|
||||
content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
||||
expect(turns).toHaveLength(1)
|
||||
expect(turns[0]?.turn).toBe(1)
|
||||
const kinds = turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.kind))
|
||||
@@ -86,6 +86,7 @@ describe('deriveTrajectoryLayout', () => {
|
||||
|
||||
it('adds runningCalls not already present and leaves their time blank', () => {
|
||||
const turns = deriveTrajectoryLayout({
|
||||
codeDispatches: new Map(),
|
||||
nodes: [] as unknown as ConversationSnapshot['nodes'],
|
||||
partial: null,
|
||||
runningCalls: [{
|
||||
@@ -111,7 +112,7 @@ describe('deriveTrajectoryLayout', () => {
|
||||
usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 3 },
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
||||
const cells = turns[0]?.groups.flatMap((g) => g.cells) ?? []
|
||||
expect(cells.find((c) => c.kind === 'message')?.timeSeconds).toBeNull()
|
||||
expect(turns[0]?.groups.find((g) => g.title === 'Step 1')?.description).toBeUndefined()
|
||||
@@ -137,7 +138,7 @@ describe('deriveTrajectoryLayout', () => {
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
||||
expect(turns[0]?.groups[0]?.description).toBe('2.9s bash×2')
|
||||
})
|
||||
|
||||
@@ -154,7 +155,7 @@ describe('deriveTrajectoryLayout', () => {
|
||||
blocks: [{ kind: 'text', text: 'ok2' }],
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
||||
expect(turns.map((t) => t.turn)).toEqual([1, 2])
|
||||
expect(turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['first', 'ok1'])
|
||||
expect(turns[1]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['second', 'ok2'])
|
||||
@@ -168,7 +169,7 @@ describe('deriveTrajectoryLayout', () => {
|
||||
usage: { inputTokens: 11, outputTokens: 22, reasoningTokens: 3 },
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
||||
const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message')
|
||||
expect(message).toMatchObject({
|
||||
text: '', input: 11, output: 22, think: 3,
|
||||
@@ -196,7 +197,7 @@ describe('deriveTrajectoryLayout', () => {
|
||||
blocks: [{ kind: 'text', text: 'done' }],
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
||||
const message = turns[0]?.groups
|
||||
.flatMap((g) => g.cells)
|
||||
.find((c) => c.kind === 'message' && c.text === 'done')
|
||||
@@ -204,3 +205,51 @@ describe('deriveTrajectoryLayout', () => {
|
||||
expect(message?.timeSeconds).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('run_code sub-dispatch cells', () => {
|
||||
const runCodeNodes = [
|
||||
{
|
||||
kind: 'assistant', seq: 2, time: 6_000, turn: 1, step: 1,
|
||||
blocks: [
|
||||
{ kind: 'tool-call', callId: 'p1', name: 'run_code', argsRaw: '{"code":"…","description":"批量读取"}' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1',
|
||||
call: { name: 'run_code', argsRaw: '{"code":"…","description":"批量读取"}' }, callTime: 6_200,
|
||||
content: [{ type: 'text', text: 'done' }], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
|
||||
const settledSub = (n: number, name: string, start: number, end: number) => ({
|
||||
kind: 'tool-result' as const, seq: 100 + n, time: end,
|
||||
callId: `p1:code:${n}`,
|
||||
call: { name, argsRaw: '{"x":1}' }, callTime: start,
|
||||
content: [{ type: 'text' as const, text: 'ok' }], isError: false, callView: null, resultView: null,
|
||||
})
|
||||
|
||||
it('nests settled sub-cells after their parent Tool cell with real durations', () => {
|
||||
const codeDispatches = new Map([['p1', [
|
||||
settledSub(1, 'bash', 6_300, 7_300),
|
||||
settledSub(2, 'read', 7_300, 7_800),
|
||||
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
|
||||
const cells = turns[0]!.groups.flatMap((g) => g.cells)
|
||||
expect(cells.map((c) => c.kind)).toEqual(['tool', 'subtool', 'subtool'])
|
||||
// Sequential indexes across the interleave; durations from the pair times.
|
||||
expect(cells.map((c) => c.index)).toEqual([1, 2, 3])
|
||||
expect(cells[1]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 })
|
||||
expect(cells[2]).toMatchObject({ timeSeconds: 0.5 })
|
||||
})
|
||||
|
||||
it('a running (unsettled) sub-call renders a subtool cell with blank time', () => {
|
||||
const running = {
|
||||
callId: 'p1:code:1', name: 'grep', argsRaw: '{"pattern":"x"}',
|
||||
turn: 0, step: 0, time: 6_400, callView: null,
|
||||
}
|
||||
const codeDispatches = new Map([['p1', [running]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
|
||||
const sub = turns[0]!.groups.flatMap((g) => g.cells).find((c) => c.kind === 'subtool')
|
||||
expect(sub).toMatchObject({ text: 'grep · {"pattern":"x"}', timeSeconds: null })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversa
|
||||
import { ConversationRoot, type ConversationRootProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationRoot.tsx'
|
||||
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
|
||||
import { deriveSpans, deriveSpanStats } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/spans.ts'
|
||||
import { deriveSpans, deriveSpanStats, deriveSubSpans } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/spans.ts'
|
||||
import { TrajectoryStatsHeader } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryStatsHeader.tsx'
|
||||
import { TrajectoryView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryView.tsx'
|
||||
import { WaterfallView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/WaterfallView.tsx'
|
||||
@@ -54,7 +54,7 @@ const NODES = [
|
||||
|
||||
function fakeSession(nodes: ConversationSnapshot['nodes']) {
|
||||
const store = createSnapshotStore({
|
||||
nodes, partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
|
||||
nodes, partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
|
||||
})
|
||||
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
|
||||
}
|
||||
@@ -117,7 +117,7 @@ function tabsOf(slots: SlotsService): ViewTab[] {
|
||||
function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
|
||||
const sessionSnapshot = createSnapshotStore({
|
||||
running: false, removed: false, promptError: null, nodes,
|
||||
partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
|
||||
partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
|
||||
})
|
||||
const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
|
||||
const chat = createChatStore().create()
|
||||
@@ -260,3 +260,115 @@ describe('node half', () => {
|
||||
expect(nodeApply()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveSubSpans (waterfall lanes)', () => {
|
||||
const dispatchNodes = [
|
||||
{ kind: 'assistant', seq: 2, time: 6_000, turn: 3, step: 1, blocks: [] },
|
||||
{
|
||||
kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1',
|
||||
call: { name: 'run_code', argsRaw: '{}' }, callTime: 6_100,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
|
||||
it('scales settled lanes into the dispatch window with real durations', () => {
|
||||
const codeDispatches = new Map([['p1', [
|
||||
{
|
||||
kind: 'tool-result', seq: 101, time: 7_000, callId: 'p1:code:1',
|
||||
call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
{
|
||||
kind: 'tool-result', seq: 102, time: 8_200, callId: 'p1:code:2',
|
||||
call: { name: 'read', argsRaw: '{}' }, callTime: 7_000,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const lanes = deriveSubSpans(dispatchNodes, codeDispatches)
|
||||
const turn3 = lanes.get(3)
|
||||
expect(turn3).toHaveLength(2)
|
||||
// Window = 6200..8200 (2000ms). bash: 0..0.4; read: 0.4..1.0.
|
||||
expect(turn3?.[0]).toMatchObject({ name: 'bash', durationMs: 800, timing: 'measured', offsetFraction: 0 })
|
||||
expect(turn3?.[0]?.widthFraction).toBeCloseTo(0.4)
|
||||
expect(turn3?.[1]).toMatchObject({ name: 'read', durationMs: 1200 })
|
||||
expect(turn3?.[1]?.offsetFraction).toBeCloseTo(0.4)
|
||||
})
|
||||
|
||||
it('a running lane extends to the window end with a null duration', () => {
|
||||
const codeDispatches = new Map([['p1', [
|
||||
{
|
||||
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
|
||||
call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
{ callId: 'p1:code:2', name: 'grep', argsRaw: '{}', turn: 0, step: 0, time: 7_000, callView: null },
|
||||
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const lanes = deriveSubSpans(dispatchNodes, codeDispatches)
|
||||
const running = lanes.get(3)?.find((lane) => lane.name === 'grep')
|
||||
expect(running).toMatchObject({ durationMs: null, timing: 'running' })
|
||||
// Extends from its start to the window end.
|
||||
expect(running!.offsetFraction + running!.widthFraction).toBeCloseTo(1)
|
||||
})
|
||||
|
||||
it('a settle-only entry (null callTime) is unknown timing, never a measured 0 ms', () => {
|
||||
const codeDispatches = new Map([['p1', [
|
||||
{
|
||||
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
|
||||
call: { name: 'bash', argsRaw: '{}' }, callTime: null,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const lane = deriveSubSpans(dispatchNodes, codeDispatches).get(3)?.[0]
|
||||
expect(lane).toMatchObject({ durationMs: null, timing: 'unknown' })
|
||||
})
|
||||
|
||||
it('waterfall renders sub-span lanes under the owning turn row', () => {
|
||||
const codeDispatches = new Map([['p1', [
|
||||
{
|
||||
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
|
||||
call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const store = createSnapshotStore({
|
||||
nodes: dispatchNodes, partial: null,
|
||||
runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches,
|
||||
})
|
||||
const props = {
|
||||
sessionId: SID,
|
||||
useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>,
|
||||
useSessions: emptySessions(),
|
||||
useWorkspaces: emptyWorkspaces(),
|
||||
} as unknown as ConvViewProps
|
||||
const view = render(createElement(WaterfallView as FC<ConvViewProps>, props))
|
||||
const lane = view.container.querySelector('[data-subspan]')
|
||||
expect(lane).not.toBeNull()
|
||||
expect(lane!.textContent).toContain('bash')
|
||||
expect(lane!.querySelector('[title*="1.80s"]')).not.toBeNull()
|
||||
expect(lane!.querySelector('[data-timing="measured"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('waterfall labels a settle-only lane as duration unknown', () => {
|
||||
const codeDispatches = new Map([['p1', [
|
||||
{
|
||||
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
|
||||
call: { name: 'read', argsRaw: '{}' }, callTime: null,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const store = createSnapshotStore({
|
||||
nodes: dispatchNodes, partial: null,
|
||||
runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches,
|
||||
})
|
||||
const props = {
|
||||
sessionId: SID,
|
||||
useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>,
|
||||
useSessions: emptySessions(),
|
||||
useWorkspaces: emptyWorkspaces(),
|
||||
} as unknown as ConvViewProps
|
||||
const view = render(createElement(WaterfallView as FC<ConvViewProps>, props))
|
||||
const bar = view.container.querySelector('[data-timing="unknown"]')
|
||||
expect(bar).not.toBeNull()
|
||||
expect(bar!.getAttribute('title')).toContain('duration unknown')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/* Shell-owned global base: full-height mount plus the theme token sheets.
|
||||
* The three ui-theme sheets are the sole token source (--dsw-*); the shell
|
||||
* The four ui-theme sheets are the sole token source (--dsw-*); the shell
|
||||
* links them here so tokens exist before any plugin CSS lands. */
|
||||
@import '@deepseek-ai/dsh-client-ui-theme/styles/base.css';
|
||||
@import '@deepseek-ai/dsh-client-ui-theme/styles/design-platform.css';
|
||||
@import '@deepseek-ai/dsh-client-ui-theme/styles/gradient-shadow-text.css';
|
||||
@import '@deepseek-ai/dsh-client-ui-theme/styles/shiki.css';
|
||||
|
||||
html,
|
||||
body,
|
||||
|
||||
Reference in New Issue
Block a user