import { DownloadIcon, EyeIcon, Loader2Icon, RadarIcon, Trash2Icon, } from "lucide-react"; import { type FormEvent, useEffect, useId, useRef, useState } from "react"; import type { SessionDiscoveryCandidate, SessionImportReceipt, SessionsDiscovery, SessionSourceStatus, } from "./api"; import { SESSION_HARNESS_LABELS } from "../../../../sessions/types"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { sessionsApi } from "./api"; import { ImportReceipt } from "./ImportReceipt"; const SOURCE_ID_PATTERN = "[a-z0-9][a-z0-9_-]{0,63}"; /** Select value for "type a collection name that does not exist yet". */ const NEW_COLLECTION = "__new__"; const SELECT_CLASS = "h-9 w-full min-w-0 rounded-md border border-input bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/50"; const FOCUS_RING = "outline-none focus-visible:ring-2 focus-visible:ring-ring/50"; /** * Focus `ref` whenever `trigger` changes to a new truthy value after mount. * Buttons disable while their request runs, which drops keyboard focus to * ; this hands it to the region that reports the outcome instead. */ function useFocusOnChange( trigger: T, ref: { current: HTMLElement | null } ): void { const previous = useRef(trigger); useEffect(() => { if (trigger && trigger !== previous.current) ref.current?.focus(); previous.current = trigger; }, [trigger, ref]); } function formatWhen(iso: string | null): string { if (!iso) return "never"; const date = new Date(iso); return Number.isNaN(date.getTime()) ? iso : date.toLocaleString(); } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } /** Parse "prefix=collection" lines into project mappings. */ export function parseProjectLines( text: string ): Array<{ prefix: string; collection: string }> | string { const mappings: Array<{ prefix: string; collection: string }> = []; for (const raw of text.split("\n")) { const line = raw.trim(); if (!line) continue; const split = line.lastIndexOf("="); const prefix = split > 0 ? line.slice(0, split).trim() : ""; const collection = split > 0 ? line.slice(split + 1).trim() : ""; if (!(prefix && collection)) { return `Project mapping "${line}" must look like /absolute/prefix=collection`; } mappings.push({ prefix, collection }); } return mappings; } interface SourceRowProps { source: SessionSourceStatus; localClient: boolean; busy: string | null; receipt: SessionImportReceipt | undefined; onImport: (sourceId: string, dryRun: boolean) => void; onRemove: (sourceId: string) => void; } function SourceRow({ source, localClient, busy, receipt, onImport, onRemove, }: SourceRowProps) { const [confirmRemove, setConfirmRemove] = useState(false); const receiptRef = useRef(null); useFocusOnChange(receipt, receiptRef); const running = busy?.startsWith(`${source.id}:`) ?? false; const { units } = source; return (
  • {source.id}

    {SESSION_HARNESS_LABELS[source.harness]} {source.available ? ( available ) : ( unavailable — archive kept )}

    → collection{" "} {source.collection} {" "} · last import {formatWhen(source.lastImportAt)}

    Units: {units.total} total · {units.complete} complete ·{" "} 0 ? "text-amber-700 dark:text-amber-300" : "" } > {units.incomplete} incomplete {" "} ·{" "} 0 ? "text-destructive" : ""}> {units.failed} failed {" "} · {units.pending} pending · {source.archivedThreads} archived threads

    {localClient && (confirmRemove ? ( <> ) : ( ))}
    {confirmRemove && (

    Removing unregisters the source. Its archived sessions stay in the archive and index.

    )} {receipt && }
  • ); } interface RegisterFormProps { candidate: SessionDiscoveryCandidate; archiveCollections: string[]; onRegistered: (sourceId: string, collection: string) => Promise; } function RegisterForm({ candidate, archiveCollections, onRegistered, }: RegisterFormProps) { const formId = useId(); const [id, setId] = useState(`${candidate.harness}-main`); // No default: the destination collection is a privacy boundary, so the // user must pick it explicitly. const [collectionChoice, setCollectionChoice] = useState(""); const [newCollection, setNewCollection] = useState(""); const [projects, setProjects] = useState(""); const [error, setError] = useState<{ text: string } | null>(null); const [saving, setSaving] = useState(false); const errorRef = useRef(null); useFocusOnChange(error, errorRef); const creatingCollection = collectionChoice === NEW_COLLECTION; const collection = creatingCollection ? newCollection.trim() : collectionChoice; const hintId = `${formId}-collection-hint`; const submit = async (event: FormEvent) => { event.preventDefault(); if (!collection) { setError({ text: "Choose a destination archive collection." }); return; } const mappings = parseProjectLines(projects); if (typeof mappings === "string") { setError({ text: mappings }); return; } setSaving(true); const result = await sessionsApi("/api/sessions/sources", { method: "POST", body: JSON.stringify({ id: id.trim(), harness: candidate.harness, path: candidate.path, collection, ...(mappings.length > 0 ? { projects: mappings } : {}), }), }); setSaving(false); if (result.error) { setError({ text: result.error }); return; } setError(null); await onRegistered(id.trim(), collection); }; return (
    void submit(event)} > {creatingCollection && ( )}