feat(sessions): implement session hiding and un-hiding functionality
- Added handlers for hiding and unhiding individual and multiple sessions in the configuration. - Updated the ConfigManager to manage hidden sessions, including methods for bulk operations. - Enhanced the IPC channels and preload scripts to support new session visibility features. - Integrated UI components to allow users to toggle session visibility in the sidebar and context menus. - Updated state management to reflect hidden sessions and support multi-select actions for bulk hiding/unhiding.
This commit is contained in:
parent
93ddd565ae
commit
12a5bf46a8
14 changed files with 686 additions and 52 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ===========================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -295,6 +295,7 @@ export function useSettingsHandlers({
|
|||
},
|
||||
sessions: {
|
||||
pinnedSessions: {},
|
||||
hiddenSessions: {},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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?: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue