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