import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type FocusEvent as ReactFocusEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, } from 'react'; import { useActions, useConnection, useSessions, useWorkspaceActions, } from '@qwen-code/webui/daemon-react-sdk'; import type { DaemonSessionGroup, DaemonSessionGroupColor, DaemonSessionSummary, } from '@qwen-code/sdk/daemon'; import { useI18n } from '../../i18n'; import { formatRelativeTime } from '../../utils/formatRelativeTime'; import { DialogShell } from '../dialogs/DialogShell'; import { SESSION_LIST_PAGE_SIZE, SESSION_ORGANIZATION_FEATURE, } from '../../constants/sessions'; import styles from './WebShellSidebar.module.css'; const SIDEBAR_WIDTH_STORAGE_KEY = 'qwen-code-web-shell-sidebar-width'; const SIDEBAR_DEFAULT_WIDTH = 260; const SIDEBAR_MIN_WIDTH = 220; const SIDEBAR_MAX_WIDTH = 420; const SIDEBAR_FOOTER_COMPACT_WIDTH = 344; const SIDEBAR_FOOTER_TIGHT_WIDTH = 250; const SIDEBAR_DRAG_VISUAL_MIN_WIDTH = 200; const SIDEBAR_COLLAPSE_DRAG_THRESHOLD = 56; const SIDEBAR_COLLAPSE_DRAG_WIDTH = SIDEBAR_DRAG_VISUAL_MIN_WIDTH - SIDEBAR_COLLAPSE_DRAG_THRESHOLD; const ACTIVE_SESSION_POLL_INTERVAL_MS = 2000; const IDLE_SESSION_POLL_INTERVAL_MS = 30_000; const DIALOG_SESSION_LABEL_MAX_LENGTH = 96; const RECENT_SESSION_SECTION_ID = 'recent'; const GROUP_MENU_WIDTH = 240; const GROUP_MENU_MARGIN = 8; /** * Palette order for the quick color-grouping buckets. Mirrors core's * `GROUP_COLOR_OPTIONS`; kept as a local constant so the client never imports * from core. Used both to order the color sections and as a fallback when the * daemon's color catalog has not loaded yet. */ const SESSION_GROUP_COLORS: DaemonSessionGroupColor[] = [ 'red', 'orange', 'yellow', 'green', 'blue', 'purple', ]; type GroupEditorMode = 'create' | 'edit'; type SessionSectionKind = 'color' | 'group' | 'recent'; interface SessionSection { id: string; kind: SessionSectionKind; label: string; countLabel?: string; color?: DaemonSessionGroupColor; group?: DaemonSessionGroup; sessions: DaemonSessionSummary[]; } interface GroupEditorState { mode: GroupEditorMode; group?: DaemonSessionGroup; targetSession?: DaemonSessionSummary; } interface GroupMenuState { session: DaemonSessionSummary; top: number; left: number; } interface WebShellSidebarProps { collapsed: boolean; onCollapsedChange: (collapsed: boolean) => void; onOpenSettings: () => void; onOpenDaemonStatus: () => void; onOpenScheduledTasks: () => void; onOpenSessions: () => void; /** * Whether to offer the Session Overview entry point. Gated to large screens * by the app: below that there is no room to make managing several sessions * side by side worthwhile. */ canOpenSessionsOverview?: boolean; onOpenSplitView: () => void; /** Whether to offer the in-window split view (large screens only). */ canOpenSplitView?: boolean; onNewSession: () => Promise | boolean; onLoadSession: (sessionId: string) => Promise | void; onError: (error: unknown, fallback: string) => void; mobileOpen?: boolean; sessionListReloadToken?: number; } function cx(...classes: Array): string { return classes.filter(Boolean).join(' '); } function isAbortError(error: unknown): boolean { return error instanceof DOMException && error.name === 'AbortError'; } function getWorkspaceName(workspaceCwd: string | undefined): string { if (!workspaceCwd) return ''; const parts = workspaceCwd.split(/[\\/]+/).filter(Boolean); return parts.at(-1) ?? workspaceCwd; } function getSessionLabel(session: DaemonSessionSummary): string { const displayName = session.displayName?.trim(); return displayName || session.sessionId.slice(0, 8); } function getCompactSessionLabel(session: DaemonSessionSummary): string { const normalized = getSessionLabel(session).replace(/\s+/g, ' ').trim(); if (normalized.length <= DIALOG_SESSION_LABEL_MAX_LENGTH) { return normalized; } return `${normalized .slice(0, DIALOG_SESSION_LABEL_MAX_LENGTH - 3) .trimEnd()}...`; } function getSessionCreatedTime(session: DaemonSessionSummary): number { if (!session.createdAt) return 0; const time = Date.parse(session.createdAt); return Number.isFinite(time) ? time : 0; } function getDefaultGroupColor( colorOptions: DaemonSessionGroupColor[], ): DaemonSessionGroupColor { return colorOptions[0] ?? 'blue'; } function getGroupColorClass(color: DaemonSessionGroupColor): string { switch (color) { case 'red': return styles.groupColorRed; case 'orange': return styles.groupColorOrange; case 'yellow': return styles.groupColorYellow; case 'green': return styles.groupColorGreen; case 'blue': return styles.groupColorBlue; case 'purple': return styles.groupColorPurple; } const exhaustive: never = color; return exhaustive; } function clampSidebarWidth(width: number): number { return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, width)); } function clampSidebarVisualWidth(width: number): number { return Math.min( SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_DRAG_VISUAL_MIN_WIDTH, width), ); } function readSidebarWidth(): number { if (typeof window === 'undefined') return SIDEBAR_DEFAULT_WIDTH; try { const raw = window.localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY); const width = raw ? Number(raw) : SIDEBAR_DEFAULT_WIDTH; return Number.isFinite(width) ? clampSidebarWidth(width) : SIDEBAR_DEFAULT_WIDTH; } catch { return SIDEBAR_DEFAULT_WIDTH; } } function writeSidebarWidth(width: number): void { try { window.localStorage.setItem( SIDEBAR_WIDTH_STORAGE_KEY, String(clampSidebarWidth(width)), ); } catch { // localStorage can be unavailable in private or embedded contexts. } } function IconNewChat() { return ( ); } /** * Qwen brand mark. Same artwork as the browser-tab favicon in index.html and * the QwenLM GitHub avatar; inlined as an SVG rather than hot-linked because * the Web Shell CSP is `img-src 'self' data: blob:` (see web-shell-static.ts), * which blocks remote images. The purple #6D44E8 fill is legible on both the * light and dark sidebar backgrounds. Filled (not stroked) so it opts out of * the shared `.navIcon svg` stroke styling. */ function IconQwenLogo() { return ( ); } function IconFolder({ expanded }: { expanded: boolean }) { return ( ); } function IconSearch() { return ( ); } function IconSettings() { return ( ); } function IconPulse() { return ( ); } function IconSchedule() { return ( ); } function IconGrid() { return ( ); } function IconColumns() { return ( ); } function IconRename() { return ( ); } function IconDownload() { return ( ); } function IconTrash() { return ( ); } function IconPin() { return ( ); } function IconArchive() { return ( ); } function IconGroup() { return ( ); } function IconUnarchive() { return ( ); } function IconMore() { return ( ); } function IconCollapse({ collapsed }: { collapsed: boolean }) { return ( ); } function IconChevron({ expanded }: { expanded: boolean }) { return ( ); } interface SessionMenuItem { key: string; label: string; icon: ReactNode; onSelect: () => void; danger?: boolean; disabled?: boolean; disabledTitle?: string; } /** * Overflow ("...") action menu for a session row. Rendered at the sidebar * root with `position: fixed` (like the row tooltip) so it escapes the * session list's `overflow: auto` clipping. Closes on outside pointer, * Escape, scroll, or resize. The anchor button is excluded from the * outside-pointer check so its own click can toggle the menu shut. */ function SessionActionsMenu({ anchorEl, items, onClose, }: { anchorEl: HTMLElement; items: SessionMenuItem[]; onClose: () => void; }) { const ref = useRef(null); useEffect(() => { const handlePointerDown = (event: PointerEvent) => { const target = event.target as Node | null; if (!target) return; if (ref.current?.contains(target)) return; if (anchorEl.contains(target)) return; onClose(); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.stopPropagation(); onClose(); } }; document.addEventListener('pointerdown', handlePointerDown, true); document.addEventListener('keydown', handleKeyDown, true); window.addEventListener('scroll', onClose, true); window.addEventListener('resize', onClose); return () => { document.removeEventListener('pointerdown', handlePointerDown, true); document.removeEventListener('keydown', handleKeyDown, true); window.removeEventListener('scroll', onClose, true); window.removeEventListener('resize', onClose); }; }, [anchorEl, onClose]); const anchor = anchorEl.getBoundingClientRect(); const estimatedHeight = items.length * 34 + 8; const openUp = anchor.bottom + estimatedHeight > window.innerHeight - 8; const style: CSSProperties = { right: Math.max(8, window.innerWidth - anchor.right), ...(openUp ? { bottom: window.innerHeight - anchor.top + 4 } : { top: anchor.bottom + 4 }), }; return (
{items.map((item) => ( ))}
); } export function WebShellSidebar({ collapsed, onCollapsedChange, onOpenSettings, onOpenDaemonStatus, onOpenScheduledTasks, onOpenSessions, canOpenSessionsOverview, onOpenSplitView, canOpenSplitView, onNewSession, onLoadSession, onError, mobileOpen, sessionListReloadToken, }: WebShellSidebarProps) { const { t } = useI18n(); const connection = useConnection(); const actions = useActions(); const workspaceActions = useWorkspaceActions(); const organizationEnabled = Boolean( connection.capabilities?.features?.includes(SESSION_ORGANIZATION_FEATURE), ); const { sessions, loading, error, reload, deleteSession, exportSession, archiveSession, } = useSessions({ autoLoad: true, pageSize: SESSION_LIST_PAGE_SIZE, archiveState: 'active', ...(organizationEnabled ? { view: 'organized' as const, group: 'all' } : {}), }); const [archivedExpanded, setArchivedExpanded] = useState(false); const { sessions: archivedSessions, loading: archivedLoading, error: archivedError, reload: reloadArchived, deleteSession: deleteArchivedSession, unarchiveSession, } = useSessions({ autoLoad: true, enabled: archivedExpanded, pageSize: SESSION_LIST_PAGE_SIZE, archiveState: 'archived', ...(organizationEnabled ? { view: 'organized' as const, group: 'all' } : {}), }); const [menuState, setMenuState] = useState<{ session: DaemonSessionSummary; isArchived: boolean; anchorEl: HTMLElement; } | null>(null); const [groups, setGroups] = useState([]); const [colorOptions, setColorOptions] = useState( [], ); const [groupBusy, setGroupBusy] = useState(false); const [editingSessionId, setEditingSessionId] = useState(null); const [editingName, setEditingName] = useState(''); const [busySessionIds, setBusySessionIds] = useState>( () => new Set(), ); const busySessionIdsRef = useRef>(new Set()); const [exportingSessionIds, setExportingSessionIds] = useState>( () => new Set(), ); const exportingSessionIdsRef = useRef>(new Set()); const [creatingSession, setCreatingSession] = useState(false); const creatingSessionRef = useRef(false); const [deleteCandidate, setDeleteCandidate] = useState(null); const [groupMenu, setGroupMenu] = useState(null); const [groupEditor, setGroupEditor] = useState(null); const [groupName, setGroupName] = useState(''); const [groupColor, setGroupColor] = useState('blue'); const [deleteGroupCandidate, setDeleteGroupCandidate] = useState(null); const [collapsedSessionSectionIds, setCollapsedSessionSectionIds] = useState< Set >(() => new Set()); const [sidebarWidth, setSidebarWidth] = useState(readSidebarWidth); const [projectExpanded, setProjectExpanded] = useState(true); const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [isResizing, setIsResizing] = useState(false); const [tooltip, setTooltip] = useState<{ content: ReactNode; top: number; left: number; } | null>(null); const [completedUnreadIds, setCompletedUnreadIds] = useState>( () => new Set(), ); const groupMenuRef = useRef(null); const tooltipHideTimer = useRef(null); const previousRunningRef = useRef | null>(null); const pollInFlightRef = useRef(false); const resizeTeardownRef = useRef<((updateState: boolean) => void) | null>( null, ); const currentSessionId = connection.sessionId; const canExportSessions = connection.capabilities?.features?.includes('session_export') ?? false; const projectName = getWorkspaceName(connection.workspaceCwd) || t('sidebar.projectFallback'); const qwenCodeVersion = connection.capabilities?.qwenCodeVersion || ''; // Numeric releases render as "v1.2.3"; a non-semver fallback such as // "unknown" is shown as-is so we never produce a bogus "vunknown". const versionLabel = qwenCodeVersion ? /^\d/.test(qwenCodeVersion) ? `v${qwenCodeVersion}` : qwenCodeVersion : ''; const footerCompact = !collapsed && sidebarWidth < SIDEBAR_FOOTER_COMPACT_WIDTH; const footerTight = !collapsed && sidebarWidth < SIDEBAR_FOOTER_TIGHT_WIDTH; const sidebarStyle = { '--web-shell-sidebar-width': `${sidebarWidth}px`, } as CSSProperties; const newSessionDisabled = creatingSession; const setSessionBusy = useCallback((sessionId: string, busy: boolean) => { const next = new Set(busySessionIdsRef.current); if (busy) { next.add(sessionId); } else { next.delete(sessionId); } busySessionIdsRef.current = next; setBusySessionIds(next); }, []); const reloadGroups = useCallback(async () => { if (!organizationEnabled) { setGroups([]); setColorOptions([]); return; } try { const catalog = await workspaceActions.listSessionGroups(); setGroups(catalog.groups); setColorOptions(catalog.colorOptions); } catch (err) { onError(err, t('sidebar.groupsLoadFailed')); } }, [onError, organizationEnabled, t, workspaceActions]); useEffect(() => { if (!organizationEnabled) { setGroups([]); setColorOptions([]); return; } void reloadGroups(); }, [organizationEnabled, reloadGroups]); useEffect(() => { if (!groupMenu) return; const handlePointerDown = (event: MouseEvent) => { if (groupMenuRef.current?.contains(event.target as Node)) return; setGroupMenu(null); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { setGroupMenu(null); } }; document.addEventListener('mousedown', handlePointerDown); document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('mousedown', handlePointerDown); document.removeEventListener('keydown', handleKeyDown); }; }, [groupMenu]); useEffect(() => { if (!groupMenu) return; const animationFrame = window.requestAnimationFrame(() => { const items = Array.from( groupMenuRef.current?.querySelectorAll( 'button:not(:disabled)', ) ?? [], ); const selected = items.find((item) => item.getAttribute('aria-checked') === 'true') ?? items[0]; selected?.focus(); }); return () => window.cancelAnimationFrame(animationFrame); }, [groupMenu]); const handleGroupMenuKeyDown = useCallback( (event: ReactKeyboardEvent) => { const items = Array.from( groupMenuRef.current?.querySelectorAll( 'button:not(:disabled)', ) ?? [], ); if (items.length === 0) return; const activeIndex = items.indexOf( document.activeElement as HTMLButtonElement, ); const currentIndex = activeIndex >= 0 ? activeIndex : -1; let nextIndex: number | undefined; if (event.key === 'ArrowDown') { nextIndex = (currentIndex + 1) % items.length; } else if (event.key === 'ArrowUp') { nextIndex = (currentIndex - 1 + items.length) % items.length; } else if (event.key === 'Home') { nextIndex = 0; } else if (event.key === 'End') { nextIndex = items.length - 1; } else if (event.key === 'Escape') { event.preventDefault(); setGroupMenu(null); return; } if (nextIndex === undefined) return; event.preventDefault(); items[nextIndex]?.focus(); }, [], ); const cancelHideTooltip = useCallback(() => { if (tooltipHideTimer.current !== null) { window.clearTimeout(tooltipHideTimer.current); tooltipHideTimer.current = null; } }, []); const hideTooltip = useCallback(() => { cancelHideTooltip(); tooltipHideTimer.current = window.setTimeout(() => { setTooltip(null); tooltipHideTimer.current = null; }, 240); }, [cancelHideTooltip]); useEffect( () => () => { cancelHideTooltip(); resizeTeardownRef.current?.(false); }, [cancelHideTooltip], ); useEffect(() => { setProjectExpanded(!collapsed); if (collapsed) { setSearchOpen(false); setSearchQuery(''); setTooltip(null); } }, [collapsed]); const hasRunningSession = useMemo( () => sessions.some((session) => session.hasActivePrompt), [sessions], ); useEffect(() => { if (!projectExpanded && !hasRunningSession) return; const pollInterval = hasRunningSession && !error ? ACTIVE_SESSION_POLL_INTERVAL_MS : IDLE_SESSION_POLL_INTERVAL_MS; const intervalId = window.setInterval(() => { if (document.hidden || pollInFlightRef.current) return; pollInFlightRef.current = true; void reload().finally(() => { pollInFlightRef.current = false; }); }, pollInterval); return () => window.clearInterval(intervalId); }, [error, hasRunningSession, projectExpanded, reload]); const prevReloadTokenRef = useRef(sessionListReloadToken); useEffect(() => { if ( sessionListReloadToken !== undefined && sessionListReloadToken !== prevReloadTokenRef.current && !document.hidden && !pollInFlightRef.current ) { prevReloadTokenRef.current = sessionListReloadToken; pollInFlightRef.current = true; void reload().finally(() => { pollInFlightRef.current = false; }); } }, [sessionListReloadToken, reload]); useEffect(() => { const runningBySessionId = new Map( sessions.map((session) => [ session.sessionId, Boolean(session.hasActivePrompt), ]), ); const previousRunningBySessionId = previousRunningRef.current; previousRunningRef.current = runningBySessionId; if (previousRunningBySessionId === null) return; setCompletedUnreadIds((current) => { const next = new Set(current); let changed = false; for (const [sessionId, wasRunning] of previousRunningBySessionId) { const isRunning = runningBySessionId.get(sessionId); if ( wasRunning && isRunning === false && sessionId !== currentSessionId && !next.has(sessionId) ) { next.add(sessionId); changed = true; } } for (const sessionId of next) { if ( sessionId === currentSessionId || !runningBySessionId.has(sessionId) || runningBySessionId.get(sessionId) ) { next.delete(sessionId); changed = true; } } return changed ? next : current; }); }, [currentSessionId, sessions]); const showTooltip = useCallback( ( event: ReactMouseEvent | ReactFocusEvent, content: ReactNode, ) => { cancelHideTooltip(); const rect = event.currentTarget.getBoundingClientRect(); setTooltip({ content, top: rect.top + rect.height / 2, left: rect.right + 8, }); }, [cancelHideTooltip], ); const renderSessionTooltip = useCallback( (session: DaemonSessionSummary) => { const label = getSessionLabel(session); const completedUnread = session.sessionId !== currentSessionId && completedUnreadIds.has(session.sessionId); return (
{label}
{session.hasActivePrompt && ( {t('sidebar.running')} )} {completedUnread && ( {t('sidebar.completedUnread')} )} {t('sidebar.clients', { count: session.clientCount ?? 0 })}
{session.sessionId}
); }, [completedUnreadIds, currentSessionId, t], ); const handleNewSession = useCallback(() => { if (creatingSessionRef.current) return; creatingSessionRef.current = true; setCreatingSession(true); void (async () => { try { const created = await onNewSession(); if (created) { void reload().catch(() => undefined); } } catch (err) { if (!isAbortError(err)) { onError(err, t('sidebar.newSessionFailed')); } } finally { creatingSessionRef.current = false; setCreatingSession(false); } })(); }, [onError, onNewSession, reload, t]); const handleLoadSession = useCallback( (sessionId: string) => { if ( sessionId === currentSessionId || busySessionIdsRef.current.has(sessionId) ) { return; } setCompletedUnreadIds((current) => { if (!current.has(sessionId)) return current; const next = new Set(current); next.delete(sessionId); return next; }); setSessionBusy(sessionId, true); void (async () => { try { await onLoadSession(sessionId); } catch (err) { if (!isAbortError(err)) { onError(err, t('sidebar.switchFailed')); } } finally { setSessionBusy(sessionId, false); } })(); }, [currentSessionId, onError, onLoadSession, setSessionBusy, t], ); const startRename = useCallback((session: DaemonSessionSummary) => { setEditingSessionId(session.sessionId); setEditingName(getSessionLabel(session)); }, []); const cancelRename = useCallback(() => { setEditingSessionId(null); setEditingName(''); }, []); const saveRename = useCallback(() => { const nextName = editingName.trim(); if (!nextName || editingSessionId !== currentSessionId) { cancelRename(); return; } const sessionId = editingSessionId; if (busySessionIdsRef.current.has(sessionId)) return; setSessionBusy(sessionId, true); actions .renameSession(nextName) .then(() => { cancelRename(); reload(); }) .catch((err: unknown) => { onError(err, t('sidebar.renameFailed')); cancelRename(); }) .finally(() => { setSessionBusy(sessionId, false); }); }, [ actions, cancelRename, currentSessionId, editingName, editingSessionId, onError, reload, setSessionBusy, t, ]); const handleDeleteSession = useCallback( (session: DaemonSessionSummary) => { if (session.sessionId === currentSessionId) return; setDeleteCandidate(session); }, [currentSessionId], ); const setSessionExporting = useCallback( (sessionId: string, exporting: boolean) => { const next = new Set(exportingSessionIdsRef.current); if (exporting) { next.add(sessionId); } else { next.delete(sessionId); } exportingSessionIdsRef.current = next; setExportingSessionIds(next); }, [], ); const handleExportSession = useCallback( (session: DaemonSessionSummary) => { const sessionId = session.sessionId; if (!canExportSessions || exportingSessionIdsRef.current.has(sessionId)) { return; } setSessionExporting(sessionId, true); void (async () => { try { const result = await exportSession(sessionId, 'html'); const blob = new Blob([result.content], { type: result.mimeType || 'text/html', }); const url = URL.createObjectURL(blob); try { const link = document.createElement('a'); link.href = url; link.download = result.filename; document.body.appendChild(link); link.click(); link.remove(); } finally { URL.revokeObjectURL(url); } } catch (err) { onError(err, t('sidebar.exportFailed')); } finally { setSessionExporting(sessionId, false); } })(); }, [canExportSessions, exportSession, onError, setSessionExporting, t], ); const confirmDeleteSession = useCallback(() => { if (!deleteCandidate) return; const sessionId = deleteCandidate.sessionId; if (sessionId === currentSessionId) { setDeleteCandidate(null); return; } const isArchived = Boolean(deleteCandidate.isArchived); setDeleteCandidate(null); if (busySessionIdsRef.current.has(sessionId)) return; setSessionBusy(sessionId, true); const removeSession = isArchived ? deleteArchivedSession : deleteSession; removeSession(sessionId) .then(() => { // A hard delete unlinks the transcript from BOTH the active and // archived directories, so resync both lists regardless of origin. void reload(); void reloadArchived(); }) .catch((err: unknown) => onError(err, t('sidebar.deleteFailed'))) .finally(() => { setSessionBusy(sessionId, false); }); }, [ currentSessionId, deleteArchivedSession, deleteCandidate, deleteSession, onError, reload, reloadArchived, setSessionBusy, t, ]); const handleRenameFromMenu = useCallback( (session: DaemonSessionSummary) => { if (session.sessionId !== currentSessionId) return; startRename(session); }, [currentSessionId, startRename], ); const handleCreateGroup = useCallback(() => { setGroupMenu(null); setGroupName(''); setGroupColor(getDefaultGroupColor(colorOptions)); setGroupEditor({ mode: 'create' }); }, [colorOptions]); const handleCreateGroupForSession = useCallback( (session: DaemonSessionSummary) => { setGroupMenu(null); setGroupName(''); setGroupColor(getDefaultGroupColor(colorOptions)); setGroupEditor({ mode: 'create', targetSession: session }); }, [colorOptions], ); const handleRenameGroup = useCallback((group: DaemonSessionGroup) => { setGroupName(group.name); setGroupColor(group.color); setGroupEditor({ mode: 'edit', group }); }, []); const closeGroupEditor = useCallback(() => { if (groupBusy) return; setGroupEditor(null); setGroupName(''); setGroupColor(getDefaultGroupColor(colorOptions)); }, [colorOptions, groupBusy]); const saveGroupEditor = useCallback(() => { if (!groupEditor) return; const name = groupName.trim(); if (!name) return; void (async () => { setGroupBusy(true); try { const group = groupEditor.mode === 'create' ? await workspaceActions.createSessionGroup({ name, color: groupColor, }) : await workspaceActions.updateSessionGroup(groupEditor.group!.id, { name, color: groupColor, }); if (groupEditor.mode === 'create') { if (groupEditor.targetSession) { try { await workspaceActions.updateSessionOrganization( groupEditor.targetSession.sessionId, // Assigning a named group clears any color tag (single choice // in the UI), matching assignSessionGroup. { groupId: group.id, color: null }, ); void reload().catch(() => undefined); } catch (err) { setGroupEditor(null); setGroupName(''); void reloadGroups().catch(() => undefined); onError(err, t('sidebar.groupAssignFailedAfterCreate')); return; } } } setGroupEditor(null); setGroupName(''); void reloadGroups().catch(() => undefined); } catch (err) { onError( err, groupEditor.mode === 'create' ? t('sidebar.groupCreateFailed') : t('sidebar.groupUpdateFailed'), ); } finally { setGroupBusy(false); } })(); }, [ groupColor, groupEditor, groupName, onError, reload, reloadGroups, t, workspaceActions, ]); const handleDeleteGroup = useCallback((group: DaemonSessionGroup) => { setDeleteGroupCandidate(group); }, []); const confirmDeleteGroup = useCallback(() => { if (!deleteGroupCandidate) return; setGroupBusy(true); workspaceActions .deleteSessionGroup(deleteGroupCandidate.id) .then(() => { setDeleteGroupCandidate(null); void reload().catch(() => undefined); }) .catch((err: unknown) => onError(err, t('sidebar.groupDeleteFailed'))) .then(() => reloadGroups().catch(() => undefined)) .finally(() => setGroupBusy(false)); }, [ deleteGroupCandidate, onError, reload, reloadGroups, t, workspaceActions, ]); const handleTogglePin = useCallback( (session: DaemonSessionSummary) => { const sessionId = session.sessionId; if (!organizationEnabled || busySessionIdsRef.current.has(sessionId)) { return; } setSessionBusy(sessionId, true); workspaceActions .updateSessionOrganization(sessionId, { isPinned: !session.isPinned, }) .then(() => { void reload().catch(() => undefined); }) .catch((err: unknown) => onError(err, t('sidebar.organizationFailed'))) .finally(() => { setSessionBusy(sessionId, false); }); }, [onError, organizationEnabled, reload, setSessionBusy, t, workspaceActions], ); const handleArchive = useCallback( (session: DaemonSessionSummary) => { const sessionId = session.sessionId; // The daemon force-ends a live turn on archive; keep the current // session off-limits, mirroring the delete guard. if (sessionId === currentSessionId) return; if (busySessionIdsRef.current.has(sessionId)) return; setSessionBusy(sessionId, true); archiveSession(sessionId) .then(() => { void reloadArchived(); }) .catch((err: unknown) => onError(err, t('sidebar.archiveFailed'))) .finally(() => { setSessionBusy(sessionId, false); }); }, [ archiveSession, currentSessionId, onError, reloadArchived, setSessionBusy, t, ], ); const handleUnarchive = useCallback( (session: DaemonSessionSummary) => { const sessionId = session.sessionId; if (busySessionIdsRef.current.has(sessionId)) return; setSessionBusy(sessionId, true); unarchiveSession(sessionId) .then(() => { void reload(); }) .catch((err: unknown) => onError(err, t('sidebar.unarchiveFailed'))) .finally(() => { setSessionBusy(sessionId, false); }); }, [onError, reload, setSessionBusy, t, unarchiveSession], ); const closeMenu = useCallback(() => setMenuState(null), []); const openGroupMenuFromAnchor = useCallback( (anchorEl: HTMLElement, session: DaemonSessionSummary) => { const rect = anchorEl.getBoundingClientRect(); const viewportWidth = typeof window === 'undefined' ? rect.right + GROUP_MENU_WIDTH : window.innerWidth; const viewportHeight = typeof window === 'undefined' ? rect.top + 320 : window.innerHeight; const estimatedHeight = Math.min( 320, 34 * (groups.length + SESSION_GROUP_COLORS.length + 2) + 25, ); const left = rect.right + GROUP_MENU_MARGIN + GROUP_MENU_WIDTH <= viewportWidth ? rect.right + GROUP_MENU_MARGIN : Math.max( GROUP_MENU_MARGIN, rect.left - GROUP_MENU_WIDTH - GROUP_MENU_MARGIN, ); const top = Math.max( GROUP_MENU_MARGIN, Math.min( rect.top, viewportHeight - estimatedHeight - GROUP_MENU_MARGIN, ), ); setTooltip(null); setGroupMenu({ session, top, left, }); }, [groups.length], ); const openGroupMenu = useCallback( ( event: ReactMouseEvent, session: DaemonSessionSummary, ) => { event.stopPropagation(); openGroupMenuFromAnchor(event.currentTarget, session); }, [openGroupMenuFromAnchor], ); const openMenu = useCallback( ( event: ReactMouseEvent, session: DaemonSessionSummary, isArchived: boolean, ) => { event.stopPropagation(); const anchorEl = event.currentTarget; setMenuState((prev) => prev && prev.session.sessionId === session.sessionId && prev.isArchived === isArchived ? null : { session, isArchived, anchorEl }, ); }, [], ); const assignSessionGroup = useCallback( (session: DaemonSessionSummary, groupId: string | null) => { const sessionId = session.sessionId; if (!organizationEnabled || busySessionIdsRef.current.has(sessionId)) { return; } setGroupMenu(null); setSessionBusy(sessionId, true); workspaceActions // Group and color are a single choice in the UI: assigning a named // group (or "Ungrouped", groupId=null) clears any color tag. .updateSessionOrganization(sessionId, { groupId, color: null }) .then(() => { void reload().catch(() => undefined); }) .catch((err: unknown) => onError(err, t('sidebar.organizationFailed'))) .finally(() => { setSessionBusy(sessionId, false); }); }, [onError, organizationEnabled, reload, setSessionBusy, t, workspaceActions], ); const assignSessionColor = useCallback( (session: DaemonSessionSummary, color: DaemonSessionGroupColor | null) => { const sessionId = session.sessionId; if (!organizationEnabled || busySessionIdsRef.current.has(sessionId)) { return; } setGroupMenu(null); setSessionBusy(sessionId, true); workspaceActions // Picking a color clears any named-group assignment (single choice). .updateSessionOrganization(sessionId, { color, groupId: null }) .then(() => { void reload().catch(() => undefined); }) .catch((err: unknown) => onError(err, t('sidebar.organizationFailed'))) .finally(() => { setSessionBusy(sessionId, false); }); }, [onError, organizationEnabled, reload, setSessionBusy, t, workspaceActions], ); const filteredSessions = useMemo(() => { const query = searchQuery.trim().toLowerCase(); const nextSessions = query ? sessions.filter((session) => { const label = getSessionLabel(session).toLowerCase(); return ( label.includes(query) || session.sessionId.toLowerCase().includes(query) ); }) : sessions.slice(); if (organizationEnabled) { return nextSessions; } const createdTimeById = new Map( nextSessions.map((session) => [ session.sessionId, getSessionCreatedTime(session), ]), ); return nextSessions.sort( (a, b) => (createdTimeById.get(b.sessionId) ?? 0) - (createdTimeById.get(a.sessionId) ?? 0), ); }, [organizationEnabled, searchQuery, sessions]); const sessionSections = useMemo(() => { if (!organizationEnabled) return []; const searching = searchQuery.trim().length > 0; const validGroupIds = new Set(groups.map((group) => group.id)); const sessionsByColor = new Map< DaemonSessionGroupColor, DaemonSessionSummary[] >(); const sessionsByGroupId = new Map(); for (const group of groups) { sessionsByGroupId.set(group.id, []); } const recentSessions: DaemonSessionSummary[] = []; for (const session of filteredSessions) { // Color takes precedence: the picker keeps color and group mutually // exclusive, but stay defensive if a store somehow carries both. if (session.color && SESSION_GROUP_COLORS.includes(session.color)) { const bucket = sessionsByColor.get(session.color) ?? []; bucket.push(session); sessionsByColor.set(session.color, bucket); continue; } const groupSessions = session.groupId && validGroupIds.has(session.groupId) ? sessionsByGroupId.get(session.groupId) : undefined; if (groupSessions) { groupSessions.push(session); } else { recentSessions.push(session); } } const sections: SessionSection[] = []; // Color buckets first, in palette order; only render non-empty ones so the // sidebar never shows six empty color headers. for (const color of SESSION_GROUP_COLORS) { const colorSessions = sessionsByColor.get(color); if (!colorSessions || colorSessions.length === 0) continue; sections.push({ id: `color:${color}`, kind: 'color', label: t(`sidebar.groupColor.${color}`), countLabel: String(colorSessions.length), color, sessions: colorSessions, }); } // Named groups next (kept visible even when empty, unless searching). for (const group of groups) { const groupSessions = sessionsByGroupId.get(group.id) ?? []; if (searching && groupSessions.length === 0) continue; sections.push({ id: `group:${group.id}`, kind: 'group', label: group.name, countLabel: String(groupSessions.length), color: group.color, group, sessions: groupSessions, }); } if (recentSessions.length > 0) { sections.push({ id: RECENT_SESSION_SECTION_ID, kind: 'recent', label: t('sidebar.groupRecent'), sessions: recentSessions, }); } return sections; }, [filteredSessions, groups, organizationEnabled, searchQuery, t]); const toggleSessionSection = useCallback((sectionId: string) => { setCollapsedSessionSectionIds((current) => { const next = new Set(current); if (next.has(sectionId)) { next.delete(sectionId); } else { next.add(sectionId); } return next; }); }, []); const handleResizePointerDown = useCallback( (event: ReactPointerEvent) => { if (collapsed) return; event.preventDefault(); resizeTeardownRef.current?.(true); setIsResizing(true); const startX = event.clientX; const startWidth = sidebarWidth; const previousCursor = document.body.style.cursor; const previousUserSelect = document.body.style.userSelect; let collapsedByDrag = false; let teardown: (updateState: boolean) => void = () => undefined; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; try { event.currentTarget.setPointerCapture(event.pointerId); } catch { // Pointer capture is best-effort; window listeners still handle drag. } function getRawWidth(clientX: number) { return startWidth + clientX - startX; } function restoreExpandedWidth() { const restoredWidth = clampSidebarWidth(startWidth); setSidebarWidth(restoredWidth); writeSidebarWidth(restoredWidth); } function collapseFromDrag() { if (collapsedByDrag) return; collapsedByDrag = true; restoreExpandedWidth(); teardown(true); onCollapsedChange(true); } function handlePointerMove(moveEvent: PointerEvent) { const rawWidth = getRawWidth(moveEvent.clientX); if (rawWidth <= SIDEBAR_COLLAPSE_DRAG_WIDTH) { collapseFromDrag(); return; } setSidebarWidth(clampSidebarVisualWidth(rawWidth)); } teardown = function resizeTeardown(updateState: boolean) { document.body.style.cursor = previousCursor; document.body.style.userSelect = previousUserSelect; window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener('pointercancel', handlePointerCancel); resizeTeardownRef.current = null; if (updateState) { setIsResizing(false); } }; function handlePointerUp(upEvent: PointerEvent) { const rawWidth = getRawWidth(upEvent.clientX); if (rawWidth <= SIDEBAR_COLLAPSE_DRAG_WIDTH) { collapseFromDrag(); return; } const nextWidth = clampSidebarWidth(rawWidth); setSidebarWidth(nextWidth); writeSidebarWidth(nextWidth); teardown(true); } function handlePointerCancel() { teardown(true); } resizeTeardownRef.current = teardown; window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointerup', handlePointerUp, { once: true }); window.addEventListener('pointercancel', handlePointerCancel, { once: true, }); }, [collapsed, onCollapsedChange, sidebarWidth], ); const deleteCandidateLabel = deleteCandidate ? getCompactSessionLabel(deleteCandidate) : ''; const groupMenuSelectedColor = groupMenu?.session.color && SESSION_GROUP_COLORS.includes(groupMenu.session.color) ? groupMenu.session.color : null; const groupMenuSelectedGroupId = !groupMenuSelectedColor && groupMenu?.session.groupId && groups.some((group) => group.id === groupMenu.session.groupId) ? groupMenu.session.groupId : null; const menuColorOptions = colorOptions.length > 0 ? colorOptions : SESSION_GROUP_COLORS; const groupMenuUngroupedSelected = groupMenuSelectedGroupId === null && groupMenuSelectedColor === null; const deleteGroupCandidateLabel = deleteGroupCandidate?.name ?? ''; const canSaveGroup = groupName.trim().length > 0 && !groupBusy; const groupEditorTitle = groupEditor?.mode === 'create' ? t('sidebar.groupCreate') : t('sidebar.groupRename'); const groupColorChoices = colorOptions.length > 0 ? colorOptions : (['blue'] as DaemonSessionGroupColor[]); const renderSessionRow = useCallback( ( session: DaemonSessionSummary, options: { isArchived?: boolean; grouped?: boolean } = {}, ) => { const { isArchived = false, grouped = false } = options; const label = getSessionLabel(session); const stamp = session.updatedAt || session.createdAt; const time = stamp ? formatRelativeTime(stamp, t) : ''; const busy = busySessionIds.has(session.sessionId); const isMenuOpen = menuState?.session.sessionId === session.sessionId && menuState.isArchived === isArchived; if (isArchived) { return (
{label}
{time}
event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()} >
); } const isCurrent = session.sessionId === currentSessionId; const isEditing = editingSessionId === session.sessionId; const exporting = exportingSessionIds.has(session.sessionId); const completedUnread = !isCurrent && completedUnreadIds.has(session.sessionId); const showInlinePin = grouped && session.isPinned && !session.hasActivePrompt; return (
showTooltip(event, renderSessionTooltip(session)) } onMouseLeave={hideTooltip} onFocus={(event) => showTooltip(event, renderSessionTooltip(session))} onBlur={hideTooltip} onClick={() => handleLoadSession(session.sessionId)} onDoubleClick={() => { if (isCurrent && !collapsed) startRename(session); }} onKeyDown={(event) => { if (event.key === 'Enter') handleLoadSession(session.sessionId); }} > {!collapsed && ( <>
); }, [ busySessionIds, canExportSessions, cancelRename, collapsed, completedUnreadIds, currentSessionId, editingName, editingSessionId, exportingSessionIds, handleArchive, handleExportSession, handleLoadSession, handleTogglePin, handleUnarchive, hideTooltip, menuState, openGroupMenu, openMenu, organizationEnabled, renderSessionTooltip, saveRename, showTooltip, startRename, t, ], ); const body = useMemo(() => { if (!projectExpanded) return null; if (loading && sessions.length === 0) { return (
{t('sidebar.loadingSessions')}
); } if (error && sessions.length === 0) { return ( ); } if ( filteredSessions.length === 0 && (searchQuery.trim() || !organizationEnabled || sessionSections.length === 0) ) { return
{t('sidebar.searchEmpty')}
; } if (!organizationEnabled) { return filteredSessions.map((session) => renderSessionRow(session)); } return sessionSections.map((section) => { const expanded = !collapsedSessionSectionIds.has(section.id); const group = section.group; return (
{section.kind === 'group' && group ? ( <> ) : section.kind === 'recent' ? ( ) : null}
{expanded && (
{section.sessions.map((session) => renderSessionRow(session, { grouped: true }), )}
)}
); }); }, [ collapsedSessionSectionIds, error, filteredSessions, groupBusy, handleCreateGroup, handleDeleteGroup, handleRenameGroup, loading, organizationEnabled, projectExpanded, reload, renderSessionRow, searchQuery, sessionSections, sessions.length, t, toggleSessionSection, ]); const archivedSection = useMemo(() => { if (collapsed || !projectExpanded || searchQuery.trim()) return null; const header = ( ); if (!archivedExpanded) { return
{header}
; } let content: ReactNode; if (archivedLoading && archivedSessions.length === 0) { content = (
{t('sidebar.loadingSessions')}
); } else if (archivedError && archivedSessions.length === 0) { content = ( ); } else if (archivedSessions.length === 0) { content = (
{t('sidebar.archivedEmpty')}
); } else { content = archivedSessions.map((session) => renderSessionRow(session, { isArchived: true }), ); } return (
{header}
{content}
); }, [ archivedError, archivedExpanded, archivedLoading, archivedSessions, collapsed, projectExpanded, reloadArchived, renderSessionRow, searchQuery, t, ]); const menuItems = useMemo(() => { if (!menuState) return []; const { session, isArchived } = menuState; if (isArchived) { return [ { key: 'unarchive', label: t('sidebar.unarchive'), icon: , onSelect: () => handleUnarchive(session), }, { key: 'delete', label: t('sidebar.delete'), icon: , danger: true, onSelect: () => handleDeleteSession(session), }, ]; } const isCurrent = session.sessionId === currentSessionId; const sessionBusy = busySessionIds.has(session.sessionId); const items: SessionMenuItem[] = [ { key: 'rename', label: t('sidebar.rename'), icon: , disabled: !isCurrent, disabledTitle: t('sidebar.renameCurrentOnly'), onSelect: () => handleRenameFromMenu(session), }, ...(organizationEnabled ? [ { key: 'pin', label: session.isPinned ? t('sidebar.unpin') : t('sidebar.pin'), icon: , disabled: sessionBusy, onSelect: () => handleTogglePin(session), }, { key: 'group', label: t('sidebar.sessionGroup'), icon: , disabled: sessionBusy, onSelect: () => openGroupMenuFromAnchor(menuState.anchorEl, session), }, ] : []), { key: 'archive', label: t('sidebar.archive'), icon: , disabled: isCurrent, disabledTitle: t('sidebar.archiveCurrentDisabled'), onSelect: () => handleArchive(session), }, { key: 'delete', label: t('sidebar.delete'), icon: , danger: true, disabled: isCurrent, disabledTitle: t('sidebar.currentDeleteDisabled'), onSelect: () => handleDeleteSession(session), }, ]; return items; }, [ busySessionIds, currentSessionId, handleArchive, handleDeleteSession, handleRenameFromMenu, handleTogglePin, handleUnarchive, menuState, openGroupMenuFromAnchor, organizationEnabled, t, ]); return ( ); }