fix(sidebar): search the managed chats along with project sessions (#3212)
* fix(sidebar): search the managed chats along with project sessions The sidebar builds search data per group, and every group renders `filteredNodes ?? []` while a query is active. Only project groups were ever given that data, so the managed chats — which render outside any project section — collapsed to an empty list as soon as anything was typed, however well a title matched. The header count ignored them for the same reason. Pass the chats group to the sections hook as a standalone group so it takes the same search pass and joins the match count. Building it before the hook runs keeps search-data ownership in one place instead of registering entries from the rendering component. No CHANGELOG entry: it is left out deliberately to keep this branch free of conflicts with other open PRs, and is to be written at release time. fixes #3200 * fix(sidebar): report the real search match count to the header The header rendered whatever `SessionSidebar` passed it, and that was a literal `0` — so "0 matches" was shown no matter how many sessions matched. The count exists: the sections hook already computes it, but only the session list can see it, and the header renders above the list. Report it upwards from the list instead of recomputing it in the header, so the number and the visible rows cannot disagree, and reset it to 0 when the list unmounts. fixes #3200 * fix(sidebar): keep chat-only search results on screen The chats render inside the scroller's top content, and the branch that handles "no project section matched" replaced the whole list with the empty state. A query matching only a chat therefore showed "No matching sessions" while the header — now reporting the real count — said one match, and the chat itself stayed hidden. That is the exact reproduction in the issue; the earlier manual check missed it because the query used also matched a project session. Tell the scroller when its top content holds results, and keep it on screen in that branch. The decision moves to `sessionProjectRender` next to the other render-selection helpers, where it can be tested without mounting the Vite-only component graph. fixes #3200
This commit is contained in:
@@ -77,6 +77,8 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
const { t } = useI18n();
|
||||
const [isSessionSearchOpen, setIsSessionSearchOpen] = React.useState(false);
|
||||
const [sessionSearchQuery, setSessionSearchQuery] = React.useState('');
|
||||
// Reported by the session list below: the header cannot see what matched.
|
||||
const [searchMatchCount, setSearchMatchCount] = React.useState(0);
|
||||
const sessionSearchContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const sessionSearchInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const [editingProjectDialogId, setEditingProjectDialogId] = React.useState<string | null>(null);
|
||||
@@ -631,7 +633,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
sessionSearchQuery={sessionSearchQuery}
|
||||
setSessionSearchQuery={setSessionSearchQuery}
|
||||
hasSessionSearchQuery={hasSessionSearchQuery}
|
||||
searchMatchCount={0}
|
||||
searchMatchCount={searchMatchCount}
|
||||
collapseAllProjects={projectView.actions.collapseAllProjects}
|
||||
expandAllProjects={projectView.actions.expandAllProjects}
|
||||
/>
|
||||
@@ -668,6 +670,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
isWorktreeTopologyLoading,
|
||||
unresolvedWorktreeProjectPaths,
|
||||
projectView: projectView.state,
|
||||
onSearchMatchCountChange: setSearchMatchCount,
|
||||
}}
|
||||
actions={{
|
||||
rowActions: {
|
||||
|
||||
@@ -35,6 +35,10 @@ import { isCapacitorApp } from '@/lib/platform';
|
||||
|
||||
const PR_NO_PR_RETRY_MS = 5 * 60_000;
|
||||
|
||||
// A stable empty array: without a chats group the sections hook must not see a
|
||||
// new reference on every render.
|
||||
const EMPTY_STANDALONE_GROUPS: SessionGroup[] = [];
|
||||
|
||||
const isRootSession = (session: Session): boolean => {
|
||||
// SAFETY: OpenCode attaches parentID to hierarchical session records,
|
||||
// although the SDK's base Session type does not currently declare it.
|
||||
@@ -84,6 +88,12 @@ type SessionProjectCollectionProps = {
|
||||
isWorktreeTopologyLoading: boolean;
|
||||
unresolvedWorktreeProjectPaths: ReadonlySet<string>;
|
||||
projectView: ReturnType<typeof useSessionProjectViewState>['state'];
|
||||
/**
|
||||
* The match count belongs in the sidebar header, which renders above this
|
||||
* list, while only the list knows what matched. Reported upwards rather
|
||||
* than recomputed there, so the number and the rows can never disagree.
|
||||
*/
|
||||
onSearchMatchCountChange: (count: number) => void;
|
||||
};
|
||||
actions: {
|
||||
rowActions: {
|
||||
@@ -181,7 +191,41 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
||||
[collection.archivedSessions, collection.sessions, topology.availableWorktreesByProject, topology.isVSCode, topology.projects],
|
||||
);
|
||||
const { getSessionsForProject, getArchivedSessionsForProject } = useProjectSessionLists({ ownership });
|
||||
const { projectSections, groupSearchDataByGroup, sectionsForRender, flatSectionsForRender } = useSessionSidebarSections({
|
||||
// Built before the sections hook runs, because that hook owns the search data
|
||||
// for every group the sidebar renders — the chats group included. A group the
|
||||
// hook never sees renders an empty list while a search is active.
|
||||
const chatGroup = React.useMemo<SessionGroup | null>(() => {
|
||||
if (topology.isVSCode) return null;
|
||||
const chatsRoot = getChatsRootForHome(view.homeDirectory)
|
||||
?? collection.chatSessions.map((session) => getChatsRootFromDirectory(session.directory)).find(Boolean)
|
||||
?? null;
|
||||
if (!chatsRoot) return null;
|
||||
const folderScopes = Array.from(new Set([
|
||||
chatsRoot,
|
||||
...collection.chatSessions.map((session) => normalizePath(session.directory ?? null)).filter(Boolean),
|
||||
])).filter((directory): directory is string => Boolean(directory))
|
||||
.map((directory) => ({ scopeKey: directory, directory }));
|
||||
return {
|
||||
id: 'managed-chats',
|
||||
label: '',
|
||||
branch: null,
|
||||
description: null,
|
||||
isMain: true,
|
||||
worktree: null,
|
||||
directory: chatsRoot,
|
||||
folderScopeKey: chatsRoot,
|
||||
folderScopes,
|
||||
draftTarget: 'chat',
|
||||
sessions: collection.chatSessions
|
||||
.filter((session) => !session.time?.archived && isRootSession(session))
|
||||
.map((session) => ({ session, children: (collection.childrenMap.get(session.id) ?? []).filter((child) => !child.time?.archived).map((child) => ({ session: child, children: [], worktree: null })), worktree: null })),
|
||||
};
|
||||
}, [collection.chatSessions, collection.childrenMap, topology.isVSCode, view.homeDirectory]);
|
||||
const standaloneGroups = React.useMemo<SessionGroup[]>(
|
||||
() => chatGroup ? [chatGroup] : EMPTY_STANDALONE_GROUPS,
|
||||
[chatGroup],
|
||||
);
|
||||
const { projectSections, groupSearchDataByGroup, sectionsForRender, flatSectionsForRender, searchMatchCount } = useSessionSidebarSections({
|
||||
normalizedProjects: topology.projects,
|
||||
getSessionsForProject,
|
||||
getArchivedSessionsForProject,
|
||||
@@ -195,8 +239,17 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
||||
filterSessionNodesForSearch,
|
||||
buildGroupSearchText,
|
||||
foldersMap,
|
||||
standaloneGroups,
|
||||
});
|
||||
|
||||
const onSearchMatchCountChange = view.onSearchMatchCountChange;
|
||||
React.useEffect(() => {
|
||||
onSearchMatchCountChange(searchMatchCount);
|
||||
}, [onSearchMatchCountChange, searchMatchCount]);
|
||||
// Unmounting means nothing is listed any more, so the header must not keep
|
||||
// showing the last count it was told about.
|
||||
React.useEffect(() => () => onSearchMatchCountChange(0), [onSearchMatchCountChange]);
|
||||
|
||||
// Second bootstrap-demand owner: the layout-level useSessionListSync keeps
|
||||
// every known directory alive at background priority even when the sidebar
|
||||
// is hidden, but only the visible collection knows which projects and
|
||||
@@ -379,33 +432,6 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
||||
scrollerActions.setActiveProjectIdOnly,
|
||||
scrollerActions.setSessionSwitcherOpen,
|
||||
]);
|
||||
const chatGroup = React.useMemo<SessionGroup | null>(() => {
|
||||
if (topology.isVSCode) return null;
|
||||
const chatsRoot = getChatsRootForHome(view.homeDirectory)
|
||||
?? collection.chatSessions.map((session) => getChatsRootFromDirectory(session.directory)).find(Boolean)
|
||||
?? null;
|
||||
if (!chatsRoot) return null;
|
||||
const folderScopes = Array.from(new Set([
|
||||
chatsRoot,
|
||||
...collection.chatSessions.map((session) => normalizePath(session.directory ?? null)).filter(Boolean),
|
||||
])).filter((directory): directory is string => Boolean(directory))
|
||||
.map((directory) => ({ scopeKey: directory, directory }));
|
||||
return {
|
||||
id: 'managed-chats',
|
||||
label: '',
|
||||
branch: null,
|
||||
description: null,
|
||||
isMain: true,
|
||||
worktree: null,
|
||||
directory: chatsRoot,
|
||||
folderScopeKey: chatsRoot,
|
||||
folderScopes,
|
||||
draftTarget: 'chat',
|
||||
sessions: collection.chatSessions
|
||||
.filter((session) => !session.time?.archived && isRootSession(session))
|
||||
.map((session) => ({ session, children: (collection.childrenMap.get(session.id) ?? []).filter((child) => !child.time?.archived).map((child) => ({ session: child, children: [], worktree: null })), worktree: null })),
|
||||
};
|
||||
}, [collection.chatSessions, collection.childrenMap, topology.isVSCode, view.homeDirectory]);
|
||||
const renderChatsSection = React.useCallback(() => {
|
||||
if (!chatGroup) return null;
|
||||
return <SessionGroupSection
|
||||
@@ -498,8 +524,14 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
||||
view.mobileVariant,
|
||||
view.normalizedSessionSearchQuery,
|
||||
]);
|
||||
// The chats live in the scroller's top content, which the "no project section
|
||||
// matched" branch drops. Tell the scroller when that content is itself a
|
||||
// search result, or a chat-only match renders as "no matches" (issue #3200).
|
||||
const topContentHasSearchMatches = view.hasSessionSearchQuery
|
||||
&& standaloneGroups.some((group) => groupSearchDataByGroup.get(group)?.hasMatch === true);
|
||||
const scrollerModel = React.useMemo(() => ({
|
||||
topContent: recentSection,
|
||||
topContentHasSearchMatches,
|
||||
hasSharedSessions: Boolean(recentSection),
|
||||
sectionsForRender: orderedSectionsForRender,
|
||||
projectSections,
|
||||
@@ -526,6 +558,7 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
||||
view.searchEmptyState,
|
||||
visibleSessionCountByGroup,
|
||||
recentSection,
|
||||
topContentHasSearchMatches,
|
||||
singleProjectMode,
|
||||
selectedSingleProjectId,
|
||||
]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { buildGroupRenderDescriptors, selectRenderedProjectSections } from './sessionProjectRender';
|
||||
import { buildGroupRenderDescriptors, resolveSearchResultPlacement, selectRenderedProjectSections } from './sessionProjectRender';
|
||||
import type { SessionGroup } from '../types';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
|
||||
@@ -89,3 +89,16 @@ describe('single-project scroller projection', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #3200: a query matching only a managed chat leaves no project section to
|
||||
// render. The chats live in the scroller's top content, so answering with the
|
||||
// empty state there hid a result the header was already counting.
|
||||
describe('resolveSearchResultPlacement', () => {
|
||||
test('keeps the top content when the only match lives there', () => {
|
||||
expect(resolveSearchResultPlacement(true)).toBe('top-content');
|
||||
});
|
||||
|
||||
test('falls back to the empty state when nothing matched anywhere', () => {
|
||||
expect(resolveSearchResultPlacement(false)).toBe('empty-state');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils';
|
||||
import type { SessionGroup } from '../types';
|
||||
import { ProjectHeaderIdentity, SortableGroupItem, SortableProjectItem } from './sortableItems';
|
||||
import { SessionGroupSection, type SessionGroupSectionProps } from './SessionGroupSection';
|
||||
import { buildGroupRenderDescriptors, selectRenderedProjectSections, type ProjectSection } from './sessionProjectRender';
|
||||
import { buildGroupRenderDescriptors, resolveSearchResultPlacement, selectRenderedProjectSections, type ProjectSection } from './sessionProjectRender';
|
||||
import { formatProjectLabel } from '../utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore';
|
||||
@@ -75,6 +75,12 @@ type SessionProjectScrollerGroupActions = Pick<SessionGroupSectionProps,
|
||||
|
||||
type SessionProjectScrollerModel = {
|
||||
topContent?: React.ReactNode;
|
||||
/**
|
||||
* Whether the top content itself holds search results. The managed chats
|
||||
* render only there, so without this the "no project section matched" branch
|
||||
* below would drop a matching chat and claim there is nothing to show.
|
||||
*/
|
||||
topContentHasSearchMatches?: boolean;
|
||||
hasSharedSessions?: boolean;
|
||||
sectionsForRender: ProjectSection[];
|
||||
projectSections: ProjectSection[];
|
||||
@@ -225,7 +231,10 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
|
||||
}
|
||||
|
||||
if (model.sectionsForRender.length === 0) {
|
||||
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className="space-y-1 pb-1 pl-2.5 pr-2">{model.searchEmptyState}</ScrollableOverlay>;
|
||||
const placement = resolveSearchResultPlacement(model.topContentHasSearchMatches === true);
|
||||
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className="space-y-1 pb-1 pl-2.5 pr-2">
|
||||
{placement === 'top-content' ? model.topContent : model.searchEmptyState}
|
||||
</ScrollableOverlay>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -21,6 +21,19 @@ export const selectRenderedProjectSections = (
|
||||
? sections.filter((section) => section.project.id === singleProjectId)
|
||||
: sections;
|
||||
|
||||
/**
|
||||
* What the sidebar shows when a search leaves no project section to render.
|
||||
*
|
||||
* The managed chats live in the scroller's top content rather than in a project
|
||||
* section, so a query that matches only a chat empties `sectionsForRender` while
|
||||
* a real result is still on screen above it. Answering `top-content` there keeps
|
||||
* that result visible; answering `empty-state` before checking it hid the chat
|
||||
* and claimed nothing matched, while the header counted the match (issue #3200).
|
||||
*/
|
||||
export const resolveSearchResultPlacement = (
|
||||
topContentHasSearchMatches: boolean,
|
||||
): 'top-content' | 'empty-state' => topContentHasSearchMatches ? 'top-content' : 'empty-state';
|
||||
|
||||
type GroupRenderDescriptor = {
|
||||
group: SessionGroup;
|
||||
groupKey: string;
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { I18nProvider } from '@/lib/i18n';
|
||||
import { useSessionGrouping } from './useSessionGrouping';
|
||||
import { useSessionSidebarSections } from './useSessionSidebarSections';
|
||||
import type { SessionGroup } from '../types';
|
||||
|
||||
const CHATS_ROOT = '/home/user/.config/openchamber/chats';
|
||||
|
||||
const chatSession = (id: string, title: string): Session => ({
|
||||
id,
|
||||
slug: id,
|
||||
projectID: 'chats',
|
||||
title,
|
||||
version: '1',
|
||||
directory: `${CHATS_ROOT}/2026-08-28/session-${id}`,
|
||||
time: { created: 1, updated: 1 },
|
||||
});
|
||||
|
||||
const chatsGroup = (sessions: Session[]): SessionGroup => ({
|
||||
id: 'managed-chats',
|
||||
label: '',
|
||||
branch: null,
|
||||
description: null,
|
||||
isMain: true,
|
||||
worktree: null,
|
||||
directory: CHATS_ROOT,
|
||||
folderScopeKey: CHATS_ROOT,
|
||||
folderScopes: [{ scopeKey: CHATS_ROOT, directory: CHATS_ROOT }],
|
||||
draftTarget: 'chat',
|
||||
sessions: sessions.map((session) => ({ session, children: [], worktree: null })),
|
||||
});
|
||||
|
||||
type Sections = ReturnType<typeof useSessionSidebarSections>;
|
||||
|
||||
// The real matcher and the real grouping callbacks run here: the reported bug
|
||||
// was never about matching, so a stubbed matcher would test nothing.
|
||||
const renderSections = (group: SessionGroup, query: string): Sections => {
|
||||
let captured: Sections | null = null;
|
||||
const Harness = () => {
|
||||
const grouping = useSessionGrouping({
|
||||
homeDirectory: '/home/user',
|
||||
worktreeMetadata: new Map(),
|
||||
pinnedSessionIds: new Set(),
|
||||
sessionOrderRanks: new Map(),
|
||||
gitBranches: new Map(),
|
||||
isVSCode: false,
|
||||
});
|
||||
captured = useSessionSidebarSections({
|
||||
normalizedProjects: [],
|
||||
getSessionsForProject: () => [],
|
||||
getArchivedSessionsForProject: () => [],
|
||||
availableWorktreesByProject: new Map(),
|
||||
projectRepoStatus: new Map(),
|
||||
projectRootBranches: new Map(),
|
||||
lastRepoStatus: false,
|
||||
buildGroupedSessions: grouping.buildGroupedSessions,
|
||||
hasSessionSearchQuery: query.length > 0,
|
||||
normalizedSessionSearchQuery: query,
|
||||
filterSessionNodesForSearch: grouping.filterSessionNodesForSearch,
|
||||
buildGroupSearchText: grouping.buildGroupSearchText,
|
||||
foldersMap: {},
|
||||
standaloneGroups: [group],
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
renderToStaticMarkup(React.createElement(I18nProvider, null, React.createElement(Harness)));
|
||||
if (!captured) throw new Error('sections hook was not mounted');
|
||||
return captured;
|
||||
};
|
||||
|
||||
// Issue #3200: the managed chats render outside every project section. They
|
||||
// were left out of the search pass, and a group without search data renders
|
||||
// `filteredNodes ?? []` — so every chat disappeared as soon as a query was
|
||||
// typed, however well its title matched.
|
||||
describe('sidebar search over standalone groups', () => {
|
||||
test('keeps a matching chat in the group the sidebar renders', () => {
|
||||
const group = chatsGroup([
|
||||
chatSession('ses_a', 'Release notes for 1.21'),
|
||||
chatSession('ses_b', 'Unrelated grocery list'),
|
||||
]);
|
||||
|
||||
const sections = renderSections(group, 'release');
|
||||
const data = sections.groupSearchDataByGroup.get(group);
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(data?.filteredNodes.map((node) => node.session.id)).toEqual(['ses_a']);
|
||||
expect(data?.hasMatch).toBe(true);
|
||||
});
|
||||
|
||||
test('counts chat matches in the header count', () => {
|
||||
const group = chatsGroup([
|
||||
chatSession('ses_a', 'Release notes for 1.21'),
|
||||
chatSession('ses_b', 'Release checklist'),
|
||||
chatSession('ses_c', 'Unrelated grocery list'),
|
||||
]);
|
||||
|
||||
expect(renderSections(group, 'release').searchMatchCount).toBe(2);
|
||||
});
|
||||
|
||||
test('reports no match for a chat group nothing matches in', () => {
|
||||
const group = chatsGroup([chatSession('ses_a', 'Release notes for 1.21')]);
|
||||
|
||||
const sections = renderSections(group, 'groceries');
|
||||
const data = sections.groupSearchDataByGroup.get(group);
|
||||
|
||||
expect(data?.filteredNodes).toEqual([]);
|
||||
expect(data?.hasMatch).toBe(false);
|
||||
expect(sections.searchMatchCount).toBe(0);
|
||||
});
|
||||
|
||||
test('skips the search pass entirely when no query is active', () => {
|
||||
const group = chatsGroup([chatSession('ses_a', 'Release notes for 1.21')]);
|
||||
|
||||
const sections = renderSections(group, '');
|
||||
|
||||
expect(sections.groupSearchDataByGroup.has(group)).toBe(false);
|
||||
expect(sections.searchMatchCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,13 @@ type Args = {
|
||||
filterSessionNodesForSearch: (nodes: SessionNode[], query: string) => SessionNode[];
|
||||
buildGroupSearchText: (group: SessionGroup) => string;
|
||||
foldersMap: SessionFoldersMap;
|
||||
/**
|
||||
* Groups the sidebar renders outside any project section — today the managed
|
||||
* chats. They search like every other group: a group with no search data
|
||||
* renders its filtered nodes as an empty list, so leaving them out made every
|
||||
* chat vanish the moment a query was typed.
|
||||
*/
|
||||
standaloneGroups: SessionGroup[];
|
||||
};
|
||||
|
||||
export const useSessionSidebarSections = (args: Args) => {
|
||||
@@ -73,6 +80,7 @@ export const useSessionSidebarSections = (args: Args) => {
|
||||
filterSessionNodesForSearch,
|
||||
buildGroupSearchText,
|
||||
foldersMap,
|
||||
standaloneGroups,
|
||||
} = args;
|
||||
const projectSectionCacheRef = React.useRef<Map<string, ProjectSectionCacheEntry>>(new Map());
|
||||
|
||||
@@ -158,29 +166,33 @@ export const useSessionSidebarSections = (args: Args) => {
|
||||
|
||||
const countNodes = (nodes: SessionNode[]): number => 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 = matchesRankQuery([buildGroupSearchText(group)], normalizedSessionSearchQuery);
|
||||
const scopeKey = normalizePath(group.directory ?? null);
|
||||
const scopeFolders = scopeKey ? (foldersMap[scopeKey] ?? []) : [];
|
||||
const folderNameMatchCount = scopeFolders.filter((folder) => matchesRankQuery([folder.name], normalizedSessionSearchQuery)).length;
|
||||
const addSearchData = (group: SessionGroup) => {
|
||||
const filteredNodes = filterSessionNodesForSearch(group.sessions, normalizedSessionSearchQuery);
|
||||
const matchedSessionCount = countNodes(filteredNodes);
|
||||
const groupMatches = matchesRankQuery([buildGroupSearchText(group)], normalizedSessionSearchQuery);
|
||||
const scopeKey = normalizePath(group.directory ?? null);
|
||||
const scopeFolders = scopeKey ? (foldersMap[scopeKey] ?? []) : [];
|
||||
const folderNameMatchCount = scopeFolders.filter((folder) => matchesRankQuery([folder.name], normalizedSessionSearchQuery)).length;
|
||||
|
||||
result.set(group, {
|
||||
filteredNodes,
|
||||
matchedSessionCount,
|
||||
folderNameMatchCount,
|
||||
groupMatches,
|
||||
hasMatch: groupMatches || matchedSessionCount > 0 || folderNameMatchCount > 0,
|
||||
});
|
||||
result.set(group, {
|
||||
filteredNodes,
|
||||
matchedSessionCount,
|
||||
folderNameMatchCount,
|
||||
groupMatches,
|
||||
hasMatch: groupMatches || matchedSessionCount > 0 || folderNameMatchCount > 0,
|
||||
});
|
||||
};
|
||||
|
||||
visibleProjectSections.forEach((section) => {
|
||||
section.groups.forEach(addSearchData);
|
||||
});
|
||||
standaloneGroups.forEach(addSearchData);
|
||||
|
||||
return result;
|
||||
}, [
|
||||
hasSessionSearchQuery,
|
||||
visibleProjectSections,
|
||||
standaloneGroups,
|
||||
filterSessionNodesForSearch,
|
||||
normalizedSessionSearchQuery,
|
||||
buildGroupSearchText,
|
||||
@@ -271,17 +283,23 @@ export const useSessionSidebarSections = (args: Args) => {
|
||||
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 countGroup = (total: number, group: SessionGroup): number => {
|
||||
const data = groupSearchDataByGroup.get(group);
|
||||
if (!data) {
|
||||
return total;
|
||||
}
|
||||
const metadataMatches = data.folderNameMatchCount + (data.groupMatches ? 1 : 0);
|
||||
return total + data.matchedSessionCount + metadataMatches;
|
||||
};
|
||||
|
||||
const projectMatches = sectionsForRender.reduce(
|
||||
(total, section) => section.groups.reduce(countGroup, total),
|
||||
0,
|
||||
);
|
||||
// Chats the user can see in the list count as matches too, or the header
|
||||
// reports zero while their results sit right underneath it.
|
||||
return standaloneGroups.reduce(countGroup, projectMatches);
|
||||
}, [hasSessionSearchQuery, sectionsForRender, standaloneGroups, groupSearchDataByGroup]);
|
||||
|
||||
return {
|
||||
projectSections,
|
||||
|
||||
Reference in New Issue
Block a user