diff --git a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md index 3fdd2981..8ddb673a 100644 --- a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md +++ b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md @@ -54,6 +54,18 @@ only changes priority. Row mounts must not start bootstrap work. Selection and activity subscriptions stay session-scoped so a structural list update does not make every row observe unrelated streaming updates. +## Search + +Sidebar and Recent queries beginning with `ses_` match only the full session ID, +case-insensitively and ignoring surrounding whitespace. Partial IDs and typos +return no matches, without falling back to titles, directories, group labels, +or folder names. Ancestors remain as tree context for a matching child. A matched +node keeps its subtree for rendering and subtree actions. Only exact ID matches +count toward the result total. +ID search does not include archived sessions. `ArchiveView` applies the same +exact-ID rule to its own archived list. Other queries keep each view's existing +matching and ordering. Search does not fetch sessions or broaden list membership. + ## Loading rules - Always publish every known project root and worktree directory. Collapse/visibility changes priority only; they do not opt a directory out of authoritative refresh. diff --git a/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.test.ts b/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.test.ts index 6641ff6c..7d3455d5 100644 --- a/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.test.ts +++ b/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.test.ts @@ -31,6 +31,16 @@ describe('normalizeFolderRoots', () => { }); describe('selectFolderIdsForProjection', () => { + test('ID queries retain folders containing results, not folders named after the ID', () => { + const folders = [ + { id: 'root', name: 'Root', parentId: null, nodeCount: 0 }, + { id: 'child', name: 'Results', parentId: 'root', nodeCount: 1 }, + { id: 'unrelated', name: 'ses_f88b1a2b3c4d', parentId: null, nodeCount: 0 }, + ]; + expect([...selectFolderIdsForProjection(folders, { archivedBucket: false, searchQuery: ' SES_F88B1A2B3C4D ' })]) + .toEqual(['root', 'child']); + }); + const malformedFolders = [ { id: 'cycle-a', name: 'cycle-a', parentId: 'cycle-b', nodeCount: 0 }, { id: 'cycle-b', name: 'cycle-b', parentId: 'cycle-a', nodeCount: 1 }, diff --git a/packages/ui/src/components/session/sidebar/projects/useSessionGrouping.ts b/packages/ui/src/components/session/sidebar/projects/useSessionGrouping.ts index cc0a17be..c8f20e23 100644 --- a/packages/ui/src/components/session/sidebar/projects/useSessionGrouping.ts +++ b/packages/ui/src/components/session/sidebar/projects/useSessionGrouping.ts @@ -50,8 +50,13 @@ export const useSessionGrouping = (args: Args) => { return nodes; } + const normalizedQuery = query.trim().toLowerCase(); + const isIdQuery = normalizedQuery.startsWith('ses_'); return nodes.flatMap((node) => { - const nodeMatches = matchesRankQuery([buildSessionSearchText(node.session)], query); + if (isIdQuery && isArchivedSession(node.session)) return []; + const nodeMatches = isIdQuery + ? node.session.id.toLowerCase() === normalizedQuery + : matchesRankQuery([buildSessionSearchText(node.session)], query); if (nodeMatches) { return [node]; } diff --git a/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.test.tsx b/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.test.tsx index 0d19e9e2..71be0aaa 100644 --- a/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.test.tsx +++ b/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.test.tsx @@ -37,7 +37,7 @@ 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 => { +const renderSections = (group: SessionGroup, query: string, projectSessions?: Session[]): Sections => { let captured: Sections | null = null; const Harness = () => { const grouping = useSessionGrouping({ @@ -49,9 +49,9 @@ const renderSections = (group: SessionGroup, query: string): Sections => { isVSCode: false, }); captured = useSessionSidebarSections({ - normalizedProjects: [], - getSessionsForProject: () => [], - getArchivedSessionsForProject: () => [], + normalizedProjects: projectSessions ? [{ id: 'project', path: CHATS_ROOT, normalizedPath: CHATS_ROOT }] : [], + getSessionsForProject: () => projectSessions?.filter((session) => !session.time.archived) ?? [], + getArchivedSessionsForProject: () => projectSessions?.filter((session) => Boolean(session.time.archived)) ?? [], availableWorktreesByProject: new Map(), projectRepoStatus: new Map(), projectRootBranches: new Map(), @@ -62,8 +62,8 @@ const renderSections = (group: SessionGroup, query: string): Sections => { normalizedSessionSearchQuery: query, filterSessionNodesForSearch: grouping.filterSessionNodesForSearch, buildGroupSearchText: grouping.buildGroupSearchText, - foldersMap: {}, - standaloneGroups: [group], + foldersMap: { [CHATS_ROOT]: [{ id: 'folder', name: group.label, sessionIds: [], createdAt: 1 }] }, + standaloneGroups: projectSessions ? [] : [group], }); return null; }; @@ -78,6 +78,64 @@ const renderSections = (group: SessionGroup, query: string): Sections => { // `filteredNodes ?? []` — so every chat disappeared as soon as a query was // typed, however well its title matched. describe('sidebar search over standalone groups', () => { + const targetId = 'ses_f88b1a2b3c4d'; + + test('finds project sessions in flat results without searching their archived bucket', () => { + const active = { ...chatSession(targetId, 'Active'), directory: CHATS_ROOT }; + const archived = { ...chatSession('ses_archived', 'Archived'), directory: CHATS_ROOT, time: { created: 1, updated: 1, archived: 2 } }; + const group = chatsGroup([]); + const sections = renderSections(group, targetId, [active, archived]); + expect(sections.flatSectionsForRender[0].groups[0].sessions.map((node) => node.session.id)).toEqual([targetId]); + expect(sections.searchMatchCount).toBe(1); + const archivedSearch = renderSections(group, archived.id, [active, archived]); + expect(archivedSearch.flatSectionsForRender).toEqual([]); + expect(archivedSearch.searchMatchCount).toBe(0); + }); + + test('matches only a complete ID, ignoring case and surrounding whitespace', () => { + const group = chatsGroup([ + chatSession(targetId, 'Release notes'), + chatSession('ses_f88b1a2b3c4e', targetId), + ]); + group.label = targetId; + for (const query of [targetId, ` ${targetId.toUpperCase()}\n`]) { + const sections = renderSections(group, query); + const data = sections.groupSearchDataByGroup.get(group); + expect(data?.filteredNodes.map((node) => node.session.id)).toEqual([targetId]); + expect(data?.groupMatches).toBe(false); + expect(data?.folderNameMatchCount).toBe(0); + expect(sections.searchMatchCount).toBe(1); + } + for (const query of ['ses_', 'ses_f88b', 'ses_f88b1a2b3c4f', `${targetId}x`, `${targetId} error`]) { + expect(renderSections(group, query).searchMatchCount).toBe(0); + } + }); + + test('keeps tree context and counts only the ID match', () => { + const group = chatsGroup([chatSession('ses_parent', 'Parent')]); + const parent = group.sessions[0]; + parent.children = [ + { session: chatSession(targetId, 'Child'), children: [], worktree: null }, + { session: chatSession('ses_sibling', 'Sibling'), children: [], worktree: null }, + ]; + const sections = renderSections(group, targetId); + const nodes = sections.groupSearchDataByGroup.get(group)?.filteredNodes; + expect(nodes?.map((node) => node.session.id)).toEqual(['ses_parent']); + expect(nodes?.[0].children.map((node) => node.session.id)).toEqual([targetId]); + expect(sections.searchMatchCount).toBe(1); + const parentSections = renderSections(group, 'ses_parent'); + expect(parentSections.groupSearchDataByGroup.get(group)?.filteredNodes[0]).toBe(parent); + expect(parentSections.searchMatchCount).toBe(1); + expect(parent.children).toHaveLength(2); + }); + + test('does not return archived sessions for an ID query', () => { + const archived = chatSession(targetId, 'Archived'); + archived.time.archived = 2; + const group = chatsGroup([archived]); + expect(renderSections(group, targetId).searchMatchCount).toBe(0); + }); + test('keeps a matching chat in the group the sidebar renders', () => { const group = chatsGroup([ chatSession('ses_a', 'Release notes for 1.21'), diff --git a/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.ts b/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.ts index 35cc465f..89504c79 100644 --- a/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.ts +++ b/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.ts @@ -195,15 +195,19 @@ export const useSessionSidebarSections = (args: Args) => { return result; } - const countNodes = (nodes: SessionNode[]): number => nodes.reduce((total, node) => total + 1 + countNodes(node.children), 0); + const idQuery = normalizedSessionSearchQuery.trim().toLowerCase(); + const isIdQuery = idQuery.startsWith('ses_'); + const countNodes = (nodes: SessionNode[]): number => nodes.reduce((total, node) => ( + total + (!isIdQuery || node.session.id.toLowerCase() === idQuery ? 1 : 0) + countNodes(node.children) + ), 0); const addSearchData = (group: SessionGroup) => { const filteredNodes = filterSessionNodesForSearch(group.sessions, normalizedSessionSearchQuery); const matchedSessionCount = countNodes(filteredNodes); - const groupMatches = matchesRankQuery([buildGroupSearchText(group)], normalizedSessionSearchQuery); + const groupMatches = !isIdQuery && 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 folderNameMatchCount = isIdQuery ? 0 : scopeFolders.filter((folder) => matchesRankQuery([folder.name], normalizedSessionSearchQuery)).length; result.set(group, { filteredNodes, diff --git a/packages/ui/src/components/session/sidebar/recent/activitySections.test.ts b/packages/ui/src/components/session/sidebar/recent/activitySections.test.ts index d711babe..eebca5cd 100644 --- a/packages/ui/src/components/session/sidebar/recent/activitySections.test.ts +++ b/packages/ui/src/components/session/sidebar/recent/activitySections.test.ts @@ -39,6 +39,24 @@ describe('deriveRecentSessions', () => { }); describe('deriveRecentActivitySections', () => { + test('matches full IDs only, without falling back to titles or changing the matched subtree', () => { + const target = { ...session('ses_f88b1a2b3c4d'), title: 'Release' }; + const other = { ...session('ses_f88b1a2b3c4e'), title: target.id }; + const node = { session: target, worktree: null, children: [{ session: other, worktree: null, children: [] }] }; + for (const query of [target.id, ` ${target.id.toUpperCase()} `, 'ses_f88b', 'ses_f88b1a2b3c4f']) { + const sections = deriveRecentActivitySections({ + sessions: [target, other], + getSessionLocation: () => null, + getSessionNode: () => node, + query, + }); + const expected = query.trim().toLowerCase() === target.id ? [target.id] : []; + expect(sections[0].items.map((item) => item.node.session.id)).toEqual(expected); + for (const item of sections[0].items) expect(item.node).toBe(node); + } + expect(node.children).toHaveLength(1); + }); + test('filters recent roots by search text and falls back to topology metadata', () => { const matching = { ...session('matching', { updated: RECENT }), diff --git a/packages/ui/src/components/session/sidebar/recent/activitySections.ts b/packages/ui/src/components/session/sidebar/recent/activitySections.ts index 0ffdbcdf..d333abc5 100644 --- a/packages/ui/src/components/session/sidebar/recent/activitySections.ts +++ b/packages/ui/src/components/session/sidebar/recent/activitySections.ts @@ -72,7 +72,12 @@ export const deriveRecentActivitySections = ({ key: 'active-now', items: sessions.flatMap((session) => { const title = typeof session.title === 'string' ? session.title.toLowerCase() : ''; - if (query && !title.includes(query)) return []; + const normalizedQuery = query.trim().toLowerCase(); + const isIdQuery = normalizedQuery.startsWith('ses_'); + const matches = isIdQuery + ? session.id.toLowerCase() === normalizedQuery + : !query || title.includes(query); + if (!matches) return []; const location = getSessionLocation(session.id); return [{ node: getSessionNode?.(session) ?? { session, children: [], worktree: null }, diff --git a/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.ts b/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.ts index f1840d24..dd7dfb16 100644 --- a/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.ts +++ b/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.ts @@ -217,6 +217,7 @@ export const selectFolderIdsForProjection = ( entries: readonly FolderProjectionEntry[], options: FolderProjectionOptions, ): Set => { + const isIdQuery = options.searchQuery.trim().toLowerCase().startsWith('ses_'); const entryById = new Map(entries.map((entry) => [entry.id, entry])); const childIdsByParentId = new Map(); const malformedIds = new Set(); @@ -260,7 +261,7 @@ export const selectFolderIdsForProjection = ( keep = (childIdsByParentId.get(folderId) ?? []).some(shouldKeep); } else { if (!keep && !options.searchQuery) keep = true; - if (!keep && (entry.nodeCount > 0 || matchesRankQuery([entry.name], options.searchQuery))) keep = true; + if (!keep && (entry.nodeCount > 0 || (!isIdQuery && matchesRankQuery([entry.name], options.searchQuery)))) keep = true; if (!keep) keep = (childIdsByParentId.get(folderId) ?? []).some(shouldKeep); } diff --git a/packages/ui/src/components/views/ArchiveView.test.tsx b/packages/ui/src/components/views/ArchiveView.test.tsx new file mode 100644 index 00000000..523d16e9 --- /dev/null +++ b/packages/ui/src/components/views/ArchiveView.test.tsx @@ -0,0 +1,75 @@ +import { afterAll, afterEach, beforeEach, expect, test } from 'bun:test'; +import React, { act } from 'react'; +import type { Root } from 'react-dom/client'; +import { Window } from 'happy-dom'; +import type { Session } from '@opencode-ai/sdk/v2'; +const browser = new Window({ url: 'http://localhost' }); +let root: Root; +const descriptors = new Map(); +// React DOM detects input-event support when imported, so install the DOM first. +for (const [key, value] of Object.entries({ window: browser, document: browser.document, navigator: browser.navigator, localStorage: browser.localStorage, Element: browser.Element, HTMLElement: browser.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true })) { + descriptors.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); + Object.defineProperty(globalThis, key, { value, configurable: true }); +} +const { createRoot } = await import('react-dom/client'); +const { I18nProvider } = await import('@/lib/i18n'); +const { useUIStore } = await import('@/stores/useUIStore'); +const { useGlobalSessionsStore } = await import('@/stores/useGlobalSessionsStore'); +const { ArchiveView } = await import('./ArchiveView'); +const initialUI = useUIStore.getState(); +const initialSessions = useGlobalSessionsStore.getState(); +const session = (id: string, title: string, archived = 2): Session => ({ + id, title, slug: id, projectID: 'project', version: '1', directory: '/workspace', + time: { created: 1, updated: 1, archived }, +}); + +beforeEach(() => { + const host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + useUIStore.setState({ isArchivePageOpen: true }); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + useUIStore.setState(initialUI); + useGlobalSessionsStore.setState(initialSessions); + document.body.replaceChildren(); +}); + +afterAll(async () => { + await browser.happyDOM.close(); + for (const [key, descriptor] of descriptors) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } +}); + +test('archive search uses exact IDs and preserves title search and archive membership', async () => { + const id = 'ses_f88b1a2b3c4d'; + useGlobalSessionsStore.setState({ + archivedSessions: [session(id, 'Release notes'), session('ses_f88b1a2b3c4e', id)], + activeSessions: [session('ses_active', 'Active session', 0)], + }); + await act(async () => root.render()); + const input = browser.document.querySelector('input'); + if (!input) throw new Error('Archive search input missing'); + const setValue = Object.getOwnPropertyDescriptor(browser.HTMLInputElement.prototype, 'value')?.set; + if (!setValue) throw new Error('Input value setter missing'); + const search = async (query: string) => { + await act(async () => { + setValue.call(input, query); + input.dispatchEvent(new browser.Event('input', { bubbles: true })); + input.dispatchEvent(new browser.Event('change', { bubbles: true })); + }); + return [...document.querySelectorAll('[role="button"] > span:first-child')].map((row) => row.textContent); + }; + expect(await search(id)).toEqual(['Release notes']); + expect(await search(` ${id.toUpperCase()} `)).toEqual(['Release notes']); + for (const query of ['ses_', 'ses_f88b', 'ses_f88b1a2b3c4f', `${id}x`, `${id} error`, 'ses_active']) { + expect(await search(query)).toEqual([]); + } + expect(await search('release')).toEqual(['Release notes']); + expect(await search('releaze')).toEqual(['Release notes']); + expect(await search('')).toHaveLength(2); +}); diff --git a/packages/ui/src/components/views/ArchiveView.tsx b/packages/ui/src/components/views/ArchiveView.tsx index 01748476..e6de5366 100644 --- a/packages/ui/src/components/views/ArchiveView.tsx +++ b/packages/ui/src/components/views/ArchiveView.tsx @@ -67,6 +67,9 @@ export function ArchiveView(): React.ReactNode { // while not searching. const filteredSessions = React.useMemo(() => { if (normalizedQuery) { + if (normalizedQuery.startsWith('ses_')) { + return sortedSessions.filter((session) => session.id.toLowerCase() === normalizedQuery); + } return rankByQuery(sortedSessions, normalizedQuery, (session) => [session.title]); } if (selectedDirectory === null) return sortedSessions;