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()
+ })
+})