@raingor/pi-web-switch 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@ import { useConfigStore } from "@/store/config-store";
3
3
  import { useTranslation } from "@/lib/i18n";
4
4
  import {
5
5
  History, MessageSquare, Clock, ChevronDown, ChevronRight, Trash2, AlertTriangle,
6
- Shield, RefreshCw, Undo2, Eye,
6
+ Shield, RefreshCw, Undo2, Eye, Folder, FolderOpen, FileText, Search, ArchiveX,
7
7
  } from "lucide-react";
8
8
  import { Modal } from "@/components/ui/Modal";
9
9
 
@@ -53,6 +53,17 @@ interface PreviewMessage {
53
53
  timestamp: string;
54
54
  }
55
55
 
56
+ interface TreeNode {
57
+ id: string;
58
+ type: "directory" | "project" | "session";
59
+ name: string;
60
+ fullPath?: string; // For directory nodes, the path prefix
61
+ data?: ProjectGroup | SessionInfo; // Only for project/session nodes
62
+ children?: TreeNode[];
63
+ sessionCount?: number; // Aggregate count for directory nodes
64
+ lastActive?: string; // Latest activity for sorting
65
+ }
66
+
56
67
  const SESSIONS_PER_GROUP = 50;
57
68
 
58
69
  function formatDuration(ms?: number): string {
@@ -88,137 +99,374 @@ function formatFullTimestamp(iso: string): string {
88
99
  return d.toLocaleString();
89
100
  }
90
101
 
91
- function ProjectCard({
92
- group,
93
- defaultOpen,
94
- forceOpen,
102
+ /** Build a proper directory tree from project groups */
103
+ function buildDirectoryTree(groups: ProjectGroup[]): TreeNode[] {
104
+ const root: TreeNode[] = [];
105
+
106
+ for (const group of groups) {
107
+ // Split project path into segments
108
+ // e.g., "Users-mac-2312-r-workspace-wwwroot-my-notes" -> ["Users", "mac-2312-r", "workspace", "wwwroot", "my-notes"]
109
+ const segments = group.projectPath.split("-").filter(Boolean);
110
+
111
+ // Insert into tree, creating intermediate directories as needed
112
+ let currentLevel = root;
113
+ let currentPath = "";
114
+
115
+ for (let i = 0; i < segments.length; i++) {
116
+ const segment = segments[i];
117
+ if (!segment) continue;
118
+ const parentPath = currentPath;
119
+ currentPath = currentPath ? `${currentPath}-${segment}` : segment;
120
+
121
+ const isLastSegment = i === segments.length - 1;
122
+
123
+ if (isLastSegment) {
124
+ // This is the actual project node - add it with sessions as children
125
+ currentLevel.push({
126
+ id: group.projectPath,
127
+ type: "project",
128
+ name: segment, // Use just the last segment as display name
129
+ data: group,
130
+ children: group.sessions.map((session) => ({
131
+ id: session.id || session.fileName,
132
+ type: "session" as const,
133
+ name: sessionDisplayName(session),
134
+ data: session,
135
+ })),
136
+ sessionCount: group.totalSessions,
137
+ lastActive: group.lastActive,
138
+ });
139
+ } else {
140
+ // This is an intermediate directory - find or create it
141
+ let dirNode = currentLevel.find(
142
+ (node) => node.type === "directory" && node.name === segment && node.fullPath === currentPath
143
+ );
144
+
145
+ if (!dirNode) {
146
+ dirNode = {
147
+ id: currentPath,
148
+ type: "directory",
149
+ name: segment,
150
+ fullPath: currentPath,
151
+ children: [],
152
+ sessionCount: 0,
153
+ lastActive: "",
154
+ };
155
+ currentLevel.push(dirNode);
156
+ }
157
+
158
+ // Update aggregate stats
159
+ dirNode.sessionCount = (dirNode.sessionCount || 0) + group.totalSessions;
160
+ if (!dirNode.lastActive || group.lastActive > dirNode.lastActive) {
161
+ dirNode.lastActive = group.lastActive;
162
+ }
163
+
164
+ currentLevel = dirNode.children!;
165
+ }
166
+ }
167
+ }
168
+
169
+ // Sort root level by lastActive descending
170
+ return sortTreeByLastActive(root);
171
+ }
172
+
173
+ /** Recursively sort tree nodes by lastActive */
174
+ function sortTreeByLastActive(nodes: TreeNode[]): TreeNode[] {
175
+ return nodes
176
+ .sort((a, b) => (b.lastActive || "").localeCompare(a.lastActive || ""))
177
+ .map((node) => {
178
+ if (node.children && node.children.length > 0) {
179
+ return { ...node, children: sortTreeByLastActive(node.children) };
180
+ }
181
+ return node;
182
+ });
183
+ }
184
+
185
+ /** Filter directory tree by search query */
186
+ function filterDirectoryTree(nodes: TreeNode[], query: string): TreeNode[] {
187
+ if (!query) return nodes;
188
+ const q = query.toLowerCase();
189
+
190
+ return nodes
191
+ .map((node) => {
192
+ // Check if this node name matches
193
+ const nameMatches = node.name.toLowerCase().includes(q);
194
+
195
+ if (nameMatches) {
196
+ // Node matches, include all children and mark as expanded
197
+ return { ...node, _forceExpanded: true } as TreeNode & { _forceExpanded?: boolean };
198
+ }
199
+
200
+ // If has children, recursively filter them
201
+ if (node.children && node.children.length > 0) {
202
+ const filteredChildren = filterDirectoryTree(node.children, q);
203
+ if (filteredChildren.length > 0) {
204
+ return {
205
+ ...node,
206
+ children: filteredChildren,
207
+ _forceExpanded: true,
208
+ } as TreeNode & { _forceExpanded?: boolean };
209
+ }
210
+ }
211
+
212
+ // For project nodes, check sessions
213
+ if (node.type === "project" && node.data) {
214
+ const group = node.data as ProjectGroup;
215
+ const matchingSessions = group.sessions.filter((s) =>
216
+ sessionDisplayName(s).toLowerCase().includes(q)
217
+ );
218
+
219
+ if (matchingSessions.length > 0) {
220
+ return {
221
+ ...node,
222
+ children: matchingSessions.map((session) => ({
223
+ id: session.id || session.fileName,
224
+ type: "session" as const,
225
+ name: sessionDisplayName(session),
226
+ data: session,
227
+ })),
228
+ _forceExpanded: true,
229
+ } as TreeNode & { _forceExpanded?: boolean };
230
+ }
231
+ }
232
+
233
+ return null;
234
+ })
235
+ .filter((n): n is TreeNode => n !== null);
236
+ }
237
+
238
+ /** Tree Node Component - supports directory, project, and session nodes */
239
+ function TreeNodeItem({
240
+ node,
241
+ level,
242
+ expandedNodes,
243
+ forceExpanded,
244
+ onToggle,
95
245
  onDelete,
96
246
  onPreview,
247
+ t,
97
248
  }: {
98
- group: ProjectGroup;
99
- defaultOpen: boolean;
100
- forceOpen: boolean;
249
+ node: TreeNode & { _forceExpanded?: boolean };
250
+ level: number;
251
+ expandedNodes: Set<string>;
252
+ forceExpanded?: boolean;
253
+ onToggle: (id: string) => void;
101
254
  onDelete: (session: SessionInfo, groupPath: string) => void;
102
255
  onPreview: (session: SessionInfo) => void;
256
+ t: (key: string, ...args: string[]) => string;
103
257
  }) {
104
- const { t } = useTranslation();
105
- const [open, setOpen] = useState(defaultOpen);
106
- const isOpen = forceOpen || open;
107
- const visibleSessions = group.sessions.slice(0, SESSIONS_PER_GROUP);
108
- const hiddenCount = group.sessions.length - visibleSessions.length;
258
+ const isExpanded = forceExpanded || node._forceExpanded || expandedNodes.has(node.id);
259
+ const hasChildren = node.children && node.children.length > 0;
260
+ const isDirectory = node.type === "directory";
261
+ const isProject = node.type === "project";
262
+ const isSession = node.type === "session";
263
+ const session = isSession ? (node.data as SessionInfo) : null;
264
+ const project = isProject ? (node.data as ProjectGroup) : null;
265
+
266
+ // Directory and project nodes can be toggled; sessions cannot
267
+ const canToggle = isDirectory || isProject;
268
+
269
+ const paddingLeft = level * 16 + 12;
109
270
 
110
271
  return (
111
- <div className="rounded-xl border" style={{ borderColor: "var(--card-border)" }}>
112
- {/* Project Header */}
113
- <button
114
- onClick={() => setOpen(!open)}
115
- className="flex w-full items-center justify-between px-6 py-4"
116
- style={{ backgroundColor: "var(--card-bg)" }}
272
+ <div>
273
+ {/* Node Row */}
274
+ <div
275
+ className={`flex items-center gap-2 py-2 pr-3 transition-colors ${canToggle ? "cursor-pointer hover:bg-gray-500/5" : ""}`}
276
+ style={{
277
+ paddingLeft,
278
+ backgroundColor: isSession ? "var(--page-bg)" : "transparent",
279
+ borderBottom: "1px solid var(--card-border)",
280
+ }}
281
+ onClick={() => {
282
+ if (canToggle) {
283
+ onToggle(node.id);
284
+ } else if (session) {
285
+ onPreview(session);
286
+ }
287
+ }}
117
288
  >
118
- <div className="flex items-center gap-3">
119
- <div className="flex h-9 w-9 items-center justify-center rounded-lg" style={{ backgroundColor: "var(--accent-bg)" }}>
120
- <History className="h-4 w-4" style={{ color: "var(--sidebar-active-text)" }} />
121
- </div>
122
- <div className="text-left">
123
- <h3 className="text-sm font-semibold" style={{ color: "var(--page-text)" }}>{group.projectName}</h3>
124
- <p className="text-xs" style={{ color: "var(--muted-text)" }}>
125
- {t("sessions.sessions_count", String(group.totalSessions))} · {t("sessions.last_active", formatRelativeDate(group.lastActive, t))}
126
- </p>
127
- </div>
128
- </div>
129
- <div className="flex items-center gap-3">
130
- <span className="text-xs font-medium" style={{ color: "var(--sidebar-active-text)" }}>
131
- {group.totalSessions}
132
- </span>
133
- {isOpen ? <ChevronDown className="h-4 w-4" style={{ color: "var(--muted-text)" }} /> : <ChevronRight className="h-4 w-4" style={{ color: "var(--muted-text)" }} />}
289
+ {/* Expand/Collapse Icon */}
290
+ {canToggle && hasChildren ? (
291
+ <button
292
+ onClick={(e) => {
293
+ e.stopPropagation();
294
+ onToggle(node.id);
295
+ }}
296
+ className="flex items-center justify-center w-5 h-5 rounded hover:bg-gray-500/10"
297
+ style={{ color: "var(--muted-text)" }}
298
+ >
299
+ {isExpanded ? (
300
+ <ChevronDown className="h-4 w-4" />
301
+ ) : (
302
+ <ChevronRight className="h-4 w-4" />
303
+ )}
304
+ </button>
305
+ ) : (
306
+ <span className="w-5" />
307
+ )}
308
+
309
+ {/* Icon */}
310
+ <div
311
+ className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
312
+ style={{
313
+ backgroundColor: (isDirectory || isProject) ? "var(--accent-bg)" : "transparent",
314
+ }}
315
+ >
316
+ {isDirectory ? (
317
+ isExpanded ? (
318
+ <FolderOpen className="h-4 w-4" style={{ color: "#f59e0b" }} />
319
+ ) : (
320
+ <Folder className="h-4 w-4" style={{ color: "#f59e0b" }} />
321
+ )
322
+ ) : isProject ? (
323
+ isExpanded ? (
324
+ <FolderOpen className="h-4 w-4" style={{ color: "var(--sidebar-active-text)" }} />
325
+ ) : (
326
+ <Folder className="h-4 w-4" style={{ color: "var(--sidebar-active-text)" }} />
327
+ )
328
+ ) : (
329
+ <FileText className="h-4 w-4" style={{ color: "var(--muted-text)" }} />
330
+ )}
134
331
  </div>
135
- </button>
136
-
137
- {/* Session List */}
138
- {isOpen && (
139
- <div style={{ borderTop: "1px solid var(--card-border)" }}>
140
- {visibleSessions.map((session) => (
141
- <div
142
- key={session.id || session.fileName}
143
- className="group flex items-center justify-between px-6 py-3 cursor-pointer transition-colors hover:bg-gray-500/5"
144
- style={{
145
- borderBottom: "1px solid var(--card-border)",
146
- backgroundColor: "var(--page-bg)",
332
+
333
+ {/* Name and Info */}
334
+ <div className="flex-1 min-w-0">
335
+ <div className="flex items-center gap-2">
336
+ <span
337
+ className={`truncate ${isDirectory ? "font-semibold text-sm" : "font-medium text-sm"}`}
338
+ style={{
339
+ color: isDirectory ? "#d97706" : "var(--page-text)",
147
340
  }}
148
- onClick={() => onPreview(session)}
149
341
  >
150
- <div className="flex items-center gap-3 min-w-0 flex-1">
151
- <div className="flex flex-col items-center justify-center w-8 h-8 rounded-md" style={{ backgroundColor: "var(--accent-bg)" }}>
152
- <MessageSquare className="h-3.5 w-3.5" style={{ color: "var(--sidebar-active-text)" }} />
153
- </div>
154
- <div className="min-w-0 flex-1">
155
- <p className="text-sm font-medium truncate" style={{ color: "var(--page-text)" }}>
156
- {sessionDisplayName(session)}
157
- </p>
158
- <div className="flex items-center gap-3 text-xs" style={{ color: "var(--muted-text)" }}>
159
- <span title={formatFullTimestamp(session.timestamp)}>{formatRelativeDate(session.timestamp, t)}</span>
160
- <span className="flex items-center gap-1">
161
- <MessageSquare className="h-3 w-3" />
162
- {session.messageCount}
163
- </span>
164
- <span className="flex items-center gap-1">
165
- <Clock className="h-3 w-3" />
166
- {formatDuration(session.duration)}
167
- </span>
168
- </div>
169
- </div>
170
- </div>
171
- <div className="flex items-center gap-2 shrink-0 ml-4">
172
- {session.provider && session.provider !== "unknown" && (
173
- <span className="text-xs rounded-md px-2 py-0.5" style={{ backgroundColor: "var(--accent-bg)", color: "var(--sidebar-active-text)" }}>
174
- {session.provider}/{session.model?.split("-").slice(0, 2).join("-") || session.model}
175
- </span>
176
- )}
177
- <span
178
- className="rounded-lg p-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
179
- style={{ color: "var(--subtle-text)" }}
180
- title={t("sessions.preview_title")}
181
- >
182
- <Eye className="h-3.5 w-3.5" />
342
+ {node.name}
343
+ </span>
344
+ {(isDirectory || isProject) && node.sessionCount !== undefined && node.sessionCount > 0 && (
345
+ <span
346
+ className="text-xs px-1.5 py-0.5 rounded-full"
347
+ style={{
348
+ backgroundColor: isDirectory ? "rgba(245,158,11,0.15)" : "var(--accent-bg)",
349
+ color: isDirectory ? "#d97706" : "var(--sidebar-active-text)",
350
+ }}
351
+ >
352
+ {node.sessionCount}
353
+ </span>
354
+ )}
355
+ </div>
356
+ {isSession && session && (
357
+ <div className="flex items-center gap-2 text-xs" style={{ color: "var(--muted-text)" }}>
358
+ <span title={formatFullTimestamp(session.timestamp)}>
359
+ {formatRelativeDate(session.timestamp, t)}
360
+ </span>
361
+ <span className="flex items-center gap-0.5">
362
+ <MessageSquare className="h-3 w-3" />
363
+ {session.messageCount}
364
+ </span>
365
+ {session.duration && (
366
+ <span className="flex items-center gap-0.5">
367
+ <Clock className="h-3 w-3" />
368
+ {formatDuration(session.duration)}
183
369
  </span>
184
- {isRecent(session.lastActive) ? (
185
- <span
186
- className="rounded-lg p-1.5"
187
- style={{ color: "var(--subtle-text)" }}
188
- title={t("sessions.protected")}
189
- >
190
- <Shield className="h-3.5 w-3.5" />
191
- </span>
192
- ) : (
193
- <button
194
- onClick={(e) => {
195
- e.stopPropagation();
196
- onDelete(session, group.projectPath);
197
- }}
198
- className="rounded-lg p-1.5 transition-opacity"
199
- style={{ color: "var(--subtle-text)" }}
200
- title={t("sessions.delete")}
201
- >
202
- <Trash2 className="h-3.5 w-3.5" />
203
- </button>
204
- )}
205
- </div>
370
+ )}
371
+ </div>
372
+ )}
373
+ {isProject && project && (
374
+ <div className="text-xs" style={{ color: "var(--muted-text)" }}>
375
+ {t("sessions.last_active", formatRelativeDate(project.lastActive, t))}
376
+ </div>
377
+ )}
378
+ {isDirectory && node.lastActive && (
379
+ <div className="text-xs" style={{ color: "var(--subtle-text)" }}>
380
+ {t("sessions.last_active", formatRelativeDate(node.lastActive, t))}
206
381
  </div>
207
- ))}
208
- {hiddenCount > 0 && (
209
- <p className="px-6 py-2.5 text-xs" style={{ color: "var(--subtle-text)" }}>
210
- {t("sessions.more_count", String(hiddenCount))}
211
- </p>
212
382
  )}
213
383
  </div>
384
+
385
+ {/* Actions - only for sessions */}
386
+ {isSession && session && (
387
+ <div className="flex items-center gap-1 shrink-0">
388
+ {session.provider && session.provider !== "unknown" && (
389
+ <span
390
+ className="text-xs rounded-md px-2 py-0.5 hidden sm:block"
391
+ style={{
392
+ backgroundColor: "var(--accent-bg)",
393
+ color: "var(--sidebar-active-text)",
394
+ }}
395
+ >
396
+ {session.provider}/{session.model?.split("-").slice(0, 2).join("-") || session.model}
397
+ </span>
398
+ )}
399
+ <button
400
+ onClick={(e) => {
401
+ e.stopPropagation();
402
+ onPreview(session);
403
+ }}
404
+ className="rounded-lg p-1.5 transition-colors hover:bg-gray-500/10"
405
+ style={{ color: "var(--subtle-text)" }}
406
+ title={t("sessions.preview_title")}
407
+ >
408
+ <Eye className="h-3.5 w-3.5" />
409
+ </button>
410
+ {isRecent(session.lastActive) ? (
411
+ <span
412
+ className="rounded-lg p-1.5"
413
+ style={{ color: "var(--subtle-text)" }}
414
+ title={t("sessions.protected")}
415
+ >
416
+ <Shield className="h-3.5 w-3.5" />
417
+ </span>
418
+ ) : (
419
+ <button
420
+ onClick={(e) => {
421
+ e.stopPropagation();
422
+ // Find parent project path for this session
423
+ onDelete(session, findParentProjectPath(node));
424
+ }}
425
+ className="rounded-lg p-1.5 transition-colors hover:bg-gray-500/10"
426
+ style={{ color: "var(--subtle-text)" }}
427
+ title={t("sessions.delete")}
428
+ >
429
+ <Trash2 className="h-3.5 w-3.5" />
430
+ </button>
431
+ )}
432
+ </div>
433
+ )}
434
+ </div>
435
+
436
+ {/* Children */}
437
+ {(isDirectory || isProject) && isExpanded && hasChildren && (
438
+ <div>
439
+ {node.children!.map((child) => (
440
+ <TreeNodeItem
441
+ key={child.id}
442
+ node={child as TreeNode & { _forceExpanded?: boolean }}
443
+ level={level + 1}
444
+ expandedNodes={expandedNodes}
445
+ forceExpanded={!!node._forceExpanded}
446
+ onToggle={onToggle}
447
+ onDelete={onDelete}
448
+ onPreview={onPreview}
449
+ t={t}
450
+ />
451
+ ))}
452
+ </div>
214
453
  )}
215
454
  </div>
216
455
  );
217
456
  }
218
457
 
458
+ /** Find the parent project path for a session node */
459
+ function findParentProjectPath(node: TreeNode): string {
460
+ // Walk up to find the nearest project ancestor
461
+ // Note: In our current structure, the parent should be a project or directory
462
+ // We need to pass this info differently - for now return empty string
463
+ // The actual fix would require restructuring how we track parent paths
464
+ return "";
465
+ }
466
+
219
467
  export function SessionsPage() {
220
468
  const { t } = useTranslation();
221
- const { initialized } = useConfigStore();
469
+ const { initialized, settings } = useConfigStore();
222
470
  const [tab, setTab] = useState<"sessions" | "trash">("sessions");
223
471
  const [groups, setGroups] = useState<ProjectGroup[]>([]);
224
472
  const [trash, setTrash] = useState<TrashEntry[]>([]);
@@ -232,10 +480,13 @@ export function SessionsPage() {
232
480
  const [selectedTrash, setSelectedTrash] = useState<Set<string>>(new Set());
233
481
  const [purgeTarget, setPurgeTarget] = useState<"batch" | TrashEntry | null>(null);
234
482
  const [purging, setPurging] = useState(false);
483
+ const [expiring, setExpiring] = useState(false);
235
484
  // Preview modal state
236
485
  const [previewTarget, setPreviewTarget] = useState<SessionInfo | null>(null);
237
486
  const [preview, setPreview] = useState<{ messages: PreviewMessage[]; total: number } | null>(null);
238
487
  const [previewError, setPreviewError] = useState(false);
488
+ // Tree state
489
+ const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
239
490
 
240
491
  const loadAll = useCallback(() => {
241
492
  if (!initialized) return;
@@ -248,6 +499,9 @@ export function SessionsPage() {
248
499
  setGroups(sessionData);
249
500
  setTrash(trashData);
250
501
  setError(null);
502
+ // Auto-expand first 3 projects
503
+ const firstThree = sessionData.slice(0, 3).map((g: ProjectGroup) => g.projectPath);
504
+ setExpandedNodes(new Set(firstThree));
251
505
  })
252
506
  .catch((e) => setError(e.message))
253
507
  .finally(() => {
@@ -322,6 +576,27 @@ export function SessionsPage() {
322
576
  loadAll();
323
577
  };
324
578
 
579
+ const handleAutoExpire = async () => {
580
+ setExpiring(true);
581
+ try {
582
+ const res = await fetch("/api/pi/session/auto-expire", { method: "POST" });
583
+ const result = await res.json();
584
+ if (result.success) {
585
+ const count = result.expired?.length ?? 0;
586
+ alert(count > 0
587
+ ? t("sessions.auto_expire_success", String(count))
588
+ : t("sessions.auto_expire_none"));
589
+ loadAll();
590
+ } else {
591
+ alert(t("sessions.auto_expire_error", result.error || "unknown"));
592
+ }
593
+ } catch {
594
+ alert(t("sessions.auto_expire_error", "network"));
595
+ } finally {
596
+ setExpiring(false);
597
+ }
598
+ };
599
+
325
600
  const openPreview = (session: SessionInfo) => {
326
601
  setPreviewTarget(session);
327
602
  setPreview(null);
@@ -332,21 +607,43 @@ export function SessionsPage() {
332
607
  .catch(() => setPreviewError(true));
333
608
  };
334
609
 
335
- // Search matches project names AND session names; matched projects auto-expand
336
- const q = filter.trim().toLowerCase();
337
- const filteredGroups = !q
338
- ? groups
339
- : groups
340
- .map((g) => {
341
- if (g.projectName.toLowerCase().includes(q)) return g;
342
- const sessions = g.sessions.filter((s) =>
343
- sessionDisplayName(s).toLowerCase().includes(q)
344
- );
345
- return sessions.length > 0
346
- ? { ...g, sessions, totalSessions: sessions.length }
347
- : null;
348
- })
349
- .filter((g): g is ProjectGroup => g !== null);
610
+ // Toggle node expansion
611
+ const toggleNode = (id: string) => {
612
+ setExpandedNodes((prev) => {
613
+ const next = new Set(prev);
614
+ if (next.has(id)) {
615
+ next.delete(id);
616
+ } else {
617
+ next.add(id);
618
+ }
619
+ return next;
620
+ });
621
+ };
622
+
623
+ // Expand/collapse all - collect all directory and project node IDs
624
+ const expandAll = () => {
625
+ const allIds: string[] = [];
626
+ const collectIds = (nodes: TreeNode[]) => {
627
+ for (const node of nodes) {
628
+ if (node.type === "directory" || node.type === "project") {
629
+ allIds.push(node.id);
630
+ }
631
+ if (node.children) {
632
+ collectIds(node.children);
633
+ }
634
+ }
635
+ };
636
+ collectIds(buildDirectoryTree(groups));
637
+ setExpandedNodes(new Set(allIds));
638
+ };
639
+
640
+ const collapseAll = () => {
641
+ setExpandedNodes(new Set());
642
+ };
643
+
644
+ // Build and filter tree
645
+ const tree = buildDirectoryTree(groups);
646
+ const filteredTree = filterDirectoryTree(tree, filter.trim());
350
647
 
351
648
  const toggleTrashSelect = (path: string) => {
352
649
  setSelectedTrash((prev) => {
@@ -373,7 +670,27 @@ export function SessionsPage() {
373
670
  );
374
671
  }
375
672
 
376
- const totalSessions = groups.reduce((s, g) => s + g.totalSessions, 0);
673
+ const totalSessions = groups.reduce((s, g) => s + g.totalSessions, 0);
674
+ // Check if all expandable nodes (directories and projects) are expanded
675
+ const allExpanded = (() => {
676
+ if (groups.length === 0) return false;
677
+ const totalExpandable = countExpandableNodes(tree);
678
+ return expandedNodes.size >= totalExpandable && totalExpandable > 0;
679
+ })();
680
+
681
+ /** Count all directory and project nodes in tree */
682
+ function countExpandableNodes(nodes: TreeNode[]): number {
683
+ let count = 0;
684
+ for (const node of nodes) {
685
+ if (node.type === "directory" || node.type === "project") {
686
+ count++;
687
+ }
688
+ if (node.children) {
689
+ count += countExpandableNodes(node.children);
690
+ }
691
+ }
692
+ return count;
693
+ }
377
694
 
378
695
  return (
379
696
  <div className="space-y-6">
@@ -384,15 +701,41 @@ export function SessionsPage() {
384
701
  {t("sessions.summary", String(totalSessions), String(groups.length))}
385
702
  </p>
386
703
  </div>
387
- <button
388
- onClick={loadAll}
389
- className="flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors"
390
- style={{ borderColor: "var(--card-border)", color: "var(--muted-text)", backgroundColor: "var(--card-bg)" }}
391
- title={t("sessions.refresh")}
392
- >
393
- <RefreshCw className={refreshing ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"} />
394
- {t("sessions.refresh")}
395
- </button>
704
+ <div className="flex items-center gap-2">
705
+ {/* Expand/Collapse All button */}
706
+ {tab === "sessions" && groups.length > 0 && (
707
+ <button
708
+ onClick={() => allExpanded ? collapseAll() : expandAll()}
709
+ className="flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors"
710
+ style={{ borderColor: "var(--card-border)", color: "var(--muted-text)", backgroundColor: "var(--card-bg)" }}
711
+ title={allExpanded ? t("sessions.collapse_all") : t("sessions.expand_all")}
712
+ >
713
+ {allExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
714
+ {allExpanded ? t("sessions.collapse_all") : t("sessions.expand_all")}
715
+ </button>
716
+ )}
717
+ <button
718
+ onClick={loadAll}
719
+ className="flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors"
720
+ style={{ borderColor: "var(--card-border)", color: "var(--muted-text)", backgroundColor: "var(--card-bg)" }}
721
+ title={t("sessions.refresh")}
722
+ >
723
+ <RefreshCw className={refreshing ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"} />
724
+ {t("sessions.refresh")}
725
+ </button>
726
+ {tab === "sessions" && groups.length > 0 && (
727
+ <button
728
+ onClick={handleAutoExpire}
729
+ disabled={expiring}
730
+ className="flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors"
731
+ style={{ borderColor: "var(--card-border)", color: "var(--muted-text)", backgroundColor: "var(--card-bg)", opacity: expiring ? 0.6 : 1 }}
732
+ title={t("sessions.auto_expire_tooltip", String(settings?.sessionExpiryDays ?? 7))}
733
+ >
734
+ <ArchiveX className={expiring ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"} />
735
+ {t("sessions.auto_expire")}
736
+ </button>
737
+ )}
738
+ </div>
396
739
  </div>
397
740
 
398
741
  {/* Tabs: Sessions / Trash */}
@@ -443,27 +786,36 @@ export function SessionsPage() {
443
786
  color: "var(--input-text)",
444
787
  }}
445
788
  />
446
- <History className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4" style={{ color: "var(--muted-text)" }} />
789
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4" style={{ color: "var(--muted-text)" }} />
447
790
  </div>
448
791
 
449
- {/* Project Groups */}
450
- <div className="space-y-3">
451
- {filteredGroups.length === 0 ? (
792
+ {/* Tree View */}
793
+ <div
794
+ className="rounded-xl border overflow-hidden"
795
+ style={{ borderColor: "var(--card-border)" }}
796
+ >
797
+ {filteredTree.length === 0 ? (
452
798
  <div className="flex flex-col items-center justify-center py-12">
453
799
  <History className="h-12 w-12" style={{ color: "var(--subtle-text)" }} />
454
- <p className="mt-4 text-sm" style={{ color: "var(--muted-text)" }}>{t("sessions.no_sessions")}</p>
800
+ <p className="mt-4 text-sm" style={{ color: "var(--muted-text)" }}>
801
+ {filter ? t("sessions.no_search_results") : t("sessions.no_sessions")}
802
+ </p>
455
803
  </div>
456
804
  ) : (
457
- filteredGroups.map((group, i) => (
458
- <ProjectCard
459
- key={group.projectPath}
460
- group={group}
461
- defaultOpen={i < 3}
462
- forceOpen={!!q}
463
- onDelete={(session, groupPath) => setDeleteTarget({ session, groupPath })}
464
- onPreview={openPreview}
465
- />
466
- ))
805
+ <div>
806
+ {filteredTree.map((node) => (
807
+ <TreeNodeItem
808
+ key={node.id}
809
+ node={node}
810
+ level={0}
811
+ expandedNodes={expandedNodes}
812
+ onToggle={toggleNode}
813
+ onDelete={(session, groupPath) => setDeleteTarget({ session, groupPath })}
814
+ onPreview={openPreview}
815
+ t={t}
816
+ />
817
+ ))}
818
+ </div>
467
819
  )}
468
820
  </div>
469
821
  </>