"use client"; import { useEffect, useLayoutEffect, useState, useCallback, useMemo, useRef, type CSSProperties, type ReactNode } from "react"; import type { SessionInfo } from "@/lib/types"; import { loadExplorerOpen, saveExplorerOpen } from "@/lib/file-explorer-state"; import { dispatchSessionRowContextMenu } from "@/lib/session-row-context-menu"; import { skillExpansionToCommand } from "@/lib/slash-display"; import { useI18n } from "@/hooks/useI18n"; import { DirectoryPicker } from "./DirectoryPicker"; import { FileExplorer, type FileExplorerHandle } from "./FileExplorer"; declare global { interface Window { piDesktop?: { selectDirectory: () => Promise; }; } } function ToolbarIconButton({ onClick, title, disabled, skipHover, color, background = "none", marginRight, ariaPressed, children, }: { onClick: () => void; title: string; disabled?: boolean; skipHover?: boolean; color: string; background?: string; marginRight?: number; ariaPressed?: boolean; children: ReactNode; }) { const enter = (e: React.MouseEvent) => { if (disabled || skipHover) return; e.currentTarget.style.color = "var(--text-muted)"; e.currentTarget.style.background = "var(--bg-hover)"; }; const leave = (e: React.MouseEvent) => { if (disabled || skipHover) return; e.currentTarget.style.color = color; e.currentTarget.style.background = background; }; return ( ); } interface Props { selectedSessionId: string | null; onSelectSession: (session: SessionInfo, isRestore?: boolean) => void; onNewSession?: (sessionId: string, cwd: string) => void; initialSessionId?: string | null; skipInitialProjectSelection?: boolean; onInitialRestoreDone?: () => void; refreshKey?: number; onSessionDeleted?: (sessionId: string) => void; selectedCwd?: string | null; onCwdChange?: (cwd: string | null, projectRoot?: string | null) => void; onOpenFile?: (filePath: string, fileName: string, options?: { sourceSessionId?: string | null; modeHint?: "diff" }) => void; explorerRefreshKey?: number; onExplorerRefresh?: () => void; onAtMention?: (relativePath: string, isDir: boolean) => void; onAtMentions?: (relativePaths: string[]) => void; /** Fired when a session that is not currently selected finishes running. * Lets the app play a cross-workspace completion tone. */ onBackgroundTaskDone?: () => void; onRunningSessionIdsChange?: (ids: Set) => void; } interface WorktreeEntry { path: string; branch: string | null; isMain: boolean; } interface WorktreeState { /** The cwd this data was fetched for — guards against stale responses */ forCwd: string; projectRoot: string; isGit: boolean; /** False when forCwd is a repo subdirectory — the switcher is hidden there * because subdir sessions keep their own project identity */ isTopLevel: boolean; /** Canonical path of the checkout containing forCwd, resolved server-side. */ currentWorktreePath: string | null; worktrees: WorktreeEntry[]; } const UNREAD_SESSIONS_STORAGE_KEY = "pi-web:unread-session-ids"; const RUNNING_SESSIONS_POLL_MS = 2500; function loadUnreadSessionIds(): Set { if (typeof window === "undefined") return new Set(); try { const raw = window.localStorage.getItem(UNREAD_SESSIONS_STORAGE_KEY); if (!raw) return new Set(); const parsed = JSON.parse(raw) as unknown; if (Array.isArray(parsed)) return new Set(parsed.filter((id): id is string => typeof id === "string")); return new Set(); } catch { return new Set(); } } function saveUnreadSessionIds(ids: Set): void { if (typeof window === "undefined") return; try { if (ids.size === 0) window.localStorage.removeItem(UNREAD_SESSIONS_STORAGE_KEY); else window.localStorage.setItem(UNREAD_SESSIONS_STORAGE_KEY, JSON.stringify([...ids])); } catch { // ignore storage quota / privacy-mode errors } } function formatRelativeTime(dateStr: string): string { const date = new Date(dateStr); const now = new Date(); const diff = now.getTime() - date.getTime(); const mins = Math.floor(diff / 60000); const hours = Math.floor(diff / 3600000); const days = Math.floor(diff / 86400000); if (mins < 1) return "just now"; if (mins < 60) return `${mins}m ago`; if (hours < 24) return `${hours}h ago`; if (days < 7) return `${days}d ago`; return date.toLocaleDateString(); } /** * Return all projects (deduped by projectRoot so worktrees collapse into their * main repo) sorted by most recent session activity. */ function getRecentProjects(sessions: SessionInfo[]): string[] { const latestByRoot = new Map(); // projectRoot -> most recent modified for (const s of sessions) { const root = s.projectRoot ?? s.cwd; if (!root) continue; const prev = latestByRoot.get(root); if (!prev || s.modified > prev) { latestByRoot.set(root, s.modified); } } return [...latestByRoot.entries()] .sort((a, b) => b[1].localeCompare(a[1])) .map(([root]) => root); } /** Substitute the home dir prefix with ~ (no path truncation — see PathLabel) */ function displayCwd(cwd: string, homeDir?: string): string { return (homeDir && cwd.startsWith(homeDir)) ? "~" + cwd.slice(homeDir.length) : cwd; } /** * Path label that ellipsizes on the LEFT, keeping the (most relevant) trailing * segments visible: "…orkspace/pi-web". Shows as much of the path as fits * instead of a fixed number of segments. The rtl container moves the ellipsis * to the left edge; the inner plaintext bidi isolation keeps the path itself * rendered strictly left-to-right (no punctuation reordering). */ function PathLabel({ text, style }: { text: string; style?: CSSProperties }) { return ( {text} ); } const DROPDOWN_ANIMATION_MS = 140; function AnimatedDropdown({ open, children, style }: { open: boolean; children: ReactNode; style: CSSProperties }) { const [mounted, setMounted] = useState(open); const [visible, setVisible] = useState(open); useEffect(() => { let frame: number | undefined; let timeout: ReturnType | undefined; if (open) { setMounted(true); setVisible(false); frame = window.requestAnimationFrame(() => { frame = window.requestAnimationFrame(() => setVisible(true)); }); } else { setVisible(false); timeout = setTimeout(() => setMounted(false), DROPDOWN_ANIMATION_MS); } return () => { if (frame !== undefined) window.cancelAnimationFrame(frame); if (timeout) clearTimeout(timeout); }; }, [open]); if (!mounted) return null; return (
{children}
); } interface SessionTreeNode { session: SessionInfo; children: SessionTreeNode[]; } function buildSessionTree(sessions: SessionInfo[]): SessionTreeNode[] { const byId = new Map(); for (const s of sessions) { byId.set(s.id, { session: s, children: [] }); } // Build a map of parentSessionId chains so we can resolve missing ancestors const parentOf = new Map(); for (const s of sessions) { if (s.parentSessionId) parentOf.set(s.id, s.parentSessionId); } // Walk up the parentSessionId chain to find the nearest ancestor that exists in byId function resolveAncestor(id: string): string | null { let cur = parentOf.get(id); const visited = new Set(); while (cur) { if (visited.has(cur)) return null; // cycle guard visited.add(cur); if (byId.has(cur)) return cur; cur = parentOf.get(cur); } return null; } const roots: SessionTreeNode[] = []; for (const node of byId.values()) { const ancestor = resolveAncestor(node.session.id); if (ancestor) { byId.get(ancestor)!.children.push(node); } else { roots.push(node); } } // Sort each level by modified desc const sort = (nodes: SessionTreeNode[]) => { nodes.sort((a, b) => b.session.modified.localeCompare(a.session.modified)); nodes.forEach((n) => sort(n.children)); }; sort(roots); return roots; } const SCRAMBLE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*"; function useScramble(target: string, running: boolean): string { const [display, setDisplay] = useState(target); const frameRef = useRef(null); const iterRef = useRef(0); useEffect(() => { if (!running) { setDisplay(target); return; } iterRef.current = 0; const totalFrames = target.length * 4; const step = () => { iterRef.current += 1; const progress = iterRef.current / totalFrames; const resolved = Math.floor(progress * target.length); setDisplay( target .split("") .map((char, i) => { if (char === " ") return " "; if (i < resolved) return char; return SCRAMBLE_CHARS[Math.floor(Math.random() * SCRAMBLE_CHARS.length)]; }) .join("") ); if (iterRef.current < totalFrames) { frameRef.current = requestAnimationFrame(step); } else { setDisplay(target); } }; frameRef.current = requestAnimationFrame(step); return () => { if (frameRef.current) cancelAnimationFrame(frameRef.current); }; }, [target, running]); return display; } function PiWebTitle() { const [showVersion, setShowVersion] = useState(false); const [scrambling, setScrambling] = useState(false); const revertTimerRef = useRef | null>(null); const target = showVersion ? `${process.env.NEXT_PUBLIC_APP_VERSION ?? "0.0.0"}p${process.env.NEXT_PUBLIC_PI_VERSION ?? "0.0.0"}` : "Pi Web"; const display = useScramble(target, scrambling); const triggerScramble = useCallback((toVersion: boolean) => { setShowVersion(toVersion); setScrambling(true); setTimeout(() => setScrambling(false), (toVersion ? 6 : 8) * 4 * (1000 / 60) + 100); }, []); const handleClick = useCallback(() => { if (revertTimerRef.current) clearTimeout(revertTimerRef.current); const next = !showVersion; triggerScramble(next); if (next) { revertTimerRef.current = setTimeout(() => triggerScramble(false), 3000); } }, [showVersion, triggerScramble]); useEffect(() => () => { if (revertTimerRef.current) clearTimeout(revertTimerRef.current); }, []); return ( ); } export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSession, initialSessionId, skipInitialProjectSelection, onInitialRestoreDone, refreshKey, onSessionDeleted, selectedCwd: selectedCwdProp, onCwdChange, onOpenFile, explorerRefreshKey, onExplorerRefresh, onAtMention, onAtMentions, onBackgroundTaskDone, onRunningSessionIdsChange }: Props) { const { t } = useI18n(); const [allSessions, setAllSessions] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedCwd, setSelectedCwd] = useState(null); const [homeDir, setHomeDir] = useState(""); const [dropdownOpen, setDropdownOpen] = useState(false); const [projectFilter, setProjectFilter] = useState(""); const [wtFilter, setWtFilter] = useState(""); const [customPathOpen, setCustomPathOpen] = useState(false); const [customPathValue, setCustomPathValue] = useState(""); const [customPathError, setCustomPathError] = useState(null); const [customPathValidating, setCustomPathValidating] = useState(false); const dropdownRef = useRef(null); // Worktree switcher state const [worktreeState, setWorktreeState] = useState(null); const [wtDropdownOpen, setWtDropdownOpen] = useState(false); const [wtNewOpen, setWtNewOpen] = useState(false); const [wtNewBranch, setWtNewBranch] = useState(""); const [wtError, setWtError] = useState(null); const [wtBusy, setWtBusy] = useState(false); const [wtConfirmRemove, setWtConfirmRemove] = useState(null); const [worktreeLoadingCwd, setWorktreeLoadingCwd] = useState(null); const wtDropdownRef = useRef(null); const wtNewInputRef = useRef(null); const [explorerOpen, setExplorerOpen] = useState(true); const [explorerKey, setExplorerKey] = useState(0); const [explorerUploadBusy, setExplorerUploadBusy] = useState(false); const [changesCount, setChangesCount] = useState(0); const [changesCollapsed, setChangesCollapsed] = useState(true); const [sessionRefreshDone, setSessionRefreshDone] = useState(false); const [explorerRefreshDone, setExplorerRefreshDone] = useState(false); const [runningSessionIds, setRunningSessionIds] = useState>(() => new Set()); const [unreadSessionIds, setUnreadSessionIds] = useState>(() => loadUnreadSessionIds()); const previousRunningSessionIdsRef = useRef>(new Set()); // Once polling has delivered a snapshot it is the source of truth for // running state; late /api/sessions responses must not overwrite it. const runningPollAuthoritativeRef = useRef(false); const sessionRefreshTimerRef = useRef | null>(null); const explorerRefreshTimerRef = useRef | null>(null); const fileExplorerRef = useRef(null); const loadSessions = useCallback(async (showLoading = false, force = false) => { try { if (showLoading) setLoading(true); const res = await fetch(force ? "/api/sessions?force=1" : "/api/sessions", { cache: "no-store", }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json() as { sessions: SessionInfo[]; runningSessionIds?: string[] }; setAllSessions(data.sessions); // Treat the fetched running set as an initial fallback only. Once the // lightweight poll is live, a slow session-list fetch cannot overwrite it. if (!runningPollAuthoritativeRef.current) { setRunningSessionIds(new Set(data.runningSessionIds ?? [])); } // Drop unread markers for sessions that no longer exist (e.g. deleted). const existingIds = new Set(data.sessions.map((s) => s.id)); setUnreadSessionIds((prev) => { if (prev.size === 0) return prev; const next = new Set([...prev].filter((id) => existingIds.has(id))); return next.size === prev.size ? prev : next; }); setError(null); if (!showLoading) { setSessionRefreshDone(true); if (sessionRefreshTimerRef.current) clearTimeout(sessionRefreshTimerRef.current); sessionRefreshTimerRef.current = setTimeout(() => setSessionRefreshDone(false), 2000); } } catch (e) { setError(String(e)); } finally { if (showLoading) setLoading(false); } }, []); const initialLoadDone = useRef(false); useEffect(() => { const isFirst = !initialLoadDone.current; initialLoadDone.current = true; loadSessions(isFirst, !isFirst); }, [loadSessions, refreshKey]); // Browser storage is unavailable during server rendering. Restore the panel // preference after hydration so a collapsed explorer stays collapsed on reload. useEffect(() => { setExplorerOpen(loadExplorerOpen()); }, []); // Persist unread markers so they survive a browser refresh before the user // has actually opened the completed session. useEffect(() => { saveUnreadSessionIds(unreadSessionIds); }, [unreadSessionIds]); useEffect(() => { let stopped = false; let timer: ReturnType | null = null; let controller: AbortController | null = null; const clearTimer = () => { if (timer) clearTimeout(timer); timer = null; }; const schedule = () => { clearTimer(); if (stopped || document.visibilityState !== "visible") return; timer = setTimeout(() => void poll(), RUNNING_SESSIONS_POLL_MS); }; const poll = async () => { if (stopped || document.visibilityState !== "visible") return; const current = new AbortController(); controller?.abort(); controller = current; try { const res = await fetch("/api/agent/running", { cache: "no-store", signal: current.signal, }); if (!res.ok) return; const data = await res.json() as { runningSessionIds?: string[] }; if (stopped || controller !== current) return; runningPollAuthoritativeRef.current = true; setRunningSessionIds(new Set(data.runningSessionIds ?? [])); } catch { // Keep the last known state; the next visible-tab poll retries. } finally { if (controller === current) controller = null; schedule(); } }; const onVisibilityChange = () => { if (document.visibilityState === "visible") { void poll(); return; } clearTimer(); controller?.abort(); controller = null; }; void poll(); document.addEventListener("visibilitychange", onVisibilityChange); return () => { stopped = true; clearTimer(); controller?.abort(); document.removeEventListener("visibilitychange", onVisibilityChange); }; }, []); useEffect(() => { onRunningSessionIdsChange?.(runningSessionIds); }, [onRunningSessionIdsChange, runningSessionIds]); useEffect(() => { const previous = previousRunningSessionIdsRef.current; const completedInBackground = [...previous].filter((id) => !runningSessionIds.has(id) && id !== selectedSessionId); const newlyRunning = [...runningSessionIds].filter((id) => !previous.has(id)); if (completedInBackground.length > 0 || newlyRunning.length > 0) { setUnreadSessionIds((prev) => { const next = new Set(prev); runningSessionIds.forEach((id) => next.delete(id)); completedInBackground.forEach((id) => next.add(id)); return next; }); } const hasUnlistedRunningSession = newlyRunning.some( (id) => !allSessions.some((session) => session.id === id), ); if (completedInBackground.length > 0 || hasUnlistedRunningSession) { loadSessions(false, true); } if (completedInBackground.length > 0) { onBackgroundTaskDone?.(); } previousRunningSessionIdsRef.current = runningSessionIds; }, [runningSessionIds, selectedSessionId, allSessions, loadSessions, onBackgroundTaskDone]); useEffect(() => { if (!selectedSessionId) return; setUnreadSessionIds((prev) => { if (!prev.has(selectedSessionId)) return prev; const next = new Set(prev); next.delete(selectedSessionId); return next; }); }, [selectedSessionId]); useEffect(() => { if (explorerRefreshKey !== undefined) setExplorerKey((k) => k + 1); }, [explorerRefreshKey]); useEffect(() => { fetch("/api/home").then((r) => r.json()).then((d: { home?: string }) => { if (d.home) setHomeDir(d.home); }).catch(() => {}); }, []); const restoredRef = useRef(false); /** Resolve the project root for a cwd from the freshest data available */ const projectRootFor = useCallback((cwd: string | null): string | null => { if (!cwd) return null; if (worktreeState && worktreeState.forCwd === cwd) return worktreeState.projectRoot; // Any path in the loaded worktree list belongs to that project — covers // worktrees without sessions, so switching to them keeps the row mounted. if (worktreeState?.worktrees.some((w) => w.path === cwd)) return worktreeState.projectRoot; const match = allSessions.find((s) => s.cwd === cwd); return match?.projectRoot ?? cwd; }, [worktreeState, allSessions]); // Notify parent only when the effective cwd actually changes (not when // projectRootFor identity changes due to session/worktree refreshes). const lastNotifiedCwdRef = useRef(null); useEffect(() => { if (lastNotifiedCwdRef.current === selectedCwd) return; lastNotifiedCwdRef.current = selectedCwd; onCwdChange?.(selectedCwd, projectRootFor(selectedCwd)); }, [selectedCwd, onCwdChange, projectRootFor]); // Sync the worktree switcher to the selected session's cwd. Sessions of all // worktrees in a project share one list, so clicking a session from another // worktree should move the effective cwd there. Only fires when the prop // value changes, so a manual switcher change is not snapped back. const lastSyncedCwdPropRef = useRef(null); useEffect(() => { if (selectedCwdProp && selectedCwdProp !== lastSyncedCwdPropRef.current) { lastSyncedCwdPropRef.current = selectedCwdProp; setSelectedCwd(selectedCwdProp); } }, [selectedCwdProp]); // Load worktrees for the current effective cwd const [wtRefreshKey, setWtRefreshKey] = useState(0); useLayoutEffect(() => { if (!selectedCwd) { setWorktreeState(null); setWorktreeLoadingCwd(null); return; } let cancelled = false; setWorktreeLoadingCwd(selectedCwd); fetch(`/api/worktrees?cwd=${encodeURIComponent(selectedCwd)}`) .then((r) => r.json()) .then((d: { projectRoot?: string; isGit?: boolean; isTopLevel?: boolean; currentWorktreePath?: string | null; worktrees?: WorktreeEntry[]; error?: string }) => { if (cancelled) return; setWorktreeLoadingCwd(null); if (d.error || !d.projectRoot) { setWorktreeState(null); return; } setWorktreeState({ forCwd: selectedCwd, projectRoot: d.projectRoot, isGit: d.isGit ?? false, isTopLevel: d.isTopLevel ?? false, currentWorktreePath: d.currentWorktreePath ?? null, worktrees: d.worktrees ?? [], }); }) .catch(() => { if (!cancelled) { setWorktreeLoadingCwd(null); setWorktreeState(null); } }); return () => { cancelled = true; }; }, [selectedCwd, wtRefreshKey, refreshKey]); // Auto-select cwd and restore session from URL on first load useEffect(() => { if (allSessions.length === 0 || skipInitialProjectSelection) return; if (selectedCwd === null) { // If restoring a session, set cwd to match that session if (initialSessionId && !restoredRef.current) { restoredRef.current = true; const target = allSessions.find((s) => s.id === initialSessionId); if (target) { setSelectedCwd(target.cwd); onSelectSession(target, true); return; } // Session not found — notify parent so it can show the placeholder onInitialRestoreDone?.(); } const projects = getRecentProjects(allSessions); if (projects.length > 0) setSelectedCwd(projects[0]); } }, [allSessions, selectedCwd, initialSessionId, skipInitialProjectSelection, onSelectSession, onInitialRestoreDone]); // Prefer an exact UI selection while a refetch is in flight. Once the // response catches up, the server-resolved path handles Windows case and // separator differences without teaching the browser OS path semantics. const currentWorktree = worktreeState ? worktreeState.worktrees.find((worktree) => worktree.path === selectedCwd) ?? (worktreeState.forCwd === selectedCwd && worktreeState.currentWorktreePath ? worktreeState.worktrees.find((worktree) => worktree.path === worktreeState.currentWorktreePath) : undefined) ?? worktreeState.worktrees.find((worktree) => worktree.isMain) : undefined; const currentWorktreePath = currentWorktree?.path ?? null; const commitCustomPath = useCallback(async (candidate?: string) => { const path = (candidate ?? customPathValue).trim(); if (!path || customPathValidating) return; setCustomPathValidating(true); setCustomPathError(null); try { const res = await fetch("/api/cwd/validate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ cwd: path }), }); const data = await res.json().catch(() => ({})) as { cwd?: string; error?: string }; if (!res.ok || data.error) { setCustomPathError(data.error ?? `HTTP ${res.status}`); return; } setSelectedCwd(data.cwd ?? path); setCustomPathOpen(false); setCustomPathValue(""); setDropdownOpen(false); } catch (e) { setCustomPathError(e instanceof Error ? e.message : String(e)); } finally { setCustomPathValidating(false); } }, [customPathValue, customPathValidating]); const handleCustomPathClick = useCallback(() => { setCustomPathOpen(true); setCustomPathError(null); setDropdownOpen(false); }, []); const handleDefaultCwd = useCallback(async () => { try { const res = await fetch("/api/default-cwd", { method: "POST" }); const data = await res.json() as { cwd?: string; error?: string }; if (data.cwd) { setSelectedCwd(data.cwd); setCustomPathOpen(false); setCustomPathValue(""); setCustomPathError(null); setDropdownOpen(false); } } catch { // ignore } }, []); const handleCreateWorktree = useCallback(async () => { const branch = wtNewBranch.trim(); if (!branch || wtBusy || !worktreeState) return; setWtBusy(true); setWtError(null); try { const res = await fetch("/api/worktrees", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ cwd: worktreeState.projectRoot, branch }), }); const data = await res.json().catch(() => ({})) as { path?: string; error?: string }; if (!res.ok || data.error || !data.path) { setWtError(data.error ?? `HTTP ${res.status}`); return; } setWtNewOpen(false); setWtNewBranch(""); setWtDropdownOpen(false); // Optimistically register the new worktree so projectRootFor() resolves // it to the main repo before the refetch lands (keeps AppShell from // treating the new cwd as a different project). setWorktreeState((prev) => prev ? { ...prev, forCwd: data.path!, currentWorktreePath: data.path!, worktrees: [...prev.worktrees, { path: data.path!, branch, isMain: false }], } : prev); setSelectedCwd(data.path); setWtRefreshKey((k) => k + 1); } catch (e) { setWtError(e instanceof Error ? e.message : String(e)); } finally { setWtBusy(false); } }, [wtNewBranch, wtBusy, worktreeState]); const handleRemoveWorktree = useCallback(async (path: string, force: boolean) => { if (!worktreeState || wtBusy) return; setWtBusy(true); setWtError(null); try { const res = await fetch("/api/worktrees", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ cwd: worktreeState.projectRoot, path, force }), }); const data = await res.json().catch(() => ({})) as { error?: string; dirty?: boolean }; if (!res.ok) { if (data.dirty && !force) { // Dirty worktree — ask the user to confirm a force removal setWtConfirmRemove(path); return; } setWtError(data.error ?? `HTTP ${res.status}`); return; } setWtConfirmRemove(null); if (currentWorktreePath === path) setSelectedCwd(worktreeState.projectRoot); setWtRefreshKey((k) => k + 1); } catch (e) { setWtError(e instanceof Error ? e.message : String(e)); } finally { setWtBusy(false); } }, [worktreeState, wtBusy, currentWorktreePath]); // Close dropdowns on outside click useEffect(() => { const handler = (e: MouseEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { setDropdownOpen(false); setProjectFilter(""); } if (wtDropdownRef.current && !wtDropdownRef.current.contains(e.target as Node)) { setWtDropdownOpen(false); setWtNewOpen(false); setWtNewBranch(""); setWtError(null); setWtConfirmRemove(null); setWtFilter(""); } }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, []); // Clicking a session moves the effective cwd to that session's worktree. // Done on the click path (not via the selectedCwd prop sync) so it also // works when the prop value won't change — e.g. re-clicking the already // open session after manually switching worktrees. const handleSelectSessionFromList = useCallback((s: SessionInfo) => { if (s.cwd) setSelectedCwd(s.cwd); onSelectSession(s); }, [onSelectSession]); const handleNewSession = useCallback(() => { if (!selectedCwd) return; // Generate a temporary UUID client-side — no backend call needed. // Pi will be spawned lazily when the user sends the first message. const tempId = typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`; onNewSession?.(tempId, selectedCwd); }, [selectedCwd, onNewSession]); const recentProjects = getRecentProjects(allSessions); const showProjectFilter = recentProjects.length > 8; const visibleProjects = projectFilter.trim() ? recentProjects.filter((p) => p.toLowerCase().includes(projectFilter.trim().toLowerCase())) : recentProjects; // Sessions of every worktree in the selected project are shown together const selectedProject = projectRootFor(selectedCwd); // Per-project activity counts (running / unread) for the workspace selector. // Keyed the same way as getRecentProjects (projectRoot ?? cwd) so the counts // line up with each dropdown item. Small data set — cheap to recompute. const projectActivity = useMemo(() => { const counts = new Map(); for (const s of allSessions) { const key = s.projectRoot ?? s.cwd; if (!key) continue; let entry = counts.get(key); if (!entry) { entry = { running: 0, unread: 0 }; counts.set(key, entry); } if (runningSessionIds.has(s.id)) entry.running++; if (unreadSessionIds.has(s.id)) entry.unread++; } return counts; }, [allSessions, runningSessionIds, unreadSessionIds]); // Any activity in a project other than the one currently selected — shown as // a dot on the (collapsed) selector button so it is visible without opening // the dropdown. const hasOtherWorkspaceActivity = useMemo( () => [...projectActivity.entries()].some( ([key, { running, unread }]) => key !== selectedProject && (running > 0 || unread > 0), ), [projectActivity, selectedProject], ); const filteredSessions = selectedProject ? allSessions.filter((s) => (s.projectRoot ?? s.cwd) === selectedProject) : allSessions; const showWorktreeSwitcher = Boolean( worktreeState?.isGit && worktreeState.isTopLevel && selectedCwd && selectedProject === worktreeState.projectRoot ); const worktreeGuide = selectedCwd && worktreeState && selectedProject === worktreeState.projectRoot && !showWorktreeSwitcher ? (worktreeState.isGit ? { label: t("sidebar.openRepoRoot"), title: t("sidebar.openRepoRootTitle"), } : { label: t("sidebar.gitRepoRootOnly"), title: t("sidebar.gitRepoRootOnlyTitle"), }) : null; const worktreeLoading = Boolean(selectedCwd && worktreeLoadingCwd === selectedCwd); const inactiveWorktreeSelector = worktreeGuide ?? (worktreeLoading && !showWorktreeSwitcher ? { label: t("sidebar.worktrees"), title: t("sidebar.checkingWorktrees"), } : null); // Build parent-child tree within the filtered set const sessionTree = buildSessionTree(filteredSessions); return (
{customPathOpen && ( { setCustomPathOpen(false); setCustomPathError(null); }} onSelect={(path) => void commitCustomPath(path)} /> )} {/* Header */}
{/* CWD picker */}
{showProjectFilter && (
setProjectFilter(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") { setProjectFilter(""); setDropdownOpen(false); } }} placeholder={t("sidebar.filterProjects")} autoFocus style={{ width: "100%", fontSize: 11, fontFamily: "var(--font-mono)", padding: "5px 8px", border: "1px solid var(--border)", borderRadius: 5, outline: "none", background: "var(--bg)", color: "var(--text)", boxSizing: "border-box", }} />
)}
{visibleProjects.map((project) => ( ))} {visibleProjects.length === 0 && projectFilter.trim() && (
{t("sidebar.noMatchingProjects")}
)}
{/* Default cwd shortcut */} {!customPathOpen && ( )} {/* Custom path directory picker */}
{/* Worktree switcher — shown only for git projects at a checkout top level (repo subdirs keep their own project identity, so switching from them would jump projects). Rendered whenever the selected cwd belongs to the loaded project (not just when forCwd matches), so switching between worktrees of one project keeps the row mounted instead of flickering while data refetches: all worktrees of a project share the same list anyway. */} {showWorktreeSwitcher && (() => { if (!worktreeState) return null; const showWtFilter = worktreeState.worktrees.length >= 8; const visibleWorktrees = showWtFilter && wtFilter.trim() ? worktreeState.worktrees.filter((w) => (w.branch ?? displayCwd(w.path, homeDir)).toLowerCase().includes(wtFilter.trim().toLowerCase())) : worktreeState.worktrees; return (
{showWtFilter && (
setWtFilter(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") { setWtFilter(""); setWtDropdownOpen(false); } }} placeholder={t("sidebar.filterWorktrees")} autoFocus style={{ width: "100%", fontSize: 11, fontFamily: "var(--font-mono)", padding: "5px 8px", border: "1px solid var(--border)", borderRadius: 5, outline: "none", background: "var(--bg)", color: "var(--text)", boxSizing: "border-box", }} />
)}
{visibleWorktrees.map((wt) => { const isCurrent = wt.path === currentWorktreePath; if (wtConfirmRemove === wt.path) { return (
{t("sidebar.forceRemoveCheckout")}
); } return (
{!wt.isMain && ( )}
); })} {showWtFilter && visibleWorktrees.length === 0 && wtFilter.trim() && (
{t("sidebar.noMatchingWorktrees")}
)}
{!wtNewOpen ? ( ) : (
{ setWtNewBranch(e.target.value); setWtError(null); }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void handleCreateWorktree(); } if (e.key === "Escape") { setWtNewOpen(false); setWtNewBranch(""); setWtError(null); } }} placeholder={t("sidebar.branchName")} style={{ width: "100%", fontSize: 11, fontFamily: "var(--font-mono)", padding: "5px 8px", border: "1px solid var(--accent)", borderRadius: 5, outline: "none", background: "var(--bg)", color: "var(--text)", boxSizing: "border-box", }} />
)} {wtError && (
{wtError}
)}
); })()} {inactiveWorktreeSelector && ( )}
{/* Session list */}
{loading && (
{t("sidebar.loading")}
)} {error && (
{error}
)} {!loading && !error && filteredSessions.length === 0 && (
{t("sidebar.noSessions")}
)} {sessionTree.map((node) => ( { onSessionDeleted?.(id); loadSessions(); }} depth={0} /> ))}
{/* File Explorer section */} {(selectedCwdProp || selectedCwd) && (
{explorerOpen && changesCount > 0 && ( setChangesCollapsed((v) => !v)} title={t("sidebar.changedFiles", { count: changesCount })} ariaPressed={!changesCollapsed} color={changesCollapsed ? "var(--text-dim)" : "var(--accent)"} background={changesCollapsed ? "none" : "var(--bg-selected)"} > )} {explorerOpen && ( fileExplorerRef.current?.openUploadPicker()} disabled={explorerUploadBusy} title={t("sidebar.uploadFilesTitle")} color="var(--text-dim)" > )} { if (onExplorerRefresh) onExplorerRefresh(); else setExplorerKey((k) => k + 1); setExplorerRefreshDone(true); if (explorerRefreshTimerRef.current) clearTimeout(explorerRefreshTimerRef.current); explorerRefreshTimerRef.current = setTimeout(() => setExplorerRefreshDone(false), 2000); }} title={t("sidebar.refreshExplorer")} skipHover={explorerRefreshDone} color={explorerRefreshDone ? "#4ade80" : "var(--text-dim)"} background={explorerRefreshDone ? "rgba(74,222,128,0.18)" : "none"} marginRight={6} > {explorerRefreshDone ? ( ) : ( )}
{explorerOpen && (
{})} refreshKey={explorerKey} onAtMention={onAtMention} onAtMentions={onAtMentions} onUploadBusyChange={setExplorerUploadBusy} changesCollapsed={changesCollapsed} onChangesCountChange={setChangesCount} />
)}
)}
); } function SessionTreeItem({ node, selectedSessionId, runningSessionIds, unreadSessionIds, onSelectSession, onRenamed, onSessionDeleted, depth, }: { node: SessionTreeNode; selectedSessionId: string | null; runningSessionIds: Set; unreadSessionIds: Set; onSelectSession: (s: SessionInfo) => void; onRenamed?: () => void; onSessionDeleted?: (id: string) => void; depth: number; }) { const [collapsed, setCollapsed] = useState(false); const hasChildren = node.children.length > 0; return (
{/* Indent line for child sessions */} {depth > 0 && (
)} onSelectSession(node.session)} onRenamed={onRenamed} onDeleted={(id) => onSessionDeleted?.(id)} depth={depth} hasChildren={hasChildren} collapsed={collapsed} onToggleCollapse={() => setCollapsed((v) => !v)} />
{hasChildren && !collapsed && (
{node.children.map((child) => ( ))}
)}
); } function RunningSessionIndicator() { const { t } = useI18n(); return ( ); } function UnreadSessionIndicator() { const { t } = useI18n(); return ( ); } /** * Compact per-project activity badges for the workspace selector dropdown items: * a spinning running icon + count and an unread dot + count. Renders nothing * when the project has no activity. Counts share the accent / unread colors of * the per-session indicators so the two stay visually consistent. */ function showProjectActivity( activity: { running: number; unread: number } | undefined, t: (key: string) => string, ): ReactNode { if (!activity || (activity.running === 0 && activity.unread === 0)) return null; return ( {activity.running > 0 && ( {activity.running} )} {activity.unread > 0 && ( {activity.unread} )} ); } function SessionItem({ session, isSelected, isRunning, isUnread, onClick, onRenamed, onDeleted, depth = 0, hasChildren = false, collapsed = false, onToggleCollapse, }: { session: SessionInfo; isSelected: boolean; isRunning?: boolean; isUnread?: boolean; onClick: () => void; onRenamed?: () => void; onDeleted?: (id: string) => void; depth?: number; hasChildren?: boolean; collapsed?: boolean; onToggleCollapse?: () => void; }) { const { t } = useI18n(); const [hovered, setHovered] = useState(false); const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(""); const [confirmDelete, setConfirmDelete] = useState(false); const [deleting, setDeleting] = useState(false); const inputRef = useRef(null); // Select the whole name once the rename input is mounted (startRename's // immediate setTimeout can fire before the input exists). useEffect(() => { if (renaming) { const id = requestAnimationFrame(() => inputRef.current?.select()); return () => cancelAnimationFrame(id); } }, [renaming]); // A stored first message may be an SDK-expanded block; collapse it // back to the compact /skill:name args command the user typed before using // it as the auto-name fallback, mirroring MessageView's rendering. const displayFirstMessage = skillExpansionToCommand(session.firstMessage) ?? session.firstMessage; const title = session.name || displayFirstMessage.slice(0, 50) || session.id.slice(0, 12); const startRename = useCallback((e: React.MouseEvent) => { e.stopPropagation(); if (session.transient) return; setRenameValue(session.name || displayFirstMessage.slice(0, 50) || session.id.slice(0, 12)); setRenaming(true); }, [session.name, session.transient, displayFirstMessage, session.id]); const commitRename = useCallback(async () => { const name = renameValue.trim(); setRenaming(false); // No-op when unchanged: the fallback title (first message / id) isn't a // real stored name, so don't persist it as one. (The rename input seeds // from the same collapsed displayFirstMessage, so an untouched rename of // a skill-invoked session stays a no-op instead of persisting raw XML.) if (renameValue === title || name === (session.name ?? "")) return; try { await fetch(`/api/sessions/${encodeURIComponent(session.id)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }), }); onRenamed?.(); } catch { // ignore } }, [renameValue, session.id, session.name, onRenamed, title]); const performDelete = useCallback(async () => { if (session.transient) return; setConfirmDelete(false); setDeleting(true); try { await fetch(`/api/sessions/${encodeURIComponent(session.id)}`, { method: "DELETE" }); onDeleted?.(session.id); } catch { setDeleting(false); } }, [session.id, session.transient, onDeleted]); const handleDeleteClick = useCallback((e: React.MouseEvent) => { e.stopPropagation(); if (e.shiftKey) { void performDelete(); } else { setConfirmDelete(true); } }, [performDelete]); const handleDeleteConfirm = useCallback((e: React.MouseEvent) => { e.stopPropagation(); void performDelete(); }, [performDelete]); const handleDeleteCancel = useCallback((e: React.MouseEvent) => { e.stopPropagation(); setConfirmDelete(false); }, []); const handleContextMenu = useCallback((e: React.MouseEvent) => { const handled = dispatchSessionRowContextMenu({ id: session.id, path: session.path, cwd: session.cwd, name: session.name, clientX: e.clientX, clientY: e.clientY, refresh: () => { onRenamed?.(); }, }); if (!handled) return; e.preventDefault(); e.stopPropagation(); }, [onRenamed, session.cwd, session.id, session.name, session.path]); // Fixed-height outer wrapper — content swaps in place so the list never reflows const ITEM_HEIGHT = 54; return (
setHovered(true)} onMouseLeave={() => { setHovered(false); }} style={{ height: ITEM_HEIGHT, display: "flex", alignItems: "center", paddingLeft: depth > 0 ? depth * 12 + 14 : 14, paddingRight: 8, cursor: confirmDelete || renaming ? "default" : "pointer", background: confirmDelete ? "rgba(239,68,68,0.06)" : isSelected ? "var(--bg-selected)" : hovered ? "var(--bg-hover)" : "transparent", borderLeft: confirmDelete ? "2px solid #ef4444" : isSelected ? "2px solid var(--accent)" : "2px solid transparent", transition: "background 0.1s", opacity: deleting ? 0.5 : 1, gap: 6, overflow: "hidden", }} > {confirmDelete ? ( /* ── Delete confirmation: same height, two flat buttons ── */ <>
{t("sidebar.deleteSession", { title: title.slice(0, 22) + (title.length > 22 ? "…" : "") })}
) : renaming ? ( /* ── Rename: input fills the same row ── */ setRenameValue(e.target.value)} onBlur={commitRename} onKeyDown={(e) => { if (e.key === "Enter") commitRename(); if (e.key === "Escape") setRenaming(false); }} autoFocus style={{ flex: 1, fontSize: 12, padding: "5px 8px", border: "1px solid var(--accent)", borderRadius: 5, outline: "none", background: "var(--bg)", color: "var(--text)", height: 30, }} /> ) : ( /* ── Normal view ── */ <> {/* Fork indicator for child sessions */} {depth > 0 && ( )}
{title}
{isRunning ? ( ) : isUnread ? ( ) : ( {formatRelativeTime(session.modified)} )} {t("sidebar.messagesCount", { count: session.messageCount })} {session.worktreeBranch && ( {session.worktreeBranch} )}
{/* Collapse toggle — always visible when has children */} {hasChildren && ( )} {/* Action buttons — shown on hover */} {hovered && !session.transient && (
)} )}
); }