TypeScript · 12471 bytes Raw Blame History
1 import { useMemo, useState } from "react";
2
3 import { PaneHeader } from "@/components/panes/PaneHeader";
4 import { relativeTime, shortEntrypoint, shortModel, tildeify } from "@/lib/format";
5 import { useSessionStore } from "@/lib/store/sessions";
6 import type { Project, SessionSummary } from "@/lib/ipc/types";
7
8 export function ProjectsPane() {
9 const projects = useSessionStore((s) => s.projects);
10 const loading = useSessionStore((s) => s.loading.projects);
11 const expanded = useSessionStore((s) => s.expandedProjectIds);
12 const selectedSessionId = useSessionStore((s) => s.selectedSessionId);
13 const toggleProject = useSessionStore((s) => s.toggleProject);
14 const selectSession = useSessionStore((s) => s.selectSession);
15 const rescan = useSessionStore((s) => s.rescan);
16
17 const [observerExpanded, setObserverExpanded] = useState(false);
18 const [archiveExpanded, setArchiveExpanded] = useState(false);
19
20 const { regular, observer, archive } = useMemo(() => {
21 const regular: Project[] = [];
22 const observer: Project[] = [];
23 const archive: Project[] = [];
24 for (const p of projects) {
25 if (p.category === "observer") observer.push(p);
26 else if (p.category === "archive") archive.push(p);
27 else regular.push(p);
28 }
29 return { regular, observer, archive };
30 }, [projects]);
31
32 const totalSessionCount = useMemo(
33 () => regular.reduce((acc, p) => acc + p.sessionCount, 0),
34 [regular],
35 );
36
37 return (
38 <div className="flex h-full flex-col overflow-hidden">
39 <PaneHeader
40 title="Threads"
41 subtitle={
42 loading
43 ? "loading…"
44 : `${regular.length} project${regular.length === 1 ? "" : "s"} · ${totalSessionCount} thread${totalSessionCount === 1 ? "" : "s"}`
45 }
46 right={
47 <button
48 type="button"
49 onClick={() => void rescan()}
50 disabled={loading}
51 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"
52 title="Rescan"
53 >
54
55 </button>
56 }
57 />
58 <div className="flex-1 overflow-y-auto">
59 {projects.length === 0 ? (
60 <EmptyState
61 label={
62 loading
63 ? "loading projects…"
64 : "no projects found under ~/.claude/projects/"
65 }
66 />
67 ) : (
68 <>
69 {regular.map((project) => (
70 <ProjectNode
71 key={project.id}
72 project={project}
73 expanded={expanded.has(project.id)}
74 onToggle={() => toggleProject(project.id)}
75 selectedSessionId={selectedSessionId}
76 onSelectSession={(session) => void selectSession(session)}
77 />
78 ))}
79 {observer.length > 0 && (
80 <CollapsibleSection
81 label="claude-mem observer"
82 tooltip="claude-mem observer agent sessions"
83 totalSessions={observer.reduce(
84 (acc, p) => acc + p.sessionCount,
85 0,
86 )}
87 projects={observer}
88 expanded={observerExpanded}
89 onToggle={() => setObserverExpanded((x) => !x)}
90 openProjects={expanded}
91 onToggleProject={toggleProject}
92 selectedSessionId={selectedSessionId}
93 onSelectSession={(s) => void selectSession(s)}
94 />
95 )}
96 {archive.length > 0 && (
97 <CollapsibleSection
98 label="archive"
99 tooltip="projects with prompt history but no on-disk transcripts. Rebuilt from ~/.claude/history.jsonl"
100 totalSessions={archive.length}
101 projects={archive}
102 expanded={archiveExpanded}
103 onToggle={() => setArchiveExpanded((x) => !x)}
104 openProjects={expanded}
105 onToggleProject={toggleProject}
106 selectedSessionId={selectedSessionId}
107 onSelectSession={(s) => void selectSession(s)}
108 />
109 )}
110 </>
111 )}
112 </div>
113 </div>
114 );
115 }
116
117 function ProjectNode({
118 project,
119 expanded,
120 onToggle,
121 selectedSessionId,
122 onSelectSession,
123 muted = false,
124 allowNewSession = true,
125 }: {
126 project: Project;
127 expanded: boolean;
128 onToggle: () => void;
129 selectedSessionId: string | null;
130 onSelectSession: (session: SessionSummary) => void;
131 muted?: boolean;
132 allowNewSession?: boolean;
133 }) {
134 const hasSessions = project.sessions.length > 0;
135 const beginNewSession = useSessionStore((s) => s.beginNewSession);
136 const ptyIds = useSessionStore((s) => s.ptyIds);
137 // Does any session under this project have a live PTY? Drives
138 // the muted rollup dot next to the project row so the user can
139 // see background activity even with the tree collapsed.
140 const hasChildPty = useMemo(
141 () => project.sessions.some((s) => ptyIds.has(s.id)),
142 [project.sessions, ptyIds],
143 );
144 return (
145 <div className={`group ${muted ? "opacity-70" : ""}`}>
146 <div className="relative flex items-stretch">
147 <button
148 type="button"
149 onClick={onToggle}
150 className="flex w-full items-start gap-1.5 px-2 py-1.5 pr-8 text-left transition hover:bg-bg-1"
151 >
152 <span className="mt-0.5 w-3 shrink-0 text-[10px] text-fg-3">
153 {hasSessions ? (expanded ? "▾" : "▸") : " "}
154 </span>
155 <span className="min-w-0 flex-1">
156 <span className="flex items-center gap-1.5 truncate text-sm font-medium text-fg-0">
157 <span className="truncate">{project.displayName}</span>
158 {hasChildPty && (
159 <span
160 className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-green-500/70"
161 title="background terminal running in this project"
162 />
163 )}
164 {project.sourceDirs.length > 1 && (
165 <span
166 className="rounded bg-bg-3 px-1 py-0.5 text-[9px] font-normal text-fg-3"
167 title={`merged from ${project.sourceDirs.length} encoded dirs:\n${project.sourceDirs.join("\n")}`}
168 >
169 {project.sourceDirs.length}
170 </span>
171 )}
172 <span className="ml-auto shrink-0 text-[10px] font-normal text-fg-3">
173 {project.sessionCount}
174 </span>
175 </span>
176 <span
177 className="block truncate text-[11px] text-fg-3"
178 title={project.cwd}
179 >
180 {tildeify(project.cwd)}
181 </span>
182 </span>
183 </button>
184 {allowNewSession && (
185 <button
186 type="button"
187 onClick={(e) => {
188 e.stopPropagation();
189 beginNewSession(project.cwd, project.displayName);
190 }}
191 title={`start new session in ${project.displayName}`}
192 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"
193 >
194 +
195 </button>
196 )}
197 </div>
198 {expanded && hasSessions && (
199 <div className="border-l border-border/60 ml-[14px] pb-1">
200 {project.sessions.map((session) => (
201 <SessionRow
202 key={session.id}
203 session={session}
204 selected={session.id === selectedSessionId}
205 onSelect={() => onSelectSession(session)}
206 />
207 ))}
208 </div>
209 )}
210 </div>
211 );
212 }
213
214 function SessionRow({
215 session,
216 selected,
217 onSelect,
218 }: {
219 session: SessionSummary;
220 selected: boolean;
221 onSelect: () => void;
222 }) {
223 const hasLivePty = useSessionStore((s) => s.ptyIds.has(session.id));
224 const closeSessionPty = useSessionStore((s) => s.closeSessionPty);
225 return (
226 // `content-visibility: auto` tells the browser it can skip
227 // layout + paint entirely for rows outside the viewport. Huge
228 // help for projects with hundreds of sessions where scrolling
229 // otherwise forces the whole ancestor chain to recalc on each
230 // frame. `contain-intrinsic-size` reserves a stable placeholder
231 // so the scrollbar doesn't jitter as rows virtualize in/out.
232 <div
233 className="group/session relative"
234 style={{
235 contentVisibility: "auto",
236 containIntrinsicSize: "0 56px",
237 }}
238 >
239 <button
240 type="button"
241 onClick={onSelect}
242 className={`flex w-full flex-col gap-0.5 border-l-2 py-1.5 pl-3 pr-8 text-left transition ${
243 selected
244 ? "border-accent bg-bg-2"
245 : "border-transparent hover:bg-bg-1"
246 }`}
247 >
248 <span
249 className="flex items-start gap-1.5 text-[12px] leading-tight text-fg-1"
250 title={session.title}
251 >
252 {hasLivePty && (
253 <span
254 className="mt-1 inline-block h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-green-500"
255 title="terminal running"
256 />
257 )}
258 <span className="line-clamp-2 flex-1">{session.title}</span>
259 </span>
260 <span className="flex items-center gap-1.5 text-[10px] text-fg-3">
261 {session.model && (
262 <span className="rounded bg-bg-3 px-1 py-0.5 font-mono text-[9px] text-fg-2">
263 {shortModel(session.model)}
264 </span>
265 )}
266 {session.entrypoint && shortEntrypoint(session.entrypoint) && (
267 <span
268 className="rounded bg-bg-3 px-1 py-0.5 font-mono text-[9px] text-fg-2"
269 title={`entrypoint: ${session.entrypoint}`}
270 >
271 {shortEntrypoint(session.entrypoint)}
272 </span>
273 )}
274 <span>{session.messageCount} msgs</span>
275 {session.lastActivityAt && (
276 <>
277 <span></span>
278 <span>{relativeTime(session.lastActivityAt)}</span>
279 </>
280 )}
281 </span>
282 </button>
283 {hasLivePty && (
284 <button
285 type="button"
286 onClick={(e) => {
287 e.stopPropagation();
288 void closeSessionPty(session.id);
289 }}
290 title="close terminal (subprocess keeps running for any other attached session)"
291 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"
292 >
293
294 </button>
295 )}
296 </div>
297 );
298 }
299
300 function CollapsibleSection({
301 label,
302 tooltip,
303 totalSessions,
304 projects,
305 expanded,
306 onToggle,
307 openProjects,
308 onToggleProject,
309 selectedSessionId,
310 onSelectSession,
311 }: {
312 label: string;
313 tooltip: string;
314 totalSessions: number;
315 projects: Project[];
316 expanded: boolean;
317 onToggle: () => void;
318 openProjects: Set<string>;
319 onToggleProject: (id: string) => void;
320 selectedSessionId: string | null;
321 onSelectSession: (session: SessionSummary) => void;
322 }) {
323 return (
324 <div className="mt-2 border-t border-border">
325 <button
326 type="button"
327 onClick={onToggle}
328 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"
329 title={tooltip}
330 >
331 <span className="text-[10px]">{expanded ? "▾" : "▸"}</span>
332 <span className="uppercase tracking-wide">{label}</span>
333 <span className="ml-auto font-mono text-[10px]">{totalSessions}</span>
334 </button>
335 {expanded && (
336 <div>
337 {projects.map((project) => (
338 <ProjectNode
339 key={project.id}
340 project={project}
341 expanded={openProjects.has(project.id)}
342 onToggle={() => onToggleProject(project.id)}
343 selectedSessionId={selectedSessionId}
344 onSelectSession={onSelectSession}
345 muted
346 allowNewSession={false}
347 />
348 ))}
349 </div>
350 )}
351 </div>
352 );
353 }
354
355 function EmptyState({ label }: { label: string }) {
356 return (
357 <div className="flex h-full items-center justify-center p-4 text-center text-xs text-fg-3">
358 {label}
359 </div>
360 );
361 }