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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
bcbc2646fd
commit
11b995d7bd
@@ -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(
|
||||
<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 = {
|
||||
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}
|
||||
<DragOverlay>
|
||||
{activeDragId && hasFolders ? (
|
||||
<div
|
||||
style={{
|
||||
<div
|
||||
style={{
|
||||
width: activeDragWidth ? `${activeDragWidth}px` : 'auto',
|
||||
height: activeDragHeight ? `${activeDragHeight}px` : 'auto'
|
||||
}}
|
||||
@@ -719,6 +769,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
hideProjectSelector = true,
|
||||
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 [editTitle, setEditTitle] = React.useState('');
|
||||
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 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<SessionSidebarProps> = ({
|
||||
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<SessionSidebarProps> = ({
|
||||
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<SessionSidebarProps> = ({
|
||||
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<SessionSidebarProps> = ({
|
||||
return active ? [active] : [projectSections[0]];
|
||||
}, [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(
|
||||
() => normalizedProjects.find((project) => project.id === activeProjectId) ?? normalizedProjects[0] ?? null,
|
||||
[normalizedProjects, activeProjectId],
|
||||
@@ -2110,7 +2342,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
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<SessionSidebarProps> = ({
|
||||
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<SessionSidebarProps> = ({
|
||||
<RiPushpinLine className="h-3 w-3 flex-shrink-0 text-primary" aria-label="Pinned session" />
|
||||
) : null}
|
||||
<div className="block min-w-0 flex-1 truncate typography-ui-label font-normal text-foreground">
|
||||
{sessionTitle}
|
||||
{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}
|
||||
</div>
|
||||
|
||||
{pendingPermissionCount > 0 ? (
|
||||
@@ -2568,6 +2800,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
sessionAttentionStates,
|
||||
permissions,
|
||||
currentSessionId,
|
||||
hasSessionSearchQuery,
|
||||
normalizedSessionSearchQuery,
|
||||
expandedParents,
|
||||
editingId,
|
||||
editTitle,
|
||||
@@ -2598,31 +2832,86 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
|
||||
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<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)
|
||||
// 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<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
|
||||
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<SessionSidebarProps> = ({
|
||||
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<SessionSidebarProps> = ({
|
||||
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<SessionSidebarProps> = ({
|
||||
>
|
||||
{renderFolderItems()}
|
||||
{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">
|
||||
No sessions in this workspace yet.
|
||||
</div>
|
||||
@@ -2827,7 +3116,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
) : null}
|
||||
<div className="min-w-0 flex flex-col justify-center">
|
||||
<p className={cn('text-[14px] font-semibold truncate', isActiveGroup ? 'text-primary' : 'text-muted-foreground')}>
|
||||
{group.label}
|
||||
{renderHighlightedText(group.label, normalizedSessionSearchQuery)}
|
||||
</p>
|
||||
{showBranchSubtitle ? (
|
||||
<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()}
|
||||
{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">
|
||||
No sessions in this workspace yet.
|
||||
</div>
|
||||
@@ -2950,6 +3239,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
expandedSessionGroups,
|
||||
collapsedGroups,
|
||||
hideDirectoryControls,
|
||||
hasSessionSearchQuery,
|
||||
normalizedSessionSearchQuery,
|
||||
groupSearchDataByGroup,
|
||||
currentSessionDirectory,
|
||||
projectRepoStatus,
|
||||
renderSessionNode,
|
||||
@@ -2988,6 +3280,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sessionSearchContainerRef}
|
||||
className={cn(
|
||||
'flex h-full flex-col text-foreground overflow-x-hidden',
|
||||
mobileVariant ? '' : 'bg-transparent',
|
||||
@@ -3115,8 +3408,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
</div>
|
||||
)}
|
||||
{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 ? (
|
||||
<>
|
||||
<div className="flex h-8 -translate-y-px items-center gap-1.5 rounded-md pl-0 pr-1">
|
||||
{stableActiveProjectIsRepo ? (
|
||||
<>
|
||||
@@ -3195,7 +3489,62 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
</DropdownMenuContent>
|
||||
</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>
|
||||
{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}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -3208,12 +3557,14 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
>
|
||||
{projectSections.length === 0 ? (
|
||||
emptyState
|
||||
) : sectionsForRender.length === 0 ? (
|
||||
searchEmptyState
|
||||
) : showOnlyMainWorkspace ? (
|
||||
<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) {
|
||||
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<SessionSidebarProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{visibleProjectSections.map((section) => {
|
||||
{sectionsForRender.map((section) => {
|
||||
const project = section.project;
|
||||
const projectKey = project.id;
|
||||
const projectLabel = formatProjectLabel(
|
||||
|
||||
Reference in New Issue
Block a user