mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
This reverts commit 1b52da3587325715f06948d5d526f71b00c7d9fb. The session parent-child tree display in sidebar is visually unappealing and unnecessary. The branch button behavior during AI response is also intentional - users should not switch branches mid-conversation. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
3588 lines
106 KiB
TypeScript
3588 lines
106 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||
import { MessageList, buildRenderModel } from './MessageList'
|
||
import { relativizeWorkspacePath } from './CurrentTurnChangeCard'
|
||
import { sessionsApi } from '../../api/sessions'
|
||
import { useChatStore } from '../../stores/chatStore'
|
||
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
||
import { useSettingsStore } from '../../stores/settingsStore'
|
||
import { useSessionStore } from '../../stores/sessionStore'
|
||
import { useTabStore } from '../../stores/tabStore'
|
||
import { useUIStore } from '../../stores/uiStore'
|
||
import type { UIMessage } from '../../types/chat'
|
||
import type { PerSessionState } from '../../stores/chatStore'
|
||
|
||
const ACTIVE_TAB = 'active-tab'
|
||
|
||
async function waitForProgrammaticScrollReset() {
|
||
await act(async () => {
|
||
await new Promise<void>((resolve) => {
|
||
requestAnimationFrame(() => resolve())
|
||
})
|
||
})
|
||
}
|
||
|
||
function makeSessionState(overrides: Partial<PerSessionState> = {}): PerSessionState {
|
||
return {
|
||
messages: [],
|
||
chatState: 'idle',
|
||
connectionState: 'connected',
|
||
streamingText: '',
|
||
streamingToolInput: '',
|
||
activeToolUseId: null,
|
||
activeToolName: null,
|
||
activeThinkingId: null,
|
||
pendingPermission: null,
|
||
pendingComputerUsePermission: null,
|
||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||
elapsedSeconds: 0,
|
||
statusVerb: '',
|
||
apiRetry: null,
|
||
slashCommands: [],
|
||
agentTaskNotifications: {},
|
||
elapsedTimer: null,
|
||
composerPrefill: null,
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
function findTextNodeContaining(container: Element, text: string) {
|
||
const walker = document.createTreeWalker(container, 4)
|
||
let current = walker.nextNode()
|
||
while (current) {
|
||
if (current.textContent?.includes(text)) return current
|
||
current = walker.nextNode()
|
||
}
|
||
throw new Error(`Unable to find text node containing ${text}`)
|
||
}
|
||
|
||
async function selectMessageText(element: Element, text: string) {
|
||
const textNode = findTextNodeContaining(element, text)
|
||
const startOffset = textNode.textContent?.indexOf(text) ?? -1
|
||
const range = document.createRange()
|
||
range.setStart(textNode, startOffset)
|
||
range.setEnd(textNode, startOffset + text.length)
|
||
Object.assign(range, {
|
||
getBoundingClientRect: () => ({
|
||
left: 160,
|
||
top: 80,
|
||
right: 280,
|
||
bottom: 98,
|
||
width: 120,
|
||
height: 18,
|
||
x: 160,
|
||
y: 80,
|
||
toJSON: () => ({}),
|
||
}),
|
||
})
|
||
|
||
const selectableRoot = element.closest('[data-message-shell]')?.parentElement?.parentElement
|
||
Object.assign(selectableRoot ?? element, {
|
||
getBoundingClientRect: () => ({
|
||
left: 120,
|
||
top: 48,
|
||
right: 620,
|
||
bottom: 240,
|
||
width: 500,
|
||
height: 192,
|
||
x: 120,
|
||
y: 48,
|
||
toJSON: () => ({}),
|
||
}),
|
||
})
|
||
|
||
window.getSelection()?.removeAllRanges()
|
||
window.getSelection()?.addRange(range)
|
||
|
||
await act(async () => {
|
||
fireEvent.mouseUp(element, { clientX: 260, clientY: 104 })
|
||
await Promise.resolve()
|
||
})
|
||
}
|
||
|
||
describe('MessageList nested tool calls', () => {
|
||
beforeEach(() => {
|
||
vi.restoreAllMocks()
|
||
vi.unstubAllGlobals()
|
||
useSettingsStore.setState({ locale: 'en' })
|
||
useUIStore.setState({ pendingSettingsTab: null })
|
||
useTabStore.setState({ activeTabId: ACTIVE_TAB, tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', type: 'session' as const, status: 'idle' }] })
|
||
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
|
||
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState() } })
|
||
useWorkspaceChatContextStore.setState(useWorkspaceChatContextStore.getInitialState(), true)
|
||
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockImplementation(
|
||
() => new Promise(() => {}),
|
||
)
|
||
vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({
|
||
state: 'ok',
|
||
workDir: '/tmp/example-project',
|
||
repoName: 'example-project',
|
||
branch: null,
|
||
isGitRepo: false,
|
||
changedFiles: [],
|
||
})
|
||
})
|
||
|
||
it('windows long transcripts instead of mounting every historical message at once', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: Array.from({ length: 220 }, (_, index) => ({
|
||
id: `assistant-${index}`,
|
||
type: 'assistant_text',
|
||
content: index % 25 === 0
|
||
? [
|
||
`assistant transcript line ${index}`,
|
||
'',
|
||
'```ts',
|
||
'const value = "this intentionally makes the row much taller"',
|
||
'```',
|
||
].join('\n')
|
||
: `assistant transcript line ${index}`,
|
||
timestamp: index,
|
||
})),
|
||
}),
|
||
},
|
||
})
|
||
|
||
const { container } = render(<MessageList />)
|
||
|
||
expect(screen.getByText('assistant transcript line 219')).toBeTruthy()
|
||
expect(screen.queryByText('assistant transcript line 0')).toBeNull()
|
||
expect(container.querySelectorAll('[data-message-shell="assistant"]').length).toBeLessThan(220)
|
||
expect(container.querySelector('[data-virtual-message-item]')).not.toBeNull()
|
||
expect(container.querySelector('[data-virtual-spacer="top"]')).not.toBeNull()
|
||
})
|
||
|
||
it('keeps small transcripts fully mounted without deferred browser painting', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'assistant-1',
|
||
type: 'assistant_text',
|
||
content: 'first assistant reply',
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'assistant-2',
|
||
type: 'assistant_text',
|
||
content: 'second assistant reply',
|
||
timestamp: 2,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
const { container } = render(<MessageList />)
|
||
const renderItems = container.querySelectorAll('.chat-render-item')
|
||
|
||
expect(renderItems).toHaveLength(2)
|
||
for (const item of renderItems) {
|
||
expect(item.className).not.toContain('content-visibility')
|
||
expect(item.className).not.toContain('contain-intrinsic-size')
|
||
}
|
||
expect(container.querySelector('[data-virtual-message-item]')).toBeNull()
|
||
})
|
||
|
||
it('virtualizes short message lists when their content is very large', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'user-huge',
|
||
type: 'user_text',
|
||
content: '超长设计内容 '.repeat(24_000),
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'assistant-tail',
|
||
type: 'assistant_text',
|
||
content: 'latest assistant reply',
|
||
timestamp: 2,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
const { container } = render(<MessageList />)
|
||
|
||
expect(container.querySelector('[data-virtual-message-item]')).not.toBeNull()
|
||
expect(screen.getByText('latest assistant reply')).toBeTruthy()
|
||
})
|
||
|
||
it('filters duplicate unresolved AskUserQuestion cards while a matching permission is pending', () => {
|
||
const messages: UIMessage[] = [
|
||
{
|
||
id: 'stale-ask',
|
||
type: 'tool_use',
|
||
toolName: 'AskUserQuestion',
|
||
toolUseId: 'stale-tool',
|
||
input: {
|
||
questions: [
|
||
{
|
||
question: 'Restore this context?',
|
||
options: [{ label: 'No' }, { label: 'Yes' }],
|
||
},
|
||
],
|
||
},
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'active-ask',
|
||
type: 'tool_use',
|
||
toolName: 'AskUserQuestion',
|
||
toolUseId: 'active-tool',
|
||
input: {
|
||
questions: [
|
||
{
|
||
question: 'Restore this context?',
|
||
options: [{ label: 'No' }, { label: 'Yes' }],
|
||
},
|
||
],
|
||
},
|
||
timestamp: 2,
|
||
},
|
||
]
|
||
|
||
const { renderItems } = buildRenderModel(messages, 'active-tool')
|
||
|
||
expect(renderItems).toHaveLength(1)
|
||
expect(renderItems[0]).toMatchObject({
|
||
kind: 'message',
|
||
message: {
|
||
type: 'tool_use',
|
||
toolName: 'AskUserQuestion',
|
||
toolUseId: 'active-tool',
|
||
},
|
||
})
|
||
})
|
||
|
||
it('keeps resolved AskUserQuestion history visible when filtering active duplicates', () => {
|
||
const messages: UIMessage[] = [
|
||
{
|
||
id: 'answered-ask',
|
||
type: 'tool_use',
|
||
toolName: 'AskUserQuestion',
|
||
toolUseId: 'answered-tool',
|
||
input: {
|
||
questions: [
|
||
{
|
||
question: 'Already answered?',
|
||
options: [{ label: 'No' }, { label: 'Yes' }],
|
||
},
|
||
],
|
||
},
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'answered-result',
|
||
type: 'tool_result',
|
||
toolUseId: 'answered-tool',
|
||
content: { answers: { 'Already answered?': 'Yes' } },
|
||
isError: false,
|
||
timestamp: 2,
|
||
},
|
||
{
|
||
id: 'active-ask',
|
||
type: 'tool_use',
|
||
toolName: 'AskUserQuestion',
|
||
toolUseId: 'active-tool',
|
||
input: {
|
||
questions: [
|
||
{
|
||
question: 'Restore this context?',
|
||
options: [{ label: 'No' }, { label: 'Yes' }],
|
||
},
|
||
],
|
||
},
|
||
timestamp: 3,
|
||
},
|
||
]
|
||
|
||
const { renderItems } = buildRenderModel(messages, 'active-tool')
|
||
|
||
expect(renderItems).toHaveLength(2)
|
||
expect(renderItems.map((item) => item.kind === 'message' && item.message.type === 'tool_use'
|
||
? item.message.toolUseId
|
||
: null,
|
||
)).toEqual(['answered-tool', 'active-tool'])
|
||
})
|
||
|
||
it('keeps only the latest unresolved AskUserQuestion when no pending permission is active', () => {
|
||
const messages: UIMessage[] = [
|
||
{
|
||
id: 'first-ask',
|
||
type: 'tool_use',
|
||
toolName: 'AskUserQuestion',
|
||
toolUseId: 'first-tool',
|
||
input: {
|
||
questions: [
|
||
{
|
||
question: 'First question?',
|
||
options: [{ label: 'A' }, { label: 'B' }],
|
||
},
|
||
],
|
||
},
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'second-ask',
|
||
type: 'tool_use',
|
||
toolName: 'AskUserQuestion',
|
||
toolUseId: 'second-tool',
|
||
input: {
|
||
questions: [
|
||
{
|
||
question: 'Second question?',
|
||
options: [{ label: 'A' }, { label: 'B' }],
|
||
},
|
||
],
|
||
},
|
||
timestamp: 2,
|
||
},
|
||
]
|
||
|
||
const { renderItems } = buildRenderModel(messages, null)
|
||
|
||
expect(renderItems).toHaveLength(1)
|
||
expect(renderItems[0]).toMatchObject({
|
||
kind: 'message',
|
||
message: {
|
||
type: 'tool_use',
|
||
toolName: 'AskUserQuestion',
|
||
toolUseId: 'second-tool',
|
||
},
|
||
})
|
||
})
|
||
|
||
it('renders goal events as visible status cards', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [{
|
||
id: 'goal-1',
|
||
type: 'goal_event',
|
||
action: 'created',
|
||
status: 'active',
|
||
objective: 'ship the smoke test',
|
||
budget: '0 / 2,000 tokens',
|
||
continuations: '0',
|
||
timestamp: 1,
|
||
}],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
expect(screen.getByText('Goal set')).toBeTruthy()
|
||
expect(screen.getByText('Objective: ship the smoke test')).toBeTruthy()
|
||
expect(screen.getByText('Status: active')).toBeTruthy()
|
||
expect(screen.getByText('Budget: 0 / 2,000 tokens')).toBeTruthy()
|
||
})
|
||
|
||
it('renders replacement goal events distinctly', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [{
|
||
id: 'goal-replaced',
|
||
type: 'goal_event',
|
||
action: 'replaced',
|
||
status: 'active',
|
||
objective: 'ship the replacement target',
|
||
budget: '0 / unlimited tokens',
|
||
timestamp: 1,
|
||
}],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
expect(screen.getByText('Goal set')).toBeTruthy()
|
||
expect(screen.getByText('Objective: ship the replacement target')).toBeTruthy()
|
||
expect(screen.getByText('Budget: 0 / unlimited tokens')).toBeTruthy()
|
||
})
|
||
|
||
it('renders non-agent background progress inline in the transcript', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'user-1',
|
||
type: 'user_text',
|
||
content: 'run review',
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'background-task-shell-1',
|
||
type: 'background_task',
|
||
timestamp: 2,
|
||
task: {
|
||
taskId: 'shell-task-1',
|
||
toolUseId: 'shell-tool-1',
|
||
status: 'running',
|
||
taskType: 'local_bash',
|
||
summary: 'Running Playwright checks',
|
||
usage: {
|
||
totalTokens: 1200,
|
||
toolUses: 4,
|
||
durationMs: 45000,
|
||
},
|
||
startedAt: 2,
|
||
updatedAt: 2,
|
||
},
|
||
},
|
||
{
|
||
id: 'assistant-1',
|
||
type: 'assistant_text',
|
||
content: 'continuing',
|
||
timestamp: 3,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
const card = screen.getByTestId('background-task-event-card')
|
||
expect(card.textContent).toContain('Background command')
|
||
expect(card.textContent).toContain('running')
|
||
expect(card.textContent).toContain('Running Playwright checks')
|
||
expect(card.textContent).toContain('1,200 tokens')
|
||
expect(card.textContent).toContain('45s')
|
||
})
|
||
|
||
it('renders stopped non-agent background tasks as neutral transcript events', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [{
|
||
id: 'background-task-shell-stopped',
|
||
type: 'background_task',
|
||
timestamp: 2,
|
||
task: {
|
||
taskId: 'shell-task-stopped',
|
||
toolUseId: 'shell-tool-stopped',
|
||
status: 'stopped',
|
||
taskType: 'local_bash',
|
||
summary: 'Command "bun test" was stopped',
|
||
startedAt: 1,
|
||
updatedAt: 2,
|
||
},
|
||
}],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
const card = screen.getByTestId('background-task-event-card')
|
||
expect(card.getAttribute('data-status')).toBe('stopped')
|
||
expect(card.textContent).toContain('stopped')
|
||
expect(card.querySelector('.text-\\[var\\(--color-error\\)\\]')).toBeNull()
|
||
})
|
||
|
||
it('uses user-facing labels for workflow and unknown background tasks', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'background-task-workflow',
|
||
type: 'background_task',
|
||
timestamp: 2,
|
||
task: {
|
||
taskId: 'workflow-task',
|
||
status: 'running',
|
||
taskType: 'local_workflow',
|
||
summary: 'Running release checklist',
|
||
startedAt: 1,
|
||
updatedAt: 2,
|
||
},
|
||
},
|
||
{
|
||
id: 'background-task-unknown',
|
||
type: 'background_task',
|
||
timestamp: 3,
|
||
task: {
|
||
taskId: 'unknown-task',
|
||
status: 'completed',
|
||
summary: 'Finished background work',
|
||
startedAt: 1,
|
||
updatedAt: 3,
|
||
},
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
const cards = screen.getAllByTestId('background-task-event-card')
|
||
expect(cards).toHaveLength(2)
|
||
expect(cards[0]?.textContent).toContain('Background workflow')
|
||
expect(cards[1]?.textContent).toContain('Background task')
|
||
})
|
||
|
||
it('does not render agent background task events as separate transcript cards', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [{
|
||
id: 'background-task-agent-hidden',
|
||
type: 'background_task',
|
||
timestamp: 2,
|
||
task: {
|
||
taskId: 'agent-task-hidden',
|
||
toolUseId: 'agent-tool-hidden',
|
||
status: 'running',
|
||
taskType: 'local_agent',
|
||
summary: 'Running Read',
|
||
startedAt: 1,
|
||
updatedAt: 2,
|
||
},
|
||
}],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
expect(screen.queryByTestId('background-task-event-card')).toBeNull()
|
||
expect(screen.queryByText('local_agent')).toBeNull()
|
||
})
|
||
|
||
it('renders the historical window when scrolling away from latest', async () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: Array.from({ length: 220 }, (_, index) => ({
|
||
id: `assistant-${index}`,
|
||
type: 'assistant_text',
|
||
content: `assistant transcript line ${index}`,
|
||
timestamp: index,
|
||
})),
|
||
}),
|
||
},
|
||
})
|
||
|
||
const { container } = render(<MessageList />)
|
||
const scrollArea = container.querySelector('.chat-scroll-area') as HTMLElement
|
||
Object.defineProperty(scrollArea, 'clientHeight', { configurable: true, value: 500 })
|
||
Object.defineProperty(scrollArea, 'scrollHeight', { configurable: true, value: 220 * 112 })
|
||
await waitForProgrammaticScrollReset()
|
||
|
||
scrollArea.scrollTop = 0
|
||
await act(async () => {
|
||
fireEvent.scroll(scrollArea)
|
||
})
|
||
|
||
expect(screen.getByText('assistant transcript line 0')).toBeTruthy()
|
||
expect(screen.queryByText('assistant transcript line 219')).toBeNull()
|
||
expect(container.querySelectorAll('[data-message-shell="assistant"]').length).toBeLessThan(220)
|
||
})
|
||
|
||
it('keeps tool-call groups reachable while scrolling virtualized history', async () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'tool-read',
|
||
type: 'tool_use',
|
||
toolName: 'Read',
|
||
toolUseId: 'read-1',
|
||
input: { file_path: '/tmp/example.ts' },
|
||
timestamp: 0,
|
||
},
|
||
{
|
||
id: 'tool-read-result',
|
||
type: 'tool_result',
|
||
toolUseId: 'read-1',
|
||
content: 'read result content',
|
||
isError: false,
|
||
timestamp: 1,
|
||
},
|
||
...Array.from({ length: 220 }, (_, index) => ({
|
||
id: `assistant-${index}`,
|
||
type: 'assistant_text' as const,
|
||
content: `assistant transcript line ${index}`,
|
||
timestamp: index + 2,
|
||
})),
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
const { container } = render(<MessageList />)
|
||
const scrollArea = container.querySelector('.chat-scroll-area') as HTMLElement
|
||
Object.defineProperty(scrollArea, 'clientHeight', { configurable: true, value: 500 })
|
||
Object.defineProperty(scrollArea, 'scrollHeight', { configurable: true, value: 222 * 112 })
|
||
await waitForProgrammaticScrollReset()
|
||
|
||
expect(screen.queryByText('Read')).toBeNull()
|
||
expect(screen.getByText('assistant transcript line 219')).toBeTruthy()
|
||
|
||
scrollArea.scrollTop = 0
|
||
await act(async () => {
|
||
fireEvent.scroll(scrollArea)
|
||
})
|
||
|
||
expect(screen.getByText('Read')).toBeTruthy()
|
||
expect(screen.queryByText('assistant transcript line 219')).toBeNull()
|
||
expect(container.querySelector('[data-virtual-message-item]')).not.toBeNull()
|
||
})
|
||
|
||
it('splits large virtualization spacers into content-visibility chunks', async () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: Array.from({ length: 240 }, (_, index) => ({
|
||
id: `assistant-${index}`,
|
||
type: 'assistant_text',
|
||
content: `assistant transcript line ${index}`,
|
||
timestamp: index,
|
||
})),
|
||
}),
|
||
},
|
||
})
|
||
|
||
const { container } = render(<MessageList />)
|
||
const scrollArea = container.querySelector('.chat-scroll-area') as HTMLElement
|
||
Object.defineProperty(scrollArea, 'clientHeight', { configurable: true, value: 500 })
|
||
Object.defineProperty(scrollArea, 'scrollHeight', { configurable: true, value: 240 * 200 })
|
||
await waitForProgrammaticScrollReset()
|
||
|
||
// Scroll to middle so both top and bottom spacers are present
|
||
scrollArea.scrollTop = 20_000
|
||
await act(async () => {
|
||
fireEvent.scroll(scrollArea)
|
||
})
|
||
|
||
const topChunks = container.querySelectorAll('[data-virtual-spacer-chunk="top"]')
|
||
const bottomChunks = container.querySelectorAll('[data-virtual-spacer-chunk="bottom"]')
|
||
expect(topChunks.length).toBeGreaterThan(1)
|
||
expect(bottomChunks.length).toBeGreaterThan(1)
|
||
|
||
const firstTopChunk = topChunks[0] as HTMLElement
|
||
expect(firstTopChunk.style.contentVisibility).toBe('auto')
|
||
expect(firstTopChunk.style.containIntrinsicSize).toMatch(/^0 \d+px$/)
|
||
|
||
// Items inside the active window must NOT carry content-visibility (this
|
||
// is the regression guard that previous content-visibility rollout hit).
|
||
const visibleItems = container.querySelectorAll('[data-virtual-message-item]')
|
||
for (const item of visibleItems) {
|
||
expect((item as HTMLElement).style.contentVisibility).toBe('')
|
||
}
|
||
})
|
||
|
||
it('renders sub-agent tool calls inline beneath the parent agent tool call', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'tool-agent',
|
||
type: 'tool_use',
|
||
toolName: 'Agent',
|
||
toolUseId: 'agent-1',
|
||
input: { description: 'Inspect src/components' },
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'tool-read',
|
||
type: 'tool_use',
|
||
toolName: 'Read',
|
||
toolUseId: 'read-1',
|
||
input: { file_path: '/tmp/example.ts' },
|
||
timestamp: 2,
|
||
parentToolUseId: 'agent-1',
|
||
},
|
||
{
|
||
id: 'result-read',
|
||
type: 'tool_result',
|
||
toolUseId: 'read-1',
|
||
content: 'const answer = 42',
|
||
isError: false,
|
||
timestamp: 3,
|
||
parentToolUseId: 'agent-1',
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
const { container } = render(<MessageList />)
|
||
|
||
expect(screen.getAllByText('Running').length).toBeGreaterThan(0)
|
||
expect(screen.getByText(/Read .*example\.ts.*done/i)).toBeTruthy()
|
||
expect(container.textContent).toContain('Agent')
|
||
})
|
||
|
||
it('shows a dedicated compacting status indicator', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
chatState: 'compacting',
|
||
statusVerb: 'Compacting conversation',
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
const divider = screen.getByTestId('compact-status-divider')
|
||
expect(within(divider).getByText('Compacting context')).toBeTruthy()
|
||
expect(screen.queryByText('Compacting context...')).toBeNull()
|
||
})
|
||
|
||
it('shows API retry metadata in the active turn indicator', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
chatState: 'thinking',
|
||
apiRetry: {
|
||
attempt: 2,
|
||
maxRetries: 10,
|
||
retryDelayMs: 3000,
|
||
errorStatus: 503,
|
||
errorType: 'server_error',
|
||
receivedAt: Date.now(),
|
||
},
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
expect(screen.getByTestId('api-retry-indicator')).toBeTruthy()
|
||
expect(screen.getByText('Request failed, retrying')).toBeTruthy()
|
||
expect(screen.getByText('retry 2/10')).toBeTruthy()
|
||
expect(screen.getByText('HTTP 503')).toBeTruthy()
|
||
expect(screen.getByText(/waiting \d+s/)).toBeTruthy()
|
||
})
|
||
|
||
it('renders compact completion as an expandable timeline divider', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'compact-1',
|
||
type: 'compact_summary',
|
||
title: 'Context compacted',
|
||
trigger: 'auto',
|
||
preTokens: 123000,
|
||
summary: 'Built the invoice import flow and verified retry behavior.',
|
||
timestamp: 1,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
const divider = screen.getByTestId('compact-status-divider')
|
||
expect(within(divider).getByText('Context automatically compacted')).toBeTruthy()
|
||
expect(divider.textContent).not.toContain('123k tokens before compact')
|
||
expect(divider.textContent).not.toContain('Built the invoice import flow')
|
||
|
||
fireEvent.click(within(divider).getByRole('button'))
|
||
|
||
expect(divider.textContent).toContain('auto')
|
||
expect(divider.textContent).toContain('123k tokens before compact')
|
||
expect(divider.textContent).toContain('Built the invoice import flow and verified retry behavior.')
|
||
})
|
||
|
||
it('keeps mixed tool groups active while a nested child tool call is unresolved', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
chatState: 'idle',
|
||
messages: [
|
||
{
|
||
id: 'tool-task-update',
|
||
type: 'tool_use',
|
||
toolName: 'TaskUpdate',
|
||
toolUseId: 'task-update-1',
|
||
input: { tasks: [{ id: '4', status: 'in_progress', content: 'Run page integration' }] },
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'tool-bash',
|
||
type: 'tool_use',
|
||
toolName: 'Bash',
|
||
toolUseId: 'bash-1',
|
||
input: { command: 'bun run dev' },
|
||
timestamp: 2,
|
||
},
|
||
{
|
||
id: 'result-task-update',
|
||
type: 'tool_result',
|
||
toolUseId: 'task-update-1',
|
||
content: 'updated',
|
||
isError: false,
|
||
timestamp: 3,
|
||
},
|
||
{
|
||
id: 'result-bash',
|
||
type: 'tool_result',
|
||
toolUseId: 'bash-1',
|
||
content: 'started',
|
||
isError: false,
|
||
timestamp: 4,
|
||
},
|
||
{
|
||
id: 'tool-local-bash',
|
||
type: 'tool_use',
|
||
toolName: 'local_bash',
|
||
toolUseId: 'local-bash-1',
|
||
input: { description: 'Run page integration checks' },
|
||
timestamp: 5,
|
||
parentToolUseId: 'task-update-1',
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
const groupSummary = screen.getByText('TaskUpdate (1), ran a command')
|
||
const groupButton = groupSummary.closest('button')
|
||
expect(groupButton?.textContent).not.toContain('check_circle')
|
||
expect(screen.getByText('local_bash')).toBeTruthy()
|
||
})
|
||
|
||
it('does not render blank assistant bubbles for whitespace-only text', () => {
|
||
const messages: UIMessage[] = [
|
||
{
|
||
id: 'assistant-empty',
|
||
type: 'assistant_text',
|
||
content: '\n\n ',
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'tool-bash',
|
||
type: 'tool_use',
|
||
toolName: 'Bash',
|
||
toolUseId: 'bash-1',
|
||
input: { command: 'pwd' },
|
||
timestamp: 2,
|
||
},
|
||
]
|
||
|
||
const { renderItems } = buildRenderModel(messages)
|
||
expect(renderItems).toHaveLength(1)
|
||
expect(renderItems[0]).toMatchObject({ kind: 'tool_group' })
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages,
|
||
streamingText: '\n ',
|
||
}),
|
||
},
|
||
})
|
||
|
||
const { container } = render(<MessageList />)
|
||
expect(container.querySelectorAll('[data-message-shell="assistant"]')).toHaveLength(0)
|
||
})
|
||
|
||
it('renders saved memory events with an entrypoint to memory settings', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'memory-1',
|
||
type: 'memory_event',
|
||
event: 'saved',
|
||
files: [
|
||
{ path: '/Users/test/.claude/projects/example/memory/preferences.md', action: 'saved' },
|
||
],
|
||
timestamp: 1,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList sessionId={ACTIVE_TAB} />)
|
||
|
||
expect(screen.getByText('Saved 1 memory file(s)')).toBeTruthy()
|
||
expect(screen.getByText('preferences.md')).toBeTruthy()
|
||
|
||
const openButton = screen.getByText('Open Memory').closest('button')
|
||
expect(openButton).toBeTruthy()
|
||
fireEvent.click(openButton!)
|
||
|
||
expect(useUIStore.getState().pendingSettingsTab).toBe('memory')
|
||
expect(useUIStore.getState().pendingMemoryPath).toBe('/Users/test/.claude/projects/example/memory/preferences.md')
|
||
expect(useTabStore.getState().activeTabId).toBe('__settings__')
|
||
})
|
||
|
||
it('promotes memory file writes from tool calls into a dedicated memory card', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'tool-write-memory',
|
||
type: 'tool_use',
|
||
toolName: 'Write',
|
||
toolUseId: 'write-memory',
|
||
input: {
|
||
file_path: '/Users/test/.claude/projects/example/memory/preferences.md',
|
||
content: '# Preferences\n',
|
||
},
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'result-write-memory',
|
||
type: 'tool_result',
|
||
toolUseId: 'write-memory',
|
||
content: 'File written successfully',
|
||
isError: false,
|
||
timestamp: 2,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList sessionId={ACTIVE_TAB} />)
|
||
|
||
expect(screen.getByText('Saved 1 memory item(s)')).toBeTruthy()
|
||
expect(screen.getByText('preferences.md')).toBeTruthy()
|
||
expect(screen.getByText('Tool details')).toBeTruthy()
|
||
const memoryCardClassName = screen.getByTestId('memory-tool-activity-card').className
|
||
expect(memoryCardClassName).toContain('border-[var(--color-memory-border)]')
|
||
expect(memoryCardClassName).toContain('bg-[var(--color-memory-surface)]')
|
||
})
|
||
|
||
it('promotes memory file reads into collapsible memory references', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'tool-read-memory-1',
|
||
type: 'tool_use',
|
||
toolName: 'Read',
|
||
toolUseId: 'read-memory-1',
|
||
input: { file_path: '/Users/test/.claude/projects/example/memory/MEMORY.md' },
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'result-read-memory-1',
|
||
type: 'tool_result',
|
||
toolUseId: 'read-memory-1',
|
||
content: '1 # Project Memory\n2\n3 billing ledger rules',
|
||
isError: false,
|
||
timestamp: 2,
|
||
},
|
||
{
|
||
id: 'tool-read-memory-2',
|
||
type: 'tool_use',
|
||
toolName: 'Read',
|
||
toolUseId: 'read-memory-2',
|
||
input: { file_path: '/Users/test/.claude/projects/example/memory/workflow.md' },
|
||
timestamp: 3,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList sessionId={ACTIVE_TAB} />)
|
||
|
||
expect(screen.getByText('2 memory reference(s)')).toBeTruthy()
|
||
fireEvent.click(screen.getByText('2 memory reference(s)'))
|
||
expect(screen.getByText('MEMORY.md')).toBeTruthy()
|
||
expect(screen.getByText('workflow.md')).toBeTruthy()
|
||
})
|
||
|
||
it('keeps non-memory tools visible when a tool group also touches memory files', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'tool-read-memory',
|
||
type: 'tool_use',
|
||
toolName: 'Read',
|
||
toolUseId: 'read-memory',
|
||
input: { file_path: '/Users/test/.claude/projects/example/memory/MEMORY.md' },
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'tool-bash',
|
||
type: 'tool_use',
|
||
toolName: 'Bash',
|
||
toolUseId: 'bash-1',
|
||
input: { command: 'bun test' },
|
||
timestamp: 2,
|
||
},
|
||
{
|
||
id: 'result-bash',
|
||
type: 'tool_result',
|
||
toolUseId: 'bash-1',
|
||
content: 'ok',
|
||
isError: false,
|
||
timestamp: 3,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList sessionId={ACTIVE_TAB} />)
|
||
|
||
expect(screen.getByText('1 memory reference(s)')).toBeTruthy()
|
||
expect(screen.getByText('Bash')).toBeTruthy()
|
||
expect(screen.getByText('bun test')).toBeTruthy()
|
||
})
|
||
|
||
it('keeps root tool runs split when nested child tool calls appear between them', () => {
|
||
const messages: UIMessage[] = [
|
||
{
|
||
id: 'tool-agent',
|
||
type: 'tool_use',
|
||
toolName: 'Agent',
|
||
toolUseId: 'agent-1',
|
||
input: { description: 'Inspect src/components' },
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'tool-read',
|
||
type: 'tool_use',
|
||
toolName: 'Read',
|
||
toolUseId: 'read-1',
|
||
input: { file_path: '/tmp/example.ts' },
|
||
timestamp: 2,
|
||
parentToolUseId: 'agent-1',
|
||
},
|
||
{
|
||
id: 'result-read',
|
||
type: 'tool_result',
|
||
toolUseId: 'read-1',
|
||
content: 'const answer = 42',
|
||
isError: false,
|
||
timestamp: 3,
|
||
parentToolUseId: 'agent-1',
|
||
},
|
||
{
|
||
id: 'tool-write',
|
||
type: 'tool_use',
|
||
toolName: 'Write',
|
||
toolUseId: 'write-1',
|
||
input: { file_path: '/tmp/out.ts', content: 'export const value = 1' },
|
||
timestamp: 4,
|
||
},
|
||
]
|
||
|
||
const { renderItems } = buildRenderModel(messages)
|
||
const toolGroups = renderItems.filter((item) => item.kind === 'tool_group')
|
||
|
||
expect(toolGroups).toHaveLength(2)
|
||
expect(toolGroups.map((item) => item.toolCalls[0]?.toolUseId)).toEqual(['agent-1', 'write-1'])
|
||
})
|
||
|
||
it('keeps task-management tools from downgrading dispatched agents into a mixed tool tree', () => {
|
||
const messages: UIMessage[] = [
|
||
{
|
||
id: 'tool-task-create',
|
||
type: 'tool_use',
|
||
toolName: 'TaskCreate',
|
||
toolUseId: 'task-create-1',
|
||
input: { subject: 'Review recent changes' },
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'tool-task-update',
|
||
type: 'tool_use',
|
||
toolName: 'TaskUpdate',
|
||
toolUseId: 'task-update-1',
|
||
input: { id: '1', status: 'in_progress' },
|
||
timestamp: 2,
|
||
},
|
||
{
|
||
id: 'tool-agent-a',
|
||
type: 'tool_use',
|
||
toolName: 'Agent',
|
||
toolUseId: 'agent-a',
|
||
input: { description: 'Review desktop impact' },
|
||
timestamp: 3,
|
||
},
|
||
{
|
||
id: 'tool-agent-b',
|
||
type: 'tool_use',
|
||
toolName: 'Agent',
|
||
toolUseId: 'agent-b',
|
||
input: { description: 'Review runtime impact' },
|
||
timestamp: 4,
|
||
},
|
||
{
|
||
id: 'tool-agent-child-bash',
|
||
type: 'tool_use',
|
||
toolName: 'Bash',
|
||
toolUseId: 'agent-a-bash',
|
||
input: { command: 'git status --short' },
|
||
timestamp: 5,
|
||
parentToolUseId: 'agent-a',
|
||
},
|
||
]
|
||
|
||
const { renderItems, childToolCallsByParent } = buildRenderModel(messages)
|
||
const toolGroups = renderItems.filter((item) => item.kind === 'tool_group')
|
||
|
||
expect(toolGroups).toHaveLength(2)
|
||
expect(toolGroups[0]?.toolCalls.map((toolCall) => toolCall.toolName)).toEqual([
|
||
'TaskCreate',
|
||
'TaskUpdate',
|
||
])
|
||
expect(toolGroups[1]?.toolCalls.map((toolCall) => toolCall.toolName)).toEqual([
|
||
'Agent',
|
||
'Agent',
|
||
])
|
||
expect(childToolCallsByParent.get('agent-a')?.map((toolCall) => toolCall.toolUseId)).toEqual([
|
||
'agent-a-bash',
|
||
])
|
||
})
|
||
|
||
it('keeps later nested tool calls under their parent after an interleaved user message', () => {
|
||
const messages: UIMessage[] = [
|
||
{
|
||
id: 'tool-agent',
|
||
type: 'tool_use',
|
||
toolName: 'Agent',
|
||
toolUseId: 'agent-1',
|
||
input: { description: 'Inspect src/components' },
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'tool-read',
|
||
type: 'tool_use',
|
||
toolName: 'Read',
|
||
toolUseId: 'read-1',
|
||
input: { file_path: '/tmp/example.ts' },
|
||
timestamp: 2,
|
||
parentToolUseId: 'agent-1',
|
||
},
|
||
{
|
||
id: 'user-follow-up',
|
||
type: 'user_text',
|
||
content: '顺便把刚才的问题也处理掉',
|
||
timestamp: 3,
|
||
},
|
||
{
|
||
id: 'tool-write',
|
||
type: 'tool_use',
|
||
toolName: 'Write',
|
||
toolUseId: 'write-1',
|
||
input: { file_path: '/tmp/out.ts', content: 'export const value = 1' },
|
||
timestamp: 4,
|
||
parentToolUseId: 'agent-1',
|
||
},
|
||
]
|
||
|
||
const { renderItems, childToolCallsByParent } = buildRenderModel(messages)
|
||
const renderedKinds = renderItems.map((item) =>
|
||
item.kind === 'tool_group'
|
||
? `tool:${item.toolCalls[0]?.toolUseId}`
|
||
: `message:${item.message.id}`,
|
||
)
|
||
|
||
expect(renderedKinds).toEqual([
|
||
'tool:agent-1',
|
||
'message:user-follow-up',
|
||
])
|
||
expect(
|
||
(childToolCallsByParent.get('agent-1') ?? []).map((toolCall) => toolCall.toolUseId),
|
||
).toEqual(['read-1', 'write-1'])
|
||
})
|
||
|
||
it('does not render parented orphan tool results as root session messages', () => {
|
||
const messages: UIMessage[] = [
|
||
{
|
||
id: 'tool-agent',
|
||
type: 'tool_use',
|
||
toolName: 'Agent',
|
||
toolUseId: 'agent-1',
|
||
input: { description: 'Inspect src/components' },
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'result-child',
|
||
type: 'tool_result',
|
||
toolUseId: 'grep-1',
|
||
content: 'Found 22 files',
|
||
isError: false,
|
||
timestamp: 2,
|
||
parentToolUseId: 'agent-1',
|
||
},
|
||
]
|
||
|
||
const { renderItems } = buildRenderModel(messages)
|
||
|
||
expect(renderItems).toHaveLength(1)
|
||
expect(renderItems[0]).toMatchObject({ kind: 'tool_group' })
|
||
})
|
||
|
||
it('shows failed agent status and compact unavailable summary for Explore launch errors', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'tool-agent',
|
||
type: 'tool_use',
|
||
toolName: 'Agent',
|
||
toolUseId: 'agent-1',
|
||
input: { description: '探索整体架构', subagent_type: 'Explore' },
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'result-agent',
|
||
type: 'tool_result',
|
||
toolUseId: 'agent-1',
|
||
content: `Agent type 'Explore' not found. Available agents: general-purpose`,
|
||
isError: true,
|
||
timestamp: 2,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
expect(screen.getByText('Failed')).toBeTruthy()
|
||
expect(screen.getByText('Explore agent unavailable in this session')).toBeTruthy()
|
||
})
|
||
|
||
it('shows completed agent output when no nested tool activity is available', () => {
|
||
const longResult = '探索完成。让我将结果整合写入计划文件。第二段补充内容用于验证 dialog 展示的是完整结果而不是截断摘要。'
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'tool-agent',
|
||
type: 'tool_use',
|
||
toolName: 'Agent',
|
||
toolUseId: 'agent-1',
|
||
input: { description: '探索整体架构' },
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'result-agent',
|
||
type: 'tool_result',
|
||
toolUseId: 'agent-1',
|
||
content: {
|
||
status: 'completed',
|
||
content: [
|
||
{ type: 'text', text: longResult },
|
||
{
|
||
type: 'text',
|
||
text: "agentId: a0c0c732f61442dc1 (use SendMessage with to: 'a0c0c732f61442dc1' to continue this agent)\n<usage>total_tokens: 17195\ntool_uses: 2\nduration_ms: 41368</usage>",
|
||
},
|
||
],
|
||
},
|
||
isError: false,
|
||
timestamp: 2,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
expect(screen.getByText('Done')).toBeTruthy()
|
||
expect(screen.getByRole('button', { name: 'View result' })).toBeTruthy()
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'View result' }))
|
||
|
||
const dialog = screen.getByRole('dialog')
|
||
expect(within(dialog).getByText(/第二段补充内容用于验证 dialog 展示的是完整结果而不是截断摘要。/)).toBeTruthy()
|
||
expect(within(dialog).queryByText(/agentId:/)).toBeNull()
|
||
expect(within(dialog).queryByText(/total_tokens/)).toBeNull()
|
||
expect(screen.getByRole('button', { name: 'Close dialog' })).toBeTruthy()
|
||
})
|
||
|
||
it('keeps async launched agents in running state until a terminal notification arrives', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'tool-agent',
|
||
type: 'tool_use',
|
||
toolName: 'Agent',
|
||
toolUseId: 'agent-1',
|
||
input: { description: '修复临时文件泄漏' },
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'result-agent',
|
||
type: 'tool_result',
|
||
toolUseId: 'agent-1',
|
||
content:
|
||
"Async agent launched successfully.\nagentId: a29934b04b20ed564 (internal ID - do not mention to user. Use SendMessage with to: 'a29934b04b20ed564' to continue this agent.)\nThe agent is working in the background. You will be notified automatically when it completes.",
|
||
isError: false,
|
||
timestamp: 2,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
expect(screen.getAllByText('Running').length).toBeGreaterThan(0)
|
||
expect(screen.queryByText('Done')).toBeNull()
|
||
expect(screen.queryByRole('button', { name: 'View result' })).toBeNull()
|
||
})
|
||
|
||
it('shows completed background agent result from the terminal task notification', () => {
|
||
const resultText = '后台 agent 已经完成:定位到 parentToolUseId 丢失并补齐了 live 事件链。'
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'tool-agent',
|
||
type: 'tool_use',
|
||
toolName: 'Agent',
|
||
toolUseId: 'agent-1',
|
||
input: { description: '排查 subagent UI' },
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'result-agent',
|
||
type: 'tool_result',
|
||
toolUseId: 'agent-1',
|
||
content:
|
||
"Async agent launched successfully.\nagentId: a29934b04b20ed564 (internal ID - do not mention to user. Use SendMessage with to: 'a29934b04b20ed564' to continue this agent.)\nThe agent is working in the background. You will be notified automatically when it completes.",
|
||
isError: false,
|
||
timestamp: 2,
|
||
},
|
||
],
|
||
agentTaskNotifications: {
|
||
'agent-1': {
|
||
taskId: 'agent-task-1',
|
||
toolUseId: 'agent-1',
|
||
status: 'completed',
|
||
summary: 'Agent "排查 subagent UI" completed',
|
||
result: resultText,
|
||
},
|
||
},
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
expect(screen.getByText('Done')).toBeTruthy()
|
||
fireEvent.click(screen.getByRole('button', { name: 'View result' }))
|
||
|
||
expect(within(screen.getByRole('dialog')).getByText(resultText)).toBeTruthy()
|
||
})
|
||
|
||
it('prefers the terminal task report over structured agent tool result JSON', () => {
|
||
const markdownReport = '## 审查安全风险\n\n- 最终报告应该按 Markdown 展示。'
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'tool-agent',
|
||
type: 'tool_use',
|
||
toolName: 'Agent',
|
||
toolUseId: 'agent-1',
|
||
input: { description: '查看安全报告' },
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'result-agent',
|
||
type: 'tool_result',
|
||
toolUseId: 'agent-1',
|
||
content: {
|
||
results: [
|
||
{
|
||
file: 'git:v0.2.6..v0.2.7',
|
||
line: 0,
|
||
snippet: 'raw structured JSON should not be shown',
|
||
context: '结构化检索结果不是给用户看的最终报告。',
|
||
},
|
||
],
|
||
},
|
||
isError: false,
|
||
timestamp: 2,
|
||
},
|
||
],
|
||
agentTaskNotifications: {
|
||
'agent-1': {
|
||
taskId: 'agent-task-1',
|
||
toolUseId: 'agent-1',
|
||
status: 'completed',
|
||
summary: 'Agent "审查安全风险" completed',
|
||
result: markdownReport,
|
||
},
|
||
},
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
expect(screen.getByText(/最终报告应该按 Markdown 展示。/)).toBeTruthy()
|
||
expect(screen.queryByText(/raw structured JSON should not be shown/)).toBeNull()
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'View result' }))
|
||
|
||
const dialog = screen.getByRole('dialog')
|
||
expect(within(dialog).getByRole('heading', { name: '审查安全风险' })).toBeTruthy()
|
||
expect(within(dialog).getByText('最终报告应该按 Markdown 展示。')).toBeTruthy()
|
||
expect(within(dialog).queryByText(/raw structured JSON should not be shown/)).toBeNull()
|
||
})
|
||
|
||
it('formats structured agent fallback results as readable markdown', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'tool-agent',
|
||
type: 'tool_use',
|
||
toolName: 'Agent',
|
||
toolUseId: 'agent-1',
|
||
input: { description: '审查安全风险' },
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'result-agent',
|
||
type: 'tool_result',
|
||
toolUseId: 'agent-1',
|
||
content: [
|
||
{
|
||
type: 'text',
|
||
text: JSON.stringify({
|
||
results: [
|
||
{
|
||
file: 'git:v0.2.6..v0.2.7',
|
||
line: 0,
|
||
snippet: 'v0.2.7 tag = a4c92ec7',
|
||
context: '版本范围判断:release-notes/v0.2.7.md 明确相比 v0.2.6。',
|
||
},
|
||
{
|
||
risk: 'medium',
|
||
items: [
|
||
{
|
||
file: '/tmp/example/src/lib.rs',
|
||
line: 220,
|
||
context: '中风险:服务默认监听 0.0.0.0。',
|
||
},
|
||
],
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
],
|
||
isError: false,
|
||
timestamp: 2,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
expect(screen.getByText(/git:v0\.2\.6\.\.v0\.2\.7:0/)).toBeTruthy()
|
||
expect(screen.queryByText(/\{"results"/)).toBeNull()
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'View result' }))
|
||
|
||
const dialog = screen.getByRole('dialog')
|
||
expect(within(dialog).getByText('git:v0.2.6..v0.2.7:0')).toBeTruthy()
|
||
expect(within(dialog).getByText('/tmp/example/src/lib.rs:220')).toBeTruthy()
|
||
expect(within(dialog).getByText(/服务默认监听 0\.0\.0\.0/)).toBeTruthy()
|
||
expect(within(dialog).queryByText(/\{"results"/)).toBeNull()
|
||
})
|
||
|
||
it('renders copy controls for user messages and scopes assistant copy to a single reply', async () => {
|
||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||
Object.assign(navigator, {
|
||
clipboard: {
|
||
writeText,
|
||
},
|
||
})
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'user-1',
|
||
type: 'user_text',
|
||
content: '请帮我探索整体架构',
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'assistant-1',
|
||
type: 'assistant_text',
|
||
content: '先看 CLI 和服务端入口。',
|
||
timestamp: 2,
|
||
},
|
||
{
|
||
id: 'assistant-2',
|
||
type: 'assistant_text',
|
||
content: '再看 desktop 前后端边界。',
|
||
timestamp: 3,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
expect(screen.getByRole('button', { name: 'Copy prompt' })).toBeTruthy()
|
||
|
||
fireEvent.click(screen.getAllByRole('button', { name: 'Copy reply' })[1]!)
|
||
|
||
await waitFor(() => {
|
||
expect(writeText).toHaveBeenCalledWith('再看 desktop 前后端边界。')
|
||
})
|
||
expect(writeText).not.toHaveBeenCalledWith(
|
||
'先看 CLI 和服务端入口。\n再看 desktop 前后端边界。'
|
||
)
|
||
})
|
||
|
||
it('adds selected user message text to the composer context', async () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [{
|
||
id: 'user-1',
|
||
type: 'user_text',
|
||
content: 'Please inspect the workspace selection behavior.',
|
||
timestamp: 1,
|
||
}],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
const userText = screen.getByText('Please inspect the workspace selection behavior.')
|
||
await selectMessageText(userText, 'workspace selection behavior')
|
||
const floatingAddButton = screen.getByRole('button', { name: 'Add to chat' })
|
||
|
||
expect(floatingAddButton.style.left).toBe('141px')
|
||
expect(floatingAddButton.style.top).toBe('26px')
|
||
|
||
fireEvent.click(floatingAddButton)
|
||
|
||
expect(useWorkspaceChatContextStore.getState().referencesBySession[ACTIVE_TAB]).toMatchObject([
|
||
{
|
||
kind: 'chat-selection',
|
||
path: 'chat://user/user-1',
|
||
name: 'User message',
|
||
messageId: 'user-1',
|
||
sourceRole: 'user',
|
||
quote: 'workspace selection behavior',
|
||
},
|
||
])
|
||
expect(window.getSelection()?.toString()).toBe('')
|
||
})
|
||
|
||
it('adds selected assistant reply text to the composer context', async () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [{
|
||
id: 'assistant-1',
|
||
type: 'assistant_text',
|
||
content: 'First inspect the file tree. Then quote the selected lines.',
|
||
timestamp: 1,
|
||
}],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
const assistantText = screen.getByText(/First inspect the file tree/)
|
||
await selectMessageText(assistantText, 'quote the selected lines')
|
||
fireEvent.click(screen.getByRole('button', { name: 'Add to chat' }))
|
||
|
||
expect(useWorkspaceChatContextStore.getState().referencesBySession[ACTIVE_TAB]).toMatchObject([
|
||
{
|
||
kind: 'chat-selection',
|
||
path: 'chat://assistant/assistant-1',
|
||
name: 'Assistant message',
|
||
messageId: 'assistant-1',
|
||
sourceRole: 'assistant',
|
||
quote: 'quote the selected lines',
|
||
},
|
||
])
|
||
})
|
||
|
||
it('dismisses the selected-message action when clicking outside the popover', async () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [{
|
||
id: 'assistant-1',
|
||
type: 'assistant_text',
|
||
content: 'Clicking outside should clear this selected reply.',
|
||
timestamp: 1,
|
||
}],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
const assistantText = screen.getByText(/Clicking outside should clear/)
|
||
await selectMessageText(assistantText, 'selected reply')
|
||
expect(screen.getByRole('button', { name: 'Add to chat' })).toBeTruthy()
|
||
|
||
await act(async () => {
|
||
fireEvent.pointerDown(document.body)
|
||
await Promise.resolve()
|
||
})
|
||
|
||
expect(screen.queryByRole('button', { name: 'Add to chat' })).toBeNull()
|
||
expect(window.getSelection()?.toString()).toBe('')
|
||
})
|
||
|
||
it('keeps only the latest selected-message action when selecting across messages', async () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'assistant-1',
|
||
type: 'assistant_text',
|
||
content: 'First assistant reply can be selected.',
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'assistant-2',
|
||
type: 'assistant_text',
|
||
content: 'Second assistant reply should replace it.',
|
||
timestamp: 2,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
const firstText = screen.getByText(/First assistant reply/)
|
||
const secondText = screen.getByText(/Second assistant reply/)
|
||
await selectMessageText(firstText, 'First assistant reply')
|
||
expect(screen.getAllByRole('button', { name: 'Add to chat' })).toHaveLength(1)
|
||
|
||
await act(async () => {
|
||
fireEvent.pointerDown(secondText)
|
||
await Promise.resolve()
|
||
})
|
||
await selectMessageText(secondText, 'Second assistant reply')
|
||
|
||
expect(screen.getAllByRole('button', { name: 'Add to chat' })).toHaveLength(1)
|
||
fireEvent.click(screen.getByRole('button', { name: 'Add to chat' }))
|
||
expect(useWorkspaceChatContextStore.getState().referencesBySession[ACTIVE_TAB]).toMatchObject([
|
||
{
|
||
messageId: 'assistant-2',
|
||
quote: 'Second assistant reply',
|
||
},
|
||
])
|
||
})
|
||
|
||
it('does not force-scroll to the bottom while the user is reading history', async () => {
|
||
const scrollIntoView = vi.fn()
|
||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||
configurable: true,
|
||
value: scrollIntoView,
|
||
})
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
chatState: 'streaming',
|
||
messages: [
|
||
{
|
||
id: 'user-1',
|
||
type: 'user_text',
|
||
content: '历史消息',
|
||
timestamp: 1,
|
||
},
|
||
],
|
||
streamingText: 'streaming',
|
||
}),
|
||
},
|
||
})
|
||
|
||
const { container } = render(<MessageList />)
|
||
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
|
||
let scrollTop = 120
|
||
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1000 })
|
||
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 })
|
||
Object.defineProperty(scroller, 'scrollTop', {
|
||
configurable: true,
|
||
get: () => scrollTop,
|
||
set: (value) => {
|
||
scrollTop = value
|
||
},
|
||
})
|
||
|
||
scrollIntoView.mockClear()
|
||
await waitForProgrammaticScrollReset()
|
||
fireEvent.scroll(scroller)
|
||
|
||
act(() => {
|
||
useChatStore.setState((state) => ({
|
||
sessions: {
|
||
...state.sessions,
|
||
[ACTIVE_TAB]: {
|
||
...state.sessions[ACTIVE_TAB]!,
|
||
streamingText: 'streaming new token',
|
||
},
|
||
},
|
||
}))
|
||
})
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('streaming new token')).toBeTruthy()
|
||
})
|
||
expect(scrollIntoView).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('keeps auto-scrolling when new output arrives while already near the bottom', async () => {
|
||
const scrollIntoView = vi.fn()
|
||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||
configurable: true,
|
||
value: scrollIntoView,
|
||
})
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
chatState: 'streaming',
|
||
messages: [
|
||
{
|
||
id: 'user-1',
|
||
type: 'user_text',
|
||
content: '最新消息',
|
||
timestamp: 1,
|
||
},
|
||
],
|
||
streamingText: 'streaming',
|
||
}),
|
||
},
|
||
})
|
||
|
||
const { container } = render(<MessageList />)
|
||
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
|
||
let scrollTop = 552
|
||
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1000 })
|
||
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 })
|
||
Object.defineProperty(scroller, 'scrollTop', {
|
||
configurable: true,
|
||
get: () => scrollTop,
|
||
set: (value) => {
|
||
scrollTop = value
|
||
},
|
||
})
|
||
|
||
scrollIntoView.mockClear()
|
||
await waitForProgrammaticScrollReset()
|
||
fireEvent.scroll(scroller)
|
||
|
||
act(() => {
|
||
useChatStore.setState((state) => ({
|
||
sessions: {
|
||
...state.sessions,
|
||
[ACTIVE_TAB]: {
|
||
...state.sessions[ACTIVE_TAB]!,
|
||
streamingText: 'streaming next token',
|
||
},
|
||
},
|
||
}))
|
||
})
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('streaming next token')).toBeTruthy()
|
||
})
|
||
expect(scrollIntoView).not.toHaveBeenCalled()
|
||
expect(scrollTop).toBe(600)
|
||
})
|
||
|
||
it('keeps auto-scrolling without reading scroll geometry synchronously', async () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
chatState: 'streaming',
|
||
messages: [
|
||
{
|
||
id: 'user-1',
|
||
type: 'user_text',
|
||
content: 'latest prompt',
|
||
timestamp: 1,
|
||
},
|
||
],
|
||
streamingText: 'streaming',
|
||
}),
|
||
},
|
||
})
|
||
|
||
const { container } = render(<MessageList />)
|
||
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
|
||
const readScrollHeight = vi.fn(() => {
|
||
throw new Error('scrollHeight should not be read while pinning to bottom')
|
||
})
|
||
const readClientHeight = vi.fn(() => {
|
||
throw new Error('clientHeight should not be read while pinning to bottom')
|
||
})
|
||
let scrollTop = 552
|
||
Object.defineProperty(scroller, 'scrollHeight', {
|
||
configurable: true,
|
||
get: readScrollHeight,
|
||
})
|
||
Object.defineProperty(scroller, 'clientHeight', {
|
||
configurable: true,
|
||
get: readClientHeight,
|
||
})
|
||
Object.defineProperty(scroller, 'scrollTop', {
|
||
configurable: true,
|
||
get: () => scrollTop,
|
||
set: (value) => {
|
||
scrollTop = value >= 1_000_000_000 ? 600 : value
|
||
},
|
||
})
|
||
Object.defineProperty(scroller, 'scrollTo', {
|
||
configurable: true,
|
||
value: vi.fn((options: ScrollToOptions | number, y?: number) => {
|
||
scroller.scrollTop = typeof options === 'number' ? y ?? 0 : options.top ?? 0
|
||
}),
|
||
})
|
||
|
||
await waitForProgrammaticScrollReset()
|
||
act(() => {
|
||
useChatStore.setState((state) => ({
|
||
sessions: {
|
||
...state.sessions,
|
||
[ACTIVE_TAB]: {
|
||
...state.sessions[ACTIVE_TAB]!,
|
||
streamingText: 'streaming next token',
|
||
},
|
||
},
|
||
}))
|
||
})
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('streaming next token')).toBeTruthy()
|
||
})
|
||
await waitForProgrammaticScrollReset()
|
||
|
||
expect(scrollTop).toBe(600)
|
||
expect(readScrollHeight).not.toHaveBeenCalled()
|
||
expect(readClientHeight).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('keeps mobile H5 streaming output pinned after the transcript height grows', async () => {
|
||
const scrollIntoView = vi.fn()
|
||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||
configurable: true,
|
||
value: scrollIntoView,
|
||
})
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
chatState: 'streaming',
|
||
messages: [
|
||
{
|
||
id: 'user-1',
|
||
type: 'user_text',
|
||
content: '移动端长回复',
|
||
timestamp: 1,
|
||
},
|
||
],
|
||
streamingText: 'streaming',
|
||
}),
|
||
},
|
||
})
|
||
|
||
const { container } = render(<MessageList />)
|
||
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
|
||
let scrollTop = 552
|
||
let scrollHeight = 1000
|
||
Object.defineProperty(scroller, 'scrollHeight', {
|
||
configurable: true,
|
||
get: () => scrollHeight,
|
||
})
|
||
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 })
|
||
Object.defineProperty(scroller, 'scrollTop', {
|
||
configurable: true,
|
||
get: () => scrollTop,
|
||
set: (value) => {
|
||
scrollTop = value
|
||
},
|
||
})
|
||
|
||
await waitForProgrammaticScrollReset()
|
||
fireEvent.scroll(scroller)
|
||
expect(screen.queryByRole('button', { name: 'Latest' })).toBeNull()
|
||
|
||
scrollIntoView.mockClear()
|
||
scrollHeight = 1400
|
||
act(() => {
|
||
useChatStore.setState((state) => ({
|
||
sessions: {
|
||
...state.sessions,
|
||
[ACTIVE_TAB]: {
|
||
...state.sessions[ACTIVE_TAB]!,
|
||
streamingText: 'streaming next token after height change',
|
||
},
|
||
},
|
||
}))
|
||
})
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('streaming next token after height change')).toBeTruthy()
|
||
})
|
||
expect(scrollIntoView).not.toHaveBeenCalled()
|
||
expect(scrollTop).toBe(1000)
|
||
|
||
await waitForProgrammaticScrollReset()
|
||
fireEvent.scroll(scroller)
|
||
|
||
expect(screen.queryByRole('button', { name: 'Latest' })).toBeNull()
|
||
})
|
||
|
||
it('keeps H5 pinned when streaming content resizes after render', async () => {
|
||
const scrollIntoView = vi.fn()
|
||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||
configurable: true,
|
||
value: scrollIntoView,
|
||
})
|
||
|
||
let resizeCallback: ResizeObserverCallback | null = null
|
||
class TestResizeObserver {
|
||
observe = vi.fn()
|
||
unobserve = vi.fn()
|
||
disconnect = vi.fn()
|
||
|
||
constructor(callback: ResizeObserverCallback) {
|
||
resizeCallback = callback
|
||
}
|
||
}
|
||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
chatState: 'streaming',
|
||
messages: [
|
||
{
|
||
id: 'user-1',
|
||
type: 'user_text',
|
||
content: '移动端异步重排',
|
||
timestamp: 1,
|
||
},
|
||
],
|
||
streamingText: 'streaming',
|
||
}),
|
||
},
|
||
})
|
||
|
||
const { container } = render(<MessageList />)
|
||
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
|
||
let scrollTop = 552
|
||
let scrollHeight = 1000
|
||
Object.defineProperty(scroller, 'scrollHeight', {
|
||
configurable: true,
|
||
get: () => scrollHeight,
|
||
})
|
||
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 })
|
||
Object.defineProperty(scroller, 'scrollTop', {
|
||
configurable: true,
|
||
get: () => scrollTop,
|
||
set: (value) => {
|
||
scrollTop = value
|
||
},
|
||
})
|
||
|
||
await waitFor(() => {
|
||
expect(resizeCallback).not.toBeNull()
|
||
})
|
||
await waitForProgrammaticScrollReset()
|
||
fireEvent.scroll(scroller)
|
||
expect(screen.queryByRole('button', { name: 'Latest' })).toBeNull()
|
||
|
||
scrollIntoView.mockClear()
|
||
scrollHeight = 1600
|
||
act(() => {
|
||
resizeCallback?.([], {} as ResizeObserver)
|
||
})
|
||
|
||
expect(scrollIntoView).not.toHaveBeenCalled()
|
||
expect(scrollTop).toBe(1200)
|
||
expect(screen.queryByRole('button', { name: 'Latest' })).toBeNull()
|
||
})
|
||
|
||
it('ignores one-pixel content resize jitter while pinned to active thinking output', async () => {
|
||
let resizeCallback: ResizeObserverCallback | null = null
|
||
class TestResizeObserver {
|
||
observe = vi.fn()
|
||
unobserve = vi.fn()
|
||
disconnect = vi.fn()
|
||
|
||
constructor(callback: ResizeObserverCallback) {
|
||
resizeCallback = callback
|
||
}
|
||
}
|
||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
chatState: 'thinking',
|
||
activeThinkingId: 'thinking-1',
|
||
messages: [
|
||
{
|
||
id: 'user-1',
|
||
type: 'user_text',
|
||
content: '触发 Windows WebView2 细微重排',
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'thinking-1',
|
||
type: 'thinking',
|
||
content: '正在分析一个静态问题',
|
||
timestamp: 2,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
const { container } = render(<MessageList />)
|
||
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
|
||
let scrollTop = 600
|
||
let scrollTopWriteCount = 0
|
||
let scrollHeight = 1000
|
||
Object.defineProperty(scroller, 'scrollHeight', {
|
||
configurable: true,
|
||
get: () => scrollHeight,
|
||
})
|
||
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 })
|
||
Object.defineProperty(scroller, 'scrollTop', {
|
||
configurable: true,
|
||
get: () => scrollTop,
|
||
set: (value) => {
|
||
scrollTopWriteCount += 1
|
||
scrollTop = value
|
||
},
|
||
})
|
||
|
||
await waitFor(() => {
|
||
expect(resizeCallback).not.toBeNull()
|
||
})
|
||
await waitForProgrammaticScrollReset()
|
||
|
||
const makeResizeEntry = (height: number) => ([{
|
||
contentRect: { height },
|
||
} as ResizeObserverEntry])
|
||
|
||
act(() => {
|
||
resizeCallback?.(makeResizeEntry(400), {} as ResizeObserver)
|
||
})
|
||
expect(scrollTop).toBe(600)
|
||
|
||
scrollTopWriteCount = 0
|
||
act(() => {
|
||
resizeCallback?.(makeResizeEntry(401), {} as ResizeObserver)
|
||
})
|
||
act(() => {
|
||
resizeCallback?.(makeResizeEntry(400), {} as ResizeObserver)
|
||
})
|
||
|
||
expect(scrollTopWriteCount).toBe(0)
|
||
expect(scrollTop).toBe(600)
|
||
|
||
scrollHeight = 1040
|
||
act(() => {
|
||
resizeCallback?.(makeResizeEntry(420), {} as ResizeObserver)
|
||
})
|
||
|
||
expect(scrollTop).toBe(640)
|
||
})
|
||
|
||
it('does not pull a completed session back to the bottom when content resizes', async () => {
|
||
const scrollIntoView = vi.fn()
|
||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||
configurable: true,
|
||
value: scrollIntoView,
|
||
})
|
||
|
||
let resizeCallback: ResizeObserverCallback | null = null
|
||
class TestResizeObserver {
|
||
observe = vi.fn()
|
||
unobserve = vi.fn()
|
||
disconnect = vi.fn()
|
||
|
||
constructor(callback: ResizeObserverCallback) {
|
||
resizeCallback = callback
|
||
}
|
||
}
|
||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
chatState: 'idle',
|
||
messages: [
|
||
{
|
||
id: 'user-1',
|
||
type: 'user_text',
|
||
content: '生成一个 todo app',
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'assistant-1',
|
||
type: 'assistant_text',
|
||
content: [
|
||
'已完成。',
|
||
'',
|
||
'```bash',
|
||
'cd /private/tmp/todo-app',
|
||
'npm run dev',
|
||
'```',
|
||
].join('\n'),
|
||
timestamp: 2,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
const { container } = render(<MessageList />)
|
||
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
|
||
let scrollTop = 180
|
||
let scrollHeight = 1400
|
||
Object.defineProperty(scroller, 'scrollHeight', {
|
||
configurable: true,
|
||
get: () => scrollHeight,
|
||
})
|
||
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 })
|
||
Object.defineProperty(scroller, 'scrollTop', {
|
||
configurable: true,
|
||
get: () => scrollTop,
|
||
set: (value) => {
|
||
scrollTop = value
|
||
},
|
||
})
|
||
|
||
await waitFor(() => {
|
||
expect(resizeCallback).not.toBeNull()
|
||
})
|
||
|
||
scrollIntoView.mockClear()
|
||
scrollHeight = 1600
|
||
act(() => {
|
||
resizeCallback?.([], {} as ResizeObserver)
|
||
})
|
||
|
||
expect(scrollIntoView).not.toHaveBeenCalled()
|
||
expect(scrollTop).toBe(180)
|
||
})
|
||
|
||
it('does not pull a restored completed session back to the bottom from stale running state', async () => {
|
||
const scrollIntoView = vi.fn()
|
||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||
configurable: true,
|
||
value: scrollIntoView,
|
||
})
|
||
|
||
let resizeCallback: ResizeObserverCallback | null = null
|
||
class TestResizeObserver {
|
||
observe = vi.fn()
|
||
unobserve = vi.fn()
|
||
disconnect = vi.fn()
|
||
|
||
constructor(callback: ResizeObserverCallback) {
|
||
resizeCallback = callback
|
||
}
|
||
}
|
||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
chatState: 'thinking',
|
||
activeThinkingId: null,
|
||
messages: [
|
||
{
|
||
id: 'user-1',
|
||
type: 'user_text',
|
||
content: '复盘这个已完成会话',
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'assistant-1',
|
||
type: 'assistant_text',
|
||
content: [
|
||
'这个会话已经完成。',
|
||
'',
|
||
'```tsx',
|
||
'export function TodoListView() {',
|
||
' return <section>Done</section>',
|
||
'}',
|
||
'```',
|
||
].join('\n'),
|
||
timestamp: 2,
|
||
},
|
||
],
|
||
streamingText: '',
|
||
}),
|
||
},
|
||
})
|
||
|
||
const { container } = render(<MessageList />)
|
||
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
|
||
let scrollTop = 260
|
||
let scrollHeight = 1800
|
||
Object.defineProperty(scroller, 'scrollHeight', {
|
||
configurable: true,
|
||
get: () => scrollHeight,
|
||
})
|
||
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 })
|
||
Object.defineProperty(scroller, 'scrollTop', {
|
||
configurable: true,
|
||
get: () => scrollTop,
|
||
set: (value) => {
|
||
scrollTop = value
|
||
},
|
||
})
|
||
|
||
await waitFor(() => {
|
||
expect(resizeCallback).not.toBeNull()
|
||
})
|
||
|
||
scrollIntoView.mockClear()
|
||
scrollHeight = 2100
|
||
act(() => {
|
||
resizeCallback?.([], {} as ResizeObserver)
|
||
})
|
||
|
||
expect(scrollIntoView).not.toHaveBeenCalled()
|
||
expect(scrollTop).toBe(260)
|
||
})
|
||
|
||
it('restores a session scroll position when switching back to a tab', async () => {
|
||
const scrollIntoView = vi.fn()
|
||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||
configurable: true,
|
||
value: scrollIntoView,
|
||
})
|
||
|
||
useTabStore.setState({
|
||
activeTabId: 'session-a',
|
||
tabs: [
|
||
{ sessionId: 'session-a', title: 'A', type: 'session' as const, status: 'idle' },
|
||
{ sessionId: 'session-b', title: 'B', type: 'session' as const, status: 'idle' },
|
||
],
|
||
})
|
||
useChatStore.setState({
|
||
sessions: {
|
||
'session-a': makeSessionState({
|
||
messages: [
|
||
{ id: 'a-user', type: 'user_text', content: 'A prompt', timestamp: 1 },
|
||
{ id: 'a-assistant', type: 'assistant_text', content: 'A response', timestamp: 2 },
|
||
],
|
||
}),
|
||
'session-b': makeSessionState({
|
||
messages: [
|
||
{ id: 'b-user', type: 'user_text', content: 'B prompt', timestamp: 1 },
|
||
{ id: 'b-assistant', type: 'assistant_text', content: 'B response', timestamp: 2 },
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
const { container } = render(<MessageList />)
|
||
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
|
||
let scrollTop = 180
|
||
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1200 })
|
||
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 })
|
||
Object.defineProperty(scroller, 'scrollTop', {
|
||
configurable: true,
|
||
get: () => scrollTop,
|
||
set: (value) => {
|
||
scrollTop = value
|
||
},
|
||
})
|
||
|
||
await waitForProgrammaticScrollReset()
|
||
fireEvent.scroll(scroller)
|
||
expect(screen.getByRole('button', { name: 'Latest' })).toBeTruthy()
|
||
|
||
act(() => {
|
||
useTabStore.setState({ activeTabId: 'session-b' })
|
||
})
|
||
await waitFor(() => {
|
||
expect(screen.getByText('B response')).toBeTruthy()
|
||
})
|
||
|
||
scrollTop = 760
|
||
await waitForProgrammaticScrollReset()
|
||
fireEvent.scroll(scroller)
|
||
|
||
act(() => {
|
||
useTabStore.setState({ activeTabId: 'session-a' })
|
||
})
|
||
await waitFor(() => {
|
||
expect(screen.getByText('A response')).toBeTruthy()
|
||
})
|
||
|
||
expect(scrollTop).toBe(180)
|
||
expect(screen.getByRole('button', { name: 'Latest' })).toBeTruthy()
|
||
})
|
||
|
||
it('scrolls new sessions to the latest message instead of inheriting another tab position', async () => {
|
||
const scrollIntoView = vi.fn()
|
||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||
configurable: true,
|
||
value: scrollIntoView,
|
||
})
|
||
|
||
useTabStore.setState({
|
||
activeTabId: 'session-a',
|
||
tabs: [
|
||
{ sessionId: 'session-a', title: 'A', type: 'session' as const, status: 'idle' },
|
||
{ sessionId: 'session-fresh', title: 'Fresh', type: 'session' as const, status: 'idle' },
|
||
],
|
||
})
|
||
useChatStore.setState({
|
||
sessions: {
|
||
'session-a': makeSessionState({
|
||
messages: [
|
||
{ id: 'a-user', type: 'user_text', content: 'A prompt', timestamp: 1 },
|
||
{ id: 'a-assistant', type: 'assistant_text', content: 'A response', timestamp: 2 },
|
||
],
|
||
}),
|
||
'session-fresh': makeSessionState({
|
||
messages: [
|
||
{ id: 'fresh-user', type: 'user_text', content: 'Fresh prompt', timestamp: 1 },
|
||
{ id: 'fresh-assistant', type: 'assistant_text', content: 'Fresh latest response', timestamp: 2 },
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
const { container } = render(<MessageList />)
|
||
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
|
||
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1200 })
|
||
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 })
|
||
Object.defineProperty(scroller, 'scrollTop', {
|
||
configurable: true,
|
||
value: 150,
|
||
writable: true,
|
||
})
|
||
|
||
fireEvent.scroll(scroller)
|
||
scrollIntoView.mockClear()
|
||
|
||
act(() => {
|
||
useTabStore.setState({ activeTabId: 'session-fresh' })
|
||
})
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('Fresh latest response')).toBeTruthy()
|
||
expect(scrollIntoView).not.toHaveBeenCalled()
|
||
})
|
||
expect(scroller.scrollTop).toBe(800)
|
||
})
|
||
|
||
it('shows a latest button when reading history and resumes following after clicking it', async () => {
|
||
const scrollIntoView = vi.fn()
|
||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||
configurable: true,
|
||
value: scrollIntoView,
|
||
})
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
chatState: 'streaming',
|
||
messages: [
|
||
{
|
||
id: 'user-1',
|
||
type: 'user_text',
|
||
content: '历史消息',
|
||
timestamp: 1,
|
||
},
|
||
],
|
||
streamingText: 'streaming',
|
||
}),
|
||
},
|
||
})
|
||
|
||
const { container } = render(<MessageList />)
|
||
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
|
||
let scrollTop = 120
|
||
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1000 })
|
||
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 })
|
||
Object.defineProperty(scroller, 'scrollTop', {
|
||
configurable: true,
|
||
get: () => scrollTop,
|
||
set: (value) => {
|
||
scrollTop = value
|
||
},
|
||
})
|
||
|
||
scrollIntoView.mockClear()
|
||
await waitForProgrammaticScrollReset()
|
||
fireEvent.scroll(scroller)
|
||
fireEvent.click(screen.getByRole('button', { name: 'Latest' }))
|
||
|
||
expect(scrollIntoView).not.toHaveBeenCalled()
|
||
expect(scrollTop).toBe(600)
|
||
expect(screen.queryByRole('button', { name: 'Latest' })).toBeNull()
|
||
|
||
scrollIntoView.mockClear()
|
||
act(() => {
|
||
useChatStore.setState((state) => ({
|
||
sessions: {
|
||
...state.sessions,
|
||
[ACTIVE_TAB]: {
|
||
...state.sessions[ACTIVE_TAB]!,
|
||
streamingText: 'streaming after jump',
|
||
},
|
||
},
|
||
}))
|
||
})
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('streaming after jump')).toBeTruthy()
|
||
})
|
||
expect(scrollIntoView).not.toHaveBeenCalled()
|
||
expect(scrollTop).toBe(600)
|
||
})
|
||
|
||
it('jumps to the latest message when the user sends a new prompt from history', async () => {
|
||
const scrollIntoView = vi.fn()
|
||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||
configurable: true,
|
||
value: scrollIntoView,
|
||
})
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'user-1',
|
||
type: 'user_text',
|
||
content: '历史消息',
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'assistant-1',
|
||
type: 'assistant_text',
|
||
content: '历史回复',
|
||
timestamp: 2,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
const { container } = render(<MessageList />)
|
||
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
|
||
let scrollTop = 120
|
||
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1000 })
|
||
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 })
|
||
Object.defineProperty(scroller, 'scrollTop', {
|
||
configurable: true,
|
||
get: () => scrollTop,
|
||
set: (value) => {
|
||
scrollTop = value
|
||
},
|
||
})
|
||
|
||
scrollIntoView.mockClear()
|
||
await waitForProgrammaticScrollReset()
|
||
fireEvent.scroll(scroller)
|
||
expect(screen.getByRole('button', { name: 'Latest' })).toBeTruthy()
|
||
|
||
act(() => {
|
||
useChatStore.setState((state) => ({
|
||
sessions: {
|
||
...state.sessions,
|
||
[ACTIVE_TAB]: {
|
||
...state.sessions[ACTIVE_TAB]!,
|
||
chatState: 'thinking',
|
||
messages: [
|
||
...state.sessions[ACTIVE_TAB]!.messages,
|
||
{
|
||
id: 'user-2',
|
||
type: 'user_text',
|
||
content: '新的问题',
|
||
timestamp: 3,
|
||
},
|
||
],
|
||
},
|
||
},
|
||
}))
|
||
})
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('新的问题')).toBeTruthy()
|
||
})
|
||
expect(scrollIntoView).not.toHaveBeenCalled()
|
||
expect(scrollTop).toBe(600)
|
||
expect(screen.queryByRole('button', { name: 'Latest' })).toBeNull()
|
||
})
|
||
|
||
it('jumps to the latest message when a sent prompt lands before chat state changes', async () => {
|
||
const scrollIntoView = vi.fn()
|
||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||
configurable: true,
|
||
value: scrollIntoView,
|
||
})
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'user-1',
|
||
type: 'user_text',
|
||
content: '历史消息',
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'assistant-1',
|
||
type: 'assistant_text',
|
||
content: '历史回复',
|
||
timestamp: 2,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
const { container } = render(<MessageList />)
|
||
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
|
||
let scrollTop = 120
|
||
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1000 })
|
||
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 })
|
||
Object.defineProperty(scroller, 'scrollTop', {
|
||
configurable: true,
|
||
get: () => scrollTop,
|
||
set: (value) => {
|
||
scrollTop = value
|
||
},
|
||
})
|
||
|
||
scrollIntoView.mockClear()
|
||
await waitForProgrammaticScrollReset()
|
||
fireEvent.scroll(scroller)
|
||
expect(screen.getByRole('button', { name: 'Latest' })).toBeTruthy()
|
||
|
||
act(() => {
|
||
useChatStore.setState((state) => ({
|
||
sessions: {
|
||
...state.sessions,
|
||
[ACTIVE_TAB]: {
|
||
...state.sessions[ACTIVE_TAB]!,
|
||
chatState: 'idle',
|
||
messages: [
|
||
...state.sessions[ACTIVE_TAB]!.messages,
|
||
{
|
||
id: 'user-2',
|
||
type: 'user_text',
|
||
content: '刚发送的问题',
|
||
timestamp: 3,
|
||
},
|
||
],
|
||
},
|
||
},
|
||
}))
|
||
})
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('刚发送的问题')).toBeTruthy()
|
||
})
|
||
expect(scrollIntoView).not.toHaveBeenCalled()
|
||
expect(scrollTop).toBe(600)
|
||
expect(screen.queryByRole('button', { name: 'Latest' })).toBeNull()
|
||
|
||
act(() => {
|
||
useChatStore.setState((state) => ({
|
||
sessions: {
|
||
...state.sessions,
|
||
[ACTIVE_TAB]: {
|
||
...state.sessions[ACTIVE_TAB]!,
|
||
chatState: 'thinking',
|
||
},
|
||
},
|
||
}))
|
||
})
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('刚发送的问题')).toBeTruthy()
|
||
})
|
||
expect(scrollTop).toBe(600)
|
||
})
|
||
|
||
it('keeps user actions anchored to the right bubble and assistant actions to the left bubble', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'user-1',
|
||
type: 'user_text',
|
||
content: '请把这条 prompt 放在右侧',
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'assistant-1',
|
||
type: 'assistant_text',
|
||
content: '这条回复应该停在左侧。',
|
||
timestamp: 2,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
const userShell = screen.getByText('请把这条 prompt 放在右侧').closest('[data-message-shell="user"]')
|
||
const assistantShell = screen.getByText('这条回复应该停在左侧。').closest('[data-message-shell="assistant"]')
|
||
const userActions = screen.getByRole('button', { name: 'Copy prompt' }).closest('[data-message-actions]')
|
||
const assistantActions = screen.getByRole('button', { name: 'Copy reply' }).closest('[data-message-actions]')
|
||
|
||
expect(userShell).toBeTruthy()
|
||
expect(userShell?.className).toContain('items-end')
|
||
expect(assistantShell).toBeTruthy()
|
||
expect(assistantShell?.className).toContain('items-start')
|
||
expect(assistantShell?.className).not.toContain('ml-10')
|
||
expect(userActions?.getAttribute('data-align')).toBe('end')
|
||
expect(assistantActions?.getAttribute('data-align')).toBe('start')
|
||
})
|
||
|
||
it('uses the document column for markdown-heavy assistant replies', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'assistant-doc',
|
||
type: 'assistant_text',
|
||
content: [
|
||
'## 交付结果',
|
||
'',
|
||
'已完成以下内容:',
|
||
'',
|
||
'- 添加任务',
|
||
'- 删除任务',
|
||
'',
|
||
'```bash',
|
||
'npm run build',
|
||
'```',
|
||
].join('\n'),
|
||
timestamp: 1,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
const assistantShell = screen.getByText('交付结果').closest('[data-message-shell="assistant"]')
|
||
expect(assistantShell?.getAttribute('data-layout')).toBe('document')
|
||
expect(assistantShell?.className).toContain('w-full')
|
||
expect(assistantShell?.className).not.toContain('ml-10')
|
||
})
|
||
|
||
it('does not expose the old message-level rewind action', async () => {
|
||
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
|
||
checkpoints: [
|
||
{
|
||
target: {
|
||
targetUserMessageId: 'user-1',
|
||
userMessageIndex: 0,
|
||
userMessageCount: 1,
|
||
},
|
||
code: {
|
||
available: true,
|
||
filesChanged: ['src/App.tsx'],
|
||
insertions: 4,
|
||
deletions: 1,
|
||
},
|
||
},
|
||
],
|
||
})
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'user-1',
|
||
type: 'user_text',
|
||
content: '做一个页面',
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'assistant-1',
|
||
type: 'assistant_text',
|
||
content: 'done',
|
||
timestamp: 2,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
expect(await screen.findByRole('button', { name: 'Undo current turn changes' })).toBeTruthy()
|
||
expect(screen.queryByRole('button', { name: 'Rewind to here' })).toBeNull()
|
||
})
|
||
|
||
it('branches from completed transcript-backed chat messages using the original transcript id', async () => {
|
||
const branchSession = vi.fn().mockResolvedValue({
|
||
sessionId: 'branched-session-1',
|
||
title: 'Branched session',
|
||
workDir: '/tmp/branched-session-1',
|
||
})
|
||
const connectToSession = vi.fn()
|
||
useSessionStore.setState({
|
||
sessions: [{
|
||
id: ACTIVE_TAB,
|
||
title: 'Source session',
|
||
createdAt: '2026-05-19T00:00:00.000Z',
|
||
modifiedAt: '2026-05-19T00:00:00.000Z',
|
||
messageCount: 2,
|
||
projectPath: '/tmp/source-project',
|
||
projectRoot: '/tmp/source-project',
|
||
workDir: '/tmp/source-project',
|
||
workDirExists: true,
|
||
}],
|
||
branchSession: branchSession as never,
|
||
})
|
||
useChatStore.setState({
|
||
connectToSession: connectToSession as never,
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'local-user-1',
|
||
transcriptMessageId: 'transcript-user-1',
|
||
type: 'user_text',
|
||
content: '从这里开始',
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'local-assistant-1',
|
||
transcriptMessageId: 'transcript-assistant-1',
|
||
type: 'assistant_text',
|
||
content: '这是完成的答复。',
|
||
timestamp: 2,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
const branchButtons = screen.getAllByRole('button', { name: 'Fork a new conversation' })
|
||
expect(branchButtons).toHaveLength(2)
|
||
expect(branchButtons[0]!.closest('[data-message-actions]')).toBe(
|
||
screen.getByRole('button', { name: 'Copy prompt' }).closest('[data-message-actions]')
|
||
)
|
||
expect(branchButtons[1]!.closest('[data-message-actions]')).toBe(
|
||
screen.getByRole('button', { name: 'Copy reply' }).closest('[data-message-actions]')
|
||
)
|
||
expect(branchButtons[1]?.getAttribute('title')).toBe('Fork a new conversation')
|
||
|
||
fireEvent.click(branchButtons[1]!)
|
||
|
||
await waitFor(() => {
|
||
expect(branchSession).toHaveBeenCalledWith(ACTIVE_TAB, 'transcript-assistant-1')
|
||
})
|
||
expect(connectToSession).toHaveBeenCalledWith('branched-session-1')
|
||
expect(useTabStore.getState().activeTabId).toBe('branched-session-1')
|
||
const tabs = useTabStore.getState().tabs
|
||
expect(tabs[tabs.length - 1]).toMatchObject({
|
||
sessionId: 'branched-session-1',
|
||
title: 'Branched session',
|
||
type: 'session',
|
||
})
|
||
const toasts = useUIStore.getState().toasts
|
||
expect(toasts[toasts.length - 1]).toMatchObject({
|
||
type: 'success',
|
||
message: 'Created forked conversation "Branched session".',
|
||
})
|
||
})
|
||
|
||
it('hides branch actions while the current session is still running', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
chatState: 'streaming',
|
||
streamingText: 'partial',
|
||
messages: [
|
||
{
|
||
id: 'local-user-1',
|
||
transcriptMessageId: 'transcript-user-1',
|
||
type: 'user_text',
|
||
content: '从这里开始',
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'local-assistant-1',
|
||
transcriptMessageId: 'transcript-assistant-1',
|
||
type: 'assistant_text',
|
||
content: '这是完成的答复。',
|
||
timestamp: 2,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
expect(screen.queryByRole('button', { name: 'Fork a new conversation' })).toBeNull()
|
||
})
|
||
|
||
it('keeps historical sessions readable when turn checkpoint payloads are missing', async () => {
|
||
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({} as never)
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'user-1',
|
||
type: 'user_text',
|
||
content: '继续优化 workflow.py',
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'assistant-1',
|
||
type: 'assistant_text',
|
||
content: '两个文件均已优化完成,功能保持不变。',
|
||
timestamp: 2,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
expect(await screen.findByText('两个文件均已优化完成,功能保持不变。')).toBeTruthy()
|
||
await waitFor(() => {
|
||
expect(sessionsApi.getTurnCheckpoints).toHaveBeenCalled()
|
||
})
|
||
expect(screen.queryByText(/Cannot read properties/)).toBeNull()
|
||
expect(screen.queryByLabelText('Turn changed files')).toBeNull()
|
||
})
|
||
|
||
it('renders multiple historical turn change cards across three turns', async () => {
|
||
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
|
||
checkpoints: [
|
||
{
|
||
target: {
|
||
targetUserMessageId: 'user-1',
|
||
userMessageIndex: 0,
|
||
userMessageCount: 3,
|
||
},
|
||
code: {
|
||
available: true,
|
||
filesChanged: ['src/first.ts'],
|
||
insertions: 3,
|
||
deletions: 1,
|
||
},
|
||
},
|
||
{
|
||
target: {
|
||
targetUserMessageId: 'user-2',
|
||
userMessageIndex: 1,
|
||
userMessageCount: 3,
|
||
},
|
||
code: {
|
||
available: true,
|
||
filesChanged: ['src/second.ts'],
|
||
insertions: 5,
|
||
deletions: 2,
|
||
},
|
||
},
|
||
{
|
||
target: {
|
||
targetUserMessageId: 'user-3',
|
||
userMessageIndex: 2,
|
||
userMessageCount: 3,
|
||
},
|
||
code: {
|
||
available: true,
|
||
filesChanged: [],
|
||
insertions: 0,
|
||
deletions: 0,
|
||
},
|
||
},
|
||
],
|
||
})
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'user-1',
|
||
type: 'user_text',
|
||
content: '第一段',
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'assistant-1',
|
||
type: 'assistant_text',
|
||
content: 'ok',
|
||
timestamp: 2,
|
||
},
|
||
{
|
||
id: 'user-2',
|
||
type: 'user_text',
|
||
content: '第二段',
|
||
timestamp: 3,
|
||
},
|
||
{
|
||
id: 'assistant-2',
|
||
type: 'assistant_text',
|
||
content: 'done',
|
||
timestamp: 4,
|
||
},
|
||
{
|
||
id: 'user-3',
|
||
type: 'user_text',
|
||
content: '第三段',
|
||
timestamp: 5,
|
||
},
|
||
{
|
||
id: 'assistant-3',
|
||
type: 'assistant_text',
|
||
content: 'done',
|
||
timestamp: 6,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
const cards = await screen.findAllByLabelText('Turn changed files')
|
||
expect(cards).toHaveLength(2)
|
||
expect(screen.getByText('src/first.ts')).toBeTruthy()
|
||
expect(screen.getByText('src/second.ts')).toBeTruthy()
|
||
expect(screen.queryByText('src/third.ts')).toBeNull()
|
||
})
|
||
|
||
it('expands a historical turn diff through the turn checkpoint diff API', async () => {
|
||
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
|
||
checkpoints: [
|
||
{
|
||
target: {
|
||
targetUserMessageId: 'user-1',
|
||
userMessageIndex: 0,
|
||
userMessageCount: 2,
|
||
},
|
||
code: {
|
||
available: true,
|
||
filesChanged: ['src/first.ts'],
|
||
insertions: 1,
|
||
deletions: 1,
|
||
},
|
||
},
|
||
{
|
||
target: {
|
||
targetUserMessageId: 'user-2',
|
||
userMessageIndex: 1,
|
||
userMessageCount: 2,
|
||
},
|
||
code: {
|
||
available: true,
|
||
filesChanged: ['src/second.ts'],
|
||
insertions: 2,
|
||
deletions: 0,
|
||
},
|
||
},
|
||
],
|
||
})
|
||
vi.spyOn(sessionsApi, 'getWorkspaceDiff').mockResolvedValue({
|
||
state: 'ok',
|
||
path: 'src/first.ts',
|
||
diff: 'diff --session a/src/first.ts b/src/first.ts\n-old\n+new',
|
||
})
|
||
vi.spyOn(sessionsApi, 'getTurnCheckpointDiff').mockResolvedValue({
|
||
state: 'ok',
|
||
path: 'src/first.ts',
|
||
diff: 'diff --session a/src/first.ts b/src/first.ts\n-old\n+new',
|
||
})
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'user-1',
|
||
type: 'user_text',
|
||
content: '第一轮',
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'assistant-1',
|
||
type: 'assistant_text',
|
||
content: 'done',
|
||
timestamp: 2,
|
||
},
|
||
{
|
||
id: 'user-2',
|
||
type: 'user_text',
|
||
content: '第二轮',
|
||
timestamp: 3,
|
||
},
|
||
{
|
||
id: 'assistant-2',
|
||
type: 'assistant_text',
|
||
content: 'done',
|
||
timestamp: 4,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
fireEvent.click(await screen.findByRole('button', { name: 'Show diff for src/first.ts' }))
|
||
|
||
const diffSurface = await screen.findByTestId('workspace-code')
|
||
expect(diffSurface.textContent).toContain('+new')
|
||
expect(sessionsApi.getTurnCheckpointDiff).toHaveBeenCalledWith(
|
||
ACTIVE_TAB,
|
||
'user-1',
|
||
'src/first.ts',
|
||
0,
|
||
)
|
||
expect(sessionsApi.getWorkspaceDiff).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('keeps checkpoint paths bound to the original turn cwd when expanding historical diffs', async () => {
|
||
vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({
|
||
state: 'ok',
|
||
workDir: '/tmp/current-project',
|
||
repoName: 'current-project',
|
||
branch: null,
|
||
isGitRepo: false,
|
||
changedFiles: [],
|
||
})
|
||
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
|
||
checkpoints: [
|
||
{
|
||
target: {
|
||
targetUserMessageId: 'user-1',
|
||
userMessageIndex: 0,
|
||
userMessageCount: 2,
|
||
},
|
||
workDir: '/tmp/old-project',
|
||
code: {
|
||
available: true,
|
||
filesChanged: ['/tmp/old-project/src/first.ts'],
|
||
insertions: 1,
|
||
deletions: 1,
|
||
},
|
||
},
|
||
],
|
||
})
|
||
vi.spyOn(sessionsApi, 'getTurnCheckpointDiff').mockResolvedValue({
|
||
state: 'ok',
|
||
path: '/tmp/old-project/src/first.ts',
|
||
diff: 'diff --git a/src/first.ts b/src/first.ts\n-old\n+new',
|
||
})
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'user-1',
|
||
type: 'user_text',
|
||
content: '第一轮',
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'assistant-1',
|
||
type: 'assistant_text',
|
||
content: 'done',
|
||
timestamp: 2,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
fireEvent.click(await screen.findByRole('button', { name: 'Show diff for src/first.ts' }))
|
||
|
||
await screen.findByTestId('workspace-code')
|
||
expect(sessionsApi.getTurnCheckpointDiff).toHaveBeenCalledWith(
|
||
ACTIVE_TAB,
|
||
'user-1',
|
||
'/tmp/old-project/src/first.ts',
|
||
0,
|
||
)
|
||
})
|
||
|
||
it('relativizes Windows checkpoint paths against the turn workdir', () => {
|
||
expect(relativizeWorkspacePath(
|
||
'C:\\Users\\Relakkes\\aacc\\src\\App.tsx',
|
||
'c:/users/relakkes/aacc',
|
||
)).toBe('src/App.tsx')
|
||
})
|
||
|
||
it('matches live turn change checkpoints by user message index when transcript ids differ from local UI ids', async () => {
|
||
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
|
||
checkpoints: [
|
||
{
|
||
target: {
|
||
targetUserMessageId: 'transcript-user-1',
|
||
userMessageIndex: 0,
|
||
userMessageCount: 1,
|
||
},
|
||
code: {
|
||
available: true,
|
||
filesChanged: ['src/live.ts'],
|
||
insertions: 7,
|
||
deletions: 0,
|
||
},
|
||
},
|
||
],
|
||
})
|
||
vi.spyOn(sessionsApi, 'getTurnCheckpointDiff').mockResolvedValue({
|
||
state: 'ok',
|
||
path: 'src/live.ts',
|
||
diff: 'diff --session a/src/live.ts b/src/live.ts\n+live',
|
||
})
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'local-user-temp-id',
|
||
type: 'user_text',
|
||
content: '实时这一轮',
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'assistant-1',
|
||
type: 'assistant_text',
|
||
content: 'done',
|
||
timestamp: 2,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
expect(await screen.findByText('src/live.ts')).toBeTruthy()
|
||
fireEvent.click(screen.getByRole('button', { name: 'Show diff for src/live.ts' }))
|
||
await screen.findByTestId('workspace-code')
|
||
expect(sessionsApi.getTurnCheckpointDiff).toHaveBeenCalledWith(
|
||
ACTIVE_TAB,
|
||
'transcript-user-1',
|
||
'src/live.ts',
|
||
0,
|
||
)
|
||
})
|
||
|
||
it('keeps turn change cards anchored when the only response item is filtered from rendering', async () => {
|
||
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
|
||
checkpoints: [
|
||
{
|
||
target: {
|
||
targetUserMessageId: 'user-1',
|
||
userMessageIndex: 0,
|
||
userMessageCount: 1,
|
||
},
|
||
code: {
|
||
available: true,
|
||
filesChanged: ['src/blank-response.ts'],
|
||
insertions: 3,
|
||
deletions: 0,
|
||
},
|
||
},
|
||
],
|
||
})
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'user-1',
|
||
type: 'user_text',
|
||
content: '生成文件',
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'assistant-empty',
|
||
type: 'assistant_text',
|
||
content: '\n ',
|
||
timestamp: 2,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
expect(await screen.findByText('src/blank-response.ts')).toBeTruthy()
|
||
})
|
||
|
||
it('keeps historical turn change cards visible while the next turn is running', async () => {
|
||
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
|
||
checkpoints: [
|
||
{
|
||
target: {
|
||
targetUserMessageId: 'user-1',
|
||
userMessageIndex: 0,
|
||
userMessageCount: 1,
|
||
},
|
||
code: {
|
||
available: true,
|
||
filesChanged: ['src/first.ts'],
|
||
insertions: 1,
|
||
deletions: 0,
|
||
},
|
||
},
|
||
],
|
||
})
|
||
|
||
const messages: UIMessage[] = [
|
||
{
|
||
id: 'user-1',
|
||
type: 'user_text',
|
||
content: '第一轮',
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'assistant-1',
|
||
type: 'assistant_text',
|
||
content: 'done',
|
||
timestamp: 2,
|
||
},
|
||
]
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({ messages }),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
expect(await screen.findByText('src/first.ts')).toBeTruthy()
|
||
|
||
act(() => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages,
|
||
chatState: 'thinking',
|
||
}),
|
||
},
|
||
})
|
||
})
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('src/first.ts')).toBeTruthy()
|
||
})
|
||
})
|
||
|
||
it('confirms before rewinding to an earlier turn from a historical change card', async () => {
|
||
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
|
||
checkpoints: [
|
||
{
|
||
target: {
|
||
targetUserMessageId: 'user-1',
|
||
userMessageIndex: 0,
|
||
userMessageCount: 2,
|
||
},
|
||
code: {
|
||
available: true,
|
||
filesChanged: ['src/first.ts'],
|
||
insertions: 1,
|
||
deletions: 0,
|
||
},
|
||
},
|
||
{
|
||
target: {
|
||
targetUserMessageId: 'user-2',
|
||
userMessageIndex: 1,
|
||
userMessageCount: 2,
|
||
},
|
||
code: {
|
||
available: true,
|
||
filesChanged: ['src/second.ts'],
|
||
insertions: 1,
|
||
deletions: 0,
|
||
},
|
||
},
|
||
],
|
||
})
|
||
vi.spyOn(sessionsApi, 'rewind')
|
||
.mockResolvedValueOnce({
|
||
target: {
|
||
targetUserMessageId: 'user-1',
|
||
userMessageIndex: 0,
|
||
userMessageCount: 1,
|
||
},
|
||
conversation: {
|
||
messagesRemoved: 2,
|
||
},
|
||
code: {
|
||
available: true,
|
||
filesChanged: ['src/App.tsx'],
|
||
insertions: 1,
|
||
deletions: 0,
|
||
},
|
||
})
|
||
.mockResolvedValueOnce({
|
||
target: {
|
||
targetUserMessageId: 'user-1',
|
||
userMessageIndex: 0,
|
||
userMessageCount: 1,
|
||
},
|
||
conversation: {
|
||
messagesRemoved: 2,
|
||
removedMessageIds: ['user-1', 'assistant-1'],
|
||
},
|
||
code: {
|
||
available: true,
|
||
filesChanged: ['src/App.tsx'],
|
||
insertions: 1,
|
||
deletions: 0,
|
||
},
|
||
})
|
||
const reloadHistory = vi.fn().mockResolvedValue(undefined)
|
||
const queueComposerPrefill = vi.fn()
|
||
|
||
useChatStore.setState({
|
||
reloadHistory,
|
||
queueComposerPrefill,
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'user-1',
|
||
type: 'user_text',
|
||
content: '做一个页面',
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'assistant-1',
|
||
type: 'assistant_text',
|
||
content: 'first done',
|
||
timestamp: 2,
|
||
},
|
||
{
|
||
id: 'user-2',
|
||
type: 'user_text',
|
||
content: '第二轮需求',
|
||
timestamp: 3,
|
||
},
|
||
{
|
||
id: 'assistant-2',
|
||
type: 'assistant_text',
|
||
content: 'second done',
|
||
timestamp: 4,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
const historicalCard = (await screen.findByText('src/first.ts')).closest('section')
|
||
expect(historicalCard).toBeTruthy()
|
||
fireEvent.click(
|
||
within(historicalCard as HTMLElement).getByRole('button', {
|
||
name: 'Rewind to before this turn',
|
||
}),
|
||
)
|
||
|
||
expect(sessionsApi.rewind).not.toHaveBeenCalled()
|
||
const dialog = await screen.findByRole('dialog', { name: 'Rewind to before this turn?' })
|
||
expect(
|
||
within(dialog).getByText(
|
||
'This will rewind the conversation to before this turn and restore tracked files for that checkpoint.',
|
||
),
|
||
).toBeTruthy()
|
||
|
||
fireEvent.click(within(dialog).getByRole('button', { name: 'Rewind to before this turn' }))
|
||
|
||
await waitFor(() => {
|
||
expect(sessionsApi.rewind).toHaveBeenLastCalledWith(ACTIVE_TAB, {
|
||
targetUserMessageId: 'user-1',
|
||
userMessageIndex: 0,
|
||
expectedContent: '做一个页面',
|
||
})
|
||
})
|
||
expect(reloadHistory).toHaveBeenCalledWith(ACTIVE_TAB)
|
||
expect(queueComposerPrefill).toHaveBeenCalledWith(ACTIVE_TAB, {
|
||
text: '做一个页面',
|
||
attachments: undefined,
|
||
})
|
||
})
|
||
|
||
it('does not render cards for turns without file changes', async () => {
|
||
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
|
||
checkpoints: [
|
||
{
|
||
target: {
|
||
targetUserMessageId: 'user-1',
|
||
userMessageIndex: 0,
|
||
userMessageCount: 2,
|
||
},
|
||
code: {
|
||
available: true,
|
||
filesChanged: ['src/first.ts'],
|
||
insertions: 2,
|
||
deletions: 1,
|
||
},
|
||
},
|
||
{
|
||
target: {
|
||
targetUserMessageId: 'user-2',
|
||
userMessageIndex: 1,
|
||
userMessageCount: 2,
|
||
},
|
||
code: {
|
||
available: true,
|
||
filesChanged: [],
|
||
insertions: 0,
|
||
deletions: 0,
|
||
},
|
||
},
|
||
],
|
||
})
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'user-1',
|
||
type: 'user_text',
|
||
content: '第一轮改文件',
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
id: 'assistant-1',
|
||
type: 'assistant_text',
|
||
content: 'first done',
|
||
timestamp: 2,
|
||
},
|
||
{
|
||
id: 'user-2',
|
||
type: 'user_text',
|
||
content: '第二轮只解释',
|
||
timestamp: 3,
|
||
},
|
||
{
|
||
id: 'assistant-2',
|
||
type: 'assistant_text',
|
||
content: 'second done',
|
||
timestamp: 4,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
const cards = await screen.findAllByLabelText('Turn changed files')
|
||
expect(cards).toHaveLength(1)
|
||
expect(screen.getByText('src/first.ts')).toBeTruthy()
|
||
expect(screen.queryByText('src/second.ts')).toBeNull()
|
||
})
|
||
|
||
it('shows raw startup details under translated CLI startup errors', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[ACTIVE_TAB]: makeSessionState({
|
||
messages: [
|
||
{
|
||
id: 'error-1',
|
||
type: 'error',
|
||
code: 'CLI_START_FAILED',
|
||
message:
|
||
'CLI exited during startup (code 1): Claude Code on Windows requires git-bash (https://git-scm.com/downloads/win).',
|
||
timestamp: 1,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
})
|
||
|
||
render(<MessageList />)
|
||
|
||
expect(screen.getByText('Failed to start CLI process.')).toBeTruthy()
|
||
expect(
|
||
screen.getByText(
|
||
'CLI exited during startup (code 1): Claude Code on Windows requires git-bash (https://git-scm.com/downloads/win).',
|
||
),
|
||
).toBeTruthy()
|
||
})
|
||
})
|