diff --git a/docs/design-handoff/IMPLEMENTATION-STATUS.md b/docs/design-handoff/IMPLEMENTATION-STATUS.md index 985213f54b..8d65b6afb6 100644 --- a/docs/design-handoff/IMPLEMENTATION-STATUS.md +++ b/docs/design-handoff/IMPLEMENTATION-STATUS.md @@ -38,7 +38,7 @@ tests), so when a project gains or loses a frame, update this table by hand. |---|---|---| | `Familiar Analytics.dc.html` | `src/components/familiar-analytics-content.tsx` | `7316804273` (#4277) — dock + stage workbench | | `Chat.dc.html` (session, list) | chat session chrome | `59527634e7` (#3983) | -| `Chat.dc.html` 2a (spine, minimap) | thread instruments | `6cc5fcb913` (#4046) | +| `Chat.dc.html` 2a (spine) | left turn spine (`src/components/chat-thread-instruments.tsx`). **Not adopted:** the frame's right-edge thread minimap — permanently removed in `cave-5m5hv`, along with its model, stylesheet and the `cave:chat:thread-instruments` preference that gated it. The right-side instrument is the `Coven Cave - Chat Session` run rail, which is automatic. | `6cc5fcb913` (#4046), `cave-5m5hv` | | `Chat.dc.html` 2b (bands) | new-session launcher | `3e5b9c450d` (`cave-iwopz`) | | `Composer.dc.html` | shared chat composer (`src/components/chat-view.tsx`, `src/components/composer-actions-menu.tsx`, `src/styles/cave-composer.css`) — edge-mounted Tools opens the existing action cascade, a linked Task edge opens the real board card, the lower action row keeps voice/enhance/send behavior, and its context meter uses the latest settled response’s measured token usage. **Not adopted:** the handoff’s Plan / Explore / Build modes, because Cave exposes no matching durable runtime state; attachment and completion surfaces remain the existing fully functional implementations. | `cave-9v9jr` | | `Chat Session - Prototype.dc.html` | Sessions list (`src/components/chat-list.tsx`, `src/lib/chat-session-status.ts`, `src/lib/chat-session-activity.ts`, `src/lib/chat-session-sort.ts`, `src/styles/chat-list.css`), the new-session hero (`src/styles/home-dashboard.css`), transcript gap divider (`src/lib/chat-turn-gap.ts`) and the accessible "N earlier turns" fold (`src/lib/chat-transcript-fold.ts`, `src/components/chat-view.tsx`, `src/styles/cave-chat/session-chrome.css`). The frame's session chrome — serif title row, slim mono context row, spine, reader — had already landed through `Chat.dc.html` (session/2a) and `Reader.dc.html` 3a. **Not adopted:** the frame's per-row step counts, tool-call counts and last-message previews — the daemon's `SessionRow` carries none of them, and inventing them would be a row that lies; the fold's prose-dimming hint (the first visible turns below a closed fold sit slightly dimmed) was deliberately dropped in `cave-u5lq7` for contrast, then landed later via `cave-4akqc` with hover/focus restore and a reduced-transparency kill-switch, so the affordance never costs the reader legibility. | `cave-n3jg2`, `a21bc785e` (`cave-u5lq7`); prose-dimming hint `cave-4akqc` | diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 72a081187e..a4d9235b49 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -1609,7 +1609,7 @@ export const SUITES = { "src/lib/harness-version.test.ts", "src/lib/hermes-shim.test.ts", "src/lib/hermes-responses-stream.test.ts", - "src/lib/thread-instruments-visibility.test.ts", + "src/lib/retired-chat-preferences.test.ts", "src/lib/openclaw-bin.test.ts", "src/lib/openclaw-bridge.test.ts", "src/lib/openclaw-compatibility.test.ts", diff --git a/src/components/chat-run-rail.test.ts b/src/components/chat-run-rail.test.ts index c52d6d092f..442365effa 100644 --- a/src/components/chat-run-rail.test.ts +++ b/src/components/chat-run-rail.test.ts @@ -9,6 +9,8 @@ import { readFileSync } from "node:fs"; const component = readFileSync(new URL("./chat-run-rail.tsx", import.meta.url), "utf8"); const css = readFileSync(new URL("../styles/cave-chat/run-rail.css", import.meta.url), "utf8"); const chatView = readFileSync(new URL("./chat-view.tsx", import.meta.url), "utf8"); +const header = readFileSync(new URL("./chat-session-header.tsx", import.meta.url), "utf8"); +const menuModel = readFileSync(new URL("../lib/chat-session-menu-model.ts", import.meta.url), "utf8"); const facade = readFileSync(new URL("../styles/cave-chat.css", import.meta.url), "utf8"); // ── mounted and styled, or it is dead code ────────────────────────────────── @@ -18,15 +20,48 @@ assert.match( / 0 && activityMapVisible \? \(\s*\n\s* 0", + "the ONLY condition on the rail is that a transcript exists — no preference, no flag", + ); +} +// Comments are stripped first, the same discipline the `order: 1` guard below +// uses: all three files now EXPLAIN in prose that the toggle is retired, and an +// unanchored search matches the explanation. Scan the code, not the paragraph. +const code = (src: string) => src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^[ \t]*\/\/.*$/gm, ""); +for (const revived of ["activityMapVisible", "useActivityMapVisible", "thread-instruments-visibility"]) { + assert.ok( + !code(chatView).includes(revived) && !code(header).includes(revived), + `${revived} is retired: re-introducing it gives a reader a way to switch the rail back off`, + ); +} +assert.ok( + !code(menuModel).includes("activity-map"), + "the session kebab carries no activity-map toggle — the rail is not optional", ); -assert.doesNotMatch( +// …and the stripper is not doing the work on its own. +assert.ok(code(menuModel).includes('"thinking"'), "the menu model still declares its real items"); +assert.ok( + !code(chatView).includes("ChatThreadMinimap"), + "chat no longer mounts the retired thread minimap", +); +// The LEFT turn spine is deliberately retained and mounted; its own pins live +// in lib/chat-thread-instruments.test.ts. Pinned here too because this file is +// the one that previously asserted it was gone. +assert.match( chatView, - /ChatThreadMinimap|ChatThreadSpine/, - "chat no longer mounts the retired thread spine or minimap", + /(null); const menuRef = useRef(null); const keyboardOpenRequested = useRef(false); @@ -72,7 +70,6 @@ export function SessionOverflowMenu({ projectRoot: activeProject?.root ?? null, hasTurns, showThinking, - activityMapVisible, reflectAvailable: Boolean(onReflect), reflecting, }); @@ -159,10 +156,6 @@ export function SessionOverflowMenu({ setShowThinking(!showThinking); close(); }, - "activity-map": () => { - setActivityMapVisible(!activityMapVisible); - close(); - }, reflect: () => { close(); onReflect?.(); @@ -211,8 +204,8 @@ export function SessionOverflowMenu({ key={item.id} icon={item.icon} checked={item.checked} - // Only `thinking` and `activity-map` carry `checked` here and - // both are independent on/off toggles, not one of a set. + // Only `thinking` carries `checked` here, and it is an + // independent on/off toggle, not one of a set. checkedRole="checkbox" disabled={item.disabled} title={item.title} diff --git a/src/components/chat-thread-instruments.tsx b/src/components/chat-thread-instruments.tsx index 6b5cb4f828..fc0a38cfb1 100644 --- a/src/components/chat-thread-instruments.tsx +++ b/src/components/chat-thread-instruments.tsx @@ -2,20 +2,24 @@ import "@/styles/cave-chat/thread-instruments.css"; -// ── Chat thread instruments (Chat.dc.html 2a, cave-j86la) ──────────────────── -// Two overlays that turn a long transcript into a navigable run: +// ── Chat turn spine (Chat.dc.html 2a, cave-j86la) ──────────────────────────── +// ChatThreadSpine — the transcript's LEFT gutter wears one node per turn +// (operator or familiar), each with that turn's tool calls rolled into a +// proportional category stack. Click a node to jump the pane to that turn. // -// • ChatThreadSpine — the left gutter wears one node per turn (operator or -// familiar), each with its tool calls rolled into a proportional category -// stack. Click a node to jump the pane to that turn. -// • ChatThreadMinimap — the right edge wears one bar per event (prompt, -// each tool call, answer), the whole thread at a glance. Click to jump; -// the caret tracks the reading position; ↑/↓ step events. +// A second instrument, ChatThreadMinimap, used to live here on the right edge: +// one bar per event, a caret tracking the reading position, ↑/↓ stepping. It is +// permanently removed (cave-5m5hv) — the Design run rail +// (`components/chat-run-rail.tsx`) is the right-side instrument now, it is +// automatic rather than preference-gated, and there is no toggle, flag or +// stored key left that can bring the minimap back. The spine survives the +// replacement because it never competed with the rail: it annotates the left +// gutter, which the rail does not occupy. // -// Both derive everything from the SAME Turn[] the transcript renders (the -// pure model in src/lib/chat-thread-instruments.ts) — no fetches — and both -// live in the transcript's existing side gutters as overlays, so they add no -// layout shift and simply stay home on panes too narrow to have gutters. +// The spine derives everything from the SAME Turn[] the transcript renders (the +// pure model in src/lib/chat-thread-instruments.ts) — no fetches — and lives in +// the transcript's existing left gutter as an overlay, so it adds no layout +// shift and simply stays home on panes too narrow to have gutters. import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; @@ -26,15 +30,12 @@ import { spineSegmentHeights, spineNodes, spineStackHeight, - threadMapEvents, type SpineNode, - type ThreadMapEvent, } from "@/lib/chat-thread-instruments"; -/** Instruments need real side gutters: the reading column is ~860px, the - * spine wants 64px and the map 84px, so anything narrower than this keeps - * the transcript clean. (The env HUD's own gate is 1536 — the map clears it - * via CSS when both show.) */ +/** The spine needs a real side gutter: the reading column is ~860px and the + * spine wants 64px beside it, so anything narrower than this keeps the + * transcript clean. */ export const THREAD_INSTRUMENTS_MIN_WIDTH = 1360; /** The spine reads as an instrument, not a decoration, from a few turns up. */ const SPINE_MIN_TURNS = 2; @@ -42,14 +43,11 @@ const SPINE_MIN_TURNS = 2; // falls back to. Keeps the gutter from collapsing on a thread whose stamps are // all missing, which would put the ring back where the clock belongs. const SPINE_STAMP_MIN_CHARS = 5; -const MAP_MIN_EVENTS = 4; -/** Map row height (px) — mirrors the design's 15px rows. */ -const MAP_ROW_H = 15; type ScrollerRef = React.RefObject; -/** Observe the scroller's content-box width so both instruments share one - * wide-pane gate. */ +/** Observe the scroller's content-box width so the spine's wide-pane gate + * tracks the reading column it hangs beside. */ function useScrollerWidth(scrollRef: ScrollerRef): number | null { const [width, setWidth] = useState(null); useEffect(() => { @@ -245,226 +243,3 @@ export function ChatThreadSpine({ ); } -// ── Minimap ────────────────────────────────────────────────────────────────── - -export function ChatThreadMinimap({ - turns, - scrollRef, - familiarName, -}: { - turns: Turn[]; - scrollRef: ScrollerRef; - familiarName: string; -}) { - const operatorName = userDisplayName(useUserProfile()?.profile); - const width = useScrollerWidth(scrollRef); - const [paneHeight, setPaneHeight] = useState(null); - const [selected, setSelected] = useState(0); - /** One shared hover card for the whole rail — the rows scroll inside an - * overflow container, so a per-row absolute card would clip; the shared - * card hangs off the rail itself and follows the hovered row's y. */ - const [hovered, setHovered] = useState<{ index: number; y: number } | null>(null); - const events = useMemo( - () => threadMapEvents(turns, { operatorName, familiarName }), - [turns, operatorName, familiarName], - ); - - useEffect(() => { - const el = scrollRef.current; - if (!el) return; - const measure = () => setPaneHeight(el.clientHeight); - measure(); - const observer = new ResizeObserver(measure); - observer.observe(el); - return () => observer.disconnect(); - }, [scrollRef]); - - // Track the reading position: the caret follows the topmost visible turn's - // first event. Passive listener + rAF coalescing (ref nulled on cancel). - const frameRef = useRef(null); - useEffect(() => { - const scroller = scrollRef.current; - if (!scroller) return; - const onScroll = () => { - if (frameRef.current !== null) return; - frameRef.current = requestAnimationFrame(() => { - frameRef.current = null; - const scrollerTop = scroller.getBoundingClientRect().top; - let currentTurn: string | null = null; - for (const el of scroller.querySelectorAll("[data-turn-id]")) { - const top = el.getBoundingClientRect().top - scrollerTop; - if (top <= 48) currentTurn = el.dataset.turnId ?? currentTurn; - else break; - } - if (!currentTurn) return; - const idx = events.findIndex((e) => e.turnId === currentTurn); - if (idx >= 0) { - setSelected((prev) => (prev === idx ? prev : idx)); - // Keep the caret in the rail's viewport — a 200-event thread scrolls - // its own body, and a caret parked off-screen tracks nothing. - const body = scroller.querySelector(".cave-thread-map__body"); - if (body) { - const target = idx * MAP_ROW_H + 8 - body.clientHeight / 2; - body.scrollTo({ top: Math.max(0, target) }); - } - } - }); - }; - scroller.addEventListener("scroll", onScroll, { passive: true }); - return () => { - scroller.removeEventListener("scroll", onScroll); - if (frameRef.current !== null) { - cancelAnimationFrame(frameRef.current); - frameRef.current = null; - } - }; - }, [scrollRef, events]); - - if (width == null || width < THREAD_INSTRUMENTS_MIN_WIDTH) return null; - if (events.length < MAP_MIN_EVENTS || paneHeight == null) return null; - - const jumpToEvent = (idx: number) => { - const event = events[idx]; - if (!event) return; - setSelected(idx); - jumpToTurn(scrollRef.current, event.turnId); - }; - - const hoveredEvent = hovered ? events[hovered.index] : null; - - return ( -
-
-
Thread
-
setHovered(null)}> - - {events.map((event, i) => ( - jumpToEvent(i)} - onHover={(y) => setHovered({ index: i, y })} - /> - ))} -
- {hoveredEvent ? ( - - ) : null} -
- - {`${selected + 1}/${events.length}`} - -
-
-
- ); -} - -function MapRow({ - event, - onJump, - onHover, -}: { - event: ThreadMapEvent; - onJump: () => void; - onHover: (y: number) => void; -}) { - const isTurn = event.kind === "turn" || event.kind === "answer"; - const reportHover = (el: HTMLElement) => { - // The card hangs off the rail (outside this scroll container), so it - // anchors to the row's y within the rail, not within the scrolled body. - const rail = el.closest(".cave-thread-map"); - if (!rail) return; - onHover(el.getBoundingClientRect().top - rail.getBoundingClientRect().top); - }; - return ( - - ); -} - -function MapHoverCard({ - event, - index, - count, - y, -}: { - event: ThreadMapEvent; - index: number; - count: number; - y: number; -}) { - const isTurn = event.kind === "turn" || event.kind === "answer"; - return ( - - - - {isTurn ? (event.kind === "turn" ? "turn start" : "answer") : event.kind} - - {`${index + 1} / ${count}`} - - {event.label} - - - turn - {event.ownerName} - - {event.ownerTime ? ( - - at - {event.ownerTime} - - ) : null} - {event.took ? ( - - took - {event.took} - - ) : null} - - click to jump - - ); -} diff --git a/src/components/chat-title-sparkle-behavior.test.tsx b/src/components/chat-title-sparkle-behavior.test.tsx index 451b4ff33e..c7ee042d86 100644 --- a/src/components/chat-title-sparkle-behavior.test.tsx +++ b/src/components/chat-title-sparkle-behavior.test.tsx @@ -6,9 +6,6 @@ vi.mock("@/lib/icon", () => ({ Icon: () => })); vi.mock("@/lib/reasoning-visibility", () => ({ useShowThinking: () => [false, vi.fn()], })); -vi.mock("@/lib/thread-instruments-visibility", () => ({ - useActivityMapVisible: () => [false, vi.fn()], -})); vi.mock("@/components/project-picker", () => ({ ProjectPickerPopover: () => null, })); diff --git a/src/components/chat-view.tsx b/src/components/chat-view.tsx index ad21ae33e7..f189ee14c7 100644 --- a/src/components/chat-view.tsx +++ b/src/components/chat-view.tsx @@ -325,7 +325,8 @@ import { useChangesSummary } from "@/lib/use-changes-summary"; import { toolVisual } from "@/lib/tool-visual"; import { toolReadableFields, prettyToolOutput, type ReadableField } from "@/lib/tool-readable"; import { useShowThinking } from "@/lib/reasoning-visibility"; -import { useActivityMapVisible } from "@/lib/thread-instruments-visibility"; +import { ChatThreadSpine } from "@/components/chat-thread-instruments"; +import { purgeRetiredChatPreferences } from "@/lib/retired-chat-preferences"; import { toolInputAsDiff, toolTargetFile, toolTargetPath } from "@/lib/tool-input-diff"; import { diffStat } from "@/lib/tool-edit-stat"; import { findTranscriptHits } from "@/lib/transcript-find"; @@ -2775,7 +2776,14 @@ export const ChatView = forwardRef(function ChatView( const turnsRef = useRef([]); const tailRef = useRef(null); const scrollRef = useRef(null); - const [activityMapVisible] = useActivityMapVisible(); + // The retired activity-map toggle wrote `cave:chat:thread-instruments`, and an + // opt-out "0" can still be sitting in a browser from an older build. Nothing + // reads it any more — the rail is automatic (cave-5m5hv) — so clear it once + // per mount rather than leaving a machine carrying a switch for a feature it + // can no longer reach. + useEffect(() => { + purgeRetiredChatPreferences(); + }, []); const threadRef = useRef(null); // Scroll-pin state (CHAT-D10-01). `following` means "keep the transcript // pinned to the newest content". It releases on user INTENT (wheel up / @@ -8216,6 +8224,21 @@ export const ChatView = forwardRef(function ChatView( hasTurns={turns.length > 0} onOpenUrl={onOpenUrl} /> + {/* Left turn spine (Chat.dc.html 2a, cave-j86la). One node per turn in + the transcript's left gutter, derived from the SAME activePath the + transcript renders. An absolutely-positioned overlay inside the + scroller, so it costs no layout and adds no horizontal axis; it + gates itself to panes wide enough to have a gutter and stays home + everywhere else. Retained deliberately through the minimap's removal + (cave-5m5hv) — it annotates the left side, which the right rail that + replaced the minimap never occupied. */} + {activePath.length > 0 ? ( + + ) : null}
(function ChatView( ) : null} {/* Run rail (Coven Cave - Chat Session handoff, cave-w716g): the timeline, tool mix and live step, derived from the SAME - activePath the transcript renders. Shares the instruments - toggle — it is the same class of furniture as the spine and - minimap, and two settings for one idea is a choice nobody - asked for. + activePath the transcript renders. + + AUTOMATIC (cave-5m5hv). This is the transcript's right-side + instrument now — it replaced the thread minimap — and it + carries no visibility preference: no kebab item, no stored + key, nothing a person can switch off. The only condition left + here is "there is a transcript at all"; the rail's own gates + (no tool calls yet → renders nothing, narrow row → CSS + display:none) are about data and room, not about choice. Mounted AFTER the transcript on purpose. It was briefly the row's first child with CSS `order` doing the visual placement, which put the rail ahead of the conversation for screen readers — `order` moves boxes, never reading order. DOM order is the accessible order, so the annotation follows the log. */} - {activePath.length > 0 && activityMapVisible ? ( + {activePath.length > 0 ? ( ) : null}
diff --git a/src/lib/chat-session-menu-model.test.ts b/src/lib/chat-session-menu-model.test.ts index d2eded9012..2689bd92d8 100644 --- a/src/lib/chat-session-menu-model.test.ts +++ b/src/lib/chat-session-menu-model.test.ts @@ -14,7 +14,6 @@ const baseCtx = { projectRoot: "/Users/dev/coven-cave" as string | null, hasTurns: true, showThinking: false, - activityMapVisible: true, reflectAvailable: true, reflecting: false, }; @@ -26,8 +25,8 @@ const baseCtx = { const ids = sections.flat().map((i) => i.id); assert.deepEqual( ids, - ["continue-on-phone", "project", "thinking", "activity-map", "reflect", "debug"], - "full context yields the slim six-item menu in two sections", + ["continue-on-phone", "project", "thinking", "reflect", "debug"], + "full context yields the slim five-item menu in two sections", ); assert.equal(sections.length, 2, "primary and tools sections"); } @@ -57,33 +56,31 @@ const baseCtx = { assert.equal(sections.length, 1, "empty primary section is dropped (no dangling separator)"); } -// ---- activity-map toggle -------------------------------------------------- - -{ - const on = sessionMenuSections(baseCtx).flat().find((i) => i.id === "activity-map")!; - assert.equal(on.checked, true, "a visible activity map renders the checkmark"); - assert.equal(on.label, "Hide activity map", "the label names the map users can see"); - assert.equal(on.title, "Hide the activity timeline and tool summary"); - - const off = sessionMenuSections({ ...baseCtx, activityMapVisible: false }) - .flat() - .find((i) => i.id === "activity-map")!; - assert.equal(off.checked, false); - assert.equal(off.label, "Show activity map"); - assert.equal(off.title, "Show the activity timeline and tool summary"); - assert.equal(on.icon, off.icon, "both states share one curated glyph"); +// ---- the retired activity-map toggle -------------------------------------- +// The transcript's right-side instrument is automatic (cave-5m5hv): the run +// rail replaced the thread minimap and carries no visibility preference. A +// menu item here would be the one surviving way to switch it back off, so the +// menu must not offer one under ANY context — including the contexts that +// previously produced it. + +for (const ctx of [ + baseCtx, + { ...baseCtx, hasTurns: false }, + { ...baseCtx, sessionId: null, projectPickerAvailable: false, reflectAvailable: false }, +]) { + const ids = sessionMenuSections(ctx).flat().map((i) => i.id) as string[]; + assert.ok( + !ids.some((id) => id.includes("activity") || id.includes("map") || id.includes("instrument")), + "no context may produce an activity-map / instruments toggle", + ); } { - // Gated on hasTurns for the same reason Show-thinking is: offering to hide - // furniture that is not on screen reads as a broken setting. - const ids = sessionMenuSections({ ...baseCtx, hasTurns: false }) - .flat() - .map((i) => i.id); - assert.ok( - !ids.includes("activity-map"), - "an empty transcript has nothing to navigate, so the toggle stays away", - ); + // Show thinking is the neighbour the retired item sat beside; keep it pinned + // so the sweep above can never pass by emptying the tools section. + const thinking = sessionMenuSections(baseCtx).flat().find((i) => i.id === "thinking")!; + assert.equal(thinking.checked, false); + assert.equal(thinking.label, "Show thinking", "the surviving toggle still works"); } { diff --git a/src/lib/chat-session-menu-model.ts b/src/lib/chat-session-menu-model.ts index f4dc077e34..85b05d4e6c 100644 --- a/src/lib/chat-session-menu-model.ts +++ b/src/lib/chat-session-menu-model.ts @@ -18,7 +18,10 @@ export type SessionMenuItemId = | "continue-on-phone" | "project" | "thinking" - | "activity-map" + // No "activity-map": the transcript's right-side instrument is automatic + // (cave-5m5hv). It replaced the retired thread minimap, and re-adding a + // visibility item here would give a person a way to turn it back off — which + // is the state the removal exists to make unreachable. | "reflect" | "debug"; @@ -45,8 +48,6 @@ export function sessionMenuSections(ctx: { projectRoot: string | null; hasTurns: boolean; showThinking: boolean; - /** Whether the chat activity map is currently shown. */ - activityMapVisible: boolean; /** Reflect-on-thread is wired (familiar has an id). */ reflectAvailable: boolean; reflecting: boolean; @@ -78,21 +79,6 @@ export function sessionMenuSections(ctx: { title: ctx.showThinking ? "Hide reasoning blocks" : "Show reasoning blocks", }); } - if (ctx.hasTurns) { - // Sits beside Show thinking: both are "how much scaffolding do I want - // around the conversation". Gated on hasTurns for the same reason — - // there is nothing to navigate in an empty transcript, and offering to - // hide furniture that is not on screen reads as a broken setting. - tools.push({ - id: "activity-map", - label: ctx.activityMapVisible ? "Hide activity map" : "Show activity map", - icon: "ph:path", - checked: ctx.activityMapVisible, - title: ctx.activityMapVisible - ? "Hide the activity timeline and tool summary" - : "Show the activity timeline and tool summary", - }); - } if (ctx.reflectAvailable) { tools.push({ id: "reflect", diff --git a/src/lib/chat-thread-instruments.test.ts b/src/lib/chat-thread-instruments.test.ts index e1901143b8..73b0d39a17 100644 --- a/src/lib/chat-thread-instruments.test.ts +++ b/src/lib/chat-thread-instruments.test.ts @@ -1,19 +1,21 @@ -// Contract tests for the transcript's navigation instruments (cave-j86la): -// the pure derivation both the run spine and the thread minimap consume, plus -// source pins for how chat-view mounts them. +// Contract tests for the transcript's LEFT turn spine (cave-j86la): the pure +// derivation it consumes, plus source pins for how chat-view mounts it. +// +// The thread minimap that used to share this module is permanently removed +// (cave-5m5hv). The pins at the bottom are what keep it removed AND what keep +// the spine mounted — a removal PR is exactly where "nothing renders" quietly +// passes for "the old thing is gone". import assert from "node:assert/strict"; import { test } from "node:test"; import { readFileSync } from "node:fs"; +import * as instrumentModel from "./chat-thread-instruments.ts"; import { - formatTookLabel, instrumentSummary, instrumentTime, spineSegmentHeights, spineNodes, spineStackHeight, - threadMapEvents, - toolBarWidth, toolCategory, type InstrumentTurn, } from "./chat-thread-instruments.ts"; @@ -89,53 +91,28 @@ test("spine segment heights stay within one stack even with a dominant category" assert.ok(heights.reduce((sum, height) => sum + height, 0) <= 100.0001); }); -test("threadMapEvents orders prompt → tools → answer and attributes owners", () => { - const events = threadMapEvents( - [ - turn({ id: "u1", role: "user", text: "Go" }), - turn({ - id: "a1", - durationMs: 51_000, - tools: [ - { id: "1", name: "bash", input: "gh run list --limit 5", status: "ok", durationMs: 900 }, - { id: "2", name: "edit", input: "release.yml", status: "error" }, - ], - }), - ], - names, - ); - assert.deepEqual( - events.map((e) => e.kind), - ["turn", "shell", "edit", "answer"], - ); - // Turn and answer bars span the row; tool bars are duration/name-keyed. - assert.equal(events[0].width, 100); - assert.equal(events[3].width, 100); - assert.ok(events[1].width >= 24 && events[1].width <= 96); - // Owner attribution: every event on an assistant turn belongs to the familiar. - assert.equal(events[1].ownerName, "Kitty"); - assert.equal(events[2].error, true, "a failed tool keeps its error flag"); - assert.equal(events[3].took, "51s"); - assert.equal(events[0].turnLabel, "VAL"); - // Ids are stable so React keys and selection survive re-renders. - assert.equal(events[1].id, "a1:tool:1"); -}); - -test("bar widths are deterministic and clamped", () => { - assert.equal(toolBarWidth("bash", 900), toolBarWidth("bash", 900)); - assert.ok(toolBarWidth("bash", 50) >= 24); - assert.ok(toolBarWidth("bash", 90 * 60_000) <= 96); - // No duration → stable name-keyed spread, not randomness. - assert.equal(toolBarWidth("read"), toolBarWidth("read")); - assert.notEqual(toolBarWidth("read"), toolBarWidth("web_fetch")); +test("the minimap derivation is gone from the module, not merely unused", () => { + // The minimap's model was `threadMapEvents` plus the two helpers only it + // called. Naming them individually is the point: a removal that leaves the + // derivation exported keeps a working minimap one import away, and the next + // reader has no way to tell "retired" from "not currently mounted" — which + // is exactly the state this change found the codebase in. + for (const retired of ["threadMapEvents", "toolBarWidth", "formatTookLabel"]) { + assert.ok( + !(retired in instrumentModel), + `${retired} was minimap-only and must not survive the removal`, + ); + } + // What the spine actually needs is still here — the sweep above must not be + // satisfiable by deleting the module. + for (const kept of ["spineNodes", "spineStackHeight", "spineSegmentHeights", "toolCategory"]) { + assert.ok(kept in instrumentModel, `${kept} is the spine's own derivation and must survive`); + } }); test("formatting helpers stay honest on absent data", () => { assert.equal(instrumentTime(undefined), null); assert.equal(instrumentTime("not a date"), null); - assert.equal(formatTookLabel(undefined), null); - assert.equal(formatTookLabel(400), "400ms"); - assert.equal(formatTookLabel(83_000), "1m 23s"); assert.equal(instrumentSummary("one line\nrest"), "one line"); assert.equal(instrumentSummary("x".repeat(200)).length, 96); }); @@ -206,38 +183,105 @@ test("the spine stamp sits in its own lane, never under the node ring", () => { ); }); -test("instrument controls use the shared focus ring and no dead running class", () => { +test("spine controls use the shared focus ring and no dead running class", () => { assert.match(instruments, /className=\{`cave-thread-spine__node focus-ring/); - assert.match(instruments, /className=\{`cave-thread-map__row focus-ring/); assert.doesNotMatch(instruments, /is-running/); }); -test("instrument tint mappings use theme-aware semantic tokens", () => { +test("spine tint mappings use theme-aware semantic tokens", () => { assert.doesNotMatch(instrumentStyles, /--tim-[^:]+:\s*oklch\(/); - assert.match(instrumentStyles, /\.cave-thread-map \.is-read \{ --tim: var\(--color-info\); \}/); + assert.match(instrumentStyles, /\.cave-thread-spine \.is-read \{ --tim: var\(--color-info\); \}/); }); -test("chat-view mounts the activity map over the SAME activePath the transcript renders", () => { +// ── the left turn spine SURVIVES the minimap's removal ────────────────────── +// This is the constraint the removal is most likely to violate by accident: +// both instruments lived in one file behind one preference, so deleting the +// preference or the file takes the spine with it and every remaining test +// still passes. Assert the spine renders, not merely that it compiles. +test("chat-view mounts the left turn spine over the transcript's own activePath", () => { + assert.match( + chatView, + /^import \{ ChatThreadSpine \} from "@\/components\/chat-thread-instruments";$/m, + "chat-view imports the spine", + ); assert.match( chatView, - / 0 && spine > 0 && thread > 0, "scroller, spine and thread are all present"); + assert.ok( + spine > scroller && spine < thread, + "the spine mounts inside the transcript scroller, above the conversation log", + ); +}); + +test("the spine draws real per-turn nodes, not an empty nav", () => { + // "Nothing renders" satisfies "the minimap is gone", so pin the marks a + // reader would actually see: a labelled nav, one button per placed turn, + // each carrying the tool-category stack that makes it an instrument. + assert.match(instruments, /aria-label="Turns in this thread"/, "the spine is a named landmark"); + assert.match(instruments, /placed\.map\(\(node\) => \(\s*\n\s* \(/, + "each node renders its tool-category stack", + ); + assert.match( + instruments, + /aria-label=\{`Jump to \$\{node\.name\}'s turn/, + "every node is an operable, named jump target", + ); +}); + +test("the retired minimap leaves no component, markup or stylesheet behind", () => { + // Strip comments first. Both files explain in prose WHY the minimap is gone, + // and an unanchored search happily matches that explanation — the "satisfied + // by a comment" trap chat-run-rail.test.ts already guards against. Scan the + // code, not the paragraph about the code. + const code = (src: string) => + src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^[ \t]*\/\/.*$/gm, ""); + assert.doesNotMatch(code(chatView), /ChatThreadMinimap/, "chat-view cannot mount it"); + assert.doesNotMatch( + code(instruments), + /ChatThreadMinimap|cave-thread-map|MapRow|MapHoverCard|threadMapEvents/, + "the component, its rows and its hover card are deleted", + ); + assert.doesNotMatch(code(instrumentStyles), /cave-thread-map/, "its stylesheet rules are deleted"); + // The stripper must not be doing the work on its own. + assert.match(code(instruments), /ChatThreadSpine/, "the spine is still real code, not a comment"); }); -test("instruments are overlays: self-gated by pane width, jump via data-turn-id", () => { +test("the spine is an overlay: self-gated by pane width, jumps via data-turn-id", () => { assert.match( instruments, /THREAD_INSTRUMENTS_MIN_WIDTH = 1360/, - "one shared wide-pane gate for both instruments", + "the spine's wide-pane gate", ); assert.match( instruments, /querySelector\(`\[data-turn-id="\$\{CSS\.escape\(turnId\)\}"\]`\)/, "jumps target the transcript's existing turn anchors", ); - // rAF-coalescing refs must null on cancel (the #2659 wedge) — both cleanups. + // The spine is `position: absolute` inside the scroller, so it can never add + // a horizontal axis to the transcript — the property phone widths depend on. + assert.match( + instrumentStyles, + /\.cave-thread-spine \{[^}]*position: absolute;/, + "the spine is an overlay, never layout", + ); + // rAF-coalescing refs must null on cancel (the #2659 wedge). One guard now + // that the minimap's scroll tracker is gone; the count is the assertion, so + // a re-added guard that forgets to null still fails. const cancels = instruments.match(/cancelAnimationFrame\(frameRef\.current\);\s*\n\s*frameRef\.current = null;/g) ?? []; - assert.equal(cancels.length, 2, "every rAF guard nulls its ref when cancelling"); + const raf = instruments.match(/requestAnimationFrame\(/g) ?? []; + assert.equal(raf.length, 1, "the spine keeps exactly one rAF-coalesced measure"); + assert.equal(cancels.length, 1, "every rAF guard nulls its ref when cancelling"); }); diff --git a/src/lib/chat-thread-instruments.ts b/src/lib/chat-thread-instruments.ts index 8ccb0a8ff2..9047ece1c6 100644 --- a/src/lib/chat-thread-instruments.ts +++ b/src/lib/chat-thread-instruments.ts @@ -1,12 +1,16 @@ -// Pure model for the transcript's two navigation instruments (Chat.dc.html 2a, -// cave-j86la): the vertical run spine in the left gutter (one node per turn, -// that turn's tool calls rolled into a proportional category stack) and the -// thread minimap on the right edge (one bar per event; click to jump). +// Pure model for the transcript's LEFT turn spine (Chat.dc.html 2a, +// cave-j86la): one node per turn in the left gutter, that turn's tool calls +// rolled into a proportional category stack. // -// Deliberately dependency-free: both instruments derive everything from the -// Turn[] the transcript already renders — no fetches, no @/ imports — so the +// Deliberately dependency-free: the spine derives everything from the Turn[] +// the transcript already renders — no fetches, no @/ imports — so the // derivation is unit-testable with bare node and can never disagree with the // thread it annotates. +// +// The thread minimap that used to share this module is permanently removed +// (cave-5m5hv); its derivation (`threadMapEvents` and helpers) went with it. +// The right edge belongs to the Design run rail (src/lib/chat-run-rail.ts), +// which is automatic and carries its own model. export type ThreadToolCategory = | "read" @@ -148,104 +152,3 @@ export function spineSegmentHeights(cats: readonly { count: number }[]): number[ const remaining = 100 - minimum * counts.length; return counts.map((count) => minimum + (count / total) * remaining); } - -export type ThreadMapEvent = { - /** Stable per-thread id (":prompt" | ":tool:" | ":answer"). */ - id: string; - turnId: string; - kind: "turn" | "answer" | ThreadToolCategory; - /** Hover-card headline: "Val · prompt", "bash · gh run list", "Kitty · answer". */ - label: string; - /** Mono initials shown inline on turn rows ("VAL"). */ - turnLabel: string | null; - /** Owner attribution rows for the hover card. */ - ownerName: string; - ownerTime: string | null; - /** Bar width, 24–100 (%). Turn/answer bars are full-width by design. */ - width: number; - /** "1.2s" for tool events with a known duration. */ - took: string | null; - error: boolean; -}; - -/** Deterministic tool-bar width: duration-scaled when known (log steps so 100ms - * and 10s both stay readable), otherwise a stable name-keyed spread — never - * random, so the map is identical across renders and resumes. */ -export function toolBarWidth(name: string, durationMs?: number): number { - if (durationMs != null && Number.isFinite(durationMs) && durationMs > 0) { - return Math.min(96, Math.max(24, Math.round(24 + Math.log10(1 + durationMs) * 16))); - } - let hash = 0; - for (const ch of name) hash = (hash * 31 + ch.charCodeAt(0)) % 997; - return 32 + (hash % 41); -} - -export function formatTookLabel(durationMs?: number): string | null { - if (durationMs == null || !Number.isFinite(durationMs) || durationMs <= 0) return null; - if (durationMs < 1000) return `${Math.round(durationMs)}ms`; - const secs = durationMs / 1000; - if (secs < 60) return `${Math.round(secs * 10) / 10}s`; - const mins = Math.floor(secs / 60); - return `${mins}m ${Math.round(secs % 60)}s`; -} - -function initials(name: string): string { - const cleaned = name.trim(); - if (!cleaned) return "?"; - return cleaned.slice(0, 3).toUpperCase(); -} - -export function threadMapEvents( - turns: InstrumentTurn[], - names: { operatorName: string; familiarName: string }, -): ThreadMapEvent[] { - const events: ThreadMapEvent[] = []; - for (const turn of turns) { - if (turn.role === "user") { - events.push({ - id: `${turn.id}:prompt`, - turnId: turn.id, - kind: "turn", - label: `${names.operatorName} · prompt`, - turnLabel: initials(names.operatorName), - ownerName: names.operatorName, - ownerTime: instrumentTime(turn.createdAt), - width: 100, - took: null, - error: false, - }); - continue; - } - if (turn.role !== "assistant") continue; - const ownerTime = instrumentTime(turn.createdAt); - for (const tool of turn.tools ?? []) { - const cat = toolCategory(tool.name); - const arg = tool.input?.trim().split(/\n/, 1)[0] ?? ""; - events.push({ - id: `${turn.id}:tool:${tool.id}`, - turnId: turn.id, - kind: cat, - label: arg ? `${tool.name} · ${instrumentSummary(arg, 48)}` : tool.name, - turnLabel: null, - ownerName: names.familiarName, - ownerTime, - width: toolBarWidth(tool.name, tool.durationMs), - took: formatTookLabel(tool.durationMs), - error: tool.status === "error", - }); - } - events.push({ - id: `${turn.id}:answer`, - turnId: turn.id, - kind: "answer", - label: `${names.familiarName} · answer`, - turnLabel: initials(names.familiarName), - ownerName: names.familiarName, - ownerTime, - width: 100, - took: formatTookLabel(turn.durationMs), - error: Boolean(turn.error), - }); - } - return events; -} diff --git a/src/lib/retired-chat-preferences.test.ts b/src/lib/retired-chat-preferences.test.ts new file mode 100644 index 0000000000..459d1fc0b7 --- /dev/null +++ b/src/lib/retired-chat-preferences.test.ts @@ -0,0 +1,95 @@ +// @ts-nocheck +// Behavioural tests for the retired-chat-preference purge (cave-5m5hv), plus a +// source pin that chat-view actually runs it. +// +// This file replaces thread-instruments-visibility.test.ts. That file's +// assertions were all properties of the preference itself — "unset defaults to +// visible", "an explicit 0 hides them", "the kebab flips it" — and every one of +// them describes behaviour this change deletes. What it did NOT cover was the +// left turn spine (unmounted at the time) or the rail's existence; its only +// rail-side property was that the rail was *conditional*. So nothing carried +// over verbatim, and what is pinned here is the inverse: the stored value is +// removed, and removing it is safe to repeat. +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const KEY = "cave:chat:thread-instruments"; + +function withStorage(storage: unknown) { + globalThis.window = { localStorage: storage } as never; +} + +const { purgeRetiredChatPreferences } = await import("./retired-chat-preferences.ts"); + +function makeStore(initial: Record = {}) { + const map = new Map(Object.entries(initial)); + return { + map, + api: { + getItem: (k: string) => (map.has(k) ? map.get(k)! : null), + removeItem: (k: string) => { map.delete(k); }, + setItem: (k: string, v: string) => { map.set(k, v); }, + }, + }; +} + +// ── the opt-out a real browser is carrying is deleted ────────────────────── +// "0" is the value that matters: it is what the retired "Hide activity map" +// item wrote, and the state a user could be stuck in with no UI left to exit. +{ + const { map, api } = makeStore({ [KEY]: "0", "cave:chat:keep-me": "1" }); + withStorage(api); + const removed = purgeRetiredChatPreferences(); + assert.deepEqual(removed, [KEY], "the retired key is reported as removed"); + assert.equal(map.has(KEY), false, "the retired key is gone from storage"); + assert.equal(map.get("cave:chat:keep-me"), "1", "unrelated chat keys are untouched"); +} + +// A "1" is just as dead — the purge is about the key, not about which way the +// switch was left. +{ + const { map, api } = makeStore({ [KEY]: "1" }); + withStorage(api); + assert.deepEqual(purgeRetiredChatPreferences(), [KEY]); + assert.equal(map.has(KEY), false); +} + +// ── idempotent: it runs on every chat mount ──────────────────────────────── +{ + const { api } = makeStore({ [KEY]: "0" }); + withStorage(api); + assert.deepEqual(purgeRetiredChatPreferences(), [KEY], "first pass removes"); + assert.deepEqual(purgeRetiredChatPreferences(), [], "second pass reports nothing and does not throw"); +} + +// ── never throws where storage is unavailable ────────────────────────────── +// Private mode, a quota error, or a browser with site data blocked. A purge +// that throws here would take the whole transcript down with it. +{ + withStorage({ + getItem: () => { throw new Error("SecurityError"); }, + removeItem: () => { throw new Error("SecurityError"); }, + }); + assert.deepEqual(purgeRetiredChatPreferences(), [], "a throwing store degrades to a no-op"); +} + +// Server render: there is no window at all. +{ + delete (globalThis as Record).window; + assert.deepEqual(purgeRetiredChatPreferences(), [], "no window → no-op, never a crash"); +} + +// ── the purge actually reaches the chat ──────────────────────────────────── +const chatView = readFileSync(new URL("../components/chat-view.tsx", import.meta.url), "utf8"); +assert.match( + chatView, + /^import \{ purgeRetiredChatPreferences \} from "@\/lib\/retired-chat-preferences";$/m, + "chat-view imports the purge", +); +assert.match( + chatView, + /useEffect\(\(\) => \{\s*\n\s*purgeRetiredChatPreferences\(\);\s*\n\s*\}, \[\]\);/, + "chat-view runs the purge once per mount — an unimported purge cleans nothing", +); + +console.log("retired-chat-preferences tests passed"); diff --git a/src/lib/retired-chat-preferences.ts b/src/lib/retired-chat-preferences.ts new file mode 100644 index 0000000000..ab1840e835 --- /dev/null +++ b/src/lib/retired-chat-preferences.ts @@ -0,0 +1,45 @@ +"use client"; + +// Residue purge for chat preferences whose feature has been removed. +// +// `cave:chat:thread-instruments` gated the transcript's right-side instrument. +// It was written by the retired "Show/Hide activity map" kebab toggle, and the +// value that survives in a real browser is `"0"` — an opt-OUT. Once the toggle +// and its reader are gone (cave-5m5hv) the run rail is automatic, so that +// stored `"0"` has no reader and cannot hide anything. +// +// It is deleted rather than left inert, and the distinction is not cosmetic. +// Leaving it means a machine keeps carrying a switch for a feature it can no +// longer reach: nothing in the product can clear it, so it survives every +// update, and the next surface that reaches for a chat preference key inherits +// a stale one that means "hidden". Migrating it was the other option and was +// rejected — there is no destination. The rail's remaining gates are data +// (no tool calls yet) and width, neither of which a person chooses, so there +// is no preference for the old value to become. +// +// Removal is also the safe direction under a downgrade: an older build finding +// the key absent reads its own documented default, which was ON. +// +// Idempotent, storage-exception-safe, and a no-op on the server. + +const RETIRED_KEYS = [ + // Retired with the thread minimap and the activity-map toggle (cave-5m5hv). + "cave:chat:thread-instruments", +] as const; + +/** Delete every retired chat preference key. Returns the keys actually removed + * so a caller (or a test) can see the purge happen rather than assume it. */ +export function purgeRetiredChatPreferences(): string[] { + if (typeof window === "undefined") return []; + const removed: string[] = []; + for (const key of RETIRED_KEYS) { + try { + if (window.localStorage.getItem(key) === null) continue; + window.localStorage.removeItem(key); + removed.push(key); + } catch { + /* private mode / quota / disabled storage — nothing to clean up here */ + } + } + return removed; +} diff --git a/src/lib/thread-instruments-visibility.test.ts b/src/lib/thread-instruments-visibility.test.ts deleted file mode 100644 index 939e2713ef..0000000000 --- a/src/lib/thread-instruments-visibility.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -// @ts-nocheck -// Behavioral tests for the persisted activity-map preference, plus source pins -// for how the toggle reaches the chat. -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; - -const store = new Map(); -const listeners = new Map void>>(); -globalThis.window = { - localStorage: { - getItem: (k: string) => (store.has(k) ? store.get(k)! : null), - setItem: (k: string, v: string) => { store.set(k, v); }, - }, - addEventListener: (type: string, fn: (e: unknown) => void) => { - if (!listeners.has(type)) listeners.set(type, new Set()); - listeners.get(type)!.add(fn); - }, - removeEventListener: (type: string, fn: (e: unknown) => void) => { - listeners.get(type)?.delete(fn); - }, - dispatchEvent: (event: { type: string }) => { - for (const fn of listeners.get(event.type) ?? []) fn(event); - return true; - }, -} as never; -globalThis.CustomEvent = class { - type: string; - detail: unknown; - constructor(type: string, init?: { detail?: unknown }) { - this.type = type; - this.detail = init?.detail; - } -} as never; - -const { - readThreadInstrumentsVisible, - writeThreadInstrumentsVisible, -} = await import("./thread-instruments-visibility.ts"); - -const KEY = "cave:chat:thread-instruments"; - -// ── default is ON, and only an explicit opt-out hides ───────────────────── -// Absent must not read as "hidden": the instruments already gate themselves to -// wide panes and long threads, so a fresh or cleared store should show them -// rather than leave the gutters mysteriously empty. -store.clear(); -assert.equal(readThreadInstrumentsVisible(), true, "unset defaults to visible"); - -store.set(KEY, "0"); -assert.equal(readThreadInstrumentsVisible(), false, "an explicit 0 hides them"); - -store.set(KEY, "1"); -assert.equal(readThreadInstrumentsVisible(), true); - -// Anything unrecognised is treated as "not opted out" — a corrupted value -// restores the default instead of silently disabling a feature. -for (const junk of ["", "true", "yes", "{}", "00"]) { - store.set(KEY, junk); - assert.equal( - readThreadInstrumentsVisible(), - true, - `a junk value (${JSON.stringify(junk)}) must fall back to visible`, - ); -} - -// ── writes persist and broadcast ────────────────────────────────────────── -let broadcast: unknown = null; -const onChange = (e: unknown) => { broadcast = (e as { detail: unknown }).detail; }; -globalThis.window.addEventListener("cave:thread-instruments-change", onChange); - -writeThreadInstrumentsVisible(false); -assert.equal(store.get(KEY), "0", "the choice is persisted"); -assert.equal(broadcast, false, "and broadcast, so the transcript hears it without prop threading"); - -writeThreadInstrumentsVisible(true); -assert.equal(store.get(KEY), "1"); -assert.equal(broadcast, true); - -// ── the toggle actually reaches the transcript ──────────────────────────── -const chatView = readFileSync(new URL("../components/chat-view.tsx", import.meta.url), "utf8"); -const header = readFileSync( - new URL("../components/chat-session-header.tsx", import.meta.url), - "utf8", -); -assert.match( - chatView, - /\{activePath\.length > 0 && activityMapVisible \? \(/, - "an unchecked toggle skips mounting the activity map entirely", -); -assert.match( - header, - /"activity-map": \(\) => \{\s*setActivityMapVisible\(!activityMapVisible\);/, - "the kebab item must flip the shared preference", -); - -console.log("thread-instruments-visibility tests passed"); diff --git a/src/lib/thread-instruments-visibility.ts b/src/lib/thread-instruments-visibility.ts deleted file mode 100644 index 06d785e934..0000000000 --- a/src/lib/thread-instruments-visibility.ts +++ /dev/null @@ -1,77 +0,0 @@ -// Global "show activity map" preference for the chat transcript. -// -// The persisted key predates the activity-map name. Keep it so existing reader -// preferences survive the replacement of the thread spine/minimap. -// -// Mirrors reasoning-visibility.ts deliberately: persisted in localStorage and -// broadcast on a custom event, because the toggle lives in the session header -// while the instruments mount deep inside the transcript. Threading state -// between them through every intervening parent is the thing this avoids. -// -// Default is ON. The instruments already gate themselves to wide panes and to -// threads long enough to navigate, so a first-run user only meets them where -// they help; the toggle exists for people who want the gutters quiet, not as -// an opt-in for a hidden feature. - -"use client"; - -import { useEffect, useState } from "react"; - -const STORAGE_KEY = "cave:chat:thread-instruments"; -const EVENT = "cave:thread-instruments-change"; - -export function readThreadInstrumentsVisible(): boolean { - if (typeof window === "undefined") return true; - try { - // Absent means "never chosen" → default on. Only an explicit "0" hides - // them, so a cleared or corrupted store restores the default rather than - // silently leaving the gutters empty with no clue why. - return window.localStorage.getItem(STORAGE_KEY) !== "0"; - } catch { - return true; - } -} - -export function writeThreadInstrumentsVisible(value: boolean): void { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem(STORAGE_KEY, value ? "1" : "0"); - } catch { - /* private mode / quota — fall back to in-memory broadcast only */ - } - window.dispatchEvent(new CustomEvent(EVENT, { detail: value })); -} - -/** - * Subscribe to the global instruments preference. Returns the current value - * and a setter that persists + broadcasts to every subscriber. - */ -export function useThreadInstrumentsVisible(): [boolean, (value: boolean) => void] { - // Start from the default rather than reading storage during render: the - // server has no localStorage, and a first paint that disagreed with hydration - // would flash the gutters. The effect below settles it on the client. - const [visible, setVisible] = useState(true); - - useEffect(() => { - setVisible(readThreadInstrumentsVisible()); - const onChange = (event: Event) => { - const detail = (event as CustomEvent).detail; - setVisible(typeof detail === "boolean" ? detail : readThreadInstrumentsVisible()); - }; - // `storage` fires in OTHER tabs/windows only, which is exactly what the - // custom event cannot reach. - const onStorage = (event: StorageEvent) => { - if (event.key === STORAGE_KEY) setVisible(readThreadInstrumentsVisible()); - }; - window.addEventListener(EVENT, onChange); - window.addEventListener("storage", onStorage); - return () => { - window.removeEventListener(EVENT, onChange); - window.removeEventListener("storage", onStorage); - }; - }, []); - - return [visible, writeThreadInstrumentsVisible]; -} - -export const useActivityMapVisible = useThreadInstrumentsVisible; diff --git a/src/styles/cave-chat/run-rail.css b/src/styles/cave-chat/run-rail.css index ceab3319d4..928619ef0a 100644 --- a/src/styles/cave-chat/run-rail.css +++ b/src/styles/cave-chat/run-rail.css @@ -4,15 +4,19 @@ lib/chat-run-rail.ts for why PLAN, LEFT, CONTEXT WINDOW and COST are absent rather than stubbed. - Category colors come from the SAME semantic tokens the spine and minimap use + Category colors come from the SAME semantic tokens the left turn spine uses (thread-instruments.css) rather than a component-local palette, so the rail's - legend and the transcript's gutters can never drift apart. */ + legend and the transcript's left gutter can never drift apart. + + The rail is AUTOMATIC (cave-5m5hv): it replaced the retired thread minimap on + the right edge and carries no visibility preference. What still gates it is + data (no tool calls yet → nothing to draw) and width (see below). */ :root { /* Shared with the component: the width gate must know what the rail costs, - or it admits the rail at widths that push the transcript below the - instruments threshold — the spine and minimap would vanish while the rail - that displaced them stayed. */ + or it admits the rail at widths that push the transcript below the turn + spine's own threshold — the spine would vanish while the rail that + displaced it stayed. */ --runrail-min-w: 232px; --runrail-max-w: 300px; } diff --git a/src/styles/cave-chat/thread-instruments.css b/src/styles/cave-chat/thread-instruments.css index bc7b12bd23..fb45f65282 100644 --- a/src/styles/cave-chat/thread-instruments.css +++ b/src/styles/cave-chat/thread-instruments.css @@ -1,29 +1,26 @@ -/* ── Thread instruments (Chat.dc.html 2a, cave-j86la) ────────────────────── - The transcript's two navigation overlays: the run spine in the left gutter - (one node per turn + a proportional tool-category stack) and the thread - minimap on the right edge (one bar per event, click to jump). Both live in - the reading column's existing side gutters — overlays, never layout — and - both stay home on narrow panes (gated in the components). +/* ── Chat turn spine (Chat.dc.html 2a, cave-j86la) ───────────────────────── + The transcript's LEFT navigation overlay: the run spine in the left gutter, + one node per turn plus a proportional tool-category stack. It lives in the + reading column's existing left gutter — an overlay, never layout — and stays + home on narrow panes (gated in the component). - Category colors deliberately come from the global semantic token contract so - every palette and mode can retune them without a component-local palette. */ + The right edge used to carry `.cave-thread-map`, the thread minimap. Those + rules are gone for good (cave-5m5hv); the Design run rail owns the right side + now and brings its own stylesheet (cave-chat/run-rail.css). -.cave-thread-spine .is-shell, -.cave-thread-map .is-shell { --tim: var(--color-success); } -.cave-thread-spine .is-edit, -.cave-thread-map .is-edit { --tim: var(--color-warning); } -.cave-thread-spine .is-search, -.cave-thread-map .is-search { --tim: var(--accent-presence); } -.cave-thread-spine .is-wait, -.cave-thread-map .is-wait { --tim: var(--color-warning); } -.cave-thread-spine .is-web, -.cave-thread-map .is-web { --tim: var(--color-info); } -.cave-thread-spine .is-agent, -.cave-thread-map .is-agent { --tim: var(--accent-presence); } -.cave-thread-spine .is-other, -.cave-thread-map .is-other { --tim: var(--text-muted); } -.cave-thread-spine .is-read, -.cave-thread-map .is-read { --tim: var(--color-info); } + Category colors deliberately come from the global semantic token contract so + every palette and mode can retune them without a component-local palette. + The run rail declares the SAME token per category, so the two instruments + cannot drift apart. */ + +.cave-thread-spine .is-shell { --tim: var(--color-success); } +.cave-thread-spine .is-edit { --tim: var(--color-warning); } +.cave-thread-spine .is-search { --tim: var(--accent-presence); } +.cave-thread-spine .is-wait { --tim: var(--color-warning); } +.cave-thread-spine .is-web { --tim: var(--color-info); } +.cave-thread-spine .is-agent { --tim: var(--accent-presence); } +.cave-thread-spine .is-other { --tim: var(--text-muted); } +.cave-thread-spine .is-read { --tim: var(--color-info); } /* ── Spine — left gutter, scrolls with the content ───────────────────────── */ @@ -249,243 +246,3 @@ color: var(--text-secondary); } -/* ── Minimap — right edge, fixed against the pane ────────────────────────── */ - -/* Sticky h-0 anchor (the env HUD's pattern): the rail hangs from it without - entering the scroll flow, so it never scrolls with the thread. */ -.cave-thread-map-anchor { - position: sticky; - top: 0; - z-index: 20; - height: 0; - display: flex; - justify-content: flex-end; -} - -.cave-thread-map { - /* Positioned so the shared hover card anchors to the 84px rail, not to the - full-width sticky anchor above it. */ - position: relative; - display: flex; - flex-direction: column; - width: 84px; - border-left: 1px solid var(--border-hairline); - background: color-mix(in oklch, var(--bg-panel) 55%, transparent); -} - -.cave-thread-map__head { - display: flex; - align-items: center; - flex: none; - height: 26px; - padding: 0 var(--space-2); - border-bottom: 1px solid var(--border-hairline); - font-family: var(--font-mono); - font-size: var(--text-2xs); - font-weight: 600; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--text-muted); -} - -.cave-thread-map__body { - position: relative; - flex: 1; - min-height: 0; - overflow-y: auto; - overflow-x: visible; - padding: var(--space-2) var(--space-2) var(--space-3); - scrollbar-width: none; -} - -.cave-thread-map__caret { - position: absolute; - left: 4px; - right: 4px; - height: 15px; - border: 1px solid color-mix(in oklch, var(--accent-presence) 55%, transparent); - border-radius: var(--radius-control); - background: color-mix(in oklch, var(--accent-presence) 10%, transparent); - pointer-events: none; - transition: top 160ms var(--ease-standard, ease); -} - -.cave-thread-map__row { - position: relative; - display: flex; - align-items: center; - width: 100%; - height: 15px; - padding: 0 var(--space-1); - border: none; - border-radius: var(--radius-pill); - background: transparent; - cursor: pointer; -} - -.cave-thread-map__row:hover, -.cave-thread-map__row:focus-visible { - background: color-mix(in oklch, var(--foreground) 8%, transparent); -} - -.cave-thread-map .is-turn { --tim: var(--text-primary); } -.cave-thread-map .is-answer { --tim: var(--color-success); } -.cave-thread-map .is-error { --tim: var(--color-danger); } - -.cave-thread-map__bar { - display: block; - border-radius: var(--radius-pill); - background: color-mix(in oklch, var(--tim, var(--text-muted)) 52%, transparent); -} - -.cave-thread-map__row:hover .cave-thread-map__bar { - background: var(--tim, var(--text-muted)); -} - -.cave-thread-map__turn-label { - position: absolute; - right: 3px; - font-family: var(--font-mono); - font-size: var(--text-2xs); - letter-spacing: 0.1em; - color: var(--text-muted); -} - -/* One shared hover card, hung off the rail itself (the rows live inside a - scroll container, which would clip a per-row card); it opens leftward into - the thread at the hovered row's y. */ -.cave-thread-map__card { - position: absolute; - right: calc(100% + 8px); - z-index: 80; - display: flex; - flex-direction: column; - gap: var(--space-1); - width: 230px; - padding: var(--space-2) var(--space-3); - border: 1px solid var(--border-hairline); - border-left: 2px solid var(--tim, var(--text-muted)); - border-radius: var(--radius-control); - background: color-mix(in oklch, var(--bg-elevated) 96%, transparent); - backdrop-filter: blur(16px); - box-shadow: 0 16px 40px oklch(0 0 0 / 52%); - text-align: left; - pointer-events: none; - animation: cave-thread-map-card-in 130ms var(--ease-standard, ease); -} - -@keyframes cave-thread-map-card-in { - from { - opacity: 0; - transform: translateX(6px); - } - to { - opacity: 1; - transform: translateX(0); - } -} - -.cave-thread-map__card-head { - display: flex; - align-items: center; - gap: var(--space-2); - font-family: var(--font-mono); - font-size: var(--text-2xs); - font-weight: 600; - letter-spacing: 0.12em; - text-transform: uppercase; - color: var(--tim, var(--text-muted)); -} - -.cave-thread-map__card-idx { - margin-left: auto; - letter-spacing: 0.04em; - color: var(--text-muted); -} - -.cave-thread-map__card-label { - font-size: var(--text-sm); - line-height: 1.4; - color: var(--text-primary); - white-space: normal; - overflow-wrap: anywhere; -} - -.cave-thread-map__card-rows { - display: flex; - flex-direction: column; - gap: var(--space-1); - padding-top: var(--space-1); - border-top: 1px solid var(--border-hairline); - font-family: var(--font-mono); - font-size: var(--text-2xs); - color: var(--text-muted); -} - -.cave-thread-map__card-row { - display: flex; - align-items: baseline; - gap: var(--space-2); -} - -.cave-thread-map__card-k { - width: 40px; - flex: none; -} - -.cave-thread-map__card-v { - flex: 1; - min-width: 0; - color: var(--text-secondary); - white-space: normal; -} - -.cave-thread-map__card-hint { - font-family: var(--font-mono); - font-size: var(--text-2xs); - letter-spacing: 0.08em; - text-transform: uppercase; - color: color-mix(in oklch, var(--text-muted) 60%, transparent); -} - -.cave-thread-map__foot { - display: flex; - align-items: center; - gap: var(--space-1); - flex: none; - height: 26px; - padding: 0 var(--space-1); - border-top: 1px solid var(--border-hairline); - font-family: var(--font-mono); - font-size: var(--text-2xs); - color: var(--text-muted); -} - -.cave-thread-map__step { - display: grid; - place-items: center; - width: 18px; - height: 18px; - border: none; - border-radius: var(--radius-pill); - background: transparent; - color: var(--text-muted); - cursor: pointer; -} - -.cave-thread-map__step:hover { - background: color-mix(in oklch, var(--foreground) 9%, transparent); - color: var(--text-primary); -} - -.cave-thread-map__pos { - flex: 1; - text-align: center; - font-variant-numeric: tabular-nums; -} - -/* The env HUD floats top-right in the same pane — when the map is up, nudge - the HUD left so the two never overlap (the design parks it at right:92px). */ -.cave-chat-transcript:has(.cave-thread-map) [data-chat-env-panel] { - padding-right: 92px; -}