Merge pull request #11 from matt1398/feat/hide-sessions

feat(sessions): implement session hiding and un-hiding functionality
This commit is contained in:
matt 2026-02-16 21:40:58 +09:00 committed by GitHub
commit 21d4e1c98e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 686 additions and 52 deletions

View file

@ -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> | void)
| null = null;
let onClaudeRootPathUpdated: ((claudeRootPath: string | null) => Promise<void> | 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<string[]> {
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<string | null> {
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<ConfigResult> {
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<ConfigResult> {
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<ConfigResult> {
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<ConfigResult> {
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');

View file

@ -191,6 +191,7 @@ export interface DisplayConfig {
export interface SessionsConfig {
pinnedSessions: Record<string, { sessionId: string; pinnedAt: number }[]>;
hiddenSessions: Record<string, { sessionId: string; hiddenAt: number }[]>;
}
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
// ===========================================================================

View file

@ -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
// =============================================================================

View file

@ -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<void> => {
return invokeIpcWithResult<void>(CONFIG_UNPIN_SESSION, projectId, sessionId);
},
hideSession: async (projectId: string, sessionId: string): Promise<void> => {
return invokeIpcWithResult<void>(CONFIG_HIDE_SESSION, projectId, sessionId);
},
unhideSession: async (projectId: string, sessionId: string): Promise<void> => {
return invokeIpcWithResult<void>(CONFIG_UNHIDE_SESSION, projectId, sessionId);
},
hideSessions: async (projectId: string, sessionIds: string[]): Promise<void> => {
return invokeIpcWithResult<void>(CONFIG_HIDE_SESSIONS, projectId, sessionIds);
},
unhideSessions: async (projectId: string, sessionIds: string[]): Promise<void> => {
return invokeIpcWithResult<void>(CONFIG_UNHIDE_SESSIONS, projectId, sessionIds);
},
},
// Deep link navigation

View file

@ -436,6 +436,14 @@ export class HttpAPIClient implements ElectronAPI {
this.post('/api/config/pin-session', { projectId, sessionId }),
unpinSession: (projectId: string, sessionId: string): Promise<void> =>
this.post('/api/config/unpin-session', { projectId, sessionId }),
hideSession: (projectId: string, sessionId: string): Promise<void> =>
this.post('/api/config/hide-session', { projectId, sessionId }),
unhideSession: (projectId: string, sessionId: string): Promise<void> =>
this.post('/api/config/unhide-session', { projectId, sessionId }),
hideSessions: (projectId: string, sessionIds: string[]): Promise<void> =>
this.post('/api/config/hide-sessions', { projectId, sessionIds }),
unhideSessions: (projectId: string, sessionIds: string[]): Promise<void> =>
this.post('/api/config/unhide-sessions', { projectId, sessionIds }),
};
// ---------------------------------------------------------------------------

View file

@ -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
}
/>
)}
</div>

View file

@ -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<HTMLDivElement>(null);
@ -114,6 +120,12 @@ export const TabContextMenu = ({
/>
</>
)}
{isSessionTab && onToggleHide && (
<MenuItem
label={isHidden ? 'Unhide from Sidebar' : 'Hide from Sidebar'}
onClick={handleClick(onToggleHide)}
/>
)}
<div className="mx-2 my-1 border-t" style={{ borderColor: 'var(--color-border)' }} />
<MenuItem label="Close All Tabs" shortcut="⇧⌘W" onClick={handleClick(onCloseAllTabs)} />
</div>

View file

@ -295,6 +295,7 @@ export function useSettingsHandlers({
},
sessions: {
pinnedSessions: {},
hiddenSessions: {},
},
};

View file

@ -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<HTMLSpanElement>(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 (
<div className="p-4">
@ -337,19 +411,100 @@ export const DateGroupedSessions = (): React.JSX.Element => {
</div>,
document.body
)}
<button
onClick={() =>
setSessionSortMode(sessionSortMode === 'recent' ? 'most-context' : 'recent')
}
className="ml-auto rounded p-1 transition-colors hover:bg-white/5"
title={sessionSortMode === 'recent' ? 'Sort by context consumption' : 'Sort by recent'}
<div className="ml-auto flex items-center gap-0.5">
{/* Multi-select toggle */}
<button
onClick={toggleSidebarMultiSelect}
className="rounded p-1 transition-colors hover:bg-white/5"
title={sidebarMultiSelectActive ? 'Exit selection mode' : 'Select sessions'}
style={{
color: sidebarMultiSelectActive ? '#818cf8' : 'var(--color-text-muted)',
}}
>
<CheckSquare className="size-3.5" />
</button>
{/* Show hidden sessions toggle - only when hidden sessions exist */}
{hasHiddenSessions && (
<button
onClick={toggleShowHiddenSessions}
className="rounded p-1 transition-colors hover:bg-white/5"
title={showHiddenSessions ? 'Hide hidden sessions' : 'Show hidden sessions'}
style={{
color: showHiddenSessions ? '#818cf8' : 'var(--color-text-muted)',
}}
>
{showHiddenSessions ? <Eye className="size-3.5" /> : <EyeOff className="size-3.5" />}
</button>
)}
{/* Sort mode toggle */}
<button
onClick={() =>
setSessionSortMode(sessionSortMode === 'recent' ? 'most-context' : 'recent')
}
className="rounded p-1 transition-colors hover:bg-white/5"
title={sessionSortMode === 'recent' ? 'Sort by context consumption' : 'Sort by recent'}
style={{
color: sessionSortMode === 'most-context' ? '#818cf8' : 'var(--color-text-muted)',
}}
>
<ArrowDownWideNarrow className="size-3.5" />
</button>
</div>
</div>
{/* Bulk action bar - shown when sessions are selected */}
{sidebarMultiSelectActive && sidebarSelectedSessionIds.length > 0 && (
<div
className="flex items-center gap-1.5 border-b px-3 py-1.5"
style={{
color: sessionSortMode === 'most-context' ? '#818cf8' : 'var(--color-text-muted)',
borderColor: 'var(--color-border)',
backgroundColor: 'var(--color-surface-raised)',
}}
>
<ArrowDownWideNarrow className="size-3.5" />
</button>
</div>
<span
className="text-[11px] font-medium"
style={{ color: 'var(--color-text-secondary)' }}
>
{sidebarSelectedSessionIds.length} selected
</span>
<div className="ml-auto flex items-center gap-1">
<button
onClick={handleBulkPin}
className="rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors hover:bg-white/5"
style={{ color: 'var(--color-text-secondary)' }}
title="Pin selected sessions"
>
<Pin className="inline-block size-3" /> Pin
</button>
<button
onClick={handleBulkHide}
className="rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors hover:bg-white/5"
style={{ color: 'var(--color-text-secondary)' }}
title="Hide selected sessions"
>
<EyeOff className="inline-block size-3" /> Hide
</button>
{showHiddenSessions && someSelectedAreHidden && (
<button
onClick={handleBulkUnhide}
className="rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors hover:bg-white/5"
style={{ color: 'var(--color-text-secondary)' }}
title="Unhide selected sessions"
>
<Eye className="inline-block size-3" /> Unhide
</button>
)}
<button
onClick={clearSidebarSelection}
className="rounded p-0.5 transition-colors hover:bg-white/5"
style={{ color: 'var(--color-text-muted)' }}
title="Cancel selection"
>
<X className="size-3.5" />
</button>
</div>
</div>
)}
<div ref={parentRef} className="flex-1 overflow-y-auto">
<div
@ -419,6 +574,10 @@ export const DateGroupedSessions = (): React.JSX.Element => {
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)}
/>
)}
</div>

View file

@ -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<HTMLDivElement>(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 ? <PinOff className="size-4" /> : <Pin className="size-4" />}
onClick={handleClick(onTogglePin)}
/>
<MenuItem
label={isHidden ? 'Unhide Session' : 'Hide Session'}
icon={isHidden ? <Eye className="size-4" /> : <EyeOff className="size-4" />}
onClick={handleClick(onToggleHide)}
/>
</div>
);
};

View file

@ -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 = ({
<span className="tabular-nums">{formatTokensCompact(phase.contribution)}</span>
{phase.postCompaction != null && (
<span style={{ color: 'var(--color-text-muted)' }}>
(compacted {formatTokensCompact(phase.postCompaction)})
(compacted to {formatTokensCompact(phase.postCompaction)})
</span>
)}
</div>
@ -129,24 +133,42 @@ export const SessionItem = ({
session,
isActive,
isPinned,
isHidden,
multiSelectActive,
isSelected,
onToggleSelect,
}: Readonly<SessionItemProps>): 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 */}
<div className="flex items-center gap-1.5">
{multiSelectActive && (
<input
type="checkbox"
checked={isSelected ?? false}
onChange={() => onToggleSelect?.()}
onClick={(e) => e.stopPropagation()}
className="size-3.5 shrink-0 accent-blue-500"
/>
)}
{session.isOngoing && <OngoingIndicator />}
{isPinned && <Pin className="size-2.5 shrink-0 text-blue-400" />}
{isHidden && <EyeOff className="size-2.5 shrink-0 text-zinc-500" />}
<span
className="truncate text-[13px] font-medium leading-tight"
style={{ color: isActive ? 'var(--color-text)' : 'var(--color-text-muted)' }}
@ -275,11 +308,13 @@ export const SessionItem = ({
sessionLabel={sessionLabel}
paneCount={paneCount}
isPinned={isPinned ?? false}
isHidden={isHidden ?? false}
onClose={() => setContextMenu(null)}
onOpenInCurrentPane={handleOpenInCurrentPane}
onOpenInNewTab={handleOpenInNewTab}
onSplitRightAndOpen={handleSplitRightAndOpen}
onTogglePin={() => void togglePinSession(session.id)}
onToggleHide={() => void toggleHideSession(session.id)}
/>,
document.body
)}

View file

@ -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<void>;
/** Set session sort mode */
setSessionSortMode: (mode: SessionSortMode) => void;
/** Toggle hide/unhide for a session */
toggleHideSession: (sessionId: string) => Promise<void>;
/** Bulk hide sessions */
hideMultipleSessions: (sessionIds: string[]) => Promise<void>;
/** Bulk unhide sessions */
unhideMultipleSessions: (sessionIds: string[]) => Promise<void>;
/** Load hidden sessions from config for current project */
loadHiddenSessions: () => Promise<void>;
/** 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<void>;
}
// =============================================================================
@ -71,6 +95,12 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
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<AppState, [], [], SessionSlice> =
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<AppState, [], [], SessionSlice> =
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);
}
},
});

View file

@ -99,6 +99,14 @@ export interface ConfigAPI {
pinSession: (projectId: string, sessionId: string) => Promise<void>;
/** Unpin a session for a project */
unpinSession: (projectId: string, sessionId: string) => Promise<void>;
/** Hide a session for a project */
hideSession: (projectId: string, sessionId: string) => Promise<void>;
/** Unhide a session for a project */
unhideSession: (projectId: string, sessionId: string) => Promise<void>;
/** Bulk hide sessions for a project */
hideSessions: (projectId: string, sessionIds: string[]) => Promise<void>;
/** Bulk unhide sessions for a project */
unhideSessions: (projectId: string, sessionIds: string[]) => Promise<void>;
}
export interface ClaudeRootInfo {

View file

@ -276,6 +276,8 @@ export interface AppConfig {
sessions: {
/** Pinned sessions per project. Key is projectId, value is array of pinned sessions */
pinnedSessions: Record<string, { sessionId: string; pinnedAt: number }[]>;
/** Hidden sessions per project. Key is projectId, value is array of hidden sessions */
hiddenSessions: Record<string, { sessionId: string; hiddenAt: number }[]>;
};
/** SSH connection settings */
ssh?: {