2026-03-06 17:22:59 +02:00
|
|
|
import React from 'react';
|
|
|
|
|
import type { Session } from '@opencode-ai/sdk/v2';
|
|
|
|
|
import {
|
|
|
|
|
DropdownMenu,
|
|
|
|
|
DropdownMenuContent,
|
|
|
|
|
DropdownMenuItem,
|
|
|
|
|
DropdownMenuSeparator,
|
|
|
|
|
DropdownMenuSub,
|
|
|
|
|
DropdownMenuSubContent,
|
|
|
|
|
DropdownMenuSubTrigger,
|
|
|
|
|
DropdownMenuTrigger,
|
|
|
|
|
} from '@/components/ui/dropdown-menu';
|
|
|
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
|
|
|
import { GridLoader } from '@/components/ui/grid-loader';
|
|
|
|
|
import {
|
|
|
|
|
RiAddLine,
|
|
|
|
|
RiArrowDownSLine,
|
|
|
|
|
RiArrowRightSLine,
|
|
|
|
|
RiChat4Line,
|
|
|
|
|
RiCheckLine,
|
|
|
|
|
RiCloseLine,
|
|
|
|
|
RiDeleteBinLine,
|
2026-04-17 15:33:26 +03:00
|
|
|
RiDownloadLine,
|
2026-03-06 17:22:59 +02:00
|
|
|
RiErrorWarningLine,
|
|
|
|
|
RiFileCopyLine,
|
|
|
|
|
RiFolderLine,
|
|
|
|
|
RiLinkUnlinkM,
|
|
|
|
|
RiMore2Line,
|
|
|
|
|
RiPencilAiLine,
|
|
|
|
|
RiPushpinLine,
|
|
|
|
|
RiShare2Line,
|
|
|
|
|
RiShieldLine,
|
|
|
|
|
RiUnpinLine,
|
2026-03-20 01:01:03 +02:00
|
|
|
RiGitBranchLine,
|
2026-03-06 17:22:59 +02:00
|
|
|
} from '@remixicon/react';
|
|
|
|
|
import { cn } from '@/lib/utils';
|
2026-03-12 23:45:45 +02:00
|
|
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
2026-04-17 15:33:26 +03:00
|
|
|
import { toast } from '@/components/ui';
|
|
|
|
|
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabel, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
|
|
|
|
|
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSession, useSessionPermissions } from '@/sync/sync-context';
|
|
|
|
|
import { useSync } from '@/sync/use-sync';
|
2026-04-04 02:19:55 +03:00
|
|
|
import { useViewportStore } from '@/sync/viewport-store';
|
2026-03-06 17:22:59 +02:00
|
|
|
import { DraggableSessionRow } from './sessionFolderDnd';
|
|
|
|
|
import type { SessionNode, SessionSummaryMeta } from './types';
|
2026-03-20 01:01:03 +02:00
|
|
|
import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText, resolveSessionDiffStats } from './utils';
|
2026-03-06 17:22:59 +02:00
|
|
|
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
2026-03-31 18:47:00 +03:00
|
|
|
import { useSessionUnseenCount } from '@/sync/notification-store';
|
2026-03-06 17:22:59 +02:00
|
|
|
|
|
|
|
|
const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]);
|
|
|
|
|
|
|
|
|
|
const getAttentionDiamondDelay = (index: number): string => {
|
|
|
|
|
return index === 4 ? '0ms' : '130ms';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type Folder = { id: string; name: string; sessionIds: string[] };
|
|
|
|
|
|
2026-03-20 01:01:03 +02:00
|
|
|
type SecondaryMeta = {
|
|
|
|
|
projectLabel?: string | null;
|
|
|
|
|
branchLabel?: string | null;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-06 17:22:59 +02:00
|
|
|
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: (sessionId: string) => void;
|
|
|
|
|
handleSessionSelect: (sessionId: string, sessionDirectory: string | null, isMissingDirectory: boolean, projectId?: string | null) => void;
|
|
|
|
|
handleSessionDoubleClick: () => void;
|
|
|
|
|
togglePinnedSession: (sessionId: string) => void;
|
|
|
|
|
handleShareSession: (session: Session) => void;
|
|
|
|
|
copiedSessionId: string | null;
|
|
|
|
|
handleCopyShareUrl: (url: string, sessionId: string) => void;
|
|
|
|
|
handleUnshareSession: (sessionId: string) => void;
|
2026-03-20 01:01:03 +02:00
|
|
|
openSidebarMenuKey: string | null;
|
|
|
|
|
setOpenSidebarMenuKey: (key: string | null) => void;
|
2026-03-06 17:22:59 +02:00
|
|
|
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 }) => void;
|
|
|
|
|
handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean }) => void;
|
|
|
|
|
mobileVariant: boolean;
|
2026-03-20 01:01:03 +02:00
|
|
|
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';
|
2026-03-06 17:22:59 +02:00
|
|
|
};
|
|
|
|
|
|
2026-04-04 02:19:55 +03:00
|
|
|
const getNodeChildSignature = (node: SessionNode): string => {
|
|
|
|
|
if (node.children.length === 0) {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return node.children
|
|
|
|
|
.map((child) => `${child.session.id}:${child.children.length}`)
|
|
|
|
|
.join('|');
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-16 17:02:23 +03:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-20 18:03:34 +03:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-04 02:19:55 +03:00
|
|
|
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;
|
2026-04-16 17:02:23 +03:00
|
|
|
if (prev.currentSessionId !== next.currentSessionId) {
|
|
|
|
|
const prevActiveInTree = treeContainsSessionId(prev.node, prev.currentSessionId);
|
|
|
|
|
const nextActiveInTree = treeContainsSessionId(next.node, next.currentSessionId);
|
|
|
|
|
if (prevActiveInTree || nextActiveInTree) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-04 02:19:55 +03:00
|
|
|
if (prev.pinnedSessionIds.has(prevSessionId) !== next.pinnedSessionIds.has(nextSessionId)) return false;
|
|
|
|
|
if (prev.expandedParents.has(prevSessionId) !== next.expandedParents.has(nextSessionId)) 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 === prevSessionId) !== (next.editingId === nextSessionId)) return false;
|
|
|
|
|
if (prev.editTitle !== next.editTitle && ((prev.editingId === prevSessionId) || (next.editingId === nextSessionId))) return false;
|
|
|
|
|
if ((prev.copiedSessionId === prevSessionId) !== (next.copiedSessionId === nextSessionId)) return false;
|
|
|
|
|
|
2026-04-20 18:03:34 +03:00
|
|
|
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;
|
2026-04-04 02:19:55 +03:00
|
|
|
|
|
|
|
|
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.renderContext ?? 'project') !== (next.renderContext ?? 'project')) return false;
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function SessionNodeItemComponent(props: Props): React.ReactNode {
|
2026-03-06 17:22:59 +02:00
|
|
|
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,
|
2026-03-20 01:01:03 +02:00
|
|
|
openSidebarMenuKey,
|
|
|
|
|
setOpenSidebarMenuKey,
|
2026-03-06 17:22:59 +02:00
|
|
|
renamingFolderId,
|
|
|
|
|
getFoldersForScope,
|
|
|
|
|
getSessionFolderId,
|
|
|
|
|
removeSessionFromFolder,
|
|
|
|
|
addSessionToFolder,
|
|
|
|
|
createFolderAndStartRename,
|
|
|
|
|
openContextPanelTab,
|
|
|
|
|
handleDeleteSession,
|
|
|
|
|
mobileVariant,
|
|
|
|
|
renderSessionNode,
|
2026-03-20 01:01:03 +02:00
|
|
|
secondaryMeta,
|
|
|
|
|
renderContext = 'project',
|
2026-03-06 17:22:59 +02:00
|
|
|
} = props;
|
2026-03-20 01:01:03 +02:00
|
|
|
const hasSecondaryProjectLabel = Boolean(secondaryMeta?.projectLabel);
|
|
|
|
|
const hasSecondaryBranchLabel = Boolean(secondaryMeta?.branchLabel);
|
2026-03-06 17:22:59 +02:00
|
|
|
|
|
|
|
|
const displayMode = useSessionDisplayStore((state) => state.displayMode);
|
|
|
|
|
const isMinimalMode = displayMode === 'minimal';
|
2026-03-12 23:45:45 +02:00
|
|
|
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
2026-03-23 23:51:55 +02:00
|
|
|
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 revealPaddingClass = isVSCode
|
|
|
|
|
? 'group-hover:pr-5'
|
|
|
|
|
: 'group-hover:pr-5 group-focus-within:pr-5';
|
2026-03-20 01:01:03 +02:00
|
|
|
const suppressNextSelectRef = React.useRef(false);
|
2026-03-06 17:22:59 +02:00
|
|
|
|
|
|
|
|
const session = node.session;
|
2026-04-04 02:19:55 +03:00
|
|
|
const liveSession = useSession(session.id);
|
|
|
|
|
const resolvedSession = liveSession ?? session;
|
2026-03-20 01:01:03 +02:00
|
|
|
const menuInstanceKey = `${renderContext}:${archivedBucket ? 'archived' : 'active'}:${session.id}`;
|
2026-03-06 17:22:59 +02:00
|
|
|
const sessionDirectory =
|
|
|
|
|
normalizePath((session as Session & { directory?: string | null }).directory ?? null)
|
|
|
|
|
?? normalizePath(groupDirectory ?? null);
|
2026-04-04 02:19:55 +03:00
|
|
|
const isZombie = useViewportStore(
|
|
|
|
|
React.useCallback((state) => Boolean(state.sessionMemoryState.get(session.id)?.isZombie), [session.id]),
|
|
|
|
|
);
|
2026-04-17 15:33:26 +03:00
|
|
|
const directoryStore = useDirectoryStore(sessionDirectory ?? undefined);
|
|
|
|
|
const sync = useSync();
|
2026-04-04 02:19:55 +03:00
|
|
|
const sessionStatus = useGlobalSessionStatus(session.id);
|
|
|
|
|
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined);
|
2026-03-06 17:22:59 +02:00
|
|
|
const directoryState = sessionDirectory ? directoryStatus.get(sessionDirectory) : null;
|
|
|
|
|
const isMissingDirectory = directoryState === 'missing';
|
|
|
|
|
const isActive = currentSessionId === session.id;
|
2026-04-04 02:19:55 +03:00
|
|
|
const sessionTitle = resolvedSession.title || 'Untitled Session';
|
2026-03-06 17:22:59 +02:00
|
|
|
const hasChildren = node.children.length > 0;
|
|
|
|
|
const isPinnedSession = pinnedSessionIds.has(session.id);
|
|
|
|
|
const isExpanded = hasSessionSearchQuery ? true : expandedParents.has(session.id);
|
2026-04-04 02:19:55 +03:00
|
|
|
const isSubtaskSession = Boolean((resolvedSession as Session & { parentID?: string | null }).parentID);
|
2026-03-31 18:47:00 +03:00
|
|
|
const unseenCount = useSessionUnseenCount(session.id);
|
|
|
|
|
const needsAttention = unseenCount > 0 && (!isSubtaskSession || notifyOnSubtasks);
|
2026-04-04 02:19:55 +03:00
|
|
|
const sessionSummary = resolvedSession.summary as SessionSummaryMeta | undefined;
|
2026-03-06 17:22:59 +02:00
|
|
|
const sessionDiffStats = resolveSessionDiffStats(sessionSummary);
|
2026-04-04 02:19:55 +03:00
|
|
|
const sessionTimestamp = resolvedSession.time?.updated || resolvedSession.time?.created || Date.now();
|
2026-03-20 01:01:03 +02:00
|
|
|
const sessionUpdatedLabel = formatSessionDateLabel(sessionTimestamp);
|
|
|
|
|
const sessionCompactUpdatedLabel = formatSessionCompactDateLabel(sessionTimestamp);
|
|
|
|
|
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
|
2026-04-17 15:33:26 +03:00
|
|
|
const handleExportSession = React.useCallback(async () => {
|
|
|
|
|
if (!sessionDirectory) {
|
|
|
|
|
toast.error('Nothing to export');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await sync.syncSession(session.id);
|
|
|
|
|
|
|
|
|
|
const records = buildSessionMessageRecordsSnapshot(directoryStore.getState(), session.id).list;
|
|
|
|
|
if (records.length === 0) {
|
|
|
|
|
toast.error('Nothing to export');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const markdown = formatSessionAsMarkdown(records, resolvedSession.title ?? null);
|
|
|
|
|
const filename = buildExportFilename(resolvedSession.title ?? null);
|
|
|
|
|
const savedPath = await saveAsMarkdownDesktop(markdown, filename);
|
|
|
|
|
|
|
|
|
|
if (savedPath) {
|
|
|
|
|
toast.success('Session exported', {
|
|
|
|
|
action: {
|
|
|
|
|
label: getExportRevealLabel(),
|
|
|
|
|
onClick: () => {
|
|
|
|
|
void revealExportedMarkdown(savedPath).then((revealed) => {
|
|
|
|
|
if (!revealed) {
|
|
|
|
|
toast.error('Failed to reveal path');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
downloadAsMarkdown(markdown, filename);
|
|
|
|
|
toast.success('Session exported');
|
|
|
|
|
}, [directoryStore, resolvedSession.title, session.id, sessionDirectory, sync]);
|
2026-03-06 17:22:59 +02:00
|
|
|
|
|
|
|
|
if (editingId === session.id) {
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={session.id}
|
2026-03-20 01:01:03 +02:00
|
|
|
className={cn('group relative flex items-center rounded-sm px-1.5 py-1', depth > 0 && 'pl-[20px]')}
|
2026-03-06 17:22:59 +02:00
|
|
|
>
|
|
|
|
|
<div className="flex min-w-0 flex-1 flex-col gap-0">
|
|
|
|
|
<form
|
|
|
|
|
className="flex w-full items-center gap-2"
|
|
|
|
|
data-keyboard-avoid="true"
|
|
|
|
|
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="Rename session"
|
|
|
|
|
onKeyDown={(event) => {
|
|
|
|
|
if (event.key === 'Escape') {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
handleCancelEdit();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.key === ' ' || event.key === 'Enter') {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<button type="submit" className="shrink-0 text-muted-foreground hover:text-foreground"><RiCheckLine className="size-4" /></button>
|
|
|
|
|
<button type="button" onClick={handleCancelEdit} className="shrink-0 text-muted-foreground hover:text-foreground"><RiCloseLine className="size-4" /></button>
|
|
|
|
|
</form>
|
|
|
|
|
{!isMinimalMode ? (
|
2026-03-20 01:01:03 +02:00
|
|
|
<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 ? <RiArrowDownSLine className="h-3 w-3" /> : <RiArrowRightSLine className="h-3 w-3" />}</span> : null}
|
|
|
|
|
<span className="flex-shrink-0">{sessionUpdatedLabel}</span>
|
|
|
|
|
{sessionDiffStats ? <span className="flex flex-shrink-0 items-center gap-0 text-[0.92em]"><span className="text-status-success/80">+{sessionDiffStats.additions}</span><span className="text-status-error/65">/-{sessionDiffStats.deletions}</span></span> : null}
|
|
|
|
|
{hasSecondaryProjectLabel ? <span className="truncate">{secondaryMeta?.projectLabel}</span> : null}
|
|
|
|
|
{hasSecondaryBranchLabel ? <span className="inline-flex min-w-0 items-center gap-0.5"><RiGitBranchLine className="h-3 w-3 flex-shrink-0 text-muted-foreground/70" /><span className="truncate">{secondaryMeta?.branchLabel}</span></span> : null}
|
|
|
|
|
</div>
|
2026-03-06 17:22:59 +02:00
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 02:19:55 +03:00
|
|
|
const statusType = sessionStatus?.type ?? 'idle';
|
2026-03-06 17:22:59 +02:00
|
|
|
const isStreaming = statusType === 'busy' || statusType === 'retry';
|
2026-04-04 02:19:55 +03:00
|
|
|
const pendingPermissionCount = sessionPermissions.length;
|
2026-03-06 17:22:59 +02:00
|
|
|
const showUnreadStatus = !isStreaming && needsAttention && !isActive;
|
|
|
|
|
const showStatusMarker = isStreaming || showUnreadStatus;
|
2026-03-20 01:01:03 +02:00
|
|
|
const statusMarkerContent = isStreaming
|
|
|
|
|
? <GridLoader size="xs" className="text-primary" />
|
|
|
|
|
: (
|
|
|
|
|
<span className="grid grid-cols-3 gap-[1px] text-[var(--status-info)]" aria-label="Unread updates" title="Unread updates">
|
|
|
|
|
{Array.from({ length: 9 }, (_, i) => (
|
|
|
|
|
ATTENTION_DIAMOND_INDICES.has(i) ? (
|
|
|
|
|
<span key={i} className="h-[3px] w-[3px] rounded-full bg-current animate-attention-diamond-pulse" style={{ animationDelay: getAttentionDiamondDelay(i) }} />
|
|
|
|
|
) : (
|
|
|
|
|
<span key={i} className="h-[3px] w-[3px]" />
|
|
|
|
|
)
|
|
|
|
|
))}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
const inlineStatusMarker = !isMinimalMode && showStatusMarker ? (
|
|
|
|
|
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
|
|
|
|
|
{statusMarkerContent}
|
|
|
|
|
</span>
|
|
|
|
|
) : null;
|
|
|
|
|
const minimalLeadingStatusMarker = isMinimalMode && showStatusMarker ? (
|
|
|
|
|
<span
|
|
|
|
|
className={cn(
|
|
|
|
|
'pointer-events-none absolute left-[-10px] top-1/2 inline-flex h-3.5 w-3.5 -translate-y-1/2 items-center justify-center transition-opacity',
|
|
|
|
|
hasChildren ? 'opacity-100 group-hover:opacity-0 group-focus-within:opacity-0' : '',
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{statusMarkerContent}
|
|
|
|
|
</span>
|
|
|
|
|
) : null;
|
|
|
|
|
const subsessionChevron = hasChildren ? (
|
|
|
|
|
<span
|
|
|
|
|
role="button"
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
toggleParent(session.id);
|
|
|
|
|
}}
|
|
|
|
|
onKeyDown={(event) => {
|
|
|
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
toggleParent(session.id);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className={cn(
|
|
|
|
|
'absolute left-[-10px] top-1/2 inline-flex h-3.5 w-3.5 -translate-y-1/2 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 && showStatusMarker
|
|
|
|
|
? '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 ? 'Collapse subsessions' : 'Expand subsessions'}
|
|
|
|
|
>
|
|
|
|
|
{isExpanded ? <RiArrowDownSLine className="h-3 w-3" /> : <RiArrowRightSLine className="h-3 w-3" />}
|
|
|
|
|
</span>
|
|
|
|
|
) : null;
|
2026-03-06 17:22:59 +02:00
|
|
|
|
2026-04-04 02:19:55 +03:00
|
|
|
const streamingIndicator = isZombie
|
2026-03-06 17:22:59 +02:00
|
|
|
? <RiErrorWarningLine className="h-4 w-4 text-status-warning" />
|
|
|
|
|
: null;
|
|
|
|
|
|
2026-03-20 01:01:03 +02:00
|
|
|
const handleMenuOpenChange = (open: boolean) => {
|
|
|
|
|
setOpenSidebarMenuKey(open ? menuInstanceKey : null);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleMenuTriggerClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
2026-04-20 18:03:34 +03:00
|
|
|
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();
|
2026-03-20 01:01:03 +02:00
|
|
|
event.stopPropagation();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleRowSelect = () => {
|
|
|
|
|
if (suppressNextSelectRef.current) {
|
|
|
|
|
suppressNextSelectRef.current = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
handleSessionSelect(session.id, sessionDirectory, isMissingDirectory, projectId);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleRowMouseDown = (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
|
|
|
if (event.button === 2 || (event.button === 0 && event.ctrlKey)) {
|
|
|
|
|
suppressNextSelectRef.current = true;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const sessionMenuContent = (
|
|
|
|
|
<DropdownMenuContent align="end" className="min-w-[180px]" onCloseAutoFocus={(event) => { if (renamingFolderId) event.preventDefault(); }}>
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setEditingId(session.id);
|
|
|
|
|
setEditTitle(sessionTitle);
|
|
|
|
|
}}
|
|
|
|
|
className="[&>svg]:mr-1"
|
|
|
|
|
>
|
|
|
|
|
<RiPencilAiLine className="mr-1 h-4 w-4" />
|
|
|
|
|
Rename
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
<DropdownMenuItem onClick={() => togglePinnedSession(session.id)} className="[&>svg]:mr-1">
|
|
|
|
|
{isPinnedSession ? <RiUnpinLine className="mr-1 h-4 w-4" /> : <RiPushpinLine className="mr-1 h-4 w-4" />}
|
|
|
|
|
{isPinnedSession ? 'Unpin session' : 'Pin session'}
|
|
|
|
|
</DropdownMenuItem>
|
2026-04-04 02:19:55 +03:00
|
|
|
{!resolvedSession.share ? (
|
|
|
|
|
<DropdownMenuItem onClick={() => handleShareSession(resolvedSession)} className="[&>svg]:mr-1">
|
2026-03-20 01:01:03 +02:00
|
|
|
<RiShare2Line className="mr-1 h-4 w-4" />
|
|
|
|
|
Share
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
2026-04-04 02:19:55 +03:00
|
|
|
<DropdownMenuItem onClick={() => { if (resolvedSession.share?.url) handleCopyShareUrl(resolvedSession.share.url, session.id); }} className="[&>svg]:mr-1">
|
2026-03-20 01:01:03 +02:00
|
|
|
{copiedSessionId === session.id ? <><RiCheckLine className="mr-1 h-4 w-4" style={{ color: 'var(--status-success)' }} />Copied</> : <><RiFileCopyLine className="mr-1 h-4 w-4" />Copy link</>}
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
<DropdownMenuItem onClick={() => handleUnshareSession(session.id)} className="[&>svg]:mr-1">
|
|
|
|
|
<RiLinkUnlinkM className="mr-1 h-4 w-4" />
|
|
|
|
|
Unshare
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-04-17 15:33:26 +03:00
|
|
|
<DropdownMenuItem onClick={() => { void handleExportSession(); }} className="[&>svg]:mr-1">
|
|
|
|
|
<RiDownloadLine className="mr-1 h-4 w-4" />
|
|
|
|
|
Export Markdown
|
|
|
|
|
</DropdownMenuItem>
|
2026-03-20 01:01:03 +02:00
|
|
|
|
|
|
|
|
{sessionDirectory && !archivedBucket ? (() => {
|
|
|
|
|
const scopeFolders = getFoldersForScope(sessionDirectory);
|
|
|
|
|
const currentFolderId = getSessionFolderId(sessionDirectory, session.id);
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
|
<DropdownMenuSub>
|
|
|
|
|
<DropdownMenuSubTrigger className="[&>svg]:mr-1"><RiFolderLine className="h-4 w-4" />Move to folder</DropdownMenuSubTrigger>
|
|
|
|
|
<DropdownMenuSubContent className="min-w-[180px]">
|
|
|
|
|
{scopeFolders.length === 0 ? (
|
|
|
|
|
<DropdownMenuItem disabled className="text-muted-foreground">No folders yet</DropdownMenuItem>
|
|
|
|
|
) : (
|
|
|
|
|
scopeFolders.map((folder) => (
|
|
|
|
|
<DropdownMenuItem 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 ? <RiCheckLine className="ml-2 h-3.5 w-3.5 text-primary flex-shrink-0" /> : null}
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
))
|
|
|
|
|
)}
|
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
|
<DropdownMenuItem onClick={() => { const newFolder = createFolderAndStartRename(sessionDirectory); if (!newFolder) return; addSessionToFolder(sessionDirectory, newFolder.id, session.id); }}>
|
|
|
|
|
<RiAddLine className="mr-1 h-4 w-4" />
|
|
|
|
|
New folder...
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
{currentFolderId ? (
|
|
|
|
|
<DropdownMenuItem onClick={() => { removeSessionFromFolder(sessionDirectory, session.id); }} className="text-destructive focus:text-destructive">
|
|
|
|
|
<RiCloseLine className="mr-1 h-4 w-4" />
|
|
|
|
|
Remove from folder
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
) : null}
|
|
|
|
|
</DropdownMenuSubContent>
|
|
|
|
|
</DropdownMenuSub>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
})() : null}
|
|
|
|
|
|
|
|
|
|
{!isVSCode ? (
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
disabled={!sessionDirectory}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (!sessionDirectory) return;
|
|
|
|
|
openContextPanelTab(sessionDirectory, {
|
|
|
|
|
mode: 'chat',
|
|
|
|
|
dedupeKey: `session:${session.id}`,
|
|
|
|
|
label: sessionTitle,
|
|
|
|
|
});
|
|
|
|
|
}}
|
|
|
|
|
className="[&>svg]:mr-1"
|
|
|
|
|
>
|
|
|
|
|
<RiChat4Line className="mr-1 h-4 w-4" />
|
|
|
|
|
<span className="truncate">Open in Side Panel</span>
|
|
|
|
|
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">beta</span>
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
|
<DropdownMenuItem className="text-destructive focus:text-destructive [&>svg]:mr-1" onClick={() => handleDeleteSession(session, { archivedBucket })}>
|
|
|
|
|
<RiDeleteBinLine className="mr-1 h-4 w-4" />
|
|
|
|
|
{archivedBucket ? 'Delete' : 'Archive'}
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-06 17:22:59 +02:00
|
|
|
return (
|
|
|
|
|
<React.Fragment key={session.id}>
|
|
|
|
|
<DraggableSessionRow sessionId={session.id} sessionDirectory={sessionDirectory ?? null} sessionTitle={sessionTitle}>
|
|
|
|
|
<div
|
2026-03-20 01:01:03 +02:00
|
|
|
className={cn('group relative flex items-center rounded-sm px-1.5 py-1', isMissingDirectory ? 'opacity-75' : '', depth > 0 && 'pl-[20px]')}
|
2026-03-06 17:22:59 +02:00
|
|
|
>
|
2026-03-20 01:01:03 +02:00
|
|
|
{minimalLeadingStatusMarker}
|
|
|
|
|
{subsessionChevron}
|
2026-03-06 17:22:59 +02:00
|
|
|
<div className="flex min-w-0 flex-1 items-center">
|
|
|
|
|
{isMinimalMode ? (
|
|
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
disabled={isMissingDirectory}
|
2026-03-20 01:01:03 +02:00
|
|
|
onMouseDown={handleRowMouseDown}
|
|
|
|
|
onClick={handleRowSelect}
|
2026-03-06 17:22:59 +02:00
|
|
|
onDoubleClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
handleSessionDoubleClick();
|
|
|
|
|
}}
|
2026-03-20 01:01:03 +02:00
|
|
|
className={cn(
|
|
|
|
|
'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-sm 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]',
|
2026-03-23 23:51:55 +02:00
|
|
|
mobileVariant
|
|
|
|
|
? (isVSCode ? revealPaddingClass : 'pr-7')
|
2026-04-20 18:03:34 +03:00
|
|
|
: revealPaddingClass,
|
2026-03-20 01:01:03 +02:00
|
|
|
)}
|
2026-03-06 17:22:59 +02:00
|
|
|
>
|
2026-03-20 01:01:03 +02:00
|
|
|
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-1')}>
|
2026-03-06 17:22:59 +02:00
|
|
|
{isPinnedSession ? <RiPushpinLine className="h-3 w-3 flex-shrink-0 text-primary" aria-label="Pinned session" /> : null}
|
2026-03-20 01:01:03 +02:00
|
|
|
<div className={cn('block min-w-0 flex-1 truncate typography-ui-label font-normal', isActive ? 'text-primary' : 'text-foreground')}>{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
|
|
|
|
|
{mobileVariant ? <span className="ml-2 flex-shrink-0 text-[0.72rem] text-muted-foreground/75">{sessionCompactUpdatedLabel}</span> : null}
|
|
|
|
|
{!mobileVariant ? (
|
|
|
|
|
<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',
|
|
|
|
|
isMenuOpen
|
|
|
|
|
? 'opacity-0'
|
2026-03-23 23:51:55 +02:00
|
|
|
: hideOnHoverClass,
|
2026-03-20 01:01:03 +02:00
|
|
|
)}>
|
|
|
|
|
{sessionCompactUpdatedLabel}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-03-06 17:22:59 +02:00
|
|
|
{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="Permission required" aria-label="Permission required">
|
|
|
|
|
<RiShieldLine className="h-3 w-3" />
|
|
|
|
|
<span className="leading-none">{pendingPermissionCount}</span>
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
</button>
|
|
|
|
|
</TooltipTrigger>
|
2026-03-20 01:01:03 +02:00
|
|
|
<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>
|
2026-03-06 17:22:59 +02:00
|
|
|
</div>
|
2026-03-20 01:01:03 +02:00
|
|
|
{secondaryMeta?.branchLabel || sessionDiffStats ? (
|
|
|
|
|
<div className={cn('flex items-center gap-3 text-left text-muted-foreground', secondaryMeta?.branchLabel ? 'justify-between' : 'justify-start')}>
|
|
|
|
|
{secondaryMeta?.branchLabel ? (
|
|
|
|
|
<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"><RiGitBranchLine className="h-3 w-3 flex-shrink-0" /><span className="truncate">{secondaryMeta.branchLabel}</span></span>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
{sessionDiffStats ? <span className="flex flex-shrink-0 items-center gap-0.5"><span className="text-status-success">+{sessionDiffStats.additions}</span><span className="text-status-error">-{sessionDiffStats.deletions}</span></span> : null}
|
2026-03-06 17:22:59 +02:00
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
) : (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
disabled={isMissingDirectory}
|
2026-03-20 01:01:03 +02:00
|
|
|
onMouseDown={handleRowMouseDown}
|
|
|
|
|
onClick={handleRowSelect}
|
2026-03-06 17:22:59 +02:00
|
|
|
onDoubleClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
handleSessionDoubleClick();
|
|
|
|
|
}}
|
2026-03-23 23:51:55 +02:00
|
|
|
className={cn(
|
|
|
|
|
'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-sm 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]',
|
|
|
|
|
mobileVariant
|
|
|
|
|
? (isVSCode ? revealPaddingClass : 'pr-7')
|
|
|
|
|
: revealPaddingClass
|
|
|
|
|
)}
|
2026-03-06 17:22:59 +02:00
|
|
|
>
|
2026-03-20 01:01:03 +02:00
|
|
|
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-1')}>
|
|
|
|
|
{inlineStatusMarker}
|
|
|
|
|
{isPinnedSession ? <RiPushpinLine className="h-3 w-3 flex-shrink-0 text-primary" aria-label="Pinned session" /> : null}
|
|
|
|
|
<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="Permission required" aria-label="Permission required">
|
|
|
|
|
<RiShieldLine 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="flex min-w-0 items-center gap-1.5 overflow-hidden">
|
|
|
|
|
<span className="flex-shrink-0">{sessionUpdatedLabel}</span>
|
|
|
|
|
{sessionDiffStats ? <span className="flex flex-shrink-0 items-center gap-0 text-[0.92em]"><span className="text-status-success/80">+{sessionDiffStats.additions}</span><span className="text-muted-foreground/60">/</span><span className="text-status-error/65">-{sessionDiffStats.deletions}</span></span> : null}
|
|
|
|
|
{hasSecondaryProjectLabel ? <span className="truncate">{secondaryMeta?.projectLabel}</span> : null}
|
|
|
|
|
{hasSecondaryBranchLabel ? <span className="inline-flex min-w-0 items-center gap-0.5"><RiGitBranchLine className="h-3 w-3 flex-shrink-0 text-muted-foreground/70" /><span className="truncate">{secondaryMeta?.branchLabel}</span></span> : null}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-03-06 17:22:59 +02:00
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{streamingIndicator && !mobileVariant ? (
|
2026-03-20 01:01:03 +02:00
|
|
|
<div className={cn('absolute top-1/2 -translate-y-1/2 z-10', isMinimalMode ? 'right-0' : 'right-[30px]')}>
|
2026-03-06 17:22:59 +02:00
|
|
|
{streamingIndicator}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
2026-04-20 18:03:34 +03:00
|
|
|
<div className={cn(
|
|
|
|
|
'absolute right-0 top-1/2 z-10 -translate-y-1/2 transition-opacity',
|
|
|
|
|
isMenuOpen
|
|
|
|
|
? 'opacity-100'
|
|
|
|
|
: (mobileVariant && !isVSCode)
|
|
|
|
|
? 'opacity-100'
|
|
|
|
|
: cn('opacity-0', revealOnHoverClass),
|
|
|
|
|
)}>
|
|
|
|
|
<DropdownMenu open={isMenuOpen} onOpenChange={handleMenuOpenChange}>
|
|
|
|
|
<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 && !mobileVariant
|
|
|
|
|
? (isMenuOpen
|
|
|
|
|
? 'h-4 w-4 opacity-100'
|
|
|
|
|
: cn('h-4 w-4 opacity-0', revealOnHoverClass))
|
|
|
|
|
: 'h-6 w-6 opacity-100',
|
|
|
|
|
)}
|
|
|
|
|
aria-label="Session menu"
|
|
|
|
|
onPointerDown={handleMenuTriggerPointerDown}
|
|
|
|
|
onMouseDown={handleMenuTriggerMouseDown}
|
|
|
|
|
onClick={handleMenuTriggerClick}
|
|
|
|
|
onKeyDown={(event) => event.stopPropagation()}
|
|
|
|
|
>
|
|
|
|
|
<RiMore2Line className={cn(isMinimalMode && !mobileVariant ? 'h-2.5 w-2.5' : 'h-3.5 w-3.5')} />
|
|
|
|
|
</button>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
{sessionMenuContent}
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
</div>
|
2026-03-06 17:22:59 +02:00
|
|
|
</div>
|
|
|
|
|
</DraggableSessionRow>
|
|
|
|
|
{hasChildren && isExpanded
|
2026-03-20 01:01:03 +02:00
|
|
|
? node.children.map((child) => renderSessionNode(child, depth + 1, sessionDirectory ?? groupDirectory, projectId, archivedBucket, undefined, renderContext))
|
2026-03-06 17:22:59 +02:00
|
|
|
: null}
|
|
|
|
|
</React.Fragment>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-04 02:19:55 +03:00
|
|
|
|
|
|
|
|
export const SessionNodeItem = React.memo(SessionNodeItemComponent, areEqual);
|