@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.
- package/README.ja.md +32 -8
- package/README.md +58 -10
- package/README.zh-CN.md +31 -7
- package/dist-electron/main/main.cjs +2453 -0
- package/package.json +22 -5
- package/pi-package/index.ts +228 -4
- package/public/apple-touch-icon.png +0 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/pi.svg +6 -41
- package/public/sw.js +28 -5
- package/public/trayIconTemplate.png +0 -0
- package/server/pi-reader.ts +707 -27
- package/src/components/dashboard/DashboardPage.tsx +88 -16
- package/src/components/layout/AppShell.tsx +12 -5
- package/src/components/layout/Sidebar.tsx +15 -4
- package/src/components/providers/ProvidersModelsPage.tsx +84 -14
- package/src/components/sessions/SessionsPage.tsx +501 -149
- package/src/components/settings/SettingsPage.tsx +72 -0
- package/src/index.css +24 -0
- package/src/lib/translations/en.ts +31 -0
- package/src/lib/translations/ja.ts +31 -0
- package/src/lib/translations/zh-CN.ts +31 -0
- package/src/lib/translations/zh-TW.ts +31 -0
- package/src/main.tsx +31 -5
- package/src/store/config-store.ts +41 -0
- package/src/types/chat.ts +217 -0
- package/src/types/index.ts +2 -0
- package/vite.config.ts +240 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
//
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
.
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
<
|
|
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
|
-
{/*
|
|
450
|
-
<div
|
|
451
|
-
|
|
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)" }}>
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
</>
|