From 633c366cc4d5e76870986f78ff303d8e3794d1c7 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Thu, 23 Jul 2026 14:46:57 -0500 Subject: [PATCH] feat(code): follow-up composer + new-session flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of the Code surface (cave-k0ua, follows #3716/#3719/#3722): the surface goes from read-only to generative. - code-composer.tsx — Codex-style 'Ask for follow-up changes' box under every workbench tab except Terminal. Sends through the sanctioned client LLM path (streamFamiliarText → /api/chat/send) RESUMING the selected session (sessionId rides), rooted at codeSessionWorkRoot (cave-9q24). Streams a compact reply tail with 'Full thread in Chat'; Stop cancels via /api/chat/stop with the send's runId. - code-new-session.tsx — '+ New session' modal: project + familiar pickers (/api/projects, /api/familiars), optional FRESH worktree (existing /api/changes action=create-worktree → .worktrees/), kickoff prompt. The send carries no sessionId (fresh thread, saved like any chat); onSession hands the id to the rail immediately while the stream keeps flowing server-side. - code-session-rail.tsx — + New session entry (also in empty state). - code-view.tsx — pendingNewIdRef guard: a just-created session's selection survives until /api/sessions/list catches up (the newest- session auto-pick must not clobber it). Pins added for all of the above in code-surface-mode.test.ts. Verified: pnpm test:app (892), test:api (239), typecheck, eslint green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/components/code-composer.tsx | 135 ++++++++++++++ src/components/code-new-session.tsx | 214 +++++++++++++++++++++++ src/components/code-session-rail.tsx | 26 ++- src/components/code-surface-mode.test.ts | 53 ++++++ src/components/code-view.tsx | 45 +++-- src/components/code-workbench.tsx | 5 + 6 files changed, 462 insertions(+), 16 deletions(-) create mode 100644 src/components/code-composer.tsx create mode 100644 src/components/code-new-session.tsx 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} +
+