Files
ai-agent/frontend/src/lib/spawn.tsx
Gabriel Vidal b364217eb7 feat(composer): guideline DAG — shortcuts that reveal their children
The prompt shaping RichInput used to do now lives here, where it belongs, and
gets a graph while it moves. PromptShortcut grows a children[] adjacency list:
a shortcut listed as someone's child only appears — and only contributes its
line — while that parent is on, so 'Open PR' can hang off 'Commit & push' and
a smoke-test note off 'Deploy'. It is a DAG, not a tree: several parents may
reach the same child (drawn once, under the first that reveals it), and the
traversal carries a visited set so a hand-made cycle terminates.

lib/guidelineDag owns it: visibleGuidelines() derives the chips (with depth)
from the graph plus the live selection, guidelineLines() turns that selection
into prompt lines — including the guidelineOff branch for a visible-but-off
either/or — and defaultGuidelineIds() seeds the default-on cascade so the
composer's first paint shows what it would send. richComposer maps the result
onto the now-generic RichTag and rebuilds the prompt in richComposePrompt.

Settings gains the nesting: a child card is indented under its parent, a
collapsed card says what reveals it and what it reveals, and a 'Nested under'
chip row edits the edges (descendants excluded, so the graph stays acyclic).
Deleting a shortcut also cuts the edges pointing at it, and a v1 store
migration re-seeds the shipped links onto built-ins persisted before the DAG.

Needs @gabvdl/ui 0.31.0.
2026-08-09 17:50:22 +02:00

251 lines
9.0 KiB
TypeScript

import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type { RichTag } from "@gabvdl/ui";
import {
fetchConversations,
spawnConversation,
type UploadedFile,
} from "@/api";
// Misclick guard: after submit we show the optimistic card but hold the real
// API call for this long, during which tapping the card fully un-sends it.
const QUEUE_DELAY_MS = 3_000;
// How long to wait for the new transcript to sync before giving up the redirect.
const SYNC_TIMEOUT_MS = 90_000;
const SYNC_POLL_MS = 1_500;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/**
* Poll the conversation list until a transcript with this session id syncs
* (~2s after a spawn/fork), and return its conversation id — or null on
* timeout/abort. Shared by the spawn lifecycle below and the fork flow.
*/
export async function waitForConversation(
sessionId: string,
opts?: { timeoutMs?: number; aborted?: () => boolean },
): Promise<string | null> {
const deadline = Date.now() + (opts?.timeoutMs ?? SYNC_TIMEOUT_MS);
while (!opts?.aborted?.() && Date.now() < deadline) {
try {
const { conversations } = await fetchConversations();
const hit = conversations.find((c) => c.sessionId === sessionId);
if (hit) return hit.id;
} catch {
/* transient — keep polling */
}
await sleep(SYNC_POLL_MS);
}
return null;
}
type SpawnStatus = "idle" | "queued" | "spawning" | "waiting" | "ready" | "error";
interface EnqueueArgs {
/** The raw text the user typed (restored to the composer on cancel). */
base: string;
/** The fully composed prompt (with tags/guidelines/attachments) to send. */
prompt: string;
/** Files echoed in the optimistic card + restored on cancel. */
files: UploadedFile[];
/** Tags active at submit (guidelines + locations), echoed in the sent card. */
tags?: RichTag[];
/** `--model` argument from the composer's model tag (undefined = default). */
model?: string;
/** Agent harness the model routes to: "claude" (default) or "pi". */
harness?: "claude" | "pi";
/** false ⇒ spawn with thinking disabled (the composer's thinking toggle). */
thinking?: boolean;
/** claude only: `--effort` level from the composer's effort select. */
effort?: string;
}
interface SpawnContextValue {
status: SpawnStatus;
/** The optimistic echo of the just-submitted prompt (null when idle). */
sentText: string | null;
sentFiles: UploadedFile[];
/** The guideline/location tags that went out with the send (chips in the card). */
sentTags: RichTag[];
error: string | null;
/** Seconds left in the un-send window (0 once the API call has fired). */
countdown: number;
/**
* The synced conversation id once the spawn has landed (status `"ready"`) —
* the sent card offers "tap to open" instead of auto-navigating there.
*/
readyId: string | null;
/** Queue a spawn; the real API call fires after QUEUE_DELAY_MS. */
enqueue: (args: EnqueueArgs) => void;
/**
* Un-send / detach the current spawn and return the raw text so the composer
* can restore it. Only works during the queued window (before the API call).
*/
cancel: () => { base: string; files: UploadedFile[] } | null;
/**
* Hide the sent card / error notification without cancelling anything: a
* spawn already fired keeps running on the host, this just stops tracking it
* (swipe-away, or collapsing the spawn box).
*/
dismiss: () => void;
}
const SpawnContext = createContext<SpawnContextValue | null>(null);
/**
* App-level owner of the "start a new session" lifecycle. Lives above the
* router so the queued API call still fires — and the transcript sync keeps
* being watched — even if the composer (SpawnBox) unmounts because the user
* navigated away before the 3s un-send window elapsed.
*/
export function SpawnProvider({ children }: { children: React.ReactNode }) {
const [status, setStatus] = useState<SpawnStatus>("idle");
const [sentText, setSentText] = useState<string | null>(null);
const [sentFiles, setSentFiles] = useState<UploadedFile[]>([]);
const [sentTags, setSentTags] = useState<RichTag[]>([]);
const [error, setError] = useState<string | null>(null);
const [countdown, setCountdown] = useState(0);
const [readyId, setReadyId] = useState<string | null>(null);
// The queued (not-yet-sent) request + its timers, and an abort token the
// in-flight sync loop checks so cancel() can detach it.
const pending = useRef<EnqueueArgs | null>(null);
const queueTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const tickTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const runId = useRef(0);
// The run id whose API call has already been fired, so run() can never spawn a
// second conversation for the same submission (idempotency guard).
const firedFor = useRef(-1);
const clearTimers = useCallback(() => {
if (queueTimer.current) clearTimeout(queueTimer.current);
if (tickTimer.current) clearInterval(tickTimer.current);
queueTimer.current = null;
tickTimer.current = null;
}, []);
const reset = useCallback(() => {
clearTimers();
pending.current = null;
setStatus("idle");
setSentText(null);
setSentFiles([]);
setSentTags([]);
setCountdown(0);
setReadyId(null);
}, [clearTimers]);
// Fire the actual API call + poll for the transcript. Deliberately NO
// auto-redirect once it lands: the sent card flips to "ready — tap to open"
// and the user decides when (or whether) to jump in.
const run = useCallback(
async (args: EnqueueArgs, id: number) => {
// Fire exactly once per submission — guards against the timer callback (or
// a stray re-entry) ever kicking off a second `claude -p` for one prompt.
if (firedFor.current === id) return;
firedFor.current = id;
const aborted = () => runId.current !== id;
setStatus("spawning");
setCountdown(0);
try {
const { sessionId } = await spawnConversation(
args.prompt,
args.model,
args.harness,
args.thinking,
args.effort,
);
if (aborted()) return;
setStatus("waiting");
const hit = await waitForConversation(sessionId, { aborted });
if (aborted()) return;
if (hit) {
setReadyId(hit);
setStatus("ready");
return;
}
// The session started but its transcript never synced in time — say so
// rather than silently dropping the card.
setError(
"Session started, but its transcript hasn't synced yet — it will show up in the conversation list shortly.",
);
setStatus("error");
} catch (e) {
if (aborted()) return;
clearTimers();
pending.current = args;
setError(e instanceof Error ? e.message : String(e));
setStatus("error");
}
},
[clearTimers],
);
const enqueue = useCallback(
(args: EnqueueArgs) => {
clearTimers();
const id = ++runId.current;
pending.current = args;
setError(null);
setSentText(args.base);
setSentFiles(args.files);
setSentTags(args.tags ?? []);
setStatus("queued");
setCountdown(Math.ceil(QUEUE_DELAY_MS / 1000));
tickTimer.current = setInterval(
() => setCountdown((c) => Math.max(0, c - 1)),
1000,
);
queueTimer.current = setTimeout(() => {
clearTimers();
void run(args, id);
}, QUEUE_DELAY_MS);
},
[clearTimers, run],
);
const cancel = useCallback(() => {
// Only the pre-API queued window can be un-sent. `queueTimer` is set exactly
// while we're still counting down and the API call has NOT yet fired; once
// it fires the timer is cleared. Refusing to "cancel" after that is what
// guarantees a single conversation — otherwise a tap racing the timer would
// silently leave a spawned session behind while restoring the composer, and
// the user would re-submit and get a duplicate.
if (!queueTimer.current) return null;
const args = pending.current;
setError(null);
reset();
return args ? { base: args.base, files: args.files } : null;
}, [reset]);
const dismiss = useCallback(() => {
// Detach any in-flight sync loop (its `aborted()` check trips on the bump),
// then clear the card/error. Anything already sent keeps running unwatched.
runId.current += 1;
setError(null);
reset();
}, [reset]);
useEffect(() => () => clearTimers(), [clearTimers]);
const value = useMemo<SpawnContextValue>(
() => ({ status, sentText, sentFiles, sentTags, error, countdown, readyId, enqueue, cancel, dismiss }),
[status, sentText, sentFiles, sentTags, error, countdown, readyId, enqueue, cancel, dismiss],
);
return <SpawnContext.Provider value={value}>{children}</SpawnContext.Provider>;
}
export function useSpawn(): SpawnContextValue {
const ctx = useContext(SpawnContext);
if (!ctx) throw new Error("useSpawn must be used within a SpawnProvider");
return ctx;
}