Files
openchamber/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx
T
weixiang1862 56ca5bcf74 fix(mobile): subagent chevron overlaps session title on mobile (#1582)
`mobile.css` applies `min-width: 36px; min-height: 36px` to all `[role="button]` elements on mobile devices for touch targets, enlarging the subagent chevron from `14×14px to 36×36px`. This extends the chevron box 20px past the content edge, visually overlapping the session title. Add inline `minWidth/minHeight: 14` to pin the chevron size.
2026-06-23 11:30:02 +03:00

1378 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, useSessionPermissions } from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync';
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
import { DraggableSessionRow } from './sessionFolderDnd';
import { nodeContainsSessionId } from './sessionNodeItemUtils';
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils';
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;
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: (titleOverride?: string) => void;
handleCancelEdit: () => void;
toggleParent: (expansionKey: string) => void;
handleSessionSelect: (sessionId: string, sessionDirectory: string | null, 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',
renderExtras?: SessionNodeRenderExtras,
) => React.ReactNode;
secondaryMeta?: SecondaryMeta | null;
renderContext?: 'project' | 'recent';
/**
* Precomputed set of session IDs whose subtree contains the current
* active session. Computed once per SessionGroupSection render (when
* currentSessionId changes) instead of being recomputed in every row's
* React.memo comparator.
*/
subtreeContainsActive: Set<string>;
/**
* Precomputed set of session IDs whose subtree contains the session
* currently being edited. Same rationale as subtreeContainsActive.
*/
subtreeContainsEditing: Set<string>;
/**
* Precomputed session ID of the row whose sidebar menu is open, or null
* if no menu is open. Only one row can have its menu open at a time.
*/
menuOpenSessionId: string | null;
/**
* Precomputed structural key for this node. Encodes the IDs and child
* counts of all descendants so a reference-only change to `node` (e.g.
* a fresh tree rebuild) can be detected with a single string compare
* instead of a recursive walk per row.
*/
nodeStructureKey: string;
/**
* Resolves the per-row render extras for each child node. SessionGroupSection
* walks the whole tree once to precompute the structure key for every
* descendant; SessionNodeItem's recursive child render uses this lookup
* to fetch the right key for each child it produces.
*/
childRenderExtrasFor?: (child: SessionNode) => SessionNodeChildRenderExtras;
/**
* Batched index of live session objects keyed by id. The previous
* implementation called `useSession(session.id)` per row, which used
* `findLiveSession` to iterate every child-store on every SSE event.
* With M visible rows that's M×child-stores per event; the batched
* map turns it into a single Map.get per row. The parent falls back
* to `useSession` only when this map returns undefined.
*/
liveSessionById: Map<string, Session>;
};
function SessionNodeItemComponent(props: Props): React.ReactNode {
const { t } = useI18n();
const {
node,
depth = 0,
groupDirectory,
projectId,
archivedBucket = false,
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',
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
childRenderExtrasFor,
liveSessionById,
} = props;
const hasSecondaryProjectLabel = Boolean(secondaryMeta?.projectLabel);
const hasSecondaryBranchLabel = Boolean(secondaryMeta?.branchLabel);
const displayMode = useSessionDisplayStore((state) => state.displayMode);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
// VS Code always uses the minimal (single-line) layout: sessions are grouped
// under workspace project headers, so the second metadata row (project/branch)
// is redundant. The display-mode toggle is hidden there, so force it on.
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
// VS Code minimal rows reveal up to three actions on hover
// (open-in-editor + quick-archive + menu, each h-4). The date sits in the
// row flow, so the title must shrink enough to clear the actions or they
// overlap the timestamp. Open-in-editor is always present in VS Code.
? (showQuickArchiveAction && showOpenInEditorAction
? 'group-hover:pr-18'
: showQuickArchiveAction || showOpenInEditorAction
? 'group-hover:pr-14'
: 'group-hover:pr-8')
: '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 [renameDraft, setRenameDraft] = React.useState(editTitle);
const renameDraftRef = React.useRef(renameDraft);
renameDraftRef.current = renameDraft;
const renameTargetRef = React.useRef<string | null>(null);
const formRef = React.useRef<HTMLFormElement>(null);
const session = node.session;
// Batched live-session lookup. `liveSessionById` is built once per
// Sidebar render from the same `useAllLiveSessions` selector that
// `useSession` would have iterated per child-store, so a Map.get
// here is equivalent in observed state but O(1) per row instead of
// O(child-stores). Falls back to the row session when the live map
// hasn't seen this id yet (sub-render latency between when a session
// is created and when the SSE-driven aggregate picks it up).
const resolvedSession = liveSessionById.get(session.id) ?? 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 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(renameDraftRef.current);
}
};
document.addEventListener('mousedown', handleDocMouseDown);
return () => document.removeEventListener('mousedown', handleDocMouseDown);
}, [editingId, session.id]);
React.useLayoutEffect(() => {
if (editingId !== session.id) {
if (renameTargetRef.current === session.id) {
renameTargetRef.current = null;
}
return;
}
if (renameTargetRef.current === session.id) return;
renameTargetRef.current = session.id;
setRenameDraft(editTitle);
}, [editingId, editTitle, 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(renameDraft);
}}
>
<input
value={renameDraft}
onChange={(event) => setRenameDraft(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) => {
event.stopPropagation();
if (event.key === 'Escape') {
handleCancelEdit();
return;
}
}}
/>
<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);
}
}}
style={{ minWidth: 14, minHeight: 14 }}
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, 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]',
// 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"
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 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>
{/* VS Code already shows project context via workspace headers, so
the per-row metadata tooltip is redundant noise there. */}
{!isVSCode ? (
<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>
) : null}
</Tooltip>
) : (
<button
type="button"
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 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): React.ReactNode => {
const childRenderExtras: SessionNodeChildRenderExtras = childRenderExtrasFor
? childRenderExtrasFor(child)
: {
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: '',
};
return renderSessionNode(
child,
depth + 1,
sessionDirectory ?? groupDirectory,
projectId,
archivedBucket,
undefined,
renderContext,
childRenderExtras,
);
})
: 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>
);
}
const getNodeSessionDirectory = (node: SessionNode): string | null => {
return normalizePath((node.session as Session & { directory?: string | null }).directory ?? null);
};
const isSecondaryMetaEqual = (prev?: SecondaryMeta | null, next?: SecondaryMeta | null): boolean => {
return (prev?.projectLabel ?? null) === (next?.projectLabel ?? null)
&& (prev?.branchLabel ?? null) === (next?.branchLabel ?? null);
};
const getMenuSessionIdFromKey = (props: Props): string | null => {
if (!props.openSidebarMenuKey) return null;
const bucketTag = props.archivedBucket ? 'archived' : 'active';
const prefix = `${props.renderContext ?? 'project'}:${bucketTag}:`;
return props.openSidebarMenuKey.startsWith(prefix)
? props.openSidebarMenuKey.slice(prefix.length)
: null;
};
const getRelevantMenuSessionId = (props: Props): string | null => {
return props.menuOpenSessionId ?? getMenuSessionIdFromKey(props);
};
const subtreeContainsSession = (
props: Props,
sessionId: string | null,
precomputed: Set<string>,
): boolean => {
if (!sessionId) return false;
if (precomputed.has(props.node.session.id)) return true;
return nodeContainsSessionId(props.node, sessionId);
};
const hasSetMembershipChangeInNode = (
prevNode: SessionNode,
nextNode: SessionNode,
prevSet: Set<string>,
nextSet: Set<string>,
getKey: (node: SessionNode) => string,
): boolean => {
if (prevNode.session.id !== nextNode.session.id) return true;
const key = getKey(prevNode);
if (prevSet.has(key) !== nextSet.has(key)) return true;
if (prevNode.children.length !== nextNode.children.length) return true;
for (let i = 0; i < prevNode.children.length; i += 1) {
if (hasSetMembershipChangeInNode(prevNode.children[i], nextNode.children[i], prevSet, nextSet, getKey)) {
return true;
}
}
return false;
};
const hasResolvedSessionChangeInNode = (
prevNode: SessionNode,
nextNode: SessionNode,
prevLiveSessionById: Map<string, Session>,
nextLiveSessionById: Map<string, Session>,
): boolean => {
if (prevNode.session.id !== nextNode.session.id) return true;
const sessionId = prevNode.session.id;
if ((prevLiveSessionById.get(sessionId) ?? prevNode.session) !== (nextLiveSessionById.get(sessionId) ?? nextNode.session)) {
return true;
}
if (prevNode.children.length !== nextNode.children.length) return true;
for (let i = 0; i < prevNode.children.length; i += 1) {
if (hasResolvedSessionChangeInNode(prevNode.children[i], nextNode.children[i], prevLiveSessionById, nextLiveSessionById)) {
return true;
}
}
return false;
};
const hasExpansionMembershipChange = (prev: Props, next: Props): boolean => {
if (prev.hasSessionSearchQuery || next.hasSessionSearchQuery) return false;
const prevBucketTag = prev.archivedBucket ? 'archived' : 'active';
const nextBucketTag = next.archivedBucket ? 'archived' : 'active';
return hasSetMembershipChangeInNode(
prev.node,
next.node,
prev.expandedParents,
next.expandedParents,
(node) => `${prev.renderContext ?? 'project'}:${prevBucketTag}:${node.session.id}`,
) || hasSetMembershipChangeInNode(
prev.node,
next.node,
prev.expandedParents,
next.expandedParents,
(node) => `${next.renderContext ?? 'project'}:${nextBucketTag}:${node.session.id}`,
);
};
const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
if (prev.node.session.id !== next.node.session.id) 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.renderContext ?? 'project') !== (next.renderContext ?? 'project')) return false;
if (prev.mobileVariant !== next.mobileVariant) return false;
if (prev.alwaysShowActions !== next.alwaysShowActions) 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.nodeStructureKey !== next.nodeStructureKey) return false;
if (getNodeSessionDirectory(prev.node) !== getNodeSessionDirectory(next.node)) return false;
if (!isSecondaryMetaEqual(prev.secondaryMeta, next.secondaryMeta)) return false;
if (prev.liveSessionById !== next.liveSessionById
&& hasResolvedSessionChangeInNode(prev.node, next.node, prev.liveSessionById, next.liveSessionById)) {
return false;
}
if (prev.pinnedSessionIds !== next.pinnedSessionIds
&& hasSetMembershipChangeInNode(prev.node, next.node, prev.pinnedSessionIds, next.pinnedSessionIds, (node) => node.session.id)) {
return false;
}
if (prev.expandedParents !== next.expandedParents && hasExpansionMembershipChange(prev, next)) {
return false;
}
if (prev.currentSessionId !== next.currentSessionId
&& (
subtreeContainsSession(prev, prev.currentSessionId, prev.subtreeContainsActive)
|| subtreeContainsSession(next, next.currentSessionId, next.subtreeContainsActive)
)) {
return false;
}
if (prev.editingId !== next.editingId
&& (
subtreeContainsSession(prev, prev.editingId, prev.subtreeContainsEditing)
|| subtreeContainsSession(next, next.editingId, next.subtreeContainsEditing)
)) {
return false;
}
if (prev.editTitle !== next.editTitle
&& (
subtreeContainsSession(prev, prev.editingId, prev.subtreeContainsEditing)
|| subtreeContainsSession(next, next.editingId, next.subtreeContainsEditing)
)) {
return false;
}
if (prev.copiedSessionId !== next.copiedSessionId
&& (
nodeContainsSessionId(prev.node, prev.copiedSessionId)
|| nodeContainsSessionId(next.node, next.copiedSessionId)
)) {
return false;
}
if (prev.openSidebarMenuKey !== next.openSidebarMenuKey) {
const prevMenuSessionId = getRelevantMenuSessionId(prev);
const nextMenuSessionId = getRelevantMenuSessionId(next);
if (nodeContainsSessionId(prev.node, prevMenuSessionId) || nodeContainsSessionId(next.node, nextMenuSessionId)) {
return false;
}
}
if (prev.renamingFolderId !== next.renamingFolderId) {
const prevMenuSessionId = getRelevantMenuSessionId(prev);
const nextMenuSessionId = getRelevantMenuSessionId(next);
if (nodeContainsSessionId(prev.node, prevMenuSessionId) || nodeContainsSessionId(next.node, nextMenuSessionId)) {
return false;
}
}
return prev.setEditingId === next.setEditingId
&& prev.setEditTitle === next.setEditTitle
&& prev.handleSaveEdit === next.handleSaveEdit
&& prev.handleCancelEdit === next.handleCancelEdit
&& prev.toggleParent === next.toggleParent
&& prev.handleSessionSelect === next.handleSessionSelect
&& prev.handleSessionDoubleClick === next.handleSessionDoubleClick
&& prev.togglePinnedSession === next.togglePinnedSession
&& prev.handleShareSession === next.handleShareSession
&& prev.handleCopyShareUrl === next.handleCopyShareUrl
&& prev.handleUnshareSession === next.handleUnshareSession
&& prev.setOpenSidebarMenuKey === next.setOpenSidebarMenuKey
&& prev.getFoldersForScope === next.getFoldersForScope
&& prev.getSessionFolderId === next.getSessionFolderId
&& prev.removeSessionFromFolder === next.removeSessionFromFolder
&& prev.addSessionToFolder === next.addSessionToFolder
&& prev.createFolderAndStartRename === next.createFolderAndStartRename
&& prev.openContextPanelTab === next.openContextPanelTab
&& prev.handleDeleteSession === next.handleDeleteSession
&& prev.renderSessionNode === next.renderSessionNode;
};
export const SessionNodeItem = React.memo(SessionNodeItemComponent, areSessionNodeItemPropsEqual);