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, RiErrorWarningLine, RiFileCopyLine, RiFileEditLine, RiFolderLine, RiLinkUnlinkM, RiMore2Line, RiPencilAiLine, RiPushpinLine, RiRobot2Line, RiShare2Line, RiShieldLine, RiUnpinLine, } from '@remixicon/react'; import { cn } from '@/lib/utils'; import { DraggableSessionRow } from './sessionFolderDnd'; import type { SessionNode, SessionSummaryMeta } from './types'; import { formatSessionDateLabel, normalizePath, renderHighlightedText, resolveSessionDiffStats } from './utils'; import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; 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[] }; type Props = { node: SessionNode; depth?: number; groupDirectory?: string | null; projectId?: string | null; archivedBucket?: boolean; directoryStatus: Map; sessionMemoryState: Map; currentSessionId: string | null; pinnedSessionIds: Set; expandedParents: Set; hasSessionSearchQuery: boolean; normalizedSessionSearchQuery: string; sessionAttentionStates: Map; notifyOnSubtasks: boolean; sessionStatus?: Map; permissions: Map; 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; openMenuSessionId: string | null; setOpenMenuSessionId: (id: 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 }) => void; handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean }) => void; mobileVariant: boolean; renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean) => React.ReactNode; }; export function SessionNodeItem(props: Props): React.ReactNode { const { node, depth = 0, groupDirectory, projectId, archivedBucket = false, directoryStatus, sessionMemoryState, currentSessionId, pinnedSessionIds, expandedParents, hasSessionSearchQuery, normalizedSessionSearchQuery, sessionAttentionStates, notifyOnSubtasks, sessionStatus, permissions, editingId, setEditingId, editTitle, setEditTitle, handleSaveEdit, handleCancelEdit, toggleParent, handleSessionSelect, handleSessionDoubleClick, togglePinnedSession, handleShareSession, copiedSessionId, handleCopyShareUrl, handleUnshareSession, openMenuSessionId, setOpenMenuSessionId, renamingFolderId, getFoldersForScope, getSessionFolderId, removeSessionFromFolder, addSessionToFolder, createFolderAndStartRename, openContextPanelTab, handleDeleteSession, mobileVariant, renderSessionNode, } = props; const displayMode = useSessionDisplayStore((state) => state.displayMode); const isMinimalMode = displayMode === 'minimal'; const session = node.session; const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? normalizePath(groupDirectory ?? null); const directoryState = sessionDirectory ? directoryStatus.get(sessionDirectory) : null; const isMissingDirectory = directoryState === 'missing'; const memoryState = sessionMemoryState.get(session.id); const isActive = currentSessionId === session.id; const sessionTitle = session.title || 'Untitled Session'; const hasChildren = node.children.length > 0; const isPinnedSession = pinnedSessionIds.has(session.id); const isExpanded = hasSessionSearchQuery ? true : expandedParents.has(session.id); const isSubtaskSession = Boolean((session as Session & { parentID?: string | null }).parentID); const rawNeedsAttention = sessionAttentionStates.get(session.id)?.needsAttention === true; const needsAttention = rawNeedsAttention && (!isSubtaskSession || notifyOnSubtasks); const sessionSummary = session.summary as SessionSummaryMeta | undefined; const sessionDiffStats = resolveSessionDiffStats(sessionSummary); if (editingId === session.id) { return (
0 && 'pl-[20px]')} >
{ event.preventDefault(); handleSaveEdit(); }} > 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(); } }} />
{!isMinimalMode ? (
{hasChildren ? {isExpanded ? : } : null} {formatSessionDateLabel(session.time?.updated || session.time?.created || Date.now())} {sessionDiffStats ? +{sessionDiffStats.additions}/-{sessionDiffStats.deletions} : null} {session.share ? : null} {(sessionSummary?.files ?? 0) > 0 || hasChildren ? ( {(sessionSummary?.files ?? 0) > 0 ? {sessionSummary!.files}

{sessionSummary!.files} changed {sessionSummary!.files === 1 ? 'file' : 'files'}

: null} {hasChildren ? {node.children.length}

{node.children.length} {node.children.length === 1 ? 'sub-session' : 'sub-sessions'}

: null}
) : null}
) : null}
); } const statusType = sessionStatus?.get(session.id)?.type ?? 'idle'; const isStreaming = statusType === 'busy' || statusType === 'retry'; const pendingPermissionCount = permissions.get(session.id)?.length ?? 0; const showUnreadStatus = !isStreaming && needsAttention && !isActive; const showStatusMarker = isStreaming || showUnreadStatus; const streamingIndicator = memoryState?.isZombie ? : null; return (
0 && 'pl-[20px]')} onContextMenu={(e) => { e.preventDefault(); setOpenMenuSessionId(session.id); }} >
{isMinimalMode ? (
{formatSessionDateLabel(session.time?.updated || session.time?.created || Date.now())} {sessionDiffStats ? ( +{sessionDiffStats.additions} / -{sessionDiffStats.deletions} ) : null}
{session.share ? (
Shared session
) : null} {(sessionSummary?.files ?? 0) > 0 ? (
{sessionSummary!.files} changed {sessionSummary!.files === 1 ? 'file' : 'files'}
) : null} {hasChildren ? (
{node.children.length} {node.children.length === 1 ? 'sub-session' : 'sub-sessions'}
) : null} {isMissingDirectory ? (
Directory missing
) : null}
) : ( )}
{streamingIndicator && !mobileVariant ? (
{streamingIndicator}
) : null}
setOpenMenuSessionId(open ? session.id : null)}> { if (renamingFolderId) event.preventDefault(); }}> { setEditingId(session.id); setEditTitle(sessionTitle); }} className="[&>svg]:mr-1" > Rename togglePinnedSession(session.id)} className="[&>svg]:mr-1"> {isPinnedSession ? : } {isPinnedSession ? 'Unpin session' : 'Pin session'} {!session.share ? ( handleShareSession(session)} className="[&>svg]:mr-1"> Share ) : ( <> { if (session.share?.url) handleCopyShareUrl(session.share.url, session.id); }} className="[&>svg]:mr-1"> {copiedSessionId === session.id ? <>Copied : <>Copy link} handleUnshareSession(session.id)} className="[&>svg]:mr-1"> Unshare )} {sessionDirectory && !archivedBucket ? (() => { const scopeFolders = getFoldersForScope(sessionDirectory); const currentFolderId = getSessionFolderId(sessionDirectory, session.id); return ( <> Move to folder {scopeFolders.length === 0 ? ( No folders yet ) : ( scopeFolders.map((folder) => ( { if (currentFolderId === folder.id) removeSessionFromFolder(sessionDirectory, session.id); else addSessionToFolder(sessionDirectory, folder.id, session.id); }}> {folder.name} {currentFolderId === folder.id ? : null} )) )} { const newFolder = createFolderAndStartRename(sessionDirectory); if (!newFolder) return; addSessionToFolder(sessionDirectory, newFolder.id, session.id); }}> New folder... {currentFolderId ? ( { removeSessionFromFolder(sessionDirectory, session.id); }} className="text-destructive focus:text-destructive"> Remove from folder ) : null} ); })() : null} { if (!sessionDirectory) return; openContextPanelTab(sessionDirectory, { mode: 'chat', dedupeKey: `session:${session.id}`, label: sessionTitle, }); }} className="[&>svg]:mr-1" > Open in Side Panel beta handleDeleteSession(session, { archivedBucket })}> {archivedBucket ? 'Delete' : 'Archive'}
{hasChildren && isExpanded ? node.children.map((child) => renderSessionNode(child, depth + 1, sessionDirectory ?? groupDirectory, projectId, archivedBucket)) : null}
); }