Files
pi-agent-integrated/pi-web/components/SessionSidebar.tsx
T

2082 lines
81 KiB
TypeScript

"use client";
import { useEffect, useLayoutEffect, useState, useCallback, useRef, type CSSProperties, type ReactNode } from "react";
import type { SessionInfo } from "@/lib/types";
import { useI18n } from "@/hooks/useI18n";
import { DirectoryPicker } from "./DirectoryPicker";
import { FileExplorer, type FileExplorerHandle } from "./FileExplorer";
declare global {
interface Window {
piDesktop?: {
selectDirectory: () => Promise<string | null>;
};
}
}
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<HTMLButtonElement>) => {
if (disabled || skipHover) return;
e.currentTarget.style.color = "var(--text-muted)";
e.currentTarget.style.background = "var(--bg-hover)";
};
const leave = (e: React.MouseEvent<HTMLButtonElement>) => {
if (disabled || skipHover) return;
e.currentTarget.style.color = color;
e.currentTarget.style.background = background;
};
return (
<button
onClick={onClick}
disabled={disabled}
title={title}
aria-label={title}
aria-pressed={ariaPressed}
style={{
position: "relative",
display: "flex", alignItems: "center", justifyContent: "center",
width: 26, height: 26, padding: 0, marginRight,
background,
border: "none",
color,
cursor: disabled ? "default" : "pointer",
borderRadius: 5,
flexShrink: 0,
opacity: disabled ? 0.6 : 1,
transition: "color 0.3s, background 0.3s",
}}
onMouseEnter={enter}
onMouseLeave={leave}
>
{children}
</button>
);
}
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;
}
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;
worktrees: WorktreeEntry[];
}
const UNREAD_SESSIONS_STORAGE_KEY = "pi-web:unread-session-ids";
function loadUnreadSessionIds(): Set<string> {
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<string>): 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<string, string>(); // 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 (
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
display: "block",
minWidth: 0,
lineHeight: 1.35,
direction: "rtl",
textAlign: "left",
...style,
}}
>
<span style={{ unicodeBidi: "plaintext" }}>{text}</span>
</span>
);
}
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<typeof setTimeout> | 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 (
<div
style={{
...style,
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0) scale(1)" : "translateY(-8px) scale(0.96)",
transformOrigin: "top center",
transition: `opacity ${DROPDOWN_ANIMATION_MS}ms ease, transform ${DROPDOWN_ANIMATION_MS}ms ease`,
pointerEvents: open ? "auto" : "none",
}}
>
{children}
</div>
);
}
interface SessionTreeNode {
session: SessionInfo;
children: SessionTreeNode[];
}
function buildSessionTree(sessions: SessionInfo[]): SessionTreeNode[] {
const byId = new Map<string, SessionTreeNode>();
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<string, string>();
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<string>();
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<number | null>(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<ReturnType<typeof setTimeout> | 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 (
<button
onClick={handleClick}
style={{
background: "none", border: "none", padding: 0, cursor: "default",
fontWeight: 700, fontSize: 15, letterSpacing: "-0.01em",
color: showVersion ? "var(--accent)" : "var(--text)",
fontFamily: "var(--font-mono)",
minWidth: "6ch",
}}
>
{display}
</button>
);
}
export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSession, initialSessionId, skipInitialProjectSelection, onInitialRestoreDone, refreshKey, onSessionDeleted, selectedCwd: selectedCwdProp, onCwdChange, onOpenFile, explorerRefreshKey, onExplorerRefresh, onAtMention, onAtMentions }: Props) {
const { t } = useI18n();
const [allSessions, setAllSessions] = useState<SessionInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedCwd, setSelectedCwd] = useState<string | null>(null);
const [homeDir, setHomeDir] = useState<string>("");
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<string | null>(null);
const [customPathValidating, setCustomPathValidating] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Worktree switcher state
const [worktreeState, setWorktreeState] = useState<WorktreeState | null>(null);
const [wtDropdownOpen, setWtDropdownOpen] = useState(false);
const [wtNewOpen, setWtNewOpen] = useState(false);
const [wtNewBranch, setWtNewBranch] = useState("");
const [wtError, setWtError] = useState<string | null>(null);
const [wtBusy, setWtBusy] = useState(false);
const [wtConfirmRemove, setWtConfirmRemove] = useState<string | null>(null);
const [worktreeLoadingCwd, setWorktreeLoadingCwd] = useState<string | null>(null);
const wtDropdownRef = useRef<HTMLDivElement>(null);
const wtNewInputRef = useRef<HTMLInputElement>(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<Set<string>>(() => new Set());
const [unreadSessionIds, setUnreadSessionIds] = useState<Set<string>>(() => loadUnreadSessionIds());
const previousRunningSessionIdsRef = useRef<Set<string>>(new Set());
// Once the SSE stream has delivered a frame it is the source of truth for
// running state; late /api/sessions responses must not overwrite it.
const sseAuthoritativeRef = useRef(false);
const sessionRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const explorerRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fileExplorerRef = useRef<FileExplorerHandle>(null);
const loadSessions = useCallback(async (showLoading = false) => {
try {
if (showLoading) setLoading(true);
const res = await fetch("/api/sessions");
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 SSE is
// live it owns this state, so a slow fetch can't revive a stale snapshot.
if (!sseAuthoritativeRef.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);
}, [loadSessions, refreshKey]);
// Persist unread markers so they survive a browser refresh before the user
// has actually opened the completed session.
useEffect(() => {
saveUnreadSessionIds(unreadSessionIds);
}, [unreadSessionIds]);
useEffect(() => {
// Live running status via SSE — no polling. The server pushes the current
// set of running session ids whenever any session starts/stops working.
const source = new EventSource("/api/agent/running/events");
source.onmessage = (e) => {
try {
const data = JSON.parse(e.data) as { type?: string; runningSessionIds?: string[] };
if (data.type === "running") {
sseAuthoritativeRef.current = true;
setRunningSessionIds(new Set(data.runningSessionIds ?? []));
}
} catch {
// ignore malformed frames
}
};
// On error EventSource auto-reconnects; keep the last known state meanwhile.
return () => source.close();
}, []);
useEffect(() => {
const previous = previousRunningSessionIdsRef.current;
const completedInBackground = [...previous].filter((id) => !runningSessionIds.has(id) && id !== selectedSessionId);
const newlyRunning = [...runningSessionIds];
if (completedInBackground.length > 0 || newlyRunning.length > 0) {
setUnreadSessionIds((prev) => {
const next = new Set(prev);
newlyRunning.forEach((id) => next.delete(id));
completedInBackground.forEach((id) => next.add(id));
return next;
});
}
if (completedInBackground.length > 0) {
loadSessions(false);
}
previousRunningSessionIdsRef.current = runningSessionIds;
}, [runningSessionIds, selectedSessionId, loadSessions]);
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<string | null>(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<string | null>(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; 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,
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]);
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!,
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 (selectedCwd === path) setSelectedCwd(worktreeState.projectRoot);
setWtRefreshKey((k) => k + 1);
} catch (e) {
setWtError(e instanceof Error ? e.message : String(e));
} finally {
setWtBusy(false);
}
}, [worktreeState, wtBusy, selectedCwd]);
// 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);
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 (
<div style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "hidden" }}>
{customPathOpen && (
<DirectoryPicker
busy={customPathValidating}
error={customPathError}
onCancel={() => {
setCustomPathOpen(false);
setCustomPathError(null);
}}
onSelect={(path) => void commitCustomPath(path)}
/>
)}
{/* Header */}
<div
style={{
padding: "12px 10px 10px",
borderBottom: "1px solid var(--border)",
flexShrink: 0,
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 10 }}>
<PiWebTitle />
<div style={{ display: "flex", gap: 6 }}>
<button
onClick={handleNewSession}
disabled={!selectedCwd}
style={{
display: "flex", alignItems: "center", justifyContent: "center", gap: 5,
background: "var(--bg-hover)",
border: "1px solid var(--border)",
color: selectedCwd ? "var(--text-muted)" : "var(--text-dim)",
cursor: selectedCwd ? "pointer" : "not-allowed",
height: 32,
paddingLeft: 10,
paddingRight: 12,
borderRadius: 7,
fontSize: 12,
fontWeight: 500,
letterSpacing: "-0.01em",
flexShrink: 0,
transition: "background 0.12s, color 0.12s, border-color 0.12s",
}}
title={selectedCwd ? t("sidebar.newSessionTitle", { path: selectedCwd }) : t("sidebar.selectProject")}
onMouseEnter={(e) => {
if (!selectedCwd) return;
e.currentTarget.style.background = "var(--bg-selected)";
e.currentTarget.style.color = "var(--accent)";
e.currentTarget.style.borderColor = "rgba(37,99,235,0.35)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "var(--bg-hover)";
e.currentTarget.style.color = selectedCwd ? "var(--text-muted)" : "var(--text-dim)";
e.currentTarget.style.borderColor = "var(--border)";
}}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<line x1="6" y1="1" x2="6" y2="11" />
<line x1="1" y1="6" x2="11" y2="6" />
</svg>
{t("sidebar.new")}
</button>
<button
onClick={() => loadSessions(false)}
style={{
display: "flex", alignItems: "center", justifyContent: "center",
background: sessionRefreshDone ? "rgba(74,222,128,0.18)" : "var(--bg-hover)",
border: `1px solid ${sessionRefreshDone ? "rgba(74,222,128,0.4)" : "var(--border)"}`,
color: sessionRefreshDone ? "#4ade80" : "var(--text-muted)",
cursor: "pointer",
width: 32, height: 32,
borderRadius: 7,
padding: 0,
flexShrink: 0,
transition: "background 0.3s, color 0.3s, border-color 0.3s",
}}
onMouseEnter={(e) => {
if (sessionRefreshDone) return;
e.currentTarget.style.background = "var(--bg-selected)";
e.currentTarget.style.color = "var(--accent)";
e.currentTarget.style.borderColor = "rgba(37,99,235,0.35)";
}}
onMouseLeave={(e) => {
if (sessionRefreshDone) return;
e.currentTarget.style.background = "var(--bg-hover)";
e.currentTarget.style.color = "var(--text-muted)";
e.currentTarget.style.borderColor = "var(--border)";
}}
title={t("sidebar.refresh")}
>
{sessionRefreshDone ? (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#4ade80" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
)}
</button>
</div>
</div>
{/* CWD picker */}
<div ref={dropdownRef} style={{ position: "relative" }}>
<button
onClick={() => setDropdownOpen((v) => !v)}
title={selectedProject ?? selectedCwd ?? ""}
style={{
width: "100%",
display: "flex",
alignItems: "center",
padding: "6px 10px",
background: selectedCwd ? "var(--bg-hover)" : "rgba(37,99,235,0.06)",
border: selectedCwd ? "1px solid var(--border)" : "1px solid rgba(37,99,235,0.4)",
borderRadius: 7,
cursor: "pointer",
fontSize: 12,
color: "var(--text)",
textAlign: "left",
transition: "border-color 0.15s, background 0.15s",
}}
>
{selectedCwd ? (
<PathLabel
text={displayCwd(selectedProject ?? selectedCwd, homeDir)}
style={{
flex: 1,
fontFamily: "var(--font-mono)",
fontSize: 11,
color: "var(--text)",
}}
/>
) : (
<span
style={{
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontFamily: "var(--font-mono)",
fontSize: 11,
color: "var(--text-dim)",
}}
>
{initialSessionId && !restoredRef.current ? "" : t("sidebar.selectProject")}
</span>
)}
</button>
<AnimatedDropdown
open={dropdownOpen}
style={{
position: "absolute",
top: "calc(100% + 4px)",
left: 0,
right: 0,
zIndex: 100,
background: "var(--bg)",
border: "1px solid var(--border)",
borderRadius: 8,
boxShadow: "0 6px 20px rgba(0,0,0,0.10)",
overflow: "hidden",
}}
>
{showProjectFilter && (
<div style={{ padding: "6px 8px", borderBottom: "1px solid var(--border)" }}>
<input
value={projectFilter}
onChange={(e) => 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",
}}
/>
</div>
)}
<div style={{ maxHeight: "min(50vh, 380px)", overflowY: "auto" }}>
{visibleProjects.map((project) => (
<button
key={project}
onClick={() => {
setSelectedCwd(project);
setProjectFilter("");
setCustomPathOpen(false);
setCustomPathValue("");
setCustomPathError(null);
setDropdownOpen(false);
}}
style={{
display: "flex",
alignItems: "center",
gap: 7,
width: "100%",
padding: "8px 10px",
background: "var(--bg)",
border: "none",
borderBottom: "1px solid var(--border)",
color: project === selectedProject ? "var(--text)" : "var(--text-muted)",
cursor: "pointer",
textAlign: "left",
fontSize: 11,
fontFamily: "var(--font-mono)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={project}
>
{project === selectedProject && (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<polyline points="1.5 5 4 7.5 8.5 2.5" />
</svg>
)}
{project !== selectedProject && <span style={{ width: 10, flexShrink: 0 }} />}
<PathLabel text={displayCwd(project, homeDir)} style={{ flex: 1 }} />
</button>
))}
{visibleProjects.length === 0 && projectFilter.trim() && (
<div style={{ padding: "8px 10px", fontSize: 11, color: "var(--text-dim)" }}>{t("sidebar.noMatchingProjects")}</div>
)}
</div>
{/* Default cwd shortcut */}
{!customPathOpen && (
<button
onClick={(e) => { e.stopPropagation(); handleDefaultCwd(); }}
style={{
display: "flex",
alignItems: "center",
gap: 7,
width: "100%",
padding: "8px 10px",
background: "none",
border: "none",
borderTop: visibleProjects.length > 0 ? "1px solid var(--border)" : "none",
color: "var(--text-muted)",
cursor: "pointer",
textAlign: "left",
fontSize: 11,
}}
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.1" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<path d="M1 3A1 1 0 0 1 2 2H4L5 3.5H8.5a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5h-7A.5.5 0 0 1 1 8V3Z" />
</svg>
<span>{t("sidebar.useDefaultDirectory")}</span>
</button>
)}
{/* Custom path directory picker */}
<button
onClick={(e) => {
e.stopPropagation();
handleCustomPathClick();
}}
style={{
display: "flex",
alignItems: "center",
gap: 7,
width: "100%",
padding: "8px 10px",
background: "none",
border: "none",
color: "var(--text-muted)",
cursor: "pointer",
textAlign: "left",
fontSize: 11,
}}
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.1" strokeLinecap="round" style={{ flexShrink: 0 }}>
<line x1="5" y1="1" x2="5" y2="9" />
<line x1="1" y1="5" x2="9" y2="5" />
</svg>
<span>{t("sidebar.customPath")}</span>
</button>
</AnimatedDropdown>
</div>
{/* 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 currentWt = worktreeState.worktrees.find((w) => w.path === selectedCwd)
?? worktreeState.worktrees.find((w) => w.isMain);
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 (
<div ref={wtDropdownRef} style={{ position: "relative", marginTop: 6 }}>
<button
onClick={() => setWtDropdownOpen((v) => !v)}
title={currentWt ? t("sidebar.switchWorktreeTitle", { path: currentWt.path }) : t("sidebar.switchWorktree")}
style={{
width: "100%",
height: 29,
boxSizing: "border-box",
display: "flex",
alignItems: "center",
gap: 6,
padding: "0 10px",
background: "var(--bg-hover)",
border: "1px solid var(--border)",
borderRadius: 7,
cursor: "pointer",
fontSize: 11,
lineHeight: 1.35,
color: "var(--text-muted)",
textAlign: "left",
}}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, color: currentWt && !currentWt.isMain ? "var(--accent)" : "var(--text-dim)" }}>
<line x1="6" y1="3" x2="6" y2="15" />
<circle cx="18" cy="6" r="3" />
<circle cx="6" cy="18" r="3" />
<path d="M18 9a9 9 0 0 1-9 9" />
</svg>
<PathLabel
text={currentWt ? (currentWt.branch ?? displayCwd(currentWt.path, homeDir)) : "…"}
style={{ flex: 1, fontFamily: "var(--font-mono)", color: "var(--text)" }}
/>
{currentWt?.isMain && (
<span style={{ flexShrink: 0, color: "var(--text-dim)", fontSize: 10 }}>{t("sidebar.main")}</span>
)}
{worktreeState.worktrees.length > 1 && (
<span style={{ flexShrink: 0, color: "var(--text-dim)", fontSize: 10 }}>
{worktreeState.worktrees.length}
</span>
)}
<svg width="9" height="9" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<polyline points="2 3.5 5 6.5 8 3.5" />
</svg>
</button>
<AnimatedDropdown
open={wtDropdownOpen}
style={{
position: "absolute",
top: "calc(100% + 4px)",
left: 0,
right: 0,
zIndex: 100,
background: "var(--bg)",
border: "1px solid var(--border)",
borderRadius: 8,
boxShadow: "0 6px 20px rgba(0,0,0,0.10)",
overflow: "hidden",
}}
>
{showWtFilter && (
<div style={{ padding: "6px 8px", borderBottom: "1px solid var(--border)" }}>
<input
value={wtFilter}
onChange={(e) => 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",
}}
/>
</div>
)}
<div style={{ maxHeight: "min(40vh, 300px)", overflowY: "auto" }}>
{visibleWorktrees.map((wt) => {
const isCurrent = wt.path === selectedCwd || (wt.isMain && !worktreeState.worktrees.some((w) => w.path === selectedCwd));
if (wtConfirmRemove === wt.path) {
return (
<div key={wt.path} style={{ display: "flex", alignItems: "center", gap: 6, padding: "7px 10px", borderBottom: "1px solid var(--border)", background: "rgba(239,68,68,0.06)" }}>
<span style={{ flex: 1, fontSize: 11, color: "var(--text)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{t("sidebar.forceRemoveCheckout")}
</span>
<button
onClick={() => void handleRemoveWorktree(wt.path, true)}
disabled={wtBusy}
style={{ padding: "3px 9px", background: "#ef4444", border: "none", borderRadius: 5, color: "#fff", fontSize: 11, fontWeight: 600, cursor: "pointer", flexShrink: 0 }}
>
{t("sidebar.force")}
</button>
<button
onClick={() => setWtConfirmRemove(null)}
style={{ padding: "3px 9px", background: "var(--bg-hover)", border: "1px solid var(--border)", borderRadius: 5, color: "var(--text-muted)", fontSize: 11, cursor: "pointer", flexShrink: 0 }}
>
{t("sidebar.cancel")}
</button>
</div>
);
}
return (
<div
key={wt.path}
className="wt-row"
style={{ display: "flex", alignItems: "center", borderBottom: "1px solid var(--border)" }}
>
<button
onClick={() => {
setSelectedCwd(wt.path);
setWtDropdownOpen(false);
setWtError(null);
setWtFilter("");
}}
title={wt.path}
style={{
flex: 1,
minWidth: 0,
display: "flex",
alignItems: "center",
gap: 7,
padding: "8px 10px",
background: "var(--bg)",
border: "none",
color: isCurrent ? "var(--text)" : "var(--text-muted)",
cursor: "pointer",
textAlign: "left",
fontSize: 11,
fontFamily: "var(--font-mono)",
}}
>
{isCurrent ? (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<polyline points="1.5 5 4 7.5 8.5 2.5" />
</svg>
) : (
<span style={{ width: 10, flexShrink: 0 }} />
)}
<PathLabel text={wt.branch ?? displayCwd(wt.path, homeDir)} style={{ flex: 1 }} />
{wt.isMain && <span style={{ flexShrink: 0, color: "var(--text-dim)", fontSize: 10 }}>{t("sidebar.main")}</span>}
</button>
{!wt.isMain && (
<button
onClick={() => void handleRemoveWorktree(wt.path, false)}
disabled={wtBusy}
title={t("sidebar.removeWorktreeTitle", { path: wt.path })}
style={{
display: "flex", alignItems: "center", justifyContent: "center",
width: 34, height: 28, padding: 0, marginRight: 4,
background: "none", border: "none",
color: "var(--text-dim)", cursor: "pointer",
borderRadius: 5, flexShrink: 0,
transition: "color 0.12s, background 0.12s",
}}
onMouseEnter={(e) => { e.currentTarget.style.color = "#ef4444"; e.currentTarget.style.background = "rgba(239,68,68,0.08)"; }}
onMouseLeave={(e) => { e.currentTarget.style.color = "var(--text-dim)"; e.currentTarget.style.background = "none"; }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</button>
)}
</div>
);
})}
{showWtFilter && visibleWorktrees.length === 0 && wtFilter.trim() && (
<div style={{ padding: "8px 10px", fontSize: 11, color: "var(--text-dim)" }}>{t("sidebar.noMatchingWorktrees")}</div>
)}
</div>
{!wtNewOpen ? (
<button
onClick={(e) => {
e.stopPropagation();
setWtNewOpen(true);
setWtError(null);
setTimeout(() => wtNewInputRef.current?.focus(), 0);
}}
title={t("sidebar.createWorktreeTitle")}
style={{
display: "flex",
alignItems: "center",
gap: 7,
width: "100%",
padding: "8px 10px",
background: "none",
border: "none",
color: "var(--text-muted)",
cursor: "pointer",
textAlign: "left",
fontSize: 11,
}}
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.1" strokeLinecap="round" style={{ flexShrink: 0 }}>
<line x1="5" y1="1" x2="5" y2="9" />
<line x1="1" y1="5" x2="9" y2="5" />
</svg>
<span>{t("sidebar.newWorktree")}</span>
</button>
) : (
<div style={{ padding: "6px 8px" }}>
<input
ref={wtNewInputRef}
value={wtNewBranch}
onChange={(e) => {
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",
}}
/>
<div style={{ display: "flex", gap: 5, marginTop: 5 }}>
<button
onClick={() => void handleCreateWorktree()}
disabled={wtBusy || !wtNewBranch.trim()}
style={{
flex: 1,
padding: "4px 0",
background: "var(--accent)",
border: "none",
borderRadius: 5,
color: "#fff",
fontSize: 11,
fontWeight: 600,
cursor: wtBusy || !wtNewBranch.trim() ? "not-allowed" : "pointer",
opacity: wtBusy || !wtNewBranch.trim() ? 0.65 : 1,
}}
>
{wtBusy ? t("sidebar.creating") : t("sidebar.create")}
</button>
<button
onClick={() => { setWtNewOpen(false); setWtNewBranch(""); setWtError(null); }}
style={{
flex: 1,
padding: "4px 0",
background: "var(--bg-hover)",
border: "1px solid var(--border)",
borderRadius: 5,
color: "var(--text-muted)",
fontSize: 11,
cursor: "pointer",
}}
>
{t("sidebar.cancel")}
</button>
</div>
</div>
)}
{wtError && (
<div style={{
padding: "5px 10px 8px",
color: "#dc2626",
fontSize: 11,
lineHeight: 1.35,
overflowWrap: "anywhere",
}}>
{wtError}
</div>
)}
</AnimatedDropdown>
</div>
);
})()}
{inactiveWorktreeSelector && (
<button
type="button"
aria-disabled="true"
tabIndex={-1}
title={inactiveWorktreeSelector.title}
style={{
width: "100%",
height: 29,
boxSizing: "border-box",
marginTop: 6,
display: "flex",
alignItems: "center",
gap: 6,
padding: "0 10px",
border: "1px solid var(--border)",
borderRadius: 7,
background: "var(--bg-hover)",
color: "var(--text-dim)",
fontSize: 11,
lineHeight: 1.35,
whiteSpace: "nowrap",
textAlign: "left",
cursor: "default",
opacity: 0.82,
}}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<line x1="6" y1="3" x2="6" y2="15" />
<circle cx="18" cy="6" r="3" />
<circle cx="6" cy="18" r="3" />
<path d="M18 9a9 9 0 0 1-9 9" />
</svg>
<span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>{inactiveWorktreeSelector.label}</span>
</button>
)}
</div>
{/* Session list */}
<div style={{ flex: explorerOpen && (selectedCwdProp || selectedCwd) ? "1 1 0" : "1 1 auto", overflowY: "auto", padding: "0", minHeight: 80 }}>
{loading && (
<div style={{ padding: "16px 14px", color: "var(--text-muted)", fontSize: 12 }}>
{t("sidebar.loading")}
</div>
)}
{error && (
<div style={{ padding: "12px 14px", color: "#f87171", fontSize: 12 }}>
{error}
</div>
)}
{!loading && !error && filteredSessions.length === 0 && (
<div style={{ padding: "16px 14px", color: "var(--text-muted)", fontSize: 12 }}>
{t("sidebar.noSessions")}
</div>
)}
{sessionTree.map((node) => (
<SessionTreeItem
key={node.session.id}
node={node}
selectedSessionId={selectedSessionId}
runningSessionIds={runningSessionIds}
unreadSessionIds={unreadSessionIds}
onSelectSession={handleSelectSessionFromList}
onRenamed={loadSessions}
onSessionDeleted={(id) => {
onSessionDeleted?.(id);
loadSessions();
}}
depth={0}
/>
))}
</div>
{/* File Explorer section */}
{(selectedCwdProp || selectedCwd) && (
<div
style={{
borderTop: "1px solid var(--border)",
display: "flex",
flexDirection: "column",
flex: explorerOpen ? "1 1 0" : "0 0 auto",
minHeight: 0,
overflow: "hidden",
}}
>
<div style={{ display: "flex", alignItems: "center", flexShrink: 0 }}>
<button
onClick={() => setExplorerOpen((v) => !v)}
style={{
display: "flex",
alignItems: "center",
gap: 6,
flex: 1,
padding: "6px 10px",
background: "none",
border: "none",
color: "var(--text-muted)",
cursor: "pointer",
fontSize: 11,
fontWeight: 600,
letterSpacing: "0.05em",
textTransform: "uppercase",
textAlign: "left",
}}
>
<svg
width="9" height="9" viewBox="0 0 10 10" fill="none"
stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"
style={{ transform: explorerOpen ? "rotate(90deg)" : "none", transition: "transform 0.15s", flexShrink: 0 }}
>
<polyline points="3 2 7 5 3 8" />
</svg>
{t("files.explorer")}
</button>
{explorerOpen && changesCount > 0 && (
<ToolbarIconButton
onClick={() => setChangesCollapsed((v) => !v)}
title={t("sidebar.changedFiles", { count: changesCount })}
ariaPressed={!changesCollapsed}
color={changesCollapsed ? "var(--text-dim)" : "var(--accent)"}
background={changesCollapsed ? "none" : "var(--bg-selected)"}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="3" />
<path d="M3 12h6" />
<path d="M15 12h6" />
</svg>
</ToolbarIconButton>
)}
{explorerOpen && (
<ToolbarIconButton
onClick={() => fileExplorerRef.current?.openUploadPicker()}
disabled={explorerUploadBusy}
title={t("sidebar.uploadFilesTitle")}
color="var(--text-dim)"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<path d="m17 8-5-5-5 5" />
<path d="M12 3v12" />
</svg>
</ToolbarIconButton>
)}
<ToolbarIconButton
onClick={() => {
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 ? (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#4ade80" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
)}
</ToolbarIconButton>
</div>
{explorerOpen && (
<div style={{ flex: 1, overflowY: "auto", overflowX: "hidden" }}>
<FileExplorer
ref={fileExplorerRef}
cwd={selectedCwd ?? selectedCwdProp!}
onOpenFile={onOpenFile ?? (() => {})}
refreshKey={explorerKey}
onAtMention={onAtMention}
onAtMentions={onAtMentions}
onUploadBusyChange={setExplorerUploadBusy}
changesCollapsed={changesCollapsed}
onChangesCountChange={setChangesCount}
/>
</div>
)}
</div>
)}
</div>
);
}
function SessionTreeItem({
node,
selectedSessionId,
runningSessionIds,
unreadSessionIds,
onSelectSession,
onRenamed,
onSessionDeleted,
depth,
}: {
node: SessionTreeNode;
selectedSessionId: string | null;
runningSessionIds: Set<string>;
unreadSessionIds: Set<string>;
onSelectSession: (s: SessionInfo) => void;
onRenamed?: () => void;
onSessionDeleted?: (id: string) => void;
depth: number;
}) {
const [collapsed, setCollapsed] = useState(false);
const hasChildren = node.children.length > 0;
return (
<div>
<div style={{ position: "relative" }}>
{/* Indent line for child sessions */}
{depth > 0 && (
<div style={{
position: "absolute",
left: depth * 12 + 6,
top: 0, bottom: 0,
width: 1,
background: "var(--border)",
pointerEvents: "none",
}} />
)}
<SessionItem
session={node.session}
isSelected={node.session.id === selectedSessionId}
isRunning={runningSessionIds.has(node.session.id)}
isUnread={unreadSessionIds.has(node.session.id)}
onClick={() => onSelectSession(node.session)}
onRenamed={onRenamed}
onDeleted={(id) => onSessionDeleted?.(id)}
depth={depth}
hasChildren={hasChildren}
collapsed={collapsed}
onToggleCollapse={() => setCollapsed((v) => !v)}
/>
</div>
{hasChildren && !collapsed && (
<div>
{node.children.map((child) => (
<SessionTreeItem
key={child.session.id}
node={child}
selectedSessionId={selectedSessionId}
runningSessionIds={runningSessionIds}
unreadSessionIds={unreadSessionIds}
onSelectSession={onSelectSession}
onRenamed={onRenamed}
onSessionDeleted={onSessionDeleted}
depth={depth + 1}
/>
))}
</div>
)}
</div>
);
}
function RunningSessionIndicator() {
const { t } = useI18n();
return (
<span
title={t("sidebar.agentRunning")}
aria-label={t("sidebar.agentRunning")}
style={{
width: 14,
height: 14,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
color: "var(--accent)",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true" style={{ display: "block" }}>
<g>
<path
d="M21 12a9 9 0 1 1-3.8-7.4"
stroke="currentColor"
strokeWidth="2.8"
strokeLinecap="round"
/>
<animateTransform
attributeName="transform"
type="rotate"
from="0 12 12"
to="360 12 12"
dur="0.9s"
repeatCount="indefinite"
/>
</g>
</svg>
</span>
);
}
function UnreadSessionIndicator() {
const { t } = useI18n();
return (
<span
title={t("sidebar.newActivity")}
aria-label={t("sidebar.newSessionActivity")}
style={{
width: 14,
height: 14,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
color: "#0891b2",
}}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true" style={{ display: "block" }}>
<circle cx="7" cy="7" r="2.5" fill="currentColor" />
<circle cx="7" cy="7" r="3" stroke="currentColor" strokeWidth="1.4" opacity="0.32">
<animate attributeName="r" values="3;6;3" dur="1.6s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.32;0;0.32" dur="1.6s" repeatCount="indefinite" />
</circle>
</svg>
</span>
);
}
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<HTMLInputElement>(null);
const title = session.name || session.firstMessage.slice(0, 50) || session.id.slice(0, 12);
const startRename = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setRenameValue(session.name ?? "");
setRenaming(true);
setTimeout(() => inputRef.current?.select(), 0);
}, [session.name]);
const commitRename = useCallback(async () => {
const name = renameValue.trim();
setRenaming(false);
if (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]);
const performDelete = useCallback(async () => {
setConfirmDelete(false);
setDeleting(true);
try {
await fetch(`/api/sessions/${encodeURIComponent(session.id)}`, { method: "DELETE" });
onDeleted?.(session.id);
} catch {
setDeleting(false);
}
}, [session.id, 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);
}, []);
// Fixed-height outer wrapper — content swaps in place so the list never reflows
const ITEM_HEIGHT = 54;
return (
<div
onClick={confirmDelete || renaming ? undefined : onClick}
onMouseEnter={() => 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 ── */
<>
<div style={{ flex: 1, minWidth: 0, fontSize: 12, color: "var(--text)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{t("sidebar.deleteSession", { title: title.slice(0, 22) + (title.length > 22 ? "…" : "") })}
</div>
<div style={{ display: "flex", gap: 5, flexShrink: 0 }}>
<button
onClick={handleDeleteConfirm}
style={{
display: "flex", alignItems: "center", justifyContent: "center", gap: 4,
height: 30, padding: "0 11px",
background: "#ef4444", border: "none",
borderRadius: 6, color: "#fff",
cursor: "pointer", fontSize: 12, fontWeight: 600,
whiteSpace: "nowrap",
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
{t("sidebar.delete")}
</button>
<button
onClick={handleDeleteCancel}
style={{
display: "flex", alignItems: "center", justifyContent: "center",
height: 30, padding: "0 11px",
background: "var(--bg)", border: "1px solid var(--border)",
borderRadius: 6, color: "var(--text-muted)",
cursor: "pointer", fontSize: 12, fontWeight: 500,
whiteSpace: "nowrap",
}}
>
{t("sidebar.cancel")}
</button>
</div>
</>
) : renaming ? (
/* ── Rename: input fills the same row ── */
<input
ref={inputRef}
value={renameValue}
onChange={(e) => 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 && (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<line x1="6" y1="3" x2="6" y2="15" />
<circle cx="18" cy="6" r="3" />
<circle cx="6" cy="18" r="3" />
<path d="M18 9a9 9 0 0 1-9 9" />
</svg>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 5,
minWidth: 0,
fontSize: 12,
fontWeight: isSelected ? 500 : 400,
lineHeight: 1.4,
color: "var(--text)",
}}
title={title}
>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", minWidth: 0 }}>
{title}
</span>
</div>
<div style={{ marginTop: 2, display: "flex", alignItems: "center", gap: 8, color: "var(--text-dim)", fontSize: 11, minWidth: 0 }}>
{isRunning ? (
<RunningSessionIndicator />
) : isUnread ? (
<UnreadSessionIndicator />
) : (
<span title={session.modified}>{formatRelativeTime(session.modified)}</span>
)}
<span>{t("sidebar.messagesCount", { count: session.messageCount })}</span>
{session.worktreeBranch && (
<span
title={`Worktree: ${session.cwd}`}
style={{ display: "flex", alignItems: "center", gap: 3, color: "var(--accent)", minWidth: 0, overflow: "hidden" }}
>
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<line x1="6" y1="3" x2="6" y2="15" />
<circle cx="18" cy="6" r="3" />
<circle cx="6" cy="18" r="3" />
<path d="M18 9a9 9 0 0 1-9 9" />
</svg>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{session.worktreeBranch}</span>
</span>
)}
</div>
</div>
{/* Collapse toggle — always visible when has children */}
{hasChildren && (
<button
onClick={(e) => { e.stopPropagation(); onToggleCollapse?.(); }}
title={collapsed ? "Expand forks" : "Collapse forks"}
style={{
display: "flex", alignItems: "center", justifyContent: "center",
width: 20, height: 20, padding: 0, flexShrink: 0,
background: "none", border: "none",
color: "var(--text-dim)", cursor: "pointer",
transform: collapsed ? "rotate(-90deg)" : "none",
transition: "transform 0.15s",
}}
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<polyline points="2 3.5 5 6.5 8 3.5" />
</svg>
</button>
)}
{/* Action buttons — shown on hover */}
{hovered && (
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
<button
onClick={startRename}
title={t("sidebar.rename")}
style={{
display: "flex", alignItems: "center", justifyContent: "center",
width: 32, height: 32, padding: 0,
background: "var(--bg-hover)", border: "1px solid var(--border)",
borderRadius: 7, color: "var(--text-muted)",
cursor: "pointer", flexShrink: 0,
transition: "background 0.12s, color 0.12s, border-color 0.12s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--bg-selected)";
e.currentTarget.style.color = "var(--accent)";
e.currentTarget.style.borderColor = "rgba(37,99,235,0.35)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "var(--bg-hover)";
e.currentTarget.style.color = "var(--text-muted)";
e.currentTarget.style.borderColor = "var(--border)";
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" />
</svg>
</button>
<button
onClick={handleDeleteClick}
title={t("sidebar.deleteWithShiftClick")}
style={{
display: "flex", alignItems: "center", justifyContent: "center",
width: 32, height: 32, padding: 0,
background: "var(--bg-hover)", border: "1px solid var(--border)",
borderRadius: 7, color: "var(--text-muted)",
cursor: "pointer", flexShrink: 0,
transition: "background 0.12s, color 0.12s, border-color 0.12s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(239,68,68,0.08)";
e.currentTarget.style.color = "#ef4444";
e.currentTarget.style.borderColor = "rgba(239,68,68,0.35)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "var(--bg-hover)";
e.currentTarget.style.color = "var(--text-muted)";
e.currentTarget.style.borderColor = "var(--border)";
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</button>
</div>
)}
</>
)}
</div>
);
}