Merge remote-tracking branch 'origin/master' into feat/todo-multi-in-progress

# Conflicts:
#	.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-23-web-todo-display.md
#	.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md
#	apps/web/tests/todo-display.snapshot.ts
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/README.md
#	packages/client/ui-conversation/README.zh.md
#	packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx
#	packages/client/ui-conversation/tests/todo-panel.spec.tsx
This commit is contained in:
Chinesezjc
2026-07-27 17:29:44 +08:00
25 changed files with 262 additions and 206 deletions
@@ -1,12 +1,13 @@
// @vitest-environment jsdom
/**
* Todo display acceptance: the shared plan model (counts plus the two halves of
* the one-line active hint — the named task and the `+N` count that parallel
* work adds, kept apart so neither surface ellipsizes the count away), the
* TodoPanel plan strip (empty-hidden, status rows, collapse with active
* hint), 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, error badge, keyboard activation).
* 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, error badge, keyboard
* activation).
*/
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -18,7 +19,7 @@ import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/clien
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/contract/todo-plan-model.ts'
import { planSummary } from '../src/client/toolviews/plan-summary.ts'
afterEach(cleanup)
@@ -44,7 +45,7 @@ describe('planSummary', () => {
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 neither surface can ellipsize it.
// them, and the count stays unjoined so the row cannot ellipsize it.
expect(planSummary(PARALLEL)).toEqual({ done: 1, total: 5, activeContent: '写组件', activeExtra: 2 })
})
@@ -73,50 +74,47 @@ describe('TodoPanel', () => {
expect(container.innerHTML).toBe('')
})
it('shows progress, one row per item with its status, and strikes done items', () => {
it('shows progress, one row per item with its status glyph', () => {
render(<TodoPanel todos={LIST} />)
expect(screen.getByTestId('todo-panel')).toBeTruthy()
expect(screen.getByText('1/3')).toBeTruthy()
expect(screen.getByText('To-dos')).toBeTruthy()
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
const items = screen.getAllByRole('listitem')
expect(items.map(li => li.getAttribute('data-status'))).toEqual(['completed', 'in_progress', 'pending'])
expect(screen.getByText('搭骨架')).toBeTruthy()
expect(screen.getByText('写组件')).toBeTruthy()
// Each status row carries an SVG glyph (not a text bullet).
expect(items.every(li => li.querySelector('svg') !== null)).toBe(true)
})
it('collapse hides the list and surfaces the active item in the header; expand restores', () => {
it('collapse hides the list; expand restores; header keeps the count summary', () => {
render(<TodoPanel todos={LIST} />)
const header = screen.getByRole('button', { expanded: true })
fireEvent.click(header)
expect(screen.queryByRole('list')).toBeNull()
// Collapsed header carries the in-progress content as the one-line hint.
expect(screen.getByText('写组件')).toBeTruthy()
// Collapsed header is title + progress only (no in-progress content hint).
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
expect(screen.queryByText('写组件')).toBeNull()
fireEvent.click(screen.getByRole('button', { expanded: false }))
expect(screen.getAllByRole('listitem')).toHaveLength(3)
})
it('shows every parallel active item expanded, and counts the extra ones collapsed', () => {
it('marks every parallel active item, and counts them all in the header', () => {
render(<TodoPanel todos={PARALLEL} />)
// Expanded: one row per item, all three active ones carrying the ● glyph.
// The cap this branch removes 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()
// Collapsed: the hint reports the other two rather than dropping them, and
// the count lives in its own element — the task-name span is the one that
// ellipsizes, so a joined "写组件 +2" would lose the count on a narrow view.
fireEvent.click(screen.getByRole('button', { expanded: true }))
expect(screen.queryByRole('list')).toBeNull()
const name = screen.getByText('写组件')
const extra = screen.getByText('+2')
expect(extra).not.toBe(name)
expect(name.contains(extra)).toBe(false)
expect(screen.getByText('1/5 tasks · 3 in progress')).toBeTruthy()
})
it('collapsed header omits the hint when nothing is in progress', () => {
it('collapsed header still shows zero in-progress when nothing is active', () => {
render(<TodoPanel todos={[{ content: '都完了', status: 'completed' }]} />)
fireEvent.click(screen.getByRole('button', { expanded: true }))
expect(screen.queryByText('都完了')).toBeNull()
expect(screen.getByText('1/1')).toBeTruthy()
expect(screen.getByText('1/1 tasks · 0 in progress')).toBeTruthy()
})
})
@@ -131,7 +129,7 @@ describe('TodoDock', () => {
render(<TodoDock {...dockProps(store)} />)
expect(screen.queryByTestId('todo-panel')).toBeNull()
act(() => { store.set({ todos: LIST }) })
expect(screen.getByText('1/3')).toBeTruthy()
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
// A rollback to the empty list retires the strip (the panel owns no data).
act(() => { store.set({ todos: [] }) })
expect(screen.queryByTestId('todo-panel')).toBeNull()