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:
@@ -91,10 +91,17 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) =>
|
|||||||
};
|
};
|
||||||
}, [isInitialized]);
|
}, [isInitialized]);
|
||||||
|
|
||||||
|
const directoryBootstrappedRef = React.useRef(false);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
if (directoryBootstrappedRef.current) return;
|
||||||
if (config.mode !== 'session') 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 });
|
setDirectory(config.directory, { showOverlay: false });
|
||||||
|
directoryBootstrappedRef.current = true;
|
||||||
}, [config.directory, config.mode, currentDirectory, setDirectory]);
|
}, [config.directory, config.mode, currentDirectory, setDirectory]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -109,13 +116,24 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) =>
|
|||||||
if (agentsCount === 0) void loadAgents();
|
if (agentsCount === 0) void loadAgents();
|
||||||
}, [agentsCount, isConnected, loadAgents, loadProviders, providersCount]);
|
}, [agentsCount, isConnected, loadAgents, loadProviders, providersCount]);
|
||||||
|
|
||||||
|
const sessionBootstrappedRef = React.useRef(false);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
if (sessionBootstrappedRef.current) return;
|
||||||
if (config.mode !== 'session' || !config.sessionId) 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);
|
const session = sessions.find((entry) => entry.id === config.sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
const directory = (session as { directory?: string | null }).directory ?? config.directory;
|
const directory = (session as { directory?: string | null }).directory ?? config.directory;
|
||||||
setCurrentSession(config.sessionId, directory);
|
setCurrentSession(config.sessionId, directory);
|
||||||
|
sessionBootstrappedRef.current = true;
|
||||||
}, [config, currentSessionId, sessions, setCurrentSession]);
|
}, [config, currentSessionId, sessions, setCurrentSession]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import { OpenInAppButton } from '@/components/desktop/OpenInAppButton';
|
|||||||
import { forceKillTerminal } from '@/lib/terminalApi';
|
import { forceKillTerminal } from '@/lib/terminalApi';
|
||||||
import { useTerminalStore } from '@/stores/useTerminalStore';
|
import { useTerminalStore } from '@/stores/useTerminalStore';
|
||||||
import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton';
|
import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton';
|
||||||
|
import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown';
|
||||||
import { canUseElectronDesktopIPC, invokeDesktop, isDesktopShell, isVSCodeRuntime, startDesktopWindowDrag } from '@/lib/desktop';
|
import { canUseElectronDesktopIPC, invokeDesktop, isDesktopShell, isVSCodeRuntime, startDesktopWindowDrag } from '@/lib/desktop';
|
||||||
import { desktopHostsGet, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts';
|
import { desktopHostsGet, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts';
|
||||||
import { resolveSessionDiffStats } from '@/components/session/sidebar/utils';
|
import { resolveSessionDiffStats } from '@/components/session/sidebar/utils';
|
||||||
@@ -1892,13 +1893,17 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
className="mr-2"
|
className="mr-2"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!isNewSessionDraftOpen ? (
|
<SessionSwitcherDropdown>
|
||||||
<div className="mr-3 min-w-0">
|
<button
|
||||||
<div className="truncate pl-1 typography-ui-label text-[14px] font-normal leading-tight text-foreground">
|
type="button"
|
||||||
{currentSessionTitle}
|
aria-label={t('sessions.switcher.openAria')}
|
||||||
</div>
|
className="app-region-no-drag mr-3 flex min-w-0 flex-col items-start rounded-md px-1 py-0.5 -my-0.5 text-left transition-colors hover:bg-interactive-hover/60 focus-visible:outline-none focus-visible:bg-interactive-hover/60"
|
||||||
{(activeProjectLabel || currentBranchLabel || hasNonZeroSessionChanges) ? (
|
>
|
||||||
<div className="flex min-w-0 items-center gap-1.5 truncate pl-1 typography-micro text-[10.5px] font-normal leading-tight text-muted-foreground/75">
|
<span className="truncate typography-ui-label text-[14px] font-normal leading-tight text-foreground max-w-full">
|
||||||
|
{isNewSessionDraftOpen ? t('sessions.switcher.draftTitle') : currentSessionTitle}
|
||||||
|
</span>
|
||||||
|
{(activeProjectLabel || currentBranchLabel || (!isNewSessionDraftOpen && (hasNonZeroSessionChanges || worktreeBadgeKind))) ? (
|
||||||
|
<span className="flex min-w-0 max-w-full items-center gap-1.5 truncate typography-micro text-[10.5px] font-normal leading-tight text-muted-foreground/75">
|
||||||
{activeProjectLabel ? <span className="truncate">{activeProjectLabel}</span> : null}
|
{activeProjectLabel ? <span className="truncate">{activeProjectLabel}</span> : null}
|
||||||
{currentBranchLabel ? (
|
{currentBranchLabel ? (
|
||||||
<span className="inline-flex min-w-0 items-center gap-0.5">
|
<span className="inline-flex min-w-0 items-center gap-0.5">
|
||||||
@@ -1906,14 +1911,14 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
<span className="truncate">{currentBranchLabel}</span>
|
<span className="truncate">{currentBranchLabel}</span>
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{hasNonZeroSessionChanges ? (
|
{!isNewSessionDraftOpen && hasNonZeroSessionChanges ? (
|
||||||
<span className="inline-flex flex-shrink-0 items-center gap-0 text-[0.92em]">
|
<span className="inline-flex flex-shrink-0 items-center gap-0 text-[0.92em]">
|
||||||
<span className="text-status-success/80">+{currentSessionChanges.additions}</span>
|
<span className="text-status-success/80">+{currentSessionChanges.additions}</span>
|
||||||
<span className="text-muted-foreground/60">/</span>
|
<span className="text-muted-foreground/60">/</span>
|
||||||
<span className="text-status-error/65">-{currentSessionChanges.deletions}</span>
|
<span className="text-status-error/65">-{currentSessionChanges.deletions}</span>
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{worktreeBadgeKind ? (
|
{!isNewSessionDraftOpen && worktreeBadgeKind ? (
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"inline-flex min-w-0 items-center gap-0.5",
|
"inline-flex min-w-0 items-center gap-0.5",
|
||||||
worktreeBadgeKind === 'attention' || worktreeBadgeKind === 'invalid' || worktreeBadgeKind === 'missing' ? 'text-status-warning' : 'text-muted-foreground/60'
|
worktreeBadgeKind === 'attention' || worktreeBadgeKind === 'invalid' || worktreeBadgeKind === 'missing' ? 'text-status-warning' : 'text-muted-foreground/60'
|
||||||
@@ -1922,10 +1927,10 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
<span className="truncate">{worktreeBadge}</span>
|
<span className="truncate">{worktreeBadge}</span>
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</button>
|
||||||
) : null}
|
</SessionSwitcherDropdown>
|
||||||
|
|
||||||
{tabs.length > 0 && (
|
{tabs.length > 0 && (
|
||||||
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-muted)]/50 p-1">
|
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-muted)]/50 p-1">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { ChatContainer } from '@/components/chat/ChatContainer';
|
import { ChatContainer } from '@/components/chat/ChatContainer';
|
||||||
import { ChatSurfaceProvider } from '@/components/chat/ChatSurfaceContext';
|
import { ChatSurfaceProvider } from '@/components/chat/ChatSurfaceContext';
|
||||||
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
|
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
|
||||||
|
import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { invokeDesktop, isElectronShell } from '@/lib/desktop';
|
import { invokeDesktop, isElectronShell } from '@/lib/desktop';
|
||||||
@@ -129,7 +130,8 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
|
|||||||
return segments.at(-1) ?? project.path;
|
return segments.at(-1) ?? project.path;
|
||||||
}, [activeProject, directoryLabel, pathMatchedProject]);
|
}, [activeProject, directoryLabel, pathMatchedProject]);
|
||||||
const gitBranchForDirectory = useGitBranchLabel(openDirectory || null);
|
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(() => {
|
const diffStats = React.useMemo(() => {
|
||||||
return resolveSessionDiffStats(session?.summary as Parameters<typeof resolveSessionDiffStats>[0]);
|
return resolveSessionDiffStats(session?.summary as Parameters<typeof resolveSessionDiffStats>[0]);
|
||||||
}, [session?.summary]);
|
}, [session?.summary]);
|
||||||
@@ -260,25 +262,35 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
|
|||||||
)}
|
)}
|
||||||
style={dragRegionStyle}
|
style={dragRegionStyle}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<SessionSwitcherDropdown>
|
||||||
<div className="truncate pl-1 typography-ui-label text-[14px] font-normal leading-tight text-foreground">{title}</div>
|
<button
|
||||||
<div className="flex min-w-0 items-center gap-1.5 truncate pl-1 typography-micro text-[10.5px] font-normal leading-tight text-muted-foreground/75">
|
type="button"
|
||||||
<span className="truncate">{projectLabel}</span>
|
aria-label={t('sessions.switcher.openAria')}
|
||||||
{branchLabel ? (
|
style={noDragRegionStyle}
|
||||||
<span className="inline-flex min-w-0 items-center gap-0.5">
|
className="flex min-w-0 max-w-full flex-col items-start rounded-md px-1 py-0.5 text-left transition-colors hover:bg-interactive-hover/60 focus-visible:outline-none focus-visible:bg-interactive-hover/60"
|
||||||
<Icon name="git-branch" className="h-3 w-3 flex-shrink-0 text-muted-foreground/70" />
|
>
|
||||||
<span className="truncate">{branchLabel}</span>
|
<span className="truncate typography-ui-label text-[14px] font-normal leading-tight text-foreground max-w-full">
|
||||||
</span>
|
{title}
|
||||||
) : null}
|
</span>
|
||||||
{hasChanges ? (
|
<span className="flex min-w-0 max-w-full items-center gap-1.5 truncate typography-micro text-[10.5px] font-normal leading-tight text-muted-foreground/75">
|
||||||
<span className="inline-flex flex-shrink-0 items-center gap-0 text-[0.92em]">
|
<span className="truncate">{projectLabel}</span>
|
||||||
<span className="text-status-success/80">+{changes.additions}</span>
|
{branchLabel ? (
|
||||||
<span className="text-muted-foreground/60">/</span>
|
<span className="inline-flex min-w-0 items-center gap-0.5">
|
||||||
<span className="text-status-error/65">-{changes.deletions}</span>
|
<Icon name="git-branch" className="h-3 w-3 flex-shrink-0 text-muted-foreground/70" />
|
||||||
</span>
|
<span className="truncate">{branchLabel}</span>
|
||||||
) : null}
|
</span>
|
||||||
</div>
|
) : null}
|
||||||
</div>
|
{hasChanges ? (
|
||||||
|
<span className="inline-flex flex-shrink-0 items-center gap-0 text-[0.92em]">
|
||||||
|
<span className="text-status-success/80">+{changes.additions}</span>
|
||||||
|
<span className="text-muted-foreground/60">/</span>
|
||||||
|
<span className="text-status-error/65">-{changes.deletions}</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</SessionSwitcherDropdown>
|
||||||
|
<div className="min-w-0 flex-1" />
|
||||||
{stableContextUsage && stableContextUsage.totalTokens > 0 ? (
|
{stableContextUsage && stableContextUsage.totalTokens > 0 ? (
|
||||||
<ContextUsageDisplay
|
<ContextUsageDisplay
|
||||||
totalTokens={stableContextUsage.totalTokens}
|
totalTokens={stableContextUsage.totalTokens}
|
||||||
|
|||||||
@@ -62,14 +62,11 @@ import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore'
|
|||||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||||
import { type SessionGroup, type SessionNode } from './sidebar/types';
|
import { type SessionGroup, type SessionNode } from './sidebar/types';
|
||||||
import {
|
import {
|
||||||
type ActiveNowEntry,
|
|
||||||
addActiveNowSession,
|
|
||||||
deriveActiveNowSessions,
|
deriveActiveNowSessions,
|
||||||
deriveLiveActiveNowSessions,
|
deriveLiveActiveNowSessions,
|
||||||
persistActiveNowEntries,
|
|
||||||
pruneActiveNowEntries,
|
|
||||||
readActiveNowEntries,
|
|
||||||
} from './sidebar/activitySections';
|
} from './sidebar/activitySections';
|
||||||
|
import { useActiveNowStore } from '@/stores/useActiveNowStore';
|
||||||
|
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
|
||||||
import {
|
import {
|
||||||
compareSessionsByPinnedAndTime,
|
compareSessionsByPinnedAndTime,
|
||||||
formatProjectLabel,
|
formatProjectLabel,
|
||||||
@@ -170,7 +167,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
() => new Map(),
|
() => new Map(),
|
||||||
);
|
);
|
||||||
const safeStorage = React.useMemo(() => getSafeStorage(), []);
|
const safeStorage = React.useMemo(() => getSafeStorage(), []);
|
||||||
const [activeNowEntries, setActiveNowEntries] = React.useState<ActiveNowEntry[]>(() => 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<Set<string>>(new Set());
|
const [collapsedProjects, setCollapsedProjects] = React.useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map());
|
const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map());
|
||||||
@@ -183,18 +182,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
const [deleteSessionConfirm, setDeleteSessionConfirm] = React.useState<DeleteSessionConfirmState>(null);
|
const [deleteSessionConfirm, setDeleteSessionConfirm] = React.useState<DeleteSessionConfirmState>(null);
|
||||||
const [deleteFolderConfirm, setDeleteFolderConfirm] = React.useState<DeleteFolderConfirmState>(null);
|
const [deleteFolderConfirm, setDeleteFolderConfirm] = React.useState<DeleteFolderConfirmState>(null);
|
||||||
const [bulkDeleteConfirm, setBulkDeleteConfirm] = React.useState<BulkDeleteSessionsConfirmState>(null);
|
const [bulkDeleteConfirm, setBulkDeleteConfirm] = React.useState<BulkDeleteSessionsConfirmState>(null);
|
||||||
const [pinnedSessionIds, setPinnedSessionIds] = React.useState<Set<string>>(() => {
|
const pinnedSessionIds = useSessionPinnedStore((state) => state.ids);
|
||||||
try {
|
const setPinnedSessionIds = useSessionPinnedStore((state) => state.setIds);
|
||||||
const raw = getSafeStorage().getItem(SESSION_PINNED_STORAGE_KEY);
|
const togglePinnedSession = useSessionPinnedStore((state) => state.toggle);
|
||||||
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 [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => {
|
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => {
|
||||||
try {
|
try {
|
||||||
const raw = getSafeStorage().getItem(GROUP_COLLAPSE_STORAGE_KEY);
|
const raw = getSafeStorage().getItem(GROUP_COLLAPSE_STORAGE_KEY);
|
||||||
@@ -547,18 +537,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
setCollapsedProjects,
|
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(() => {
|
const sortedSessions = React.useMemo(() => {
|
||||||
return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
|
return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
|
||||||
}, [sessions, pinnedSessionIds]);
|
}, [sessions, pinnedSessionIds]);
|
||||||
@@ -969,9 +947,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
|| section.project.normalizedPath,
|
|| section.project.normalizedPath,
|
||||||
);
|
);
|
||||||
section.groups.forEach((group) => {
|
section.groups.forEach((group) => {
|
||||||
const secondaryMeta = group.branch && group.branch !== projectLabel
|
const branchCandidate = group.branch && group.branch !== 'HEAD' && group.branch !== projectLabel
|
||||||
? { projectLabel, branchLabel: group.branch }
|
? group.branch
|
||||||
: { projectLabel, branchLabel: null };
|
: null;
|
||||||
|
const secondaryMeta = { projectLabel, branchLabel: branchCandidate };
|
||||||
|
|
||||||
const visit = (nodes: SessionNode[]) => {
|
const visit = (nodes: SessionNode[]) => {
|
||||||
nodes.forEach((node) => {
|
nodes.forEach((node) => {
|
||||||
@@ -1025,15 +1004,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setActiveNowEntries((prev) => {
|
liveActiveSessions.forEach((session) => addActiveNowSessionToStore(session.id));
|
||||||
const next = liveActiveSessions.reduce((entries, session) => addActiveNowSession(entries, session.id), prev);
|
}, [addActiveNowSessionToStore, liveActiveSessions, showRecentSection]);
|
||||||
if (next === prev) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
persistActiveNowEntries(safeStorage, next);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, [liveActiveSessions, safeStorage, showRecentSection]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!showRecentSection) {
|
if (!showRecentSection) {
|
||||||
@@ -1045,14 +1017,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
allKnownSessionsById.set(session.id, session);
|
allKnownSessionsById.set(session.id, session);
|
||||||
});
|
});
|
||||||
|
|
||||||
const pruned = pruneActiveNowEntries(activeNowEntries, allKnownSessionsById);
|
pruneActiveNowEntriesInStore(allKnownSessionsById);
|
||||||
if (pruned.length === activeNowEntries.length && pruned.every((entry, index) => entry.sessionId === activeNowEntries[index]?.sessionId)) {
|
}, [archivedSessions, pruneActiveNowEntriesInStore, sessions, showRecentSection]);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveNowEntries(pruned);
|
|
||||||
persistActiveNowEntries(safeStorage, pruned);
|
|
||||||
}, [activeNowEntries, archivedSessions, safeStorage, sessions, showRecentSection]);
|
|
||||||
|
|
||||||
// Prefetch is wired below, after recentSessionIds is computed.
|
// Prefetch is wired below, after recentSessionIds is computed.
|
||||||
|
|
||||||
@@ -1077,6 +1043,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
];
|
];
|
||||||
}, [activeNowSessions, sessionSidebarMetaById, showRecentSection, t]);
|
}, [activeNowSessions, sessionSidebarMetaById, showRecentSection, t]);
|
||||||
|
|
||||||
|
|
||||||
const recentSessionIds = React.useMemo(() => {
|
const recentSessionIds = React.useMemo(() => {
|
||||||
return new Set(activeNowSessions.map((session) => session.id));
|
return new Set(activeNowSessions.map((session) => session.id));
|
||||||
}, [activeNowSessions]);
|
}, [activeNowSessions]);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<DropdownMenu open={isOpen} onOpenChange={setOpen} modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="start"
|
||||||
|
sideOffset={6}
|
||||||
|
className="w-[360px] max-w-[calc(100vw-32px)] overflow-hidden p-1"
|
||||||
|
>
|
||||||
|
{isOpen ? <SwitcherContent onSelect={() => setOpen(false)} /> : null}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SwitcherContent({ onSelect }: { onSelect: () => void }): React.ReactElement {
|
||||||
|
const items = useSwitcherItems(true);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const [expandedParents, setExpandedParents] = React.useState<Set<string>>(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 (
|
||||||
|
<div className="max-h-[60vh] overflow-y-auto">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="px-3 py-4 text-center typography-meta text-muted-foreground">
|
||||||
|
{t('sessions.switcher.empty')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{items.map((item) => (
|
||||||
|
<SwitcherNode
|
||||||
|
key={item.node.session.id}
|
||||||
|
item={item}
|
||||||
|
depth={0}
|
||||||
|
expandedParents={expandedParents}
|
||||||
|
toggleParent={toggleParent}
|
||||||
|
closeDropdown={onSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SwitcherNodeProps = {
|
||||||
|
item: { node: SessionNode; projectId: string | null; groupDirectory: string | null; secondaryMeta: SecondaryMeta };
|
||||||
|
depth: number;
|
||||||
|
expandedParents: Set<string>;
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<SwitcherRow
|
||||||
|
session={session}
|
||||||
|
depth={depth}
|
||||||
|
secondaryMeta={secondaryMeta}
|
||||||
|
hasChildren={hasChildren}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
onToggleExpand={hasChildren ? () => toggleParent(session.id) : undefined}
|
||||||
|
closeDropdown={closeDropdown}
|
||||||
|
/>
|
||||||
|
{hasChildren && isExpanded
|
||||||
|
? node.children.map((childNode) => (
|
||||||
|
<SwitcherNode
|
||||||
|
key={childNode.session.id}
|
||||||
|
item={{ node: childNode, projectId: item.projectId, groupDirectory: item.groupDirectory, secondaryMeta }}
|
||||||
|
depth={depth + 1}
|
||||||
|
expandedParents={expandedParents}
|
||||||
|
toggleParent={toggleParent}
|
||||||
|
closeDropdown={closeDropdown}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: 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 (
|
||||||
|
<BaseMenu.Item
|
||||||
|
onClick={(event) => {
|
||||||
|
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 }}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<span className={cn('truncate text-[14px] font-normal leading-tight', isActive ? 'text-primary' : 'text-foreground')}>
|
||||||
|
{sessionTitle}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className="flex min-w-0 items-center gap-1.5 truncate text-muted-foreground/70 leading-tight"
|
||||||
|
style={{ fontSize: 'calc(var(--text-ui-label) * 0.85)' }}
|
||||||
|
>
|
||||||
|
{hasChildren ? (
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
data-switcher-expand
|
||||||
|
onClick={(event) => {
|
||||||
|
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 ? <Icon name="arrow-down-s" className="h-3 w-3" /> : <Icon name="arrow-right-s" className="h-3 w-3" />}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="flex-shrink-0">{timeLabel}</span>
|
||||||
|
{projectLabel ? <span className="truncate">{projectLabel}</span> : null}
|
||||||
|
{branchLabel ? (
|
||||||
|
<span className="inline-flex min-w-0 items-center gap-0.5">
|
||||||
|
<Icon name="git-branch" className="h-3 w-3 flex-shrink-0 text-muted-foreground/70" />
|
||||||
|
<span className="truncate">{branchLabel}</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{diffStats ? (
|
||||||
|
<span className="inline-flex flex-shrink-0 items-center gap-0 text-[0.92em]">
|
||||||
|
<span className="text-status-success/80">+{diffStats.additions}</span>
|
||||||
|
<span className="text-muted-foreground/60">/</span>
|
||||||
|
<span className="text-status-error/65">-{diffStats.deletions}</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isStreaming || showUnreadDot ? (
|
||||||
|
<span className="flex h-3 w-3 flex-shrink-0 items-center justify-center self-center">
|
||||||
|
{isStreaming ? (
|
||||||
|
<span
|
||||||
|
className="h-1.5 w-1.5 rounded-full bg-primary animate-busy-pulse"
|
||||||
|
aria-label={t('sessions.sidebar.session.status.active')}
|
||||||
|
title={t('sessions.sidebar.session.status.active')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="h-1.5 w-1.5 rounded-full bg-[var(--status-info)]"
|
||||||
|
aria-label={t('sessions.sidebar.session.status.unread')}
|
||||||
|
title={t('sessions.sidebar.session.status.unread')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</BaseMenu.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,7 +39,6 @@ export const useSidebarPersistence = (args: Args) => {
|
|||||||
safeStorage,
|
safeStorage,
|
||||||
keys,
|
keys,
|
||||||
sessions,
|
sessions,
|
||||||
pinnedSessionIds,
|
|
||||||
setPinnedSessionIds,
|
setPinnedSessionIds,
|
||||||
groupOrderByProject,
|
groupOrderByProject,
|
||||||
activeSessionByProject,
|
activeSessionByProject,
|
||||||
@@ -135,14 +134,6 @@ export const useSidebarPersistence = (args: Args) => {
|
|||||||
});
|
});
|
||||||
}, [hasLoadedGlobalSessions, sessions, setPinnedSessionIds]);
|
}, [hasLoadedGlobalSessions, sessions, setPinnedSessionIds]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
try {
|
|
||||||
safeStorage.setItem(keys.sessionPinned, JSON.stringify(Array.from(pinnedSessionIds)));
|
|
||||||
} catch {
|
|
||||||
// ignored
|
|
||||||
}
|
|
||||||
}, [keys.sessionPinned, pinnedSessionIds, safeStorage]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const serialized = Object.fromEntries(groupOrderByProject.entries());
|
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;
|
||||||
|
};
|
||||||
@@ -214,6 +214,9 @@ export const dict = {
|
|||||||
'sessions.sidebar.empty.noMatches.title': 'No matching sessions',
|
'sessions.sidebar.empty.noMatches.title': 'No matching sessions',
|
||||||
'sessions.sidebar.empty.noMatches.description': 'Try a different title, branch, folder, or path.',
|
'sessions.sidebar.empty.noMatches.description': 'Try a different title, branch, folder, or path.',
|
||||||
'sessions.sidebar.activity.recentTitle': 'recent',
|
'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.errorTitle': 'Failed to check for updates',
|
||||||
'sessions.sidebar.updateCheck.latestVersion': 'You are on the latest version',
|
'sessions.sidebar.updateCheck.latestVersion': 'You are on the latest version',
|
||||||
'sessions.sidebar.directory.errorAddProjectTitle': 'Failed to add project',
|
'sessions.sidebar.directory.errorAddProjectTitle': 'Failed to add project',
|
||||||
|
|||||||
@@ -215,6 +215,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"sessions.sidebar.empty.noMatches.title": "No hay sesiones coincidentes",
|
"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.empty.noMatches.description": "Inténtalo con un título, rama, carpeta o ruta diferente.",
|
||||||
"sessions.sidebar.activity.recentTitle": "reciente",
|
"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.errorTitle": "No se pudo comprobar actualizaciones",
|
||||||
"sessions.sidebar.updateCheck.latestVersion": "Estás en la última versión",
|
"sessions.sidebar.updateCheck.latestVersion": "Estás en la última versión",
|
||||||
"sessions.sidebar.directory.errorAddProjectTitle": "No se pudo añadir el proyecto",
|
"sessions.sidebar.directory.errorAddProjectTitle": "No se pudo añadir el proyecto",
|
||||||
|
|||||||
@@ -215,6 +215,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sessions.sidebar.empty.noMatches.title': '일치하는 세션 없음',
|
'sessions.sidebar.empty.noMatches.title': '일치하는 세션 없음',
|
||||||
'sessions.sidebar.empty.noMatches.description': '다른 제목, 브랜치, 폴더 또는 경로로 검색해 보세요.',
|
'sessions.sidebar.empty.noMatches.description': '다른 제목, 브랜치, 폴더 또는 경로로 검색해 보세요.',
|
||||||
'sessions.sidebar.activity.recentTitle': '최근',
|
'sessions.sidebar.activity.recentTitle': '최근',
|
||||||
|
'sessions.switcher.openAria': '세션 전환기 열기',
|
||||||
|
'sessions.switcher.empty': '최근 세션 없음',
|
||||||
|
'sessions.switcher.draftTitle': '새 세션',
|
||||||
'sessions.sidebar.updateCheck.errorTitle': '업데이트 확인 실패',
|
'sessions.sidebar.updateCheck.errorTitle': '업데이트 확인 실패',
|
||||||
'sessions.sidebar.updateCheck.latestVersion': '최신 버전을 사용 중입니다',
|
'sessions.sidebar.updateCheck.latestVersion': '최신 버전을 사용 중입니다',
|
||||||
'sessions.sidebar.directory.errorAddProjectTitle': '프로젝트 추가 실패',
|
'sessions.sidebar.directory.errorAddProjectTitle': '프로젝트 추가 실패',
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sessions.sidebar.empty.noMatches.title': 'Brak pasujących sesji',
|
'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.empty.noMatches.description': 'Spróbuj innego tytułu, gałęzi, folderu lub ścieżki.',
|
||||||
'sessions.sidebar.activity.recentTitle': 'ostatnie',
|
'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.errorTitle': 'Nie udało się sprawdzić aktualizacji',
|
||||||
'sessions.sidebar.updateCheck.latestVersion': 'Masz najnowszą wersję',
|
'sessions.sidebar.updateCheck.latestVersion': 'Masz najnowszą wersję',
|
||||||
'sessions.sidebar.directory.errorAddProjectTitle': 'Nie udało się dodać projektu',
|
'sessions.sidebar.directory.errorAddProjectTitle': 'Nie udało się dodać projektu',
|
||||||
|
|||||||
@@ -215,6 +215,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"sessions.sidebar.empty.noMatches.title": "Não há sessões coincidentes",
|
"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.empty.noMatches.description": "Tente com outro título, branch, pasta ou caminho.",
|
||||||
"sessions.sidebar.activity.recentTitle": "recente",
|
"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.errorTitle": "Não foi possível verificar atualizações",
|
||||||
"sessions.sidebar.updateCheck.latestVersion": "Você está na versão mais recente",
|
"sessions.sidebar.updateCheck.latestVersion": "Você está na versão mais recente",
|
||||||
"sessions.sidebar.directory.errorAddProjectTitle": "Não foi possível adicionar o projeto",
|
"sessions.sidebar.directory.errorAddProjectTitle": "Não foi possível adicionar o projeto",
|
||||||
|
|||||||
@@ -215,6 +215,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"sessions.sidebar.empty.noMatches.title": "Немає відповідних сесій",
|
"sessions.sidebar.empty.noMatches.title": "Немає відповідних сесій",
|
||||||
"sessions.sidebar.empty.noMatches.description": "Спробуйте інший заголовок, гілку, папку або шлях.",
|
"sessions.sidebar.empty.noMatches.description": "Спробуйте інший заголовок, гілку, папку або шлях.",
|
||||||
"sessions.sidebar.activity.recentTitle": "Останні",
|
"sessions.sidebar.activity.recentTitle": "Останні",
|
||||||
|
"sessions.switcher.openAria": "Відкрити перемикач сесій",
|
||||||
|
"sessions.switcher.empty": "Немає недавніх сесій",
|
||||||
|
"sessions.switcher.draftTitle": "Нова сесія",
|
||||||
"sessions.sidebar.updateCheck.errorTitle": "Не вдалося перейти на наявність оновлень",
|
"sessions.sidebar.updateCheck.errorTitle": "Не вдалося перейти на наявність оновлень",
|
||||||
"sessions.sidebar.updateCheck.latestVersion": "Ви використовуєте останню версію",
|
"sessions.sidebar.updateCheck.latestVersion": "Ви використовуєте останню версію",
|
||||||
"sessions.sidebar.directory.errorAddProjectTitle": "Не вдалося додати проєкт",
|
"sessions.sidebar.directory.errorAddProjectTitle": "Не вдалося додати проєкт",
|
||||||
|
|||||||
@@ -215,6 +215,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sessions.sidebar.empty.noMatches.title': '没有匹配的会话',
|
'sessions.sidebar.empty.noMatches.title': '没有匹配的会话',
|
||||||
'sessions.sidebar.empty.noMatches.description': '请尝试其他标题、分支、文件夹或路径。',
|
'sessions.sidebar.empty.noMatches.description': '请尝试其他标题、分支、文件夹或路径。',
|
||||||
'sessions.sidebar.activity.recentTitle': '最近',
|
'sessions.sidebar.activity.recentTitle': '最近',
|
||||||
|
'sessions.switcher.openAria': '打开会话切换器',
|
||||||
|
'sessions.switcher.empty': '没有最近会话',
|
||||||
|
'sessions.switcher.draftTitle': '新会话',
|
||||||
'sessions.sidebar.updateCheck.errorTitle': '检查更新失败',
|
'sessions.sidebar.updateCheck.errorTitle': '检查更新失败',
|
||||||
'sessions.sidebar.updateCheck.latestVersion': '你已是最新版本',
|
'sessions.sidebar.updateCheck.latestVersion': '你已是最新版本',
|
||||||
'sessions.sidebar.directory.errorAddProjectTitle': '添加项目失败',
|
'sessions.sidebar.directory.errorAddProjectTitle': '添加项目失败',
|
||||||
|
|||||||
@@ -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<string, Session>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeStorage = getSafeStorage();
|
||||||
|
|
||||||
|
export const useActiveNowStore = create<ActiveNowStore>((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);
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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<string> => {
|
||||||
|
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<string>): void => {
|
||||||
|
try {
|
||||||
|
storage.setItem(SESSION_PINNED_STORAGE_KEY, JSON.stringify([...ids]));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionPinnedStore = {
|
||||||
|
ids: Set<string>;
|
||||||
|
setIds: (next: Set<string> | ((prev: Set<string>) => Set<string>)) => void;
|
||||||
|
toggle: (sessionId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeStorage = getSafeStorage();
|
||||||
|
|
||||||
|
export const useSessionPinnedStore = create<SessionPinnedStore>((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);
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -492,6 +492,7 @@ interface UIStore {
|
|||||||
bottomTerminalHeight: number;
|
bottomTerminalHeight: number;
|
||||||
hasManuallyResizedBottomTerminal: boolean;
|
hasManuallyResizedBottomTerminal: boolean;
|
||||||
isSessionSwitcherOpen: boolean;
|
isSessionSwitcherOpen: boolean;
|
||||||
|
isSessionDropdownOpen: boolean;
|
||||||
activeMainTab: MainTab;
|
activeMainTab: MainTab;
|
||||||
mainTabGuard: MainTabGuard | null;
|
mainTabGuard: MainTabGuard | null;
|
||||||
sidebarOpenBeforeFullscreenTab: boolean | null;
|
sidebarOpenBeforeFullscreenTab: boolean | null;
|
||||||
@@ -619,6 +620,7 @@ interface UIStore {
|
|||||||
setBottomTerminalExpanded: (expanded: boolean) => void;
|
setBottomTerminalExpanded: (expanded: boolean) => void;
|
||||||
setBottomTerminalHeight: (height: number) => void;
|
setBottomTerminalHeight: (height: number) => void;
|
||||||
setSessionSwitcherOpen: (open: boolean) => void;
|
setSessionSwitcherOpen: (open: boolean) => void;
|
||||||
|
setSessionDropdownOpen: (open: boolean) => void;
|
||||||
setActiveMainTab: (tab: MainTab) => void;
|
setActiveMainTab: (tab: MainTab) => void;
|
||||||
setMainTabGuard: (guard: MainTabGuard | null) => void;
|
setMainTabGuard: (guard: MainTabGuard | null) => void;
|
||||||
setPendingDiffFile: (filePath: string | null) => void;
|
setPendingDiffFile: (filePath: string | null) => void;
|
||||||
@@ -749,6 +751,7 @@ export const useUIStore = create<UIStore>()(
|
|||||||
bottomTerminalHeight: 300,
|
bottomTerminalHeight: 300,
|
||||||
hasManuallyResizedBottomTerminal: false,
|
hasManuallyResizedBottomTerminal: false,
|
||||||
isSessionSwitcherOpen: false,
|
isSessionSwitcherOpen: false,
|
||||||
|
isSessionDropdownOpen: false,
|
||||||
activeMainTab: 'chat',
|
activeMainTab: 'chat',
|
||||||
mainTabGuard: null,
|
mainTabGuard: null,
|
||||||
sidebarOpenBeforeFullscreenTab: null,
|
sidebarOpenBeforeFullscreenTab: null,
|
||||||
@@ -1279,6 +1282,10 @@ export const useUIStore = create<UIStore>()(
|
|||||||
set({ isSessionSwitcherOpen: open });
|
set({ isSessionSwitcherOpen: open });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setSessionDropdownOpen: (open) => {
|
||||||
|
set({ isSessionDropdownOpen: open });
|
||||||
|
},
|
||||||
|
|
||||||
setMainTabGuard: (guard) => {
|
setMainTabGuard: (guard) => {
|
||||||
if (get().mainTabGuard === guard) {
|
if (get().mainTabGuard === guard) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user