From 604a6d2f84fc8223c47ea6d6626726e207761a5f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 15:42:28 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=E5=AF=B9=E8=AF=9D=EF=BC=9A=E5=AE=BD?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E6=97=B6=E6=8C=82=E4=B8=8A=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E5=A4=A7=E7=BA=B2=E8=BD=A8=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设置打开、对话面板够宽、至少两条用户消息时,原先在消息未齐时量宽失败后不再重绑,轨道一直不出现。改为量对话舞台宽度、量宽节点始终挂上,并补上挂载回归。 Co-authored-by: NiceChen --- src/pages/chat/ChatOutlineRail.test.ts | 3 +- src/pages/chat/ChatOutlineRail.tsx | 35 ++++++++++----- src/pages/chat/ChatTranscript.test.ts | 55 +++++++++++++++++++++-- src/pages/chat/ChatTranscript.tsx | 15 ++++++- src/pages/chat/chat-layout.test.ts | 7 +++ src/pages/chat/chat-outline-model.test.ts | 27 +++++++++++ src/pages/chat/chat-outline-model.ts | 15 +++++++ src/pages/chat/chat-outline-pref.test.ts | 18 +++++++- src/pages/chat/chat-outline-pref.ts | 10 +++++ src/pages/chat/use-chat-outline.ts | 48 ++++++++++++++++++++ 10 files changed, 217 insertions(+), 16 deletions(-) create mode 100644 src/pages/chat/use-chat-outline.ts diff --git a/src/pages/chat/ChatOutlineRail.test.ts b/src/pages/chat/ChatOutlineRail.test.ts index bd7074d8..ab4945ce 100644 --- a/src/pages/chat/ChatOutlineRail.test.ts +++ b/src/pages/chat/ChatOutlineRail.test.ts @@ -139,8 +139,9 @@ describe('ChatOutlineRail visibility gates', () => { }); describe('ChatOutlineRail markup', () => { - it('does not draw a rail for one user message', () => { + it('keeps the measure host mounted for one user message so width can attach', () => { const html = renderRail({ turns: turns('only one'), measuredWidth: 800 }); + expect(html).toContain('data-chat-outline-measure'); expect(html).not.toContain('chat-outline-rail'); expect(html).not.toContain('role="tablist"'); }); diff --git a/src/pages/chat/ChatOutlineRail.tsx b/src/pages/chat/ChatOutlineRail.tsx index 846ed62b..a5b6df6d 100644 --- a/src/pages/chat/ChatOutlineRail.tsx +++ b/src/pages/chat/ChatOutlineRail.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, - useRef, useState, type MouseEvent, type PointerEvent, @@ -16,9 +15,11 @@ import type { TurnGroup } from './chat-format'; import { createChatOutlineHoverIntent } from './chat-outline-hover'; import { OUTLINE_READING_LINE_PX, + outlinePanelElement, outlinePromptsFromTurns, outlineTickSize, promptTickMagnification, + readOutlinePanelWidth, resolveActivePromptId, shouldShowChatOutline, type ChatOutlinePrompt, @@ -48,7 +49,10 @@ export function ChatOutlineRail({ const isEnabled = enabled ?? prefEnabled; const [observedWidth, setObservedWidth] = useState(0); const panelWidth = measuredWidth ?? observedWidth; - const measureRef = useRef(null); + const [measureNode, setMeasureNode] = useState(null); + const assignMeasureRef = useCallback((node: HTMLDivElement | null) => { + setMeasureNode((prev) => (prev === node ? prev : node)); + }, []); const [hoveredIndex, setHoveredIndex] = useState(null); const [focusedIndex, setFocusedIndex] = useState(null); const [activeId, setActiveId] = useState(null); @@ -68,14 +72,21 @@ export function ChatOutlineRail({ useEffect(() => { if (measuredWidth != null) return; - const node = measureRef.current; - if (!node || typeof ResizeObserver === 'undefined') return; - const apply = () => setObservedWidth(node.getBoundingClientRect().width); - const observer = new ResizeObserver(apply); - observer.observe(node); + const node = measureNode; + if (!node) return; + const apply = () => { + const next = readOutlinePanelWidth(node); + setObservedWidth((prev) => (prev === next ? prev : next)); + }; apply(); + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', apply); + return () => window.removeEventListener('resize', apply); + } + const observer = new ResizeObserver(apply); + observer.observe(outlinePanelElement(node) ?? node); return () => observer.disconnect(); - }, [measuredWidth]); + }, [measuredWidth, measureNode]); const readActivePrompt = useCallback(() => { const container = scrollRef?.current; @@ -134,10 +145,14 @@ export function ChatOutlineRail({ if (!visible) hoverIntent.leave(); }, [hoverIntent, visible]); - if (!isEnabled || prompts.length < 2) return null; + if (!isEnabled) return null; return ( -
+
{visible ? (
{ // Unmeasured panel width is 0, so the 720px gate still hides the ticks. expect(html).not.toContain('data-testid="chat-outline-rail"'); }); + + it('mounts the outline rail when the setting, two prompts, and a 720px panel hold', () => { + const html = renderMarkup( + createElement(ChatTranscript, { + active: conversation(), + turns: [ + { turn: 1, user: userMessage('first prompt', 'u1', 1), agents: [] }, + { turn: 2, user: userMessage('second prompt', 'u2', 2), agents: [] }, + ], + processMap: {}, + listLoading: false, + messagesLoading: false, + sending: false, + retryDisabled: false, + scrollRef: createRef(), + bottomRef: createRef(), + onScroll: () => undefined, + onRetry: () => undefined, + measuredWidth: 720, + outlineEnabled: true, + }), + ); + expect(html).toContain('data-testid="chat-outline-rail"'); + expect(html).toContain('data-testid="chat-outline-tick-u1"'); + expect(html).toContain('data-testid="chat-outline-tick-u2"'); + expect(html).toContain('data-chat-outline-host'); + }); + + it('does not mount the rail ticks when only one user message exists', () => { + const html = renderMarkup( + createElement(ChatTranscript, { + active: conversation(), + turns: [{ turn: 1, user: userMessage('only one', 'u1'), agents: [] }], + processMap: {}, + listLoading: false, + messagesLoading: false, + sending: false, + retryDisabled: false, + scrollRef: createRef(), + bottomRef: createRef(), + onScroll: () => undefined, + onRetry: () => undefined, + measuredWidth: 900, + outlineEnabled: true, + }), + ); + expect(html).toContain('data-chat-outline-host'); + expect(html).not.toContain('chat-outline-rail'); + }); }); diff --git a/src/pages/chat/ChatTranscript.tsx b/src/pages/chat/ChatTranscript.tsx index 16bf1ecf..e37d4f3f 100644 --- a/src/pages/chat/ChatTranscript.tsx +++ b/src/pages/chat/ChatTranscript.tsx @@ -39,6 +39,7 @@ import { import { emptyStarterChipHint, emptyTranscriptCopy } from './chat-empty-state'; import { ChatMessageBubble } from './ChatMessageBubble'; import { ChatOutlineRail } from './ChatOutlineRail'; +import { useChatOutlineEnabled, useOutlinePanelWidth } from './use-chat-outline'; export function ChatTranscript({ active, @@ -63,6 +64,8 @@ export function ChatTranscript({ firstBlocker = null, onBlockerAction, onJumpToOutline, + measuredWidth, + outlineEnabled, }: { active: Conversation | null; turns: TurnGroup[]; @@ -86,8 +89,12 @@ export function ChatTranscript({ firstBlocker?: ChatSendBlocker | null; onBlockerAction?: (target: ChatBlockerPrimaryTarget) => void; onJumpToOutline?: (messageId: string) => void; + measuredWidth?: number; + outlineEnabled?: boolean; }) { const { t } = useI18n(); + const outlineOn = useChatOutlineEnabled(outlineEnabled); + const outlinePanel = useOutlinePanelWidth(outlineOn && Boolean(active), measuredWidth); if (listLoading && !active) { return (
@@ -101,7 +108,11 @@ export function ChatTranscript({ const lastTurn = turns[turns.length - 1]?.turn; return ( -
+
); diff --git a/src/pages/chat/chat-layout.test.ts b/src/pages/chat/chat-layout.test.ts index 1cad2b7a..df5a6b3f 100644 --- a/src/pages/chat/chat-layout.test.ts +++ b/src/pages/chat/chat-layout.test.ts @@ -579,8 +579,15 @@ describe('chat layout wiring', () => { expect(source('ChatTranscript.tsx')).toContain('ChatOutlineRail'); expect(source('ChatTranscript.tsx')).toContain('relative flex min-h-0 flex-1 flex-col'); expect(source('ChatTranscript.tsx')).toContain('onJumpToOutline'); + expect(source('ChatTranscript.tsx')).toContain('measuredWidth={outlinePanel.width}'); + expect(source('ChatTranscript.tsx')).toContain('enabled={outlineOn}'); + expect(source('ChatTranscript.tsx')).toContain('useOutlinePanelWidth'); + expect(source('ChatOutlineRail.tsx')).toContain('data-chat-outline-measure'); + expect(source('ChatOutlineRail.tsx')).toContain('if (!isEnabled) return null'); + expect(source('ChatOutlineRail.tsx')).not.toContain('prompts.length < 2) return null'); expect(source('ChatSessionRail.tsx')).not.toContain('ChatOutlineRail'); expect(source('index.tsx')).toContain('onJumpToOutline={page.jumpToOutlinePrompt}'); + expect(source('index.tsx')).toContain('data-chat-stage'); expect(source('use-chat-page.ts')).toContain('jumpToOutlinePrompt'); expect(source('use-chat-page.ts')).toContain('stickToBottomRef.current = false'); expect(source('use-chat-page.ts')).toContain("scrollIntoView({ block: 'start' })"); diff --git a/src/pages/chat/chat-outline-model.test.ts b/src/pages/chat/chat-outline-model.test.ts index 6e48d6d8..0056d19c 100644 --- a/src/pages/chat/chat-outline-model.test.ts +++ b/src/pages/chat/chat-outline-model.test.ts @@ -6,11 +6,13 @@ import { OUTLINE_MIN_PANEL_WIDTH_PX, OUTLINE_MIN_PROMPTS, applyOutlineJumpOffset, + outlinePanelElement, outlinePromptPreview, outlinePromptsFromTurns, outlineTickSize, planOutlineJumpScroll, promptTickMagnification, + readOutlinePanelWidth, resolveActivePromptId, shouldShowChatOutline, type ChatOutlinePrompt, @@ -110,6 +112,31 @@ describe('shouldShowChatOutline', () => { }); }); +describe('outline panel measurement', () => { + it('prefers the chat stage ancestor over the inner host', () => { + const stage = { id: 'stage' }; + const host = { + closest: (selector: string) => (selector === '[data-chat-stage]' ? stage : null), + }; + expect(outlinePanelElement(host as unknown as Element)).toBe(stage); + }); + + it('falls back to the host when no stage ancestor exists', () => { + const host = { closest: () => null }; + expect(outlinePanelElement(host as unknown as Element)).toBe(host); + expect(outlinePanelElement(null)).toBeNull(); + }); + + it('reads the stage width used for the mount gate', () => { + const stage = { + closest: () => stage, + getBoundingClientRect: () => ({ width: 1100 }), + }; + expect(readOutlinePanelWidth(stage as unknown as Element)).toBe(1100); + expect(readOutlinePanelWidth(null)).toBe(0); + }); +}); + describe('outlineTickSize', () => { it('grows from the resting or current width toward the magnified size', () => { expect(outlineTickSize(false, 0)).toEqual({ width: 10, height: 2 }); diff --git a/src/pages/chat/chat-outline-model.ts b/src/pages/chat/chat-outline-model.ts index 736bdbcd..46027240 100644 --- a/src/pages/chat/chat-outline-model.ts +++ b/src/pages/chat/chat-outline-model.ts @@ -62,6 +62,21 @@ export function shouldShowChatOutline(input: { ); } +/** Chat stage (the wide panel), not the inner adaptive content column. */ +export const OUTLINE_PANEL_SELECTOR = '[data-chat-stage]'; + +export function outlinePanelElement(node: Element | null): Element | null { + if (!node) return null; + return node.closest(OUTLINE_PANEL_SELECTOR) ?? node; +} + +export function readOutlinePanelWidth(node: Element | null): number { + const panel = outlinePanelElement(node); + if (!panel) return 0; + const width = panel.getBoundingClientRect().width; + return Number.isFinite(width) ? width : 0; +} + export function outlineTickSize(isActive: boolean, magnification: number): { width: number; height: number; diff --git a/src/pages/chat/chat-outline-pref.test.ts b/src/pages/chat/chat-outline-pref.test.ts index 41c1bdbb..fe49a235 100644 --- a/src/pages/chat/chat-outline-pref.test.ts +++ b/src/pages/chat/chat-outline-pref.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { StorageKey } from '@/lib/storage-key'; -import { loadChatOutlineEnabled, saveChatOutlineEnabled } from './chat-outline-pref'; +import { + loadChatOutlineEnabled, + saveChatOutlineEnabled, + subscribeChatOutlineEnabled, +} from './chat-outline-pref'; const store = new Map(); @@ -35,4 +39,16 @@ describe('chat outline preference', () => { expect(store.get(StorageKey.chatOutlineEnabled)).toBe('1'); expect(loadChatOutlineEnabled()).toBe(true); }); + + it('notifies subscribers so a mounted rail can restore after toggle', () => { + const seen: boolean[] = []; + const stop = subscribeChatOutlineEnabled((value) => { + seen.push(value); + }); + saveChatOutlineEnabled(false); + saveChatOutlineEnabled(true); + stop(); + saveChatOutlineEnabled(false); + expect(seen).toEqual([false, true]); + }); }); diff --git a/src/pages/chat/chat-outline-pref.ts b/src/pages/chat/chat-outline-pref.ts index 27f584e7..5163c31c 100644 --- a/src/pages/chat/chat-outline-pref.ts +++ b/src/pages/chat/chat-outline-pref.ts @@ -1,9 +1,19 @@ import { loadBool, saveBool, StorageKey } from '@/lib/ui-preferences'; +const listeners = new Set<(enabled: boolean) => void>(); + export function loadChatOutlineEnabled(): boolean { return loadBool(StorageKey.chatOutlineEnabled, true); } export function saveChatOutlineEnabled(value: boolean): void { saveBool(StorageKey.chatOutlineEnabled, value); + for (const listener of listeners) listener(value); +} + +export function subscribeChatOutlineEnabled(listener: (enabled: boolean) => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; } diff --git a/src/pages/chat/use-chat-outline.ts b/src/pages/chat/use-chat-outline.ts new file mode 100644 index 00000000..e1926dae --- /dev/null +++ b/src/pages/chat/use-chat-outline.ts @@ -0,0 +1,48 @@ +import { useCallback, useEffect, useState } from 'react'; +import { outlinePanelElement, readOutlinePanelWidth } from './chat-outline-model'; +import { + loadChatOutlineEnabled, + subscribeChatOutlineEnabled, +} from './chat-outline-pref'; + +export function useChatOutlineEnabled(override?: boolean): boolean { + const [enabled, setEnabled] = useState(loadChatOutlineEnabled); + useEffect(() => subscribeChatOutlineEnabled(setEnabled), []); + return override ?? enabled; +} + +/** + * Measure the chat stage (wide panel). A numeric override is for tests / SSR + * so the rail can mount without ResizeObserver. + */ +export function useOutlinePanelWidth(enabled: boolean, override?: number): { + width: number; + assignRef: (node: HTMLDivElement | null) => void; +} { + const [hostNode, setHostNode] = useState(null); + const [observed, setObserved] = useState(0); + + const assignRef = useCallback((node: HTMLDivElement | null) => { + setHostNode((prev) => (prev === node ? prev : node)); + }, []); + + useEffect(() => { + if (override != null || !enabled) return; + const node = hostNode; + if (!node) return; + const apply = () => { + const next = readOutlinePanelWidth(node); + setObserved((prev) => (prev === next ? prev : next)); + }; + apply(); + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', apply); + return () => window.removeEventListener('resize', apply); + } + const observer = new ResizeObserver(apply); + observer.observe(outlinePanelElement(node) ?? node); + return () => observer.disconnect(); + }, [enabled, hostNode, override]); + + return { width: override ?? observed, assignRef }; +} From da44276345d57a1e3505568f095091db9848670d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 15:42:31 +0000 Subject: [PATCH 2/4] =?UTF-8?q?=E5=AF=B9=E8=AF=9D=EF=BC=9A=E6=96=B0?= =?UTF-8?q?=E5=BB=BA=E5=AF=B9=E8=AF=9D=E4=B8=8D=E5=86=8D=E6=8A=8A=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E4=BA=8B=E4=BB=B6=E5=86=99=E8=BF=9B=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主按钮把点击事件当成文件夹传给创建会话,JSON.stringify 遇到循环引用后会话建不出来。点击不再传事件,路径只接受字符串或空,并补上回归。 Co-authored-by: NiceChen --- src/lib/api/chat.test.ts | 11 +++++++ src/lib/api/chat.ts | 3 +- src/lib/backend/tauri/chat.ts | 3 +- src/lib/open-chat-cwd.test.ts | 30 +++++++++++++++++++ src/lib/open-chat-cwd.ts | 16 ++++++++++ src/pages/chat/ChatSessionRail.test.ts | 6 ++++ src/pages/chat/use-chat-page-sessions.test.ts | 10 +++++++ src/pages/chat/use-chat-page-sessions.ts | 4 ++- 8 files changed, 80 insertions(+), 3 deletions(-) diff --git a/src/lib/api/chat.test.ts b/src/lib/api/chat.test.ts index 6e74023a..9af8e890 100644 --- a/src/lib/api/chat.test.ts +++ b/src/lib/api/chat.test.ts @@ -63,6 +63,17 @@ describe('chat API (browser mock)', () => { await rejected; }); + it('createConversation drops a cyclic click-event cwd instead of throwing', async () => { + const cyclic: { target?: unknown } = {}; + cyclic.target = cyclic; + expect(() => JSON.stringify(cyclic)).toThrow(/circular|cyclic/i); + const createP = createConversation(['claude'], cyclic as unknown as string); + await vi.runAllTimersAsync(); + const created = await createP; + expect(created.agentIds).toEqual(['claude']); + expect(created.cwd).toBeNull(); + }); + it('ensureDefaultConversation reuses the initial blank conversation', async () => { const firstP = ensureDefaultConversation(['claude']); await vi.runAllTimersAsync(); diff --git a/src/lib/api/chat.ts b/src/lib/api/chat.ts index 79aa09a9..42ce49f6 100644 --- a/src/lib/api/chat.ts +++ b/src/lib/api/chat.ts @@ -2,6 +2,7 @@ * Chat API façade — delegates to app runtime backend. */ import { getBackend } from '@/app/runtime'; +import { createConversationCwd } from '@/lib/open-chat-cwd'; import type { AgentKey, ChatEvent, ChatHistoryTurn, ChatMessage, Conversation } from '@/lib/types'; import type { MarkdownFilePreviewDto } from '@/lib/backend/contracts/chat-port'; import type { RuntimeOptions, RuntimeReply, RuntimeSnapshot, RuntimeStartExtras, RuntimeTurnSettings } from '@/lib/backend/contracts/chat-runtime'; @@ -23,7 +24,7 @@ export async function createConversation( agentIds: AgentKey[], cwd?: string | null, ): Promise { - return getBackend().chat.createConversation(agentIds, cwd); + return getBackend().chat.createConversation(agentIds, createConversationCwd(cwd)); } export async function ensureDefaultConversation( diff --git a/src/lib/backend/tauri/chat.ts b/src/lib/backend/tauri/chat.ts index 6fcf0926..5ce86adb 100644 --- a/src/lib/backend/tauri/chat.ts +++ b/src/lib/backend/tauri/chat.ts @@ -1,4 +1,5 @@ import type { ChatPort, MarkdownFilePreviewDto } from '@/lib/backend/contracts'; +import { createConversationCwd } from '@/lib/open-chat-cwd'; import { mapChatMessage, mapConversation, @@ -20,7 +21,7 @@ export function createTauriChatPort(): ChatPort { async createConversation(agentIds, cwd) { const row = await invoke('create_conversation', { agentIds, - cwd: cwd ?? null, + cwd: createConversationCwd(cwd), }); return mapConversation(row); }, diff --git a/src/lib/open-chat-cwd.test.ts b/src/lib/open-chat-cwd.test.ts index f3a8f12b..482d7f56 100644 --- a/src/lib/open-chat-cwd.test.ts +++ b/src/lib/open-chat-cwd.test.ts @@ -4,7 +4,9 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it, vi } from 'vitest'; import { consumePendingOpenChatCwd, + createConversationCwd, folderNameFromCwd, + newChatCwdArg, shellOpenChatBootstrap, shellOpenChatHref, } from './open-chat-cwd'; @@ -26,6 +28,25 @@ describe('folderNameFromCwd', () => { }); }); +describe('newChatCwdArg', () => { + it('keeps a folder path or explicit null and drops click events', () => { + expect(newChatCwdArg('/workspace')).toBe('/workspace'); + expect(newChatCwdArg(null)).toBeNull(); + expect(newChatCwdArg(undefined)).toBeUndefined(); + const cyclic: { target?: unknown } = {}; + cyclic.target = cyclic; + expect(() => JSON.stringify(cyclic)).toThrow(/circular|cyclic/i); + expect(newChatCwdArg(cyclic)).toBeUndefined(); + expect(() => JSON.stringify({ + agentIds: ['grok'], + cwd: createConversationCwd(cyclic), + })).not.toThrow(); + expect(createConversationCwd(cyclic)).toBeNull(); + expect(createConversationCwd('/workspace')).toBe('/workspace'); + expect(createConversationCwd(null)).toBeNull(); + }); +}); + describe('consumePendingOpenChatCwd', () => { it('is a no-op when takePending returns nothing', async () => { const applyBootstrap = vi.fn(); @@ -85,4 +106,13 @@ describe('App open-chat wiring', () => { /HashRouter `useNavigate` changes identity with pathname; do not resubscribe\.\s*\n\s*\}, \[\]\);/, ); }); + + it('omits non-string cwd before create-conversation persist and IPC', () => { + const dir = path.dirname(fileURLToPath(import.meta.url)); + const api = readFileSync(path.resolve(dir, 'api/chat.ts'), 'utf8'); + const tauri = readFileSync(path.resolve(dir, 'backend/tauri/chat.ts'), 'utf8'); + expect(api).toContain('createConversationCwd'); + expect(tauri).toContain('createConversationCwd'); + expect(tauri).toContain('cwd: createConversationCwd(cwd)'); + }); }); diff --git a/src/lib/open-chat-cwd.ts b/src/lib/open-chat-cwd.ts index 84b38230..9da1ef96 100644 --- a/src/lib/open-chat-cwd.ts +++ b/src/lib/open-chat-cwd.ts @@ -1,5 +1,21 @@ import type { ChatBootstrap } from '@/lib/types'; +/** + * New-chat cwd for persist / IPC. Only a folder path or explicit null. + * Click events and other objects are dropped so JSON.stringify never sees a cycle. + */ +export function newChatCwdArg(value: unknown): string | null | undefined { + if (value === undefined) return undefined; + if (value === null) return null; + if (typeof value === 'string') return value; + return undefined; +} + +/** Wire / invoke shape: never pass a non-string through JSON.stringify. */ +export function createConversationCwd(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + /** Last path segment for a folder chosen in the OS file manager. */ export function folderNameFromCwd(cwd: string): string { const trimmed = cwd.trim().replace(/[\\/]+$/, ''); diff --git a/src/pages/chat/ChatSessionRail.test.ts b/src/pages/chat/ChatSessionRail.test.ts index 4894e7e9..2f242f20 100644 --- a/src/pages/chat/ChatSessionRail.test.ts +++ b/src/pages/chat/ChatSessionRail.test.ts @@ -224,5 +224,11 @@ describe('ChatSessionRail titles', () => { const unsetGroup = html.slice(html.lastIndexOf('data-help="chat-workspace-group"', unsetAt), unsetAt); expect(unsetGroup).not.toContain('data-help="chat-workspace-new"'); }); + + it('starts the main new chat without passing the click event as a folder', () => { + const src = readFileSync(new URL('./ChatSessionRail.tsx', import.meta.url), 'utf8'); + expect(src).toContain('onClick={() => onNewChat()}'); + expect(src).not.toContain('onClick={onNewChat}'); + }); }); diff --git a/src/pages/chat/use-chat-page-sessions.test.ts b/src/pages/chat/use-chat-page-sessions.test.ts index 96866c01..4aa4f544 100644 --- a/src/pages/chat/use-chat-page-sessions.test.ts +++ b/src/pages/chat/use-chat-page-sessions.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import type { Conversation } from '@/lib/types'; import { mergeHandoffConversations } from './use-chat-page-sessions'; @@ -38,3 +39,12 @@ describe('mergeHandoffConversations', () => { expect(mergeHandoffConversations([folder], loaded)).toBe(loaded); }); }); + +describe('handleNewChat cwd', () => { + it('sanitizes the new-chat folder before createConversation', () => { + const src = readFileSync(new URL('./use-chat-page-sessions.ts', import.meta.url), 'utf8'); + expect(src).toContain('newChatCwdArg'); + expect(src).toContain('cwd === undefined ? defaults.cwd : cwd'); + expect(src).not.toContain('cwdOverride === undefined ? defaults.cwd : cwdOverride'); + }); +}); diff --git a/src/pages/chat/use-chat-page-sessions.ts b/src/pages/chat/use-chat-page-sessions.ts index 7e0d696a..76da9a62 100644 --- a/src/pages/chat/use-chat-page-sessions.ts +++ b/src/pages/chat/use-chat-page-sessions.ts @@ -28,6 +28,7 @@ import { takeChatBootstrap, } from '@/lib/chat-bootstrap'; import { rememberFallbackCwd } from '@/lib/chat-cwd-fallback'; +import { newChatCwdArg } from '@/lib/open-chat-cwd'; import type { AgentKey, AgentStatus, ChatMessage, Conversation } from '@/lib/types'; import { draftForFocusedConversation, @@ -378,9 +379,10 @@ export function useChatPageSessions(input: { if (defaults.agentIds.length === 0) return; try { if (activeId) draftsRef.current.set(activeId, draft); + const cwd = newChatCwdArg(cwdOverride); const conv = await createConversation( defaults.agentIds, - cwdOverride === undefined ? defaults.cwd : cwdOverride, + cwd === undefined ? defaults.cwd : cwd, ); setConversations((prev) => [conv, ...prev]); setActiveId(conv.id); From 588caa11c5af88bd0f8b3e5716acbbfb1819f52c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 15:44:36 +0000 Subject: [PATCH 3/4] =?UTF-8?q?=E5=AF=B9=E8=AF=9D=EF=BC=9A=E9=9B=B6?= =?UTF-8?q?=E5=AE=BD=E7=A9=BA=E5=A3=B3=E4=B8=8D=E7=AE=97=E5=A4=A7=E7=BA=B2?= =?UTF-8?q?=E5=B7=B2=E6=8C=82=E4=B8=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 探针确认宽面板里轨道没挂上、也点不了。量宽为 0 时不再当成已量到,空测量层不能冒充轨道;回归要求出现可点的 tab 刻度。 Co-authored-by: NiceChen --- src/pages/chat/ChatOutlineRail.test.ts | 10 +++++++ src/pages/chat/ChatOutlineRail.tsx | 8 +++--- src/pages/chat/ChatTranscript.test.ts | 33 +++++++++++++++++++++++ src/pages/chat/chat-layout.test.ts | 4 +++ src/pages/chat/chat-outline-model.test.ts | 9 +++++++ src/pages/chat/chat-outline-model.ts | 6 +++++ src/pages/chat/use-chat-outline.ts | 13 ++++++--- 7 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/pages/chat/ChatOutlineRail.test.ts b/src/pages/chat/ChatOutlineRail.test.ts index ab4945ce..766e11f9 100644 --- a/src/pages/chat/ChatOutlineRail.test.ts +++ b/src/pages/chat/ChatOutlineRail.test.ts @@ -151,12 +151,22 @@ describe('ChatOutlineRail markup', () => { expect(html).toContain('data-testid="chat-outline-rail"'); expect(html).toContain('role="tablist"'); expect(html).toContain('role="tab"'); + expect(html).toContain('type="button"'); expect(html).toContain('data-testid="chat-outline-tick-u1"'); expect(html).toContain('data-testid="chat-outline-tick-u2"'); expect(html).toContain('1 / 2:first'); expect(html).toContain('2 / 2:second'); }); + it('does not treat a zero-width empty host as a mounted rail', () => { + const html = renderRail({ turns: turns('first', 'second'), measuredWidth: 0 }); + expect(html).toContain('data-chat-outline-measure'); + expect(html).not.toContain('chat-outline-rail'); + expect(html).not.toContain('role="tablist"'); + expect(html).not.toContain('role="tab"'); + expect(html).not.toContain('chat-outline-tick-'); + }); + it('hides the rail when the panel is narrower than 720px', () => { const html = renderRail({ turns: turns('first', 'second'), measuredWidth: 719 }); expect(html).not.toContain('chat-outline-rail'); diff --git a/src/pages/chat/ChatOutlineRail.tsx b/src/pages/chat/ChatOutlineRail.tsx index a5b6df6d..52872b3a 100644 --- a/src/pages/chat/ChatOutlineRail.tsx +++ b/src/pages/chat/ChatOutlineRail.tsx @@ -16,6 +16,7 @@ import { createChatOutlineHoverIntent } from './chat-outline-hover'; import { OUTLINE_READING_LINE_PX, outlinePanelElement, + outlinePanelWidthReady, outlinePromptsFromTurns, outlineTickSize, promptTickMagnification, @@ -48,7 +49,8 @@ export function ChatOutlineRail({ const [prefEnabled] = useState(loadChatOutlineEnabled); const isEnabled = enabled ?? prefEnabled; const [observedWidth, setObservedWidth] = useState(0); - const panelWidth = measuredWidth ?? observedWidth; + const hasMeasuredWidth = outlinePanelWidthReady(measuredWidth); + const panelWidth = hasMeasuredWidth ? measuredWidth : observedWidth; const [measureNode, setMeasureNode] = useState(null); const assignMeasureRef = useCallback((node: HTMLDivElement | null) => { setMeasureNode((prev) => (prev === node ? prev : node)); @@ -71,7 +73,7 @@ export function ChatOutlineRail({ useEffect(() => () => hoverIntent.dispose(), [hoverIntent]); useEffect(() => { - if (measuredWidth != null) return; + if (hasMeasuredWidth) return; const node = measureNode; if (!node) return; const apply = () => { @@ -86,7 +88,7 @@ export function ChatOutlineRail({ const observer = new ResizeObserver(apply); observer.observe(outlinePanelElement(node) ?? node); return () => observer.disconnect(); - }, [measuredWidth, measureNode]); + }, [hasMeasuredWidth, measureNode]); const readActivePrompt = useCallback(() => { const container = scrollRef?.current; diff --git a/src/pages/chat/ChatTranscript.test.ts b/src/pages/chat/ChatTranscript.test.ts index 04d10945..2747bf01 100644 --- a/src/pages/chat/ChatTranscript.test.ts +++ b/src/pages/chat/ChatTranscript.test.ts @@ -215,11 +215,44 @@ describe('ChatTranscript surfaces', () => { }), ); expect(html).toContain('data-testid="chat-outline-rail"'); + expect(html).toContain('role="tablist"'); + expect(html).toContain('role="tab"'); + expect(html).toContain('type="button"'); expect(html).toContain('data-testid="chat-outline-tick-u1"'); expect(html).toContain('data-testid="chat-outline-tick-u2"'); + expect(html).toContain('1 / 2:first prompt'); + expect(html).toContain('2 / 2:second prompt'); expect(html).toContain('data-chat-outline-host'); }); + it('does not count an empty measure host as the outline', () => { + const html = renderMarkup( + createElement(ChatTranscript, { + active: conversation(), + turns: [ + { turn: 1, user: userMessage('first prompt', 'u1', 1), agents: [] }, + { turn: 2, user: userMessage('second prompt', 'u2', 2), agents: [] }, + ], + processMap: {}, + listLoading: false, + messagesLoading: false, + sending: false, + retryDisabled: false, + scrollRef: createRef(), + bottomRef: createRef(), + onScroll: () => undefined, + onRetry: () => undefined, + measuredWidth: 0, + outlineEnabled: true, + }), + ); + expect(html).toContain('data-chat-outline-host'); + expect(html).toContain('data-chat-outline-measure'); + expect(html).not.toContain('chat-outline-rail'); + expect(html).not.toContain('role="tablist"'); + expect(html).not.toContain('chat-outline-tick-'); + }); + it('does not mount the rail ticks when only one user message exists', () => { const html = renderMarkup( createElement(ChatTranscript, { diff --git a/src/pages/chat/chat-layout.test.ts b/src/pages/chat/chat-layout.test.ts index df5a6b3f..600fea68 100644 --- a/src/pages/chat/chat-layout.test.ts +++ b/src/pages/chat/chat-layout.test.ts @@ -582,9 +582,13 @@ describe('chat layout wiring', () => { expect(source('ChatTranscript.tsx')).toContain('measuredWidth={outlinePanel.width}'); expect(source('ChatTranscript.tsx')).toContain('enabled={outlineOn}'); expect(source('ChatTranscript.tsx')).toContain('useOutlinePanelWidth'); + expect(source('ChatTranscript.tsx')).toContain('onJumpToPrompt={onJumpToOutline}'); expect(source('ChatOutlineRail.tsx')).toContain('data-chat-outline-measure'); + expect(source('ChatOutlineRail.tsx')).toContain('outlinePanelWidthReady'); expect(source('ChatOutlineRail.tsx')).toContain('if (!isEnabled) return null'); expect(source('ChatOutlineRail.tsx')).not.toContain('prompts.length < 2) return null'); + expect(source('ChatOutlineRail.tsx')).toContain('onJumpToPrompt={onJumpToPrompt}'); + expect(source('use-chat-outline.ts')).toContain('outlinePanelWidthReady'); expect(source('ChatSessionRail.tsx')).not.toContain('ChatOutlineRail'); expect(source('index.tsx')).toContain('onJumpToOutline={page.jumpToOutlinePrompt}'); expect(source('index.tsx')).toContain('data-chat-stage'); diff --git a/src/pages/chat/chat-outline-model.test.ts b/src/pages/chat/chat-outline-model.test.ts index 0056d19c..7dd85d0a 100644 --- a/src/pages/chat/chat-outline-model.test.ts +++ b/src/pages/chat/chat-outline-model.test.ts @@ -7,6 +7,7 @@ import { OUTLINE_MIN_PROMPTS, applyOutlineJumpOffset, outlinePanelElement, + outlinePanelWidthReady, outlinePromptPreview, outlinePromptsFromTurns, outlineTickSize, @@ -109,6 +110,14 @@ describe('shouldShowChatOutline', () => { 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); + expect(shouldShowChatOutline({ enabled: true, promptCount: 2, panelWidth: 0 })).toBe(false); + }); + + it('does not treat a zero-width empty host as a measured panel', () => { + expect(outlinePanelWidthReady(0)).toBe(false); + expect(outlinePanelWidthReady(Number.NaN)).toBe(false); + expect(outlinePanelWidthReady(undefined)).toBe(false); + expect(outlinePanelWidthReady(720)).toBe(true); }); }); diff --git a/src/pages/chat/chat-outline-model.ts b/src/pages/chat/chat-outline-model.ts index 46027240..cc544d1d 100644 --- a/src/pages/chat/chat-outline-model.ts +++ b/src/pages/chat/chat-outline-model.ts @@ -58,10 +58,16 @@ export function shouldShowChatOutline(input: { return ( input.enabled && input.promptCount >= OUTLINE_MIN_PROMPTS && + outlinePanelWidthReady(input.panelWidth) && input.panelWidth >= OUTLINE_MIN_PANEL_WIDTH_PX ); } +/** 0 / NaN means “not measured yet”. Do not treat an empty host as a mounted rail. */ +export function outlinePanelWidthReady(width: number | null | undefined): width is number { + return typeof width === 'number' && Number.isFinite(width) && width > 0; +} + /** Chat stage (the wide panel), not the inner adaptive content column. */ export const OUTLINE_PANEL_SELECTOR = '[data-chat-stage]'; diff --git a/src/pages/chat/use-chat-outline.ts b/src/pages/chat/use-chat-outline.ts index e1926dae..874e527a 100644 --- a/src/pages/chat/use-chat-outline.ts +++ b/src/pages/chat/use-chat-outline.ts @@ -1,5 +1,9 @@ import { useCallback, useEffect, useState } from 'react'; -import { outlinePanelElement, readOutlinePanelWidth } from './chat-outline-model'; +import { + outlinePanelElement, + outlinePanelWidthReady, + readOutlinePanelWidth, +} from './chat-outline-model'; import { loadChatOutlineEnabled, subscribeChatOutlineEnabled, @@ -27,7 +31,7 @@ export function useOutlinePanelWidth(enabled: boolean, override?: number): { }, []); useEffect(() => { - if (override != null || !enabled) return; + if (outlinePanelWidthReady(override) || !enabled) return; const node = hostNode; if (!node) return; const apply = () => { @@ -44,5 +48,8 @@ export function useOutlinePanelWidth(enabled: boolean, override?: number): { return () => observer.disconnect(); }, [enabled, hostNode, override]); - return { width: override ?? observed, assignRef }; + return { + width: outlinePanelWidthReady(override) ? override : observed, + assignRef, + }; } From 4bd45ae3ced48414799da127c99f32aed1264271 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 15:59:51 +0000 Subject: [PATCH 4/4] =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=9A=E4=B8=80?= =?UTF-8?q?=E6=9D=A1=E7=94=A8=E6=88=B7=E6=B6=88=E6=81=AF=E6=97=B6=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E5=A4=A7=E7=BA=B2=E9=87=8F=E5=AE=BD=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #372 按旧实现要求一条用户消息时整段空 DOM。产品侧设置打开时要挂着量宽层,轨道仍隐藏。门槛用例改成认量宽层,不认空壳当轨道。 Co-authored-by: NiceChen --- src/pages/chat/ChatOutlineRail.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/pages/chat/ChatOutlineRail.test.ts b/src/pages/chat/ChatOutlineRail.test.ts index 766e11f9..a7ca7c33 100644 --- a/src/pages/chat/ChatOutlineRail.test.ts +++ b/src/pages/chat/ChatOutlineRail.test.ts @@ -94,17 +94,21 @@ describe('ChatOutlineRail visibility gates', () => { { 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(''); + const html = renderRail({ turns: oneUser, measuredWidth: 800, enabled: true }); + expect(hasOutline(html)).toBe(false); + expect(html).toContain('data-chat-outline-measure'); + expect(html).not.toContain('role="tablist"'); }); - it('returns nothing when the setting is off or there are fewer than two user messages', () => { + it('returns nothing when the setting is off, and keeps a measure host for one user message', () => { expect(renderRail({ turns: turns('first', 'second'), measuredWidth: 800, enabled: false, })).toBe(''); - expect(renderRail({ turns: turns('only one'), measuredWidth: 800 })).toBe(''); + const one = renderRail({ turns: turns('only one'), measuredWidth: 800 }); + expect(hasOutline(one)).toBe(false); + expect(one).toContain('data-chat-outline-measure'); }); describe('stored preference when enabled is omitted', () => {