import { Loader2Icon, PauseIcon, PlayIcon, Trash2Icon, WorkflowIcon, } from "lucide-react"; import { type FormEvent, useEffect, useId, useRef, useState } from "react"; import type { SessionAutomationRunResult, SessionAutomationStatus, SessionProfileStatus, SessionSourceStatus, } from "./api"; import { retryWording } from "../../../../sessions/format"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { sessionsApi } from "./api"; const PROFILE_ID_PATTERN = "[a-z0-9][a-z0-9_-]{0,63}"; const CADENCE_PATTERN = "[1-9][0-9]{0,5}[smhd]"; /** Local-only preview returned before a trigger is switched on. */ interface AutomationPreview { archiveRoot: string; sources: Array<{ id: string; harness: string | null; path: string | null; collection: string | null; }>; collections: string[]; hook: { settings: string; command: string }; daemon: { state: string; command: string }; notes: string[]; } type Trigger = "hook" | "schedule"; const STATE_LABELS: Record = { off: "off", idle: "idle", pending: "pending", running: "running", retrying: "retrying", partial: "partial", failed: "needs attention", }; function stateVariant( state: SessionProfileStatus["state"] ): "secondary" | "outline" | "destructive" { if (state === "failed") return "destructive"; if (state === "off" || state === "idle") return "outline"; return "secondary"; } /** Poll status this often while a run is in progress. */ const RUN_POLL_MS = 2000; type Notify = (text: string) => void; function scheduleLine( daemonRunning: boolean, nextDueAt: string | null, at: (iso: string | null) => string ): string { if (!daemonRunning) return "not running: no daemon"; return nextDueAt ? `next ${at(nextDueAt)}` : "due time set on the next tick"; } /** After Run now: the retry time, the recovery action, or remaining work. */ function runOutcomeTail( run: SessionAutomationRunResult, profile: SessionProfileStatus, daemonRunning: boolean, at: (iso: string | null) => string ): string { if (run.outcome === "failed") { const retry = profile.state === "retrying" ? retryWording(profile.retryAt, daemonRunning, new Date(), at) : null; if (retry) return `; ${retry}`; return profile.recovery ? "; see the recovery action above" : ""; } return run.pending ? "; work is still pending" : ""; } function clockFor(timezone: string): (iso: string | null) => string { return (iso) => { if (!iso) return "never"; const date = new Date(iso); if (Number.isNaN(date.getTime())) return iso; try { return date.toLocaleString(undefined, { timeZone: timezone }); } catch { return date.toLocaleString(); } }; } function CreateProfileForm({ sources, onDone, notify, }: { sources: SessionSourceStatus[]; onDone: () => Promise; notify: Notify; }) { const idField = useId(); const [id, setId] = useState(""); const [selected, setSelected] = useState([]); const [error, setError] = useState<{ text: string; nonce: number } | null>( null ); const [saving, setSaving] = useState(false); const errorRef = useRef(null); useEffect(() => { if (error) errorRef.current?.focus(); }, [error]); const fail = (text: string) => setError((current) => ({ text, nonce: (current?.nonce ?? 0) + 1 })); const submit = async (event: FormEvent) => { event.preventDefault(); if (selected.length === 0) { fail("Select at least one registered source."); return; } setSaving(true); const result = await sessionsApi( `/api/sessions/automation/${encodeURIComponent(id.trim())}`, { method: "PUT", body: JSON.stringify({ sources: selected }) } ); setSaving(false); if (result.error) { fail(result.error); return; } const created = id.trim(); setError(null); setId(""); setSelected([]); await onDone(); notify(`Profile ${created} created; every trigger is off.`); }; return (
void submit(event)} >
Sources this profile imports {sources.map((source) => ( ))}
{error && (

{error.text}

)}
); } function PreviewConfirm({ trigger, preview, cadence, onCadence, saving, onConfirm, onCancel, }: { trigger: Trigger; preview: AutomationPreview; cadence: string; onCadence: (value: string) => void; saving: boolean; onConfirm: () => void; onCancel: () => void; }) { const cadenceField = useId(); const groupRef = useRef(null); useEffect(() => { groupRef.current?.focus(); }, []); return (

{trigger === "hook" ? "Install the Claude Code SessionEnd hook?" : "Run this profile on the daemon's schedule?"}

    {preview.sources.map((source) => (
  • {source.id}{" "} {source.path ?? "not registered"} โ†’ {source.collection ?? "none"}
  • ))}
  • Archive: {preview.archiveRoot}
  • {trigger === "hook" && (
  • Adds one owned entry to {preview.hook.settings}; other hooks are kept.
  • )}
{trigger === "schedule" && ( )}

Daemon:{" "} {preview.daemon.state === "running" ? "running" : "not running: no daemon"} . Runs happen only while{" "} {preview.daemon.command} {" "} runs; GNO never installs or starts it.

); } function ProfileCard({ profile, timezone, daemonRunning, localClient, onChanged, notify, onRunActive, }: { profile: SessionProfileStatus; timezone: string; daemonRunning: boolean; localClient: boolean; onChanged: () => Promise; notify: Notify; onRunActive: (active: boolean) => void; }) { const at = clockFor(timezone); const hookSwitch = useId(); const scheduleSwitch = useId(); const [busy, setBusy] = useState(null); const [error, setError] = useState<{ text: string; nonce: number } | null>( null ); const [confirming, setConfirming] = useState<{ trigger: Trigger; preview: AutomationPreview; } | null>(null); const [cadence, setCadence] = useState(profile.schedule?.cadence ?? ""); const [confirmRemove, setConfirmRemove] = useState(false); const confirmRemoveRef = useRef(null); const removeRef = useRef(null); const removeReturnFocus = useRef(false); // The Remove button unmounts when its confirm step opens: move focus to // Confirm, and back to Remove on Cancel. useEffect(() => { if (confirmRemove) confirmRemoveRef.current?.focus(); else if (removeReturnFocus.current) { removeReturnFocus.current = false; removeRef.current?.focus(); } }, [confirmRemove]); const [lastRun, setLastRun] = useState( null ); const base = `/api/sessions/automation/${encodeURIComponent(profile.id)}`; const switches = { hook: useRef(null), schedule: useRef(null), }; const outcomeRef = useRef(null); const errorRef = useRef(null); // Disabled buttons drop keyboard focus; hand it to the outcome instead. useEffect(() => { if (lastRun) outcomeRef.current?.focus(); }, [lastRun]); useEffect(() => { if (error) errorRef.current?.focus(); }, [error]); const closeConfirm = (trigger: Trigger) => { setConfirming(null); switches[trigger].current?.focus(); }; const call = async ( label: string, endpoint: string, init: RequestInit ): Promise => { setBusy(label); const result = await sessionsApi(endpoint, init); setBusy(null); if (result.error) { const text = result.error; setError((current) => ({ text, nonce: (current?.nonce ?? 0) + 1 })); return null; } setError(null); return result.data; }; const startEnable = async (trigger: Trigger) => { const preview = await call( "preview", `${base}/preview`, { method: "GET", } ); if (preview) setConfirming({ trigger, preview }); }; const confirmEnable = async () => { if (!confirming) return; const body = confirming.trigger === "hook" ? { hook: { harness: "claude-code" } } : { schedule: { cadence: cadence.trim() } }; const done = await call("enable", `${base}/enable`, { method: "POST", body: JSON.stringify(body), }); if (done) { closeConfirm(confirming.trigger); await onChanged(); } }; const disable = async (which: Partial>) => { const done = await call("disable", `${base}/disable`, { method: "POST", body: JSON.stringify(which), }); if (!done) return; await onChanged(); // The Pause button is gone once nothing is enabled: land on a switch. (which.schedule && !which.hook ? switches.schedule : switches.hook ).current?.focus(); }; const toggle = (trigger: Trigger, on: boolean) => { // Switches stay focusable while a request or confirmation is open. if (busy !== null || confirming !== null) return; if (on) void startEnable(trigger); else void disable({ [trigger]: true }); }; const runNow = async () => { onRunActive(true); const result = await call( "run", "/api/sessions/automation/run", { method: "POST", body: JSON.stringify({ profileId: profile.id }) } ).finally(() => onRunActive(false)); if (result) { setLastRun(result); await onChanged(); } }; const remove = async () => { const done = await call("remove", base, { method: "DELETE" }); if (!done) return; // Announce (and focus) before the refresh unmounts this card. notify(`Profile ${profile.id} removed; archived sessions were kept.`); await onChanged(); }; const retryLine = retryWording( profile.retryAt, daemonRunning, new Date(), at ); const hookOn = profile.hook?.enabled === true; const scheduleOn = profile.schedule?.enabled === true; const run = profile.lastRun; return (
  • {profile.id}

    {STATE_LABELS[profile.state]}

    {`Sources ${profile.sources.join(", ")} โ†’ ${profile.collections.join(", ") || "no collection"} ยท ${profile.limit} units per source per run`}

    {localClient && (hookOn || scheduleOn) && ( )} {localClient && (confirmRemove ? ( <> ) : ( ))}
    {confirming && ( closeConfirm(confirming.trigger)} onConfirm={() => void confirmEnable()} preview={confirming.preview} saving={busy === "enable"} trigger={confirming.trigger} /> )}
    {profile.pending && profile.state !== "failed" && ( <>
    Pending
    since {at(profile.pending.since)} ( {profile.pending.triggers.join(", ") || "continuation"}) {retryLine ? `; ${retryLine}` : ""}
    )} {profile.running && ( <>
    Running
    since {at(profile.running.startedAt)}
    )}
    Last run
    {run ? `${at(run.finishedAt)}: ${run.outcome}${run.reason ? ` (${run.reason})` : ""}; ${run.threads.imported} imported, ${run.threads.updated} updated, ${run.units.failed} failed units` : "never"}
    Last success
    {at(profile.lastSuccessAt)}
    {profile.recovery && (

    {profile.recovery}

    )} {lastRun && (

    Run now:{" "} {lastRun.ran ? `${lastRun.outcome}${lastRun.reason ? ` (${lastRun.reason})` : ""}` : `not started (${lastRun.reason ?? "unknown"})`} {runOutcomeTail(lastRun, profile, daemonRunning, at)}

    )} {error && (

    {error.text}

    )}
  • ); } export interface AutomationPanelProps { automation: SessionAutomationStatus; sources: SessionSourceStatus[]; localClient: boolean; onChanged: () => Promise; } /** * Opt-in automation: every trigger is off until a same-host owner switches * it on after reviewing the preview. Remote browsers can read status and run * a configured profile, never enable machine integrations. */ export function AutomationPanel({ automation, sources, localClient, onChanged, }: AutomationPanelProps) { const at = clockFor(automation.timezone); const daemonRunning = automation.daemon.state === "running"; const [notice, setNotice] = useState<{ text: string; nonce: number } | null>( null ); const noticeRef = useRef(null); useEffect(() => { if (notice) noticeRef.current?.focus(); }, [notice]); const notify: Notify = (text) => setNotice((current) => ({ text, nonce: (current?.nonce ?? 0) + 1 })); // Poll while a run is in progress (here, in the daemon, or requested from // this page) so running/pending states appear without a reload. const [runsInFlight, setRunsInFlight] = useState(0); const onRunActive = (active: boolean) => setRunsInFlight((count) => Math.max(0, count + (active ? 1 : -1))); const active = runsInFlight > 0 || automation.profiles.some( (profile) => profile.state === "running" || (daemonRunning && profile.state === "pending") ); const refresh = useRef(onChanged); refresh.current = onChanged; useEffect(() => { if (!active) return; let polling = false; const timer = setInterval(() => { if (polling) return; polling = true; void refresh.current().finally(() => { polling = false; }); }, RUN_POLL_MS); return () => clearInterval(timer); }, [active]); let daemonLine = "not running: no daemon"; if (daemonRunning) { daemonLine = `running (heartbeat ${at(automation.daemon.heartbeatAt)})`; } else if (automation.daemon.state === "stale") { daemonLine = `stale (last heartbeat ${at(automation.daemon.heartbeatAt)})`; } return (

    Off by default. A hook or schedule only marks a profile pending; the import runs in gno daemon on this archive (or with Run now).{" "} gno serve never runs it. Daemon:{" "} {daemonLine}. Times in {automation.timezone}.

    {!localClient && (

    Only a browser on this machine can change hooks or schedules.

    )}
    {automation.profiles.length > 0 && (
      {automation.profiles.map((profile) => ( ))}
    )} {localClient && sources.length > 0 && ( )} {notice && (

    {notice.text}

    )}
    ); }