diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index a2f6fe1d..0e5e11e1 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -77,6 +77,8 @@ const SessionSidebarComponent: React.FC = ({ 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(null); const sessionSearchInputRef = React.useRef(null); const [editingProjectDialogId, setEditingProjectDialogId] = React.useState(null); @@ -631,7 +633,7 @@ const SessionSidebarComponent: React.FC = ({ 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 = ({ isWorktreeTopologyLoading, unresolvedWorktreeProjectPaths, projectView: projectView.state, + onSearchMatchCountChange: setSearchMatchCount, }} actions={{ rowActions: { diff --git a/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx b/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx index 1e37bfc0..d6ccadb6 100644 --- a/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx +++ b/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx @@ -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; projectView: ReturnType['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 = ({ 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(() => { + 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( + () => 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 = ({ 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 = ({ topol scrollerActions.setActiveProjectIdOnly, scrollerActions.setSessionSwitcherOpen, ]); - const chatGroup = React.useMemo(() => { - 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 = ({ 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 = ({ topol view.searchEmptyState, visibleSessionCountByGroup, recentSection, + topContentHasSearchMatches, singleProjectMode, selectedSingleProjectId, ]); diff --git a/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.test.ts b/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.test.ts index 11fc263f..b159230f 100644 --- a/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.test.ts +++ b/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.test.ts @@ -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'); + }); +}); diff --git a/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.tsx b/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.tsx index 9a8c854b..ab3864ea 100644 --- a/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.tsx +++ b/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.tsx @@ -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{model.searchEmptyState}; + const placement = resolveSearchResultPlacement(model.topContentHasSearchMatches === true); + return + {placement === 'top-content' ? model.topContent : model.searchEmptyState} + ; } return ( diff --git a/packages/ui/src/components/session/sidebar/projects/sessionProjectRender.ts b/packages/ui/src/components/session/sidebar/projects/sessionProjectRender.ts index 8f5d0ea6..15c9db3e 100644 --- a/packages/ui/src/components/session/sidebar/projects/sessionProjectRender.ts +++ b/packages/ui/src/components/session/sidebar/projects/sessionProjectRender.ts @@ -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; diff --git a/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.test.tsx b/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.test.tsx new file mode 100644 index 00000000..6b4038e8 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.test.tsx @@ -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; + +// 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); + }); +}); diff --git a/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.ts b/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.ts index 90658193..e8976177 100644 --- a/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.ts +++ b/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.ts @@ -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>(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,