Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions src/components/code-composer.tsx
Original file line number Diff line number Diff line change
@@ -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<Phase>({ kind: "idle" });
const [reply, setReply] = useState("");
const abortRef = useRef<AbortController | null>(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" });
}
Comment on lines +48 to +69

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 (
<div className="shrink-0 border-t border-[var(--border-hairline)] px-4 py-3">
{reply && phase.kind !== "idle" ? (
<div className="mb-2 flex items-start justify-between gap-3 text-[length:var(--text-xs)]">
<pre className="min-w-0 flex-1 whitespace-pre-wrap break-words font-sans text-[var(--text-secondary)]">
{replyTail(reply)}
</pre>
<button
type="button"
className="focus-ring shrink-0 rounded px-1 text-[length:var(--text-2xs)] text-[var(--text-muted)] underline decoration-dotted underline-offset-2 hover:text-[var(--text-primary)]"
onClick={() => onJumpToSession(row.id, row.familiarId)}
>
Full thread in Chat
</button>
</div>
) : null}
{phase.kind === "error" ? (
<p role="alert" className="mb-2 text-[length:var(--text-xs)] text-[var(--color-danger)]">
{phase.message}
</p>
) : null}
<div className="flex items-end gap-2">
<textarea
className="focus-ring-inset min-h-9 w-full resize-y rounded border border-[var(--border-hairline)] bg-transparent px-2.5 py-1.5 text-[length:var(--text-xs)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)]"
rows={2}
placeholder={busy ? "The familiar is working…" : "Ask for follow-up changes…"}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
void send();
}
}}
disabled={busy}
aria-label="Ask for follow-up changes"
/>
{busy ? (
<Button size="sm" variant="danger-ghost" onClick={() => void stop()}>
Stop
</Button>
) : (
<Button size="sm" variant="primary" disabled={!prompt.trim() || !row.familiarId} onClick={() => void send()}>
Send
</Button>
)}
</div>
</div>
);
}
214 changes: 214 additions & 0 deletions src/components/code-new-session.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
"use client";

/**
* CodeNewSession — the Code surface's "+ New session" flow (cave-k0ua):
* pick a project + familiar, optionally provision a FRESH worktree for a
* named branch (POST /api/changes action=create-worktree — .worktrees/<branch>
* off origin's default), write the kickoff prompt, and start the conversation
* through the sanctioned client LLM path (streamFamiliarText, no sessionId =
* new thread) rooted at the chosen cwd. The moment the bridge announces the
* backing session id we hand it to the rail and close — the stream keeps
* running server-side; the transcript lives in Chat.
*/

import { useEffect, useState } from "react";
import { Modal } from "@/components/ui/modal";
import { Button } from "@/components/ui/button";
import { StandardSelect } from "@/components/ui/select";
import { streamFamiliarText } from "@/lib/familiar-stream";
import type { CaveProject } from "@/lib/cave-projects-types";
import type { Familiar } from "@/lib/types";

type Phase = { kind: "idle" } | { kind: "provisioning" } | { kind: "starting" } | { kind: "error"; message: string };

export function CodeNewSession({
open,
onClose,
onCreated,
}: {
open: boolean;
onClose: () => void;
/** Fired as soon as the bridge announces the new session id. */
onCreated: (sessionId: string) => void;
}) {
const [projects, setProjects] = useState<CaveProject[]>([]);
const [familiars, setFamiliars] = useState<Familiar[]>([]);
const [projectId, setProjectId] = useState("");
const [familiarId, setFamiliarId] = useState("");
const [freshWorktree, setFreshWorktree] = useState(false);
const [branch, setBranch] = useState("");
const [prompt, setPrompt] = useState("");
const [phase, setPhase] = useState<Phase>({ kind: "idle" });
const busy = phase.kind === "provisioning" || phase.kind === "starting";

useEffect(() => {
if (!open) return;
let cancelled = false;
(async () => {
try {
const [projRes, famRes] = await Promise.all([
fetch("/api/projects", { cache: "no-store" }),
fetch("/api/familiars", { cache: "no-store" }),
]);
const proj = (await projRes.json().catch(() => null)) as { ok?: boolean; projects?: CaveProject[] } | null;
const fam = (await famRes.json().catch(() => null)) as { ok?: boolean; familiars?: Familiar[] } | null;
if (cancelled) return;
const projectRows = proj?.ok && Array.isArray(proj.projects) ? proj.projects : [];
const familiarRows = fam?.ok && Array.isArray(fam.familiars) ? fam.familiars : [];
setProjects(projectRows);
setFamiliars(familiarRows);
setProjectId((prev) => prev || (projectRows[0]?.id ?? ""));
setFamiliarId((prev) => prev || (familiarRows[0]?.id ?? ""));
} catch {
if (!cancelled) setPhase({ kind: "error", message: "Couldn’t load projects/familiars." });
}
})();
return () => {
cancelled = true;
};
}, [open]);

const project = projects.find((p) => p.id === projectId) ?? null;
const canCreate = Boolean(project && familiarId && prompt.trim() && (!freshWorktree || branch.trim()) && !busy);

async function create() {
if (!project || !canCreate) return;
let cwd = project.root;

if (freshWorktree) {
setPhase({ kind: "provisioning" });
try {
const res = await fetch("/api/changes", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ projectRoot: project.root, action: "create-worktree", branch: branch.trim() }),
});
const json = (await res.json().catch(() => null)) as
| { ok?: boolean; worktree?: string; error?: string }
| null;
if (!res.ok || !json?.ok || !json.worktree) {
setPhase({ kind: "error", message: json?.error ?? "Couldn’t create the worktree." });
return;
}
cwd = json.worktree;
} catch (err) {
setPhase({ kind: "error", message: err instanceof Error ? err.message : "Couldn’t create the worktree." });
return;
}
}

setPhase({ kind: "starting" });
let announced = false;
// Fire-and-continue: the moment the session id arrives the rail can select
// it; the stream keeps flowing server-side and Chat shows the transcript.
void streamFamiliarText({
familiarId,
prompt: prompt.trim(),
projectRoot: cwd,
runId: `code-new-session-${Date.now().toString(36)}`,
onSession: (sessionId) => {
if (announced) return;
announced = true;
onCreated(sessionId);
},
}).then((result) => {
if (!announced && result.sessionId) {
announced = true;
onCreated(result.sessionId);
} else if (!announced && result.error) {
setPhase({ kind: "error", message: result.error });
}
});
Comment on lines +104 to +121
}

function reset() {
setPhase({ kind: "idle" });
setPrompt("");
setBranch("");
setFreshWorktree(false);
}

return (
<Modal
open={open}
onClose={() => {
if (busy && phase.kind !== "starting") return; // don't abandon mid-provision
reset();
onClose();
}}
breadcrumb={["Code", "New session"]}
footerActions={
<>
<Button
size="sm"
variant="ghost"
onClick={() => {
reset();
onClose();
}}
disabled={phase.kind === "provisioning"}
>
Cancel
</Button>
<Button size="sm" variant="primary" disabled={!canCreate} onClick={() => void create()}>
{phase.kind === "provisioning"
? "Creating worktree…"
: phase.kind === "starting"
? "Starting session…"
: "Start session"}
</Button>
</>
}
>
<div className="flex flex-col gap-3">
<StandardSelect
label="Project"
value={projectId}
onChange={setProjectId}
options={projects.map((p) => ({ value: p.id, label: p.name || p.root }))}
disabled={busy || projects.length === 0}
/>
<StandardSelect
label="Familiar"
value={familiarId}
onChange={setFamiliarId}
options={familiars.map((f) => ({ value: f.id, label: f.display_name }))}
disabled={busy || familiars.length === 0}
/>
<label className="flex items-center gap-2 text-[length:var(--text-xs)] text-[var(--text-secondary)]">
<input
type="checkbox"
checked={freshWorktree}
onChange={(e) => setFreshWorktree(e.target.checked)}
disabled={busy}
/>
Work in a fresh worktree
</label>
{freshWorktree ? (
<input
type="text"
className="focus-ring-inset w-full rounded border border-[var(--border-hairline)] bg-transparent px-2.5 py-1.5 font-mono text-[length:var(--text-xs)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)]"
placeholder="branch name (e.g. feat/my-change)"
value={branch}
onChange={(e) => setBranch(e.target.value)}
disabled={busy}
aria-label="Worktree branch name"
/>
) : null}
<textarea
className="focus-ring-inset min-h-20 w-full resize-y rounded border border-[var(--border-hairline)] bg-transparent px-2.5 py-1.5 text-[length:var(--text-xs)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)]"
placeholder="What should this session work on?"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
disabled={busy}
aria-label="Kickoff prompt"
/>
{phase.kind === "error" ? (
<p role="alert" className="text-[length:var(--text-xs)] text-[var(--color-danger)]">
{phase.message}
</p>
) : null}
</div>
</Modal>
);
}
26 changes: 21 additions & 5 deletions src/components/code-session-rail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,21 +51,37 @@ export type CodeSessionRailProps = {
sessions: SessionRow[];
selectedId: string | null;
onSelect: (sessionId: string) => void;
onNewSession?: () => void;
};

export function CodeSessionRail({ sessions, selectedId, onSelect }: CodeSessionRailProps) {
export function CodeSessionRail({ sessions, selectedId, onSelect, onNewSession }: CodeSessionRailProps) {
const groups = groupCodeRailSessions(sessions);
const newButton = onNewSession ? (
<div className="px-2 pb-1">
<button
type="button"
className="focus-ring flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-[length:var(--text-xs)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]"
onClick={onNewSession}
>
<Icon name="ph:plus" width={12} height={12} />
New session
</button>
</div>
) : null;
if (groups.length === 0) {
return (
<div className="px-3 py-6 text-[length:var(--text-xs)] text-[var(--text-muted)]">
No coding sessions yet. Start one from Chat — it will appear here with
its branch, diff, and PR context.
<div className="flex h-full flex-col py-2">
{newButton}
<div className="px-3 py-4 text-[length:var(--text-xs)] text-[var(--text-muted)]">
No coding sessions yet. Start one here — or from Chat — and it will
appear with its branch, diff, and PR context.
</div>
</div>
);
}
return (
<nav aria-label="Coding sessions" className="flex h-full min-h-0 flex-col overflow-y-auto py-2">
{groups.map((group) => (
{newButton} {groups.map((group) => (
<section key={group.root || "(unknown)"} className="mb-2">
<div
className="truncate px-3 py-1 text-[length:var(--text-2xs)] font-semibold uppercase tracking-wider text-[var(--text-secondary)]"
Expand Down
Loading
Loading