@raingor/pi-web-switch 0.3.1 → 0.3.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.md +0 -1
- package/README.zh-CN.md +0 -1
- package/package.json +1 -1
- package/server/pi-reader.ts +403 -4
- package/src/App.tsx +3 -0
- package/src/components/dashboard/DashboardPage.tsx +275 -117
- package/src/components/layout/Sidebar.tsx +2 -0
- package/src/components/providers/ProvidersModelsPage.tsx +1387 -0
- package/src/components/sessions/MemoryPage.tsx +480 -85
- package/src/components/sessions/SessionsPage.tsx +473 -89
- package/src/components/settings/SettingsPage.tsx +555 -243
- package/src/data/builtin-providers.ts +0 -12
- package/src/data/mock-config.ts +1 -15
- package/src/data/mock-usage.ts +0 -2
- package/src/lib/translations/en.ts +114 -23
- package/src/lib/translations/ja.ts +114 -23
- package/src/lib/translations/zh-CN.ts +114 -23
- package/src/lib/translations/zh-TW.ts +114 -23
- package/src/main.tsx +9 -0
- package/src/store/config-store.ts +18 -12
- package/src/types/index.ts +14 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vite.config.ts +115 -3
- package/src/components/models/ModelsPage.tsx +0 -570
- package/src/components/providers/ProvidersPage.tsx +0 -466
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { useState, useEffect } from "react";
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
2
|
import { useConfigStore } from "@/store/config-store";
|
|
3
3
|
import { useTranslation } from "@/lib/i18n";
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
History, MessageSquare, Clock, ChevronDown, ChevronRight, Trash2, AlertTriangle,
|
|
6
|
+
Shield, RefreshCw, Undo2, Eye,
|
|
7
|
+
} from "lucide-react";
|
|
6
8
|
import { Modal } from "@/components/ui/Modal";
|
|
7
9
|
|
|
8
10
|
function isRecent(isoDate: string): boolean {
|
|
@@ -34,6 +36,25 @@ interface ProjectGroup {
|
|
|
34
36
|
lastActive: string;
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
interface TrashEntry {
|
|
40
|
+
trashPath: string;
|
|
41
|
+
originalPath: string;
|
|
42
|
+
fileName: string;
|
|
43
|
+
trashedAt: string;
|
|
44
|
+
sessionId: string;
|
|
45
|
+
sessionName: string;
|
|
46
|
+
lastActive: string;
|
|
47
|
+
messageCount: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface PreviewMessage {
|
|
51
|
+
role: string;
|
|
52
|
+
text: string;
|
|
53
|
+
timestamp: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const SESSIONS_PER_GROUP = 50;
|
|
57
|
+
|
|
37
58
|
function formatDuration(ms?: number): string {
|
|
38
59
|
if (!ms) return "—";
|
|
39
60
|
const seconds = Math.floor(ms / 1000);
|
|
@@ -44,18 +65,47 @@ function formatDuration(ms?: number): string {
|
|
|
44
65
|
return `${hours}h ${minutes % 60}m`;
|
|
45
66
|
}
|
|
46
67
|
|
|
68
|
+
/** Relative date: today / yesterday / Nd ago / short date */
|
|
69
|
+
function formatRelativeDate(iso: string, t: (key: string, ...args: string[]) => string): string {
|
|
70
|
+
if (!iso) return "—";
|
|
71
|
+
const d = new Date(iso);
|
|
72
|
+
const days = Math.floor((Date.now() - d.getTime()) / 86_400_000);
|
|
73
|
+
if (days <= 0) return t("common.today");
|
|
74
|
+
if (days === 1) return t("common.yesterday");
|
|
75
|
+
if (days < 7) return t("common.days_ago", String(days));
|
|
76
|
+
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function sessionDisplayName(s: { name?: string; fileName: string; id?: string }): string {
|
|
80
|
+
return s.name || s.fileName.replace(/\.jsonl$/, "").split("_").pop() || s.id?.slice(0, 12) || s.fileName;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Full timestamp for tooltips — accepts ISO strings, guards invalid dates */
|
|
84
|
+
function formatFullTimestamp(iso: string): string {
|
|
85
|
+
if (!iso) return "—";
|
|
86
|
+
const d = new Date(iso);
|
|
87
|
+
if (isNaN(d.getTime())) return "—";
|
|
88
|
+
return d.toLocaleString();
|
|
89
|
+
}
|
|
90
|
+
|
|
47
91
|
function ProjectCard({
|
|
48
92
|
group,
|
|
49
93
|
defaultOpen,
|
|
94
|
+
forceOpen,
|
|
50
95
|
onDelete,
|
|
96
|
+
onPreview,
|
|
51
97
|
}: {
|
|
52
98
|
group: ProjectGroup;
|
|
53
99
|
defaultOpen: boolean;
|
|
100
|
+
forceOpen: boolean;
|
|
54
101
|
onDelete: (session: SessionInfo, groupPath: string) => void;
|
|
102
|
+
onPreview: (session: SessionInfo) => void;
|
|
55
103
|
}) {
|
|
56
104
|
const { t } = useTranslation();
|
|
57
105
|
const [open, setOpen] = useState(defaultOpen);
|
|
58
|
-
const
|
|
106
|
+
const isOpen = forceOpen || open;
|
|
107
|
+
const visibleSessions = group.sessions.slice(0, SESSIONS_PER_GROUP);
|
|
108
|
+
const hiddenCount = group.sessions.length - visibleSessions.length;
|
|
59
109
|
|
|
60
110
|
return (
|
|
61
111
|
<div className="rounded-xl border" style={{ borderColor: "var(--card-border)" }}>
|
|
@@ -72,7 +122,7 @@ function ProjectCard({
|
|
|
72
122
|
<div className="text-left">
|
|
73
123
|
<h3 className="text-sm font-semibold" style={{ color: "var(--page-text)" }}>{group.projectName}</h3>
|
|
74
124
|
<p className="text-xs" style={{ color: "var(--muted-text)" }}>
|
|
75
|
-
{
|
|
125
|
+
{t("sessions.sessions_count", String(group.totalSessions))} · {t("sessions.last_active", formatRelativeDate(group.lastActive, t))}
|
|
76
126
|
</p>
|
|
77
127
|
</div>
|
|
78
128
|
</div>
|
|
@@ -80,22 +130,22 @@ function ProjectCard({
|
|
|
80
130
|
<span className="text-xs font-medium" style={{ color: "var(--sidebar-active-text)" }}>
|
|
81
131
|
{group.totalSessions}
|
|
82
132
|
</span>
|
|
83
|
-
{
|
|
133
|
+
{isOpen ? <ChevronDown className="h-4 w-4" style={{ color: "var(--muted-text)" }} /> : <ChevronRight className="h-4 w-4" style={{ color: "var(--muted-text)" }} />}
|
|
84
134
|
</div>
|
|
85
135
|
</button>
|
|
86
136
|
|
|
87
137
|
{/* Session List */}
|
|
88
|
-
{
|
|
138
|
+
{isOpen && (
|
|
89
139
|
<div style={{ borderTop: "1px solid var(--card-border)" }}>
|
|
90
|
-
{
|
|
140
|
+
{visibleSessions.map((session) => (
|
|
91
141
|
<div
|
|
92
142
|
key={session.id || session.fileName}
|
|
93
|
-
className="flex items-center justify-between px-6 py-3"
|
|
143
|
+
className="group flex items-center justify-between px-6 py-3 cursor-pointer transition-colors hover:bg-gray-500/5"
|
|
94
144
|
style={{
|
|
95
145
|
borderBottom: "1px solid var(--card-border)",
|
|
96
146
|
backgroundColor: "var(--page-bg)",
|
|
97
|
-
opacity: deleting === session.fileName ? 0.5 : 1,
|
|
98
147
|
}}
|
|
148
|
+
onClick={() => onPreview(session)}
|
|
99
149
|
>
|
|
100
150
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
101
151
|
<div className="flex flex-col items-center justify-center w-8 h-8 rounded-md" style={{ backgroundColor: "var(--accent-bg)" }}>
|
|
@@ -103,10 +153,10 @@ function ProjectCard({
|
|
|
103
153
|
</div>
|
|
104
154
|
<div className="min-w-0 flex-1">
|
|
105
155
|
<p className="text-sm font-medium truncate" style={{ color: "var(--page-text)" }}>
|
|
106
|
-
{
|
|
156
|
+
{sessionDisplayName(session)}
|
|
107
157
|
</p>
|
|
108
158
|
<div className="flex items-center gap-3 text-xs" style={{ color: "var(--muted-text)" }}>
|
|
109
|
-
<span>{
|
|
159
|
+
<span title={formatFullTimestamp(session.timestamp)}>{formatRelativeDate(session.timestamp, t)}</span>
|
|
110
160
|
<span className="flex items-center gap-1">
|
|
111
161
|
<MessageSquare className="h-3 w-3" />
|
|
112
162
|
{session.messageCount}
|
|
@@ -124,6 +174,13 @@ function ProjectCard({
|
|
|
124
174
|
{session.provider}/{session.model?.split("-").slice(0, 2).join("-") || session.model}
|
|
125
175
|
</span>
|
|
126
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" />
|
|
183
|
+
</span>
|
|
127
184
|
{isRecent(session.lastActive) ? (
|
|
128
185
|
<span
|
|
129
186
|
className="rounded-lg p-1.5"
|
|
@@ -148,6 +205,11 @@ function ProjectCard({
|
|
|
148
205
|
</div>
|
|
149
206
|
</div>
|
|
150
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
|
+
)}
|
|
151
213
|
</div>
|
|
152
214
|
)}
|
|
153
215
|
</div>
|
|
@@ -157,32 +219,46 @@ function ProjectCard({
|
|
|
157
219
|
export function SessionsPage() {
|
|
158
220
|
const { t } = useTranslation();
|
|
159
221
|
const { initialized } = useConfigStore();
|
|
222
|
+
const [tab, setTab] = useState<"sessions" | "trash">("sessions");
|
|
160
223
|
const [groups, setGroups] = useState<ProjectGroup[]>([]);
|
|
224
|
+
const [trash, setTrash] = useState<TrashEntry[]>([]);
|
|
161
225
|
const [loading, setLoading] = useState(true);
|
|
226
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
162
227
|
const [error, setError] = useState<string | null>(null);
|
|
163
228
|
const [filter, setFilter] = useState("");
|
|
164
229
|
const [deleteTarget, setDeleteTarget] = useState<{ session: SessionInfo; groupPath: string } | null>(null);
|
|
165
230
|
const [deleting, setDeleting] = useState(false);
|
|
231
|
+
// Trash tab state
|
|
232
|
+
const [selectedTrash, setSelectedTrash] = useState<Set<string>>(new Set());
|
|
233
|
+
const [purgeTarget, setPurgeTarget] = useState<"batch" | TrashEntry | null>(null);
|
|
234
|
+
const [purging, setPurging] = useState(false);
|
|
235
|
+
// Preview modal state
|
|
236
|
+
const [previewTarget, setPreviewTarget] = useState<SessionInfo | null>(null);
|
|
237
|
+
const [preview, setPreview] = useState<{ messages: PreviewMessage[]; total: number } | null>(null);
|
|
238
|
+
const [previewError, setPreviewError] = useState(false);
|
|
166
239
|
|
|
167
|
-
const
|
|
240
|
+
const loadAll = useCallback(() => {
|
|
168
241
|
if (!initialized) return;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
.then((r) => r.json())
|
|
172
|
-
.then((
|
|
173
|
-
|
|
174
|
-
|
|
242
|
+
setRefreshing(true);
|
|
243
|
+
Promise.all([
|
|
244
|
+
fetch("/api/pi/sessions").then((r) => r.json()),
|
|
245
|
+
fetch("/api/pi/trash").then((r) => r.json()),
|
|
246
|
+
])
|
|
247
|
+
.then(([sessionData, trashData]) => {
|
|
248
|
+
setGroups(sessionData);
|
|
249
|
+
setTrash(trashData);
|
|
250
|
+
setError(null);
|
|
175
251
|
})
|
|
176
|
-
.catch((e) =>
|
|
177
|
-
|
|
252
|
+
.catch((e) => setError(e.message))
|
|
253
|
+
.finally(() => {
|
|
178
254
|
setLoading(false);
|
|
255
|
+
setRefreshing(false);
|
|
179
256
|
});
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
useEffect(() => {
|
|
183
|
-
loadSessions();
|
|
184
257
|
}, [initialized]);
|
|
185
258
|
|
|
259
|
+
useEffect(() => { loadAll(); }, [loadAll]);
|
|
260
|
+
|
|
261
|
+
// Move session to trash (recoverable)
|
|
186
262
|
const handleDelete = async () => {
|
|
187
263
|
if (!deleteTarget) return;
|
|
188
264
|
setDeleting(true);
|
|
@@ -193,20 +269,8 @@ export function SessionsPage() {
|
|
|
193
269
|
);
|
|
194
270
|
const result = await res.json();
|
|
195
271
|
if (result.success) {
|
|
196
|
-
const { groupPath, session } = deleteTarget;
|
|
197
272
|
setDeleteTarget(null);
|
|
198
|
-
|
|
199
|
-
const updated = prev
|
|
200
|
-
.map((g) => {
|
|
201
|
-
if (g.projectPath !== groupPath) return g;
|
|
202
|
-
const remaining = g.sessions.filter((s) => s.filePath !== session.filePath);
|
|
203
|
-
return remaining.length === 0
|
|
204
|
-
? null
|
|
205
|
-
: { ...g, sessions: remaining, totalSessions: remaining.length };
|
|
206
|
-
})
|
|
207
|
-
.filter(Boolean) as ProjectGroup[];
|
|
208
|
-
return updated;
|
|
209
|
-
});
|
|
273
|
+
loadAll();
|
|
210
274
|
} else {
|
|
211
275
|
alert(t("sessions.delete_failed"));
|
|
212
276
|
}
|
|
@@ -217,11 +281,81 @@ export function SessionsPage() {
|
|
|
217
281
|
}
|
|
218
282
|
};
|
|
219
283
|
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
284
|
+
const handleRestore = async (trashPath: string) => {
|
|
285
|
+
try {
|
|
286
|
+
await fetch("/api/pi/session/restore", {
|
|
287
|
+
method: "POST",
|
|
288
|
+
headers: { "Content-Type": "application/json" },
|
|
289
|
+
body: JSON.stringify({ trashPath }),
|
|
290
|
+
});
|
|
291
|
+
} catch { /* reload below reflects the actual state */ }
|
|
292
|
+
setSelectedTrash((prev) => { const next = new Set(prev); next.delete(trashPath); return next; });
|
|
293
|
+
loadAll();
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const handlePurge = async () => {
|
|
297
|
+
if (!purgeTarget) return;
|
|
298
|
+
setPurging(true);
|
|
299
|
+
const paths = purgeTarget === "batch" ? [...selectedTrash] : [purgeTarget.trashPath];
|
|
300
|
+
for (const p of paths) {
|
|
301
|
+
try {
|
|
302
|
+
await fetch(`/api/pi/trash?path=${encodeURIComponent(p)}`, { method: "DELETE" });
|
|
303
|
+
} catch { /* continue */ }
|
|
304
|
+
}
|
|
305
|
+
setPurging(false);
|
|
306
|
+
setPurgeTarget(null);
|
|
307
|
+
setSelectedTrash(new Set());
|
|
308
|
+
loadAll();
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const handleBatchRestore = async () => {
|
|
312
|
+
for (const p of selectedTrash) {
|
|
313
|
+
try {
|
|
314
|
+
await fetch("/api/pi/session/restore", {
|
|
315
|
+
method: "POST",
|
|
316
|
+
headers: { "Content-Type": "application/json" },
|
|
317
|
+
body: JSON.stringify({ trashPath: p }),
|
|
318
|
+
});
|
|
319
|
+
} catch { /* continue */ }
|
|
320
|
+
}
|
|
321
|
+
setSelectedTrash(new Set());
|
|
322
|
+
loadAll();
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const openPreview = (session: SessionInfo) => {
|
|
326
|
+
setPreviewTarget(session);
|
|
327
|
+
setPreview(null);
|
|
328
|
+
setPreviewError(false);
|
|
329
|
+
fetch(`/api/pi/session-preview?path=${encodeURIComponent(session.filePath)}`)
|
|
330
|
+
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
|
|
331
|
+
.then(setPreview)
|
|
332
|
+
.catch(() => setPreviewError(true));
|
|
333
|
+
};
|
|
334
|
+
|
|
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);
|
|
350
|
+
|
|
351
|
+
const toggleTrashSelect = (path: string) => {
|
|
352
|
+
setSelectedTrash((prev) => {
|
|
353
|
+
const next = new Set(prev);
|
|
354
|
+
if (next.has(path)) next.delete(path); else next.add(path);
|
|
355
|
+
return next;
|
|
356
|
+
});
|
|
357
|
+
};
|
|
358
|
+
const allTrashSelected = trash.length > 0 && trash.every((e) => selectedTrash.has(e.trashPath));
|
|
225
359
|
|
|
226
360
|
if (loading) {
|
|
227
361
|
return (
|
|
@@ -234,7 +368,7 @@ export function SessionsPage() {
|
|
|
234
368
|
if (error) {
|
|
235
369
|
return (
|
|
236
370
|
<div className="flex items-center justify-center h-64">
|
|
237
|
-
<p className="text-sm" style={{ color: "var(--error-text, #ef4444)" }}>{t("sessions.
|
|
371
|
+
<p className="text-sm" style={{ color: "var(--error-text, #ef4444)" }}>{t("sessions.load_failed")}: {error}</p>
|
|
238
372
|
</div>
|
|
239
373
|
);
|
|
240
374
|
}
|
|
@@ -243,50 +377,193 @@ export function SessionsPage() {
|
|
|
243
377
|
|
|
244
378
|
return (
|
|
245
379
|
<div className="space-y-6">
|
|
246
|
-
<div>
|
|
247
|
-
<
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
380
|
+
<div className="flex items-start justify-between">
|
|
381
|
+
<div>
|
|
382
|
+
<h1 className="text-2xl font-bold" style={{ color: "var(--page-text)" }}>{t("sessions.title")}</h1>
|
|
383
|
+
<p className="mt-1 text-sm" style={{ color: "var(--muted-text)" }}>
|
|
384
|
+
{t("sessions.summary", String(totalSessions), String(groups.length))}
|
|
385
|
+
</p>
|
|
386
|
+
</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>
|
|
251
396
|
</div>
|
|
252
397
|
|
|
253
|
-
{/*
|
|
254
|
-
<div className="
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
398
|
+
{/* Tabs: Sessions / Trash */}
|
|
399
|
+
<div className="flex items-center gap-1 border-b" style={{ borderColor: "var(--card-border)" }}>
|
|
400
|
+
{([
|
|
401
|
+
{ key: "sessions" as const, label: t("sessions.tab_sessions"), count: totalSessions },
|
|
402
|
+
{ key: "trash" as const, label: t("sessions.tab_trash"), count: trash.length },
|
|
403
|
+
]).map((item) => (
|
|
404
|
+
<button
|
|
405
|
+
key={item.key}
|
|
406
|
+
onClick={() => setTab(item.key)}
|
|
407
|
+
className="flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors"
|
|
408
|
+
style={{
|
|
409
|
+
color: tab === item.key ? "#3b82f6" : "var(--muted-text)",
|
|
410
|
+
borderBottomColor: tab === item.key ? "#3b82f6" : "transparent",
|
|
411
|
+
}}
|
|
412
|
+
>
|
|
413
|
+
{item.key === "trash" && <Trash2 className="h-3.5 w-3.5" />}
|
|
414
|
+
{item.label}
|
|
415
|
+
{item.count > 0 && (
|
|
416
|
+
<span
|
|
417
|
+
className="rounded-full px-1.5 py-0.5 text-[10px] font-semibold"
|
|
418
|
+
style={{
|
|
419
|
+
backgroundColor: item.key === "trash" && item.count > 0 ? "rgba(239,68,68,0.15)" : "var(--accent-bg)",
|
|
420
|
+
color: item.key === "trash" && item.count > 0 ? "#ef4444" : "var(--sidebar-active-text)",
|
|
421
|
+
}}
|
|
422
|
+
>
|
|
423
|
+
{item.count}
|
|
424
|
+
</span>
|
|
425
|
+
)}
|
|
426
|
+
</button>
|
|
427
|
+
))}
|
|
268
428
|
</div>
|
|
269
429
|
|
|
270
|
-
{
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
<div className="
|
|
274
|
-
<
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
430
|
+
{tab === "sessions" && (
|
|
431
|
+
<>
|
|
432
|
+
{/* Search */}
|
|
433
|
+
<div className="relative">
|
|
434
|
+
<input
|
|
435
|
+
type="text"
|
|
436
|
+
value={filter}
|
|
437
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
438
|
+
placeholder={t("sessions.filter_placeholder")}
|
|
439
|
+
className="w-full rounded-lg border px-4 py-2.5 pl-10 text-sm"
|
|
440
|
+
style={{
|
|
441
|
+
backgroundColor: "var(--input-bg)",
|
|
442
|
+
borderColor: "var(--input-border)",
|
|
443
|
+
color: "var(--input-text)",
|
|
444
|
+
}}
|
|
284
445
|
/>
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
446
|
+
<History className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4" style={{ color: "var(--muted-text)" }} />
|
|
447
|
+
</div>
|
|
448
|
+
|
|
449
|
+
{/* Project Groups */}
|
|
450
|
+
<div className="space-y-3">
|
|
451
|
+
{filteredGroups.length === 0 ? (
|
|
452
|
+
<div className="flex flex-col items-center justify-center py-12">
|
|
453
|
+
<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>
|
|
455
|
+
</div>
|
|
456
|
+
) : (
|
|
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
|
+
))
|
|
467
|
+
)}
|
|
468
|
+
</div>
|
|
469
|
+
</>
|
|
470
|
+
)}
|
|
471
|
+
|
|
472
|
+
{tab === "trash" && (
|
|
473
|
+
<div className="space-y-3">
|
|
474
|
+
<p className="text-xs" style={{ color: "var(--muted-text)" }}>{t("sessions.trash_desc")}</p>
|
|
475
|
+
|
|
476
|
+
{/* Batch action bar */}
|
|
477
|
+
{trash.length > 0 && (
|
|
478
|
+
<div className="flex items-center gap-3 rounded-lg border px-4 py-2.5" style={{ borderColor: "var(--card-border)", backgroundColor: "var(--card-bg)" }}>
|
|
479
|
+
<label className="flex items-center gap-2 text-xs cursor-pointer" style={{ color: "var(--muted-text)" }}>
|
|
480
|
+
<input
|
|
481
|
+
type="checkbox"
|
|
482
|
+
checked={allTrashSelected}
|
|
483
|
+
onChange={() => setSelectedTrash(allTrashSelected ? new Set() : new Set(trash.map((e) => e.trashPath)))}
|
|
484
|
+
/>
|
|
485
|
+
{t("sessions.select_all")}
|
|
486
|
+
</label>
|
|
487
|
+
{selectedTrash.size > 0 && (
|
|
488
|
+
<>
|
|
489
|
+
<span className="text-xs" style={{ color: "var(--page-text)" }}>{t("sessions.selected_count", String(selectedTrash.size))}</span>
|
|
490
|
+
<button
|
|
491
|
+
onClick={handleBatchRestore}
|
|
492
|
+
className="flex items-center gap-1 rounded-lg border px-2.5 py-1 text-xs font-medium"
|
|
493
|
+
style={{ borderColor: "#10b981", color: "#10b981" }}
|
|
494
|
+
>
|
|
495
|
+
<Undo2 className="h-3 w-3" />
|
|
496
|
+
{t("sessions.restore_selected")}
|
|
497
|
+
</button>
|
|
498
|
+
<button
|
|
499
|
+
onClick={() => setPurgeTarget("batch")}
|
|
500
|
+
className="flex items-center gap-1 rounded-lg border px-2.5 py-1 text-xs font-medium"
|
|
501
|
+
style={{ borderColor: "#ef4444", color: "#ef4444" }}
|
|
502
|
+
>
|
|
503
|
+
<Trash2 className="h-3 w-3" />
|
|
504
|
+
{t("sessions.delete_selected")}
|
|
505
|
+
</button>
|
|
506
|
+
</>
|
|
507
|
+
)}
|
|
508
|
+
</div>
|
|
509
|
+
)}
|
|
510
|
+
|
|
511
|
+
{trash.length === 0 ? (
|
|
512
|
+
<div className="flex flex-col items-center justify-center py-12">
|
|
513
|
+
<Trash2 className="h-12 w-12" style={{ color: "var(--subtle-text)" }} />
|
|
514
|
+
<p className="mt-4 text-sm" style={{ color: "var(--muted-text)" }}>{t("sessions.trash_empty")}</p>
|
|
515
|
+
</div>
|
|
516
|
+
) : (
|
|
517
|
+
trash.map((entry) => (
|
|
518
|
+
<div
|
|
519
|
+
key={entry.trashPath}
|
|
520
|
+
className="flex items-center gap-3 rounded-lg border px-4 py-3"
|
|
521
|
+
style={{
|
|
522
|
+
borderColor: selectedTrash.has(entry.trashPath) ? "#3b82f6" : "var(--card-border)",
|
|
523
|
+
backgroundColor: "var(--card-bg)",
|
|
524
|
+
}}
|
|
525
|
+
>
|
|
526
|
+
<input
|
|
527
|
+
type="checkbox"
|
|
528
|
+
checked={selectedTrash.has(entry.trashPath)}
|
|
529
|
+
onChange={() => toggleTrashSelect(entry.trashPath)}
|
|
530
|
+
/>
|
|
531
|
+
<div className="min-w-0 flex-1">
|
|
532
|
+
<p className="text-sm font-medium truncate" style={{ color: "var(--page-text)" }}>
|
|
533
|
+
{entry.sessionName || entry.fileName.replace(/\.jsonl$/, "")}
|
|
534
|
+
</p>
|
|
535
|
+
<div className="flex items-center gap-3 text-xs mt-0.5" style={{ color: "var(--muted-text)" }}>
|
|
536
|
+
<span style={{ color: "#ef4444" }}>{t("sessions.trashed_at", formatRelativeDate(entry.trashedAt, t))}</span>
|
|
537
|
+
<span className="flex items-center gap-1">
|
|
538
|
+
<MessageSquare className="h-3 w-3" />
|
|
539
|
+
{entry.messageCount}
|
|
540
|
+
</span>
|
|
541
|
+
<span className="truncate">{entry.fileName}</span>
|
|
542
|
+
</div>
|
|
543
|
+
</div>
|
|
544
|
+
<button
|
|
545
|
+
onClick={() => handleRestore(entry.trashPath)}
|
|
546
|
+
className="flex items-center gap-1 rounded-lg border px-2.5 py-1.5 text-xs font-medium shrink-0"
|
|
547
|
+
style={{ borderColor: "#10b981", color: "#10b981" }}
|
|
548
|
+
>
|
|
549
|
+
<Undo2 className="h-3 w-3" />
|
|
550
|
+
{t("sessions.restore")}
|
|
551
|
+
</button>
|
|
552
|
+
<button
|
|
553
|
+
onClick={() => setPurgeTarget(entry)}
|
|
554
|
+
className="flex items-center gap-1 rounded-lg border px-2.5 py-1.5 text-xs font-medium shrink-0"
|
|
555
|
+
style={{ borderColor: "#ef4444", color: "#ef4444" }}
|
|
556
|
+
>
|
|
557
|
+
<Trash2 className="h-3 w-3" />
|
|
558
|
+
{t("sessions.delete_forever")}
|
|
559
|
+
</button>
|
|
560
|
+
</div>
|
|
561
|
+
))
|
|
562
|
+
)}
|
|
563
|
+
</div>
|
|
564
|
+
)}
|
|
288
565
|
|
|
289
|
-
{/*
|
|
566
|
+
{/* Move-to-Trash Confirmation Modal */}
|
|
290
567
|
<Modal
|
|
291
568
|
open={!!deleteTarget}
|
|
292
569
|
onClose={() => !deleting && setDeleteTarget(null)}
|
|
@@ -294,21 +571,21 @@ export function SessionsPage() {
|
|
|
294
571
|
>
|
|
295
572
|
<div className="space-y-4">
|
|
296
573
|
<div className="flex items-start gap-3">
|
|
297
|
-
<AlertTriangle className="h-5 w-5 shrink-0 mt-0.5" style={{ color: "#
|
|
574
|
+
<AlertTriangle className="h-5 w-5 shrink-0 mt-0.5" style={{ color: "#f59e0b" }} />
|
|
298
575
|
<div>
|
|
299
576
|
<p className="text-sm" style={{ color: "var(--page-text)" }}>
|
|
300
|
-
|
|
577
|
+
{t("sessions.delete_confirm")}
|
|
301
578
|
</p>
|
|
302
579
|
<p className="text-xs mt-1" style={{ color: "var(--muted-text)" }}>
|
|
303
580
|
{deleteTarget?.session.name || deleteTarget?.session.fileName}
|
|
304
581
|
</p>
|
|
305
582
|
{deleteTarget?.session.messageCount && (
|
|
306
583
|
<p className="text-xs mt-1" style={{ color: "var(--subtle-text)" }}>
|
|
307
|
-
{deleteTarget.session.messageCount}
|
|
584
|
+
{t("sessions.messages", String(deleteTarget.session.messageCount))} · {formatFullTimestamp(deleteTarget.session.lastActive || deleteTarget.session.timestamp)}
|
|
308
585
|
</p>
|
|
309
586
|
)}
|
|
310
587
|
<p className="text-xs mt-2" style={{ color: "var(--subtle-text)" }}>
|
|
311
|
-
|
|
588
|
+
{t("sessions.delete_to_trash_note")}
|
|
312
589
|
</p>
|
|
313
590
|
</div>
|
|
314
591
|
</div>
|
|
@@ -319,7 +596,7 @@ export function SessionsPage() {
|
|
|
319
596
|
className="rounded-lg px-4 py-2 text-sm"
|
|
320
597
|
style={{ color: "var(--muted-text)" }}
|
|
321
598
|
>
|
|
322
|
-
|
|
599
|
+
{t("sessions.cancel")}
|
|
323
600
|
</button>
|
|
324
601
|
<button
|
|
325
602
|
onClick={handleDelete}
|
|
@@ -330,18 +607,125 @@ export function SessionsPage() {
|
|
|
330
607
|
{deleting ? (
|
|
331
608
|
<>
|
|
332
609
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
|
333
|
-
|
|
610
|
+
{t("sessions.deleting")}
|
|
334
611
|
</>
|
|
335
612
|
) : (
|
|
336
613
|
<>
|
|
337
614
|
<Trash2 className="h-4 w-4" />
|
|
338
|
-
|
|
615
|
+
{t("sessions.delete")}
|
|
339
616
|
</>
|
|
340
617
|
)}
|
|
341
618
|
</button>
|
|
342
619
|
</div>
|
|
343
620
|
</div>
|
|
344
621
|
</Modal>
|
|
622
|
+
|
|
623
|
+
{/* Permanent-Delete Confirmation Modal */}
|
|
624
|
+
<Modal
|
|
625
|
+
open={!!purgeTarget}
|
|
626
|
+
onClose={() => !purging && setPurgeTarget(null)}
|
|
627
|
+
title={t("sessions.delete_forever")}
|
|
628
|
+
>
|
|
629
|
+
<div className="space-y-4">
|
|
630
|
+
<div className="flex items-start gap-3">
|
|
631
|
+
<AlertTriangle className="h-5 w-5 shrink-0 mt-0.5" style={{ color: "#ef4444" }} />
|
|
632
|
+
<div>
|
|
633
|
+
<p className="text-sm" style={{ color: "var(--page-text)" }}>
|
|
634
|
+
{t("sessions.delete_forever_confirm")}
|
|
635
|
+
</p>
|
|
636
|
+
<p className="text-xs mt-1" style={{ color: "var(--muted-text)" }}>
|
|
637
|
+
{purgeTarget === "batch"
|
|
638
|
+
? t("sessions.selected_count", String(selectedTrash.size))
|
|
639
|
+
: purgeTarget?.sessionName || purgeTarget?.fileName}
|
|
640
|
+
</p>
|
|
641
|
+
</div>
|
|
642
|
+
</div>
|
|
643
|
+
<div className="flex justify-end gap-3">
|
|
644
|
+
<button
|
|
645
|
+
onClick={() => setPurgeTarget(null)}
|
|
646
|
+
disabled={purging}
|
|
647
|
+
className="rounded-lg px-4 py-2 text-sm"
|
|
648
|
+
style={{ color: "var(--muted-text)" }}
|
|
649
|
+
>
|
|
650
|
+
{t("sessions.cancel")}
|
|
651
|
+
</button>
|
|
652
|
+
<button
|
|
653
|
+
onClick={handlePurge}
|
|
654
|
+
disabled={purging}
|
|
655
|
+
className="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white"
|
|
656
|
+
style={{ backgroundColor: "#dc2626" }}
|
|
657
|
+
>
|
|
658
|
+
{purging ? (
|
|
659
|
+
<>
|
|
660
|
+
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
|
661
|
+
{t("sessions.deleting")}
|
|
662
|
+
</>
|
|
663
|
+
) : (
|
|
664
|
+
<>
|
|
665
|
+
<Trash2 className="h-4 w-4" />
|
|
666
|
+
{t("sessions.delete_forever")}
|
|
667
|
+
</>
|
|
668
|
+
)}
|
|
669
|
+
</button>
|
|
670
|
+
</div>
|
|
671
|
+
</div>
|
|
672
|
+
</Modal>
|
|
673
|
+
|
|
674
|
+
{/* Session Preview Modal */}
|
|
675
|
+
<Modal
|
|
676
|
+
open={!!previewTarget}
|
|
677
|
+
onClose={() => setPreviewTarget(null)}
|
|
678
|
+
title={previewTarget ? sessionDisplayName(previewTarget) : t("sessions.preview_title")}
|
|
679
|
+
size="lg"
|
|
680
|
+
>
|
|
681
|
+
<div className="max-h-[60vh] overflow-y-auto space-y-2.5">
|
|
682
|
+
{previewError ? (
|
|
683
|
+
<p className="text-sm py-6 text-center" style={{ color: "#ef4444" }}>{t("sessions.preview_failed")}</p>
|
|
684
|
+
) : !preview ? (
|
|
685
|
+
<div className="flex items-center justify-center py-10">
|
|
686
|
+
<div className="h-6 w-6 animate-spin rounded-full border-2 border-gray-600 border-t-blue-500" />
|
|
687
|
+
</div>
|
|
688
|
+
) : preview.messages.length === 0 ? (
|
|
689
|
+
<p className="text-sm py-6 text-center" style={{ color: "var(--muted-text)" }}>{t("sessions.preview_empty")}</p>
|
|
690
|
+
) : (
|
|
691
|
+
<>
|
|
692
|
+
<p className="text-xs" style={{ color: "var(--subtle-text)" }}>
|
|
693
|
+
{t("sessions.preview_total", String(preview.total), String(preview.messages.length))}
|
|
694
|
+
</p>
|
|
695
|
+
{preview.messages.map((m, i) => (
|
|
696
|
+
<div
|
|
697
|
+
key={i}
|
|
698
|
+
className="rounded-lg border px-3.5 py-2.5"
|
|
699
|
+
style={{
|
|
700
|
+
backgroundColor: "var(--card-bg)",
|
|
701
|
+
borderColor: m.role === "user" ? "rgba(59,130,246,0.4)" : "var(--card-border)",
|
|
702
|
+
}}
|
|
703
|
+
>
|
|
704
|
+
<div className="flex items-center gap-2 mb-1">
|
|
705
|
+
<span
|
|
706
|
+
className="rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase"
|
|
707
|
+
style={{
|
|
708
|
+
backgroundColor: m.role === "user" ? "rgba(59,130,246,0.15)" : "var(--accent-bg)",
|
|
709
|
+
color: m.role === "user" ? "#3b82f6" : "var(--sidebar-active-text)",
|
|
710
|
+
}}
|
|
711
|
+
>
|
|
712
|
+
{m.role}
|
|
713
|
+
</span>
|
|
714
|
+
{m.timestamp && (
|
|
715
|
+
<span className="text-[10px]" style={{ color: "var(--subtle-text)" }}>
|
|
716
|
+
{new Date(m.timestamp).toLocaleString()}
|
|
717
|
+
</span>
|
|
718
|
+
)}
|
|
719
|
+
</div>
|
|
720
|
+
<p className="whitespace-pre-wrap text-xs leading-relaxed break-words" style={{ color: "var(--page-text)" }}>
|
|
721
|
+
{m.text || "—"}
|
|
722
|
+
</p>
|
|
723
|
+
</div>
|
|
724
|
+
))}
|
|
725
|
+
</>
|
|
726
|
+
)}
|
|
727
|
+
</div>
|
|
728
|
+
</Modal>
|
|
345
729
|
</div>
|
|
346
730
|
);
|
|
347
731
|
}
|