cleanup(client): detach conversation rows from Tool presentation
This commit is contained in:
@@ -12,13 +12,11 @@
|
|||||||
import { memo, useMemo } from 'react'
|
import { memo, useMemo } from 'react'
|
||||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||||
import {
|
import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
IconThinkOutline14, JsonBlock, MarkdownText,
|
|
||||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
||||||
import type { ChatViewSlotProps, TurnTailOwnerProps } from '../contract/slots.ts'
|
import type { ChatViewSlotProps, TurnTailOwnerProps } from '../contract/slots.ts'
|
||||||
import { hasContentText } from './chat-flow.ts'
|
import { hasContentText } from './chat-flow.ts'
|
||||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||||
import { ToolRow } from './ToolRow.tsx'
|
import { ReasoningRow } from './ReasoningRow.tsx'
|
||||||
import css from './AssistantMarkdown.module.css'
|
import css from './AssistantMarkdown.module.css'
|
||||||
|
|
||||||
export interface AssistantMarkdownProps {
|
export interface AssistantMarkdownProps {
|
||||||
@@ -49,18 +47,6 @@ export interface AssistantMarkdownProps {
|
|||||||
t: ChatViewSlotProps['t']
|
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). */
|
/** Joined text blocks for the copy action (reasoning / tool heads stay out). */
|
||||||
function copyText(blocks: readonly AssistantBlock[]): string {
|
function copyText(blocks: readonly AssistantBlock[]): string {
|
||||||
const parts: 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). */
|
/** 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({
|
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||||
blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail, t,
|
blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail, t,
|
||||||
}: AssistantMarkdownProps) {
|
}: AssistantMarkdownProps) {
|
||||||
@@ -109,7 +81,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
|||||||
case 'text': return (
|
case 'text': return (
|
||||||
<MarkdownText key={i} text={block.text} streaming={streaming} codeLabels={codeLabels} />
|
<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.
|
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
|
||||||
case 'tool-call': return null
|
case 'tool-call': return null
|
||||||
default: return (
|
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 {
|
.root {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export interface DisclosureRowProps {
|
|||||||
expandOnRowClick?: boolean | undefined
|
expandOnRowClick?: boolean | undefined
|
||||||
/** Replaces the collapsed icon with a chevron while the row is hovered. */
|
/** Replaces the collapsed icon with a chevron while the row is hovered. */
|
||||||
previewChevron?: boolean | undefined
|
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
|
keepContentWhenOpen?: boolean | undefined
|
||||||
collapsedContent?: ReactNode
|
collapsedContent?: ReactNode
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
@@ -28,7 +28,7 @@ export interface DisclosureRowProps {
|
|||||||
/**
|
/**
|
||||||
* Render one disclosure header and its controlled expanded content.
|
* Render one disclosure header and its controlled expanded content.
|
||||||
* @param props - Visual content, controlled state, and interaction policy.
|
* @param props - Visual content, controlled state, and interaction policy.
|
||||||
* @returns The disclosure row.
|
* @returns the disclosure row.
|
||||||
*/
|
*/
|
||||||
export function DisclosureRow({
|
export function DisclosureRow({
|
||||||
icon,
|
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
|
// fallback (an unregistered command name lands here); registrants may compose
|
||||||
// it as a base, feeding the same owner payload through.
|
// it as a base, feeding the same owner payload through.
|
||||||
|
|
||||||
import { ToolRow } from './ToolRow.tsx'
|
import { useState, type ReactNode } from 'react'
|
||||||
import type { ToolRowState } from '../contract/tool-call-model.ts'
|
|
||||||
import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts'
|
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). */
|
/** 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'
|
if (outcome === null) return 'running'
|
||||||
return outcome.kind === 'error' ? 'error' : 'ok'
|
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). */
|
/** Card props: the owner payload plus the render site's locale seat (plain prop). */
|
||||||
export interface GenericCommandCardProps extends CommandRowOwnerProps {
|
export interface GenericCommandCardProps extends CommandRowOwnerProps {
|
||||||
t: ChatViewSlotProps['t']
|
t: ChatViewSlotProps['t']
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GenericCommandCard({ node, t }: GenericCommandCardProps) {
|
export function GenericCommandCard({ node, t }: GenericCommandCardProps) {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
const text = node.outcome?.text
|
const text = node.outcome?.text
|
||||||
const summary = node.outcome === null
|
const summary = node.outcome === null
|
||||||
? t('command.running')
|
? t('command.running')
|
||||||
@@ -30,16 +38,32 @@ export function GenericCommandCard({ node, t }: GenericCommandCardProps) {
|
|||||||
// settlement text says (`permission · preset workspace-write`). A
|
// settlement text says (`permission · preset workspace-write`). A
|
||||||
// cross-window node whose run page fell out of the window has no name.
|
// cross-window node whose run page fell out of the window has no name.
|
||||||
const title = node.name ?? t('command.title')
|
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 (
|
return (
|
||||||
<ToolRow
|
<div className={css.root} data-state={state}>
|
||||||
t={t}
|
<DisclosureRow
|
||||||
variant="others"
|
rowClassName={css.row}
|
||||||
icon={<IconApiOutline14 size={14} />}
|
leadingClassName={css.leading}
|
||||||
title={title}
|
titleClassName={css.title}
|
||||||
summary={summary}
|
chevronClassName={css.chevron}
|
||||||
// Expandable only when the outcome text overflows a one-line summary.
|
icon={leadingFor(state)}
|
||||||
body={text !== undefined && text.includes('\n') ? text : null}
|
title={title}
|
||||||
state={stateOf(node.outcome)}
|
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. */
|
/** Frame-throttled scheduling for non-essential visual alignment. */
|
||||||
|
|
||||||
import { useCallback, useLayoutEffect, useRef } from 'react'
|
import { useCallback, useLayoutEffect, useRef } from 'react'
|
||||||
|
|
||||||
const DEFAULT_INTERVAL_FRAMES = 3
|
const DEFAULT_INTERVAL_FRAMES = 3
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return a stable scheduler that coalesces visual updates over a frame interval.
|
* 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 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.
|
* @returns a stable function that schedules the latest update.
|
||||||
*/
|
*/
|
||||||
export function useThrottledVisualUpdate(
|
export function useThrottledVisualUpdate(
|
||||||
|
|||||||
115
packages/client/ui-conversation/tests/reasoning-row.spec.tsx
Normal file
115
packages/client/ui-conversation/tests/reasoning-row.spec.tsx
Normal file
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user