mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
Thinking markdown should remain available only after the user opens the disclosure. The prior preview mounted markdown content while collapsed, which made internal thinking occupy visible transcript space by default. Constraint: Thinking content must stay behind the existing disclosure while preserving markdown rendering after expansion Rejected: Ten-line collapsed markdown preview | still exposes thinking content in the normal chat flow Confidence: high Scope-risk: narrow Directive: Do not render ThinkingBlock content while aria-expanded=false; keep MarkdownRenderer inside the expanded branch Tested: cd desktop && bun run test -- chatBlocks.test.tsx Tested: bun run check:desktop Tested: git diff --check Not-tested: Live desktop visual smoke with a real provider transcript Related: #660
238 lines
8.6 KiB
TypeScript
238 lines
8.6 KiB
TypeScript
import { beforeEach, describe, expect, it } from 'vitest'
|
|
import { act, fireEvent, render, screen } from '@testing-library/react'
|
|
import { ThinkingBlock } from './ThinkingBlock'
|
|
import { ToolCallBlock } from './ToolCallBlock'
|
|
import { PermissionDialog } from './PermissionDialog'
|
|
import { useChatStore } from '../../stores/chatStore'
|
|
import { useSettingsStore } from '../../stores/settingsStore'
|
|
import { useTabStore } from '../../stores/tabStore'
|
|
|
|
describe('chat blocks', () => {
|
|
beforeEach(() => {
|
|
useSettingsStore.setState({ locale: 'en' })
|
|
useTabStore.setState({ activeTabId: 'active-tab', tabs: [{ sessionId: 'active-tab', title: 'Test', type: 'session' as const, status: 'idle' }] })
|
|
useChatStore.setState({ sessions: {} })
|
|
})
|
|
|
|
it('keeps thinking collapsed by default', () => {
|
|
const { container } = render(<ThinkingBlock content="this is a long internal reasoning trace" isActive />)
|
|
|
|
expect(screen.getByText(/Thinking/)).toBeTruthy()
|
|
expect(container.textContent).not.toContain('this is a long internal reasoning trace')
|
|
expect(container.querySelector('.thinking-cursor')).toBeNull()
|
|
})
|
|
|
|
it('does not animate inactive historical thinking blocks', () => {
|
|
const { container } = render(<ThinkingBlock content="old reasoning" isActive={false} />)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /Thinking/ }))
|
|
|
|
expect(container.textContent).toContain('old reasoning')
|
|
expect(container.querySelector('.thinking-cursor')).toBeNull()
|
|
})
|
|
|
|
it('renders thinking content as markdown only after expanding', () => {
|
|
const { container } = render(<ThinkingBlock content={'**important**\n\n- item one'} />)
|
|
|
|
expect(container.textContent).not.toContain('important')
|
|
expect(container.querySelector('strong')).toBeNull()
|
|
expect(container.querySelector('li')).toBeNull()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /Thinking/ }))
|
|
|
|
expect(container.querySelector('strong')?.textContent).toBe('important')
|
|
expect(container.querySelector('li')?.textContent).toBe('item one')
|
|
})
|
|
|
|
it('hides full thinking content until expanded', () => {
|
|
const content = Array.from({ length: 12 }, (_, index) => `line-${index + 1}`).join('\n')
|
|
const { container } = render(<ThinkingBlock content={content} />)
|
|
|
|
expect(container.textContent).not.toContain('line-1')
|
|
expect(container.textContent).not.toContain('line-11')
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /Thinking/ }))
|
|
|
|
expect(container.textContent).toContain('line-1')
|
|
expect(container.textContent).toContain('line-11')
|
|
expect(container.textContent).toContain('line-12')
|
|
})
|
|
|
|
it('shows tool previews only after expanding the tool block', () => {
|
|
const { container } = render(
|
|
<ToolCallBlock
|
|
toolName="Read"
|
|
input={{ file_path: '/tmp/example.ts', limit: 20 }}
|
|
result={{ content: 'const answer = 42\nconsole.log(answer)', isError: false }}
|
|
/>,
|
|
)
|
|
|
|
expect(container.textContent).toContain('Read')
|
|
expect(container.textContent).not.toContain('const answer = 42')
|
|
|
|
fireEvent.click(screen.getByRole('button'))
|
|
|
|
expect(container.textContent).toContain('Tool Input')
|
|
expect(container.textContent).not.toContain('const answer = 42')
|
|
})
|
|
|
|
it('does not surface bash stdout in the transcript preview', () => {
|
|
const { container } = render(
|
|
<ToolCallBlock
|
|
toolName="Bash"
|
|
input={{ command: 'ls -la', description: 'List files' }}
|
|
result={{ content: 'file-a\nfile-b\nfile-c', isError: false }}
|
|
/>,
|
|
)
|
|
|
|
expect(container.textContent).toContain('Bash')
|
|
expect(container.textContent).not.toContain('file-a')
|
|
|
|
fireEvent.click(screen.getByRole('button'))
|
|
|
|
expect(container.textContent).toContain('ls -la')
|
|
expect(container.textContent).not.toContain('file-a')
|
|
})
|
|
|
|
it('shows pending Write tool calls while input is still streaming', () => {
|
|
const { container } = render(
|
|
<ToolCallBlock
|
|
toolName="Write"
|
|
input={{ file_path: '/private/tmp/ai-code-novel.md' }}
|
|
isPending
|
|
partialInput={'{"file_path":"/private/tmp/ai-code-novel.md","content":"第一章'}
|
|
/>,
|
|
)
|
|
|
|
expect(container.textContent).toContain('Write')
|
|
expect(container.textContent).toContain('ai-code-novel.md')
|
|
expect(container.textContent).toContain('Generating content')
|
|
})
|
|
|
|
it('expands pending Write tool calls into a live writer preview instead of raw JSON', () => {
|
|
const { container } = render(
|
|
<ToolCallBlock
|
|
toolName="Write"
|
|
input={{ file_path: '/private/tmp/ai-code-novel.md' }}
|
|
isPending
|
|
partialInput={'{"file_path":"/private/tmp/ai-code-novel.md","content":"# 第一章\\n\\n正文正在生成'}
|
|
/>,
|
|
)
|
|
|
|
fireEvent.click(screen.getByRole('button'))
|
|
|
|
expect(container.textContent).toContain('Writer')
|
|
expect(container.textContent).toContain('# 第一章')
|
|
expect(container.textContent).toContain('正文正在生成')
|
|
expect(container.textContent).not.toContain('"content"')
|
|
})
|
|
|
|
it('windows long pending Write previews to the latest content', () => {
|
|
const lines = Array.from({ length: 180 }, (_, index) => `line-${index + 1}`)
|
|
const escapedContent = lines.join('\\n')
|
|
const { container } = render(
|
|
<ToolCallBlock
|
|
toolName="Write"
|
|
input={{ file_path: '/private/tmp/generated.ts' }}
|
|
isPending
|
|
partialInput={`{"file_path":"/private/tmp/generated.ts","content":"${escapedContent}`}
|
|
/>,
|
|
)
|
|
|
|
fireEvent.click(screen.getByRole('button'))
|
|
|
|
expect(container.textContent).toContain('latest')
|
|
expect(container.textContent).toContain('line-180')
|
|
expect(container.textContent).not.toContain('line-30')
|
|
})
|
|
|
|
it('shows a collapsed error summary for failed bash commands', () => {
|
|
const { container } = render(
|
|
<ToolCallBlock
|
|
toolName="Bash"
|
|
input={{ command: 'git show 5016bc0 --no-stat', description: 'Show full diff of latest commit' }}
|
|
result={{ content: 'fatal: unrecognized argument: --no-stat\nExit code 128', isError: true }}
|
|
/>,
|
|
)
|
|
|
|
expect(container.textContent).toContain('Bash')
|
|
expect(container.textContent).toContain('fatal: unrecognized argument: --no-stat')
|
|
})
|
|
|
|
it('expands tool errors so full Computer Use gate messages are readable', () => {
|
|
const { container } = render(
|
|
<ToolCallBlock
|
|
toolName="mcp__computer-use__left_click"
|
|
input={{ coordinate: [120, 220] }}
|
|
result={{
|
|
content: '"Claude Code Haha" is not in the allowed applications and is currently in front. Take a new screenshot — it may have appeared since your last one.',
|
|
isError: true,
|
|
}}
|
|
/>,
|
|
)
|
|
|
|
expect(container.textContent).toContain('mcp__computer-use__left_click')
|
|
expect(container.textContent).not.toContain('Take a new screenshot')
|
|
|
|
fireEvent.click(screen.getByRole('button'))
|
|
|
|
expect(container.textContent).toContain('Take a new screenshot')
|
|
expect(container.textContent).toContain('allowed applications')
|
|
})
|
|
|
|
it('shows a diff preview for edit permission requests', async () => {
|
|
useChatStore.setState({
|
|
sessions: {
|
|
'active-tab': {
|
|
messages: [],
|
|
chatState: 'idle',
|
|
connectionState: 'connected',
|
|
streamingText: '',
|
|
streamingToolInput: '',
|
|
activeToolUseId: null,
|
|
activeToolName: null,
|
|
activeThinkingId: null,
|
|
pendingPermission: {
|
|
requestId: 'perm-1',
|
|
toolName: 'Edit',
|
|
input: {
|
|
file_path: '/tmp/example.ts',
|
|
old_string: 'const count = 1',
|
|
new_string: 'const count = 2',
|
|
},
|
|
},
|
|
pendingComputerUsePermission: null,
|
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
|
elapsedSeconds: 0,
|
|
statusVerb: '',
|
|
slashCommands: [],
|
|
agentTaskNotifications: {},
|
|
elapsedTimer: null,
|
|
},
|
|
},
|
|
})
|
|
|
|
let container!: HTMLElement
|
|
await act(async () => {
|
|
container = render(
|
|
<PermissionDialog
|
|
requestId="perm-1"
|
|
toolName="Edit"
|
|
input={{
|
|
file_path: '/tmp/example.ts',
|
|
old_string: 'const count = 1',
|
|
new_string: 'const count = 2',
|
|
}}
|
|
/>,
|
|
).container
|
|
await Promise.resolve()
|
|
})
|
|
|
|
expect(container.textContent).toContain('/tmp/example.ts')
|
|
expect(container.textContent).toContain('Allow')
|
|
// react-diff-viewer-continued uses styled-components tables that don't
|
|
// fully render in jsdom, so we verify the DiffViewer wrapper is mounted
|
|
expect(container.querySelector('[class*="rounded-[var(--radius-lg)]"]')).toBeTruthy()
|
|
})
|
|
})
|