* perf(startup): hydrate providers/agents from cache (stale-while-revalidate)
Persist last-known provider/agent snapshots instead of stripping them, so the
model/agent pickers paint instantly on cold start. Freshness is preserved by the
background refresh in initializeApp() and activateDirectory() (which overwrite on
success) and by the existing provider/agent config-change subscriptions, so the
prior stale-provider regression stays fixed without blanking the UI during fetch.
* perf(startup): cache directory session list for instant sidebar
Persist a capped slice of each directory's session list and seed the child store
from it on creation, so the sidebar paints chats immediately on cold start.
Bootstrap phase-3 loadSessions overwrites with the fresh list; its empty-list
race guard preserves the seeded sessions during OpenCode warmup.
* perf(startup): hold API requests through OpenCode warmup instead of 503
The readiness gate returned 503 the instant OpenCode wasn't ready, pushing the
client into an exponential-backoff retry loop (500ms -> 1s -> ...) that wasted
seconds of cold-start time and could fail bootstrap outright. Now hold the
request and poll readiness up to a bounded window so the first call succeeds as
soon as OpenCode is up (typically sub-second); still 503 fast past the window so
a genuinely-down server doesn't hang. Adds coverage for both paths.
* perf(startup): surface cached providers/agents in pickers (optimistic readiness)
The model/agent pickers gated purely on isInitialized, so they showed
"Loading…" for the entire init round-trip even when provider/agent data was
already hydrated from cache — making the persisted-cache work invisible. Treat
the pickers as ready as soon as cached providers are present (stale-while-
revalidate), so they paint last-known models/agents instantly and refresh in the
background. First-ever launch (no cache) still shows Loading until init.
* perf(startup): don't abort directory bootstrap on transient phase-1 failure
A failed initial path.get OR session.status aborted the whole directory
bootstrap, stranding it in loading and skipping phase 2/3 (session load).
session.status is live data the event pipeline keeps current, and path.get is
tolerable once a project is resolved from global state. Now only a total
failure (or path.get failing with no resolved project) aborts, so the sidebar
and chat keep advancing and loading sessions through warmup hiccups.
* perf(startup): don't bootstrap directories from archived sidebar rows
Each sidebar session row called useDirectoryStore(dir), which defaulted to
bootstrap:true and triggered a full directory bootstrap. Archived sessions point
at dozens of (often deleted) worktrees, so on startup this fired a session-list
fetch + 6x2s empty-retry storm per dead directory (the logs the user saw). The
store ref there is only read on-demand via getState() in export handlers, never
subscribed, so archived rows don't need it bootstrapped. Add a { bootstrap }
option to useDirectoryStore and skip bootstrap for archived rows; active rows
still bootstrap so live cross-directory session/status keeps aggregating.
* perf(startup): stop empty-session bootstrap retry storm on web/desktop
The post-bootstrap retry re-ran the full directory bootstrap 6x2s whenever the
session list came back empty, on the theory that empty meant OpenCode wasn't
ready. But loadSessions already retries transient failures twice over
(listGlobalSessionPages throws on 5xx and retries internally), so on web/desktop
an empty result is authoritative — the directory genuinely has no sessions (e.g.
deleted worktrees referenced only by archived sessions). That produced the
dozens of '[bootstrap] sessions empty ... 6 attempts; giving up' log storms.
Gate the retry to VS Code, where the bridge can return an empty 200 during
warmup that the inner retries can't catch.
* perf(startup): scope provider/agent config to project (worktrees inherit)
Providers/agents/defaults are project-level, but were keyed per directory, so a
worktree fetched and cached its own snapshot — duplicating the parent project's
load (the trace showed initializeApp loading the worktree and activateDirectory
loading the project concurrently, ~8s of redundant background work).
- resolveConfigDirectory() maps a worktree to its owning project; loadProviders
/loadAgents/activateDirectory now key by it, so a worktree reuses one shared
project snapshot. activateDirectory resolves up-front so activeDirectoryKey and
the snapshot key always match (picker stays consistent); the OpenCode working
directory is unaffected.
- Add a 30s runtime freshness guard so the stale-while-revalidate background
refresh skips re-fetching config that was just loaded (initializeApp then
activateDirectory for the same project), and to avoid churn on rapid project
switches. Config-change invalidation clears the snapshot, which bypasses the
guard, so freshness never masks a needed refresh.
* fix(sidebar): default archived sessions to hidden to avoid startup flash
useSessionDisplayStore defaulted showArchivedSessions to true, so on startup
archived sessions rendered by default and then vanished once the persisted
preference rehydrated to hidden — a visible flash. Default to hidden so the
pre-hydration state is the quiet one; users who opted into showing archived keep
their persisted true (default change doesn't override persisted state).
* perf(startup): persist worktree->project mapping to kill cold double-load
The worktree->project map (availableWorktreesByProject) is populated by async git
discovery, so it isn't ready when initializeApp runs — a worktree's first config
load couldn't resolve to its project and duplicated the project's provider/agent
load, saturating OpenCode during cold start (the source of the slow first
createSession/send the user observed). Cache resolved worktree->project mappings
to localStorage so resolveConfigDirectory resolves synchronously at init on
subsequent launches; the project is loaded once and activateDirectory hits the
freshness guard. worktree->project is immutable so a cached entry is safe; live
resolution still populates/corrects the cache.
* perf(startup): persist worktree map for instant sidebar + first-launch keying
Worktree discovery is async (git), so availableWorktreesByProject was empty at
startup: the sidebar worktree list appeared late, and useConfigStore couldn't
resolve a worktree to its project on the first launch (causing the cold
worktree+project double-load). Persist the discovered worktree map to
localStorage and seed it synchronously on store init (stale-while-revalidate:
discovery refreshes in the background via the existing setState, which now
write-through persists). The sidebar paints worktrees instantly and
resolveConfigDirectory resolves the project from the very first launch.
* perf(startup): coalesce concurrent duplicate OpenCode reads in runtimeFetch
On cold start the sync bootstrap and the config store independently fire the same
idempotent reads (providers, config, path, agents, project) concurrently with no
shared dedup, saturating the single OpenCode process and delaying work queued
behind it (e.g. createSession). Coalesce genuinely-concurrent identical GETs to
those read endpoints at the transport layer so OpenCode does the work once; each
caller receives an independent response clone. Tightly scoped: GET only,
allowlisted read paths, never event streams, never a signal-bearing request (so
one caller's abort can't cancel the shared fetch). Entries clear on settle, so it
only shares overlapping in-flight requests — never a stale response.
* perf(startup): cache git branches so the draft branch selector paints instantly
The branch selector above the composer was the slowest-loading element: it's
gated behind a cold 'git branch' fetch (useGitStore, not persisted). Cache the
per-directory branch list to localStorage and seed the store on init (with
isGitRepo:true so the selector's gate passes), and write the cache on every
successful fetchBranches. The ChatInput draft-branch effect now refreshes on
staleness (>30s) rather than mere absence, so seeded branches show immediately
and still refresh in the background without a spinner — no stale-forever
regression. Only the branch list is cached; status/log/diff are untouched.
1213 lines
56 KiB
TypeScript
1213 lines
56 KiB
TypeScript
import React from 'react';
|
||
import type { Session } from '@opencode-ai/sdk/v2';
|
||
import { ContextMenu } from '@base-ui/react/context-menu';
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuSeparator,
|
||
DropdownMenuSub,
|
||
DropdownMenuSubContent,
|
||
DropdownMenuSubTrigger,
|
||
DropdownMenuTrigger,
|
||
} from '@/components/ui/dropdown-menu';
|
||
import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass, dropdownMenuSubTriggerClass } from '@/components/ui/dropdown-menu.styles';
|
||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||
import { cn } from '@/lib/utils';
|
||
import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/desktop';
|
||
import { toast } from '@/components/ui';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||
import { Icon } from "@/components/icon/Icon";
|
||
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
|
||
import type { ChildSessionExport } from '@/lib/exportSession';
|
||
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSession, useSessionPermissions } from '@/sync/sync-context';
|
||
import { useSync } from '@/sync/use-sync';
|
||
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
|
||
import { DraggableSessionRow } from './sessionFolderDnd';
|
||
import type { SessionNode } from './types';
|
||
import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from './utils';
|
||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||
import { useSessionUnseenCount } from '@/sync/notification-store';
|
||
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
|
||
import { useI18n } from '@/lib/i18n';
|
||
import { getRuntimeBearerTokenSync } from '@/lib/runtime-auth';
|
||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||
import { parseMultiRunSessionTitle } from '@/lib/multirun/title';
|
||
import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog';
|
||
import { FusionIcon } from '@/components/icons/FusionIcon';
|
||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||
|
||
type Folder = { id: string; name: string; sessionIds: string[] };
|
||
|
||
type SecondaryMeta = {
|
||
projectLabel?: string | null;
|
||
branchLabel?: string | null;
|
||
};
|
||
|
||
type Props = {
|
||
node: SessionNode;
|
||
depth?: number;
|
||
groupDirectory?: string | null;
|
||
projectId?: string | null;
|
||
archivedBucket?: boolean;
|
||
directoryStatus: Map<string, 'unknown' | 'exists' | 'missing'>;
|
||
currentSessionId: string | null;
|
||
pinnedSessionIds: Set<string>;
|
||
expandedParents: Set<string>;
|
||
hasSessionSearchQuery: boolean;
|
||
normalizedSessionSearchQuery: string;
|
||
notifyOnSubtasks: boolean;
|
||
editingId: string | null;
|
||
setEditingId: (id: string | null) => void;
|
||
editTitle: string;
|
||
setEditTitle: (value: string) => void;
|
||
handleSaveEdit: () => void;
|
||
handleCancelEdit: () => void;
|
||
toggleParent: (expansionKey: string) => void;
|
||
handleSessionSelect: (sessionId: string, sessionDirectory: string | null, isMissingDirectory: boolean, projectId?: string | null) => void;
|
||
handleSessionDoubleClick: (sessionId: string, sessionTitle: string) => void;
|
||
togglePinnedSession: (sessionId: string) => void;
|
||
handleShareSession: (session: Session) => void;
|
||
copiedSessionId: string | null;
|
||
handleCopyShareUrl: (url: string, sessionId: string) => void;
|
||
handleUnshareSession: (sessionId: string) => void;
|
||
openSidebarMenuKey: string | null;
|
||
setOpenSidebarMenuKey: (key: string | null) => void;
|
||
renamingFolderId: string | null;
|
||
getFoldersForScope: (scopeKey: string) => Folder[];
|
||
getSessionFolderId: (scopeKey: string, sessionId: string) => string | null;
|
||
removeSessionFromFolder: (scopeKey: string, sessionId: string) => void;
|
||
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void;
|
||
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
|
||
openContextPanelTab: (directory: string, options: { mode: 'chat'; dedupeKey: string; label: string; readOnly?: boolean }) => void;
|
||
handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean; hardDelete?: boolean }) => void;
|
||
mobileVariant: boolean;
|
||
alwaysShowActions: boolean;
|
||
renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean, secondaryMeta?: SecondaryMeta | null, renderContext?: 'project' | 'recent') => React.ReactNode;
|
||
secondaryMeta?: SecondaryMeta | null;
|
||
renderContext?: 'project' | 'recent';
|
||
};
|
||
|
||
const getNodeChildSignature = (node: SessionNode): string => {
|
||
if (node.children.length === 0) {
|
||
return '';
|
||
}
|
||
|
||
return node.children
|
||
.map((child) => `${child.session.id}:${child.children.length}`)
|
||
.join('|');
|
||
};
|
||
|
||
const treeContainsSessionId = (node: SessionNode, sessionId: string | null): boolean => {
|
||
if (!sessionId) {
|
||
return false;
|
||
}
|
||
|
||
if (node.session.id === sessionId) {
|
||
return true;
|
||
}
|
||
|
||
for (const child of node.children) {
|
||
if (treeContainsSessionId(child, sessionId)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
};
|
||
|
||
const treeContainsMenuKey = (
|
||
node: SessionNode,
|
||
menuKey: string | null,
|
||
renderContext: 'project' | 'recent',
|
||
archivedBucket: boolean,
|
||
): boolean => {
|
||
if (!menuKey) {
|
||
return false;
|
||
}
|
||
|
||
const nodeMenuKey = `${renderContext}:${archivedBucket ? 'archived' : 'active'}:${node.session.id}`;
|
||
if (nodeMenuKey === menuKey) {
|
||
return true;
|
||
}
|
||
|
||
for (const child of node.children) {
|
||
if (treeContainsMenuKey(child, menuKey, renderContext, archivedBucket)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
};
|
||
|
||
const areEqual = (prev: Props, next: Props): boolean => {
|
||
const prevSession = prev.node.session;
|
||
const nextSession = next.node.session;
|
||
const prevSessionId = prevSession.id;
|
||
const nextSessionId = nextSession.id;
|
||
|
||
if (prevSessionId !== nextSessionId) return false;
|
||
if (prev.node.session !== next.node.session) return false;
|
||
if (getNodeChildSignature(prev.node) !== getNodeChildSignature(next.node)) return false;
|
||
if (prev.depth !== next.depth) return false;
|
||
if (prev.groupDirectory !== next.groupDirectory) return false;
|
||
if (prev.projectId !== next.projectId) return false;
|
||
if (prev.archivedBucket !== next.archivedBucket) return false;
|
||
if (prev.currentSessionId !== next.currentSessionId) {
|
||
const prevActiveInTree = treeContainsSessionId(prev.node, prev.currentSessionId);
|
||
const nextActiveInTree = treeContainsSessionId(next.node, next.currentSessionId);
|
||
if (prevActiveInTree || nextActiveInTree) {
|
||
return false;
|
||
}
|
||
}
|
||
if (prev.pinnedSessionIds.has(prevSessionId) !== next.pinnedSessionIds.has(nextSessionId)) return false;
|
||
// Expansion is keyed per render context, so compare the composite key
|
||
// matching the one isExpanded reads from in render. If a session appears
|
||
// in two contexts (project + recent), they have independent state.
|
||
{
|
||
const prevRenderContext = prev.renderContext ?? 'project';
|
||
const nextRenderContext = next.renderContext ?? 'project';
|
||
const prevArchived = prev.archivedBucket ?? false;
|
||
const nextArchived = next.archivedBucket ?? false;
|
||
const prevExpansionKey = `${prevRenderContext}:${prevArchived ? 'archived' : 'active'}:${prevSessionId}`;
|
||
const nextExpansionKey = `${nextRenderContext}:${nextArchived ? 'archived' : 'active'}:${nextSessionId}`;
|
||
if (prev.expandedParents.has(prevExpansionKey) !== next.expandedParents.has(nextExpansionKey)) return false;
|
||
}
|
||
if (prev.hasSessionSearchQuery !== next.hasSessionSearchQuery) return false;
|
||
if (prev.normalizedSessionSearchQuery !== next.normalizedSessionSearchQuery) return false;
|
||
if (prev.notifyOnSubtasks !== next.notifyOnSubtasks) return false;
|
||
if (prev.editingId !== next.editingId) {
|
||
const prevEditingInTree = treeContainsSessionId(prev.node, prev.editingId);
|
||
const nextEditingInTree = treeContainsSessionId(next.node, next.editingId);
|
||
if (prevEditingInTree || nextEditingInTree) {
|
||
return false;
|
||
}
|
||
}
|
||
if (prev.editTitle !== next.editTitle) {
|
||
const prevEditingInTree = treeContainsSessionId(prev.node, prev.editingId);
|
||
const nextEditingInTree = treeContainsSessionId(next.node, next.editingId);
|
||
if (prevEditingInTree || nextEditingInTree) {
|
||
return false;
|
||
}
|
||
}
|
||
if ((prev.copiedSessionId === prevSessionId) !== (next.copiedSessionId === nextSessionId)) return false;
|
||
|
||
const prevMenuInTree = treeContainsMenuKey(prev.node, prev.openSidebarMenuKey, prev.renderContext ?? 'project', prev.archivedBucket ?? false);
|
||
const nextMenuInTree = treeContainsMenuKey(next.node, next.openSidebarMenuKey, next.renderContext ?? 'project', next.archivedBucket ?? false);
|
||
if (prevMenuInTree !== nextMenuInTree) return false;
|
||
|
||
const prevDirectory = normalizePath((prevSession as Session & { directory?: string | null }).directory ?? null)
|
||
?? normalizePath(prev.groupDirectory ?? null);
|
||
const nextDirectory = normalizePath((nextSession as Session & { directory?: string | null }).directory ?? null)
|
||
?? normalizePath(next.groupDirectory ?? null);
|
||
if (prevDirectory !== nextDirectory) return false;
|
||
if ((prevDirectory ? prev.directoryStatus.get(prevDirectory) : null) !== (nextDirectory ? next.directoryStatus.get(nextDirectory) : null)) return false;
|
||
|
||
if ((prev.secondaryMeta?.projectLabel ?? null) !== (next.secondaryMeta?.projectLabel ?? null)) return false;
|
||
if ((prev.secondaryMeta?.branchLabel ?? null) !== (next.secondaryMeta?.branchLabel ?? null)) return false;
|
||
if (prev.mobileVariant !== next.mobileVariant) return false;
|
||
if (prev.alwaysShowActions !== next.alwaysShowActions) return false;
|
||
if ((prev.renderContext ?? 'project') !== (next.renderContext ?? 'project')) return false;
|
||
if (prev.renamingFolderId !== next.renamingFolderId) return false;
|
||
|
||
return true;
|
||
};
|
||
|
||
function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||
const { t } = useI18n();
|
||
const {
|
||
node,
|
||
depth = 0,
|
||
groupDirectory,
|
||
projectId,
|
||
archivedBucket = false,
|
||
directoryStatus,
|
||
currentSessionId,
|
||
pinnedSessionIds,
|
||
expandedParents,
|
||
hasSessionSearchQuery,
|
||
normalizedSessionSearchQuery,
|
||
notifyOnSubtasks,
|
||
editingId,
|
||
setEditingId,
|
||
editTitle,
|
||
setEditTitle,
|
||
handleSaveEdit,
|
||
handleCancelEdit,
|
||
toggleParent,
|
||
handleSessionSelect,
|
||
handleSessionDoubleClick,
|
||
togglePinnedSession,
|
||
handleShareSession,
|
||
copiedSessionId,
|
||
handleCopyShareUrl,
|
||
handleUnshareSession,
|
||
openSidebarMenuKey,
|
||
setOpenSidebarMenuKey,
|
||
renamingFolderId,
|
||
getFoldersForScope,
|
||
getSessionFolderId,
|
||
removeSessionFromFolder,
|
||
addSessionToFolder,
|
||
createFolderAndStartRename,
|
||
openContextPanelTab,
|
||
handleDeleteSession,
|
||
mobileVariant,
|
||
alwaysShowActions,
|
||
renderSessionNode,
|
||
secondaryMeta,
|
||
renderContext = 'project',
|
||
} = props;
|
||
const hasSecondaryProjectLabel = Boolean(secondaryMeta?.projectLabel);
|
||
const hasSecondaryBranchLabel = Boolean(secondaryMeta?.branchLabel);
|
||
|
||
const displayMode = useSessionDisplayStore((state) => state.displayMode);
|
||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||
// VS Code keeps the expanded "default" layout regardless of the stored mode:
|
||
// multi-workspace lists rely on inline project/branch, and hover tooltips
|
||
// across the whole list would be impractical there.
|
||
const isMinimalMode = displayMode === 'minimal' && !isVSCode;
|
||
const isElectron = React.useMemo(() => canUseElectronDesktopIPC(), []);
|
||
const runtimeApis = React.useContext(RuntimeAPIContext);
|
||
const revealOnHoverClass = isVSCode
|
||
? 'group-hover:opacity-100 group-hover:pointer-events-auto'
|
||
: 'group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto';
|
||
const hideOnHoverClass = isVSCode
|
||
? 'group-hover:opacity-0'
|
||
: 'group-hover:opacity-0 group-focus-within:opacity-0';
|
||
const showOpenInEditorAction = isVSCode;
|
||
const showQuickArchiveAction = !archivedBucket && !mobileVariant;
|
||
const revealPaddingClass = isMinimalMode
|
||
? (isVSCode
|
||
? 'group-hover:pr-2'
|
||
: 'group-hover:pr-2 group-focus-within:pr-2')
|
||
: (isVSCode
|
||
? (showQuickArchiveAction && showOpenInEditorAction
|
||
? 'group-hover:pr-18'
|
||
: showQuickArchiveAction || showOpenInEditorAction
|
||
? 'group-hover:pr-12'
|
||
: 'group-hover:pr-5')
|
||
: (showQuickArchiveAction ? 'group-hover:pr-12 group-focus-within:pr-12' : 'group-hover:pr-5 group-focus-within:pr-5'));
|
||
const alwaysActionPaddingClass = showQuickArchiveAction ? 'pr-13' : 'pr-7';
|
||
const suppressNextSelectRef = React.useRef(false);
|
||
const [isTouchPressed, setIsTouchPressed] = React.useState(false);
|
||
const editingIdRef = React.useRef(editingId);
|
||
editingIdRef.current = editingId;
|
||
const pendingRenameRef = React.useRef<{ id: string; title: string } | null>(null);
|
||
const handleSaveEditRef = React.useRef(handleSaveEdit);
|
||
handleSaveEditRef.current = handleSaveEdit;
|
||
const formRef = React.useRef<HTMLFormElement>(null);
|
||
|
||
const session = node.session;
|
||
const liveSession = useSession(session.id);
|
||
const resolvedSession = liveSession ?? session;
|
||
|
||
const sessionDirectory =
|
||
normalizePath((session as Session & { directory?: string | null }).directory ?? null)
|
||
?? normalizePath(groupDirectory ?? null);
|
||
// Archived rows are historical and never need live state, yet they point at
|
||
// dozens of (often deleted) worktrees — bootstrapping each from the sidebar
|
||
// triggers a pointless session-list fetch + 6×2s empty-retry storm on startup.
|
||
// Skip bootstrap for archived rows; the store ref is only read on-demand via
|
||
// getState() in the export handlers (never subscribed). Active rows keep
|
||
// bootstrapping so live cross-directory session/status still aggregates.
|
||
const directoryStore = useDirectoryStore(sessionDirectory ?? undefined, { bootstrap: !archivedBucket });
|
||
const sync = useSync();
|
||
|
||
const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled);
|
||
const isRowSelected = useSessionMultiSelectStore(
|
||
React.useCallback((state) => state.selectedIds.has(session.id), [session.id]),
|
||
);
|
||
const toggleRowSelected = useSessionMultiSelectStore((state) => state.toggleSelected);
|
||
const setRowRange = useSessionMultiSelectStore((state) => state.setRange);
|
||
|
||
const collectNodeDescendantIds = React.useCallback((root: SessionNode): string[] => {
|
||
const out: string[] = [];
|
||
const walk = (n: SessionNode) => {
|
||
n.children.forEach((child) => {
|
||
out.push(child.session.id);
|
||
walk(child);
|
||
});
|
||
};
|
||
walk(root);
|
||
return out;
|
||
}, []);
|
||
|
||
const [exportDialogOpen, setExportDialogOpen] = React.useState(false);
|
||
const [exportIncludeSubtasks, setExportIncludeSubtasks] = React.useState(true);
|
||
|
||
const menuInstanceKey = `${renderContext}:${archivedBucket ? 'archived' : 'active'}:${session.id}`;
|
||
const isZombie = useViewportStore(
|
||
React.useCallback((state) => Boolean(state.sessionMemoryState.get(viewportSessionKey(session.id))?.isZombie), [session.id]),
|
||
);
|
||
const sessionStatus = useGlobalSessionStatus(session.id);
|
||
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined);
|
||
const directoryState = sessionDirectory ? directoryStatus.get(sessionDirectory) : null;
|
||
const isMissingDirectory = directoryState === 'missing';
|
||
const isActive = currentSessionId === session.id;
|
||
const sessionTitle = resolvedSession.title || t('sessions.sidebar.session.untitled');
|
||
const hasChildren = node.children.length > 0;
|
||
const isPinnedSession = pinnedSessionIds.has(session.id);
|
||
// Per-render-context expansion key: the same session can appear in both
|
||
// the project's root and the "Recent" list, and expanding one should not
|
||
// expand the other. Matches the format of menuInstanceKey.
|
||
const expansionKey = menuInstanceKey;
|
||
const isExpanded = hasSessionSearchQuery ? true : expandedParents.has(expansionKey);
|
||
const isSubtaskSession = Boolean((resolvedSession as Session & { parentID?: string | null }).parentID);
|
||
const unseenCount = useSessionUnseenCount(session.id);
|
||
const needsAttention = unseenCount > 0 && (!isSubtaskSession || notifyOnSubtasks);
|
||
const sessionTimestamp = resolvedSession.time?.updated || resolvedSession.time?.created || Date.now();
|
||
const sessionUpdatedLabel = formatSessionDateLabel(sessionTimestamp);
|
||
const sessionCompactUpdatedLabel = formatSessionCompactDateLabel(sessionTimestamp);
|
||
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
|
||
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false);
|
||
const isSessionMenuOpen = isMenuOpen || isContextMenuOpen;
|
||
const isMultiRunLikeSession = React.useMemo(() => parseMultiRunSessionTitle(resolvedSession.title) !== null, [resolvedSession.title]);
|
||
const [fusionDialogOpen, setFusionDialogOpen] = React.useState(false);
|
||
const metadataSubsessionChevron = isVSCode && renderContext === 'recent' && !isMinimalMode;
|
||
const inlineSubsessionChevron = isVSCode && renderContext === 'recent' && isMinimalMode;
|
||
|
||
const descendantCount = React.useMemo(() => collectNodeDescendantIds(node).length, [collectNodeDescendantIds, node]);
|
||
|
||
const collectChildExports = React.useCallback(async (children: SessionNode[]): Promise<{ children: ChildSessionExport[]; skipped: number }> => {
|
||
const results: ChildSessionExport[] = [];
|
||
let skipped = 0;
|
||
for (const child of children) {
|
||
try {
|
||
await sync.ensureSessionRenderable(child.session.id);
|
||
const childRecords = buildSessionMessageRecordsSnapshot(directoryStore.getState(), child.session.id).list;
|
||
const childTitle = child.session.title || t('sessions.sidebar.session.export.untitledSubagent');
|
||
const childAgent = (child.session as Session & { agent?: string }).agent;
|
||
const grandChildren = await collectChildExports(child.children);
|
||
skipped += grandChildren.skipped;
|
||
results.push({
|
||
title: childTitle,
|
||
agent: childAgent,
|
||
records: childRecords,
|
||
children: grandChildren.children,
|
||
});
|
||
} catch {
|
||
skipped += collectNodeDescendantIds(child).length + 1;
|
||
}
|
||
}
|
||
return { children: results, skipped };
|
||
}, [collectNodeDescendantIds, directoryStore, sync, t]);
|
||
|
||
const showSkippedSubtasksWarning = React.useCallback((count: number) => {
|
||
if (count <= 0) return;
|
||
toast.warning(count === 1
|
||
? t('sessions.sidebar.session.export.skippedSubtaskSingle', { count })
|
||
: t('sessions.sidebar.session.export.skippedSubtaskMany', { count }));
|
||
}, [t]);
|
||
|
||
const doExportSession = React.useCallback(async (includeSubtasks: boolean) => {
|
||
if (!sessionDirectory) {
|
||
toast.error(t('sessions.sidebar.session.export.nothingToExport'));
|
||
return;
|
||
}
|
||
|
||
await sync.ensureSessionRenderable(session.id);
|
||
|
||
const records = buildSessionMessageRecordsSnapshot(directoryStore.getState(), session.id).list;
|
||
if (records.length === 0) {
|
||
toast.error(t('sessions.sidebar.session.export.nothingToExport'));
|
||
return;
|
||
}
|
||
|
||
let childExports: ChildSessionExport[] | undefined;
|
||
let skippedSubtaskCount = 0;
|
||
if (includeSubtasks && node.children.length > 0) {
|
||
const collected = await collectChildExports(node.children);
|
||
childExports = collected.children;
|
||
skippedSubtaskCount = collected.skipped;
|
||
}
|
||
|
||
const markdown = formatSessionAsMarkdown(records, resolvedSession.title ?? null, childExports);
|
||
const filename = buildExportFilename(resolvedSession.title ?? null);
|
||
const savedPath = await saveAsMarkdownDesktop(markdown, filename);
|
||
|
||
if (savedPath) {
|
||
toast.success(t('sessions.sidebar.session.export.success'), {
|
||
action: {
|
||
label: t(getExportRevealLabelKey()),
|
||
onClick: () => {
|
||
void revealExportedMarkdown(savedPath).then((revealed) => {
|
||
if (!revealed) {
|
||
toast.error(t('sessions.sidebar.session.export.failedRevealPath'));
|
||
}
|
||
});
|
||
},
|
||
},
|
||
});
|
||
showSkippedSubtasksWarning(skippedSubtaskCount);
|
||
return;
|
||
}
|
||
|
||
downloadAsMarkdown(markdown, filename);
|
||
toast.success(t('sessions.sidebar.session.export.success'));
|
||
showSkippedSubtasksWarning(skippedSubtaskCount);
|
||
}, [collectChildExports, directoryStore, node.children, resolvedSession.title, session.id, sessionDirectory, showSkippedSubtasksWarning, sync, t]);
|
||
const handleExportSession = React.useCallback(async () => {
|
||
if (node.children.length > 0) {
|
||
setExportIncludeSubtasks(true);
|
||
setExportDialogOpen(true);
|
||
return;
|
||
}
|
||
await doExportSession(false);
|
||
}, [doExportSession, node.children.length]);
|
||
|
||
const handleOpenMiniChatWindow = React.useCallback(() => {
|
||
if (!sessionDirectory) return;
|
||
void invokeDesktop('desktop_open_session_mini_chat_window', {
|
||
sessionId: session.id,
|
||
directory: sessionDirectory,
|
||
apiBaseUrl: getRuntimeApiBaseUrl(),
|
||
clientToken: getRuntimeBearerTokenSync(),
|
||
}).catch((error) => {
|
||
console.warn('[session-sidebar] failed to open mini chat window', error);
|
||
});
|
||
}, [session.id, sessionDirectory]);
|
||
|
||
// Capture outside-clicks to save edits — immune to focus-race with onBlur.
|
||
React.useEffect(() => {
|
||
if (editingId !== session.id) return;
|
||
const handleDocMouseDown = (e: MouseEvent) => {
|
||
if (formRef.current && !formRef.current.contains(e.target as Node)) {
|
||
handleSaveEditRef.current();
|
||
}
|
||
};
|
||
document.addEventListener('mousedown', handleDocMouseDown);
|
||
return () => document.removeEventListener('mousedown', handleDocMouseDown);
|
||
}, [editingId, session.id]);
|
||
|
||
if (editingId === session.id) {
|
||
return (
|
||
<div
|
||
key={session.id}
|
||
className={cn('group relative flex items-center rounded-sm px-1.5 py-1', depth > 0 && 'pl-[20px]')}
|
||
>
|
||
<div className="flex min-w-0 flex-1 flex-col gap-0">
|
||
<form
|
||
ref={formRef}
|
||
className="flex w-full items-center gap-2"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
handleSaveEdit();
|
||
}}
|
||
>
|
||
<input
|
||
value={editTitle}
|
||
onChange={(event) => setEditTitle(event.target.value)}
|
||
className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground"
|
||
autoFocus
|
||
placeholder={t('sessions.sidebar.session.menu.rename')}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Escape') {
|
||
event.stopPropagation();
|
||
handleCancelEdit();
|
||
return;
|
||
}
|
||
if (event.key === ' ' || event.key === 'Enter') {
|
||
event.stopPropagation();
|
||
}
|
||
}}
|
||
/>
|
||
<button
|
||
type="submit"
|
||
aria-label={t('sessions.sidebar.session.rename.save')}
|
||
title={t('sessions.sidebar.session.rename.save')}
|
||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||
>
|
||
<Icon name="check" className="size-4" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleCancelEdit}
|
||
aria-label={t('sessions.sidebar.session.rename.cancel')}
|
||
title={t('sessions.sidebar.session.rename.cancel')}
|
||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||
>
|
||
<Icon name="close" className="size-4" />
|
||
</button>
|
||
</form>
|
||
{!isMinimalMode ? (
|
||
<div className="flex items-center justify-between gap-3 text-muted-foreground/60 min-w-0 overflow-hidden leading-tight" style={{ fontSize: 'calc(var(--text-ui-label) * 0.85)' }}>
|
||
<div className="flex min-w-0 items-center gap-1.5 overflow-hidden">
|
||
{hasChildren ? <span className="inline-flex items-center justify-center flex-shrink-0">{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">{sessionUpdatedLabel}</span>
|
||
{hasSecondaryProjectLabel ? <span className="truncate">{secondaryMeta?.projectLabel}</span> : null}
|
||
{hasSecondaryBranchLabel ? <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">{secondaryMeta?.branchLabel}</span></span> : null}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const statusType = sessionStatus?.type ?? 'idle';
|
||
const isStreaming = statusType === 'busy' || statusType === 'retry';
|
||
const pendingPermissionCount = sessionPermissions.length;
|
||
const showUnreadStatus = !isStreaming && needsAttention && !isActive;
|
||
const showStatusMarker = isStreaming || showUnreadStatus;
|
||
const statusMarkerContent = 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')}
|
||
/>
|
||
);
|
||
const leadingIndicators = showStatusMarker || isPinnedSession ? (
|
||
<span
|
||
className={cn(
|
||
'pointer-events-none absolute inline-flex h-3.5 items-center justify-center gap-0.5 transition-opacity',
|
||
isMinimalMode ? 'top-1/2 -translate-y-1/2' : 'top-[14.5px] -translate-y-1/2',
|
||
showStatusMarker && isPinnedSession ? 'left-[-6px] w-6' : 'left-0.5 w-3.5',
|
||
hasChildren && !alwaysShowActions ? 'opacity-100 group-hover:opacity-0 group-focus-within:opacity-0' : '',
|
||
)}
|
||
>
|
||
{showStatusMarker ? statusMarkerContent : null}
|
||
{isPinnedSession ? <Icon name="pushpin" className="h-3 w-3 flex-shrink-0 text-primary" aria-label={t('sessions.sidebar.session.status.pinned')}/> : null}
|
||
</span>
|
||
) : null;
|
||
const subsessionChevron = hasChildren ? (
|
||
<span
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
toggleParent(expansionKey);
|
||
}}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter' || event.key === ' ') {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
toggleParent(expansionKey);
|
||
}
|
||
}}
|
||
className={cn(
|
||
'inline-flex h-3.5 w-3.5 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
|
||
metadataSubsessionChevron
|
||
? 'absolute left-1.5 bottom-1'
|
||
: inlineSubsessionChevron
|
||
? 'relative mr-0.5 shrink-0'
|
||
: cn('absolute left-0.5', isMinimalMode ? 'top-1/2 -translate-y-1/2' : 'top-[14.5px] -translate-y-1/2'),
|
||
!metadataSubsessionChevron && !inlineSubsessionChevron && isMinimalMode && showStatusMarker && !alwaysShowActions
|
||
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto'
|
||
: '',
|
||
)}
|
||
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;
|
||
|
||
const streamingIndicator = isZombie
|
||
? <Icon name="error-warning" className="h-4 w-4 text-status-warning" />
|
||
: null;
|
||
|
||
const handleMenuOpenChange = (open: boolean) => {
|
||
if (open) {
|
||
setIsContextMenuOpen(false);
|
||
}
|
||
setOpenSidebarMenuKey(open ? menuInstanceKey : null);
|
||
};
|
||
|
||
const handleMenuOpenChangeComplete = (open: boolean) => {
|
||
if (!open && pendingRenameRef.current) {
|
||
const { id, title } = pendingRenameRef.current;
|
||
pendingRenameRef.current = null;
|
||
setEditingId(id);
|
||
setEditTitle(title);
|
||
}
|
||
};
|
||
|
||
const handleContextMenuOpenChange = (open: boolean) => {
|
||
setIsContextMenuOpen(open);
|
||
};
|
||
|
||
const handleMenuTriggerClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
setOpenSidebarMenuKey(isMenuOpen ? null : menuInstanceKey);
|
||
};
|
||
|
||
const handleMenuTriggerPointerDown = (event: React.PointerEvent<HTMLButtonElement>) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
};
|
||
|
||
const handleMenuTriggerMouseDown = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
};
|
||
|
||
const handleQuickArchivePointerDown = (event: React.PointerEvent<HTMLButtonElement>) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
};
|
||
|
||
const handleQuickArchiveMouseDown = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
};
|
||
|
||
const handleQuickArchiveClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
setOpenSidebarMenuKey(null);
|
||
handleDeleteSession(session, { archivedBucket });
|
||
};
|
||
|
||
const handleOpenInEditorPointerDown = (event: React.PointerEvent<HTMLButtonElement>) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
};
|
||
|
||
const handleOpenInEditorMouseDown = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
};
|
||
|
||
const handleOpenInEditorClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
void runtimeApis?.vscode?.executeCommand('openchamber.openSessionInEditor', session.id, sessionTitle);
|
||
};
|
||
|
||
const handleRowSelect = (event?: React.MouseEvent<HTMLButtonElement>) => {
|
||
if (suppressNextSelectRef.current) {
|
||
suppressNextSelectRef.current = false;
|
||
return;
|
||
}
|
||
if (selectionModeEnabled) {
|
||
event?.preventDefault();
|
||
event?.stopPropagation();
|
||
if (event?.shiftKey) {
|
||
const rows = typeof document !== 'undefined'
|
||
? Array.from(document.querySelectorAll<HTMLElement>('[data-session-row]'))
|
||
: [];
|
||
const orderedIds = rows
|
||
.map((el) => el.getAttribute('data-session-row'))
|
||
.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||
const currentAnchor = useSessionMultiSelectStore.getState().anchorId;
|
||
const descendantsById = new Map<string, string[]>();
|
||
descendantsById.set(session.id, collectNodeDescendantIds(node));
|
||
setRowRange(currentAnchor, session.id, orderedIds, sessionDirectory ?? null, descendantsById);
|
||
return;
|
||
}
|
||
toggleRowSelected(session.id, sessionDirectory ?? null, collectNodeDescendantIds(node));
|
||
return;
|
||
}
|
||
handleSessionSelect(session.id, sessionDirectory, isMissingDirectory, projectId);
|
||
};
|
||
|
||
const handleRowMouseDown = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||
if (event.button === 2 || (event.button === 0 && event.ctrlKey && !selectionModeEnabled)) {
|
||
suppressNextSelectRef.current = true;
|
||
}
|
||
};
|
||
const handleRowPointerDown = (event: React.PointerEvent<HTMLButtonElement>) => {
|
||
if (mobileVariant && event.pointerType === 'touch') {
|
||
setIsTouchPressed(true);
|
||
}
|
||
};
|
||
const handleRowPointerEnd = (event: React.PointerEvent<HTMLButtonElement>) => {
|
||
if (mobileVariant && event.pointerType === 'touch') {
|
||
setIsTouchPressed(false);
|
||
}
|
||
};
|
||
|
||
const renderSessionMenuItems = ({
|
||
Item,
|
||
Separator,
|
||
Sub,
|
||
SubTrigger,
|
||
SubContent,
|
||
}: {
|
||
Item: React.ElementType;
|
||
Separator: React.ElementType;
|
||
Sub: React.ElementType;
|
||
SubTrigger: React.ElementType;
|
||
SubContent: React.ElementType;
|
||
}) => (
|
||
<>
|
||
<Item
|
||
onClick={() => {
|
||
// Defer rename until dropdown close transition completes.
|
||
// onOpenChangeComplete fires after animation + focus cleanup are done,
|
||
// avoiding focus stealing from Base UI's unmount cleanup.
|
||
pendingRenameRef.current = { id: session.id, title: sessionTitle };
|
||
}}
|
||
className="[&>svg]:mr-1"
|
||
>
|
||
<Icon name="pencil-ai" className="mr-1 h-4 w-4" />
|
||
{t('sessions.sidebar.session.menu.rename')}
|
||
</Item>
|
||
<Item onClick={() => togglePinnedSession(session.id)} className="[&>svg]:mr-1">
|
||
{isPinnedSession ? <Icon name="unpin" className="mr-1 h-4 w-4" /> : <Icon name="pushpin" className="mr-1 h-4 w-4" />}
|
||
{isPinnedSession ? t('sessions.sidebar.session.menu.unpin') : t('sessions.sidebar.session.menu.pin')}
|
||
</Item>
|
||
{!resolvedSession.share ? (
|
||
<Item onClick={() => handleShareSession(resolvedSession)} className="[&>svg]:mr-1">
|
||
<Icon name="share-2" className="mr-1 h-4 w-4" />
|
||
{t('sessions.sidebar.session.menu.share')}
|
||
</Item>
|
||
) : (
|
||
<>
|
||
<Item onClick={() => { if (resolvedSession.share?.url) handleCopyShareUrl(resolvedSession.share.url, session.id); }} className="[&>svg]:mr-1">
|
||
{copiedSessionId === session.id
|
||
? <><Icon name="check" className="mr-1 h-4 w-4" style={{ color: 'var(--status-success)' }}/>{t('sessions.sidebar.session.menu.copied')}</>
|
||
: <><Icon name="file-copy" className="mr-1 h-4 w-4" />{t('sessions.sidebar.session.menu.copyLink')}</>}
|
||
</Item>
|
||
<Item onClick={() => handleUnshareSession(session.id)} className="[&>svg]:mr-1">
|
||
<Icon name="link-unlink-m" className="mr-1 h-4 w-4" />
|
||
{t('sessions.sidebar.session.menu.unshare')}
|
||
</Item>
|
||
</>
|
||
)}
|
||
<Item onClick={() => { void handleExportSession(); }} className="[&>svg]:mr-1">
|
||
<Icon name="download" className="mr-1 h-4 w-4" />
|
||
{t('sessions.sidebar.session.menu.exportMarkdown')}
|
||
</Item>
|
||
{isMultiRunLikeSession ? (
|
||
<Item onClick={() => setFusionDialogOpen(true)} className="[&>svg]:mr-1">
|
||
<FusionIcon className="mr-1 h-4 w-4" />
|
||
{t('sessions.sidebar.session.menu.runFusion')}
|
||
</Item>
|
||
) : null}
|
||
|
||
{sessionDirectory && !archivedBucket ? (() => {
|
||
const scopeFolders = getFoldersForScope(sessionDirectory);
|
||
const currentFolderId = getSessionFolderId(sessionDirectory, session.id);
|
||
return (
|
||
<>
|
||
<Separator />
|
||
<Sub>
|
||
<SubTrigger className="[&>svg]:mr-1"><Icon name="folder" className="h-4 w-4" />{t('sessions.sidebar.folders.moveToFolder')}</SubTrigger>
|
||
<SubContent className="min-w-[180px]">
|
||
{scopeFolders.length === 0 ? (
|
||
<Item disabled className="text-muted-foreground">{t('sessions.sidebar.folders.none')}</Item>
|
||
) : (
|
||
scopeFolders.map((folder) => (
|
||
<Item key={folder.id} onClick={() => { if (currentFolderId === folder.id) removeSessionFromFolder(sessionDirectory, session.id); else addSessionToFolder(sessionDirectory, folder.id, session.id); }}>
|
||
<span className="flex-1 truncate">{folder.name}</span>
|
||
{currentFolderId === folder.id ? <Icon name="check" className="ml-2 h-3.5 w-3.5 text-primary flex-shrink-0" /> : null}
|
||
</Item>
|
||
))
|
||
)}
|
||
<Separator />
|
||
<Item onClick={() => { const newFolder = createFolderAndStartRename(sessionDirectory); if (!newFolder) return; addSessionToFolder(sessionDirectory, newFolder.id, session.id); }}>
|
||
<Icon name="add" className="mr-1 h-4 w-4" />
|
||
{t('sessions.sidebar.folders.newFolderEllipsis')}
|
||
</Item>
|
||
{currentFolderId ? (
|
||
<Item onClick={() => { removeSessionFromFolder(sessionDirectory, session.id); }} className="text-destructive focus:text-destructive">
|
||
<Icon name="close" className="mr-1 h-4 w-4" />
|
||
{t('sessions.sidebar.folders.removeFromFolder')}
|
||
</Item>
|
||
) : null}
|
||
</SubContent>
|
||
</Sub>
|
||
</>
|
||
);
|
||
})() : null}
|
||
|
||
{!isVSCode ? (
|
||
<Item
|
||
disabled={!sessionDirectory}
|
||
onClick={() => {
|
||
if (!sessionDirectory) return;
|
||
openContextPanelTab(sessionDirectory, {
|
||
mode: 'chat',
|
||
dedupeKey: `session:${session.id}`,
|
||
label: sessionTitle,
|
||
});
|
||
}}
|
||
className="[&>svg]:mr-1"
|
||
>
|
||
<Icon name="chat-4" className="mr-1 h-4 w-4" />
|
||
<span className="truncate">{t('sessions.sidebar.session.menu.openInSidePanel')}</span>
|
||
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">{t('sessions.sidebar.session.menu.betaBadge')}</span>
|
||
</Item>
|
||
) : null}
|
||
|
||
{isElectron ? (
|
||
<Item
|
||
disabled={!sessionDirectory}
|
||
onClick={handleOpenMiniChatWindow}
|
||
className="[&>svg]:mr-1"
|
||
>
|
||
<Icon name="window" className="mr-1 h-4 w-4" />
|
||
<span className="truncate">{t('sessions.sidebar.session.menu.openMiniChatWindow')}</span>
|
||
</Item>
|
||
) : null}
|
||
|
||
<Separator />
|
||
{!archivedBucket ? (
|
||
<Item className="[&>svg]:mr-1" onClick={() => handleDeleteSession(session, { archivedBucket })}>
|
||
<Icon name="inbox-archive" className="mr-1 h-4 w-4" />
|
||
{t('sessions.sidebar.bulkActions.archive')}
|
||
</Item>
|
||
) : null}
|
||
<Item className="text-destructive focus:text-destructive [&>svg]:mr-1" onClick={() => handleDeleteSession(session, { archivedBucket, hardDelete: true })}>
|
||
<Icon name="delete-bin" className="mr-1 h-4 w-4" />
|
||
{t('sessions.sidebar.bulkActions.delete')}
|
||
</Item>
|
||
</>
|
||
);
|
||
|
||
const sessionMenuContent = (
|
||
<DropdownMenuContent align="end" className="min-w-[180px]" finalFocus={() => (renamingFolderId || editingIdRef.current) ? false : true}>
|
||
{renderSessionMenuItems({
|
||
Item: DropdownMenuItem,
|
||
Separator: DropdownMenuSeparator,
|
||
Sub: DropdownMenuSub,
|
||
SubTrigger: DropdownMenuSubTrigger,
|
||
SubContent: DropdownMenuSubContent,
|
||
})}
|
||
</DropdownMenuContent>
|
||
);
|
||
|
||
const contextMenuContent = (
|
||
<ContextMenu.Portal>
|
||
<ContextMenu.Positioner className="app-region-no-drag z-50">
|
||
<ContextMenu.Popup
|
||
data-slot="dropdown-menu-content"
|
||
finalFocus={() => (renamingFolderId || editingIdRef.current) ? false : true}
|
||
style={{
|
||
backgroundColor: 'var(--surface-elevated)',
|
||
color: 'var(--surface-elevated-foreground)',
|
||
}}
|
||
className={cn(dropdownMenuPopupClass, 'min-w-[180px]')}
|
||
>
|
||
{renderSessionMenuItems({
|
||
Item: ({ className, ...itemProps }: React.ComponentProps<typeof ContextMenu.Item>) => (
|
||
<ContextMenu.Item className={cn(dropdownMenuItemClass, className)} {...itemProps} />
|
||
),
|
||
Separator: ({ className, ...separatorProps }: React.ComponentProps<typeof ContextMenu.Separator>) => (
|
||
<ContextMenu.Separator className={cn(dropdownMenuSeparatorClass, className)} {...separatorProps} />
|
||
),
|
||
Sub: ContextMenu.SubmenuRoot,
|
||
SubTrigger: ({ className, children, ...triggerProps }: React.ComponentProps<typeof ContextMenu.SubmenuTrigger>) => (
|
||
<ContextMenu.SubmenuTrigger className={cn(dropdownMenuSubTriggerClass, className)} {...triggerProps}>
|
||
{children}
|
||
<Icon name="arrow-right-s" className="ml-auto size-3.5" />
|
||
</ContextMenu.SubmenuTrigger>
|
||
),
|
||
SubContent: ({ className, children, ...popupProps }: React.ComponentProps<typeof ContextMenu.Popup>) => (
|
||
<ContextMenu.Portal>
|
||
<ContextMenu.Positioner className="app-region-no-drag z-50">
|
||
<ContextMenu.Popup
|
||
data-slot="dropdown-menu-sub-content"
|
||
style={{
|
||
backgroundColor: 'var(--surface-elevated)',
|
||
color: 'var(--surface-elevated-foreground)',
|
||
}}
|
||
className={cn(dropdownMenuPopupClass, className)}
|
||
{...popupProps}
|
||
>
|
||
{children}
|
||
</ContextMenu.Popup>
|
||
</ContextMenu.Positioner>
|
||
</ContextMenu.Portal>
|
||
),
|
||
})}
|
||
</ContextMenu.Popup>
|
||
</ContextMenu.Positioner>
|
||
</ContextMenu.Portal>
|
||
);
|
||
|
||
return (
|
||
<React.Fragment key={session.id}>
|
||
<DraggableSessionRow sessionId={session.id} sessionDirectory={sessionDirectory ?? null} sessionTitle={sessionTitle}>
|
||
<ContextMenu.Root open={isContextMenuOpen} onOpenChange={handleContextMenuOpenChange} onOpenChangeComplete={handleMenuOpenChangeComplete}>
|
||
<ContextMenu.Trigger
|
||
render={
|
||
<div
|
||
data-session-row={session.id}
|
||
data-session-scope={sessionDirectory ?? ''}
|
||
data-session-archived={archivedBucket ? '1' : '0'}
|
||
className={cn(
|
||
'group relative my-0.5 flex items-center rounded-md py-1 pr-1.5',
|
||
// Pull the row box left into the container gutter so the
|
||
// selection highlight covers the chevron/status markers
|
||
// (which sit in that gutter), then re-pad so the title text
|
||
// stays put.
|
||
'-ml-3',
|
||
depth > 0 ? 'pl-[32px]' : 'pl-[18px]',
|
||
isMissingDirectory ? 'opacity-75' : '',
|
||
// Active (currently open) session gets a subtle primary tint;
|
||
// multi-select highlight takes precedence when both apply.
|
||
isActive && !isRowSelected && 'bg-primary/10',
|
||
isRowSelected && 'bg-interactive-selection',
|
||
)}
|
||
/>
|
||
}
|
||
>
|
||
{leadingIndicators}
|
||
{subsessionChevron}
|
||
<div className="flex min-w-0 flex-1 items-center">
|
||
{isMinimalMode ? (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
type="button"
|
||
disabled={isMissingDirectory}
|
||
onPointerDown={handleRowPointerDown}
|
||
onPointerUp={handleRowPointerEnd}
|
||
onPointerCancel={handleRowPointerEnd}
|
||
onMouseDown={handleRowMouseDown}
|
||
onClick={(event) => handleRowSelect(event)}
|
||
onDoubleClick={(e) => {
|
||
e.stopPropagation();
|
||
handleSessionDoubleClick(session.id, sessionTitle);
|
||
}}
|
||
className={cn(
|
||
'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none disabled:cursor-not-allowed transition-[padding]',
|
||
isTouchPressed && 'bg-interactive-hover/70',
|
||
alwaysShowActions
|
||
? (isVSCode ? revealPaddingClass : alwaysActionPaddingClass)
|
||
: revealPaddingClass,
|
||
)}
|
||
>
|
||
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-1')}>
|
||
<div className={cn('block min-w-0 flex-1 truncate typography-ui-label font-normal', isActive ? 'text-primary' : 'text-foreground')}>{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
|
||
{alwaysShowActions ? <span className="ml-2 flex-shrink-0 text-[0.72rem] text-muted-foreground/75">{sessionCompactUpdatedLabel}</span> : null}
|
||
{!alwaysShowActions ? (
|
||
<div className="relative ml-1 flex h-4 min-w-4 flex-shrink-0 items-center justify-end">
|
||
<span className={cn(
|
||
'whitespace-nowrap text-right text-[0.72rem] text-muted-foreground/75 transition-opacity duration-150',
|
||
isSessionMenuOpen
|
||
? 'opacity-0'
|
||
: hideOnHoverClass,
|
||
)}>
|
||
{sessionCompactUpdatedLabel}
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
{pendingPermissionCount > 0 ? (
|
||
<span className="inline-flex items-center gap-1 rounded bg-destructive/10 px-1 py-0.5 text-[0.7rem] text-destructive flex-shrink-0" title={t('sessions.sidebar.session.status.permissionRequired')} aria-label={t('sessions.sidebar.session.status.permissionRequired')}>
|
||
<Icon name="shield" className="h-3 w-3" />
|
||
<span className="leading-none">{pendingPermissionCount}</span>
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right" sideOffset={8} className="max-w-xs text-left">
|
||
<div className="flex flex-col gap-1 text-left text-xs">
|
||
<div className={cn('flex items-center gap-3 text-left text-muted-foreground', secondaryMeta?.projectLabel ? 'justify-between' : 'justify-start')}>
|
||
{secondaryMeta?.projectLabel ? <div className="min-w-0 truncate">{secondaryMeta.projectLabel}</div> : null}
|
||
<div className="flex-shrink-0">{sessionUpdatedLabel}</div>
|
||
</div>
|
||
{secondaryMeta?.branchLabel ? (
|
||
<div className="flex items-center gap-3 text-left text-muted-foreground justify-start">
|
||
<div className="flex min-w-0 items-center gap-1.5 overflow-hidden">
|
||
<span className="inline-flex min-w-0 items-center gap-0.5"><Icon name="git-branch" className="h-3 w-3 flex-shrink-0" /><span className="truncate">{secondaryMeta.branchLabel}</span></span>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
disabled={isMissingDirectory}
|
||
onPointerDown={handleRowPointerDown}
|
||
onPointerUp={handleRowPointerEnd}
|
||
onPointerCancel={handleRowPointerEnd}
|
||
onMouseDown={handleRowMouseDown}
|
||
onClick={(event) => handleRowSelect(event)}
|
||
onDoubleClick={(e) => {
|
||
e.stopPropagation();
|
||
handleSessionDoubleClick(session.id, sessionTitle);
|
||
}}
|
||
className={cn(
|
||
'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none disabled:cursor-not-allowed transition-[padding]',
|
||
isTouchPressed && 'bg-interactive-hover/70',
|
||
alwaysShowActions
|
||
? (isVSCode ? revealPaddingClass : alwaysActionPaddingClass)
|
||
: revealPaddingClass
|
||
)}
|
||
>
|
||
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-1')}>
|
||
<div className={cn('block min-w-0 flex-1 truncate typography-ui-label font-normal', isActive ? 'text-primary' : 'text-foreground')}>{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
|
||
{pendingPermissionCount > 0 ? (
|
||
<span className="inline-flex items-center gap-1 rounded bg-destructive/10 px-1 py-0.5 text-[0.7rem] text-destructive flex-shrink-0" title={t('sessions.sidebar.session.status.permissionRequired')} aria-label={t('sessions.sidebar.session.status.permissionRequired')}>
|
||
<Icon name="shield" className="h-3 w-3" />
|
||
<span className="leading-none">{pendingPermissionCount}</span>
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
|
||
{!isMinimalMode ? (
|
||
<div className="flex items-center justify-between gap-3 text-muted-foreground/60 min-w-0 overflow-hidden leading-tight" style={{ fontSize: 'calc(var(--text-ui-label) * 0.85)' }}>
|
||
<div className={cn('flex min-w-0 items-center gap-1.5 overflow-hidden', metadataSubsessionChevron && hasChildren ? 'pl-4' : '')}>
|
||
<span className="flex-shrink-0">{sessionUpdatedLabel}</span>
|
||
{hasSecondaryProjectLabel ? <span className="truncate">{secondaryMeta?.projectLabel}</span> : null}
|
||
{hasSecondaryBranchLabel ? <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">{secondaryMeta?.branchLabel}</span></span> : null}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{streamingIndicator && !mobileVariant ? (
|
||
<div className={cn('absolute top-1/2 -translate-y-1/2 z-10', isMinimalMode ? 'right-0' : 'right-[30px]')}>
|
||
{streamingIndicator}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className={cn(
|
||
'absolute right-0 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5 transition-opacity',
|
||
isSessionMenuOpen
|
||
? 'opacity-100'
|
||
: (alwaysShowActions && !isVSCode)
|
||
? 'opacity-100'
|
||
: cn('opacity-0', revealOnHoverClass),
|
||
)}>
|
||
{showQuickArchiveAction ? (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
'inline-flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
|
||
isMinimalMode && !alwaysShowActions ? 'h-4 w-4' : 'h-6 w-6',
|
||
)}
|
||
aria-label={t('sessions.sidebar.bulkActions.archive')}
|
||
onPointerDown={handleQuickArchivePointerDown}
|
||
onMouseDown={handleQuickArchiveMouseDown}
|
||
onClick={handleQuickArchiveClick}
|
||
onKeyDown={(event) => event.stopPropagation()}
|
||
>
|
||
<Icon name="archive" className={cn(isMinimalMode && !alwaysShowActions ? 'h-2.5 w-2.5' : 'h-3.5 w-3.5')} />
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="left" sideOffset={8}>
|
||
{t('sessions.sidebar.bulkActions.archive')}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
) : null}
|
||
{showOpenInEditorAction ? (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
'inline-flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
|
||
isMinimalMode && !alwaysShowActions ? 'h-4 w-4' : 'h-6 w-6',
|
||
)}
|
||
aria-label={t('sessions.sidebar.session.actions.openInEditor')}
|
||
onPointerDown={handleOpenInEditorPointerDown}
|
||
onMouseDown={handleOpenInEditorMouseDown}
|
||
onClick={handleOpenInEditorClick}
|
||
onKeyDown={(event) => event.stopPropagation()}
|
||
>
|
||
<Icon name="external-link" className={cn(isMinimalMode && !alwaysShowActions ? 'h-2.5 w-2.5' : 'h-3.5 w-3.5')} />
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="left" sideOffset={8}>
|
||
{t('sessions.sidebar.session.actions.openInEditor')}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
) : null}
|
||
<DropdownMenu open={isMenuOpen} onOpenChange={handleMenuOpenChange} onOpenChangeComplete={handleMenuOpenChangeComplete}>
|
||
<DropdownMenuTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
'inline-flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
|
||
isMinimalMode && !alwaysShowActions
|
||
? (isSessionMenuOpen
|
||
? 'h-4 w-4 opacity-100'
|
||
: cn('h-4 w-4 opacity-0', revealOnHoverClass))
|
||
: 'h-6 w-6 opacity-100',
|
||
)}
|
||
aria-label={t('sessions.sidebar.session.menu.label')}
|
||
onPointerDown={handleMenuTriggerPointerDown}
|
||
onMouseDown={handleMenuTriggerMouseDown}
|
||
onClick={handleMenuTriggerClick}
|
||
onKeyDown={(event) => event.stopPropagation()}
|
||
>
|
||
<Icon name="more-2" className={cn(isMinimalMode && !alwaysShowActions ? 'h-2.5 w-2.5' : 'h-3.5 w-3.5')} />
|
||
</button>
|
||
</DropdownMenuTrigger>
|
||
{sessionMenuContent}
|
||
</DropdownMenu>
|
||
</div>
|
||
</ContextMenu.Trigger>
|
||
{contextMenuContent}
|
||
</ContextMenu.Root>
|
||
</DraggableSessionRow>
|
||
{hasChildren && isExpanded
|
||
? node.children.map((child) => renderSessionNode(child, depth + 1, sessionDirectory ?? groupDirectory, projectId, archivedBucket, undefined, renderContext))
|
||
: null}
|
||
<Dialog open={exportDialogOpen} onOpenChange={setExportDialogOpen}>
|
||
<DialogContent showCloseButton={false} className="max-w-sm gap-5">
|
||
<DialogHeader>
|
||
<DialogTitle>{t('sessions.sidebar.session.export.dialog.title')}</DialogTitle>
|
||
<DialogDescription>
|
||
{descendantCount === 1
|
||
? t('sessions.sidebar.session.export.dialog.descriptionSingle', { count: descendantCount })
|
||
: t('sessions.sidebar.session.export.dialog.descriptionMany', { count: descendantCount })}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<label className="flex items-center gap-2 typography-ui-label cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={exportIncludeSubtasks}
|
||
onChange={(e) => setExportIncludeSubtasks(e.target.checked)}
|
||
className="h-4 w-4 rounded border-border accent-primary"
|
||
/>
|
||
{t('sessions.sidebar.session.export.dialog.includeSubtasks')}
|
||
</label>
|
||
<DialogFooter>
|
||
<Button
|
||
type="button"
|
||
onClick={() => setExportDialogOpen(false)}
|
||
variant="outline"
|
||
size="sm"
|
||
>
|
||
{t('sessions.sidebar.dialogs.cancel')}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
onClick={() => {
|
||
setExportDialogOpen(false);
|
||
void doExportSession(exportIncludeSubtasks);
|
||
}}
|
||
size="sm"
|
||
>
|
||
{t('sessions.sidebar.session.export.dialog.confirm')}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
{isMultiRunLikeSession ? (
|
||
<MultiRunFusionDialog
|
||
session={resolvedSession}
|
||
open={fusionDialogOpen}
|
||
onOpenChange={setFusionDialogOpen}
|
||
/>
|
||
) : null}
|
||
</React.Fragment>
|
||
);
|
||
}
|
||
|
||
export const SessionNodeItem = React.memo(SessionNodeItemComponent, areEqual);
|