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
3 changes: 2 additions & 1 deletion scripts/run-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions src/components/chat-rail-modern-redesign.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────
Expand Down
122 changes: 122 additions & 0 deletions src/components/code-session-rail.tsx
Original file line number Diff line number Diff line change
@@ -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<SessionRow["pullRequest"]> }) {
const state = (pr.state ?? "").toLowerCase();
const tone =
state === "merged"
? "text-[var(--accent-presence)]"
: state === "closed"
? "text-[var(--color-danger)]"
: "text-[var(--text-secondary)]";
return (
<span
className={`inline-flex h-4 shrink-0 items-center gap-0.5 rounded border border-[var(--border-hairline)] px-1 font-mono text-[length:var(--text-2xs)] ${tone}`}
title={pr.url ? `${pr.url}${state ? ` (${state})` : ""}` : undefined}
>
<Icon name="ph:git-pull-request" width={10} height={10} />
{pr.number != null ? `#${pr.number}` : state || "PR"}
</span>
);
}

function ActivityDot({ row }: { row: SessionRow }) {
const activity = codeSessionActivity(row);
if (activity === "running") {
return <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--accent-presence)]" aria-label="running" />;
}
if (activity === "error") {
return <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--color-danger)]" aria-label="failed" />;
}
return <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--border-hairline)]" aria-hidden />;
}

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) {
Comment on lines +56 to +58
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>
);
}
return (
<nav aria-label="Coding sessions" className="flex h-full min-h-0 flex-col overflow-y-auto py-2">
{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)]"
title={group.root || undefined}
>
{group.label}
</div>
<ul className="flex flex-col">
{group.sessions.map((row) => {
const branch = codeSessionBranch(row);
const diffstat = codeSessionDiffstat(row);
const selected = row.id === selectedId;
return (
<li key={row.id}>
<button
type="button"
onClick={() => onSelect(row.id)}
aria-current={selected ? "true" : undefined}
className={`focus-ring-inset flex w-full flex-col gap-0.5 px-3 py-1.5 text-left ${
selected ? "bg-[var(--bg-hover)]" : "hover:bg-[var(--bg-hover)]"
}`}
>
<span className="flex min-w-0 items-center gap-1.5">
<ActivityDot row={row} />
<span className="min-w-0 truncate text-[length:var(--text-xs)] text-[var(--text-primary)]">
{row.title || row.id}
</span>
</span>
{(branch || diffstat || row.pullRequest || row.git?.isWorktree) ? (
<span className="flex min-w-0 items-center gap-1.5 pl-3">
{row.git?.isWorktree ? (
<Icon name="ph:git-fork" width={10} height={10} className="shrink-0 text-[var(--text-muted)]" />
) : null}
{branch ? (
<span className="min-w-0 truncate font-mono text-[length:var(--text-2xs)] text-[var(--text-muted)]" title={branch}>
{branch}
</span>
) : null}
{diffstat ? (
<span className="shrink-0 font-mono text-[length:var(--text-2xs)] text-[var(--text-secondary)]">{diffstat}</span>
) : null}
{row.pullRequest ? <PrChip pr={row.pullRequest} /> : null}
</span>
) : null}
</button>
</li>
);
})}
</ul>
</section>
))}
</nav>
);
}
119 changes: 119 additions & 0 deletions src/components/code-surface-mode.test.ts
Original file line number Diff line number Diff line change
@@ -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*<CodeView/,
"Workspace renders CodeView on the code mode",
);
assert.match(
lazySurfaces,
/const loadCodeView = \(\) => 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");
69 changes: 0 additions & 69 deletions src/components/code-view.test.ts

This file was deleted.

Loading
Loading