feat(sessions): add exact ID search to sidebar and archive (#3428)
This commit is contained in:
@@ -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];
|
||||
}
|
||||
|
||||
+64
-6
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user