import { ArchiveIcon, InfoIcon, Loader2Icon, MessagesSquareIcon, } from "lucide-react"; import { type FormEvent, useCallback, useEffect, useRef, useState, } from "react"; import type { SessionImportReceipt, SessionsStatus, } from "../components/sessions/api"; import { sessionsApi } from "../components/sessions/api"; import { AutomationPanel } from "../components/sessions/AutomationPanel"; import { SessionSearch } from "../components/sessions/SessionSearch"; import { SourcesPanel } from "../components/sessions/SourcesPanel"; import { Badge } from "../components/ui/badge"; import { Button } from "../components/ui/button"; import { Card, CardContent } from "../components/ui/card"; import { Input } from "../components/ui/input"; import { fetchServerCapabilities } from "../lib/server-capabilities"; interface PageProps { navigate: (to: string | number) => void; } /** Codes meaning "this instance is not a usable session archive". */ const UNBOUND_CODES = new Set([ "SESSIONS_NOT_CONFIGURED", "SESSIONS_BINDING_MISMATCH", ]); const INIT_COMMAND = "gno --config --index sessions sessions init --archive --collection "; const SERVE_COMMAND = "gno --config --index sessions serve"; function CommandLine({ children }: { children: string }) { return ( {children} ); } function InitForm({ onDone }: { onDone: () => Promise }) { const [archive, setArchive] = useState(""); const [collection, setCollection] = useState("sessions"); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); const submit = async (event: FormEvent) => { event.preventDefault(); setSaving(true); const result = await sessionsApi("/api/sessions/init", { method: "POST", body: JSON.stringify({ archive: archive.trim(), collection: collection.trim(), }), }); setSaving(false); if (result.error) { setError(result.error); return; } setError(null); await onDone(); }; return (
void submit(event)} > {error && (

{error}

)}

Only works when this server was started with a dedicated archive config and a named index; the default config is refused.

); } export default function Sessions({ navigate }: PageProps) { const [status, setStatus] = useState(null); const [unbound, setUnbound] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [localClient, setLocalClient] = useState(false); const [busy, setBusy] = useState(null); const [receipts, setReceipts] = useState< Record >({}); // Bumped when an action (import/remove) fails so focus moves to the alert // instead of dropping to from the disabled button. const [actionErrorCount, setActionErrorCount] = useState(0); const errorRef = useRef(null); useEffect(() => { if (actionErrorCount > 0) errorRef.current?.focus(); }, [actionErrorCount]); const loadStatus = useCallback(async () => { const result = await sessionsApi("/api/sessions/status"); setLoading(false); if (result.data) { setStatus(result.data); setUnbound(null); setError(null); return; } if (result.sessionsCode && UNBOUND_CODES.has(result.sessionsCode)) { setStatus(null); setUnbound(result.error); setError(null); return; } // A transient failure (for example during background polling) keeps the // last good status on screen, so run monitoring and controls survive. setError(result.error); }, []); useEffect(() => { void loadStatus(); void fetchServerCapabilities().then(({ data }) => { setLocalClient(data?.localClient === true); }); }, [loadStatus]); const runImport = async (sourceId: string, dryRun: boolean) => { setBusy(`${sourceId}:${dryRun ? "preview" : "import"}`); const result = await sessionsApi( "/api/sessions/import", { method: "POST", body: JSON.stringify({ sourceId, dryRun }), } ); setBusy(null); if (!result.data) { setError(result.error); setActionErrorCount((count) => count + 1); return; } const receipt = result.data; setError(null); setReceipts((current) => ({ ...current, [sourceId]: receipt })); if (!dryRun) await loadStatus(); }; const removeSource = async (sourceId: string) => { setBusy(`${sourceId}:remove`); const result = await sessionsApi( `/api/sessions/sources/${encodeURIComponent(sourceId)}`, { method: "DELETE" } ); setBusy(null); if (result.error) { setError(result.error); setActionErrorCount((count) => count + 1); return; } setReceipts((current) => { const next = { ...current }; delete next[sourceId]; return next; }); await loadStatus(); }; const archiveCollections = status?.collections.map((c) => c.name) ?? []; return (

Agent sessions

{status && (

Archive index {status.index} · {status.collections.length} collection {status.collections.length === 1 ? "" : "s"}

)}

Import is manual by default. Nothing watches your agents or imports on its own unless you switch on a hook or schedule below.

{error && ( {error} )} {loading && (

Loading archive status…

)} {unbound !== null && (

{unbound}

Session archives live in their own config file and named index. Create one, then start a server for it:

{INIT_COMMAND} {SERVE_COMMAND} {localClient && }
)} {status && ( <> {status.warnings.length > 0 && (
    {status.warnings.map((warning) => (
  • {warning}
  • ))}
)} void runImport(sourceId, dryRun)} onRemove={(sourceId) => void removeSource(sourceId)} receipts={receipts} sources={status.sources} /> navigate(to)} /> )}
); }