diff --git a/apps/web/tests/todo-display.snapshot.ts b/apps/web/tests/todo-display.snapshot.ts index ef710129d8..4083e5b1d2 100644 --- a/apps/web/tests/todo-display.snapshot.ts +++ b/apps/web/tests/todo-display.snapshot.ts @@ -160,7 +160,7 @@ it('renders the todo_write turn: dedicated tool row + the dock plan strip', asyn "text": "浏览器验收", }, ], - "row": "☰更新任务清单1/3 已完成 · 实现 fixture 样本", + "row": "更新任务清单1/3 已完成 · 实现 fixture 样本", "rowState": "ok", } `) diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 0e91afcc07..6daf78719e 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -40,13 +40,20 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) { export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted }: AssistantMarkdownProps) { const last = blocks.length - 1 + // Tool-call heads render as tool rows in the chat view's grouping pass, so + // a node that is only those heads (or empty) would paint an empty root + // between tool groups — skip the shell unless something visible remains. + const hasVisible = streaming + || interrupted === true + || blocks.some(block => block.kind !== 'tool-call') + if (!hasVisible) return null return (
{blocks.map((block, i) => { switch (block.kind) { case 'text': return case 'reasoning': return - // Tool-call heads render as tool rows in the chat view's grouping pass. + // Grouped into tool rows by ChatView; hasVisible above skips an empty shell. case 'tool-call': return null default: return } diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css index ff4068d49c..dd32d56b01 100644 --- a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css @@ -1,29 +1,44 @@ -/* todo_write plan-update row: title + progress summary on one line. */ +/* todo_write plan-update row: ToolRow chrome (figma 780:53675) — + [16 checklist] gap6 [title 14/24] gap8 [2x2 dot] gap8 [summary FILL truncate]. */ .row { display: flex; align-items: center; - gap: 8px; height: 24px; min-width: 0; cursor: pointer; border-radius: 6px; - font-size: 13px; } .row:hover { background: var(--dsw-alias-interactive-bg-hover); } -.badge { +.leading { flex: none; - color: var(--dsw-alias-state-business-primary); + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-right: 6px; + color: var(--dsw-alias-label-tertiary); } .title { flex: none; - font-weight: 510; - color: var(--dsw-alias-label-primary); + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-primary-dimmed); +} + +.sep { + flex: none; + width: 2px; + height: 2px; + border-radius: 1px; + margin: 0 8px; + background: var(--dsw-alias-label-caption); } .summary { @@ -32,11 +47,15 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - color: var(--dsw-alias-label-secondary); + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); } .err { flex: none; + margin-left: 8px; color: var(--dsw-alias-state-error-primary); font-size: 11px; + line-height: 16px; } diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx index 353e7a5441..a47322b614 100644 --- a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx @@ -3,13 +3,13 @@ // hole like the bash sample (a product registration, not a sample). The row // summarizes the written list (counts + active item) from the call args; the // durable list itself renders in the TodoPanel above the composer, so the -// row stays one line. +// row stays one line. Chrome matches ToolRow (figma 780:53675). import type { KeyboardEvent } from 'react' import type { Context } from 'cordis' -import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconChecklistOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolRowProps } from '../contract/slots.ts' -import { toolRowModel } from '../contract/tool-call-model.ts' +import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' import css from './todo-row.module.css' /** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */ @@ -40,6 +40,17 @@ function summarize(argsRaw: string): string | null { : head } +/** Leading-slot state substitution matches ToolRow / bash: icon yields to the + * state semantic while running or failed; ok keeps the checklist glyph. */ +function leadingFor(state: ToolRowState) { + switch (state) { + case 'running': return + case 'error': return + case 'stopped': return + default: return + } +} + /** One-line plan update row (click opens the raw args in details). Non-ok * execution states keep the generic row's dot semantics — a cancelled call * wrote no todo/write, so it must not read as a completed update. */ @@ -64,10 +75,9 @@ export function TodoRow({ toolName, block, openDetails }: ToolRowProps) { onClick={openDetails} onKeyDown={openFromKeyboard} > - {model.state === 'ok' - ? - : } + {leadingFor(model.state)} 更新任务清单 + {summary} {model.state === 'error' && failed} {model.state === 'stopped' && 已中断} diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index 136901bd6d..96f9cf1aa6 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -60,6 +60,20 @@ describe('tails', () => { expect(stopped.getByText('已停止')).toBeTruthy() }) + it('AssistantMarkdown skips the root shell when only tool-call heads remain', () => { + // Tool heads are drawn by ChatView's tool groups; an empty root between + // groups is layout noise (no text, no pulse, no interrupted marker). + const empty = render( + , + ) + expect(empty.container.firstChild).toBeNull() + const blank = render() + expect(blank.container.firstChild).toBeNull() + }) + it('a settled others-variant row renders the sparkle icon in the leading slot', () => { const settled: ToolResultNode = { kind: 'tool-result', seq: 2, time: 2_000, callId: 'c5', diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 46bf94c865..23b3bcb835 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -653,6 +653,16 @@ export const IconDataOutline16 = ({ size = 16, className }: IconProps) => ( ) +/** ic_checklist_outline_16 (figma extract): two rings + two list bars. */ +export const IconChecklistOutline16 = ({ size = 16, className }: IconProps) => ( + + + + + + +) + /** ic_ds_List_Pen_outline_16 */ export const IconListPenOutline16 = ({ size = 16, className }: IconProps) => ( diff --git a/packages/client/ui-primitives/tests/icons.spec.tsx b/packages/client/ui-primitives/tests/icons.spec.tsx index 86cf99e49b..c06a2e52fc 100644 --- a/packages/client/ui-primitives/tests/icons.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.spec.tsx @@ -14,8 +14,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full P-I set (43 deepsuite + 12 figma extracts)', () => { - expect(iconNames.length).toBe(55) + it('exports the full P-I set (43 deepsuite + 13 figma extracts)', () => { + expect(iconNames.length).toBe(56) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {