From 51294da034d95dc1e9f87034bdd0e60cec0500c1 Mon Sep 17 00:00:00 2001 From: KaustubhPatange Date: Sat, 21 Feb 2026 12:46:01 +0530 Subject: [PATCH 01/11] feat: search session globally across projects --- src/main/http/search.ts | 28 ++ src/main/ipc/search.ts | 28 ++ src/main/services/discovery/ProjectScanner.ts | 68 +++ src/preload/index.ts | 2 + src/renderer/api/httpClient.ts | 6 + .../components/search/CommandPalette.tsx | 163 +++++-- src/renderer/store/slices/tabSlice.ts | 13 +- src/renderer/utils/keyboardUtils.ts | 38 ++ src/shared/types/api.ts | 1 + test/main/ipc/globalSearch.test.ts | 405 ++++++++++++++++++ test/renderer/utils/keyboardUtils.test.ts | 161 +++++++ 11 files changed, 863 insertions(+), 50 deletions(-) create mode 100644 src/renderer/utils/keyboardUtils.ts create mode 100644 test/main/ipc/globalSearch.test.ts create mode 100644 test/renderer/utils/keyboardUtils.test.ts diff --git a/src/main/http/search.ts b/src/main/http/search.ts index 920a6898..0a72d0a7 100644 --- a/src/main/http/search.ts +++ b/src/main/http/search.ts @@ -47,4 +47,32 @@ export function registerSearchRoutes(app: FastifyInstance, services: HttpService return { results: [], totalMatches: 0, sessionsSearched: 0, query }; } }); + + app.get<{ + Querystring: { q?: string; maxResults?: string }; + }>('/api/search', async (request) => { + const query = request.query.q ?? ''; + + try { + const validatedQuery = validateSearchQuery(query); + if (!validatedQuery.valid) { + logger.error(`GET global search rejected: ${validatedQuery.error ?? 'Invalid query'}`); + return { results: [], totalMatches: 0, sessionsSearched: 0, query }; + } + + const maxResults = coerceSearchMaxResults( + request.query.maxResults ? Number(request.query.maxResults) : undefined, + 50 + ); + + const result = await services.projectScanner.searchAllProjects( + validatedQuery.value!, + maxResults + ); + return result; + } catch (error) { + logger.error('Error in GET global search:', error); + return { results: [], totalMatches: 0, sessionsSearched: 0, query }; + } + }); } diff --git a/src/main/ipc/search.ts b/src/main/ipc/search.ts index 940897d8..b738c08e 100644 --- a/src/main/ipc/search.ts +++ b/src/main/ipc/search.ts @@ -31,6 +31,7 @@ export function initializeSearchHandlers(contextRegistry: ServiceContextRegistry */ export function registerSearchHandlers(ipcMain: IpcMain): void { ipcMain.handle('search-sessions', handleSearchSessions); + ipcMain.handle('search-all-projects', handleSearchAllProjects); logger.info('Search handlers registered'); } @@ -40,6 +41,7 @@ export function registerSearchHandlers(ipcMain: IpcMain): void { */ export function removeSearchHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler('search-sessions'); + ipcMain.removeHandler('search-all-projects'); logger.info('Search handlers removed'); } @@ -81,3 +83,29 @@ async function handleSearchSessions( return { results: [], totalMatches: 0, sessionsSearched: 0, query }; } } + +/** + * Handler for 'search-all-projects' IPC call. + * Searches sessions across all projects for a query string. + */ +async function handleSearchAllProjects( + _event: IpcMainInvokeEvent, + query: string, + maxResults?: number +): Promise { + try { + const validatedQuery = validateSearchQuery(query); + if (!validatedQuery.valid) { + logger.error(`search-all-projects rejected: ${validatedQuery.error ?? 'Invalid query'}`); + return { results: [], totalMatches: 0, sessionsSearched: 0, query }; + } + + const { projectScanner } = registry.getActive(); + const safeMaxResults = coerceSearchMaxResults(maxResults, 50); + const result = await projectScanner.searchAllProjects(validatedQuery.value!, safeMaxResults); + return result; + } catch (error) { + logger.error('Error in search-all-projects:', error); + return { results: [], totalMatches: 0, sessionsSearched: 0, query }; + } +} diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 90884236..65dfe785 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -1070,6 +1070,74 @@ export class ProjectScanner { return this.sessionSearcher.searchSessions(projectId, query, maxResults); } + /** + * Searches sessions across all projects for a query string. + * Filters out noise messages and returns matching content. + * + * @param query - Search query string + * @param maxResults - Maximum number of results to return (default 50) + */ + async searchAllProjects(query: string, maxResults: number = 50): Promise { + const startedAt = Date.now(); + try { + if (!query || query.trim().length === 0) { + return { results: [], totalMatches: 0, sessionsSearched: 0, query }; + } + + // Get all projects + const projects = await this.scan(); + + if (projects.length === 0) { + return { results: [], totalMatches: 0, sessionsSearched: 0, query }; + } + + // Search across all projects with bounded concurrency + const allResults: SearchSessionsResult[] = []; + const searchBatchSize = this.fsProvider.type === 'ssh' ? 2 : 4; + + for (let i = 0; i < projects.length; i += searchBatchSize) { + const batch = projects.slice(i, i + searchBatchSize); + const batchResults = await Promise.allSettled( + batch.map((project) => this.sessionSearcher.searchSessions(project.id, query, maxResults)) + ); + + for (const result of batchResults) { + if (result.status === 'fulfilled') { + allResults.push(result.value); + } + } + + // Check if we have enough results already + const totalMatches = allResults.reduce((sum, r) => sum + r.totalMatches, 0); + if (totalMatches >= maxResults) { + break; + } + } + + // Merge results from all projects + const mergedResults = allResults.flatMap((r) => r.results); + const totalSessionsSearched = allResults.reduce((sum, r) => sum + r.sessionsSearched, 0); + + // Sort by timestamp (most recent first) and limit to maxResults + mergedResults.sort((a, b) => b.timestamp - a.timestamp); + const limitedResults = mergedResults.slice(0, maxResults); + + logger.debug( + `Global search completed: ${limitedResults.length} results from ${totalSessionsSearched} sessions across ${projects.length} projects in ${Date.now() - startedAt}ms` + ); + + return { + results: limitedResults, + totalMatches: limitedResults.length, + sessionsSearched: totalSessionsSearched, + query, + }; + } catch (error) { + logger.error('Error searching all projects:', error); + return { results: [], totalMatches: 0, sessionsSearched: 0, query }; + } + } + /** * Resolve best-available file timestamps from directory entry metadata or stat fallback. */ diff --git a/src/preload/index.ts b/src/preload/index.ts index 5f59e1c8..88882804 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -135,6 +135,8 @@ const electronAPI: ElectronAPI = { ) => ipcRenderer.invoke('get-sessions-paginated', projectId, cursor, limit, options), searchSessions: (projectId: string, query: string, maxResults?: number) => ipcRenderer.invoke('search-sessions', projectId, query, maxResults), + searchAllProjects: (query: string, maxResults?: number) => + ipcRenderer.invoke('search-all-projects', query, maxResults), getSessionDetail: (projectId: string, sessionId: string) => ipcRenderer.invoke('get-session-detail', projectId, sessionId), getSessionMetrics: (projectId: string, sessionId: string) => diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 26f904ac..50672309 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -214,6 +214,12 @@ export class HttpAPIClient implements ElectronAPI { ); }; + searchAllProjects = (query: string, maxResults?: number): Promise => { + const params = new URLSearchParams({ q: query }); + if (maxResults) params.set('maxResults', String(maxResults)); + return this.get(`/api/search?${params}`); + }; + getSessionDetail = (projectId: string, sessionId: string): Promise => this.get( `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}` diff --git a/src/renderer/components/search/CommandPalette.tsx b/src/renderer/components/search/CommandPalette.tsx index 17f3c5f3..4777fb91 100644 --- a/src/renderer/components/search/CommandPalette.tsx +++ b/src/renderer/components/search/CommandPalette.tsx @@ -11,12 +11,23 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; +import { formatModifierShortcut } from '@renderer/utils/keyboardUtils'; import { createLogger } from '@shared/utils/logger'; import { useShallow } from 'zustand/react/shallow'; const logger = createLogger('Component:CommandPalette'); import { formatDistanceToNow } from 'date-fns'; -import { Bot, FileText, FolderGit2, Loader2, MessageSquare, Search, User, X } from 'lucide-react'; +import { + Bot, + FileText, + FolderGit2, + Globe, + Loader2, + MessageSquare, + Search, + User, + X, +} from 'lucide-react'; import type { RepositoryGroup, SearchResult } from '@renderer/types/data'; @@ -83,6 +94,8 @@ interface SessionResultItemProps { isSelected: boolean; onClick: () => void; highlightMatch: (context: string, matchedText: string) => React.ReactNode; + showProjectName?: boolean; + projectName?: string; } const SessionResultItemInner = ({ @@ -90,6 +103,8 @@ const SessionResultItemInner = ({ isSelected, onClick, highlightMatch, + showProjectName = false, + projectName, }: Readonly): React.JSX.Element => { return ( @@ -459,15 +526,25 @@ export const CommandPalette = (): React.JSX.Element | null => { ) : (
- {sessionResults.map((result, index) => ( - handleSessionResultClick(result)} - highlightMatch={highlightMatch} - /> - ))} + {sessionResults.map((result, index) => { + // Find project name for this result when in global search mode + const projectName = globalSearchEnabled + ? repositoryGroups.find((r) => r.worktrees.some((w) => w.id === result.projectId)) + ?.name + : undefined; + + return ( + handleSessionResultClick(result)} + highlightMatch={highlightMatch} + showProjectName={globalSearchEnabled} + projectName={projectName} + /> + ); + })}
)} @@ -478,7 +555,7 @@ export const CommandPalette = (): React.JSX.Element | null => { {searchMode === 'projects' ? `${filteredProjects.length} project${filteredProjects.length !== 1 ? 's' : ''}` : totalMatches > 0 - ? `${totalMatches} ${searchIsPartial ? 'fast ' : ''}result${totalMatches !== 1 ? 's' : ''}` + ? `${totalMatches} ${searchIsPartial ? 'fast ' : ''}result${totalMatches !== 1 ? 's' : ''}${globalSearchEnabled ? ' across all projects' : ''}` : 'Type to search'}
@@ -490,6 +567,12 @@ export const CommandPalette = (): React.JSX.Element | null => { {' '} {searchMode === 'projects' ? 'select' : 'open'} + + + {formatModifierShortcut('G')} + {' '} + global + esc close diff --git a/src/renderer/store/slices/tabSlice.ts b/src/renderer/store/slices/tabSlice.ts index a6da3673..f0d4f3af 100644 --- a/src/renderer/store/slices/tabSlice.ts +++ b/src/renderer/store/slices/tabSlice.ts @@ -649,11 +649,6 @@ export const createTabSlice: StateCreator = (set, ge ) => { const state = get(); - // If different project, select it first - if (state.selectedProjectId !== projectId) { - state.selectProject(projectId); - } - // Check if session tab is already open in any pane const allTabs = getAllTabs(state.paneLayout); const existingTab = @@ -703,6 +698,9 @@ export const createTabSlice: StateCreator = (set, ge const newState = get(); const newTabId = newState.activeTabId; if (newTabId) { + // Re-focus tab via setActiveTab for proper sidebar sync + state.setActiveTab(newTabId); + const searchPayload = { query: searchContext.query, messageTimestamp: searchContext.messageTimestamp, @@ -731,10 +729,5 @@ export const createTabSlice: StateCreator = (set, ge const newTabIdForFetch = get().activeTabId ?? undefined; void state.fetchSessionDetail(projectId, sessionId, newTabIdForFetch); } - - // If opened from search, clear sidebar selection to deselect - if (fromSearch) { - set({ selectedSessionId: null }); - } }, }); diff --git a/src/renderer/utils/keyboardUtils.ts b/src/renderer/utils/keyboardUtils.ts new file mode 100644 index 00000000..c525ad06 --- /dev/null +++ b/src/renderer/utils/keyboardUtils.ts @@ -0,0 +1,38 @@ +/** + * Keyboard utility functions for platform-aware shortcuts + */ + +/** + * Detect if running on macOS + */ +export function isMacOS(): boolean { + return navigator.userAgent.toLowerCase().includes('mac'); +} + +/** + * Get the primary modifier key name for the current platform + * @returns 'Cmd' on macOS, 'Ctrl' on other platforms + */ +export function getModifierKeyName(): string { + return isMacOS() ? 'Cmd' : 'Ctrl'; +} + +/** + * Get the primary modifier key symbol for the current platform + * @returns '⌘' on macOS, 'Ctrl' on other platforms + */ +export function getModifierKeySymbol(): string { + return isMacOS() ? '⌘' : 'Ctrl'; +} + +/** + * Format a keyboard shortcut for display + * @param key - The key to press (e.g., 'K', 'G', 'Enter') + * @param useSymbol - Whether to use symbols (⌘) or text (Cmd) + * @returns Formatted shortcut string (e.g., '⌘K' or 'Ctrl+K') + */ +export function formatModifierShortcut(key: string, useSymbol = true): string { + const modifier = useSymbol ? getModifierKeySymbol() : getModifierKeyName(); + const separator = useSymbol && isMacOS() ? '' : '+'; + return `${modifier}${separator}${key}`; +} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 4d405b02..9c8847ba 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -327,6 +327,7 @@ export interface ElectronAPI { query: string, maxResults?: number ) => Promise; + searchAllProjects: (query: string, maxResults?: number) => Promise; getSessionDetail: (projectId: string, sessionId: string) => Promise; getSessionMetrics: (projectId: string, sessionId: string) => Promise; getWaterfallData: (projectId: string, sessionId: string) => Promise; diff --git a/test/main/ipc/globalSearch.test.ts b/test/main/ipc/globalSearch.test.ts new file mode 100644 index 00000000..a25da8e9 --- /dev/null +++ b/test/main/ipc/globalSearch.test.ts @@ -0,0 +1,405 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ProjectScanner } from '../../../src/main/services/discovery/ProjectScanner'; + +import type { Project, SearchSessionsResult } from '../../../src/main/types'; + +/** + * Tests for global search functionality across all projects + */ +describe('Global Search - ProjectScanner.searchAllProjects', () => { + let projectScanner: ProjectScanner; + let mockScan: ReturnType; + let mockSearchSessions: ReturnType; + + beforeEach(() => { + // Create a real ProjectScanner instance + projectScanner = new ProjectScanner(); + + // Mock the scan() method + mockScan = vi.fn(); + projectScanner.scan = mockScan; + + // Mock the sessionSearcher.searchSessions() method + mockSearchSessions = vi.fn(); + // @ts-expect-error - Accessing private property for testing + projectScanner.sessionSearcher = { + searchSessions: mockSearchSessions, + }; + }); + + describe('searchAllProjects', () => { + it('should return empty results for empty query', async () => { + const result = await projectScanner.searchAllProjects('', 50); + + expect(result.results).toEqual([]); + expect(result.totalMatches).toBe(0); + expect(result.sessionsSearched).toBe(0); + expect(mockScan).not.toHaveBeenCalled(); + }); + + it('should return empty results for whitespace query', async () => { + const result = await projectScanner.searchAllProjects(' ', 50); + + expect(result.results).toEqual([]); + expect(result.totalMatches).toBe(0); + expect(result.sessionsSearched).toBe(0); + expect(mockScan).not.toHaveBeenCalled(); + }); + + it('should return empty results when no projects exist', async () => { + mockScan.mockResolvedValue([]); + + const result = await projectScanner.searchAllProjects('test', 50); + + expect(result.results).toEqual([]); + expect(result.totalMatches).toBe(0); + expect(result.sessionsSearched).toBe(0); + expect(mockScan).toHaveBeenCalledOnce(); + }); + + it('should search across multiple projects and merge results', async () => { + const now = Date.now(); + + // Mock scan() to return 2 projects + const mockProjects: Project[] = [ + { + id: 'project1', + path: '/path/to/project1', + name: 'Project 1', + sessions: ['session1'], + createdAt: now - 10000, + mostRecentSession: now, + }, + { + id: 'project2', + path: '/path/to/project2', + name: 'Project 2', + sessions: ['session2'], + createdAt: now - 20000, + mostRecentSession: now - 1000, + }, + ]; + mockScan.mockResolvedValue(mockProjects); + + // Mock searchSessions() to return different results for each project + mockSearchSessions.mockImplementation((projectId: string) => { + if (projectId === 'project1') { + return Promise.resolve({ + results: [ + { + projectId: 'project1', + sessionId: 'session1', + sessionTitle: 'Test Session 1', + context: 'This is a test message', + matchedText: 'test', + messageType: 'user' as const, + timestamp: now, + groupId: 'group1', + matchIndexInItem: 0, + matchStartOffset: 10, + messageUuid: 'uuid1', + }, + ], + totalMatches: 1, + sessionsSearched: 5, + query: 'test', + } satisfies SearchSessionsResult); + } else { + return Promise.resolve({ + results: [ + { + projectId: 'project2', + sessionId: 'session2', + sessionTitle: 'Test Session 2', + context: 'Another test message', + matchedText: 'test', + messageType: 'assistant' as const, + timestamp: now - 1000, + groupId: 'group2', + matchIndexInItem: 0, + matchStartOffset: 8, + messageUuid: 'uuid2', + }, + ], + totalMatches: 1, + sessionsSearched: 3, + query: 'test', + } satisfies SearchSessionsResult); + } + }); + + const result = await projectScanner.searchAllProjects('test', 50); + + expect(mockScan).toHaveBeenCalledOnce(); + expect(mockSearchSessions).toHaveBeenCalledTimes(2); + expect(mockSearchSessions).toHaveBeenCalledWith('project1', 'test', 50); + expect(mockSearchSessions).toHaveBeenCalledWith('project2', 'test', 50); + + expect(result.results).toHaveLength(2); + expect(result.totalMatches).toBe(2); + expect(result.sessionsSearched).toBe(8); // 5 + 3 + + // Verify results from different projects + expect(result.results[0].projectId).toBe('project1'); + expect(result.results[1].projectId).toBe('project2'); + }); + + it('should sort results by timestamp (most recent first)', async () => { + const now = Date.now(); + + const mockProjects: Project[] = [ + { + id: 'project1', + path: '/path/to/project1', + name: 'Project 1', + sessions: ['session1'], + createdAt: now - 10000, + }, + { + id: 'project2', + path: '/path/to/project2', + name: 'Project 2', + sessions: ['session2'], + createdAt: now - 20000, + }, + ]; + mockScan.mockResolvedValue(mockProjects); + + // Project1 has older result, Project2 has newer result + mockSearchSessions.mockImplementation((projectId: string) => { + if (projectId === 'project1') { + return Promise.resolve({ + results: [ + { + projectId: 'project1', + sessionId: 'session1', + sessionTitle: 'Old Session', + context: 'test', + matchedText: 'test', + messageType: 'user' as const, + timestamp: now - 10000, // Older + groupId: 'group1', + matchIndexInItem: 0, + matchStartOffset: 0, + messageUuid: 'uuid1', + }, + ], + totalMatches: 1, + sessionsSearched: 5, + query: 'test', + } satisfies SearchSessionsResult); + } else { + return Promise.resolve({ + results: [ + { + projectId: 'project2', + sessionId: 'session2', + sessionTitle: 'New Session', + context: 'test', + matchedText: 'test', + messageType: 'user' as const, + timestamp: now, // Newer + groupId: 'group2', + matchIndexInItem: 0, + matchStartOffset: 0, + messageUuid: 'uuid2', + }, + ], + totalMatches: 1, + sessionsSearched: 3, + query: 'test', + } satisfies SearchSessionsResult); + } + }); + + const result = await projectScanner.searchAllProjects('test', 50); + + // Should be sorted newest first + expect(result.results[0].sessionTitle).toBe('New Session'); + expect(result.results[1].sessionTitle).toBe('Old Session'); + expect(result.results[0].timestamp).toBeGreaterThan(result.results[1].timestamp); + }); + + it('should respect maxResults limit', async () => { + const now = Date.now(); + + const mockProjects: Project[] = [ + { + id: 'project1', + path: '/path/to/project1', + name: 'Project 1', + sessions: ['session1'], + createdAt: now, + }, + ]; + mockScan.mockResolvedValue(mockProjects); + + // Return 30 results from search + const mockResults = Array.from({ length: 30 }, (_, i) => ({ + projectId: 'project1', + sessionId: `session${i}`, + sessionTitle: `Session ${i}`, + context: 'test context', + matchedText: 'test', + messageType: 'user' as const, + timestamp: now - i * 1000, + groupId: `group${i}`, + matchIndexInItem: 0, + matchStartOffset: 0, + messageUuid: `uuid${i}`, + })); + + mockSearchSessions.mockResolvedValue({ + results: mockResults, + totalMatches: 30, + sessionsSearched: 50, + query: 'test', + } satisfies SearchSessionsResult); + + const result = await projectScanner.searchAllProjects('test', 25); + + expect(result.results.length).toBe(25); // Limited to maxResults + expect(mockSearchSessions).toHaveBeenCalledWith('project1', 'test', 25); + }); + + it('should handle search errors gracefully', async () => { + const now = Date.now(); + + const mockProjects: Project[] = [ + { + id: 'project1', + path: '/path/to/project1', + name: 'Project 1', + sessions: ['session1'], + createdAt: now, + }, + { + id: 'project2', + path: '/path/to/project2', + name: 'Project 2', + sessions: ['session2'], + createdAt: now - 1000, + }, + ]; + mockScan.mockResolvedValue(mockProjects); + + // First project fails, second succeeds + mockSearchSessions.mockImplementation((projectId: string) => { + if (projectId === 'project1') { + return Promise.reject(new Error('Search failed')); + } else { + return Promise.resolve({ + results: [ + { + projectId: 'project2', + sessionId: 'session2', + sessionTitle: 'Test Session 2', + context: 'test', + matchedText: 'test', + messageType: 'user' as const, + timestamp: now, + groupId: 'group2', + matchIndexInItem: 0, + matchStartOffset: 0, + messageUuid: 'uuid2', + }, + ], + totalMatches: 1, + sessionsSearched: 3, + query: 'test', + } satisfies SearchSessionsResult); + } + }); + + const result = await projectScanner.searchAllProjects('test', 50); + + // Should still return results from successful project + expect(result.results).toHaveLength(1); + expect(result.results[0].projectId).toBe('project2'); + expect(result.totalMatches).toBe(1); + expect(result.sessionsSearched).toBe(3); + }); + + it('should use batched concurrency for local FS', async () => { + const now = Date.now(); + + // Create 10 projects to test batching (local uses batch size 4) + const mockProjects: Project[] = Array.from({ length: 10 }, (_, i) => ({ + id: `project${i}`, + path: `/path/to/project${i}`, + name: `Project ${i}`, + sessions: [`session${i}`], + createdAt: now - i * 1000, + })); + mockScan.mockResolvedValue(mockProjects); + + // Track call order to verify batching + const callOrder: string[] = []; + mockSearchSessions.mockImplementation((projectId: string) => { + callOrder.push(projectId); + return Promise.resolve({ + results: [], + totalMatches: 0, + sessionsSearched: 1, + query: 'test', + } satisfies SearchSessionsResult); + }); + + await projectScanner.searchAllProjects('test', 50); + + // All 10 projects should be searched + expect(mockSearchSessions).toHaveBeenCalledTimes(10); + expect(callOrder).toHaveLength(10); + }); + + it('should stop searching when enough results are found', async () => { + const now = Date.now(); + + // Create 10 projects + const mockProjects: Project[] = Array.from({ length: 10 }, (_, i) => ({ + id: `project${i}`, + path: `/path/to/project${i}`, + name: `Project ${i}`, + sessions: [`session${i}`], + createdAt: now - i * 1000, + })); + mockScan.mockResolvedValue(mockProjects); + + // Each project returns 10 results (total would be 100) + mockSearchSessions.mockImplementation((projectId: string) => { + const results = Array.from({ length: 10 }, (_, i) => ({ + projectId, + sessionId: `session${i}`, + sessionTitle: `Session ${i}`, + context: 'test', + matchedText: 'test', + messageType: 'user' as const, + timestamp: now - i * 1000, + groupId: `group${i}`, + matchIndexInItem: 0, + matchStartOffset: 0, + messageUuid: `uuid${i}`, + })); + + return Promise.resolve({ + results, + totalMatches: 10, + sessionsSearched: 1, + query: 'test', + } satisfies SearchSessionsResult); + }); + + const result = await projectScanner.searchAllProjects('test', 50); + + // Should stop after getting enough results (checks after each batch of 4) + // Batch 1 (4 projects): 40 matches < 50, continue + // Batch 2 (4 projects): 80 matches >= 50, stop + expect(mockSearchSessions.mock.calls.length).toBeGreaterThanOrEqual(4); + expect(mockSearchSessions.mock.calls.length).toBeLessThanOrEqual(8); + + // Result should be limited to maxResults + expect(result.results.length).toBe(50); + }); + }); +}); diff --git a/test/renderer/utils/keyboardUtils.test.ts b/test/renderer/utils/keyboardUtils.test.ts new file mode 100644 index 00000000..5303c261 --- /dev/null +++ b/test/renderer/utils/keyboardUtils.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + formatModifierShortcut, + getModifierKeyName, + getModifierKeySymbol, + isMacOS, +} from '../../../src/renderer/utils/keyboardUtils'; + +describe('keyboardUtils', () => { + describe('isMacOS', () => { + beforeEach(() => { + // Reset userAgent before each test + Object.defineProperty(navigator, 'userAgent', { + writable: true, + configurable: true, + value: '', + }); + }); + + it('should return true when userAgent contains "mac"', () => { + Object.defineProperty(navigator, 'userAgent', { + writable: true, + configurable: true, + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', + }); + expect(isMacOS()).toBe(true); + }); + + it('should return false when userAgent does not contain "mac"', () => { + Object.defineProperty(navigator, 'userAgent', { + writable: true, + configurable: true, + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + }); + expect(isMacOS()).toBe(false); + }); + + it('should be case-insensitive', () => { + Object.defineProperty(navigator, 'userAgent', { + writable: true, + configurable: true, + value: 'Mozilla/5.0 (MAC OS)', + }); + expect(isMacOS()).toBe(true); + }); + }); + + describe('getModifierKeyName', () => { + it('should return "Cmd" on macOS', () => { + Object.defineProperty(navigator, 'userAgent', { + writable: true, + configurable: true, + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', + }); + expect(getModifierKeyName()).toBe('Cmd'); + }); + + it('should return "Ctrl" on Windows', () => { + Object.defineProperty(navigator, 'userAgent', { + writable: true, + configurable: true, + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + }); + expect(getModifierKeyName()).toBe('Ctrl'); + }); + + it('should return "Ctrl" on Linux', () => { + Object.defineProperty(navigator, 'userAgent', { + writable: true, + configurable: true, + value: 'Mozilla/5.0 (X11; Linux x86_64)', + }); + expect(getModifierKeyName()).toBe('Ctrl'); + }); + }); + + describe('getModifierKeySymbol', () => { + it('should return "⌘" on macOS', () => { + Object.defineProperty(navigator, 'userAgent', { + writable: true, + configurable: true, + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', + }); + expect(getModifierKeySymbol()).toBe('⌘'); + }); + + it('should return "Ctrl" on Windows', () => { + Object.defineProperty(navigator, 'userAgent', { + writable: true, + configurable: true, + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + }); + expect(getModifierKeySymbol()).toBe('Ctrl'); + }); + + it('should return "Ctrl" on Linux', () => { + Object.defineProperty(navigator, 'userAgent', { + writable: true, + configurable: true, + value: 'Mozilla/5.0 (X11; Linux x86_64)', + }); + expect(getModifierKeySymbol()).toBe('Ctrl'); + }); + }); + + describe('formatModifierShortcut', () => { + describe('macOS', () => { + beforeEach(() => { + Object.defineProperty(navigator, 'userAgent', { + writable: true, + configurable: true, + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', + }); + }); + + it('should format with symbol by default', () => { + expect(formatModifierShortcut('K')).toBe('⌘K'); + }); + + it('should format with text when useSymbol is false', () => { + expect(formatModifierShortcut('K', false)).toBe('Cmd+K'); + }); + + it('should work with different keys', () => { + expect(formatModifierShortcut('G')).toBe('⌘G'); + expect(formatModifierShortcut('S')).toBe('⌘S'); + expect(formatModifierShortcut('Enter')).toBe('⌘Enter'); + }); + }); + + describe('Windows/Linux', () => { + beforeEach(() => { + Object.defineProperty(navigator, 'userAgent', { + writable: true, + configurable: true, + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + }); + }); + + it('should format with symbol by default', () => { + expect(formatModifierShortcut('K')).toBe('Ctrl+K'); + }); + + it('should format with text when useSymbol is false', () => { + expect(formatModifierShortcut('K', false)).toBe('Ctrl+K'); + }); + + it('should work with different keys', () => { + expect(formatModifierShortcut('G')).toBe('Ctrl+G'); + expect(formatModifierShortcut('S')).toBe('Ctrl+S'); + expect(formatModifierShortcut('Enter')).toBe('Ctrl+Enter'); + }); + + it('should always include + separator', () => { + expect(formatModifierShortcut('K')).toContain('+'); + expect(formatModifierShortcut('K', false)).toContain('+'); + }); + }); + }); +}); From 6e9c6219b2f8051dc3f418254fab7139092accc5 Mon Sep 17 00:00:00 2001 From: Psypeal Gwai Date: Sat, 21 Feb 2026 01:15:55 -0800 Subject: [PATCH 02/11] fix: correct context badge count to sum actual items instead of injection objects (#2) --- src/renderer/components/chat/ContextBadge.tsx | 15 +++++++++++++-- src/renderer/utils/contextTracker.ts | 4 ++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/renderer/components/chat/ContextBadge.tsx b/src/renderer/components/chat/ContextBadge.tsx index 737ee229..2b4d5991 100644 --- a/src/renderer/components/chat/ContextBadge.tsx +++ b/src/renderer/components/chat/ContextBadge.tsx @@ -215,6 +215,17 @@ export const ContextBadge = ({ [newTaskCoordinationInjections] ); + // Compute actual item counts (not injection-object counts) for accurate badge display + const toolOutputCount = useMemo( + () => newToolOutputInjections.reduce((sum, inj) => sum + inj.toolCount, 0), + [newToolOutputInjections] + ); + + const taskCoordinationCount = useMemo( + () => newTaskCoordinationInjections.reduce((sum, inj) => sum + inj.breakdown.length, 0), + [newTaskCoordinationInjections] + ); + const userMessageTokens = useMemo( () => newUserMessageInjections.reduce((sum, inj) => sum + inj.estimatedTokens, 0), [newUserMessageInjections] @@ -478,7 +489,7 @@ export const ContextBadge = ({ {newToolOutputInjections.length > 0 && ( {newToolOutputInjections.map((injection) => @@ -501,7 +512,7 @@ export const ContextBadge = ({ {newTaskCoordinationInjections.length > 0 && ( {newTaskCoordinationInjections.map((injection) => diff --git a/src/renderer/utils/contextTracker.ts b/src/renderer/utils/contextTracker.ts index 021afb5a..d9756145 100644 --- a/src/renderer/utils/contextTracker.ts +++ b/src/renderer/utils/contextTracker.ts @@ -836,13 +836,13 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats { newCounts.mentionedFiles++; break; case 'tool-output': - newCounts.toolOutputs++; + newCounts.toolOutputs += (injection).toolCount; break; case 'thinking-text': newCounts.thinkingText++; break; case 'task-coordination': - newCounts.taskCoordination++; + newCounts.taskCoordination += (injection).breakdown.length; break; case 'user-message': newCounts.userMessages++; From b883e411f29570abcf3296764c18d68baed0566c Mon Sep 17 00:00:00 2001 From: Cesar Augusto Fonseca Date: Sat, 21 Feb 2026 12:11:55 -0300 Subject: [PATCH 03/11] fix: increase macOS traffic light content gap for better title spacing --- src/shared/constants/trafficLights.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/constants/trafficLights.ts b/src/shared/constants/trafficLights.ts index 6c523c76..f7d0f6d9 100644 --- a/src/shared/constants/trafficLights.ts +++ b/src/shared/constants/trafficLights.ts @@ -22,7 +22,7 @@ const MACOS_TRAFFIC_LIGHT_GROUP_HEIGHT = 16; const MACOS_TRAFFIC_LIGHT_GROUP_WIDTH = 52; /** Visual gap between traffic lights and first left-aligned content */ -const MACOS_TRAFFIC_LIGHT_CONTENT_GAP = 8; +const MACOS_TRAFFIC_LIGHT_CONTENT_GAP = 16; const MIN_ZOOM_FACTOR = 0.25; From d3b7d9dfeb23d42df55c226925007f7c10f0e201 Mon Sep 17 00:00:00 2001 From: Paul Holstein <44263169+holstein13@users.noreply.github.com> Date: Sat, 21 Feb 2026 11:18:52 -0500 Subject: [PATCH 04/11] feat: add session export (Markdown, JSON, Plain Text) Add an export button to the TabBar header that lets users export the current session as Markdown, JSON, or Plain Text. The button appears between Search and Notifications, only for session tabs. - sessionExporter.ts: formatters for all three formats + download trigger - ExportDropdown.tsx: dropdown UI component with format selection - TabBar.tsx: integration with conditional rendering for session tabs - 51 new tests covering all formatters, edge cases, and download Co-Authored-By: Claude Opus 4.6 --- .../components/common/ExportDropdown.tsx | 142 ++++ src/renderer/components/layout/TabBar.tsx | 14 + src/renderer/utils/sessionExporter.ts | 427 +++++++++++ test/renderer/utils/sessionExporter.test.ts | 716 ++++++++++++++++++ 4 files changed, 1299 insertions(+) create mode 100644 src/renderer/components/common/ExportDropdown.tsx create mode 100644 src/renderer/utils/sessionExporter.ts create mode 100644 test/renderer/utils/sessionExporter.test.ts diff --git a/src/renderer/components/common/ExportDropdown.tsx b/src/renderer/components/common/ExportDropdown.tsx new file mode 100644 index 00000000..c35f71d9 --- /dev/null +++ b/src/renderer/components/common/ExportDropdown.tsx @@ -0,0 +1,142 @@ +/** + * ExportDropdown - Download icon button with dropdown for exporting session data. + * + * Supports three formats: Markdown (.md), JSON (.json), Plain Text (.txt). + * Follows the same close-on-outside-click / Escape patterns as RepositoryDropdown. + */ + +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import { triggerDownload } from '@renderer/utils/sessionExporter'; +import { Braces, Download, FileText, Type } from 'lucide-react'; + +import type { SessionDetail } from '@renderer/types/data'; +import type { ExportFormat } from '@renderer/utils/sessionExporter'; + +interface ExportDropdownProps { + sessionDetail: SessionDetail; +} + +interface FormatOption { + format: ExportFormat; + label: string; + icon: React.ComponentType<{ className?: string }>; + ext: string; +} + +const FORMAT_OPTIONS: FormatOption[] = [ + { format: 'markdown', label: 'Markdown', icon: FileText, ext: '.md' }, + { format: 'json', label: 'JSON', icon: Braces, ext: '.json' }, + { format: 'plaintext', label: 'Plain Text', icon: Type, ext: '.txt' }, +]; + +export const ExportDropdown = ({ + sessionDetail, +}: Readonly): React.JSX.Element => { + const [isOpen, setIsOpen] = useState(false); + const [buttonHover, setButtonHover] = useState(false); + const [hoveredFormat, setHoveredFormat] = useState(null); + const containerRef = useRef(null); + + // Close on outside click + useEffect(() => { + if (!isOpen) return; + + const handleClickOutside = (event: MouseEvent): void => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isOpen]); + + // Close on Escape + useEffect(() => { + if (!isOpen) return; + + const handleEscape = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + setIsOpen(false); + } + }; + + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isOpen]); + + const handleExport = useCallback( + (format: ExportFormat) => { + triggerDownload(sessionDetail, format); + setIsOpen(false); + }, + [sessionDetail] + ); + + return ( +
+ {/* Trigger button */} + + + {/* Dropdown menu */} + {isOpen && ( +
+ {/* Header */} +
+ Export Session +
+ + {/* Format options */} + {FORMAT_OPTIONS.map((option) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 6748a0a5..61915d35 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -17,6 +17,8 @@ import { useStore } from '@renderer/store'; import { Bell, PanelLeft, Plus, RefreshCw, Search, Settings } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; +import { ExportDropdown } from '../common/ExportDropdown'; + import { SortableTab } from './SortableTab'; import { TabContextMenu } from './TabContextMenu'; @@ -50,6 +52,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { pinnedSessionIds, toggleHideSession, hiddenSessionIds, + tabSessionData, } = useStore( useShallow((s) => ({ pane: s.paneLayout.panes.find((p) => p.id === paneId), @@ -76,6 +79,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { pinnedSessionIds: s.pinnedSessionIds, toggleHideSession: s.toggleHideSession, hiddenSessionIds: s.hiddenSessionIds, + tabSessionData: s.tabSessionData, })) ); @@ -89,6 +93,11 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { // Derive stable tab IDs array for SortableContext const tabIds = useMemo(() => openTabs.map((t) => t.id), [openTabs]); + // Derive session detail for the active tab (used by export dropdown) + const activeTabSessionDetail = activeTabId + ? (tabSessionData[activeTabId]?.sessionDetail ?? null) + : null; + // Hover states for buttons const [expandHover, setExpandHover] = useState(false); const [refreshHover, setRefreshHover] = useState(false); @@ -372,6 +381,11 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { + {/* Export dropdown - show only for session tabs with loaded data */} + {activeTab?.type === 'session' && activeTabSessionDetail && ( + + )} + {/* Notifications bell icon */}