cleanup(client): detach conversation rows from Tool presentation

This commit is contained in:
imccyu
2026-08-08 16:53:22 +08:00
parent a44e35793c
commit 1674af147e
9 changed files with 389 additions and 51 deletions
@@ -12,13 +12,11 @@
import { memo, useMemo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import {
IconThinkOutline14, JsonBlock, MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps, TurnTailOwnerProps } from '../contract/slots.ts'
import { hasContentText } from './chat-flow.ts'
import { MessageIconActions } from './MessageIconActions.tsx'
import { ToolRow } from './ToolRow.tsx'
import { ReasoningRow } from './ReasoningRow.tsx'
import css from './AssistantMarkdown.module.css'
export interface AssistantMarkdownProps {
@@ -49,18 +47,6 @@ export interface AssistantMarkdownProps {
t: ChatViewSlotProps['t']
}
function firstLine(text: string): string {
const nl = text.indexOf('\n')
return nl === -1 ? text : text.slice(0, nl)
}
/** Latest non-blank reasoning line while the block is still streaming. */
function latestLine(text: string): string {
const visible = text.trimEnd()
const nl = visible.lastIndexOf('\n')
return nl === -1 ? visible : visible.slice(nl + 1)
}
/** Joined text blocks for the copy action (reasoning / tool heads stay out). */
function copyText(blocks: readonly AssistantBlock[]): string {
const parts: string[] = []
@@ -71,20 +57,6 @@ function copyText(blocks: readonly AssistantBlock[]): string {
}
/** Reasoning block as the Think variant summary row (figma 39:28304). */
function ThinkRow({ text, running, t }: { text: string; running: boolean; t: AssistantMarkdownProps['t'] }) {
return (
<ToolRow
t={t}
variant="think"
icon={<IconThinkOutline14 size={14} />}
title="Think"
summary={running ? latestLine(text) : firstLine(text)}
body={text}
state={running ? 'running' : 'ok'}
/>
)
}
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail, t,
}: AssistantMarkdownProps) {
@@ -109,7 +81,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
case 'text': return (
<MarkdownText key={i} text={block.text} streaming={streaming} codeLabels={codeLabels} />
)
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} t={t} />
case 'reasoning': return <ReasoningRow key={i} text={block.text} running={streaming && i === last} />
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
case 'tool-call': return null
default: return (
@@ -1,4 +1,4 @@
/* Shared Tool calls disclosure header: [16px leading] gap 6 [title 14/24]. */
/* Shared conversation disclosure header: [16px leading] gap 6 [title 14/24]. */
.root {
display: flex;
@@ -14,7 +14,7 @@ export interface DisclosureRowProps {
expandOnRowClick?: boolean | undefined
/** Replaces the collapsed icon with a chevron while the row is hovered. */
previewChevron?: boolean | undefined
/** Keeps `collapsedContent` inline while open (ToolRow's summary stays readable next to the expanded card). */
/** Keeps `collapsedContent` inline while open. */
keepContentWhenOpen?: boolean | undefined
collapsedContent?: ReactNode
children?: ReactNode
@@ -28,7 +28,7 @@ export interface DisclosureRowProps {
/**
* Render one disclosure header and its controlled expanded content.
* @param props - Visual content, controlled state, and interaction policy.
* @returns The disclosure row.
* @returns the disclosure row.
*/
export function DisclosureRow({
icon,
@@ -0,0 +1,86 @@
.root {
display: flex;
flex-direction: column;
}
.row {
position: relative;
overflow: hidden;
}
.root[data-state='running'] .row::after {
content: '';
position: absolute;
inset-block: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-command-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-command-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
.leading {
flex-shrink: 0;
}
.chevron {
color: var(--dsw-alias-label-secondary);
}
.title {
font-weight: 400;
}
.separator {
flex: none;
width: 2px;
height: 2px;
margin: 0 8px;
border-radius: 1px;
background: var(--dsw-alias-label-caption);
}
.summary {
min-width: 0;
overflow: hidden;
flex: 1 1 auto;
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
.summary[data-error],
.body[data-error] {
color: var(--dsw-alias-state-error-primary);
}
.body {
max-height: 260px;
margin: 4px 0 4px 4px;
padding: 12px 16px;
overflow: auto;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 12px;
background: var(--dsw-alias-markdown-code-block);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-markdown-code-block-small);
white-space: pre-wrap;
}
@media (prefers-reduced-motion: reduce) {
.root[data-state='running'] .row::after {
animation: none;
}
}
@@ -4,23 +4,31 @@
// fallback (an unregistered command name lands here); registrants may compose
// it as a base, feeding the same owner payload through.
import { ToolRow } from './ToolRow.tsx'
import type { ToolRowState } from '../contract/tool-call-model.ts'
import { useState, type ReactNode } from 'react'
import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts'
import { IconApiOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconApiOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import { DisclosureRow } from './DisclosureRow.tsx'
import css from './GenericCommandCard.module.css'
type CommandRowState = 'running' | 'ok' | 'error'
/** Node state → row state semantic (running while unsettled; outcome kind after). */
function stateOf(outcome: CommandRowOwnerProps['node']['outcome']): ToolRowState {
function stateOf(outcome: CommandRowOwnerProps['node']['outcome']): CommandRowState {
if (outcome === null) return 'running'
return outcome.kind === 'error' ? 'error' : 'ok'
}
function leadingFor(state: CommandRowState): ReactNode {
return state === 'error' ? <StateDot state="error" /> : <IconApiOutline14 size={14} />
}
/** Card props: the owner payload plus the render site's locale seat (plain prop). */
export interface GenericCommandCardProps extends CommandRowOwnerProps {
t: ChatViewSlotProps['t']
}
export function GenericCommandCard({ node, t }: GenericCommandCardProps) {
const [expanded, setExpanded] = useState(false)
const text = node.outcome?.text
const summary = node.outcome === null
? t('command.running')
@@ -30,16 +38,32 @@ export function GenericCommandCard({ node, t }: GenericCommandCardProps) {
// settlement text says (`permission · preset workspace-write`). A
// cross-window node whose run page fell out of the window has no name.
const title = node.name ?? t('command.title')
const state = stateOf(node.outcome)
const body = text !== undefined && text.includes('\n') ? text : null
const open = expanded && body !== null
return (
<ToolRow
t={t}
variant="others"
icon={<IconApiOutline14 size={14} />}
title={title}
summary={summary}
// Expandable only when the outcome text overflows a one-line summary.
body={text !== undefined && text.includes('\n') ? text : null}
state={stateOf(node.outcome)}
/>
<div className={css.root} data-state={state}>
<DisclosureRow
rowClassName={css.row}
leadingClassName={css.leading}
titleClassName={css.title}
chevronClassName={css.chevron}
icon={leadingFor(state)}
title={title}
open={open}
expandable={body !== null}
expandOnRowClick
keepContentWhenOpen
onToggle={() => { setExpanded(value => !value) }}
collapsedContent={(
<>
<span className={css.separator} aria-hidden />
<span className={css.summary} data-error={state === 'error' || undefined}>{summary}</span>
</>
)}
>
<pre className={css.body} data-error={state === 'error' || undefined}>{body}</pre>
</DisclosureRow>
</div>
)
}
@@ -0,0 +1,81 @@
.root {
display: flex;
flex-direction: column;
}
.row {
position: relative;
overflow: hidden;
}
.root[data-state='running'] .row::after {
content: '';
position: absolute;
inset-block: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-reasoning-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-reasoning-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
.leading {
flex-shrink: 0;
}
.chevron {
color: var(--dsw-alias-label-secondary);
}
.title {
font-weight: 400;
}
.separator {
flex: none;
width: 2px;
height: 2px;
margin: 0 8px;
border-radius: 1px;
background: var(--dsw-alias-label-caption);
}
.summary {
min-width: 0;
overflow: hidden;
flex: 1 1 auto;
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
.summary[data-follow-end] {
text-overflow: clip;
}
.thinkBody {
padding: 4px 0 4px 22px;
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 24px;
white-space: pre-wrap;
word-break: break-word;
}
@media (prefers-reduced-motion: reduce) {
.root[data-state='running'] .row::after {
animation: none;
}
}
@@ -0,0 +1,62 @@
/** Assistant reasoning disclosure, independent of Tool-call presentation. */
import { useEffect, useRef, useState } from 'react'
import { IconThinkOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import { DisclosureRow } from './DisclosureRow.tsx'
import { useThrottledVisualUpdate } from './use-throttled-visual-update.ts'
import css from './ReasoningRow.module.css'
function firstLine(text: string): string {
const newline = text.indexOf('\n')
return newline === -1 ? text : text.slice(0, newline)
}
function latestLine(text: string): string {
const visible = text.trimEnd()
const newline = visible.lastIndexOf('\n')
return newline === -1 ? visible : visible.slice(newline + 1)
}
/**
* Render one assistant reasoning block as the Think disclosure row.
* @param props.text - complete or streaming reasoning text.
* @param props.running - whether this block is the streaming tail.
* @returns the reasoning disclosure.
*/
export function ReasoningRow({ text, running }: { text: string; running: boolean }) {
const [expanded, setExpanded] = useState(false)
const summaryRef = useRef<HTMLSpanElement>(null)
const summary = running ? latestLine(text) : firstLine(text)
const scheduleSummaryScroll = useThrottledVisualUpdate(() => {
const element = summaryRef.current
if (element === null) return
element.scrollLeft = running ? element.scrollWidth - element.clientWidth : 0
})
useEffect(() => {
scheduleSummaryScroll()
}, [running, scheduleSummaryScroll, summary])
return (
<div className={css.root} data-state={running ? 'running' : 'ok'}>
<DisclosureRow
rowClassName={css.row}
leadingClassName={css.leading}
titleClassName={css.title}
chevronClassName={css.chevron}
icon={<IconThinkOutline14 size={14} />}
title="Think"
open={expanded}
expandable
expandOnRowClick
onToggle={() => { setExpanded(value => !value) }}
collapsedContent={(
<>
<span className={css.separator} aria-hidden />
<span ref={summaryRef} className={css.summary} data-follow-end={running || undefined}>{summary}</span>
</>
)}
>
<div className={css.thinkBody}>{text}</div>
</DisclosureRow>
</div>
)
}
@@ -1,14 +1,12 @@
/** Frame-throttled scheduling for non-essential visual alignment. */
import { useCallback, useLayoutEffect, useRef } from 'react'
const DEFAULT_INTERVAL_FRAMES = 3
/**
* Return a stable scheduler that coalesces visual updates over a frame interval.
* Repeated calls retain the latest callback, and unmount cancels pending work.
* @param update - DOM alignment to run after the throttle interval.
* @param intervalFrames - Frames to wait before applying the latest alignment.
* @param intervalFrames - frames to wait before applying the latest alignment.
* @returns a stable function that schedules the latest update.
*/
export function useThrottledVisualUpdate(
@@ -0,0 +1,115 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { zh } from '../src/client/locales.ts'
let nextAnimationFrameId = 1
let animationFrames = new Map<number, FrameRequestCallback>()
function flushAnimationFrames(count: number): void {
for (let index = 0; index < count; index += 1) {
const callbacks = [...animationFrames.values()]
animationFrames.clear()
for (const callback of callbacks) callback(index)
}
}
beforeEach(() => {
nextAnimationFrameId = 1
animationFrames = new Map()
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
const id = nextAnimationFrameId
nextAnimationFrameId += 1
animationFrames.set(id, callback)
return id
})
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
animationFrames.delete(id)
})
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
const t = makeTranslate(zh, commonZh)
describe('ReasoningRow', () => {
it('follows the latest streaming line, scrolls to its end, then restores the settled first line', () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens' }]}
streaming
/>,
)
const summary = view.getByText('Newest reasoning tokens')
Object.defineProperties(summary, {
scrollWidth: { configurable: true, value: 300 },
clientWidth: { configurable: true, value: 100 },
})
view.rerender(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving' }]}
streaming
/>,
)
expect(summary.scrollLeft).toBe(0)
flushAnimationFrames(2)
expect(summary.scrollLeft).toBe(0)
flushAnimationFrames(1)
expect(summary.scrollLeft).toBe(200)
expect(summary.getAttribute('data-follow-end')).toBe('true')
view.rerender(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving\n' }]}
streaming={false}
/>,
)
flushAnimationFrames(3)
expect(view.getByText('Inspect the session')).toBeTruthy()
expect(summary.scrollLeft).toBe(0)
expect(summary.hasAttribute('data-follow-end')).toBe(false)
})
it('expands from either Think or the reasoning summary', () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
streaming={false}
/>,
)
const row = view.getByRole('button')
fireEvent.click(view.getByText('Inspect the session'))
expect(row.getAttribute('aria-expanded')).toBe('true')
expect(view.getByText(/Check persistence/)).toBeTruthy()
fireEvent.click(view.getByText('Think'))
expect(row.getAttribute('aria-expanded')).toBe('false')
})
it('expanded Think drops the inline summary and renders plain prose, no IN card', () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
streaming={false}
/>,
)
fireEvent.click(view.getByText('Think'))
expect(view.getAllByText(/Inspect the session/)).toHaveLength(1)
expect(view.queryByText('IN')).toBeNull()
expect(view.container.querySelector('[class*="ioCard"]')).toBeNull()
expect(view.container.querySelector('[class*="thinkBody"]')).not.toBeNull()
})
})