feat(sessions): add exact ID search to sidebar and archive (#3428)

This commit is contained in:
𝖎𝖚𝖑𝖎𝖎𝖆
2026-09-08 15:42:32 +03:00
committed by GitHub
parent 05915e2859
commit 9ef235a6b4
10 changed files with 203 additions and 12 deletions
@@ -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.
@@ -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 },
@@ -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];
}
@@ -37,7 +37,7 @@ 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 => {
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'),
@@ -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,
@@ -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 }),
@@ -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 },
@@ -217,6 +217,7 @@ export const selectFolderIdsForProjection = (
entries: readonly FolderProjectionEntry[],
options: FolderProjectionOptions,
): Set<string> => {
const isIdQuery = options.searchQuery.trim().toLowerCase().startsWith('ses_');
const entryById = new Map(entries.map((entry) => [entry.id, entry]));
const childIdsByParentId = new Map<string, string[]>();
const malformedIds = new Set<string>();
@@ -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);
}
@@ -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<string, PropertyDescriptor | undefined>();
// 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(<I18nProvider><ArchiveView /></I18nProvider>));
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);
});
@@ -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;