diff --git a/src/components/code-composer.tsx b/src/components/code-composer.tsx new file mode 100644 index 000000000..638fe9fba --- /dev/null +++ b/src/components/code-composer.tsx @@ -0,0 +1,135 @@ +"use client"; + +/** + * CodeComposer — the workbench's "Ask for follow-up changes" box (cave-k0ua): + * sends a prompt to the SELECTED session's agent through the sanctioned + * client LLM path (streamFamiliarText → /api/chat/send with sessionId, the + * same resume the chat surface uses) and shows a compact tail of the reply + * as it streams. The full transcript stays in Chat — Open in Chat jumps + * there. Stop cancels the run via /api/chat/stop with the send's runId. + */ + +import { useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { streamFamiliarText } from "@/lib/familiar-stream"; +import { codeSessionWorkRoot } from "@/lib/code-surface"; +import type { SessionRow } from "@/lib/types"; + +type Phase = { kind: "idle" } | { kind: "streaming"; runId: string } | { kind: "done" } | { kind: "error"; message: string }; + +/** Last few lines of the streamed reply — a peek, not a transcript. */ +function replyTail(text: string, lines = 3): string { + const all = text.trimEnd().split("\n"); + return all.slice(-lines).join("\n"); +} + +export function CodeComposer({ + row, + onJumpToSession, +}: { + row: SessionRow; + onJumpToSession: (sessionId: string, familiarId?: string | null) => void; +}) { + const [prompt, setPrompt] = useState(""); + const [phase, setPhase] = useState({ kind: "idle" }); + const [reply, setReply] = useState(""); + const abortRef = useRef(null); + const busy = phase.kind === "streaming"; + + async function send() { + const trimmed = prompt.trim(); + if (!trimmed || busy || !row.familiarId) return; + const runId = `code-composer-${Date.now().toString(36)}`; + const controller = new AbortController(); + abortRef.current = controller; + setPhase({ kind: "streaming", runId }); + setReply(""); + setPrompt(""); + const result = await streamFamiliarText({ + familiarId: row.familiarId, + sessionId: row.id, + prompt: trimmed, + projectRoot: codeSessionWorkRoot(row), + runId, + signal: controller.signal, + onText: setReply, + }); + abortRef.current = null; + if (controller.signal.aborted) { + setPhase({ kind: "done" }); + return; + } + if (result.error && !result.text) { + setPhase({ kind: "error", message: result.error }); + setPrompt(trimmed); // let the user retry without retyping + return; + } + setReply(result.text); + setPhase({ kind: "done" }); + } + + async function stop() { + if (phase.kind !== "streaming") return; + // Ask the bridge to stop the run, then drop the stream client-side too. + try { + await fetch("/api/chat/stop", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ runId: phase.runId, sessionId: row.id }), + }); + } catch { + /* the local abort below still ends the stream */ + } + abortRef.current?.abort(); + } + + return ( +
+ {reply && phase.kind !== "idle" ? ( +
+
+            {replyTail(reply)}
+          
+ +
+ ) : null} + {phase.kind === "error" ? ( +

+ {phase.message} +

+ ) : null} +
+