返回 DeepSeek-Reasonix
HistoryPanel.tsx
根目录 / desktop / frontend / src / components / HistoryPanel.tsx
1 import { useManagementT } from "../lib/managementLocale";
2 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3 import type { MouseEvent as ReactMouseEvent } from "react";
4 import { Archive, GitBranch, Pencil, Search, Trash2, RotateCcw } from "lucide-react";
5 import { app } from "../lib/bridge";
6 import { t, useT } from "../lib/i18n";
7 import { historySearchHitDisplayTitle, historySessionDisplayTitle, sessionActivityTime } from "../lib/session";
8 import type { HistoryMessage, HistorySearchContextLine, HistorySearchHit, RecoveryLineageView, SessionMeta } from "../lib/types";
9 import { historyMessagesToItems, type Item } from "../lib/useController";
10 import { useHistoryCatalog } from "../lib/useHistoryCatalog";
11 import { Transcript } from "./Transcript";
12 import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
13 import { useDeferredClose } from "../lib/useMountTransition";
14 import { ModalCloseButton } from "./ModalCloseButton";
15 import { HistoryFilterSelect } from "./HistoryFilterSelect";
16 import { normalizeRecoveryLineageView, userVisibleRecoveryVersions } from "../lib/sessionRecoveryVersions";
17
18 type HistoryScopeFilter = "all" | "project" | "global";
19 type HistoryStatusFilter = "all" | "current" | "open";
20 type HistoryDateFilter = "all" | "today" | "yesterday" | "older";
21
22 // HistoryPanel lists saved sessions newest-first. In the wide management modal,
23 // a single click selects a read-only preview; explicit actions resume, restore,
24 // rename, or delete the selected session.
25 export function HistoryPanel({
26 presentation = "dialog", active = true, busy = false,
27 kind = "history",
28 sessions: suppliedSessions,
29 running,
30 onResume,
31 onPreview,
32 onDelete,
33 onRename,
34 onRestore,
35 onPurge,
36 onPurgeAll,
37 onInspectVersions,
38 onClose,
39 }: {
40 presentation?: "dialog" | "page"; active?: boolean; busy?: boolean;
41 kind?: "history" | "trash";
42 sessions: SessionMeta[];
43 running: boolean;
44 onResume: (session: SessionMeta) => void;
45 onPreview: (path: string) => Promise<HistoryMessage[]>;
46 onDelete: (path: string) => void;
47 onRename: (session: SessionMeta, title: string) => void;
48 onRestore?: (path: string) => Promise<void>;
49 onPurge?: (path: string) => Promise<void>;
50 onPurgeAll?: (paths: string[]) => Promise<void>;
51 onInspectVersions?: (session: SessionMeta, view: RecoveryLineageView) => void;
52 onClose: () => void;
53 }) {
54 const tr = useT();
55 const m = useManagementT();
56 const [detailVisible, setDetailVisible] = useState(false);
57 const previousPaths = useRef<string[]>([]);
58 const isTrash = kind === "trash";
59 // Play the modal exit animation, then let the parent unmount us.
60 const { status, requestClose } = useDeferredClose(onClose, 240);
61 const [editing, setEditing] = useState<string | null>(null);
62 const [draft, setDraft] = useState("");
63 const [query, setQuery] = useState("");
64 const [scopeFilter, setScopeFilter] = useState<HistoryScopeFilter>("all");
65 const [statusFilter, setStatusFilter] = useState<HistoryStatusFilter>("all");
66 const [dateFilter, setDateFilter] = useState<HistoryDateFilter>("all");
67 const [showSystemRecoveryData, setShowSystemRecoveryData] = useState(false);
68 const [selectedVersions, setSelectedVersions] = useState<RecoveryLineageView | null>(null);
69 const [searchContext, setSearchContext] = useState<{ hit: HistorySearchHit; lines: HistorySearchContextLine[]; loading: boolean } | null>(null);
70 const { sessions, nextCursor, partial: catalogPartial, progress: catalogProgress, searchHits, loadMore } = useHistoryCatalog({
71 isTrash, suppliedSessions, scope: scopeFilter, status: statusFilter, timeFilter: dateFilter, query,
72 });
73 const [menuSession, setMenuSession] = useState<SessionMeta | null>(null);
74 const [menuPoint, setMenuPoint] = useState<ContextMenuPoint | null>(null);
75 const [blankMenuPoint, setBlankMenuPoint] = useState<ContextMenuPoint | null>(null);
76 const [menuConfirmTarget, setMenuConfirmTarget] = useState<
77 { kind: "delete"; path: string } | { kind: "purge"; path: string } | { kind: "clear" } | null
78 >(null);
79 const [preview, setPreview] = useState<{
80 path: string;
81 title: string;
82 meta: string;
83 messages: HistoryMessage[];
84 loading: boolean;
85 error?: boolean;
86 } | null>(null);
87 const previewSeq = useRef(0);
88 const lineageSeq = useRef(0);
89
90 const loadSearchContext = useCallback(async (hit: HistorySearchHit) => {
91 const seq = ++previewSeq.current;
92 setPreview(null);
93 setSearchContext({ hit, lines: [], loading: true });
94 const lines = await app.GetHistorySearchContext({ sessionPath: hit.sessionPath, messageIndex: hit.messageIndex, before: 2, after: 2 }).catch(() => []);
95 if (seq === previewSeq.current) setSearchContext({ hit, lines, loading: false });
96 }, []);
97
98 const startRename = (s: SessionMeta) => {
99 if (running) return;
100 setEditing(s.path);
101 setDraft((s.topicId ? s.topicTitle : s.title) || s.preview || "");
102 };
103 const commitRename = (session: SessionMeta) => {
104 if (running) return;
105 onRename(session, draft.trim());
106 setEditing(null);
107 };
108 const loadPreview = useCallback(
109 async (s: SessionMeta) => {
110 const seq = ++previewSeq.current;
111 setSearchContext(null);
112 setEditing(null);
113 setPreview({
114 path: s.path,
115 title: historySessionDisplayTitle(s, tr("history.emptySession")),
116 meta: sessionMetaLine(s, tr, isTrash),
117 messages: [],
118 loading: true,
119 });
120 try {
121 const messages = await onPreview(s.path);
122 if (seq === previewSeq.current) setPreview((cur) => cur?.path === s.path ? { ...cur, messages, loading: false } : cur);
123 } catch {
124 if (seq === previewSeq.current) setPreview((cur) => cur?.path === s.path ? { ...cur, loading: false, error: true } : cur);
125 }
126 },
127 [isTrash, onPreview, tr],
128 );
129
130 const ordinarySessions = useMemo(() => sessions.filter((session) => !session.recoveryCopy), [sessions]);
131 const scopeCounts = useMemo(
132 () => ({
133 all: ordinarySessions.length,
134 project: ordinarySessions.filter((s) => sessionScope(s) === "project").length,
135 global: ordinarySessions.filter((s) => sessionScope(s) === "global").length,
136 }),
137 [ordinarySessions],
138 );
139 const statusCounts = useMemo(
140 () => ({
141 all: ordinarySessions.length,
142 current: ordinarySessions.filter((s) => s.current).length,
143 open: ordinarySessions.filter((s) => s.open && !s.current).length,
144 }),
145 [ordinarySessions],
146 );
147 const dateCounts = useMemo(() => {
148 const counts: Record<HistoryDateFilter, number> = { all: ordinarySessions.length, today: 0, yesterday: 0, older: 0 };
149 for (const s of ordinarySessions) counts[dateBucket(sessionTimeForGrouping(s, isTrash))]++;
150 return counts;
151 }, [isTrash, ordinarySessions]);
152
153 useEffect(() => {
154 if (!isTrash || presentation === "page") return;
155 if (scopeFilter === "project" && scopeCounts.project === 0) setScopeFilter("all");
156 if (scopeFilter === "global" && scopeCounts.global === 0) setScopeFilter("all");
157 }, [isTrash, presentation, scopeCounts.global, scopeCounts.project, scopeFilter]);
158
159 useEffect(() => {
160 if (!isTrash) return;
161 if (statusFilter === "current" && statusCounts.current === 0) setStatusFilter("all");
162 if (statusFilter === "open" && statusCounts.open === 0) setStatusFilter("all");
163 }, [isTrash, statusCounts.current, statusCounts.open, statusFilter]);
164
165 useEffect(() => {
166 if (!isTrash || presentation === "page") return;
167 if (dateFilter !== "all" && dateCounts[dateFilter] === 0) setDateFilter("all");
168 }, [dateCounts, dateFilter, isTrash, presentation]);
169
170 const filteredSessions = useMemo(() => {
171 const q = isTrash ? query.trim().toLowerCase() : "";
172 return sessions.filter((s) => {
173 if (scopeFilter !== "all" && sessionScope(s) !== scopeFilter) return false;
174 if (!isTrash && statusFilter === "current" && !s.current) return false;
175 if (!isTrash && statusFilter === "open" && (!s.open || s.current)) return false;
176 if (dateFilter !== "all" && dateBucket(sessionTimeForGrouping(s, isTrash)) !== dateFilter) return false;
177 if (!q) return true;
178 return [s.title, s.preview, s.path, s.topicTitle, s.workspaceRoot].some((part) => (part ?? "").toLowerCase().includes(q));
179 });
180 }, [dateFilter, isTrash, query, scopeFilter, sessions, statusFilter]);
181 const displayedSessions = useMemo(
182 () => filteredSessions.filter((session) => !session.recoveryCopy),
183 [filteredSessions],
184 );
185 const systemRecoverySessions = useMemo(
186 () => isTrash ? filteredSessions.filter((session) => session.recoveryCopy) : [],
187 [filteredSessions, isTrash],
188 );
189 const selectableSessions = useMemo(
190 () => isTrash && showSystemRecoveryData ? [...displayedSessions, ...systemRecoverySessions] : displayedSessions,
191 [displayedSessions, isTrash, showSystemRecoveryData, systemRecoverySessions],
192 );
193
194 // Sessions arrive newest-first; bucket consecutive ones under a day heading
195 // (Today / Yesterday / a date) while preserving that order.
196 const groups: { label: string; items: SessionMeta[] }[] = [];
197 for (const s of displayedSessions) {
198 const label = dayLabel(sessionTimeForGrouping(s, isTrash));
199 const last = groups[groups.length - 1];
200 if (last && last.label === label) last.items.push(s);
201 else groups.push({ label, items: [s] });
202 }
203
204 useEffect(() => {
205 setMenuSession(null);
206 setMenuPoint(null);
207 setBlankMenuPoint(null);
208 setMenuConfirmTarget(null);
209 }, [isTrash]);
210
211 useEffect(() => {
212 if (isTrash) setStatusFilter("all");
213 }, [isTrash]);
214
215 useEffect(() => {
216 if (!active) return;
217 const oldPaths = previousPaths.current;
218 previousPaths.current = displayedSessions.map((item) => item.path);
219 if (preview && selectableSessions.some((item) => item.path === preview.path)) return;
220 if (!displayedSessions.length) { ++previewSeq.current; setPreview(null); return; }
221 const oldIndex = preview ? oldPaths.indexOf(preview.path) : 0;
222 const next = displayedSessions[Math.max(0, Math.min(oldIndex, displayedSessions.length - 1))];
223 void loadPreview(next);
224 }, [active, displayedSessions, loadPreview, preview, selectableSessions]);
225 useEffect(() => {
226 if (!active) { setMenuSession(null); setBlankMenuPoint(null); setMenuConfirmTarget(null); }
227 }, [active]);
228
229 const previewItems = useMemo(() => previewMessagesToItems(preview?.messages ?? []), [preview?.messages]);
230 const selectedSession = useMemo(
231 () => (preview ? selectableSessions.find((s) => s.path === preview.path) ?? null : null),
232 [preview, selectableSessions],
233 );
234 useEffect(() => {
235 const seq = ++lineageSeq.current;
236 setSelectedVersions(null);
237 if (isTrash || !selectedSession?.topicId) return;
238 const topic = {
239 scope: selectedSession.scope || "global",
240 workspaceRoot: selectedSession.workspaceRoot || undefined,
241 topicId: selectedSession.topicId,
242 path: selectedSession.path,
243 };
244 void app.GetRecoveryLineage(topic)
245 .then((value) => {
246 if (seq !== lineageSeq.current) return;
247 const view = normalizeRecoveryLineageView(value);
248 if (userVisibleRecoveryVersions(view).length > 1) setSelectedVersions(view);
249 })
250 .catch(() => undefined);
251 }, [isTrash, selectedSession?.path, selectedSession?.scope, selectedSession?.topicId, selectedSession?.workspaceRoot]);
252 const openSessionMenu = (event: ReactMouseEvent<HTMLElement>, s: SessionMeta) => {
253 event.preventDefault();
254 event.stopPropagation();
255 setMenuConfirmTarget(null);
256 setBlankMenuPoint(null);
257 setMenuSession(s);
258 setMenuPoint(contextMenuPointFromEvent(event));
259 };
260 const openTrashBlankMenu = (event: ReactMouseEvent<HTMLDivElement>) => {
261 if (!isTrash || busy || ordinarySessions.length === 0) return;
262 if (presentation === "page") { onPurgeAll?.(ordinarySessions.map((item) => item.path)); return; }
263 const target = event.target as HTMLElement | null;
264 if (target?.closest(".hist-item,.history-search,.history-preview,button,input,textarea,select")) return;
265 event.preventDefault();
266 setMenuConfirmTarget(null);
267 setMenuSession(null);
268 setMenuPoint(null);
269 setBlankMenuPoint(contextMenuPointFromEvent(event));
270 };
271 const armClearTrash = () => {
272 if (!isTrash || busy || ordinarySessions.length === 0) return;
273 if (presentation === "page") { onPurgeAll?.(ordinarySessions.map((item) => item.path)); return; }
274 setMenuSession(null);
275 setMenuPoint(null);
276 setBlankMenuPoint(null);
277 setMenuConfirmTarget({ kind: "clear" });
278 };
279 const closeHistoryMenus = () => {
280 setMenuSession(null);
281 setMenuPoint(null);
282 setBlankMenuPoint(null);
283 setMenuConfirmTarget(null);
284 };
285 const deleteHistorySession = (s: SessionMeta) => {
286 closeHistoryMenus();
287 onDelete(s.path);
288 };
289 const purgeTrashSession = (s: SessionMeta) => {
290 closeHistoryMenus();
291 if (!busy) onPurge?.(s.path);
292 };
293 const clearTrash = () => {
294 const paths = ordinarySessions.map((s) => s.path);
295 closeHistoryMenus();
296 if (!busy) onPurgeAll?.(paths);
297 };
298 const sessionMenuItems: ContextMenuItem[] = menuSession
299 ? isTrash
300 ? [
301 {
302 key: "restore",
303 icon: <RotateCcw size={13} />,
304 label: tr("history.restoreSession"),
305 onSelect: () => {
306 if (!busy) onRestore?.(menuSession.path);
307 closeHistoryMenus();
308 },
309 },
310 { type: "separator", key: "trash-session-separator" },
311 {
312 key: "purge",
313 icon: <Trash2 size={13} />,
314 label:
315 menuConfirmTarget?.kind === "purge" && menuConfirmTarget.path === menuSession.path
316 ? tr("history.confirmPurge")
317 : tr("history.purgeSession"),
318 danger: true,
319 onSelect: () => {
320 if (presentation === "page" || (menuConfirmTarget?.kind === "purge" && menuConfirmTarget.path === menuSession.path)) {
321 purgeTrashSession(menuSession);
322 } else {
323 setMenuConfirmTarget({ kind: "purge", path: menuSession.path });
324 }
325 },
326 },
327 ]
328 : [
329 {
330 key: "rename",
331 icon: <Pencil size={13} />,
332 label: tr("history.rename"),
333 disabled: running,
334 onSelect: () => {
335 const target = menuSession;
336 closeHistoryMenus();
337 startRename(target);
338 },
339 },
340 ...(menuSession.current
341 ? []
342 : [
343 {
344 key: "delete",
345 icon: <Archive size={13} />,
346 label:
347 menuConfirmTarget?.kind === "delete" && menuConfirmTarget.path === menuSession.path
348 ? tr("history.confirmMoveToTrash")
349 : tr("history.moveToTrash"),
350 disabled: running,
351 danger: menuConfirmTarget?.kind === "delete" && menuConfirmTarget.path === menuSession.path,
352 onSelect: () => {
353 if (menuConfirmTarget?.kind === "delete" && menuConfirmTarget.path === menuSession.path) {
354 deleteHistorySession(menuSession);
355 } else {
356 setMenuConfirmTarget({ kind: "delete", path: menuSession.path });
357 }
358 },
359 } as ContextMenuItem,
360 ]),
361 ]
362 : [];
363 const trashBlankMenuItems: ContextMenuItem[] =
364 menuConfirmTarget?.kind === "clear"
365 ? [
366 {
367 key: "clear-trash-confirm",
368 icon: <Trash2 size={13} />,
369 label: tr("history.confirmClearTrash"),
370 danger: true,
371 onSelect: clearTrash,
372 },
373 ]
374 : [
375 {
376 key: "clear-trash",
377 icon: <Trash2 size={13} />,
378 label: tr("history.clearTrashMenu"),
379 danger: true,
380 onSelect: armClearTrash,
381 },
382 ];
383 const actionConfirmDelete =
384 selectedSession && menuConfirmTarget?.kind === "delete" && menuConfirmTarget.path === selectedSession.path;
385 const actionConfirmPurge =
386 selectedSession && menuConfirmTarget?.kind === "purge" && menuConfirmTarget.path === selectedSession.path;
387 const actionConfirmClearTrash = isTrash && menuConfirmTarget?.kind === "clear";
388
389 const openSelected = () => {
390 if (!selectedSession || running || isTrash) return;
391 onResume(selectedSession);
392 };
393 const inspectSelectedVersions = () => {
394 if (isTrash || !selectedSession || !selectedVersions) return;
395 closeHistoryMenus();
396 onInspectVersions?.(selectedSession, selectedVersions);
397 };
398 const renameSelected = () => {
399 if (!selectedSession || running || isTrash) return;
400 closeHistoryMenus();
401 startRename(selectedSession);
402 };
403 const moveSelectedToTrash = () => {
404 if (!selectedSession || running || isTrash || selectedSession.current) return;
405 if (actionConfirmDelete) deleteHistorySession(selectedSession);
406 else setMenuConfirmTarget({ kind: "delete", path: selectedSession.path });
407 };
408 const restoreSelected = () => {
409 if (!selectedSession || !isTrash) return;
410 closeHistoryMenus();
411 if (!busy) onRestore?.(selectedSession.path);
412 };
413 const purgeSelected = () => {
414 if (!selectedSession || !isTrash) return;
415 if (presentation === "page" || actionConfirmPurge) purgeTrashSession(selectedSession);
416 else setMenuConfirmTarget({ kind: "purge", path: selectedSession.path });
417 };
418
419 const renderSessionItem = (session: SessionMeta) => {
420 const selected = preview?.path === session.path;
421 return (
422 <div
423 className={`hist-item${session.current ? " hist-item--current" : ""}${selected ? " hist-item--selected" : ""}`}
424 key={session.path}
425 onContextMenu={(event) => openSessionMenu(event, session)}
426 >
427 {editing === session.path ? (
428 <input
429 className="hist-item__rename"
430 autoFocus
431 value={draft}
432 onChange={(event) => setDraft(event.target.value)}
433 onKeyDown={(event) => {
434 if (event.key === "Enter") commitRename(session);
435 if (event.key === "Escape") setEditing(null);
436 }}
437 onBlur={() => commitRename(session)}
438 placeholder={tr("history.namePlaceholder")}
439 />
440 ) : (
441 <button
442 className="hist-item__main"
443 aria-pressed={selected}
444 onClick={() => {
445 setMenuConfirmTarget(null);
446 setDetailVisible(true);
447 void loadPreview(session);
448 }}
449 onDoubleClick={() => {
450 if (!isTrash && !running) onResume(session);
451 }}
452 >
453 <div className="hist-item__preview">{historySessionDisplayTitle(session, tr("history.emptySession"))}</div>
454 <div className="hist-item__meta">
455 {!isTrash && isChannelSession(session) && <span className="hist-item__badge hist-item__badge--open">{tr("history.channel")}</span>}
456 {!isTrash && session.current && <span className="hist-item__badge hist-item__badge--current">{tr("history.current")}</span>}
457 {!isTrash && !session.current && session.open && <span className="hist-item__badge hist-item__badge--open">{tr("history.open")}</span>}
458 {isTrash && <span className="hist-item__badge hist-item__badge--deleted">{tr("history.deleted")}</span>}
459 {sessionLocation(session, tr) && <span className="hist-item__scope">{sessionLocation(session, tr)}</span>}
460 <span className="hist-item__metaspacer" />
461 <span className="hist-item__stat">
462 {session.turnsState === "unknown"
463 ? tr("history.indexing")
464 : tr(session.turns === 1 ? "history.turnOne" : "history.turnOther", { n: session.turns })}
465 </span>
466 <span className="hist-item__dot">·</span>
467 <span className="hist-item__stat">{timeLabel(isTrash ? session.deletedAt || sessionActivityTime(session) : sessionActivityTime(session))}</span>
468 {!isTrash && running && (
469 <>
470 <span className="hist-item__dot">·</span>
471 <span className="hist-item__stat">{tr("history.preview")}</span>
472 </>
473 )}
474 </div>
475 </button>
476 )}
477 </div>
478 );
479 };
480
481 const content = (<>
482 {presentation === "dialog" && <header className="management-modal__head history-modal__head">
483 <div>
484 <div className="management-modal__title history-modal__title">{tr(isTrash ? "history.trashTitle" : "history.title")}</div>
485 {!isTrash && running && <div className="management-modal__summary history-modal__summary">{tr("history.readOnlyHint")}</div>}
486 </div>
487 <div className="management-modal__actions history-modal__actions">
488 {isTrash && ordinarySessions.length > 0 && (
489 <button
490 className={`chip history-clear${actionConfirmClearTrash ? " history-clear--confirm" : ""}`}
491 type="button" disabled={busy}
492 onClick={actionConfirmClearTrash ? clearTrash : armClearTrash}
493 >
494 {tr(actionConfirmClearTrash ? "history.confirmClearTrash" : "history.clearTrash")}
495 </button>
496 )}
497 {presentation === "dialog" && <ModalCloseButton label={tr("common.close")} onClick={requestClose} />}
498 </div>
499 </header>}
500
501 <div
502 className="history-manager"
503 onContextMenu={openTrashBlankMenu}
504 >
505 <div className="history-toolbar" aria-label={tr("history.filters")}>
506 {/* Keep search available for body-only hits (metadata sessions may be empty). */}
507 {!isTrash && (
508 <label className="mem-search history-search">
509 <Search size={13} />
510 <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={tr("history.searchPlaceholder")} />
511 </label>
512 )}
513 {isTrash && sessions.length > 0 && (
514 <label className="mem-search history-search">
515 <Search size={13} />
516 <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={tr("history.searchPlaceholder")} />
517 </label>
518 )}
519 <HistoryFilterSelect
520 label={tr("history.filterScope")}
521 options={[
522 { id: "all", label: tr("history.filterAll"), count: scopeCounts.all },
523 { id: "project", label: tr("history.filterProject"), count: scopeCounts.project },
524 { id: "global", label: tr("history.filterGlobal"), count: scopeCounts.global },
525 ]}
526 value={scopeFilter}
527 onChange={(next) => setScopeFilter(next as HistoryScopeFilter)}
528 />
529 {!isTrash && (
530 <HistoryFilterSelect
531 label={tr("history.filterStatus")}
532 options={[
533 { id: "all", label: tr("history.filterAll"), count: statusCounts.all },
534 { id: "current", label: tr("history.filterCurrent"), count: statusCounts.current },
535 { id: "open", label: tr("history.filterOpen"), count: statusCounts.open },
536 ]}
537 value={statusFilter}
538 onChange={(next) => setStatusFilter(next as HistoryStatusFilter)}
539 />
540 )}
541 <HistoryFilterSelect
542 label={tr(isTrash ? "history.filterDeletedAt" : "history.filterActivity")}
543 options={[
544 { id: "all", label: tr("history.filterAll"), count: dateCounts.all },
545 { id: "today", label: tr("history.today"), count: dateCounts.today },
546 { id: "yesterday", label: tr("history.yesterday"), count: dateCounts.yesterday },
547 { id: "older", label: tr("history.older"), count: dateCounts.older },
548 ]}
549 value={dateFilter}
550 onChange={(next) => setDateFilter(next as HistoryDateFilter)}
551 />
552 </div>
553 {!isTrash && catalogPartial && (
554 <div className="management-modal__summary history-modal__summary" role="status">
555 History index is still building ({catalogProgress.indexed}/{catalogProgress.total}); results may be incomplete.
556 </div>
557 )}
558
559 <div className="history-content" data-detail={detailVisible}>
560 <div className={`history-list${isTrash ? " history-list--trash" : ""}`}>
561 {(() => {
562 const hasBodyHits = !isTrash && searchHits.length > 0;
563 if (ordinarySessions.length === 0 && (!isTrash || systemRecoverySessions.length === 0) && !hasBodyHits) {
564 return (
565 <div className={`mem-empty${isTrash ? " mem-empty--trash" : ""}`}>
566 {isTrash && <Trash2 size={22} />}
567 <span>{tr(isTrash ? "history.trashEmpty" : "history.empty")}</span>
568 </div>
569 );
570 }
571 if (displayedSessions.length === 0 && (!isTrash || systemRecoverySessions.length === 0) && !hasBodyHits) {
572 return <div className="mem-empty">{tr("history.noResults")}<button className="btn btn--small" onClick={() => { setQuery(""); setScopeFilter("all"); setDateFilter("all"); }}>{m("clearFilters")}</button></div>;
573 }
574 return (
575 <>
576 {hasBodyHits && (
577 <section className="mem-section history-search-results">
578 <div className="mem-section__title hist-group__title">
579 <span>Content matches</span>
580 <span className="hist-group__count">{searchHits.length}</span>
581 </div>
582 {searchHits.map((hit) => (
583 <div className="hist-item" key={`${hit.sessionPath}:${hit.messageIndex}:${hit.kind}:${hit.toolName ?? ""}`}>
584 <button className="hist-item__main" type="button" onClick={() => void loadSearchContext(hit)}>
585 <div className="hist-item__preview">{historySearchHitDisplayTitle(hit)}</div>
586 <div className="hist-item__meta">
587 <span className="hist-item__badge">{hit.role} · {hit.kind}</span>
588 {hit.toolName && <span className="hist-item__scope">{hit.toolName}</span>}
589 </div>
590 <div className="hist-item__meta">{hit.snippet}</div>
591 </button>
592 </div>
593 ))}
594 </section>
595 )}
596 {groups.map((g) => (
597 <section className="mem-section" key={g.label}>
598 <div className="mem-section__title hist-group__title">
599 <span>{g.label}</span>
600 <span className="hist-group__count">{g.items.length}</span>
601 </div>
602 {g.items.map(renderSessionItem)}
603 </section>
604 ))}
605 {isTrash && systemRecoverySessions.length > 0 && (
606 <section className="mem-section history-system-recovery">
607 <button
608 className="mem-section__title hist-group__title history-system-recovery__toggle"
609 type="button"
610 aria-expanded={showSystemRecoveryData}
611 onClick={() => setShowSystemRecoveryData((value) => !value)}
612 >
613 <span>{tr("history.systemRecoveryData")}</span>
614 <span className="hist-group__count">{systemRecoverySessions.length}</span>
615 </button>
616 {showSystemRecoveryData && (
617 <div className="history-system-recovery__items" aria-label={tr("history.systemRecoveryData")}>
618 {systemRecoverySessions.map(renderSessionItem)}
619 </div>
620 )}
621 </section>
622 )}
623 {!isTrash && nextCursor && (
624 <button
625 className="btn btn--small"
626 type="button"
627 onClick={loadMore}
628 >
629 Load more
630 </button>
631 )}
632 </>
633 );
634 })()}
635 </div>
636
637 <section className={`history-preview${!preview && !searchContext ? " history-preview--empty" : ""}`}>
638 {presentation === "page" && <button className="btn btn--small management-list-back" onClick={() => setDetailVisible(false)}>{m("listBack")}</button>}
639 {searchContext ? (
640 <>
641 <div className="history-preview__head">
642 <div className="history-preview__copy">
643 <div className="history-preview__title">{historySearchHitDisplayTitle(searchContext.hit)}</div>
644 <div className="history-preview__meta">{searchContext.hit.role} · {searchContext.hit.kind}</div>
645 </div>
646 </div>
647 <div className="history-preview__body">
648 {searchContext.loading ? (
649 <div className="mem-empty">{tr("common.loading")}</div>
650 ) : searchContext.lines.length === 0 ? (
651 <div className="mem-empty">{tr("history.previewEmpty")}</div>
652 ) : (
653 searchContext.lines.map((line) => (
654 <div className="hist-item" key={line.index}>
655 <div className="hist-item__meta"><span className="hist-item__badge">{line.role}</span></div>
656 <div className="hist-item__preview">{line.text}</div>
657 </div>
658 ))
659 )}
660 </div>
661 </>
662 ) : preview ? (
663 <>
664 <div className="history-preview__head">
665 <div className="history-preview__copy">
666 <div className="history-preview__title">{preview.title}</div>
667 <div className="history-preview__meta">{preview.meta}</div>
668 </div>
669 <div className="history-preview__actions">
670 {isTrash ? (
671 <>
672 <button className="btn btn--primary btn--small" type="button" disabled={!selectedSession || busy} onClick={restoreSelected}>
673 {tr("history.restore")}
674 </button>
675 <button className="btn btn--small btn--danger" type="button" disabled={!selectedSession || busy} onClick={purgeSelected}>
676 {actionConfirmPurge ? tr("history.confirmPurge") : tr("history.purge")}
677 </button>
678 </>
679 ) : (
680 <>
681 <button className="btn btn--primary btn--small" type="button" disabled={!selectedSession || running} onClick={openSelected}>
682 {tr("history.openSession")}
683 </button>
684 <button className="btn btn--small" type="button" disabled={!selectedSession || running} onClick={renameSelected}>
685 {tr("history.rename")}
686 </button>
687 {selectedSession && selectedVersions && (
688 <button
689 className="btn btn--small"
690 type="button"
691 onClick={inspectSelectedVersions}
692 >
693 <GitBranch size={13} /> {tr("recovery.inspectLineage")}
694 </button>
695 )}
696 <button
697 className="btn btn--small btn--danger"
698 type="button"
699 disabled={!selectedSession || running || selectedSession.current}
700 onClick={moveSelectedToTrash}
701 >
702 {actionConfirmDelete ? tr("history.confirmMoveToTrash") : tr("history.moveToTrash")}
703 </button>
704 </>
705 )}
706 </div>
707 </div>
708 <div className="history-preview__body">
709 {preview.loading ? (
710 <div className="mem-empty">{tr("common.loading")}</div>
711 ) : preview.error ? (
712 <div className="mem-empty" role="alert">{m("loadFailed")}<button className="btn btn--small" onClick={() => { if (selectedSession) void loadPreview(selectedSession); }}>{m("retry")}</button></div>
713 ) : previewItems.length === 0 ? (
714 <div className="mem-empty">{tr("history.previewEmpty")}</div>
715 ) : (
716 <Transcript items={previewItems} onPrompt={() => {}} />
717 )}
718 </div>
719 </>
720 ) : (
721 <div className="history-preview__empty">{tr("history.selectSession")}</div>
722 )}
723 </section>
724 </div>
725 <ContextMenu
726 open={Boolean(menuSession)}
727 point={menuPoint}
728 items={sessionMenuItems}
729 minWidth={220}
730 ariaLabel={isTrash ? tr("history.trashSessionActions") : tr("history.historySessionActions")}
731 onClose={closeHistoryMenus}
732 />
733 <ContextMenu
734 open={Boolean(blankMenuPoint)}
735 point={blankMenuPoint}
736 items={trashBlankMenuItems}
737 minWidth={220}
738 ariaLabel={tr("history.trashActions")}
739 onClose={closeHistoryMenus}
740 />
741 </div>
742 </>);
743 if (presentation === "page") return <div className="history-page" aria-busy={busy}>{content}</div>;
744 return <div className="management-modal-backdrop history-modal-backdrop" data-app-overlay="" data-state={status} onMouseDown={(e) => { if (e.target === e.currentTarget) requestClose(); }}>
745 <section className="management-modal history-modal" data-state={status} aria-label={tr(isTrash ? "history.trashTitle" : "history.title")} onClick={(e) => e.stopPropagation()}>{content}</section>
746 </div>;
747 }
748
749 // dayLabel buckets a timestamp into "Today", "Yesterday", or a locale date. It's
750 // module-level (not a component), so it uses the non-reactive translator; the
751 // panel re-renders on a locale switch via its parent, picking up the new strings.
752 function dayLabel(ms: number): string {
753 const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
754 const days = Math.round((startOfDay(new Date()) - startOfDay(new Date(ms))) / 86_400_000);
755 if (days <= 0) return t("history.today");
756 if (days === 1) return t("history.yesterday");
757 return new Date(ms).toLocaleDateString();
758 }
759
760 function timeLabel(ms: number): string {
761 return new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
762 }
763
764 function dateBucket(ms: number): Exclude<HistoryDateFilter, "all"> {
765 const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
766 const days = Math.round((startOfDay(new Date()) - startOfDay(new Date(ms))) / 86_400_000);
767 if (days <= 0) return "today";
768 if (days === 1) return "yesterday";
769 return "older";
770 }
771
772 function sessionTimeForGrouping(s: SessionMeta, isTrash: boolean): number {
773 return isTrash ? s.deletedAt || sessionActivityTime(s) : sessionActivityTime(s);
774 }
775
776 function sessionScope(s: SessionMeta): "project" | "global" {
777 return s.scope === "project" ? "project" : "global";
778 }
779
780 function isChannelSession(s: SessionMeta): boolean {
781 return s.kind === "channel" || s.sessionSource === "auto";
782 }
783
784 function sessionLocation(s: SessionMeta, tr: ReturnType<typeof useT>): string {
785 if (isChannelSession(s)) {
786 return [s.channelLabel || s.channel || tr("history.channel"), s.remoteId].filter(Boolean).join(" · ");
787 }
788 if (s.workspaceRoot) {
789 const parts = s.workspaceRoot.split(/[\\/]/).filter(Boolean);
790 return parts[parts.length - 1] || s.workspaceRoot;
791 }
792 return sessionScope(s) === "project" ? tr("history.filterProject") : tr("history.filterGlobal");
793 }
794
795 function sessionMetaLine(s: SessionMeta, tr: ReturnType<typeof useT>, isTrash = false): string {
796 const time = timeLabel(isTrash ? s.deletedAt || sessionActivityTime(s) : sessionActivityTime(s));
797 const suffix = isTrash && s.deletedAt ? ` · ${tr("history.deleted")}` : "";
798 const prefix = isChannelSession(s) ? `${tr("history.channelReadOnly")} · ` : "";
799 const turns = s.turnsState === "unknown"
800 ? tr("history.indexing")
801 : tr(s.turns === 1 ? "history.turnOne" : "history.turnOther", { n: s.turns });
802 return `${prefix}${turns} · ${time}${suffix}`;
803 }
804
805 function previewMessagesToItems(messages: HistoryMessage[]): Item[] {
806 return historyMessagesToItems(messages, "hp").items;
807 }
808
808 lines Plain Text