Преглед изворни кода

cleanup(client): detach conversation rows from Tool presentation

imccyu пре 2 месеци
родитељ
комит
1674af147e

+ 3 - 31
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 (
-    <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 - 1
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;

+ 2 - 2
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,

+ 86 - 0
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;
+  }
+}

+ 38 - 14
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' ? <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>
   )
 }

+ 81 - 0
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;
+  }
+}

+ 62 - 0
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<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 - 3
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(

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