/** * The 会话归档管理 first-level settings section: full inventory list with * filters/search/sort, cross-result multi-select, batch archive/unarchive/ * physical delete with progress and per-session reasons, session preview, * and the automatic-maintenance settings panel. * @module @linxin666/dsh-session-archive/client/SessionArchiveCard */ import { useEffect, useMemo, useState, useSyncExternalStore, type ReactNode } from 'react' import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client' import type { SessionArchiveConfig } from '../core/config.ts' import { AUTO_ARCHIVE_DAYS_MAX, AUTO_ARCHIVE_DAYS_MIN, AUTO_DELETE_DAYS_MAX, AUTO_DELETE_DAYS_MIN, validateDays, } from '../core/config.ts' import type { ArchiveSessionRow } from '../core/types.ts' import { filterRows, PAGE_SIZE, selectionSummary, sortRows, type SortDir, type SortKey } from '../core/selection.ts' import type { ArchiveController } from './archive-controller.ts' import { formatBytes, formatTime, BatchDialog, DeleteConfirmDialog, PreviewDialog } from './dialogs.tsx' import { AutoSettingsPanel } from './AutoSettings.tsx' import { Select } from './Select.tsx' import { t } from './locales.ts' import styles from './archive.module.css' /** The registration-side face the section's slot entry injects. */ export interface SessionArchiveFace { controller: ArchiveController settings: ConfigForm } export interface SessionArchiveProps extends SessionArchiveFace { /** Close the settings panel (the shell owns the open state). */ close: () => void } /** Row status chips: the conjunction of running/blank/archived facts. */ function statusChips(row: ArchiveSessionRow, currentId: string | undefined): { key: string; label: string; tone: string }[] { const chips: { key: string; label: string; tone: string }[] = [] if (row.running) chips.push({ key: 'running', label: t('arch.status.running'), tone: styles.chipWarn }) else if (row.blank) chips.push({ key: 'blank', label: t('arch.status.blank'), tone: styles.chipNeutral }) else chips.push({ key: 'active', label: t('arch.status.active'), tone: styles.chipSuccess }) if (row.archived) chips.push({ key: 'archived', label: t('arch.status.archived'), tone: styles.chipBusiness }) if (row.origin === 'subagent') chips.push({ key: 'subagent', label: t('arch.status.subagent'), tone: styles.chipNeutral }) if (currentId !== undefined && row.id === currentId) chips.push({ key: 'current', label: t('arch.current.badge'), tone: styles.chipDanger }) return chips } function issueLabels(row: ArchiveSessionRow): string[] { return row.issues.map((issue) => t(`arch.issue.${issue}`)) } export function SessionArchiveCard(props: SessionArchiveProps): ReactNode { const { controller } = props const ui = useSyncExternalStore(controller.store.subscribe, controller.store.getSnapshot) const [currentId, setCurrentId] = useState(undefined) useEffect(() => { void controller.load() setCurrentId(controller.getCurrentSessionId()) }, [controller]) const rows = ui.inventory?.rows ?? [] const workspaces = ui.inventory?.workspaces ?? [] const filtered = useMemo( () => sortRows(filterRows(rows, ui.filter), ui.sortKey, ui.sortDir), [rows, ui.filter, ui.sortKey, ui.sortDir], ) const selection = useMemo(() => new Set(ui.selection), [ui.selection]) const summary = selectionSummary(selection, filtered) // Pagination is a render window only: the filtered set (and therefore // select-all) always spans the complete result, never just this page. const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)) const page = Math.min(ui.page, totalPages - 1) const shown = filtered.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE) const busy = ui.batch !== null && ui.batch.running const workspaceTitle = (id: string): string => workspaces.find((workspace) => workspace.id === id)?.title ?? id const onSelectAll = (): void => { controller.store.actions.selectMany(filtered.map((row) => row.id), true) } const runBatch = (kind: 'archive' | 'unarchive'): void => { void controller.runBatch(kind, ui.selection) } const requestDelete = (ids: string[]): void => { void controller.confirmDelete(ids) } return (
{t('arch.title')} {t('arch.results.count', { n: filtered.length })}
{ui.status === 'error' && (
{t('arch.error', { error: ui.error ?? '' })}
)}
{(['all', 'active', 'archived'] as const).map((status) => ( ))}
{ controller.store.actions.setFilter({ query: event.target.value }) }} /> { controller.store.actions.setFilter({ issuesOnly: event.target.checked }) }} /> {t('arch.filter.issues')}
{t('arch.select.selected', { n: selection.size })} {summary.outside > 0 && ( {t('arch.select.holdNote', { selected: summary.selected, outside: summary.outside })} )}
{filtered.length === 0 &&
{t('arch.empty')}
} {shown.map((row) => { const chips = statusChips(row, currentId) const issues = issueLabels(row) const selected = selection.has(row.id) return (
{ controller.store.actions.toggleRow(row.id, event.target.checked) }} />
{row.title ?? t('arch.row.noTitle')} {chips.map((chip) => ( {chip.label} ))} {row.childCount > 0 && {t('arch.row.children', { n: row.childCount })}}
{row.id} {row.workspaceIds.length > 0 ? {row.workspaceIds.map(workspaceTitle).join(' · ')} : {t('arch.filter.workspace.none')}} {row.lastActivityAt === undefined ? t('arch.row.lastActivity.unknown') : t('arch.row.lastActivity', { time: formatTime(row.lastActivityAt) })} {row.archived && {row.archivedAt === undefined ? t('arch.row.archivedAt.unknown') : `${t('arch.row.archivedAt', { time: formatTime(row.archivedAt) })}${row.archivedAtSource === 'observed' ? t('arch.row.archivedAt.backfilled') : ''}`}} {row.sizeBytes !== undefined && {t('arch.row.size', { size: formatBytes(row.sizeBytes) })}} {issues.length > 0 && {issues.join(' · ')}}
{!row.archived && ( )} {row.archived && ( )}
) })} {filtered.length > 0 && (
{t('arch.page.info', { page: page + 1, pages: totalPages, n: filtered.length })}
)}
{ui.confirmDelete !== null && ( { void controller.runBatch('delete', ui.confirmDelete?.ids ?? []) }} onCancel={() => { controller.store.actions.setConfirmDelete(null) }} /> )} {ui.batch !== null && ( { controller.store.actions.closeBatch() }} onRetryFailed={() => { void controller.retryFailed() }} /> )} {ui.preview !== null && ( { controller.store.actions.setPreview(null) }} onRetry={() => { void controller.openPreview(ui.preview?.id ?? '') }} /> )}
) }