Merge remote-tracking branch 'origin/master' into worktree/context-source-cards

# Conflicts:
#	apps/web/tests/snapshots/bash-abort-row/ui.expected.md
#	packages/client/runtime/README.i18n.yaml
#	packages/client/ui-conversation/README.i18n.yaml
This commit is contained in:
creatixchu
2026-08-05 10:40:23 +08:00
274 changed files with 8323 additions and 1093 deletions
@@ -122,7 +122,7 @@ describe('todo_write assembly (product registrations, no outlet twins)', () => {
// (default-collapsed: the header summary shows; rows appear on expand).
const panel = view.container.querySelector('[data-testid="todo-panel"]')
expect(panel).not.toBeNull()
expect(panel!.textContent).toContain('1/3 项任务 · 1 项进行中')
expect(panel!.textContent).toContain('1 已完成\u2002·\u20021 进行中\u2002·\u20021 待处理')
fireEvent.click(panel!.querySelector('button')!)
expect([...panel!.querySelectorAll('li')].map(li => li.getAttribute('data-status')))
.toEqual(['completed', 'in_progress', 'pending'])
@@ -102,16 +102,10 @@ describe('MessageItem arms', () => {
expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支')
})
it('user copy stays quiet when execCommand throws or is absent', () => {
it('user copy never claims success when the host rejects the write', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
})
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: () => {
throw new Error('denied')
},
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
})
render(
<MessageItem t={t} node={{
@@ -122,12 +116,91 @@ describe('MessageItem arms', () => {
/>,
)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
})
Object.defineProperty(document, 'execCommand', {
it('copy swaps to the check success chrome, gates re-clicks, and reverts after a second', async () => {
vi.useFakeTimers()
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
value: { writeText },
})
render(
<MessageItem t={t} node={{
kind: 'user', seq: 1, time: 1_000,
content: [{ type: 'text', text: 'copied body' }] as never,
source: null,
}}
/>,
)
const copy = screen.getByRole('button', { name: '复制' })
fireEvent.click(copy)
fireEvent.click(copy)
expect(writeText).toHaveBeenCalledTimes(1)
// Two microtask ticks: writeClipboard's own await, then the .then that
// lands the success chrome.
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
const done = screen.getByRole('button', { name: '复制成功' })
fireEvent.click(done)
expect(writeText).toHaveBeenCalledTimes(1)
act(() => { vi.advanceTimersByTime(1000) })
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
})
it('clears copy feedback work when the message unmounts', async () => {
vi.useFakeTimers()
let finishWrite!: () => void
const writeText = vi.fn(() => new Promise<void>((resolve) => { finishWrite = resolve }))
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
const view = render(
<MessageItem t={t} node={{
kind: 'user', seq: 1, time: 1_000,
content: [{ type: 'text', text: 'copied body' }] as never,
source: null,
}}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
view.unmount()
await act(async () => {
finishWrite()
await Promise.resolve()
await Promise.resolve()
})
expect(vi.getTimerCount()).toBe(0)
const mounted = render(
<MessageItem t={t} node={{
kind: 'user', seq: 2, time: 1_000,
content: [{ type: 'text', text: 'copied body' }] as never,
source: null,
}}
/>,
)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: vi.fn().mockResolvedValue(undefined) },
})
fireEvent.click(screen.getByRole('button', { name: '复制' }))
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
mounted.unmount()
expect(vi.getTimerCount()).toBe(0)
})
it('consumed steering is captioned as an interjection and keeps copy and branch actions', () => {
@@ -548,6 +621,6 @@ describe('small branch tails', () => {
: undefined}
/>,
)
expect(view.container.textContent).toBe('1 turns · 1 steps|Input 0 tok · Output 10 tok')
expect(view.container.textContent).toBe('1 turns · 1 steps| Input 0 tok · Output 10 tok')
})
})
@@ -90,7 +90,7 @@ async function bench(snapshot: ConversationSnapshot) {
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
const list = createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, waitingApproval: false, blank: false, updatedAt: 1 } },
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } },
current: SID,
phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
})
@@ -133,7 +133,7 @@ describe('StatsLine', () => {
const view = render(<StatsLine {...props(source)} />)
// No timing on the fixture: the duration group drops out whole. Tokens come
// from the projection, so paging the window cannot change them.
expect(view.container.textContent).toBe('1 turns · 1 steps|Cache hit 90%|Input 100 tok · Output 5 tok')
expect(view.container.textContent).toBe('1 turns · 1 steps| Cache hit 90%| Input 100 tok · Output 5 tok')
const empty = makeSource()
const emptyView = render(<StatsLine {...props(empty.source, {
tokenUsage: { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
@@ -149,7 +149,7 @@ describe('StatsLine', () => {
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
})} />)
expect(view.container.textContent)
.toBe('Context 25% of 128K|Cache hit 90%|Input 100 tok · Output 5 tok')
.toBe('Context 25% of 128K| Cache hit 90%| Input 100 tok · Output 5 tok')
})
it('renders context occupancy only when the projection knows a capacity', () => {
@@ -196,7 +196,7 @@ describe('StatsLine', () => {
const view = render(<StatsLine {...props(source, {
tokenUsage: { uncachedInputTokens: 0, outputTokens: 7, cacheReadTokens: 0, cacheWriteTokens: 0 },
})} />)
expect(view.container.textContent).toBe('1 turns · 1 steps|Input 0 tok · Output 7 tok')
expect(view.container.textContent).toBe('1 turns · 1 steps| Input 0 tok · Output 7 tok')
})
it('includes cache writes in billed input and the cache-hit denominator', () => {
@@ -210,7 +210,7 @@ describe('StatsLine', () => {
},
})} />)
expect(view.container.textContent)
.toBe('1 turns · 1 steps|Cache hit 45%|Input 200 tok · Output 7 tok')
.toBe('1 turns · 1 steps| Cache hit 45%| Input 200 tok · Output 7 tok')
})
it('renders ZERO times during streaming chunk frames (RFC hard acceptance)', () => {
@@ -244,7 +244,7 @@ describe('bash sample row', () => {
return createSnapshotStore<SessionListState>({
ids: [SID],
byId: {
[SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 },
[SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 },
},
current: undefined,
phase: 'ready',
@@ -93,7 +93,7 @@ describe('tails', () => {
const sid = 'root-1' as SessionId
const list = createSnapshotStore<SessionListState>({
ids: [sid],
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 } },
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
current: undefined,
phase: 'ready',
subagentsByParent: {},
@@ -153,7 +153,7 @@ describe('chat row diff body', () => {
describe('FileMutationRow diff card', () => {
const list = () => createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } },
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
current: SID,
phase: 'ready',
subagentsByParent: {},
@@ -306,7 +306,7 @@ describe('DetailsPanel diff Output section', () => {
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
: {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
current: SID,
phase: 'ready',
subagentsByParent: {},
@@ -143,7 +143,6 @@ function bench(over?: BenchOptions) {
}
const view = render(<InputBar {...props} />)
const textarea = view.container.querySelector('textarea')!
// aria-label (not role name): title carries the same label and would double-match.
const stopping = over?.running === true && over.subagent === undefined
const button = view.container.querySelector<HTMLButtonElement>(
`button[aria-label="${stopping ? '停止生成' : '发送消息'}"]`,
@@ -724,6 +723,8 @@ describe('command launcher chrome and control seats', () => {
const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement
// Title-case display is presentation only; the menu ids stay machine names.
expect(trigger.textContent).toBe('Read Only')
expect([...trigger.querySelectorAll('svg')]
.every(icon => icon.closest('[aria-hidden="true"]') !== null)).toBe(true)
fireEvent.click(trigger)
const items = view.getAllByRole('menuitem')
expect(items.map(o => o.textContent)).toEqual(['Read Only', 'Workspace Write', 'Full access'])
@@ -167,7 +167,7 @@ describe('GenericToolCard read body', () => {
describe('ReadRow keyed toolview', () => {
const list = () => createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } },
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
current: SID,
phase: 'ready',
subagentsByParent: {},
@@ -254,7 +254,7 @@ describe('DetailsPanel Output section (read)', () => {
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
: {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
current: SID,
phase: 'ready',
subagentsByParent: {},
@@ -92,10 +92,10 @@ function mount(
} = {},
) {
const root = sid('root')
const rootRow = { id: root, displayTitle: 'Root', running: false, waitingApproval: false, blank: false, updatedAt: 1 }
const rootRow = { id: root, displayTitle: 'Root', running: false, blank: false, updatedAt: 1 }
const childRow = {
id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one',
running: false, waitingApproval: false, blank: options.summaryBlank ?? false, updatedAt: 2,
running: false, blank: options.summaryBlank ?? false, updatedAt: 2,
...(options.summaryOrigin === undefined ? {} : { origin: options.summaryOrigin }),
}
const listed = options.omitSummaryRow !== true
@@ -342,7 +342,7 @@ describe('chat row terminal body', () => {
describe('BashRow terminal card', () => {
const list = () => createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0 } },
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
current: undefined,
phase: 'ready',
subagentsByParent: {},
@@ -448,7 +448,7 @@ describe('DetailsPanel Output section', () => {
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }
: {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
current: SID,
phase: 'ready',
subagentsByParent: {},
@@ -38,15 +38,24 @@ describe('TodoPanel', () => {
expect(container.innerHTML).toBe('')
})
it('starts collapsed with the progress summary visible', () => {
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/3 项任务 · 1 项进行中')).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 }))
@@ -65,17 +74,18 @@ describe('TodoPanel', () => {
fireEvent.click(header)
expect(screen.queryByRole('list')).toBeNull()
// Collapsed header is title + progress only (no in-progress content hint).
expect(screen.getByText('1/3 项任务 · 1 项进行中')).toBeTruthy()
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('collapsed header still shows zero in-progress when nothing is active', () => {
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/1 项任务 · 0 项进行中')).toBeTruthy()
expect(screen.getByText('1 已完成')).toBeTruthy()
expect(screen.queryByText(/进行中|待处理/)).toBeNull()
})
})
@@ -93,7 +103,7 @@ describe('TodoDock', () => {
// Capability absent (no baseline/frame yet) renders nothing.
expect(screen.queryByTestId('todo-panel')).toBeNull()
act(() => { store.set({ value: LIST }) })
expect(screen.getByText('1/3 项任务 · 1 项进行中')).toBeTruthy()
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()