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-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-07 03:57:46 +02:00
|
|
|
type Modifier,
|
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,
|
|
|
|
|
} 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-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';
|
2025-12-07 19:32:53 +02:00
|
|
|
import {
|
|
|
|
|
RiAddLine,
|
|
|
|
|
RiArrowDownSLine,
|
|
|
|
|
RiArrowRightSLine,
|
|
|
|
|
RiCheckLine,
|
|
|
|
|
RiCloseLine,
|
|
|
|
|
RiDeleteBinLine,
|
|
|
|
|
RiErrorWarningLine,
|
|
|
|
|
RiFileCopyLine,
|
2026-01-08 01:48:42 +02:00
|
|
|
RiFolderAddLine,
|
2026-01-08 00:06:02 +02:00
|
|
|
RiGitBranchLine,
|
2026-02-09 13:25:42 +02:00
|
|
|
RiGitPullRequestLine,
|
2025-12-07 19:32:53 +02:00
|
|
|
RiLinkUnlinkM,
|
2026-01-17 11:54:23 +01:00
|
|
|
|
2026-01-24 00:30:34 +02:00
|
|
|
RiGithubLine,
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
RiMore2Line,
|
|
|
|
|
RiPencilAiLine,
|
|
|
|
|
RiShare2Line,
|
2026-01-06 21:31:04 +02:00
|
|
|
RiShieldLine,
|
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-02-07 03:57:46 +02:00
|
|
|
import { createWorktreeOnly, createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
|
|
|
|
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
2026-02-08 16:51:05 -08:00
|
|
|
import { useGitStore } from '@/stores/useGitStore';
|
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-01-24 00:30:34 +02:00
|
|
|
import { GitHubIssuePickerDialog } from './GitHubIssuePickerDialog';
|
2026-02-09 13:25:42 +02:00
|
|
|
import { GitHubPullRequestPickerDialog } from './GitHubPullRequestPickerDialog';
|
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';
|
|
|
|
|
|
|
|
|
|
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(',', '');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const centerDragOverlayUnderPointer: Modifier = ({ transform, activeNodeRect, activatorEvent }) => {
|
|
|
|
|
if (!(activatorEvent instanceof MouseEvent) || !activeNodeRect) {
|
|
|
|
|
return transform;
|
|
|
|
|
}
|
|
|
|
|
const overlayHeight = 32;
|
|
|
|
|
const pointerLiftY = 16;
|
|
|
|
|
return {
|
|
|
|
|
...transform,
|
|
|
|
|
x: transform.x,
|
|
|
|
|
y: transform.y - overlayHeight / 2 - pointerLiftY,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
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
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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-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-24 00:30:34 +02:00
|
|
|
onNewSessionFromGitHubIssue?: () => void;
|
2026-02-09 13:25:42 +02:00
|
|
|
onNewSessionFromGitHubPR?: () => 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-24 00:30:34 +02:00
|
|
|
onNewSessionFromGitHubIssue,
|
2026-02-09 13:25:42 +02:00
|
|
|
onNewSessionFromGitHubPR,
|
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,
|
|
|
|
|
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 (
|
|
|
|
|
<div ref={setNodeRef} className={cn('relative', isDragging && 'opacity-40')}>
|
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',
|
|
|
|
|
!isDesktopShell && 'bg-sidebar',
|
|
|
|
|
)}
|
|
|
|
|
style={{
|
|
|
|
|
backgroundColor: isDesktopShell
|
|
|
|
|
? isStuck ? 'var(--sidebar-stuck-bg)' : 'transparent'
|
|
|
|
|
: 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 && onNewSessionFromGitHubIssue && (
|
2026-02-06 02:07:50 +02:00
|
|
|
<DropdownMenuItem onClick={onNewSessionFromGitHubIssue}>
|
|
|
|
|
<RiGithubLine className="mr-1.5 h-4 w-4" />
|
|
|
|
|
New session from GitHub issue
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
)}
|
2026-02-09 13:25:42 +02:00
|
|
|
{showCreateButtons && isRepo && !hideDirectoryControls && onNewSessionFromGitHubPR && (
|
|
|
|
|
<DropdownMenuItem onClick={onNewSessionFromGitHubPR}>
|
|
|
|
|
<RiGitPullRequestLine className="mr-1.5 h-4 w-4" />
|
|
|
|
|
New session from GitHub PR
|
|
|
|
|
</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}
|
|
|
|
|
style={{ transform: CSS.Transform.toString(transform), transition, willChange: 'transform' }}
|
|
|
|
|
className={cn(
|
|
|
|
|
'space-y-0.5 rounded-md',
|
|
|
|
|
isDragging && 'opacity-0',
|
|
|
|
|
)}
|
|
|
|
|
{...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);
|
|
|
|
|
|
|
|
|
|
const GroupDragOverlayBase: React.FC<{ label: string; showBranchIcon: boolean; width?: number }> = ({ label, showBranchIcon, width }) => {
|
|
|
|
|
return (
|
|
|
|
|
<div style={width ? { width: `${width}px` } : undefined} className="h-8 min-w-[180px] max-w-[320px] rounded-sm border border-border bg-sidebar px-2 shadow-lg flex items-center gap-1.5">
|
|
|
|
|
{showBranchIcon ? <RiGitBranchLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" /> : null}
|
|
|
|
|
<p className="text-[15px] font-semibold truncate text-foreground">{label}</p>
|
2026-01-06 21:31:04 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-07 03:57:46 +02:00
|
|
|
const GroupDragOverlay = React.memo(GroupDragOverlayBase);
|
|
|
|
|
|
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-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-01-01 20:39:05 +01:00
|
|
|
showOnlyMainWorkspace = false,
|
2025-12-13 16:34:17 +02:00
|
|
|
}) => {
|
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(),
|
|
|
|
|
);
|
|
|
|
|
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-01-24 00:30:34 +02:00
|
|
|
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
|
2026-02-09 13:25:42 +02:00
|
|
|
const [pullRequestPickerOpen, setPullRequestPickerOpen] = 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-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();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
const [activeDraggedGroupId, setActiveDraggedGroupId] = React.useState<string | null>(null);
|
|
|
|
|
const [activeDraggedGroupWidth, setActiveDraggedGroupWidth] = React.useState<number | null>(null);
|
|
|
|
|
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 setActiveProject = useProjectsStore((state) => state.setActiveProject);
|
|
|
|
|
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);
|
|
|
|
|
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
|
2026-01-01 14:59:51 +02:00
|
|
|
const openMultiRunLauncher = useUIStore((state) => state.openMultiRunLauncher);
|
2026-01-08 01:48:42 +02:00
|
|
|
const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
|
|
|
|
|
|
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);
|
|
|
|
|
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]);
|
|
|
|
|
|
|
|
|
|
const sortedSessions = React.useMemo(() => {
|
2026-01-12 21:40:14 +08:00
|
|
|
return [...sessions].sort((a, b) => (b.time?.updated || 0) - (a.time?.updated || 0));
|
2025-12-07 19:32:53 +02:00
|
|
|
}, [sessions]);
|
|
|
|
|
|
|
|
|
|
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-01-12 21:40:14 +08:00
|
|
|
map.forEach((list) => list.sort((a, b) => (b.time?.updated || 0) - (a.time?.updated || 0)));
|
2025-12-07 19:32:53 +02:00
|
|
|
return map;
|
|
|
|
|
}, [sortedSessions]);
|
|
|
|
|
|
|
|
|
|
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) => {
|
|
|
|
|
const known = directoryStatus.get(directory);
|
|
|
|
|
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-01-06 21:31:04 +02:00
|
|
|
}, [sortedSessions, projects, directoryStatus]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
return () => {
|
|
|
|
|
if (copyTimeout.current) {
|
|
|
|
|
clearTimeout(copyTimeout.current);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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-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) {
|
|
|
|
|
if (!allowReselect) {
|
|
|
|
|
onSessionSelected?.(sessionId);
|
|
|
|
|
}
|
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);
|
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,
|
|
|
|
|
mobileVariant,
|
|
|
|
|
onSessionSelected,
|
|
|
|
|
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,
|
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) => {
|
|
|
|
|
navigator.clipboard
|
|
|
|
|
.writeText(url)
|
|
|
|
|
.then(() => {
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
const handleDeleteSession = React.useCallback(
|
|
|
|
|
async (session: Session) => {
|
|
|
|
|
const descendants = collectDescendants(session.id);
|
2026-01-07 23:37:32 +02:00
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
if (descendants.length === 0) {
|
|
|
|
|
|
|
|
|
|
const success = await deleteSession(session.id);
|
|
|
|
|
if (success) {
|
2026-01-18 18:28:49 +06:00
|
|
|
toast.success('Session deleted', {
|
|
|
|
|
action: {
|
|
|
|
|
label: 'OK',
|
|
|
|
|
onClick: () => { },
|
|
|
|
|
},
|
|
|
|
|
});
|
2025-12-07 19:32:53 +02:00
|
|
|
} else {
|
|
|
|
|
toast.error('Failed to delete session');
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
|
|
const ids = [session.id, ...descendants.map((s) => s.id)];
|
|
|
|
|
const { deletedIds, failedIds } = await deleteSessions(ids);
|
|
|
|
|
if (deletedIds.length > 0) {
|
2026-01-18 18:28:49 +06:00
|
|
|
toast.success(`Deleted ${deletedIds.length} session${deletedIds.length === 1 ? '' : 's'}`, {
|
|
|
|
|
action: {
|
|
|
|
|
label: 'OK',
|
|
|
|
|
onClick: () => { },
|
|
|
|
|
},
|
|
|
|
|
});
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
if (failedIds.length > 0) {
|
|
|
|
|
toast.error(`Failed to delete ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-02-07 03:57:46 +02:00
|
|
|
[collectDescendants, deleteSession, deleteSessions],
|
2026-01-06 21:31:04 +02:00
|
|
|
);
|
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]);
|
|
|
|
|
|
|
|
|
|
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-01-12 21:40:14 +08:00
|
|
|
const sortedProjectSessions = [...projectSessions].sort((a, b) => (b.time?.updated || 0) - (a.time?.updated || 0));
|
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-01-12 21:40:14 +08:00
|
|
|
childrenMap.forEach((list) => list.sort((a, b) => (b.time?.updated || 0) - (a.time?.updated || 0)));
|
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-08 16:51:05 -08:00
|
|
|
const label = meta.label || meta.name || formatDirectoryName(directory, homeDirectory) || directory;
|
2026-02-07 03:57:46 +02:00
|
|
|
groups.push({
|
|
|
|
|
id: `worktree:${directory}`,
|
|
|
|
|
label,
|
2026-02-08 16:51:05 -08:00
|
|
|
branch: meta.branch || null,
|
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) => {
|
|
|
|
|
groups.push({
|
|
|
|
|
id: `worktree:orphan:${directory}`,
|
|
|
|
|
label: formatDirectoryName(directory, homeDirectory) || directory,
|
2026-02-08 16:51:05 -08:00
|
|
|
branch: null,
|
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
|
|
|
},
|
|
|
|
|
[homeDirectory, worktreeMetadata]
|
|
|
|
|
);
|
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-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
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const projectSections = React.useMemo(() => {
|
|
|
|
|
return normalizedProjects.map((project) => {
|
|
|
|
|
const projectSessions = getSessionsForProject(project);
|
|
|
|
|
const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? [];
|
2026-02-07 03:57:46 +02:00
|
|
|
const groups = buildGroupedSessions(
|
|
|
|
|
projectSessions,
|
|
|
|
|
project.normalizedPath,
|
|
|
|
|
worktreesForProject,
|
|
|
|
|
projectRootBranches.get(project.id) ?? null,
|
|
|
|
|
Boolean(projectRepoStatus.get(project.id)),
|
|
|
|
|
);
|
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]);
|
|
|
|
|
|
|
|
|
|
const activeProjectForHeader = React.useMemo(
|
|
|
|
|
() => normalizedProjects.find((project) => project.id === activeProjectId) ?? normalizedProjects[0] ?? null,
|
|
|
|
|
[normalizedProjects, activeProjectId],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const activeProjectIsRepo = React.useMemo(
|
|
|
|
|
() => (activeProjectForHeader ? Boolean(projectRepoStatus.get(activeProjectForHeader.id)) : false),
|
|
|
|
|
[activeProjectForHeader, projectRepoStatus],
|
|
|
|
|
);
|
|
|
|
|
const activeProjectRepoState = React.useMemo(
|
|
|
|
|
() => (activeProjectForHeader ? projectRepoStatus.get(activeProjectForHeader.id) : undefined),
|
|
|
|
|
[activeProjectForHeader, projectRepoStatus],
|
|
|
|
|
);
|
|
|
|
|
const reserveHeaderActionsSpace = activeProjectRepoState !== false;
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
React.useLayoutEffect(() => {
|
2026-02-11 19:28:22 +02:00
|
|
|
// While a new session draft is open, keep the sidebar from auto-selecting remembered/fallback sessions.
|
|
|
|
|
// This is especially important in VS Code where the sidebar view is frequently mounted/unmounted.
|
|
|
|
|
if (newSessionDraftOpen) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-07 03:57:46 +02:00
|
|
|
if (!activeProjectId || previousActiveProjectRef.current === activeProjectId) {
|
|
|
|
|
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 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]);
|
|
|
|
|
|
|
|
|
|
const headerActionButtonClass =
|
|
|
|
|
'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';
|
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;
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
const isExpanded = expandedParents.has(session.id);
|
2026-02-07 03:57:46 +02:00
|
|
|
const needsAttention = sessionAttentionStates.get(session.id)?.needsAttention === true;
|
|
|
|
|
const sessionSummary = session.summary as
|
|
|
|
|
| {
|
|
|
|
|
additions?: number | string | null;
|
|
|
|
|
deletions?: number | string | null;
|
|
|
|
|
diffs?: Array<{ additions?: number | string | null; deletions?: number | string | null }>;
|
|
|
|
|
}
|
|
|
|
|
| undefined;
|
|
|
|
|
const diffTotals = sessionSummary?.diffs?.reduce<{ additions: number; deletions: number }>(
|
|
|
|
|
(acc, diff) => ({
|
|
|
|
|
additions: acc.additions + (toFiniteNumber(diff?.additions) ?? 0),
|
|
|
|
|
deletions: acc.deletions + (toFiniteNumber(diff?.deletions) ?? 0),
|
|
|
|
|
}),
|
|
|
|
|
{ additions: 0, deletions: 0 },
|
|
|
|
|
);
|
|
|
|
|
const additions = toFiniteNumber(sessionSummary?.additions) ?? diffTotals?.additions;
|
|
|
|
|
const deletions = toFiniteNumber(sessionSummary?.deletions) ?? diffTotals?.deletions;
|
2025-12-07 19:32:53 +02:00
|
|
|
const hasSummary = typeof additions === 'number' || typeof deletions === 'number';
|
|
|
|
|
|
|
|
|
|
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}
|
|
|
|
|
<span className="flex-shrink-0">{formatDateLabel(session.time?.created || Date.now())}</span>
|
|
|
|
|
{session.share ? (
|
|
|
|
|
<RiShare2Line className="h-3 w-3 text-[color:var(--status-info)] flex-shrink-0" />
|
|
|
|
|
) : null}
|
|
|
|
|
{hasSummary && ((additions ?? 0) !== 0 || (deletions ?? 0) !== 0) ? (
|
|
|
|
|
<span className="flex-shrink-0 text-[0.7rem] leading-none">
|
|
|
|
|
<span className="text-[color:var(--status-success)]">+{Math.max(0, additions ?? 0)}</span>
|
|
|
|
|
<span className="text-muted-foreground/50">/</span>
|
|
|
|
|
<span className="text-destructive">-{Math.max(0, deletions ?? 0)}</span>
|
|
|
|
|
</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}>
|
|
|
|
|
<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-01-30 02:56:16 +02:00
|
|
|
<div className="flex w-full items-center gap-2 min-w-0 flex-1 overflow-hidden">
|
2026-02-07 03:57:46 +02:00
|
|
|
{showStatusMarker ? (
|
|
|
|
|
<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-01-30 02:56:16 +02:00
|
|
|
<div className="block min-w-0 flex-1 truncate typography-ui-label font-normal text-foreground">
|
2025-12-07 19:32:53 +02:00
|
|
|
{sessionTitle}
|
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}
|
|
|
|
|
<span className="flex-shrink-0">{formatDateLabel(session.time?.created || Date.now())}</span>
|
|
|
|
|
{session.share ? (
|
|
|
|
|
<RiShare2Line className="h-3 w-3 text-[color:var(--status-info)] flex-shrink-0" />
|
|
|
|
|
) : null}
|
|
|
|
|
{hasSummary && ((additions ?? 0) !== 0 || (deletions ?? 0) !== 0) ? (
|
|
|
|
|
<span className="flex-shrink-0 text-[0.7rem] leading-none">
|
|
|
|
|
<span className="text-[color:var(--status-success)]">+{Math.max(0, additions ?? 0)}</span>
|
|
|
|
|
<span className="text-muted-foreground/50">/</span>
|
|
|
|
|
<span className="text-destructive">-{Math.max(0, deletions ?? 0)}</span>
|
|
|
|
|
</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>
|
|
|
|
|
<DropdownMenuContent align="end" className="min-w-[180px]">
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setEditingId(session.id);
|
|
|
|
|
setEditTitle(sessionTitle);
|
|
|
|
|
}}
|
|
|
|
|
className="[&>svg]:mr-1"
|
|
|
|
|
>
|
|
|
|
|
<RiPencilAiLine className="mr-1 h-4 w-4" />
|
|
|
|
|
Rename
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
{!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>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
<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>
|
|
|
|
|
{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,
|
|
|
|
|
expandedParents,
|
|
|
|
|
editingId,
|
|
|
|
|
editTitle,
|
|
|
|
|
handleSaveEdit,
|
|
|
|
|
handleCancelEdit,
|
|
|
|
|
toggleParent,
|
|
|
|
|
handleSessionSelect,
|
2026-02-11 10:08:15 -08:00
|
|
|
handleSessionDoubleClick,
|
2025-12-07 19:32:53 +02:00
|
|
|
handleShareSession,
|
|
|
|
|
handleCopyShareUrl,
|
|
|
|
|
handleUnshareSession,
|
|
|
|
|
handleDeleteSession,
|
|
|
|
|
copiedSessionId,
|
|
|
|
|
mobileVariant,
|
2026-01-16 17:45:37 +01:00
|
|
|
openMenuSessionId,
|
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-01-06 21:31:04 +02:00
|
|
|
const isExpanded = expandedSessionGroups.has(groupKey);
|
2026-02-07 03:57:46 +02:00
|
|
|
const isCollapsed = collapsedGroups.has(groupKey);
|
2026-01-06 21:31:04 +02:00
|
|
|
const maxVisible = hideDirectoryControls ? 10 : 5;
|
|
|
|
|
const totalSessions = group.sessions.length;
|
|
|
|
|
const visibleSessions = isExpanded ? group.sessions : group.sessions.slice(0, maxVisible);
|
|
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
const allGroupSessions = collectGroupSessions(group.sessions);
|
|
|
|
|
const normalizedGroupDirectory = normalizePath(group.directory ?? null);
|
|
|
|
|
const isGitProject = Boolean(projectId && projectRepoStatus.get(projectId));
|
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-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.
|
|
|
|
|
if (hideGroupLabel) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="oc-group">
|
|
|
|
|
<div className="oc-group-body pb-3">
|
|
|
|
|
{visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId))}
|
|
|
|
|
{totalSessions === 0 ? (
|
|
|
|
|
<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}
|
|
|
|
|
</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(
|
|
|
|
|
"group/gh flex items-center justify-between gap-2 py-1 min-w-0 rounded-sm",
|
|
|
|
|
!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 ? (
|
|
|
|
|
<div className="min-w-0 flex items-center gap-1.5 px-0">
|
|
|
|
|
{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" />
|
|
|
|
|
)}
|
|
|
|
|
{!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">
|
|
|
|
|
<p className={cn('text-[15px] font-semibold truncate', isActiveGroup ? 'text-primary' : 'text-muted-foreground')}>
|
|
|
|
|
{group.label}
|
|
|
|
|
</p>
|
|
|
|
|
{showBranchSubtitle ? (
|
|
|
|
|
<span className="text-[10px] sm:text-[11px] text-muted-foreground/80 truncate leading-tight">
|
|
|
|
|
{group.branch}
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
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 ? (
|
|
|
|
|
<div className="flex items-center gap-1 px-0.5">
|
|
|
|
|
{!group.isMain && group.worktree ? (
|
|
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
sessionEvents.requestDelete({
|
|
|
|
|
sessions: allGroupSessions,
|
|
|
|
|
mode: 'worktree',
|
|
|
|
|
worktree: group.worktree,
|
|
|
|
|
});
|
|
|
|
|
}}
|
|
|
|
|
className={cn(
|
|
|
|
|
'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 transition-opacity',
|
|
|
|
|
mobileVariant ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100',
|
|
|
|
|
)}
|
|
|
|
|
aria-label={`Delete ${group.label}`}
|
|
|
|
|
>
|
|
|
|
|
<RiDeleteBinLine className="h-4 w-4" />
|
|
|
|
|
</button>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent side="bottom" sideOffset={4}>
|
|
|
|
|
<p>Delete worktree</p>
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
) : null}
|
|
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
if (projectId && projectId !== activeProjectId) {
|
|
|
|
|
setActiveProject(projectId);
|
|
|
|
|
}
|
|
|
|
|
setActiveMainTab('chat');
|
|
|
|
|
if (mobileVariant) {
|
|
|
|
|
setSessionSwitcherOpen(false);
|
|
|
|
|
}
|
|
|
|
|
openNewSessionDraft({ directoryOverride: group.directory });
|
|
|
|
|
}}
|
|
|
|
|
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}`}
|
|
|
|
|
>
|
|
|
|
|
<RiAddLine className="h-4 w-4" />
|
|
|
|
|
</button>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent side="bottom" sideOffset={4}>
|
|
|
|
|
<p>New session</p>
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
{!isCollapsed ? (
|
|
|
|
|
<div className="oc-group-body pb-3">
|
|
|
|
|
{visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId))}
|
|
|
|
|
{totalSessions === 0 ? (
|
|
|
|
|
<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-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,
|
|
|
|
|
currentSessionDirectory,
|
|
|
|
|
projectRepoStatus,
|
|
|
|
|
renderSessionNode,
|
|
|
|
|
toggleGroupSessionLimit,
|
|
|
|
|
activeProjectId,
|
|
|
|
|
setActiveProject,
|
|
|
|
|
setActiveMainTab,
|
|
|
|
|
mobileVariant,
|
|
|
|
|
setSessionSwitcherOpen,
|
|
|
|
|
openNewSessionDraft,
|
|
|
|
|
]
|
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
|
|
|
|
|
className={cn(
|
|
|
|
|
'flex h-full flex-col text-foreground overflow-x-hidden',
|
2026-01-25 19:16:26 +02:00
|
|
|
mobileVariant ? '' : 'bg-sidebar',
|
2025-12-07 19:32:53 +02:00
|
|
|
)}
|
|
|
|
|
>
|
2025-12-13 16:34:17 +02:00
|
|
|
{!hideDirectoryControls && (
|
2026-02-07 03:57:46 +02:00
|
|
|
<div className="select-none pl-3.5 pr-2 py-1.5 flex-shrink-0 border-b border-border/60">
|
|
|
|
|
<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}
|
|
|
|
|
onClick={() => setActiveProject(project.id)}
|
|
|
|
|
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}
|
2025-12-07 19:32:53 +02:00
|
|
|
className={cn(
|
2026-02-01 18:29:34 +02:00
|
|
|
'inline-flex h-8 w-8 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',
|
2026-02-05 01:59:49 +02:00
|
|
|
!isDesktopShellRuntime && 'bg-sidebar/60 hover:bg-sidebar',
|
2025-12-07 19:32:53 +02:00
|
|
|
)}
|
2026-01-06 21:31:04 +02:00
|
|
|
aria-label="Add project"
|
|
|
|
|
title="Add project"
|
2025-12-07 19:32:53 +02:00
|
|
|
>
|
2026-01-08 01:48:42 +02:00
|
|
|
<RiFolderAddLine className="h-4.5 w-4.5" />
|
2025-12-07 19:32:53 +02:00
|
|
|
</button>
|
2025-12-13 16:34:17 +02:00
|
|
|
</div>
|
2026-02-07 03:57:46 +02:00
|
|
|
{reserveHeaderActionsSpace ? (
|
|
|
|
|
<div className="mt-1 h-8 pl-1">
|
|
|
|
|
{activeProjectIsRepo ? (
|
|
|
|
|
<div className="inline-flex h-8 items-center gap-1.5 rounded-md pl-0 pr-1">
|
|
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
if (!activeProjectForHeader) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (activeProjectForHeader.id !== activeProjectId) {
|
|
|
|
|
setActiveProject(activeProjectForHeader.id);
|
|
|
|
|
}
|
|
|
|
|
const newWorktreePath = await createWorktreeOnly();
|
|
|
|
|
if (!newWorktreePath) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setActiveMainTab('chat');
|
|
|
|
|
if (mobileVariant) {
|
|
|
|
|
setSessionSwitcherOpen(false);
|
|
|
|
|
}
|
|
|
|
|
openNewSessionDraft({ directoryOverride: newWorktreePath });
|
|
|
|
|
}}
|
|
|
|
|
className={headerActionButtonClass}
|
|
|
|
|
aria-label="New worktree"
|
|
|
|
|
>
|
|
|
|
|
<RiGitBranchLine className="h-4.5 w-4.5" />
|
|
|
|
|
</button>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent side="bottom" sideOffset={4}><p>New worktree</p></TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setIssuePickerOpen(true)}
|
|
|
|
|
className={headerActionButtonClass}
|
|
|
|
|
aria-label="New from issue"
|
|
|
|
|
>
|
|
|
|
|
<RiGithubLine className="h-4.5 w-4.5" />
|
|
|
|
|
</button>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent side="bottom" sideOffset={4}><p>New from issue</p></TooltipContent>
|
|
|
|
|
</Tooltip>
|
2026-02-09 13:25:42 +02:00
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setPullRequestPickerOpen(true)}
|
|
|
|
|
className={headerActionButtonClass}
|
|
|
|
|
aria-label="New from PR"
|
|
|
|
|
>
|
|
|
|
|
<RiGitPullRequestLine className="h-4.5 w-4.5" />
|
|
|
|
|
</button>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent side="bottom" sideOffset={4}><p>New from PR</p></TooltipContent>
|
|
|
|
|
</Tooltip>
|
2026-02-07 03:57:46 +02:00
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={openMultiRunLauncher}
|
|
|
|
|
className={headerActionButtonClass}
|
|
|
|
|
aria-label="New multi-run"
|
|
|
|
|
>
|
|
|
|
|
<ArrowsMerge className="h-4.5 w-4.5" />
|
|
|
|
|
</button>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent side="bottom" sideOffset={4}><p>New multi-run</p></TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</div>
|
|
|
|
|
) : 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-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-01-06 21:31:04 +02:00
|
|
|
const activeSection = projectSections.find((section) => section.project.id === activeProjectId) ?? projectSections[0];
|
|
|
|
|
if (!activeSection) {
|
|
|
|
|
return emptyState;
|
|
|
|
|
}
|
|
|
|
|
// 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
|
|
|
<>
|
|
|
|
|
{visibleProjectSections.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) {
|
|
|
|
|
setActiveProject(projectKey);
|
|
|
|
|
}
|
|
|
|
|
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) {
|
|
|
|
|
setActiveProject(projectKey);
|
|
|
|
|
}
|
|
|
|
|
setActiveMainTab('chat');
|
|
|
|
|
if (mobileVariant) {
|
|
|
|
|
setSessionSwitcherOpen(false);
|
|
|
|
|
}
|
|
|
|
|
createWorktreeSession();
|
|
|
|
|
}}
|
2026-01-24 00:30:34 +02:00
|
|
|
onNewSessionFromGitHubIssue={() => {
|
|
|
|
|
if (projectKey !== activeProjectId) {
|
|
|
|
|
setActiveProject(projectKey);
|
|
|
|
|
}
|
|
|
|
|
setIssuePickerOpen(true);
|
|
|
|
|
}}
|
2026-02-09 13:25:42 +02:00
|
|
|
onNewSessionFromGitHubPR={() => {
|
|
|
|
|
if (projectKey !== activeProjectId) {
|
|
|
|
|
setActiveProject(projectKey);
|
|
|
|
|
}
|
|
|
|
|
setPullRequestPickerOpen(true);
|
|
|
|
|
}}
|
2026-01-06 21:31:04 +02:00
|
|
|
onOpenMultiRunLauncher={() => {
|
|
|
|
|
if (projectKey !== activeProjectId) {
|
|
|
|
|
setActiveProject(projectKey);
|
|
|
|
|
}
|
|
|
|
|
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}
|
|
|
|
|
onDragStart={(event) => {
|
|
|
|
|
setActiveDraggedGroupId(String(event.active.id));
|
|
|
|
|
const width = (event.active.rect.current.initial?.width ?? null);
|
|
|
|
|
setActiveDraggedGroupWidth(typeof width === 'number' ? width : null);
|
|
|
|
|
}}
|
|
|
|
|
onDragCancel={() => {
|
|
|
|
|
setActiveDraggedGroupId(null);
|
|
|
|
|
setActiveDraggedGroupWidth(null);
|
|
|
|
|
}}
|
|
|
|
|
onDragEnd={(event) => {
|
|
|
|
|
const { active, over } = event;
|
|
|
|
|
setActiveDraggedGroupId(null);
|
|
|
|
|
setActiveDraggedGroupWidth(null);
|
|
|
|
|
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>
|
|
|
|
|
<DragOverlay modifiers={[centerDragOverlayUnderPointer]} dropAnimation={null}>
|
|
|
|
|
{activeDraggedGroupId ? (
|
|
|
|
|
(() => {
|
|
|
|
|
const dragGroup = orderedGroups.find((group) => group.id === activeDraggedGroupId);
|
|
|
|
|
if (!dragGroup) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const showBranchIcon = !dragGroup.isMain || Boolean(isRepo);
|
|
|
|
|
return <GroupDragOverlay label={dragGroup.label} showBranchIcon={showBranchIcon} width={activeDraggedGroupWidth ?? undefined} />;
|
|
|
|
|
})()
|
|
|
|
|
) : null}
|
|
|
|
|
</DragOverlay>
|
|
|
|
|
</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-01-24 00:30:34 +02:00
|
|
|
<GitHubIssuePickerDialog
|
|
|
|
|
open={issuePickerOpen}
|
2026-02-06 02:29:26 -08:00
|
|
|
onOpenChange={(open) => {
|
|
|
|
|
setIssuePickerOpen(open);
|
|
|
|
|
if (!open && mobileVariant) {
|
|
|
|
|
setActiveMainTab('chat');
|
|
|
|
|
setSessionSwitcherOpen(false);
|
|
|
|
|
}
|
|
|
|
|
}}
|
2026-01-24 00:30:34 +02:00
|
|
|
/>
|
2026-02-09 13:25:42 +02:00
|
|
|
|
|
|
|
|
<GitHubPullRequestPickerDialog
|
|
|
|
|
open={pullRequestPickerOpen}
|
|
|
|
|
onOpenChange={(open) => {
|
|
|
|
|
setPullRequestPickerOpen(open);
|
|
|
|
|
if (!open && mobileVariant) {
|
|
|
|
|
setActiveMainTab('chat');
|
|
|
|
|
setSessionSwitcherOpen(false);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2025-12-07 19:32:53 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|