feat: add session switcher dropdown in header

Open recent sessions from chat headers
Support session switching in mini chat
Share pinned and active session state
This commit is contained in:
Bohdan Triapitsyn
2026-05-15 18:16:09 +03:00
parent 81cb31f1fd
commit b6149614bd
17 changed files with 593 additions and 93 deletions
@@ -39,7 +39,6 @@ export const useSidebarPersistence = (args: Args) => {
safeStorage,
keys,
sessions,
pinnedSessionIds,
setPinnedSessionIds,
groupOrderByProject,
activeSessionByProject,
@@ -135,14 +134,6 @@ export const useSidebarPersistence = (args: Args) => {
});
}, [hasLoadedGlobalSessions, sessions, setPinnedSessionIds]);
React.useEffect(() => {
try {
safeStorage.setItem(keys.sessionPinned, JSON.stringify(Array.from(pinnedSessionIds)));
} catch {
// ignored
}
}, [keys.sessionPinned, pinnedSessionIds, safeStorage]);
React.useEffect(() => {
try {
const serialized = Object.fromEntries(groupOrderByProject.entries());
@@ -0,0 +1,128 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
import { useGitAllBranches, useGitStore } from '@/stores/useGitStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import type { SessionNode } from '../types';
import { compareSessionsByPinnedAndTime } from '../utils';
export type SwitcherItem = {
node: SessionNode;
projectId: string | null;
groupDirectory: string | null;
secondaryMeta: {
projectLabel?: string | null;
branchLabel?: string | null;
} | null;
};
const MAX_PARENT_SESSIONS = 7;
const normalize = (value: string | null | undefined): string | null => {
if (!value) return null;
const replaced = value.replace(/\\/g, '/');
if (replaced === '/') return '/';
return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced;
};
const formatProjectLabel = (project: { label?: string | null; path: string } | null): string | null => {
if (!project) return null;
const trimmed = project.label?.trim();
if (trimmed) return trimmed;
const segments = project.path.split(/[\\/]/).filter(Boolean);
return segments[segments.length - 1] ?? null;
};
export const useSwitcherItems = (enabled: boolean): SwitcherItem[] => {
const activeSessions = useGlobalSessionsStore((state) => state.activeSessions);
const projects = useProjectsStore((state) => state.projects);
const pinnedSessionIds = useSessionPinnedStore((state) => state.ids);
const branchesByDirectory = useGitAllBranches();
const ensureGitStatus = useGitStore((state) => state.ensureStatus);
const { git: gitApi } = useRuntimeAPIs();
const normalizedProjects = React.useMemo(
() => projects
.map((project) => ({ ...project, normalizedPath: normalize(project.path) }))
.filter((project) => project.normalizedPath),
[projects],
);
const findProjectForDirectory = React.useCallback(
(directory: string | null) => {
if (!directory) return null;
const matches = normalizedProjects
.filter((project) => directory === project.normalizedPath || directory.startsWith(`${project.normalizedPath}/`))
.sort((a, b) => (b.normalizedPath?.length ?? 0) - (a.normalizedPath?.length ?? 0));
return matches[0] ?? null;
},
[normalizedProjects],
);
const items = React.useMemo<SwitcherItem[]>(() => {
if (!enabled) return [];
const childrenByParent = new Map<string, Session[]>();
for (const session of activeSessions) {
const parentId = (session as Session & { parentID?: string | null }).parentID;
if (!parentId) continue;
if (session.time?.archived) continue;
const bucket = childrenByParent.get(parentId);
if (bucket) {
bucket.push(session);
} else {
childrenByParent.set(parentId, [session]);
}
}
childrenByParent.forEach((list) => {
list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
});
const parents = activeSessions
.filter((session) => !session.time?.archived)
.filter((session) => !(session as Session & { parentID?: string | null }).parentID)
.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds))
.slice(0, MAX_PARENT_SESSIONS);
const buildNode = (session: Session): SessionNode => {
const childSessions = childrenByParent.get(session.id) ?? [];
return {
session,
children: childSessions.map((child) => buildNode(child)),
worktree: null,
};
};
return parents.map((session) => {
const directory = resolveGlobalSessionDirectory(session);
const matchedProject = findProjectForDirectory(directory);
const projectLabel = formatProjectLabel(matchedProject);
const branchLabel = directory ? branchesByDirectory.get(directory) ?? null : null;
return {
node: buildNode(session),
projectId: matchedProject?.id ?? null,
groupDirectory: directory,
secondaryMeta: {
projectLabel,
branchLabel: branchLabel && branchLabel !== projectLabel ? branchLabel : null,
},
};
});
}, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, pinnedSessionIds]);
React.useEffect(() => {
if (!enabled || !gitApi) return;
const seen = new Set<string>();
for (const item of items) {
const dir = item.groupDirectory;
if (!dir || seen.has(dir)) continue;
seen.add(dir);
void ensureGitStatus(dir, gitApi).catch(() => {});
}
}, [enabled, ensureGitStatus, gitApi, items]);
return items;
};