From a404d1a077b7edb28f30d3a27fd2dfb838a4e7d2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 15:32:36 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=9A=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E5=AF=B9=E8=AF=9D=E5=A4=A7=E7=BA=B2=E3=80=81=E9=A1=B6?= =?UTF-8?q?=E6=A0=8F=E5=88=87=E6=8D=A2=E3=80=81=E8=AE=A1=E5=88=92=E6=9D=A1?= =?UTF-8?q?=E4=B8=8E=E6=94=B9=E8=BF=87=E6=96=87=E4=BB=B6=E7=9A=84=E8=A6=86?= =?UTF-8?q?=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 #367–#371 及邻近对话能力补 Vitest 与 Rust 单元测试:大纲跳到发出的消息、顶栏切换会话、历史按工作目录分组、改过文件预览、思考/工具分栏、计划条、工作区显示。不改产品行为。 Co-authored-by: NiceChen --- .../src/utils/stream_parse/claude/tests.rs | 97 +++++++++++++ src/lib/chat-process.test.ts | 61 ++++++++ src/pages/chat/ChatMessageBubble.test.ts | 63 ++++++++ src/pages/chat/ChatOutlineRail.test.ts | 23 +++ src/pages/chat/ChatPlanBar.test.ts | 57 ++++++++ src/pages/chat/ChatProcessPanel.test.ts | 35 +++++ src/pages/chat/ChatSessionRail.test.ts | 28 ++++ src/pages/chat/ChatTranscript.test.ts | 20 +++ src/pages/chat/chat-edit-preview.test.ts | 134 +++++++++++++++++- src/pages/chat/chat-format.test.ts | 25 ++++ src/pages/chat/chat-model.test.ts | 30 ++++ src/pages/chat/chat-outline-hover.test.ts | 23 +++ src/pages/chat/chat-outline-model.test.ts | 24 ++++ src/pages/chat/chat-preview-model.test.ts | 15 ++ src/pages/chat/chat-runtime-model.test.ts | 39 +++++ src/pages/chat/chat-session-switch.test.ts | 43 ++++++ src/pages/chat/use-chat-page-sessions.test.ts | 7 + 17 files changed, 722 insertions(+), 2 deletions(-) create mode 100644 src/pages/chat/ChatPlanBar.test.ts diff --git a/crates/agenthub-core/src/utils/stream_parse/claude/tests.rs b/crates/agenthub-core/src/utils/stream_parse/claude/tests.rs index 57690025..a2170499 100644 --- a/crates/agenthub-core/src/utils/stream_parse/claude/tests.rs +++ b/crates/agenthub-core/src/utils/stream_parse/claude/tests.rs @@ -260,3 +260,100 @@ fn bash_tool_use_still_emits_process_step() { })) .is_empty()); } + +#[test] +fn hyphenated_plan_tool_names_and_nested_event_unwrap() { + assert!(super::is_claude_plan_tool_name("Todo-Write")); + assert!(super::is_claude_plan_tool_name("Task Create")); + assert!(super::is_claude_plan_tool_name("todo_read")); + assert!(!super::is_claude_plan_tool_name("Bash")); + let payload = serde_json::json!({ + "event": { + "type": "assistant", + "message": { + "content": [{ + "type": "tool_use", + "id": "toolu_todo", + "name": "Todo-Write", + "input": { + "todos": [ + { "title": "read docs", "status": "pending" }, + { "content": " " } + ] + } + }] + } + } + }); + match &super::extract_todo_plan(&payload)[0] { + super::ClaudePlanOp::Replace(entries) => { + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].content, "read docs"); + assert_eq!(entries[0].status.as_deref(), Some("pending")); + } + other => panic!("expected replace, got {other:?}"), + } + assert_eq!( + super::plan_tool_use_ids(payload.get("event").expect("event")), + vec!["toolu_todo".to_string()] + ); +} + +#[test] +fn content_block_task_create_and_skipped_read_tools() { + let create = serde_json::json!({ + "type": "content_block_start", + "content_block": { + "type": "tool_use", + "id": "toolu_create", + "name": "TaskCreate", + "input": { "title": "build auth", "priority": "high" } + } + }); + assert_eq!( + super::extract_todo_plan(&create), + vec![super::ClaudePlanOp::Create { + tool_use_id: Some("toolu_create".into()), + content: "build auth".into(), + status: Some("pending".into()), + id: None, + priority: Some("high".into()), + }] + ); + assert!(super::extract_todo_plan(&serde_json::json!({ + "type": "tool_use", + "name": "TodoRead", + "input": { "todos": [{ "content": "ignore" }] } + })) + .is_empty()); + assert!(super::extract_todo_plan(&serde_json::json!({ + "type": "tool_use", + "name": "TaskUpdate", + "input": { "status": "in_progress" } + })) + .is_empty()); + assert!(super::extract_todo_plan(&serde_json::json!({ + "type": "tool_use", + "name": "TaskCreate", + "input": { "status": "pending" } + })) + .is_empty()); +} + +#[test] +fn task_update_accepts_task_id_alias() { + let update = serde_json::json!({ + "type": "tool_use", + "name": "TaskUpdate", + "input": { "task_id": "task-3", "subject": "rewrite", "status": "completed" } + }); + assert_eq!( + super::extract_todo_plan(&update), + vec![super::ClaudePlanOp::Update { + id: "task-3".into(), + status: Some("completed".into()), + content: Some("rewrite".into()), + priority: None, + }] + ); +} diff --git a/src/lib/chat-process.test.ts b/src/lib/chat-process.test.ts index 6c16259f..ff78e087 100644 --- a/src/lib/chat-process.test.ts +++ b/src/lib/chat-process.test.ts @@ -1083,3 +1083,64 @@ describe('chat-process human tool labels', () => { expect(isProtocolProcessStep({ type: 'tool', name: 'Read', status: 'start' })).toBe(false); }); }); + +describe('thinking / tools pane helpers', () => { + it('keeps thinking-only timelines off the tool chip', () => { + const thinking = [{ type: 'thinking' as const, text: 'plan', done: false }]; + expect(latestThinkingStep(thinking)?.text).toBe('plan'); + expect(latestThinkingStep([])).toBeUndefined(); + expect(latestThinkingStep(undefined)).toBeUndefined(); + expect(timelineHasToolRow(thinking)).toBe(false); + expect(showBubbleThinkingBar(thinking, false)).toBe(true); + expect(formatProcessHeadline(thinking, 'running', t)).toBe('思考中'); + expect(thinkingElapsedMs(undefined, 1000)).toBe(0); + expect(thinkingElapsedMs({ + turn: 1, + agent: 'codex', + phase: 'running', + stdout: '', + stderr: '', + steps: thinking, + updatedAt: 1, + }, 1000)).toBe(0); + expect(thinkingElapsedMs({ + turn: 1, + agent: 'codex', + phase: 'ok', + stdout: '', + stderr: '', + steps: thinking, + updatedAt: 1, + thinkingStartedAt: 500, + thinkingDurationMs: -12, + }, 900)).toBe(0); + }); + + it('folds same-id execute updates and skips blank command output', () => { + expect(timelineProcessSteps([ + { type: 'raw', text: ' ', note: 'command output' }, + { type: 'tool', id: 'run-1', name: 'Bash', status: 'start', input: { command: 'ls' } }, + { type: 'tool', id: 'run-1', name: 'Bash', status: 'end', result: 'docs' }, + { type: 'status', phase: 'starting', detail: 'thread.started' }, + { type: 'usage', scope: 'turn', input: 1, output: 1 }, + ])).toEqual([ + { + type: 'tool', + id: 'run-1', + name: 'Bash', + status: 'end', + input: { command: 'ls' }, + result: 'docs', + }, + ]); + expect(timelineHasToolRow([ + { type: 'tool', name: 'Bash', status: 'end' }, + { type: 'error', message: 'boom' }, + ])).toBe(true); + expect(formatProcessHeadline( + [{ type: 'tool', name: 'Bash', status: 'error', input: { command: 'ls' } }], + 'failed', + t, + )).toBe('没法执行 ls'); + }); +}); diff --git a/src/pages/chat/ChatMessageBubble.test.ts b/src/pages/chat/ChatMessageBubble.test.ts index cd629ff6..13bbd4b6 100644 --- a/src/pages/chat/ChatMessageBubble.test.ts +++ b/src/pages/chat/ChatMessageBubble.test.ts @@ -150,6 +150,69 @@ describe('ChatMessageBubble streaming feel', () => { expect(html).not.toContain('done thinking body'); }); + it('hides the thinking bar once the reply body arrives', () => { + const process: AgentProcessView = { + turn: 1, + agent: 'codex', + phase: 'running', + stdout: '', + stderr: '', + steps: [{ type: 'thinking', text: 'secret plan', done: true }], + updatedAt: 1, + thinkingStartedAt: 1, + thinkingDurationMs: 1200, + }; + const html = renderToStaticMarkup( + createElement(TooltipProvider, null, createElement(ChatMessageBubble, { + message: agentMessage('第一段正文'), + process, + isLastTurn: true, + multiAgent: false, + retryDisabled: false, + onRetry: () => undefined, + onOpenProcess: () => undefined, + })), + ); + expect(html).not.toContain('data-help="chat-thinking-bar"'); + expect(html).not.toContain('secret plan'); + expect(html).toContain('第一段正文'); + }); + + it('shows the thinking bar and a tool chip together before any body', () => { + const process: AgentProcessView = { + turn: 1, + agent: 'codex', + phase: 'running', + stdout: '', + stderr: '', + steps: [ + { type: 'thinking', text: 'secret plan', done: false }, + { type: 'tool', name: 'Read', status: 'start', input: { path: 'README.md' } }, + ], + updatedAt: 1, + thinkingStartedAt: Date.now() - 1500, + }; + const html = renderToStaticMarkup( + createElement(TooltipProvider, null, createElement(ChatMessageBubble, { + message: agentMessage(''), + process, + isLastTurn: true, + multiAgent: false, + retryDisabled: false, + onRetry: () => undefined, + onOpenProcess: () => undefined, + })), + ); + expect(html).toContain('data-help="chat-thinking-bar"'); + expect(html).toContain('data-help="chat-process-chip"'); + expect(html).toContain('正在读取 README.md'); + expect(html).not.toContain('secret plan'); + const barAt = html.indexOf('data-help="chat-thinking-bar"'); + const chipAt = html.indexOf('data-help="chat-process-chip"'); + expect(barAt).toBeGreaterThan(-1); + expect(chipAt).toBeGreaterThan(barAt); + }); + it('opens process details from a one-line chip', () => { const process: AgentProcessView = { turn: 1, diff --git a/src/pages/chat/ChatOutlineRail.test.ts b/src/pages/chat/ChatOutlineRail.test.ts index a63b63bb..a4d420f3 100644 --- a/src/pages/chat/ChatOutlineRail.test.ts +++ b/src/pages/chat/ChatOutlineRail.test.ts @@ -71,4 +71,27 @@ describe('ChatOutlineRail markup', () => { }); expect(html).not.toContain('chat-outline-rail'); }); + + it('keeps a measure wrapper when two prompts are too narrow to draw ticks', () => { + const html = renderRail({ turns: turns('first', 'second'), measuredWidth: 719 }); + expect(html).toContain('pointer-events-none'); + expect(html).not.toContain('data-testid="chat-outline-rail"'); + }); + + it('skips agent-only turns and still jumps by the user message id', () => { + const html = renderRail({ + turns: [ + { turn: 1, user: user('u1', 'first'), agents: [] }, + { turn: 2, agents: [] }, + { turn: 3, user: user('u3', 'third'), agents: [] }, + ], + measuredWidth: 800, + }); + expect(html).toContain('data-testid="chat-outline-tick-u1"'); + expect(html).toContain('data-testid="chat-outline-tick-u3"'); + expect(html).not.toContain('chat-outline-tick-u2'); + expect(html).toContain('1 / 2:first'); + expect(html).toContain('2 / 2:third'); + expect(html).not.toContain('data-testid="chat-outline-preview"'); + }); }); diff --git a/src/pages/chat/ChatPlanBar.test.ts b/src/pages/chat/ChatPlanBar.test.ts new file mode 100644 index 00000000..b53e7ef8 --- /dev/null +++ b/src/pages/chat/ChatPlanBar.test.ts @@ -0,0 +1,57 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import type { RuntimePlanEntry } from '@/lib/api/chat'; +import { ChatPlanBar } from './ChatPlanBar'; + +function renderPlan(plan?: RuntimePlanEntry[] | null): string { + return renderToStaticMarkup(createElement(ChatPlanBar, { plan })); +} + +describe('ChatPlanBar', () => { + it('draws nothing when the plan is missing or only blank rows', () => { + expect(renderPlan(undefined)).toBe(''); + expect(renderPlan(null)).toBe(''); + expect(renderPlan([])).toBe(''); + expect(renderPlan([{ content: ' ' }])).toBe(''); + }); + + it('shows progress, live/failed counts, and every kept row when expanded', () => { + const html = renderPlan([ + { content: 'read', status: 'completed' }, + { content: 'edit', status: 'in_progress' }, + { content: ' ' }, + { content: 'test', status: 'pending' }, + { content: 'broken', status: 'failed' }, + ]); + expect(html).toContain('data-help="chat-plan-bar"'); + expect(html).toContain('aria-expanded="true"'); + expect(html).toContain('1/4 已完成'); + expect(html).toContain('进行中 1'); + expect(html).toContain('失败 1'); + expect(html).toContain('已完成'); + expect(html).toContain('read'); + expect(html).toContain('edit'); + expect(html).toContain('test'); + expect(html).toContain('broken'); + expect(html).toContain('收起计划'); + expect(html).not.toContain(' '); + }); + + it('maps vendor status aliases onto the same live / done / pending / failed labels', () => { + const html = renderPlan([ + { content: 'done-row', status: 'complete' }, + { content: 'live-row', status: 'running' }, + { content: 'fail-row', status: 'canceled' }, + { content: 'wait-row' }, + ]); + expect(html).toContain('1/4 已完成'); + expect(html).toContain('进行中 1'); + expect(html).toContain('失败 1'); + expect(html).toContain('待做'); + expect(html).toContain('done-row'); + expect(html).toContain('live-row'); + expect(html).toContain('fail-row'); + expect(html).toContain('wait-row'); + }); +}); diff --git a/src/pages/chat/ChatProcessPanel.test.ts b/src/pages/chat/ChatProcessPanel.test.ts index 4dea1f5a..8a3f564b 100644 --- a/src/pages/chat/ChatProcessPanel.test.ts +++ b/src/pages/chat/ChatProcessPanel.test.ts @@ -113,6 +113,41 @@ describe('ChatProcessPanel human copy', () => { expect(html).toMatch(/]*open/); }); + it('shows a thinking fold without a tool row when no tools ran', () => { + const html = renderPanel( + view({ + phase: 'running', + steps: [{ type: 'thinking', text: 'only thinking', done: false }], + thinkingStartedAt: Date.now() - 800, + }), + ); + expect(html).toContain('data-help="chat-process-thinking"'); + expect(html).toContain('only thinking'); + expect(html).not.toContain('data-help="chat-process-tool"'); + }); + + it('keeps an error on the tools timeline, not inside the thinking fold', () => { + const html = renderPanel( + view({ + phase: 'failed', + steps: [ + { type: 'thinking', text: 'tried', done: true }, + { type: 'error', message: 'disk full' }, + ], + thinkingStartedAt: 1, + thinkingDurationMs: 400, + }), + 'failed', + ); + expect(html).toContain('data-help="chat-process-thinking"'); + expect(html).toContain('tried'); + expect(html).toContain('disk full'); + expect(html).toContain('text-danger'); + const thinkingAt = html.indexOf('data-help="chat-process-thinking"'); + const errorAt = html.indexOf('disk full'); + expect(errorAt).toBeGreaterThan(thinkingAt); + }); + it('keeps thinking as a fold separate from tool rows and pins live text', () => { const html = renderPanel( view({ diff --git a/src/pages/chat/ChatSessionRail.test.ts b/src/pages/chat/ChatSessionRail.test.ts index 64684e9b..4894e7e9 100644 --- a/src/pages/chat/ChatSessionRail.test.ts +++ b/src/pages/chat/ChatSessionRail.test.ts @@ -196,5 +196,33 @@ describe('ChatSessionRail titles', () => { expect(html).toContain('bg-accent'); expect(html).not.toContain('删除确认 Enter'); }); + + it('keeps two working-directory groups and an unset group on separate headers', () => { + const app = conversation({ id: 'app', cwd: '/workspace/demo-project', title: '修登录' }); + const other = conversation({ id: 'other', cwd: '/tmp/other', title: '另一场' }); + const unset = conversation({ id: 'unset', cwd: null, title: '未设' }); + const html = renderMarkup( + rail({ + groups: [ + workspaceGroup([app]), + workspaceGroup([other], { key: 'path:/tmp/other', label: 'other', cwd: '/tmp/other' }), + workspaceGroup([unset], { key: 'unset', label: '未设置工作目录', cwd: null }), + ], + conversations: [app, other, unset], + filteredCount: 3, + }), + ); + expect(html.split('data-help="chat-workspace-group"')).toHaveLength(4); + expect(html).toContain('demo-project'); + expect(html).toContain('other'); + expect(html).toContain('未设置工作目录'); + expect(html).toContain('data-session-id="app"'); + expect(html).toContain('data-session-id="other"'); + expect(html).toContain('data-session-id="unset"'); + expect(html.split('data-help="chat-workspace-new"')).toHaveLength(3); + const unsetAt = html.indexOf('data-session-id="unset"'); + const unsetGroup = html.slice(html.lastIndexOf('data-help="chat-workspace-group"', unsetAt), unsetAt); + expect(unsetGroup).not.toContain('data-help="chat-workspace-new"'); + }); }); diff --git a/src/pages/chat/ChatTranscript.test.ts b/src/pages/chat/ChatTranscript.test.ts index 668d06fd..3fcdee46 100644 --- a/src/pages/chat/ChatTranscript.test.ts +++ b/src/pages/chat/ChatTranscript.test.ts @@ -161,5 +161,25 @@ describe('ChatTranscript surfaces', () => { expect(html).not.toContain('rounded-composer bg-panel'); expect(html).not.toContain('rounded-composer bg-canvas'); expect(html).toContain('hello from chat'); + expect(html).toContain('id="chat-msg-m-user"'); + }); + + it('anchors each sent prompt so the outline can jump to it', () => { + const first = userMessage('first prompt'); + const second = { + ...userMessage('second prompt'), + id: 'm-user-2', + turn: 2, + }; + const html = renderTranscript([ + { turn: 1, user: first, agents: [] }, + { turn: 2, user: second, agents: [] }, + ]); + expect(html).toContain('id="chat-msg-m-user"'); + expect(html).toContain('id="chat-msg-m-user-2"'); + expect(html).toContain('first prompt'); + expect(html).toContain('second prompt'); + expect(html).toContain('pointer-events-none'); + expect(html).not.toContain('data-testid="chat-outline-rail"'); }); }); diff --git a/src/pages/chat/chat-edit-preview.test.ts b/src/pages/chat/chat-edit-preview.test.ts index c7bdb57f..dc45b084 100644 --- a/src/pages/chat/chat-edit-preview.test.ts +++ b/src/pages/chat/chat-edit-preview.test.ts @@ -1,10 +1,10 @@ import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { TooltipProvider } from '@/components/ui/tooltip'; import type { ProcessMap } from '@/lib/chat-process'; import type { ProcessStep } from '@/lib/types'; -import { ChatTurnEditList } from './ChatEditPreviewPanel'; +import { ChatEditPreviewPanel, ChatTurnEditList } from './ChatEditPreviewPanel'; import { extractEditFilesFromSteps, extractTurnEdits, @@ -15,6 +15,11 @@ import { turnEditHasInlineDiff, } from './chat-edit-preview'; +vi.mock('@/components/shared/SourcePreview', () => ({ + SourcePreview: ({ value, fileName }: { value: string; fileName: string }) => + `PREVIEW:${fileName}:${value}`, +})); + function tool( name: string, status: string, @@ -188,6 +193,56 @@ describe('extractEditFilesFromSteps', () => { extractEditFilesFromSteps([tool('Write src/named.ts', 'end')]), ).toEqual([{ path: 'src/named.ts', status: 'done' }]); }); + + it('reads locations[], files[], and file:// without localhost', () => { + expect( + extractEditFilesFromSteps([ + tool('apply_patch', 'end', { + locations: [{ path: 'src/c.ts', old_text: 'c1', new_text: 'c2' }], + }), + ]), + ).toEqual([{ path: 'src/c.ts', status: 'done', before: 'c1', after: 'c2' }]); + expect( + extractEditFilesFromSteps([ + tool('Write', 'end', { files: ['src/a.ts', { filePath: 'src/b.ts' }] }), + ]), + ).toEqual([ + { path: 'src/a.ts', status: 'done' }, + { path: 'src/b.ts', status: 'done' }, + ]); + expect( + extractEditFilesFromSteps([ + tool('Edit', 'end', { uri: 'file:///workspace/notes.md' }), + ]), + ).toEqual([{ path: '/workspace/notes.md', status: 'done' }]); + }); + + it('ignores a bare string, a space-only name, and invalid JSON results', () => { + expect(extractEditFilesFromSteps([tool('Write', 'end', 'hello world')])).toEqual([]); + expect(extractEditFilesFromSteps([tool('Write readme', 'end')])).toEqual([]); + expect( + extractEditFilesFromSteps([ + tool('Write', 'end', { path: 'notes.md' }, '{not-json'), + ]), + ).toEqual([{ path: 'notes.md', status: 'done' }]); + }); + + it('stops walking nested item wrappers after three levels', () => { + expect( + extractEditFilesFromSteps([ + tool('Write', 'end', { + item: { item: { item: { item: { path: 'too-deep.ts' } } } }, + }), + ]), + ).toEqual([]); + expect( + extractEditFilesFromSteps([ + tool('Write', 'end', { + item: { item: { path: 'ok.ts' } }, + }), + ]), + ).toEqual([{ path: 'ok.ts', status: 'done' }]); + }); }); describe('extractTurnEdits', () => { @@ -209,6 +264,17 @@ describe('extractTurnEdits', () => { it('returns an empty list when there is no process map', () => { expect(extractTurnEdits({})).toEqual([]); + expect(latestProcessTurn({})).toBeNull(); + expect(latestProcessTurn({ + 'x:codex': { + agent: 'codex', + phase: 'ok', + stdout: '', + stderr: '', + updatedAt: 1, + steps: [], + } as unknown as ProcessMap[string], + })).toBeNull(); }); }); @@ -228,6 +294,8 @@ describe('simple diff', () => { it('sameEditPath treats slash variants as one file', () => { expect(sameEditPath('src\\a.ts', 'src/a.ts')).toBe(true); expect(sameEditPath('src/a.ts', 'src/b.ts')).toBe(false); + expect(sameEditPath('src/a.ts/', 'src/a.ts')).toBe(true); + expect(sameEditPath(' src/a.ts ', 'src/a.ts')).toBe(true); }); it('turnEditDiffText prefers a real patch over inventing one', () => { @@ -264,6 +332,7 @@ describe('ChatTurnEditList', () => { { path: 'src/a.ts', status: 'live' }, { path: 'src/b.ts', status: 'done' }, ], + selectedPath: 'src\\a.ts', onSelect: () => undefined, }), ), @@ -274,5 +343,66 @@ describe('ChatTurnEditList', () => { expect(html).toContain('已修改'); expect(html).toContain('src/a.ts'); expect(html).toContain('src/b.ts'); + expect(html).toContain('aria-current="true"'); + }); + + it('draws nothing when this turn has no edited files', () => { + expect( + renderToStaticMarkup( + createElement(ChatTurnEditList, { files: [], onSelect: () => undefined }), + ), + ).toBe(''); + }); +}); + +describe('ChatEditPreviewPanel', () => { + it('returns nothing when the pane is closed', () => { + expect( + renderToStaticMarkup( + createElement(ChatEditPreviewPanel, { + file: { path: 'src/a.ts', status: 'done', before: 'a', after: 'b' }, + open: false, + onClose: () => undefined, + }), + ), + ).toBe(''); + }); + + it('renders a diff preview from old and new text', () => { + const html = renderToStaticMarkup( + createElement( + TooltipProvider, + null, + createElement(ChatEditPreviewPanel, { + file: { path: 'src/app.ts', status: 'done', before: 'old', after: 'new' }, + open: true, + width: 360, + onClose: () => undefined, + }), + ), + ); + expect(html).toContain('data-chat-edit-preview'); + expect(html).toContain('app.ts'); + expect(html).toContain('查看修改'); + expect(html).toContain('PREVIEW:app.ts.diff:'); + expect(html).toContain('-old'); + expect(html).toContain('+new'); + expect(html).toContain('收起'); + }); + + it('shows the empty-body hint when there is no patch', () => { + const html = renderToStaticMarkup( + createElement( + TooltipProvider, + null, + createElement(ChatEditPreviewPanel, { + file: { path: 'src/a.ts', status: 'live' }, + open: true, + onClose: () => undefined, + }), + ), + ); + expect(html).toContain('没有内容'); + expect(html).not.toContain('PREVIEW:'); }); }); diff --git a/src/pages/chat/chat-format.test.ts b/src/pages/chat/chat-format.test.ts index c677db99..19f71ec2 100644 --- a/src/pages/chat/chat-format.test.ts +++ b/src/pages/chat/chat-format.test.ts @@ -20,6 +20,7 @@ import { resolvePiChatCurrentModel, sanitizeCliChatText, shouldFetchChatRemoteModels, + groupByTurn, thinkingChromeLabel, } from './chat-format'; @@ -143,6 +144,30 @@ describe('chat-format thinking chrome', () => { expect(formatDurationMs(65_000)).toBe('1m 5s'); }); + it('groups user and agent messages by turn so the outline can jump to a prompt', () => { + const userFirst = chatMsg({ id: 'u1', role: 'user', content: 'first', turn: 2 }); + const agentOnly = chatMsg({ + id: 'a2', + role: 'agent', + agentId: 'claude', + content: 'no user', + turn: 1, + }); + const userLater = chatMsg({ id: 'u1b', role: 'user', content: 'overwrite', turn: 2 }); + const agentReply = chatMsg({ + id: 'a1', + role: 'agent', + agentId: 'claude', + content: 'ok', + turn: 2, + }); + expect(groupByTurn([userFirst, agentOnly, userLater, agentReply])).toEqual([ + { turn: 1, agents: [agentOnly] }, + { turn: 2, user: userLater, agents: [agentReply] }, + ]); + expect(groupByTurn([])).toEqual([]); + }); + it('thinkingChromeLabel matches live / thought-for / done copy', () => { expect(thinkingChromeLabel(false, 0, t)).toBe('思考中 · 0ms'); expect(thinkingChromeLabel(false, 3200, t)).toBe('思考中 · 3.2s'); diff --git a/src/pages/chat/chat-model.test.ts b/src/pages/chat/chat-model.test.ts index 34d32ccd..208bd3e9 100644 --- a/src/pages/chat/chat-model.test.ts +++ b/src/pages/chat/chat-model.test.ts @@ -366,6 +366,8 @@ describe('cwdShortName', () => { expect(cwdShortName('.', t)).toBe('未设目录'); expect(cwdShortName('./', t)).toBe('未设目录'); expect(cwdShortName('.\\', t)).toBe('未设目录'); + expect(cwdShortName('..', t)).toBe('未设目录'); + expect(cwdShortName('../', t)).toBe('未设目录'); }); }); @@ -461,6 +463,34 @@ describe('groupConversationsByWorkspace', () => { expect(groups[0].label).toBe('Demo'); expect(groups[0].items.map((c) => c.id)).toEqual(['dot', 'plain']); }); + + it('returns no groups for an empty list and uses id when updated times match', () => { + expect(groupConversationsByWorkspace([], t)).toEqual([]); + const a = conv({ id: 'b-row', cwd: '/tmp/a', updatedAt: at(10) }); + const b = conv({ id: 'a-row', cwd: '/tmp/a', updatedAt: at(10) }); + const groups = groupConversationsByWorkspace([a, b], t); + expect(groups).toHaveLength(1); + expect(groups[0].items.map((c) => c.id)).toEqual(['a-row', 'b-row']); + }); + + it('does not merge a Windows folder with a POSIX folder of the same name', () => { + const win = conv({ id: 'win', cwd: 'D:\\tmp\\app', updatedAt: at(16) }); + const posix = conv({ id: 'posix', cwd: '/tmp/app', updatedAt: at(15) }); + const groups = groupConversationsByWorkspace([win, posix], t); + expect(groups).toHaveLength(2); + expect(groups.map((g) => g.cwd)).toEqual(['D:\\tmp\\app', '/tmp/app']); + expect(groups.map((g) => g.label)).toEqual(['app', 'app']); + }); +}); + +describe('conversationWorkspaceKey', () => { + it('uses one unset key for missing or blank working directories', () => { + expect(conversationWorkspaceKey(null)).toBe(UNSET_WORKSPACE_KEY); + expect(conversationWorkspaceKey(undefined)).toBe(UNSET_WORKSPACE_KEY); + expect(conversationWorkspaceKey('')).toBe(UNSET_WORKSPACE_KEY); + expect(conversationWorkspaceKey(' ')).toBe(UNSET_WORKSPACE_KEY); + expect(conversationWorkspaceKey('/tmp/app')).toMatch(/^path:/); + }); }); describe('sendBlockers', () => { diff --git a/src/pages/chat/chat-outline-hover.test.ts b/src/pages/chat/chat-outline-hover.test.ts index 8cf51dc8..008f5da6 100644 --- a/src/pages/chat/chat-outline-hover.test.ts +++ b/src/pages/chat/chat-outline-hover.test.ts @@ -122,4 +122,27 @@ describe('chat outline hover intent', () => { scheduleCount: 2, }); }); + + it('does not activate a pending tick after dispose, and move without enter is idle', () => { + const scheduler = createFakeScheduler(); + const activations: Array = []; + const intent = createChatOutlineHoverIntent({ + activate: (index) => activations.push(index), + schedule: scheduler.schedule, + cancel: scheduler.cancel, + }); + + intent.pointAt(1); + intent.move({ x: 40, y: 20 }); + intent.dispose(); + scheduler.runPending(); + expect(activations).toEqual([]); + + intent.enter({ x: 10, y: 10 }); + intent.pointAt(2); + intent.move({ x: 11, y: 10 }); + expect(scheduler.scheduleCount()).toBe(2); + scheduler.runPending(); + expect(activations).toEqual([2]); + }); }); diff --git a/src/pages/chat/chat-outline-model.test.ts b/src/pages/chat/chat-outline-model.test.ts index 0974aed6..670a0c66 100644 --- a/src/pages/chat/chat-outline-model.test.ts +++ b/src/pages/chat/chat-outline-model.test.ts @@ -45,6 +45,11 @@ describe('promptTickMagnification', () => { expect(above).toEqual(below); expect(above).toEqual([...above].sort((left, right) => right - left)); }); + + it('treats non-finite distances as outside the bulge', () => { + expect(promptTickMagnification(Number.NaN)).toBe(0); + expect(promptTickMagnification(Number.POSITIVE_INFINITY)).toBe(0); + }); }); describe('outlinePromptPreview', () => { @@ -54,6 +59,11 @@ describe('outlinePromptPreview', () => { expect(outlinePromptPreview('a'.repeat(121))).toBe(`${'a'.repeat(120)}…`); expect(outlinePromptPreview(' \n\t ')).toBe(''); }); + + it('honors a custom clip limit', () => { + expect(outlinePromptPreview('abcdefghij', 6)).toBe('abcdef…'); + expect(outlinePromptPreview('short', 6)).toBe('short'); + }); }); describe('outlinePromptsFromTurns', () => { @@ -68,6 +78,11 @@ describe('outlinePromptsFromTurns', () => { { id: 'u2', preview: 'second line' }, ]); }); + + it('returns nothing when every turn is agent-only or empty', () => { + expect(outlinePromptsFromTurns([])).toEqual([]); + expect(outlinePromptsFromTurns([{ turn: 1, agents: [] }])).toEqual([]); + }); }); describe('shouldShowChatOutline', () => { @@ -85,6 +100,8 @@ describe('outlineTickSize', () => { expect(outlineTickSize(true, 0)).toEqual({ width: 18, height: 2 }); expect(outlineTickSize(false, 1)).toEqual({ width: 26, height: 4 }); expect(outlineTickSize(true, 1)).toEqual({ width: 26, height: 4 }); + expect(outlineTickSize(false, 0.5)).toEqual({ width: 18, height: 3 }); + expect(outlineTickSize(true, 0.5)).toEqual({ width: 22, height: 3 }); }); }); @@ -101,11 +118,18 @@ describe('resolveActivePromptId', () => { expect(resolveActivePromptId(prompts, [100, 200, 300], 50)).toBeNull(); expect(resolveActivePromptId([], [], 100)).toBeNull(); }); + + it('skips missing or non-finite tops and keeps the last valid mark', () => { + expect(resolveActivePromptId(prompts, [100, Number.NaN, 300], 300)).toBe('c'); + expect(resolveActivePromptId(prompts, [undefined as unknown as number, 200], 200)).toBe('b'); + expect(resolveActivePromptId(prompts, [Number.POSITIVE_INFINITY, 200, 50], 80)).toBe('c'); + }); }); describe('outline jump scroll', () => { it('places the target 8px below the container top', () => { expect(planOutlineJumpScroll(80, 40, 200)).toBe(152); + expect(planOutlineJumpScroll(80, 40, 200, 0)).toBe(160); }); it('writes that offset onto the container after a start-aligned jump', () => { diff --git a/src/pages/chat/chat-preview-model.test.ts b/src/pages/chat/chat-preview-model.test.ts index 76b14e5d..55b0d557 100644 --- a/src/pages/chat/chat-preview-model.test.ts +++ b/src/pages/chat/chat-preview-model.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { chatPreviewCanBack, + chatPreviewLine, chatPreviewPath, isChatEditPreview, isChatFilePreview, @@ -48,6 +49,20 @@ describe('chat preview stack', () => { ); }); + it('keeps a 1-based line only on file previews', () => { + expect(chatPreviewLine(openChatPreviewRoot('/repo/README.md', 12))).toBe(12); + expect(chatPreviewLine(openChatPreviewRoot('/repo/README.md', 0))).toBeUndefined(); + expect(chatPreviewLine(openChatPreviewRoot('/repo/README.md', -1))).toBeUndefined(); + expect(chatPreviewLine(openChatEditPreview('src/a.ts'))).toBeUndefined(); + expect(chatPreviewLine(null)).toBeUndefined(); + const same = pushChatPreview(openChatPreviewRoot('/repo/README.md', 3), '/repo/README.md', 9); + expect(chatPreviewLine(same)).toBe(9); + expect(chatPreviewPath(same)).toBe('/repo/README.md'); + expect(pushChatPreview(null, '/repo/README.md', 4)).toEqual( + openChatPreviewRoot('/repo/README.md', 4), + ); + }); + it('opens an edit preview by path without a back stack', () => { const target = openChatEditPreview('src/a.ts'); expect(isChatEditPreview(target)).toBe(true); diff --git a/src/pages/chat/chat-runtime-model.test.ts b/src/pages/chat/chat-runtime-model.test.ts index 967596fc..f00686e3 100644 --- a/src/pages/chat/chat-runtime-model.test.ts +++ b/src/pages/chat/chat-runtime-model.test.ts @@ -5,6 +5,7 @@ import { acceptsRuntimeSnapshot, bindRuntimeSnapshotToAgent, canSubmitRuntimeQuestions, + fileChangeKindLabel, fileChangePreviewHintKey, runtimeFileChangePreview, runtimeReplyFields, @@ -251,5 +252,43 @@ describe('chat runtime transport guards', () => { expect(runtimePlanStatusKey('in_progress')).toBe('chat.runtime.planStatusLive'); expect(translate('zh', runtimePlanStatusKey('completed'))).toBe('已完成'); expect(translate('zh', 'chat.runtime.planProgress', { done: 1, total: 3 })).toBe('1/3 已完成'); + expect(runtimePlanEntryTone('complete')).toBe('done'); + expect(runtimePlanEntryTone('DONE')).toBe('done'); + expect(runtimePlanEntryTone('in-progress')).toBe('live'); + expect(runtimePlanEntryTone('inprogress')).toBe('live'); + expect(runtimePlanEntryTone('running')).toBe('live'); + expect(runtimePlanEntryTone('start')).toBe('live'); + expect(runtimePlanEntryTone('error')).toBe('failed'); + expect(runtimePlanEntryTone('cancelled')).toBe('failed'); + expect(runtimePlanEntryTone('canceled')).toBe('failed'); + expect(runtimePlanEntryTone(null)).toBe('pending'); + expect(runtimePlanEntryTone('')).toBe('pending'); + expect(runtimePlanProgress(null)).toEqual({ + total: 0, done: 0, live: 0, pending: 0, failed: 0, + }); + expect(runtimePlanProgress([{ content: ' ' }])).toEqual({ + total: 0, done: 0, live: 0, pending: 0, failed: 0, + }); + expect(runtimePlanStatusKey('canceled')).toBe('chat.runtime.planStatusFailed'); + expect(fileChangeKindLabel('add', (key, params) => translate('zh', key, params))).toBe('新增'); + expect(fileChangeKindLabel('delete', (key, params) => translate('zh', key, params))).toBe('删除'); + expect(fileChangeKindLabel('update', (key, params) => translate('zh', key, params))).toBe('修改'); + expect(runtimeFileChangePreview({ + kind: 'file', + detail: '', + fileChanges: [ + { path: 'a.ts', kind: 'create_file' }, + { path: 'b.ts', kind: 'write' }, + { path: 'c.ts', kind: 'remove_file' }, + ], + })).toEqual({ + shown: true, + empty: true, + rows: [ + { path: 'a.ts', kind: 'add', preview: null }, + { path: 'b.ts', kind: 'update', preview: null }, + { path: 'c.ts', kind: 'delete', preview: null }, + ], + }); }); }); diff --git a/src/pages/chat/chat-session-switch.test.ts b/src/pages/chat/chat-session-switch.test.ts index 561b3b72..508550b2 100644 --- a/src/pages/chat/chat-session-switch.test.ts +++ b/src/pages/chat/chat-session-switch.test.ts @@ -64,6 +64,8 @@ describe('sessionSwitchNeighbors', () => { expect(sessionSwitchNeighbors(sessions, 'b')).toEqual({ prevId: 'a', nextId: 'c' }); expect(sessionSwitchNeighbors(sessions, 'a')).toEqual({ prevId: 'c', nextId: 'b' }); expect(sessionSwitchNeighbors([{ id: 'a' }], 'a')).toEqual({ prevId: null, nextId: null }); + expect(sessionSwitchNeighbors(sessions, null)).toEqual({ prevId: 'c', nextId: 'a' }); + expect(sessionSwitchNeighbors([], 'a')).toEqual({ prevId: null, nextId: null }); }); }); @@ -94,6 +96,14 @@ describe('chatSessionSwitchShortcutAction', () => { expect(chatSessionSwitchShortcutAction({ ...base, key: 'ArrowUp', altKey: false })).toBeNull(); expect(chatSessionSwitchShortcutAction({ ...base, key: 'ArrowUp', ctrlKey: true })).toBeNull(); expect(chatSessionSwitchShortcutAction({ ...base, key: 'ArrowUp', overlayOpen: true })).toBeNull(); + expect(chatSessionSwitchShortcutAction({ ...base, key: 'ArrowUp', shiftKey: true })).toBeNull(); + expect(chatSessionSwitchShortcutAction({ ...base, key: 'ArrowDown', metaKey: true })).toBeNull(); + }); + + it('reads ArrowDown from code when key is unidentified', () => { + expect( + chatSessionSwitchShortcutAction({ ...base, key: 'Unidentified', code: 'ArrowDown' }), + ).toBe('next'); }); }); @@ -150,4 +160,37 @@ describe('collapsed session switcher', () => { expect(html).toContain('data-help="chat-settings"'); expect(html).toContain('会话设置'); }); + + it('disables prev/next when the filtered list has one session', () => { + const html = renderToStaticMarkup( + createElement(TooltipProvider, null, header({ sessions: [conv()] })), + ); + expect(html).toContain('data-help="chat-session-switch"'); + expect(html).toContain('disabled=""'); + expect(html).toContain('aria-keyshortcuts="Alt+ArrowUp"'); + expect(html).toContain('aria-keyshortcuts="Alt+ArrowDown"'); + }); + + it('shows the unset folder name when the current session has no working directory', () => { + const html = renderToStaticMarkup( + createElement( + TooltipProvider, + null, + header({ + active: conv({ cwd: null }), + sessions: [conv({ cwd: null }), conv({ id: 'b', title: '下一场', cwd: 'D:\\work\\other' })], + }), + ), + ); + expect(html).toContain('未设目录'); + expect(html).toContain('修登录'); + }); + + it('falls back to the title button when the history list is empty', () => { + const html = renderToStaticMarkup( + createElement(TooltipProvider, null, header({ sessions: [] })), + ); + expect(html).not.toContain('data-help="chat-session-switch"'); + expect(html).toContain('修登录'); + }); }); diff --git a/src/pages/chat/use-chat-page-sessions.test.ts b/src/pages/chat/use-chat-page-sessions.test.ts index b956d975..96866c01 100644 --- a/src/pages/chat/use-chat-page-sessions.test.ts +++ b/src/pages/chat/use-chat-page-sessions.test.ts @@ -30,4 +30,11 @@ describe('mergeHandoffConversations', () => { const loaded = [conv('a'), conv('b')]; expect(mergeHandoffConversations([], loaded)).toBe(loaded); }); + + it('returns the loaded list identity when the handoff session is already present', () => { + const folder = conv('folder', 'D:\\work\\app'); + const existing = conv('existing'); + const loaded = [folder, existing]; + expect(mergeHandoffConversations([folder], loaded)).toBe(loaded); + }); }); From f928c023906d2a22e6ebbf0c46b7983be12c900d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 15:34:06 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=9A=E9=94=81?= =?UTF-8?q?=E6=AD=BB=E5=A4=A7=E7=BA=B2=E6=98=BE=E7=A4=BA=E9=97=A8=E6=A7=9B?= =?UTF-8?q?=EF=BC=88=E8=AE=BE=E7=BD=AE=E3=80=81=E4=B8=A4=E6=9D=A1=E5=8F=91?= =?UTF-8?q?=E5=87=BA=E7=9A=84=E6=B6=88=E6=81=AF=E3=80=81720px=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户找不到左侧大纲时,三项须同时成立:偏好打开、至少两条用户消息、面板宽度 ≥720。补 shouldShowChatOutline 与 ChatOutlineRail / ChatTranscript 的显示与隐藏对照,不改产品代码。 Co-authored-by: NiceChen --- src/pages/chat/ChatOutlineRail.test.ts | 102 +++++++++++++++++++++- src/pages/chat/ChatTranscript.test.ts | 10 +++ src/pages/chat/chat-outline-model.test.ts | 24 ++++- 3 files changed, 130 insertions(+), 6 deletions(-) diff --git a/src/pages/chat/ChatOutlineRail.test.ts b/src/pages/chat/ChatOutlineRail.test.ts index a4d420f3..bd7074d8 100644 --- a/src/pages/chat/ChatOutlineRail.test.ts +++ b/src/pages/chat/ChatOutlineRail.test.ts @@ -1,9 +1,11 @@ import { createElement, type ReactElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { StorageKey } from '@/lib/storage-key'; import type { ChatMessage } from '@/lib/types'; import type { TurnGroup } from './chat-format'; import { ChatOutlineRail } from './ChatOutlineRail'; +import { saveChatOutlineEnabled } from './chat-outline-pref'; function user(id: string, content: string): ChatMessage { return { @@ -35,11 +37,107 @@ function renderRail(props: { createElement(ChatOutlineRail, { turns: props.turns, measuredWidth: props.measuredWidth, - enabled: props.enabled ?? true, + ...(props.enabled === undefined ? {} : { enabled: props.enabled }), }) as ReactElement, ); } +function hasOutline(html: string): boolean { + return html.includes('data-testid="chat-outline-rail"'); +} + +describe('ChatOutlineRail visibility gates', () => { + it('shows the rail only when the setting, two user messages, and 720px all hold', () => { + expect(hasOutline(renderRail({ + turns: turns('first', 'second'), + measuredWidth: 720, + enabled: true, + }))).toBe(true); + expect(hasOutline(renderRail({ + turns: turns('first', 'second', 'third'), + measuredWidth: 800, + enabled: true, + }))).toBe(true); + }); + + it('hides when any one gate fails', () => { + expect(hasOutline(renderRail({ + turns: turns('first', 'second'), + measuredWidth: 720, + enabled: false, + }))).toBe(false); + expect(hasOutline(renderRail({ + turns: turns('only one'), + measuredWidth: 800, + enabled: true, + }))).toBe(false); + expect(hasOutline(renderRail({ + turns: [], + measuredWidth: 800, + enabled: true, + }))).toBe(false); + expect(hasOutline(renderRail({ + turns: turns('first', 'second'), + measuredWidth: 719, + enabled: true, + }))).toBe(false); + expect(hasOutline(renderRail({ + turns: turns('first', 'second'), + measuredWidth: 0, + enabled: true, + }))).toBe(false); + }); + + it('does not count agent-only turns toward the two-prompt gate', () => { + const oneUser = [ + { turn: 1, user: user('u1', 'only user'), agents: [] }, + { turn: 2, agents: [] }, + { turn: 3, agents: [] }, + ]; + expect(hasOutline(renderRail({ turns: oneUser, measuredWidth: 800, enabled: true }))).toBe(false); + expect(renderRail({ turns: oneUser, measuredWidth: 800, enabled: true })).toBe(''); + }); + + it('returns nothing when the setting is off or there are fewer than two user messages', () => { + expect(renderRail({ + turns: turns('first', 'second'), + measuredWidth: 800, + enabled: false, + })).toBe(''); + expect(renderRail({ turns: turns('only one'), measuredWidth: 800 })).toBe(''); + }); + + describe('stored preference when enabled is omitted', () => { + const store = new Map(); + + beforeEach(() => { + store.clear(); + vi.stubGlobal('localStorage', { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + removeItem: (key: string) => { + store.delete(key); + }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('defaults on, and hides after the preference is saved off', () => { + const wideTwo = { turns: turns('first', 'second'), measuredWidth: 720 }; + expect(hasOutline(renderRail(wideTwo))).toBe(true); + saveChatOutlineEnabled(false); + expect(store.get(StorageKey.chatOutlineEnabled)).toBe('0'); + expect(hasOutline(renderRail(wideTwo))).toBe(false); + expect(renderRail(wideTwo)).toBe(''); + }); + }); +}); + describe('ChatOutlineRail markup', () => { it('does not draw a rail for one user message', () => { const html = renderRail({ turns: turns('only one'), measuredWidth: 800 }); diff --git a/src/pages/chat/ChatTranscript.test.ts b/src/pages/chat/ChatTranscript.test.ts index 3fcdee46..e926d94b 100644 --- a/src/pages/chat/ChatTranscript.test.ts +++ b/src/pages/chat/ChatTranscript.test.ts @@ -162,6 +162,15 @@ describe('ChatTranscript surfaces', () => { expect(html).not.toContain('rounded-composer bg-canvas'); expect(html).toContain('hello from chat'); expect(html).toContain('id="chat-msg-m-user"'); + expect(html).not.toContain('data-testid="chat-outline-rail"'); + expect(html).not.toContain('role="tablist"'); + }); + + it('does not mount an outline for an empty transcript or a single user message', () => { + expect(renderTranscript([])).not.toContain('data-testid="chat-outline-rail"'); + expect(renderTranscript([ + { turn: 1, user: userMessage('hello from chat'), agents: [] }, + ])).not.toContain('chat-outline-rail'); }); it('anchors each sent prompt so the outline can jump to it', () => { @@ -180,6 +189,7 @@ describe('ChatTranscript surfaces', () => { expect(html).toContain('first prompt'); expect(html).toContain('second prompt'); expect(html).toContain('pointer-events-none'); + // Unmeasured panel width is 0, so the 720px gate still hides the ticks. expect(html).not.toContain('data-testid="chat-outline-rail"'); }); }); diff --git a/src/pages/chat/chat-outline-model.test.ts b/src/pages/chat/chat-outline-model.test.ts index 670a0c66..6e48d6d8 100644 --- a/src/pages/chat/chat-outline-model.test.ts +++ b/src/pages/chat/chat-outline-model.test.ts @@ -3,6 +3,8 @@ import type { ChatMessage } from '@/lib/types'; import type { TurnGroup } from './chat-format'; import { OUTLINE_MAGNIFY_RADIUS, + OUTLINE_MIN_PANEL_WIDTH_PX, + OUTLINE_MIN_PROMPTS, applyOutlineJumpOffset, outlinePromptPreview, outlinePromptsFromTurns, @@ -86,10 +88,24 @@ describe('outlinePromptsFromTurns', () => { }); describe('shouldShowChatOutline', () => { - it('requires the setting, two prompts, and a 720px panel', () => { - expect(shouldShowChatOutline({ enabled: true, promptCount: 2, panelWidth: 720 })).toBe(true); - expect(shouldShowChatOutline({ enabled: true, promptCount: 1, panelWidth: 900 })).toBe(false); - expect(shouldShowChatOutline({ enabled: true, promptCount: 2, panelWidth: 719 })).toBe(false); + it('locks the gates at preference on, two user prompts, and 720px', () => { + expect(OUTLINE_MIN_PROMPTS).toBe(2); + expect(OUTLINE_MIN_PANEL_WIDTH_PX).toBe(720); + }); + + it('shows only when preference, prompt count, and panel width all hold', () => { + const shown = { enabled: true, promptCount: 2, panelWidth: 720 }; + expect(shouldShowChatOutline(shown)).toBe(true); + expect(shouldShowChatOutline({ ...shown, promptCount: 3, panelWidth: 721 })).toBe(true); + + expect(shouldShowChatOutline({ ...shown, enabled: false })).toBe(false); + expect(shouldShowChatOutline({ ...shown, promptCount: 0 })).toBe(false); + expect(shouldShowChatOutline({ ...shown, promptCount: 1 })).toBe(false); + expect(shouldShowChatOutline({ ...shown, panelWidth: 0 })).toBe(false); + expect(shouldShowChatOutline({ ...shown, panelWidth: 719 })).toBe(false); + + expect(shouldShowChatOutline({ enabled: false, promptCount: 1, panelWidth: 719 })).toBe(false); + expect(shouldShowChatOutline({ enabled: true, promptCount: 5, panelWidth: 719 })).toBe(false); expect(shouldShowChatOutline({ enabled: false, promptCount: 5, panelWidth: 900 })).toBe(false); }); }); From e719184813af97aa3cbbdb0cc6b8e9afe434900d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 15:43:35 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E9=A1=B6?= =?UTF-8?q?=E6=A0=8F=E6=96=B0=E5=BB=BA=E5=AF=B9=E8=AF=9D=E7=9A=84=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E4=B8=8E=E5=86=92=E7=83=9F=E9=80=89=E6=8B=A9=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onClick 包一层再调 onNewChat,避免把点击事件当成工作目录。冒烟用 exact name 和 data-help=chat-new 对上唯一的「新建对话」,与组上的加号分开。 Co-authored-by: NiceChen --- e2e/browser/chat-overflow.spec.ts | 3 ++- src/pages/chat/ChatSessionRail.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/e2e/browser/chat-overflow.spec.ts b/e2e/browser/chat-overflow.spec.ts index f0b12a2d..209f80b9 100644 --- a/e2e/browser/chat-overflow.spec.ts +++ b/e2e/browser/chat-overflow.spec.ts @@ -10,7 +10,8 @@ test('selected Agent has no duplicate overflow menu; other entries stay', async await expect(composer.getByRole('button', { name: '更多操作' })).toHaveCount(0); await expect(page.getByRole('button', { name: '更多操作' })).toHaveCount(0); - await expect(page.getByRole('button', { name: '新建对话' })).toBeVisible(); + await expect(page.getByRole('button', { name: '新建对话', exact: true })).toBeVisible(); + await expect(page.locator('[data-help="chat-new"]')).toHaveCount(1); await expect(page.getByLabel('搜索标题或工作目录')).toBeVisible(); await expect(page.getByRole('button', { name: '会话设置' })).toBeVisible(); diff --git a/src/pages/chat/ChatSessionRail.tsx b/src/pages/chat/ChatSessionRail.tsx index 46ec0266..785672e2 100644 --- a/src/pages/chat/ChatSessionRail.tsx +++ b/src/pages/chat/ChatSessionRail.tsx @@ -160,7 +160,7 @@ export function ChatSessionRail({ disabled={agentsReady && !hasUsableAgent} data-help="chat-new" aria-keyshortcuts="Control+N" - onClick={onNewChat} + onClick={() => onNewChat()} > {t('chat.rail.newChat')}