From b6149614bddf3e90f0e802f4b9c31361a9fa0433 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 15 May 2026 18:16:03 +0300 Subject: [PATCH] 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 --- packages/ui/src/apps/ElectronMiniChatApp.tsx | 22 +- packages/ui/src/components/layout/Header.tsx | 29 +- .../components/mini-chat/MiniChatLayout.tsx | 52 ++-- .../src/components/session/SessionSidebar.tsx | 67 ++--- .../session/SessionSwitcherDropdown.tsx | 251 ++++++++++++++++++ .../sidebar/hooks/useSidebarPersistence.ts | 9 - .../session/sidebar/hooks/useSwitcherItems.ts | 128 +++++++++ packages/ui/src/lib/i18n/messages/en.ts | 3 + packages/ui/src/lib/i18n/messages/es.ts | 3 + packages/ui/src/lib/i18n/messages/ko.ts | 3 + packages/ui/src/lib/i18n/messages/pl.ts | 3 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 3 + packages/ui/src/lib/i18n/messages/uk.ts | 3 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 3 + packages/ui/src/stores/useActiveNowStore.ts | 46 ++++ .../ui/src/stores/useSessionPinnedStore.ts | 54 ++++ packages/ui/src/stores/useUIStore.ts | 7 + 17 files changed, 593 insertions(+), 93 deletions(-) create mode 100644 packages/ui/src/components/session/SessionSwitcherDropdown.tsx create mode 100644 packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts create mode 100644 packages/ui/src/stores/useActiveNowStore.ts create mode 100644 packages/ui/src/stores/useSessionPinnedStore.ts diff --git a/packages/ui/src/apps/ElectronMiniChatApp.tsx b/packages/ui/src/apps/ElectronMiniChatApp.tsx index e951946c..40bbc928 100644 --- a/packages/ui/src/apps/ElectronMiniChatApp.tsx +++ b/packages/ui/src/apps/ElectronMiniChatApp.tsx @@ -91,10 +91,17 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) => }; }, [isInitialized]); + const directoryBootstrappedRef = React.useRef(false); React.useEffect(() => { + if (directoryBootstrappedRef.current) return; if (config.mode !== 'session') return; - if (!config.directory || currentDirectory === config.directory) return; + if (!config.directory) return; + if (currentDirectory === config.directory) { + directoryBootstrappedRef.current = true; + return; + } setDirectory(config.directory, { showOverlay: false }); + directoryBootstrappedRef.current = true; }, [config.directory, config.mode, currentDirectory, setDirectory]); React.useEffect(() => { @@ -109,13 +116,24 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) => if (agentsCount === 0) void loadAgents(); }, [agentsCount, isConnected, loadAgents, loadProviders, providersCount]); + const sessionBootstrappedRef = React.useRef(false); React.useEffect(() => { + if (sessionBootstrappedRef.current) return; if (config.mode !== 'session' || !config.sessionId) return; - if (currentSessionId === config.sessionId) return; + if (currentSessionId === config.sessionId) { + sessionBootstrappedRef.current = true; + return; + } + if (currentSessionId) { + // User already has a different session selected (e.g. from a prior switch); don't override. + sessionBootstrappedRef.current = true; + return; + } const session = sessions.find((entry) => entry.id === config.sessionId); if (!session) return; const directory = (session as { directory?: string | null }).directory ?? config.directory; setCurrentSession(config.sessionId, directory); + sessionBootstrappedRef.current = true; }, [config, currentSessionId, sessions, setCurrentSession]); React.useEffect(() => { diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index a8db774c..3cf1063a 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -63,6 +63,7 @@ import { OpenInAppButton } from '@/components/desktop/OpenInAppButton'; import { forceKillTerminal } from '@/lib/terminalApi'; import { useTerminalStore } from '@/stores/useTerminalStore'; import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton'; +import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown'; import { canUseElectronDesktopIPC, invokeDesktop, isDesktopShell, isVSCodeRuntime, startDesktopWindowDrag } from '@/lib/desktop'; import { desktopHostsGet, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts'; import { resolveSessionDiffStats } from '@/components/session/sidebar/utils'; @@ -1892,13 +1893,17 @@ export const Header: React.FC = ({ className="mr-2" /> )} - {!isNewSessionDraftOpen ? ( -
-
- {currentSessionTitle} -
- {(activeProjectLabel || currentBranchLabel || hasNonZeroSessionChanges) ? ( -
+ +
+ ) : null} -
- ) : null} + + {tabs.length > 0 && (
diff --git a/packages/ui/src/components/mini-chat/MiniChatLayout.tsx b/packages/ui/src/components/mini-chat/MiniChatLayout.tsx index c3fc967a..825cc250 100644 --- a/packages/ui/src/components/mini-chat/MiniChatLayout.tsx +++ b/packages/ui/src/components/mini-chat/MiniChatLayout.tsx @@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button'; import { ChatContainer } from '@/components/chat/ChatContainer'; import { ChatSurfaceProvider } from '@/components/chat/ChatSurfaceContext'; import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; +import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; import { invokeDesktop, isElectronShell } from '@/lib/desktop'; @@ -129,7 +130,8 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { return segments.at(-1) ?? project.path; }, [activeProject, directoryLabel, pathMatchedProject]); const gitBranchForDirectory = useGitBranchLabel(openDirectory || null); - const branchLabel = gitBranchForDirectory || worktreeMetadataBranch || sessionWorktreeMetadata?.branch?.trim() || worktreeAttachment?.branch?.trim() || catalogWorktreeBranch; + const rawBranchLabel = gitBranchForDirectory || worktreeMetadataBranch || sessionWorktreeMetadata?.branch?.trim() || worktreeAttachment?.branch?.trim() || catalogWorktreeBranch; + const branchLabel = rawBranchLabel && rawBranchLabel !== 'HEAD' ? rawBranchLabel : null; const diffStats = React.useMemo(() => { return resolveSessionDiffStats(session?.summary as Parameters[0]); }, [session?.summary]); @@ -260,25 +262,35 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { )} style={dragRegionStyle} > -
-
{title}
-
- {projectLabel} - {branchLabel ? ( - - - {branchLabel} - - ) : null} - {hasChanges ? ( - - +{changes.additions} - / - -{changes.deletions} - - ) : null} -
-
+ + + +
{stableContextUsage && stableContextUsage.totalTokens > 0 ? ( = ({ () => new Map(), ); const safeStorage = React.useMemo(() => getSafeStorage(), []); - const [activeNowEntries, setActiveNowEntries] = React.useState(() => readActiveNowEntries(safeStorage)); + const activeNowEntries = useActiveNowStore((state) => state.entries); + const addActiveNowSessionToStore = useActiveNowStore((state) => state.addSession); + const pruneActiveNowEntriesInStore = useActiveNowStore((state) => state.prune); const [collapsedProjects, setCollapsedProjects] = React.useState>(new Set()); const [projectRepoStatus, setProjectRepoStatus] = React.useState>(new Map()); @@ -183,18 +182,9 @@ export const SessionSidebar: React.FC = ({ const [deleteSessionConfirm, setDeleteSessionConfirm] = React.useState(null); const [deleteFolderConfirm, setDeleteFolderConfirm] = React.useState(null); const [bulkDeleteConfirm, setBulkDeleteConfirm] = React.useState(null); - const [pinnedSessionIds, setPinnedSessionIds] = React.useState>(() => { - try { - const raw = getSafeStorage().getItem(SESSION_PINNED_STORAGE_KEY); - if (!raw) { - return new Set(); - } - const parsed = JSON.parse(raw) as string[]; - return new Set(Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : []); - } catch { - return new Set(); - } - }); + const pinnedSessionIds = useSessionPinnedStore((state) => state.ids); + const setPinnedSessionIds = useSessionPinnedStore((state) => state.setIds); + const togglePinnedSession = useSessionPinnedStore((state) => state.toggle); const [collapsedGroups, setCollapsedGroups] = React.useState>(() => { try { const raw = getSafeStorage().getItem(GROUP_COLLAPSE_STORAGE_KEY); @@ -547,18 +537,6 @@ export const SessionSidebar: React.FC = ({ setCollapsedProjects, }); - const togglePinnedSession = React.useCallback((sessionId: string) => { - setPinnedSessionIds((prev) => { - const next = new Set(prev); - if (next.has(sessionId)) { - next.delete(sessionId); - } else { - next.add(sessionId); - } - return next; - }); - }, []); - const sortedSessions = React.useMemo(() => { return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds)); }, [sessions, pinnedSessionIds]); @@ -969,9 +947,10 @@ export const SessionSidebar: React.FC = ({ || section.project.normalizedPath, ); section.groups.forEach((group) => { - const secondaryMeta = group.branch && group.branch !== projectLabel - ? { projectLabel, branchLabel: group.branch } - : { projectLabel, branchLabel: null }; + const branchCandidate = group.branch && group.branch !== 'HEAD' && group.branch !== projectLabel + ? group.branch + : null; + const secondaryMeta = { projectLabel, branchLabel: branchCandidate }; const visit = (nodes: SessionNode[]) => { nodes.forEach((node) => { @@ -1025,15 +1004,8 @@ export const SessionSidebar: React.FC = ({ return; } - setActiveNowEntries((prev) => { - const next = liveActiveSessions.reduce((entries, session) => addActiveNowSession(entries, session.id), prev); - if (next === prev) { - return prev; - } - persistActiveNowEntries(safeStorage, next); - return next; - }); - }, [liveActiveSessions, safeStorage, showRecentSection]); + liveActiveSessions.forEach((session) => addActiveNowSessionToStore(session.id)); + }, [addActiveNowSessionToStore, liveActiveSessions, showRecentSection]); React.useEffect(() => { if (!showRecentSection) { @@ -1045,14 +1017,8 @@ export const SessionSidebar: React.FC = ({ allKnownSessionsById.set(session.id, session); }); - const pruned = pruneActiveNowEntries(activeNowEntries, allKnownSessionsById); - if (pruned.length === activeNowEntries.length && pruned.every((entry, index) => entry.sessionId === activeNowEntries[index]?.sessionId)) { - return; - } - - setActiveNowEntries(pruned); - persistActiveNowEntries(safeStorage, pruned); - }, [activeNowEntries, archivedSessions, safeStorage, sessions, showRecentSection]); + pruneActiveNowEntriesInStore(allKnownSessionsById); + }, [archivedSessions, pruneActiveNowEntriesInStore, sessions, showRecentSection]); // Prefetch is wired below, after recentSessionIds is computed. @@ -1077,6 +1043,7 @@ export const SessionSidebar: React.FC = ({ ]; }, [activeNowSessions, sessionSidebarMetaById, showRecentSection, t]); + const recentSessionIds = React.useMemo(() => { return new Set(activeNowSessions.map((session) => session.id)); }, [activeNowSessions]); diff --git a/packages/ui/src/components/session/SessionSwitcherDropdown.tsx b/packages/ui/src/components/session/SessionSwitcherDropdown.tsx new file mode 100644 index 00000000..c9ff2e60 --- /dev/null +++ b/packages/ui/src/components/session/SessionSwitcherDropdown.tsx @@ -0,0 +1,251 @@ +import React from 'react'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import type { Session } from '@opencode-ai/sdk/v2'; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +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/hooks/useSwitcherItems'; +import { useUIStore } from '@/stores/useUIStore'; +import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; +import { formatSessionCompactDateLabel, resolveSessionDiffStats } from './sidebar/utils'; +import type { SessionNode, SessionSummaryMeta } from './sidebar/types'; +import { useI18n } from '@/lib/i18n'; +import { cn } from '@/lib/utils'; + +type SecondaryMeta = SwitcherItem['secondaryMeta']; + +type SessionSwitcherDropdownProps = { + children: React.ReactNode; +}; + +export function SessionSwitcherDropdown({ children }: SessionSwitcherDropdownProps): React.ReactElement { + const isOpen = useUIStore((state) => state.isSessionDropdownOpen); + const setOpen = useUIStore((state) => state.setSessionDropdownOpen); + + return ( + + {children} + + {isOpen ? setOpen(false)} /> : null} + + + ); +} + +function SwitcherContent({ onSelect }: { onSelect: () => void }): React.ReactElement { + const items = useSwitcherItems(true); + const { t } = useI18n(); + + const [expandedParents, setExpandedParents] = React.useState>(new Set()); + const toggleParent = React.useCallback((sessionId: string) => { + setExpandedParents((prev) => { + const next = new Set(prev); + if (next.has(sessionId)) { + next.delete(sessionId); + } else { + next.add(sessionId); + } + return next; + }); + }, []); + + return ( +
+ {items.length === 0 ? ( +
+ {t('sessions.switcher.empty')} +
+ ) : ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+ ); +} + +type SwitcherNodeProps = { + item: { node: SessionNode; projectId: string | null; groupDirectory: string | null; secondaryMeta: SecondaryMeta }; + depth: number; + expandedParents: Set; + toggleParent: (sessionId: string) => void; + closeDropdown: () => void; +}; + +function SwitcherNode({ item, depth, expandedParents, toggleParent, closeDropdown }: SwitcherNodeProps): React.ReactElement { + const { node, secondaryMeta } = item; + const session = node.session; + const hasChildren = node.children.length > 0; + const isExpanded = expandedParents.has(session.id); + + return ( + <> + toggleParent(session.id) : undefined} + closeDropdown={closeDropdown} + /> + {hasChildren && isExpanded + ? node.children.map((childNode) => ( + + )) + : null} + + ); +} + +type SwitcherRowProps = { + session: Session; + depth: number; + secondaryMeta: SecondaryMeta; + hasChildren: boolean; + isExpanded: boolean; + onToggleExpand?: () => void; + closeDropdown: () => void; +}; + +function SwitcherRow({ session, depth, secondaryMeta, hasChildren, isExpanded, onToggleExpand, closeDropdown }: SwitcherRowProps): React.ReactElement { + const { t } = useI18n(); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); + const notifyOnSubtasks = useUIStore((state) => state.notifyOnSubtasks); + + const sessionStatus = useGlobalSessionStatus(session.id); + const unseenCount = useSessionUnseenCount(session.id); + + const isActive = currentSessionId === session.id; + const sessionTitle = session.title?.trim() || t('sessions.sidebar.session.untitled'); + const isSubtask = Boolean((session as Session & { parentID?: string | null }).parentID); + const needsAttention = unseenCount > 0 && (!isSubtask || notifyOnSubtasks); + const statusType = sessionStatus?.type ?? 'idle'; + const isStreaming = statusType === 'busy' || statusType === 'retry'; + const showUnreadDot = !isStreaming && needsAttention && !isActive; + + const summary = session.summary as SessionSummaryMeta | undefined; + const diffStats = resolveSessionDiffStats(summary); + const timestamp = session.time?.updated || session.time?.created || Date.now(); + const timeLabel = formatSessionCompactDateLabel(timestamp); + + const projectLabel = secondaryMeta?.projectLabel?.trim() || null; + const rawBranchLabel = secondaryMeta?.branchLabel?.trim() || null; + const branchLabel = rawBranchLabel && rawBranchLabel !== 'HEAD' ? rawBranchLabel : null; + + const handleSelect = React.useCallback(() => { + if (isActive) { + closeDropdown(); + return; + } + const directory = resolveGlobalSessionDirectory(session); + setCurrentSession(session.id, directory ?? null); + closeDropdown(); + }, [closeDropdown, isActive, session, setCurrentSession]); + + return ( + { + if ((event.target as HTMLElement | null)?.closest('[data-switcher-expand]')) { + event.preventDefault(); + return; + } + handleSelect(); + }} + data-slot="session-switcher-item" + 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', + )} + style={{ paddingLeft: 8 + depth * 12 }} + > +
+ + {sessionTitle} + +
+ {hasChildren ? ( + { + event.preventDefault(); + event.stopPropagation(); + onToggleExpand?.(); + }} + className="inline-flex h-3 w-3 flex-shrink-0 items-center justify-center rounded text-muted-foreground/70 hover:text-foreground" + aria-label={isExpanded ? t('sessions.sidebar.session.subsessions.collapse') : t('sessions.sidebar.session.subsessions.expand')} + > + {isExpanded ? : } + + ) : null} + {timeLabel} + {projectLabel ? {projectLabel} : null} + {branchLabel ? ( + + + {branchLabel} + + ) : null} + {diffStats ? ( + + +{diffStats.additions} + / + -{diffStats.deletions} + + ) : null} +
+
+ + {isStreaming || showUnreadDot ? ( + + {isStreaming ? ( + + ) : ( + + )} + + ) : null} +
+ ); +} diff --git a/packages/ui/src/components/session/sidebar/hooks/useSidebarPersistence.ts b/packages/ui/src/components/session/sidebar/hooks/useSidebarPersistence.ts index 061b8c30..668fa478 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSidebarPersistence.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSidebarPersistence.ts @@ -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()); diff --git a/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts b/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts new file mode 100644 index 00000000..913300be --- /dev/null +++ b/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts @@ -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(() => { + if (!enabled) return []; + + const childrenByParent = new Map(); + 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(); + 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; +}; diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index f209725d..916ff6a2 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -214,6 +214,9 @@ export const dict = { 'sessions.sidebar.empty.noMatches.title': 'No matching sessions', 'sessions.sidebar.empty.noMatches.description': 'Try a different title, branch, folder, or path.', 'sessions.sidebar.activity.recentTitle': 'recent', + 'sessions.switcher.openAria': 'Open session switcher', + 'sessions.switcher.empty': 'No recent sessions', + 'sessions.switcher.draftTitle': 'New session', 'sessions.sidebar.updateCheck.errorTitle': 'Failed to check for updates', 'sessions.sidebar.updateCheck.latestVersion': 'You are on the latest version', 'sessions.sidebar.directory.errorAddProjectTitle': 'Failed to add project', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 4e16161e..9536fcb2 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -215,6 +215,9 @@ export const dict: Record = { "sessions.sidebar.empty.noMatches.title": "No hay sesiones coincidentes", "sessions.sidebar.empty.noMatches.description": "Inténtalo con un título, rama, carpeta o ruta diferente.", "sessions.sidebar.activity.recentTitle": "reciente", + "sessions.switcher.openAria": "Abrir selector de sesiones", + "sessions.switcher.empty": "No hay sesiones recientes", + "sessions.switcher.draftTitle": "Nueva sesión", "sessions.sidebar.updateCheck.errorTitle": "No se pudo comprobar actualizaciones", "sessions.sidebar.updateCheck.latestVersion": "Estás en la última versión", "sessions.sidebar.directory.errorAddProjectTitle": "No se pudo añadir el proyecto", diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 5fd9abef..c8c92b5d 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -215,6 +215,9 @@ export const dict: Record = { 'sessions.sidebar.empty.noMatches.title': '일치하는 세션 없음', 'sessions.sidebar.empty.noMatches.description': '다른 제목, 브랜치, 폴더 또는 경로로 검색해 보세요.', 'sessions.sidebar.activity.recentTitle': '최근', + 'sessions.switcher.openAria': '세션 전환기 열기', + 'sessions.switcher.empty': '최근 세션 없음', + 'sessions.switcher.draftTitle': '새 세션', 'sessions.sidebar.updateCheck.errorTitle': '업데이트 확인 실패', 'sessions.sidebar.updateCheck.latestVersion': '최신 버전을 사용 중입니다', 'sessions.sidebar.directory.errorAddProjectTitle': '프로젝트 추가 실패', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 8996027e..7bc655da 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -59,6 +59,9 @@ export const dict: Record = { 'sessions.sidebar.empty.noMatches.title': 'Brak pasujących sesji', 'sessions.sidebar.empty.noMatches.description': 'Spróbuj innego tytułu, gałęzi, folderu lub ścieżki.', 'sessions.sidebar.activity.recentTitle': 'ostatnie', + 'sessions.switcher.openAria': 'Otwórz przełącznik sesji', + 'sessions.switcher.empty': 'Brak ostatnich sesji', + 'sessions.switcher.draftTitle': 'Nowa sesja', 'sessions.sidebar.updateCheck.errorTitle': 'Nie udało się sprawdzić aktualizacji', 'sessions.sidebar.updateCheck.latestVersion': 'Masz najnowszą wersję', 'sessions.sidebar.directory.errorAddProjectTitle': 'Nie udało się dodać projektu', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index a859b592..8087fcc0 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -215,6 +215,9 @@ export const dict: Record = { "sessions.sidebar.empty.noMatches.title": "Não há sessões coincidentes", "sessions.sidebar.empty.noMatches.description": "Tente com outro título, branch, pasta ou caminho.", "sessions.sidebar.activity.recentTitle": "recente", + "sessions.switcher.openAria": "Abrir seletor de sessões", + "sessions.switcher.empty": "Nenhuma sessão recente", + "sessions.switcher.draftTitle": "Nova sessão", "sessions.sidebar.updateCheck.errorTitle": "Não foi possível verificar atualizações", "sessions.sidebar.updateCheck.latestVersion": "Você está na versão mais recente", "sessions.sidebar.directory.errorAddProjectTitle": "Não foi possível adicionar o projeto", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index fbc55842..a5bc0107 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -215,6 +215,9 @@ export const dict: Record = { "sessions.sidebar.empty.noMatches.title": "Немає відповідних сесій", "sessions.sidebar.empty.noMatches.description": "Спробуйте інший заголовок, гілку, папку або шлях.", "sessions.sidebar.activity.recentTitle": "Останні", + "sessions.switcher.openAria": "Відкрити перемикач сесій", + "sessions.switcher.empty": "Немає недавніх сесій", + "sessions.switcher.draftTitle": "Нова сесія", "sessions.sidebar.updateCheck.errorTitle": "Не вдалося перейти на наявність оновлень", "sessions.sidebar.updateCheck.latestVersion": "Ви використовуєте останню версію", "sessions.sidebar.directory.errorAddProjectTitle": "Не вдалося додати проєкт", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 12578fc3..d0080e5f 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -215,6 +215,9 @@ export const dict: Record = { 'sessions.sidebar.empty.noMatches.title': '没有匹配的会话', 'sessions.sidebar.empty.noMatches.description': '请尝试其他标题、分支、文件夹或路径。', 'sessions.sidebar.activity.recentTitle': '最近', + 'sessions.switcher.openAria': '打开会话切换器', + 'sessions.switcher.empty': '没有最近会话', + 'sessions.switcher.draftTitle': '新会话', 'sessions.sidebar.updateCheck.errorTitle': '检查更新失败', 'sessions.sidebar.updateCheck.latestVersion': '你已是最新版本', 'sessions.sidebar.directory.errorAddProjectTitle': '添加项目失败', diff --git a/packages/ui/src/stores/useActiveNowStore.ts b/packages/ui/src/stores/useActiveNowStore.ts new file mode 100644 index 00000000..131b03dd --- /dev/null +++ b/packages/ui/src/stores/useActiveNowStore.ts @@ -0,0 +1,46 @@ +import { create } from 'zustand'; +import type { Session } from '@opencode-ai/sdk/v2'; +import { + type ActiveNowEntry, + addActiveNowSession, + persistActiveNowEntries, + pruneActiveNowEntries, + readActiveNowEntries, +} from '@/components/session/sidebar/activitySections'; +import { getSafeStorage } from './utils/safeStorage'; + +type ActiveNowStore = { + entries: ActiveNowEntry[]; + setEntries: (entries: ActiveNowEntry[]) => void; + addSession: (sessionId: string) => void; + prune: (sessionsById: Map) => void; +}; + +const safeStorage = getSafeStorage(); + +export const useActiveNowStore = create((set, get) => ({ + entries: readActiveNowEntries(safeStorage), + setEntries: (entries) => { + if (entries === get().entries) return; + set({ entries }); + persistActiveNowEntries(safeStorage, entries); + }, + addSession: (sessionId) => { + const next = addActiveNowSession(get().entries, sessionId); + if (next === get().entries) return; + set({ entries: next }); + persistActiveNowEntries(safeStorage, next); + }, + prune: (sessionsById) => { + const current = get().entries; + const pruned = pruneActiveNowEntries(current, sessionsById); + if ( + pruned.length === current.length + && pruned.every((entry, index) => entry.sessionId === current[index]?.sessionId) + ) { + return; + } + set({ entries: pruned }); + persistActiveNowEntries(safeStorage, pruned); + }, +})); diff --git a/packages/ui/src/stores/useSessionPinnedStore.ts b/packages/ui/src/stores/useSessionPinnedStore.ts new file mode 100644 index 00000000..1f60304b --- /dev/null +++ b/packages/ui/src/stores/useSessionPinnedStore.ts @@ -0,0 +1,54 @@ +import { create } from 'zustand'; +import { getSafeStorage } from './utils/safeStorage'; + +const SESSION_PINNED_STORAGE_KEY = 'oc.sessions.pinned'; + +const readPinned = (storage: Storage): Set => { + try { + const raw = storage.getItem(SESSION_PINNED_STORAGE_KEY); + if (!raw) return new Set(); + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return new Set(); + return new Set(parsed.filter((item): item is string => typeof item === 'string')); + } catch { + return new Set(); + } +}; + +const persistPinned = (storage: Storage, ids: Set): void => { + try { + storage.setItem(SESSION_PINNED_STORAGE_KEY, JSON.stringify([...ids])); + } catch { + // ignore + } +}; + +type SessionPinnedStore = { + ids: Set; + setIds: (next: Set | ((prev: Set) => Set)) => void; + toggle: (sessionId: string) => void; +}; + +const safeStorage = getSafeStorage(); + +export const useSessionPinnedStore = create((set, get) => ({ + ids: readPinned(safeStorage), + setIds: (next) => { + const current = get().ids; + const resolved = typeof next === 'function' ? next(current) : next; + if (resolved === current) return; + set({ ids: resolved }); + persistPinned(safeStorage, resolved); + }, + toggle: (sessionId) => { + const current = get().ids; + const next = new Set(current); + if (next.has(sessionId)) { + next.delete(sessionId); + } else { + next.add(sessionId); + } + set({ ids: next }); + persistPinned(safeStorage, next); + }, +})); diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 3c314da9..329c60b3 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -492,6 +492,7 @@ interface UIStore { bottomTerminalHeight: number; hasManuallyResizedBottomTerminal: boolean; isSessionSwitcherOpen: boolean; + isSessionDropdownOpen: boolean; activeMainTab: MainTab; mainTabGuard: MainTabGuard | null; sidebarOpenBeforeFullscreenTab: boolean | null; @@ -619,6 +620,7 @@ interface UIStore { setBottomTerminalExpanded: (expanded: boolean) => void; setBottomTerminalHeight: (height: number) => void; setSessionSwitcherOpen: (open: boolean) => void; + setSessionDropdownOpen: (open: boolean) => void; setActiveMainTab: (tab: MainTab) => void; setMainTabGuard: (guard: MainTabGuard | null) => void; setPendingDiffFile: (filePath: string | null) => void; @@ -749,6 +751,7 @@ export const useUIStore = create()( bottomTerminalHeight: 300, hasManuallyResizedBottomTerminal: false, isSessionSwitcherOpen: false, + isSessionDropdownOpen: false, activeMainTab: 'chat', mainTabGuard: null, sidebarOpenBeforeFullscreenTab: null, @@ -1279,6 +1282,10 @@ export const useUIStore = create()( set({ isSessionSwitcherOpen: open }); }, + setSessionDropdownOpen: (open) => { + set({ isSessionDropdownOpen: open }); + }, + setMainTabGuard: (guard) => { if (get().mainTabGuard === guard) { return;