feat(sidebar): add active-project session search (#568)

* feat(sidebar): add active-project session search in sidebar

* fix: improve sidebar session search behavior

Keep `Esc to clear` visible to prevent header layout shift.
Show all matched sessions during active search instead of hiding some behind “Show more”.
Align search match counts with visible results, including group and folder-name matches.

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
KJdotIO
2026-03-03 01:08:11 +02:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent bcbc2646fd
commit 11b995d7bd
@@ -67,6 +67,7 @@ import {
RiMore2Line, RiMore2Line,
RiPencilAiLine, RiPencilAiLine,
RiPushpinLine, RiPushpinLine,
RiSearchLine,
RiShare2Line, RiShare2Line,
RiShieldLine, RiShieldLine,
RiUnpinLine, RiUnpinLine,
@@ -93,6 +94,7 @@ import { NewWorktreeDialog } from './NewWorktreeDialog';
import { ProjectNotesTodoPanel } from './ProjectNotesTodoPanel'; import { ProjectNotesTodoPanel } from './ProjectNotesTodoPanel';
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore'; import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
import { SessionFolderItem } from './SessionFolderItem'; import { SessionFolderItem } from './SessionFolderItem';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]); 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()); .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(
<mark
key={`${matchIndex}-${matchText}`}
className="bg-primary text-primary-foreground ring-1 ring-primary/90"
>
{matchText}
</mark>,
);
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 = { type SessionNode = {
session: Session; session: Session;
children: SessionNode[]; children: SessionNode[];
@@ -239,6 +281,14 @@ type SessionGroup = {
sessions: SessionNode[]; sessions: SessionNode[];
}; };
type GroupSearchData = {
filteredNodes: SessionNode[];
matchedSessionCount: number;
folderNameMatchCount: number;
groupMatches: boolean;
hasMatch: boolean;
};
// --- Session Folder DnD helpers --- // --- Session Folder DnD helpers ---
/** /**
@@ -361,8 +411,8 @@ const SessionFolderDndScope: React.FC<{
{children} {children}
<DragOverlay> <DragOverlay>
{activeDragId && hasFolders ? ( {activeDragId && hasFolders ? (
<div <div
style={{ style={{
width: activeDragWidth ? `${activeDragWidth}px` : 'auto', width: activeDragWidth ? `${activeDragWidth}px` : 'auto',
height: activeDragHeight ? `${activeDragHeight}px` : 'auto' height: activeDragHeight ? `${activeDragHeight}px` : 'auto'
}} }}
@@ -719,6 +769,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
hideProjectSelector = true, hideProjectSelector = true,
showOnlyMainWorkspace = false, showOnlyMainWorkspace = false,
}) => { }) => {
const [isSessionSearchOpen, setIsSessionSearchOpen] = React.useState(false);
const [sessionSearchQuery, setSessionSearchQuery] = React.useState('');
const sessionSearchContainerRef = React.useRef<HTMLDivElement | null>(null);
const sessionSearchInputRef = React.useRef<HTMLInputElement | null>(null);
const [editingId, setEditingId] = React.useState<string | null>(null); const [editingId, setEditingId] = React.useState<string | null>(null);
const [editTitle, setEditTitle] = React.useState(''); const [editTitle, setEditTitle] = React.useState('');
const [editingProjectId, setEditingProjectId] = React.useState<string | null>(null); const [editingProjectId, setEditingProjectId] = React.useState<string | null>(null);
@@ -844,6 +898,14 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog); const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree); 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 // Session Folders store
const collapsedFolderIds = useSessionFoldersStore((state) => state.collapsedFolderIds); const collapsedFolderIds = useSessionFoldersStore((state) => state.collapsedFolderIds);
const getFoldersForScope = useSessionFoldersStore((state) => state.getFoldersForScope); const getFoldersForScope = useSessionFoldersStore((state) => state.getFoldersForScope);
@@ -856,6 +918,75 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const cleanupSessions = useSessionFoldersStore((state) => state.cleanupSessions); const cleanupSessions = useSessionFoldersStore((state) => state.cleanupSessions);
const getSessionFolderId = useSessionFoldersStore((state) => state.getSessionFolderId); 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 gitDirectories = useGitStore((state) => state.directories);
const sessions = useSessionStore((state) => state.sessions); const sessions = useSessionStore((state) => state.sessions);
@@ -1234,6 +1365,14 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
return; return;
} }
const resetSessionSearch = () => {
if (!isSessionSearchOpen && sessionSearchQuery.length === 0) {
return;
}
setSessionSearchQuery('');
setIsSessionSearchOpen(false);
};
if (projectId && projectId !== activeProjectId) { if (projectId && projectId !== activeProjectId) {
// Important: avoid switching to the project root first (that can select the wrong session). // Important: avoid switching to the project root first (that can select the wrong session).
setActiveProjectIdOnly(projectId); setActiveProjectIdOnly(projectId);
@@ -1253,22 +1392,28 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
if (allowReselect) { if (allowReselect) {
onSessionSelected?.(sessionId); onSessionSelected?.(sessionId);
} }
resetSessionSearch();
return; return;
} }
setCurrentSession(sessionId); setCurrentSession(sessionId);
onSessionSelected?.(sessionId); onSessionSelected?.(sessionId);
resetSessionSearch();
}, },
[ [
activeProjectId, activeProjectId,
allowReselect, allowReselect,
currentDirectory, currentDirectory,
currentSessionId, currentSessionId,
isSessionSearchOpen,
mobileVariant, mobileVariant,
onSessionSelected, onSessionSelected,
sessionSearchQuery,
setActiveMainTab, setActiveMainTab,
setActiveProjectIdOnly, setActiveProjectIdOnly,
setCurrentSession, setCurrentSession,
setDirectory, setDirectory,
setIsSessionSearchOpen,
setSessionSearchQuery,
setSessionSwitcherOpen, setSessionSwitcherOpen,
], ],
); );
@@ -1855,6 +2000,93 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
return active ? [active] : [projectSections[0]]; return active ? [active] : [projectSections[0]];
}, [projectSections, activeProjectId]); }, [projectSections, activeProjectId]);
const groupSearchDataByGroup = React.useMemo(() => {
const result = new WeakMap<SessionGroup, GroupSearchData>();
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 = (
<div className="py-6 text-center text-muted-foreground">
<p className="typography-ui-label font-semibold">No matching sessions</p>
<p className="typography-meta mt-1">Try a different title, branch, folder, or path.</p>
</div>
);
const activeProjectForHeader = React.useMemo( const activeProjectForHeader = React.useMemo(
() => normalizedProjects.find((project) => project.id === activeProjectId) ?? normalizedProjects[0] ?? null, () => normalizedProjects.find((project) => project.id === activeProjectId) ?? normalizedProjects[0] ?? null,
[normalizedProjects, activeProjectId], [normalizedProjects, activeProjectId],
@@ -2110,7 +2342,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
entries.forEach((entry) => { entries.forEach((entry) => {
const projectId = (entry.target as HTMLElement).dataset.projectId; const projectId = (entry.target as HTMLElement).dataset.projectId;
if (!projectId) return; if (!projectId) return;
setStuckProjectHeaders((prev) => { setStuckProjectHeaders((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (!entry.isIntersecting) { if (!entry.isIntersecting) {
@@ -2145,7 +2377,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const sessionTitle = session.title || 'Untitled Session'; const sessionTitle = session.title || 'Untitled Session';
const hasChildren = node.children.length > 0; const hasChildren = node.children.length > 0;
const isPinnedSession = pinnedSessionIds.has(session.id); 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 isSubtaskSession = Boolean((session as Session & { parentID?: string | null }).parentID);
const rawNeedsAttention = sessionAttentionStates.get(session.id)?.needsAttention === true; const rawNeedsAttention = sessionAttentionStates.get(session.id)?.needsAttention === true;
// When notifyOnSubtasks is disabled, suppress attention dots for child sessions. // When notifyOnSubtasks is disabled, suppress attention dots for child sessions.
@@ -2308,7 +2540,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
<RiPushpinLine className="h-3 w-3 flex-shrink-0 text-primary" aria-label="Pinned session" /> <RiPushpinLine className="h-3 w-3 flex-shrink-0 text-primary" aria-label="Pinned session" />
) : null} ) : null}
<div className="block min-w-0 flex-1 truncate typography-ui-label font-normal text-foreground"> <div className="block min-w-0 flex-1 truncate typography-ui-label font-normal text-foreground">
{sessionTitle} {renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}
</div> </div>
{pendingPermissionCount > 0 ? ( {pendingPermissionCount > 0 ? (
@@ -2568,6 +2800,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
sessionAttentionStates, sessionAttentionStates,
permissions, permissions,
currentSessionId, currentSessionId,
hasSessionSearchQuery,
normalizedSessionSearchQuery,
expandedParents, expandedParents,
editingId, editingId,
editTitle, editTitle,
@@ -2598,31 +2832,86 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const renderGroupSessions = React.useCallback( const renderGroupSessions = React.useCallback(
(group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean) => { (group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean) => {
const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null;
const isExpanded = expandedSessionGroups.has(groupKey); const isExpanded = expandedSessionGroups.has(groupKey);
const isCollapsed = collapsedGroups.has(groupKey); const isCollapsed = hasSessionSearchQuery ? false : collapsedGroups.has(groupKey);
const maxVisible = hideDirectoryControls ? 10 : 5; 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 --- // --- Session Folders: split into foldered vs ungrouped ---
const folderScopeKey = normalizePath(group.directory ?? null); const folderScopeKey = normalizePath(group.directory ?? null);
const scopeFolders = folderScopeKey ? getFoldersForScope(folderScopeKey) : []; 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<string, SessionNode>();
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) // ALL folders for this scope – including empty ones (so newly created folders show up)
// Build enriched list: { folder, nodes } for every folder in scope // Build enriched list: { folder, nodes } for every folder in scope
const allFoldersForGroup = scopeFolders.map((folder) => { const allFoldersForGroupBase = scopeFolders.map((folder) => {
const nodes = folder.sessionIds 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)) .filter((n): n is SessionNode => Boolean(n))
.sort((a, b) => compareSessionsByPinnedAndTime(a.session, b.session, pinnedSessionIds)); .sort((a, b) => compareSessionsByPinnedAndTime(a.session, b.session, pinnedSessionIds));
return { folder, nodes }; return { folder, nodes };
}); });
const shouldKeepFolder = (folderId: string, folderMap: Map<string, { folder: (typeof allFoldersForGroupBase)[number]['folder']; nodes: SessionNode[] }>): 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 // Root-level folders (no parentId) — sub-folders are rendered inside their parent
const rootFolders = allFoldersForGroup.filter(({ folder }) => !folder.parentId); const rootFolders = allFoldersForGroup.filter(({ folder }) => !folder.parentId);
if (hasSessionSearchQuery && !groupMatchesSearch && rootFolders.length === 0 && ungroupedSessions.length === 0) {
return null;
}
const totalSessions = ungroupedSessions.length; 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 remainingCount = totalSessions - visibleSessions.length;
const collectGroupSessions = (nodes: SessionNode[]): Session[] => { const collectGroupSessions = (nodes: SessionNode[]): Session[] => {
const collected: Session[] = []; const collected: Session[] = [];
@@ -2637,7 +2926,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
visit(nodes); visit(nodes);
return collected; return collected;
}; };
const allGroupSessions = collectGroupSessions(group.sessions); const allGroupSessions = collectGroupSessions(sourceGroupNodes);
const normalizedGroupDirectory = normalizePath(group.directory ?? null); const normalizedGroupDirectory = normalizePath(group.directory ?? null);
const isGitProject = projectId && projectRepoStatus.has(projectId) const isGitProject = projectId && projectRepoStatus.has(projectId)
? Boolean(projectRepoStatus.get(projectId)) ? Boolean(projectRepoStatus.get(projectId))
@@ -2664,7 +2953,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
folder={folder} folder={folder}
sessions={nodes} sessions={nodes}
subFolderItems={subFolderItems} subFolderItems={subFolderItems}
isCollapsed={collapsedFolderIds.has(folder.id)} isCollapsed={hasSessionSearchQuery ? false : collapsedFolderIds.has(folder.id)}
onToggle={() => toggleFolderCollapse(folder.id)} onToggle={() => toggleFolderCollapse(folder.id)}
onRename={(name) => { onRename={(name) => {
if (folderScopeKey) renameFolder(folderScopeKey, folder.id, name); if (folderScopeKey) renameFolder(folderScopeKey, folder.id, name);
@@ -2748,7 +3037,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
> >
{renderFolderItems()} {renderFolderItems()}
{visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId))} {visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId))}
{totalSessions === 0 && scopeFolders.length === 0 ? ( {totalSessions === 0 && allFoldersForGroup.length === 0 ? (
<div className="py-1 text-left typography-micro text-muted-foreground"> <div className="py-1 text-left typography-micro text-muted-foreground">
No sessions in this workspace yet. No sessions in this workspace yet.
</div> </div>
@@ -2827,7 +3116,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
) : null} ) : null}
<div className="min-w-0 flex flex-col justify-center"> <div className="min-w-0 flex flex-col justify-center">
<p className={cn('text-[14px] font-semibold truncate', isActiveGroup ? 'text-primary' : 'text-muted-foreground')}> <p className={cn('text-[14px] font-semibold truncate', isActiveGroup ? 'text-primary' : 'text-muted-foreground')}>
{group.label} {renderHighlightedText(group.label, normalizedSessionSearchQuery)}
</p> </p>
{showBranchSubtitle ? ( {showBranchSubtitle ? (
<span className="text-[10px] sm:text-[11px] text-muted-foreground/80 truncate leading-tight"> <span className="text-[10px] sm:text-[11px] text-muted-foreground/80 truncate leading-tight">
@@ -2917,7 +3206,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
> >
{renderFolderItems()} {renderFolderItems()}
{visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId))} {visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId))}
{totalSessions === 0 && scopeFolders.length === 0 ? ( {totalSessions === 0 && allFoldersForGroup.length === 0 ? (
<div className="py-1 text-left typography-micro text-muted-foreground"> <div className="py-1 text-left typography-micro text-muted-foreground">
No sessions in this workspace yet. No sessions in this workspace yet.
</div> </div>
@@ -2950,6 +3239,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
expandedSessionGroups, expandedSessionGroups,
collapsedGroups, collapsedGroups,
hideDirectoryControls, hideDirectoryControls,
hasSessionSearchQuery,
normalizedSessionSearchQuery,
groupSearchDataByGroup,
currentSessionDirectory, currentSessionDirectory,
projectRepoStatus, projectRepoStatus,
renderSessionNode, renderSessionNode,
@@ -2988,6 +3280,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
return ( return (
<div <div
ref={sessionSearchContainerRef}
className={cn( className={cn(
'flex h-full flex-col text-foreground overflow-x-hidden', 'flex h-full flex-col text-foreground overflow-x-hidden',
mobileVariant ? '' : 'bg-transparent', mobileVariant ? '' : 'bg-transparent',
@@ -3115,8 +3408,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
</div> </div>
)} )}
{reserveHeaderActionsSpace ? ( {reserveHeaderActionsSpace ? (
<div className="-ml-1 flex h-8 items-center"> <div className="-ml-1 flex h-auto min-h-8 flex-col gap-1">
{activeProjectForHeader ? ( {activeProjectForHeader ? (
<>
<div className="flex h-8 -translate-y-px items-center gap-1.5 rounded-md pl-0 pr-1"> <div className="flex h-8 -translate-y-px items-center gap-1.5 rounded-md pl-0 pr-1">
{stableActiveProjectIsRepo ? ( {stableActiveProjectIsRepo ? (
<> <>
@@ -3195,7 +3489,62 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setIsSessionSearchOpen((prev) => !prev)}
className={headerActionButtonClass}
aria-label="Search sessions"
aria-expanded={isSessionSearchOpen}
>
<RiSearchLine className={headerActionIconClass} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>Search sessions</p></TooltipContent>
</Tooltip>
</div> </div>
{isSessionSearchOpen ? (
<div className="px-1 pb-1">
<div className="mb-1 flex items-center justify-between px-0.5 typography-micro text-muted-foreground/80">
{hasSessionSearchQuery ? (
<span>{searchMatchCount} {searchMatchCount === 1 ? 'match' : 'matches'}</span>
) : <span />}
<span>Esc to clear</span>
</div>
<div className="relative">
<RiSearchLine className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
ref={sessionSearchInputRef}
value={sessionSearchQuery}
onChange={(event) => 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 ? (
<button
type="button"
onClick={() => setSessionSearchQuery('')}
className="absolute right-1 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label="Clear search"
>
<RiCloseLine className="h-3.5 w-3.5" />
</button>
) : null}
</div>
</div>
) : null}
</>
) : null} ) : null}
</div> </div>
) : null} ) : null}
@@ -3208,12 +3557,14 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
> >
{projectSections.length === 0 ? ( {projectSections.length === 0 ? (
emptyState emptyState
) : sectionsForRender.length === 0 ? (
searchEmptyState
) : showOnlyMainWorkspace ? ( ) : showOnlyMainWorkspace ? (
<div className="space-y-[0.6rem] py-1"> <div className="space-y-[0.6rem] py-1">
{(() => { {(() => {
const activeSection = projectSections.find((section) => section.project.id === activeProjectId) ?? projectSections[0]; const activeSection = sectionsForRender.find((section) => section.project.id === activeProjectId) ?? sectionsForRender[0];
if (!activeSection) { if (!activeSection) {
return emptyState; return hasSessionSearchQuery ? searchEmptyState : emptyState;
} }
// VS Code sessions view typically only shows one workspace, but sessions may live in worktrees or // 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 // 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<SessionSidebarProps> = ({
</div> </div>
) : ( ) : (
<> <>
{visibleProjectSections.map((section) => { {sectionsForRender.map((section) => {
const project = section.project; const project = section.project;
const projectKey = project.id; const projectKey = project.id;
const projectLabel = formatProjectLabel( const projectLabel = formatProjectLabel(