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
onImport(source.id, true)}
size="sm"
variant="outline"
>
{busy === `${source.id}:preview` ? (
) : (
)}
Preview (dry run)
onImport(source.id, false)}
size="sm"
>
{busy === `${source.id}:import` ? (
) : (
)}
Import
{localClient &&
(confirmRemove ? (
<>
onRemove(source.id)}
size="sm"
variant="destructive"
>
Confirm remove
setConfirmRemove(false)}
size="sm"
variant="ghost"
>
Cancel
>
) : (
setConfirmRemove(true)}
size="sm"
variant="ghost"
>
Remove
))}
{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 (
);
}
interface SourcesPanelProps {
sources: SessionSourceStatus[];
archiveCollections: string[];
localClient: boolean;
busy: string | null;
receipts: Record;
onImport: (sourceId: string, dryRun: boolean) => void;
onRemove: (sourceId: string) => void;
onChanged: () => Promise;
}
export function SourcesPanel({
sources,
archiveCollections,
localClient,
busy,
receipts,
onImport,
onRemove,
onChanged,
}: SourcesPanelProps) {
const [discovery, setDiscovery] = useState(null);
const [discovering, setDiscovering] = useState(false);
const [discoverError, setDiscoverError] = useState(null);
const [notice, setNotice] = useState<{ text: string } | null>(null);
const noticeRef = useRef(null);
useFocusOnChange(notice, noticeRef);
// Bumped when the Discover button's run finishes: focus moves to its outcome.
const [discoveredCount, setDiscoveredCount] = useState(0);
const discoveryRef = useRef(null);
useFocusOnChange(discoveredCount, discoveryRef);
const discover = async (focusOutcome = false) => {
setDiscovering(true);
const result = await sessionsApi(
"/api/sessions/discover"
);
setDiscovering(false);
setDiscoverError(result.error);
setDiscovery(result.data);
if (focusOutcome) setDiscoveredCount((count) => count + 1);
};
const registered = async (sourceId: string, collection: string) => {
setNotice({
text: `Registered source ${sourceId} → collection ${collection}. Nothing was imported yet.`,
});
await onChanged();
await discover();
};
return (
Sources
{localClient && (
void discover(true)}
size="sm"
variant="outline"
>
{discovering ? (
) : (
)}
Discover local sources
)}
{sources.length === 0 ? (
No sources registered yet.{" "}
{localClient
? "Discover local sources and register the ones you permit."
: "Sources can only be registered from a browser on this machine."}
) : (
{sources.map((source) => (
))}
)}
{notice && (
{notice.text}
)}
{(discoverError || discovery) && (
{discoverError && (
{discoverError}
)}
{discovery && (
Discovered on this machine
Preview only: discovery reads nothing into the archive. Register
a source to permit manual imports from it.
{discovery.candidates.length === 0 && (
No supported session stores were found.
)}
{discovery.candidates.map((candidate) => (
{SESSION_HARNESS_LABELS[candidate.harness]}
{candidate.path}
{candidate.units}
{candidate.truncated ? "+" : ""} units ·{" "}
{formatBytes(candidate.bytes)}
{candidate.formatVersions.length > 0 &&
` · format ${candidate.formatVersions.join(", ")}`}
{candidate.registeredAs ? (
Registered as{" "}
{candidate.registeredAs}
) : (
)}
))}
{discovery.warnings.map((warning) => (
{warning}
))}
)}
)}
);
}