diff --git a/src/main/ipc/config.ts b/src/main/ipc/config.ts index 28327d5b..f5d7a402 100644 --- a/src/main/ipc/config.ts +++ b/src/main/ipc/config.ts @@ -17,10 +17,7 @@ * - config:testTrigger: Test a trigger against historical session data */ -import { - getAutoDetectedClaudeBasePath, - getClaudeBasePath, -} from '@main/utils/pathDecoder'; +import { getAutoDetectedClaudeBasePath, getClaudeBasePath } from '@main/utils/pathDecoder'; import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; import { execFile } from 'child_process'; @@ -54,9 +51,8 @@ const execFileAsync = promisify(execFile); // Get singleton instance const configManager = ConfigManager.getInstance(); -let onClaudeRootPathUpdated: - | ((claudeRootPath: string | null) => Promise | void) - | null = null; +let onClaudeRootPathUpdated: ((claudeRootPath: string | null) => Promise | void) | null = + null; /** * Response type for config operations @@ -111,6 +107,12 @@ export function registerConfigHandlers(ipcMain: IpcMain): void { ipcMain.handle('config:pinSession', handlePinSession); ipcMain.handle('config:unpinSession', handleUnpinSession); + // Session hide handlers + ipcMain.handle('config:hideSession', handleHideSession); + ipcMain.handle('config:unhideSession', handleUnhideSession); + ipcMain.handle('config:hideSessions', handleHideSessions); + ipcMain.handle('config:unhideSessions', handleUnhideSessions); + // Dialog handlers ipcMain.handle('config:selectFolders', handleSelectFolders); ipcMain.handle('config:selectClaudeRootFolder', handleSelectClaudeRootFolder); @@ -789,9 +791,10 @@ function decodeWslOutput(output: string | Buffer | undefined): string { } const hasUtf16LeBom = output.length >= 2 && output[0] === 0xff && output[1] === 0xfe; - const decoded = hasUtf16LeBom || looksLikeUtf16Le(output) - ? output.toString('utf16le') - : output.toString('utf8'); + const decoded = + hasUtf16LeBom || looksLikeUtf16Le(output) + ? output.toString('utf16le') + : output.toString('utf8'); return decoded.replace(/\0/g, ''); } @@ -853,11 +856,7 @@ function parseWslDistros(stdout: string): string[] { } async function listWslDistros(): Promise { - const commands: string[][] = [ - ['--list', '--quiet'], - ['-l', '-q'], - ['-l'], - ]; + const commands: string[][] = [['--list', '--quiet'], ['-l', '-q'], ['-l']]; for (const command of commands) { try { @@ -885,10 +884,7 @@ function stripDefaultSuffix(input: string): string { async function resolveWslHome(distro: string): Promise { try { - const { stdout } = await runWsl( - ['-d', distro, '--', 'sh', '-lc', 'printf %s "$HOME"'], - 5000 - ); + const { stdout } = await runWsl(['-d', distro, '--', 'sh', '-lc', 'printf %s "$HOME"'], 5000); return normalizeWslHomePath(stdout); } catch { return null; @@ -958,6 +954,102 @@ async function handleFindWslClaudeRoots( } } +/** + * Handler for 'config:hideSession' - Hides a session for a project. + */ +async function handleHideSession( + _event: IpcMainInvokeEvent, + projectId: string, + sessionId: string +): Promise { + try { + if (!projectId || typeof projectId !== 'string') { + return { success: false, error: 'Project ID is required and must be a string' }; + } + if (!sessionId || typeof sessionId !== 'string') { + return { success: false, error: 'Session ID is required and must be a string' }; + } + + configManager.hideSession(projectId, sessionId); + return { success: true }; + } catch (error) { + logger.error('Error in config:hideSession:', error); + return { success: false, error: getErrorMessage(error) }; + } +} + +/** + * Handler for 'config:unhideSession' - Unhides a session for a project. + */ +async function handleUnhideSession( + _event: IpcMainInvokeEvent, + projectId: string, + sessionId: string +): Promise { + try { + if (!projectId || typeof projectId !== 'string') { + return { success: false, error: 'Project ID is required and must be a string' }; + } + if (!sessionId || typeof sessionId !== 'string') { + return { success: false, error: 'Session ID is required and must be a string' }; + } + + configManager.unhideSession(projectId, sessionId); + return { success: true }; + } catch (error) { + logger.error('Error in config:unhideSession:', error); + return { success: false, error: getErrorMessage(error) }; + } +} + +/** + * Handler for 'config:hideSessions' - Bulk hide sessions for a project. + */ +async function handleHideSessions( + _event: IpcMainInvokeEvent, + projectId: string, + sessionIds: string[] +): Promise { + try { + if (!projectId || typeof projectId !== 'string') { + return { success: false, error: 'Project ID is required and must be a string' }; + } + if (!Array.isArray(sessionIds) || sessionIds.some((id) => typeof id !== 'string')) { + return { success: false, error: 'Session IDs must be an array of strings' }; + } + + configManager.hideSessions(projectId, sessionIds); + return { success: true }; + } catch (error) { + logger.error('Error in config:hideSessions:', error); + return { success: false, error: getErrorMessage(error) }; + } +} + +/** + * Handler for 'config:unhideSessions' - Bulk unhide sessions for a project. + */ +async function handleUnhideSessions( + _event: IpcMainInvokeEvent, + projectId: string, + sessionIds: string[] +): Promise { + try { + if (!projectId || typeof projectId !== 'string') { + return { success: false, error: 'Project ID is required and must be a string' }; + } + if (!Array.isArray(sessionIds) || sessionIds.some((id) => typeof id !== 'string')) { + return { success: false, error: 'Session IDs must be an array of strings' }; + } + + configManager.unhideSessions(projectId, sessionIds); + return { success: true }; + } catch (error) { + logger.error('Error in config:unhideSessions:', error); + return { success: false, error: getErrorMessage(error) }; + } +} + // ============================================================================= // Cleanup // ============================================================================= @@ -982,6 +1074,10 @@ export function removeConfigHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler('config:testTrigger'); ipcMain.removeHandler('config:pinSession'); ipcMain.removeHandler('config:unpinSession'); + ipcMain.removeHandler('config:hideSession'); + ipcMain.removeHandler('config:unhideSession'); + ipcMain.removeHandler('config:hideSessions'); + ipcMain.removeHandler('config:unhideSessions'); ipcMain.removeHandler('config:selectFolders'); ipcMain.removeHandler('config:selectClaudeRootFolder'); ipcMain.removeHandler('config:getClaudeRootInfo'); diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index e7984bf0..b8999536 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -191,6 +191,7 @@ export interface DisplayConfig { export interface SessionsConfig { pinnedSessions: Record; + hiddenSessions: Record; } export interface SshPersistConfig { @@ -255,6 +256,7 @@ const DEFAULT_CONFIG: AppConfig = { }, sessions: { pinnedSessions: {}, + hiddenSessions: {}, }, ssh: { lastConnection: null, @@ -746,6 +748,89 @@ export class ConfigManager { this.saveConfig(); } + // =========================================================================== + // Session Hide Management + // =========================================================================== + + /** + * Hides a session for a project. + * @param projectId - The project ID + * @param sessionId - The session ID to hide + */ + hideSession(projectId: string, sessionId: string): void { + const hidden = this.config.sessions.hiddenSessions[projectId] ?? []; + + if (hidden.some((h) => h.sessionId === sessionId)) { + return; + } + + this.config.sessions.hiddenSessions[projectId] = [ + { sessionId, hiddenAt: Date.now() }, + ...hidden, + ]; + this.saveConfig(); + } + + /** + * Unhides a session for a project. + * @param projectId - The project ID + * @param sessionId - The session ID to unhide + */ + unhideSession(projectId: string, sessionId: string): void { + const hidden = this.config.sessions.hiddenSessions[projectId]; + if (!hidden) return; + + this.config.sessions.hiddenSessions[projectId] = hidden.filter( + (h) => h.sessionId !== sessionId + ); + + if (this.config.sessions.hiddenSessions[projectId].length === 0) { + delete this.config.sessions.hiddenSessions[projectId]; + } + + this.saveConfig(); + } + + /** + * Hides multiple sessions for a project in a single write. + * @param projectId - The project ID + * @param sessionIds - The session IDs to hide + */ + hideSessions(projectId: string, sessionIds: string[]): void { + const hidden = this.config.sessions.hiddenSessions[projectId] ?? []; + const existingIds = new Set(hidden.map((h) => h.sessionId)); + const now = Date.now(); + const newEntries = sessionIds + .filter((id) => !existingIds.has(id)) + .map((sessionId) => ({ sessionId, hiddenAt: now })); + + if (newEntries.length === 0) return; + + this.config.sessions.hiddenSessions[projectId] = [...newEntries, ...hidden]; + this.saveConfig(); + } + + /** + * Unhides multiple sessions for a project in a single write. + * @param projectId - The project ID + * @param sessionIds - The session IDs to unhide + */ + unhideSessions(projectId: string, sessionIds: string[]): void { + const hidden = this.config.sessions.hiddenSessions[projectId]; + if (!hidden) return; + + const toRemove = new Set(sessionIds); + this.config.sessions.hiddenSessions[projectId] = hidden.filter( + (h) => !toRemove.has(h.sessionId) + ); + + if (this.config.sessions.hiddenSessions[projectId].length === 0) { + delete this.config.sessions.hiddenSessions[projectId]; + } + + this.saveConfig(); + } + // =========================================================================== // SSH Profile Management // =========================================================================== diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 8d730cd4..eec0bc05 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -68,6 +68,18 @@ export const CONFIG_PIN_SESSION = 'config:pinSession'; /** Unpin a session */ export const CONFIG_UNPIN_SESSION = 'config:unpinSession'; +/** Hide a session */ +export const CONFIG_HIDE_SESSION = 'config:hideSession'; + +/** Unhide a session */ +export const CONFIG_UNHIDE_SESSION = 'config:unhideSession'; + +/** Bulk hide sessions */ +export const CONFIG_HIDE_SESSIONS = 'config:hideSessions'; + +/** Bulk unhide sessions */ +export const CONFIG_UNHIDE_SESSIONS = 'config:unhideSessions'; + // ============================================================================= // SSH API Channels // ============================================================================= diff --git a/src/preload/index.ts b/src/preload/index.ts index 0eb5e6b9..5f59e1c8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -36,6 +36,8 @@ import { CONFIG_GET, CONFIG_GET_CLAUDE_ROOT_INFO, CONFIG_GET_TRIGGERS, + CONFIG_HIDE_SESSION, + CONFIG_HIDE_SESSIONS, CONFIG_OPEN_IN_EDITOR, CONFIG_PIN_SESSION, CONFIG_REMOVE_IGNORE_REGEX, @@ -45,6 +47,8 @@ import { CONFIG_SELECT_FOLDERS, CONFIG_SNOOZE, CONFIG_TEST_TRIGGER, + CONFIG_UNHIDE_SESSION, + CONFIG_UNHIDE_SESSIONS, CONFIG_UNPIN_SESSION, CONFIG_UPDATE, CONFIG_UPDATE_TRIGGER, @@ -292,6 +296,18 @@ const electronAPI: ElectronAPI = { unpinSession: async (projectId: string, sessionId: string): Promise => { return invokeIpcWithResult(CONFIG_UNPIN_SESSION, projectId, sessionId); }, + hideSession: async (projectId: string, sessionId: string): Promise => { + return invokeIpcWithResult(CONFIG_HIDE_SESSION, projectId, sessionId); + }, + unhideSession: async (projectId: string, sessionId: string): Promise => { + return invokeIpcWithResult(CONFIG_UNHIDE_SESSION, projectId, sessionId); + }, + hideSessions: async (projectId: string, sessionIds: string[]): Promise => { + return invokeIpcWithResult(CONFIG_HIDE_SESSIONS, projectId, sessionIds); + }, + unhideSessions: async (projectId: string, sessionIds: string[]): Promise => { + return invokeIpcWithResult(CONFIG_UNHIDE_SESSIONS, projectId, sessionIds); + }, }, // Deep link navigation diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index b5389dcf..0d038926 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -436,6 +436,14 @@ export class HttpAPIClient implements ElectronAPI { this.post('/api/config/pin-session', { projectId, sessionId }), unpinSession: (projectId: string, sessionId: string): Promise => this.post('/api/config/unpin-session', { projectId, sessionId }), + hideSession: (projectId: string, sessionId: string): Promise => + this.post('/api/config/hide-session', { projectId, sessionId }), + unhideSession: (projectId: string, sessionId: string): Promise => + this.post('/api/config/unhide-session', { projectId, sessionId }), + hideSessions: (projectId: string, sessionIds: string[]): Promise => + this.post('/api/config/hide-sessions', { projectId, sessionIds }), + unhideSessions: (projectId: string, sessionIds: string[]): Promise => + this.post('/api/config/unhide-sessions', { projectId, sessionIds }), }; // --------------------------------------------------------------------------- diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 26663710..25726017 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -48,6 +48,8 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { splitPane, togglePinSession, pinnedSessionIds, + toggleHideSession, + hiddenSessionIds, } = useStore( useShallow((s) => ({ pane: s.paneLayout.panes.find((p) => p.id === paneId), @@ -72,6 +74,8 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { splitPane: s.splitPane, togglePinSession: s.togglePinSession, pinnedSessionIds: s.pinnedSessionIds, + toggleHideSession: s.toggleHideSession, + hiddenSessionIds: s.hiddenSessionIds, })) ); @@ -235,6 +239,10 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { isContextMenuTabSession && contextMenuTab?.sessionId ? pinnedSessionIds.includes(contextMenuTab.sessionId) : false; + const isContextMenuTabHidden = + isContextMenuTabSession && contextMenuTab?.sessionId + ? hiddenSessionIds.includes(contextMenuTab.sessionId) + : false; // Show sidebar expand button only in the leftmost pane const isLeftmostPane = useStore( @@ -427,6 +435,12 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { ? () => togglePinSession(contextMenuTab.sessionId!) : undefined } + isHidden={isContextMenuTabHidden} + onToggleHide={ + isContextMenuTabSession && contextMenuTab?.sessionId + ? () => toggleHideSession(contextMenuTab.sessionId!) + : undefined + } /> )} diff --git a/src/renderer/components/layout/TabContextMenu.tsx b/src/renderer/components/layout/TabContextMenu.tsx index dd7165ea..cd3fedf8 100644 --- a/src/renderer/components/layout/TabContextMenu.tsx +++ b/src/renderer/components/layout/TabContextMenu.tsx @@ -27,6 +27,10 @@ interface TabContextMenuProps { isPinned?: boolean; /** Callback to toggle pin state */ onTogglePin?: () => void; + /** Whether this session is currently hidden from the sidebar */ + isHidden?: boolean; + /** Callback to toggle hide state */ + onToggleHide?: () => void; } export const TabContextMenu = ({ @@ -44,6 +48,8 @@ export const TabContextMenu = ({ isSessionTab, isPinned, onTogglePin, + isHidden, + onToggleHide, }: TabContextMenuProps): React.JSX.Element => { const menuRef = useRef(null); @@ -114,6 +120,12 @@ export const TabContextMenu = ({ /> )} + {isSessionTab && onToggleHide && ( + + )}
diff --git a/src/renderer/components/settings/hooks/useSettingsHandlers.ts b/src/renderer/components/settings/hooks/useSettingsHandlers.ts index e24add0c..5d6941b0 100644 --- a/src/renderer/components/settings/hooks/useSettingsHandlers.ts +++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts @@ -295,6 +295,7 @@ export function useSettingsHandlers({ }, sessions: { pinnedSessions: {}, + hiddenSessions: {}, }, }; diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx index 45aa32e1..62ee6f4c 100644 --- a/src/renderer/components/sidebar/DateGroupedSessions.tsx +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -1,6 +1,7 @@ /** * DateGroupedSessions - Sessions organized by date categories with virtual scrolling. * Uses @tanstack/react-virtual for efficient DOM rendering with infinite scroll. + * Supports multi-select with bulk actions and hidden session filtering. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -13,7 +14,17 @@ import { separatePinnedSessions, } from '@renderer/utils/dateGrouping'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { ArrowDownWideNarrow, Calendar, Loader2, MessageSquareOff, Pin } from 'lucide-react'; +import { + ArrowDownWideNarrow, + Calendar, + CheckSquare, + Eye, + EyeOff, + Loader2, + MessageSquareOff, + Pin, + X, +} from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { SessionItem } from './SessionItem'; @@ -25,7 +36,7 @@ import type { DateCategory } from '@renderer/types/tabs'; type VirtualItem = | { type: 'header'; category: DateCategory; id: string } | { type: 'pinned-header'; id: string } - | { type: 'session'; session: Session; isPinned: boolean; id: string } + | { type: 'session'; session: Session; isPinned: boolean; isHidden: boolean; id: string } | { type: 'loader'; id: string }; /** @@ -52,6 +63,17 @@ export const DateGroupedSessions = (): React.JSX.Element => { pinnedSessionIds, sessionSortMode, setSessionSortMode, + hiddenSessionIds, + showHiddenSessions, + toggleShowHiddenSessions, + sidebarSelectedSessionIds, + sidebarMultiSelectActive, + toggleSidebarSessionSelection, + clearSidebarSelection, + toggleSidebarMultiSelect, + hideMultipleSessions, + unhideMultipleSessions, + pinMultipleSessions, } = useStore( useShallow((s) => ({ sessions: s.sessions, @@ -65,6 +87,17 @@ export const DateGroupedSessions = (): React.JSX.Element => { pinnedSessionIds: s.pinnedSessionIds, sessionSortMode: s.sessionSortMode, setSessionSortMode: s.setSessionSortMode, + hiddenSessionIds: s.hiddenSessionIds, + showHiddenSessions: s.showHiddenSessions, + toggleShowHiddenSessions: s.toggleShowHiddenSessions, + sidebarSelectedSessionIds: s.sidebarSelectedSessionIds, + sidebarMultiSelectActive: s.sidebarMultiSelectActive, + toggleSidebarSessionSelection: s.toggleSidebarSessionSelection, + clearSidebarSelection: s.clearSidebarSelection, + toggleSidebarMultiSelect: s.toggleSidebarMultiSelect, + hideMultipleSessions: s.hideMultipleSessions, + unhideMultipleSessions: s.unhideMultipleSessions, + pinMultipleSessions: s.pinMultipleSessions, })) ); @@ -72,10 +105,19 @@ export const DateGroupedSessions = (): React.JSX.Element => { const countRef = useRef(null); const [showCountTooltip, setShowCountTooltip] = useState(false); + const hiddenSet = useMemo(() => new Set(hiddenSessionIds), [hiddenSessionIds]); + const hasHiddenSessions = hiddenSessionIds.length > 0; + + // Filter out hidden sessions unless showHiddenSessions is on + const visibleSessions = useMemo(() => { + if (showHiddenSessions) return sessions; + return sessions.filter((s) => !hiddenSet.has(s.id)); + }, [sessions, hiddenSet, showHiddenSessions]); + // Separate pinned sessions from unpinned const { pinned: pinnedSessions, unpinned: unpinnedSessions } = useMemo( - () => separatePinnedSessions(sessions, pinnedSessionIds), - [sessions, pinnedSessionIds] + () => separatePinnedSessions(visibleSessions, pinnedSessionIds), + [visibleSessions, pinnedSessionIds] ); // Group only unpinned sessions by date @@ -90,8 +132,10 @@ export const DateGroupedSessions = (): React.JSX.Element => { // Sessions sorted by context consumption (for most-context sort mode) const contextSortedSessions = useMemo(() => { if (sessionSortMode !== 'most-context') return []; - return [...sessions].sort((a, b) => (b.contextConsumption ?? 0) - (a.contextConsumption ?? 0)); - }, [sessions, sessionSortMode]); + return [...visibleSessions].sort( + (a, b) => (b.contextConsumption ?? 0) - (a.contextConsumption ?? 0) + ); + }, [visibleSessions, sessionSortMode]); // Flatten sessions with date headers into virtual list items const virtualItems = useMemo((): VirtualItem[] => { @@ -104,6 +148,7 @@ export const DateGroupedSessions = (): React.JSX.Element => { type: 'session', session, isPinned: pinnedSessionIds.includes(session.id), + isHidden: hiddenSet.has(session.id), id: `session-${session.id}`, }); } @@ -120,6 +165,7 @@ export const DateGroupedSessions = (): React.JSX.Element => { type: 'session', session, isPinned: true, + isHidden: hiddenSet.has(session.id), id: `session-${session.id}`, }); } @@ -137,6 +183,7 @@ export const DateGroupedSessions = (): React.JSX.Element => { type: 'session', session, isPinned: false, + isHidden: hiddenSet.has(session.id), id: `session-${session.id}`, }); } @@ -156,6 +203,7 @@ export const DateGroupedSessions = (): React.JSX.Element => { sessionSortMode, contextSortedSessions, pinnedSessionIds, + hiddenSet, pinnedSessions, nonEmptyCategories, groupedSessions, @@ -221,6 +269,32 @@ export const DateGroupedSessions = (): React.JSX.Element => { fetchSessionsMore, ]); + // Bulk action helpers + const selectedSet = useMemo( + () => new Set(sidebarSelectedSessionIds), + [sidebarSelectedSessionIds] + ); + const someSelectedAreHidden = useMemo( + () => sidebarSelectedSessionIds.some((id) => hiddenSet.has(id)), + [sidebarSelectedSessionIds, hiddenSet] + ); + + const handleBulkHide = useCallback(() => { + void hideMultipleSessions(sidebarSelectedSessionIds); + clearSidebarSelection(); + }, [hideMultipleSessions, sidebarSelectedSessionIds, clearSidebarSelection]); + + const handleBulkUnhide = useCallback(() => { + const hiddenSelected = sidebarSelectedSessionIds.filter((id) => hiddenSet.has(id)); + void unhideMultipleSessions(hiddenSelected); + clearSidebarSelection(); + }, [unhideMultipleSessions, sidebarSelectedSessionIds, hiddenSet, clearSidebarSelection]); + + const handleBulkPin = useCallback(() => { + void pinMultipleSessions(sidebarSelectedSessionIds); + clearSidebarSelection(); + }, [pinMultipleSessions, sidebarSelectedSessionIds, clearSidebarSelection]); + if (!selectedProjectId) { return (
@@ -337,19 +411,100 @@ export const DateGroupedSessions = (): React.JSX.Element => {
, document.body )} - + {/* Show hidden sessions toggle - only when hidden sessions exist */} + {hasHiddenSessions && ( + + )} + {/* Sort mode toggle */} + + + + + {/* Bulk action bar - shown when sessions are selected */} + {sidebarMultiSelectActive && sidebarSelectedSessionIds.length > 0 && ( +
- - -
+ + {sidebarSelectedSessionIds.length} selected + +
+ + + {showHiddenSessions && someSelectedAreHidden && ( + + )} + +
+ + )}
{ session={item.session} isActive={selectedSessionId === item.session.id} isPinned={item.isPinned} + isHidden={item.isHidden} + multiSelectActive={sidebarMultiSelectActive} + isSelected={selectedSet.has(item.session.id)} + onToggleSelect={() => toggleSidebarSessionSelection(item.session.id)} /> )}
diff --git a/src/renderer/components/sidebar/SessionContextMenu.tsx b/src/renderer/components/sidebar/SessionContextMenu.tsx index 37acea03..9aa1073b 100644 --- a/src/renderer/components/sidebar/SessionContextMenu.tsx +++ b/src/renderer/components/sidebar/SessionContextMenu.tsx @@ -7,7 +7,7 @@ import { useEffect, useRef } from 'react'; import { MAX_PANES } from '@renderer/types/panes'; -import { Pin, PinOff } from 'lucide-react'; +import { Eye, EyeOff, Pin, PinOff } from 'lucide-react'; interface SessionContextMenuProps { x: number; @@ -17,11 +17,13 @@ interface SessionContextMenuProps { sessionLabel: string; paneCount: number; isPinned: boolean; + isHidden: boolean; onClose: () => void; onOpenInCurrentPane: () => void; onOpenInNewTab: () => void; onSplitRightAndOpen: () => void; onTogglePin: () => void; + onToggleHide: () => void; } export const SessionContextMenu = ({ @@ -29,11 +31,13 @@ export const SessionContextMenu = ({ y, paneCount, isPinned, + isHidden, onClose, onOpenInCurrentPane, onOpenInNewTab, onSplitRightAndOpen, onTogglePin, + onToggleHide, }: SessionContextMenuProps): React.JSX.Element => { const menuRef = useRef(null); @@ -55,7 +59,7 @@ export const SessionContextMenu = ({ }, [onClose]); const menuWidth = 240; - const menuHeight = 180; + const menuHeight = 204; const clampedX = Math.min(x, window.innerWidth - menuWidth - 8); const clampedY = Math.min(y, window.innerHeight - menuHeight - 8); @@ -92,6 +96,11 @@ export const SessionContextMenu = ({ icon={isPinned ? : } onClick={handleClick(onTogglePin)} /> + : } + onClick={handleClick(onToggleHide)} + />
); }; diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx index 6345765d..2a4be156 100644 --- a/src/renderer/components/sidebar/SessionItem.tsx +++ b/src/renderer/components/sidebar/SessionItem.tsx @@ -10,7 +10,7 @@ import { createPortal } from 'react-dom'; import { useStore } from '@renderer/store'; import { formatTokensCompact } from '@shared/utils/tokenFormatting'; import { formatDistanceToNowStrict } from 'date-fns'; -import { MessageSquare, Pin } from 'lucide-react'; +import { EyeOff, MessageSquare, Pin } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { OngoingIndicator } from '../common/OngoingIndicator'; @@ -23,6 +23,10 @@ interface SessionItemProps { session: Session; isActive?: boolean; isPinned?: boolean; + isHidden?: boolean; + multiSelectActive?: boolean; + isSelected?: boolean; + onToggleSelect?: () => void; } /** @@ -112,7 +116,7 @@ const ConsumptionBadge = ({ {formatTokensCompact(phase.contribution)} {phase.postCompaction != null && ( - (compacted → {formatTokensCompact(phase.postCompaction)}) + (compacted to {formatTokensCompact(phase.postCompaction)}) )} @@ -129,24 +133,42 @@ export const SessionItem = ({ session, isActive, isPinned, + isHidden, + multiSelectActive, + isSelected, + onToggleSelect, }: Readonly): React.JSX.Element => { - const { openTab, activeProjectId, selectSession, paneCount, splitPane, togglePinSession } = - useStore( - useShallow((s) => ({ - openTab: s.openTab, - activeProjectId: s.activeProjectId, - selectSession: s.selectSession, - paneCount: s.paneLayout.panes.length, - splitPane: s.splitPane, - togglePinSession: s.togglePinSession, - })) - ); + const { + openTab, + activeProjectId, + selectSession, + paneCount, + splitPane, + togglePinSession, + toggleHideSession, + } = useStore( + useShallow((s) => ({ + openTab: s.openTab, + activeProjectId: s.activeProjectId, + selectSession: s.selectSession, + paneCount: s.paneLayout.panes.length, + splitPane: s.splitPane, + togglePinSession: s.togglePinSession, + toggleHideSession: s.toggleHideSession, + })) + ); const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const handleClick = (event: React.MouseEvent): void => { if (!activeProjectId) return; + // In multi-select mode, clicks toggle selection + if (multiSelectActive && onToggleSelect) { + onToggleSelect(); + return; + } + // Cmd/Ctrl+click: open in new tab; plain click: replace current tab const forceNewTab = event.ctrlKey || event.metaKey; @@ -227,12 +249,23 @@ export const SessionItem = ({ style={{ borderColor: 'var(--color-border)', ...(isActive ? { backgroundColor: 'var(--color-surface-raised)' } : {}), + ...(isHidden ? { opacity: 0.5 } : {}), }} > - {/* First line: title + ongoing indicator + pin icon */} + {/* First line: title + ongoing indicator + pin/hidden icons */}
+ {multiSelectActive && ( + onToggleSelect?.()} + onClick={(e) => e.stopPropagation()} + className="size-3.5 shrink-0 accent-blue-500" + /> + )} {session.isOngoing && } {isPinned && } + {isHidden && } setContextMenu(null)} onOpenInCurrentPane={handleOpenInCurrentPane} onOpenInNewTab={handleOpenInNewTab} onSplitRightAndOpen={handleSplitRightAndOpen} onTogglePin={() => void togglePinSession(session.id)} + onToggleHide={() => void toggleHideSession(session.id)} />, document.body )} diff --git a/src/renderer/store/slices/sessionSlice.ts b/src/renderer/store/slices/sessionSlice.ts index a7632c47..fe1862ad 100644 --- a/src/renderer/store/slices/sessionSlice.ts +++ b/src/renderer/store/slices/sessionSlice.ts @@ -34,6 +34,12 @@ export interface SessionSlice { sessionsLoadingMore: boolean; // Pinned sessions pinnedSessionIds: string[]; + // Hidden sessions + hiddenSessionIds: string[]; + showHiddenSessions: boolean; + // Multi-select + sidebarSelectedSessionIds: string[]; + sidebarMultiSelectActive: boolean; // Sort mode sessionSortMode: SessionSortMode; @@ -52,6 +58,24 @@ export interface SessionSlice { loadPinnedSessions: () => Promise; /** Set session sort mode */ setSessionSortMode: (mode: SessionSortMode) => void; + /** Toggle hide/unhide for a session */ + toggleHideSession: (sessionId: string) => Promise; + /** Bulk hide sessions */ + hideMultipleSessions: (sessionIds: string[]) => Promise; + /** Bulk unhide sessions */ + unhideMultipleSessions: (sessionIds: string[]) => Promise; + /** Load hidden sessions from config for current project */ + loadHiddenSessions: () => Promise; + /** Toggle showing hidden sessions in sidebar */ + toggleShowHiddenSessions: () => void; + /** Toggle one session's checkbox in sidebar multi-select */ + toggleSidebarSessionSelection: (sessionId: string) => void; + /** Clear all selections and exit multi-select mode */ + clearSidebarSelection: () => void; + /** Enter/exit selection mode */ + toggleSidebarMultiSelect: () => void; + /** Bulk pin for multi-select */ + pinMultipleSessions: (sessionIds: string[]) => Promise; } // ============================================================================= @@ -71,6 +95,12 @@ export const createSessionSlice: StateCreator = sessionsLoadingMore: false, // Pinned sessions pinnedSessionIds: [], + // Hidden sessions + hiddenSessionIds: [], + showHiddenSessions: false, + // Multi-select + sidebarSelectedSessionIds: [], + sidebarMultiSelectActive: false, // Sort mode sessionSortMode: 'recent' as SessionSortMode, @@ -115,8 +145,9 @@ export const createSessionSlice: StateCreator = sessionsLoading: false, }); - // Load pinned sessions after fetching session list + // Load pinned and hidden sessions after fetching session list void get().loadPinnedSessions(); + void get().loadHiddenSessions(); } catch (error) { set({ sessionsError: error instanceof Error ? error.message : 'Failed to fetch sessions', @@ -328,4 +359,150 @@ export const createSessionSlice: StateCreator = setSessionSortMode: (mode: SessionSortMode) => { set({ sessionSortMode: mode }); }, + + // Toggle hide/unhide for a session (optimistic update) + toggleHideSession: async (sessionId: string) => { + const state = get(); + const projectId = state.selectedProjectId; + if (!projectId) return; + + const isHidden = state.hiddenSessionIds.includes(sessionId); + const previousHiddenIds = state.hiddenSessionIds; + + // Optimistic: update UI immediately + if (isHidden) { + set({ hiddenSessionIds: previousHiddenIds.filter((id) => id !== sessionId) }); + } else { + set({ hiddenSessionIds: [sessionId, ...previousHiddenIds] }); + } + + try { + if (isHidden) { + await api.config.unhideSession(projectId, sessionId); + } else { + await api.config.hideSession(projectId, sessionId); + } + } catch (error) { + // Rollback on failure + set({ hiddenSessionIds: previousHiddenIds }); + logger.error('toggleHideSession error:', error); + } + }, + + // Bulk hide sessions + hideMultipleSessions: async (sessionIds: string[]) => { + const state = get(); + const projectId = state.selectedProjectId; + if (!projectId || sessionIds.length === 0) return; + + const previousHiddenIds = state.hiddenSessionIds; + const existingSet = new Set(previousHiddenIds); + const newIds = sessionIds.filter((id) => !existingSet.has(id)); + + // Optimistic update + set({ hiddenSessionIds: [...newIds, ...previousHiddenIds] }); + + try { + await api.config.hideSessions(projectId, sessionIds); + } catch (error) { + set({ hiddenSessionIds: previousHiddenIds }); + logger.error('hideMultipleSessions error:', error); + } + }, + + // Bulk unhide sessions + unhideMultipleSessions: async (sessionIds: string[]) => { + const state = get(); + const projectId = state.selectedProjectId; + if (!projectId || sessionIds.length === 0) return; + + const previousHiddenIds = state.hiddenSessionIds; + const toRemove = new Set(sessionIds); + + // Optimistic update + set({ hiddenSessionIds: previousHiddenIds.filter((id) => !toRemove.has(id)) }); + + try { + await api.config.unhideSessions(projectId, sessionIds); + } catch (error) { + set({ hiddenSessionIds: previousHiddenIds }); + logger.error('unhideMultipleSessions error:', error); + } + }, + + // Load hidden sessions from config for current project + loadHiddenSessions: async () => { + const state = get(); + const projectId = state.selectedProjectId; + if (!projectId) { + set({ hiddenSessionIds: [] }); + return; + } + + try { + const config = await api.config.get(); + const hidden = config.sessions?.hiddenSessions?.[projectId] ?? []; + const hiddenIds = hidden.map((h) => h.sessionId); + set({ hiddenSessionIds: hiddenIds }); + } catch (error) { + logger.error('loadHiddenSessions error:', error); + set({ hiddenSessionIds: [] }); + } + }, + + // Toggle showing hidden sessions in sidebar + toggleShowHiddenSessions: () => { + set((prev) => ({ showHiddenSessions: !prev.showHiddenSessions })); + }, + + // Toggle one session's checkbox in sidebar multi-select + toggleSidebarSessionSelection: (sessionId: string) => { + set((prev) => { + const selected = prev.sidebarSelectedSessionIds; + if (selected.includes(sessionId)) { + return { sidebarSelectedSessionIds: selected.filter((id) => id !== sessionId) }; + } + return { + sidebarSelectedSessionIds: [...selected, sessionId], + sidebarMultiSelectActive: true, + }; + }); + }, + + // Clear all selections and exit multi-select mode + clearSidebarSelection: () => { + set({ sidebarSelectedSessionIds: [], sidebarMultiSelectActive: false }); + }, + + // Enter/exit selection mode + toggleSidebarMultiSelect: () => { + set((prev) => { + if (prev.sidebarMultiSelectActive) { + return { sidebarMultiSelectActive: false, sidebarSelectedSessionIds: [] }; + } + return { sidebarMultiSelectActive: true }; + }); + }, + + // Bulk pin for multi-select + pinMultipleSessions: async (sessionIds: string[]) => { + const state = get(); + const projectId = state.selectedProjectId; + if (!projectId || sessionIds.length === 0) return; + + const previousPinnedIds = state.pinnedSessionIds; + const existingSet = new Set(previousPinnedIds); + const newIds = sessionIds.filter((id) => !existingSet.has(id)); + + // Optimistic update + set({ pinnedSessionIds: [...newIds, ...previousPinnedIds] }); + + try { + // Pin each session individually (no bulk pin IPC) + await Promise.all(newIds.map((sessionId) => api.config.pinSession(projectId, sessionId))); + } catch (error) { + set({ pinnedSessionIds: previousPinnedIds }); + logger.error('pinMultipleSessions error:', error); + } + }, }); diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 47a00364..4d405b02 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -99,6 +99,14 @@ export interface ConfigAPI { pinSession: (projectId: string, sessionId: string) => Promise; /** Unpin a session for a project */ unpinSession: (projectId: string, sessionId: string) => Promise; + /** Hide a session for a project */ + hideSession: (projectId: string, sessionId: string) => Promise; + /** Unhide a session for a project */ + unhideSession: (projectId: string, sessionId: string) => Promise; + /** Bulk hide sessions for a project */ + hideSessions: (projectId: string, sessionIds: string[]) => Promise; + /** Bulk unhide sessions for a project */ + unhideSessions: (projectId: string, sessionIds: string[]) => Promise; } export interface ClaudeRootInfo { diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts index 84a133dc..245663ab 100644 --- a/src/shared/types/notifications.ts +++ b/src/shared/types/notifications.ts @@ -276,6 +276,8 @@ export interface AppConfig { sessions: { /** Pinned sessions per project. Key is projectId, value is array of pinned sessions */ pinnedSessions: Record; + /** Hidden sessions per project. Key is projectId, value is array of hidden sessions */ + hiddenSessions: Record; }; /** SSH connection settings */ ssh?: {