1. 统一会话列宽度轴:新增 --dsh-chat-content-width(748px),输入框、todo、goal、queue、approval、plan review、ask question 等容器的宽度与边距全部由该变量推导,消除各面板之间的像素漂移,窄视口下的边缘留白也保持一致。 2. 输入框自适应与细节:控制行改为容器查询(460px 阈值以下权限选择器只显示图标+下拉,隐藏文字);卡片圆角 20→22、行内边距调整并整体下移 2px(发送按钮除外);permission/model 触发器统一 24px 圆角;Plan 与 Read Only 间距 +8。 3. 修复浮层菜单溢出:slash 菜单与命令弹层钳制到输入卡片宽度,超长行以省略号截断;Tooltip 增加 12px 视口边缘安全距离。 4. 增加与替换图标:Add provider 改用输入框同款加号图标(IconPlusOutline16),统一图标尺寸与字号。 5. 统一 Settings → Models 组件:补齐按钮 hover 态、select 下拉箭头不再贴边、标题区与 provider 卡片间距 +12。 6. 侧边栏交互:add workspace / group by / create session / 收起侧边栏四个图标按钮增加 500ms 延迟 tooltip(前两个向下弹出);展开态的 New Session 不再重复显示 tooltip;侧边栏窄屏自适应收起逻辑微调。 7. 其他:hero 区 workspace 徽章右移对齐;附带 Agent Note(中英双语)记录共享宽度轴与容器查询的设计取舍。
207 lines
10 KiB
TypeScript
207 lines
10 KiB
TypeScript
// @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).
|
|
*/
|
|
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
|
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import type { TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
|
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
|
// Export discipline: packages/client/AGENTS.md.
|
|
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 { NS, zh } from '../src/client/locales.ts'
|
|
|
|
type TodoRowProps = Parameters<typeof TodoRow>[0]
|
|
|
|
// Mirrors the real lookup chain (conversation namespace, then common).
|
|
const t: TodoDockProps['t'] = makeTranslate(zh, commonZh)
|
|
|
|
afterEach(cleanup)
|
|
|
|
const LIST: TodoItem[] = [
|
|
{ content: '搭骨架', status: 'completed' },
|
|
{ content: '写组件', status: 'in_progress' },
|
|
{ content: '补测试', status: 'pending' },
|
|
]
|
|
|
|
describe('TodoPanel', () => {
|
|
it('renders nothing while the list is empty', () => {
|
|
const { container } = render(<TodoPanel todos={[]} t={t} />)
|
|
expect(container.innerHTML).toBe('')
|
|
})
|
|
|
|
it('starts collapsed with the per-status count summary visible', () => {
|
|
render(<TodoPanel todos={LIST} t={t} />)
|
|
expect(screen.getByTestId('todo-panel')).toBeTruthy()
|
|
expect(screen.getByText('任务')).toBeTruthy()
|
|
expect(screen.getByText('1 已完成 · 1 进行中 · 1 待处理')).toBeTruthy()
|
|
expect(screen.getByRole('button', { expanded: false })).toBeTruthy()
|
|
expect(screen.queryByRole('list')).toBeNull()
|
|
})
|
|
|
|
it('omits the completed segment while nothing is done yet', () => {
|
|
render(<TodoPanel todos={[
|
|
{ content: '写组件', status: 'in_progress' },
|
|
{ content: '补测试', status: 'pending' },
|
|
]} t={t} />)
|
|
expect(screen.getByText('1 进行中 · 1 待处理')).toBeTruthy()
|
|
expect(screen.queryByText(/已完成/)).toBeNull()
|
|
})
|
|
|
|
it('expands to show one row per item with its status glyph', () => {
|
|
render(<TodoPanel todos={LIST} t={t} />)
|
|
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
|
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 an expanded list; expand restores; header keeps the count summary', () => {
|
|
render(<TodoPanel todos={LIST} t={t} />)
|
|
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
|
const header = screen.getByRole('button', { expanded: true })
|
|
fireEvent.click(header)
|
|
expect(screen.queryByRole('list')).toBeNull()
|
|
// Collapsed header is title + progress only (no in-progress content hint).
|
|
expect(screen.getByText('1 已完成 · 1 进行中 · 1 待处理')).toBeTruthy()
|
|
expect(screen.queryByText('写组件')).toBeNull()
|
|
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
|
expect(screen.getAllByRole('listitem')).toHaveLength(3)
|
|
})
|
|
|
|
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()
|
|
expect(screen.queryByText('都完了')).toBeNull()
|
|
expect(screen.getByText('1 已完成')).toBeTruthy()
|
|
expect(screen.queryByText(/进行中|待处理/)).toBeNull()
|
|
})
|
|
})
|
|
|
|
/** Dock props stub: the adapter reads the 'todos' projection only; the rest of the owner share is unused. */
|
|
function dockProps(store: ReturnType<typeof createSnapshotStore<{ value: readonly TodoItem[] | null | undefined }>>): TodoDockProps {
|
|
const useProjection = (_key: string, selector?: (v: unknown) => unknown) =>
|
|
bindSnapshotSelector(store)(s => (selector ?? (v => v))(s.value))
|
|
return { useProjection, t } as unknown as TodoDockProps
|
|
}
|
|
|
|
describe('TodoDock', () => {
|
|
it('reads the host-computed todos projection and follows pushed updates', () => {
|
|
const store = createSnapshotStore<{ value: readonly TodoItem[] | null | undefined }>({ value: undefined })
|
|
render(<TodoDock {...dockProps(store)} />)
|
|
// Capability absent (no baseline/frame yet) renders nothing.
|
|
expect(screen.queryByTestId('todo-panel')).toBeNull()
|
|
act(() => { store.set({ value: LIST }) })
|
|
expect(screen.getByText('1 已完成 · 1 进行中 · 1 待处理')).toBeTruthy()
|
|
// The pre-first-write whole value (null) retires the strip (the panel owns no data).
|
|
act(() => { store.set({ value: null }) })
|
|
expect(screen.queryByTestId('todo-panel')).toBeNull()
|
|
})
|
|
|
|
it('registers before the goal and queue entries', () => {
|
|
expect(todoDockEntry.name).toBe('conversation-todo-dock')
|
|
expect(todoDockEntry.inject).toEqual(['slots', 'conversation'])
|
|
const register = vi.fn()
|
|
todoDockEntry.apply({ slots: { register } } as never)
|
|
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock)
|
|
})
|
|
})
|
|
|
|
const resultNode = (argsRaw: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
|
|
kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
|
|
call: { name: 'todo_write', argsRaw },
|
|
content: [], isError: false, callView: null, resultView: null, ...over,
|
|
})
|
|
|
|
function rowProps(block: unknown): TodoRowProps {
|
|
return {
|
|
callId: 'c1', toolName: 'todo_write', block,
|
|
openFile: vi.fn(),
|
|
sessionId: 's1',
|
|
useSessions: () => undefined,
|
|
t,
|
|
} as unknown as TodoRowProps
|
|
}
|
|
|
|
describe('TodoRow', () => {
|
|
const ARGS = JSON.stringify({ todos: LIST })
|
|
|
|
it('summarizes counts and the active item from the call args', () => {
|
|
render(<TodoRow {...rowProps(resultNode(ARGS))} />)
|
|
expect(screen.getByText('更新任务清单')).toBeTruthy()
|
|
expect(screen.getByText('1/3 已完成 · 写组件')).toBeTruthy()
|
|
})
|
|
|
|
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 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 })
|
|
const running = render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
|
|
expect(running.container.querySelector('[data-state="running"]')).not.toBeNull()
|
|
expect(running.container.querySelector('[data-state="running"] svg')).not.toBeNull()
|
|
running.unmount()
|
|
// A cancelled call wrote no todo/write: the row must not read as a completed update.
|
|
const stopped = render(<TodoRow {...rowProps(resultNode(args, { isError: true, error: { name: 'Interrupted', code: 'interrupted' } }))} />)
|
|
expect(stopped.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
|
})
|
|
|
|
it('falls back to the generic summary on malformed args and marks the error state', () => {
|
|
const view = render(<TodoRow {...rowProps(resultNode('not json', { isError: true }))} />)
|
|
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
|
|
// Generic others summary: "<tool> · <raw>".
|
|
expect(screen.getByText('todo_write · not json')).toBeTruthy()
|
|
})
|
|
|
|
it('falls back when parsed args carry no todos array', () => {
|
|
render(<TodoRow {...rowProps(resultNode('{"other":1}'))} />)
|
|
expect(screen.getByText('todo_write · {"other":1}')).toBeTruthy()
|
|
})
|
|
|
|
it('leading toggle expands the raw args body', () => {
|
|
render(<TodoRow {...rowProps(resultNode(ARGS))} />)
|
|
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
|
expect(screen.getByRole('button', { expanded: true })).toBeTruthy()
|
|
// The expanded body is the pretty-printed args, not the tool output.
|
|
expect(screen.getByText(/搭骨架/)).toBeTruthy()
|
|
})
|
|
|
|
it.each([
|
|
{ label: 'null root', argsRaw: 'null' },
|
|
{ label: 'non-object root', argsRaw: '42' },
|
|
{ label: 'null items', argsRaw: '{"todos":[null]}' },
|
|
])('falls back to the generic summary on valid JSON with an invalid shape ($label)', ({ argsRaw }) => {
|
|
render(<TodoRow {...rowProps(resultNode(argsRaw))} />)
|
|
// No throw, and the generic others summary carries the raw args verbatim.
|
|
expect(screen.getByText(`todo_write · ${argsRaw}`)).toBeTruthy()
|
|
})
|
|
|
|
it('window-truncated result (call head lost) falls back to the callId summary', () => {
|
|
render(<TodoRow {...rowProps(resultNode('', { call: null }))} />)
|
|
expect(screen.getByText('todo_write · c1')).toBeTruthy()
|
|
})
|
|
|
|
it('todoToolview is a plain registrant riding the conversation load-order seam', () => {
|
|
expect(todoToolview.name).toBe('todo-toolview')
|
|
expect(todoToolview.inject).toEqual(['slots', 'conversation'])
|
|
const register = vi.fn()
|
|
todoToolview.apply({ slots: { register } } as never)
|
|
expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'todo_write', locale: NS }, TodoRow)
|
|
})
|
|
})
|