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