/** * Dialog surfaces for the session-archive section: the physical-delete * confirmation (with family cascade accounting and a strong second * confirmation for large deletes), the live batch progress, and the * session preview. All dialogs are keyboard-accessible (Esc closes, focus * moves in on open and back on close) and render with role=dialog. * @module @linxin666/dsh-session-archive/client/dialogs */ import { useEffect, useRef, useState, type ReactNode } from 'react' import type { ConfirmDeleteState, BatchProgress } from './archive-store.ts' import { t } from './locales.ts' import styles from './archive.module.css' /** Human-readable byte size. */ export function formatBytes(bytes: number | undefined): string { if (bytes === undefined || !Number.isFinite(bytes) || bytes <= 0) return '0 B' const units = ['B', 'KB', 'MB', 'GB', 'TB'] let value = bytes let unit = 0 while (value >= 1024 && unit < units.length - 1) { value /= 1024 unit += 1 } return `${value >= 100 || unit === 0 ? Math.round(value) : value.toFixed(1)} ${units[unit]}` } export function formatTime(ms: number | undefined): string { if (ms === undefined || !Number.isFinite(ms)) return t('arch.time.unknown') try { return new Date(ms).toLocaleString() } catch { return String(ms) } } interface ModalProps { title: string onClose: () => void children: ReactNode danger?: boolean /** Extra width for content-heavy surfaces (session preview). */ wide?: boolean } /** Shared modal shell: focus on open, Esc to close, restore focus after. */ export function Modal({ title, onClose, children, danger, wide }: ModalProps): ReactNode { const ref = useRef(null) useEffect(() => { const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null ref.current?.focus() const onKey = (event: KeyboardEvent): void => { if (event.key === 'Escape') { event.stopPropagation() event.stopImmediatePropagation?.() onClose() } } document.addEventListener('keydown', onKey, { capture: true }) return () => { document.removeEventListener('keydown', onKey, { capture: true }) previous?.focus() } }, [onClose]) return (
{ if (event.target === event.currentTarget) onClose() }}>
{title}
{children}
) } /** Delete confirmation with full cascade accounting. */ export function DeleteConfirmDialog(props: { state: ConfirmDeleteState onConfirm: () => void onCancel: () => void }): ReactNode { const [acknowledged, setAcknowledged] = useState(false) const needCheck = props.state.strong return (
{t('arch.confirm.direct', { n: props.state.ids.length })}
{t('arch.confirm.descendants', { n: props.state.descendants })}
{t('arch.confirm.total', { n: props.state.total })}
{props.state.skippedProtected > 0 && (
{t('arch.confirm.skipped', { n: props.state.skippedProtected })}
)} {props.state.totalBytes > 0 && (
{t('arch.confirm.freed', { size: formatBytes(props.state.totalBytes) })}
)}
{t('arch.confirm.irrecoverable')}
{needCheck && ( )}
) } /** Live batch progress with per-session skip/fail reasons. */ export function BatchDialog(props: { batch: BatchProgress onClose: () => void onRetryFailed: () => void }): ReactNode { const { batch } = props const failed = batch.results.filter((result) => result.status === 'failed') const skipped = batch.results.filter((result) => result.status === 'skipped') const ok = batch.results.filter((result) => result.status === 'ok') const reasonText = (result: (typeof batch.results)[number]): string => { const base = result.reason === undefined ? t('arch.reason.error') : t(`arch.reason.${result.reason}`) return result.detail === undefined ? base : `${base} (${result.detail})` } return ( {} : props.onClose}>
{t('arch.batch.progress', { processed: batch.processed, total: batch.total })}
{batch.running ? t('arch.batch.running') : batch.error !== null ? t('arch.batch.withError') : t('arch.batch.finished')} {t('arch.batch.ok', { n: ok.length })} {t('arch.batch.skipped', { n: skipped.length })} {t('arch.batch.failed', { n: failed.length })} {batch.freedBytes > 0 && {t('arch.batch.freed', { size: formatBytes(batch.freedBytes) })}}
{!batch.running && skipped.length > 0 && (
{t('arch.batch.skipSummary', { summary: Object.entries( skipped.reduce>((acc, result) => { const key = result.reason ?? 'error' acc[key] = (acc[key] ?? 0) + 1 return acc }, {}), ) .map(([reason, n]) => `${t(`arch.reason.${reason}`)} ×${n}`) .join(' · '), })}
)} {!batch.running && failed.length > 0 && ( )} {(failed.length > 0 || skipped.length > 0) && (
    {[...failed, ...skipped].slice(0, 60).map((result) => (
  • {result.id} {reasonText(result)}
  • ))}
)}
) } /** Session preview: basic info + a tolerant conversation excerpt. */ export function PreviewDialog(props: { preview: { id: string; status: 'loading' | 'ready' | 'error'; data?: { title?: string; createdAt?: number; cwd?: string; sizeBytes?: number; messageCount: number; excerpt: readonly { role: string; text: string }[] }; error?: string } onClose: () => void onRetry: () => void }): ReactNode { const { preview } = props return (
{preview.status === 'loading' &&
{t('arch.loading')}
} {preview.status === 'error' && ( <>
{t('arch.preview.loadError', { error: preview.error ?? '' })}
)} {preview.status === 'ready' && preview.data !== undefined && ( <>
{preview.id}
{preview.data.title !== undefined &&
{preview.data.title}
} {preview.data.createdAt !== undefined &&
{t('arch.row.created', { time: formatTime(preview.data.createdAt) })}
} {preview.data.cwd !== undefined &&
{preview.data.cwd}
} {preview.data.sizeBytes !== undefined &&
{t('arch.row.size', { size: formatBytes(preview.data.sizeBytes) })}
}
{t('arch.preview.messages', { n: preview.data.messageCount })}
{preview.data.excerpt.length === 0 ?
{t('arch.preview.empty')}
: ( <>
{t('arch.preview.excerpt')}
    {preview.data.excerpt.map((message, index) => (
  • {message.role} {message.text}
  • ))}
)} )}
) }