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/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/__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/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/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/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;
+}
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).