diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 7f36ca949..6b34435a9 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -514,7 +514,8 @@ export const SUITES = { "src/components/workspace-feedback.test.ts", "src/components/session-changes-inner.test.ts", "src/components/code-editor.test.ts", - "src/components/code-view.test.ts", + "src/components/code-surface-mode.test.ts", + "src/lib/code-surface.test.ts", "src/components/chat-prompt-enhance.test.ts", "src/components/settings-shortcut.test.ts", "src/components/bottom-terminal-sr-mirror.test.ts", diff --git a/src/components/chat-rail-modern-redesign.test.ts b/src/components/chat-rail-modern-redesign.test.ts index c72e85007..bb9735fef 100644 --- a/src/components/chat-rail-modern-redesign.test.ts +++ b/src/components/chat-rail-modern-redesign.test.ts @@ -32,8 +32,8 @@ assert.match( ); assert.match( workspace, - /const targetMode = \(e as CustomEvent<\{ mode\?: string \}>\)\.detail\?\.mode;[\s\S]*?if \(targetMode === "code"\)[\s\S]*?setMode\(targetMode as WorkspaceMode\)/, - "The navigate listener redirects retired code links, then calls setMode with other requested modes", + /const targetMode = \(e as CustomEvent<\{ mode\?: string \}>\)\.detail\?\.mode;[\s\S]*?if \(targetMode === "code" && !caveCodeSurface\(\)\)[\s\S]*?setMode\(targetMode as WorkspaceMode\)/, + "The navigate listener redirects flag-off code links, then calls setMode with other requested modes", ); // ── Uppercase counted section headers (RESULTS) + compact Projects header ──── diff --git a/src/components/code-session-rail.tsx b/src/components/code-session-rail.tsx new file mode 100644 index 000000000..976432d69 --- /dev/null +++ b/src/components/code-session-rail.tsx @@ -0,0 +1,122 @@ +"use client"; + +/** + * CodeSessionRail — left rail of the Code surface (cave-k0ua): every active + * coding conversation grouped by project, newest first, with per-session git + * attribution badges (branch, PR, diffstat, worktree). Selection drives the + * workbench; the rail never mutates sessions itself. + */ + +import React from "react"; +import { Icon } from "@/lib/icon"; +import { + codeSessionActivity, + codeSessionBranch, + codeSessionDiffstat, + groupCodeRailSessions, +} from "@/lib/code-surface"; +import type { SessionRow } from "@/lib/types"; + +function PrChip({ pr }: { pr: NonNullable }) { + const state = (pr.state ?? "").toLowerCase(); + const tone = + state === "merged" + ? "text-[var(--accent-presence)]" + : state === "closed" + ? "text-[var(--color-danger)]" + : "text-[var(--text-secondary)]"; + return ( + + + {pr.number != null ? `#${pr.number}` : state || "PR"} + + ); +} + +function ActivityDot({ row }: { row: SessionRow }) { + const activity = codeSessionActivity(row); + if (activity === "running") { + return ; + } + if (activity === "error") { + return ; + } + return ; +} + +export type CodeSessionRailProps = { + sessions: SessionRow[]; + selectedId: string | null; + onSelect: (sessionId: string) => void; +}; + +export function CodeSessionRail({ sessions, selectedId, onSelect }: CodeSessionRailProps) { + const groups = groupCodeRailSessions(sessions); + if (groups.length === 0) { + return ( +
+ No coding sessions yet. Start one from Chat — it will appear here with + its branch, diff, and PR context. +
+ ); + } + return ( + + ); +} diff --git a/src/components/code-surface-mode.test.ts b/src/components/code-surface-mode.test.ts new file mode 100644 index 000000000..8adaf3a7e --- /dev/null +++ b/src/components/code-surface-mode.test.ts @@ -0,0 +1,119 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +// Pin suite for the dedicated Code surface (cave-k0ua). +// +// History: a standalone "code" WorkspaceMode was retired and this file's +// predecessor (code-view.test.ts) was the retirement guard keeping it deleted. +// The owner requested a Codex-style multi-session coding surface — diffs, +// files, terminal, per-session PR context, worktrees, branches, with GitHub +// absorbed as a tab — so the mode returned, flag-gated by caveCodeSurface() +// (NEXT_PUBLIC_CAVE_CODE_SURFACE). These pins document the sanctioned shape: +// the surface exists, the flag gates entry, and Chat's own code rail stays +// untouched until the flagged follow-up that slims it. + +const workspace = await readFile(new URL("./workspace.tsx", import.meta.url), "utf8"); +const sidebar = await readFile(new URL("./sidebar-minimal.tsx", import.meta.url), "utf8"); +const modeType = await readFile(new URL("../lib/workspace-mode.ts", import.meta.url), "utf8"); +const codeView = await readFile(new URL("./code-view.tsx", import.meta.url), "utf8"); +const lazySurfaces = await readFile(new URL("./lazy-surfaces.tsx", import.meta.url), "utf8"); +const chatSurface = await readFile(new URL("./chat-surface.tsx", import.meta.url), "utf8"); +const chatRouter = await readFile(new URL("./chat-router.tsx", import.meta.url), "utf8"); +const chatView = await readFile(new URL("./chat-view.tsx", import.meta.url), "utf8"); + +// ── Mode vocabulary ────────────────────────────────────────────────────────── + +assert.match(modeType, /\|\s*"code"/, "WorkspaceMode includes the Code surface again (cave-k0ua)"); + +// ── Workspace wiring ───────────────────────────────────────────────────────── + +assert.match( + workspace, + /code: "Code"/, + "WORKSPACE_MODE_TITLES names the Code surface (canonical-nav agreement)", +); +assert.match( + workspace, + /mode === "code" \? \(\s* import\("@\/components\/code-view"\)\.then\(\(m\) => m\.CodeView\)/, + "CodeView stays code-split behind lazy-surfaces — its chunk must not join the boot bundle", +); + +// Flag gating: while caveCodeSurface() is off, "code" deep links keep the +// retirement-era fallback (newest repo chat); with it on they land on the +// surface. Both behaviors live in the same navigate-mode branch. +assert.match( + workspace, + /if \(targetMode === "code" && !caveCodeSurface\(\)\) \{[\s\S]*?filter\(\(s\) => s\.project_root\)[\s\S]*?openFamiliarSession\(repoSession\.id, repoSession\.familiarId\)[\s\S]*?setMode\("chat"\)/, + "flag-off code deep-links redirect to the newest repo chat or Chat fallback", +); +// The setMode funnel is the choke point for every other entry (?mode= deep +// link, persisted last-surface restore): flag off, "code" lands on Chat +// instead of rendering a gated surface. +assert.match( + workspace, + /if \(next === "code" && !caveCodeSurface\(\)\) \{[\s\S]{0,600}?setModeRaw\("chat"\);\s*return;/, + "setMode funnels flag-off code requests to Chat", +); + +// File/diff links from inbox cards etc. still target Chat's code rail this +// phase — retargeting them to the Code surface is an explicit follow-up. +assert.match( + workspace, + /File\/diff links target ChatSurface's code rail[\s\S]*?setPendingCodeRailOpen\([\s\S]*?setMode\("chat"\)/, + "file-open events keep targeting Chat's code rail until the flagged follow-up", +); + +// The primary keyboard cluster is unchanged: Code is a quiet destination, not +// a ⌘1-5 surface. +assert.match( + workspace, + /const SURFACE_ORDER: WorkspaceMode\[\] = \[\s*"home", "chat", "board", "inbox", "browser",\s*\]/, + "keyboard surface order keeps the primary cluster without Code", +); + +// ── Sidebar row swap ───────────────────────────────────────────────────────── + +// One quiet slot, two vocabularies: flag on → Code row (GitHub becomes a tab +// inside the surface); flag off → the standalone GitHub row, byte-identical to +// the pre-flag sidebar. The conditional spread keeps FOLDER_MODES a single +// literal so palette/mobile/canonical-name extraction regexes stay valid. +assert.match( + sidebar, + /\.\.\.\(caveCodeSurface\(\)\s*\?\s*\[\{ id: "code", label: "Code", iconName: "ph:code"/, + "flag on: the Code quiet row takes the GitHub slot", +); +assert.match( + sidebar, + /\{ id: "github", label: "GitHub", iconName: "ph:github-logo"/, + "flag off: the GitHub row literal survives in FOLDER_MODES", +); +assert.match( + codeView, + /import\("@\/components\/github-view"\)\.then\(\(m\) => m\.GitHubView\)/, + "the Code surface mounts GitHubView whole under its GitHub tab", +); + +// ── Chat stays untouched this phase ────────────────────────────────────────── + +// Phase 1 builds the surface *behind the flag* without slimming Chat: the code +// rail, its surface-agnostic shape, and composer copy are exactly as the +// retirement left them. Removing/redirecting them is the follow-up phase. +assert.doesNotMatch( + chatSurface, + /surface\s*=\s*"chat"|surface === "code"|isCodeSurface|CodeInlineToolbar|data-surface=\{surface\}/, + "ChatSurface must not regrow a code-surface branch", +); +assert.match(chatSurface, /const compactRail = hideThreadRail/, "ChatSurface compact mode is driven only by hideThreadRail"); +assert.match(chatSurface, /\{\s*id:\s*"projects",\s*label:\s*"Projects"\s*\}/, "Chat keeps Projects as its second primary tab"); +assert.match(workspace, /const contextualNav = mode === "chat" \? chatSidebar : sidebar;/, "chat mode replaces the global nav with the contextual Chats sidebar"); +assert.match(workspace, /nav=\{contextualNav\}\s*list=\{undefined\}/, "workspace mounts the contextual Chat nav without an independent list pane"); +assert.doesNotMatch(chatRouter, /surface\?:|surface=\{surface\}/, "ChatRouter must not forward a surface prop"); +assert.doesNotMatch(chatView, /surface\?:|surface === "code"|Ask for follow-up changes/, "ChatView must not carry Code-specific composer copy this phase"); + +console.log("code-surface-mode.test.ts: ok"); diff --git a/src/components/code-view.test.ts b/src/components/code-view.test.ts deleted file mode 100644 index e280769bb..000000000 --- a/src/components/code-view.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -// @ts-nocheck -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; - -// Retirement guard for the standalone Code workspace mode. Coding affordances -// now live inside Chat's repo-aware rail and the Library/Projects browser; the -// web shell must not expose a separate "code" WorkspaceMode, sidebar row, addon, -// command-palette row, CodeView branch, or ChatSurface surface switch. - -await assert.rejects( - readFile(new URL("./code-view.tsx", import.meta.url), "utf8"), - /ENOENT/, - "CodeView source should stay deleted", -); - -const workspace = await readFile(new URL("./workspace.tsx", import.meta.url), "utf8"); -const sidebar = await readFile(new URL("./sidebar-minimal.tsx", import.meta.url), "utf8"); -const modeType = await readFile(new URL("../lib/workspace-mode.ts", import.meta.url), "utf8"); -const chatSurface = await readFile(new URL("./chat-surface.tsx", import.meta.url), "utf8"); -const chatRouter = await readFile(new URL("./chat-router.tsx", import.meta.url), "utf8"); -const chatView = await readFile(new URL("./chat-view.tsx", import.meta.url), "utf8"); -const commandPalette = await readFile(new URL("./command-palette.tsx", import.meta.url), "utf8"); -const settingsShell = await readFile(new URL("./settings-shell.tsx", import.meta.url), "utf8"); - -assert.doesNotMatch(modeType, /\|\s*"code"/, "WorkspaceMode should not include retired code mode"); -assert.doesNotMatch(workspace, /import \{ CodeView \}| s\.project_root\)[\s\S]*?openFamiliarSession\(repoSession\.id, repoSession\.familiarId\)[\s\S]*?setMode\("chat"\)/, - "legacy code deep-links should redirect to the newest repo chat or Chat fallback", -); -assert.match( - workspace, - /File\/diff links target ChatSurface's code rail[\s\S]*?setPendingCodeRailOpen\([\s\S]*?setMode\("chat"\)/, - "file-open events should stay in the unified Chat workspace and preserve their code-rail target after Code mode retirement", -); -assert.match( - workspace, - /const SURFACE_ORDER: WorkspaceMode\[\] = \[\s*"home", "chat", "board", "inbox", "browser",\s*\]/, - "keyboard surface order should not include a standalone Terminal destination", -); -assert.match(workspace, /const contextualNav = mode === "chat" \? chatSidebar : sidebar;/, "chat mode replaces the global nav with the contextual Chats sidebar"); -assert.match(workspace, /nav=\{contextualNav\}\s*list=\{undefined\}/, "workspace mounts the contextual Chat nav without an independent list pane"); - -assert.doesNotMatch( - chatSurface, - /surface\s*=\s*"chat"|surface === "code"|isCodeSurface|CodeInlineToolbar|data-surface=\{surface\}/, - "ChatSurface should not keep a code-surface branch", -); -assert.match(chatSurface, /const compactRail = hideThreadRail/, "ChatSurface compact mode is driven only by hideThreadRail"); -assert.match(chatSurface, /\{\s*id:\s*"projects",\s*label:\s*"Projects"\s*\}/, "Chat keeps Projects as its second primary tab"); - -assert.doesNotMatch(chatRouter, /surface\?:|surface=\{surface\}/, "ChatRouter should not forward a retired surface prop"); -assert.doesNotMatch(chatView, /surface\?:|surface === "code"|Ask for follow-up changes/, "ChatView should not keep Code-specific composer copy"); - -console.log("code-view.test.ts: ok"); diff --git a/src/components/code-view.tsx b/src/components/code-view.tsx new file mode 100644 index 000000000..442cd1a23 --- /dev/null +++ b/src/components/code-view.tsx @@ -0,0 +1,226 @@ +"use client"; + +/** + * CodeView — the dedicated Code surface (cave-k0ua): a Codex-style + * multi-session coding tab. Reverses the earlier Code-mode retirement on the + * owner's request; gated by caveCodeSurface() (NEXT_PUBLIC_CAVE_CODE_SURFACE). + * + * Phase 1 (this shell): top-level Sessions/GitHub tabs, the session rail + * grouped by project with git-attribution badges, and a per-session overview + * pane. The workbench tabs (Diff, Files, Terminal, PR), inspector, composer, + * and new-session flow land in follow-up PRs — see the CODE_WORKBENCH_TABS + * vocabulary in src/lib/code-surface.ts which already fixes their deep-link + * names. GitHub mounts whole under the GitHub tab (its sidebar row hides when + * the flag is on). + */ + +import React, { useEffect, useMemo, useState } from "react"; +import dynamic from "next/dynamic"; +import { Icon } from "@/lib/icon"; +import { Button } from "@/components/ui/button"; +import { relativeTime } from "@/lib/relative-time"; +import { + codeSessionBranch, + codeSessionDiffstat, + groupCodeRailSessions, + parseCodeDeepLink, + type CodeTopTab, +} from "@/lib/code-surface"; +import { CodeSessionRail } from "@/components/code-session-rail"; +import type { GitHubItemTarget } from "@/lib/github-item-url"; +import type { SessionRow } from "@/lib/types"; + +// GitHubView keeps its own chunk: CodeView opens far more often than its +// GitHub tab, and github-view is a 3k-line surface (same split posture as +// lazy-surfaces.tsx, done locally to avoid a lazy-surfaces ↔ code-view cycle). +const LazyGitHubView = dynamic( + () => import("@/components/github-view").then((m) => m.GitHubView), + { ssr: false }, +); + +export type CodeViewProps = { + sessions: SessionRow[]; + onJumpToSession: (sessionId: string, familiarId?: string | null) => void; + onFocusCard: (cardId: string) => void; + githubTarget?: GitHubItemTarget | null; + onTasksRefresh: () => void; +}; + +function OverviewRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {label} + + + {children} + +
+ ); +} + +function SessionOverview({ + row, + onJumpToSession, +}: { + row: SessionRow; + onJumpToSession: CodeViewProps["onJumpToSession"]; +}) { + const branch = codeSessionBranch(row); + const diffstat = codeSessionDiffstat(row); + const pr = row.pullRequest; + return ( +
+
+
+

+ {row.title || row.id} +

+

+ Updated {relativeTime(row.updated_at)} +

+
+ +
+
+ {row.project_root || "—"} + + {branch ?? "—"} + {row.git?.isWorktree ? " (worktree)" : ""} + + {row.git?.worktreeRoot ? {row.git.worktreeRoot} : null} + {diffstat ?? "clean"} + + {pr?.url ? ( + + {pr.number != null ? `#${pr.number}` : pr.url} + {pr.state ? ` (${pr.state})` : ""} + + ) : ( + "—" + )} + + + {row.harness} + {row.model ? ` · ${row.model}` : ""} + +
+

+ Diff, Files, Terminal, and PR tabs land here next. +

+
+ ); +} + +export function CodeView({ + sessions, + onJumpToSession, + onFocusCard, + githubTarget, + onTasksRefresh, +}: CodeViewProps) { + // `?mode=code&session=&ctab=` deep link — read once on + // mount, then strip (the workspace's ?mode= idiom) so reloads stay clean. + // wtab joins when the workbench tabs land. + const [deepLink] = useState(() => { + if (typeof window === "undefined") return null; + const params = new URLSearchParams(window.location.search); + const parsed = parseCodeDeepLink(params); + if (params.has("session") || params.has("ctab") || params.has("wtab")) { + params.delete("session"); + params.delete("ctab"); + params.delete("wtab"); + const query = params.toString(); + window.history.replaceState(null, "", window.location.pathname + (query ? `?${query}` : "") + window.location.hash); + } + return parsed; + }); + const [topTab, setTopTab] = useState( + githubTarget ? "github" : deepLink?.topTab ?? "sessions", + ); + const [selectedId, setSelectedId] = useState(deepLink?.sessionId ?? null); + + const groups = useMemo(() => groupCodeRailSessions(sessions), [sessions]); + const selected = useMemo(() => { + if (!selectedId) return null; + for (const group of groups) { + const hit = group.sessions.find((row) => row.id === selectedId); + if (hit) return hit; + } + return null; + }, [groups, selectedId]); + + // Land on the newest session so the surface is immediately useful; keep the + // user's explicit pick as long as that session is still visible. + useEffect(() => { + if (selected) return; + const first = groups[0]?.sessions[0]; + if (first) setSelectedId(first.id); + }, [groups, selected]); + + return ( +
+
+ + +
+ {topTab === "github" ? ( +
+ +
+ ) : ( +
+
+ +
+
+ {selected ? ( + + ) : ( +
+ Select a session to see its branch, diff, and PR context. +
+ )} +
+
+ )} +
+ ); +} diff --git a/src/components/lazy-surfaces.tsx b/src/components/lazy-surfaces.tsx index 6db5b63f5..c391b81c4 100644 --- a/src/components/lazy-surfaces.tsx +++ b/src/components/lazy-surfaces.tsx @@ -64,6 +64,7 @@ function timed(name: string, loader: () => Promise): () => Promise { // warm-up must call the loaders directly to fetch a sidebar's chunks without // mounting the surface (and therefore without running any of its effects). const loadGitHubView = () => import("@/components/github-view").then((m) => m.GitHubView); +const loadCodeView = () => import("@/components/code-view").then((m) => m.CodeView); const loadCalendarView = () => import("@/components/calendar-view").then((m) => m.CalendarView); const loadBoardView = () => import("@/components/board-view").then((m) => m.BoardView); const loadMarketplaceView = () => @@ -92,6 +93,13 @@ export const GitHubView = dynamic( { ssr: false, loading: SurfaceFallback }, ); +// Code surface (cave-k0ua): hosts diffs, file tree + editor, terminal and the +// GitHub tab — its chunk (CodeMirror et al.) must stay out of the boot bundle. +export const CodeView = dynamic( + timed("code", loadCodeView), + { ssr: false, loading: SurfaceFallback }, +); + export const CalendarView = dynamic( timed("calendar", loadCalendarView), { ssr: false, loading: SurfaceFallback }, diff --git a/src/components/sidebar-minimal.tsx b/src/components/sidebar-minimal.tsx index 031cc201b..d7b68aa8b 100644 --- a/src/components/sidebar-minimal.tsx +++ b/src/components/sidebar-minimal.tsx @@ -22,6 +22,7 @@ import { import { sidebarRowState, type SidebarRowState } from "@/lib/sidebar-nav-state"; import { RecentActivityRollup } from "@/components/recent-activity-rollup"; import { SidebarFooter } from "@/components/sidebar-footer"; +import { caveCodeSurface } from "@/lib/feature-flags"; import type { ResolvedFamiliar } from "@/lib/familiar-resolve"; import type { SessionRow } from "@/lib/types"; import type { InboxItem } from "@/lib/cave-inbox"; @@ -81,7 +82,7 @@ function badgeText(n?: number): string | undefined { return n > 99 ? "99+" : String(n); } -const FOLDER_MODES: Array<{ +type FolderModeRow = { id: FolderMode; label: string; iconName: Parameters[0]["name"]; @@ -99,7 +100,9 @@ const FOLDER_MODES: Array<{ * surfaces you summon on demand rather than navigate to daily — the Browser * opens itself when a link/URL is clicked, so it needn't sit in the nav. */ navHidden?: boolean; -}> = [ +}; + +const FOLDER_MODES: Array = [ { id: "home", label: "Home", iconName: "ph:house-bold", kbd: "⌘1", description: "Overview and quick actions" }, { id: "chat", label: "Chat", iconName: "ph:chats", kbd: "⌘2", description: "Talk with your familiars — 1:1 or a Group tab for a whole coven" }, // Group Chat ("coven") is no longer a standalone destination — it lives as the @@ -128,7 +131,16 @@ const FOLDER_MODES: Array<{ { id: "marketplace", label: "Marketplace", iconName: "ph:storefront-bold", description: "Browse the store and manage your familiars' crafts and skills", quiet: true }, // Submissions (OpenCoven runtime/harness submit) is hidden from the nav; the // mode + page remain reachable programmatically but aren't surfaced here. - { id: "github", label: "GitHub", iconName: "ph:github-logo", description: "Issues and PRs assigned to you", badge: (p) => badgeText(p.githubAssignedCount), quiet: true }, + // + // Code ⇄ GitHub row swap (cave-k0ua): behind caveCodeSurface(), the Codex- + // style Code surface takes over this quiet slot and GitHub becomes a tab + // inside it (the row keeps carrying the assigned-work badge). Flag off keeps + // the standalone GitHub row exactly as before. A conditional spread keeps + // FOLDER_MODES one literal, so the palette, mobile tabs, and the + // canonical-name pins all derive from whichever vocabulary is active. + ...(caveCodeSurface() + ? [{ id: "code", label: "Code", iconName: "ph:code", description: "Multi-session coding — diffs, files, branches, worktrees, and GitHub", badge: (p) => badgeText(p.githubAssignedCount), quiet: true } satisfies FolderModeRow] + : [{ id: "github", label: "GitHub", iconName: "ph:github-logo", description: "Issues and PRs assigned to you", badge: (p) => badgeText(p.githubAssignedCount), quiet: true } satisfies FolderModeRow]), ]; // Rows actually rendered in the sidebar — everything except on-demand surfaces diff --git a/src/components/workspace.tsx b/src/components/workspace.tsx index 88c669489..2ed9712a6 100644 --- a/src/components/workspace.tsx +++ b/src/components/workspace.tsx @@ -73,6 +73,7 @@ import { FamiliarWorkQueueView, FamiliarGlyphPicker, GitHubView, + CodeView, GrimoireView, InboxEscalationsView, MarketplaceView, @@ -86,6 +87,7 @@ import { ShortcutsSheet, } from "@/components/lazy-surfaces"; import { WorkspaceSidebar } from "@/components/workspace-sidebar"; +import { caveCodeSurface } from "@/lib/feature-flags"; import { CHAT_OPEN_PROJECTS_EVENT, CHAT_FOCUS_PROJECT_EVENT, CHAT_OPEN_COVEN_EVENT, markCovenTabPending, markProjectsTabPending } from "@/lib/chat-tab-events"; import { HomeComposer } from "@/components/home-composer"; import { ChatSurface } from "@/components/chat-surface"; @@ -216,6 +218,7 @@ const WORKSPACE_MODE_TITLES: Record = { inbox: "Rituals", browser: "Browser", github: "GitHub", + code: "Code", roles: "Roles", marketplace: "Marketplace", flow: "Flow", @@ -364,6 +367,15 @@ export function Workspace() { setModeRaw("inbox"); return; } + if (next === "code" && !caveCodeSurface()) { + // The Code surface is flag-gated (cave-k0ua). While off, "code" is a + // valid mode string (deep links, persisted last-surface) but must not + // render — land on Chat, the retirement-era fallback. The + // cave:navigate-mode handler upgrades this to "newest repo session" + // before reaching here. + setModeRaw("chat"); + return; + } setModeRaw(next); }, []); // Chat mode replaces the global nav with the project-grouped Chats sidebar. @@ -1767,8 +1779,10 @@ export function Workspace() { const onNavigate = (e: Event) => { const targetMode = (e as CustomEvent<{ mode?: string }>).detail?.mode; if (!targetMode) return; - // "code" was retired — redirect to the most-recent repo session in chat. - if (targetMode === "code") { + // Legacy fallback while the Code surface flag is off: redirect to the + // most-recent repo session in chat (the retirement-era behavior). With + // the flag on, "code" falls through to setMode and lands on the surface. + if (targetMode === "code" && !caveCodeSurface()) { const repoSession = [...sessionsRef.current] .filter((s) => s.project_root) .sort((a, b) => (b.updated_at || b.created_at).localeCompare(a.updated_at || a.created_at))[0]; @@ -2848,6 +2862,14 @@ export function Workspace() { initialTarget={githubTarget} onTasksRefresh={() => void loadGitHubTasks(true)} /> + ) : mode === "code" ? ( + onPaletteIntent({ kind: "focus-card", cardId })} + githubTarget={githubTarget} + onTasksRefresh={() => void loadGitHubTasks(true)} + /> ) : mode === "marketplace" || mode === "roles" || mode === "capabilities" ? ( // Roles and Marketplace merged into one hub. The "roles"/"capabilities" // modes still resolve here (deep links / navigate-mode) but land on diff --git a/src/lib/code-surface.test.ts b/src/lib/code-surface.test.ts new file mode 100644 index 000000000..84db89641 --- /dev/null +++ b/src/lib/code-surface.test.ts @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + codeSessionActivity, + codeSessionBranch, + codeSessionDiffstat, + groupCodeRailSessions, + isCodeRailSession, + isCodeTopTab, + isCodeWorkbenchTab, + parseCodeDeepLink, +} from "./code-surface.ts"; +import type { SessionRow } from "./types.ts"; + +// Behavioral tests for the Code surface's pure model (cave-k0ua): session rail +// grouping, per-session git attribution badges, and deep-link parsing. + +function row(overrides: Partial): SessionRow { + return { + id: "s1", + project_root: "/repo/a", + harness: "coven", + title: "Session", + status: "idle", + exit_code: null, + archived_at: null, + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-01T00:00:00Z", + ...overrides, + }; +} + +test("rail hides archived and generator-spawned sessions", () => { + assert.ok(isCodeRailSession(row({}))); + assert.ok(!isCodeRailSession(row({ archived_at: "2026-07-01T00:00:00Z" }))); + assert.ok(!isCodeRailSession(row({ generated: true }))); +}); + +test("groups by project root, newest group and newest session first", () => { + const groups = groupCodeRailSessions([ + row({ id: "a-old", project_root: "/repo/a", updated_at: "2026-07-01T00:00:00Z" }), + row({ id: "b-new", project_root: "/repo/b", updated_at: "2026-07-03T00:00:00Z" }), + row({ id: "a-new", project_root: "/repo/a", updated_at: "2026-07-02T00:00:00Z" }), + row({ id: "hidden", project_root: "/repo/b", generated: true, updated_at: "2026-07-04T00:00:00Z" }), + ]); + assert.deepEqual( + groups.map((g) => ({ label: g.label, ids: g.sessions.map((s) => s.id) })), + [ + { label: "b", ids: ["b-new"] }, + { label: "a", ids: ["a-new", "a-old"] }, + ], + ); +}); + +test("sessions without a project root land in a trailing (unknown) group", () => { + const groups = groupCodeRailSessions([ + row({ id: "rootless", project_root: "", updated_at: "2026-07-09T00:00:00Z" }), + row({ id: "rooted", project_root: "/repo/a", updated_at: "2026-07-01T00:00:00Z" }), + ]); + assert.deepEqual( + groups.map((g) => g.label), + ["a", "(unknown)"], + "the unknown group trails even when its sessions are newer", + ); +}); + +test("group labels come from the root basename, tolerating trailing slashes", () => { + const groups = groupCodeRailSessions([ + row({ id: "x", project_root: "/home/user/proj/" }), + row({ id: "y", project_root: "C:\\repos\\win-proj", updated_at: "2026-06-30T00:00:00Z" }), + ]); + assert.deepEqual(groups.map((g) => g.label), ["proj", "win-proj"]); +}); + +test("session branch prefers workBranch, then worktree branch, then PR branch — never a shared checkout's git.branch", () => { + assert.equal(codeSessionBranch(row({ workBranch: "feat/x", git: { branch: "main" } })), "feat/x"); + assert.equal( + codeSessionBranch(row({ git: { branch: "feat/wt", isWorktree: true, worktreeRoot: "/wt" } })), + "feat/wt", + ); + assert.equal( + codeSessionBranch(row({ git: { branch: "main", isWorktree: false } })), + null, + "a shared checkout's current branch is not this session's branch (cave-9q24)", + ); + assert.equal( + codeSessionBranch(row({ pullRequest: { repo: "o/r", branch: "pr-branch" } })), + "pr-branch", + ); +}); + +test("diffstat renders +N −N and hides when clean or unknown", () => { + assert.equal(codeSessionDiffstat(row({ diff: { additions: 3, deletions: 1 } })), "+3 \u22121"); + assert.equal(codeSessionDiffstat(row({ diff: { additions: 0, deletions: 0 } })), null); + assert.equal(codeSessionDiffstat(row({ diff: null })), null); + assert.equal(codeSessionDiffstat(row({})), null); +}); + +test("activity maps running/exit-code/idle", () => { + assert.equal(codeSessionActivity(row({ status: "running" })), "running"); + assert.equal(codeSessionActivity(row({ status: "exited", exit_code: 1 })), "error"); + assert.equal(codeSessionActivity(row({ status: "exited", exit_code: 0 })), "idle"); + assert.equal(codeSessionActivity(row({})), "idle"); +}); + +test("deep-link parsing falls back to defaults on unknown values", () => { + const parsed = parseCodeDeepLink(new URLSearchParams("session=abc&ctab=github&wtab=files")); + assert.deepEqual(parsed, { sessionId: "abc", topTab: "github", workbenchTab: "files" }); + const fallback = parseCodeDeepLink(new URLSearchParams("ctab=bogus&wtab=nope")); + assert.deepEqual(fallback, { sessionId: null, topTab: "sessions", workbenchTab: "diff" }); +}); + +test("tab guards accept exactly the fixed vocabularies", () => { + for (const tab of ["diff", "files", "terminal", "pr"]) assert.ok(isCodeWorkbenchTab(tab)); + for (const tab of ["sessions", "github"]) assert.ok(isCodeTopTab(tab)); + assert.ok(!isCodeWorkbenchTab("overview")); + assert.ok(!isCodeTopTab("code")); + assert.ok(!isCodeWorkbenchTab(null)); + assert.ok(!isCodeTopTab(undefined)); +}); diff --git a/src/lib/code-surface.ts b/src/lib/code-surface.ts new file mode 100644 index 000000000..582d1b0c6 --- /dev/null +++ b/src/lib/code-surface.ts @@ -0,0 +1,139 @@ +/** + * Pure model for the Code surface (cave-k0ua) — the Codex-style multi-session + * coding tab. Keeps session grouping, badge derivation, and tab vocabulary out + * of the React tree so they stay behaviorally testable (repo convention: + * behavioral tests for pure logic, source pins for wiring). + */ + +import type { SessionRow } from "@/lib/types"; + +/** Workbench tabs within a selected session. Diff/Files/Terminal/PR land in + * follow-up PRs; the vocabulary is fixed here so deep links stay stable. */ +export const CODE_WORKBENCH_TABS = ["diff", "files", "terminal", "pr"] as const; +export type CodeWorkbenchTab = (typeof CODE_WORKBENCH_TABS)[number]; + +export function isCodeWorkbenchTab(value: string | null | undefined): value is CodeWorkbenchTab { + return (CODE_WORKBENCH_TABS as readonly string[]).includes(value ?? ""); +} + +/** Top-level surface tabs: the session workbench, or the absorbed GitHub view. */ +export const CODE_TOP_TABS = ["sessions", "github"] as const; +export type CodeTopTab = (typeof CODE_TOP_TABS)[number]; + +export function isCodeTopTab(value: string | null | undefined): value is CodeTopTab { + return (CODE_TOP_TABS as readonly string[]).includes(value ?? ""); +} + +/** A project group in the session rail: one repo/root, newest work first. */ +export type CodeRailGroup = { + /** Absolute project root shared by the group's sessions. */ + root: string; + /** Short display label (basename of the root). */ + label: string; + sessions: SessionRow[]; +}; + +/** + * Sessions that belong on the Code surface: real conversations (not + * generator-spawned runs) that haven't been archived. Mirrors the chat list's + * visibility posture — the rail is a different lens over the same sessions, + * so hiding rules must not drift apart. + */ +export function isCodeRailSession(row: SessionRow): boolean { + if (row.archived_at) return false; + if (row.generated) return false; + return true; +} + +function projectLabel(root: string): string { + const trimmed = root.replace(/[\\/]+$/, ""); + const idx = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + const base = idx >= 0 ? trimmed.slice(idx + 1) : trimmed; + return base || trimmed || "(unknown)"; +} + +function newestUpdatedAt(rows: SessionRow[]): number { + let newest = 0; + for (const row of rows) { + const t = Date.parse(row.updated_at); + if (Number.isFinite(t) && t > newest) newest = t; + } + return newest; +} + +/** + * Group rail sessions by project root, newest group first, newest session + * first within each group. Empty roots collapse into a trailing "(unknown)" + * group rather than being dropped — a session you can't find is worse than an + * ugly label. + */ +export function groupCodeRailSessions(rows: SessionRow[]): CodeRailGroup[] { + const byRoot = new Map(); + for (const row of rows) { + if (!isCodeRailSession(row)) continue; + const root = row.project_root || ""; + const list = byRoot.get(root); + if (list) list.push(row); + else byRoot.set(root, [row]); + } + const groups: CodeRailGroup[] = []; + for (const [root, sessions] of byRoot) { + sessions.sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at)); + groups.push({ root, label: root ? projectLabel(root) : "(unknown)", sessions }); + } + groups.sort((a, b) => { + if (!a.root && b.root) return 1; + if (a.root && !b.root) return -1; + return newestUpdatedAt(b.sessions) - newestUpdatedAt(a.sessions); + }); + return groups; +} + +/** + * The branch attributable to *this session* — workBranch (conversation + * snapshot) or a worktree's branch. Never falls back to `git.branch` for + * shared checkouts: that's whatever the root has checked out at poll time, + * not this session's work (cave-9q24 attribution rule). + */ +export function codeSessionBranch(row: SessionRow): string | null { + if (row.workBranch) return row.workBranch; + if (row.git?.isWorktree && row.git.branch) return row.git.branch; + return row.pullRequest?.branch ?? null; +} + +/** "+N −N" working-tree size, or null when unknown/clean. */ +export function codeSessionDiffstat(row: SessionRow): string | null { + const diff = row.diff; + if (!diff) return null; + if (!diff.additions && !diff.deletions) return null; + return `+${diff.additions} \u2212${diff.deletions}`; +} + +export type CodeSessionActivity = "running" | "error" | "idle"; + +export function codeSessionActivity(row: SessionRow): CodeSessionActivity { + if (row.status === "running") return "running"; + if (typeof row.exit_code === "number" && row.exit_code !== 0) return "error"; + return "idle"; +} + +export type CodeDeepLink = { + sessionId: string | null; + topTab: CodeTopTab; + workbenchTab: CodeWorkbenchTab; +}; + +/** + * Parse `?mode=code&session=&ctab=&wtab=` search params. + * Unknown values fall back to defaults instead of failing — deep links from + * older builds must keep landing somewhere sensible. + */ +export function parseCodeDeepLink(params: Pick): CodeDeepLink { + const rawTop = params.get("ctab"); + const rawTab = params.get("wtab"); + return { + sessionId: params.get("session") || null, + topTab: isCodeTopTab(rawTop) ? rawTop : "sessions", + workbenchTab: isCodeWorkbenchTab(rawTab) ? rawTab : "diff", + }; +} diff --git a/src/lib/feature-flags.ts b/src/lib/feature-flags.ts index 435c73d63..700711d98 100644 --- a/src/lib/feature-flags.ts +++ b/src/lib/feature-flags.ts @@ -6,3 +6,15 @@ function envFlag(value: string | undefined): boolean { export function caveChatoutCodex(): boolean { return envFlag(process.env.NEXT_PUBLIC_CAVE_CHATOUT_CODEX); } + +/** + * Dedicated Code surface (cave-k0ua): the Codex-style multi-session coding + * tab. While flagged off, the sidebar keeps the GitHub row and `?mode=code` + * deep links fall back to the legacy redirect (most-recent repo chat). When + * on, the Code row replaces the GitHub row (GitHub mounts as a tab inside + * Code). NEXT_PUBLIC_ env vars are inlined at build time, so both branches + * of the swap stay in the bundle but the choice is fixed per build. + */ +export function caveCodeSurface(): boolean { + return envFlag(process.env.NEXT_PUBLIC_CAVE_CODE_SURFACE); +} diff --git a/src/lib/workspace-mode.test.ts b/src/lib/workspace-mode.test.ts index f36a53a55..3ccb9123f 100644 --- a/src/lib/workspace-mode.test.ts +++ b/src/lib/workspace-mode.test.ts @@ -37,7 +37,8 @@ test("isWorkspaceMode accepts the full vocabulary and nothing else", () => { } // Retired or foreign mode strings must be rejected (the persisted // last-surface restore and ?mode= deep links validate through this guard). - for (const retired of ["projects", "code", "terminal", "evals", "retro", "workflows", "", "surface:threads", "__proto__", "constructor"]) { + // Note: "code" left this list when the Code surface returned (cave-k0ua). + for (const retired of ["projects", "terminal", "evals", "retro", "workflows", "", "surface:threads", "__proto__", "constructor"]) { assert.ok(!isWorkspaceMode(retired), `"${retired}" must not validate as a workspace mode`); } }); diff --git a/src/lib/workspace-mode.ts b/src/lib/workspace-mode.ts index 253160f12..8ad7cf524 100644 --- a/src/lib/workspace-mode.ts +++ b/src/lib/workspace-mode.ts @@ -18,6 +18,7 @@ export type CanonicalWorkspaceMode = | "inbox" | "browser" | "github" + | "code" | "marketplace" | "submissions" | "grimoire" @@ -42,6 +43,10 @@ export const CANONICAL_WORKSPACE_MODES: readonly CanonicalWorkspaceMode[] = [ "inbox", "browser", "github", + // Code — the Codex-style multi-session coding surface (cave-k0ua). Reverses + // the earlier Code-mode retirement: gated by caveCodeSurface(); while the + // flag is off, setMode redirects "code" to the legacy chat fallback. + "code", "marketplace", "submissions", "grimoire",