feat(ui): migrate all shortcut surfaces to the centralized registry

This commit is contained in:
Bohdan Triapitsyn
2026-08-26 10:59:04 +03:00
parent f1c3870909
commit 94f6b5fd38
24 changed files with 1057 additions and 904 deletions
@@ -0,0 +1,44 @@
import { describe, expect, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import {
findSwitcherItemAncestorIds,
selectSwitcherParents,
type SwitcherItem,
} from './useSwitcherItems';
const session = (id: string, options: { parentID?: string; archived?: boolean; projectId?: string } = {}): Session => ({
id,
parentID: options.parentID,
time: options.archived ? { archived: Date.now() } : undefined,
projectId: options.projectId ?? 'project-a',
} as unknown as Session);
const selectParents = (sessions: Session[], currentSessionId: string | null, scopeProjectId: string | null = null): Session[] => (
selectSwitcherParents(sessions, new Set(), new Map(), scopeProjectId, currentSessionId, (item) => (item as Session & { projectId: string }).projectId)
);
describe('session switcher initial selection', () => {
test('finds all local ancestors for a current child session', () => {
const items: SwitcherItem[] = [{
node: { session: session('root'), worktree: null, children: [{ session: session('parent', { parentID: 'root' }), worktree: null, children: [{ session: session('child', { parentID: 'parent' }), worktree: null, children: [] }] }] },
projectId: 'project-a', groupDirectory: null, secondaryMeta: null,
}];
expect(findSwitcherItemAncestorIds(items, 'child')).toEqual(['root', 'parent']);
expect(findSwitcherItemAncestorIds(items, 'missing')).toBeNull();
});
test('replaces the final recent slot with the current root and excludes invalid current sessions', () => {
const roots = Array.from({ length: 8 }, (_, index) => session(`root-${index}`));
const child = session('child', { parentID: 'root-7' });
expect(selectParents([...roots, child], 'child').map((item) => item.id)).toEqual([
'root-0', 'root-1', 'root-2', 'root-3', 'root-4', 'root-5', 'root-7',
]);
expect(selectParents([...roots, child], 'missing').map((item) => item.id)).toEqual(roots.slice(0, 7).map((item) => item.id));
expect(selectParents([...roots, child], 'child', 'project-b').map((item) => item.id)).toEqual([]);
expect(selectParents([...roots.slice(0, 7), session('archived', { archived: true })], 'archived').map((item) => item.id)).toEqual(roots.slice(0, 7).map((item) => item.id));
expect(selectParents([...roots, session('archived-child', { archived: true, parentID: 'root-7' })], 'archived-child').map((item) => item.id)).toEqual(roots.slice(0, 7).map((item) => item.id));
});
});
@@ -27,6 +27,7 @@ const MAX_PARENT_SESSIONS = 7;
type SwitcherItemsOptions = {
scopeProjectId?: string | null;
currentSessionId?: string | null;
/** How many parent sessions to return (default 7 — the desktop dropdown). */
maxParents?: number;
};
@@ -46,8 +47,69 @@ const formatProjectLabel = (project: { label?: string | null; path: string } | n
return segments[segments.length - 1] ?? null;
};
export const findSwitcherItemAncestorIds = (items: SwitcherItem[], sessionId: string): string[] | null => {
const visit = (node: SessionNode, ancestors: string[]): string[] | null => {
if (node.session.id === sessionId) return ancestors;
for (const child of node.children) {
const result = visit(child, [...ancestors, node.session.id]);
if (result) return result;
}
return null;
};
for (const item of items) {
const result = visit(item.node, []);
if (result) return result;
}
return null;
};
export const selectSwitcherParents = (
activeSessions: Session[],
pinnedSessionIds: Set<string>,
sessionOrderRanks: Map<string, number>,
scopeProjectId: string | null,
currentSessionId: string | null,
getProjectId: (session: Session) => string | null,
maxParents = MAX_PARENT_SESSIONS,
isExcluded?: (session: Session) => boolean,
): Session[] => {
const sessionsById = new Map(activeSessions.map((session) => [session.id, session]));
const isEligibleParent = (session: Session): boolean => {
if (session.time?.archived) return false;
if (isExcluded?.(session)) return false;
// SAFETY: the SDK Session type omits parentID, but the server includes it on child sessions.
if ((session as Session & { parentID?: string | null }).parentID) return false;
return !scopeProjectId || getProjectId(session) === scopeProjectId;
};
const parents = activeSessions
.filter(isEligibleParent)
.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks));
const currentSession = currentSessionId ? sessionsById.get(currentSessionId) ?? null : null;
let currentRoot: Session | null = currentSession?.time?.archived ? null : currentSession;
const visited = new Set<string>();
while (currentRoot) {
// SAFETY: the SDK Session type omits parentID, but the server includes it on child sessions.
const parentId = (currentRoot as Session & { parentID?: string | null }).parentID;
if (!parentId) break;
if (visited.has(parentId)) {
currentRoot = null;
break;
}
visited.add(parentId);
currentRoot = sessionsById.get(parentId) ?? null;
}
const currentRootIndex = currentRoot && isEligibleParent(currentRoot) ? parents.indexOf(currentRoot) : -1;
if (currentRootIndex >= maxParents) {
return [...parents.slice(0, Math.max(0, maxParents - 1)), currentRoot!];
}
return parents.slice(0, maxParents);
};
export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions = {}): SwitcherItem[] => {
const { scopeProjectId = null, maxParents = MAX_PARENT_SESSIONS } = options;
const { scopeProjectId = null, currentSessionId = null, maxParents = MAX_PARENT_SESSIONS } = options;
const activeSessions = useGlobalSessionsStore((state) => state.activeSessions);
const projects = useProjectsStore((state) => state.projects);
const pinnedSessionIds = useSessionPinnedStore((state) => state.ids);
@@ -116,19 +178,17 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
list.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks));
});
const parents = activeSessions
.filter((session) => !session.time?.archived)
const parents = selectSwitcherParents(
activeSessions,
pinnedSessionIds,
sessionOrderRanks,
scopeProjectId,
currentSessionId,
(session) => findProjectForDirectory(resolveGlobalSessionDirectory(session))?.id ?? null,
maxParents,
// btw forks stay hidden until promoted to a full session
.filter((session) => !isBtwSession(session))
.filter((session) => !isVSCode || !isChatDirectoryPath(resolveGlobalSessionDirectory(session)))
.filter((session) => !(session as Session & { parentID?: string | null }).parentID)
.filter((session) => {
if (!scopeProjectId) return true;
const directory = resolveGlobalSessionDirectory(session);
return findProjectForDirectory(directory)?.id === scopeProjectId;
})
.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks))
.slice(0, maxParents);
(session) => isBtwSession(session) || (isVSCode && isChatDirectoryPath(resolveGlobalSessionDirectory(session))),
);
const buildNode = (session: Session): SessionNode => {
const childSessions = childrenByParent.get(session.id) ?? [];
@@ -158,7 +218,7 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
},
};
});
}, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, isVSCode, maxParents, pinnedSessionIds, scopeProjectId, sessionOrderRanks, worktreeInfoByPath]);
}, [activeSessions, branchesByDirectory, currentSessionId, enabled, findProjectForDirectory, isVSCode, maxParents, pinnedSessionIds, scopeProjectId, sessionOrderRanks, worktreeInfoByPath]);
return items;
};