2025-12-07 19:32:53 +02:00
import React from 'react' ;
2026-01-03 13:39:59 +02:00
import type { Session } from '@opencode-ai/sdk/v2' ;
2026-01-19 02:50:40 +02:00
import { toast } from '@/components/ui' ;
2026-02-20 14:50:26 +02:00
import { copyTextToClipboard } from '@/lib/clipboard' ;
2026-02-11 20:07:44 +02:00
import { isDesktopLocalOriginActive , isDesktopShell , isTauriShell } from '@/lib/desktop' ;
2026-01-06 21:31:04 +02:00
import {
DndContext ,
DragOverlay ,
closestCenter ,
KeyboardSensor ,
PointerSensor ,
useSensor ,
useSensors ,
2026-02-23 03:56:38 +07:00
useDraggable ,
useDroppable ,
type DragEndEvent ,
2026-01-06 21:31:04 +02:00
} from '@dnd-kit/core' ;
import {
SortableContext ,
2026-02-07 03:57:46 +02:00
arrayMove ,
2026-01-06 21:31:04 +02:00
sortableKeyboardCoordinates ,
useSortable ,
verticalListSortingStrategy ,
} from '@dnd-kit/sortable' ;
2026-02-07 03:57:46 +02:00
import { CSS } from '@dnd-kit/utilities' ;
2025-12-07 19:32:53 +02:00
import {
DropdownMenu ,
DropdownMenuContent ,
DropdownMenuItem ,
DropdownMenuTrigger ,
2026-02-21 06:09:55 +07:00
DropdownMenuSub ,
DropdownMenuSubTrigger ,
DropdownMenuSubContent ,
DropdownMenuSeparator ,
2025-12-07 19:32:53 +02:00
} from '@/components/ui/dropdown-menu' ;
2026-01-07 12:08:53 +02:00
2025-12-07 19:32:53 +02:00
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay' ;
2026-02-23 03:56:38 +07:00
import {
Dialog ,
DialogContent ,
DialogHeader ,
DialogTitle ,
DialogDescription ,
DialogFooter ,
} from '@/components/ui/dialog' ;
2026-01-06 21:31:04 +02:00
import { Tooltip , TooltipTrigger , TooltipContent } from '@/components/ui/tooltip' ;
2026-01-16 17:48:57 +01:00
import { GridLoader } from '@/components/ui/grid-loader' ;
2026-02-11 23:53:37 -08:00
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel' ;
2025-12-07 19:32:53 +02:00
import {
RiAddLine ,
RiArrowDownSLine ,
RiArrowRightSLine ,
2026-03-02 02:11:33 +02:00
RiChat4Line ,
2026-02-24 03:28:30 +02:00
RiCheckboxBlankLine ,
RiCheckboxLine ,
2025-12-07 19:32:53 +02:00
RiCheckLine ,
RiCloseLine ,
RiDeleteBinLine ,
RiErrorWarningLine ,
RiFileCopyLine ,
2026-01-08 01:48:42 +02:00
RiFolderAddLine ,
2026-02-21 06:09:55 +07:00
RiFolderLine ,
2026-01-08 00:06:02 +02:00
RiGitBranchLine ,
2026-02-17 19:14:04 +02:00
RiNodeTree ,
2026-02-11 23:53:37 -08:00
RiStickyNoteLine ,
2025-12-07 19:32:53 +02:00
RiLinkUnlinkM ,
RiMore2Line ,
RiPencilAiLine ,
2026-02-16 14:15:19 +02:00
RiPushpinLine ,
2026-03-02 23:08:11 +00:00
RiSearchLine ,
2025-12-07 19:32:53 +02:00
RiShare2Line ,
2026-01-06 21:31:04 +02:00
RiShieldLine ,
2026-02-16 14:15:19 +02:00
RiUnpinLine ,
2025-12-07 19:32:53 +02:00
} from '@remixicon/react' ;
import { sessionEvents } from '@/lib/sessionEvents' ;
2026-01-01 14:59:51 +02:00
import { ArrowsMerge } from '@/components/icons/ArrowsMerge' ;
2025-12-07 19:32:53 +02:00
import { formatDirectoryName , formatPathForDisplay , cn } from '@/lib/utils' ;
import { useSessionStore } from '@/stores/useSessionStore' ;
import { useDirectoryStore } from '@/stores/useDirectoryStore' ;
2026-01-06 21:31:04 +02:00
import { useProjectsStore } from '@/stores/useProjectsStore' ;
2025-12-21 20:18:51 +02:00
import { useUIStore } from '@/stores/useUIStore' ;
2026-01-08 01:48:42 +02:00
import { useConfigStore } from '@/stores/useConfigStore' ;
2025-12-07 19:32:53 +02:00
import type { WorktreeMetadata } from '@/types/worktree' ;
import { opencodeClient } from '@/lib/opencode/client' ;
import { checkIsGitRepository } from '@/lib/gitApi' ;
import { getSafeStorage } from '@/stores/utils/safeStorage' ;
2026-03-03 00:20:15 +02:00
import { createWorktreeSession } from '@/lib/worktreeSessionCreator' ;
2026-02-07 03:57:46 +02:00
import { getRootBranch } from '@/lib/worktrees/worktreeStatus' ;
2026-02-08 16:51:05 -08:00
import { useGitStore } from '@/stores/useGitStore' ;
2026-02-11 23:53:37 -08:00
import { useDeviceInfo } from '@/lib/device' ;
2026-01-08 17:59:36 +02:00
import { isVSCodeRuntime } from '@/lib/desktop' ;
2026-02-05 01:59:49 +02:00
import { updateDesktopSettings } from '@/lib/persistence' ;
2026-03-03 00:20:15 +02:00
import { NewWorktreeDialog } from './NewWorktreeDialog' ;
2026-02-11 23:53:37 -08:00
import { ProjectNotesTodoPanel } from './ProjectNotesTodoPanel' ;
2026-02-21 06:09:55 +07:00
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore' ;
import { SessionFolderItem } from './SessionFolderItem' ;
2026-03-02 23:08:11 +00:00
import { useDebouncedValue } from '@/hooks/useDebouncedValue' ;
2025-12-07 19:32:53 +02:00
2026-02-07 03:57:46 +02:00
const ATTENTION_DIAMOND_INDICES = new Set ([ 1 , 3 , 4 , 5 , 7 ]);
const getAttentionDiamondDelay = ( index : number ) : string => {
return index === 4 ? '0ms' : '130ms' ;
};
2026-01-06 21:31:04 +02:00
const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse' ;
2026-02-07 03:57:46 +02:00
const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder' ;
const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse' ;
const PROJECT_ACTIVE_SESSION_STORAGE_KEY = 'oc.sessions.activeSessionByProject' ;
2025-12-07 19:32:53 +02:00
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents' ;
2026-02-16 14:15:19 +02:00
const SESSION_PINNED_STORAGE_KEY = 'oc.sessions.pinned' ;
2025-12-07 19:32:53 +02:00
2026-02-25 14:32:23 +02:00
const SESSION_PREFETCH_HOVER_DELAY_MS = 180 ;
const SESSION_PREFETCH_CONCURRENCY = 1 ;
const SESSION_PREFETCH_PENDING_LIMIT = 6 ;
2025-12-07 19:32:53 +02:00
const formatDateLabel = ( value : string | number ) => {
const targetDate = new Date ( value );
const today = new Date ();
const isSameDay = ( a : Date , b : Date ) =>
a . getFullYear () === b . getFullYear () &&
a . getMonth () === b . getMonth () &&
a . getDate () === b . getDate ();
const yesterday = new Date ( today );
yesterday . setDate ( today . getDate () - 1 );
if ( isSameDay ( targetDate , today )) {
return 'Today' ;
}
if ( isSameDay ( targetDate , yesterday )) {
return 'Yesterday' ;
}
const formatted = targetDate . toLocaleDateString ( 'en-US' , {
month : 'short' ,
day : 'numeric' ,
year : 'numeric' ,
});
return formatted . replace ( ',' , '' );
};
2026-02-23 03:56:38 +07:00
/** Returns relative time if updated today, otherwise falls back to formatDateLabel using updated time. */
const formatSessionDateLabel = ( updatedMs : number ) : string => {
const today = new Date ();
const updatedDate = new Date ( updatedMs );
const isSameDay = ( a : Date , b : Date ) =>
a . getFullYear () === b . getFullYear () &&
a . getMonth () === b . getMonth () &&
a . getDate () === b . getDate ();
if ( isSameDay ( updatedDate , today )) {
const diff = Date . now () - updatedMs ;
if ( diff < 60 _000 ) return 'Just now' ;
if ( diff < 3 _600_000 ) return ` ${ Math . floor ( diff / 60 _000 ) } min ago` ;
return ` ${ Math . floor ( diff / 3 _600_000 ) } h ago` ;
}
return formatDateLabel ( updatedMs );
};
2025-12-07 19:32:53 +02:00
const normalizePath = ( value? : string | null ) => {
if ( ! value ) {
return null ;
}
const normalized = value . replace ( /\\/g , '/' ). replace ( /\/+$/ , '' );
return normalized . length === 0 ? '/' : normalized ;
};
2026-02-08 16:51:05 -08:00
const normalizeForBranchComparison = ( value : string ) : string => {
return value
. toLowerCase ()
. replace ( /^opencode[/-]?/i , '' )
. replace ( /[-_]/g , '' )
. trim ();
};
const isBranchDifferentFromLabel = ( branch : string | null , label : string ) : boolean => {
if ( ! branch ) return false ;
return normalizeForBranchComparison ( branch ) !== normalizeForBranchComparison ( label );
};
2026-02-07 03:57:46 +02:00
const toFiniteNumber = ( value : unknown ) : number | undefined => {
if ( typeof value === 'number' && Number . isFinite ( value )) {
return value ;
}
if ( typeof value === 'string' && value . trim (). length > 0 ) {
const parsed = Number ( value );
if ( Number . isFinite ( parsed )) {
return parsed ;
}
}
return undefined ;
};
2026-02-16 14:15:19 +02:00
const getSessionCreatedAt = ( session : Session ) : number => {
return toFiniteNumber ( session . time ? . created ) ?? 0 ;
};
2026-02-24 03:28:30 +02:00
const getSessionUpdatedAt = ( session : Session ) : number => {
return toFiniteNumber ( session . time ? . updated ) ?? toFiniteNumber ( session . time ? . created ) ?? 0 ;
2026-02-16 14:15:19 +02:00
};
2026-02-23 03:56:38 +07:00
const compareSessionsByPinnedAndTime = (
a : Session ,
b : Session ,
2026-02-24 03:28:30 +02:00
pinnedSessionIds : Set < string >
2026-02-23 03:56:38 +07:00
) : number => {
2026-02-16 14:15:19 +02:00
const aPinned = pinnedSessionIds . has ( a . id );
const bPinned = pinnedSessionIds . has ( b . id );
if ( aPinned !== bPinned ) {
return aPinned ? - 1 : 1 ;
}
if ( aPinned && bPinned ) {
return getSessionCreatedAt ( b ) - getSessionCreatedAt ( a );
}
2026-02-24 03:28:30 +02:00
return getSessionUpdatedAt ( b ) - getSessionUpdatedAt ( a );
2026-02-16 14:15:19 +02:00
};
2026-01-06 21:31:04 +02:00
// Format project label: kebab-case/snake_case → Title Case
const formatProjectLabel = ( label : string ) : string => {
return label
. replace ( /[-_]/g , ' ' )
. replace ( /\b\w/g , ( char ) => char . toUpperCase ());
2025-12-07 19:32:53 +02:00
};
2026-03-02 23:08:11 +00:00
const renderHighlightedText = ( text : string , query : string ) : React . ReactNode => {
if ( ! query ) {
return text ;
}
const loweredText = text . toLowerCase ();
const loweredQuery = query . toLowerCase ();
const queryLength = loweredQuery . length ;
if ( queryLength === 0 ) {
return text ;
}
const parts : React.ReactNode [] = [];
let cursor = 0 ;
let matchIndex = loweredText . indexOf ( loweredQuery , cursor );
while ( matchIndex !== - 1 ) {
if ( matchIndex > cursor ) {
parts . push ( text . slice ( cursor , matchIndex ));
}
const matchText = text . slice ( matchIndex , matchIndex + queryLength );
parts . push (
< mark
key = { ` ${ matchIndex } - ${ matchText } ` }
className = "bg-primary text-primary-foreground ring-1 ring-primary/90"
>
{ matchText }
</ mark >,
);
cursor = matchIndex + queryLength ;
matchIndex = loweredText . indexOf ( loweredQuery , cursor );
}
if ( cursor < text . length ) {
parts . push ( text . slice ( cursor ));
}
return parts . length > 0 ? parts : text ;
};
2025-12-07 19:32:53 +02:00
type SessionNode = {
session : Session ;
children : SessionNode [];
2026-01-08 00:06:02 +02:00
worktree : WorktreeMetadata | null ;
2025-12-07 19:32:53 +02:00
};
type SessionGroup = {
id : string ;
label : string ;
2026-02-08 16:51:05 -08:00
branch : string | null ;
2025-12-07 19:32:53 +02:00
description : string | null ;
isMain : boolean ;
worktree : WorktreeMetadata | null ;
directory : string | null ;
sessions : SessionNode [];
};
2026-03-02 23:08:11 +00:00
type GroupSearchData = {
filteredNodes : SessionNode [];
matchedSessionCount : number ;
folderNameMatchCount : number ;
groupMatches : boolean ;
hasMatch : boolean ;
};
2026-02-23 03:56:38 +07:00
// --- Session Folder DnD helpers ---
/**
* Wraps a session row so the entire row is draggable onto folder drop zones.
* Stops pointer propagation so the outer group-reorder DndContext does not
* capture the drag (otherwise dragging a session moves the whole workspace group).
*/
const DraggableSessionRow : React.FC < {
sessionId : string ;
sessionDirectory : string | null ;
sessionTitle : string ;
children : React.ReactNode ;
} > = ({ sessionId , sessionDirectory , sessionTitle , children }) => {
const { attributes , listeners , setNodeRef , isDragging } = useDraggable ({
id : `session-drag: ${ sessionId } ` ,
data : { type : 'session' , sessionId , sessionDirectory , sessionTitle },
});
const handlePointerDown = React . useCallback (
( e : React.PointerEvent < HTMLDivElement >) => {
// Stop event from bubbling to the outer group-reorder DndContext
e . stopPropagation ();
if ( listeners ? . onPointerDown ) {
( listeners . onPointerDown as ( event : React.PointerEvent ) => void )( e );
}
},
[ listeners ],
);
return (
< div
ref = { setNodeRef }
{ ...attributes }
onPointerDown = { handlePointerDown }
2026-02-25 14:32:23 +02:00
className = { isDragging ? 'opacity-30' : undefined }
2026-02-23 03:56:38 +07:00
>
{ children }
</ div >
);
};
/**
* Wraps a <SessionFolderItem> and makes it a droppable target.
* Uses a render-prop pattern so the ref/isOver state can be passed
* down as props (avoids hooks-in-callbacks restrictions).
*/
const DroppableFolderWrapper : React.FC < {
folderId : string ;
children : (
droppableRef : ( node : HTMLElement | null ) => void ,
isOver : boolean ,
) => React . ReactNode ;
} > = ({ folderId , children }) => {
const { setNodeRef , isOver } = useDroppable ({
id : `folder-drop: ${ folderId } ` ,
data : { type : 'folder' , folderId },
});
return <>{ children ( setNodeRef , isOver )}</>;
};
/**
* Provides an inner DndContext scoped to one group, allowing sessions to be
* dragged onto folder headers within that group.
*/
const SessionFolderDndScope : React.FC < {
scopeKey : string | null ;
hasFolders : boolean ;
onSessionDroppedOnFolder : ( sessionId : string , folderId : string ) => void ;
children : React.ReactNode ;
} > = ({ scopeKey , hasFolders , onSessionDroppedOnFolder , children }) => {
const sensors = useSensors (
useSensor ( PointerSensor , { activationConstraint : { distance : 8 } }),
);
const [ activeDragId , setActiveDragId ] = React . useState < string | null >( null );
const [ activeDragTitle , setActiveDragTitle ] = React . useState < string >( 'Session' );
const [ activeDragWidth , setActiveDragWidth ] = React . useState < number | null >( null );
const [ activeDragHeight , setActiveDragHeight ] = React . useState < number | null >( null );
// Always need DndContext when scopeKey exists (DraggableSessionRow requires it).
// When there are no folders the drag just has nowhere to land – that's fine.
if ( ! scopeKey ) {
return <>{ children }</>;
}
const handleDragEnd = ( event : DragEndEvent ) => {
setActiveDragId ( null );
setActiveDragWidth ( null );
setActiveDragHeight ( null );
const { active , over } = event ;
if ( ! over ) return ;
const activeData = active . data . current as { type ?: string ; sessionId? : string } | undefined ;
const overData = over . data . current as { type ?: string ; folderId? : string } | undefined ;
if ( activeData ? . type === 'session' && activeData . sessionId && overData ? . type === 'folder' && overData . folderId ) {
onSessionDroppedOnFolder ( activeData . sessionId , overData . folderId );
}
};
return (
< DndContext
sensors = { sensors }
collisionDetection = { closestCenter }
onDragStart = {( event ) => {
const data = event . active . data . current as { type ?: string ; sessionId? : string ; sessionTitle? : string } | undefined ;
if ( data ? . type === 'session' && data . sessionId ) {
setActiveDragId ( data . sessionId );
setActiveDragTitle ( data . sessionTitle ?? 'Session' );
const width = event . active . rect . current . initial ? . width ;
const height = event . active . rect . current . initial ? . height ;
setActiveDragWidth ( typeof width === 'number' ? width : null );
setActiveDragHeight ( typeof height === 'number' ? height : null );
}
}}
onDragCancel = {() => {
setActiveDragId ( null );
setActiveDragWidth ( null );
setActiveDragHeight ( null );
}}
onDragEnd = { handleDragEnd }
>
{ children }
2026-02-25 14:32:23 +02:00
< DragOverlay >
2026-02-23 03:56:38 +07:00
{ activeDragId && hasFolders ? (
2026-03-02 23:08:11 +00:00
< div
style = {{
2026-02-23 03:56:38 +07:00
width : activeDragWidth ? ` ${ activeDragWidth } px` : 'auto' ,
height : activeDragHeight ? ` ${ activeDragHeight } px` : 'auto'
}}
2026-02-25 14:32:23 +02:00
className = "flex items-center rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2.5 py-1 shadow-none pointer-events-none"
2026-02-23 03:56:38 +07:00
>
< RiStickyNoteLine className = "h-4 w-4 text-muted-foreground mr-2 flex-shrink-0" />
< div className = "min-w-0 flex-1 truncate typography-ui-label font-normal text-foreground" >
{ activeDragTitle }
</ div >
</ div >
) : null }
</ DragOverlay >
</ DndContext >
);
};
// --- End Session Folder DnD helpers ---
2026-01-06 21:31:04 +02:00
interface SortableProjectItemProps {
id : string ;
projectLabel : string ;
projectDescription : string ;
isCollapsed : boolean ;
isActiveProject : boolean ;
isRepo : boolean ;
isHovered : boolean ;
2026-02-05 01:59:49 +02:00
isDesktopShell : boolean ;
2026-01-06 21:31:04 +02:00
isStuck : boolean ;
hideDirectoryControls : boolean ;
mobileVariant : boolean ;
onToggle : () => void ;
onHoverChange : ( hovered : boolean ) => void ;
2026-01-08 00:29:20 +02:00
onNewSession : () => void ;
2026-01-16 17:49:46 +01:00
onNewWorktreeSession ?: () => void ;
2026-01-06 21:31:04 +02:00
onOpenMultiRunLauncher : () => void ;
2026-02-06 02:07:50 +02:00
onRenameStart : () => void ;
onRenameSave : () => void ;
onRenameCancel : () => void ;
onRenameValueChange : ( value : string ) => void ;
renameValue : string ;
isRenaming : boolean ;
2026-01-06 21:31:04 +02:00
onClose : () => void ;
sentinelRef : ( el : HTMLDivElement | null ) => void ;
children? : React.ReactNode ;
2026-01-17 11:00:21 +02:00
settingsAutoCreateWorktree : boolean ;
2026-02-07 03:57:46 +02:00
showCreateButtons? : boolean ;
hideHeader? : boolean ;
2026-01-06 21:31:04 +02:00
}
const SortableProjectItem : React.FC < SortableProjectItemProps > = ({
id ,
projectLabel ,
projectDescription ,
isCollapsed ,
isActiveProject ,
isRepo ,
isHovered ,
2026-02-05 01:59:49 +02:00
isDesktopShell ,
2026-01-06 21:31:04 +02:00
isStuck ,
hideDirectoryControls ,
mobileVariant ,
onToggle ,
onHoverChange ,
2026-01-08 00:29:20 +02:00
onNewSession ,
2026-01-16 17:49:46 +01:00
onNewWorktreeSession ,
2026-01-06 21:31:04 +02:00
onOpenMultiRunLauncher ,
2026-02-06 02:07:50 +02:00
onRenameStart ,
onRenameSave ,
onRenameCancel ,
onRenameValueChange ,
renameValue ,
isRenaming ,
2026-01-06 21:31:04 +02:00
onClose ,
sentinelRef ,
children ,
2026-01-17 11:00:21 +02:00
settingsAutoCreateWorktree ,
2026-02-07 03:57:46 +02:00
showCreateButtons = true ,
hideHeader = false ,
2026-01-06 21:31:04 +02:00
}) => {
const {
attributes ,
listeners ,
setNodeRef ,
2026-02-25 14:32:23 +02:00
transform ,
transition ,
2026-01-06 21:31:04 +02:00
isDragging ,
} = useSortable ({ id });
2026-01-17 22:15:28 +02:00
const [ isMenuOpen , setIsMenuOpen ] = React . useState ( false );
2026-01-06 21:31:04 +02:00
return (
2026-02-25 14:32:23 +02:00
< div
ref = { setNodeRef }
style = {{ transform : CSS.Transform.toString ( transform ), transition }}
className = { cn ( 'relative' , isDragging && 'opacity-30' )}
>
2026-02-07 03:57:46 +02:00
{ ! hideHeader ? (
<>
{ /* Sentinel for sticky detection */ }
{ isDesktopShell && (
< div
ref = { sentinelRef }
data-project-id = { id }
className = "absolute top-0 h-px w-full pointer-events-none"
aria-hidden = "true"
/>
)}
{ /* Project header - sticky like workspace groups */ }
< div
className = { cn (
'sticky top-0 z-10 pt-2 pb-1.5 w-full text-left cursor-pointer group/project border-b select-none' ,
2026-02-25 14:32:23 +02:00
! isDesktopShell && 'bg-transparent' ,
2026-02-07 03:57:46 +02:00
)}
style = {{
backgroundColor : isDesktopShell
2026-02-25 14:32:23 +02:00
? ( isStuck ? 'transparent' : 'transparent' )
2026-02-07 03:57:46 +02:00
: undefined ,
borderColor : isHovered
? 'var(--color-border-hover)'
: isCollapsed
? 'color-mix(in srgb, var(--color-border) 35%, transparent)'
: 'var(--color-border)'
}}
onMouseEnter = {() => onHoverChange ( true )}
onMouseLeave = {() => onHoverChange ( false )}
onContextMenu = {( event ) => {
event . preventDefault ();
if ( ! isRenaming ) {
setIsMenuOpen ( true );
}
}}
>
2026-01-06 21:31:04 +02:00
< div className = "relative flex items-center gap-1 px-1" { ...attributes }>
2026-02-06 02:07:50 +02:00
{ isRenaming ? (
< form
className = "flex min-w-0 flex-1 items-center gap-2"
data-keyboard-avoid = "true"
onSubmit = {( event ) => {
event . preventDefault ();
onRenameSave ();
}}
>
< input
value = { renameValue }
onChange = {( event ) => onRenameValueChange ( event . target . value )}
className = "flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground"
autoFocus
placeholder = "Rename project"
onKeyDown = {( event ) => {
if ( event . key === 'Escape' ) {
2026-02-10 02:30:25 +02:00
event . stopPropagation ();
2026-02-06 02:07:50 +02:00
onRenameCancel ();
2026-02-10 02:30:25 +02:00
return ;
}
if ( event . key === ' ' || event . key === 'Enter' ) {
event . stopPropagation ();
2026-02-06 02:07:50 +02:00
}
}}
/>
2026-01-06 21:31:04 +02:00
< button
2026-02-06 02:07:50 +02:00
type = "submit"
className = "shrink-0 text-muted-foreground hover:text-foreground"
2026-01-06 21:31:04 +02:00
>
2026-02-06 02:07:50 +02:00
< RiCheckLine className = "size-4" />
2026-01-06 21:31:04 +02:00
</ button >
< button
type = "button"
2026-02-06 02:07:50 +02:00
onClick = { onRenameCancel }
className = "shrink-0 text-muted-foreground hover:text-foreground"
2026-01-06 21:31:04 +02:00
>
2026-02-06 02:07:50 +02:00
< RiCloseLine className = "size-4" />
2026-01-06 21:31:04 +02:00
</ button >
2026-02-06 02:07:50 +02:00
</ form >
) : (
< Tooltip delayDuration = { 1500 }>
< TooltipTrigger asChild >
< button
type = "button"
onClick = { onToggle }
{ ...listeners }
className = "flex-1 min-w-0 flex items-center gap-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-sm cursor-grab active:cursor-grabbing"
>
< span className = { cn (
"typography-ui font-semibold truncate" ,
isActiveProject ? "text-primary" : "text-foreground group-hover/project:text-foreground"
)}>
{ projectLabel }
</ span >
</ button >
</ TooltipTrigger >
< TooltipContent side = "right" sideOffset = { 8 }>
{ projectDescription }
</ TooltipContent >
</ Tooltip >
)}
{ ! isRenaming ? (
< DropdownMenu
open = { isMenuOpen }
onOpenChange = { setIsMenuOpen }
>
< DropdownMenuTrigger asChild >
< button
type = "button"
className = { cn (
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground' ,
mobileVariant ? 'opacity-70' : 'opacity-0 group-hover/project:opacity-100' ,
)}
aria-label = "Project menu"
onClick = {( e ) => e . stopPropagation ()}
>
< RiMore2Line className = "h-3.5 w-3.5" />
</ button >
</ DropdownMenuTrigger >
< DropdownMenuContent align = "end" className = "min-w-[180px]" >
2026-02-07 03:57:46 +02:00
{ showCreateButtons && isRepo && ! hideDirectoryControls && settingsAutoCreateWorktree && onNewSession && (
2026-02-06 02:07:50 +02:00
< DropdownMenuItem onClick = { onNewSession }>
< RiAddLine className = "mr-1.5 h-4 w-4" />
New Session
</ DropdownMenuItem >
)}
2026-02-07 03:57:46 +02:00
{ showCreateButtons && isRepo && ! hideDirectoryControls && ! settingsAutoCreateWorktree && onNewWorktreeSession && (
2026-02-06 02:07:50 +02:00
< DropdownMenuItem onClick = { onNewWorktreeSession }>
< RiGitBranchLine className = "mr-1.5 h-4 w-4" />
New Session in Worktree
</ DropdownMenuItem >
)}
2026-02-07 03:57:46 +02:00
{ showCreateButtons && isRepo && ! hideDirectoryControls && (
2026-02-06 02:07:50 +02:00
< DropdownMenuItem onClick = { onOpenMultiRunLauncher }>
< ArrowsMerge className = "mr-1.5 h-4 w-4" />
New Multi - Run
</ DropdownMenuItem >
)}
< DropdownMenuItem onClick = { onRenameStart }>
< RiPencilAiLine className = "mr-1.5 h-4 w-4" />
Rename
2026-01-08 00:29:20 +02:00
</ DropdownMenuItem >
2026-02-06 02:07:50 +02:00
< DropdownMenuItem
onClick = { onClose }
className = "text-destructive focus:text-destructive"
>
< RiCloseLine className = "mr-1.5 h-4 w-4" />
Close Project
2026-01-27 19:59:48 +02:00
</ DropdownMenuItem >
2026-02-06 02:07:50 +02:00
</ DropdownMenuContent >
</ DropdownMenu >
) : null }
2026-01-06 21:31:04 +02:00
2026-02-07 03:57:46 +02:00
{ showCreateButtons && isRepo && ! hideDirectoryControls && onNewWorktreeSession && settingsAutoCreateWorktree && ! isRenaming && (
2026-01-16 17:49:46 +01:00
< Tooltip >
< TooltipTrigger asChild >
< button
type = "button"
onClick = {( e ) => {
e . stopPropagation ();
onNewWorktreeSession ();
}}
className = { cn (
2026-02-01 18:29:34 +02:00
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground hover:bg-interactive-hover/50 flex-shrink-0' ,
2026-01-17 22:15:28 +02:00
mobileVariant ? 'opacity-70' : 'opacity-100' ,
2026-01-16 17:49:46 +01:00
)}
aria-label = "New session in worktree"
>
< RiGitBranchLine className = "h-4 w-4" />
</ button >
</ TooltipTrigger >
< TooltipContent side = "bottom" sideOffset = { 4 }>
< p > New session in worktree </ p >
</ TooltipContent >
</ Tooltip >
)}
2026-02-07 03:57:46 +02:00
{ showCreateButtons && ( ! settingsAutoCreateWorktree || ! isRepo ) && ! isRenaming && (
2026-01-17 22:15:28 +02:00
< Tooltip >
2026-01-17 11:54:23 +01:00
< TooltipTrigger asChild >
< button
type = "button"
onClick = {( e ) => {
e . stopPropagation ();
2026-01-17 22:15:28 +02:00
onNewSession ();
2026-01-17 11:54:23 +01:00
}}
2026-02-01 18:29:34 +02:00
className = "inline-flex h-6 w-6 items-center justify-center text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 flex-shrink-0 rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
2026-01-17 22:15:28 +02:00
aria-label = "New session"
2026-01-17 11:54:23 +01:00
>
2026-01-17 22:15:28 +02:00
< RiAddLine className = "h-4 w-4" />
2026-01-17 11:54:23 +01:00
</ button >
</ TooltipTrigger >
< TooltipContent side = "bottom" sideOffset = { 4 }>
2026-01-17 22:15:28 +02:00
< p > New session </ p >
2026-01-17 11:54:23 +01:00
</ TooltipContent >
</ Tooltip >
)}
2026-01-06 21:31:04 +02:00
</ div >
2026-02-07 03:57:46 +02:00
</ div >
</>
) : null }
2026-01-06 21:31:04 +02:00
{ /* Children (workspace groups and sessions) */ }
{ children }
</ div >
);
};
2026-02-07 03:57:46 +02:00
const SortableGroupItemBase : React.FC < {
id : string ;
children : React.ReactNode ;
} > = ({ id , children }) => {
const {
attributes ,
listeners ,
setNodeRef ,
transform ,
transition ,
isDragging ,
} = useSortable ({ id });
2026-01-06 21:31:04 +02:00
return (
< div
2026-02-07 03:57:46 +02:00
ref = { setNodeRef }
2026-02-25 14:32:23 +02:00
style = {{
transform : CSS.Transform.toString ( transform ),
transition ,
}}
2026-02-07 03:57:46 +02:00
className = { cn (
'space-y-0.5 rounded-md' ,
2026-02-25 14:32:23 +02:00
isDragging && 'opacity-50' ,
2026-02-07 03:57:46 +02:00
)}
{ ...attributes }
{ ...listeners }
2026-01-06 21:31:04 +02:00
>
2026-02-07 03:57:46 +02:00
{ children }
</ div >
);
};
const SortableGroupItem = React . memo ( SortableGroupItemBase );
2026-01-06 21:31:04 +02:00
2026-02-07 03:57:46 +02:00
2025-12-07 19:32:53 +02:00
interface SessionSidebarProps {
mobileVariant? : boolean ;
2025-12-13 16:34:17 +02:00
onSessionSelected ?: ( sessionId : string ) => void ;
allowReselect? : boolean ;
hideDirectoryControls? : boolean ;
2026-02-16 14:15:19 +02:00
hideProjectSelector? : boolean ;
2026-01-01 20:39:05 +01:00
showOnlyMainWorkspace? : boolean ;
2025-12-07 19:32:53 +02:00
}
2025-12-13 16:34:17 +02:00
export const SessionSidebar : React.FC < SessionSidebarProps > = ({
mobileVariant = false ,
onSessionSelected ,
allowReselect = false ,
hideDirectoryControls = false ,
2026-02-25 14:32:23 +02:00
hideProjectSelector = true ,
2026-01-01 20:39:05 +01:00
showOnlyMainWorkspace = false ,
2025-12-13 16:34:17 +02:00
}) => {
2026-03-02 23:08:11 +00:00
const [ isSessionSearchOpen , setIsSessionSearchOpen ] = React . useState ( false );
const [ sessionSearchQuery , setSessionSearchQuery ] = React . useState ( '' );
const sessionSearchContainerRef = React . useRef < HTMLDivElement | null >( null );
const sessionSearchInputRef = React . useRef < HTMLInputElement | null >( null );
2025-12-07 19:32:53 +02:00
const [ editingId , setEditingId ] = React . useState < string | null >( null );
const [ editTitle , setEditTitle ] = React . useState ( '' );
2026-02-06 02:07:50 +02:00
const [ editingProjectId , setEditingProjectId ] = React . useState < string | null >( null );
const [ editProjectTitle , setEditProjectTitle ] = React . useState ( '' );
2025-12-07 19:32:53 +02:00
const [ copiedSessionId , setCopiedSessionId ] = React . useState < string | null >( null );
const copyTimeout = React . useRef < number | null >( null );
const [ expandedParents , setExpandedParents ] = React . useState < Set < string >>( new Set ());
const [ directoryStatus , setDirectoryStatus ] = React . useState < Map < string , 'unknown' | 'exists' | 'missing' >>(
() => new Map (),
);
2026-02-25 14:32:23 +02:00
const directoryStatusRef = React . useRef < Map < string , 'unknown' | 'exists' | 'missing' >>( new Map ());
2025-12-07 19:32:53 +02:00
const checkingDirectories = React . useRef < Set < string >>( new Set ());
const safeStorage = React . useMemo (() => getSafeStorage (), []);
2026-01-06 21:31:04 +02:00
const [ collapsedProjects , setCollapsedProjects ] = React . useState < Set < string >>( new Set ());
2026-01-07 12:08:53 +02:00
2026-01-06 21:31:04 +02:00
const [ projectRepoStatus , setProjectRepoStatus ] = React . useState < Map < string , boolean | null >>( new Map ());
2025-12-07 19:32:53 +02:00
const [ expandedSessionGroups , setExpandedSessionGroups ] = React . useState < Set < string >>( new Set ());
2026-01-06 21:31:04 +02:00
const [ hoveredProjectId , setHoveredProjectId ] = React . useState < string | null >( null );
2026-03-03 00:20:15 +02:00
const [ newWorktreeDialogOpen , setNewWorktreeDialogOpen ] = React . useState ( false );
2026-02-11 23:53:37 -08:00
const [ projectNotesPanelOpen , setProjectNotesPanelOpen ] = React . useState ( false );
2026-01-06 21:31:04 +02:00
const [ stuckProjectHeaders , setStuckProjectHeaders ] = React . useState < Set < string >>( new Set ());
2026-01-16 17:45:37 +01:00
const [ openMenuSessionId , setOpenMenuSessionId ] = React . useState < string | null >( null );
2026-02-21 06:09:55 +07:00
const [ renamingFolderId , setRenamingFolderId ] = React . useState < string | null >( null );
const [ renameFolderDraft , setRenameFolderDraft ] = React . useState ( '' );
2026-02-23 03:56:38 +07:00
const [ deleteSessionConfirm , setDeleteSessionConfirm ] = React . useState < {
session : Session ;
descendantCount : number ;
} | null > ( null );
const [ deleteFolderConfirm , setDeleteFolderConfirm ] = React . useState < {
scopeKey : string ;
folderId : string ;
folderName : string ;
subFolderCount : number ;
sessionCount : number ;
} | null > ( null );
2026-02-16 14:15:19 +02:00
const [ pinnedSessionIds , setPinnedSessionIds ] = React . useState < Set < string >>(() => {
try {
const raw = getSafeStorage (). getItem ( SESSION_PINNED_STORAGE_KEY );
if ( ! raw ) {
return new Set ();
}
const parsed = JSON . parse ( raw ) as string [];
return new Set ( Array . isArray ( parsed ) ? parsed . filter (( item ) => typeof item === 'string' ) : []);
} catch {
return new Set ();
}
});
2026-02-07 03:57:46 +02:00
const [ collapsedGroups , setCollapsedGroups ] = React . useState < Set < string >>(() => {
try {
const raw = getSafeStorage (). getItem ( GROUP_COLLAPSE_STORAGE_KEY );
if ( ! raw ) {
return new Set ();
}
const parsed = JSON . parse ( raw ) as string [];
return new Set ( Array . isArray ( parsed ) ? parsed . filter (( item ) => typeof item === 'string' ) : []);
} catch {
return new Set ();
}
});
const [ groupOrderByProject , setGroupOrderByProject ] = React . useState < Map < string , string [] >>(() => {
try {
const raw = getSafeStorage (). getItem ( GROUP_ORDER_STORAGE_KEY );
if ( ! raw ) {
return new Map ();
}
const parsed = JSON . parse ( raw ) as Record < string , string [] >;
const next = new Map < string , string [] >();
Object . entries ( parsed ). forEach (([ projectId , order ]) => {
if ( Array . isArray ( order )) {
next . set ( projectId , order . filter (( item ) => typeof item === 'string' ));
}
});
return next ;
} catch {
return new Map ();
}
});
const [ activeSessionByProject , setActiveSessionByProject ] = React . useState < Map < string , string >>(() => {
try {
const raw = getSafeStorage (). getItem ( PROJECT_ACTIVE_SESSION_STORAGE_KEY );
if ( ! raw ) {
return new Map ();
}
const parsed = JSON . parse ( raw ) as Record < string , string >;
const next = new Map < string , string >();
Object . entries ( parsed ). forEach (([ projectId , sessionId ]) => {
if ( typeof sessionId === 'string' && sessionId . length > 0 ) {
next . set ( projectId , sessionId );
}
});
return next ;
} catch {
return new Map ();
}
});
2026-02-25 14:32:23 +02:00
2026-02-07 03:57:46 +02:00
const [ isProjectRenameInline , setIsProjectRenameInline ] = React . useState ( false );
const [ projectRenameDraft , setProjectRenameDraft ] = React . useState ( '' );
const [ projectRootBranches , setProjectRootBranches ] = React . useState < Map < string , string >>( new Map ());
2026-01-06 21:31:04 +02:00
const projectHeaderSentinelRefs = React . useRef < Map < string , HTMLDivElement | null >>( new Map ());
const ignoreIntersectionUntil = React . useRef < number >( 0 );
2026-02-05 01:59:49 +02:00
const persistCollapsedProjectsTimer = React . useRef < number | null >( null );
const pendingCollapsedProjects = React . useRef < Set < string > | null >( null );
2025-12-07 19:32:53 +02:00
const homeDirectory = useDirectoryStore (( state ) => state . homeDirectory );
2026-01-06 21:31:04 +02:00
const currentDirectory = useDirectoryStore (( state ) => state . currentDirectory );
2025-12-07 19:32:53 +02:00
const setDirectory = useDirectoryStore (( state ) => state . setDirectory );
2026-01-06 21:31:04 +02:00
const projects = useProjectsStore (( state ) => state . projects );
const activeProjectId = useProjectsStore (( state ) => state . activeProjectId );
const addProject = useProjectsStore (( state ) => state . addProject );
const removeProject = useProjectsStore (( state ) => state . removeProject );
const setActiveProjectIdOnly = useProjectsStore (( state ) => state . setActiveProjectIdOnly );
2026-02-06 02:07:50 +02:00
const renameProject = useProjectsStore (( state ) => state . renameProject );
2026-01-06 21:31:04 +02:00
2025-12-21 20:18:51 +02:00
const setActiveMainTab = useUIStore (( state ) => state . setActiveMainTab );
2026-03-02 02:11:33 +02:00
const openContextPanelTab = useUIStore (( state ) => state . openContextPanelTab );
2026-02-11 23:53:37 -08:00
const deviceInfo = useDeviceInfo ();
2025-12-21 20:18:51 +02:00
const setSessionSwitcherOpen = useUIStore (( state ) => state . setSessionSwitcherOpen );
2026-01-01 14:59:51 +02:00
const openMultiRunLauncher = useUIStore (( state ) => state . openMultiRunLauncher );
2026-02-21 05:46:15 +07:00
const notifyOnSubtasks = useUIStore (( state ) => state . notifyOnSubtasks );
2026-02-24 03:28:30 +02:00
const showDeletionDialog = useUIStore (( state ) => state . showDeletionDialog );
const setShowDeletionDialog = useUIStore (( state ) => state . setShowDeletionDialog );
2026-01-08 01:48:42 +02:00
const settingsAutoCreateWorktree = useConfigStore (( state ) => state . settingsAutoCreateWorktree );
2026-03-02 23:08:11 +00:00
const debouncedSessionSearchQuery = useDebouncedValue ( sessionSearchQuery , 120 );
const normalizedSessionSearchQuery = React . useMemo (
() => debouncedSessionSearchQuery . trim (). toLowerCase (),
[ debouncedSessionSearchQuery ],
);
const hasSessionSearchQuery = normalizedSessionSearchQuery . length > 0 ;
2026-02-21 06:09:55 +07:00
// Session Folders store
const collapsedFolderIds = useSessionFoldersStore (( state ) => state . collapsedFolderIds );
const getFoldersForScope = useSessionFoldersStore (( state ) => state . getFoldersForScope );
const createFolder = useSessionFoldersStore (( state ) => state . createFolder );
const renameFolder = useSessionFoldersStore (( state ) => state . renameFolder );
const deleteFolder = useSessionFoldersStore (( state ) => state . deleteFolder );
const addSessionToFolder = useSessionFoldersStore (( state ) => state . addSessionToFolder );
const removeSessionFromFolder = useSessionFoldersStore (( state ) => state . removeSessionFromFolder );
const toggleFolderCollapse = useSessionFoldersStore (( state ) => state . toggleFolderCollapse );
const cleanupSessions = useSessionFoldersStore (( state ) => state . cleanupSessions );
const getSessionFolderId = useSessionFoldersStore (( state ) => state . getSessionFolderId );
2026-03-02 23:08:11 +00:00
const buildGroupSearchText = React . useCallback (( group : SessionGroup ) : string => {
return [
group . label ,
group . branch ?? '' ,
group . description ?? '' ,
group . directory ?? '' ,
]
. join ( ' ' )
. toLowerCase ();
}, []);
const buildSessionSearchText = React . useCallback (( session : Session ) : string => {
const sessionDirectory = normalizePath (( session as Session & { directory? : string | null }). directory ?? null ) ?? '' ;
const sessionTitle = ( session . title || 'Untitled Session' ). trim ();
return ` ${ sessionTitle } ${ sessionDirectory } ` . toLowerCase ();
}, []);
const filterSessionNodesForSearch = React . useCallback (
( nodes : SessionNode [], query : string ) : SessionNode [] => {
if ( ! query ) {
return nodes ;
}
return nodes . flatMap (( node ) => {
const nodeMatches = buildSessionSearchText ( node . session ). includes ( query );
if ( nodeMatches ) {
return [ node ];
}
const filteredChildren = filterSessionNodesForSearch ( node . children , query );
if ( filteredChildren . length === 0 ) {
return [];
}
return [{
... node ,
children : filteredChildren ,
}];
});
},
[ buildSessionSearchText ],
);
React . useEffect (() => {
if ( ! isSessionSearchOpen || typeof window === 'undefined' ) {
return ;
}
const raf = window . requestAnimationFrame (() => {
sessionSearchInputRef . current ? . focus ();
sessionSearchInputRef . current ? . select ();
});
return () => window . cancelAnimationFrame ( raf );
}, [ isSessionSearchOpen ]);
React . useEffect (() => {
if ( ! isSessionSearchOpen || typeof document === 'undefined' ) {
return ;
}
const handlePointerDown = ( event : MouseEvent ) => {
if ( ! sessionSearchContainerRef . current ) {
return ;
}
if ( ! sessionSearchContainerRef . current . contains ( event . target as Node )) {
setIsSessionSearchOpen ( false );
}
};
document . addEventListener ( 'mousedown' , handlePointerDown );
return () => document . removeEventListener ( 'mousedown' , handlePointerDown );
}, [ isSessionSearchOpen ]);
2026-02-08 16:51:05 -08:00
const gitDirectories = useGitStore (( state ) => state . directories );
2026-01-06 21:31:04 +02:00
const sessions = useSessionStore (( state ) => state . sessions );
const sessionsByDirectory = useSessionStore (( state ) => state . sessionsByDirectory );
2025-12-07 19:32:53 +02:00
const currentSessionId = useSessionStore (( state ) => state . currentSessionId );
2026-02-11 19:28:22 +02:00
const newSessionDraftOpen = useSessionStore (( state ) => Boolean ( state . newSessionDraft ? . open ));
2025-12-07 19:32:53 +02:00
const setCurrentSession = useSessionStore (( state ) => state . setCurrentSession );
2026-02-25 14:32:23 +02:00
const loadMessages = useSessionStore (( state ) => state . loadMessages );
2025-12-07 19:32:53 +02:00
const updateSessionTitle = useSessionStore (( state ) => state . updateSessionTitle );
const shareSession = useSessionStore (( state ) => state . shareSession );
const unshareSession = useSessionStore (( state ) => state . unshareSession );
const sessionMemoryState = useSessionStore (( state ) => state . sessionMemoryState );
2026-02-05 01:59:49 +02:00
const sessionStatus = useSessionStore (( state ) => state . sessionStatus );
2026-02-07 03:57:46 +02:00
const sessionAttentionStates = useSessionStore (( state ) => state . sessionAttentionStates );
2026-01-06 21:31:04 +02:00
const permissions = useSessionStore (( state ) => state . permissions );
2025-12-07 19:32:53 +02:00
const worktreeMetadata = useSessionStore (( state ) => state . worktreeMetadata );
2026-01-06 21:31:04 +02:00
const availableWorktreesByProject = useSessionStore (( state ) => state . availableWorktreesByProject );
const getSessionsByDirectory = useSessionStore (( state ) => state . getSessionsByDirectory );
2026-01-08 00:29:20 +02:00
const openNewSessionDraft = useSessionStore (( state ) => state . openNewSessionDraft );
2025-12-07 19:32:53 +02:00
2026-02-05 01:59:49 +02:00
const tauriIpcAvailable = React . useMemo (() => isTauriShell (), []);
const isDesktopShellRuntime = React . useMemo (() => isDesktopShell (), []);
const isVSCode = React . useMemo (() => isVSCodeRuntime (), []);
const flushCollapsedProjectsPersist = React . useCallback (() => {
if ( isVSCode ) {
return ;
}
const collapsed = pendingCollapsedProjects . current ;
pendingCollapsedProjects . current = null ;
persistCollapsedProjectsTimer . current = null ;
if ( ! collapsed ) {
return ;
}
const { projects } = useProjectsStore . getState ();
const updatedProjects = projects . map (( project ) => ({
... project ,
sidebarCollapsed : collapsed.has ( project . id ),
}));
void updateDesktopSettings ({ projects : updatedProjects }). catch (() => {});
}, [ isVSCode ]);
const scheduleCollapsedProjectsPersist = React . useCallback (( collapsed : Set < string >) => {
2025-12-07 19:32:53 +02:00
if ( typeof window === 'undefined' ) {
2026-02-05 01:59:49 +02:00
return ;
}
if ( isVSCode ) {
return ;
2025-12-07 19:32:53 +02:00
}
2026-02-05 01:59:49 +02:00
pendingCollapsedProjects . current = collapsed ;
if ( persistCollapsedProjectsTimer . current !== null ) {
window . clearTimeout ( persistCollapsedProjectsTimer . current );
}
persistCollapsedProjectsTimer . current = window . setTimeout (() => {
flushCollapsedProjectsPersist ();
}, 700 );
}, [ flushCollapsedProjectsPersist , isVSCode ]);
React . useEffect (() => {
return () => {
if ( typeof window !== 'undefined' && persistCollapsedProjectsTimer . current !== null ) {
window . clearTimeout ( persistCollapsedProjectsTimer . current );
}
persistCollapsedProjectsTimer . current = null ;
pendingCollapsedProjects . current = null ;
};
}, []);
2026-01-08 17:59:36 +02:00
2025-12-07 19:32:53 +02:00
React . useEffect (() => {
try {
const storedParents = safeStorage . getItem ( SESSION_EXPANDED_STORAGE_KEY );
if ( storedParents ) {
const parsed = JSON . parse ( storedParents );
if ( Array . isArray ( parsed )) {
setExpandedParents ( new Set ( parsed . filter (( item ) => typeof item === 'string' )));
}
}
2026-01-06 21:31:04 +02:00
const storedProjects = safeStorage . getItem ( PROJECT_COLLAPSE_STORAGE_KEY );
if ( storedProjects ) {
const parsed = JSON . parse ( storedProjects );
if ( Array . isArray ( parsed )) {
setCollapsedProjects ( new Set ( parsed . filter (( item ) => typeof item === 'string' )));
}
}
2025-12-07 19:32:53 +02:00
} catch { /* ignored */ }
}, [ safeStorage ]);
2026-02-16 14:15:19 +02:00
React . useEffect (() => {
const existingSessionIds = new Set ( sessions . map (( session ) => session . id ));
setPinnedSessionIds (( prev ) => {
let changed = false ;
const next = new Set < string >();
prev . forEach (( id ) => {
if ( existingSessionIds . has ( id )) {
next . add ( id );
} else {
changed = true ;
}
});
return changed ? next : prev ;
});
2025-12-07 19:32:53 +02:00
}, [ sessions ]);
2026-02-16 14:15:19 +02:00
React . useEffect (() => {
try {
safeStorage . setItem ( SESSION_PINNED_STORAGE_KEY , JSON . stringify ( Array . from ( pinnedSessionIds )));
} catch {
// ignored
}
}, [ pinnedSessionIds , safeStorage ]);
const togglePinnedSession = React . useCallback (( sessionId : string ) => {
setPinnedSessionIds (( prev ) => {
const next = new Set ( prev );
if ( next . has ( sessionId )) {
next . delete ( sessionId );
} else {
next . add ( sessionId );
}
return next ;
});
}, []);
const sortedSessions = React . useMemo (() => {
2026-02-24 03:28:30 +02:00
return [... sessions ]. sort (( a , b ) => compareSessionsByPinnedAndTime ( a , b , pinnedSessionIds ));
}, [ sessions , pinnedSessionIds ]);
2026-02-16 14:15:19 +02:00
2026-02-25 14:32:23 +02:00
React . useEffect (() => {
directoryStatusRef . current = directoryStatus ;
}, [ directoryStatus ]);
const sessionPrefetchTimersRef = React . useRef < Map < string , number >>( new Map ());
const sessionPrefetchQueueRef = React . useRef < string [] >([]);
const sessionPrefetchInFlightRef = React . useRef < Set < string >>( new Set ());
const pumpSessionPrefetchQueue = React . useCallback (() => {
if ( typeof window === 'undefined' ) {
return ;
}
while ( sessionPrefetchInFlightRef . current . size < SESSION_PREFETCH_CONCURRENCY && sessionPrefetchQueueRef . current . length > 0 ) {
const nextSessionId = sessionPrefetchQueueRef . current . shift ();
if ( ! nextSessionId ) {
break ;
}
const state = useSessionStore . getState ();
if ( state . currentSessionId === nextSessionId ) {
continue ;
}
const hasMessages = state . messages . has ( nextSessionId );
const memory = state . sessionMemoryState . get ( nextSessionId );
const isHydrated = hasMessages && memory ? . historyComplete !== undefined ;
if ( isHydrated ) {
continue ;
}
sessionPrefetchInFlightRef . current . add ( nextSessionId );
void loadMessages ( nextSessionId )
. catch (() => {
return ;
})
. finally (() => {
sessionPrefetchInFlightRef . current . delete ( nextSessionId );
pumpSessionPrefetchQueue ();
});
}
}, [ loadMessages ]);
const scheduleSessionPrefetch = React . useCallback (( sessionId : string | null | undefined ) => {
if ( ! sessionId || sessionId === currentSessionId || typeof window === 'undefined' ) {
return ;
}
const state = useSessionStore . getState ();
const hasMessages = state . messages . has ( sessionId );
const memory = state . sessionMemoryState . get ( sessionId );
const isHydrated = hasMessages && memory ? . historyComplete !== undefined ;
if ( isHydrated ) {
return ;
}
if ( sessionPrefetchInFlightRef . current . has ( sessionId )) {
return ;
}
if ( sessionPrefetchQueueRef . current . includes ( sessionId )) {
return ;
}
if ( sessionPrefetchQueueRef . current . length >= SESSION_PREFETCH_PENDING_LIMIT ) {
sessionPrefetchQueueRef . current . shift ();
}
const existingTimer = sessionPrefetchTimersRef . current . get ( sessionId );
if ( existingTimer !== undefined ) {
window . clearTimeout ( existingTimer );
}
const timer = window . setTimeout (() => {
sessionPrefetchTimersRef . current . delete ( sessionId );
sessionPrefetchQueueRef . current . push ( sessionId );
pumpSessionPrefetchQueue ();
}, SESSION_PREFETCH_HOVER_DELAY_MS );
sessionPrefetchTimersRef . current . set ( sessionId , timer );
}, [ currentSessionId , pumpSessionPrefetchQueue ]);
React . useEffect (() => {
if ( ! currentSessionId || sortedSessions . length === 0 ) {
return ;
}
const currentIndex = sortedSessions . findIndex (( session ) => session . id === currentSessionId );
if ( currentIndex < 0 ) {
return ;
}
scheduleSessionPrefetch ( sortedSessions [ currentIndex - 1 ] ? . id );
scheduleSessionPrefetch ( sortedSessions [ currentIndex + 1 ] ? . id );
}, [ currentSessionId , scheduleSessionPrefetch , sortedSessions ]);
2025-12-07 19:32:53 +02:00
React . useEffect (() => {
let cancelled = false ;
2026-01-06 21:31:04 +02:00
const normalizedProjects = projects
. map (( project ) => ({ id : project.id , path : normalizePath ( project . path ) }))
. filter (( project ) : project is { id : string ; path : string } => Boolean ( project . path ));
setProjectRepoStatus ( new Map ());
if ( normalizedProjects . length === 0 ) {
return () => {
cancelled = true ;
};
}
normalizedProjects . forEach (( project ) => {
checkIsGitRepository ( project . path )
. then (( result ) => {
if ( ! cancelled ) {
setProjectRepoStatus (( prev ) => {
const next = new Map ( prev );
next . set ( project . id , result );
return next ;
});
}
})
. catch (() => {
if ( ! cancelled ) {
setProjectRepoStatus (( prev ) => {
const next = new Map ( prev );
next . set ( project . id , null );
return next ;
});
}
});
});
2025-12-07 19:32:53 +02:00
return () => {
cancelled = true ;
};
2026-01-06 21:31:04 +02:00
}, [ projects ]);
2025-12-07 19:32:53 +02:00
const childrenMap = React . useMemo (() => {
const map = new Map < string , Session [] >();
sortedSessions . forEach (( session ) => {
const parentID = ( session as Session & { parentID? : string | null }). parentID ;
if ( ! parentID ) {
return ;
}
const collection = map . get ( parentID ) ?? [];
collection . push ( session );
map . set ( parentID , collection );
});
2026-02-24 03:28:30 +02:00
map . forEach (( list ) => list . sort (( a , b ) => compareSessionsByPinnedAndTime ( a , b , pinnedSessionIds )));
2025-12-07 19:32:53 +02:00
return map ;
2026-02-24 03:28:30 +02:00
}, [ sortedSessions , pinnedSessionIds ]);
2025-12-07 19:32:53 +02:00
React . useEffect (() => {
const directories = new Set < string >();
sortedSessions . forEach (( session ) => {
const dir = normalizePath (( session as Session & { directory? : string | null }). directory ?? null );
if ( dir ) {
directories . add ( dir );
}
});
2026-01-06 21:31:04 +02:00
projects . forEach (( project ) => {
const normalized = normalizePath ( project . path );
if ( normalized ) {
directories . add ( normalized );
}
});
2025-12-07 19:32:53 +02:00
directories . forEach (( directory ) => {
2026-02-25 14:32:23 +02:00
const known = directoryStatusRef . current . get ( directory );
2025-12-07 19:32:53 +02:00
if (( known && known !== 'unknown' ) || checkingDirectories . current . has ( directory )) {
return ;
}
checkingDirectories . current . add ( directory );
opencodeClient
. listLocalDirectory ( directory )
. then (() => {
setDirectoryStatus (( prev ) => {
const next = new Map ( prev );
if ( next . get ( directory ) === 'exists' ) {
return prev ;
}
next . set ( directory , 'exists' );
return next ;
});
})
2026-01-27 19:59:48 +02:00
. catch ( async () => {
// SDK worktrees can be outside UI runtime FS permissions.
// Probe via OpenCode API instead of local FS.
const looksLikeSdkWorktree =
directory . includes ( '/opencode/worktree/' ) ||
directory . includes ( '/.opencode/data/worktree/' ) ||
directory . includes ( '/.local/share/opencode/worktree/' );
if ( looksLikeSdkWorktree ) {
const ok = await opencodeClient . probeDirectory ( directory ). catch (() => false );
if ( ok ) {
setDirectoryStatus (( prev ) => {
const next = new Map ( prev );
if ( next . get ( directory ) === 'exists' ) {
return prev ;
}
next . set ( directory , 'exists' );
return next ;
});
return ;
}
}
2025-12-07 19:32:53 +02:00
setDirectoryStatus (( prev ) => {
const next = new Map ( prev );
if ( next . get ( directory ) === 'missing' ) {
return prev ;
}
next . set ( directory , 'missing' );
return next ;
});
})
. finally (() => {
checkingDirectories . current . delete ( directory );
});
});
2026-02-25 14:32:23 +02:00
}, [ sortedSessions , projects ]);
2025-12-07 19:32:53 +02:00
React . useEffect (() => {
2026-02-25 14:32:23 +02:00
const prefetchTimers = sessionPrefetchTimersRef . current ;
2025-12-07 19:32:53 +02:00
return () => {
if ( copyTimeout . current ) {
clearTimeout ( copyTimeout . current );
}
2026-02-25 14:32:23 +02:00
prefetchTimers . forEach (( timer ) => {
clearTimeout ( timer );
});
prefetchTimers . clear ();
sessionPrefetchQueueRef . current = [];
2025-12-07 19:32:53 +02:00
};
}, []);
const emptyState = (
< div className = "py-6 text-center text-muted-foreground" >
< p className = "typography-ui-label font-semibold" > No sessions yet </ p >
< p className = "typography-meta mt-1" > Create your first session to start coding .</ p >
</ div >
);
const handleSessionSelect = React . useCallback (
2026-01-06 21:31:04 +02:00
( sessionId : string , sessionDirectory? : string | null , disabled? : boolean , projectId? : string | null ) => {
2025-12-07 19:32:53 +02:00
if ( disabled ) {
return ;
}
2025-12-21 20:18:51 +02:00
2026-03-02 23:08:11 +00:00
const resetSessionSearch = () => {
if ( ! isSessionSearchOpen && sessionSearchQuery . length === 0 ) {
return ;
}
setSessionSearchQuery ( '' );
setIsSessionSearchOpen ( false );
};
2026-01-06 21:31:04 +02:00
if ( projectId && projectId !== activeProjectId ) {
// Important: avoid switching to the project root first (that can select the wrong session).
setActiveProjectIdOnly ( projectId );
}
if ( sessionDirectory && sessionDirectory !== currentDirectory ) {
setDirectory ( sessionDirectory , { showOverlay : false });
}
2025-12-21 20:18:51 +02:00
if ( mobileVariant ) {
setActiveMainTab ( 'chat' );
setSessionSwitcherOpen ( false );
}
2026-02-11 19:28:22 +02:00
// Always return early if same session is selected to avoid unnecessary store operations
if ( sessionId === currentSessionId ) {
2026-03-02 02:31:09 +02:00
if ( allowReselect ) {
2026-02-11 19:28:22 +02:00
onSessionSelected ? .( sessionId );
}
2026-03-02 23:08:11 +00:00
resetSessionSearch ();
2025-12-13 16:34:17 +02:00
return ;
}
2025-12-07 19:32:53 +02:00
setCurrentSession ( sessionId );
2025-12-13 16:34:17 +02:00
onSessionSelected ? .( sessionId );
2026-03-02 23:08:11 +00:00
resetSessionSearch ();
2025-12-07 19:32:53 +02:00
},
2025-12-21 20:18:51 +02:00
[
2026-01-06 21:31:04 +02:00
activeProjectId ,
2025-12-21 20:18:51 +02:00
allowReselect ,
2026-01-06 21:31:04 +02:00
currentDirectory ,
2025-12-21 20:18:51 +02:00
currentSessionId ,
2026-03-02 23:08:11 +00:00
isSessionSearchOpen ,
2025-12-21 20:18:51 +02:00
mobileVariant ,
onSessionSelected ,
2026-03-02 23:08:11 +00:00
sessionSearchQuery ,
2025-12-21 20:18:51 +02:00
setActiveMainTab ,
2026-01-06 21:31:04 +02:00
setActiveProjectIdOnly ,
2025-12-21 20:18:51 +02:00
setCurrentSession ,
2026-01-06 21:31:04 +02:00
setDirectory ,
2026-03-02 23:08:11 +00:00
setIsSessionSearchOpen ,
setSessionSearchQuery ,
2025-12-21 20:18:51 +02:00
setSessionSwitcherOpen ,
],
2025-12-07 19:32:53 +02:00
);
2026-02-11 10:08:15 -08:00
const handleSessionDoubleClick = React . useCallback (() => {
// On double-click/tap, switch to the Chat tab
setActiveMainTab ( 'chat' );
}, [ setActiveMainTab ]);
2025-12-07 19:32:53 +02:00
const handleSaveEdit = React . useCallback ( async () => {
if ( editingId && editTitle . trim ()) {
await updateSessionTitle ( editingId , editTitle . trim ());
setEditingId ( null );
setEditTitle ( '' );
}
}, [ editingId , editTitle , updateSessionTitle ]);
const handleCancelEdit = React . useCallback (() => {
setEditingId ( null );
setEditTitle ( '' );
}, []);
2026-02-06 02:07:50 +02:00
const handleSaveProjectEdit = React . useCallback (() => {
if ( editingProjectId && editProjectTitle . trim ()) {
renameProject ( editingProjectId , editProjectTitle . trim ());
setEditingProjectId ( null );
setEditProjectTitle ( '' );
}
}, [ editingProjectId , editProjectTitle , renameProject ]);
const handleCancelProjectEdit = React . useCallback (() => {
setEditingProjectId ( null );
setEditProjectTitle ( '' );
}, []);
2025-12-07 19:32:53 +02:00
const handleShareSession = React . useCallback (
async ( session : Session ) => {
const result = await shareSession ( session . id );
if ( result && result . share ? . url ) {
toast . success ( 'Session shared' , {
description : 'You can copy the link from the menu.' ,
});
} else {
toast . error ( 'Unable to share session' );
}
},
[ shareSession ],
);
const handleCopyShareUrl = React . useCallback (( url : string , sessionId : string ) => {
2026-02-20 14:50:26 +02:00
void copyTextToClipboard ( url )
. then (( result ) => {
if ( ! result . ok ) {
toast . error ( 'Failed to copy URL' );
return ;
}
2025-12-07 19:32:53 +02:00
setCopiedSessionId ( sessionId );
if ( copyTimeout . current ) {
clearTimeout ( copyTimeout . current );
}
copyTimeout . current = window . setTimeout (() => {
setCopiedSessionId ( null );
copyTimeout . current = null ;
}, 2000 );
})
. catch (() => {
toast . error ( 'Failed to copy URL' );
});
}, []);
const handleUnshareSession = React . useCallback (
async ( sessionId : string ) => {
const result = await unshareSession ( sessionId );
if ( result ) {
toast . success ( 'Session unshared' );
} else {
toast . error ( 'Unable to unshare session' );
}
},
[ unshareSession ],
);
const collectDescendants = React . useCallback (
( sessionId : string ) : Session [] => {
const collected : Session [] = [];
const visit = ( id : string ) => {
const children = childrenMap . get ( id ) ?? [];
children . forEach (( child ) => {
collected . push ( child );
visit ( child . id );
});
};
visit ( sessionId );
return collected ;
},
[ childrenMap ],
);
const deleteSession = useSessionStore (( state ) => state . deleteSession );
const deleteSessions = useSessionStore (( state ) => state . deleteSessions );
2026-02-24 03:28:30 +02:00
const executeDeleteSession = React . useCallback (
async ( session : Session ) => {
const descendants = collectDescendants ( session . id );
if ( descendants . length === 0 ) {
const success = await deleteSession ( session . id );
if ( success ) {
toast . success ( 'Session deleted' );
} else {
toast . error ( 'Failed to delete session' );
}
return ;
}
const ids = [ session . id , ... descendants . map (( s ) => s . id )];
const { deletedIds , failedIds } = await deleteSessions ( ids );
if ( deletedIds . length > 0 ) {
toast . success ( `Deleted ${ deletedIds . length } session ${ deletedIds . length === 1 ? '' : 's' } ` );
}
if ( failedIds . length > 0 ) {
toast . error ( `Failed to delete ${ failedIds . length } session ${ failedIds . length === 1 ? '' : 's' } ` );
}
},
[ collectDescendants , deleteSession , deleteSessions ],
);
2025-12-07 19:32:53 +02:00
const handleDeleteSession = React . useCallback (
2026-02-23 03:56:38 +07:00
( session : Session ) => {
2025-12-07 19:32:53 +02:00
const descendants = collectDescendants ( session . id );
2026-02-24 03:28:30 +02:00
if ( ! showDeletionDialog ) {
void executeDeleteSession ( session );
return ;
}
2026-02-23 03:56:38 +07:00
setDeleteSessionConfirm ({ session , descendantCount : descendants.length });
},
2026-02-24 03:28:30 +02:00
[ collectDescendants , showDeletionDialog , executeDeleteSession ],
2026-02-23 03:56:38 +07:00
);
2026-01-07 23:37:32 +02:00
2026-02-23 03:56:38 +07:00
const confirmDeleteSession = React . useCallback ( async () => {
if ( ! deleteSessionConfirm ) return ;
const { session } = deleteSessionConfirm ;
setDeleteSessionConfirm ( null );
2026-02-24 03:28:30 +02:00
await executeDeleteSession ( session );
}, [ deleteSessionConfirm , executeDeleteSession ]);
2026-02-23 03:56:38 +07:00
const confirmDeleteFolder = React . useCallback (() => {
if ( ! deleteFolderConfirm ) return ;
const { scopeKey , folderId } = deleteFolderConfirm ;
setDeleteFolderConfirm ( null );
deleteFolder ( scopeKey , folderId );
}, [ deleteFolderConfirm , deleteFolder ]);
2025-12-07 19:32:53 +02:00
const handleOpenDirectoryDialog = React . useCallback (() => {
2026-02-11 20:07:44 +02:00
if ( ! tauriIpcAvailable || ! isDesktopLocalOriginActive ()) {
2025-12-07 19:32:53 +02:00
sessionEvents . requestDirectoryDialog ();
2026-02-05 01:59:49 +02:00
return ;
2025-12-07 19:32:53 +02:00
}
2026-02-05 01:59:49 +02:00
import ( '@/lib/desktop' )
. then (({ requestDirectoryAccess }) => requestDirectoryAccess ( '' ))
. then (( result ) => {
if ( result . success && result . path ) {
const added = addProject ( result . path , { id : result.projectId });
if ( ! added ) {
toast . error ( 'Failed to add project' , {
description : 'Please select a valid directory.' ,
});
}
} else if ( result . error && result . error !== 'Directory selection cancelled' ) {
toast . error ( 'Failed to select directory' , {
description : result.error ,
});
}
})
. catch (( error ) => {
console . error ( 'Desktop: Error selecting directory:' , error );
toast . error ( 'Failed to select directory' );
});
}, [ addProject , tauriIpcAvailable ]);
2026-01-06 21:31:04 +02:00
2025-12-07 19:32:53 +02:00
const toggleParent = React . useCallback (( sessionId : string ) => {
setExpandedParents (( prev ) => {
const next = new Set ( prev );
if ( next . has ( sessionId )) {
next . delete ( sessionId );
} else {
next . add ( sessionId );
}
try {
safeStorage . setItem ( SESSION_EXPANDED_STORAGE_KEY , JSON . stringify ( Array . from ( next )));
} catch { /* ignored */ }
return next ;
});
}, [ safeStorage ]);
2026-02-24 03:28:30 +02:00
const createFolderAndStartRename = React . useCallback (
( scopeKey : string , parentId? : string | null ) => {
if ( ! scopeKey ) {
return null ;
}
if ( parentId && collapsedFolderIds . has ( parentId )) {
toggleFolderCollapse ( parentId );
}
const newFolder = createFolder ( scopeKey , 'New folder' , parentId );
setRenamingFolderId ( newFolder . id );
setRenameFolderDraft ( newFolder . name );
return newFolder ;
},
[ collapsedFolderIds , toggleFolderCollapse , createFolder ],
);
2025-12-07 19:32:53 +02:00
const buildNode = React . useCallback (
( session : Session ) : SessionNode => {
const children = childrenMap . get ( session . id ) ?? [];
return {
session ,
children : children.map (( child ) => buildNode ( child )),
2026-01-08 00:06:02 +02:00
worktree : worktreeMetadata.get ( session . id ) ?? null ,
2025-12-07 19:32:53 +02:00
};
},
2026-01-08 00:06:02 +02:00
[ childrenMap , worktreeMetadata ],
2025-12-07 19:32:53 +02:00
);
2026-01-06 21:31:04 +02:00
const buildGroupedSessions = React . useCallback (
2026-02-07 03:57:46 +02:00
(
projectSessions : Session [],
projectRoot : string | null ,
availableWorktrees : WorktreeMetadata [],
projectRootBranch : string | null ,
projectIsRepo : boolean ,
) => {
2026-01-06 21:31:04 +02:00
const normalizedProjectRoot = normalizePath ( projectRoot ?? null );
2026-02-24 03:28:30 +02:00
const sortedProjectSessions = [... projectSessions ]. sort (( a , b ) => compareSessionsByPinnedAndTime ( a , b , pinnedSessionIds ));
2025-12-07 19:32:53 +02:00
2026-01-06 21:31:04 +02:00
const sessionMap = new Map ( sortedProjectSessions . map (( session ) => [ session . id , session ]));
const childrenMap = new Map < string , Session [] >();
sortedProjectSessions . forEach (( session ) => {
const parentID = ( session as Session & { parentID? : string | null }). parentID ;
if ( ! parentID ) {
return ;
}
const collection = childrenMap . get ( parentID ) ?? [];
collection . push ( session );
childrenMap . set ( parentID , collection );
});
2026-02-24 03:28:30 +02:00
childrenMap . forEach (( list ) => list . sort (( a , b ) => compareSessionsByPinnedAndTime ( a , b , pinnedSessionIds )));
2026-01-06 21:31:04 +02:00
2026-01-08 00:06:02 +02:00
// Build worktree lookup map
2026-01-06 21:31:04 +02:00
const worktreeByPath = new Map < string , WorktreeMetadata >();
availableWorktrees . forEach (( meta ) => {
if ( meta . path ) {
const normalized = normalizePath ( meta . path ) ?? meta . path ;
worktreeByPath . set ( normalized , meta );
}
});
2025-12-07 19:32:53 +02:00
2026-01-08 00:06:02 +02:00
// Helper to get worktree metadata for a session
const getSessionWorktree = ( session : Session ) : WorktreeMetadata | null => {
2026-01-06 21:31:04 +02:00
const sessionDirectory = normalizePath (( session as Session & { directory? : string | null }). directory ?? null );
const sessionWorktreeMeta = worktreeMetadata . get ( session . id ) ?? null ;
2026-01-08 00:06:02 +02:00
if ( sessionWorktreeMeta ) return sessionWorktreeMeta ;
if ( sessionDirectory ) {
const worktree = worktreeByPath . get ( sessionDirectory ) ?? null ;
// Only count as worktree if it's not the main project root
if ( worktree && sessionDirectory !== normalizedProjectRoot ) {
return worktree ;
}
2026-01-06 21:31:04 +02:00
}
2026-01-08 00:06:02 +02:00
return null ;
};
const buildProjectNode = ( session : Session ) : SessionNode => {
const children = childrenMap . get ( session . id ) ?? [];
return {
session ,
children : children.map (( child ) => buildProjectNode ( child )),
worktree : getSessionWorktree ( session ),
};
2026-01-06 21:31:04 +02:00
};
2026-01-08 00:06:02 +02:00
// Find root sessions (no parent or parent not in current project)
2026-01-06 21:31:04 +02:00
const roots = sortedProjectSessions . filter (( session ) => {
const parentID = ( session as Session & { parentID? : string | null }). parentID ;
if ( ! parentID ) {
return true ;
}
return ! sessionMap . has ( parentID );
});
2026-02-07 03:57:46 +02:00
const groupedNodes = new Map < string , SessionNode [] >();
const groupOrder = new Map < string , number >();
const getGroupKey = ( session : Session ) => {
const metadataPath = normalizePath ( worktreeMetadata . get ( session . id ) ? . path ?? null );
const sessionDirectory = normalizePath (( session as Session & { directory? : string | null }). directory ?? null );
const normalizedDir = metadataPath ?? sessionDirectory ;
if ( normalizedDir && normalizedDir !== normalizedProjectRoot && worktreeByPath . has ( normalizedDir )) {
return normalizedDir ;
}
return normalizedProjectRoot ?? '__project_root__' ;
};
2026-01-08 00:06:02 +02:00
2026-02-07 03:57:46 +02:00
roots . forEach (( session , index ) => {
const node = buildProjectNode ( session );
const groupKey = getGroupKey ( session );
if ( ! groupedNodes . has ( groupKey )) {
groupedNodes . set ( groupKey , []);
groupOrder . set ( groupKey , index );
}
groupedNodes . get ( groupKey ) ? . push ( node );
});
const rootKey = normalizedProjectRoot ?? '__project_root__' ;
const groups : SessionGroup [] = [{
id : 'root' ,
label : ( projectIsRepo && projectRootBranch && projectRootBranch !== 'HEAD' )
? `project root: ${ projectRootBranch } `
: 'project root' ,
2026-02-08 16:51:05 -08:00
branch : projectRootBranch ?? null ,
2026-01-08 00:06:02 +02:00
description : normalizedProjectRoot ? formatPathForDisplay ( normalizedProjectRoot , homeDirectory ) : null ,
isMain : true ,
worktree : null ,
directory : normalizedProjectRoot ,
2026-02-07 03:57:46 +02:00
sessions : groupedNodes.get ( rootKey ) ?? [],
2026-01-08 00:06:02 +02:00
}];
2026-02-07 03:57:46 +02:00
const sortedWorktrees = [... availableWorktrees ]. sort (( a , b ) => {
const aLabel = ( a . label || a . branch || a . name || a . path || '' ). toLowerCase ();
const bLabel = ( b . label || b . branch || b . name || b . path || '' ). toLowerCase ();
return aLabel . localeCompare ( bLabel );
});
sortedWorktrees . forEach (( meta ) => {
const directory = normalizePath ( meta . path ) ?? meta . path ;
2026-02-17 18:25:03 +02:00
const currentBranch = gitDirectories . get ( directory ) ? . status ? . current ? . trim () || null ;
const metadataBranch = meta . branch ? . trim () || null ;
const shouldSyncLabelWithBranch = Boolean (
currentBranch
&& metadataBranch
&& meta . label
&& normalizeForBranchComparison ( meta . label ) === normalizeForBranchComparison ( metadataBranch ),
);
const label = shouldSyncLabelWithBranch
? currentBranch !
: ( meta . label || meta . name || formatDirectoryName ( directory , homeDirectory ) || directory );
2026-02-07 03:57:46 +02:00
groups . push ({
id : `worktree: ${ directory } ` ,
label ,
2026-02-17 18:25:03 +02:00
branch : currentBranch || metadataBranch ,
2026-02-07 03:57:46 +02:00
description : formatPathForDisplay ( directory , homeDirectory ),
isMain : false ,
worktree : meta ,
directory ,
sessions : groupedNodes.get ( directory ) ?? [],
});
});
const represented = new Set ( groups . map (( group ) => group . directory ). filter (( value ) : value is string => Boolean ( value )));
const orphanKeys = Array . from ( groupedNodes . keys ())
. filter (( key ) => ! represented . has ( key ) && key !== rootKey )
. sort (( a , b ) => ( groupOrder . get ( a ) ?? 0 ) - ( groupOrder . get ( b ) ?? 0 ));
orphanKeys . forEach (( directory ) => {
2026-02-17 18:25:03 +02:00
const currentBranch = gitDirectories . get ( directory ) ? . status ? . current ? . trim () || null ;
2026-02-07 03:57:46 +02:00
groups . push ({
id : `worktree:orphan: ${ directory } ` ,
label : formatDirectoryName ( directory , homeDirectory ) || directory ,
2026-02-17 18:25:03 +02:00
branch : currentBranch ,
2026-02-07 03:57:46 +02:00
description : formatPathForDisplay ( directory , homeDirectory ),
isMain : false ,
worktree : null ,
directory ,
sessions : groupedNodes.get ( directory ) ?? [],
});
});
return groups ;
2026-01-06 21:31:04 +02:00
},
2026-02-24 03:28:30 +02:00
[ homeDirectory , worktreeMetadata , pinnedSessionIds , gitDirectories ]
2026-01-06 21:31:04 +02:00
);
2025-12-07 19:32:53 +02:00
const toggleGroupSessionLimit = React . useCallback (( groupId : string ) => {
setExpandedSessionGroups (( prev ) => {
const next = new Set ( prev );
if ( next . has ( groupId )) {
next . delete ( groupId );
} else {
next . add ( groupId );
}
return next ;
});
}, []);
2026-01-06 21:31:04 +02:00
const toggleProject = React . useCallback (( projectId : string ) => {
// Ignore intersection events for a short period after toggling
ignoreIntersectionUntil . current = Date . now () + 150 ;
setCollapsedProjects (( prev ) => {
const next = new Set ( prev );
if ( next . has ( projectId )) {
next . delete ( projectId );
} else {
next . add ( projectId );
}
try {
safeStorage . setItem ( PROJECT_COLLAPSE_STORAGE_KEY , JSON . stringify ( Array . from ( next )));
} catch { /* ignored */ }
2026-02-05 01:59:49 +02:00
// Persist collapse state to server settings (web + desktop local/remote).
if ( ! isVSCode ) {
scheduleCollapsedProjectsPersist ( next );
}
2026-01-06 21:31:04 +02:00
return next ;
});
2026-02-05 01:59:49 +02:00
}, [ isVSCode , safeStorage , scheduleCollapsedProjectsPersist ]);
2026-01-06 21:31:04 +02:00
2026-02-07 03:57:46 +02:00
React . useEffect (() => {
try {
const serialized = Object . fromEntries ( groupOrderByProject . entries ());
safeStorage . setItem ( GROUP_ORDER_STORAGE_KEY , JSON . stringify ( serialized ));
} catch {
// ignored
}
}, [ groupOrderByProject , safeStorage ]);
React . useEffect (() => {
try {
const serialized = Object . fromEntries ( activeSessionByProject . entries ());
safeStorage . setItem ( PROJECT_ACTIVE_SESSION_STORAGE_KEY , JSON . stringify ( serialized ));
} catch {
// ignored
}
}, [ activeSessionByProject , safeStorage ]);
React . useEffect (() => {
try {
safeStorage . setItem ( GROUP_COLLAPSE_STORAGE_KEY , JSON . stringify ( Array . from ( collapsedGroups )));
} catch {
// ignored
}
}, [ collapsedGroups , safeStorage ]);
2026-01-06 21:31:04 +02:00
const normalizedProjects = React . useMemo (() => {
return projects
. map (( project ) => ({
... project ,
normalizedPath : normalizePath ( project . path ),
}))
. filter (( project ) => Boolean ( project . normalizedPath )) as Array < {
id : string ;
path : string ;
label? : string ;
normalizedPath : string ;
} > ;
}, [ projects ]);
2026-02-08 16:51:05 -08:00
// Compute a dependency that changes when any project's git branch changes
const projectGitBranchesKey = React . useMemo (() => {
return normalizedProjects
. map (( project ) => {
const dirState = gitDirectories . get ( project . normalizedPath );
return ` ${ project . id } : ${ dirState ? . status ? . current ?? '' } ` ;
})
. join ( '|' );
}, [ normalizedProjects , gitDirectories ]);
2026-02-07 03:57:46 +02:00
React . useEffect (() => {
let cancelled = false ;
const run = async () => {
const entries = await Promise . all (
normalizedProjects . map ( async ( project ) => {
const branch = await getRootBranch ( project . normalizedPath ). catch (() => null );
return { id : project.id , branch };
}),
);
if ( cancelled ) {
return ;
}
setProjectRootBranches (( prev ) => {
const next = new Map ( prev );
entries . forEach (({ id , branch }) => {
if ( branch ) {
next . set ( id , branch );
}
});
return next ;
});
};
void run ();
return () => {
cancelled = true ;
};
2026-02-08 16:51:05 -08:00
}, [ normalizedProjects , projectGitBranchesKey ]);
2026-02-07 03:57:46 +02:00
2026-02-23 03:56:38 +07:00
// Session Folders: cleanup stale session IDs when sessions are removed.
// Guard: skip cleanup while sessions are still loading to avoid wiping folder
// assignments before the server has returned its full session list.
const isSessionsLoading = useSessionStore (( state ) => state . isLoading );
2026-02-21 06:09:55 +07:00
React . useEffect (() => {
2026-02-23 03:56:38 +07:00
if ( isSessionsLoading ) return ;
2026-02-21 06:09:55 +07:00
const idsByScope = new Map < string , Set < string >>();
sessions . forEach (( session ) => {
const directory = normalizePath (( session as Session & { directory? : string | null }). directory ?? null );
if ( ! directory ) return ;
const existing = idsByScope . get ( directory );
if ( existing ) {
existing . add ( session . id );
return ;
}
idsByScope . set ( directory , new Set ([ session . id ]));
});
2026-02-23 03:56:38 +07:00
const currentFoldersMap = useSessionFoldersStore . getState (). foldersMap ;
const allScopeKeys = new Set ([... Object . keys ( currentFoldersMap ), ... idsByScope . keys ()]);
2026-02-21 06:09:55 +07:00
allScopeKeys . forEach (( scopeKey ) => {
cleanupSessions ( scopeKey , idsByScope . get ( scopeKey ) ?? new Set < string >());
});
2026-02-23 03:56:38 +07:00
}, [ sessions , isSessionsLoading , cleanupSessions ]); // removed foldersMap from deps to prevent cascade re-renders
2026-02-21 06:09:55 +07:00
2026-01-06 21:31:04 +02:00
const getSessionsForProject = React . useCallback (
( project : { normalizedPath : string }) => {
2026-01-08 17:59:36 +02:00
// In VS Code, only show sessions from the main project directory (skip worktrees)
const worktreesForProject = isVSCode ? [] : ( availableWorktreesByProject . get ( project . normalizedPath ) ?? []);
2026-01-06 21:31:04 +02:00
const directories = [
project . normalizedPath ,
... worktreesForProject
. map (( meta ) => normalizePath ( meta . path ) ?? meta . path )
. filter (( value ) : value is string => Boolean ( value )),
];
const seen = new Set < string >();
const collected : Session [] = [];
directories . forEach (( directory ) => {
const sessionsForDirectory = sessionsByDirectory . get ( directory ) ?? getSessionsByDirectory ( directory );
sessionsForDirectory . forEach (( session ) => {
if ( seen . has ( session . id )) {
return ;
}
seen . add ( session . id );
collected . push ( session );
});
});
return collected ;
},
2026-01-08 17:59:36 +02:00
[ availableWorktreesByProject , getSessionsByDirectory , sessionsByDirectory , isVSCode ],
2026-01-06 21:31:04 +02:00
);
2026-02-16 14:15:19 +02:00
// Keep last-known repo status to avoid UI jiggling during project switch
const lastRepoStatusRef = React . useRef ( false );
if ( activeProjectId && projectRepoStatus . has ( activeProjectId )) {
lastRepoStatusRef . current = Boolean ( projectRepoStatus . get ( activeProjectId ));
}
2026-01-06 21:31:04 +02:00
const projectSections = React . useMemo (() => {
return normalizedProjects . map (( project ) => {
const projectSessions = getSessionsForProject ( project );
const worktreesForProject = availableWorktreesByProject . get ( project . normalizedPath ) ?? [];
2026-02-16 14:15:19 +02:00
const isRepo = projectRepoStatus . has ( project . id )
? Boolean ( projectRepoStatus . get ( project . id ))
: lastRepoStatusRef . current ;
2026-02-07 03:57:46 +02:00
const groups = buildGroupedSessions (
projectSessions ,
project . normalizedPath ,
worktreesForProject ,
projectRootBranches . get ( project . id ) ?? null ,
2026-02-16 14:15:19 +02:00
isRepo ,
2026-02-07 03:57:46 +02:00
);
2026-01-06 21:31:04 +02:00
return {
project ,
groups ,
};
});
2026-02-07 03:57:46 +02:00
}, [ normalizedProjects , getSessionsForProject , buildGroupedSessions , availableWorktreesByProject , projectRootBranches , projectRepoStatus ]);
const visibleProjectSections = React . useMemo (() => {
if ( projectSections . length === 0 ) {
return projectSections ;
}
const active = projectSections . find (( section ) => section . project . id === activeProjectId );
return active ? [ active ] : [ projectSections [ 0 ]];
}, [ projectSections , activeProjectId ]);
2026-03-02 23:08:11 +00:00
const groupSearchDataByGroup = React . useMemo (() => {
const result = new WeakMap < SessionGroup , GroupSearchData >();
if ( ! hasSessionSearchQuery ) {
return result ;
}
const countNodes = ( nodes : SessionNode []) : number => {
return nodes . reduce (( total , node ) => total + 1 + countNodes ( node . children ), 0 );
};
visibleProjectSections . forEach (( section ) => {
section . groups . forEach (( group ) => {
const filteredNodes = filterSessionNodesForSearch ( group . sessions , normalizedSessionSearchQuery );
const matchedSessionCount = countNodes ( filteredNodes );
const groupMatches = buildGroupSearchText ( group ). includes ( normalizedSessionSearchQuery );
const scopeKey = normalizePath ( group . directory ?? null );
const folderNameMatchCount = scopeKey
? getFoldersForScope ( scopeKey ). filter (( folder ) => folder . name . toLowerCase (). includes ( normalizedSessionSearchQuery )). length
: 0 ;
result . set ( group , {
filteredNodes ,
matchedSessionCount ,
folderNameMatchCount ,
groupMatches ,
hasMatch : groupMatches || matchedSessionCount > 0 || folderNameMatchCount > 0 ,
});
});
});
return result ;
}, [
hasSessionSearchQuery ,
visibleProjectSections ,
filterSessionNodesForSearch ,
normalizedSessionSearchQuery ,
buildGroupSearchText ,
getFoldersForScope ,
]);
const searchableProjectSections = React . useMemo (() => {
if ( ! hasSessionSearchQuery ) {
return visibleProjectSections ;
}
return visibleProjectSections
. map (( section ) => ({
... section ,
groups : section.groups.filter (( group ) => groupSearchDataByGroup . get ( group ) ? . hasMatch === true ),
}))
. filter (( section ) => section . groups . length > 0 );
}, [
hasSessionSearchQuery ,
visibleProjectSections ,
groupSearchDataByGroup ,
]);
const sectionsForRender = hasSessionSearchQuery ? searchableProjectSections : visibleProjectSections ;
const searchMatchCount = React . useMemo (() => {
if ( ! hasSessionSearchQuery ) {
return 0 ;
}
return sectionsForRender . reduce (( total , section ) => {
return total + section . groups . reduce (( groupTotal , group ) => {
const data = groupSearchDataByGroup . get ( group );
if ( ! data ) {
return groupTotal ;
}
const metadataMatches = data . folderNameMatchCount + ( data . groupMatches ? 1 : 0 );
return groupTotal + data . matchedSessionCount + metadataMatches ;
}, 0 );
}, 0 );
}, [
hasSessionSearchQuery ,
sectionsForRender ,
groupSearchDataByGroup ,
]);
const searchEmptyState = (
< div className = "py-6 text-center text-muted-foreground" >
< p className = "typography-ui-label font-semibold" > No matching sessions </ p >
< p className = "typography-meta mt-1" > Try a different title , branch , folder , or path .</ p >
</ div >
);
2026-02-07 03:57:46 +02:00
const activeProjectForHeader = React . useMemo (
() => normalizedProjects . find (( project ) => project . id === activeProjectId ) ?? normalizedProjects [ 0 ] ?? null ,
[ normalizedProjects , activeProjectId ],
);
2026-02-11 23:53:37 -08:00
const activeProjectRefForHeader = React . useMemo (
() => ( activeProjectForHeader
? {
id : activeProjectForHeader.id ,
path : activeProjectForHeader.normalizedPath ,
}
: null ),
[ activeProjectForHeader ],
);
2026-02-07 03:57:46 +02:00
const activeProjectIsRepo = React . useMemo (
() => ( activeProjectForHeader ? Boolean ( projectRepoStatus . get ( activeProjectForHeader . id )) : false ),
[ activeProjectForHeader , projectRepoStatus ],
);
2026-02-16 14:15:19 +02:00
// Only flip to false once the new project's status is actually resolved (present in map)
const stableActiveProjectIsRepo = activeProjectForHeader && projectRepoStatus . has ( activeProjectForHeader . id )
? activeProjectIsRepo
: lastRepoStatusRef.current ;
2026-02-11 23:53:37 -08:00
const reserveHeaderActionsSpace = Boolean ( activeProjectForHeader );
const useMobileNotesPanel = mobileVariant || deviceInfo . isMobile ;
React . useEffect (() => {
if ( ! activeProjectForHeader ) {
setProjectNotesPanelOpen ( false );
}
}, [ activeProjectForHeader ]);
2026-02-07 03:57:46 +02:00
const projectSessionMeta = React . useMemo (() => {
const metaByProject = new Map < string , Map < string , { directory : string | null }>>();
const firstSessionByProject = new Map < string , { id : string ; directory : string | null }>();
const visitNodes = (
projectId : string ,
projectRoot : string ,
fallbackDirectory : string | null ,
nodes : SessionNode [],
) => {
if ( ! metaByProject . has ( projectId )) {
metaByProject . set ( projectId , new Map ());
}
const projectMap = metaByProject . get ( projectId ) ! ;
nodes . forEach (( node ) => {
const sessionDirectory = normalizePath (
node . worktree ? . path
?? ( node . session as Session & { directory? : string | null }). directory
?? fallbackDirectory
?? projectRoot ,
);
projectMap . set ( node . session . id , { directory : sessionDirectory });
if ( ! firstSessionByProject . has ( projectId )) {
firstSessionByProject . set ( projectId , { id : node.session.id , directory : sessionDirectory });
}
if ( node . children . length > 0 ) {
visitNodes ( projectId , projectRoot , sessionDirectory , node . children );
}
});
};
projectSections . forEach (( section ) => {
section . groups . forEach (( group ) => {
visitNodes ( section . project . id , section . project . normalizedPath , group . directory , group . sessions );
});
});
return { metaByProject , firstSessionByProject };
}, [ projectSections ]);
const previousActiveProjectRef = React . useRef < string | null >( null );
2026-02-11 22:11:53 +02:00
const lastSeenActiveProjectRef = React . useRef < string | null >( null );
2026-02-07 03:57:46 +02:00
React . useLayoutEffect (() => {
2026-02-11 22:11:53 +02:00
if ( ! activeProjectId ) {
return ;
}
const previousSeenProjectId = lastSeenActiveProjectRef . current ;
const isProjectSwitch = Boolean ( previousSeenProjectId && previousSeenProjectId !== activeProjectId );
// Always record the active project so we can detect real project switches even if we early-return.
lastSeenActiveProjectRef . current = activeProjectId ;
2026-02-11 19:28:22 +02:00
// While a new session draft is open, keep the sidebar from auto-selecting remembered/fallback sessions.
2026-02-11 22:11:53 +02:00
// Exception (web/desktop only): when the user switches projects, prefer the last selected session
// for the target project instead of carrying the draft across.
// In VS Code, keep existing behavior (sidebar frequently mounts/unmounts and draft should stay put).
if ( newSessionDraftOpen && ( isVSCode || ! isProjectSwitch )) {
2026-02-11 19:28:22 +02:00
return ;
}
2026-02-11 22:11:53 +02:00
if ( previousActiveProjectRef . current === activeProjectId ) {
2026-02-07 03:57:46 +02:00
return ;
}
const section = projectSections . find (( item ) => item . project . id === activeProjectId );
if ( ! section ) {
return ;
}
previousActiveProjectRef . current = activeProjectId ;
const projectMap = projectSessionMeta . metaByProject . get ( activeProjectId );
2026-02-11 19:28:22 +02:00
// If we already have an active session that belongs to this project (eg user just selected it,
// or sidebar remounted after "back"), do NOT override it with remembered/fallback session.
if ( currentSessionId && projectMap && projectMap . has ( currentSessionId )) {
setActiveSessionByProject (( prev ) => {
if ( prev . get ( activeProjectId ) === currentSessionId ) {
return prev ;
}
const next = new Map ( prev );
next . set ( activeProjectId , currentSessionId );
return next ;
});
return ;
}
2026-02-07 03:57:46 +02:00
if ( ! projectMap || projectMap . size === 0 ) {
setActiveMainTab ( 'chat' );
if ( mobileVariant ) {
setSessionSwitcherOpen ( false );
}
openNewSessionDraft ({ directoryOverride : section.project.normalizedPath });
return ;
}
const rememberedSessionId = activeSessionByProject . get ( activeProjectId );
const remembered = rememberedSessionId && projectMap . has ( rememberedSessionId )
? rememberedSessionId
: null ;
const fallback = projectSessionMeta . firstSessionByProject . get ( activeProjectId ) ? . id ?? null ;
const targetSessionId = remembered ?? fallback ;
if ( ! targetSessionId || targetSessionId === currentSessionId ) {
return ;
}
const targetDirectory = projectMap . get ( targetSessionId ) ? . directory ?? null ;
handleSessionSelect ( targetSessionId , targetDirectory , false , activeProjectId );
}, [
activeProjectId ,
activeSessionByProject ,
currentSessionId ,
handleSessionSelect ,
2026-02-11 22:11:53 +02:00
isVSCode ,
2026-02-11 19:28:22 +02:00
newSessionDraftOpen ,
2026-02-07 03:57:46 +02:00
mobileVariant ,
openNewSessionDraft ,
projectSections ,
projectSessionMeta ,
setActiveMainTab ,
setSessionSwitcherOpen ,
]);
React . useEffect (() => {
if ( ! activeProjectId || ! currentSessionId ) {
return ;
}
const projectMap = projectSessionMeta . metaByProject . get ( activeProjectId );
if ( ! projectMap || ! projectMap . has ( currentSessionId )) {
return ;
}
setActiveSessionByProject (( prev ) => {
if ( prev . get ( activeProjectId ) === currentSessionId ) {
return prev ;
}
const next = new Map ( prev );
next . set ( activeProjectId , currentSessionId );
return next ;
});
}, [ activeProjectId , currentSessionId , projectSessionMeta ]);
const currentSessionDirectory = React . useMemo (() => {
if ( ! currentSessionId ) {
return null ;
}
const metadataPath = worktreeMetadata . get ( currentSessionId ) ? . path ;
if ( metadataPath ) {
return normalizePath ( metadataPath ) ?? metadataPath ;
}
const activeSession = sessions . find (( session ) => session . id === currentSessionId );
if ( ! activeSession ) {
return null ;
}
return normalizePath (( activeSession as Session & { directory? : string | null }). directory ?? null );
}, [ currentSessionId , sessions , worktreeMetadata ]);
const getOrderedGroups = React . useCallback (
( projectId : string , groups : SessionGroup []) => {
const preferredOrder = groupOrderByProject . get ( projectId );
if ( ! preferredOrder || preferredOrder . length === 0 ) {
return groups ;
}
const groupById = new Map ( groups . map (( group ) => [ group . id , group ]));
const ordered : SessionGroup [] = [];
preferredOrder . forEach (( id ) => {
const group = groupById . get ( id );
if ( group ) {
ordered . push ( group );
groupById . delete ( id );
}
});
groups . forEach (( group ) => {
if ( groupById . has ( group . id )) {
ordered . push ( group );
}
});
return ordered ;
},
[ groupOrderByProject ],
);
const handleStartInlineProjectRename = React . useCallback (() => {
if ( ! activeProjectForHeader ) {
return ;
}
setProjectRenameDraft ( formatProjectLabel (
activeProjectForHeader . label ? . trim ()
|| formatDirectoryName ( activeProjectForHeader . normalizedPath , homeDirectory )
|| activeProjectForHeader . normalizedPath ,
));
setIsProjectRenameInline ( true );
}, [ activeProjectForHeader , homeDirectory ]);
const handleSaveInlineProjectRename = React . useCallback (() => {
if ( ! activeProjectForHeader ) {
return ;
}
const trimmed = projectRenameDraft . trim ();
if ( ! trimmed ) {
return ;
}
renameProject ( activeProjectForHeader . id , trimmed );
setIsProjectRenameInline ( false );
}, [ activeProjectForHeader , projectRenameDraft , renameProject ]);
2026-02-25 14:32:23 +02:00
const desktopHeaderActionButtonClass =
'inline-flex h-6 w-6 items-center justify-center rounded-md leading-none text-foreground hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50' ;
const mobileHeaderActionButtonClass =
2026-02-17 19:14:04 +02:00
'inline-flex h-6 w-6 items-center justify-center rounded-md leading-none text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50' ;
2026-02-25 14:32:23 +02:00
const headerActionButtonClass = mobileVariant ? mobileHeaderActionButtonClass : desktopHeaderActionButtonClass ;
const headerActionIconClass = 'h-4.5 w-4.5' ;
const addProjectButtonClass = cn (
'inline-flex items-center justify-center rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50' ,
mobileVariant
? 'h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50'
: 'h-8 w-8 text-foreground hover:bg-interactive-hover' ,
! isDesktopShellRuntime && 'bg-transparent hover:bg-sidebar/40' ,
);
2026-01-06 21:31:04 +02:00
// Track when project sticky headers become "stuck"
React . useEffect (() => {
2026-02-05 01:59:49 +02:00
if ( ! isDesktopShellRuntime ) return ;
2026-01-06 21:31:04 +02:00
const observer = new IntersectionObserver (
( entries ) => {
entries . forEach (( entry ) => {
const projectId = ( entry . target as HTMLElement ). dataset . projectId ;
if ( ! projectId ) return ;
2026-03-02 23:08:11 +00:00
2026-01-06 21:31:04 +02:00
setStuckProjectHeaders (( prev ) => {
const next = new Set ( prev );
if ( ! entry . isIntersecting ) {
next . add ( projectId );
} else {
next . delete ( projectId );
}
return next ;
});
});
},
2026-01-01 14:24:57 +02:00
{ threshold : 0 }
);
2026-01-06 21:31:04 +02:00
projectHeaderSentinelRefs . current . forEach (( el ) => {
2026-01-01 14:24:57 +02:00
if ( el ) observer . observe ( el );
});
return () => observer . disconnect ();
2026-02-05 01:59:49 +02:00
}, [ isDesktopShellRuntime , projectSections ]);
2026-01-01 14:24:57 +02:00
2025-12-07 19:32:53 +02:00
const renderSessionNode = React . useCallback (
2026-01-06 21:31:04 +02:00
( node : SessionNode , depth = 0 , groupDirectory? : string | null , projectId? : string | null ) : React . ReactNode => {
2025-12-07 19:32:53 +02:00
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 ;
2026-02-16 14:15:19 +02:00
const isPinnedSession = pinnedSessionIds . has ( session . id );
2026-03-02 23:08:11 +00:00
const isExpanded = hasSessionSearchQuery ? true : expandedParents . has ( session . id );
2026-02-21 05:46:15 +07:00
const isSubtaskSession = Boolean (( session as Session & { parentID? : string | null }). parentID );
const rawNeedsAttention = sessionAttentionStates . get ( session . id ) ? . needsAttention === true ;
// When notifyOnSubtasks is disabled, suppress attention dots for child sessions.
const needsAttention = rawNeedsAttention && ( ! isSubtaskSession || notifyOnSubtasks );
2026-02-07 03:57:46 +02:00
const sessionSummary = session . summary as
| {
additions? : number | string | null ;
deletions? : number | string | null ;
2026-02-23 03:56:38 +07:00
files? : number | null ;
2026-02-07 03:57:46 +02:00
diffs? : Array < { additions? : number | string | null ; deletions? : number | string | null } > ;
}
| undefined ;
2025-12-07 19:32:53 +02:00
if ( editingId === session . id ) {
return (
2025-12-19 02:48:58 +02:00
< div
key = { session . id }
className = { cn (
'group relative flex items-center rounded-md px-1.5 py-1' ,
2026-02-01 18:29:34 +02:00
'bg-interactive-selection' ,
2025-12-19 02:48:58 +02:00
depth > 0 && 'pl-[20px]' ,
)}
>
< div className = "flex min-w-0 flex-1 flex-col gap-0" >
< form
className = "flex w-full items-center gap-2"
2026-01-01 16:02:41 +02:00
data-keyboard-avoid = "true"
2025-12-19 02:48:58 +02:00
onSubmit = {( event ) => {
event . preventDefault ();
handleSaveEdit ();
2025-12-07 19:32:53 +02:00
}}
2025-12-19 02:48:58 +02:00
>
< 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 ) => {
2026-02-10 02:30:25 +02:00
if ( event . key === 'Escape' ) {
event . stopPropagation ();
handleCancelEdit ();
return ;
}
if ( event . key === ' ' || event . key === 'Enter' ) {
event . stopPropagation ();
}
2025-12-19 02:48:58 +02:00
}}
/>
< button
type = "submit"
className = "shrink-0 text-muted-foreground hover:text-foreground"
>
< RiCheckLine className = "size-4" />
</ button >
< button
2025-12-07 19:32:53 +02:00
type = "button"
onClick = { handleCancelEdit }
2025-12-19 02:48:58 +02:00
className = "shrink-0 text-muted-foreground hover:text-foreground"
2025-12-07 19:32:53 +02:00
>
2025-12-19 02:48:58 +02:00
< RiCloseLine className = "size-4" />
</ button >
</ form >
< div className = "flex items-center gap-2 typography-micro text-muted-foreground/60 min-w-0 overflow-hidden leading-tight" >
{ 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 }
2026-02-23 03:56:38 +07:00
< span className = "flex-shrink-0" >{ formatSessionDateLabel ( session . time ? . updated || session . time ? . created || Date . now ())}</ span >
2025-12-19 02:48:58 +02:00
{ session . share ? (
< RiShare2Line className = "h-3 w-3 text-[color:var(--status-info)] flex-shrink-0" />
) : null }
2026-02-23 03:56:38 +07:00
{( sessionSummary ? . files ?? 0 ) > 0 ? (
< span className = "flex-shrink-0" >
· { sessionSummary ! . files } { sessionSummary ! . files === 1 ? 'file' : 'files' } changed
2025-12-19 02:48:58 +02:00
</ span >
) : null }
{ hasChildren ? (
< span className = "truncate" >
{ node . children . length } { node . children . length === 1 ? 'task' : 'tasks' }
</ span >
) : null }
2025-12-07 19:32:53 +02:00
</ div >
</ div >
</ div >
);
}
2026-02-05 01:59:49 +02:00
const statusType = sessionStatus ? . get ( session . id ) ? . type ?? 'idle' ;
const isStreaming = statusType === 'busy' || statusType === 'retry' ;
2026-01-06 21:31:04 +02:00
const pendingPermissionCount = permissions . get ( session . id ) ? . length ?? 0 ;
2026-02-07 03:57:46 +02:00
const showUnreadStatus = ! isStreaming && needsAttention && ! isActive ;
const showStatusMarker = isStreaming || showUnreadStatus ;
2025-12-07 19:32:53 +02:00
const streamingIndicator = (() => {
if ( ! memoryState ) return null ;
if ( memoryState . isZombie ) {
2026-02-01 18:29:34 +02:00
return < RiErrorWarningLine className = "h-4 w-4 text-status-warning" />;
2025-12-07 19:32:53 +02:00
}
return null ;
})();
return (
< React.Fragment key = { session . id }>
2026-02-23 03:56:38 +07:00
< DraggableSessionRow sessionId = { session . id } sessionDirectory = { sessionDirectory ?? null } sessionTitle = { sessionTitle }>
2025-12-07 19:32:53 +02:00
< div
className = { cn (
'group relative flex items-center rounded-md px-1.5 py-1' ,
2026-02-01 18:29:34 +02:00
isActive ? 'bg-interactive-selection' : 'hover:bg-interactive-hover' ,
2025-12-07 19:32:53 +02:00
isMissingDirectory ? 'opacity-75' : '' ,
depth > 0 && 'pl-[20px]' ,
)}
2026-01-16 17:45:37 +01:00
onContextMenu = {( e ) => {
e . preventDefault ();
setOpenMenuSessionId ( session . id );
}}
2025-12-07 19:32:53 +02:00
>
< div className = "flex min-w-0 flex-1 items-center" >
< button
type = "button"
disabled = { isMissingDirectory }
2026-01-06 21:31:04 +02:00
onClick = {() => handleSessionSelect ( session . id , sessionDirectory , isMissingDirectory , projectId )}
2026-02-11 10:08:15 -08:00
onDoubleClick = {( e ) => {
e . stopPropagation ();
handleSessionDoubleClick ();
}}
2025-12-07 19:32:53 +02:00
className = { cn (
2026-01-30 01:18:38 +02:00
'flex min-w-0 flex-1 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' ,
2025-12-07 19:32:53 +02:00
)}
>
{}
2026-02-16 14:15:19 +02:00
< div className = "flex w-full items-center gap-2 min-w-0 flex-1 overflow-hidden" >
{ showStatusMarker ? (
2026-02-07 03:57:46 +02:00
< span className = "inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center" >
{ 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 >
)}
</ span >
2026-01-16 17:48:57 +01:00
) : null }
2026-02-16 14:15:19 +02:00
{ isPinnedSession ? (
< RiPushpinLine className = "h-3 w-3 flex-shrink-0 text-primary" aria-label = "Pinned session" />
) : null }
2026-01-30 02:56:16 +02:00
< div className = "block min-w-0 flex-1 truncate typography-ui-label font-normal text-foreground" >
2026-03-02 23:08:11 +00:00
{ renderHighlightedText ( sessionTitle , normalizedSessionSearchQuery )}
2026-01-30 01:18:38 +02:00
</ div >
2026-01-06 21:31:04 +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 }
2025-12-07 19:32:53 +02:00
</ div >
{}
< div className = "flex items-center gap-2 typography-micro text-muted-foreground/60 min-w-0 overflow-hidden leading-tight" >
{ 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 = "inline-flex items-center justify-center text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 flex-shrink-0 rounded-sm"
aria-label = { isExpanded ? 'Collapse subsessions' : 'Expand subsessions' }
>
{ isExpanded ? (
< RiArrowDownSLine className = "h-3 w-3" />
) : (
< RiArrowRightSLine className = "h-3 w-3" />
)}
</ span >
) : null }
2026-02-23 03:56:38 +07:00
< span className = "flex-shrink-0" >{ formatSessionDateLabel ( session . time ? . updated || session . time ? . created || Date . now ())}</ span >
2025-12-07 19:32:53 +02:00
{ session . share ? (
< RiShare2Line className = "h-3 w-3 text-[color:var(--status-info)] flex-shrink-0" />
) : null }
2026-02-23 03:56:38 +07:00
{( sessionSummary ? . files ?? 0 ) > 0 ? (
< span className = "flex-shrink-0" >
· { sessionSummary ! . files } { sessionSummary ! . files === 1 ? 'file' : 'files' } changed
2025-12-07 19:32:53 +02:00
</ span >
) : null }
{ hasChildren ? (
< span className = "truncate" >
{ node . children . length } { node . children . length === 1 ? 'task' : 'tasks' }
</ span >
) : null }
{ isMissingDirectory ? (
2026-02-01 18:29:34 +02:00
< span className = "inline-flex items-center gap-0.5 text-status-warning flex-shrink-0" >
2025-12-07 19:32:53 +02:00
< RiErrorWarningLine className = "h-3 w-3" />
Missing
</ span >
) : null }
</ div >
</ button >
< div className = "flex items-center gap-1.5 self-stretch" >
{ streamingIndicator }
2026-01-16 17:45:37 +01:00
< DropdownMenu
open = { openMenuSessionId === session . id }
onOpenChange = {( open ) => setOpenMenuSessionId ( open ? session.id : null )}
>
2025-12-07 19:32:53 +02:00
< DropdownMenuTrigger asChild >
< button
type = "button"
className = { cn (
2025-12-19 17:22:02 +02:00
'inline-flex h-3.5 w-[18px] items-center justify-center rounded-md text-muted-foreground transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50' ,
mobileVariant ? 'opacity-70' : 'opacity-0 group-hover:opacity-100' ,
2025-12-07 19:32:53 +02:00
)}
aria-label = "Session menu"
onClick = {( event ) => event . stopPropagation ()}
onKeyDown = {( event ) => event . stopPropagation ()}
>
< RiMore2Line className = { mobileVariant ? 'h-4 w-4' : 'h-3.5 w-3.5' } />
</ button >
</ DropdownMenuTrigger >
2026-02-21 06:09:55 +07:00
< DropdownMenuContent
align = "end"
className = "min-w-[180px]"
onCloseAutoFocus = {( event ) => {
if ( renamingFolderId ) {
event . preventDefault ();
}
}}
>
2025-12-07 19:32:53 +02:00
< DropdownMenuItem
onClick = {() => {
setEditingId ( session . id );
setEditTitle ( sessionTitle );
}}
className = "[&>svg]:mr-1"
>
< RiPencilAiLine className = "mr-1 h-4 w-4" />
Rename
</ DropdownMenuItem >
2026-02-16 14:15:19 +02:00
< 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 >
2025-12-07 19:32:53 +02:00
{ ! session . share ? (
< DropdownMenuItem onClick = {() => handleShareSession ( session )} className = "[&>svg]:mr-1" >
< RiShare2Line className = "mr-1 h-4 w-4" />
Share
</ DropdownMenuItem >
) : (
<>
< DropdownMenuItem
onClick = {() => {
if ( session . share ? . url ) {
handleCopyShareUrl ( session . share . url , session . id );
}
}}
className = "[&>svg]:mr-1"
>
{ 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-02-21 06:09:55 +07:00
{ /* Move to folder submenu */ }
{ sessionDirectory ? (() => {
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
2026-02-23 03:56:38 +07:00
onClick = {() => {
2026-02-24 03:28:30 +02:00
const newFolder = createFolderAndStartRename ( sessionDirectory );
if ( ! newFolder ) {
return ;
}
addSessionToFolder ( sessionDirectory , newFolder . id , session . id );
}}
2026-02-21 06:09:55 +07:00
>
< 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 }
2026-03-02 02:11:33 +02:00
< 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 >
2026-02-21 06:09:55 +07:00
< DropdownMenuSeparator />
2025-12-07 19:32:53 +02:00
< DropdownMenuItem
className = "text-destructive focus:text-destructive [&>svg]:mr-1"
onClick = {() => handleDeleteSession ( session )}
>
< RiDeleteBinLine className = "mr-1 h-4 w-4" />
Remove
</ DropdownMenuItem >
</ DropdownMenuContent >
</ DropdownMenu >
</ div >
</ div >
</ div >
2026-02-23 03:56:38 +07:00
</ DraggableSessionRow >
2025-12-07 19:32:53 +02:00
{ hasChildren && isExpanded
? node . children . map (( child ) =>
2026-01-06 21:31:04 +02:00
renderSessionNode ( child , depth + 1 , sessionDirectory ?? groupDirectory , projectId ),
2025-12-07 19:32:53 +02:00
)
: null }
</ React.Fragment >
);
},
[
directoryStatus ,
sessionMemoryState ,
2026-02-05 01:59:49 +02:00
sessionStatus ,
2026-02-07 03:57:46 +02:00
sessionAttentionStates ,
2026-01-06 21:31:04 +02:00
permissions ,
2025-12-07 19:32:53 +02:00
currentSessionId ,
2026-03-02 23:08:11 +00:00
hasSessionSearchQuery ,
normalizedSessionSearchQuery ,
2025-12-07 19:32:53 +02:00
expandedParents ,
editingId ,
editTitle ,
handleSaveEdit ,
handleCancelEdit ,
toggleParent ,
handleSessionSelect ,
2026-02-11 10:08:15 -08:00
handleSessionDoubleClick ,
2026-02-16 14:15:19 +02:00
pinnedSessionIds ,
togglePinnedSession ,
2025-12-07 19:32:53 +02:00
handleShareSession ,
handleCopyShareUrl ,
handleUnshareSession ,
handleDeleteSession ,
copiedSessionId ,
mobileVariant ,
2026-01-16 17:45:37 +01:00
openMenuSessionId ,
2026-02-21 06:09:55 +07:00
renamingFolderId ,
getFoldersForScope ,
getSessionFolderId ,
addSessionToFolder ,
removeSessionFromFolder ,
2026-02-24 03:28:30 +02:00
createFolderAndStartRename ,
2026-03-02 02:11:33 +02:00
openContextPanelTab ,
2026-02-21 05:46:15 +07:00
notifyOnSubtasks ,
2025-12-07 19:32:53 +02:00
],
);
2026-01-06 21:31:04 +02:00
const renderGroupSessions = React . useCallback (
2026-02-11 19:28:22 +02:00
( group : SessionGroup , groupKey : string , projectId? : string | null , hideGroupLabel? : boolean ) => {
2026-03-02 23:08:11 +00:00
const searchData = hasSessionSearchQuery ? groupSearchDataByGroup . get ( group ) : null ;
2026-01-06 21:31:04 +02:00
const isExpanded = expandedSessionGroups . has ( groupKey );
2026-03-02 23:08:11 +00:00
const isCollapsed = hasSessionSearchQuery ? false : collapsedGroups . has ( groupKey );
2026-01-06 21:31:04 +02:00
const maxVisible = hideDirectoryControls ? 10 : 5 ;
2026-03-02 23:08:11 +00:00
const groupMatchesSearch = hasSessionSearchQuery
? searchData ? . groupMatches === true
: false ;
const shouldFilterGroupContents = hasSessionSearchQuery ;
const sourceGroupNodes = shouldFilterGroupContents
? ( searchData ? . filteredNodes ?? [])
: group . sessions ;
2026-02-21 06:09:55 +07:00
// --- Session Folders: split into foldered vs ungrouped ---
const folderScopeKey = normalizePath ( group . directory ?? null );
const scopeFolders = folderScopeKey ? getFoldersForScope ( folderScopeKey ) : [];
2026-03-02 23:08:11 +00:00
const nodeBySessionId = new Map < string , SessionNode >();
const collectNodeLookup = ( nodes : SessionNode []) => {
nodes . forEach (( node ) => {
nodeBySessionId . set ( node . session . id , node );
if ( node . children . length > 0 ) {
collectNodeLookup ( node . children );
}
});
};
collectNodeLookup ( sourceGroupNodes );
2026-02-21 06:09:55 +07:00
2026-02-23 03:56:38 +07:00
// ALL folders for this scope – including empty ones (so newly created folders show up)
// Build enriched list: { folder, nodes } for every folder in scope
2026-03-02 23:08:11 +00:00
const allFoldersForGroupBase = scopeFolders . map (( folder ) => {
2026-02-23 03:56:38 +07:00
const nodes = folder . sessionIds
2026-03-02 23:08:11 +00:00
. map (( sid ) => nodeBySessionId . get ( sid ))
2026-02-23 03:56:38 +07:00
. filter (( n ) : n is SessionNode => Boolean ( n ))
2026-02-24 03:28:30 +02:00
. sort (( a , b ) => compareSessionsByPinnedAndTime ( a . session , b . session , pinnedSessionIds ));
2026-02-23 03:56:38 +07:00
return { folder , nodes };
});
2026-03-02 23:08:11 +00:00
const shouldKeepFolder = ( folderId : string , folderMap : Map < string , { folder : ( typeof allFoldersForGroupBase )[ number ][ 'folder' ]; nodes : SessionNode [] }>) : boolean => {
const entry = folderMap . get ( folderId );
if ( ! entry ) {
return false ;
}
if ( ! hasSessionSearchQuery ) {
return true ;
}
const folderMatches = entry . folder . name . toLowerCase (). includes ( normalizedSessionSearchQuery );
if ( folderMatches || entry . nodes . length > 0 ) {
return true ;
}
return allFoldersForGroupBase
. filter (({ folder }) => folder . parentId === folderId )
. some (({ folder }) => shouldKeepFolder ( folder . id , folderMap ));
};
const folderMapById = new Map (
allFoldersForGroupBase . map (( entry ) => [ entry . folder . id , entry ]),
);
const allFoldersForGroup = hasSessionSearchQuery
? allFoldersForGroupBase . filter (({ folder }) => shouldKeepFolder ( folder . id , folderMapById ))
: allFoldersForGroupBase ;
const sessionIdsInFolders = new Set ( allFoldersForGroup . flatMap (( f ) => f . folder . sessionIds ));
const ungroupedSessions = sourceGroupNodes . filter (( node ) => ! sessionIdsInFolders . has ( node . session . id ));
2026-02-23 03:56:38 +07:00
// Root-level folders (no parentId) — sub-folders are rendered inside their parent
const rootFolders = allFoldersForGroup . filter (({ folder }) => ! folder . parentId );
2026-02-21 06:09:55 +07:00
2026-03-02 23:08:11 +00:00
if ( hasSessionSearchQuery && ! groupMatchesSearch && rootFolders . length === 0 && ungroupedSessions . length === 0 ) {
return null ;
}
2026-02-21 06:09:55 +07:00
const totalSessions = ungroupedSessions . length ;
2026-03-02 23:08:11 +00:00
const visibleSessions = hasSessionSearchQuery
? ungroupedSessions
: ( isExpanded ? ungroupedSessions : ungroupedSessions.slice ( 0 , maxVisible ));
2026-01-06 21:31:04 +02:00
const remainingCount = totalSessions - visibleSessions . length ;
2026-02-07 03:57:46 +02:00
const collectGroupSessions = ( nodes : SessionNode []) : Session [] => {
const collected : Session [] = [];
const visit = ( list : SessionNode []) => {
list . forEach (( node ) => {
collected . push ( node . session );
if ( node . children . length > 0 ) {
visit ( node . children );
}
});
};
visit ( nodes );
return collected ;
};
2026-03-02 23:08:11 +00:00
const allGroupSessions = collectGroupSessions ( sourceGroupNodes );
2026-02-07 03:57:46 +02:00
const normalizedGroupDirectory = normalizePath ( group . directory ?? null );
2026-02-16 14:15:19 +02:00
const isGitProject = projectId && projectRepoStatus . has ( projectId )
? Boolean ( projectRepoStatus . get ( projectId ))
: lastRepoStatusRef . current ;
2026-02-09 13:37:21 +02:00
const showBranchSubtitle = ! group . isMain && isBranchDifferentFromLabel ( group . branch , group . label );
2026-02-07 03:57:46 +02:00
const isActiveGroup = Boolean (
normalizedGroupDirectory
&& currentSessionDirectory
&& normalizedGroupDirectory === currentSessionDirectory ,
);
2026-01-06 21:31:04 +02:00
2026-02-23 03:56:38 +07:00
// Helper: render a single folder item (root or sub) wrapped in DroppableFolderWrapper
const renderOneFolderItem = ( folder : ( typeof allFoldersForGroup )[ number ][ 'folder' ], nodes : SessionNode [], depth : number ) => {
// Find direct sub-folders of this folder
const directSubFolders = allFoldersForGroup . filter (({ folder : f }) => f . parentId === folder . id );
const subFolderItems = directSubFolders . length > 0 ? (
<>{ directSubFolders . map (({ folder : sf , nodes : sn }) => renderOneFolderItem ( sf , sn , depth + 1 ))}</>
) : undefined ;
return (
< DroppableFolderWrapper key = { folder . id } folderId = { folder . id }>
{( droppableRef , isDropTarget ) => (
< SessionFolderItem
folder = { folder }
sessions = { nodes }
subFolderItems = { subFolderItems }
2026-03-02 23:08:11 +00:00
isCollapsed = { hasSessionSearchQuery ? false : collapsedFolderIds . has ( folder . id )}
2026-02-23 03:56:38 +07:00
onToggle = {() => toggleFolderCollapse ( folder . id )}
onRename = {( name ) => {
if ( folderScopeKey ) renameFolder ( folderScopeKey , folder . id , name );
}}
onDelete = {() => {
if ( ! folderScopeKey ) return ;
2026-02-24 03:28:30 +02:00
if ( ! showDeletionDialog ) {
deleteFolder ( folderScopeKey , folder . id );
return ;
}
2026-02-23 03:56:38 +07:00
// Count affected sub-folders and sessions for the confirm dialog
const subFolderCount = allFoldersForGroup . filter (({ folder : f }) => f . parentId === folder . id ). length ;
const sessionCount = nodes . length ;
setDeleteFolderConfirm ({
scopeKey : folderScopeKey ,
folderId : folder.id ,
folderName : folder.name ,
subFolderCount ,
sessionCount ,
});
}}
renderSessionNode = { renderSessionNode }
groupDirectory = { group . directory }
projectId = { projectId }
mobileVariant = { mobileVariant }
isRenaming = { renamingFolderId === folder . id }
renameDraft = { renamingFolderId === folder . id ? renameFolderDraft : undefined }
onRenameDraftChange = {( value ) => setRenameFolderDraft ( value )}
onRenameSave = {() => {
const trimmed = renameFolderDraft . trim ();
if ( trimmed && folderScopeKey ) {
renameFolder ( folderScopeKey , folder . id , trimmed );
}
setRenamingFolderId ( null );
setRenameFolderDraft ( '' );
}}
onRenameCancel = {() => {
setRenamingFolderId ( null );
setRenameFolderDraft ( '' );
}}
droppableRef = { droppableRef }
isDropTarget = { isDropTarget }
depth = { depth }
onNewSession = {() => {
if ( projectId && projectId !== activeProjectId ) {
2026-02-25 14:32:23 +02:00
setActiveProjectIdOnly ( projectId );
2026-02-23 03:56:38 +07:00
}
setActiveMainTab ( 'chat' );
if ( mobileVariant ) {
setSessionSwitcherOpen ( false );
}
openNewSessionDraft ({ directoryOverride : group.directory , targetFolderId : folder.id });
}}
2026-02-24 03:28:30 +02:00
onNewSubFolder = { depth === 0 ? () => {
if ( ! folderScopeKey ) return ;
createFolderAndStartRename ( folderScopeKey , folder . id );
} : undefined }
2026-02-23 03:56:38 +07:00
/>
)}
</ DroppableFolderWrapper >
);
};
2026-02-11 19:28:22 +02:00
// VS Code sessions list uses a separate header (Agent Manager / New Session).
// When the caller requests a flat list (hideGroupLabel), omit the per-group header entirely.
2026-02-23 03:56:38 +07:00
// Shared folder rendering helper (used in both branches).
// Uses DroppableFolderWrapper so each folder header becomes a DnD drop zone.
2026-02-21 06:09:55 +07:00
const renderFolderItems = () =>
2026-02-23 03:56:38 +07:00
rootFolders . map (({ folder , nodes }) => renderOneFolderItem ( folder , nodes , 0 ));
2026-02-21 06:09:55 +07:00
2026-02-11 19:28:22 +02:00
if ( hideGroupLabel ) {
return (
< div className = "oc-group" >
< div className = "oc-group-body pb-3" >
2026-02-23 03:56:38 +07:00
< SessionFolderDndScope
scopeKey = { folderScopeKey }
hasFolders = { allFoldersForGroup . length > 0 }
onSessionDroppedOnFolder = {( sessionId , folderId ) => {
if ( folderScopeKey ) addSessionToFolder ( folderScopeKey , folderId , sessionId );
}}
>
2026-02-21 06:09:55 +07:00
{ renderFolderItems ()}
2026-02-11 19:28:22 +02:00
{ visibleSessions . map (( node ) => renderSessionNode ( node , 0 , group . directory , projectId ))}
2026-03-02 23:08:11 +00:00
{ totalSessions === 0 && allFoldersForGroup . length === 0 ? (
2026-02-11 19:28:22 +02:00
< div className = "py-1 text-left typography-micro text-muted-foreground" >
No sessions in this workspace yet .
</ div >
) : null }
{ remainingCount > 0 && ! isExpanded ? (
< button
type = "button"
onClick = {() => toggleGroupSessionLimit ( groupKey )}
className = "mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
Show { remainingCount } more { remainingCount === 1 ? 'session' : 'sessions' }
</ button >
) : null }
{ isExpanded && totalSessions > maxVisible ? (
< button
type = "button"
onClick = {() => toggleGroupSessionLimit ( groupKey )}
className = "mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
Show fewer sessions
</ button >
) : null }
2026-02-23 03:56:38 +07:00
</ SessionFolderDndScope >
2026-02-11 19:28:22 +02:00
</ div >
</ div >
);
}
2026-01-06 21:31:04 +02:00
return (
2026-02-07 03:57:46 +02:00
< div className = "oc-group" >
< div
2026-02-11 19:28:22 +02:00
className = { cn (
2026-02-25 14:32:23 +02:00
"group/gh relative flex items-center justify-between gap-1 py-1 min-w-0 rounded-sm" ,
2026-02-11 19:28:22 +02:00
! hideGroupLabel && "hover:bg-interactive-hover/50 cursor-pointer"
)}
onClick = { ! hideGroupLabel ? () => {
2026-02-07 03:57:46 +02:00
setCollapsedGroups (( prev ) => {
const next = new Set ( prev );
if ( next . has ( groupKey )) {
next . delete ( groupKey );
} else {
next . add ( groupKey );
}
return next ;
});
2026-02-11 19:28:22 +02:00
} : undefined }
role = { ! hideGroupLabel ? "button" : undefined }
tabIndex = { ! hideGroupLabel ? 0 : undefined }
onKeyDown = { ! hideGroupLabel ? ( event ) => {
2026-02-07 03:57:46 +02:00
if ( event . key === 'Enter' || event . key === ' ' ) {
event . preventDefault ();
setCollapsedGroups (( prev ) => {
const next = new Set ( prev );
if ( next . has ( groupKey )) {
next . delete ( groupKey );
} else {
next . add ( groupKey );
}
return next ;
});
}
2026-02-11 19:28:22 +02:00
} : undefined }
aria-label = { ! hideGroupLabel ? ( isCollapsed ? `Expand ${ group . label } ` : `Collapse ${ group . label } ` ) : undefined }
2026-02-07 03:57:46 +02:00
>
2026-02-11 19:28:22 +02:00
{ ! hideGroupLabel ? (
2026-02-25 14:32:23 +02:00
< div className = { cn (
"min-w-0 flex items-center gap-1.5 pl-1.5 transition-[padding]" ,
mobileVariant
? ( ! group . isMain && group . worktree ? "pr-14" : "pr-7" )
: ( ! group . isMain && group . worktree
? "group-hover/gh:pr-14 group-focus-within/gh:pr-14"
: "group-hover/gh:pr-7 group-focus-within/gh:pr-7" ),
)}>
2026-02-11 19:28:22 +02:00
{ ! group . isMain || isGitProject ? (
< RiGitBranchLine className = "h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
2026-02-08 16:51:05 -08:00
) : null }
2026-02-11 19:28:22 +02:00
< div className = "min-w-0 flex flex-col justify-center" >
2026-02-25 14:32:23 +02:00
< p className = { cn ( 'text-[14px] font-semibold truncate' , isActiveGroup ? 'text-primary' : 'text-muted-foreground' )}>
2026-03-02 23:08:11 +00:00
{ renderHighlightedText ( group . label , normalizedSessionSearchQuery )}
2026-02-11 19:28:22 +02:00
</ p >
{ showBranchSubtitle ? (
< span className = "text-[10px] sm:text-[11px] text-muted-foreground/80 truncate leading-tight" >
{ group . branch }
</ span >
) : null }
</ div >
2026-02-16 14:15:19 +02:00
{ isCollapsed ? (
< RiArrowRightSLine className = "h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
) : (
< RiArrowDownSLine className = "h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
)}
2026-02-08 16:51:05 -08:00
</ div >
2026-02-11 19:28:22 +02:00
) : < div />}
2026-02-07 03:57:46 +02:00
{ group . directory ? (
2026-02-25 14:32:23 +02:00
<>
2026-02-07 03:57:46 +02:00
{ ! group . isMain && group . worktree ? (
2026-02-25 14:32:23 +02:00
< div className = { cn (
'absolute right-7 top-1/2 -translate-y-1/2 z-10 transition-opacity' ,
mobileVariant ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100' ,
)}>
< Tooltip >
< TooltipTrigger asChild >
< button
type = "button"
onClick = {( event ) => {
event . stopPropagation ();
sessionEvents . requestDelete ({
sessions : allGroupSessions ,
mode : 'worktree' ,
worktree : group.worktree ,
});
}}
className = "inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label = { `Delete ${ group . label } ` }
>
< RiDeleteBinLine className = "h-4 w-4" />
</ button >
</ TooltipTrigger >
< TooltipContent side = "bottom" sideOffset = { 4 }>
< p > Delete worktree </ p >
</ TooltipContent >
</ Tooltip >
</ div >
) : null }
< div className = { cn (
'absolute right-0.5 top-1/2 -translate-y-1/2 z-10 transition-opacity' ,
mobileVariant ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100' ,
)}>
2026-02-07 03:57:46 +02:00
< Tooltip >
< TooltipTrigger asChild >
< button
type = "button"
onClick = {( event ) => {
event . stopPropagation ();
2026-02-25 14:32:23 +02:00
if ( projectId && projectId !== activeProjectId ) {
setActiveProjectIdOnly ( projectId );
}
setActiveMainTab ( 'chat' );
if ( mobileVariant ) {
setSessionSwitcherOpen ( false );
}
openNewSessionDraft ({ directoryOverride : group.directory });
2026-02-07 03:57:46 +02:00
}}
2026-02-25 14:32:23 +02:00
className = "inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label = { `New session in ${ group . label } ` }
2026-02-07 03:57:46 +02:00
>
2026-02-25 14:32:23 +02:00
< RiAddLine className = "h-4 w-4" />
2026-02-07 03:57:46 +02:00
</ button >
</ TooltipTrigger >
< TooltipContent side = "bottom" sideOffset = { 4 }>
2026-02-25 14:32:23 +02:00
< p > New session </ p >
2026-02-07 03:57:46 +02:00
</ TooltipContent >
</ Tooltip >
2026-02-25 14:32:23 +02:00
</ div >
</>
2026-02-07 03:57:46 +02:00
) : null }
</ div >
{ ! isCollapsed ? (
< div className = "oc-group-body pb-3" >
2026-02-23 03:56:38 +07:00
< SessionFolderDndScope
scopeKey = { folderScopeKey }
hasFolders = { allFoldersForGroup . length > 0 }
onSessionDroppedOnFolder = {( sessionId , folderId ) => {
if ( folderScopeKey ) addSessionToFolder ( folderScopeKey , folderId , sessionId );
}}
>
{ renderFolderItems ()}
{ visibleSessions . map (( node ) => renderSessionNode ( node , 0 , group . directory , projectId ))}
2026-03-02 23:08:11 +00:00
{ totalSessions === 0 && allFoldersForGroup . length === 0 ? (
2026-02-23 03:56:38 +07:00
< div className = "py-1 text-left typography-micro text-muted-foreground" >
No sessions in this workspace yet .
</ div >
) : null }
{ remainingCount > 0 && ! isExpanded ? (
< button
type = "button"
onClick = {() => toggleGroupSessionLimit ( groupKey )}
className = "mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
Show { remainingCount } more { remainingCount === 1 ? 'session' : 'sessions' }
</ button >
) : null }
{ isExpanded && totalSessions > maxVisible ? (
< button
type = "button"
onClick = {() => toggleGroupSessionLimit ( groupKey )}
className = "mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
Show fewer sessions
</ button >
) : null }
</ SessionFolderDndScope >
2026-01-06 21:31:04 +02:00
</ div >
) : null }
2026-02-07 03:57:46 +02:00
</ div >
2026-01-06 21:31:04 +02:00
);
},
2026-02-07 03:57:46 +02:00
[
expandedSessionGroups ,
collapsedGroups ,
hideDirectoryControls ,
2026-03-02 23:08:11 +00:00
hasSessionSearchQuery ,
normalizedSessionSearchQuery ,
groupSearchDataByGroup ,
2026-02-07 03:57:46 +02:00
currentSessionDirectory ,
projectRepoStatus ,
renderSessionNode ,
toggleGroupSessionLimit ,
activeProjectId ,
2026-02-25 14:32:23 +02:00
setActiveProjectIdOnly ,
2026-02-07 03:57:46 +02:00
setActiveMainTab ,
mobileVariant ,
setSessionSwitcherOpen ,
openNewSessionDraft ,
2026-02-21 06:09:55 +07:00
getFoldersForScope ,
collapsedFolderIds ,
toggleFolderCollapse ,
2026-02-24 03:28:30 +02:00
createFolderAndStartRename ,
2026-02-21 06:09:55 +07:00
renameFolder ,
deleteFolder ,
2026-02-24 03:28:30 +02:00
showDeletionDialog ,
2026-02-23 03:56:38 +07:00
addSessionToFolder ,
2026-02-21 06:09:55 +07:00
renamingFolderId ,
renameFolderDraft ,
2026-02-23 03:56:38 +07:00
pinnedSessionIds ,
2026-02-07 03:57:46 +02:00
]
2026-01-06 21:31:04 +02:00
);
// DnD sensors for project reordering
const sensors = useSensors (
useSensor ( PointerSensor , {
activationConstraint : {
distance : 8 ,
},
}),
useSensor ( KeyboardSensor , {
coordinateGetter : sortableKeyboardCoordinates ,
})
);
2025-12-07 19:32:53 +02:00
return (
< div
2026-03-02 23:08:11 +00:00
ref = { sessionSearchContainerRef }
2025-12-07 19:32:53 +02:00
className = { cn (
'flex h-full flex-col text-foreground overflow-x-hidden' ,
2026-02-25 14:32:23 +02:00
mobileVariant ? '' : 'bg-transparent' ,
2025-12-07 19:32:53 +02:00
)}
>
2025-12-13 16:34:17 +02:00
{ ! hideDirectoryControls && (
2026-02-16 14:15:19 +02:00
< div className = { cn ( 'select-none pl-3.5 pr-2 flex-shrink-0 border-b border-border/60' , hideProjectSelector ? 'py-1' : 'py-1.5' )}>
{ ! hideProjectSelector && (
2026-02-07 03:57:46 +02:00
< div className = "flex h-8 items-center justify-between gap-2" >
< DropdownMenu
onOpenChange = {( open ) => {
if ( ! open ) {
setIsProjectRenameInline ( false );
}
}}
>
< DropdownMenuTrigger asChild >
< button
type = "button"
className = "flex h-8 min-w-0 max-w-[calc(100%-2.5rem)] items-center gap-1 rounded-md px-2 text-left text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
>
< span className = "text-base font-semibold truncate" >
{ activeProjectForHeader
? formatProjectLabel (
activeProjectForHeader . label ? . trim ()
|| formatDirectoryName ( activeProjectForHeader . normalizedPath , homeDirectory )
|| activeProjectForHeader . normalizedPath ,
)
: 'Projects' }
</ span >
< RiArrowDownSLine className = "h-4 w-4 flex-shrink-0 text-muted-foreground" />
</ button >
</ DropdownMenuTrigger >
< DropdownMenuContent align = "start" className = "min-w-[220px] max-w-[320px]" >
{ normalizedProjects . map (( project ) => {
const label = formatProjectLabel (
project . label ? . trim ()
|| formatDirectoryName ( project . normalizedPath , homeDirectory )
|| project . normalizedPath
);
return (
< DropdownMenuItem
key = { project . id }
2026-02-25 14:32:23 +02:00
onClick = {() => setActiveProjectIdOnly ( project . id )}
2026-02-07 03:57:46 +02:00
className = { cn ( 'truncate' , project . id === activeProjectId && 'text-primary' )}
>
< span className = "truncate" >{ label }</ span >
</ DropdownMenuItem >
);
})}
< div className = "my-1 h-px bg-border/70" />
{ ! isProjectRenameInline ? (
< DropdownMenuItem
onClick = {( event ) => {
event . preventDefault ();
handleStartInlineProjectRename ();
}}
className = "gap-2"
>
< RiPencilAiLine className = "h-4 w-4" />
Rename project
</ DropdownMenuItem >
) : (
< div className = "px-2 py-1.5" >
< form
className = "flex items-center gap-1"
onSubmit = {( event ) => {
event . preventDefault ();
handleSaveInlineProjectRename ();
}}
>
< input
value = { projectRenameDraft }
onChange = {( event ) => setProjectRenameDraft ( event . target . value )}
className = "h-7 flex-1 rounded border border-border bg-transparent px-2 typography-ui-label text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
placeholder = "Rename project"
autoFocus
2026-02-10 02:30:25 +02:00
onKeyDown = {( event ) => {
if ( event . key === 'Escape' ) {
event . stopPropagation ();
setIsProjectRenameInline ( false );
return ;
}
if ( event . key === ' ' || event . key === 'Enter' ) {
event . stopPropagation ();
}
}}
2026-02-07 03:57:46 +02:00
/>
< button type = "submit" className = "inline-flex h-7 w-7 items-center justify-center rounded text-muted-foreground hover:text-foreground" >
< RiCheckLine className = "h-4 w-4" />
</ button >
< button
type = "button"
onClick = {() => setIsProjectRenameInline ( false )}
className = "inline-flex h-7 w-7 items-center justify-center rounded text-muted-foreground hover:text-foreground"
>
< RiCloseLine className = "h-4 w-4" />
</ button >
</ form >
</ div >
)}
< DropdownMenuItem
onClick = {() => {
if ( ! activeProjectForHeader ) {
return ;
}
removeProject ( activeProjectForHeader . id );
}}
className = "text-destructive focus:text-destructive gap-2"
>
< RiCloseLine className = "h-4 w-4" />
Close project
</ DropdownMenuItem >
</ DropdownMenuContent >
</ DropdownMenu >
2025-12-07 19:32:53 +02:00
< button
type = "button"
2025-12-13 16:34:17 +02:00
onClick = { handleOpenDirectoryDialog }
2026-02-25 14:32:23 +02:00
className = { addProjectButtonClass }
2026-01-06 21:31:04 +02:00
aria-label = "Add project"
title = "Add project"
2025-12-07 19:32:53 +02:00
>
2026-02-25 14:32:23 +02:00
< RiFolderAddLine className = { headerActionIconClass } />
2025-12-07 19:32:53 +02:00
</ button >
2025-12-13 16:34:17 +02:00
</ div >
2026-02-16 14:15:19 +02:00
)}
2026-02-07 03:57:46 +02:00
{ reserveHeaderActionsSpace ? (
2026-03-02 23:08:11 +00:00
< div className = "-ml-1 flex h-auto min-h-8 flex-col gap-1" >
2026-02-11 23:53:37 -08:00
{ activeProjectForHeader ? (
2026-03-02 23:08:11 +00:00
<>
2026-02-17 19:14:04 +02:00
< div className = "flex h-8 -translate-y-px items-center gap-1.5 rounded-md pl-0 pr-1" >
2026-02-16 14:15:19 +02:00
{ stableActiveProjectIsRepo ? (
2026-02-11 23:53:37 -08:00
<>
2026-02-07 03:57:46 +02:00
< Tooltip >
< TooltipTrigger asChild >
< button
type = "button"
onClick = { async () => {
if ( ! activeProjectForHeader ) {
return ;
}
if ( activeProjectForHeader . id !== activeProjectId ) {
2026-02-25 14:32:23 +02:00
setActiveProjectIdOnly ( activeProjectForHeader . id );
2026-02-07 03:57:46 +02:00
}
2026-03-03 00:20:15 +02:00
setNewWorktreeDialogOpen ( true );
2026-02-07 03:57:46 +02:00
}}
className = { headerActionButtonClass }
aria-label = "New worktree"
>
2026-02-25 14:32:23 +02:00
< RiNodeTree className = { headerActionIconClass } />
2026-02-07 03:57:46 +02:00
</ button >
</ TooltipTrigger >
< TooltipContent side = "bottom" sideOffset = { 4 }>< p > New worktree </ p ></ TooltipContent >
</ Tooltip >
< Tooltip >
< TooltipTrigger asChild >
< button
type = "button"
onClick = { openMultiRunLauncher }
className = { headerActionButtonClass }
aria-label = "New multi-run"
>
2026-02-25 14:32:23 +02:00
< ArrowsMerge className = { headerActionIconClass } />
2026-02-07 03:57:46 +02:00
</ button >
</ TooltipTrigger >
< TooltipContent side = "bottom" sideOffset = { 4 }>< p > New multi - run </ p ></ TooltipContent >
</ Tooltip >
2026-02-11 23:53:37 -08:00
</>
) : null }
{ useMobileNotesPanel ? (
< Tooltip >
< TooltipTrigger asChild >
< button
type = "button"
onClick = {() => setProjectNotesPanelOpen ( true )}
className = { headerActionButtonClass }
aria-label = "Project notes and todos"
>
2026-02-25 14:32:23 +02:00
< RiStickyNoteLine className = { headerActionIconClass } />
2026-02-11 23:53:37 -08:00
</ button >
</ TooltipTrigger >
< TooltipContent side = "bottom" sideOffset = { 4 }>< p > Project notes </ p ></ TooltipContent >
</ Tooltip >
) : (
< DropdownMenu open = { projectNotesPanelOpen } onOpenChange = { setProjectNotesPanelOpen } modal = { false }>
< Tooltip >
< TooltipTrigger asChild >
< DropdownMenuTrigger asChild >
< button
type = "button"
className = { headerActionButtonClass }
aria-label = "Project notes and todos"
>
2026-02-25 14:32:23 +02:00
< RiStickyNoteLine className = { headerActionIconClass } />
2026-02-11 23:53:37 -08:00
</ button >
</ DropdownMenuTrigger >
</ TooltipTrigger >
< TooltipContent side = "bottom" sideOffset = { 4 }>< p > Project notes </ p ></ TooltipContent >
</ Tooltip >
< DropdownMenuContent align = "start" className = "w-[340px] p-0" >
< ProjectNotesTodoPanel
projectRef = { activeProjectRefForHeader }
2026-02-16 14:15:19 +02:00
canCreateWorktree = { stableActiveProjectIsRepo }
2026-02-11 23:53:37 -08:00
onActionComplete = {() => setProjectNotesPanelOpen ( false )}
/>
</ DropdownMenuContent >
</ DropdownMenu >
)}
2026-03-02 23:08:11 +00:00
< Tooltip >
< TooltipTrigger asChild >
< button
type = "button"
onClick = {() => setIsSessionSearchOpen (( prev ) => ! prev )}
className = { headerActionButtonClass }
aria-label = "Search sessions"
aria-expanded = { isSessionSearchOpen }
>
< RiSearchLine className = { headerActionIconClass } />
</ button >
</ TooltipTrigger >
< TooltipContent side = "bottom" sideOffset = { 4 }>< p > Search sessions </ p ></ TooltipContent >
</ Tooltip >
2026-02-07 03:57:46 +02:00
</ div >
2026-03-02 23:08:11 +00:00
{ isSessionSearchOpen ? (
< div className = "px-1 pb-1" >
< div className = "mb-1 flex items-center justify-between px-0.5 typography-micro text-muted-foreground/80" >
{ hasSessionSearchQuery ? (
< span >{ searchMatchCount } { searchMatchCount === 1 ? 'match' : 'matches' }</ span >
) : < span />}
< span > Esc to clear </ span >
</ div >
< div className = "relative" >
< RiSearchLine className = "pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
< input
ref = { sessionSearchInputRef }
value = { sessionSearchQuery }
onChange = {( event ) => setSessionSearchQuery ( event . target . value )}
placeholder = "Search sessions..."
className = "h-8 w-full rounded-md border border-border bg-transparent pl-8 pr-8 typography-ui-label text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
onKeyDown = {( event ) => {
if ( event . key === 'Escape' ) {
event . stopPropagation ();
if ( hasSessionSearchQuery ) {
setSessionSearchQuery ( '' );
} else {
setIsSessionSearchOpen ( false );
}
}
}}
/>
{ sessionSearchQuery . length > 0 ? (
< button
type = "button"
onClick = {() => setSessionSearchQuery ( '' )}
className = "absolute right-1 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label = "Clear search"
>
< RiCloseLine className = "h-3.5 w-3.5" />
</ button >
) : null }
</ div >
</ div >
) : null }
</>
2026-02-07 03:57:46 +02:00
) : null }
</ div >
) : null }
2025-12-07 19:32:53 +02:00
</ div >
2025-12-13 16:34:17 +02:00
)}
2025-12-07 19:32:53 +02:00
< ScrollableOverlay
outerClassName = "flex-1 min-h-0"
className = { cn ( 'space-y-1 pb-1 pl-2.5 pr-1' , mobileVariant ? '' : '' )}
>
2026-01-06 21:31:04 +02:00
{ projectSections . length === 0 ? (
2025-12-07 19:32:53 +02:00
emptyState
2026-03-02 23:08:11 +00:00
) : sectionsForRender . length === 0 ? (
searchEmptyState
2026-01-06 21:31:04 +02:00
) : showOnlyMainWorkspace ? (
2025-12-13 16:34:17 +02:00
< div className = "space-y-[0.6rem] py-1" >
2025-12-21 20:18:51 +02:00
{(() => {
2026-03-02 23:08:11 +00:00
const activeSection = sectionsForRender . find (( section ) => section . project . id === activeProjectId ) ?? sectionsForRender [ 0 ];
2026-01-06 21:31:04 +02:00
if ( ! activeSection ) {
2026-03-02 23:08:11 +00:00
return hasSessionSearchQuery ? searchEmptyState : emptyState ;
2026-01-06 21:31:04 +02:00
}
// VS Code sessions view typically only shows one workspace, but sessions may live in worktrees or
// canonicalized paths. Prefer the main group if it has sessions; otherwise fall back to any group
// that contains sessions so we don't show an empty list when sessions exist.
const group =
activeSection . groups . find (( candidate ) => candidate . isMain && candidate . sessions . length > 0 )
?? activeSection . groups . find (( candidate ) => candidate . sessions . length > 0 )
?? activeSection . groups . find (( candidate ) => candidate . isMain )
?? activeSection . groups [ 0 ];
if ( ! group ) {
2025-12-21 20:18:51 +02:00
return (
< div className = "py-1 text-left typography-micro text-muted-foreground" >
No sessions yet .
</ div >
);
}
2026-01-06 21:31:04 +02:00
const groupKey = ` ${ activeSection . project . id } : ${ group . id } ` ;
2026-02-11 19:28:22 +02:00
// In VS Code mode with showOnlyMainWorkspace, hide the group header to show a flat session list
return renderGroupSessions ( group , groupKey , activeSection . project . id , showOnlyMainWorkspace );
2025-12-21 20:18:51 +02:00
})()}
2025-12-13 16:34:17 +02:00
</ div >
2025-12-07 19:32:53 +02:00
) : (
2026-02-07 03:57:46 +02:00
<>
2026-03-02 23:08:11 +00:00
{ sectionsForRender . map (( section ) => {
2026-01-06 21:31:04 +02:00
const project = section . project ;
const projectKey = project . id ;
const projectLabel = formatProjectLabel (
project . label ? . trim ()
|| formatDirectoryName ( project . normalizedPath , homeDirectory )
|| project . normalizedPath
);
const projectDescription = formatPathForDisplay ( project . normalizedPath , homeDirectory );
2026-02-07 03:57:46 +02:00
const isCollapsed = collapsedProjects . has ( projectKey ) && hideDirectoryControls ;
2026-01-06 21:31:04 +02:00
const isActiveProject = projectKey === activeProjectId ;
const isRepo = projectRepoStatus . get ( projectKey );
const isHovered = hoveredProjectId === projectKey ;
2026-02-07 03:57:46 +02:00
const orderedGroups = getOrderedGroups ( projectKey , section . groups );
2025-12-07 19:32:53 +02:00
2026-01-06 21:31:04 +02:00
return (
< SortableProjectItem
key = { projectKey }
id = { projectKey }
projectLabel = { projectLabel }
projectDescription = { projectDescription }
isCollapsed = { isCollapsed }
isActiveProject = { isActiveProject }
isRepo = { Boolean ( isRepo )}
isHovered = { isHovered }
2026-02-05 01:59:49 +02:00
isDesktopShell = { isDesktopShellRuntime }
2026-01-06 21:31:04 +02:00
isStuck = { stuckProjectHeaders . has ( projectKey )}
hideDirectoryControls = { hideDirectoryControls }
mobileVariant = { mobileVariant }
onToggle = {() => toggleProject ( projectKey )}
onHoverChange = {( hovered ) => setHoveredProjectId ( hovered ? projectKey : null )}
2026-01-08 00:29:20 +02:00
onNewSession = {() => {
if ( projectKey !== activeProjectId ) {
2026-02-25 14:32:23 +02:00
setActiveProjectIdOnly ( projectKey );
2026-01-08 00:29:20 +02:00
}
setActiveMainTab ( 'chat' );
if ( mobileVariant ) {
setSessionSwitcherOpen ( false );
}
2026-01-17 22:15:28 +02:00
openNewSessionDraft ({ directoryOverride : project.normalizedPath });
2026-01-08 00:29:20 +02:00
}}
2026-01-16 17:49:46 +01:00
onNewWorktreeSession = {() => {
if ( projectKey !== activeProjectId ) {
2026-02-25 14:32:23 +02:00
setActiveProjectIdOnly ( projectKey );
2026-01-16 17:49:46 +01:00
}
setActiveMainTab ( 'chat' );
if ( mobileVariant ) {
setSessionSwitcherOpen ( false );
}
createWorktreeSession ();
}}
2026-01-06 21:31:04 +02:00
onOpenMultiRunLauncher = {() => {
if ( projectKey !== activeProjectId ) {
2026-02-25 14:32:23 +02:00
setActiveProjectIdOnly ( projectKey );
2026-01-06 21:31:04 +02:00
}
openMultiRunLauncher ();
}}
2026-02-06 02:07:50 +02:00
onRenameStart = {() => {
setEditingProjectId ( projectKey );
setEditProjectTitle ( project . label ? . trim () || formatDirectoryName ( project . normalizedPath , homeDirectory ) || project . normalizedPath );
}}
onRenameSave = { handleSaveProjectEdit }
onRenameCancel = { handleCancelProjectEdit }
onRenameValueChange = { setEditProjectTitle }
renameValue = { editingProjectId === projectKey ? editProjectTitle : '' }
isRenaming = { editingProjectId === projectKey }
2026-01-07 12:08:53 +02:00
onClose = {() => removeProject ( projectKey )}
2026-01-06 21:31:04 +02:00
sentinelRef = {( el ) => { projectHeaderSentinelRefs . current . set ( projectKey , el ); }}
2026-01-17 11:00:21 +02:00
settingsAutoCreateWorktree = { settingsAutoCreateWorktree }
2026-02-07 03:57:46 +02:00
showCreateButtons = { false }
hideHeader
2026-01-06 21:31:04 +02:00
>
{ ! isCollapsed ? (
2026-02-07 03:57:46 +02:00
< div className = "space-y-[0.6rem] py-1" >
{ section . groups . length > 0 ? (
< DndContext
sensors = { sensors }
collisionDetection = { closestCenter }
onDragEnd = {( event ) => {
const { active , over } = event ;
if ( ! over || active . id === over . id ) {
return ;
}
const oldIndex = orderedGroups . findIndex (( item ) => item . id === active . id );
const newIndex = orderedGroups . findIndex (( item ) => item . id === over . id );
if ( oldIndex === - 1 || newIndex === - 1 || oldIndex === newIndex ) {
return ;
}
const next = arrayMove ( orderedGroups , oldIndex , newIndex ). map (( item ) => item . id );
setGroupOrderByProject (( prev ) => {
const map = new Map ( prev );
map . set ( projectKey , next );
return map ;
});
}}
>
< SortableContext
items = { orderedGroups . map (( group ) => group . id )}
strategy = { verticalListSortingStrategy }
>
{ orderedGroups . map (( group ) => {
const groupKey = ` ${ projectKey } : ${ group . id } ` ;
return (
< SortableGroupItem key = { group . id } id = { group . id }>
{ renderGroupSessions ( group , groupKey , projectKey )}
</ SortableGroupItem >
);
})}
</ SortableContext >
2026-02-25 14:32:23 +02:00
< DragOverlay dropAnimation = { null } />
2026-02-07 03:57:46 +02:00
</ DndContext >
) : (
2026-01-08 00:06:02 +02:00
< div className = "py-1 text-left typography-micro text-muted-foreground" >
No sessions yet .
</ div >
)}
2026-01-06 21:31:04 +02:00
</ div >
) : null }
</ SortableProjectItem >
);
})}
2026-02-07 03:57:46 +02:00
</>
2025-12-07 19:32:53 +02:00
)}
</ ScrollableOverlay >
2026-01-06 21:31:04 +02:00
2026-03-03 00:20:15 +02:00
< NewWorktreeDialog
open = { newWorktreeDialogOpen }
onOpenChange = { setNewWorktreeDialogOpen }
onWorktreeCreated = {( worktreePath , options ) => {
setActiveMainTab ( 'chat' );
if ( mobileVariant ) {
2026-02-06 02:29:26 -08:00
setSessionSwitcherOpen ( false );
}
2026-03-03 00:20:15 +02:00
if ( options ? . sessionId ) {
setCurrentSession ( options . sessionId );
return ;
2026-02-09 13:25:42 +02:00
}
2026-03-03 00:20:15 +02:00
openNewSessionDraft ({ directoryOverride : worktreePath });
2026-02-09 13:25:42 +02:00
}}
/>
2026-02-11 23:53:37 -08:00
{ useMobileNotesPanel ? (
< MobileOverlayPanel
open = { projectNotesPanelOpen }
onClose = {() => setProjectNotesPanelOpen ( false )}
title = "Project notes"
>
< ProjectNotesTodoPanel
projectRef = { activeProjectRefForHeader }
2026-02-16 14:15:19 +02:00
canCreateWorktree = { stableActiveProjectIsRepo }
2026-02-11 23:53:37 -08:00
onActionComplete = {() => setProjectNotesPanelOpen ( false )}
className = "p-0"
/>
</ MobileOverlayPanel >
) : null }
2026-02-23 03:56:38 +07:00
{ /* Confirm delete session dialog */ }
< Dialog open = { Boolean ( deleteSessionConfirm )} onOpenChange = {( open ) => { if ( ! open ) setDeleteSessionConfirm ( null ); }}>
< DialogContent showCloseButton = { false } className = "max-w-sm gap-5" >
< DialogHeader >
< DialogTitle > Delete session ? </ DialogTitle >
< DialogDescription >
{ deleteSessionConfirm && deleteSessionConfirm . descendantCount > 0
? `" ${ deleteSessionConfirm . session . title || 'Untitled Session' } " and its ${ deleteSessionConfirm . descendantCount } sub-task ${ deleteSessionConfirm . descendantCount === 1 ? '' : 's' } will be permanently deleted.`
: `" ${ deleteSessionConfirm ? . session . title || 'Untitled Session' } " will be permanently deleted.` }
</ DialogDescription >
</ DialogHeader >
2026-02-24 03:28:30 +02:00
< DialogFooter className = "w-full sm:items-center sm:justify-between" >
2026-02-23 03:56:38 +07:00
< button
type = "button"
2026-02-24 03:28:30 +02:00
onClick = {() => setShowDeletionDialog ( ! showDeletionDialog )}
className = "inline-flex items-center gap-1.5 typography-ui-label text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
aria-pressed = { ! showDeletionDialog }
2026-02-23 03:56:38 +07:00
>
2026-02-24 03:28:30 +02:00
{ ! showDeletionDialog ? < RiCheckboxLine className = "h-4 w-4 text-primary" /> : < RiCheckboxBlankLine className = "h-4 w-4" />}
Never ask
2026-02-23 03:56:38 +07:00
</ button >
2026-02-24 03:28:30 +02:00
< div className = "flex items-center gap-2" >
< button
type = "button"
onClick = {() => setDeleteSessionConfirm ( null )}
className = "inline-flex h-8 items-center justify-center rounded-md border border-border px-3 typography-ui-label text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
>
Cancel
</ button >
< button
type = "button"
onClick = {() => void confirmDeleteSession ()}
className = "inline-flex h-8 items-center justify-center rounded-md bg-destructive px-3 typography-ui-label text-destructive-foreground hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/50"
>
Delete
</ button >
</ div >
2026-02-23 03:56:38 +07:00
</ DialogFooter >
</ DialogContent >
</ Dialog >
{ /* Confirm delete folder dialog */ }
< Dialog open = { Boolean ( deleteFolderConfirm )} onOpenChange = {( open ) => { if ( ! open ) setDeleteFolderConfirm ( null ); }}>
< DialogContent showCloseButton = { false } className = "max-w-sm gap-5" >
< DialogHeader >
< DialogTitle > Delete folder ? </ DialogTitle >
< DialogDescription >
{ deleteFolderConfirm && ( deleteFolderConfirm . subFolderCount > 0 || deleteFolderConfirm . sessionCount > 0 )
? `" ${ deleteFolderConfirm . folderName } " will be deleted ${ deleteFolderConfirm . subFolderCount > 0 ? ` along with ${ deleteFolderConfirm . subFolderCount } sub-folder ${ deleteFolderConfirm . subFolderCount === 1 ? '' : 's' } ` : '' } . Sessions inside will not be deleted.`
: `" ${ deleteFolderConfirm ? . folderName } " will be permanently deleted.` }
</ DialogDescription >
</ DialogHeader >
< DialogFooter >
< button
type = "button"
onClick = {() => setDeleteFolderConfirm ( null )}
className = "inline-flex h-8 items-center justify-center rounded-md border border-border px-3 typography-ui-label text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
>
Cancel
</ button >
< button
type = "button"
onClick = { confirmDeleteFolder }
className = "inline-flex h-8 items-center justify-center rounded-md bg-destructive px-3 typography-ui-label text-destructive-foreground hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/50"
>
Delete
</ button >
</ DialogFooter >
</ DialogContent >
</ Dialog >
2025-12-07 19:32:53 +02:00
</ div >
);
};