diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index c4925eb9..23648a90 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -67,6 +67,7 @@ import { RiMore2Line, RiPencilAiLine, RiPushpinLine, + RiSearchLine, RiShare2Line, RiShieldLine, RiUnpinLine, @@ -93,6 +94,7 @@ import { NewWorktreeDialog } from './NewWorktreeDialog'; import { ProjectNotesTodoPanel } from './ProjectNotesTodoPanel'; import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore'; import { SessionFolderItem } from './SessionFolderItem'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]); @@ -222,6 +224,46 @@ const formatProjectLabel = (label: string): string => { .replace(/\b\w/g, (char) => char.toUpperCase()); }; +const renderHighlightedText = (text: string, query: string): React.ReactNode => { + if (!query) { + return text; + } + + const loweredText = text.toLowerCase(); + const loweredQuery = query.toLowerCase(); + const queryLength = loweredQuery.length; + if (queryLength === 0) { + return text; + } + + const parts: React.ReactNode[] = []; + let cursor = 0; + let matchIndex = loweredText.indexOf(loweredQuery, cursor); + + while (matchIndex !== -1) { + if (matchIndex > cursor) { + parts.push(text.slice(cursor, matchIndex)); + } + const matchText = text.slice(matchIndex, matchIndex + queryLength); + parts.push( + + {matchText} + , + ); + cursor = matchIndex + queryLength; + matchIndex = loweredText.indexOf(loweredQuery, cursor); + } + + if (cursor < text.length) { + parts.push(text.slice(cursor)); + } + + return parts.length > 0 ? parts : text; +}; + type SessionNode = { session: Session; children: SessionNode[]; @@ -239,6 +281,14 @@ type SessionGroup = { sessions: SessionNode[]; }; +type GroupSearchData = { + filteredNodes: SessionNode[]; + matchedSessionCount: number; + folderNameMatchCount: number; + groupMatches: boolean; + hasMatch: boolean; +}; + // --- Session Folder DnD helpers --- /** @@ -361,8 +411,8 @@ const SessionFolderDndScope: React.FC<{ {children} {activeDragId && hasFolders ? ( -
= ({ hideProjectSelector = true, showOnlyMainWorkspace = false, }) => { + const [isSessionSearchOpen, setIsSessionSearchOpen] = React.useState(false); + const [sessionSearchQuery, setSessionSearchQuery] = React.useState(''); + const sessionSearchContainerRef = React.useRef(null); + const sessionSearchInputRef = React.useRef(null); const [editingId, setEditingId] = React.useState(null); const [editTitle, setEditTitle] = React.useState(''); const [editingProjectId, setEditingProjectId] = React.useState(null); @@ -844,6 +898,14 @@ export const SessionSidebar: React.FC = ({ const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog); const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree); + const debouncedSessionSearchQuery = useDebouncedValue(sessionSearchQuery, 120); + const normalizedSessionSearchQuery = React.useMemo( + () => debouncedSessionSearchQuery.trim().toLowerCase(), + [debouncedSessionSearchQuery], + ); + + const hasSessionSearchQuery = normalizedSessionSearchQuery.length > 0; + // Session Folders store const collapsedFolderIds = useSessionFoldersStore((state) => state.collapsedFolderIds); const getFoldersForScope = useSessionFoldersStore((state) => state.getFoldersForScope); @@ -856,6 +918,75 @@ export const SessionSidebar: React.FC = ({ const cleanupSessions = useSessionFoldersStore((state) => state.cleanupSessions); const getSessionFolderId = useSessionFoldersStore((state) => state.getSessionFolderId); + const buildGroupSearchText = React.useCallback((group: SessionGroup): string => { + return [ + group.label, + group.branch ?? '', + group.description ?? '', + group.directory ?? '', + ] + .join(' ') + .toLowerCase(); + }, []); + + const buildSessionSearchText = React.useCallback((session: Session): string => { + const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? ''; + const sessionTitle = (session.title || 'Untitled Session').trim(); + return `${sessionTitle} ${sessionDirectory}`.toLowerCase(); + }, []); + + const filterSessionNodesForSearch = React.useCallback( + (nodes: SessionNode[], query: string): SessionNode[] => { + if (!query) { + return nodes; + } + + return nodes.flatMap((node) => { + const nodeMatches = buildSessionSearchText(node.session).includes(query); + if (nodeMatches) { + return [node]; + } + + const filteredChildren = filterSessionNodesForSearch(node.children, query); + if (filteredChildren.length === 0) { + return []; + } + + return [{ + ...node, + children: filteredChildren, + }]; + }); + }, + [buildSessionSearchText], + ); + + React.useEffect(() => { + if (!isSessionSearchOpen || typeof window === 'undefined') { + return; + } + const raf = window.requestAnimationFrame(() => { + sessionSearchInputRef.current?.focus(); + sessionSearchInputRef.current?.select(); + }); + return () => window.cancelAnimationFrame(raf); + }, [isSessionSearchOpen]); + React.useEffect(() => { + if (!isSessionSearchOpen || typeof document === 'undefined') { + return; + } + const handlePointerDown = (event: MouseEvent) => { + if (!sessionSearchContainerRef.current) { + return; + } + if (!sessionSearchContainerRef.current.contains(event.target as Node)) { + setIsSessionSearchOpen(false); + } + }; + document.addEventListener('mousedown', handlePointerDown); + return () => document.removeEventListener('mousedown', handlePointerDown); + }, [isSessionSearchOpen]); + const gitDirectories = useGitStore((state) => state.directories); const sessions = useSessionStore((state) => state.sessions); @@ -1234,6 +1365,14 @@ export const SessionSidebar: React.FC = ({ return; } + const resetSessionSearch = () => { + if (!isSessionSearchOpen && sessionSearchQuery.length === 0) { + return; + } + setSessionSearchQuery(''); + setIsSessionSearchOpen(false); + }; + if (projectId && projectId !== activeProjectId) { // Important: avoid switching to the project root first (that can select the wrong session). setActiveProjectIdOnly(projectId); @@ -1253,22 +1392,28 @@ export const SessionSidebar: React.FC = ({ if (allowReselect) { onSessionSelected?.(sessionId); } + resetSessionSearch(); return; } setCurrentSession(sessionId); onSessionSelected?.(sessionId); + resetSessionSearch(); }, [ activeProjectId, allowReselect, currentDirectory, currentSessionId, + isSessionSearchOpen, mobileVariant, onSessionSelected, + sessionSearchQuery, setActiveMainTab, setActiveProjectIdOnly, setCurrentSession, setDirectory, + setIsSessionSearchOpen, + setSessionSearchQuery, setSessionSwitcherOpen, ], ); @@ -1855,6 +2000,93 @@ export const SessionSidebar: React.FC = ({ return active ? [active] : [projectSections[0]]; }, [projectSections, activeProjectId]); + const groupSearchDataByGroup = React.useMemo(() => { + const result = new WeakMap(); + if (!hasSessionSearchQuery) { + return result; + } + + const countNodes = (nodes: SessionNode[]): number => { + return nodes.reduce((total, node) => total + 1 + countNodes(node.children), 0); + }; + + visibleProjectSections.forEach((section) => { + section.groups.forEach((group) => { + const filteredNodes = filterSessionNodesForSearch(group.sessions, normalizedSessionSearchQuery); + const matchedSessionCount = countNodes(filteredNodes); + const groupMatches = buildGroupSearchText(group).includes(normalizedSessionSearchQuery); + const scopeKey = normalizePath(group.directory ?? null); + const folderNameMatchCount = scopeKey + ? getFoldersForScope(scopeKey).filter((folder) => folder.name.toLowerCase().includes(normalizedSessionSearchQuery)).length + : 0; + + result.set(group, { + filteredNodes, + matchedSessionCount, + folderNameMatchCount, + groupMatches, + hasMatch: groupMatches || matchedSessionCount > 0 || folderNameMatchCount > 0, + }); + }); + }); + + return result; + }, [ + hasSessionSearchQuery, + visibleProjectSections, + filterSessionNodesForSearch, + normalizedSessionSearchQuery, + buildGroupSearchText, + getFoldersForScope, + ]); + + const searchableProjectSections = React.useMemo(() => { + if (!hasSessionSearchQuery) { + return visibleProjectSections; + } + + return visibleProjectSections + .map((section) => ({ + ...section, + groups: section.groups.filter((group) => groupSearchDataByGroup.get(group)?.hasMatch === true), + })) + .filter((section) => section.groups.length > 0); + }, [ + hasSessionSearchQuery, + visibleProjectSections, + groupSearchDataByGroup, + ]); + + const sectionsForRender = hasSessionSearchQuery ? searchableProjectSections : visibleProjectSections; + + const searchMatchCount = React.useMemo(() => { + if (!hasSessionSearchQuery) { + return 0; + } + + return sectionsForRender.reduce((total, section) => { + return total + section.groups.reduce((groupTotal, group) => { + const data = groupSearchDataByGroup.get(group); + if (!data) { + return groupTotal; + } + const metadataMatches = data.folderNameMatchCount + (data.groupMatches ? 1 : 0); + return groupTotal + data.matchedSessionCount + metadataMatches; + }, 0); + }, 0); + }, [ + hasSessionSearchQuery, + sectionsForRender, + groupSearchDataByGroup, + ]); + + const searchEmptyState = ( +
+

No matching sessions

+

Try a different title, branch, folder, or path.

+
+ ); + const activeProjectForHeader = React.useMemo( () => normalizedProjects.find((project) => project.id === activeProjectId) ?? normalizedProjects[0] ?? null, [normalizedProjects, activeProjectId], @@ -2110,7 +2342,7 @@ export const SessionSidebar: React.FC = ({ entries.forEach((entry) => { const projectId = (entry.target as HTMLElement).dataset.projectId; if (!projectId) return; - + setStuckProjectHeaders((prev) => { const next = new Set(prev); if (!entry.isIntersecting) { @@ -2145,7 +2377,7 @@ export const SessionSidebar: React.FC = ({ const sessionTitle = session.title || 'Untitled Session'; const hasChildren = node.children.length > 0; const isPinnedSession = pinnedSessionIds.has(session.id); - const isExpanded = expandedParents.has(session.id); + const isExpanded = hasSessionSearchQuery ? true : expandedParents.has(session.id); const isSubtaskSession = Boolean((session as Session & { parentID?: string | null }).parentID); const rawNeedsAttention = sessionAttentionStates.get(session.id)?.needsAttention === true; // When notifyOnSubtasks is disabled, suppress attention dots for child sessions. @@ -2308,7 +2540,7 @@ export const SessionSidebar: React.FC = ({ ) : null}
- {sessionTitle} + {renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}
{pendingPermissionCount > 0 ? ( @@ -2568,6 +2800,8 @@ export const SessionSidebar: React.FC = ({ sessionAttentionStates, permissions, currentSessionId, + hasSessionSearchQuery, + normalizedSessionSearchQuery, expandedParents, editingId, editTitle, @@ -2598,31 +2832,86 @@ export const SessionSidebar: React.FC = ({ const renderGroupSessions = React.useCallback( (group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean) => { + const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null; const isExpanded = expandedSessionGroups.has(groupKey); - const isCollapsed = collapsedGroups.has(groupKey); + const isCollapsed = hasSessionSearchQuery ? false : collapsedGroups.has(groupKey); const maxVisible = hideDirectoryControls ? 10 : 5; + const groupMatchesSearch = hasSessionSearchQuery + ? searchData?.groupMatches === true + : false; + const shouldFilterGroupContents = hasSessionSearchQuery; + + const sourceGroupNodes = shouldFilterGroupContents + ? (searchData?.filteredNodes ?? []) + : group.sessions; // --- Session Folders: split into foldered vs ungrouped --- const folderScopeKey = normalizePath(group.directory ?? null); const scopeFolders = folderScopeKey ? getFoldersForScope(folderScopeKey) : []; - const sessionIdsInFolders = new Set(scopeFolders.flatMap((f) => f.sessionIds)); - const ungroupedSessions = group.sessions.filter((node) => !sessionIdsInFolders.has(node.session.id)); + + const nodeBySessionId = new Map(); + const collectNodeLookup = (nodes: SessionNode[]) => { + nodes.forEach((node) => { + nodeBySessionId.set(node.session.id, node); + if (node.children.length > 0) { + collectNodeLookup(node.children); + } + }); + }; + collectNodeLookup(sourceGroupNodes); // ALL folders for this scope – including empty ones (so newly created folders show up) // Build enriched list: { folder, nodes } for every folder in scope - const allFoldersForGroup = scopeFolders.map((folder) => { + const allFoldersForGroupBase = scopeFolders.map((folder) => { const nodes = folder.sessionIds - .map((sid) => group.sessions.find((node) => node.session.id === sid)) + .map((sid) => nodeBySessionId.get(sid)) .filter((n): n is SessionNode => Boolean(n)) .sort((a, b) => compareSessionsByPinnedAndTime(a.session, b.session, pinnedSessionIds)); return { folder, nodes }; }); + const shouldKeepFolder = (folderId: string, folderMap: Map): boolean => { + const entry = folderMap.get(folderId); + if (!entry) { + return false; + } + + if (!hasSessionSearchQuery) { + return true; + } + + const folderMatches = entry.folder.name.toLowerCase().includes(normalizedSessionSearchQuery); + if (folderMatches || entry.nodes.length > 0) { + return true; + } + + return allFoldersForGroupBase + .filter(({ folder }) => folder.parentId === folderId) + .some(({ folder }) => shouldKeepFolder(folder.id, folderMap)); + }; + + const folderMapById = new Map( + allFoldersForGroupBase.map((entry) => [entry.folder.id, entry]), + ); + + const allFoldersForGroup = hasSessionSearchQuery + ? allFoldersForGroupBase.filter(({ folder }) => shouldKeepFolder(folder.id, folderMapById)) + : allFoldersForGroupBase; + + const sessionIdsInFolders = new Set(allFoldersForGroup.flatMap((f) => f.folder.sessionIds)); + const ungroupedSessions = sourceGroupNodes.filter((node) => !sessionIdsInFolders.has(node.session.id)); + // Root-level folders (no parentId) — sub-folders are rendered inside their parent const rootFolders = allFoldersForGroup.filter(({ folder }) => !folder.parentId); + if (hasSessionSearchQuery && !groupMatchesSearch && rootFolders.length === 0 && ungroupedSessions.length === 0) { + return null; + } + const totalSessions = ungroupedSessions.length; - const visibleSessions = isExpanded ? ungroupedSessions : ungroupedSessions.slice(0, maxVisible); + const visibleSessions = hasSessionSearchQuery + ? ungroupedSessions + : (isExpanded ? ungroupedSessions : ungroupedSessions.slice(0, maxVisible)); const remainingCount = totalSessions - visibleSessions.length; const collectGroupSessions = (nodes: SessionNode[]): Session[] => { const collected: Session[] = []; @@ -2637,7 +2926,7 @@ export const SessionSidebar: React.FC = ({ visit(nodes); return collected; }; - const allGroupSessions = collectGroupSessions(group.sessions); + const allGroupSessions = collectGroupSessions(sourceGroupNodes); const normalizedGroupDirectory = normalizePath(group.directory ?? null); const isGitProject = projectId && projectRepoStatus.has(projectId) ? Boolean(projectRepoStatus.get(projectId)) @@ -2664,7 +2953,7 @@ export const SessionSidebar: React.FC = ({ folder={folder} sessions={nodes} subFolderItems={subFolderItems} - isCollapsed={collapsedFolderIds.has(folder.id)} + isCollapsed={hasSessionSearchQuery ? false : collapsedFolderIds.has(folder.id)} onToggle={() => toggleFolderCollapse(folder.id)} onRename={(name) => { if (folderScopeKey) renameFolder(folderScopeKey, folder.id, name); @@ -2748,7 +3037,7 @@ export const SessionSidebar: React.FC = ({ > {renderFolderItems()} {visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId))} - {totalSessions === 0 && scopeFolders.length === 0 ? ( + {totalSessions === 0 && allFoldersForGroup.length === 0 ? (
No sessions in this workspace yet.
@@ -2827,7 +3116,7 @@ export const SessionSidebar: React.FC = ({ ) : null}

- {group.label} + {renderHighlightedText(group.label, normalizedSessionSearchQuery)}

{showBranchSubtitle ? ( @@ -2917,7 +3206,7 @@ export const SessionSidebar: React.FC = ({ > {renderFolderItems()} {visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId))} - {totalSessions === 0 && scopeFolders.length === 0 ? ( + {totalSessions === 0 && allFoldersForGroup.length === 0 ? (
No sessions in this workspace yet.
@@ -2950,6 +3239,9 @@ export const SessionSidebar: React.FC = ({ expandedSessionGroups, collapsedGroups, hideDirectoryControls, + hasSessionSearchQuery, + normalizedSessionSearchQuery, + groupSearchDataByGroup, currentSessionDirectory, projectRepoStatus, renderSessionNode, @@ -2988,6 +3280,7 @@ export const SessionSidebar: React.FC = ({ return (
= ({
)} {reserveHeaderActionsSpace ? ( -
+
{activeProjectForHeader ? ( + <>
{stableActiveProjectIsRepo ? ( <> @@ -3195,7 +3489,62 @@ export const SessionSidebar: React.FC = ({ )} + + + + +

Search sessions

+
+ {isSessionSearchOpen ? ( +
+
+ {hasSessionSearchQuery ? ( + {searchMatchCount} {searchMatchCount === 1 ? 'match' : 'matches'} + ) : } + Esc to clear +
+
+ + setSessionSearchQuery(event.target.value)} + placeholder="Search sessions..." + className="h-8 w-full rounded-md border border-border bg-transparent pl-8 pr-8 typography-ui-label text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary/50" + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.stopPropagation(); + if (hasSessionSearchQuery) { + setSessionSearchQuery(''); + } else { + setIsSessionSearchOpen(false); + } + } + }} + /> + {sessionSearchQuery.length > 0 ? ( + + ) : null} +
+
+ ) : null} + ) : null}
) : null} @@ -3208,12 +3557,14 @@ export const SessionSidebar: React.FC = ({ > {projectSections.length === 0 ? ( emptyState + ) : sectionsForRender.length === 0 ? ( + searchEmptyState ) : showOnlyMainWorkspace ? (
{(() => { - const activeSection = projectSections.find((section) => section.project.id === activeProjectId) ?? projectSections[0]; + const activeSection = sectionsForRender.find((section) => section.project.id === activeProjectId) ?? sectionsForRender[0]; if (!activeSection) { - return emptyState; + return hasSessionSearchQuery ? searchEmptyState : emptyState; } // VS Code sessions view typically only shows one workspace, but sessions may live in worktrees or // canonicalized paths. Prefer the main group if it has sessions; otherwise fall back to any group @@ -3237,7 +3588,7 @@ export const SessionSidebar: React.FC = ({
) : ( <> - {visibleProjectSections.map((section) => { + {sectionsForRender.map((section) => { const project = section.project; const projectKey = project.id; const projectLabel = formatProjectLabel(