diff --git a/bun.lock b/bun.lock index 9e37394a..a488d8ed 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.2.17", + "@opencode-ai/sdk": "^1.2.20", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -98,7 +98,7 @@ }, "packages/desktop": { "name": "@openchamber/desktop", - "version": "1.8.4", + "version": "1.8.5", "devDependencies": { "@tauri-apps/cli": "^2", "@types/node": "^24.3.1", @@ -107,7 +107,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.8.4", + "version": "1.8.5", "dependencies": { "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.1", @@ -137,7 +137,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.2.17", + "@opencode-ai/sdk": "^1.2.20", "@pierre/diffs": "1.1.0-beta.13", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", @@ -211,10 +211,10 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.8.4", + "version": "1.8.5", "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.2.17", + "@opencode-ai/sdk": "^1.2.20", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", @@ -234,7 +234,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.8.4", + "version": "1.8.5", "bin": { "openchamber": "./bin/cli.js", }, @@ -245,7 +245,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.2.17", + "@opencode-ai/sdk": "^1.2.20", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -836,7 +836,7 @@ "@openchamber/web": ["@openchamber/web@workspace:packages/web"], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.17", "", {}, "sha512-HdeLeyJ2/Yl/NBHqw9pGFBnkIXuf0Id1kX1GMXDcnZwbJROUJ6TtrW/wLngTYW478E4CCm1jwknjxxmDuxzVMQ=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.20", "", {}, "sha512-U5ROpG21D8jg9rkc1IgKAk1g5dn6X/rkOBfveupd0peSDO9n6VM9aikYccVLaMObxVqdjtG08IeQOFTPVS8ySQ=="], "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.13", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-D35rxDu5V7XHX5aVGU6PF12GhscL+I+9QYgxK/i3h0d2XSirAxDdVNm49aYwlOhgmdvL0NbS1IHxPswVB5yJvw=="], diff --git a/package.json b/package.json index 135298ca..f53694c3 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.2.17", + "@opencode-ai/sdk": "^1.2.20", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/ui/package.json b/packages/ui/package.json index cd839a22..88766d09 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -39,7 +39,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.2.17", + "@opencode-ai/sdk": "^1.2.20", "@pierre/diffs": "1.1.0-beta.13", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 2bdd29cb..2e349a40 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -19,6 +19,7 @@ import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; import { usePwaManifestSync } from '@/hooks/usePwaManifestSync'; import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt'; import { useWindowTitle } from '@/hooks/useWindowTitle'; +import { useGitHubPrBackgroundTracking } from '@/hooks/useGitHubPrBackgroundTracking'; import { GitPollingProvider } from '@/hooks/useGitPolling'; import { useConfigStore } from '@/stores/useConfigStore'; import { hasModifier } from '@/lib/utils'; @@ -133,6 +134,8 @@ function App({ apis }: AppProps) { void refreshGitHubAuthStatus(apis.github, { force: true }); }, [apis.github, embeddedSessionChat, refreshGitHubAuthStatus]); + useGitHubPrBackgroundTracking(embeddedBackgroundWorkEnabled ? apis.github : undefined, apis.git); + React.useEffect(() => { if (typeof document === 'undefined') { return; diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index f80aad40..7dfe0399 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -979,7 +979,7 @@ const ChatMessage: React.FC = ({
-
+
{ const { deleteSession, deleteSessions, + archiveSession, + archiveSessions, loadSessions, getWorktreeMetadata, } = useSessionStore(); @@ -423,15 +425,17 @@ export const SessionDialogs: React.FC = () => { if (deleteDialog.sessions.length === 1) { const target = deleteDialog.sessions[0]; - const success = await deleteSession(target.id, { - // In "worktree" mode, remove the selected worktree explicitly below. - // Don't try to derive worktree removal from per-session metadata (may be missing). - archiveWorktree: isWorktreeDelete ? false : shouldArchive, - deleteRemoteBranch: removeRemoteBranch, - deleteLocalBranch, - }); + const success = isWorktreeDelete + ? await archiveSession(target.id) + : await deleteSession(target.id, { + // In "worktree" mode, remove the selected worktree explicitly below. + // Don't try to derive worktree removal from per-session metadata (may be missing). + archiveWorktree: false, + deleteRemoteBranch: removeRemoteBranch, + deleteLocalBranch, + }); if (!success) { - toast.error('Failed to delete session'); + toast.error(isWorktreeDelete ? 'Failed to archive session' : 'Failed to delete session'); setIsProcessingDelete(false); return; } @@ -440,7 +444,7 @@ export const SessionDialogs: React.FC = () => { ? 'Worktree and remote branch removed.' : 'Attached worktree archived.' : undefined; - toast.success('Session deleted', { + toast.success(isWorktreeDelete ? 'Session archived' : 'Session deleted', { description: renderToastDescription(archiveNote), action: { label: 'OK', @@ -449,11 +453,21 @@ export const SessionDialogs: React.FC = () => { }); } else { const ids = deleteDialog.sessions.map((session) => session.id); - const { deletedIds, failedIds } = await deleteSessions(ids, { - archiveWorktree: isWorktreeDelete ? false : shouldArchive, - deleteRemoteBranch: removeRemoteBranch, - deleteLocalBranch, - }); + let deletedIds: string[] = []; + let failedIds: string[] = []; + if (isWorktreeDelete) { + const result = await archiveSessions(ids); + deletedIds = result.archivedIds; + failedIds = result.failedIds; + } else { + const result = await deleteSessions(ids, { + archiveWorktree: false, + deleteRemoteBranch: removeRemoteBranch, + deleteLocalBranch, + }); + deletedIds = result.deletedIds; + failedIds = result.failedIds; + } if (isWorktreeDelete && deleteDialog.worktree && failedIds.length === 0) { // Remove selected worktree even if per-session metadata is missing. @@ -472,12 +486,12 @@ export const SessionDialogs: React.FC = () => { : undefined; const successDescription = failedIds.length > 0 - ? `${failedIds.length} session${failedIds.length === 1 ? '' : 's'} could not be deleted.` + ? `${failedIds.length} session${failedIds.length === 1 ? '' : 's'} could not be ${isWorktreeDelete ? 'archived' : 'deleted'}.` : deleteDialog.dateLabel ? `Removed all sessions from ${deleteDialog.dateLabel}.` : undefined; const combinedDescription = [successDescription, archiveNote].filter(Boolean).join(' '); - toast.success(`Deleted ${deletedIds.length} session${deletedIds.length === 1 ? '' : 's'}`, { + toast.success(`${isWorktreeDelete ? 'Archived' : 'Deleted'} ${deletedIds.length} session${deletedIds.length === 1 ? '' : 's'}`, { description: renderToastDescription(combinedDescription || undefined), action: { label: 'OK', @@ -487,7 +501,7 @@ export const SessionDialogs: React.FC = () => { } if (failedIds.length > 0) { - toast.error(`Failed to delete ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`, { + toast.error(`Failed to ${isWorktreeDelete ? 'archive' : 'delete'} ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`, { description: renderToastDescription('Please try again in a moment.'), }); if (deletedIds.length === 0) { @@ -514,6 +528,8 @@ export const SessionDialogs: React.FC = () => { deleteDialogShouldDeleteLocalBranch, deleteSession, deleteSessions, + archiveSession, + archiveSessions, closeDeleteDialog, shouldArchiveWorktree, isWorktreeDelete, @@ -527,7 +543,7 @@ export const SessionDialogs: React.FC = () => { ? deleteDialog.mode === 'worktree' ? deleteDialog.sessions.length === 0 ? 'This removes the selected worktree.' - : `This removes the selected worktree and ${deleteDialog.sessions.length === 1 ? '1 linked session' : `${deleteDialog.sessions.length} linked sessions`}.` + : `This removes the selected worktree and archives ${deleteDialog.sessions.length === 1 ? '1 linked session' : `${deleteDialog.sessions.length} linked sessions`}.` : `This action permanently removes ${deleteDialog.sessions.length === 1 ? '1 session' : `${deleteDialog.sessions.length} sessions`}${deleteDialog.dateLabel ? ` from ${deleteDialog.dateLabel}` : '' }.` : ''; diff --git a/packages/ui/src/components/session/SessionFolderItem.tsx b/packages/ui/src/components/session/SessionFolderItem.tsx index 24b201fe..87890f45 100644 --- a/packages/ui/src/components/session/SessionFolderItem.tsx +++ b/packages/ui/src/components/session/SessionFolderItem.tsx @@ -28,6 +28,7 @@ interface SessionFolderItemProps { depth?: number, groupDir?: string | null, projectId?: string | null, + archivedBucket?: boolean, ) => React.ReactNode; groupDirectory?: string | null; projectId?: string | null; @@ -47,6 +48,10 @@ interface SessionFolderItemProps { onNewSubFolder?: () => void; /** Visual indent depth (0 = root folder, 1 = sub-folder) */ depth?: number; + /** Hide folder action buttons (rename/delete/new) */ + hideActions?: boolean; + /** Whether folder belongs to archived section */ + archivedBucket?: boolean; } const SessionFolderItemBase = ({ @@ -71,6 +76,8 @@ const SessionFolderItemBase = ({ onNewSession, onNewSubFolder, depth = 0, + hideActions = false, + archivedBucket = false, }: SessionFolderItemProps) => { const [localRenaming, setLocalRenaming] = React.useState(false); const [localDraft, setLocalDraft] = React.useState(''); @@ -228,7 +235,7 @@ const SessionFolderItemBase = ({
{/* Action buttons */} - {!renaming ? ( + {!renaming && !hideActions ? (
({ {/* Then sessions */} {sessions.length > 0 ? ( sessions.map((node) => - renderSessionNode(node, 0, groupDirectory ?? null, projectId ?? null), + renderSessionNode(node, 0, groupDirectory ?? null, projectId ?? null, archivedBucket), ) ) : !subFolderItems ? (
diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 39646c1f..12c1baa4 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -1,106 +1,56 @@ import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; import { toast } from '@/components/ui'; -import { copyTextToClipboard } from '@/lib/clipboard'; import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/desktop'; -import { - DndContext, - DragOverlay, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - useDraggable, - useDroppable, - type DragEndEvent, -} from '@dnd-kit/core'; -import { - SortableContext, - arrayMove, - sortableKeyboardCoordinates, - useSortable, - verticalListSortingStrategy, -} from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - DropdownMenuSub, - DropdownMenuSubTrigger, - DropdownMenuSubContent, - DropdownMenuSeparator, -} from '@/components/ui/dropdown-menu'; - -import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from '@/components/ui/dialog'; -import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; -import { GridLoader } from '@/components/ui/grid-loader'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; -import { - RiAddLine, - RiArrowDownSLine, - RiArrowRightSLine, - RiChat4Line, - RiCheckboxBlankLine, - RiCheckboxLine, - RiCheckLine, - RiCloseLine, - RiDeleteBinLine, - RiErrorWarningLine, - RiFileCopyLine, - RiFolderAddLine, - RiFolderLine, - RiGitBranchLine, - RiNodeTree, - RiStickyNoteLine, - RiLinkUnlinkM, - RiMore2Line, - RiPencilAiLine, - RiPushpinLine, - RiSearchLine, - RiShare2Line, - RiShieldLine, - RiUnpinLine, -} from '@remixicon/react'; import { sessionEvents } from '@/lib/sessionEvents'; -import { ArrowsMerge } from '@/components/icons/ArrowsMerge'; -import { formatDirectoryName, formatPathForDisplay, cn } from '@/lib/utils'; +import { formatDirectoryName, cn } from '@/lib/utils'; import { useSessionStore } from '@/stores/useSessionStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useUIStore } from '@/stores/useUIStore'; import { useConfigStore } from '@/stores/useConfigStore'; -import type { WorktreeMetadata } from '@/types/worktree'; -import { opencodeClient } from '@/lib/opencode/client'; -import { checkIsGitRepository } from '@/lib/gitApi'; +import type { GitHubPullRequestStatus } from '@/lib/api/types'; import { getSafeStorage } from '@/stores/utils/safeStorage'; import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; -import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { useGitStore } from '@/stores/useGitStore'; import { useDeviceInfo } from '@/lib/device'; import { isVSCodeRuntime } from '@/lib/desktop'; -import { updateDesktopSettings } from '@/lib/persistence'; import { NewWorktreeDialog } from './NewWorktreeDialog'; import { ProjectNotesTodoPanel } from './ProjectNotesTodoPanel'; import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore'; -import { SessionFolderItem } from './SessionFolderItem'; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; - -const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]); - -const getAttentionDiamondDelay = (index: number): string => { - return index === 4 ? '0ms' : '130ms'; -}; +import { useArchivedAutoFolders } from './sidebar/hooks/useArchivedAutoFolders'; +import { useSessionSidebarSections } from './sidebar/hooks/useSessionSidebarSections'; +import { useProjectSessionSelection } from './sidebar/hooks/useProjectSessionSelection'; +import { useGroupOrdering } from './sidebar/hooks/useGroupOrdering'; +import { useSessionGrouping } from './sidebar/hooks/useSessionGrouping'; +import { useSessionSearchEffects } from './sidebar/hooks/useSessionSearchEffects'; +import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch'; +import { useDirectoryStatusProbe } from './sidebar/hooks/useDirectoryStatusProbe'; +import { useSessionActions } from './sidebar/hooks/useSessionActions'; +import { useSidebarPersistence } from './sidebar/hooks/useSidebarPersistence'; +import { useProjectRepoStatus } from './sidebar/hooks/useProjectRepoStatus'; +import { useProjectSessionLists } from './sidebar/hooks/useProjectSessionLists'; +import { useSessionFolderCleanup } from './sidebar/hooks/useSessionFolderCleanup'; +import { useStickyProjectHeaders } from './sidebar/hooks/useStickyProjectHeaders'; +import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; +import { SessionGroupSection } from './sidebar/SessionGroupSection'; +import { SidebarHeader } from './sidebar/SidebarHeader'; +import { SidebarProjectsList } from './sidebar/SidebarProjectsList'; +import { SessionNodeItem } from './sidebar/SessionNodeItem'; +import { + FolderDeleteConfirmDialog, + SessionDeleteConfirmDialog, + type DeleteFolderConfirmState, + type DeleteSessionConfirmState, +} from './sidebar/ConfirmDialogs'; +import { type SessionGroup, type SessionNode } from './sidebar/types'; +import { + compareSessionsByPinnedAndTime, + formatProjectLabel, + normalizePath, +} from './sidebar/utils'; const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse'; const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder'; @@ -109,649 +59,71 @@ const PROJECT_ACTIVE_SESSION_STORAGE_KEY = 'oc.sessions.activeSessionByProject'; const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents'; const SESSION_PINNED_STORAGE_KEY = 'oc.sessions.pinned'; -const SESSION_PREFETCH_HOVER_DELAY_MS = 180; -const SESSION_PREFETCH_CONCURRENCY = 1; -const SESSION_PREFETCH_PENDING_LIMIT = 6; +type PrVisualState = 'draft' | 'open' | 'blocked' | 'merged' | 'closed'; -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(',', ''); +type PrIndicator = { + visualState: PrVisualState; + number: number; + url: string | null; + state: 'open' | 'closed' | 'merged'; + draft: boolean; + title: string | null; + base: string | null; + head: string | null; + checks: { + state: 'success' | 'failure' | 'pending' | 'unknown'; + total: number; + success: number; + failure: number; + pending: number; + } | null; + canMerge: boolean | null; + mergeableState: string | null; + repo: { + owner: string; + repo: string; + } | null; }; -/** Returns relative time if updated today, otherwise falls back to formatDateLabel using updated time. */ -const formatSessionDateLabel = (updatedMs: number): string => { - const today = new Date(); - const updatedDate = new Date(updatedMs); - const isSameDay = (a: Date, b: Date) => - a.getFullYear() === b.getFullYear() && - a.getMonth() === b.getMonth() && - a.getDate() === b.getDate(); - - if (isSameDay(updatedDate, today)) { - const diff = Date.now() - updatedMs; - if (diff < 60_000) return 'Just now'; - if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}min ago`; - return `${Math.floor(diff / 3_600_000)}h ago`; - } - - return formatDateLabel(updatedMs); -}; - -const normalizePath = (value?: string | null) => { - if (!value) { +const getPrVisualState = (status: GitHubPullRequestStatus | null): PrVisualState | null => { + const pr = status?.pr; + if (!pr) { return null; } - const normalized = value.replace(/\\/g, '/').replace(/\/+$/, ''); - return normalized.length === 0 ? '/' : normalized; -}; - -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); -}; - -const toFiniteNumber = (value: unknown): number | undefined => { - if (typeof value === 'number' && Number.isFinite(value)) { - return value; + if (pr.state === 'merged') { + return 'merged'; } - if (typeof value === 'string' && value.trim().length > 0) { - const parsed = Number(value); - if (Number.isFinite(parsed)) { - return parsed; - } + if (pr.state === 'closed') { + return 'closed'; } - return undefined; -}; - -const getSessionCreatedAt = (session: Session): number => { - return toFiniteNumber(session.time?.created) ?? 0; -}; - -const getSessionUpdatedAt = (session: Session): number => { - return toFiniteNumber(session.time?.updated) ?? toFiniteNumber(session.time?.created) ?? 0; -}; - -const compareSessionsByPinnedAndTime = ( - a: Session, - b: Session, - pinnedSessionIds: Set -): number => { - const aPinned = pinnedSessionIds.has(a.id); - const bPinned = pinnedSessionIds.has(b.id); - if (aPinned !== bPinned) { - return aPinned ? -1 : 1; + if (pr.draft) { + return 'draft'; } - - if (aPinned && bPinned) { - return getSessionCreatedAt(b) - getSessionCreatedAt(a); + const checksFailed = status?.checks?.state === 'failure'; + const notMergeable = status?.canMerge === false || pr.mergeable === false; + if (checksFailed || notMergeable) { + return 'blocked'; } - - return getSessionUpdatedAt(b) - getSessionUpdatedAt(a); + return 'open'; }; -// 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()); -}; - -const renderHighlightedText = (text: string, query: string): React.ReactNode => { - if (!query) { - return text; +const getPrVisualPriority = (state: PrVisualState): number => { + switch (state) { + case 'open': + return 5; + case 'blocked': + return 4; + case 'draft': + return 3; + case 'merged': + return 2; + case 'closed': + return 1; + default: + return 0; } - - const loweredText = text.toLowerCase(); - const loweredQuery = query.toLowerCase(); - const queryLength = loweredQuery.length; - if (queryLength === 0) { - return text; - } - - const parts: React.ReactNode[] = []; - let cursor = 0; - let matchIndex = loweredText.indexOf(loweredQuery, cursor); - - while (matchIndex !== -1) { - if (matchIndex > cursor) { - parts.push(text.slice(cursor, matchIndex)); - } - const matchText = text.slice(matchIndex, matchIndex + queryLength); - parts.push( - - {matchText} - , - ); - cursor = matchIndex + queryLength; - matchIndex = loweredText.indexOf(loweredQuery, cursor); - } - - if (cursor < text.length) { - parts.push(text.slice(cursor)); - } - - return parts.length > 0 ? parts : text; }; -type SessionNode = { - session: Session; - children: SessionNode[]; - worktree: WorktreeMetadata | null; -}; - -type SessionGroup = { - id: string; - label: string; - branch: string | null; - description: string | null; - isMain: boolean; - worktree: WorktreeMetadata | null; - directory: string | null; - sessions: SessionNode[]; -}; - -type GroupSearchData = { - filteredNodes: SessionNode[]; - matchedSessionCount: number; - folderNameMatchCount: number; - groupMatches: boolean; - hasMatch: boolean; -}; - -// --- Session Folder DnD helpers --- - -/** - * Wraps a session row so the entire row is draggable onto folder drop zones. - * Stops pointer propagation so the outer group-reorder DndContext does not - * capture the drag (otherwise dragging a session moves the whole workspace group). - */ -const DraggableSessionRow: React.FC<{ - sessionId: string; - sessionDirectory: string | null; - sessionTitle: string; - children: React.ReactNode; -}> = ({ sessionId, sessionDirectory, sessionTitle, children }) => { - const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ - id: `session-drag:${sessionId}`, - data: { type: 'session', sessionId, sessionDirectory, sessionTitle }, - }); - - const handlePointerDown = React.useCallback( - (e: React.PointerEvent) => { - // Stop event from bubbling to the outer group-reorder DndContext - e.stopPropagation(); - if (listeners?.onPointerDown) { - (listeners.onPointerDown as (event: React.PointerEvent) => void)(e); - } - }, - [listeners], - ); - - return ( -
- {children} -
- ); -}; - -/** - * Wraps a and makes it a droppable target. - * Uses a render-prop pattern so the ref/isOver state can be passed - * down as props (avoids hooks-in-callbacks restrictions). - */ -const DroppableFolderWrapper: React.FC<{ - folderId: string; - children: ( - droppableRef: (node: HTMLElement | null) => void, - isOver: boolean, - ) => React.ReactNode; -}> = ({ folderId, children }) => { - const { setNodeRef, isOver } = useDroppable({ - id: `folder-drop:${folderId}`, - data: { type: 'folder', folderId }, - }); - return <>{children(setNodeRef, isOver)}; -}; - -/** - * Provides an inner DndContext scoped to one group, allowing sessions to be - * dragged onto folder headers within that group. - */ -const SessionFolderDndScope: React.FC<{ - scopeKey: string | null; - hasFolders: boolean; - onSessionDroppedOnFolder: (sessionId: string, folderId: string) => void; - children: React.ReactNode; -}> = ({ scopeKey, hasFolders, onSessionDroppedOnFolder, children }) => { - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), - ); - const [activeDragId, setActiveDragId] = React.useState(null); - const [activeDragTitle, setActiveDragTitle] = React.useState('Session'); - const [activeDragWidth, setActiveDragWidth] = React.useState(null); - const [activeDragHeight, setActiveDragHeight] = React.useState(null); - - // Always need DndContext when scopeKey exists (DraggableSessionRow requires it). - // When there are no folders the drag just has nowhere to land – that's fine. - if (!scopeKey) { - return <>{children}; - } - - const handleDragEnd = (event: DragEndEvent) => { - setActiveDragId(null); - setActiveDragWidth(null); - setActiveDragHeight(null); - const { active, over } = event; - if (!over) return; - const activeData = active.data.current as { type?: string; sessionId?: string } | undefined; - const overData = over.data.current as { type?: string; folderId?: string } | undefined; - if (activeData?.type === 'session' && activeData.sessionId && overData?.type === 'folder' && overData.folderId) { - onSessionDroppedOnFolder(activeData.sessionId, overData.folderId); - } - }; - - return ( - { - const data = event.active.data.current as { type?: string; sessionId?: string; sessionTitle?: string } | undefined; - if (data?.type === 'session' && data.sessionId) { - setActiveDragId(data.sessionId); - setActiveDragTitle(data.sessionTitle ?? 'Session'); - const width = event.active.rect.current.initial?.width; - const height = event.active.rect.current.initial?.height; - setActiveDragWidth(typeof width === 'number' ? width : null); - setActiveDragHeight(typeof height === 'number' ? height : null); - } - }} - onDragCancel={() => { - setActiveDragId(null); - setActiveDragWidth(null); - setActiveDragHeight(null); - }} - onDragEnd={handleDragEnd} - > - {children} - - {activeDragId && hasFolders ? ( -
- -
- {activeDragTitle} -
-
- ) : null} -
-
- ); -}; - -// --- End Session Folder DnD helpers --- - -interface SortableProjectItemProps { - id: string; - projectLabel: string; - projectDescription: string; - isCollapsed: boolean; - isActiveProject: boolean; - isRepo: boolean; - isHovered: boolean; - isDesktopShell: boolean; - isStuck: boolean; - hideDirectoryControls: boolean; - mobileVariant: boolean; - onToggle: () => void; - onHoverChange: (hovered: boolean) => void; - onNewSession: () => void; - onNewWorktreeSession?: () => void; - onOpenMultiRunLauncher: () => void; - onRenameStart: () => void; - onRenameSave: () => void; - onRenameCancel: () => void; - onRenameValueChange: (value: string) => void; - renameValue: string; - isRenaming: boolean; - onClose: () => void; - sentinelRef: (el: HTMLDivElement | null) => void; - children?: React.ReactNode; - settingsAutoCreateWorktree: boolean; - showCreateButtons?: boolean; - hideHeader?: boolean; -} - -const SortableProjectItem: React.FC = ({ - id, - projectLabel, - projectDescription, - isCollapsed, - isActiveProject, - isRepo, - isHovered, - isDesktopShell, - isStuck, - hideDirectoryControls, - mobileVariant, - onToggle, - onHoverChange, - onNewSession, - onNewWorktreeSession, - onOpenMultiRunLauncher, - onRenameStart, - onRenameSave, - onRenameCancel, - onRenameValueChange, - renameValue, - isRenaming, - onClose, - sentinelRef, - children, - settingsAutoCreateWorktree, - showCreateButtons = true, - hideHeader = false, -}) => { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id }); - - const [isMenuOpen, setIsMenuOpen] = React.useState(false); - - return ( -
- {!hideHeader ? ( - <> - {/* Sentinel for sticky detection */} - {isDesktopShell && ( - - ); -}; - -const SortableGroupItemBase: React.FC<{ - id: string; - children: React.ReactNode; -}> = ({ id, children }) => { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id }); - - return ( -
- {children} -
- ); -}; - -const SortableGroupItem = React.memo(SortableGroupItemBase); - - - interface SessionSidebarProps { mobileVariant?: boolean; onSessionSelected?: (sessionId: string) => void; @@ -777,14 +149,10 @@ export const SessionSidebar: React.FC = ({ const [editTitle, setEditTitle] = React.useState(''); const [editingProjectId, setEditingProjectId] = React.useState(null); const [editProjectTitle, setEditProjectTitle] = React.useState(''); - const [copiedSessionId, setCopiedSessionId] = React.useState(null); - const copyTimeout = React.useRef(null); const [expandedParents, setExpandedParents] = React.useState>(new Set()); const [directoryStatus, setDirectoryStatus] = React.useState>( () => new Map(), ); - const directoryStatusRef = React.useRef>(new Map()); - const checkingDirectories = React.useRef>(new Set()); const safeStorage = React.useMemo(() => getSafeStorage(), []); const [collapsedProjects, setCollapsedProjects] = React.useState>(new Set()); @@ -793,21 +161,11 @@ export const SessionSidebar: React.FC = ({ const [hoveredProjectId, setHoveredProjectId] = React.useState(null); const [newWorktreeDialogOpen, setNewWorktreeDialogOpen] = React.useState(false); const [projectNotesPanelOpen, setProjectNotesPanelOpen] = React.useState(false); - const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState>(new Set()); const [openMenuSessionId, setOpenMenuSessionId] = React.useState(null); const [renamingFolderId, setRenamingFolderId] = React.useState(null); const [renameFolderDraft, setRenameFolderDraft] = React.useState(''); - const [deleteSessionConfirm, setDeleteSessionConfirm] = React.useState<{ - session: Session; - descendantCount: number; - } | null>(null); - const [deleteFolderConfirm, setDeleteFolderConfirm] = React.useState<{ - scopeKey: string; - folderId: string; - folderName: string; - subFolderCount: number; - sessionCount: number; - } | null>(null); + const [deleteSessionConfirm, setDeleteSessionConfirm] = React.useState(null); + const [deleteFolderConfirm, setDeleteFolderConfirm] = React.useState(null); const [pinnedSessionIds, setPinnedSessionIds] = React.useState>(() => { try { const raw = getSafeStorage().getItem(SESSION_PINNED_STORAGE_KEY); @@ -874,8 +232,6 @@ export const SessionSidebar: React.FC = ({ const [projectRootBranches, setProjectRootBranches] = React.useState>(new Map()); const projectHeaderSentinelRefs = React.useRef>(new Map()); const ignoreIntersectionUntil = React.useRef(0); - const persistCollapsedProjectsTimer = React.useRef(null); - const pendingCollapsedProjects = React.useRef | null>(null); const homeDirectory = useDirectoryStore((state) => state.homeDirectory); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); @@ -908,6 +264,7 @@ export const SessionSidebar: React.FC = ({ // Session Folders store const collapsedFolderIds = useSessionFoldersStore((state) => state.collapsedFolderIds); + const foldersMap = useSessionFoldersStore((state) => state.foldersMap); const getFoldersForScope = useSessionFoldersStore((state) => state.getFoldersForScope); const createFolder = useSessionFoldersStore((state) => state.createFolder); const renameFolder = useSessionFoldersStore((state) => state.renameFolder); @@ -918,78 +275,17 @@ export const SessionSidebar: React.FC = ({ const cleanupSessions = useSessionFoldersStore((state) => state.cleanupSessions); const getSessionFolderId = useSessionFoldersStore((state) => state.getSessionFolderId); - const buildGroupSearchText = React.useCallback((group: SessionGroup): string => { - return [ - group.label, - group.branch ?? '', - group.description ?? '', - group.directory ?? '', - ] - .join(' ') - .toLowerCase(); - }, []); - - const buildSessionSearchText = React.useCallback((session: Session): string => { - const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? ''; - const sessionTitle = (session.title || 'Untitled Session').trim(); - return `${sessionTitle} ${sessionDirectory}`.toLowerCase(); - }, []); - - const filterSessionNodesForSearch = React.useCallback( - (nodes: SessionNode[], query: string): SessionNode[] => { - if (!query) { - return nodes; - } - - return nodes.flatMap((node) => { - const nodeMatches = buildSessionSearchText(node.session).includes(query); - if (nodeMatches) { - return [node]; - } - - const filteredChildren = filterSessionNodesForSearch(node.children, query); - if (filteredChildren.length === 0) { - return []; - } - - return [{ - ...node, - children: filteredChildren, - }]; - }); - }, - [buildSessionSearchText], - ); - - React.useEffect(() => { - if (!isSessionSearchOpen || typeof window === 'undefined') { - return; - } - const raf = window.requestAnimationFrame(() => { - sessionSearchInputRef.current?.focus(); - sessionSearchInputRef.current?.select(); - }); - return () => window.cancelAnimationFrame(raf); - }, [isSessionSearchOpen]); - React.useEffect(() => { - if (!isSessionSearchOpen || typeof document === 'undefined') { - return; - } - const handlePointerDown = (event: MouseEvent) => { - if (!sessionSearchContainerRef.current) { - return; - } - if (!sessionSearchContainerRef.current.contains(event.target as Node)) { - setIsSessionSearchOpen(false); - } - }; - document.addEventListener('mousedown', handlePointerDown); - return () => document.removeEventListener('mousedown', handlePointerDown); - }, [isSessionSearchOpen]); + useSessionSearchEffects({ + isSessionSearchOpen, + setIsSessionSearchOpen, + sessionSearchInputRef, + sessionSearchContainerRef, + }); const gitDirectories = useGitStore((state) => state.directories); const sessions = useSessionStore((state) => state.sessions); + const archivedSessions = useSessionStore((state) => state.archivedSessions); const sessionsByDirectory = useSessionStore((state) => state.sessionsByDirectory); const currentSessionId = useSessionStore((state) => state.currentSessionId); const newSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open)); @@ -1006,100 +302,44 @@ export const SessionSidebar: React.FC = ({ const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject); const getSessionsByDirectory = useSessionStore((state) => state.getSessionsByDirectory); const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft); + const prStatusEntries = useGitHubPrStatusStore((state) => state.entries); 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 { + buildGroupSearchText, + filterSessionNodesForSearch, + buildGroupedSessions, + } = useSessionGrouping({ + homeDirectory, + worktreeMetadata, + pinnedSessionIds, + gitDirectories, + }); - 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) => { - if (typeof window === 'undefined') { - return; - } - if (isVSCode) { - return; - } - - 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; - }; - }, []); - - 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'))); - } - } - 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'))); - } - } - } catch { /* ignored */ } - }, [safeStorage]); - - React.useEffect(() => { - const existingSessionIds = new Set(sessions.map((session) => session.id)); - setPinnedSessionIds((prev) => { - let changed = false; - const next = new Set(); - prev.forEach((id) => { - if (existingSessionIds.has(id)) { - next.add(id); - } else { - changed = true; - } - }); - return changed ? next : prev; - }); - }, [sessions]); - - React.useEffect(() => { - try { - safeStorage.setItem(SESSION_PINNED_STORAGE_KEY, JSON.stringify(Array.from(pinnedSessionIds))); - } catch { - // ignored - } - }, [pinnedSessionIds, safeStorage]); + const { scheduleCollapsedProjectsPersist } = useSidebarPersistence({ + isVSCode, + safeStorage, + keys: { + sessionExpanded: SESSION_EXPANDED_STORAGE_KEY, + projectCollapse: PROJECT_COLLAPSE_STORAGE_KEY, + sessionPinned: SESSION_PINNED_STORAGE_KEY, + groupOrder: GROUP_ORDER_STORAGE_KEY, + projectActiveSession: PROJECT_ACTIVE_SESSION_STORAGE_KEY, + groupCollapse: GROUP_COLLAPSE_STORAGE_KEY, + }, + sessions, + pinnedSessionIds, + setPinnedSessionIds, + groupOrderByProject, + activeSessionByProject, + collapsedGroups, + setExpandedParents, + setCollapsedProjects, + }); const togglePinnedSession = React.useCallback((sessionId: string) => { setPinnedSessionIds((prev) => { @@ -1117,139 +357,11 @@ export const SessionSidebar: React.FC = ({ return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds)); }, [sessions, pinnedSessionIds]); - React.useEffect(() => { - directoryStatusRef.current = directoryStatus; - }, [directoryStatus]); - - const sessionPrefetchTimersRef = React.useRef>(new Map()); - const sessionPrefetchQueueRef = React.useRef([]); - const sessionPrefetchInFlightRef = React.useRef>(new Set()); - - const pumpSessionPrefetchQueue = React.useCallback(() => { - if (typeof window === 'undefined') { - return; - } - - while (sessionPrefetchInFlightRef.current.size < SESSION_PREFETCH_CONCURRENCY && sessionPrefetchQueueRef.current.length > 0) { - const nextSessionId = sessionPrefetchQueueRef.current.shift(); - if (!nextSessionId) { - break; - } - - const state = useSessionStore.getState(); - if (state.currentSessionId === nextSessionId) { - continue; - } - - const hasMessages = state.messages.has(nextSessionId); - const memory = state.sessionMemoryState.get(nextSessionId); - const isHydrated = hasMessages && memory?.historyComplete !== undefined; - if (isHydrated) { - continue; - } - - sessionPrefetchInFlightRef.current.add(nextSessionId); - void loadMessages(nextSessionId) - .catch(() => { - return; - }) - .finally(() => { - sessionPrefetchInFlightRef.current.delete(nextSessionId); - pumpSessionPrefetchQueue(); - }); - } - }, [loadMessages]); - - const scheduleSessionPrefetch = React.useCallback((sessionId: string | null | undefined) => { - if (!sessionId || sessionId === currentSessionId || typeof window === 'undefined') { - return; - } - - const state = useSessionStore.getState(); - const hasMessages = state.messages.has(sessionId); - const memory = state.sessionMemoryState.get(sessionId); - const isHydrated = hasMessages && memory?.historyComplete !== undefined; - if (isHydrated) { - return; - } - - if (sessionPrefetchInFlightRef.current.has(sessionId)) { - return; - } - - if (sessionPrefetchQueueRef.current.includes(sessionId)) { - return; - } - - if (sessionPrefetchQueueRef.current.length >= SESSION_PREFETCH_PENDING_LIMIT) { - sessionPrefetchQueueRef.current.shift(); - } - - const existingTimer = sessionPrefetchTimersRef.current.get(sessionId); - if (existingTimer !== undefined) { - window.clearTimeout(existingTimer); - } - - const timer = window.setTimeout(() => { - sessionPrefetchTimersRef.current.delete(sessionId); - sessionPrefetchQueueRef.current.push(sessionId); - pumpSessionPrefetchQueue(); - }, SESSION_PREFETCH_HOVER_DELAY_MS); - sessionPrefetchTimersRef.current.set(sessionId, timer); - }, [currentSessionId, pumpSessionPrefetchQueue]); - - React.useEffect(() => { - if (!currentSessionId || sortedSessions.length === 0) { - return; - } - const currentIndex = sortedSessions.findIndex((session) => session.id === currentSessionId); - if (currentIndex < 0) { - return; - } - scheduleSessionPrefetch(sortedSessions[currentIndex - 1]?.id); - scheduleSessionPrefetch(sortedSessions[currentIndex + 1]?.id); - }, [currentSessionId, scheduleSessionPrefetch, sortedSessions]); - - React.useEffect(() => { - let cancelled = false; - 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; - }); - } - }); - }); - - return () => { - cancelled = true; - }; - }, [projects]); + useSessionPrefetch({ + currentSessionId, + sortedSessions, + loadMessages, + }); const childrenMap = React.useMemo(() => { const map = new Map(); @@ -1266,91 +378,12 @@ export const SessionSidebar: React.FC = ({ return map; }, [sortedSessions, pinnedSessionIds]); - React.useEffect(() => { - const directories = new Set(); - sortedSessions.forEach((session) => { - const dir = normalizePath((session as Session & { directory?: string | null }).directory ?? null); - if (dir) { - directories.add(dir); - } - }); - projects.forEach((project) => { - const normalized = normalizePath(project.path); - if (normalized) { - directories.add(normalized); - } - }); - - directories.forEach((directory) => { - const known = directoryStatusRef.current.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; - }); - }) - .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; - } - } - - 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); - }); - }); - }, [sortedSessions, projects]); - - React.useEffect(() => { - const prefetchTimers = sessionPrefetchTimersRef.current; - return () => { - if (copyTimeout.current) { - clearTimeout(copyTimeout.current); - } - prefetchTimers.forEach((timer) => { - clearTimeout(timer); - }); - prefetchTimers.clear(); - sessionPrefetchQueueRef.current = []; - }; - }, []); - + useDirectoryStatusProbe({ + sortedSessions, + projects, + directoryStatus, + setDirectoryStatus, + }); const emptyState = (
@@ -1359,83 +392,6 @@ export const SessionSidebar: React.FC = ({
); - const handleSessionSelect = React.useCallback( - (sessionId: string, sessionDirectory?: string | null, disabled?: boolean, projectId?: string | null) => { - if (disabled) { - return; - } - - const resetSessionSearch = () => { - if (!isSessionSearchOpen && sessionSearchQuery.length === 0) { - return; - } - setSessionSearchQuery(''); - setIsSessionSearchOpen(false); - }; - - 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 }); - } - - if (mobileVariant) { - setActiveMainTab('chat'); - setSessionSwitcherOpen(false); - } - - // Always return early if same session is selected to avoid unnecessary store operations - if (sessionId === currentSessionId) { - if (allowReselect) { - onSessionSelected?.(sessionId); - } - resetSessionSearch(); - return; - } - setCurrentSession(sessionId); - onSessionSelected?.(sessionId); - resetSessionSearch(); - }, - [ - activeProjectId, - allowReselect, - currentDirectory, - currentSessionId, - isSessionSearchOpen, - mobileVariant, - onSessionSelected, - sessionSearchQuery, - setActiveMainTab, - setActiveProjectIdOnly, - setCurrentSession, - setDirectory, - setIsSessionSearchOpen, - setSessionSearchQuery, - setSessionSwitcherOpen, - ], - ); - - const handleSessionDoubleClick = React.useCallback(() => { - // On double-click/tap, switch to the Chat tab - setActiveMainTab('chat'); - }, [setActiveMainTab]); - - 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(''); - }, []); - const handleSaveProjectEdit = React.useCallback(() => { if (editingProjectId && editProjectTitle.trim()) { renameProject(editingProjectId, editProjectTitle.trim()); @@ -1449,115 +405,54 @@ export const SessionSidebar: React.FC = ({ setEditProjectTitle(''); }, []); - 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) => { - void copyTextToClipboard(url) - .then((result) => { - if (!result.ok) { - toast.error('Failed to copy URL'); - return; - } - 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 archiveSession = useSessionStore((state) => state.archiveSession); + const archiveSessions = useSessionStore((state) => state.archiveSessions); - const executeDeleteSession = React.useCallback( - async (session: Session) => { - const descendants = collectDescendants(session.id); - if (descendants.length === 0) { - const success = await deleteSession(session.id); - if (success) { - toast.success('Session deleted'); - } else { - toast.error('Failed to delete session'); - } - return; - } - - const ids = [session.id, ...descendants.map((s) => s.id)]; - const { deletedIds, failedIds } = await deleteSessions(ids); - if (deletedIds.length > 0) { - toast.success(`Deleted ${deletedIds.length} session${deletedIds.length === 1 ? '' : 's'}`); - } - if (failedIds.length > 0) { - toast.error(`Failed to delete ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`); - } - }, - [collectDescendants, deleteSession, deleteSessions], - ); - - const handleDeleteSession = React.useCallback( - (session: Session) => { - const descendants = collectDescendants(session.id); - if (!showDeletionDialog) { - void executeDeleteSession(session); - return; - } - setDeleteSessionConfirm({ session, descendantCount: descendants.length }); - }, - [collectDescendants, showDeletionDialog, executeDeleteSession], - ); - - const confirmDeleteSession = React.useCallback(async () => { - if (!deleteSessionConfirm) return; - const { session } = deleteSessionConfirm; - setDeleteSessionConfirm(null); - await executeDeleteSession(session); - }, [deleteSessionConfirm, executeDeleteSession]); + const { + copiedSessionId, + handleSessionSelect, + handleSessionDoubleClick, + handleSaveEdit, + handleCancelEdit, + handleShareSession, + handleCopyShareUrl, + handleUnshareSession, + handleDeleteSession, + confirmDeleteSession, + } = useSessionActions({ + activeProjectId, + currentDirectory, + currentSessionId, + mobileVariant, + allowReselect, + onSessionSelected, + isSessionSearchOpen, + sessionSearchQuery, + setSessionSearchQuery, + setIsSessionSearchOpen, + setActiveProjectIdOnly, + setDirectory, + setActiveMainTab, + setSessionSwitcherOpen, + setCurrentSession, + updateSessionTitle, + shareSession, + unshareSession, + deleteSession, + deleteSessions, + archiveSession, + archiveSessions, + childrenMap, + showDeletionDialog, + setDeleteSessionConfirm, + deleteSessionConfirm, + setEditingId, + setEditTitle, + editingId, + editTitle, + }); const confirmDeleteFolder = React.useCallback(() => { if (!deleteFolderConfirm) return; @@ -1627,177 +522,6 @@ export const SessionSidebar: React.FC = ({ [collapsedFolderIds, toggleFolderCollapse, createFolder], ); - const buildNode = React.useCallback( - (session: Session): SessionNode => { - const children = childrenMap.get(session.id) ?? []; - return { - session, - children: children.map((child) => buildNode(child)), - worktree: worktreeMetadata.get(session.id) ?? null, - }; - }, - [childrenMap, worktreeMetadata], - ); - - - const buildGroupedSessions = React.useCallback( - ( - projectSessions: Session[], - projectRoot: string | null, - availableWorktrees: WorktreeMetadata[], - projectRootBranch: string | null, - projectIsRepo: boolean, - ) => { - const normalizedProjectRoot = normalizePath(projectRoot ?? null); - const sortedProjectSessions = [...projectSessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds)); - - const sessionMap = new Map(sortedProjectSessions.map((session) => [session.id, session])); - const childrenMap = new Map(); - 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); - }); - childrenMap.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds))); - - // Build worktree lookup map - const worktreeByPath = new Map(); - availableWorktrees.forEach((meta) => { - if (meta.path) { - const normalized = normalizePath(meta.path) ?? meta.path; - worktreeByPath.set(normalized, meta); - } - }); - - // Helper to get worktree metadata for a session - const getSessionWorktree = (session: Session): WorktreeMetadata | null => { - const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); - const sessionWorktreeMeta = worktreeMetadata.get(session.id) ?? null; - 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; - } - } - return null; - }; - - const buildProjectNode = (session: Session): SessionNode => { - const children = childrenMap.get(session.id) ?? []; - return { - session, - children: children.map((child) => buildProjectNode(child)), - worktree: getSessionWorktree(session), - }; - }; - - // Find root sessions (no parent or parent not in current project) - const roots = sortedProjectSessions.filter((session) => { - const parentID = (session as Session & { parentID?: string | null }).parentID; - if (!parentID) { - return true; - } - return !sessionMap.has(parentID); - }); - - const groupedNodes = new Map(); - const groupOrder = new Map(); - - 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__'; - }; - - 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', - branch: projectRootBranch ?? null, - description: normalizedProjectRoot ? formatPathForDisplay(normalizedProjectRoot, homeDirectory) : null, - isMain: true, - worktree: null, - directory: normalizedProjectRoot, - sessions: groupedNodes.get(rootKey) ?? [], - }]; - - 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; - const currentBranch = gitDirectories.get(directory)?.status?.current?.trim() || null; - const metadataBranch = meta.branch?.trim() || null; - const shouldSyncLabelWithBranch = Boolean( - currentBranch - && metadataBranch - && meta.label - && normalizeForBranchComparison(meta.label) === normalizeForBranchComparison(metadataBranch), - ); - const label = shouldSyncLabelWithBranch - ? currentBranch! - : (meta.label || meta.name || formatDirectoryName(directory, homeDirectory) || directory); - groups.push({ - id: `worktree:${directory}`, - label, - branch: currentBranch || metadataBranch, - 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) => { - const currentBranch = gitDirectories.get(directory)?.status?.current?.trim() || null; - groups.push({ - id: `worktree:orphan:${directory}`, - label: formatDirectoryName(directory, homeDirectory) || directory, - branch: currentBranch, - description: formatPathForDisplay(directory, homeDirectory), - isMain: false, - worktree: null, - directory, - sessions: groupedNodes.get(directory) ?? [], - }); - }); - - return groups; - }, - [homeDirectory, worktreeMetadata, pinnedSessionIds, gitDirectories] - ); - const toggleGroupSessionLimit = React.useCallback((groupId: string) => { setExpandedSessionGroups((prev) => { const next = new Set(prev); @@ -1832,32 +556,6 @@ export const SessionSidebar: React.FC = ({ }); }, [isVSCode, safeStorage, scheduleCollapsedProjectsPersist]); - 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]); - const normalizedProjects = React.useMemo(() => { return projects .map((project) => ({ @@ -1872,98 +570,47 @@ export const SessionSidebar: React.FC = ({ }>; }, [projects]); - // 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]); + useProjectRepoStatus({ + projects, + normalizedProjects, + normalizePath, + gitDirectories, + setProjectRepoStatus, + setProjectRootBranches, + }); - 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; - }; - }, [normalizedProjects, projectGitBranchesKey]); - - // Session Folders: cleanup stale session IDs when sessions are removed. - // Guard: skip cleanup while sessions are still loading to avoid wiping folder - // assignments before the server has returned its full session list. const isSessionsLoading = useSessionStore((state) => state.isLoading); - React.useEffect(() => { - if (isSessionsLoading) return; - const idsByScope = new Map>(); - sessions.forEach((session) => { - const directory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); - if (!directory) return; - const existing = idsByScope.get(directory); - if (existing) { - existing.add(session.id); - return; - } - idsByScope.set(directory, new Set([session.id])); - }); + useSessionFolderCleanup({ + isSessionsLoading, + sessions, + archivedSessions, + normalizedProjects, + isVSCode, + availableWorktreesByProject, + cleanupSessions, + }); - const currentFoldersMap = useSessionFoldersStore.getState().foldersMap; - const allScopeKeys = new Set([...Object.keys(currentFoldersMap), ...idsByScope.keys()]); - allScopeKeys.forEach((scopeKey) => { - cleanupSessions(scopeKey, idsByScope.get(scopeKey) ?? new Set()); - }); - }, [sessions, isSessionsLoading, cleanupSessions]); // removed foldersMap from deps to prevent cascade re-renders + const { getSessionsForProject, getArchivedSessionsForProject } = useProjectSessionLists({ + isVSCode, + sessions, + archivedSessions, + sessionsByDirectory, + getSessionsByDirectory, + availableWorktreesByProject, + }); - const getSessionsForProject = React.useCallback( - (project: { normalizedPath: string }) => { - // In VS Code, only show sessions from the main project directory (skip worktrees) - const worktreesForProject = isVSCode ? [] : (availableWorktreesByProject.get(project.normalizedPath) ?? []); - const directories = [ - project.normalizedPath, - ...worktreesForProject - .map((meta) => normalizePath(meta.path) ?? meta.path) - .filter((value): value is string => Boolean(value)), - ]; - - const seen = new Set(); - 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; - }, - [availableWorktreesByProject, getSessionsByDirectory, sessionsByDirectory, isVSCode], - ); + useArchivedAutoFolders({ + normalizedProjects, + sessions, + archivedSessions, + availableWorktreesByProject, + isVSCode, + isSessionsLoading, + foldersMap, + createFolder, + addSessionToFolder, + cleanupSessions, + }); // Keep last-known repo status to avoid UI jiggling during project switch const lastRepoStatusRef = React.useRef(false); @@ -1971,114 +618,27 @@ export const SessionSidebar: React.FC = ({ lastRepoStatusRef.current = Boolean(projectRepoStatus.get(activeProjectId)); } - const projectSections = React.useMemo(() => { - return normalizedProjects.map((project) => { - const projectSessions = getSessionsForProject(project); - const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? []; - const isRepo = projectRepoStatus.has(project.id) - ? Boolean(projectRepoStatus.get(project.id)) - : lastRepoStatusRef.current; - const groups = buildGroupedSessions( - projectSessions, - project.normalizedPath, - worktreesForProject, - projectRootBranches.get(project.id) ?? null, - isRepo, - ); - return { - project, - groups, - }; - }); - }, [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 groupSearchDataByGroup = React.useMemo(() => { - const result = new WeakMap(); - if (!hasSessionSearchQuery) { - return result; - } - - const countNodes = (nodes: SessionNode[]): number => { - return nodes.reduce((total, node) => total + 1 + countNodes(node.children), 0); - }; - - visibleProjectSections.forEach((section) => { - section.groups.forEach((group) => { - const filteredNodes = filterSessionNodesForSearch(group.sessions, normalizedSessionSearchQuery); - const matchedSessionCount = countNodes(filteredNodes); - const groupMatches = buildGroupSearchText(group).includes(normalizedSessionSearchQuery); - const scopeKey = normalizePath(group.directory ?? null); - const folderNameMatchCount = scopeKey - ? getFoldersForScope(scopeKey).filter((folder) => folder.name.toLowerCase().includes(normalizedSessionSearchQuery)).length - : 0; - - result.set(group, { - filteredNodes, - matchedSessionCount, - folderNameMatchCount, - groupMatches, - hasMatch: groupMatches || matchedSessionCount > 0 || folderNameMatchCount > 0, - }); - }); - }); - - return result; - }, [ + const { + projectSections, + groupSearchDataByGroup, + sectionsForRender, + searchMatchCount, + } = useSessionSidebarSections({ + normalizedProjects, + activeProjectId, + getSessionsForProject, + getArchivedSessionsForProject, + availableWorktreesByProject, + projectRepoStatus, + projectRootBranches, + lastRepoStatus: lastRepoStatusRef.current, + buildGroupedSessions, hasSessionSearchQuery, - visibleProjectSections, - filterSessionNodesForSearch, normalizedSessionSearchQuery, + filterSessionNodesForSearch, buildGroupSearchText, getFoldersForScope, - ]); - - const searchableProjectSections = React.useMemo(() => { - if (!hasSessionSearchQuery) { - return visibleProjectSections; - } - - return visibleProjectSections - .map((section) => ({ - ...section, - groups: section.groups.filter((group) => groupSearchDataByGroup.get(group)?.hasMatch === true), - })) - .filter((section) => section.groups.length > 0); - }, [ - hasSessionSearchQuery, - visibleProjectSections, - groupSearchDataByGroup, - ]); - - const sectionsForRender = hasSessionSearchQuery ? searchableProjectSections : visibleProjectSections; - - const searchMatchCount = React.useMemo(() => { - if (!hasSessionSearchQuery) { - return 0; - } - - return sectionsForRender.reduce((total, section) => { - return total + section.groups.reduce((groupTotal, group) => { - const data = groupSearchDataByGroup.get(group); - if (!data) { - return groupTotal; - } - const metadataMatches = data.folderNameMatchCount + (data.groupMatches ? 1 : 0); - return groupTotal + data.matchedSessionCount + metadataMatches; - }, 0); - }, 0); - }, [ - hasSessionSearchQuery, - sectionsForRender, - groupSearchDataByGroup, - ]); + }); const searchEmptyState = (
@@ -2118,182 +678,24 @@ export const SessionSidebar: React.FC = ({ } }, [activeProjectForHeader]); - const projectSessionMeta = React.useMemo(() => { - const metaByProject = new Map>(); - const firstSessionByProject = new Map(); - - 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(null); - const lastSeenActiveProjectRef = React.useRef(null); - React.useLayoutEffect(() => { - if (!activeProjectId) { - return; - } - - const previousSeenProjectId = lastSeenActiveProjectRef.current; - const isProjectSwitch = Boolean(previousSeenProjectId && previousSeenProjectId !== activeProjectId); - // Always record the active project so we can detect real project switches even if we early-return. - lastSeenActiveProjectRef.current = activeProjectId; - - // While a new session draft is open, keep the sidebar from auto-selecting remembered/fallback sessions. - // Exception (web/desktop only): when the user switches projects, prefer the last selected session - // for the target project instead of carrying the draft across. - // In VS Code, keep existing behavior (sidebar frequently mounts/unmounts and draft should stay put). - if (newSessionDraftOpen && (isVSCode || !isProjectSwitch)) { - return; - } - - if (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); - - // 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; - } - - 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); - }, [ + const { currentSessionDirectory } = useProjectSessionSelection({ + projectSections, activeProjectId, activeSessionByProject, + setActiveSessionByProject, currentSessionId, handleSessionSelect, isVSCode, newSessionDraftOpen, mobileVariant, openNewSessionDraft, - projectSections, - projectSessionMeta, setActiveMainTab, setSessionSwitcherOpen, - ]); + sessions, + worktreeMetadata, + }); - 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 { getOrderedGroups } = useGroupOrdering(groupOrderByProject); const handleStartInlineProjectRename = React.useCallback(() => { if (!activeProjectForHeader) { @@ -2333,949 +735,239 @@ export const SessionSidebar: React.FC = ({ !isDesktopShellRuntime && 'bg-transparent hover:bg-sidebar/40', ); - // Track when project sticky headers become "stuck" - React.useEffect(() => { - if (!isDesktopShellRuntime) return; - - 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; - }); - }); - }, - { threshold: 0 } - ); - - projectHeaderSentinelRefs.current.forEach((el) => { - if (el) observer.observe(el); - }); - - return () => observer.disconnect(); - }, [isDesktopShellRuntime, projectSections]); + const stuckProjectHeaders = useStickyProjectHeaders({ + isDesktopShellRuntime, + projectSections, + projectHeaderSentinelRefs, + }); const renderSessionNode = React.useCallback( - (node: SessionNode, depth = 0, groupDirectory?: string | null, projectId?: string | null): React.ReactNode => { - const session = node.session; - const sessionDirectory = - normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? - normalizePath(groupDirectory ?? null); - const directoryState = sessionDirectory ? directoryStatus.get(sessionDirectory) : null; - const isMissingDirectory = directoryState === 'missing'; - const memoryState = sessionMemoryState.get(session.id); - const isActive = currentSessionId === session.id; - const sessionTitle = session.title || 'Untitled Session'; - const hasChildren = node.children.length > 0; - const isPinnedSession = pinnedSessionIds.has(session.id); - const isExpanded = hasSessionSearchQuery ? true : expandedParents.has(session.id); - const isSubtaskSession = Boolean((session as Session & { parentID?: string | null }).parentID); - const rawNeedsAttention = sessionAttentionStates.get(session.id)?.needsAttention === true; - // When notifyOnSubtasks is disabled, suppress attention dots for child sessions. - const needsAttention = rawNeedsAttention && (!isSubtaskSession || notifyOnSubtasks); - const sessionSummary = session.summary as - | { - additions?: number | string | null; - deletions?: number | string | null; - files?: number | null; - diffs?: Array<{ additions?: number | string | null; deletions?: number | string | null }>; - } - | undefined; - - if (editingId === session.id) { - return ( -
0 && 'pl-[20px]', - )} - > -
-
{ - event.preventDefault(); - handleSaveEdit(); - }} - > - setEditTitle(event.target.value)} - className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground" - autoFocus - placeholder="Rename session" - onKeyDown={(event) => { - if (event.key === 'Escape') { - event.stopPropagation(); - handleCancelEdit(); - return; - } - if (event.key === ' ' || event.key === 'Enter') { - event.stopPropagation(); - } - }} - /> - - -
-
- {hasChildren ? ( - - {isExpanded ? ( - - ) : ( - - )} - - ) : null} - {formatSessionDateLabel(session.time?.updated || session.time?.created || Date.now())} - {session.share ? ( - - ) : null} - {(sessionSummary?.files ?? 0) > 0 ? ( - - · {sessionSummary!.files} {sessionSummary!.files === 1 ? 'file' : 'files'} changed - - ) : null} - {hasChildren ? ( - - {node.children.length} {node.children.length === 1 ? 'task' : 'tasks'} - - ) : null} -
-
-
- ); - } - - const statusType = sessionStatus?.get(session.id)?.type ?? 'idle'; - const isStreaming = statusType === 'busy' || statusType === 'retry'; - const pendingPermissionCount = permissions.get(session.id)?.length ?? 0; - const showUnreadStatus = !isStreaming && needsAttention && !isActive; - const showStatusMarker = isStreaming || showUnreadStatus; - - const streamingIndicator = (() => { - if (!memoryState) return null; - if (memoryState.isZombie) { - return ; - } - return null; - })(); - - return ( - - -
0 && 'pl-[20px]', - )} - onContextMenu={(e) => { - e.preventDefault(); - setOpenMenuSessionId(session.id); - }} - > -
- - -
- {streamingIndicator} - setOpenMenuSessionId(open ? session.id : null)} - > - - - - { - if (renamingFolderId) { - event.preventDefault(); - } - }} - > - { - setEditingId(session.id); - setEditTitle(sessionTitle); - }} - className="[&>svg]:mr-1" - > - - Rename - - togglePinnedSession(session.id)} className="[&>svg]:mr-1"> - {isPinnedSession ? ( - - ) : ( - - )} - {isPinnedSession ? 'Unpin session' : 'Pin session'} - - {!session.share ? ( - handleShareSession(session)} className="[&>svg]:mr-1"> - - Share - - ) : ( - <> - { - if (session.share?.url) { - handleCopyShareUrl(session.share.url, session.id); - } - }} - className="[&>svg]:mr-1" - > - {copiedSessionId === session.id ? ( - <> - - Copied - - ) : ( - <> - - Copy link - - )} - - handleUnshareSession(session.id)} className="[&>svg]:mr-1"> - - Unshare - - - )} - {/* Move to folder submenu */} - {sessionDirectory ? (() => { - const scopeFolders = getFoldersForScope(sessionDirectory); - const currentFolderId = getSessionFolderId(sessionDirectory, session.id); - return ( - <> - - - - - Move to folder - - - {scopeFolders.length === 0 ? ( - - No folders yet - - ) : ( - scopeFolders.map((folder) => ( - { - if (currentFolderId === folder.id) { - removeSessionFromFolder(sessionDirectory, session.id); - } else { - addSessionToFolder(sessionDirectory, folder.id, session.id); - } - }} - > - {folder.name} - {currentFolderId === folder.id ? ( - - ) : null} - - )) - )} - - { - const newFolder = createFolderAndStartRename(sessionDirectory); - if (!newFolder) { - return; - } - addSessionToFolder(sessionDirectory, newFolder.id, session.id); - }} - > - - New folder... - - {currentFolderId ? ( - { - removeSessionFromFolder(sessionDirectory, session.id); - }} - className="text-destructive focus:text-destructive" - > - - Remove from folder - - ) : null} - - - - ); - })() : null} - { - if (!sessionDirectory) { - return; - } - - openContextPanelTab(sessionDirectory, { - mode: 'chat', - dedupeKey: `session:${session.id}`, - label: sessionTitle, - }); - }} - className="[&>svg]:mr-1" - > - - Open in Side Panel - - beta - - - - handleDeleteSession(session)} - > - - Remove - - - -
-
-
-
- {hasChildren && isExpanded - ? node.children.map((child) => - renderSessionNode(child, depth + 1, sessionDirectory ?? groupDirectory, projectId), - ) - : null} -
- ); - }, + ( + node: SessionNode, + depth = 0, + groupDirectory?: string | null, + projectId?: string | null, + archivedBucket = false, + ): React.ReactNode => ( + } + currentSessionId={currentSessionId} + pinnedSessionIds={pinnedSessionIds} + expandedParents={expandedParents} + hasSessionSearchQuery={hasSessionSearchQuery} + normalizedSessionSearchQuery={normalizedSessionSearchQuery} + sessionAttentionStates={sessionAttentionStates as Map} + notifyOnSubtasks={notifyOnSubtasks} + sessionStatus={sessionStatus as Map | undefined} + permissions={permissions as Map} + editingId={editingId} + setEditingId={setEditingId} + editTitle={editTitle} + setEditTitle={setEditTitle} + handleSaveEdit={handleSaveEdit} + handleCancelEdit={handleCancelEdit} + toggleParent={toggleParent} + handleSessionSelect={handleSessionSelect} + handleSessionDoubleClick={handleSessionDoubleClick} + togglePinnedSession={togglePinnedSession} + handleShareSession={handleShareSession} + copiedSessionId={copiedSessionId} + handleCopyShareUrl={handleCopyShareUrl} + handleUnshareSession={handleUnshareSession} + openMenuSessionId={openMenuSessionId} + setOpenMenuSessionId={setOpenMenuSessionId} + renamingFolderId={renamingFolderId} + getFoldersForScope={getFoldersForScope} + getSessionFolderId={getSessionFolderId} + removeSessionFromFolder={removeSessionFromFolder} + addSessionToFolder={addSessionToFolder} + createFolderAndStartRename={createFolderAndStartRename} + openContextPanelTab={openContextPanelTab} + handleDeleteSession={handleDeleteSession} + mobileVariant={mobileVariant} + renderSessionNode={renderSessionNode} + /> + ), [ directoryStatus, sessionMemoryState, - sessionStatus, - sessionAttentionStates, - permissions, currentSessionId, + pinnedSessionIds, + expandedParents, hasSessionSearchQuery, normalizedSessionSearchQuery, - expandedParents, + sessionAttentionStates, + notifyOnSubtasks, + sessionStatus, + permissions, editingId, + setEditingId, editTitle, + setEditTitle, handleSaveEdit, handleCancelEdit, toggleParent, handleSessionSelect, handleSessionDoubleClick, - pinnedSessionIds, togglePinnedSession, handleShareSession, + copiedSessionId, handleCopyShareUrl, handleUnshareSession, - handleDeleteSession, - copiedSessionId, - mobileVariant, openMenuSessionId, + setOpenMenuSessionId, renamingFolderId, getFoldersForScope, getSessionFolderId, - addSessionToFolder, removeSessionFromFolder, + addSessionToFolder, createFolderAndStartRename, openContextPanelTab, - notifyOnSubtasks, + handleDeleteSession, + mobileVariant, ], ); - const renderGroupSessions = React.useCallback( - (group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean) => { - const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null; - const isExpanded = expandedSessionGroups.has(groupKey); - const isCollapsed = hasSessionSearchQuery ? false : collapsedGroups.has(groupKey); - const maxVisible = hideDirectoryControls ? 10 : 5; - const groupMatchesSearch = hasSessionSearchQuery - ? searchData?.groupMatches === true - : false; - const shouldFilterGroupContents = hasSessionSearchQuery; + const toggleCollapsedGroup = React.useCallback((key: string) => { + setCollapsedGroups((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, []); - const sourceGroupNodes = shouldFilterGroupContents - ? (searchData?.filteredNodes ?? []) - : group.sessions; + const prVisualStateByDirectoryBranch = React.useMemo(() => { + const result = new Map(); - // --- Session Folders: split into foldered vs ungrouped --- - const folderScopeKey = normalizePath(group.directory ?? null); - const scopeFolders = folderScopeKey ? getFoldersForScope(folderScopeKey) : []; + Object.values(prStatusEntries).forEach((entry) => { + const directory = normalizePath(entry.params?.directory ?? null); + const branch = entry.params?.branch?.trim(); + if (!directory || !branch) { + return; + } + const state = getPrVisualState(entry.status ?? null); + const pr = entry.status?.pr; + if (!state || !pr?.number) { + return; + } - const nodeBySessionId = new Map(); - const collectNodeLookup = (nodes: SessionNode[]) => { - nodes.forEach((node) => { - nodeBySessionId.set(node.session.id, node); - if (node.children.length > 0) { - collectNodeLookup(node.children); + const key = `${directory}::${branch}`; + const nextIndicator: PrIndicator = { + visualState: state, + number: pr.number, + url: typeof pr.url === 'string' && pr.url.trim().length > 0 ? pr.url : null, + state: pr.state, + draft: Boolean(pr.draft), + title: typeof pr.title === 'string' && pr.title.trim().length > 0 ? pr.title : null, + base: typeof pr.base === 'string' && pr.base.trim().length > 0 ? pr.base : null, + head: typeof pr.head === 'string' && pr.head.trim().length > 0 ? pr.head : null, + checks: entry.status?.checks + ? { + state: entry.status.checks.state, + total: entry.status.checks.total, + success: entry.status.checks.success, + failure: entry.status.checks.failure, + pending: entry.status.checks.pending, } - }); + : null, + canMerge: typeof entry.status?.canMerge === 'boolean' ? entry.status.canMerge : null, + mergeableState: typeof pr.mergeableState === 'string' ? pr.mergeableState : null, + repo: entry.status?.repo + ? { + owner: entry.status.repo.owner, + repo: entry.status.repo.repo, + } + : null, }; - collectNodeLookup(sourceGroupNodes); - - // ALL folders for this scope – including empty ones (so newly created folders show up) - // Build enriched list: { folder, nodes } for every folder in scope - const allFoldersForGroupBase = scopeFolders.map((folder) => { - const nodes = folder.sessionIds - .map((sid) => nodeBySessionId.get(sid)) - .filter((n): n is SessionNode => Boolean(n)) - .sort((a, b) => compareSessionsByPinnedAndTime(a.session, b.session, pinnedSessionIds)); - return { folder, nodes }; - }); - - const shouldKeepFolder = (folderId: string, folderMap: Map): boolean => { - const entry = folderMap.get(folderId); - if (!entry) { - return false; - } - - if (!hasSessionSearchQuery) { - return true; - } - - const folderMatches = entry.folder.name.toLowerCase().includes(normalizedSessionSearchQuery); - if (folderMatches || entry.nodes.length > 0) { - return true; - } - - return allFoldersForGroupBase - .filter(({ folder }) => folder.parentId === folderId) - .some(({ folder }) => shouldKeepFolder(folder.id, folderMap)); - }; - - const folderMapById = new Map( - allFoldersForGroupBase.map((entry) => [entry.folder.id, entry]), - ); - - const allFoldersForGroup = hasSessionSearchQuery - ? allFoldersForGroupBase.filter(({ folder }) => shouldKeepFolder(folder.id, folderMapById)) - : allFoldersForGroupBase; - - const sessionIdsInFolders = new Set(allFoldersForGroup.flatMap((f) => f.folder.sessionIds)); - const ungroupedSessions = sourceGroupNodes.filter((node) => !sessionIdsInFolders.has(node.session.id)); - - // Root-level folders (no parentId) — sub-folders are rendered inside their parent - const rootFolders = allFoldersForGroup.filter(({ folder }) => !folder.parentId); - - if (hasSessionSearchQuery && !groupMatchesSearch && rootFolders.length === 0 && ungroupedSessions.length === 0) { - return null; + const existing = result.get(key); + if (!existing || getPrVisualPriority(nextIndicator.visualState) > getPrVisualPriority(existing.visualState)) { + result.set(key, nextIndicator); } + }); - const totalSessions = ungroupedSessions.length; - const visibleSessions = hasSessionSearchQuery - ? ungroupedSessions - : (isExpanded ? ungroupedSessions : ungroupedSessions.slice(0, maxVisible)); - const remainingCount = totalSessions - visibleSessions.length; - 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(sourceGroupNodes); - const normalizedGroupDirectory = normalizePath(group.directory ?? null); - const isGitProject = projectId && projectRepoStatus.has(projectId) - ? Boolean(projectRepoStatus.get(projectId)) - : lastRepoStatusRef.current; - const showBranchSubtitle = !group.isMain && isBranchDifferentFromLabel(group.branch, group.label); - const isActiveGroup = Boolean( - normalizedGroupDirectory - && currentSessionDirectory - && normalizedGroupDirectory === currentSessionDirectory, - ); + return result; + }, [prStatusEntries]); - // Helper: render a single folder item (root or sub) wrapped in DroppableFolderWrapper - const renderOneFolderItem = (folder: (typeof allFoldersForGroup)[number]['folder'], nodes: SessionNode[], depth: number) => { - // Find direct sub-folders of this folder - const directSubFolders = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id); - const subFolderItems = directSubFolders.length > 0 ? ( - <>{directSubFolders.map(({ folder: sf, nodes: sn }) => renderOneFolderItem(sf, sn, depth + 1))} - ) : undefined; - - return ( - - {(droppableRef, isDropTarget) => ( - toggleFolderCollapse(folder.id)} - onRename={(name) => { - if (folderScopeKey) renameFolder(folderScopeKey, folder.id, name); - }} - onDelete={() => { - if (!folderScopeKey) return; - if (!showDeletionDialog) { - deleteFolder(folderScopeKey, folder.id); - return; - } - // Count affected sub-folders and sessions for the confirm dialog - const subFolderCount = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id).length; - const sessionCount = nodes.length; - setDeleteFolderConfirm({ - scopeKey: folderScopeKey, - folderId: folder.id, - folderName: folder.name, - subFolderCount, - sessionCount, - }); - }} - renderSessionNode={renderSessionNode} - groupDirectory={group.directory} - projectId={projectId} - mobileVariant={mobileVariant} - isRenaming={renamingFolderId === folder.id} - renameDraft={renamingFolderId === folder.id ? renameFolderDraft : undefined} - onRenameDraftChange={(value) => setRenameFolderDraft(value)} - onRenameSave={() => { - const trimmed = renameFolderDraft.trim(); - if (trimmed && folderScopeKey) { - renameFolder(folderScopeKey, folder.id, trimmed); - } - setRenamingFolderId(null); - setRenameFolderDraft(''); - }} - onRenameCancel={() => { - setRenamingFolderId(null); - setRenameFolderDraft(''); - }} - droppableRef={droppableRef} - isDropTarget={isDropTarget} - depth={depth} - onNewSession={() => { - if (projectId && projectId !== activeProjectId) { - setActiveProjectIdOnly(projectId); - } - setActiveMainTab('chat'); - if (mobileVariant) { - setSessionSwitcherOpen(false); - } - openNewSessionDraft({ directoryOverride: group.directory, targetFolderId: folder.id }); - }} - onNewSubFolder={depth === 0 ? () => { - if (!folderScopeKey) return; - createFolderAndStartRename(folderScopeKey, folder.id); - } : undefined} - /> - )} - - ); - }; - - // 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. - // Shared folder rendering helper (used in both branches). - // Uses DroppableFolderWrapper so each folder header becomes a DnD drop zone. - const renderFolderItems = () => - rootFolders.map(({ folder, nodes }) => renderOneFolderItem(folder, nodes, 0)); - - if (hideGroupLabel) { - return ( -
-
- 0} - onSessionDroppedOnFolder={(sessionId, folderId) => { - if (folderScopeKey) addSessionToFolder(folderScopeKey, folderId, sessionId); - }} - > - {renderFolderItems()} - {visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId))} - {totalSessions === 0 && allFoldersForGroup.length === 0 ? ( -
- No sessions in this workspace yet. -
- ) : null} - {remainingCount > 0 && !isExpanded ? ( - - ) : null} - {isExpanded && totalSessions > maxVisible ? ( - - ) : null} -
-
-
- ); - } - - return ( -
-
{ - setCollapsedGroups((prev) => { - const next = new Set(prev); - if (next.has(groupKey)) { - next.delete(groupKey); - } else { - next.add(groupKey); - } - return next; - }); - } : undefined} - role={!hideGroupLabel ? "button" : undefined} - tabIndex={!hideGroupLabel ? 0 : undefined} - onKeyDown={!hideGroupLabel ? (event) => { - 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; - }); - } - } : undefined} - aria-label={!hideGroupLabel ? (isCollapsed ? `Expand ${group.label}` : `Collapse ${group.label}`) : undefined} - > - {!hideGroupLabel ? ( -
- {!group.isMain || isGitProject ? ( - - ) : null} -
-

- {renderHighlightedText(group.label, normalizedSessionSearchQuery)} -

- {showBranchSubtitle ? ( - - {group.branch} - - ) : null} -
- {isCollapsed ? ( - - ) : ( - - )} -
- ) :
} - {group.directory ? ( - <> - {!group.isMain && group.worktree ? ( -
- - - - - -

Delete worktree

-
-
-
- ) : null} -
- - - - - -

New session

-
-
-
- - ) : null} -
- {!isCollapsed ? ( -
- 0} - onSessionDroppedOnFolder={(sessionId, folderId) => { - if (folderScopeKey) addSessionToFolder(folderScopeKey, folderId, sessionId); - }} - > - {renderFolderItems()} - {visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId))} - {totalSessions === 0 && allFoldersForGroup.length === 0 ? ( -
- No sessions in this workspace yet. -
- ) : null} - {remainingCount > 0 && !isExpanded ? ( - - ) : null} - {isExpanded && totalSessions > maxVisible ? ( - - ) : null} -
-
- ) : null} -
- ); - }, + const renderGroupSessions = React.useCallback( + (group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean) => ( + + ), [ - expandedSessionGroups, - collapsedGroups, - hideDirectoryControls, hasSessionSearchQuery, normalizedSessionSearchQuery, groupSearchDataByGroup, - currentSessionDirectory, - projectRepoStatus, - renderSessionNode, - toggleGroupSessionLimit, - activeProjectId, - setActiveProjectIdOnly, - setActiveMainTab, - mobileVariant, - setSessionSwitcherOpen, - openNewSessionDraft, + expandedSessionGroups, + collapsedGroups, + hideDirectoryControls, getFoldersForScope, collapsedFolderIds, toggleFolderCollapse, - createFolderAndStartRename, renameFolder, deleteFolder, showDeletionDialog, + renderSessionNode, + currentSessionDirectory, + projectRepoStatus, + toggleGroupSessionLimit, + mobileVariant, + activeProjectId, + setActiveProjectIdOnly, + setActiveMainTab, + setSessionSwitcherOpen, + openNewSessionDraft, addSessionToFolder, + createFolderAndStartRename, renamingFolderId, renameFolderDraft, pinnedSessionIds, - ] - ); - - // DnD sensors for project reordering - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 8, - }, - }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }) + prVisualStateByDirectoryBranch, + toggleCollapsedGroup, + ], ); return ( @@ -3286,432 +978,80 @@ export const SessionSidebar: React.FC = ({ mobileVariant ? '' : 'bg-transparent', )} > - {!hideDirectoryControls && ( -
- {!hideProjectSelector && ( -
- { - if (!open) { - setIsProjectRenameInline(false); - } - }} - > - - - - - {normalizedProjects.map((project) => { - const label = formatProjectLabel( - project.label?.trim() - || formatDirectoryName(project.normalizedPath, homeDirectory) - || project.normalizedPath - ); - return ( - setActiveProjectIdOnly(project.id)} - className={cn('truncate', project.id === activeProjectId && 'text-primary')} - > - {label} - - ); - })} -
- {!isProjectRenameInline ? ( - { - event.preventDefault(); - handleStartInlineProjectRename(); - }} - className="gap-2" - > - - Rename project - - ) : ( -
-
{ - event.preventDefault(); - handleSaveInlineProjectRename(); - }} - > - 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 - onKeyDown={(event) => { - if (event.key === 'Escape') { - event.stopPropagation(); - setIsProjectRenameInline(false); - return; - } - if (event.key === ' ' || event.key === 'Enter') { - event.stopPropagation(); - } - }} - /> - - -
-
- )} - { - if (!activeProjectForHeader) { - return; - } - removeProject(activeProjectForHeader.id); - }} - className="text-destructive focus:text-destructive gap-2" - > - - Close project - - - - -
- )} - {reserveHeaderActionsSpace ? ( -
- {activeProjectForHeader ? ( - <> -
- {stableActiveProjectIsRepo ? ( - <> - - - - -

New worktree

-
- - - - -

New multi-run

-
- - ) : null} - {useMobileNotesPanel ? ( - - - - -

Project notes

-
- ) : ( - - - - - - - -

Project notes

-
- - setProjectNotesPanelOpen(false)} - /> - -
- )} - - - - -

Search sessions

-
-
- {isSessionSearchOpen ? ( -
-
- {hasSessionSearchQuery ? ( - {searchMatchCount} {searchMatchCount === 1 ? 'match' : 'matches'} - ) : } - Esc to clear -
-
- - setSessionSearchQuery(event.target.value)} - placeholder="Search sessions..." - className="h-8 w-full rounded-md border border-border bg-transparent pl-8 pr-8 typography-ui-label text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary/50" - onKeyDown={(event) => { - if (event.key === 'Escape') { - event.stopPropagation(); - if (hasSessionSearchQuery) { - setSessionSearchQuery(''); - } else { - setIsSessionSearchOpen(false); - } - } - }} - /> - {sessionSearchQuery.length > 0 ? ( - - ) : null} -
-
- ) : null} - - ) : null} -
- ) : null} -
- )} + - - {projectSections.length === 0 ? ( - emptyState - ) : sectionsForRender.length === 0 ? ( - searchEmptyState - ) : showOnlyMainWorkspace ? ( -
- {(() => { - const activeSection = sectionsForRender.find((section) => section.project.id === activeProjectId) ?? sectionsForRender[0]; - if (!activeSection) { - return hasSessionSearchQuery ? searchEmptyState : 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) { - return ( -
- No sessions yet. -
- ); - } - const groupKey = `${activeSection.project.id}:${group.id}`; - // In VS Code mode with showOnlyMainWorkspace, hide the group header to show a flat session list - return renderGroupSessions(group, groupKey, activeSection.project.id, showOnlyMainWorkspace); - })()} -
- ) : ( - <> - {sectionsForRender.map((section) => { - 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); - const isCollapsed = collapsedProjects.has(projectKey) && hideDirectoryControls; - const isActiveProject = projectKey === activeProjectId; - const isRepo = projectRepoStatus.get(projectKey); - const isHovered = hoveredProjectId === projectKey; - const orderedGroups = getOrderedGroups(projectKey, section.groups); - - return ( - toggleProject(projectKey)} - onHoverChange={(hovered) => setHoveredProjectId(hovered ? projectKey : null)} - onNewSession={() => { - if (projectKey !== activeProjectId) { - setActiveProjectIdOnly(projectKey); - } - setActiveMainTab('chat'); - if (mobileVariant) { - setSessionSwitcherOpen(false); - } - openNewSessionDraft({ directoryOverride: project.normalizedPath }); - }} - onNewWorktreeSession={() => { - if (projectKey !== activeProjectId) { - setActiveProjectIdOnly(projectKey); - } - setActiveMainTab('chat'); - if (mobileVariant) { - setSessionSwitcherOpen(false); - } - createWorktreeSession(); - }} - onOpenMultiRunLauncher={() => { - if (projectKey !== activeProjectId) { - setActiveProjectIdOnly(projectKey); - } - openMultiRunLauncher(); - }} - 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} - onClose={() => removeProject(projectKey)} - sentinelRef={(el) => { projectHeaderSentinelRefs.current.set(projectKey, el); }} - settingsAutoCreateWorktree={settingsAutoCreateWorktree} - showCreateButtons={false} - hideHeader - > - {!isCollapsed ? ( -
- {section.groups.length > 0 ? ( - { - const { active, over } = event; - if (!over || active.id === over.id) { - return; - } - const oldIndex = orderedGroups.findIndex((item) => item.id === active.id); - const newIndex = orderedGroups.findIndex((item) => item.id === over.id); - if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) { - return; - } - const next = arrayMove(orderedGroups, oldIndex, newIndex).map((item) => item.id); - setGroupOrderByProject((prev) => { - const map = new Map(prev); - map.set(projectKey, next); - return map; - }); - }} - > - group.id)} - strategy={verticalListSortingStrategy} - > - {orderedGroups.map((group) => { - const groupKey = `${projectKey}:${group.id}`; - return ( - - {renderGroupSessions(group, groupKey, projectKey)} - - ); - })} - - - - ) : ( -
- No sessions yet. -
- )} -
- ) : null} -
- ); - })} - - )} -
+ = ({ ) : null} - {/* Confirm delete session dialog */} - { if (!open) setDeleteSessionConfirm(null); }}> - - - Delete session? - - {deleteSessionConfirm && deleteSessionConfirm.descendantCount > 0 - ? `"${deleteSessionConfirm.session.title || 'Untitled Session'}" and its ${deleteSessionConfirm.descendantCount} sub-task${deleteSessionConfirm.descendantCount === 1 ? '' : 's'} will be permanently deleted.` - : `"${deleteSessionConfirm?.session.title || 'Untitled Session'}" will be permanently deleted.`} - - - - -
- - -
-
-
-
+ - {/* Confirm delete folder dialog */} - { if (!open) setDeleteFolderConfirm(null); }}> - - - Delete folder? - - {deleteFolderConfirm && (deleteFolderConfirm.subFolderCount > 0 || deleteFolderConfirm.sessionCount > 0) - ? `"${deleteFolderConfirm.folderName}" will be deleted${deleteFolderConfirm.subFolderCount > 0 ? ` along with ${deleteFolderConfirm.subFolderCount} sub-folder${deleteFolderConfirm.subFolderCount === 1 ? '' : 's'}` : ''}. Sessions inside will not be deleted.` - : `"${deleteFolderConfirm?.folderName}" will be permanently deleted.`} - - - - - - - - +
); }; diff --git a/packages/ui/src/components/session/sidebar/ConfirmDialogs.tsx b/packages/ui/src/components/session/sidebar/ConfirmDialogs.tsx new file mode 100644 index 00000000..a52e8091 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/ConfirmDialogs.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { RiCheckboxBlankLine, RiCheckboxLine } from '@remixicon/react'; +import type { Session } from '@opencode-ai/sdk/v2'; + +export type DeleteSessionConfirmState = { + session: Session; + descendantCount: number; + archivedBucket: boolean; +} | null; + +export function SessionDeleteConfirmDialog(props: { + value: DeleteSessionConfirmState; + setValue: (next: DeleteSessionConfirmState) => void; + showDeletionDialog: boolean; + setShowDeletionDialog: (next: boolean) => void; + onConfirm: () => Promise | void; +}): React.ReactNode { + const { value, setValue, showDeletionDialog, setShowDeletionDialog, onConfirm } = props; + + return ( + { if (!open) setValue(null); }}> + + + {value?.archivedBucket ? 'Delete session?' : 'Archive session?'} + + {value && value.descendantCount > 0 + ? value.archivedBucket + ? `"${value.session.title || 'Untitled Session'}" and its ${value.descendantCount} sub-task${value.descendantCount === 1 ? '' : 's'} will be permanently deleted.` + : `"${value.session.title || 'Untitled Session'}" and its ${value.descendantCount} sub-task${value.descendantCount === 1 ? '' : 's'} will be archived.` + : value?.archivedBucket + ? `"${value?.session.title || 'Untitled Session'}" will be permanently deleted.` + : `"${value?.session.title || 'Untitled Session'}" will be archived.`} + + + + +
+ + +
+
+
+
+ ); +} + +export type DeleteFolderConfirmState = { + scopeKey: string; + folderId: string; + folderName: string; + subFolderCount: number; + sessionCount: number; +} | null; + +export function FolderDeleteConfirmDialog(props: { + value: DeleteFolderConfirmState; + setValue: (next: DeleteFolderConfirmState) => void; + onConfirm: () => void; +}): React.ReactNode { + const { value, setValue, onConfirm } = props; + + return ( + { if (!open) setValue(null); }}> + + + Delete folder? + + {value && (value.subFolderCount > 0 || value.sessionCount > 0) + ? `"${value.folderName}" will be deleted${value.subFolderCount > 0 ? ` along with ${value.subFolderCount} sub-folder${value.subFolderCount === 1 ? '' : 's'}` : ''}. Sessions inside will not be deleted.` + : `"${value?.folderName}" will be permanently deleted.`} + + + + + + + + + ); +} diff --git a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md new file mode 100644 index 00000000..ce09b800 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md @@ -0,0 +1,46 @@ +# Session Sidebar Documentation + +## Refactor result + +- `SessionSidebar.tsx` now acts mainly as orchestration; core logic moved to focused hooks/components. +- Sidebar behavior stays intact: global+archived session grouping, folder operations, delete/archive semantics, project/worktree rendering, and search. +- Recent migration gaps were fixed (persistence + repo-status hooks fully wired). +- New extractions in latest pass reduced local effect/callback bulk further: + - project session list builders + - folder cleanup sync + - sticky project header observer +- Baseline checks pass after refactor: `type-check`, `lint`, `build`. + +## File summaries + +### Components + +- `SidebarHeader.tsx`: Top header UI (project selector/rename, search, add/open actions, notes/worktree entry points). +- `SidebarProjectsList.tsx`: Main scrollable list renderer for project sections/groups, empty states, and project-level interactions. +- `SessionGroupSection.tsx`: Renders a single group (root sessions + folders), collapse/expand, and group-level controls. +- `SessionNodeItem.tsx`: Renders one session row/tree node with metadata, menu actions, inline rename, and nested children. +- `ConfirmDialogs.tsx`: Shared confirm dialog wrappers for session delete and folder delete flows. +- `sortableItems.tsx`: DnD sortable wrappers for project and group ordering with drag handles/overlays. +- `sessionFolderDnd.tsx`: Folder/session DnD scope and wrappers for dropping/moving sessions into folders. + +### Hooks + +- `hooks/useSessionActions.ts`: Centralizes session row actions (select/open, rename, share/unshare, archive/delete, confirmations). +- `hooks/useSessionSearchEffects.ts`: Handles search open/close UX and input focus behavior. +- `hooks/useSessionPrefetch.ts`: Prefetches messages for nearby/active sessions to improve perceived load speed. +- `hooks/useDirectoryStatusProbe.ts`: Probes and caches directory existence status for session/path indicators. +- `hooks/useSessionGrouping.ts`: Builds grouped session structures and search text/filter helpers. +- `hooks/useSessionSidebarSections.ts`: Composes final per-project sections and group search metadata for rendering. +- `hooks/useProjectSessionSelection.ts`: Resolves active/current project-session selection logic and session-directory context. +- `hooks/useGroupOrdering.ts`: Applies persisted/custom group order with stable fallback ordering. +- `hooks/useArchivedAutoFolders.ts`: Maintains archived auto-folder structure and assignment behavior. +- `hooks/useSidebarPersistence.ts`: Persists sidebar UI state (expanded/collapsed/pinned/group order/active session) to storage + desktop settings. +- `hooks/useProjectRepoStatus.ts`: Tracks per-project git-repo state and root branch metadata. +- `hooks/useProjectSessionLists.ts`: Builds live and archived session lists for a given project (including worktrees + dedupe). +- `hooks/useSessionFolderCleanup.ts`: Cleans stale folder session IDs by reconciling known sessions/archived scopes. +- `hooks/useStickyProjectHeaders.ts`: Tracks which project headers are sticky/stuck via `IntersectionObserver`. + +### Types and utilities + +- `types.ts`: Shared sidebar types (`SessionNode`, `SessionGroup`, summary/search metadata). +- `utils.tsx`: Shared sidebar utilities (path normalization, sorting, dedupe, archived scope keys, project relation checks, text highlight, labels). diff --git a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx new file mode 100644 index 00000000..a2ebf80c --- /dev/null +++ b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx @@ -0,0 +1,580 @@ +import React from 'react'; +import type { Session } from '@opencode-ai/sdk/v2'; +import { + RiAddLine, + RiArchiveLine, + RiArrowDownSLine, + RiArrowLeftLongLine, + RiArrowRightSLine, + RiDeleteBinLine, + RiGitBranchLine, +} from '@remixicon/react'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +import { sessionEvents } from '@/lib/sessionEvents'; +import type { MainTab } from '@/stores/useUIStore'; +import { SessionFolderItem } from '../SessionFolderItem'; +import { DroppableFolderWrapper, SessionFolderDndScope } from './sessionFolderDnd'; +import type { GroupSearchData, SessionGroup, SessionNode } from './types'; +import { compareSessionsByPinnedAndTime, isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils'; +import type { SessionFolder } from '@/stores/useSessionFoldersStore'; + +type DeleteFolderConfirm = { + scopeKey: string; + folderId: string; + folderName: string; + subFolderCount: number; + sessionCount: number; +} | null; + +type Props = { + group: SessionGroup; + groupKey: string; + projectId?: string | null; + hideGroupLabel?: boolean; + hasSessionSearchQuery: boolean; + normalizedSessionSearchQuery: string; + groupSearchDataByGroup: WeakMap; + expandedSessionGroups: Set; + collapsedGroups: Set; + hideDirectoryControls: boolean; + getFoldersForScope: (scopeKey: string) => SessionFolder[]; + collapsedFolderIds: Set; + toggleFolderCollapse: (folderId: string) => void; + renameFolder: (scopeKey: string, folderId: string, name: string) => void; + deleteFolder: (scopeKey: string, folderId: string) => void; + showDeletionDialog: boolean; + setDeleteFolderConfirm: React.Dispatch>; + renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean) => React.ReactNode; + currentSessionDirectory: string | null; + projectRepoStatus: Map; + lastRepoStatus: boolean; + toggleGroupSessionLimit: (groupKey: string) => void; + mobileVariant: boolean; + activeProjectId: string | null; + setActiveProjectIdOnly: (id: string) => void; + setActiveMainTab: (tab: MainTab) => void; + setSessionSwitcherOpen: (open: boolean) => void; + openNewSessionDraft: (options?: { directoryOverride?: string | null; targetFolderId?: string }) => void; + addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void; + createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null; + renamingFolderId: string | null; + renameFolderDraft: string; + setRenameFolderDraft: React.Dispatch>; + setRenamingFolderId: React.Dispatch>; + pinnedSessionIds: Set; + prVisualStateByDirectoryBranch: Map; + onToggleCollapsedGroup: (groupKey: string) => void; +}; + +export function SessionGroupSection(props: Props): React.ReactNode { + const { + group, + groupKey, + projectId, + hideGroupLabel, + hasSessionSearchQuery, + normalizedSessionSearchQuery, + groupSearchDataByGroup, + expandedSessionGroups, + collapsedGroups, + hideDirectoryControls, + getFoldersForScope, + collapsedFolderIds, + toggleFolderCollapse, + renameFolder, + deleteFolder, + showDeletionDialog, + setDeleteFolderConfirm, + renderSessionNode, + currentSessionDirectory, + projectRepoStatus, + lastRepoStatus, + toggleGroupSessionLimit, + mobileVariant, + activeProjectId, + setActiveProjectIdOnly, + setActiveMainTab, + setSessionSwitcherOpen, + openNewSessionDraft, + addSessionToFolder, + createFolderAndStartRename, + renamingFolderId, + renameFolderDraft, + setRenameFolderDraft, + setRenamingFolderId, + pinnedSessionIds, + prVisualStateByDirectoryBranch, + onToggleCollapsedGroup, + } = props; + + const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null; + const isExpanded = expandedSessionGroups.has(groupKey); + const isCollapsed = hasSessionSearchQuery ? false : collapsedGroups.has(groupKey); + const maxVisible = hideDirectoryControls ? 10 : 5; + const groupMatchesSearch = hasSessionSearchQuery ? searchData?.groupMatches === true : false; + const shouldFilterGroupContents = hasSessionSearchQuery; + const sourceGroupNodes = shouldFilterGroupContents ? (searchData?.filteredNodes ?? []) : group.sessions; + const folderScopeKey = group.folderScopeKey ?? normalizePath(group.directory ?? null); + const scopeFolders = folderScopeKey ? getFoldersForScope(folderScopeKey) : []; + + const nodeBySessionId = new Map(); + const collectNodeLookup = (nodes: SessionNode[]) => { + nodes.forEach((node) => { + nodeBySessionId.set(node.session.id, node); + if (node.children.length > 0) { + collectNodeLookup(node.children); + } + }); + }; + collectNodeLookup(sourceGroupNodes); + + const allFoldersForGroupBase = scopeFolders.map((folder) => { + const nodes = folder.sessionIds + .map((sid) => nodeBySessionId.get(sid)) + .filter((n): n is SessionNode => Boolean(n)) + .sort((a, b) => compareSessionsByPinnedAndTime(a.session, b.session, pinnedSessionIds)); + return { folder, nodes }; + }); + + const folderMapById = new Map(allFoldersForGroupBase.map((entry) => [entry.folder.id, entry])); + const shouldKeepFolder = (folderId: string): boolean => { + const entry = folderMapById.get(folderId); + if (!entry) return false; + if (!hasSessionSearchQuery) return true; + const folderMatches = entry.folder.name.toLowerCase().includes(normalizedSessionSearchQuery); + if (folderMatches || entry.nodes.length > 0) return true; + return allFoldersForGroupBase + .filter(({ folder }) => folder.parentId === folderId) + .some(({ folder }) => shouldKeepFolder(folder.id)); + }; + + const allFoldersForGroup = hasSessionSearchQuery + ? allFoldersForGroupBase.filter(({ folder }) => shouldKeepFolder(folder.id)) + : allFoldersForGroupBase; + + const sessionIdsInFolders = new Set(allFoldersForGroup.flatMap((f) => f.folder.sessionIds)); + const ungroupedSessions = sourceGroupNodes.filter((node) => !sessionIdsInFolders.has(node.session.id)); + const rootFolders = allFoldersForGroup.filter(({ folder }) => !folder.parentId); + + if (hasSessionSearchQuery && !groupMatchesSearch && rootFolders.length === 0 && ungroupedSessions.length === 0) { + return null; + } + + const totalSessions = ungroupedSessions.length; + const visibleSessions = group.isArchivedBucket + ? ungroupedSessions + : hasSessionSearchQuery + ? ungroupedSessions + : (isExpanded ? ungroupedSessions : ungroupedSessions.slice(0, maxVisible)); + const remainingCount = totalSessions - visibleSessions.length; + + 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(sourceGroupNodes); + const normalizedGroupDirectory = normalizePath(group.directory ?? null); + const isGitProject = projectId && projectRepoStatus.has(projectId) + ? Boolean(projectRepoStatus.get(projectId)) + : lastRepoStatus; + const isActiveGroup = Boolean( + normalizedGroupDirectory + && currentSessionDirectory + && normalizedGroupDirectory === currentSessionDirectory, + ); + const groupDirectoryKey = normalizePath(group.directory ?? null); + const groupBranchKey = group.branch?.trim() ?? null; + const prIndicator = groupDirectoryKey && groupBranchKey + ? (prVisualStateByDirectoryBranch.get(`${groupDirectoryKey}::${groupBranchKey}`) ?? null) + : null; + const showInlinePrTitle = Boolean(prIndicator && group.branch); + const showBranchSubtitle = !group.isMain && (isBranchDifferentFromLabel(group.branch, group.label) || Boolean(prIndicator)); + const prVisualState = prIndicator?.visualState ?? null; + const branchIconColor = prVisualState ? `var(--pr-${prVisualState})` : undefined; + const checksSummary = prIndicator && prIndicator.state === 'open' && prIndicator.checks + ? `${prIndicator.checks.success}/${prIndicator.checks.total} checks passed` + : null; + const checksTail = prIndicator && prIndicator.state === 'open' && prIndicator.checks + ? [ + prIndicator.checks.failure > 0 ? `${prIndicator.checks.failure} failing` : null, + prIndicator.checks.pending > 0 ? `${prIndicator.checks.pending} pending` : null, + ].filter((item): item is string => Boolean(item)).join(', ') + : null; + const mergeabilityLabel = prIndicator && prIndicator.state === 'open' + ? (prIndicator.canMerge === true + ? 'Mergeable' + : (prIndicator.canMerge === false ? 'Conflicts or blocked' : null)) + : null; + const mergeStateLabel = prIndicator && prIndicator.state === 'open' && prIndicator.mergeableState + ? `Merge state: ${prIndicator.mergeableState}` + : null; + const baseBranchLabel = prIndicator?.base ?? null; + const headBranchLabel = prIndicator?.head ?? null; + const handlePrLinkClick = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + const url = prIndicator?.url; + if (!url || typeof window === 'undefined') { + return; + } + const tauri = (window as unknown as { __TAURI__?: { shell?: { open?: (target: string) => Promise } } }).__TAURI__; + if (tauri?.shell?.open) { + void tauri.shell.open(url).catch(() => { + window.open(url, '_blank', 'noopener,noreferrer'); + }); + return; + } + window.open(url, '_blank', 'noopener,noreferrer'); + }; + + const renderOneFolderItem = (folder: SessionFolder, nodes: SessionNode[], depth: number): React.ReactNode => { + const directSubFolders = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id); + const subFolderItems = directSubFolders.length > 0 + ? <>{directSubFolders.map(({ folder: sf, nodes: sn }) => renderOneFolderItem(sf, sn, depth + 1))} + : undefined; + + return ( + + {(droppableRef, isDropTarget) => ( + toggleFolderCollapse(folder.id)} + onRename={(name) => { + if (folderScopeKey) renameFolder(folderScopeKey, folder.id, name); + }} + onDelete={() => { + if (!folderScopeKey) return; + if (!showDeletionDialog) { + deleteFolder(folderScopeKey, folder.id); + return; + } + const subFolderCount = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id).length; + const sessionCount = nodes.length; + setDeleteFolderConfirm({ + scopeKey: folderScopeKey, + folderId: folder.id, + folderName: folder.name, + subFolderCount, + sessionCount, + }); + }} + renderSessionNode={renderSessionNode} + groupDirectory={group.directory} + projectId={projectId} + mobileVariant={mobileVariant} + isRenaming={renamingFolderId === folder.id} + renameDraft={renamingFolderId === folder.id ? renameFolderDraft : undefined} + onRenameDraftChange={(value) => setRenameFolderDraft(value)} + onRenameSave={() => { + const trimmed = renameFolderDraft.trim(); + if (trimmed && folderScopeKey) { + renameFolder(folderScopeKey, folder.id, trimmed); + } + setRenamingFolderId(null); + setRenameFolderDraft(''); + }} + onRenameCancel={() => { + setRenamingFolderId(null); + setRenameFolderDraft(''); + }} + droppableRef={droppableRef} + isDropTarget={isDropTarget} + depth={depth} + onNewSession={() => { + if (projectId && projectId !== activeProjectId) setActiveProjectIdOnly(projectId); + setActiveMainTab('chat'); + if (mobileVariant) setSessionSwitcherOpen(false); + openNewSessionDraft({ directoryOverride: group.directory, targetFolderId: folder.id }); + }} + onNewSubFolder={depth === 0 ? () => { + if (!folderScopeKey) return; + createFolderAndStartRename(folderScopeKey, folder.id); + } : undefined} + hideActions={group.isArchivedBucket === true} + archivedBucket={group.isArchivedBucket === true} + /> + )} + + ); + }; + + const renderFolderItems = () => rootFolders.map(({ folder, nodes }) => renderOneFolderItem(folder, nodes, 0)); + + const body = ( + 0} + onSessionDroppedOnFolder={(sessionId, folderId) => { + if (folderScopeKey) addSessionToFolder(folderScopeKey, folderId, sessionId); + }} + > + {renderFolderItems()} + {visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true))} + {totalSessions === 0 && allFoldersForGroup.length === 0 ? ( +
+ {group.isArchivedBucket ? 'No archived sessions yet.' : 'No sessions in this workspace yet.'} +
+ ) : null} + {remainingCount > 0 && !isExpanded ? ( + + ) : null} + {isExpanded && totalSessions > maxVisible ? ( + + ) : null} +
+ ); + + if (hideGroupLabel) { + return
{body}
; + } + + return ( +
+
onToggleCollapsedGroup(groupKey)} + role="button" + tabIndex={0} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onToggleCollapsedGroup(groupKey); + } + }} + aria-label={isCollapsed ? `Expand ${group.label}` : `Collapse ${group.label}`} + > +
+ {group.isArchivedBucket ? ( + + ) : (!group.isMain || isGitProject) ? ( + showInlinePrTitle && prIndicator ? ( + + + + + + + +
+ {(baseBranchLabel || headBranchLabel) ? ( +
+ {baseBranchLabel && headBranchLabel ? ( + <> + {baseBranchLabel} + + {headBranchLabel} + + ) : ( + {baseBranchLabel ?? headBranchLabel ?? ''} + )} +
+ ) : null} + {mergeStateLabel ?
{mergeStateLabel}
: null} + {(mergeabilityLabel || checksSummary) ? ( +
+ {mergeabilityLabel ?? ''} + {mergeabilityLabel && checksSummary ? ' • ' : ''} + {checksSummary ?? ''} + {checksTail ? ` (${checksTail})` : ''} +
+ ) : null} +
+
+
+ ) : ( + + ) + ) : null} +
+

+ {showInlinePrTitle && prIndicator ? ( + <> + + + + {prIndicator.url ? ( + + ) : ( + #{prIndicator.number} + )} + + + +

+ {(baseBranchLabel || headBranchLabel) ? ( +
+ {baseBranchLabel && headBranchLabel ? ( + <> + {baseBranchLabel} + + {headBranchLabel} + + ) : ( + {baseBranchLabel ?? headBranchLabel ?? ''} + )} +
+ ) : null} + {mergeStateLabel ?
{mergeStateLabel}
: null} + {(mergeabilityLabel || checksSummary) ? ( +
+ {mergeabilityLabel ?? ''} + {mergeabilityLabel && checksSummary ? ' • ' : ''} + {checksSummary ?? ''} + {checksTail ? ` (${checksTail})` : ''} +
+ ) : null} +
+ + + {` ${group.branch}`} + + ) : ( + renderHighlightedText(group.label, normalizedSessionSearchQuery) + )} +

+ {!showInlinePrTitle && showBranchSubtitle ? ( + + {prIndicator ? ( + <> + {prIndicator.url ? ( + + ) : ( + #{prIndicator.number} + )} + {group.branch ? {` ${group.branch}`} : null} + + ) : ( + group.branch + )} + + ) : null} +
+ {isCollapsed ? ( + + ) : ( + + )} +
+ {group.directory && !group.isMain && group.worktree ? ( +
+ + + + +

Delete worktree

+
+
+ ) : null} + {group.directory ? ( +
+ + + + +

New session

+
+
+ ) : null} +
+ {!isCollapsed ?
{body}
: null} +
+ ); +} diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx new file mode 100644 index 00000000..83f5f95e --- /dev/null +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -0,0 +1,498 @@ +import React from 'react'; +import type { Session } from '@opencode-ai/sdk/v2'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { GridLoader } from '@/components/ui/grid-loader'; +import { + RiAddLine, + RiArrowDownSLine, + RiArrowRightSLine, + RiChat4Line, + RiCheckLine, + RiCloseLine, + RiDeleteBinLine, + RiErrorWarningLine, + RiFileCopyLine, + RiFileEditLine, + RiFolderLine, + RiLinkUnlinkM, + RiMore2Line, + RiPencilAiLine, + RiPushpinLine, + RiRobot2Line, + RiShare2Line, + RiShieldLine, + RiUnpinLine, +} from '@remixicon/react'; +import { cn } from '@/lib/utils'; +import { DraggableSessionRow } from './sessionFolderDnd'; +import type { SessionNode, SessionSummaryMeta } from './types'; +import { formatSessionDateLabel, normalizePath, renderHighlightedText, resolveSessionDiffStats } from './utils'; +import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; + +const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]); + +const getAttentionDiamondDelay = (index: number): string => { + return index === 4 ? '0ms' : '130ms'; +}; + +type Folder = { id: string; name: string; sessionIds: string[] }; + +type Props = { + node: SessionNode; + depth?: number; + groupDirectory?: string | null; + projectId?: string | null; + archivedBucket?: boolean; + directoryStatus: Map; + sessionMemoryState: Map; + currentSessionId: string | null; + pinnedSessionIds: Set; + expandedParents: Set; + hasSessionSearchQuery: boolean; + normalizedSessionSearchQuery: string; + sessionAttentionStates: Map; + notifyOnSubtasks: boolean; + sessionStatus?: Map; + permissions: Map; + editingId: string | null; + setEditingId: (id: string | null) => void; + editTitle: string; + setEditTitle: (value: string) => void; + handleSaveEdit: () => void; + handleCancelEdit: () => void; + toggleParent: (sessionId: string) => void; + handleSessionSelect: (sessionId: string, sessionDirectory: string | null, isMissingDirectory: boolean, projectId?: string | null) => void; + handleSessionDoubleClick: () => void; + togglePinnedSession: (sessionId: string) => void; + handleShareSession: (session: Session) => void; + copiedSessionId: string | null; + handleCopyShareUrl: (url: string, sessionId: string) => void; + handleUnshareSession: (sessionId: string) => void; + openMenuSessionId: string | null; + setOpenMenuSessionId: (id: string | null) => void; + renamingFolderId: string | null; + getFoldersForScope: (scopeKey: string) => Folder[]; + getSessionFolderId: (scopeKey: string, sessionId: string) => string | null; + removeSessionFromFolder: (scopeKey: string, sessionId: string) => void; + addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void; + createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null; + openContextPanelTab: (directory: string, options: { mode: 'chat'; dedupeKey: string; label: string }) => void; + handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean }) => void; + mobileVariant: boolean; + renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean) => React.ReactNode; +}; + +export function SessionNodeItem(props: Props): React.ReactNode { + const { + node, + depth = 0, + groupDirectory, + projectId, + archivedBucket = false, + directoryStatus, + sessionMemoryState, + currentSessionId, + pinnedSessionIds, + expandedParents, + hasSessionSearchQuery, + normalizedSessionSearchQuery, + sessionAttentionStates, + notifyOnSubtasks, + sessionStatus, + permissions, + editingId, + setEditingId, + editTitle, + setEditTitle, + handleSaveEdit, + handleCancelEdit, + toggleParent, + handleSessionSelect, + handleSessionDoubleClick, + togglePinnedSession, + handleShareSession, + copiedSessionId, + handleCopyShareUrl, + handleUnshareSession, + openMenuSessionId, + setOpenMenuSessionId, + renamingFolderId, + getFoldersForScope, + getSessionFolderId, + removeSessionFromFolder, + addSessionToFolder, + createFolderAndStartRename, + openContextPanelTab, + handleDeleteSession, + mobileVariant, + renderSessionNode, + } = props; + + const displayMode = useSessionDisplayStore((state) => state.displayMode); + const isMinimalMode = displayMode === 'minimal'; + + const session = node.session; + const sessionDirectory = + normalizePath((session as Session & { directory?: string | null }).directory ?? null) + ?? normalizePath(groupDirectory ?? null); + const directoryState = sessionDirectory ? directoryStatus.get(sessionDirectory) : null; + const isMissingDirectory = directoryState === 'missing'; + const memoryState = sessionMemoryState.get(session.id); + const isActive = currentSessionId === session.id; + const sessionTitle = session.title || 'Untitled Session'; + const hasChildren = node.children.length > 0; + const isPinnedSession = pinnedSessionIds.has(session.id); + const isExpanded = hasSessionSearchQuery ? true : expandedParents.has(session.id); + const isSubtaskSession = Boolean((session as Session & { parentID?: string | null }).parentID); + const rawNeedsAttention = sessionAttentionStates.get(session.id)?.needsAttention === true; + const needsAttention = rawNeedsAttention && (!isSubtaskSession || notifyOnSubtasks); + const sessionSummary = session.summary as SessionSummaryMeta | undefined; + const sessionDiffStats = resolveSessionDiffStats(sessionSummary); + + if (editingId === session.id) { + return ( +
0 && 'pl-[20px]')} + > +
+
{ + event.preventDefault(); + handleSaveEdit(); + }} + > + setEditTitle(event.target.value)} + className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground" + autoFocus + placeholder="Rename session" + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.stopPropagation(); + handleCancelEdit(); + return; + } + if (event.key === ' ' || event.key === 'Enter') { + event.stopPropagation(); + } + }} + /> + + +
+ {!isMinimalMode ? ( +
+ {hasChildren ? {isExpanded ? : } : null} + {formatSessionDateLabel(session.time?.updated || session.time?.created || Date.now())} + {sessionDiffStats ? +{sessionDiffStats.additions}/-{sessionDiffStats.deletions} : null} + {session.share ? : null} + {(sessionSummary?.files ?? 0) > 0 || hasChildren ? ( + + {(sessionSummary?.files ?? 0) > 0 ? {sessionSummary!.files}

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

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

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

: null} +
+ ) : null} +
+ ) : null} +
+
+ ); + } + + const statusType = sessionStatus?.get(session.id)?.type ?? 'idle'; + const isStreaming = statusType === 'busy' || statusType === 'retry'; + const pendingPermissionCount = permissions.get(session.id)?.length ?? 0; + const showUnreadStatus = !isStreaming && needsAttention && !isActive; + const showStatusMarker = isStreaming || showUnreadStatus; + + const streamingIndicator = memoryState?.isZombie + ? + : null; + + return ( + + +
0 && 'pl-[20px]')} + onContextMenu={(e) => { + e.preventDefault(); + setOpenMenuSessionId(session.id); + }} + > +
+ {isMinimalMode ? ( + + + + + +
+
+ {formatSessionDateLabel(session.time?.updated || session.time?.created || Date.now())} + {sessionDiffStats ? ( + + +{sessionDiffStats.additions} + / + -{sessionDiffStats.deletions} + + ) : null} +
+ {session.share ? ( +
+ + Shared session +
+ ) : null} + {(sessionSummary?.files ?? 0) > 0 ? ( +
+ + {sessionSummary!.files} changed {sessionSummary!.files === 1 ? 'file' : 'files'} +
+ ) : null} + {hasChildren ? ( +
+ + {node.children.length} {node.children.length === 1 ? 'sub-session' : 'sub-sessions'} +
+ ) : null} + {isMissingDirectory ? ( +
+ + Directory missing +
+ ) : null} +
+
+
+ ) : ( + + )} +
+ + {streamingIndicator && !mobileVariant ? ( +
+ {streamingIndicator} +
+ ) : null} + +
+ setOpenMenuSessionId(open ? session.id : null)}> + + + + { if (renamingFolderId) event.preventDefault(); }}> + { + setEditingId(session.id); + setEditTitle(sessionTitle); + }} + className="[&>svg]:mr-1" + > + + Rename + + togglePinnedSession(session.id)} className="[&>svg]:mr-1"> + {isPinnedSession ? : } + {isPinnedSession ? 'Unpin session' : 'Pin session'} + + {!session.share ? ( + handleShareSession(session)} className="[&>svg]:mr-1"> + + Share + + ) : ( + <> + { if (session.share?.url) handleCopyShareUrl(session.share.url, session.id); }} className="[&>svg]:mr-1"> + {copiedSessionId === session.id ? <>Copied : <>Copy link} + + handleUnshareSession(session.id)} className="[&>svg]:mr-1"> + + Unshare + + + )} + + {sessionDirectory && !archivedBucket ? (() => { + const scopeFolders = getFoldersForScope(sessionDirectory); + const currentFolderId = getSessionFolderId(sessionDirectory, session.id); + return ( + <> + + + Move to folder + + {scopeFolders.length === 0 ? ( + No folders yet + ) : ( + scopeFolders.map((folder) => ( + { if (currentFolderId === folder.id) removeSessionFromFolder(sessionDirectory, session.id); else addSessionToFolder(sessionDirectory, folder.id, session.id); }}> + {folder.name} + {currentFolderId === folder.id ? : null} + + )) + )} + + { const newFolder = createFolderAndStartRename(sessionDirectory); if (!newFolder) return; addSessionToFolder(sessionDirectory, newFolder.id, session.id); }}> + + New folder... + + {currentFolderId ? ( + { removeSessionFromFolder(sessionDirectory, session.id); }} className="text-destructive focus:text-destructive"> + + Remove from folder + + ) : null} + + + + ); + })() : null} + + { + if (!sessionDirectory) return; + openContextPanelTab(sessionDirectory, { + mode: 'chat', + dedupeKey: `session:${session.id}`, + label: sessionTitle, + }); + }} + className="[&>svg]:mr-1" + > + + Open in Side Panel + beta + + + + handleDeleteSession(session, { archivedBucket })}> + + {archivedBucket ? 'Delete' : 'Archive'} + + + +
+
+
+ {hasChildren && isExpanded + ? node.children.map((child) => renderSessionNode(child, depth + 1, sessionDirectory ?? groupDirectory, projectId, archivedBucket)) + : null} +
+ ); +} diff --git a/packages/ui/src/components/session/sidebar/SidebarHeader.tsx b/packages/ui/src/components/session/sidebar/SidebarHeader.tsx new file mode 100644 index 00000000..dab8817e --- /dev/null +++ b/packages/ui/src/components/session/sidebar/SidebarHeader.tsx @@ -0,0 +1,440 @@ +import React from 'react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { + RiArrowDownSLine, + RiCheckLine, + RiCloseLine, + RiEqualizer2Line, + RiNodeTree, + RiPencilAiLine, + RiSearchLine, + RiStickyNoteLine, +} from '@remixicon/react'; +import { ArrowsMerge } from '@/components/icons/ArrowsMerge'; +import { ProjectNotesTodoPanel } from '../ProjectNotesTodoPanel'; +import { formatDirectoryName } from '@/lib/utils'; +import { formatProjectLabel } from './utils'; +import type { ProjectRef } from '@/lib/openchamberConfig'; +import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; + +type ProjectItem = { + id: string; + label?: string; + normalizedPath: string; +}; + +type ActiveProject = { + id: string; + label?: string; + normalizedPath: string; +} | null; + +type Props = { + hideDirectoryControls: boolean; + hideProjectSelector: boolean; + activeProjectForHeader: ActiveProject; + homeDirectory: string | null; + normalizedProjects: ProjectItem[]; + activeProjectId: string | null; + setActiveProjectIdOnly: (projectId: string) => void; + isProjectRenameInline: boolean; + setIsProjectRenameInline: (value: boolean) => void; + handleStartInlineProjectRename: () => void; + handleSaveInlineProjectRename: () => void; + projectRenameDraft: string; + setProjectRenameDraft: (value: string) => void; + removeProject: (projectId: string) => void; + handleOpenDirectoryDialog: () => void; + addProjectButtonClass: string; + headerActionIconClass: string; + reserveHeaderActionsSpace: boolean; + stableActiveProjectIsRepo: boolean; + useMobileNotesPanel: boolean; + projectNotesPanelOpen: boolean; + setProjectNotesPanelOpen: (open: boolean) => void; + activeProjectRefForHeader: ProjectRef | null; + openMultiRunLauncher: () => void; + headerActionButtonClass: string; + setNewWorktreeDialogOpen: (open: boolean) => void; + setActiveMainTab: (tab: 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files') => void; + isSessionSearchOpen: boolean; + setIsSessionSearchOpen: (open: boolean | ((prev: boolean) => boolean)) => void; + sessionSearchInputRef: React.RefObject; + sessionSearchQuery: string; + setSessionSearchQuery: (value: string) => void; + hasSessionSearchQuery: boolean; + searchMatchCount: number; +}; + +export function SidebarHeader(props: Props): React.ReactNode { + const { + hideDirectoryControls, + hideProjectSelector, + activeProjectForHeader, + homeDirectory, + normalizedProjects, + activeProjectId, + setActiveProjectIdOnly, + isProjectRenameInline, + setIsProjectRenameInline, + handleStartInlineProjectRename, + handleSaveInlineProjectRename, + projectRenameDraft, + setProjectRenameDraft, + removeProject, + addProjectButtonClass, + headerActionIconClass, + reserveHeaderActionsSpace, + stableActiveProjectIsRepo, + useMobileNotesPanel, + projectNotesPanelOpen, + setProjectNotesPanelOpen, + activeProjectRefForHeader, + openMultiRunLauncher, + headerActionButtonClass, + setNewWorktreeDialogOpen, + setActiveMainTab, + isSessionSearchOpen, + setIsSessionSearchOpen, + sessionSearchInputRef, + sessionSearchQuery, + setSessionSearchQuery, + hasSessionSearchQuery, + searchMatchCount, + } = props; + + const displayMode = useSessionDisplayStore((state) => state.displayMode); + const setDisplayMode = useSessionDisplayStore((state) => state.setDisplayMode); + + if (hideDirectoryControls) { + return null; + } + + return ( +
+ {!hideProjectSelector && ( +
+ { + if (!open) setIsProjectRenameInline(false); + }} + > + + + + + {normalizedProjects.map((project) => { + const label = formatProjectLabel( + project.label?.trim() + || formatDirectoryName(project.normalizedPath, homeDirectory) + || project.normalizedPath, + ); + return ( + setActiveProjectIdOnly(project.id)} + className={`truncate ${project.id === activeProjectId ? 'text-primary' : ''}`} + > + {label} + + ); + })} +
+ {!isProjectRenameInline ? ( + { + event.preventDefault(); + handleStartInlineProjectRename(); + }} + className="gap-2" + > + + Rename project + + ) : ( +
+
{ + event.preventDefault(); + handleSaveInlineProjectRename(); + }} + > + 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 + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.stopPropagation(); + setIsProjectRenameInline(false); + return; + } + if (event.key === ' ' || event.key === 'Enter') { + event.stopPropagation(); + } + }} + /> + + +
+
+ )} + { + if (!activeProjectForHeader) return; + removeProject(activeProjectForHeader.id); + }} + className="text-destructive focus:text-destructive gap-2" + > + + Close project + + + + + + + + + + +

Display mode

+
+ + setDisplayMode('default')} + className="flex items-center justify-between" + > + Default + {displayMode === 'default' ? : null} + + setDisplayMode('minimal')} + className="flex items-center justify-between" + > + Minimal + {displayMode === 'minimal' ? : null} + + +
+
+ )} + + {reserveHeaderActionsSpace ? ( +
+ {activeProjectForHeader ? ( + <> +
+
+ {stableActiveProjectIsRepo ? ( + <> + + + + +

New worktree

+
+ + + + +

New multi-run

+
+ + ) : null} + + {useMobileNotesPanel ? ( + + + + +

Project notes

+
+ ) : ( + + + + + + + +

Project notes

+
+ + setProjectNotesPanelOpen(false)} + /> + +
+ )} + + + + + +

Search sessions

+
+
+ + + + + + + + +

Display mode

+
+ + setDisplayMode('default')} + className="flex items-center justify-between" + > + Default + {displayMode === 'default' ? : null} + + setDisplayMode('minimal')} + className="flex items-center justify-between" + > + Minimal + {displayMode === 'minimal' ? : null} + + +
+
+ + {isSessionSearchOpen ? ( +
+
+ {hasSessionSearchQuery ? ( + {searchMatchCount} {searchMatchCount === 1 ? 'match' : 'matches'} + ) : } + Esc to clear +
+
+ + setSessionSearchQuery(event.target.value)} + placeholder="Search sessions..." + className="h-8 w-full rounded-md border border-border bg-transparent pl-8 pr-8 typography-ui-label text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary/50" + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.stopPropagation(); + if (hasSessionSearchQuery) { + setSessionSearchQuery(''); + } else { + setIsSessionSearchOpen(false); + } + } + }} + /> + {sessionSearchQuery.length > 0 ? ( + + ) : null} +
+
+ ) : null} + + ) : null} +
+ ) : null} +
+ ); +} diff --git a/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx b/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx new file mode 100644 index 00000000..f4b6baf2 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx @@ -0,0 +1,208 @@ +import React from 'react'; +import { + DndContext, + DragOverlay, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { SortableContext, arrayMove, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { formatDirectoryName, formatPathForDisplay, cn } from '@/lib/utils'; +import type { SessionGroup } from './types'; +import { SortableGroupItem, SortableProjectItem } from './sortableItems'; +import { formatProjectLabel } from './utils'; + +type ProjectSection = { + project: { + id: string; + label?: string; + normalizedPath: string; + }; + groups: SessionGroup[]; +}; + +type Props = { + sectionsForRender: ProjectSection[]; + projectSections: ProjectSection[]; + activeProjectId: string | null; + showOnlyMainWorkspace: boolean; + hasSessionSearchQuery: boolean; + emptyState: React.ReactNode; + searchEmptyState: React.ReactNode; + renderGroupSessions: (group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean) => React.ReactNode; + homeDirectory: string | null; + collapsedProjects: Set; + hideDirectoryControls: boolean; + projectRepoStatus: Map; + hoveredProjectId: string | null; + setHoveredProjectId: (id: string | null) => void; + isDesktopShellRuntime: boolean; + stuckProjectHeaders: Set; + mobileVariant: boolean; + toggleProject: (id: string) => void; + setActiveProjectIdOnly: (id: string) => void; + setActiveMainTab: (tab: 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files') => void; + setSessionSwitcherOpen: (open: boolean) => void; + openNewSessionDraft: (options?: { directoryOverride?: string | null }) => void; + createWorktreeSession: () => void; + openMultiRunLauncher: () => void; + setEditingProjectId: (id: string | null) => void; + setEditProjectTitle: (title: string) => void; + editingProjectId: string | null; + editProjectTitle: string; + handleSaveProjectEdit: () => void; + handleCancelProjectEdit: () => void; + removeProject: (id: string) => void; + projectHeaderSentinelRefs: React.MutableRefObject>; + settingsAutoCreateWorktree: boolean; + getOrderedGroups: (projectId: string, groups: SessionGroup[]) => SessionGroup[]; + setGroupOrderByProject: React.Dispatch>>; +}; + +export function SidebarProjectsList(props: Props): React.ReactNode { + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + if (props.projectSections.length === 0) { + return {props.emptyState}; + } + + if (props.sectionsForRender.length === 0) { + return {props.searchEmptyState}; + } + + return ( + + {props.showOnlyMainWorkspace ? ( +
+ {(() => { + const activeSection = props.sectionsForRender.find((section) => section.project.id === props.activeProjectId) ?? props.sectionsForRender[0]; + if (!activeSection) { + return props.hasSessionSearchQuery ? props.searchEmptyState : props.emptyState; + } + 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) { + return
No sessions yet.
; + } + const groupKey = `${activeSection.project.id}:${group.id}`; + return props.renderGroupSessions(group, groupKey, activeSection.project.id, props.showOnlyMainWorkspace); + })()} +
+ ) : ( + <> + {props.sectionsForRender.map((section) => { + const project = section.project; + const projectKey = project.id; + const projectLabel = formatProjectLabel( + project.label?.trim() + || formatDirectoryName(project.normalizedPath, props.homeDirectory) + || project.normalizedPath, + ); + const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory); + const isCollapsed = props.collapsedProjects.has(projectKey) && props.hideDirectoryControls; + const isActiveProject = projectKey === props.activeProjectId; + const isRepo = props.projectRepoStatus.get(projectKey); + const isHovered = props.hoveredProjectId === projectKey; + const orderedGroups = props.getOrderedGroups(projectKey, section.groups); + + return ( + props.toggleProject(projectKey)} + onHoverChange={(hovered) => props.setHoveredProjectId(hovered ? projectKey : null)} + onNewSession={() => { + if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey); + props.setActiveMainTab('chat'); + if (props.mobileVariant) props.setSessionSwitcherOpen(false); + props.openNewSessionDraft({ directoryOverride: project.normalizedPath }); + }} + onNewWorktreeSession={() => { + if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey); + props.setActiveMainTab('chat'); + if (props.mobileVariant) props.setSessionSwitcherOpen(false); + props.createWorktreeSession(); + }} + onOpenMultiRunLauncher={() => { + if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey); + props.openMultiRunLauncher(); + }} + onRenameStart={() => { + props.setEditingProjectId(projectKey); + props.setEditProjectTitle(project.label?.trim() || formatDirectoryName(project.normalizedPath, props.homeDirectory) || project.normalizedPath); + }} + onRenameSave={props.handleSaveProjectEdit} + onRenameCancel={props.handleCancelProjectEdit} + onRenameValueChange={props.setEditProjectTitle} + renameValue={props.editingProjectId === projectKey ? props.editProjectTitle : ''} + isRenaming={props.editingProjectId === projectKey} + onClose={() => props.removeProject(projectKey)} + sentinelRef={(el) => { props.projectHeaderSentinelRefs.current.set(projectKey, el); }} + settingsAutoCreateWorktree={props.settingsAutoCreateWorktree} + showCreateButtons={false} + hideHeader + > + {!isCollapsed ? ( +
+ {section.groups.length > 0 ? ( + { + const { active, over } = event; + if (!over || active.id === over.id) return; + const oldIndex = orderedGroups.findIndex((item) => item.id === active.id); + const newIndex = orderedGroups.findIndex((item) => item.id === over.id); + if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return; + const next = arrayMove(orderedGroups, oldIndex, newIndex).map((item) => item.id); + props.setGroupOrderByProject((prev) => { + const map = new Map(prev); + map.set(projectKey, next); + return map; + }); + }} + > + group.id)} strategy={verticalListSortingStrategy}> + {orderedGroups.map((group) => { + const groupKey = `${projectKey}:${group.id}`; + return ( + + {props.renderGroupSessions(group, groupKey, projectKey)} + + ); + })} + + + + ) : ( +
No sessions yet.
+ )} +
+ ) : null} +
+ ); + })} + + )} +
+ ); +} diff --git a/packages/ui/src/components/session/sidebar/hooks/useArchivedAutoFolders.ts b/packages/ui/src/components/session/sidebar/hooks/useArchivedAutoFolders.ts new file mode 100644 index 00000000..1722209d --- /dev/null +++ b/packages/ui/src/components/session/sidebar/hooks/useArchivedAutoFolders.ts @@ -0,0 +1,120 @@ +import React from 'react'; +import type { Session } from '@opencode-ai/sdk/v2'; +import type { WorktreeMetadata } from '@/types/worktree'; +import { dedupeSessionsById, getArchivedScopeKey, isSessionRelatedToProject, normalizePath, resolveArchivedFolderName } from '../utils'; + +export type ProjectForArchivedFolders = { + normalizedPath: string; +}; + +type FolderEntry = { + id: string; + name: string; + sessionIds: string[]; +}; + +type Args = { + normalizedProjects: ProjectForArchivedFolders[]; + sessions: Session[]; + archivedSessions: Session[]; + availableWorktreesByProject: Map; + isVSCode: boolean; + isSessionsLoading: boolean; + foldersMap: Record; + createFolder: (scopeKey: string, name: string, parentId?: string | null) => FolderEntry; + addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void; + cleanupSessions: (scopeKey: string, existingSessionIds: Set) => void; +}; + +const getArchivedSessionsForProject = ( + project: ProjectForArchivedFolders, + params: Pick, +): Session[] => { + const worktreesForProject = params.isVSCode ? [] : (params.availableWorktreesByProject.get(project.normalizedPath) ?? []); + const validDirectories = new Set([ + project.normalizedPath, + ...worktreesForProject + .map((meta) => normalizePath(meta.path) ?? meta.path) + .filter((value): value is string => Boolean(value)), + ]); + + const collect = (input: Session[]): Session[] => input.filter((session) => + isSessionRelatedToProject(session, project.normalizedPath, validDirectories), + ); + + const archived = collect(params.archivedSessions); + const unassignedLive = params.sessions.filter((session) => { + if (session.time?.archived) { + return false; + } + const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); + if (sessionDirectory) { + return false; + } + return isSessionRelatedToProject(session, project.normalizedPath, validDirectories); + }); + + return dedupeSessionsById([...archived, ...unassignedLive]); +}; + +export const useArchivedAutoFolders = (args: Args): void => { + const { + normalizedProjects, + sessions, + archivedSessions, + availableWorktreesByProject, + isVSCode, + isSessionsLoading, + foldersMap, + createFolder, + addSessionToFolder, + cleanupSessions, + } = args; + + React.useEffect(() => { + if (isSessionsLoading) { + return; + } + + normalizedProjects.forEach((project) => { + const scopeKey = getArchivedScopeKey(project.normalizedPath); + const projectArchivedSessions = getArchivedSessionsForProject(project, { + sessions, + archivedSessions, + availableWorktreesByProject, + isVSCode, + }); + const sessionIds = new Set(projectArchivedSessions.map((session) => session.id)); + + const existingFolders = foldersMap[scopeKey] ?? []; + const folderByName = new Map(existingFolders.map((folder) => [folder.name.toLowerCase(), folder])); + + projectArchivedSessions.forEach((session) => { + const folderName = resolveArchivedFolderName(session, project.normalizedPath); + const key = folderName.toLowerCase(); + let folder = folderByName.get(key); + if (!folder) { + folder = createFolder(scopeKey, folderName); + folderByName.set(key, folder); + } + + if (!folder.sessionIds.includes(session.id)) { + addSessionToFolder(scopeKey, folder.id, session.id); + } + }); + + cleanupSessions(scopeKey, sessionIds); + }); + }, [ + normalizedProjects, + sessions, + archivedSessions, + availableWorktreesByProject, + isVSCode, + isSessionsLoading, + foldersMap, + createFolder, + addSessionToFolder, + cleanupSessions, + ]); +}; diff --git a/packages/ui/src/components/session/sidebar/hooks/useDirectoryStatusProbe.ts b/packages/ui/src/components/session/sidebar/hooks/useDirectoryStatusProbe.ts new file mode 100644 index 00000000..622091a2 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/hooks/useDirectoryStatusProbe.ts @@ -0,0 +1,96 @@ +import React from 'react'; +import type { Session } from '@opencode-ai/sdk/v2'; +import { opencodeClient } from '@/lib/opencode/client'; +import { normalizePath } from '../utils'; + +type ProjectLike = { path: string }; + +type Args = { + sortedSessions: Session[]; + projects: ProjectLike[]; + directoryStatus: Map; + setDirectoryStatus: React.Dispatch>>; +}; + +export const useDirectoryStatusProbe = ({ + sortedSessions, + projects, + directoryStatus, + setDirectoryStatus, +}: Args): void => { + const directoryStatusRef = React.useRef>(new Map()); + const checkingDirectories = React.useRef>(new Set()); + + React.useEffect(() => { + directoryStatusRef.current = directoryStatus; + }, [directoryStatus]); + + React.useEffect(() => { + const directories = new Set(); + sortedSessions.forEach((session) => { + const dir = normalizePath((session as Session & { directory?: string | null }).directory ?? null); + if (dir) { + directories.add(dir); + } + }); + projects.forEach((project) => { + const normalized = normalizePath(project.path); + if (normalized) { + directories.add(normalized); + } + }); + + directories.forEach((directory) => { + const known = directoryStatusRef.current.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; + }); + }) + .catch(async () => { + 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; + } + } + + 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); + }); + }); + }, [sortedSessions, projects, setDirectoryStatus]); +}; diff --git a/packages/ui/src/components/session/sidebar/hooks/useGroupOrdering.ts b/packages/ui/src/components/session/sidebar/hooks/useGroupOrdering.ts new file mode 100644 index 00000000..0d6cbdef --- /dev/null +++ b/packages/ui/src/components/session/sidebar/hooks/useGroupOrdering.ts @@ -0,0 +1,33 @@ +import React from 'react'; +import type { SessionGroup } from '../types'; + +export const useGroupOrdering = (groupOrderByProject: Map) => { + const getOrderedGroups = React.useCallback( + (projectId: string, groups: SessionGroup[]) => { + const archivedGroup = groups.find((group) => group.isArchivedBucket === true) ?? null; + const reorderableGroups = archivedGroup ? groups.filter((group) => group !== archivedGroup) : groups; + const preferredOrder = groupOrderByProject.get(projectId); + if (!preferredOrder || preferredOrder.length === 0) { + return archivedGroup ? [...reorderableGroups, archivedGroup] : reorderableGroups; + } + const groupById = new Map(reorderableGroups.map((group) => [group.id, group])); + const ordered: SessionGroup[] = []; + preferredOrder.forEach((id) => { + const group = groupById.get(id); + if (group) { + ordered.push(group); + groupById.delete(id); + } + }); + reorderableGroups.forEach((group) => { + if (groupById.has(group.id)) { + ordered.push(group); + } + }); + return archivedGroup ? [...ordered, archivedGroup] : ordered; + }, + [groupOrderByProject], + ); + + return { getOrderedGroups }; +}; diff --git a/packages/ui/src/components/session/sidebar/hooks/useProjectRepoStatus.ts b/packages/ui/src/components/session/sidebar/hooks/useProjectRepoStatus.ts new file mode 100644 index 00000000..3119e816 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/hooks/useProjectRepoStatus.ts @@ -0,0 +1,105 @@ +import React from 'react'; +import { checkIsGitRepository } from '@/lib/gitApi'; +import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; + +type Project = { id: string; path: string; normalizedPath: string }; + +type DirectoryState = { status?: { current?: string | null } | null }; + +type Args = { + projects: Array<{ id: string; path: string }>; + normalizedProjects: Project[]; + normalizePath: (value?: string | null) => string | null; + gitDirectories: Map; + setProjectRepoStatus: React.Dispatch>>; + setProjectRootBranches: React.Dispatch>>; +}; + +export const useProjectRepoStatus = (args: Args): void => { + const { + projects, + normalizedProjects, + normalizePath, + gitDirectories, + setProjectRepoStatus, + setProjectRootBranches, + } = args; + + React.useEffect(() => { + let cancelled = false; + const normalized = 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 (normalized.length === 0) { + return () => { + cancelled = true; + }; + } + + normalized.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; + }); + } + }); + }); + + return () => { + cancelled = true; + }; + }, [normalizePath, projects, setProjectRepoStatus]); + + const projectGitBranchesKey = React.useMemo(() => { + return normalizedProjects + .map((project) => { + const dirState = gitDirectories.get(project.normalizedPath); + return `${project.id}:${dirState?.status?.current ?? ''}`; + }) + .join('|'); + }, [normalizedProjects, gitDirectories]); + + 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; + }; + }, [normalizedProjects, projectGitBranchesKey, setProjectRootBranches]); +}; diff --git a/packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts b/packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts new file mode 100644 index 00000000..418680e0 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts @@ -0,0 +1,94 @@ +import React from 'react'; +import type { Session } from '@opencode-ai/sdk/v2'; +import { dedupeSessionsById, isSessionRelatedToProject, normalizePath } from '../utils'; + +type WorktreeMeta = { path: string }; + +type Args = { + isVSCode: boolean; + sessions: Session[]; + archivedSessions: Session[]; + sessionsByDirectory: Map; + getSessionsByDirectory: (directory: string) => Session[]; + availableWorktreesByProject: Map; +}; + +export const useProjectSessionLists = (args: Args) => { + const { + isVSCode, + sessions, + archivedSessions, + sessionsByDirectory, + getSessionsByDirectory, + availableWorktreesByProject, + } = args; + + const getSessionsForProject = React.useCallback( + (project: { normalizedPath: string }) => { + const worktreesForProject = isVSCode ? [] : (availableWorktreesByProject.get(project.normalizedPath) ?? []); + const directories = [ + project.normalizedPath, + ...worktreesForProject + .map((meta) => normalizePath(meta.path) ?? meta.path) + .filter((value): value is string => Boolean(value)), + ]; + + const seen = new Set(); + 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; + }, + [availableWorktreesByProject, getSessionsByDirectory, isVSCode, sessionsByDirectory], + ); + + const getArchivedSessionsForProject = React.useCallback( + (project: { normalizedPath: string }) => { + const worktreesForProject = isVSCode ? [] : (availableWorktreesByProject.get(project.normalizedPath) ?? []); + const validDirectories = new Set([ + project.normalizedPath, + ...worktreesForProject + .map((meta) => normalizePath(meta.path) ?? meta.path) + .filter((value): value is string => Boolean(value)), + ]); + + const collect = (input: Session[]): Session[] => input.filter((session) => + isSessionRelatedToProject(session, project.normalizedPath, validDirectories), + ); + + const archived = collect(archivedSessions); + const unassignedLive = sessions.filter((session) => { + if (session.time?.archived) { + return false; + } + const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); + if (sessionDirectory) { + return false; + } + const projectWorktree = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null); + if (!projectWorktree) { + return false; + } + return projectWorktree === project.normalizedPath || projectWorktree.startsWith(`${project.normalizedPath}/`); + }); + + return dedupeSessionsById([...archived, ...unassignedLive]); + }, + [archivedSessions, availableWorktreesByProject, isVSCode, sessions], + ); + + return { + getSessionsForProject, + getArchivedSessionsForProject, + }; +}; diff --git a/packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.ts b/packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.ts new file mode 100644 index 00000000..fb415e67 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.ts @@ -0,0 +1,194 @@ +import React from 'react'; +import type { Session } from '@opencode-ai/sdk/v2'; +import type { SessionGroup, SessionNode } from '../types'; +import { normalizePath } from '../utils'; + +type ProjectSection = { + project: { id: string; normalizedPath: string }; + groups: SessionGroup[]; +}; + +type Args = { + projectSections: ProjectSection[]; + activeProjectId: string | null; + activeSessionByProject: Map; + setActiveSessionByProject: React.Dispatch>>; + currentSessionId: string | null; + handleSessionSelect: (sessionId: string, sessionDirectory: string | null, isMissingDirectory: boolean, projectId?: string | null) => void; + isVSCode: boolean; + newSessionDraftOpen: boolean; + mobileVariant: boolean; + openNewSessionDraft: (options?: { directoryOverride?: string | null }) => void; + setActiveMainTab: (tab: 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files') => void; + setSessionSwitcherOpen: (open: boolean) => void; + sessions: Session[]; + worktreeMetadata: Map; +}; + +export const useProjectSessionSelection = (args: Args): { currentSessionDirectory: string | null } => { + const { + projectSections, + activeProjectId, + activeSessionByProject, + setActiveSessionByProject, + currentSessionId, + handleSessionSelect, + isVSCode, + newSessionDraftOpen, + mobileVariant, + openNewSessionDraft, + setActiveMainTab, + setSessionSwitcherOpen, + sessions, + worktreeMetadata, + } = args; + + const projectSessionMeta = React.useMemo(() => { + const metaByProject = new Map>(); + const firstSessionByProject = new Map(); + + 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(null); + const lastSeenActiveProjectRef = React.useRef(null); + + React.useLayoutEffect(() => { + if (!activeProjectId) { + return; + } + + const previousSeenProjectId = lastSeenActiveProjectRef.current; + const isProjectSwitch = Boolean(previousSeenProjectId && previousSeenProjectId !== activeProjectId); + lastSeenActiveProjectRef.current = activeProjectId; + + if (newSessionDraftOpen && (isVSCode || !isProjectSwitch)) { + return; + } + + if (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); + + 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; + } + + 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, + isVSCode, + newSessionDraftOpen, + mobileVariant, + openNewSessionDraft, + projectSections, + projectSessionMeta, + setActiveMainTab, + setSessionSwitcherOpen, + setActiveSessionByProject, + ]); + + 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, setActiveSessionByProject]); + + 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]); + + return { currentSessionDirectory }; +}; diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts new file mode 100644 index 00000000..02095207 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts @@ -0,0 +1,239 @@ +import React from 'react'; +import type { Session } from '@opencode-ai/sdk/v2'; +import { toast } from '@/components/ui'; +import { copyTextToClipboard } from '@/lib/clipboard'; + +type DeleteSessionConfirmSetter = React.Dispatch>; + +type Args = { + activeProjectId: string | null; + currentDirectory: string | null; + currentSessionId: string | null; + mobileVariant: boolean; + allowReselect: boolean; + onSessionSelected?: (sessionId: string) => void; + isSessionSearchOpen: boolean; + sessionSearchQuery: string; + setSessionSearchQuery: (value: string) => void; + setIsSessionSearchOpen: (open: boolean) => void; + setActiveProjectIdOnly: (id: string) => void; + setDirectory: (directory: string, options?: { showOverlay?: boolean }) => void; + setActiveMainTab: (tab: 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files') => void; + setSessionSwitcherOpen: (open: boolean) => void; + setCurrentSession: (sessionId: string | null) => void; + updateSessionTitle: (id: string, title: string) => Promise; + shareSession: (id: string) => Promise; + unshareSession: (id: string) => Promise; + deleteSession: (id: string) => Promise; + deleteSessions: (ids: string[]) => Promise<{ deletedIds: string[]; failedIds: string[] }>; + archiveSession: (id: string) => Promise; + archiveSessions: (ids: string[]) => Promise<{ archivedIds: string[]; failedIds: string[] }>; + childrenMap: Map; + showDeletionDialog: boolean; + setDeleteSessionConfirm: DeleteSessionConfirmSetter; + deleteSessionConfirm: { session: Session; descendantCount: number; archivedBucket: boolean } | null; + setEditingId: (id: string | null) => void; + setEditTitle: (value: string) => void; + editingId: string | null; + editTitle: string; +}; + +export const useSessionActions = (args: Args) => { + const [copiedSessionId, setCopiedSessionId] = React.useState(null); + const copyTimeout = React.useRef(null); + + React.useEffect(() => { + return () => { + if (copyTimeout.current) { + clearTimeout(copyTimeout.current); + } + }; + }, []); + + const handleSessionSelect = React.useCallback( + (sessionId: string, sessionDirectory?: string | null, disabled?: boolean, projectId?: string | null) => { + if (disabled) { + return; + } + + const resetSessionSearch = () => { + if (!args.isSessionSearchOpen && args.sessionSearchQuery.length === 0) { + return; + } + args.setSessionSearchQuery(''); + args.setIsSessionSearchOpen(false); + }; + + if (projectId && projectId !== args.activeProjectId) { + args.setActiveProjectIdOnly(projectId); + } + + if (sessionDirectory && sessionDirectory !== args.currentDirectory) { + args.setDirectory(sessionDirectory, { showOverlay: false }); + } + + if (args.mobileVariant) { + args.setActiveMainTab('chat'); + args.setSessionSwitcherOpen(false); + } + + if (sessionId === args.currentSessionId) { + if (args.allowReselect) { + args.onSessionSelected?.(sessionId); + } + resetSessionSearch(); + return; + } + args.setCurrentSession(sessionId); + args.onSessionSelected?.(sessionId); + resetSessionSearch(); + }, + [args], + ); + + const handleSessionDoubleClick = React.useCallback(() => { + args.setActiveMainTab('chat'); + }, [args]); + + const handleSaveEdit = React.useCallback(async () => { + if (args.editingId && args.editTitle.trim()) { + await args.updateSessionTitle(args.editingId, args.editTitle.trim()); + args.setEditingId(null); + args.setEditTitle(''); + } + }, [args]); + + const handleCancelEdit = React.useCallback(() => { + args.setEditingId(null); + args.setEditTitle(''); + }, [args]); + + const handleShareSession = React.useCallback(async (session: Session) => { + const result = await args.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'); + } + }, [args]); + + const handleCopyShareUrl = React.useCallback((url: string, sessionId: string) => { + void copyTextToClipboard(url) + .then((result) => { + if (!result.ok) { + toast.error('Failed to copy URL'); + return; + } + 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 args.unshareSession(sessionId); + if (result) { + toast.success('Session unshared'); + } else { + toast.error('Unable to unshare session'); + } + }, [args]); + + const collectDescendants = React.useCallback((sessionId: string): Session[] => { + const collected: Session[] = []; + const visit = (id: string) => { + const children = args.childrenMap.get(id) ?? []; + children.forEach((child) => { + collected.push(child); + visit(child.id); + }); + }; + visit(sessionId); + return collected; + }, [args.childrenMap]); + + const executeDeleteSession = React.useCallback( + async (session: Session, source?: { archivedBucket?: boolean }) => { + const descendants = collectDescendants(session.id); + const shouldHardDelete = source?.archivedBucket === true; + if (descendants.length === 0) { + const success = shouldHardDelete + ? await args.deleteSession(session.id) + : await args.archiveSession(session.id); + if (success) { + toast.success(shouldHardDelete ? 'Session deleted' : 'Session archived'); + } else { + toast.error(shouldHardDelete ? 'Failed to delete session' : 'Failed to archive session'); + } + return; + } + + const ids = [session.id, ...descendants.map((s) => s.id)]; + if (shouldHardDelete) { + const { deletedIds, failedIds } = await args.deleteSessions(ids); + if (deletedIds.length > 0) { + toast.success(`Deleted ${deletedIds.length} session${deletedIds.length === 1 ? '' : 's'}`); + } + if (failedIds.length > 0) { + toast.error(`Failed to delete ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`); + } + return; + } + + const { archivedIds, failedIds } = await args.archiveSessions(ids); + if (archivedIds.length > 0) { + toast.success(`Archived ${archivedIds.length} session${archivedIds.length === 1 ? '' : 's'}`); + } + if (failedIds.length > 0) { + toast.error(`Failed to archive ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`); + } + }, + [args, collectDescendants], + ); + + const handleDeleteSession = React.useCallback( + (session: Session, source?: { archivedBucket?: boolean }) => { + const descendants = collectDescendants(session.id); + if (!args.showDeletionDialog) { + void executeDeleteSession(session, source); + return; + } + args.setDeleteSessionConfirm({ session, descendantCount: descendants.length, archivedBucket: source?.archivedBucket === true }); + }, + [args, collectDescendants, executeDeleteSession], + ); + + const confirmDeleteSession = React.useCallback(async () => { + if (!args.deleteSessionConfirm) return; + const { session, archivedBucket } = args.deleteSessionConfirm; + args.setDeleteSessionConfirm(null); + await executeDeleteSession(session, { archivedBucket }); + }, [args, executeDeleteSession]); + + return { + copiedSessionId, + handleSessionSelect, + handleSessionDoubleClick, + handleSaveEdit, + handleCancelEdit, + handleShareSession, + handleCopyShareUrl, + handleUnshareSession, + handleDeleteSession, + confirmDeleteSession, + }; +}; diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionFolderCleanup.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionFolderCleanup.ts new file mode 100644 index 00000000..2858a7d4 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionFolderCleanup.ts @@ -0,0 +1,94 @@ +import React from 'react'; +import type { Session } from '@opencode-ai/sdk/v2'; +import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore'; +import { dedupeSessionsById, getArchivedScopeKey, isSessionRelatedToProject, normalizePath } from '../utils'; + +type NormalizedProject = { + id: string; + normalizedPath: string; +}; + +type WorktreeMeta = { path: string }; + +type Args = { + isSessionsLoading: boolean; + sessions: Session[]; + archivedSessions: Session[]; + normalizedProjects: NormalizedProject[]; + isVSCode: boolean; + availableWorktreesByProject: Map; + cleanupSessions: (scopeKey: string, validSessionIds: Set) => void; +}; + +export const useSessionFolderCleanup = (args: Args): void => { + const { + isSessionsLoading, + sessions, + archivedSessions, + normalizedProjects, + isVSCode, + availableWorktreesByProject, + cleanupSessions, + } = args; + + React.useEffect(() => { + if (isSessionsLoading) { + return; + } + + const idsByScope = new Map>(); + sessions.forEach((session) => { + const directory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); + if (!directory) { + return; + } + const existing = idsByScope.get(directory); + if (existing) { + existing.add(session.id); + return; + } + idsByScope.set(directory, new Set([session.id])); + }); + + normalizedProjects.forEach((project) => { + const scopeKey = getArchivedScopeKey(project.normalizedPath); + const worktreesForProject = isVSCode ? [] : (availableWorktreesByProject.get(project.normalizedPath) ?? []); + const validDirectories = new Set([ + project.normalizedPath, + ...worktreesForProject + .map((meta) => normalizePath(meta.path) ?? meta.path) + .filter((value): value is string => Boolean(value)), + ]); + + const archivedForProject = dedupeSessionsById([ + ...archivedSessions, + ...sessions.filter((session) => { + if (session.time?.archived) { + return false; + } + const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); + if (sessionDirectory) { + return false; + } + return isSessionRelatedToProject(session, project.normalizedPath, validDirectories); + }), + ]).filter((session) => isSessionRelatedToProject(session, project.normalizedPath, validDirectories)); + + idsByScope.set(scopeKey, new Set(archivedForProject.map((session) => session.id))); + }); + + const currentFoldersMap = useSessionFoldersStore.getState().foldersMap; + const allScopeKeys = new Set([...Object.keys(currentFoldersMap), ...idsByScope.keys()]); + allScopeKeys.forEach((scopeKey) => { + cleanupSessions(scopeKey, idsByScope.get(scopeKey) ?? new Set()); + }); + }, [ + archivedSessions, + availableWorktreesByProject, + cleanupSessions, + isSessionsLoading, + isVSCode, + normalizedProjects, + sessions, + ]); +}; diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts new file mode 100644 index 00000000..ed03cd97 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts @@ -0,0 +1,202 @@ +import React from 'react'; +import type { Session } from '@opencode-ai/sdk/v2'; +import type { WorktreeMetadata } from '@/types/worktree'; +import type { SessionGroup, SessionNode } from '../types'; +import { + compareSessionsByPinnedAndTime, + dedupeSessionsById, + getArchivedScopeKey, + normalizeForBranchComparison, + normalizePath, +} from '../utils'; +import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils'; + +type Args = { + homeDirectory: string | null; + worktreeMetadata: Map; + pinnedSessionIds: Set; + gitDirectories: Map; +}; + +export const useSessionGrouping = (args: Args) => { + const buildGroupSearchText = React.useCallback((group: SessionGroup): string => { + return [group.label, group.branch ?? '', group.description ?? '', group.directory ?? ''].join(' ').toLowerCase(); + }, []); + + const buildSessionSearchText = React.useCallback((session: Session): string => { + const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? ''; + const sessionTitle = (session.title || 'Untitled Session').trim(); + return `${sessionTitle} ${sessionDirectory}`.toLowerCase(); + }, []); + + const filterSessionNodesForSearch = React.useCallback( + (nodes: SessionNode[], query: string): SessionNode[] => { + if (!query) { + return nodes; + } + + return nodes.flatMap((node) => { + const nodeMatches = buildSessionSearchText(node.session).includes(query); + if (nodeMatches) { + return [node]; + } + + const filteredChildren = filterSessionNodesForSearch(node.children, query); + if (filteredChildren.length === 0) { + return []; + } + + return [{ ...node, children: filteredChildren }]; + }); + }, + [buildSessionSearchText], + ); + + const buildGroupedSessions = React.useCallback( + ( + projectSessions: Session[], + projectRoot: string | null, + availableWorktrees: WorktreeMetadata[], + projectRootBranch: string | null, + projectIsRepo: boolean, + ) => { + const normalizedProjectRoot = normalizePath(projectRoot ?? null); + const sortedProjectSessions = dedupeSessionsById(projectSessions) + .sort((a, b) => compareSessionsByPinnedAndTime(a, b, args.pinnedSessionIds)); + + const sessionMap = new Map(sortedProjectSessions.map((session) => [session.id, session])); + const childrenMap = new Map(); + 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); + }); + childrenMap.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, args.pinnedSessionIds))); + + const worktreeByPath = new Map(); + availableWorktrees.forEach((meta) => { + if (meta.path) { + const normalized = normalizePath(meta.path) ?? meta.path; + worktreeByPath.set(normalized, meta); + } + }); + + const getSessionWorktree = (session: Session): WorktreeMetadata | null => { + const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); + const sessionWorktreeMeta = args.worktreeMetadata.get(session.id) ?? null; + if (sessionWorktreeMeta) return sessionWorktreeMeta; + if (sessionDirectory) { + const worktree = worktreeByPath.get(sessionDirectory) ?? null; + if (worktree && sessionDirectory !== normalizedProjectRoot) { + return worktree; + } + } + return null; + }; + + const buildProjectNode = (session: Session): SessionNode => { + const children = childrenMap.get(session.id) ?? []; + return { session, children: children.map((child) => buildProjectNode(child)), worktree: getSessionWorktree(session) }; + }; + + const roots = sortedProjectSessions.filter((session) => { + const parentID = (session as Session & { parentID?: string | null }).parentID; + if (!parentID) return true; + return !sessionMap.has(parentID); + }); + + const groupedNodes = new Map(); + const archivedKey = '__archived__'; + + const getGroupKey = (session: Session) => { + if (session.time?.archived) return archivedKey; + const metadataPath = normalizePath(args.worktreeMetadata.get(session.id)?.path ?? null); + const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); + if (!metadataPath && !sessionDirectory) return archivedKey; + const fallbackDirectory = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null); + const normalizedDir = metadataPath ?? sessionDirectory ?? fallbackDirectory; + if (!normalizedDir) return archivedKey; + if (normalizedDir !== normalizedProjectRoot && worktreeByPath.has(normalizedDir)) return normalizedDir; + if (normalizedDir === normalizedProjectRoot) return normalizedProjectRoot ?? '__project_root__'; + return archivedKey; + }; + + roots.forEach((session) => { + const node = buildProjectNode(session); + const groupKey = getGroupKey(session); + if (!groupedNodes.has(groupKey)) groupedNodes.set(groupKey, []); + 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', + branch: projectRootBranch ?? null, + description: normalizedProjectRoot ? formatPathForDisplay(normalizedProjectRoot, args.homeDirectory) : null, + isMain: true, + isArchivedBucket: false, + worktree: null, + directory: normalizedProjectRoot, + folderScopeKey: normalizedProjectRoot, + sessions: groupedNodes.get(rootKey) ?? [], + }]; + + 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; + const currentBranch = args.gitDirectories.get(directory)?.status?.current?.trim() || null; + const metadataBranch = meta.branch?.trim() || null; + const shouldSyncLabelWithBranch = Boolean( + currentBranch && metadataBranch && meta.label && normalizeForBranchComparison(meta.label) === normalizeForBranchComparison(metadataBranch), + ); + const label = shouldSyncLabelWithBranch + ? currentBranch! + : (meta.label || meta.name || formatDirectoryName(directory, args.homeDirectory) || directory); + + groups.push({ + id: `worktree:${directory}`, + label, + branch: currentBranch || metadataBranch, + description: formatPathForDisplay(directory, args.homeDirectory), + isMain: false, + isArchivedBucket: false, + worktree: meta, + directory, + folderScopeKey: directory, + sessions: groupedNodes.get(directory) ?? [], + }); + }); + + groups.push({ + id: 'archived', + label: 'archived', + branch: null, + description: 'Archived and unassigned sessions', + isMain: false, + isArchivedBucket: true, + worktree: null, + directory: null, + folderScopeKey: normalizedProjectRoot ? getArchivedScopeKey(normalizedProjectRoot) : null, + sessions: groupedNodes.get(archivedKey) ?? [], + }); + + return groups; + }, + [args.homeDirectory, args.worktreeMetadata, args.pinnedSessionIds, args.gitDirectories], + ); + + return { + buildGroupSearchText, + buildSessionSearchText, + filterSessionNodesForSearch, + buildGroupedSessions, + }; +}; diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts new file mode 100644 index 00000000..9fa099b1 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts @@ -0,0 +1,113 @@ +import React from 'react'; +import type { Session } from '@opencode-ai/sdk/v2'; +import { useSessionStore } from '@/stores/useSessionStore'; + +const SESSION_PREFETCH_HOVER_DELAY_MS = 180; +const SESSION_PREFETCH_CONCURRENCY = 1; +const SESSION_PREFETCH_PENDING_LIMIT = 6; + +type Args = { + currentSessionId: string | null; + sortedSessions: Session[]; + loadMessages: (sessionId: string, limit?: number) => Promise; +}; + +export const useSessionPrefetch = ({ currentSessionId, sortedSessions, loadMessages }: Args): void => { + const sessionPrefetchTimersRef = React.useRef>(new Map()); + const sessionPrefetchQueueRef = React.useRef([]); + const sessionPrefetchInFlightRef = React.useRef>(new Set()); + + const pumpSessionPrefetchQueue = React.useCallback(() => { + if (typeof window === 'undefined') { + return; + } + + while (sessionPrefetchInFlightRef.current.size < SESSION_PREFETCH_CONCURRENCY && sessionPrefetchQueueRef.current.length > 0) { + const nextSessionId = sessionPrefetchQueueRef.current.shift(); + if (!nextSessionId) { + break; + } + + const state = useSessionStore.getState(); + if (state.currentSessionId === nextSessionId) { + continue; + } + + const hasMessages = state.messages.has(nextSessionId); + const memory = state.sessionMemoryState.get(nextSessionId); + const isHydrated = hasMessages && memory?.historyComplete !== undefined; + if (isHydrated) { + continue; + } + + sessionPrefetchInFlightRef.current.add(nextSessionId); + void loadMessages(nextSessionId) + .catch(() => undefined) + .finally(() => { + sessionPrefetchInFlightRef.current.delete(nextSessionId); + pumpSessionPrefetchQueue(); + }); + } + }, [loadMessages]); + + const scheduleSessionPrefetch = React.useCallback((sessionId: string | null | undefined) => { + if (!sessionId || sessionId === currentSessionId || typeof window === 'undefined') { + return; + } + + const state = useSessionStore.getState(); + const hasMessages = state.messages.has(sessionId); + const memory = state.sessionMemoryState.get(sessionId); + const isHydrated = hasMessages && memory?.historyComplete !== undefined; + if (isHydrated) { + return; + } + + if (sessionPrefetchInFlightRef.current.has(sessionId)) { + return; + } + + if (sessionPrefetchQueueRef.current.includes(sessionId)) { + return; + } + + if (sessionPrefetchQueueRef.current.length >= SESSION_PREFETCH_PENDING_LIMIT) { + sessionPrefetchQueueRef.current.shift(); + } + + const existingTimer = sessionPrefetchTimersRef.current.get(sessionId); + if (existingTimer !== undefined) { + window.clearTimeout(existingTimer); + } + + const timer = window.setTimeout(() => { + sessionPrefetchTimersRef.current.delete(sessionId); + sessionPrefetchQueueRef.current.push(sessionId); + pumpSessionPrefetchQueue(); + }, SESSION_PREFETCH_HOVER_DELAY_MS); + sessionPrefetchTimersRef.current.set(sessionId, timer); + }, [currentSessionId, pumpSessionPrefetchQueue]); + + React.useEffect(() => { + if (!currentSessionId || sortedSessions.length === 0) { + return; + } + const currentIndex = sortedSessions.findIndex((session) => session.id === currentSessionId); + if (currentIndex < 0) { + return; + } + scheduleSessionPrefetch(sortedSessions[currentIndex - 1]?.id); + scheduleSessionPrefetch(sortedSessions[currentIndex + 1]?.id); + }, [currentSessionId, scheduleSessionPrefetch, sortedSessions]); + + React.useEffect(() => { + const prefetchTimers = sessionPrefetchTimersRef.current; + return () => { + prefetchTimers.forEach((timer) => { + clearTimeout(timer); + }); + prefetchTimers.clear(); + sessionPrefetchQueueRef.current = []; + }; + }, []); +}; diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionSearchEffects.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionSearchEffects.ts new file mode 100644 index 00000000..bb0a760c --- /dev/null +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionSearchEffects.ts @@ -0,0 +1,42 @@ +import React from 'react'; + +type Args = { + isSessionSearchOpen: boolean; + setIsSessionSearchOpen: (open: boolean) => void; + sessionSearchInputRef: React.RefObject; + sessionSearchContainerRef: React.RefObject; +}; + +export const useSessionSearchEffects = ({ + isSessionSearchOpen, + setIsSessionSearchOpen, + sessionSearchInputRef, + sessionSearchContainerRef, +}: Args): void => { + React.useEffect(() => { + if (!isSessionSearchOpen || typeof window === 'undefined') { + return; + } + const raf = window.requestAnimationFrame(() => { + sessionSearchInputRef.current?.focus(); + sessionSearchInputRef.current?.select(); + }); + return () => window.cancelAnimationFrame(raf); + }, [isSessionSearchOpen, sessionSearchInputRef]); + + React.useEffect(() => { + if (!isSessionSearchOpen || typeof document === 'undefined') { + return; + } + const handlePointerDown = (event: MouseEvent) => { + if (!sessionSearchContainerRef.current) { + return; + } + if (!sessionSearchContainerRef.current.contains(event.target as Node)) { + setIsSessionSearchOpen(false); + } + }; + document.addEventListener('mousedown', handlePointerDown); + return () => document.removeEventListener('mousedown', handlePointerDown); + }, [isSessionSearchOpen, setIsSessionSearchOpen, sessionSearchContainerRef]); +}; diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionSidebarSections.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionSidebarSections.ts new file mode 100644 index 00000000..2fda5413 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionSidebarSections.ts @@ -0,0 +1,176 @@ +import React from 'react'; +import type { Session } from '@opencode-ai/sdk/v2'; +import type { SessionGroup, SessionNode, GroupSearchData } from '../types'; +import { dedupeSessionsById, normalizePath } from '../utils'; +import type { WorktreeMetadata } from '@/types/worktree'; + +type ProjectItem = { + id: string; + path: string; + label?: string; + normalizedPath: string; +}; + +type ProjectSection = { + project: ProjectItem; + groups: SessionGroup[]; +}; + +type Args = { + normalizedProjects: ProjectItem[]; + activeProjectId: string | null; + getSessionsForProject: (project: { normalizedPath: string }) => Session[]; + getArchivedSessionsForProject: (project: { normalizedPath: string }) => Session[]; + availableWorktreesByProject: Map; + projectRepoStatus: Map; + projectRootBranches: Map; + lastRepoStatus: boolean; + buildGroupedSessions: ( + sessions: Session[], + projectRoot: string, + availableWorktrees: WorktreeMetadata[], + rootBranch: string | null, + isRepo: boolean, + ) => SessionGroup[]; + hasSessionSearchQuery: boolean; + normalizedSessionSearchQuery: string; + filterSessionNodesForSearch: (nodes: SessionNode[], query: string) => SessionNode[]; + buildGroupSearchText: (group: SessionGroup) => string; + getFoldersForScope: (scopeKey: string) => Array<{ name: string }>; +}; + +export const useSessionSidebarSections = (args: Args) => { + const { + normalizedProjects, + activeProjectId, + getSessionsForProject, + getArchivedSessionsForProject, + availableWorktreesByProject, + projectRepoStatus, + projectRootBranches, + lastRepoStatus, + buildGroupedSessions, + hasSessionSearchQuery, + normalizedSessionSearchQuery, + filterSessionNodesForSearch, + buildGroupSearchText, + getFoldersForScope, + } = args; + + const projectSections = React.useMemo(() => { + return normalizedProjects.map((project) => { + const projectSessions = dedupeSessionsById([ + ...getSessionsForProject(project), + ...getArchivedSessionsForProject(project), + ]); + const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? []; + const isRepo = projectRepoStatus.has(project.id) + ? Boolean(projectRepoStatus.get(project.id)) + : lastRepoStatus; + const groups = buildGroupedSessions( + projectSessions, + project.normalizedPath, + worktreesForProject, + projectRootBranches.get(project.id) ?? null, + isRepo, + ); + return { project, groups }; + }); + }, [ + normalizedProjects, + getSessionsForProject, + getArchivedSessionsForProject, + availableWorktreesByProject, + projectRepoStatus, + lastRepoStatus, + buildGroupedSessions, + projectRootBranches, + ]); + + 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 groupSearchDataByGroup = React.useMemo(() => { + const result = new WeakMap(); + if (!hasSessionSearchQuery) { + return result; + } + + const countNodes = (nodes: SessionNode[]): number => nodes.reduce((total, node) => total + 1 + countNodes(node.children), 0); + + visibleProjectSections.forEach((section) => { + section.groups.forEach((group) => { + const filteredNodes = filterSessionNodesForSearch(group.sessions, normalizedSessionSearchQuery); + const matchedSessionCount = countNodes(filteredNodes); + const groupMatches = buildGroupSearchText(group).includes(normalizedSessionSearchQuery); + const scopeKey = normalizePath(group.directory ?? null); + const folderNameMatchCount = scopeKey + ? getFoldersForScope(scopeKey).filter((folder) => folder.name.toLowerCase().includes(normalizedSessionSearchQuery)).length + : 0; + + result.set(group, { + filteredNodes, + matchedSessionCount, + folderNameMatchCount, + groupMatches, + hasMatch: groupMatches || matchedSessionCount > 0 || folderNameMatchCount > 0, + }); + }); + }); + + return result; + }, [ + hasSessionSearchQuery, + visibleProjectSections, + filterSessionNodesForSearch, + normalizedSessionSearchQuery, + buildGroupSearchText, + getFoldersForScope, + ]); + + const searchableProjectSections = React.useMemo(() => { + if (!hasSessionSearchQuery) { + return visibleProjectSections; + } + + return visibleProjectSections + .map((section) => ({ + ...section, + groups: section.groups.filter((group) => groupSearchDataByGroup.get(group)?.hasMatch === true), + })) + .filter((section) => section.groups.length > 0); + }, [hasSessionSearchQuery, visibleProjectSections, groupSearchDataByGroup]); + + const sectionsForRender = hasSessionSearchQuery ? searchableProjectSections : visibleProjectSections; + + const searchMatchCount = React.useMemo(() => { + if (!hasSessionSearchQuery) { + return 0; + } + + return sectionsForRender.reduce((total, section) => { + return total + section.groups.reduce((groupTotal, group) => { + const data = groupSearchDataByGroup.get(group); + if (!data) { + return groupTotal; + } + const metadataMatches = data.folderNameMatchCount + (data.groupMatches ? 1 : 0); + return groupTotal + data.matchedSessionCount + metadataMatches; + }, 0); + }, 0); + }, [hasSessionSearchQuery, sectionsForRender, groupSearchDataByGroup]); + + return { + projectSections, + visibleProjectSections, + groupSearchDataByGroup, + searchableProjectSections, + sectionsForRender, + searchMatchCount, + }; +}; diff --git a/packages/ui/src/components/session/sidebar/hooks/useSidebarPersistence.ts b/packages/ui/src/components/session/sidebar/hooks/useSidebarPersistence.ts new file mode 100644 index 00000000..67406851 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/hooks/useSidebarPersistence.ts @@ -0,0 +1,167 @@ +import React from 'react'; +import type { Session } from '@opencode-ai/sdk/v2'; +import { updateDesktopSettings } from '@/lib/persistence'; +import { useProjectsStore } from '@/stores/useProjectsStore'; + +type SafeStorageLike = { + getItem: (key: string) => string | null; + setItem: (key: string, value: string) => void; +}; + +type Keys = { + sessionExpanded: string; + projectCollapse: string; + sessionPinned: string; + groupOrder: string; + projectActiveSession: string; + groupCollapse: string; +}; + +type Args = { + isVSCode: boolean; + safeStorage: SafeStorageLike; + keys: Keys; + sessions: Session[]; + pinnedSessionIds: Set; + setPinnedSessionIds: React.Dispatch>>; + groupOrderByProject: Map; + activeSessionByProject: Map; + collapsedGroups: Set; + setExpandedParents: React.Dispatch>>; + setCollapsedProjects: React.Dispatch>>; +}; + +export const useSidebarPersistence = (args: Args) => { + const { + isVSCode, + safeStorage, + keys, + sessions, + pinnedSessionIds, + setPinnedSessionIds, + groupOrderByProject, + activeSessionByProject, + collapsedGroups, + setExpandedParents, + setCollapsedProjects, + } = args; + + const persistCollapsedProjectsTimer = React.useRef(null); + const pendingCollapsedProjects = React.useRef | null>(null); + + 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) => { + if (typeof window === 'undefined' || isVSCode) { + return; + } + + pendingCollapsedProjects.current = collapsed; + if (persistCollapsedProjectsTimer.current !== null) { + window.clearTimeout(persistCollapsedProjectsTimer.current); + } + persistCollapsedProjectsTimer.current = window.setTimeout(() => { + flushCollapsedProjectsPersist(); + }, 700); + }, [isVSCode, flushCollapsedProjectsPersist]); + + React.useEffect(() => { + return () => { + if (typeof window !== 'undefined' && persistCollapsedProjectsTimer.current !== null) { + window.clearTimeout(persistCollapsedProjectsTimer.current); + } + persistCollapsedProjectsTimer.current = null; + pendingCollapsedProjects.current = null; + }; + }, []); + + React.useEffect(() => { + try { + const storedParents = safeStorage.getItem(keys.sessionExpanded); + if (storedParents) { + const parsed = JSON.parse(storedParents); + if (Array.isArray(parsed)) { + setExpandedParents(new Set(parsed.filter((item) => typeof item === 'string'))); + } + } + const storedProjects = safeStorage.getItem(keys.projectCollapse); + if (storedProjects) { + const parsed = JSON.parse(storedProjects); + if (Array.isArray(parsed)) { + setCollapsedProjects(new Set(parsed.filter((item) => typeof item === 'string'))); + } + } + } catch { + // ignored + } + }, [keys.projectCollapse, keys.sessionExpanded, safeStorage, setCollapsedProjects, setExpandedParents]); + + React.useEffect(() => { + const existingSessionIds = new Set(sessions.map((session) => session.id)); + setPinnedSessionIds((prev) => { + let changed = false; + const next = new Set(); + prev.forEach((id) => { + if (existingSessionIds.has(id)) { + next.add(id); + } else { + changed = true; + } + }); + return changed ? next : prev; + }); + }, [sessions, setPinnedSessionIds]); + + React.useEffect(() => { + try { + safeStorage.setItem(keys.sessionPinned, JSON.stringify(Array.from(pinnedSessionIds))); + } catch { + // ignored + } + }, [keys.sessionPinned, pinnedSessionIds, safeStorage]); + + React.useEffect(() => { + try { + const serialized = Object.fromEntries(groupOrderByProject.entries()); + safeStorage.setItem(keys.groupOrder, JSON.stringify(serialized)); + } catch { + // ignored + } + }, [groupOrderByProject, keys.groupOrder, safeStorage]); + + React.useEffect(() => { + try { + const serialized = Object.fromEntries(activeSessionByProject.entries()); + safeStorage.setItem(keys.projectActiveSession, JSON.stringify(serialized)); + } catch { + // ignored + } + }, [activeSessionByProject, keys.projectActiveSession, safeStorage]); + + React.useEffect(() => { + try { + safeStorage.setItem(keys.groupCollapse, JSON.stringify(Array.from(collapsedGroups))); + } catch { + // ignored + } + }, [collapsedGroups, keys.groupCollapse, safeStorage]); + + return { scheduleCollapsedProjectsPersist }; +}; diff --git a/packages/ui/src/components/session/sidebar/hooks/useStickyProjectHeaders.ts b/packages/ui/src/components/session/sidebar/hooks/useStickyProjectHeaders.ts new file mode 100644 index 00000000..9a48e4bf --- /dev/null +++ b/packages/ui/src/components/session/sidebar/hooks/useStickyProjectHeaders.ts @@ -0,0 +1,50 @@ +import React from 'react'; + +type Args = { + isDesktopShellRuntime: boolean; + projectSections: unknown[]; + projectHeaderSentinelRefs: React.MutableRefObject>; +}; + +export const useStickyProjectHeaders = (args: Args): Set => { + const { isDesktopShellRuntime, projectSections, projectHeaderSentinelRefs } = args; + const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState>(new Set()); + + React.useEffect(() => { + if (!isDesktopShellRuntime) { + return; + } + + 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; + }); + }); + }, + { threshold: 0 }, + ); + + projectHeaderSentinelRefs.current.forEach((el) => { + if (el) { + observer.observe(el); + } + }); + + return () => observer.disconnect(); + }, [isDesktopShellRuntime, projectHeaderSentinelRefs, projectSections]); + + return stuckProjectHeaders; +}; diff --git a/packages/ui/src/components/session/sidebar/sessionFolderDnd.tsx b/packages/ui/src/components/session/sidebar/sessionFolderDnd.tsx new file mode 100644 index 00000000..5aacba24 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/sessionFolderDnd.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import { + DndContext, + DragOverlay, + PointerSensor, + closestCenter, + useSensor, + useSensors, + useDraggable, + useDroppable, + type DragEndEvent, +} from '@dnd-kit/core'; +import { RiStickyNoteLine } from '@remixicon/react'; + +export const DraggableSessionRow: React.FC<{ + sessionId: string; + sessionDirectory: string | null; + sessionTitle: string; + children: React.ReactNode; +}> = ({ sessionId, sessionDirectory, sessionTitle, children }) => { + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: `session-drag:${sessionId}`, + data: { type: 'session', sessionId, sessionDirectory, sessionTitle }, + }); + + const handlePointerDown = React.useCallback( + (e: React.PointerEvent) => { + e.stopPropagation(); + if (listeners?.onPointerDown) { + (listeners.onPointerDown as (event: React.PointerEvent) => void)(e); + } + }, + [listeners], + ); + + return ( +
+ {children} +
+ ); +}; + +export const DroppableFolderWrapper: React.FC<{ + folderId: string; + children: ( + droppableRef: (node: HTMLElement | null) => void, + isOver: boolean, + ) => React.ReactNode; +}> = ({ folderId, children }) => { + const { setNodeRef, isOver } = useDroppable({ + id: `folder-drop:${folderId}`, + data: { type: 'folder', folderId }, + }); + return <>{children(setNodeRef, isOver)}; +}; + +export const SessionFolderDndScope: React.FC<{ + scopeKey: string | null; + hasFolders: boolean; + onSessionDroppedOnFolder: (sessionId: string, folderId: string) => void; + children: React.ReactNode; +}> = ({ scopeKey, hasFolders, onSessionDroppedOnFolder, children }) => { + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + ); + const [activeDragId, setActiveDragId] = React.useState(null); + const [activeDragTitle, setActiveDragTitle] = React.useState('Session'); + const [activeDragWidth, setActiveDragWidth] = React.useState(null); + const [activeDragHeight, setActiveDragHeight] = React.useState(null); + + if (!scopeKey) { + return <>{children}; + } + + const handleDragEnd = (event: DragEndEvent) => { + setActiveDragId(null); + setActiveDragWidth(null); + setActiveDragHeight(null); + const { active, over } = event; + if (!over) return; + const activeData = active.data.current as { type?: string; sessionId?: string } | undefined; + const overData = over.data.current as { type?: string; folderId?: string } | undefined; + if (activeData?.type === 'session' && activeData.sessionId && overData?.type === 'folder' && overData.folderId) { + onSessionDroppedOnFolder(activeData.sessionId, overData.folderId); + } + }; + + return ( + { + const data = event.active.data.current as { type?: string; sessionId?: string; sessionTitle?: string } | undefined; + if (data?.type === 'session' && data.sessionId) { + setActiveDragId(data.sessionId); + setActiveDragTitle(data.sessionTitle ?? 'Session'); + const width = event.active.rect.current.initial?.width; + const height = event.active.rect.current.initial?.height; + setActiveDragWidth(typeof width === 'number' ? width : null); + setActiveDragHeight(typeof height === 'number' ? height : null); + } + }} + onDragCancel={() => { + setActiveDragId(null); + setActiveDragWidth(null); + setActiveDragHeight(null); + }} + onDragEnd={handleDragEnd} + > + {children} + + {activeDragId && hasFolders ? ( +
+ +
+ {activeDragTitle} +
+
+ ) : null} +
+
+ ); +}; diff --git a/packages/ui/src/components/session/sidebar/sortableItems.tsx b/packages/ui/src/components/session/sidebar/sortableItems.tsx new file mode 100644 index 00000000..062a8662 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/sortableItems.tsx @@ -0,0 +1,336 @@ +import React from 'react'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; +import { + RiAddLine, + RiCheckLine, + RiCloseLine, + RiGitBranchLine, + RiMore2Line, + RiPencilAiLine, +} from '@remixicon/react'; +import { ArrowsMerge } from '@/components/icons/ArrowsMerge'; +import { cn } from '@/lib/utils'; + +export interface SortableProjectItemProps { + id: string; + projectLabel: string; + projectDescription: string; + isCollapsed: boolean; + isActiveProject: boolean; + isRepo: boolean; + isHovered: boolean; + isDesktopShell: boolean; + isStuck: boolean; + hideDirectoryControls: boolean; + mobileVariant: boolean; + onToggle: () => void; + onHoverChange: (hovered: boolean) => void; + onNewSession: () => void; + onNewWorktreeSession?: () => void; + onOpenMultiRunLauncher: () => void; + onRenameStart: () => void; + onRenameSave: () => void; + onRenameCancel: () => void; + onRenameValueChange: (value: string) => void; + renameValue: string; + isRenaming: boolean; + onClose: () => void; + sentinelRef: (el: HTMLDivElement | null) => void; + children?: React.ReactNode; + settingsAutoCreateWorktree: boolean; + showCreateButtons?: boolean; + hideHeader?: boolean; +} + +export const SortableProjectItem: React.FC = ({ + id, + projectLabel, + projectDescription, + isCollapsed, + isActiveProject, + isRepo, + isHovered, + isDesktopShell, + isStuck, + hideDirectoryControls, + mobileVariant, + onToggle, + onHoverChange, + onNewSession, + onNewWorktreeSession, + onOpenMultiRunLauncher, + onRenameStart, + onRenameSave, + onRenameCancel, + onRenameValueChange, + renameValue, + isRenaming, + onClose, + sentinelRef, + children, + settingsAutoCreateWorktree, + showCreateButtons = true, + hideHeader = false, +}) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }); + + const [isMenuOpen, setIsMenuOpen] = React.useState(false); + + return ( +
+ {!hideHeader ? ( + <> + {isDesktopShell && ( + + ); +}; + +const SortableGroupItemBase: React.FC<{ + id: string; + children: React.ReactNode; +}> = ({ id, children }) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }); + + return ( +
+ {children} +
+ ); +}; + +export const SortableGroupItem = React.memo(SortableGroupItemBase); diff --git a/packages/ui/src/components/session/sidebar/types.ts b/packages/ui/src/components/session/sidebar/types.ts new file mode 100644 index 00000000..6f971c2c --- /dev/null +++ b/packages/ui/src/components/session/sidebar/types.ts @@ -0,0 +1,36 @@ +import type { Session } from '@opencode-ai/sdk/v2'; +import type { WorktreeMetadata } from '@/types/worktree'; + +export type SessionSummaryMeta = { + additions?: number | string | null; + deletions?: number | string | null; + files?: number | null; + diffs?: Array<{ additions?: number | string | null; deletions?: number | string | null }>; +}; + +export type SessionNode = { + session: Session; + children: SessionNode[]; + worktree: WorktreeMetadata | null; +}; + +export type SessionGroup = { + id: string; + label: string; + branch: string | null; + description: string | null; + isMain: boolean; + isArchivedBucket?: boolean; + worktree: WorktreeMetadata | null; + directory: string | null; + folderScopeKey?: string | null; + sessions: SessionNode[]; +}; + +export type GroupSearchData = { + filteredNodes: SessionNode[]; + matchedSessionCount: number; + folderNameMatchCount: number; + groupMatches: boolean; + hasMatch: boolean; +}; diff --git a/packages/ui/src/components/session/sidebar/utils.tsx b/packages/ui/src/components/session/sidebar/utils.tsx new file mode 100644 index 00000000..f959d529 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/utils.tsx @@ -0,0 +1,242 @@ +import React from 'react'; +import type { Session } from '@opencode-ai/sdk/v2'; +import type { SessionSummaryMeta } from './types'; + +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(',', ''); +}; + +export const formatSessionDateLabel = (updatedMs: number): string => { + const today = new Date(); + const updatedDate = new Date(updatedMs); + const isSameDay = (a: Date, b: Date) => + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate(); + + if (isSameDay(updatedDate, today)) { + const diff = Date.now() - updatedMs; + if (diff < 60_000) return 'Just now'; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}min ago`; + return `${Math.floor(diff / 3_600_000)}h ago`; + } + + return formatDateLabel(updatedMs); +}; + +export const normalizePath = (value?: string | null) => { + if (!value) { + return null; + } + const normalized = value.replace(/\\/g, '/').replace(/\/+$/, ''); + return normalized.length === 0 ? '/' : normalized; +}; + +export const normalizeForBranchComparison = (value: string): string => { + return value + .toLowerCase() + .replace(/^opencode[/-]?/i, '') + .replace(/[-_]/g, '') + .trim(); +}; + +export const isBranchDifferentFromLabel = (branch: string | null, label: string): boolean => { + if (!branch) return false; + return normalizeForBranchComparison(branch) !== normalizeForBranchComparison(label); +}; + +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 getSessionCreatedAt = (session: Session): number => { + return toFiniteNumber(session.time?.created) ?? 0; +}; + +const getSessionUpdatedAt = (session: Session): number => { + return toFiniteNumber(session.time?.updated) ?? toFiniteNumber(session.time?.created) ?? 0; +}; + +export const compareSessionsByPinnedAndTime = ( + a: Session, + b: Session, + pinnedSessionIds: Set, +): number => { + const aPinned = pinnedSessionIds.has(a.id); + const bPinned = pinnedSessionIds.has(b.id); + if (aPinned !== bPinned) { + return aPinned ? -1 : 1; + } + + if (aPinned && bPinned) { + return getSessionCreatedAt(b) - getSessionCreatedAt(a); + } + + return getSessionUpdatedAt(b) - getSessionUpdatedAt(a); +}; + +export const dedupeSessionsById = (sessions: Session[]): Session[] => { + const byId = new Map(); + sessions.forEach((session) => { + byId.set(session.id, session); + }); + return Array.from(byId.values()); +}; + +export const getArchivedScopeKey = (projectRoot: string): string => `__archived__:${projectRoot}`; + +export const resolveArchivedFolderName = (session: Session, projectRoot: string | null): string => { + const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); + const projectWorktree = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null); + const resolved = sessionDirectory ?? projectWorktree; + if (!resolved) { + return 'unassigned'; + } + if (projectRoot && resolved === projectRoot) { + return 'project root'; + } + const source = projectRoot && resolved.startsWith(`${projectRoot}/`) + ? resolved.slice(projectRoot.length + 1) + : resolved; + const segments = source.split('/').filter(Boolean); + return segments[segments.length - 1] ?? 'unassigned'; +}; + +export const isSessionRelatedToProject = ( + session: Session, + projectRoot: string, + validDirectories?: Set, +): boolean => { + const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); + const projectWorktree = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null); + + if (projectWorktree && (projectWorktree === projectRoot || projectWorktree.startsWith(`${projectRoot}/`))) { + return true; + } + + if (!sessionDirectory) { + return false; + } + if (validDirectories && validDirectories.has(sessionDirectory)) { + return true; + } + return sessionDirectory === projectRoot || sessionDirectory.startsWith(`${projectRoot}/`); +}; + +const parseSummaryCount = (value: number | string | null | undefined): number | null => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return null; +}; + +export const resolveSessionDiffStats = (summary?: SessionSummaryMeta): { additions: number; deletions: number } | null => { + if (!summary) { + return null; + } + + const directAdditions = parseSummaryCount(summary.additions); + const directDeletions = parseSummaryCount(summary.deletions); + if (directAdditions !== null || directDeletions !== null) { + const stats = { + additions: Math.max(0, directAdditions ?? 0), + deletions: Math.max(0, directDeletions ?? 0), + }; + return stats.additions === 0 && stats.deletions === 0 ? null : stats; + } + + const diffs = Array.isArray(summary.diffs) ? summary.diffs : []; + if (diffs.length === 0) { + return null; + } + + let additions = 0; + let deletions = 0; + diffs.forEach((diff) => { + additions += Math.max(0, parseSummaryCount(diff.additions) ?? 0); + deletions += Math.max(0, parseSummaryCount(diff.deletions) ?? 0); + }); + return additions === 0 && deletions === 0 ? null : { additions, deletions }; +}; + +export const formatProjectLabel = (label: string): string => { + return label + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, (char) => char.toUpperCase()); +}; + +export const renderHighlightedText = (text: string, query: string): React.ReactNode => { + if (!query) { + return text; + } + + const loweredText = text.toLowerCase(); + const loweredQuery = query.toLowerCase(); + const queryLength = loweredQuery.length; + if (queryLength === 0) { + return text; + } + + const parts: React.ReactNode[] = []; + let cursor = 0; + let matchIndex = loweredText.indexOf(loweredQuery, cursor); + + while (matchIndex !== -1) { + if (matchIndex > cursor) { + parts.push(text.slice(cursor, matchIndex)); + } + const matchText = text.slice(matchIndex, matchIndex + queryLength); + parts.push( + + {matchText} + , + ); + cursor = matchIndex + queryLength; + matchIndex = loweredText.indexOf(loweredQuery, cursor); + } + + if (cursor < text.length) { + parts.push(text.slice(cursor)); + } + + return parts.length > 0 ? parts : text; +}; diff --git a/packages/ui/src/components/ui/MemoryDebugPanel.tsx b/packages/ui/src/components/ui/MemoryDebugPanel.tsx index d5a4a52c..da9ce239 100644 --- a/packages/ui/src/components/ui/MemoryDebugPanel.tsx +++ b/packages/ui/src/components/ui/MemoryDebugPanel.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useSessionStore, MEMORY_LIMITS } from '@/stores/useSessionStore'; +import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; import { getMessageLimit, getBackgroundTrimLimit } from '@/stores/types/sessionTypes'; import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -20,6 +21,7 @@ export const MemoryDebugPanel: React.FC = ({ onClose }) = trimToViewportWindow, evictLeastRecentlyUsed } = useSessionStore(); + const totalGitHubRequests = useGitHubPrStatusStore((state) => state.totalRequestCount); const totalMessages = React.useMemo(() => { let total = 0; @@ -96,6 +98,10 @@ export const MemoryDebugPanel: React.FC = ({ onClose }) = Zombie Timeout: {MEMORY_LIMITS.ZOMBIE_TIMEOUT / 1000 / 60} minutes
+
+ GitHub Total Requests: + {totalGitHubRequests} +
{} diff --git a/packages/ui/src/components/ui/sonner.tsx b/packages/ui/src/components/ui/sonner.tsx index d68bf6a5..ccaebae6 100644 --- a/packages/ui/src/components/ui/sonner.tsx +++ b/packages/ui/src/components/ui/sonner.tsx @@ -12,6 +12,17 @@ const Toaster = ({ ...props }: ToasterProps) => { theme={theme as ToasterProps["theme"]} className="toaster group" closeButton={false} + toastOptions={{ + style: { + borderRadius: "var(--radius-md)", + }, + classNames: { + toast: "rounded-[var(--radius-md)]", + actionButton: "rounded-[var(--radius-sm)]", + cancelButton: "rounded-[var(--radius-sm)]", + closeButton: "rounded-[var(--radius-sm)]", + }, + }} style={ { "--normal-bg": "var(--popover)", diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 68c65023..91d53a94 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -1190,13 +1190,14 @@ export const GitView: React.FC = () => { const selectedCount = selectedPaths.size; const isBusy = isLoading || syncAction !== null || commitAction !== null; + const currentBranch = status?.current ?? null; const canShowIntegrateCommitsSection = Boolean( worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits ); const canShowPullRequestSection = Boolean( - currentDirectory && status?.current && status?.tracking && status.current !== baseBranch + currentDirectory && currentBranch && status?.tracking && currentBranch !== baseBranch ); - const canShowBranchWorkflows = Boolean(status?.current); + const canShowBranchWorkflows = Boolean(currentBranch); const integrateCommitsProps = canShowIntegrateCommitsSection && repoRootForIntegrate && sourceBranchForIntegrate && worktreeMetadata ? { @@ -1205,13 +1206,15 @@ export const GitView: React.FC = () => { worktreeMetadata, } : null; - const pullRequestProps = - canShowPullRequestSection && currentDirectory && status?.current - ? { - directory: currentDirectory, - branch: status.current, - } - : null; + const pullRequestProps = React.useMemo(() => { + if (!canShowPullRequestSection || !currentDirectory || !currentBranch) { + return null; + } + return { + directory: currentDirectory, + branch: currentBranch, + }; + }, [canShowPullRequestSection, currentBranch, currentDirectory]); // Keep these sections stable in layout; individual cards render placeholders when unavailable. const toggleFileSelection = (path: string) => { diff --git a/packages/ui/src/components/views/git/PullRequestSection.tsx b/packages/ui/src/components/views/git/PullRequestSection.tsx index 1a24bea5..15d69bc7 100644 --- a/packages/ui/src/components/views/git/PullRequestSection.tsx +++ b/packages/ui/src/components/views/git/PullRequestSection.tsx @@ -54,6 +54,7 @@ import { useMessageStore } from '@/stores/messageStore'; import { useSessionStore } from '@/stores/useSessionStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; +import { getGitHubPrStatusKey, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; import type { GitHubPullRequest, GitHubCheckRun, @@ -64,10 +65,6 @@ import type { type MergeMethod = 'merge' | 'squash' | 'rebase'; -const PR_REVALIDATE_TTL_MS = 90_000; -const PR_REVALIDATE_INTERVAL_MS = 30_000; -const PR_DISCOVERY_INTERVAL_MS = 5 * 60_000; - const statusColor = (state: string | undefined | null): string => { switch (state) { case 'success': @@ -215,6 +212,35 @@ const pickInitialPrRemote = ( return remotes[0] ?? null; }; +const isEphemeralPrRemote = (name: string): boolean => name.startsWith('pr-'); + +const rankRemotesForAutoSelect = ( + remotes: GitRemote[], + trackingBranch?: string, +): GitRemote[] => { + const trackingRemote = getTrackingRemoteName(trackingBranch); + const byName = new Map(remotes.map((remote) => [remote.name, remote])); + const ordered: GitRemote[] = []; + const pushUnique = (remote: GitRemote | null | undefined) => { + if (!remote) return; + if (ordered.some((item) => item.name === remote.name)) return; + ordered.push(remote); + }; + + if (trackingRemote) { + pushUnique(byName.get(trackingRemote)); + } + pushUnique(byName.get('upstream')); + pushUnique(byName.get('origin')); + + remotes + .filter((remote) => !isEphemeralPrRemote(remote.name)) + .forEach((remote) => pushUnique(remote)); + remotes.forEach((remote) => pushUnique(remote)); + + return ordered; +}; + type TimelineCommentItem = { id: string; body: string; @@ -236,7 +262,6 @@ type ChatDispatchTarget = { }; const pullRequestDraftSnapshots = new Map(); -const pullRequestStatusSnapshots = new Map(); type TauriShell = { shell?: { @@ -293,15 +318,12 @@ export const PullRequestSection: React.FC<{ () => pullRequestDraftSnapshots.get(snapshotKey) ?? null, [snapshotKey] ); - const initialStatusSnapshot = React.useMemo( - () => pullRequestStatusSnapshots.get(snapshotKey) ?? null, - [snapshotKey] - ); - - const [isLoading, setIsLoading] = React.useState(false); - const [status, setStatus] = React.useState(() => initialStatusSnapshot); - const [error, setError] = React.useState(null); - const [isInitialStatusResolved, setIsInitialStatusResolved] = React.useState(() => Boolean(initialStatusSnapshot)); + const ensurePrStatusEntry = useGitHubPrStatusStore((state) => state.ensureEntry); + const setPrStatusParams = useGitHubPrStatusStore((state) => state.setParams); + const startPrStatusWatching = useGitHubPrStatusStore((state) => state.startWatching); + const stopPrStatusWatching = useGitHubPrStatusStore((state) => state.stopWatching); + const refreshPrStatus = useGitHubPrStatusStore((state) => state.refresh); + const updatePrStatus = useGitHubPrStatusStore((state) => state.updateStatus); const [title, setTitle] = React.useState(() => initialSnapshot?.title ?? branchToTitle(branch)); const [body, setBody] = React.useState(() => initialSnapshot?.body ?? ''); @@ -337,6 +359,17 @@ export const PullRequestSection: React.FC<{ }) ); + const prStatusKey = React.useMemo( + () => getGitHubPrStatusKey(directory, branch), + [directory, branch], + ); + const statusEntry = useGitHubPrStatusStore((state) => state.entries[prStatusKey]); + + const isLoading = statusEntry?.isLoading ?? false; + const status = statusEntry?.status ?? null; + const error = statusEntry?.error ?? null; + const isInitialStatusResolved = statusEntry?.isInitialStatusResolved ?? false; + const availableBaseBranches = React.useMemo(() => { const selectedRemoteName = selectedRemote?.name?.trim() || null; const unique = new Set(); @@ -412,13 +445,10 @@ export const PullRequestSection: React.FC<{ const [commentsDetails, setCommentsDetails] = React.useState(null); const [isLoadingCommentsDetails, setIsLoadingCommentsDetails] = React.useState(false); - const isRefreshInFlightRef = React.useRef(false); - const lastRefreshAtRef = React.useRef(0); - const lastDiscoveryPollAtRef = React.useRef(0); - const statusRef = React.useRef(null); - const selectedRemoteNameRef = React.useRef(selectedRemote?.name ?? null); const attemptedBodyHydrationRef = React.useRef>(new Set()); const lastSyncedPrNumberRef = React.useRef(null); + const didUserOverrideRemoteRef = React.useRef(false); + const autoRemoteProbeDoneRef = React.useRef>(new Set()); const canShow = Boolean(directory && branch && baseBranch && branch !== baseBranch); @@ -454,7 +484,7 @@ export const PullRequestSection: React.FC<{ if (!ctxPr) { return; } - setStatus((prev) => { + updatePrStatus(prStatusKey, (prev) => { if (!prev?.pr || prev.pr.number !== pr.number) { return prev; } @@ -478,7 +508,7 @@ export const PullRequestSection: React.FC<{ return () => { cancelled = true; }; - }, [directory, github, pr]); + }, [directory, github, pr, prStatusKey, updatePrStatus]); React.useEffect(() => { if (!pr) { @@ -923,116 +953,100 @@ export const PullRequestSection: React.FC<{ dispatchSyntheticPrompt(target, visibleText, instructionsText, payloadText); }, [commentsDetails, dispatchSyntheticPrompt, pr, resolveChatDispatchTarget, setActiveMainTab]); - React.useEffect(() => { - statusRef.current = status; - }, [status]); - - React.useEffect(() => { - selectedRemoteNameRef.current = selectedRemote?.name ?? null; - }, [selectedRemote?.name]); - const refresh = React.useCallback(async (options?: { force?: boolean; onlyExistingPr?: boolean; silent?: boolean; markInitialResolved?: boolean }) => { - if (!canShow) return; - if (options?.onlyExistingPr && !statusRef.current?.pr) { - return; - } - if (!options?.force && Date.now() - lastRefreshAtRef.current < PR_REVALIDATE_TTL_MS) { - return; - } - if (isRefreshInFlightRef.current) { - return; - } - - isRefreshInFlightRef.current = true; - lastRefreshAtRef.current = Date.now(); - - if (githubAuthChecked && githubAuthStatus?.connected === false) { - setStatus({ connected: false }); - setError(null); - if (!options?.silent) { - setIsLoading(false); - } - if (options?.markInitialResolved !== false) { - setIsInitialStatusResolved(true); - } - isRefreshInFlightRef.current = false; - return; - } - if (!github?.prStatus) { - setStatus(null); - setError('GitHub runtime API unavailable'); - if (options?.markInitialResolved !== false) { - setIsInitialStatusResolved(true); - } - isRefreshInFlightRef.current = false; - return; - } - if (!options?.silent) { - setIsLoading(true); - } - setError(null); - try { - const next = await github.prStatus(directory, branch, selectedRemoteNameRef.current ?? undefined); - setStatus((prev) => { - const nextPr = next.pr; - const prevPr = prev?.pr; - // Some runtimes occasionally return PR status without body. - // Keep already hydrated description for the same PR number. - const shouldCarryBody = Boolean( - nextPr - && prevPr - && nextPr.number === prevPr.number - && (!nextPr.body || !nextPr.body.trim()) - && typeof prevPr.body === 'string' - && prevPr.body.trim().length > 0, - ); - - if (!shouldCarryBody || !nextPr) { - return next; - } - - const carriedBody = prevPr?.body; - if (!carriedBody) { - return next; - } - - return { - ...next, - pr: { - ...nextPr, - body: carriedBody, - }, - }; - }); - if (next.connected === false) { - setError(null); - } - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - setError(message || 'Failed to load PR status'); - } finally { - if (!options?.silent) { - setIsLoading(false); - } - if (options?.markInitialResolved !== false) { - setIsInitialStatusResolved(true); - } - isRefreshInFlightRef.current = false; - } - }, [branch, canShow, directory, github, githubAuthChecked, githubAuthStatus]); + await refreshPrStatus(prStatusKey, options); + }, [prStatusKey, refreshPrStatus]); // Refetch PR status when selected remote changes const handleRemoteChange = React.useCallback((remote: GitRemote) => { + didUserOverrideRemoteRef.current = true; setSelectedRemote((prev) => (prev?.name === remote.name ? prev : remote)); - // Clear current status and refetch - setStatus(null); - setError(null); - lastRefreshAtRef.current = 0; // Force refresh }, []); + React.useEffect(() => { + if (!github?.prStatus || !canShow || remotes.length <= 1) { + return; + } + if (didUserOverrideRemoteRef.current) { + return; + } + if (status?.pr) { + return; + } + + const probeKey = `${snapshotKey}::${selectedRemote?.name ?? ''}`; + if (autoRemoteProbeDoneRef.current.has(probeKey)) { + return; + } + autoRemoteProbeDoneRef.current.add(probeKey); + + const candidates = rankRemotesForAutoSelect(remotes, trackingBranch) + .filter((remote) => remote.name !== selectedRemote?.name); + if (candidates.length === 0) { + return; + } + + let cancelled = false; + const run = async () => { + for (const candidate of candidates) { + if (cancelled) { + return; + } + try { + const next = await github.prStatus(directory, branch, candidate.name); + if (!next?.pr) { + continue; + } + if (cancelled) { + return; + } + setSelectedRemote((prev) => (prev?.name === candidate.name ? prev : candidate)); + return; + } catch { + // ignore + } + } + }; + + void run(); + return () => { + cancelled = true; + }; + }, [branch, canShow, directory, github, remotes, selectedRemote?.name, snapshotKey, status?.pr, trackingBranch]); + + React.useEffect(() => { + ensurePrStatusEntry(prStatusKey); + setPrStatusParams(prStatusKey, { + directory, + branch, + remoteName: selectedRemote?.name ?? null, + canShow, + github, + githubAuthChecked, + githubConnected: githubAuthStatus?.connected ?? null, + }); + }, [ + branch, + canShow, + directory, + ensurePrStatusEntry, + github, + githubAuthChecked, + githubAuthStatus?.connected, + prStatusKey, + selectedRemote?.name, + setPrStatusParams, + ]); + + React.useEffect(() => { + startPrStatusWatching(prStatusKey); + return () => { + stopPrStatusWatching(prStatusKey); + }; + }, [prStatusKey, startPrStatusWatching, stopPrStatusWatching]); + React.useEffect(() => { const snapshot = pullRequestDraftSnapshots.get(snapshotKey) ?? null; - const statusSnapshot = pullRequestStatusSnapshots.get(snapshotKey) ?? null; setTitle(snapshot?.title ?? branchToTitle(branch)); setBody(snapshot?.body ?? ''); setDraft(snapshot?.draft ?? false); @@ -1042,29 +1056,35 @@ export const PullRequestSection: React.FC<{ trackingBranch, }); setSelectedRemote((prev) => (prev?.name === nextRemote?.name ? prev : nextRemote)); - setStatus(statusSnapshot); - setError(null); - setIsInitialStatusResolved(Boolean(statusSnapshot)); }, [baseBranch, branch, remotes, snapshotKey, trackingBranch]); React.useEffect(() => { - void refresh({ force: true, markInitialResolved: true }); - }, [snapshotKey, refresh]); + void refresh({ markInitialResolved: true }); + }, [prStatusKey, refresh]); - // Refetch when selected remote changes React.useEffect(() => { - if (selectedRemote?.name) { - void refresh({ force: true, markInitialResolved: true }); + if (!canShow || !selectedRemote?.name) { + return; } - }, [selectedRemote?.name, refresh]); + void refresh({ force: true, silent: true, markInitialResolved: true }); + }, [canShow, refresh, selectedRemote?.name]); React.useEffect(() => { + const isTerminal = status?.pr?.state === 'closed' || status?.pr?.state === 'merged'; + const lastRefreshAt = statusEntry?.lastRefreshAt ?? 0; + const isStale = Date.now() - lastRefreshAt > 60_000; + const shouldRefresh = !isTerminal && isStale; + const onFocus = () => { - void refresh({ force: true, silent: true }); + if (shouldRefresh) { + void refresh({ force: true, silent: true }); + } }; const onVisibility = () => { if (document.visibilityState === 'visible') { - void refresh({ force: true, silent: true }); + if (shouldRefresh) { + void refresh({ force: true, silent: true }); + } } }; @@ -1074,40 +1094,13 @@ export const PullRequestSection: React.FC<{ window.removeEventListener('focus', onFocus); document.removeEventListener('visibilitychange', onVisibility); }; - }, [refresh]); - - React.useEffect(() => { - const interval = window.setInterval(() => { - if (document.visibilityState !== 'visible') { - return; - } - - const hasPr = Boolean(statusRef.current?.pr); - if (!hasPr) { - const now = Date.now(); - const shouldRunDiscovery = now - lastDiscoveryPollAtRef.current >= PR_DISCOVERY_INTERVAL_MS; - if (!shouldRunDiscovery) { - return; - } - lastDiscoveryPollAtRef.current = now; - void refresh({ force: true, silent: true }); - return; - } - - void refresh({ onlyExistingPr: true, force: true, silent: true }); - }, PR_REVALIDATE_INTERVAL_MS); - - return () => { - window.clearInterval(interval); - }; - }, [refresh]); + }, [refresh, status?.pr?.state, statusEntry?.lastRefreshAt]); React.useEffect(() => { if (githubAuthChecked && githubAuthStatus?.connected === false) { - setStatus({ connected: false }); - setError(null); + void refresh({ force: true, silent: true, markInitialResolved: true }); } - }, [githubAuthChecked, githubAuthStatus]); + }, [githubAuthChecked, githubAuthStatus, refresh]); React.useEffect(() => { if (!directory || !branch) { @@ -1123,13 +1116,6 @@ export const PullRequestSection: React.FC<{ }); }, [snapshotKey, title, body, draft, additionalContext, targetBaseBranch, selectedRemote?.name, directory, branch]); - React.useEffect(() => { - if (!status) { - return; - } - pullRequestStatusSnapshots.set(snapshotKey, status); - }, [snapshotKey, status]); - const generateDescription = React.useCallback(async () => { if (isGenerating) return; if (!directory) return; @@ -1194,7 +1180,7 @@ export const PullRequestSection: React.FC<{ ...(selectedRemote ? { remote: selectedRemote.name } : {}), }); toast.success('PR created'); - setStatus((prev) => (prev ? { ...prev, pr } : prev)); + updatePrStatus(prStatusKey, (prev) => (prev ? { ...prev, pr } : prev)); await refresh({ force: true }); } catch (e) { const message = e instanceof Error ? e.message : String(e); @@ -1202,7 +1188,7 @@ export const PullRequestSection: React.FC<{ } finally { setIsCreating(false); } - }, [body, branch, directory, draft, github, refresh, selectedRemote, targetBaseBranch, title]); + }, [body, branch, directory, draft, github, prStatusKey, refresh, selectedRemote, targetBaseBranch, title, updatePrStatus]); const mergePr = React.useCallback(async (pr: GitHubPullRequest) => { if (!github?.prMerge) { @@ -1270,7 +1256,7 @@ export const PullRequestSection: React.FC<{ title: trimmedTitle, body: editBody, }); - setStatus((prev) => (prev + updatePrStatus(prStatusKey, (prev) => (prev ? { ...prev, pr: { @@ -1288,7 +1274,7 @@ export const PullRequestSection: React.FC<{ } finally { setIsUpdating(false); } - }, [directory, editBody, editTitle, github, refresh]); + }, [directory, editBody, editTitle, github, prStatusKey, refresh, updatePrStatus]); if (!canShow) { return null; diff --git a/packages/ui/src/hooks/useGitHubPrBackgroundTracking.ts b/packages/ui/src/hooks/useGitHubPrBackgroundTracking.ts new file mode 100644 index 00000000..5299a5f6 --- /dev/null +++ b/packages/ui/src/hooks/useGitHubPrBackgroundTracking.ts @@ -0,0 +1,222 @@ +import React from 'react'; +import type { Session } from '@opencode-ai/sdk/v2'; +import type { RuntimeAPIs } from '@/lib/api/types'; +import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; +import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useSessionStore } from '@/stores/useSessionStore'; + +const MAX_BACKGROUND_PR_DIRECTORIES = 50; +const BRANCH_REFRESH_TTL_MS = 2 * 60_000; +const BRANCH_REFRESH_INTERVAL_MS = 60_000; + +const normalizePath = (value?: string | null): string | null => { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const normalized = trimmed.replace(/\\/g, '/'); + if (normalized === '/') { + return '/'; + } + return normalized.replace(/\/+$/, ''); +}; + +type SessionLike = Session & { + directory?: string | null; + project?: { worktree?: string | null } | null; +}; + +type BranchCacheEntry = { + branch: string | null; + fetchedAt: number; +}; + +export const useGitHubPrBackgroundTracking = ( + github: RuntimeAPIs['github'] | undefined, + git: RuntimeAPIs['git'], +): void => { + const currentDirectory = useDirectoryStore((state) => state.currentDirectory); + const projects = useProjectsStore((state) => state.projects); + const sessions = useSessionStore((state) => state.sessions); + const archivedSessions = useSessionStore((state) => state.archivedSessions); + const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject); + const worktreeMetadata = useSessionStore((state) => state.worktreeMetadata); + + const githubAuthStatus = useGitHubAuthStore((state) => state.status); + const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); + const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus); + + const syncBackgroundTargets = useGitHubPrStatusStore((state) => state.syncBackgroundTargets); + + const [branchCache, setBranchCache] = React.useState>(new Map()); + const branchCacheRef = React.useRef>(new Map()); + + React.useEffect(() => { + branchCacheRef.current = branchCache; + }, [branchCache]); + + React.useEffect(() => { + if (!github || githubAuthChecked) { + return; + } + void refreshGitHubAuthStatus(github); + }, [github, githubAuthChecked, refreshGitHubAuthStatus]); + + const candidateDirectories = React.useMemo(() => { + const ordered = new Map(); + const add = (value?: string | null) => { + const normalized = normalizePath(value); + if (!normalized || ordered.has(normalized)) { + return; + } + ordered.set(normalized, normalized); + }; + + add(currentDirectory); + projects.forEach((project) => { + add(project.path); + }); + availableWorktreesByProject.forEach((worktrees) => { + worktrees.forEach((worktree) => { + add(worktree.path); + }); + }); + worktreeMetadata.forEach((metadata) => { + add(metadata.path); + }); + + [...sessions, ...archivedSessions] + .sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0)) + .forEach((rawSession) => { + const session = rawSession as SessionLike; + add(session.directory ?? null); + add(session.project?.worktree ?? null); + }); + + return Array.from(ordered.values()).slice(0, MAX_BACKGROUND_PR_DIRECTORIES); + }, [archivedSessions, availableWorktreesByProject, currentDirectory, projects, sessions, worktreeMetadata]); + + React.useEffect(() => { + let cancelled = false; + + const refreshBranches = async (force = false) => { + const now = Date.now(); + const directoriesToFetch = candidateDirectories.filter((directory) => { + const cached = branchCacheRef.current.get(directory); + if (!cached) { + return true; + } + if (force) { + return true; + } + return now - cached.fetchedAt > BRANCH_REFRESH_TTL_MS; + }); + + if (directoriesToFetch.length === 0) { + return; + } + + const results = await Promise.all( + directoriesToFetch.map(async (directory) => { + try { + const status = await git.getGitStatus(directory); + const branch = typeof status.current === 'string' ? status.current.trim() : ''; + return { directory, branch: branch && branch !== 'HEAD' ? branch : null }; + } catch { + return { directory, branch: null }; + } + }), + ); + + if (cancelled) { + return; + } + + setBranchCache((prev) => { + const next = new Map(prev); + let changed = false; + results.forEach(({ directory, branch }) => { + const previous = next.get(directory); + const fetchedAt = Date.now(); + if (!previous || previous.branch !== branch) { + changed = true; + } + if (!previous || previous.fetchedAt !== fetchedAt || previous.branch !== branch) { + next.set(directory, { branch, fetchedAt }); + } + }); + + if (!changed && results.length > 0) { + return prev; + } + + branchCacheRef.current = next; + return next; + }); + }; + + void refreshBranches(); + + const intervalId = window.setInterval(() => { + if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { + return; + } + void refreshBranches(); + }, BRANCH_REFRESH_INTERVAL_MS); + + return () => { + cancelled = true; + window.clearInterval(intervalId); + }; + }, [candidateDirectories, git]); + + React.useEffect(() => { + const validDirectories = new Set(candidateDirectories); + setBranchCache((prev) => { + let changed = false; + const next = new Map(); + prev.forEach((value, key) => { + if (!validDirectories.has(key)) { + changed = true; + return; + } + next.set(key, value); + }); + if (!changed) { + return prev; + } + branchCacheRef.current = next; + return next; + }); + }, [candidateDirectories]); + + const targets = React.useMemo(() => { + const result: Array<{ directory: string; branch: string; remoteName?: string | null }> = []; + candidateDirectories.forEach((directory) => { + const cached = branchCache.get(directory); + if (!cached?.branch) { + return; + } + result.push({ + directory, + branch: cached.branch, + remoteName: null, + }); + }); + return result; + }, [branchCache, candidateDirectories]); + + React.useEffect(() => { + syncBackgroundTargets({ + targets, + github, + githubAuthChecked, + githubConnected: githubAuthStatus?.connected ?? null, + }); + }, [github, githubAuthChecked, githubAuthStatus?.connected, syncBackgroundTargets, targets]); +}; diff --git a/packages/ui/src/stores/globalSessions.ts b/packages/ui/src/stores/globalSessions.ts new file mode 100644 index 00000000..59da696e --- /dev/null +++ b/packages/ui/src/stores/globalSessions.ts @@ -0,0 +1,96 @@ +import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2"; + +export type GlobalSessionRecord = Session & { + project?: { + id: string; + name?: string; + worktree?: string; + } | null; +}; + +const toNumber = (value: string | null): number | null => { + if (!value) { + return null; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; + +const readResponseHeader = (response: unknown, header: string): string | null => { + if (!response || typeof response !== "object") { + return null; + } + const container = response as { headers?: unknown }; + const headers = container.headers; + if (!headers || typeof headers !== "object") { + return null; + } + + const maybeGet = headers as { get?: (name: string) => string | null }; + if (typeof maybeGet.get === "function") { + return maybeGet.get(header); + } + + const maybeRecord = headers as Record; + const direct = maybeRecord[header] ?? maybeRecord[header.toLowerCase()]; + return typeof direct === "string" ? direct : null; +}; + +export const readNextCursor = (response: unknown): number | null => { + return toNumber(readResponseHeader(response, "x-next-cursor")); +}; + +export const isMissingGlobalSessionsEndpointError = (error: unknown): boolean => { + if (!error || typeof error !== "object") { + return false; + } + + const value = error as { + status?: number; + response?: { status?: number }; + cause?: { status?: number; response?: { status?: number } }; + }; + + const status = value.status + ?? value.response?.status + ?? value.cause?.status + ?? value.cause?.response?.status; + + return status === 404; +}; + +export async function listGlobalSessionPages( + apiClient: OpencodeClient, + options: { + archived: boolean; + pageSize: number; + onPage?: (sessions: GlobalSessionRecord[]) => void; + }, +): Promise { + const all: GlobalSessionRecord[] = []; + let cursor: number | undefined; + + while (true) { + const response = await apiClient.experimental.session.list({ + archived: options.archived, + limit: options.pageSize, + ...(cursor ? { cursor } : {}), + }); + + const payload = Array.isArray(response.data) ? (response.data as GlobalSessionRecord[]) : []; + if (payload.length === 0) { + break; + } + + all.push(...payload); + options.onPage?.(payload); + + const nextCursor = toNumber(readResponseHeader(response, "x-next-cursor")); + if (!nextCursor) { + break; + } + cursor = nextCursor; + } + + return all; +} diff --git a/packages/ui/src/stores/sessionStore.ts b/packages/ui/src/stores/sessionStore.ts index f7652f5d..c1a44c45 100644 --- a/packages/ui/src/stores/sessionStore.ts +++ b/packages/ui/src/stores/sessionStore.ts @@ -12,9 +12,11 @@ import { triggerSessionStatusPoll } from "@/hooks/useServerSessionStatus"; import type { ProjectEntry } from "@/lib/api/types"; import { checkIsGitRepository } from "@/lib/gitApi"; import { streamDebugEnabled } from "@/stores/utils/streamDebug"; +import { isMissingGlobalSessionsEndpointError, readNextCursor, type GlobalSessionRecord } from "./globalSessions"; interface SessionState { sessions: Session[]; + archivedSessions: Session[]; sessionsByDirectory: Map; currentSessionId: string | null; lastLoadedDirectory: string | null; @@ -31,6 +33,8 @@ interface SessionActions { createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise; deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string }) => Promise; deleteSessions: (ids: string[], options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string; silent?: boolean }) => Promise<{ deletedIds: string[]; failedIds: string[] }>; + archiveSession: (id: string) => Promise; + archiveSessions: (ids: string[], options?: { silent?: boolean }) => Promise<{ archivedIds: string[]; failedIds: string[] }>; updateSessionTitle: (id: string, title: string) => Promise; shareSession: (id: string) => Promise; unshareSession: (id: string) => Promise; @@ -97,24 +101,10 @@ type ProjectRepoCacheEntry = { isGitRepo: boolean; }; -const PROJECT_SESSION_CACHE_TTL_MS = 30_000; const PROJECT_REPO_STATUS_CACHE_TTL_MS = 120_000; const projectSessionCache = new Map(); const projectRepoStatusCache = new Map(); -const getFreshProjectSessionCache = (projectPath: string): ProjectSessionResult | null => { - const key = normalizePath(projectPath) ?? projectPath; - const cached = projectSessionCache.get(key); - if (!cached) { - return null; - } - if (Date.now() - cached.cachedAt > PROJECT_SESSION_CACHE_TTL_MS) { - projectSessionCache.delete(key); - return null; - } - return cached.result; -}; - const setProjectSessionCache = (projectPath: string, result: ProjectSessionResult) => { const key = normalizePath(projectPath) ?? projectPath; projectSessionCache.set(key, { cachedAt: Date.now(), result }); @@ -241,6 +231,21 @@ const deleteSessionOnServer = async (sessionId: string, directory?: string | nul return Boolean(response.data); }; +const setSessionArchivedOnServer = async ( + sessionId: string, + archivedAt: number, + directory?: string | null, +): Promise => { + const apiClient = opencodeClient.getApiClient(); + const normalizedDirectory = normalizePath(directory ?? null); + const response = await apiClient.session.update({ + sessionID: sessionId, + ...(normalizedDirectory ? { directory: normalizedDirectory } : {}), + time: { archived: archivedAt }, + }); + return response.data ?? null; +}; + const normalizePath = (value?: string | null): string | null => { if (typeof value !== "string") { return null; @@ -446,6 +451,7 @@ export const useSessionStore = create()( (set, get) => ({ sessions: [], + archivedSessions: [], sessionsByDirectory: new Map(), currentSessionId: null, lastLoadedDirectory: null, @@ -475,151 +481,6 @@ export const useSessionStore = create()( activeProjectId: projectsStore.activeProjectId, }); - const canonicalDirectoryCache = new Map(); - - const resolveCanonicalDirectory = async (directory: string): Promise => { - const normalizedRequested = normalizePath(directory) ?? directory; - const cacheKey = normalizedRequested; - const cached = canonicalDirectoryCache.get(cacheKey); - if (cached) { - return cached; - } - - try { - const info = await apiClient.path.get({ directory }); - const canonical = normalizePath((info.data as { directory?: string | null } | null)?.directory ?? null); - const resolved = canonical ?? normalizedRequested; - canonicalDirectoryCache.set(cacheKey, resolved); - return resolved; - } catch { - canonicalDirectoryCache.set(cacheKey, normalizedRequested); - return normalizedRequested; - } - }; - - const filterSessionsToDirectory = ( - sessions: Session[], - directory: string, - options?: { includeDescendants?: boolean; includeMissingDirectory?: boolean } - ): Session[] => { - const normalized = normalizePath(directory); - if (!normalized) { - return sessions; - } - const includeDescendants = options?.includeDescendants === true; - const prefix = includeDescendants ? `${normalized}/` : null; - const includeMissingDirectory = options?.includeMissingDirectory === true; - return sessions.filter((session) => { - const sessionDir = normalizePath((session as { directory?: string | null }).directory ?? null); - if (!sessionDir) return includeMissingDirectory; - if (sessionDir === normalized) return true; - if (prefix && sessionDir.startsWith(prefix)) return true; - return false; - }); - }; - - const assignRequestedDirectory = ( - sessions: Session[], - requestedDirectory: string, - canonicalDirectory?: string | null - ): Session[] => { - const normalizedRequested = normalizePath(requestedDirectory); - if (!normalizedRequested) { - return sessions; - } - const normalizedCanonical = normalizePath(canonicalDirectory ?? null); - if (!normalizedCanonical || normalizedCanonical === normalizedRequested) { - return sessions.map((session) => { - const sessionDir = normalizePath((session as { directory?: string | null }).directory ?? null); - if (sessionDir) { - return session; - } - return ({ ...session, directory: normalizedRequested } as Session); - }); - } - - const canonicalPrefix = normalizedCanonical === "/" ? "/" : `${normalizedCanonical}/`; - const requestedPrefix = normalizedRequested === "/" ? "/" : `${normalizedRequested}/`; - - return sessions.map((session) => { - const sessionDir = normalizePath((session as { directory?: string | null }).directory ?? null); - if (!sessionDir) { - return ({ ...session, directory: normalizedRequested } as Session); - } - if (sessionDir === normalizedCanonical) { - return ({ ...session, directory: normalizedRequested } as Session); - } - if (canonicalPrefix !== "/" && sessionDir.startsWith(canonicalPrefix)) { - const suffix = sessionDir.slice(canonicalPrefix.length); - return ({ ...session, directory: `${requestedPrefix}${suffix}` } as Session); - } - return session; - }); - }; - - const fetchSessionsForDirectory = async (directoryParam?: string | null): Promise => { - const requestedDirectory = normalizePath(directoryParam); - if (!requestedDirectory) { - try { - const response = await apiClient.session.list(undefined); - return Array.isArray(response.data) ? response.data : []; - } catch (error) { - console.debug("Failed to list sessions (global):", error); - throw error; - } - } - - const canonicalDirectory = await resolveCanonicalDirectory(requestedDirectory); - - const listFromDirectoryScopedCall = async (): Promise => { - const response = await apiClient.session.list({ directory: requestedDirectory }); - return Array.isArray(response.data) ? response.data : []; - }; - - let sessions: Session[] = []; - let listError: unknown = null; - let usedGlobalFallback = false; - try { - sessions = await listFromDirectoryScopedCall(); - } catch (error) { - console.debug("Failed to list sessions for directory:", requestedDirectory, error); - listError = error; - sessions = []; - } - - // Some runtimes canonicalize directory paths (e.g. realpath). If the scoped call returns no results, - // fall back to the global list and map canonical paths back to the requested directory. - if (sessions.length === 0) { - usedGlobalFallback = true; - try { - const globalResponse = await apiClient.session.list(undefined); - const globalList = Array.isArray(globalResponse.data) ? globalResponse.data : []; - sessions = filterSessionsToDirectory(globalList, canonicalDirectory, { - includeDescendants, - includeMissingDirectory: false, - }); - } catch (error) { - console.debug("Failed to list sessions (global fallback):", error); - if (listError) { - throw listError; - } - throw error; - } - } - - const filtered = filterSessionsToDirectory(sessions, canonicalDirectory, { - includeDescendants, - includeMissingDirectory: !usedGlobalFallback, - }); - vscodeDebugLog("fetchSessionsForDirectory", { - requestedDirectory, - canonicalDirectory, - fetched: sessions.length, - filtered: filtered.length, - }); - return assignRequestedDirectory(filtered, requestedDirectory, canonicalDirectory); - }; - const normalizedFallback = normalizePath(directoryStore.currentDirectory ?? opencodeClient.getDirectory() ?? null); const activeProject = projectsStore.projects.find((project) => project.id === projectsStore.activeProjectId) ?? null; const activeProjectRoot = normalizePath(activeProject?.path ?? null); @@ -630,7 +491,26 @@ export const useSessionStore = create()( ? projectsStore.projects : (legacyRoot ? [{ id: 'legacy', path: legacyRoot }] : []); - const applyProjectResults = async (projectResults: ProjectSessionResult[]) => { + const resolveSessionDirectory = (session: Session): string | null => { + const direct = normalizePath((session as { directory?: string | null }).directory ?? null); + if (direct) { + return direct; + } + const projectWorktree = normalizePath((session as GlobalSessionRecord).project?.worktree ?? null); + return projectWorktree; + }; + + const matchesProjectDirectory = (sessionDirectory: string | null, projectDirectory: string): boolean => { + if (!sessionDirectory) { + return false; + } + if (sessionDirectory === projectDirectory) { + return true; + } + return includeDescendants && sessionDirectory.startsWith(`${projectDirectory}/`); + }; + + const applyProjectResults = async (projectResults: ProjectSessionResult[], archivedSessions: Session[]) => { const sessionsByDirectory = new Map(); projectResults.forEach((result) => { if (!result.projectPath) { @@ -751,6 +631,7 @@ export const useSessionStore = create()( set({ sessions: mergedSessions, + archivedSessions, sessionsByDirectory, currentSessionId: nextCurrentId, lastLoadedDirectory: activeDirectory ?? null, @@ -774,6 +655,7 @@ export const useSessionStore = create()( } set({ sessions: [], + archivedSessions: [], sessionsByDirectory: new Map(), currentSessionId: null, lastLoadedDirectory: null, @@ -787,110 +669,134 @@ export const useSessionStore = create()( pruneProjectCaches(projectEntries.map((entry) => entry.path)); - const activeProjectId = projectsStore.activeProjectId; - const cachedProjectResults: ProjectSessionResult[] = []; - projectEntries.forEach((project) => { - const normalizedProject = normalizePath(project.path); - if (!normalizedProject) { - return; - } - const cached = getFreshProjectSessionCache(normalizedProject); - if (cached) { - cachedProjectResults.push(cached); - } - }); + const buildProjectResults = async (sourceSessions: Session[]): Promise => { + return Promise.all( + projectEntries.map(async (project: Pick) => { + const normalizedProject = normalizePath(project.path); + if (!normalizedProject) { + return { + projectId: project.id, + projectPath: null, + sessions: [], + discoveredWorktrees: [], + validPaths: new Set(), + }; + } - const hasCachedActiveProject = cachedProjectResults.some( - (result) => result.projectId === activeProjectId - ); - - if (hasCachedActiveProject && isLatestRequest()) { - await applyProjectResults(cachedProjectResults); - } - - const projectResults: ProjectSessionResult[] = await Promise.all( - projectEntries.map(async (project: Pick) => { - const normalizedProject = normalizePath(project.path); - if (!normalizedProject) { - return { - projectId: project.id, - projectPath: null, - sessions: [], - discoveredWorktrees: [], - validPaths: new Set(), - }; - } - - const cached = getFreshProjectSessionCache(normalizedProject); - const isActiveProject = project.id === activeProjectId; - if (cached && !isActiveProject) { - return cached; - } - - const isGitRepo = await getProjectRepoStatus(normalizedProject); - const parentSessions = await fetchSessionsForDirectory(normalizedProject || null); - vscodeDebugLog("projectSessions", { - projectId: project.id, - projectPath: normalizedProject, - isGitRepo, - parentSessions: parentSessions.length, - }); - - const subdirectorySessions: Session[] = []; - let discoveredWorktrees: WorktreeMetadata[] = []; - const validPaths = new Set(); - validPaths.add(normalizedProject); - - if (isGitRepo) { - try { - const candidates = new Set(); - - const managedWorktrees = await listProjectWorktrees({ + const isGitRepo = await getProjectRepoStatus(normalizedProject); + let discoveredWorktrees: WorktreeMetadata[] = []; + const validPaths = new Set([normalizedProject]); + if (isGitRepo) { + discoveredWorktrees = await listProjectWorktrees({ id: project.id, path: normalizedProject, }).catch(() => []); - discoveredWorktrees = managedWorktrees; - managedWorktrees.forEach((meta) => { + discoveredWorktrees.forEach((meta) => { if (meta?.path) { - candidates.add(normalizePath(meta.path) ?? meta.path); + validPaths.add(normalizePath(meta.path) ?? meta.path); } }); - - candidates.forEach((candidate) => { - const normalizedCandidate = normalizePath(candidate) ?? candidate; - validPaths.add(normalizedCandidate); - }); - - if (candidates.size > 0) { - const results = await Promise.allSettled( - Array.from(candidates).map((path) => fetchSessionsForDirectory(path)) - ); - results.forEach((result) => { - if (result.status === "fulfilled" && Array.isArray(result.value)) { - subdirectorySessions.push(...result.value); - } - }); - } - } catch { - discoveredWorktrees = []; } + + const mergedSessions = dedupeSessionsById( + sourceSessions.filter((session) => { + const sessionDirectory = resolveSessionDirectory(session); + if (!sessionDirectory) { + return false; + } + for (const projectPath of validPaths) { + if (matchesProjectDirectory(sessionDirectory, projectPath)) { + return true; + } + } + return false; + }), + ); + + const result: ProjectSessionResult = { + projectId: project.id, + projectPath: normalizedProject, + sessions: mergedSessions, + discoveredWorktrees, + validPaths, + }; + setProjectSessionCache(normalizedProject, result); + return result; + }), + ); + }; + + try { + const pageSize = 500; + const firstPage = await apiClient.experimental.session.list({ limit: pageSize, archived: false }); + let liveSessions = dedupeSessionsById(Array.isArray(firstPage.data) ? firstPage.data as Session[] : []); + let archivedSessions: Session[] = []; + + const apply = async () => { + if (!isLatestRequest()) { + return; + } + const projectResults = await buildProjectResults(liveSessions); + await applyProjectResults(projectResults, dedupeSessionsById(archivedSessions)); + }; + + await apply(); + + const backgroundLoad = async () => { + let cursor = readNextCursor(firstPage) ?? undefined; + while (cursor && isLatestRequest()) { + const response = await apiClient.experimental.session.list({ + limit: pageSize, + cursor, + archived: false, + }); + const page = Array.isArray(response.data) ? response.data as Session[] : []; + if (page.length === 0) { + break; + } + liveSessions = dedupeSessionsById([...liveSessions, ...page]); + await apply(); + cursor = readNextCursor(response) ?? undefined; } - const mergedSessions = dedupeSessionsById([...parentSessions, ...subdirectorySessions]); + let archivedCursor: number | undefined; + while (isLatestRequest()) { + const response = await apiClient.experimental.session.list({ + limit: pageSize, + archived: true, + ...(archivedCursor ? { cursor: archivedCursor } : {}), + }); + const page = Array.isArray(response.data) + ? (response.data as Session[]).filter((session) => Boolean(session.time?.archived)) + : []; + if (page.length > 0) { + archivedSessions = dedupeSessionsById([...archivedSessions, ...page]); + await apply(); + } + const next = readNextCursor(response); + if (!next) { + break; + } + archivedCursor = next; + } + }; - const result: ProjectSessionResult = { - projectId: project.id, - projectPath: normalizedProject, - sessions: mergedSessions, - discoveredWorktrees, - validPaths, - }; + void backgroundLoad().catch((error) => { + console.debug("Failed to load additional global sessions:", error); + }); - setProjectSessionCache(normalizedProject, result); - return result; - }) - ); - await applyProjectResults(projectResults); + return; + } catch (error) { + if (!isMissingGlobalSessionsEndpointError(error)) { + throw error; + } + console.debug("Global session endpoint unavailable, using legacy loader"); + } + + const fallbackResponse = await apiClient.session.list(undefined); + const fallbackSessions = dedupeSessionsById(Array.isArray(fallbackResponse.data) ? fallbackResponse.data : []); + const fallbackProjectResults = await buildProjectResults(fallbackSessions); + await applyProjectResults(fallbackProjectResults, []); } catch (error) { if (!isLatestRequest()) { return; @@ -1057,7 +963,8 @@ export const useSessionStore = create()( const metadata = get().worktreeMetadata.get(id); const metadataPath = typeof metadata?.path === 'string' ? metadata.path : null; const metadataProjectDirectory = typeof metadata?.projectDirectory === 'string' ? metadata.projectDirectory : null; - const sessionDirectory = getSessionDirectory(get().sessions, id); + const snapshot = get(); + const sessionDirectory = getSessionDirectory([...snapshot.sessions, ...snapshot.archivedSessions], id); const requestDirectory = normalizePath(metadataProjectDirectory) ?? normalizePath(sessionDirectory) ?? normalizePath(opencodeClient.getDirectory() ?? null) @@ -1091,6 +998,7 @@ export const useSessionStore = create()( let nextCurrentId: string | null = null; set((state) => { const filteredSessions = state.sessions.filter((s) => s.id !== id); + const filteredArchivedSessions = state.archivedSessions.filter((s) => s.id !== id); nextCurrentId = state.currentSessionId === id ? null : state.currentSessionId; const nextMetadata = new Map(state.worktreeMetadata); nextMetadata.delete(id); @@ -1109,6 +1017,7 @@ export const useSessionStore = create()( } return { sessions: filteredSessions, + archivedSessions: filteredArchivedSessions, sessionsByDirectory: buildSessionsByDirectory(filteredSessions), currentSessionId: nextCurrentId, isLoading: false, @@ -1155,7 +1064,7 @@ export const useSessionStore = create()( for (const id of uniqueIds) { try { const metadata = get().worktreeMetadata.get(id); - const sessionDirectory = getSessionDirectory(get().sessions, id); + const sessionDirectory = getSessionDirectory([...get().sessions, ...get().archivedSessions], id); const requestDirectory = normalizePath(metadata?.projectDirectory ?? null) ?? normalizePath(sessionDirectory) ?? normalizePath(opencodeClient.getDirectory() ?? null) @@ -1218,6 +1127,7 @@ export const useSessionStore = create()( set((state) => { const filteredSessions = state.sessions.filter((session) => !deletedSet.has(session.id)); + const filteredArchivedSessions = state.archivedSessions.filter((session) => !deletedSet.has(session.id)); if (state.currentSessionId && deletedSet.has(state.currentSessionId)) { nextCurrentId = null; } else { @@ -1261,6 +1171,7 @@ export const useSessionStore = create()( return { sessions: filteredSessions, + archivedSessions: filteredArchivedSessions, sessionsByDirectory: buildSessionsByDirectory(filteredSessions), currentSessionId: nextCurrentId, ...(silent ? {} : { isLoading: false, error: errorMessage }), @@ -1276,6 +1187,88 @@ export const useSessionStore = create()( return { deletedIds, failedIds }; }, + archiveSession: async (id: string) => { + const { archivedIds, failedIds } = await get().archiveSessions([id]); + return archivedIds.length === 1 && failedIds.length === 0; + }, + + archiveSessions: async (ids: string[], options?: { silent?: boolean }) => { + const uniqueIds = Array.from(new Set(ids.filter((id): id is string => typeof id === "string" && id.length > 0))); + if (uniqueIds.length === 0) { + return { archivedIds: [], failedIds: [] }; + } + + const silent = options?.silent === true; + if (!silent) { + set({ isLoading: true, error: null }); + } + + const archivedIds: string[] = []; + const failedIds: string[] = []; + + for (const id of uniqueIds) { + try { + const metadata = get().worktreeMetadata.get(id); + const sessionDirectory = getSessionDirectory([...get().sessions, ...get().archivedSessions], id); + const requestDirectory = normalizePath(metadata?.projectDirectory ?? null) + ?? normalizePath(sessionDirectory) + ?? normalizePath(opencodeClient.getDirectory() ?? null) + ?? null; + const archived = await setSessionArchivedOnServer(id, Date.now(), requestDirectory); + if (!archived) { + failedIds.push(id); + continue; + } + archivedIds.push(id); + } catch { + failedIds.push(id); + } + } + + const archivedSet = new Set(archivedIds); + let nextCurrentId: string | null = null; + const errorMessage = failedIds.length > 0 + ? (failedIds.length === uniqueIds.length ? "Failed to archive sessions" : "Failed to archive some sessions") + : null; + + set((state) => { + if (archivedSet.size === 0) { + return silent ? state : { ...state, isLoading: false, error: errorMessage }; + } + + const archivedRows = state.sessions.filter((session) => archivedSet.has(session.id)).map((session) => ({ + ...session, + time: { + ...session.time, + archived: Date.now(), + }, + } as Session)); + + const remaining = state.sessions.filter((session) => !archivedSet.has(session.id)); + const nextArchivedSessions = dedupeSessionsById([...archivedRows, ...state.archivedSessions]); + + if (state.currentSessionId && archivedSet.has(state.currentSessionId)) { + nextCurrentId = remaining[0]?.id ?? null; + } else { + nextCurrentId = state.currentSessionId; + } + + return { + sessions: remaining, + archivedSessions: nextArchivedSessions, + sessionsByDirectory: buildSessionsByDirectory(remaining), + currentSessionId: nextCurrentId, + ...(silent ? {} : { isLoading: false, error: errorMessage }), + }; + }); + + if (!silent && archivedSet.size === 0) { + set({ isLoading: false, error: errorMessage }); + } + + return { archivedIds, failedIds }; + }, + updateSessionTitle: async (id: string, title: string) => { try { const sessionDirectory = getSessionDirectory(get().sessions, id); @@ -1560,14 +1553,23 @@ export const useSessionStore = create()( updateSession: (session: Session) => { set((state) => { const index = state.sessions.findIndex((s) => s.id === session.id); + const archivedIndex = state.archivedSessions.findIndex((s) => s.id === session.id); + const isArchived = Boolean(session.time?.archived); + const nextSessions = index === -1 - ? [session, ...state.sessions] + ? (isArchived ? state.sessions : [session, ...state.sessions]) : state.sessions.map((s) => (s.id === session.id ? session : s)); - const deduped = dedupeSessionsById(nextSessions); + const nextArchivedSessions = archivedIndex === -1 + ? (isArchived ? [session, ...state.archivedSessions] : state.archivedSessions) + : state.archivedSessions.map((s) => (s.id === session.id ? session : s)); + + const deduped = dedupeSessionsById(nextSessions.filter((item) => !item.time?.archived)); + const dedupedArchived = dedupeSessionsById(nextArchivedSessions.filter((item) => Boolean(item.time?.archived))); return { sessions: deduped, + archivedSessions: dedupedArchived, sessionsByDirectory: buildSessionsByDirectory(deduped), }; }); @@ -1579,11 +1581,13 @@ export const useSessionStore = create()( } set((state) => { - const target = state.sessions.find((session) => session.id === sessionId) as { directory?: string | null } | undefined; + const target = [...state.sessions, ...state.archivedSessions] + .find((session) => session.id === sessionId) as { directory?: string | null } | undefined; const directory = normalizePath(target?.directory ?? null); const filteredSessions = state.sessions.filter((session) => session.id !== sessionId); - if (filteredSessions.length === state.sessions.length) { + const filteredArchivedSessions = state.archivedSessions.filter((session) => session.id !== sessionId); + if (filteredSessions.length === state.sessions.length && filteredArchivedSessions.length === state.archivedSessions.length) { return state; } @@ -1598,6 +1602,7 @@ export const useSessionStore = create()( return { sessions: filteredSessions, + archivedSessions: filteredArchivedSessions, sessionsByDirectory: buildSessionsByDirectory(filteredSessions), currentSessionId: nextCurrentId, worktreeMetadata: nextMetadata, @@ -1611,6 +1616,7 @@ export const useSessionStore = create()( partialize: (state) => ({ currentSessionId: state.currentSessionId, sessions: state.sessions, + archivedSessions: state.archivedSessions, lastLoadedDirectory: state.lastLoadedDirectory, webUICreatedSessions: Array.from(state.webUICreatedSessions), worktreeMetadata: Array.from(state.worktreeMetadata.entries()), @@ -1628,6 +1634,9 @@ export const useSessionStore = create()( const persistedSessions = Array.isArray(persistedState.sessions) ? (persistedState.sessions as Session[]) : currentState.sessions; + const persistedArchivedSessions = Array.isArray(persistedState.archivedSessions) + ? (persistedState.archivedSessions as Session[]) + : currentState.archivedSessions; const persistedCurrentSessionId = typeof persistedState.currentSessionId === "string" || persistedState.currentSessionId === null @@ -1657,11 +1666,13 @@ export const useSessionStore = create()( : currentState.lastLoadedDirectory ?? null; const mergedSessions = dedupeSessionsById(persistedSessions); + const mergedArchivedSessions = dedupeSessionsById(persistedArchivedSessions); const mergedResult = { ...currentState, ...persistedState, sessions: mergedSessions, + archivedSessions: mergedArchivedSessions, sessionsByDirectory: buildSessionsByDirectory(mergedSessions), currentSessionId: persistedCurrentSessionId, webUICreatedSessions: new Set(webUiSessionsArray), diff --git a/packages/ui/src/stores/types/sessionTypes.ts b/packages/ui/src/stores/types/sessionTypes.ts index db737f2d..b8a1dc84 100644 --- a/packages/ui/src/stores/types/sessionTypes.ts +++ b/packages/ui/src/stores/types/sessionTypes.ts @@ -137,6 +137,7 @@ export interface VoiceState { export interface SessionStore { sessions: Session[]; + archivedSessions: Session[]; sessionsByDirectory: Map; currentSessionId: string | null; lastLoadedDirectory: string | null; @@ -221,6 +222,8 @@ export interface SessionStore { deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string }) => Promise; deleteSessions: (ids: string[], options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string; silent?: boolean }) => Promise<{ deletedIds: string[]; failedIds: string[] }>; + archiveSession: (id: string) => Promise; + archiveSessions: (ids: string[], options?: { silent?: boolean }) => Promise<{ archivedIds: string[]; failedIds: string[] }>; updateSessionTitle: (id: string, title: string) => Promise; shareSession: (id: string) => Promise; unshareSession: (id: string) => Promise; diff --git a/packages/ui/src/stores/useGitHubPrStatusStore.ts b/packages/ui/src/stores/useGitHubPrStatusStore.ts new file mode 100644 index 00000000..28815b91 --- /dev/null +++ b/packages/ui/src/stores/useGitHubPrStatusStore.ts @@ -0,0 +1,562 @@ +import { create } from 'zustand'; +import type { GitHubPullRequestStatus, RuntimeAPIs } from '@/lib/api/types'; + +const PR_REVALIDATE_TTL_MS = 90_000; +const PR_REVALIDATE_INTERVAL_MS = 15_000; +const PR_DISCOVERY_INTERVAL_MS = 5 * 60_000; +const PR_BOOTSTRAP_RETRY_DELAYS_MS = [2_000, 5_000] as const; +const PR_OPEN_BUSY_INTERVAL_MS = 60_000; +const PR_OPEN_DEFAULT_INTERVAL_MS = 2 * 60_000; +const PR_OPEN_STABLE_INTERVAL_MS = 5 * 60_000; + +const isTerminalPrState = (state: string | null | undefined): boolean => state === 'closed' || state === 'merged'; +const isPendingChecks = (status: GitHubPullRequestStatus | null): boolean => { + const checks = status?.checks; + if (!checks) { + return false; + } + return checks.state === 'pending' || checks.pending > 0; +}; + +export const getGitHubPrStatusKey = (directory: string, branch: string, remoteName?: string | null): string => { + void remoteName; + return `${directory}::${branch}`; +}; + +type RefreshOptions = { + force?: boolean; + onlyExistingPr?: boolean; + silent?: boolean; + markInitialResolved?: boolean; +}; + +type PrTrackingTarget = { + directory: string; + branch: string; + remoteName?: string | null; +}; + +type PrRuntimeParams = { + directory: string; + branch: string; + remoteName: string | null; + canShow: boolean; + github?: RuntimeAPIs['github']; + githubAuthChecked: boolean; + githubConnected: boolean | null; +}; + +type PrStatusEntry = { + status: GitHubPullRequestStatus | null; + isLoading: boolean; + error: string | null; + isInitialStatusResolved: boolean; + lastRefreshAt: number; + lastDiscoveryPollAt: number; + watchers: number; + params: PrRuntimeParams | null; +}; + +type GitHubPrStatusStore = { + entries: Record; + activeRequestCount: number; + totalRequestCount: number; + ensureEntry: (key: string) => void; + setParams: (key: string, params: PrRuntimeParams) => void; + startWatching: (key: string) => void; + stopWatching: (key: string) => void; + refresh: (key: string, options?: RefreshOptions) => Promise; + updateStatus: (key: string, updater: (prev: GitHubPullRequestStatus | null) => GitHubPullRequestStatus | null) => void; + syncBackgroundTargets: (args: { + targets: PrTrackingTarget[]; + github?: RuntimeAPIs['github']; + githubAuthChecked: boolean; + githubConnected: boolean | null; + }) => void; +}; + +const timers = new Map(); +const bootstrapTimers = new Map(); +const inFlightBySignature = new Set(); +const lastRefreshBySignature = new Map(); +const backgroundWatchingKeys = new Set(); + +const getSignatureFromParams = (params: PrRuntimeParams | null | undefined): string | null => { + if (!params?.directory || !params.branch) { + return null; + } + return `${params.directory}::${params.branch}`; +}; + +const getKeysBySignature = (entries: Record, signature: string): string[] => { + return Object.entries(entries) + .filter(([, entry]) => getSignatureFromParams(entry.params) === signature) + .map(([key]) => key); +}; + +const pickFetchParamsForSignature = ( + entries: Record, + signature: string, + preferredKey: string, +): PrRuntimeParams | null => { + const keys = getKeysBySignature(entries, signature); + const candidates = keys + .map((key) => entries[key]) + .filter((entry): entry is PrStatusEntry => Boolean(entry?.params)) + .map((entry) => entry.params) + .filter((params): params is PrRuntimeParams => Boolean(params?.canShow && params.github?.prStatus)); + + if (candidates.length === 0) { + return null; + } + + const preferred = entries[preferredKey]?.params; + if ( + preferred + && getSignatureFromParams(preferred) === signature + && preferred.canShow + && preferred.github?.prStatus + ) { + return preferred; + } + + const withRemote = candidates.find((params) => Boolean(params.remoteName)); + if (withRemote) { + return withRemote; + } + + return candidates[0] ?? null; +}; + +const createEntry = (): PrStatusEntry => ({ + status: null, + isLoading: false, + error: null, + isInitialStatusResolved: false, + lastRefreshAt: 0, + lastDiscoveryPollAt: 0, + watchers: 0, + params: null, +}); + +const mergeParams = (current: PrRuntimeParams | null, next: PrRuntimeParams): PrRuntimeParams => { + if (!current) { + return next; + } + + return { + ...current, + ...next, + remoteName: next.remoteName ?? current.remoteName ?? null, + }; +}; + +export const useGitHubPrStatusStore = create((set, get) => ({ + entries: {}, + activeRequestCount: 0, + totalRequestCount: 0, + + ensureEntry: (key) => { + set((state) => { + if (state.entries[key]) { + return state; + } + return { + entries: { + ...state.entries, + [key]: createEntry(), + }, + }; + }); + }, + + setParams: (key, params) => { + set((state) => { + const current = state.entries[key] ?? createEntry(); + return { + entries: { + ...state.entries, + [key]: { + ...current, + params: mergeParams(current.params, params), + }, + }, + }; + }); + }, + + startWatching: (key) => { + set((state) => { + const current = state.entries[key] ?? createEntry(); + return { + entries: { + ...state.entries, + [key]: { + ...current, + watchers: current.watchers + 1, + }, + }, + }; + }); + + if (timers.has(key)) { + return; + } + + const runBootstrapRefresh = (delayMs: number) => { + const timerId = window.setTimeout(() => { + if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { + return; + } + const entry = get().entries[key]; + if (!entry || entry.watchers <= 0) { + return; + } + if (entry.status?.pr) { + return; + } + void get().refresh(key, { force: true, silent: true, markInitialResolved: true }); + }, delayMs); + const existing = bootstrapTimers.get(key) ?? []; + existing.push(timerId); + bootstrapTimers.set(key, existing); + }; + + void get().refresh(key, { force: true, silent: true, markInitialResolved: true }); + PR_BOOTSTRAP_RETRY_DELAYS_MS.forEach((delay) => runBootstrapRefresh(delay)); + + const timerId = window.setInterval(() => { + if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { + return; + } + + const entry = get().entries[key]; + if (!entry || entry.watchers <= 0) { + return; + } + + const hasPr = Boolean(entry.status?.pr); + if (!hasPr) { + const now = Date.now(); + if (now - entry.lastDiscoveryPollAt < PR_DISCOVERY_INTERVAL_MS) { + return; + } + set((state) => { + const current = state.entries[key]; + if (!current) { + return state; + } + return { + entries: { + ...state.entries, + [key]: { + ...current, + lastDiscoveryPollAt: now, + }, + }, + }; + }); + void get().refresh(key, { force: true, silent: true, markInitialResolved: true }); + return; + } + + if (isTerminalPrState(entry.status?.pr?.state)) { + return; + } + + const elapsed = Date.now() - entry.lastRefreshAt; + const nextInterval = isPendingChecks(entry.status) + ? PR_OPEN_BUSY_INTERVAL_MS + : (entry.status?.checks && entry.status.checks.state !== 'pending' + ? PR_OPEN_STABLE_INTERVAL_MS + : PR_OPEN_DEFAULT_INTERVAL_MS); + if (elapsed < nextInterval) { + return; + } + + void get().refresh(key, { force: true, onlyExistingPr: true, silent: true, markInitialResolved: true }); + }, PR_REVALIDATE_INTERVAL_MS); + + timers.set(key, timerId); + }, + + stopWatching: (key) => { + set((state) => { + const current = state.entries[key]; + if (!current) { + return state; + } + + const watchers = Math.max(0, current.watchers - 1); + return { + entries: { + ...state.entries, + [key]: { + ...current, + watchers, + }, + }, + }; + }); + + const entry = get().entries[key]; + if (entry && entry.watchers > 0) { + return; + } + + const timerId = timers.get(key); + if (typeof timerId === 'number') { + window.clearInterval(timerId); + } + timers.delete(key); + + const pendingBootstrapTimers = bootstrapTimers.get(key); + if (pendingBootstrapTimers && pendingBootstrapTimers.length > 0) { + pendingBootstrapTimers.forEach((id) => { + window.clearTimeout(id); + }); + } + bootstrapTimers.delete(key); + }, + + refresh: async (key, options) => { + const state = get(); + const entry = state.entries[key]; + const signature = getSignatureFromParams(entry?.params); + + if (!entry || !signature) { + return; + } + const signatureKeys = getKeysBySignature(state.entries, signature); + const hasExistingPr = signatureKeys.some((signatureKey) => Boolean(state.entries[signatureKey]?.status?.pr)); + if (options?.onlyExistingPr && !hasExistingPr) { + return; + } + const lastRefreshAt = lastRefreshBySignature.get(signature) ?? 0; + if (!options?.force && Date.now() - lastRefreshAt < PR_REVALIDATE_TTL_MS) { + return; + } + if (inFlightBySignature.has(signature)) { + return; + } + + const params = pickFetchParamsForSignature(state.entries, signature, key); + if (!params) { + return; + } + + inFlightBySignature.add(signature); + lastRefreshBySignature.set(signature, Date.now()); + + set((prev) => { + const nextEntries = { ...prev.entries }; + signatureKeys.forEach((signatureKey) => { + const current = nextEntries[signatureKey]; + if (!current) { + return; + } + nextEntries[signatureKey] = { + ...current, + lastRefreshAt: Date.now(), + isLoading: options?.silent ? current.isLoading : true, + error: null, + }; + }); + return { + entries: nextEntries, + }; + }); + + if (params.githubAuthChecked && params.githubConnected === false) { + set((prev) => { + const nextEntries = { ...prev.entries }; + signatureKeys.forEach((signatureKey) => { + const current = nextEntries[signatureKey]; + if (!current) { + return; + } + nextEntries[signatureKey] = { + ...current, + status: { connected: false }, + error: null, + isLoading: options?.silent ? current.isLoading : false, + isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true, + }; + }); + return { + entries: nextEntries, + }; + }); + inFlightBySignature.delete(signature); + return; + } + + if (!params.github?.prStatus) { + set((prev) => { + const nextEntries = { ...prev.entries }; + signatureKeys.forEach((signatureKey) => { + const current = nextEntries[signatureKey]; + if (!current) { + return; + } + nextEntries[signatureKey] = { + ...current, + status: null, + error: 'GitHub runtime API unavailable', + isLoading: options?.silent ? current.isLoading : false, + isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true, + }; + }); + return { + entries: nextEntries, + }; + }); + inFlightBySignature.delete(signature); + return; + } + + try { + set((prev) => ({ + ...prev, + activeRequestCount: prev.activeRequestCount + 1, + totalRequestCount: prev.totalRequestCount + 1, + })); + const next = await params.github.prStatus(params.directory, params.branch, params.remoteName ?? undefined); + set((prev) => { + const nextEntries = { ...prev.entries }; + signatureKeys.forEach((signatureKey) => { + const current = nextEntries[signatureKey]; + if (!current) { + return; + } + + const prevPr = current.status?.pr; + const nextPr = next.pr; + const shouldCarryBody = Boolean( + nextPr + && prevPr + && nextPr.number === prevPr.number + && (!nextPr.body || !nextPr.body.trim()) + && typeof prevPr.body === 'string' + && prevPr.body.trim().length > 0, + ); + + const status = shouldCarryBody && nextPr && prevPr?.body + ? { + ...next, + pr: { + ...nextPr, + body: prevPr.body, + }, + } + : next; + + nextEntries[signatureKey] = { + ...current, + status, + error: null, + isLoading: options?.silent ? current.isLoading : false, + isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true, + }; + }); + + return { + entries: nextEntries, + }; + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + set((prev) => { + const nextEntries = { ...prev.entries }; + signatureKeys.forEach((signatureKey) => { + const current = nextEntries[signatureKey]; + if (!current) { + return; + } + nextEntries[signatureKey] = { + ...current, + error: message || 'Failed to load PR status', + isLoading: options?.silent ? current.isLoading : false, + isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true, + }; + }); + return { + entries: nextEntries, + }; + }); + } finally { + inFlightBySignature.delete(signature); + set((prev) => ({ ...prev, activeRequestCount: Math.max(0, prev.activeRequestCount - 1) })); + } + }, + + updateStatus: (key, updater) => { + set((state) => { + const current = state.entries[key] ?? createEntry(); + return { + entries: { + ...state.entries, + [key]: { + ...current, + status: updater(current.status), + }, + }, + }; + }); + }, + + syncBackgroundTargets: ({ targets, github, githubAuthChecked, githubConnected }) => { + if (!github || targets.length === 0) { + Array.from(backgroundWatchingKeys).forEach((key) => { + get().stopWatching(key); + backgroundWatchingKeys.delete(key); + }); + return; + } + + const uniqueTargets = new Map(); + targets.forEach((target) => { + const directory = target.directory.trim(); + const branch = target.branch.trim(); + if (!directory || !branch) { + return; + } + const key = getGitHubPrStatusKey(directory, branch, target.remoteName ?? null); + if (!uniqueTargets.has(key)) { + uniqueTargets.set(key, { + directory, + branch, + remoteName: target.remoteName ?? null, + }); + } + }); + + const nextKeys = new Set(uniqueTargets.keys()); + + Array.from(backgroundWatchingKeys).forEach((key) => { + if (nextKeys.has(key)) { + return; + } + get().stopWatching(key); + backgroundWatchingKeys.delete(key); + }); + + uniqueTargets.forEach((target, key) => { + get().ensureEntry(key); + get().setParams(key, { + directory: target.directory, + branch: target.branch, + remoteName: target.remoteName ?? null, + canShow: true, + github, + githubAuthChecked, + githubConnected, + }); + + if (!backgroundWatchingKeys.has(key)) { + get().startWatching(key); + backgroundWatchingKeys.add(key); + } + }); + }, +})); diff --git a/packages/ui/src/stores/useSessionDisplayStore.ts b/packages/ui/src/stores/useSessionDisplayStore.ts new file mode 100644 index 00000000..66da8a59 --- /dev/null +++ b/packages/ui/src/stores/useSessionDisplayStore.ts @@ -0,0 +1,21 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +export type SessionDisplayMode = 'default' | 'minimal'; + +type SessionDisplayStore = { + displayMode: SessionDisplayMode; + setDisplayMode: (mode: SessionDisplayMode) => void; +}; + +export const useSessionDisplayStore = create()( + persist( + (set) => ({ + displayMode: 'default', + setDisplayMode: (mode) => set({ displayMode: mode }), + }), + { + name: 'session-display-mode', + }, + ), +); diff --git a/packages/ui/src/stores/useSessionFoldersStore.ts b/packages/ui/src/stores/useSessionFoldersStore.ts index 6728f0b8..c6e0d2e8 100644 --- a/packages/ui/src/stores/useSessionFoldersStore.ts +++ b/packages/ui/src/stores/useSessionFoldersStore.ts @@ -1,6 +1,8 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import { getSafeStorage } from './utils/safeStorage'; +import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import { useDirectoryStore } from './useDirectoryStore'; // --- Types --- @@ -38,8 +40,77 @@ type SessionFoldersStore = SessionFoldersState & SessionFoldersActions; const FOLDERS_STORAGE_KEY = 'oc.sessions.folders'; const COLLAPSED_STORAGE_KEY = 'oc.sessions.folderCollapse'; +const SESSIONS_DIRECTORIES_PATH_SUFFIX = '.config/openchamber/sessions-directories.json'; +const DISK_WRITE_DEBOUNCE_MS = 250; +const ARCHIVED_SCOPE_PREFIX = '__archived__:'; const safeStorage = getSafeStorage(); +let diskWriteTimer: ReturnType | null = null; +let diskHydrated = false; +let diskHydrationInFlight = false; + +const getSessionsDirectoriesPath = (): string | null => { + const directoryState = useDirectoryStore.getState(); + const homeDirectory = typeof directoryState.homeDirectory === 'string' && directoryState.homeDirectory.length > 0 + ? directoryState.homeDirectory + : (safeStorage.getItem('homeDirectory') || ''); + + if (!homeDirectory) { + return null; + } + + return `${homeDirectory.replace(/\/$/, '')}/${SESSIONS_DIRECTORIES_PATH_SUFFIX}`; +}; + +const getParentDirectory = (path: string): string | null => { + const index = path.lastIndexOf('/'); + if (index <= 0) { + return null; + } + return path.slice(0, index); +}; + +const schedulePersistToDisk = (foldersMap: SessionFoldersMap, collapsedFolderIds: Set): void => { + if (typeof window === 'undefined') { + return; + } + + if (diskWriteTimer) { + clearTimeout(diskWriteTimer); + } + + const foldersSnapshot = JSON.parse(JSON.stringify(foldersMap)) as SessionFoldersMap; + const collapsedSnapshot = Array.from(collapsedFolderIds); + + diskWriteTimer = setTimeout(() => { + diskWriteTimer = null; + void (async () => { + const runtimeFiles = getRegisteredRuntimeAPIs()?.files; + if (!runtimeFiles?.writeFile) { + return; + } + + const path = getSessionsDirectoriesPath(); + if (!path) { + return; + } + + const parentDirectory = getParentDirectory(path); + if (parentDirectory) { + await runtimeFiles.createDirectory(parentDirectory).catch(() => undefined); + } + + const payload = { + version: 1, + foldersMap: foldersSnapshot, + collapsedFolderIds: collapsedSnapshot, + updatedAt: Date.now(), + }; + + await runtimeFiles.writeFile(path, JSON.stringify(payload, null, 2)).catch(() => undefined); + })(); + }, DISK_WRITE_DEBOUNCE_MS); +}; const readPersistedFolders = (): SessionFoldersMap => { try { @@ -112,6 +183,12 @@ const persistCollapsed = (collapsedFolderIds: Set): void => { } }; +const persistState = (foldersMap: SessionFoldersMap, collapsedFolderIds: Set): void => { + persistFolders(foldersMap); + persistCollapsed(collapsedFolderIds); + schedulePersistToDisk(foldersMap, collapsedFolderIds); +}; + const createFolderId = (): string => { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); @@ -139,6 +216,14 @@ const syncCollapsedAfterFolderCleanup = ( return nextCollapsed; }; +const pruneEmptyArchivedFolders = (scopeKey: string, folders: SessionFolder[]): SessionFolder[] => { + if (!scopeKey.startsWith(ARCHIVED_SCOPE_PREFIX)) { + return folders; + } + + return folders.filter((folder) => folder.sessionIds.length > 0); +}; + // --- Store --- export const useSessionFoldersStore = create()( @@ -168,7 +253,7 @@ export const useSessionFoldersStore = create()( [scopeKey]: [...scopeFolders, folder], }; set({ foldersMap: nextMap }); - persistFolders(nextMap); + persistState(nextMap, get().collapsedFolderIds); return folder; }, @@ -183,7 +268,7 @@ export const useSessionFoldersStore = create()( ); const nextMap: SessionFoldersMap = { ...current, [scopeKey]: nextFolders }; set({ foldersMap: nextMap }); - persistFolders(nextMap); + persistState(nextMap, get().collapsedFolderIds); }, deleteFolder: (scopeKey: string, folderId: string): void => { @@ -206,7 +291,7 @@ export const useSessionFoldersStore = create()( const nextFolders = scopeFolders.filter((folder) => !idsToDelete.has(folder.id)); const nextMap: SessionFoldersMap = { ...current, [scopeKey]: nextFolders }; set({ foldersMap: nextMap }); - persistFolders(nextMap); + persistState(nextMap, get().collapsedFolderIds); // Clean up collapsed state for all deleted folders const collapsed = get().collapsedFolderIds; @@ -215,7 +300,7 @@ export const useSessionFoldersStore = create()( const nextCollapsed = new Set(collapsed); idsToDelete.forEach((id) => nextCollapsed.delete(id)); set({ collapsedFolderIds: nextCollapsed }); - persistCollapsed(nextCollapsed); + persistState(nextMap, nextCollapsed); } }, @@ -243,10 +328,7 @@ export const useSessionFoldersStore = create()( set(nextCollapsed ? { foldersMap: nextMap, collapsedFolderIds: nextCollapsed } : { foldersMap: nextMap }); - persistFolders(nextMap); - if (nextCollapsed) { - persistCollapsed(nextCollapsed); - } + persistState(nextMap, nextCollapsed ?? get().collapsedFolderIds); }, removeSessionFromFolder: (scopeKey: string, sessionId: string): void => { @@ -272,10 +354,7 @@ export const useSessionFoldersStore = create()( set(nextCollapsed ? { foldersMap: nextMap, collapsedFolderIds: nextCollapsed } : { foldersMap: nextMap }); - persistFolders(nextMap); - if (nextCollapsed) { - persistCollapsed(nextCollapsed); - } + persistState(nextMap, nextCollapsed ?? get().collapsedFolderIds); }, toggleFolderCollapse: (folderId: string): void => { @@ -287,7 +366,7 @@ export const useSessionFoldersStore = create()( next.add(folderId); } set({ collapsedFolderIds: next }); - persistCollapsed(next); + persistState(get().foldersMap, next); }, cleanupSessions: (scopeKey: string, existingSessionIds: Set): void => { @@ -297,7 +376,7 @@ export const useSessionFoldersStore = create()( if (!scopeFolders || scopeFolders.length === 0) return; let changed = false; - const nextFolders = scopeFolders.map((folder) => { + const filteredFolders = scopeFolders.map((folder) => { const filtered = folder.sessionIds.filter((id) => existingSessionIds.has(id)); if (filtered.length !== folder.sessionIds.length) { changed = true; @@ -306,6 +385,11 @@ export const useSessionFoldersStore = create()( return folder; }); + const nextFolders = pruneEmptyArchivedFolders(scopeKey, filteredFolders); + if (nextFolders.length !== filteredFolders.length) { + changed = true; + } + if (!changed) return; const nextMap: SessionFoldersMap = { ...current, [scopeKey]: nextFolders }; const nextCollapsed = syncCollapsedAfterFolderCleanup(scopeFolders, nextFolders, get().collapsedFolderIds); @@ -313,10 +397,7 @@ export const useSessionFoldersStore = create()( set(nextCollapsed ? { foldersMap: nextMap, collapsedFolderIds: nextCollapsed } : { foldersMap: nextMap }); - persistFolders(nextMap); - if (nextCollapsed) { - persistCollapsed(nextCollapsed); - } + persistState(nextMap, nextCollapsed ?? get().collapsedFolderIds); }, getSessionFolderId: (scopeKey: string, sessionId: string): string | null => { @@ -334,3 +415,84 @@ export const useSessionFoldersStore = create()( { name: 'session-folders-store' }, ), ); + +const hydrateSessionFoldersFromDisk = async (): Promise => { + if (diskHydrated || diskHydrationInFlight || typeof window === 'undefined') { + return; + } + + const runtimeFiles = getRegisteredRuntimeAPIs()?.files; + if (!runtimeFiles?.readFile) { + return; + } + + const path = getSessionsDirectoriesPath(); + if (!path) { + return; + } + + diskHydrationInFlight = true; + + const result = await runtimeFiles.readFile(path).catch(() => null); + if (!result?.content) { + diskHydrationInFlight = false; + diskHydrated = true; + return; + } + + try { + const parsed = JSON.parse(result.content) as { + foldersMap?: SessionFoldersMap; + collapsedFolderIds?: string[]; + }; + + const diskFolders = parsed?.foldersMap && typeof parsed.foldersMap === 'object' + ? parsed.foldersMap + : {}; + const diskCollapsed = Array.isArray(parsed?.collapsedFolderIds) + ? new Set(parsed.collapsedFolderIds.filter((value): value is string => typeof value === 'string')) + : new Set(); + + const hasDiskData = Object.keys(diskFolders).length > 0 || diskCollapsed.size > 0; + if (!hasDiskData) { + return; + } + + useSessionFoldersStore.setState({ + foldersMap: diskFolders, + collapsedFolderIds: diskCollapsed, + }); + + persistFolders(diskFolders); + persistCollapsed(diskCollapsed); + } catch { + // ignored + } finally { + diskHydrationInFlight = false; + diskHydrated = true; + } +}; + +const bootstrapSessionFoldersDiskHydration = (): void => { + if (typeof window === 'undefined') { + return; + } + + let attempts = 0; + const maxAttempts = 20; + + const runAttempt = () => { + attempts += 1; + void hydrateSessionFoldersFromDisk(); + + if (diskHydrated || attempts >= maxAttempts) { + return; + } + + setTimeout(runAttempt, 500); + }; + + runAttempt(); +}; + +bootstrapSessionFoldersDiskHydration(); diff --git a/packages/ui/src/stores/useSessionStore.ts b/packages/ui/src/stores/useSessionStore.ts index 8b5a306f..4536c193 100644 --- a/packages/ui/src/stores/useSessionStore.ts +++ b/packages/ui/src/stores/useSessionStore.ts @@ -83,6 +83,7 @@ export const useSessionStore = create()( (set, get) => ({ sessions: [], + archivedSessions: [], sessionsByDirectory: new Map(), currentSessionId: null, lastLoadedDirectory: null, @@ -280,6 +281,8 @@ export const useSessionStore = create()( }, deleteSession: (id: string, options) => useSessionManagementStore.getState().deleteSession(id, options), deleteSessions: (ids: string[], options) => useSessionManagementStore.getState().deleteSessions(ids, options), + archiveSession: (id: string) => useSessionManagementStore.getState().archiveSession(id), + archiveSessions: (ids: string[], options) => useSessionManagementStore.getState().archiveSessions(ids, options), updateSessionTitle: (id: string, title: string) => useSessionManagementStore.getState().updateSessionTitle(id, title), shareSession: (id: string) => useSessionManagementStore.getState().shareSession(id), unshareSession: (id: string) => useSessionManagementStore.getState().unshareSession(id), @@ -876,6 +879,7 @@ useSessionManagementStore.subscribe((state, prevState) => { if ( state.sessions === prevState.sessions && + state.archivedSessions === prevState.archivedSessions && state.sessionsByDirectory === prevState.sessionsByDirectory && state.currentSessionId === prevState.currentSessionId && state.lastLoadedDirectory === prevState.lastLoadedDirectory && @@ -893,6 +897,7 @@ useSessionManagementStore.subscribe((state, prevState) => { useSessionStore.setState({ sessions: state.sessions, + archivedSessions: state.archivedSessions, sessionsByDirectory: state.sessionsByDirectory, currentSessionId: draftOpen ? null : state.currentSessionId, lastLoadedDirectory: state.lastLoadedDirectory, diff --git a/packages/vscode/package.json b/packages/vscode/package.json index d2f52b69..48b4b490 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -229,7 +229,7 @@ }, "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.2.17", + "@opencode-ai/sdk": "^1.2.20", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", diff --git a/packages/web/package.json b/packages/web/package.json index 69a4dcc7..6b43fdeb 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -28,7 +28,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.2.17", + "@opencode-ai/sdk": "^1.2.20", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/web/server/index.js b/packages/web/server/index.js index f87aa22d..ac1fc63c 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -9806,36 +9806,40 @@ async function main(options = {}) { return res.json({ connected: true, repo: null, branch, pr: null, checks: null, canMerge: false }); } - // Determine the head owner for PR search - // Priority: 1) tracking branch remote, 2) origin (if different from target), 3) target repo owner - let headOwnerForSearch = null; - - // First, check the branch's tracking info to see which remote it's on + let originRepo = null; + if (remote !== 'origin') { + const originResolved = await resolveGitHubRepoFromDirectory(directory, 'origin').catch(() => ({ repo: null })); + originRepo = originResolved?.repo || null; + } + + const candidateHeadOwners = []; + const pushHeadOwner = (owner) => { + if (typeof owner !== 'string') return; + const normalized = owner.trim(); + if (!normalized) return; + if (candidateHeadOwners.includes(normalized)) return; + candidateHeadOwners.push(normalized); + }; + + // First, use branch tracking remote owner (where branch is usually pushed). const { getStatus } = await import('./lib/git/index.js'); const status = await getStatus(directory).catch(() => null); if (status?.tracking) { const trackingRemote = status.tracking.split('/')[0]; - if (trackingRemote && trackingRemote !== remote) { - // Branch is tracked on a different remote - get that remote's owner - const { repo: trackingRepo } = await resolveGitHubRepoFromDirectory(directory, trackingRemote); - if (trackingRepo && trackingRepo.owner !== repo.owner) { - headOwnerForSearch = trackingRepo.owner; - } - } - } - - // Fallback: if targeting non-origin, check if origin has a different owner (fork scenario) - if (!headOwnerForSearch && remote !== 'origin') { - const { repo: originRepo } = await resolveGitHubRepoFromDirectory(directory, 'origin'); - if (originRepo && originRepo.owner !== repo.owner) { - headOwnerForSearch = originRepo.owner; + if (trackingRemote) { + const trackingResolved = await resolveGitHubRepoFromDirectory(directory, trackingRemote).catch(() => ({ repo: null })); + pushHeadOwner(trackingResolved?.repo?.owner); } } - const listByHead = async (state, headOwner = repo.owner) => { + // Then same-repo and origin fallback owners. + pushHeadOwner(repo.owner); + pushHeadOwner(originRepo?.owner); + + const listByHead = async (targetRepo, state, headOwner) => { const resp = await octokit.rest.pulls.list({ - owner: repo.owner, - repo: repo.repo, + owner: targetRepo.owner, + repo: targetRepo.repo, state, head: `${headOwner}:${branch}`, per_page: 10, @@ -9843,10 +9847,10 @@ async function main(options = {}) { return Array.isArray(resp?.data) ? resp.data[0] : null; }; - const listByHeadRef = async (state) => { + const listByHeadRef = async (targetRepo, state) => { const resp = await octokit.rest.pulls.list({ - owner: repo.owner, - repo: repo.repo, + owner: targetRepo.owner, + repo: targetRepo.repo, state, per_page: 100, }); @@ -9856,34 +9860,41 @@ async function main(options = {}) { return matches[0] ?? null; }; - // PR status by branch: - // - Prefer open PRs. - // - If none, also surface closed/merged PRs. - // - For cross-repo PRs: first try with head owner, then fall back to target owner, then ref match. - let first = null; - - // For cross-repo workflows, try head owner first - if (headOwnerForSearch) { - first = await listByHead('open', headOwnerForSearch); - if (!first) first = await listByHead('closed', headOwnerForSearch); + const tryFindPr = async (targetRepo) => { + let found = null; + for (const owner of candidateHeadOwners) { + found = await listByHead(targetRepo, 'open', owner); + if (found) return found; + found = await listByHead(targetRepo, 'closed', owner); + if (found) return found; + } + found = await listByHeadRef(targetRepo, 'open'); + if (found) return found; + return listByHeadRef(targetRepo, 'closed'); + }; + + // Try requested remote target repo first, then origin target repo fallback for fork flows. + let searchRepo = repo; + let first = await tryFindPr(searchRepo); + if (!first && originRepo) { + const isDifferentRepo = originRepo.owner !== repo.owner || originRepo.repo !== repo.repo; + if (isDifferentRepo) { + const originMatch = await tryFindPr(originRepo); + if (originMatch) { + first = originMatch; + searchRepo = originRepo; + } + } } - - // Try with target repo owner (same-repo PRs) - if (!first) first = await listByHead('open'); - if (!first) first = await listByHead('closed'); - - // Fall back to matching head.ref directly (handles edge cases) - if (!first) first = await listByHeadRef('open'); - if (!first) first = await listByHeadRef('closed'); if (!first) { - return res.json({ connected: true, repo, branch, pr: null, checks: null, canMerge: false }); + return res.json({ connected: true, repo: searchRepo, branch, pr: null, checks: null, canMerge: false }); } // Enrich with mergeability fields - const prFull = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: first.number }); + const prFull = await octokit.rest.pulls.get({ owner: searchRepo.owner, repo: searchRepo.repo, pull_number: first.number }); const prData = prFull?.data; if (!prData) { - return res.json({ connected: true, repo, branch, pr: null, checks: null, canMerge: false }); + return res.json({ connected: true, repo: searchRepo, branch, pr: null, checks: null, canMerge: false }); } // Checks summary: prefer check-runs (Actions), fallback to classic statuses. @@ -9892,8 +9903,8 @@ async function main(options = {}) { if (sha) { try { const runs = await octokit.rest.checks.listForRef({ - owner: repo.owner, - repo: repo.repo, + owner: searchRepo.owner, + repo: searchRepo.repo, ref: sha, per_page: 100, }); @@ -9930,8 +9941,8 @@ async function main(options = {}) { if (!checks) { try { const combined = await octokit.rest.repos.getCombinedStatusForRef({ - owner: repo.owner, - repo: repo.repo, + owner: searchRepo.owner, + repo: searchRepo.repo, ref: sha, }); const statuses = Array.isArray(combined?.data?.statuses) ? combined.data.statuses : []; @@ -9959,8 +9970,8 @@ async function main(options = {}) { const username = auth?.user?.login; if (username) { const perm = await octokit.rest.repos.getCollaboratorPermissionLevel({ - owner: repo.owner, - repo: repo.repo, + owner: searchRepo.owner, + repo: searchRepo.repo, username, }); const level = perm?.data?.permission; @@ -9975,7 +9986,7 @@ async function main(options = {}) { return res.json({ connected: true, - repo, + repo: searchRepo, branch, pr: { number: prData.number,