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