feat(ui): migrate all shortcut surfaces to the centralized registry
This commit is contained in:
@@ -24,6 +24,7 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { formatShortcutForDisplay } from '@/lib/shortcuts';
|
||||
import {
|
||||
isFilesystemError,
|
||||
type FilesystemErrorReason,
|
||||
@@ -360,9 +361,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
const hasHighlightedBrowseItem = Boolean(
|
||||
highlightedRow && (highlightedRow.type === 'up' || (highlightedRow.type === 'directory' && !highlightedRow.disabled))
|
||||
);
|
||||
const submitModifierLabel = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform)
|
||||
? '⌘'
|
||||
: 'Ctrl';
|
||||
const submitModifierLabel = formatShortcutForDisplay('mod');
|
||||
const submitActionLabel = isAlreadyAdded
|
||||
? t('directoryExplorerDialog.actions.alreadyAdded')
|
||||
: isCloneMode
|
||||
|
||||
@@ -11,7 +11,11 @@ import { Icon } from '@/components/icon/Icon';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useGlobalSessionStatus } from '@/sync/sync-context';
|
||||
import { useSessionUnseenCount } from '@/sync/notification-store';
|
||||
import { useSwitcherItems, type SwitcherItem } from '@/components/session/sidebar/shell/useSwitcherItems';
|
||||
import {
|
||||
findSwitcherItemAncestorIds,
|
||||
useSwitcherItems,
|
||||
type SwitcherItem,
|
||||
} from '@/components/session/sidebar/shell/useSwitcherItems';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
import { formatSessionCompactDateLabel } from './sidebar/utils';
|
||||
@@ -22,6 +26,7 @@ import { cn } from '@/lib/utils';
|
||||
type SecondaryMeta = SwitcherItem['secondaryMeta'];
|
||||
|
||||
type SwitcherVariant = 'default' | 'compact';
|
||||
const NEW_SESSION_SWITCHER_TARGET = 'new-session';
|
||||
|
||||
type SessionSwitcherDropdownProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -40,7 +45,7 @@ export function SessionSwitcherDropdown({
|
||||
const setOpen = useUIStore((state) => state.setSessionDropdownOpen);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setOpen} modal={false}>
|
||||
<DropdownMenu open={isOpen} onOpenChange={setOpen} modal={false} disableGlobalShortcuts>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
@@ -69,7 +74,9 @@ type SwitcherContentProps = {
|
||||
};
|
||||
|
||||
function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentProps): React.ReactElement {
|
||||
const items = useSwitcherItems(true, { scopeProjectId });
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const isNewSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft.open === true);
|
||||
const items = useSwitcherItems(true, { scopeProjectId, currentSessionId });
|
||||
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -79,6 +86,9 @@ function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentP
|
||||
}, [onSelect, openNewSessionDraft]);
|
||||
|
||||
const [expandedParents, setExpandedParents] = React.useState<Set<string>>(new Set());
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const initialFocusCompleteRef = React.useRef(false);
|
||||
const initialTarget = isNewSessionDraftOpen ? NEW_SESSION_SWITCHER_TARGET : currentSessionId;
|
||||
const toggleParent = React.useCallback((sessionId: string) => {
|
||||
setExpandedParents((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -91,10 +101,36 @@ function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentP
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (initialFocusCompleteRef.current || !initialTarget) return;
|
||||
|
||||
const ancestorIds = initialTarget === NEW_SESSION_SWITCHER_TARGET
|
||||
? []
|
||||
: findSwitcherItemAncestorIds(items, initialTarget);
|
||||
if (!ancestorIds) return;
|
||||
|
||||
if (ancestorIds.some((id) => !expandedParents.has(id))) {
|
||||
setExpandedParents((previous) => new Set([...previous, ...ancestorIds]));
|
||||
return;
|
||||
}
|
||||
|
||||
const animationFrame = requestAnimationFrame(() => {
|
||||
const item = Array.from(
|
||||
contentRef.current?.querySelectorAll<HTMLElement>('[data-switcher-item-id]') ?? [],
|
||||
).find((element) => element.dataset.switcherItemId === initialTarget);
|
||||
if (!item) return;
|
||||
item.focus();
|
||||
item.scrollIntoView({ block: 'nearest' });
|
||||
initialFocusCompleteRef.current = true;
|
||||
});
|
||||
return () => cancelAnimationFrame(animationFrame);
|
||||
}, [expandedParents, initialTarget, items]);
|
||||
|
||||
return (
|
||||
<div className="max-h-[60vh] overflow-y-auto">
|
||||
<div ref={contentRef} className="max-h-[60vh] overflow-y-auto">
|
||||
<div className="space-y-0.5">
|
||||
<BaseMenu.Item
|
||||
data-switcher-item-id={NEW_SESSION_SWITCHER_TARGET}
|
||||
onClick={handleNewSession}
|
||||
className={cn(
|
||||
'group relative flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 outline-hidden select-none',
|
||||
@@ -227,6 +263,7 @@ function SwitcherRow({ session, depth, variant, secondaryMeta, hasChildren, isEx
|
||||
handleSelect();
|
||||
}}
|
||||
data-slot="session-switcher-item"
|
||||
data-switcher-item-id={session.id}
|
||||
className={cn(
|
||||
'group relative flex w-full cursor-pointer items-start gap-2 rounded-lg px-2 py-1.5 outline-hidden select-none',
|
||||
'data-[highlighted]:bg-interactive-hover hover:bg-interactive-hover',
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user