@raingor/pi-web-switch 0.4.0 → 0.4.1
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.
- package/package.json +5 -1
- package/public/sw.js +28 -5
- package/server/agent-session-manager.ts +827 -0
- package/server/chat-api-plugin.ts +488 -0
- package/server/pi-reader.ts +242 -1
- package/src/App.tsx +2 -0
- package/src/components/chat/ChatInput.tsx +863 -0
- package/src/components/chat/ChatPage.tsx +617 -0
- package/src/components/chat/ChatWindow.tsx +338 -0
- package/src/components/chat/MessageView.tsx +595 -0
- package/src/components/dashboard/DashboardPage.tsx +45 -5
- package/src/components/layout/AppShell.tsx +12 -5
- package/src/components/layout/Sidebar.tsx +2 -0
- package/src/components/providers/ProvidersModelsPage.tsx +28 -10
- package/src/components/sessions/SessionsPage.tsx +466 -148
- package/src/hooks/useAgentSession.ts +1104 -0
- package/src/index.css +24 -0
- package/src/lib/translations/en.ts +69 -0
- package/src/lib/translations/ja.ts +69 -0
- package/src/lib/translations/zh-CN.ts +69 -0
- package/src/lib/translations/zh-TW.ts +69 -0
- package/src/main.tsx +4 -2
- package/src/store/config-store.ts +41 -0
- package/src/types/chat.ts +217 -0
- package/vite.config.ts +141 -0
|
@@ -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,
|
|
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,134 +99,371 @@ function formatFullTimestamp(iso: string): string {
|
|
|
88
99
|
return d.toLocaleString();
|
|
89
100
|
}
|
|
90
101
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
const
|
|
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
|
|
112
|
-
{/*
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
+
)}
|
|
206
371
|
</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
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))}
|
|
381
|
+
</div>
|
|
382
|
+
)}
|
|
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
|
+
))}
|
|
213
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
469
|
const { initialized } = useConfigStore();
|
|
@@ -236,6 +484,8 @@ export function SessionsPage() {
|
|
|
236
484
|
const [previewTarget, setPreviewTarget] = useState<SessionInfo | null>(null);
|
|
237
485
|
const [preview, setPreview] = useState<{ messages: PreviewMessage[]; total: number } | null>(null);
|
|
238
486
|
const [previewError, setPreviewError] = useState(false);
|
|
487
|
+
// Tree state
|
|
488
|
+
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
|
239
489
|
|
|
240
490
|
const loadAll = useCallback(() => {
|
|
241
491
|
if (!initialized) return;
|
|
@@ -248,6 +498,9 @@ export function SessionsPage() {
|
|
|
248
498
|
setGroups(sessionData);
|
|
249
499
|
setTrash(trashData);
|
|
250
500
|
setError(null);
|
|
501
|
+
// Auto-expand first 3 projects
|
|
502
|
+
const firstThree = sessionData.slice(0, 3).map((g: ProjectGroup) => g.projectPath);
|
|
503
|
+
setExpandedNodes(new Set(firstThree));
|
|
251
504
|
})
|
|
252
505
|
.catch((e) => setError(e.message))
|
|
253
506
|
.finally(() => {
|
|
@@ -332,21 +585,43 @@ export function SessionsPage() {
|
|
|
332
585
|
.catch(() => setPreviewError(true));
|
|
333
586
|
};
|
|
334
587
|
|
|
335
|
-
//
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
.
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
588
|
+
// Toggle node expansion
|
|
589
|
+
const toggleNode = (id: string) => {
|
|
590
|
+
setExpandedNodes((prev) => {
|
|
591
|
+
const next = new Set(prev);
|
|
592
|
+
if (next.has(id)) {
|
|
593
|
+
next.delete(id);
|
|
594
|
+
} else {
|
|
595
|
+
next.add(id);
|
|
596
|
+
}
|
|
597
|
+
return next;
|
|
598
|
+
});
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
// Expand/collapse all - collect all directory and project node IDs
|
|
602
|
+
const expandAll = () => {
|
|
603
|
+
const allIds: string[] = [];
|
|
604
|
+
const collectIds = (nodes: TreeNode[]) => {
|
|
605
|
+
for (const node of nodes) {
|
|
606
|
+
if (node.type === "directory" || node.type === "project") {
|
|
607
|
+
allIds.push(node.id);
|
|
608
|
+
}
|
|
609
|
+
if (node.children) {
|
|
610
|
+
collectIds(node.children);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
collectIds(buildDirectoryTree(groups));
|
|
615
|
+
setExpandedNodes(new Set(allIds));
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
const collapseAll = () => {
|
|
619
|
+
setExpandedNodes(new Set());
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
// Build and filter tree
|
|
623
|
+
const tree = buildDirectoryTree(groups);
|
|
624
|
+
const filteredTree = filterDirectoryTree(tree, filter.trim());
|
|
350
625
|
|
|
351
626
|
const toggleTrashSelect = (path: string) => {
|
|
352
627
|
setSelectedTrash((prev) => {
|
|
@@ -373,7 +648,27 @@ export function SessionsPage() {
|
|
|
373
648
|
);
|
|
374
649
|
}
|
|
375
650
|
|
|
376
|
-
|
|
651
|
+
const totalSessions = groups.reduce((s, g) => s + g.totalSessions, 0);
|
|
652
|
+
// Check if all expandable nodes (directories and projects) are expanded
|
|
653
|
+
const allExpanded = (() => {
|
|
654
|
+
if (groups.length === 0) return false;
|
|
655
|
+
const totalExpandable = countExpandableNodes(tree);
|
|
656
|
+
return expandedNodes.size >= totalExpandable && totalExpandable > 0;
|
|
657
|
+
})();
|
|
658
|
+
|
|
659
|
+
/** Count all directory and project nodes in tree */
|
|
660
|
+
function countExpandableNodes(nodes: TreeNode[]): number {
|
|
661
|
+
let count = 0;
|
|
662
|
+
for (const node of nodes) {
|
|
663
|
+
if (node.type === "directory" || node.type === "project") {
|
|
664
|
+
count++;
|
|
665
|
+
}
|
|
666
|
+
if (node.children) {
|
|
667
|
+
count += countExpandableNodes(node.children);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return count;
|
|
671
|
+
}
|
|
377
672
|
|
|
378
673
|
return (
|
|
379
674
|
<div className="space-y-6">
|
|
@@ -384,15 +679,29 @@ export function SessionsPage() {
|
|
|
384
679
|
{t("sessions.summary", String(totalSessions), String(groups.length))}
|
|
385
680
|
</p>
|
|
386
681
|
</div>
|
|
387
|
-
<
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
682
|
+
<div className="flex items-center gap-2">
|
|
683
|
+
{/* Expand/Collapse All button */}
|
|
684
|
+
{tab === "sessions" && groups.length > 0 && (
|
|
685
|
+
<button
|
|
686
|
+
onClick={() => allExpanded ? collapseAll() : expandAll()}
|
|
687
|
+
className="flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors"
|
|
688
|
+
style={{ borderColor: "var(--card-border)", color: "var(--muted-text)", backgroundColor: "var(--card-bg)" }}
|
|
689
|
+
title={allExpanded ? t("sessions.collapse_all") : t("sessions.expand_all")}
|
|
690
|
+
>
|
|
691
|
+
{allExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
|
692
|
+
{allExpanded ? t("sessions.collapse_all") : t("sessions.expand_all")}
|
|
693
|
+
</button>
|
|
694
|
+
)}
|
|
695
|
+
<button
|
|
696
|
+
onClick={loadAll}
|
|
697
|
+
className="flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors"
|
|
698
|
+
style={{ borderColor: "var(--card-border)", color: "var(--muted-text)", backgroundColor: "var(--card-bg)" }}
|
|
699
|
+
title={t("sessions.refresh")}
|
|
700
|
+
>
|
|
701
|
+
<RefreshCw className={refreshing ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"} />
|
|
702
|
+
{t("sessions.refresh")}
|
|
703
|
+
</button>
|
|
704
|
+
</div>
|
|
396
705
|
</div>
|
|
397
706
|
|
|
398
707
|
{/* Tabs: Sessions / Trash */}
|
|
@@ -443,27 +752,36 @@ export function SessionsPage() {
|
|
|
443
752
|
color: "var(--input-text)",
|
|
444
753
|
}}
|
|
445
754
|
/>
|
|
446
|
-
<
|
|
755
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4" style={{ color: "var(--muted-text)" }} />
|
|
447
756
|
</div>
|
|
448
757
|
|
|
449
|
-
{/*
|
|
450
|
-
<div
|
|
451
|
-
|
|
758
|
+
{/* Tree View */}
|
|
759
|
+
<div
|
|
760
|
+
className="rounded-xl border overflow-hidden"
|
|
761
|
+
style={{ borderColor: "var(--card-border)" }}
|
|
762
|
+
>
|
|
763
|
+
{filteredTree.length === 0 ? (
|
|
452
764
|
<div className="flex flex-col items-center justify-center py-12">
|
|
453
765
|
<History className="h-12 w-12" style={{ color: "var(--subtle-text)" }} />
|
|
454
|
-
<p className="mt-4 text-sm" style={{ color: "var(--muted-text)" }}>
|
|
766
|
+
<p className="mt-4 text-sm" style={{ color: "var(--muted-text)" }}>
|
|
767
|
+
{filter ? t("sessions.no_search_results") : t("sessions.no_sessions")}
|
|
768
|
+
</p>
|
|
455
769
|
</div>
|
|
456
770
|
) : (
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
771
|
+
<div>
|
|
772
|
+
{filteredTree.map((node) => (
|
|
773
|
+
<TreeNodeItem
|
|
774
|
+
key={node.id}
|
|
775
|
+
node={node}
|
|
776
|
+
level={0}
|
|
777
|
+
expandedNodes={expandedNodes}
|
|
778
|
+
onToggle={toggleNode}
|
|
779
|
+
onDelete={(session, groupPath) => setDeleteTarget({ session, groupPath })}
|
|
780
|
+
onPreview={openPreview}
|
|
781
|
+
t={t}
|
|
782
|
+
/>
|
|
783
|
+
))}
|
|
784
|
+
</div>
|
|
467
785
|
)}
|
|
468
786
|
</div>
|
|
469
787
|
</>
|