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/ChatOutlineRail.test.ts b/src/pages/chat/ChatOutlineRail.test.ts index bd7074d8..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', () => { @@ -139,8 +143,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"'); }); @@ -150,12 +155,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 846ed62b..52872b3a 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,12 @@ import type { TurnGroup } from './chat-format'; import { createChatOutlineHoverIntent } from './chat-outline-hover'; import { OUTLINE_READING_LINE_PX, + outlinePanelElement, + outlinePanelWidthReady, outlinePromptsFromTurns, outlineTickSize, promptTickMagnification, + readOutlinePanelWidth, resolveActivePromptId, shouldShowChatOutline, type ChatOutlinePrompt, @@ -47,8 +49,12 @@ export function ChatOutlineRail({ const [prefEnabled] = useState(loadChatOutlineEnabled); const isEnabled = enabled ?? prefEnabled; const [observedWidth, setObservedWidth] = useState(0); - const panelWidth = measuredWidth ?? observedWidth; - const measureRef = useRef(null); + 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)); + }, []); const [hoveredIndex, setHoveredIndex] = useState(null); const [focusedIndex, setFocusedIndex] = useState(null); const [activeId, setActiveId] = useState(null); @@ -67,15 +73,22 @@ export function ChatOutlineRail({ useEffect(() => () => hoverIntent.dispose(), [hoverIntent]); 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); + if (hasMeasuredWidth) return; + 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]); + }, [hasMeasuredWidth, measureNode]); const readActivePrompt = useCallback(() => { const container = scrollRef?.current; @@ -134,10 +147,14 @@ export function ChatOutlineRail({ if (!visible) hoverIntent.leave(); }, [hoverIntent, visible]); - if (!isEnabled || prompts.length < 2) return null; + if (!isEnabled) return null; return ( -
+
{visible ? (
{ 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/ChatTranscript.test.ts b/src/pages/chat/ChatTranscript.test.ts index e926d94b..2747bf01 100644 --- a/src/pages/chat/ChatTranscript.test.ts +++ b/src/pages/chat/ChatTranscript.test.ts @@ -24,11 +24,11 @@ function conversation(): Conversation { }; } -function userMessage(content: string): ChatMessage { +function userMessage(content: string, id = 'm-user', turn = 1): ChatMessage { return { - id: 'm-user', + id, conversationId: 'c1', - turn: 1, + turn, role: 'user', content, status: 'ok', @@ -192,4 +192,86 @@ describe('ChatTranscript surfaces', () => { // 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('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, { + 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..600fea68 100644 --- a/src/pages/chat/chat-layout.test.ts +++ b/src/pages/chat/chat-layout.test.ts @@ -579,8 +579,19 @@ 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('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'); 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..7dd85d0a 100644 --- a/src/pages/chat/chat-outline-model.test.ts +++ b/src/pages/chat/chat-outline-model.test.ts @@ -6,11 +6,14 @@ import { OUTLINE_MIN_PANEL_WIDTH_PX, OUTLINE_MIN_PROMPTS, applyOutlineJumpOffset, + outlinePanelElement, + outlinePanelWidthReady, outlinePromptPreview, outlinePromptsFromTurns, outlineTickSize, planOutlineJumpScroll, promptTickMagnification, + readOutlinePanelWidth, resolveActivePromptId, shouldShowChatOutline, type ChatOutlinePrompt, @@ -107,6 +110,39 @@ 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); + }); +}); + +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); }); }); diff --git a/src/pages/chat/chat-outline-model.ts b/src/pages/chat/chat-outline-model.ts index 736bdbcd..cc544d1d 100644 --- a/src/pages/chat/chat-outline-model.ts +++ b/src/pages/chat/chat-outline-model.ts @@ -58,10 +58,31 @@ 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]'; + +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..874e527a --- /dev/null +++ b/src/pages/chat/use-chat-outline.ts @@ -0,0 +1,55 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + outlinePanelElement, + outlinePanelWidthReady, + 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 (outlinePanelWidthReady(override) || !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: outlinePanelWidthReady(override) ? override : observed, + assignRef, + }; +} 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);