| 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 |