Merge remote-tracking branch 'origin/master' into feat/web-workspace-file-links

# Conflicts:
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/src/client/chat/ToolRow.module.css
#	packages/host/apiproxy/src/native-path-opener.ts
This commit is contained in:
imccyu
2026-08-07 16:49:20 +08:00
604 changed files with 21472 additions and 2659 deletions
@@ -36,7 +36,7 @@ afterEach(() => {
const t: MessageItemProps['t'] = makeTranslate(zh, commonZh)
describe('MessageItem arms', () => {
it('user bubbles expose clock / copy / branch and no edit; copy writes the text', () => {
it('user bubbles expose clock / copy and neither branch nor edit; copy writes the text', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
@@ -45,24 +45,20 @@ describe('MessageItem arms', () => {
// Same-day clock: construct "today at 14:24" so the label stays `HH:mm`.
const now = new Date()
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
const onFork = vi.fn()
render(
<MessageItem t={t} node={{
kind: 'user', seq: 1, time,
content: [{ type: 'text', text: 'hello bubble' }] as never,
source: null,
}}
onFork={onFork}
/>,
)
expect(screen.getByText('14:24')).toBeTruthy()
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
expect(screen.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
expect(screen.queryByRole('button', { name: '在新对话中分支' })).toBeNull()
expect(screen.queryByRole('button', { name: '编辑' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('hello bubble')
fireEvent.click(screen.getByRole('button', { name: '在新对话中分支' }))
expect(onFork).toHaveBeenCalledWith(1)
})
it('user copy falls back to execCommand when clipboard.writeText is unavailable', () => {
@@ -87,30 +83,6 @@ describe('MessageItem arms', () => {
expect(exec).toHaveBeenCalledWith('copy')
})
it('keeps an unavailable branch focusable and explains why without sending a fork', () => {
const onFork = vi.fn()
render(
<MessageItem t={t} node={{
kind: 'user', seq: 1, time: 1_000,
content: [{ type: 'text', text: 'open turn' }] as never,
source: null,
}}
onFork={onFork}
forkUnavailable
/>,
)
const branch = screen.getByRole('button', { name: '在新对话中分支' }) as HTMLButtonElement
expect(branch.disabled).toBe(false)
expect(branch.getAttribute('aria-disabled')).toBe('true')
const reasonId = branch.getAttribute('aria-describedby')
expect(reasonId).not.toBeNull()
expect(document.getElementById(reasonId!)?.textContent).toBe('仅可从已完成轮次的最后一条消息分支')
fireEvent.click(branch)
expect(onFork).not.toHaveBeenCalled()
fireEvent.focus(branch)
expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支')
})
it('user copy never claims success when the host rejects the write', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
@@ -212,19 +184,17 @@ describe('MessageItem arms', () => {
expect(vi.getTimerCount()).toBe(0)
})
it('consumed steering is captioned as an interjection and keeps copy and branch actions', () => {
it('consumed steering is captioned as an interjection and keeps copy without branch', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
const fork = vi.fn()
const view = render(
<MessageItem t={t} node={{
kind: 'steering', messageId: 'steer-message', seq: 2, time: 1_000, turn: 1, source: null,
content: [{ type: 'text', text: 'steer!' }, { type: 'image', data: 'x' }] as never,
} as never}
onFork={fork}
/>,
)
expect(view.getByText('插话')).toBeTruthy()
@@ -232,8 +202,7 @@ describe('MessageItem arms', () => {
expect(view.getByText(/附加内容块/)).toBeTruthy()
fireEvent.click(view.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('steer!')
fireEvent.click(view.getByRole('button', { name: '在新对话中分支' }))
expect(fork).toHaveBeenCalledWith(2)
expect(view.queryByRole('button', { name: '在新对话中分支' })).toBeNull()
})
it('context uses the Tool calls disclosure chrome and keeps its body collapsed by default', () => {
@@ -1002,6 +971,31 @@ describe('small branch tails', () => {
expect(streaming.queryByText('14:24')).toBeNull()
})
it('keeps an unavailable branch focusable and explains why without sending a fork', () => {
const onFork = vi.fn()
render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'text', text: 'answer before a trailing tool row' }]}
streaming={false}
time={1_000}
seq={1}
onFork={onFork}
forkUnavailable
/>,
)
const branch = screen.getByRole('button', { name: '在新对话中分支' }) as HTMLButtonElement
expect(branch.disabled).toBe(false)
expect(branch.getAttribute('aria-disabled')).toBe('true')
const reasonId = branch.getAttribute('aria-describedby')
expect(reasonId).not.toBeNull()
expect(document.getElementById(reasonId!)?.textContent).toBe('仅可从已完成轮次的最后一条消息分支')
fireEvent.click(branch)
expect(onFork).not.toHaveBeenCalled()
fireEvent.focus(branch)
expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支')
})
it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => {
// Cache hit is null only when all three prompt buckets are zero (pure
// output accounting) — any billed input makes it a real 0%.
@@ -301,6 +301,20 @@ describe('ToolRow', () => {
expect(view.getByText('List files')).toBeTruthy()
})
it('renders summarySuffix outside the ellipsized summary span, and drops it on a failure line', () => {
const view = render(<ToolRow {...rowProps} summarySuffix="+2" />)
const summary = view.getByText('List files')
const suffix = view.getByText('+2')
// Separate spans: .summary truncates, the suffix must not travel inside it.
expect(summary.contains(suffix)).toBe(false)
view.unmount()
// The failure line replaces the summary wholesale, so the suffix goes with it.
const failed = render(
<ToolRow {...rowProps} state="error" errorSummary="boom" summarySuffix="+2" />,
)
expect(failed.queryByText('+2')).toBeNull()
})
it('an error file row drops the open-file link (the summary is failure prose, not the path)', () => {
const open = vi.fn()
const view = render(
@@ -20,7 +20,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
import { createChatStore } from '../src/client/stores.ts'
import { ChatView } from '../src/client/chat/ChatView.tsx'
import { zh } from '../src/client/locales.ts'
import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs, runningTurnStartTime } from '../src/client/chat/chat-flow.ts'
import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, flowKeys, runningTurnStartTime } from '../src/client/chat/chat-flow.ts'
import { formatRunDuration } from '../src/client/chat/message-chrome.ts'
afterEach(() => {
@@ -158,9 +158,9 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, forkAt, setSelection }
}
/** Simulate reader input before the browser delivers the host scroll event. */
/** Simulate reader input (any device): a delivered position that deviates
* from the observed-top ledger of programmatic writes. */
function readerScroll(element: HTMLElement, top: number): void {
fireEvent.wheel(element, { deltaY: top < element.scrollTop ? -120 : 120 })
element.scrollTop = top
fireEvent.scroll(element)
}
@@ -225,12 +225,12 @@ describe('chat-flow derivation', () => {
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5')
})
it('assistantActionsSeqs keeps only the last content assistant per turn', () => {
it('assistantActionsSeqs keeps only the last content assistant per completed turn', () => {
const thinkOnly: AssistantMessageNode = {
kind: 'assistant', seq: 3, time: 3_000, turn: 1, step: 2,
blocks: [{ kind: 'reasoning', text: 'planning' }],
}
const seqs = assistantActionsSeqs([
const nodes: ConversationNode[] = [
user(1, 'hi'),
assistant(2, 'looking', 1),
thinkOnly,
@@ -238,8 +238,11 @@ describe('chat-flow derivation', () => {
assistant(5, 'done', 1),
user(6, 'again'),
assistant(7, 'second turn', 2),
])
expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7])
]
expect([...assistantActionsSeqs(nodes, new Map([[1, 5], [2, 7]]))].sort((a, b) => a - b)).toEqual([5, 7])
// Turn 2 is still producing steps: its latest narration owns nothing, and
// the settled turn 1 keeps its seat.
expect([...assistantActionsSeqs(nodes, new Map([[1, 5]]))]).toEqual([5])
})
it('runningTurnStartTime selects the latest turn/start without a turn/end', () => {
@@ -261,7 +264,7 @@ describe('chat-flow derivation', () => {
expect(formatRunDuration(125_000, t)).toBe('2分05秒')
})
it('messageBranchSeqs keeps only message rows at completed transcript tails', () => {
it('assistantBranchSeqs keeps only content-assistant tails; user/steering tails own no branch', () => {
const interruptedThink: AssistantMessageNode = {
kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2,
blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true,
@@ -276,8 +279,8 @@ describe('chat-flow derivation', () => {
user(10, 'user-only tail'),
user(13, 'steering tail'),
]
const seqs = messageBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11], [4, 14]]))
expect([...seqs]).toEqual([7, 10, 13])
const seqs = assistantBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11], [4, 14]]))
expect([...seqs]).toEqual([7])
})
})
@@ -401,21 +404,24 @@ describe('ChatView', () => {
expect(view.getAllByText('interrupt now')).toHaveLength(1)
expect(view.container.querySelector('[data-pending-steering]')).toBeNull()
expect(view.getAllByText('插话')).toHaveLength(1)
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2)
// Only the durable steering bubble: the turn is still running, so its
// assistant narration owns no footer yet, and a steering bubble never
// carries a branch action.
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(1)
const durableBubble = view.getByText('interrupt now').closest('[class*="userRow"]') as HTMLElement
const unavailable = within(durableBubble).getByRole('button', { name: '在新对话中分支' })
expect(unavailable.getAttribute('aria-disabled')).toBe('true')
fireEvent.click(unavailable)
expect(h.forkAt).not.toHaveBeenCalled()
expect(within(durableBubble).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
act(() => {
h.set({ running: false, turnEnds: new Map([[1, 3]]) })
})
// The completed turn's transcript tail is the steering bubble, not the
// narration, so the assistant's branch action stays unavailable and the
// steering bubble still offers none.
const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' })
expect(branchButtons).toHaveLength(2)
expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null])
fireEvent.click(branchButtons[1]!)
expect(h.forkAt).toHaveBeenCalledWith(2)
expect(branchButtons).toHaveLength(1)
expect(branchButtons[0]!.getAttribute('aria-disabled')).toBe('true')
fireEvent.click(branchButtons[0]!)
expect(h.forkAt).not.toHaveBeenCalled()
})
it('keeps a later pending occurrence visible when it reuses a durable MessageId', () => {
@@ -518,11 +524,35 @@ describe('ChatView', () => {
turnEnds: new Map([[1, 4], [2, 6]]),
})
const view = render(<h.ChatView {...h.props} />)
// Every message footer keeps branch visible; only completed assistant tails enable it.
// Branch renders only under assistant answers; user bubbles keep copy alone.
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4)
const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' })
expect(branchButtons).toHaveLength(4)
expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null, 'true', null])
expect(branchButtons).toHaveLength(2)
expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual([null, null])
})
it('withholds assistant IconActions while the turn is still running', () => {
const h = makeHarness({
running: true,
runningCalls: [runningCall('a')],
nodes: [
user(1, 'first'),
assistant(2, 'previous answer', 1),
user(4, 'second'),
assistant(5, 'mid-turn text', 2),
],
// Boundary seqs follow the log: a turn/end is strictly after its own nodes.
turnEnds: new Map([[1, 3]]),
})
const view = render(<h.ChatView {...h.props} />)
// 2 user + the settled turn-1 tail, which keeps its seat while a later
// turn runs; turn 2's narration stays chrome-free while its tool runs, so
// the footer never appears and then moves.
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(3)
expect(view.getByText('mid-turn text')).toBeTruthy()
// turn/end lands: the same node becomes the settled answer and takes the seat.
act(() => { h.set({ running: false, runningCalls: [], turnEnds: new Map([[1, 3], [2, 6]]) }) })
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4)
})
it('the actions-owning assistant footer shows the turn run time', () => {
@@ -606,11 +636,11 @@ describe('ChatView', () => {
turnEnds: new Map([[1, 3]]),
})
const view = render(<h.ChatView {...h.props} />)
// The user bubble offers no branch; the settled answer's is live.
const buttons = view.getAllByRole('button', { name: '在新对话中分支' })
expect(buttons).toHaveLength(2)
expect(buttons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null])
expect(buttons).toHaveLength(1)
expect(buttons[0]!.getAttribute('aria-disabled')).toBeNull()
fireEvent.click(buttons[0]!)
fireEvent.click(buttons[1]!)
expect(h.forkAt.mock.calls).toEqual([[2]])
})
@@ -626,10 +656,9 @@ describe('ChatView', () => {
const view = render(<h.ChatView {...h.props} />)
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2)
const buttons = view.getAllByRole('button', { name: '在新对话中分支' })
expect(buttons).toHaveLength(2)
expect(buttons.every(button => button.getAttribute('aria-disabled') === 'true')).toBe(true)
expect(buttons).toHaveLength(1)
expect(buttons[0]!.getAttribute('aria-disabled')).toBe('true')
fireEvent.click(buttons[0]!)
fireEvent.click(buttons[1]!)
expect(h.forkAt).not.toHaveBeenCalled()
})
@@ -912,7 +941,7 @@ describe('ChatView', () => {
expect(view.queryByLabelText('回到底部')).toBeNull()
})
it('keeps following when a delayed clamp scroll arrives after layout regrows', () => {
it('keeps following when a stream-finalization shrink clamp delivers its scroll', () => {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
@@ -920,12 +949,12 @@ describe('ChatView', () => {
scroller.scrollTop = 700
fireEvent.scroll(scroller)
// The wheel cannot move farther down. A stream-finalization shrink clamps
// the old position, then reflow grows the layout before scroll delivery.
fireEvent.wheel(scroller, { deltaY: 120 })
metrics.setLayout(1_040, 500)
// Stream finalization shrinks the column: the browser clamps the pinned
// position onto the new floor and delivers a scroll event. The clamp
// lands exactly on the ledger's floor min, so it is not reader input.
metrics.setLayout(800, 700)
fireEvent.scroll(scroller)
expect(scroller.scrollTop).toBe(740)
expect(scroller.scrollTop).toBe(500)
expect(view.queryByLabelText('回到底部')).toBeNull()
expect(h.chatScroll.read()).toBeNull()
@@ -934,7 +963,7 @@ describe('ChatView', () => {
expect(scroller.scrollTop).toBe(900)
})
it('uses the last delivered top when compositor scrolling precedes passive wheel delivery', () => {
it('uses the last delivered top when compositor scrolling precedes scroll delivery', () => {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
@@ -942,8 +971,10 @@ describe('ChatView', () => {
scroller.scrollTop = 700
fireEvent.scroll(scroller)
// Chromium advances compositor geometry before delivering the event:
// attribution must compare against the observed-top ledger, never a
// baseline sampled from already-moved raw geometry.
scroller.scrollTop = 500
fireEvent.wheel(scroller, { deltaY: -200 })
fireEvent.scroll(scroller)
expect(view.getByLabelText('回到底部')).toBeTruthy()
})
@@ -1,10 +1,13 @@
// @vitest-environment jsdom
/**
* Todo display acceptance: the TodoPanel plan strip (empty-hidden, status
* rows, collapse), its TodoDock adapter (selects the plan off the session
* snapshot and follows changes), and the todo_write toolview row (progress
* summary from args, generic fallback on malformed JSON, shared ToolRow
* state dots and leading expansion).
* Todo display acceptance: the TodoPanel plan strip (empty-hidden, status rows
* including several `in_progress` at once, collapse), its TodoDock adapter
* (selects the plan off the session snapshot and follows changes), the row's
* plan summary (counts plus the two halves of the active summary — the named
* task and the `+N` count that parallel work adds, kept apart so the row never
* ellipsizes the count away), and the todo_write toolview row (progress summary
* from args, generic fallback on malformed JSON, shared ToolRow state dots and
* leading expansion).
*/
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -17,6 +20,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
import { TodoRow, todoToolview } from '../src/client/toolviews/todo-row.tsx'
import type { TodoDockProps } from '../src/client/skeleton/TodoPanel.tsx'
import { TodoDock, TodoPanel, todoDockEntry } from '../src/client/skeleton/TodoPanel.tsx'
import { planSummary } from '../src/client/toolviews/plan-summary.ts'
import { NS, zh } from '../src/client/locales.ts'
type TodoRowProps = Parameters<typeof TodoRow>[0]
@@ -32,6 +36,49 @@ const LIST: TodoItem[] = [
{ content: '补测试', status: 'pending' },
]
/** A parallel plan: three tasks running at once (concurrent subagents). */
const PARALLEL: TodoItem[] = [
{ content: '搭骨架', status: 'completed' },
{ content: '写组件', status: 'in_progress' },
{ content: '跑后台构建', status: 'in_progress' },
{ content: '读源码', status: 'in_progress' },
{ content: '补测试', status: 'pending' },
]
describe('planSummary', () => {
it('counts done/total and names the single active item with no extra count', () => {
expect(planSummary(LIST)).toEqual({ done: 1, total: 3, activeContent: '写组件', activeExtra: 0 })
})
it('reports the extra active count separately when several items are in progress', () => {
// Parallel work marks several: naming one and hiding the rest would lose
// them, and the count stays unjoined so the row cannot ellipsize it.
expect(planSummary(PARALLEL)).toEqual({ done: 1, total: 5, activeContent: '写组件', activeExtra: 2 })
})
it('has no hint when nothing is in progress', () => {
expect(planSummary([{ content: '都完了', status: 'completed' }]))
.toEqual({ done: 1, total: 1, activeContent: null, activeExtra: 0 })
})
it('has no hint when the first active item carries no usable content (model JSON)', () => {
// Unvalidated args: a missing, mistyped, empty, or whitespace-only content
// yields no hint — and no orphan count, even with a second active item to
// count. Whitespace-only is the tool's own rejection rule (trimmed
// non-empty), and a rejected call keeps its args verbatim.
expect(planSummary([{ status: 'in_progress' }, { content: 'x', status: 'in_progress' }]))
.toMatchObject({ activeContent: null, activeExtra: 0 })
expect(planSummary([{ content: 42, status: 'in_progress' }]).activeContent).toBeNull()
expect(planSummary([{ content: '', status: 'in_progress' }]).activeContent).toBeNull()
expect(planSummary([{ content: ' ', status: 'in_progress' }, { content: 'x', status: 'in_progress' }]))
.toMatchObject({ activeContent: null, activeExtra: 0 })
})
it('is empty-safe', () => {
expect(planSummary([])).toEqual({ done: 0, total: 0, activeContent: null, activeExtra: 0 })
})
})
describe('TodoPanel', () => {
it('renders nothing while the list is empty', () => {
const { container } = render(<TodoPanel todos={[]} t={t} />)
@@ -80,6 +127,18 @@ describe('TodoPanel', () => {
expect(screen.getAllByRole('listitem')).toHaveLength(3)
})
it('marks every parallel active item, and counts them all in the header', () => {
render(<TodoPanel todos={PARALLEL} t={t} />)
fireEvent.click(screen.getByRole('button', { expanded: false }))
// The old unconditional cap made this list unreachable: three items carry
// the in-progress glyph at once, and the header counts all three.
const statuses = screen.getAllByRole('listitem').map(li => li.getAttribute('data-status'))
expect(statuses.filter(s => s === 'in_progress')).toHaveLength(3)
expect(screen.getByText('跑后台构建')).toBeTruthy()
expect(screen.getByText('读源码')).toBeTruthy()
expect(screen.getByText('1 已完成 · 3 进行中 · 1 待处理')).toBeTruthy()
})
it('an all-completed list collapses the summary to the done count alone', () => {
render(<TodoPanel todos={[{ content: '都完了', status: 'completed' }]} t={t} />)
expect(screen.getByRole('button', { expanded: false })).toBeTruthy()
@@ -145,12 +204,30 @@ describe('TodoRow', () => {
expect(screen.getByText('1/3 已完成 · 写组件')).toBeTruthy()
})
it('reports the extra active count outside the ellipsized summary text', () => {
const { container } = render(<TodoRow {...rowProps(resultNode(JSON.stringify({ todos: PARALLEL })))} />)
const text = screen.getByText('1/5 已完成 · 写组件')
const extra = screen.getByText('+2')
// Separate spans: .summary truncates, the count must not travel inside it.
expect(text.contains(extra)).toBe(false)
expect(container.textContent).toContain('1/5 已完成 · 写组件+2')
})
it('omits the active clause when no item is in progress and reads running-call args', () => {
const args = JSON.stringify({ todos: [{ content: 'x', status: 'completed' }] })
render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
expect(screen.getByText('1/1 已完成')).toBeTruthy()
})
it('keeps the counts when an active item has unusable content, instead of the generic summary', () => {
// planSummary yields activeContent null here, but the counts are known good,
// so the row drops only the active clause — `?? model.summary` never runs.
const args = JSON.stringify({ todos: [{ content: 'done', status: 'completed' }, { content: 42, status: 'in_progress' }] })
const { container } = render(<TodoRow {...rowProps(resultNode(args))} />)
expect(screen.getByText('1/2 已完成')).toBeTruthy()
expect(container.textContent).not.toContain('+')
})
it('keeps the non-ok execution states visible through the shared row states', () => {
// A running call (no result yet) carries the running state (row sweep).
const args = JSON.stringify({ todos: LIST })
@@ -0,0 +1,45 @@
/**
* The one-line contract of the ToolRow summary line as CSS text. jsdom has no
* layout, so the rendering specs (chat-tool-row.spec.tsx) can pin which spans
* exist but not whether a narrow row still fits on one line; these read the
* declarations the layout depends on.
*/
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const css = readFileSync(fileURLToPath(new URL('../src/client/chat/ToolRow.module.css', import.meta.url)), 'utf8')
/** Declarations only: the sheet's prose names the properties it explains. */
const declarationText = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
function declarations(selector: string): string[] {
// Anchored at a rule boundary: an unanchored match would silently read a
// compound rule that merely contains the selector (`.root:hover .summarySuffix`)
// if one ever lands above the base rule.
const rule = new RegExp(`(?:^|\\})\\s*\\${selector}\\s*\\{([^{}]*)\\}`).exec(declarationText)
if (rule === null) throw new Error(`ToolRow.module.css has no \`${selector}\` rule`)
return (rule[1] ?? '').split(';').map(part => part.trim()).filter(Boolean)
}
describe('ToolRow.module.css summary line', () => {
it('keeps the summary suffix on one line and unshrunk', () => {
// `flex: none` stops the box shrinking, not the text wrapping: without
// `nowrap`, a row too narrow for title + separator + suffix wraps the `+n`
// onto a second line — the exact case the slot exists to survive.
expect(declarations('.summarySuffix')).toEqual(expect.arrayContaining([
'flex: none',
'white-space: nowrap',
]))
})
it('leaves the truncation to the summary text alone', () => {
// The suffix must never ellipsize: a clipped count reads as a smaller
// number rather than as missing information.
expect(declarations('.summary')).toEqual(expect.arrayContaining([
'overflow: hidden',
'text-overflow: ellipsis',
'white-space: nowrap',
]))
expect(declarations('.summarySuffix')).not.toEqual(expect.arrayContaining(['text-overflow: ellipsis']))
})
})