From 6943ee5c5ae0bd3ae5cd13f6100865eee697fcc1 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Thu, 23 Jul 2026 15:02:59 -0500 Subject: [PATCH] =?UTF-8?q?feat(code):=20session=20inspector=20=E2=80=94?= =?UTF-8?q?=20branches,=20worktrees,=20env=20(right=20column)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth slice of the dedicated Code surface (cave-k0ua): the workbench grows a toggleable right-hand inspector panel. - code-inspector.tsx: session env rows (harness/model/root/updated), local branch list with one-click switch, and fresh-worktree provisioning — the same /api/changes contracts chat's composer git chip uses (?branches=1, action=switch-branch, action=create-worktree), scoped to the session's work root (cave-9q24: worktree sessions mutate their own checkout). - code-workbench.tsx: header toggle (ph:sliders-bold, aria-pressed) shows the inspector as a w-72 border-l column on md+; dynamic() import; keyed by work root; narrow-screen treatment deferred to the mobile layout pass. - code-view.tsx: threads onTasksRefresh into the workbench so branch/worktree mutations re-poll the enriched session list (branch chips stay honest). - code-surface-mode.test.ts: inspector pin section (work-root scoping, ?branches=1 contract, switch/create actions, toggle accessibility, refresh threading). Verified: pin suite, tsc --noEmit, eslint, 892 app + 239 api test files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/components/code-inspector.tsx | 226 +++++++++++++++++++++++ src/components/code-surface-mode.test.ts | 53 ++++++ src/components/code-view.tsx | 1 + src/components/code-workbench.tsx | 72 ++++++-- 4 files changed, 335 insertions(+), 17 deletions(-) create mode 100644 src/components/code-inspector.tsx diff --git a/src/components/code-inspector.tsx b/src/components/code-inspector.tsx new file mode 100644 index 000000000..53f9903c8 --- /dev/null +++ b/src/components/code-inspector.tsx @@ -0,0 +1,226 @@ +"use client"; + +/** + * CodeInspector — the workbench's right-hand details panel (cave-k0ua): + * session environment (harness, model, work root), local branches with + * one-click switch, and fresh-worktree provisioning — the same /api/changes + * surface chat's composer git chip uses (?branches=1, action=switch-branch, + * action=create-worktree), scoped to the session's work root (cave-9q24). + * + * Switching a branch mutates the CHECKOUT at the work root — for worktree + * sessions that's their private worktree; for shared-checkout sessions it's + * the shared root (identical semantics to chat's git chip, user-explicit). + */ + +import { useCallback, useEffect, useState } from "react"; +import { Icon } from "@/lib/icon"; +import { Button } from "@/components/ui/button"; +import { relativeTime } from "@/lib/relative-time"; +import { codeSessionWorkRoot } from "@/lib/code-surface"; +import type { SessionRow } from "@/lib/types"; + +type BranchRow = { + name: string; + current: boolean; + /** Checkout dir basename when some worktree has the branch checked out. */ + worktree: string | null; + worktreePath?: string | null; +}; + +type BranchesState = + | { phase: "loading" } + | { phase: "ready"; branches: BranchRow[] } + | { phase: "error"; message: string }; + +function useBranches(projectRoot: string): BranchesState & { refresh: () => void } { + const [state, setState] = useState({ phase: "loading" }); + const [tick, setTick] = useState(0); + useEffect(() => { + let cancelled = false; + setState((prev) => (tick > 0 && prev.phase === "ready" ? prev : { phase: "loading" })); + (async () => { + try { + const res = await fetch(`/api/changes?projectRoot=${encodeURIComponent(projectRoot)}&branches=1`, { + cache: "no-store", + }); + const json = (await res.json().catch(() => null)) as + | { ok?: boolean; error?: string; branches?: BranchRow[] } + | null; + if (cancelled) return; + if (!res.ok || !json?.ok || !Array.isArray(json.branches)) { + setState({ phase: "error", message: json?.error ?? `branches HTTP ${res.status}` }); + return; + } + setState({ phase: "ready", branches: json.branches }); + } catch (err) { + if (!cancelled) + setState({ phase: "error", message: err instanceof Error ? err.message : "couldn't list branches" }); + } + })(); + return () => { + cancelled = true; + }; + }, [projectRoot, tick]); + const refresh = useCallback(() => setTick((t) => t + 1), []); + return { ...state, refresh }; +} + +function EnvRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+ {label} + + {value} + +
+ ); +} + +export function CodeInspector({ row, onChanged }: { row: SessionRow; onChanged?: () => void }) { + const workRoot = codeSessionWorkRoot(row); + const branches = useBranches(workRoot); + const [busyBranch, setBusyBranch] = useState(null); + const [newBranch, setNewBranch] = useState(""); + const [creating, setCreating] = useState(false); + const [notice, setNotice] = useState<{ kind: "ok" | "err"; text: string } | null>(null); + + async function post(body: Record): Promise<{ ok: boolean; error?: string; worktree?: string }> { + try { + const res = await fetch("/api/changes", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + const json = (await res.json().catch(() => null)) as + | { ok?: boolean; error?: string; worktree?: string } + | null; + if (!res.ok || !json?.ok) return { ok: false, error: json?.error ?? `HTTP ${res.status}` }; + return { ok: true, worktree: json.worktree }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "network error" }; + } + } + + async function switchBranch(name: string) { + if (busyBranch) return; + setBusyBranch(name); + setNotice(null); + const result = await post({ projectRoot: workRoot, action: "switch-branch", branch: name }); + setBusyBranch(null); + if (result.ok) { + setNotice({ kind: "ok", text: `Switched to ${name}.` }); + branches.refresh(); + onChanged?.(); + } else { + setNotice({ kind: "err", text: result.error ?? "Switch failed." }); + } + } + + async function createWorktree() { + const name = newBranch.trim(); + if (!name || creating) return; + setCreating(true); + setNotice(null); + const result = await post({ projectRoot: workRoot, action: "create-worktree", branch: name }); + setCreating(false); + if (result.ok) { + setNewBranch(""); + setNotice({ kind: "ok", text: `Worktree ready${result.worktree ? ` at ${result.worktree}` : ""}.` }); + branches.refresh(); + onChanged?.(); + } else { + setNotice({ kind: "err", text: result.error ?? "Worktree creation failed." }); + } + } + + return ( +
+
+

+ Session +

+ + {row.model ? : null} + + +
+ +
+

+ Branches +

+ {branches.phase === "loading" ? ( +

Loading…

+ ) : branches.phase === "error" ? ( +

{branches.message}

+ ) : ( +
    + {branches.branches.map((b) => ( +
  • + +
  • + ))} +
+ )} +
+ +
+

+ New worktree +

+
+ setNewBranch(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void createWorktree(); + }} + disabled={creating} + aria-label="New worktree branch name" + /> + +
+
+ + {notice ? ( +

+ {notice.text} +

+ ) : null} +
+ ); +} diff --git a/src/components/code-surface-mode.test.ts b/src/components/code-surface-mode.test.ts index b9b854b86..b73cc9946 100644 --- a/src/components/code-surface-mode.test.ts +++ b/src/components/code-surface-mode.test.ts @@ -235,6 +235,59 @@ assert.match( "the composer rides under every tab except Terminal (which owns its input)", ); +// ── Inspector: branches / worktrees / session env (right column) ───────────── + +// The inspector reuses the exact /api/changes surface chat's composer git chip +// speaks (?branches=1, switch-branch, create-worktree) but scopes every call +// to the session's WORK ROOT — a worktree session mutates its own checkout, +// never the shared root (cave-9q24). +const inspector = await readFile(new URL("./code-inspector.tsx", import.meta.url), "utf8"); +assert.match( + inspector, + /const workRoot = codeSessionWorkRoot\(row\);/, + "every inspector call is scoped to the session's work root", +); +assert.match( + inspector, + /\/api\/changes\?projectRoot=\$\{encodeURIComponent\(projectRoot\)\}&branches=1/, + "branch list comes from the same ?branches=1 contract as chat's git chip", +); +assert.match( + inspector, + /action: "switch-branch", branch: name/, + "one-click branch switch posts the existing switch-branch action", +); +assert.match( + inspector, + /action: "create-worktree", branch: name/, + "fresh-worktree provisioning posts the existing create-worktree action", +); +assert.match( + inspector, + /disabled=\{b\.current \|\| busyBranch != null\}/, + "the checked-out branch is not a switch target and switches don't overlap", +); +assert.match( + workbench, + /aria-pressed=\{inspectorOpen\}/, + "the header exposes an accessible inspector toggle", +); +assert.match( + workbench, + /\{inspectorOpen \? \(\s*