@raingor/pi-web-switch 0.3.0 → 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.
@@ -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 { formatDateFull } from "@/lib/utils";
5
- import { History, MessageSquare, Clock, ChevronDown, ChevronRight, Trash2, AlertTriangle, Shield } from "lucide-react";
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 [deleting, setDeleting] = useState<string | null>(null);
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
- {group.totalSessions} {t("sessions.sessions_count", String(group.totalSessions)).split(" ")[0]} · {t("sessions.last_active", new Date(group.lastActive).toLocaleDateString())}
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
- {open ? <ChevronDown className="h-4 w-4" style={{ color: "var(--muted-text)" }} /> : <ChevronRight className="h-4 w-4" style={{ color: "var(--muted-text)" }} />}
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
- {open && (
138
+ {isOpen && (
89
139
  <div style={{ borderTop: "1px solid var(--card-border)" }}>
90
- {group.sessions.map((session) => (
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
- {session.name || session.fileName.replace(/\.jsonl$/, "").split("_").pop() || session.id.slice(0, 12)}
156
+ {sessionDisplayName(session)}
107
157
  </p>
108
158
  <div className="flex items-center gap-3 text-xs" style={{ color: "var(--muted-text)" }}>
109
- <span>{formatDateFull(session.timestamp)}</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 loadSessions = () => {
240
+ const loadAll = useCallback(() => {
168
241
  if (!initialized) return;
169
- setLoading(true);
170
- fetch("/api/pi/sessions")
171
- .then((r) => r.json())
172
- .then((data) => {
173
- setGroups(data);
174
- setLoading(false);
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
- setError(e.message);
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
- setGroups((prev) => {
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 filteredGroups = filter
221
- ? groups.filter((g) =>
222
- g.projectName.toLowerCase().includes(filter.toLowerCase())
223
- )
224
- : groups;
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.delete_failed")}: {error}</p>
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
- <h1 className="text-2xl font-bold" style={{ color: "var(--page-text)" }}>{t("sessions.title")}</h1>
248
- <p className="mt-1 text-sm" style={{ color: "var(--muted-text)" }}>
249
- {t("sessions.summary", String(totalSessions), String(groups.length))}
250
- </p>
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
- {/* Search */}
254
- <div className="relative">
255
- <input
256
- type="text"
257
- value={filter}
258
- onChange={(e) => setFilter(e.target.value)}
259
- placeholder={t("sessions.filter_placeholder")}
260
- className="w-full rounded-lg border px-4 py-2.5 pl-10 text-sm"
261
- style={{
262
- backgroundColor: "var(--input-bg)",
263
- borderColor: "var(--input-border)",
264
- color: "var(--input-text)",
265
- }}
266
- />
267
- <History className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4" style={{ color: "var(--muted-text)" }} />
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
- {/* Project Groups */}
271
- <div className="space-y-3">
272
- {filteredGroups.length === 0 ? (
273
- <div className="flex flex-col items-center justify-center py-12">
274
- <History className="h-12 w-12" style={{ color: "var(--subtle-text)" }} />
275
- <p className="mt-4 text-sm" style={{ color: "var(--muted-text)" }}>{t("sessions.no_sessions")}</p>
276
- </div>
277
- ) : (
278
- filteredGroups.map((group, i) => (
279
- <ProjectCard
280
- key={group.projectPath}
281
- group={group}
282
- defaultOpen={i < 3}
283
- onDelete={(session, groupPath) => setDeleteTarget({ session, groupPath })}
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
- </div>
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
- {/* Delete Confirmation Modal */}
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: "#ef4444" }} />
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
- Are you sure you want to delete this session?
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} messages · {formatDateFull(deleteTarget.session.timestamp)}
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
- This will permanently delete the session file from disk.
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
- Cancel
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
- Deleting...
610
+ {t("sessions.deleting")}
334
611
  </>
335
612
  ) : (
336
613
  <>
337
614
  <Trash2 className="h-4 w-4" />
338
- Delete
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
  }