From f4c5c69e6f56f761f4662bf86c0b101638833ecb Mon Sep 17 00:00:00 2001 From: Raza Rauf Date: Mon, 24 Aug 2026 17:51:24 -0400 Subject: [PATCH 1/2] fix(document-graph): count tasks with the shared fence-aware helper The node stats footer counted markdown checkboxes with a local copy of `countMarkdownTasks` that ran two `content.match(/.../gm)` passes over the whole document. It had no code-fence bookkeeping, so a checkbox inside a fenced block counted as a real task. A document with one open task, one done task, and a ```markdown example showing the syntax read as "2 of 4 tasks" instead of "1 of 2". Its own comment said "Reuses pattern from FilePreview.tsx", which is how it got here: it was copied from File Preview, then File Preview's version grew fence handling and the copy did not. Now imports the exported `countMarkdownTasks` from `filePreviewUtils`, which skips fenced blocks and already has coverage for it. That helper returns `{ open, closed }` while this footer renders " of ", so the call site maps the two rather than reshaping either side. Also moves `buildFileTreeFromPaths` out of the view and into `src/renderer/utils/fileTree.ts`. It is a generic paths-to-`FileNode` transform with no DocumentGraph knowledge in it, and the view is 2,296 lines. Deliberately not merged with `buildTreeFromPaths` in `fileExplorer.ts`: that one takes a separate directory list plus a file list and returns `FileTreeNode` for the Files panel, whereas this one infers folders from the paths themselves and returns `FileNode` for wiki-link resolution. The new file's header records that distinction so the two are not folded together by mistake later. Eight tests added for the extracted helper, covering folder inference, shared ancestors across diverging branches, empty path entries, and two same-named files in different folders staying distinct (`fullPath` is what wiki-link resolution matches on). The task-counting change inherits the existing `filePreviewUtils` coverage. beyond the corrected count. --- src/__tests__/renderer/utils/fileTree.test.ts | 58 ++++++++++++++++ .../DocumentGraph/DocumentGraphView.tsx | 67 ++----------------- src/renderer/utils/fileTree.ts | 67 +++++++++++++++++++ 3 files changed, 130 insertions(+), 62 deletions(-) create mode 100644 src/__tests__/renderer/utils/fileTree.test.ts create mode 100644 src/renderer/utils/fileTree.ts diff --git a/src/__tests__/renderer/utils/fileTree.test.ts b/src/__tests__/renderer/utils/fileTree.test.ts new file mode 100644 index 0000000000..aac6c1875f --- /dev/null +++ b/src/__tests__/renderer/utils/fileTree.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { buildFileTreeFromPaths } from '../../../renderer/utils/fileTree'; +import type { FileNode } from '../../../renderer/types/fileTree'; + +const names = (nodes: FileNode[]) => nodes.map((n) => n.name); +const folder = (nodes: FileNode[], name: string) => + nodes.find((n) => n.name === name && n.type === 'folder'); + +describe('buildFileTreeFromPaths', () => { + it('returns an empty tree for no paths', () => { + expect(buildFileTreeFromPaths([])).toEqual([]); + }); + + it('places a root-level file at the top level', () => { + const tree = buildFileTreeFromPaths(['README.md']); + expect(tree).toEqual([{ name: 'README.md', type: 'file', fullPath: 'README.md' }]); + }); + + it('infers folders from the path segments', () => { + const tree = buildFileTreeFromPaths(['docs/guides/setup.md']); + const docs = folder(tree, 'docs'); + expect(docs?.isFolder).toBe(true); + const guides = folder(docs!.children!, 'guides'); + expect(names(guides!.children!)).toEqual(['setup.md']); + }); + + it('keeps fullPath as the original path, not just the basename', () => { + const tree = buildFileTreeFromPaths(['docs/guides/setup.md']); + const leaf = folder(folder(tree, 'docs')!.children!, 'guides')!.children![0]; + // Wiki-link resolution matches on fullPath, so a basename here would make + // two same-named files in different folders indistinguishable. + expect(leaf.fullPath).toBe('docs/guides/setup.md'); + }); + + it('reuses a shared folder rather than duplicating it', () => { + const tree = buildFileTreeFromPaths(['docs/a.md', 'docs/b.md']); + expect(tree.filter((n) => n.name === 'docs')).toHaveLength(1); + expect(names(folder(tree, 'docs')!.children!)).toEqual(['a.md', 'b.md']); + }); + + it('reuses shared ancestors across diverging branches', () => { + const tree = buildFileTreeFromPaths(['docs/guides/a.md', 'docs/specs/b.md']); + const docs = folder(tree, 'docs')!; + expect(names(docs.children!).sort()).toEqual(['guides', 'specs']); + }); + + it('skips empty path entries instead of creating blank nodes', () => { + const tree = buildFileTreeFromPaths(['', 'docs/a.md', '']); + expect(tree).toHaveLength(1); + expect(names(tree)).toEqual(['docs']); + }); + + it('keeps two same-named files in different folders separate', () => { + const tree = buildFileTreeFromPaths(['docs/index.md', 'specs/index.md']); + expect(folder(tree, 'docs')!.children![0].fullPath).toBe('docs/index.md'); + expect(folder(tree, 'specs')!.children![0].fullPath).toBe('specs/index.md'); + }); +}); diff --git a/src/renderer/components/DocumentGraph/DocumentGraphView.tsx b/src/renderer/components/DocumentGraph/DocumentGraphView.tsx index 09d3040eec..4c1f63e040 100644 --- a/src/renderer/components/DocumentGraph/DocumentGraphView.tsx +++ b/src/renderer/components/DocumentGraph/DocumentGraphView.tsx @@ -70,77 +70,19 @@ import { GraphLegend } from './GraphLegend'; import { MarkdownRenderer } from '../MarkdownRenderer'; import { generateProseStyles } from '../../utils/markdownConfig'; import { safeClipboardWrite } from '../../utils/clipboard'; -import type { FileNode } from '../../types/fileTree'; +import { buildFileTreeFromPaths } from '../../utils/fileTree'; +import { countMarkdownTasks } from '../FilePreview/filePreviewUtils'; import { logger } from '../../utils/logger'; import { useSettingsStore } from '../../stores/settingsStore'; /** Debounce delay for graph rebuilds when settings change (ms) */ const GRAPH_REBUILD_DEBOUNCE_DELAY = 300; -/** - * Build a file tree structure from graph node file paths. - * This enables wiki-link resolution in the preview panel. - */ -function buildFileTreeFromPaths(filePaths: string[]): FileNode[] { - const root: FileNode[] = []; - const folderMap = new Map(); - - for (const filePath of filePaths) { - if (!filePath) continue; - - const parts = filePath.split('/'); - let currentLevel = root; - let currentPath = ''; - - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - const isLastPart = i === parts.length - 1; - currentPath = currentPath ? `${currentPath}/${part}` : part; - - if (isLastPart) { - // It's a file - currentLevel.push({ - name: part, - type: 'file', - fullPath: filePath, - }); - } else { - // It's a folder - check if it already exists - let folder = folderMap.get(currentPath); - if (!folder) { - folder = { - name: part, - type: 'folder', - isFolder: true, - children: [], - }; - folderMap.set(currentPath, folder); - currentLevel.push(folder); - } - currentLevel = folder.children!; - } - } - } - - return root; -} /** Default maximum number of nodes to load initially */ const DEFAULT_MAX_NODES = 200; /** Number of additional nodes to load when clicking "Load more" */ const LOAD_MORE_INCREMENT = 25; -/** - * Count markdown tasks (checkboxes) in content - * Reuses pattern from FilePreview.tsx - */ -const countMarkdownTasks = (content: string): { completed: number; total: number } => { - const openMatches = content.match(/^[\s]*[-*]\s*\[\s*\]/gm); - const closedMatches = content.match(/^[\s]*[-*]\s*\[[xX]\]/gm); - const open = openMatches?.length || 0; - const closed = closedMatches?.length || 0; - return { completed: closed, total: open + closed }; -}; - /** * Format date for display in footer */ @@ -897,8 +839,9 @@ export function DocumentGraphView({ .readFile(fullPath, sshRemoteId) .then((content) => { if (!content) return; - const tasks = countMarkdownTasks(content); - setSelectedNodeTasks(tasks.total > 0 ? tasks : null); + const { open, closed } = countMarkdownTasks(content); + const total = open + closed; + setSelectedNodeTasks(total > 0 ? { completed: closed, total } : null); }) .catch(() => { setSelectedNodeTasks(null); diff --git a/src/renderer/utils/fileTree.ts b/src/renderer/utils/fileTree.ts new file mode 100644 index 0000000000..250668cc1b --- /dev/null +++ b/src/renderer/utils/fileTree.ts @@ -0,0 +1,67 @@ +/** + * File tree construction helpers. + * + * Companion to the `FileNode` shape in `src/renderer/types/fileTree.ts`. + * + * Distinct from `buildTreeFromPaths()` in `fileExplorer.ts`, which takes a + * separate directory list plus a file list and produces `FileTreeNode` for the + * Files panel. This one infers folders from the paths themselves and produces + * `FileNode`, which is what wiki-link resolution consumes. + */ + +import type { FileNode } from '../types/fileTree'; + +/** + * Build a nested `FileNode` tree from a flat list of relative file paths. + * + * Folders are inferred from the path segments rather than supplied separately, + * so a caller that only knows which files exist (the Document Graph knows its + * nodes, not the directories around them) still gets a tree that wiki-link + * resolution can index. + * + * Empty and duplicate-folder paths are tolerated: blank entries are skipped and + * a folder seen twice is reused rather than duplicated. + */ +export function buildFileTreeFromPaths(filePaths: string[]): FileNode[] { + const root: FileNode[] = []; + const folderMap = new Map(); + + for (const filePath of filePaths) { + if (!filePath) continue; + + const parts = filePath.split('/'); + let currentLevel = root; + let currentPath = ''; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const isLastPart = i === parts.length - 1; + currentPath = currentPath ? `${currentPath}/${part}` : part; + + if (isLastPart) { + // It's a file + currentLevel.push({ + name: part, + type: 'file', + fullPath: filePath, + }); + } else { + // It's a folder - check if it already exists + let folder = folderMap.get(currentPath); + if (!folder) { + folder = { + name: part, + type: 'folder', + isFolder: true, + children: [], + }; + folderMap.set(currentPath, folder); + currentLevel.push(folder); + } + currentLevel = folder.children!; + } + } + } + + return root; +} From a0b73e30fc6abb7872d5db8b09a481dbd4ddfc1b Mon Sep 17 00:00:00 2001 From: Raza Rauf Date: Wed, 26 Aug 2026 15:10:52 -0400 Subject: [PATCH 2/2] fix(search): keep Opt+Cmd+F on all-tabs search and stop Find from stealing side-pane Cmd+F An open transcript Find bar was grabbing every Cmd+F, even after a click on the Right or Left Bar, and a Right Bar blur with a null relatedTarget bounced focus back to Main. Group Find also ignored in-place streaming text. --- .../renderer/components/RightPanel.test.tsx | 39 +++++++ .../hooks/useMainKeyboardHandler.test.ts | 106 ++++++++++++++++++ .../hooks/useOutputSearchMatching.test.ts | 80 +++++++++++++ .../renderer/utils/outputSearch.test.ts | 29 ++++- src/renderer/components/GroupChatPanel.tsx | 8 +- src/renderer/components/RightPanel.tsx | 17 ++- .../hooks/keyboard/useMainKeyboardHandler.ts | 27 ++++- src/renderer/utils/outputSearch.ts | 14 +++ 8 files changed, 308 insertions(+), 12 deletions(-) diff --git a/src/__tests__/renderer/components/RightPanel.test.tsx b/src/__tests__/renderer/components/RightPanel.test.tsx index cba6c6aa9d..1a608c36fd 100644 --- a/src/__tests__/renderer/components/RightPanel.test.tsx +++ b/src/__tests__/renderer/components/RightPanel.test.tsx @@ -421,6 +421,45 @@ describe('RightPanel', () => { expect(spy).toHaveBeenCalledWith('right'); }); + it('keeps Right Bar focus when blur relatedTarget is null but a child is still active', async () => { + useUIStore.setState({ activeFocus: 'right' }); + const spy = vi.spyOn(useUIStore.getState(), 'setActiveFocus'); + const props = createDefaultProps(); + const { container } = render(); + + const panel = container.firstChild as HTMLElement; + const contentArea = container.querySelector('.overflow-y-auto') as HTMLElement; + contentArea.focus(); + spy.mockClear(); + + fireEvent.blur(panel, { relatedTarget: null }); + await act(async () => { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + }); + + expect(spy).not.toHaveBeenCalledWith('main'); + expect(useUIStore.getState().activeFocus).toBe('right'); + }); + + it('hands focus to Main when blur really leaves the panel', async () => { + useUIStore.setState({ activeFocus: 'right' }); + const outside = document.createElement('button'); + document.body.appendChild(outside); + const props = createDefaultProps(); + const { container } = render(); + + const panel = container.firstChild as HTMLElement; + panel.focus(); + fireEvent.blur(panel, { relatedTarget: outside }); + outside.focus(); + await act(async () => { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + }); + + expect(useUIStore.getState().activeFocus).toBe('main'); + outside.remove(); + }); + it('should show focus ring when activeFocus is right', () => { useUIStore.setState({ activeFocus: 'right' }); const props = createDefaultProps(); diff --git a/src/__tests__/renderer/hooks/useMainKeyboardHandler.test.ts b/src/__tests__/renderer/hooks/useMainKeyboardHandler.test.ts index 13d56ab9c0..2c6a755400 100644 --- a/src/__tests__/renderer/hooks/useMainKeyboardHandler.test.ts +++ b/src/__tests__/renderer/hooks/useMainKeyboardHandler.test.ts @@ -5,6 +5,7 @@ import { useSettingsStore } from '../../../renderer/stores/settingsStore'; import { useModalStore } from '../../../renderer/stores/modalStore'; import { useUIStore } from '../../../renderer/stores/uiStore'; import { useSessionStore } from '../../../renderer/stores/sessionStore'; +import { groupChatOutputSearchKey } from '../../../renderer/utils/outputSearch'; // Cmd+Shift+J delegates to the shared tile action. Mocked so the test asserts the // wiring rather than re-running the layout transform (covered in tileNewTab.test). @@ -3860,6 +3861,57 @@ describe('useMainKeyboardHandler', () => { expect(document.activeElement).toBe(searchInput); }); + it('does not steal Cmd+F back to Find when the Right Bar is focused', () => { + useUIStore.getState().setOutputSearchOpen(SEARCH_KEY, true); + const setFileTreeFilterOpen = vi.fn(); + + const { result } = renderHook(() => useMainKeyboardHandler()); + result.current.keyboardHandlerRef.current = createMockContext({ + // Find overlay is registered while the bar is open. + hasOpenLayers: () => true, + activeFocus: 'right', + activeRightTab: 'files', + fileTreeFilterOpen: false, + setFileTreeFilterOpen, + fileTreeFilterInputRef: { current: null }, + }); + + act(() => { + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'f', + metaKey: true, + bubbles: true, + }) + ); + }); + + expect(document.activeElement).not.toBe(searchInput); + expect(setFileTreeFilterOpen).toHaveBeenCalledWith(true); + }); + + it('does not steal Cmd+F back to Find when the Left Bar is focused', () => { + useUIStore.getState().setOutputSearchOpen(SEARCH_KEY, true); + + const { result } = renderHook(() => useMainKeyboardHandler()); + result.current.keyboardHandlerRef.current = createMockContext({ + hasOpenLayers: () => true, + activeFocus: 'sidebar', + }); + + act(() => { + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'f', + metaKey: true, + bubbles: true, + }) + ); + }); + + expect(document.activeElement).not.toBe(searchInput); + }); + it('does not steal Cmd+F focus when output search is closed', () => { useUIStore.getState().setOutputSearchOpen(SEARCH_KEY, false); @@ -4063,6 +4115,60 @@ describe('useMainKeyboardHandler', () => { expect(evt.defaultPrevented).toBe(false); }); }); + + describe('group chat Cmd+F vs Opt+Cmd+F', () => { + const GROUP_ID = 'gc-find'; + const SEARCH_KEY = groupChatOutputSearchKey(GROUP_ID); + + afterEach(() => { + useUIStore.getState().setOutputSearchOpen(SEARCH_KEY, false); + }); + + it('opens group Find on Cmd+F', () => { + const { result } = renderHook(() => useMainKeyboardHandler()); + result.current.keyboardHandlerRef.current = createMockContext({ + activeGroupChatId: GROUP_ID, + activeFocus: 'main', + isShortcut: () => false, + }); + + act(() => { + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'f', + metaKey: true, + bubbles: true, + }) + ); + }); + + expect(useUIStore.getState().outputSearchByKey[SEARCH_KEY]?.open).toBe(true); + }); + + it('does not open group Find on Opt+Cmd+F', () => { + const { result } = renderHook(() => useMainKeyboardHandler()); + result.current.keyboardHandlerRef.current = createMockContext({ + activeGroupChatId: GROUP_ID, + activeFocus: 'main', + isShortcut: (_e: KeyboardEvent, id: string) => id === 'searchAllTabs', + handleOpenCrossTabSearch: vi.fn(), + }); + + act(() => { + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'f', + code: 'KeyF', + altKey: true, + metaKey: true, + bubbles: true, + }) + ); + }); + + expect(useUIStore.getState().outputSearchByKey[SEARCH_KEY]?.open).toBeFalsy(); + }); + }); }); /** diff --git a/src/__tests__/renderer/hooks/useOutputSearchMatching.test.ts b/src/__tests__/renderer/hooks/useOutputSearchMatching.test.ts index 34676038c8..a5f0398ad2 100644 --- a/src/__tests__/renderer/hooks/useOutputSearchMatching.test.ts +++ b/src/__tests__/renderer/hooks/useOutputSearchMatching.test.ts @@ -68,4 +68,84 @@ describe('useOutputSearchMatching', () => { unmount(); container.remove(); }); + + it('rescans when contentRevision changes after the DOM text updates', async () => { + const container = mountContainer('

alpha

'); + const { result, rerender, unmount } = renderHook( + ({ contentRevision }: { contentRevision: number }) => { + const containerRef = useRef(container); + return useOutputSearchMatching({ + containerRef, + outputSearchOpen: true, + outputSearchRegex: false, + debouncedSearchQuery: 'alpha', + contentRevision, + }); + }, + { initialProps: { contentRevision: 1 } } + ); + + await waitFor(() => { + expect(result.current.totalMatches).toBe(1); + }); + + container.innerHTML = '

alpha alpha

'; + rerender({ contentRevision: 2 }); + + await waitFor(() => { + expect(result.current.totalMatches).toBe(2); + }); + + unmount(); + container.remove(); + }); + + it('sets regexError and reports zero matches for an invalid regex', async () => { + const container = mountContainer('

alpha

'); + const { result, unmount } = renderHook(() => { + const containerRef = useRef(container); + return useOutputSearchMatching({ + containerRef, + outputSearchOpen: true, + outputSearchRegex: true, + debouncedSearchQuery: '[', + contentRevision: 1, + }); + }); + + await waitFor(() => { + expect(result.current.regexError).toBeTruthy(); + }); + expect(result.current.totalMatches).toBe(0); + + unmount(); + container.remove(); + }); + + it('selects the match inside the jumped-to row', async () => { + const container = mountContainer( + '
alpha
alpha
' + ); + const jumpRef = { current: 'b' as string | null }; + const { result, unmount } = renderHook(() => { + const containerRef = useRef(container); + return useOutputSearchMatching({ + containerRef, + outputSearchOpen: true, + outputSearchRegex: false, + debouncedSearchQuery: 'alpha', + contentRevision: 1, + pendingJumpMatchIdRef: jumpRef, + jumpIdAttribute: 'data-log-id', + }); + }); + + await waitFor(() => { + expect(result.current.currentMatchIndex).toBe(1); + }); + expect(jumpRef.current).toBeNull(); + + unmount(); + container.remove(); + }); }); diff --git a/src/__tests__/renderer/utils/outputSearch.test.ts b/src/__tests__/renderer/utils/outputSearch.test.ts index bbda6ad27c..739767fb02 100644 --- a/src/__tests__/renderer/utils/outputSearch.test.ts +++ b/src/__tests__/renderer/utils/outputSearch.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { outputSearchKeyFor, groupChatOutputSearchKey, + groupChatSearchContentRevision, getActiveOutputSearchKey, } from '../../../renderer/utils/outputSearch'; import { useGroupChatStore } from '../../../renderer/stores/groupChatStore'; @@ -14,7 +15,7 @@ import { useSessionStore } from '../../../renderer/stores/sessionStore'; describe('outputSearch keys', () => { beforeEach(() => { useGroupChatStore.setState({ activeGroupChatId: null }); - useSessionStore.setState({ sessions: [], activeSessionId: null }); + useSessionStore.setState({ sessions: [], activeSessionId: '' }); }); it('builds a stable agent+tab key', () => { @@ -56,3 +57,29 @@ describe('outputSearch keys', () => { expect(getActiveOutputSearchKey()).toBe('sess-1::tab-a'); }); }); + +describe('groupChatSearchContentRevision', () => { + const query = 'alpha'; + + it('changes when an existing message text grows (streaming) even if count stays 1', () => { + const before = groupChatSearchContentRevision([{ content: 'hel' }], query, true); + const after = groupChatSearchContentRevision([{ content: 'hello world' }], query, true); + expect(before).not.toBe(after); + }); + + it('stays stable when neither text length, query, nor open flag changes', () => { + const a = groupChatSearchContentRevision([{ content: 'hello' }], query, true); + const b = groupChatSearchContentRevision([{ content: 'hello' }], query, true); + expect(a).toBe(b); + }); + + it('still changes when a new message arrives', () => { + const before = groupChatSearchContentRevision([{ content: 'hello' }], query, true); + const after = groupChatSearchContentRevision( + [{ content: 'hello' }, { content: 'alpha' }], + query, + true + ); + expect(before).not.toBe(after); + }); +}); diff --git a/src/renderer/components/GroupChatPanel.tsx b/src/renderer/components/GroupChatPanel.tsx index a3ea6743f2..695a8da237 100644 --- a/src/renderer/components/GroupChatPanel.tsx +++ b/src/renderer/components/GroupChatPanel.tsx @@ -20,7 +20,7 @@ import { GroupChatHeader } from './GroupChatHeader'; import { GroupChatMessages, type GroupChatMessagesHandle } from './GroupChatMessages'; import { GroupChatInput } from './GroupChatInput'; import { OutputSearchBar } from './TerminalOutput/components/OutputSearchBar'; -import { groupChatOutputSearchKey } from '../utils/outputSearch'; +import { groupChatOutputSearchKey, groupChatSearchContentRevision } from '../utils/outputSearch'; import { useOutputSearchSlot } from '../hooks/ui/useOutputSearchSlot'; import { useOutputSearchLayer } from '../hooks/ui/useOutputSearchLayer'; import { useOutputSearchMatching } from '../hooks/ui/useOutputSearchMatching'; @@ -153,7 +153,11 @@ export function GroupChatPanel({ outputSearchOpen, outputSearchRegex, debouncedSearchQuery, - contentRevision: `${messages.length}:${debouncedSearchQuery}:${outputSearchOpen}`, + contentRevision: groupChatSearchContentRevision( + messages, + debouncedSearchQuery, + outputSearchOpen + ), }); // Leaving this group chat (unmount or switch to another id) must clear the diff --git a/src/renderer/components/RightPanel.tsx b/src/renderer/components/RightPanel.tsx index 4f615e4e22..3071d5850f 100644 --- a/src/renderer/components/RightPanel.tsx +++ b/src/renderer/components/RightPanel.tsx @@ -458,12 +458,23 @@ export const RightPanel = memo( onClick={() => setActiveFocus('right')} onFocus={() => setActiveFocus('right')} onBlur={(e) => { - // Clear focus ring when focus moves entirely outside this panel - if (!e.currentTarget.contains(e.relatedTarget as Node)) { + const panel = e.currentTarget; + const next = e.relatedTarget as Node | null; + // Focus moved to another node still inside this panel (tab button, + // file row, filter input). Keep the Right Bar focused. + if (next && panel.contains(next)) return; + // relatedTarget is null when the click landed on a non-focusable + // child (padding, file-tree row). That is NOT "left the panel" - + // React fires blur anyway, and treating null as outside handed + // focus to Main on a second click. Check after the click: only + // drop the ring when the caret actually left. + requestAnimationFrame(() => { + if (!panel.isConnected) return; + if (panel.contains(document.activeElement)) return; if (useUIStore.getState().activeFocus === 'right') { setActiveFocus('main'); } - } + }); }} > {/* Resize Handle */} diff --git a/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts b/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts index 177307197b..c29c48cc35 100644 --- a/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts +++ b/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts @@ -372,6 +372,14 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn { ctx.activeFocus === 'right' && ctx.activeRightTab === 'files' && ctx.fileTreeFilterOpen; + // Cmd+F on a focused side pane is that pane's local filter, even if + // the transcript Find bar is still open from earlier. + const isPaneLocalFindShortcut = + (e.metaKey || e.ctrlKey) && + !e.altKey && + !e.shiftKey && + keyLower === 'f' && + (ctx.activeFocus === 'right' || ctx.activeFocus === 'sidebar'); // The Concerto keys stay live through the guard. The stage is a // workspace surface, not a dialog, so its own toggle has to be able to // close it - a toggle that only ever opens is a dead keypress. And @@ -459,6 +467,7 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn { !isBrowserFindShortcut && !isBrowserNavShortcut && !isFileFilterRefocusShortcut && + !isPaneLocalFindShortcut && !isOutputSearchGlobalShortcut && !isOutputSearchRefocusShortcut && !isConcertoToggleShortcut && @@ -512,11 +521,14 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn { }; // Cmd+F while the output find bar is already open: bring focus back to its - // input from anywhere instead of no-opping. The find bar's own keydown - // handler only opens search when it's closed, so without this re-pressing - // the shortcut after focus moved away (e.g. to the AI input) does nothing. + // input from anywhere in the MAIN chat, instead of no-opping. Do NOT steal + // the chord when a side pane is focused: the Right Bar (Files/History) and + // Left Bar have their own Cmd+F filters, and a click on those panes is the + // user asking for that filter, not the transcript Find they left open. + const sidePaneOwnsFind = ctx.activeFocus === 'right' || ctx.activeFocus === 'sidebar'; if ( isActiveOutputSearchOpen() && + !sidePaneOwnsFind && (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && @@ -1513,12 +1525,15 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn { } } - // Cmd+F contextual shortcuts - prioritize explicit focus over input mode - if (e.key === 'f' && (e.metaKey || e.ctrlKey) && !e.shiftKey) { + // Cmd+F contextual shortcuts - prioritize explicit focus over input mode. + // Alt is excluded: Opt+Cmd+F is searchAllTabs (cross-tab message search) + // and must not also open in-tab Find, group Find, Files filter, or + // terminal/browser find. + if (e.key === 'f' && (e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // Browser-tab in-page find takes precedence whenever a browser tab is // the active tab. Routed both here (when webview isn't focused) and via // `onBrowserTabShortcutKey` (when it is). - if (activeSession?.activeBrowserTabId && !e.altKey) { + if (activeSession?.activeBrowserTabId) { e.preventDefault(); ctx.mainPanelRef?.current?.openBrowserFind(); trackShortcut('searchOutput'); diff --git a/src/renderer/utils/outputSearch.ts b/src/renderer/utils/outputSearch.ts index 79c78c22af..3bc45219d0 100644 --- a/src/renderer/utils/outputSearch.ts +++ b/src/renderer/utils/outputSearch.ts @@ -24,6 +24,20 @@ export function groupChatOutputSearchKey(groupChatId: string): string { return `group-chat::${groupChatId}`; } +/** + * Fingerprint for group-chat Find. Message COUNT is not enough: a streaming + * reply grows `content` in place, so the Find bar must re-scan when any + * message's text length changes. Query and open are included so a closed bar + * with a leftover query does not share a revision with an open one. + */ +export function groupChatSearchContentRevision( + messages: ReadonlyArray<{ content: string }>, + searchQuery: string, + searchOpen: boolean +): string { + return `${messages.length}:${messages.map((m) => m.content.length).join(',')}:${searchQuery}:${searchOpen}`; +} + /** * Key for the currently active chat window, or null when none. * Prefers the active group chat when one is open (MainPanel is unmounted then).