diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index bc1c6c7e32..9d0c26506e 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -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 ( - } - 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 ( ) - case 'reasoning': return + case 'reasoning': return // 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/chat/DisclosureRow.module.css b/packages/client/ui-conversation/src/client/chat/DisclosureRow.module.css index 04f2d18d0a..51937ab462 100644 --- a/packages/client/ui-conversation/src/client/chat/DisclosureRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/DisclosureRow.module.css @@ -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; diff --git a/packages/client/ui-conversation/src/client/chat/DisclosureRow.tsx b/packages/client/ui-conversation/src/client/chat/DisclosureRow.tsx index 361fb24517..a95114ff2f 100644 --- a/packages/client/ui-conversation/src/client/chat/DisclosureRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/DisclosureRow.tsx @@ -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, diff --git a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.module.css b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.module.css new file mode 100644 index 0000000000..d090c890b2 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.module.css @@ -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; + } +} diff --git a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx index aa236c87ca..7bf7d74d2f 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx @@ -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' ? : +} + /** 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 ( - } - 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)} - /> +
+ { setExpanded(value => !value) }} + collapsedContent={( + <> + + {summary} + + )} + > +
{body}
+
+
) } diff --git a/packages/client/ui-conversation/src/client/chat/ReasoningRow.module.css b/packages/client/ui-conversation/src/client/chat/ReasoningRow.module.css new file mode 100644 index 0000000000..6b2f6c0cd3 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/ReasoningRow.module.css @@ -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; + } +} diff --git a/packages/client/ui-conversation/src/client/chat/ReasoningRow.tsx b/packages/client/ui-conversation/src/client/chat/ReasoningRow.tsx new file mode 100644 index 0000000000..d02bd855b4 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/ReasoningRow.tsx @@ -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(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 ( +
+ } + title="Think" + open={expanded} + expandable + expandOnRowClick + onToggle={() => { setExpanded(value => !value) }} + collapsedContent={( + <> + + {summary} + + )} + > +
{text}
+
+
+ ) +} diff --git a/packages/client/ui-conversation/src/client/chat/use-throttled-visual-update.ts b/packages/client/ui-conversation/src/client/chat/use-throttled-visual-update.ts index e282b4b087..8fec1832d1 100644 --- a/packages/client/ui-conversation/src/client/chat/use-throttled-visual-update.ts +++ b/packages/client/ui-conversation/src/client/chat/use-throttled-visual-update.ts @@ -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( diff --git a/packages/client/ui-conversation/tests/reasoning-row.spec.tsx b/packages/client/ui-conversation/tests/reasoning-row.spec.tsx new file mode 100644 index 0000000000..243b665ce2 --- /dev/null +++ b/packages/client/ui-conversation/tests/reasoning-row.spec.tsx @@ -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() + +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( + , + ) + const summary = view.getByText('Newest reasoning tokens') + Object.defineProperties(summary, { + scrollWidth: { configurable: true, value: 300 }, + clientWidth: { configurable: true, value: 100 }, + }) + + view.rerender( + , + ) + 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( + , + ) + 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( + , + ) + 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( + , + ) + 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() + }) +})