TypeScript · 14687 bytes Raw Blame History
1 import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
2 import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
3
4 import { PaneHeader } from "@/components/panes/PaneHeader";
5 import { relativeTime, shortEntrypoint, shortModel, tildeify } from "@/lib/format";
6 import { useSessionStore } from "@/lib/store/sessions";
7 import type { Project, SessionSummary } from "@/lib/ipc/types";
8
9 // Flat-row virtualization model for the sidebar. The previous tree
10 // walk reconciled every row on every store tick; virtualizing
11 // bounds the live row count to the viewport and a small overscan
12 // buffer, and memoized row components mean rescans that return
13 // equal-by-key rows don't re-render.
14 type ProjectsRow =
15 | {
16 kind: "project-header";
17 key: string;
18 project: Project;
19 expanded: boolean;
20 muted: boolean;
21 allowNewSession: boolean;
22 }
23 | {
24 kind: "session-row";
25 key: string;
26 session: SessionSummary;
27 muted: boolean;
28 }
29 | {
30 kind: "section-header";
31 key: string;
32 section: "observer" | "archive";
33 label: string;
34 tooltip: string;
35 totalSessions: number;
36 expanded: boolean;
37 }
38 | { kind: "empty-state"; key: string; label: string };
39
40 function buildRows(
41 projects: Project[],
42 expandedProjectIds: Set<string>,
43 observerExpanded: boolean,
44 archiveExpanded: boolean,
45 loading: boolean,
46 ): ProjectsRow[] {
47 if (projects.length === 0) {
48 return [
49 {
50 kind: "empty-state",
51 key: "empty",
52 label: loading
53 ? "loading projects…"
54 : "no projects found under ~/.claude/projects/",
55 },
56 ];
57 }
58
59 const regular: Project[] = [];
60 const observer: Project[] = [];
61 const archive: Project[] = [];
62 for (const p of projects) {
63 if (p.category === "observer") observer.push(p);
64 else if (p.category === "archive") archive.push(p);
65 else regular.push(p);
66 }
67
68 const rows: ProjectsRow[] = [];
69
70 const pushProjectAndSessions = (
71 project: Project,
72 muted: boolean,
73 allowNewSession: boolean,
74 ) => {
75 const expanded = expandedProjectIds.has(project.id);
76 rows.push({
77 kind: "project-header",
78 key: `proj:${project.id}`,
79 project,
80 expanded,
81 muted,
82 allowNewSession,
83 });
84 if (expanded) {
85 for (const session of project.sessions) {
86 rows.push({
87 kind: "session-row",
88 key: `sess:${session.id}`,
89 session,
90 muted,
91 });
92 }
93 }
94 };
95
96 for (const p of regular) pushProjectAndSessions(p, false, true);
97
98 if (observer.length > 0) {
99 const total = observer.reduce((acc, p) => acc + p.sessionCount, 0);
100 rows.push({
101 kind: "section-header",
102 key: "sec:observer",
103 section: "observer",
104 label: "claude-mem observer",
105 tooltip: "claude-mem observer agent sessions",
106 totalSessions: total,
107 expanded: observerExpanded,
108 });
109 if (observerExpanded) {
110 for (const p of observer) pushProjectAndSessions(p, true, false);
111 }
112 }
113
114 if (archive.length > 0) {
115 rows.push({
116 kind: "section-header",
117 key: "sec:archive",
118 section: "archive",
119 label: "archive",
120 tooltip:
121 "projects with prompt history but no on-disk transcripts. Rebuilt from ~/.claude/history.jsonl",
122 totalSessions: archive.length,
123 expanded: archiveExpanded,
124 });
125 if (archiveExpanded) {
126 for (const p of archive) pushProjectAndSessions(p, true, false);
127 }
128 }
129
130 return rows;
131 }
132
133 export function ProjectsPane() {
134 const projects = useSessionStore((s) => s.projects);
135 const loading = useSessionStore((s) => s.loading.projects);
136 const expandedProjectIds = useSessionStore((s) => s.expandedProjectIds);
137 const selectedSessionId = useSessionStore((s) => s.selectedSessionId);
138 const rescan = useSessionStore((s) => s.rescan);
139
140 const [observerExpanded, setObserverExpanded] = useState(false);
141 const [archiveExpanded, setArchiveExpanded] = useState(false);
142
143 const virtuosoRef = useRef<VirtuosoHandle | null>(null);
144 const rowsRef = useRef<ProjectsRow[]>([]);
145
146 const rows = useMemo(
147 () =>
148 buildRows(
149 projects,
150 expandedProjectIds,
151 observerExpanded,
152 archiveExpanded,
153 loading,
154 ),
155 [projects, expandedProjectIds, observerExpanded, archiveExpanded, loading],
156 );
157 rowsRef.current = rows;
158
159 // Single pass over projects for the header subtitle.
160 const { regularCount, totalSessionCount } = useMemo(() => {
161 let count = 0;
162 let total = 0;
163 for (const p of projects) {
164 if (p.category === "regular") {
165 count += 1;
166 total += p.sessionCount;
167 }
168 }
169 return { regularCount: count, totalSessionCount: total };
170 }, [projects]);
171
172 // Scroll the selected session into view only when the selection
173 // itself changes — the previous version fired on every rescan
174 // because `rows` was in its dep array, which yanked the viewport
175 // back to the selected row every 2s as the user scrolled away.
176 useEffect(() => {
177 if (!selectedSessionId) return;
178 const idx = rowsRef.current.findIndex(
179 (r) => r.kind === "session-row" && r.session.id === selectedSessionId,
180 );
181 if (idx >= 0) {
182 virtuosoRef.current?.scrollToIndex({
183 index: idx,
184 align: "center",
185 behavior: "auto",
186 });
187 }
188 }, [selectedSessionId]);
189
190 const toggleObserver = useCallback(
191 () => setObserverExpanded((x) => !x),
192 [],
193 );
194 const toggleArchive = useCallback(() => setArchiveExpanded((x) => !x), []);
195
196 const renderItem = useCallback(
197 (_index: number, row: ProjectsRow) => (
198 <RowRenderer
199 row={row}
200 onToggleObserver={toggleObserver}
201 onToggleArchive={toggleArchive}
202 />
203 ),
204 [toggleObserver, toggleArchive],
205 );
206
207 return (
208 <div className="flex h-full flex-col overflow-hidden">
209 <PaneHeader
210 title="Threads"
211 subtitle={
212 loading
213 ? "loading…"
214 : `${regularCount} project${regularCount === 1 ? "" : "s"} · ${totalSessionCount} thread${totalSessionCount === 1 ? "" : "s"}`
215 }
216 right={
217 <button
218 type="button"
219 onClick={() => void rescan()}
220 disabled={loading}
221 className="rounded border border-border bg-bg-2 px-2 py-0.5 text-[11px] text-fg-2 transition hover:bg-bg-3 disabled:cursor-not-allowed disabled:opacity-40"
222 title="Rescan"
223 >
224
225 </button>
226 }
227 />
228 <div className="min-h-0 flex-1">
229 <Virtuoso
230 ref={virtuosoRef}
231 data={rows}
232 computeItemKey={(_index, row) => row.key}
233 increaseViewportBy={{ top: 400, bottom: 400 }}
234 itemContent={renderItem}
235 />
236 </div>
237 </div>
238 );
239 }
240
241 interface RowRendererProps {
242 row: ProjectsRow;
243 onToggleObserver: () => void;
244 onToggleArchive: () => void;
245 }
246
247 const RowRenderer = memo(function RowRenderer({
248 row,
249 onToggleObserver,
250 onToggleArchive,
251 }: RowRendererProps) {
252 switch (row.kind) {
253 case "project-header":
254 return (
255 <ProjectHeaderRow
256 project={row.project}
257 expanded={row.expanded}
258 muted={row.muted}
259 allowNewSession={row.allowNewSession}
260 />
261 );
262 case "session-row":
263 return <SessionRowInner session={row.session} muted={row.muted} />;
264 case "section-header":
265 return (
266 <SectionHeaderRow
267 label={row.label}
268 tooltip={row.tooltip}
269 totalSessions={row.totalSessions}
270 expanded={row.expanded}
271 onToggle={
272 row.section === "observer" ? onToggleObserver : onToggleArchive
273 }
274 />
275 );
276 case "empty-state":
277 return <EmptyState label={row.label} />;
278 }
279 });
280
281 const ProjectHeaderRow = memo(function ProjectHeaderRow({
282 project,
283 expanded,
284 muted,
285 allowNewSession,
286 }: {
287 project: Project;
288 expanded: boolean;
289 muted: boolean;
290 allowNewSession: boolean;
291 }) {
292 const hasSessions = project.sessions.length > 0;
293 const beginNewSession = useSessionStore((s) => s.beginNewSession);
294 const toggleProject = useSessionStore((s) => s.toggleProject);
295 // Narrow selector — only re-render when THIS project's live-pty
296 // rollup flips, not on every unrelated ptyIds mutation.
297 const hasChildPty = useSessionStore((s) => {
298 for (const session of project.sessions) {
299 if (s.ptyIds.has(session.id)) return true;
300 }
301 return false;
302 });
303 return (
304 <div className={`group ${muted ? "opacity-70" : ""}`}>
305 <div className="relative flex items-stretch">
306 <button
307 type="button"
308 onClick={() => toggleProject(project.id)}
309 className="flex w-full items-start gap-1.5 px-2 py-1.5 pr-8 text-left transition hover:bg-bg-1"
310 >
311 <span className="mt-0.5 w-3 shrink-0 text-[10px] text-fg-3">
312 {hasSessions ? (expanded ? "▾" : "▸") : " "}
313 </span>
314 <span className="min-w-0 flex-1">
315 <span className="flex items-center gap-1.5 truncate text-sm font-medium text-fg-0">
316 <span className="truncate">{project.displayName}</span>
317 {hasChildPty && (
318 <span
319 className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-green-500/70"
320 title="background terminal running in this project"
321 />
322 )}
323 {project.sourceDirs.length > 1 && (
324 <span
325 className="rounded bg-bg-3 px-1 py-0.5 text-[9px] font-normal text-fg-3"
326 title={`merged from ${project.sourceDirs.length} encoded dirs:\n${project.sourceDirs.join("\n")}`}
327 >
328 {project.sourceDirs.length}
329 </span>
330 )}
331 <span className="ml-auto shrink-0 text-[10px] font-normal text-fg-3">
332 {project.sessionCount}
333 </span>
334 </span>
335 <span
336 className="block truncate text-[11px] text-fg-3"
337 title={project.cwd}
338 >
339 {tildeify(project.cwd)}
340 </span>
341 </span>
342 </button>
343 {allowNewSession && (
344 <button
345 type="button"
346 onClick={(e) => {
347 e.stopPropagation();
348 beginNewSession(project.cwd, project.displayName);
349 }}
350 title={`start new session in ${project.displayName}`}
351 className="absolute right-1 top-1 rounded border border-border bg-bg-2 px-1.5 text-[10px] font-mono text-fg-3 opacity-0 transition hover:bg-bg-3 hover:text-fg-0 group-hover:opacity-100"
352 >
353 +
354 </button>
355 )}
356 </div>
357 </div>
358 );
359 });
360
361 const SessionRowInner = memo(function SessionRowInner({
362 session,
363 muted,
364 }: {
365 session: SessionSummary;
366 muted: boolean;
367 }) {
368 // Narrow selectors — only re-render when the row's own pty/selected
369 // state actually flips, not on every unrelated store mutation.
370 const hasLivePty = useSessionStore((s) => s.ptyIds.has(session.id));
371 const selected = useSessionStore((s) => s.selectedSessionId === session.id);
372 const closeSessionPty = useSessionStore((s) => s.closeSessionPty);
373 const selectSession = useSessionStore((s) => s.selectSession);
374 return (
375 <div
376 className={`group/session relative ml-[14px] border-l border-border/60 ${
377 muted ? "opacity-70" : ""
378 }`}
379 >
380 <button
381 type="button"
382 onClick={() => void selectSession(session)}
383 className={`flex w-full flex-col gap-0.5 border-l-2 py-1.5 pl-3 pr-8 text-left transition ${
384 selected
385 ? "border-accent bg-bg-2"
386 : "border-transparent hover:bg-bg-1"
387 }`}
388 >
389 <span
390 className="flex items-start gap-1.5 text-[12px] leading-tight text-fg-1"
391 title={session.title}
392 >
393 {hasLivePty && (
394 <span
395 className="mt-1 inline-block h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-green-500"
396 title="terminal running"
397 />
398 )}
399 <span className="line-clamp-2 flex-1">{session.title}</span>
400 </span>
401 <span className="flex items-center gap-1.5 text-[10px] text-fg-3">
402 {session.model && (
403 <span className="rounded bg-bg-3 px-1 py-0.5 font-mono text-[9px] text-fg-2">
404 {shortModel(session.model)}
405 </span>
406 )}
407 {session.entrypoint && shortEntrypoint(session.entrypoint) && (
408 <span
409 className="rounded bg-bg-3 px-1 py-0.5 font-mono text-[9px] text-fg-2"
410 title={`entrypoint: ${session.entrypoint}`}
411 >
412 {shortEntrypoint(session.entrypoint)}
413 </span>
414 )}
415 <span>{session.messageCount} msgs</span>
416 {session.lastActivityAt && (
417 <>
418 <span></span>
419 <span>{relativeTime(session.lastActivityAt)}</span>
420 </>
421 )}
422 </span>
423 </button>
424 {hasLivePty && (
425 <button
426 type="button"
427 onClick={(e) => {
428 e.stopPropagation();
429 void closeSessionPty(session.id);
430 }}
431 title="close terminal (subprocess keeps running for any other attached session)"
432 className="absolute right-1 top-1 rounded border border-red-900/60 bg-red-950/40 px-1 text-[9px] font-mono text-red-300 opacity-0 transition hover:bg-red-900/50 group-hover/session:opacity-100"
433 >
434
435 </button>
436 )}
437 </div>
438 );
439 });
440
441 const SectionHeaderRow = memo(function SectionHeaderRow({
442 label,
443 tooltip,
444 totalSessions,
445 expanded,
446 onToggle,
447 }: {
448 label: string;
449 tooltip: string;
450 totalSessions: number;
451 expanded: boolean;
452 onToggle: () => void;
453 }) {
454 return (
455 <div className="mt-2 border-t border-border">
456 <button
457 type="button"
458 onClick={onToggle}
459 className="flex w-full items-center gap-2 bg-bg-1/60 px-3 py-1.5 text-left text-[11px] text-fg-3 transition hover:bg-bg-2"
460 title={tooltip}
461 >
462 <span className="text-[10px]">{expanded ? "▾" : "▸"}</span>
463 <span className="uppercase tracking-wide">{label}</span>
464 <span className="ml-auto font-mono text-[10px]">{totalSessions}</span>
465 </button>
466 </div>
467 );
468 });
469
470 function EmptyState({ label }: { label: string }) {
471 return (
472 <div className="flex h-full items-center justify-center p-4 text-center text-xs text-fg-3">
473 {label}
474 </div>
475 );
476 }