diff --git a/AGENTS.md b/AGENTS.md index d608c6e50..3a451cc8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,3 +17,10 @@ A good comment answers questions like: Don't bother explaining *what* the code does — that's what reading the code is for. Explain *why it exists*, *why it's shaped this way*, and *what would make it wrong*. Make agentic development and decision-making **transparent in the diff**, not in a doc that drifts. + +## Directory conventions + +Two top-level folders look superficially similar but mean very different things. Do not mix them. + +- `vendor/` — read-only **source code references** kept locally so future-you can grep the original implementation of something we wrap or interoperate with. Never imported, never built, never shipped. Examples: `claude-code-src/`, vendored upstream checkouts for bug-bisection. +- `third_party/` — pinned **runtime artifacts** that we ship inside packaged Agent Code (or that the release pipeline fetches at build time). Each tool gets its own directory with a `manifest.json` (version + per-arch sha256 + URL template), `README.md`, `LICENSE.md`, and a `.gitignore` that keeps `cache/` and `build/` out of git. Binaries are NEVER committed — the manifest plus `scripts/runtime-tools/` is the source of truth, and version bumps are one-file PRs against `manifest.json`. Examples: `third_party/mitmproxy/`, `third_party/tmux/`. See issues #119 and #120 for the full bundling plan. diff --git a/CODEX.md b/CODEX.md deleted file mode 100644 index d608c6e50..000000000 --- a/CODEX.md +++ /dev/null @@ -1,19 +0,0 @@ -# Agent Code - -This codebase is written and developed by AI. Future you will pick up where past you left off, with no shared memory beyond what's in the repo. - -## Comment policy - -Write **thick WHY comments** liberally throughout the code. Be generous — over-comment, don't under-comment. The cost of an extra paragraph is nothing; the cost of future-you re-deriving a tricky decision from scratch is real. - -A good comment answers questions like: - -- *Why this approach and not the obvious alternative?* -- *What constraint forced this shape?* -- *What did we try first that didn't work?* -- *What invariant must hold here, and what breaks if it doesn't?* -- *What's the source of truth for this value?* - -Don't bother explaining *what* the code does — that's what reading the code is for. Explain *why it exists*, *why it's shaped this way*, and *what would make it wrong*. - -Make agentic development and decision-making **transparent in the diff**, not in a doc that drifts. diff --git a/MANIFESTO.md b/MANIFESTO.md index 154921fc5..89fd1973b 100644 --- a/MANIFESTO.md +++ b/MANIFESTO.md @@ -32,10 +32,6 @@ The whole app is built for efficient development. All terminals are tmux-persist ## Structure -One of the principles I understood early is that getting open-source contributors on board with something like this is hard. So Agent Code is dependent on 4 separate projects that it imports as packages, all more broadly scoped, which will hopefully attract more open-source attention. - -## Structure - One of the principles I understood early is that getting open-source contributors on board with something like this is hard. So Agent Code is dependent on 4 separate projects that it imports as packages — all more broadly scoped, which will hopefully attract more open-source attention. **1. Headless Claude Code and Codex** diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..4f731473a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,27 @@ +# Agent Code docs + +Layout and status conventions. Superseded/closed material moves to `archive/` +and carries a `Status:`/`ARCHIVED` header — a doc without one is meant to be +live. + +## Live + +- **`rendering/`** — the current rendering engine. `rendering-rewrite-plan-2026-07.md` + is canonical; `residue-plan-2026-07.md` and `session-recording-plan-2026-07.md` + are its live follow-ons; `legacy-deletion-manifest.md` is the cutover contract; + `rendering-knowledge-dump.md` is the evidence bible. `research-2026-07/` holds + the supporting research (rewrite research at the top level, the + session-recording research under `session-recording/`). +- **`design/`** — living subsystem design (conditions, ghost system). +- **`plans_and_ideas/`** — active/near-term plans and idea notes. +- **`superpowers/`** — dated planning space: `plans/` (feature/fix plans) and + `specs/` (design specs). Accumulates by date; a plan carries a `Status:` header + once it ships or is superseded. +- **`command-style.md`** — command-authoring conventions. +- **`screenshots/`** — README assets. + +## Archived (`archive/`) + +- **`codex-rewrite-render/`** — the pre-rewrite rendering notebook (superseded). +- **`issue-investigations/`** — investigations for issues now closed. +- **`audit-plans/`** — the deep-audit roadmap and its completed execution logs. diff --git a/docs/audit-plans/deep-audit-master-roadmap.md b/docs/archive/audit-plans/deep-audit-master-roadmap.md similarity index 100% rename from docs/audit-plans/deep-audit-master-roadmap.md rename to docs/archive/audit-plans/deep-audit-master-roadmap.md diff --git a/docs/audit-plans/execution/debug-dictation-release-implementation-log.md b/docs/archive/audit-plans/execution/debug-dictation-release-implementation-log.md similarity index 100% rename from docs/audit-plans/execution/debug-dictation-release-implementation-log.md rename to docs/archive/audit-plans/execution/debug-dictation-release-implementation-log.md diff --git a/docs/audit-plans/execution/dispatch-shell-cleanup-implementation-log.md b/docs/archive/audit-plans/execution/dispatch-shell-cleanup-implementation-log.md similarity index 100% rename from docs/audit-plans/execution/dispatch-shell-cleanup-implementation-log.md rename to docs/archive/audit-plans/execution/dispatch-shell-cleanup-implementation-log.md diff --git a/docs/audit-plans/execution/feed-conditions-weirdness-implementation-log.md b/docs/archive/audit-plans/execution/feed-conditions-weirdness-implementation-log.md similarity index 100% rename from docs/audit-plans/execution/feed-conditions-weirdness-implementation-log.md rename to docs/archive/audit-plans/execution/feed-conditions-weirdness-implementation-log.md diff --git a/docs/audit-plans/execution/ipc-shared-contracts-implementation-log.md b/docs/archive/audit-plans/execution/ipc-shared-contracts-implementation-log.md similarity index 100% rename from docs/audit-plans/execution/ipc-shared-contracts-implementation-log.md rename to docs/archive/audit-plans/execution/ipc-shared-contracts-implementation-log.md diff --git a/docs/audit-plans/execution/lifecycle-orchestration-runtime-implementation-log.md b/docs/archive/audit-plans/execution/lifecycle-orchestration-runtime-implementation-log.md similarity index 100% rename from docs/audit-plans/execution/lifecycle-orchestration-runtime-implementation-log.md rename to docs/archive/audit-plans/execution/lifecycle-orchestration-runtime-implementation-log.md diff --git a/docs/audit-plans/execution/provider-headless-boundaries-implementation-log.md b/docs/archive/audit-plans/execution/provider-headless-boundaries-implementation-log.md similarity index 100% rename from docs/audit-plans/execution/provider-headless-boundaries-implementation-log.md rename to docs/archive/audit-plans/execution/provider-headless-boundaries-implementation-log.md diff --git a/docs/codex-rewrite-render/README.md b/docs/archive/codex-rewrite-render/README.md similarity index 87% rename from docs/codex-rewrite-render/README.md rename to docs/archive/codex-rewrite-render/README.md index 6617d9b94..57e5aadb5 100644 --- a/docs/codex-rewrite-render/README.md +++ b/docs/archive/codex-rewrite-render/README.md @@ -1,3 +1,10 @@ +> **ARCHIVED / SUPERSEDED (2026-07-07).** This was the first-principles working +> notebook for the feed-rendering rewrite. It has been fully absorbed into the +> canonical plan `docs/rendering/rendering-rewrite-plan-2026-07.md` and its +> research digest `docs/rendering/research-2026-07/rewrite-notebook-extraction.md`. +> Kept for history; reads present-tense but describes the OLD `deriveFeedRenderModel` +> approach, which the ownership-ledger pipeline (`src/renderer/src/rendering/`) replaced. + # Codex Render Rewrite Notes This directory is the working notebook for rebuilding feed rendering from first principles while keeping live streaming. diff --git a/docs/codex-rewrite-render/feed-render-item-plan.md b/docs/archive/codex-rewrite-render/feed-render-item-plan.md similarity index 100% rename from docs/codex-rewrite-render/feed-render-item-plan.md rename to docs/archive/codex-rewrite-render/feed-render-item-plan.md diff --git a/docs/codex-rewrite-render/feed-ui-rendering.md b/docs/archive/codex-rewrite-render/feed-ui-rendering.md similarity index 100% rename from docs/codex-rewrite-render/feed-ui-rendering.md rename to docs/archive/codex-rewrite-render/feed-ui-rendering.md diff --git a/docs/codex-rewrite-render/first-principles-render-model.md b/docs/archive/codex-rewrite-render/first-principles-render-model.md similarity index 100% rename from docs/codex-rewrite-render/first-principles-render-model.md rename to docs/archive/codex-rewrite-render/first-principles-render-model.md diff --git a/docs/codex-rewrite-render/headless-channel-model.md b/docs/archive/codex-rewrite-render/headless-channel-model.md similarity index 100% rename from docs/codex-rewrite-render/headless-channel-model.md rename to docs/archive/codex-rewrite-render/headless-channel-model.md diff --git a/docs/codex-rewrite-render/renderer-runtime-ingestion.md b/docs/archive/codex-rewrite-render/renderer-runtime-ingestion.md similarity index 100% rename from docs/codex-rewrite-render/renderer-runtime-ingestion.md rename to docs/archive/codex-rewrite-render/renderer-runtime-ingestion.md diff --git a/docs/codex-rewrite-render/submit-queue-debug.md b/docs/archive/codex-rewrite-render/submit-queue-debug.md similarity index 100% rename from docs/codex-rewrite-render/submit-queue-debug.md rename to docs/archive/codex-rewrite-render/submit-queue-debug.md diff --git a/docs/codex-rewrite-render/upstream-codex-rendering.md b/docs/archive/codex-rewrite-render/upstream-codex-rendering.md similarity index 100% rename from docs/codex-rewrite-render/upstream-codex-rendering.md rename to docs/archive/codex-rewrite-render/upstream-codex-rendering.md diff --git a/docs/issue-investigations/147-multi-instance-policy.md b/docs/archive/issue-investigations/147-multi-instance-policy.md similarity index 99% rename from docs/issue-investigations/147-multi-instance-policy.md rename to docs/archive/issue-investigations/147-multi-instance-policy.md index 3bcf8335b..cd62b6c96 100644 --- a/docs/issue-investigations/147-multi-instance-policy.md +++ b/docs/archive/issue-investigations/147-multi-instance-policy.md @@ -1,3 +1,5 @@ +> **ARCHIVED (2026-07-07).** The investigated issue is closed; kept as history. + # Issue #147: Multiple Agent Code Instances ## Issue Summary diff --git a/docs/issue-investigations/152-dispatch-terminals.md b/docs/archive/issue-investigations/152-dispatch-terminals.md similarity index 99% rename from docs/issue-investigations/152-dispatch-terminals.md rename to docs/archive/issue-investigations/152-dispatch-terminals.md index ed6855b03..301d21990 100644 --- a/docs/issue-investigations/152-dispatch-terminals.md +++ b/docs/archive/issue-investigations/152-dispatch-terminals.md @@ -1,3 +1,5 @@ +> **ARCHIVED (2026-07-07).** The investigated issue is closed; kept as history. + # Issue 152: Add terminal sessions to Dispatch Mode ## Issue summary diff --git a/docs/issue-investigations/154-undo-close.md b/docs/archive/issue-investigations/154-undo-close.md similarity index 99% rename from docs/issue-investigations/154-undo-close.md rename to docs/archive/issue-investigations/154-undo-close.md index f01e2689a..70d86c292 100644 --- a/docs/issue-investigations/154-undo-close.md +++ b/docs/archive/issue-investigations/154-undo-close.md @@ -1,3 +1,5 @@ +> **ARCHIVED (2026-07-07).** The investigated issue is closed; kept as history. + # Issue 154: Extend Undo Close History and Retention ## Issue Summary diff --git a/docs/issue-investigations/180-181-rendered-link-activation.md b/docs/archive/issue-investigations/180-181-rendered-link-activation.md similarity index 99% rename from docs/issue-investigations/180-181-rendered-link-activation.md rename to docs/archive/issue-investigations/180-181-rendered-link-activation.md index 0ec1e4710..9cb1a2d32 100644 --- a/docs/issue-investigations/180-181-rendered-link-activation.md +++ b/docs/archive/issue-investigations/180-181-rendered-link-activation.md @@ -1,3 +1,5 @@ +> **ARCHIVED (2026-07-07).** The investigated issue is closed; kept as history. + # Issues #180 and #181: Rendered Link Activation ## Issue Summaries diff --git a/docs/rendering/research-2026-07/rec-research-precedent.md b/docs/rendering/research-2026-07/session-recording/rec-research-precedent.md similarity index 100% rename from docs/rendering/research-2026-07/rec-research-precedent.md rename to docs/rendering/research-2026-07/session-recording/rec-research-precedent.md diff --git a/docs/rendering/research-2026-07/rec-research-priorart.md b/docs/rendering/research-2026-07/session-recording/rec-research-priorart.md similarity index 100% rename from docs/rendering/research-2026-07/rec-research-priorart.md rename to docs/rendering/research-2026-07/session-recording/rec-research-priorart.md diff --git a/docs/rendering/research-2026-07/rec-research-seam.md b/docs/rendering/research-2026-07/session-recording/rec-research-seam.md similarity index 100% rename from docs/rendering/research-2026-07/rec-research-seam.md rename to docs/rendering/research-2026-07/session-recording/rec-research-seam.md diff --git a/docs/rendering/research-2026-07/rec-research-tap.md b/docs/rendering/research-2026-07/session-recording/rec-research-tap.md similarity index 100% rename from docs/rendering/research-2026-07/rec-research-tap.md rename to docs/rendering/research-2026-07/session-recording/rec-research-tap.md diff --git a/docs/rendering/session-recording-plan-2026-07.md b/docs/rendering/session-recording-plan-2026-07.md index 7225a8487..bba2a6e17 100644 --- a/docs/rendering/session-recording-plan-2026-07.md +++ b/docs/rendering/session-recording-plan-2026-07.md @@ -5,7 +5,7 @@ stream**, replayable deterministically through the real pipeline in the test suite. GitHub issue: #467. This plan is the synthesis of four research sweeps (full reports in -`research-2026-07/rec-research-{seam,precedent,tap,priorart}.md`): the +`research-2026-07/session-recording/rec-research-{seam,precedent,tap,priorart}.md`): the replay seam, the existing corpus/journal machinery to reuse, the IPC tap point, and prior art + the OOM constraints a continuous recorder must respect. @@ -222,7 +222,7 @@ one file per session per app-run. `0o600`. - `ch` is one of the 9 allowlisted channels. - `payload` is the channel's payload **verbatim** (post-coalesce for jsonl-entries). Full table of the 9 channel shapes: - `research-2026-07/rec-research-tap.md`. + `research-2026-07/session-recording/rec-research-tap.md`. - `t` (monotonic) drives replay ordering; `wall` is injected as the fold's clock during replay so wall-clock-dependent fold behavior (the orphan sweep, `unreadSince`/phase stamps) is deterministic. The ledger itself is diff --git a/issues.md b/issues.md deleted file mode 100644 index 01a37c2e9..000000000 --- a/issues.md +++ /dev/null @@ -1,112 +0,0 @@ -# Reporting Issues - -This codebase is worked on primarily by AI agents. This guide is for -them. - -**The more technical detail, the better.** File paths with line numbers, -exact symbols, transcript IDs, commit SHAs, error output verbatim, -stack traces, environment variables — over-share rather than -under-share. Every fact you omit is a follow-up round trip the next -person has to do before they can reproduce. - -## Which repo - -| Repo | What goes here | -|---|---| -| **agent-code** | UI, tile-tree, dispatch, session management, IPC, system-perf, settings — all renderer + main-process behaviour. Default. | -| **claude-code-headless** | Driving the real `claude` CLI: TUI parsing, JSONL handling, proxy/SSE streaming, slash-picker, permission prompts. | -| **codex-headless** | Driving the real `codex` CLI: rollout parsing, Responses adapter, screen parsing, approval overlays. | -| **agent-transcript-parser** | Claude↔Codex transcript translation, ghost primitives, rewind/clone helpers. | -| **agent-voice-dictation** | Recording, STT clients, OpenRouter polish, composer integration. | - -## Bug report - -```markdown -### What happened -One or two sentences. The visible symptom. - -### Repro -Exact steps. If non-deterministic, say so explicitly and describe how -often / under what conditions: "happens after ~30 min of normal use -with 10+ agents active." Link the relevant transcript path — -`~/.claude/projects/-Users-foo-bar/.jsonl` for Claude or -`~/.codex/sessions/YYYY/MM/DD/rollout--.jsonl` for -Codex. Include the commit SHA you reproduced on. - -### Where -File:line and the function or symbol if you've located it. Examples: -`src/main/sessionManager.ts:912 spawnClaude`, -`src/renderer/src/features/feed/ui/Feed.tsx onScroll`, -`packages/claude-code-headless/src/proxy/ClaudeProxyAdapter.ts:804`. -If unlocated, name the subsystem: feed renderer, JSONL tailer, proxy -adapter, dispatch mode, work-context, image paste, dictation. - -### Output -Error text, stack trace, log lines — verbatim, don't paraphrase or -truncate. Include surrounding console lines if a single line was the -trigger but the context is in the lines around it. Native frames -(C++/Rust) in a v8 fatal-error trace are important — keep them. - -### State -Anything that affects reproducibility: number of active panes, which -providers (Claude / Codex / terminal), how long the app had been -running, whether `AGENT_CODE_PERF` was set, whether you were in -Dispatch Mode, whether worktrees were attached. -``` - -## Feature request - -```markdown -### Use case -The workflow the current app makes harder than it should be. - -### Example -One concrete scenario, end to end — what you'd do today vs. what you -wish you could do. - -### (Optional) Sketch -Implementation thoughts. Skip unless you have specifics. -``` - -## Performance / crash - -Different evidence than functional bugs. Attach what applies: - -- **Fatal-error block from the terminal.** When the main process - OOM's, the terminal where `npm start` ran will have a `FATAL - ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out - of memory` line followed by a `Native stack trace` block. Include - the whole block, not just the headline — the native frames identify - the allocator that broke (typical signatures: `node::Buffer::New`, - `v8::CppHeap::wrapper_descriptor`, Rust `font_types::...`). -- **System-perf popover state at the time.** Requires - `AGENT_CODE_PERF=1` set when launching. Click the heap badge in the - header. Report: heap used / limit, RSS, top heap spaces with sizes - (especially `old_space` and `large_object_space`), - detached-contexts count, native-contexts count, event-loop p99, - 60s growth Δ for heap and RSS. -- **Heap snapshot.** Manual: click "Capture heap snapshot" in the - popover; writes to `~/.config/agent-code/heap-snapshots/manual--.heapsnapshot`. - Automatic: the watchdog writes one when `used_heap_size` crosses - `min(3 GiB, 0.75 × heap_size_limit)` — same directory, `main-` prefix. - These files are 10–500 MB; link a cloud share rather than attaching - directly if large. -- **Growth pattern.** Three shapes matter: - - steady monotonic climb → retention leak - - spike-up-then-drop-to-zero cycle → transient large allocations - (whole-file reads, big buffers, GC churn) - - sudden jump → one-shot allocation (giant string, image, file load) - - Saying which shape you observed narrows the suspect list before - anyone opens the snapshot. - -## Security - -Disclose privately to the maintainer rather than via a public GitHub -issue. - -## Don't file - -- How-to questions → Discussions. -- Upstream CLI bugs (`claude` / `codex` flag behaviour) → the upstream - repos for those tools, not here. diff --git a/testing/regression-claude-tool-result.ts b/testing/regression-claude-tool-result.ts deleted file mode 100644 index af1012a35..000000000 --- a/testing/regression-claude-tool-result.ts +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env tsx -import assert from 'node:assert/strict' - -import type { SemanticRuntimeState } from '@renderer/workspace/workspaceState.ts' - -function baseState(): SemanticRuntimeState { - const state: SemanticRuntimeState = { - currentTurn: null, - history: [], - flows: {}, - errors: [], - log: [], - nextLogId: 1, - } - state.currentTurn = { - turnId: 'msg_aaa', - text: 'partial', - source: 'proxy', - blocks: { - 0: { - blockIndex: 0, - kind: 'tool_use', - toolName: 'Bash', - toolUseId: 'toolu_xyz', - text: '', - thinking: '', - inputJson: '{}', - }, - }, - blockOrder: [0], - stopReason: 'tool_use', - usage: null, - task: { - todos: [], - doneCount: 0, - totalCount: 0, - inProgressToolUseIds: ['toolu_xyz'], - activeToolNames: ['Bash'], - }, - lookups: { - toolCallsById: { - toolu_xyz: { - toolUseId: 'toolu_xyz', - blockIndex: 0, - kind: 'tool_use', - toolName: 'Bash', - status: 'in_progress', - inputJson: '{}', - resultContent: null, - }, - }, - toolUseIdsInOrder: ['toolu_xyz'], - resolvedToolUseIds: [], - erroredToolUseIds: [], - }, - startedAt: 1, - endedAt: 2, - } - return state -} - -async function main(): Promise { - const documentElement = { - dataset: {} as Record, - style: { setProperty() {} }, - } - Object.assign(globalThis, { - document: { documentElement }, - CustomEvent: class CustomEvent { - constructor( - public type: string, - public init?: { detail?: T }, - ) {} - }, - localStorage: { - getItem() { return null }, - setItem() {}, - removeItem() {}, - }, - window: { - dispatchEvent() { return true }, - addEventListener() {}, - removeEventListener() {}, - api: {}, - }, - }) - const { foldSemanticEvent } = await import('@renderer/workspace/workspaceStore.ts') - - const next = foldSemanticEvent( - baseState(), - { - type: 'tool_result', - toolUseId: 'toolu_xyz', - content: 'ok', - isError: false, - source: 'jsonl', - confidence: 'high', - ts: Date.now(), - }, - 'claude', - ) - - assert.equal(next.currentTurn, null) - assert.equal(next.history.length, 1) - const archived = next.history[0] - assert.equal(archived.turnId, 'msg_aaa') - assert.match(archived.text, /partial/) - - console.log('ok - claude tool_result without turnId still resolves by toolUseId') -} - -main().catch(err => { - console.error(err) - process.exit(1) -}) diff --git a/testing/rendering/README.md b/testing/rendering/README.md deleted file mode 100644 index 7d12e4283..000000000 --- a/testing/rendering/README.md +++ /dev/null @@ -1,175 +0,0 @@ -# Agent Code rendering harness - -Standalone Electron app for **working on the rendering engine in -isolation**. The harness reproduces Agent Code's full rendering pipeline -— PTY → JSONL → proxy SSE → mapper → Feed component tree — against -real, live agent sessions, with every layer exposed side by side so -visual regressions can be traced to the layer that broke. - -If you are debugging or extending **only** the rendering layer, work -here. Edits to vendored render components in this folder do **not** -touch Agent Code. Edits to data-plumbing modules under `src/` (parsers, -transcript types, workspaceStore mappers, settings store) DO affect -Agent Code — by design, so a regression in one app reproduces in the -other. - -No tabs. No command palette. No workspace. One session, one window, -every stream visible at once. - ---- - -## Isolation model - -| Layer | Location | Shared with Agent Code? | -|--------------------------------------|----------------------------------------------------------|------------------------| -| Render UI (Feed, rows, code, git) | `testing/rendering/renderer/components/` | **No** — vendored copy | -| Harness shell + debug panels | `testing/rendering/renderer/RenderingHarnessApp.tsx` | No | -| Main process (sessions/IPC) | `testing/rendering/main.ts` | No | -| Preload bridge | `src/preload/index.ts` | Yes (reused) | -| Transcript types | `src/shared/types/transcript.ts` + provider types | Yes | -| Parsers (extract / screen / git) | `src/shared/parsers/`, `src/shared/git/` | Yes | -| Workspace mappers + semantic fold | `src/renderer/src/workspace/workspaceStore.ts` (pure exports)| Yes | -| Settings store + theme | `src/renderer/src/state/` | Yes | -| Headless PTY runtimes / providers | `src/providers/`, `claude-code-headless`, `codex-headless` | Yes | - -The vendored render layer is a **one-time copy** with manual sync. -When Agent Code ships a real Feed/row fix, port it into the vendored -copy by hand. When you want to experiment with rendering only here, -edit only the files under `testing/rendering/renderer/components/` -and Agent Code is untouched. - -## Run - -```bash -npm run testing:rendering # build + launch -npm run testing:rendering:build # build only -npm run testing:rendering:preview # launch from existing build -``` - -Output lives under `testing/rendering/out/` — separate from Agent Code's -`out/`, so the two builds never clobber each other. - -## What you see - -**Phase 1 — session picker.** Scans `~/.claude/projects/**/*.jsonl` -and `~/.codex/sessions/**` directly. Every past session, newest first, -filterable. Click one to resume. - -**Phase 2 — debug split view.** Top bar shows provider, resumed -session id, cwd, layer counts, isLive gate, current turn id, baseline -length, close button. - -Left column — the real rendered Feed plus a composer that pipes input -directly into the agent's TUI. - -Right column, stacked: -- **Raw terminal** — plain TUI screen snapshot + collapsed markdown - reconstruction. Upstream of every parser. -- **Raw JSONL** — every entry as it lands on disk, newest first, - click to expand. Upstream of the feed mapper. -- **Semantic events** — per-block events from the Claude proxy - adapter / Codex Responses adapter (or screen-fallback deltas). -- **FAT debug stream** — unified per-layer log: - - `JSONL` — each raw entry's shape - - `MAP` — mapper kept/dropped per entry, with reason - - `SEM` — every semantic event (per-token deltas rolled up) - - `STATE` — spawn / exit / process state / history loads / baseline capture - - `RENDER` — for each appended feed entry, the exact dispatcher path - each block will take (`EditRow`, `ToolUseRow(Bash)`, `TextProse(N)`, …) - - Filter chips toggle each layer. Copy buttons (`copy 50`, `copy 200`, - `copy all`) snapshot the visible view. - -## Bug-fix registry - -Tickets here track **Agent Code rendering bugs** that the harness -helped us reproduce or fix. Harness setup mistakes (wrong CSS, hook -order, etc.) are NOT in this list — see "Harness architecture notes" -below for those. - -Every ticket is referenced from a thick WHY-comment at the patch -site so future-you can jump from code to context and back. - -| Ticket | Bug in Agent Code rendering | Harness patch site | Agent Code status | -|----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------| -| REND-1 | Bootstrap race: bulk `session:jsonl-entries` burst (~200 entries) fires inside `await spawnSession()`. Any consumer that subscribes AFTER spawn returns drops the entire bootstrap. Visually: only the very last message appears. | `RenderingHarnessApp.tsx` — subscriptions hoisted to App level, refs reset before `spawnSession` | Fixed: `workspaceStore` subscribes at module mount, before any spawn. | -| REND-2 | "Double-rendered last message" on resume. The screen still contains the last assistant message, so `` extracts and renders it AT THE SAME TIME the JSONL bootstrap renders it as an ``. Both visible. | `RenderingHarnessApp.tsx` — `isLive` gate on `streamingScreen` prop (mirror of `TileLeaf.isSessionLive`) | Fixed: `TileLeaf.isSessionLive` zeroes `streamingScreen` when no turn is in flight. | -| REND-3 | After submitting a prompt, the previous assistant text rendered below the user message until the new turn's first text_delta. Two root causes: (a) no `streamingBaseline` captured at submit, (b) proxy semantic events were never folded into a `SemanticLiveTurn` so Feed always fell back to `` (screen scrape) instead of `` (parsed events). | `RenderingHarnessApp.tsx` — `foldSemanticEvent` fold + `onBeforeSubmit` baseline capture | **PARTIAL.** Agent Code captures the baseline (`TileLeaf.tsx` onSubmit) and folds events, but `Feed.tsx isStaleStreamingExtract` early-returns false for Claude — only Codex gets the prefix-containment heuristic. Claude can still flicker the previous turn's text during the submit→first-delta gap. | - -### Adding a new ticket - -1. Pick the next `REND-N`. -2. Add a row to the table above (one-line summary + harness patch site - + Agent Code status: fixed / partial / not fixed). -3. At the harness patch site, write a thick WHY-comment that **starts** - with the ticket id, e.g. - ``` - // REND-4 — older-history prepend dropped compact_summary entries. - // - // Reason: the prepend loop only ran the conversation/compact filter - // once, but compact_summary is wrapped in a `progress` envelope that - // had to be unwrapped first. Mirror of workspaceStore's loadOlder - // path in Agent Code. - ``` -4. Commit with the ticket id in the subject: `REND-4: …`. - -## Harness architecture notes - -Items below are **NOT** Agent Code bugs — they are harness setup -decisions / first-cut mistakes documented here so future maintainers -don't re-litigate them. None of these have a ticket id; they're -just constraints of the harness itself. - -- **Dedicated main process** (`testing/rendering/main.ts`): the - harness does NOT reuse `src/main/index.ts`. That entrypoint boots - tmux detection, workspace persistence, switch-provider, LSP, fs - helpers — all log noise and timing variance that hides real - rendering bugs. The harness main only wires the session - IPC channels its renderer talks to and stubs the rest. -- **Subscribe-before-spawn**: REND-1 in the registry. Hooks here - for cross-reference. -- **flex-row split, not grid-cols**: a single-row CSS grid resolves - row height to `auto`, which collapses Feed's `h-full overflow-auto`. - The split between rendered feed and debug panels uses `flex` for - this reason. -- **Hook order**: every hook above any conditional `return`. Easy - to violate when refactoring; React error #310 is unhelpful. -- **`process` global is not available** in the renderer (Electron - contextIsolation). Use constants for fallback paths. -- **Vendored UI render layer**: `Feed.tsx`, row dispatchers, - `CodeBlock`, `GitRows`, `monacoRuntime` are vendored at - `renderer/components/`. Edits stay in the harness; data plumbing - is shared. Bottom padding inside the vendored Feed (`pb-24`) is - larger than Agent Code's (`pb-8`) because the harness has a tighter - pane. -- **FAT debug stream**: per-layer debug log - (`JSONL` / `MAP` / `SEM` / `STATE` / `RENDER`) so a regression can - be traced to the layer that broke. - -## Adding a new debug-stream layer - -`DebugLayer` is a discriminated union in `RenderingHarnessApp.tsx`. -To add e.g. a `PTY` layer: - -1. Append `'PTY'` to the `DebugLayer` union. -2. Add a color in `LAYER_TEXT` and `LAYER_BORDER`. -3. Push events from the relevant subscription handler: - ``` - pushDebug({ layer: 'PTY', kind: 'data', summary: `${bytes} bytes` }) - ``` -4. The filter chip and copy buttons pick it up automatically. - -## Required Agent Code additions - -Some additions in `src/` exist purely to support this harness. They -are pure additions — no existing Agent Code behavior changed. - -- `session:list-all` IPC handler in `src/main/index.ts` -- `listAllSessions` API in `src/preload/index.ts` -- `listAllClaudeSessions` exported from `src/providers/claude/runtime/sessionList.ts` -- `mapCodexRolloutToFeedEntries`, `extractEmbeddedClaudeProgressEntry`, `claudeHistoryMarker`, `codexHistoryMarker`, `foldSemanticEvent` exported from `src/renderer/src/workspace/workspaceStore.ts` - -If the harness build complains about a missing export, that export -needs to be added to Agent Code — never duplicate the implementation -into the harness; the whole point is that the data layer stays -shared. diff --git a/testing/rendering/electron.vite.config.ts b/testing/rendering/electron.vite.config.ts deleted file mode 100644 index 66e519c8a..000000000 --- a/testing/rendering/electron.vite.config.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { defineConfig, externalizeDepsPlugin } from 'electron-vite' -import react from '@vitejs/plugin-react' -import tailwindcss from '@tailwindcss/vite' -import { resolve } from 'path' - -const repoRoot = resolve(__dirname, '../..') - -// WHY `packages/*` and not a bare root path: the headless workspaces live under -// `packages/` (see the repo `packages/` dir + tsconfig `paths`). An earlier -// version of this config aliased them to `repoRoot/claude-code-headless/...`, -// directories that do not exist in this checkout — so `testing:rendering:build` -// could not resolve the package imports at all. These must stay byte-for-byte -// in step with the `paths` maps in tsconfig.node.json / tsconfig.web.json and -// the `resolve.alias` map in the app's electron.vite.config.ts; if they drift, -// the harness type-checks/bundles against a module graph the app cannot load. -const headlessAlias = { - 'claude-code-headless': resolve(repoRoot, 'packages/claude-code-headless/src/index.ts'), - 'codex-headless': resolve(repoRoot, 'packages/codex-headless/src/index.ts'), - 'agent-transcript-parser': resolve(repoRoot, 'packages/agent-transcript-parser/src/index.ts'), - '@renderer': resolve(repoRoot, 'src/renderer/src'), - '@shared': resolve(repoRoot, 'src/shared'), - '@providers': resolve(repoRoot, 'src/providers'), -} - -const headlessExclude = ['claude-code-headless', 'codex-headless', 'agent-transcript-parser'] - -export default defineConfig({ - main: { - plugins: [externalizeDepsPlugin({ exclude: headlessExclude })], - resolve: { alias: headlessAlias }, - build: { - outDir: resolve(__dirname, 'out/main'), - rollupOptions: { - // Dedicated harness main — no tmux, no workspace, no LSP, no - // switch-provider. See testing/rendering/main.ts for the WHY. - input: resolve(__dirname, 'main.ts'), - }, - }, - }, - preload: { - plugins: [externalizeDepsPlugin({ exclude: headlessExclude })], - resolve: { alias: headlessAlias }, - build: { - outDir: resolve(__dirname, 'out/preload'), - rollupOptions: { - input: resolve(repoRoot, 'src/preload/index.ts'), - }, - }, - }, - renderer: { - root: resolve(__dirname, 'renderer'), - resolve: { alias: headlessAlias }, - server: { - fs: { - allow: [repoRoot], - }, - }, - build: { - outDir: resolve(__dirname, 'out/renderer'), - rollupOptions: { - input: resolve(__dirname, 'renderer/index.html'), - }, - }, - plugins: [react(), tailwindcss()], - optimizeDeps: { - include: ['monaco-editor'], - }, - }, -}) diff --git a/testing/rendering/main.ts b/testing/rendering/main.ts deleted file mode 100644 index adb8a4f88..000000000 --- a/testing/rendering/main.ts +++ /dev/null @@ -1,391 +0,0 @@ -// Rendering-debug harness — dedicated Electron main process. -// -// Setup constraint (NOT an Agent Code bug; see README → "Harness -// architecture notes"). The harness does NOT load `src/main/index.ts` -// because that entrypoint boots tmux detection (noisy startup logs: -// "[tmux] available", "[tmux] recovery: 2 recoverable"), workspace -// persistence, switch-provider, LSP, fs helpers, git status — all -// log noise + timing variance that obscures real rendering bugs. -// -// This file is the minimum surface the harness renderer talks to: -// SessionManager + the IPC channels it actually invokes. Everything -// else is stubbed in registerIpc so the unmodified preload bridge -// keeps working without rejecting calls. -// -// What this main DOES need (the rendering pipeline): -// 1. Spawn an agent session (Claude/Codex) via SessionManager. -// 2. Force useProxy=true on Claude so the proxy SSE adapter -// drives semantic events. The whole point of this harness is -// to compare proxy/screen/jsonl layers side by side; without -// proxy the semantic panel only ever shows screen-fallback. -// 3. Forward exactly the IPC channels the harness renderer + the -// shared Feed call into. Stub the rest as no-ops so the shared -// preload bridge keeps working. -// 4. Coalesce jsonl-entry bursts into session:jsonl-entries — same -// reason main does it: bootstrapTail fires ~200 entries -// synchronously and forwarding each as a separate IPC message -// makes the renderer pay 200 React renders for what should be -// one append. -// -// What we explicitly stub: -// - tmux registry — terminals are out of scope -// - workspace persistence — the harness doesn't keep state -// - lsp — code blocks degrade to static highlight -// - switch-provider — out of scope -// - git, fs, image save — not used by Feed in this harness -// -// Stubs return shape-compatible no-ops so the unmodified preload -// bridge never rejects a call and the renderer doesn't see -// "no handler registered" errors. - -import { app, BrowserWindow, ipcMain } from 'electron' -import { dirname, join } from 'path' -import { fileURLToPath } from 'url' -import { homedir } from 'os' -import { readdir, readFile, stat } from 'fs/promises' - -import { SessionManager } from '@main/sessionManager.js' -import { getMainProvider } from '@providers/registry.main.js' -import type { AgentProviderKind } from '@shared/types/providerKind.js' -import type { SessionInfo } from '@shared/types/session.js' - -type AgentKind = 'claude' | 'codex' - -const __dirname = dirname(fileURLToPath(import.meta.url)) - -let mainWindow: BrowserWindow | null = null - -// SessionManager constructed with no tmux registry — the harness -// never spawns terminal sessions, so detection would just print log -// lines for nothing. This keeps the harness boot quiet. -const manager: SessionManager = new SessionManager(null) - -// --- IPC sender ---------------------------------------------------- - -function send(channel: string, ...args: unknown[]): void { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(channel, ...args) - } -} - -// --- jsonl-entries coalescer (mirrored from src/main/index.ts) ----- -// -// WHY: bootstrapTail synchronously emits ~200 jsonl-entry events on -// resume. One IPC send per event = 200 renderer renders. Coalesce -// each setImmediate tick into a single bulk delivery. Live single -// entries become 1-element bulk arrays — same channel, same shape. - -type PendingJsonlBuffer = { - entries: Array<{ entry: Record; file: string }> - flushScheduled: boolean -} - -const jsonlPending = new Map() - -function flushJsonlFor(sessionId: string): void { - const pending = jsonlPending.get(sessionId) - if (!pending) return - pending.flushScheduled = false - if (pending.entries.length === 0) return - const payload = { sessionId, entries: pending.entries } - pending.entries = [] - send('session:jsonl-entries', payload) -} - -function enqueueJsonl( - sessionId: string, - entry: Record, - file: string, -): void { - let pending = jsonlPending.get(sessionId) - if (!pending) { - pending = { entries: [], flushScheduled: false } - jsonlPending.set(sessionId, pending) - } - pending.entries.push({ entry, file }) - if (!pending.flushScheduled) { - pending.flushScheduled = true - setImmediate(() => flushJsonlFor(sessionId)) - } -} - -// --- Manager → IPC wiring ------------------------------------------ - -function wireManager(): void { - manager.on('started', p => send('session:started', p)) - manager.on('screen', p => send('session:screen', p)) - manager.on('jsonl-entry', p => enqueueJsonl(p.sessionId, p.entry as Record, p.file)) - manager.on('jsonl-error', ({ sessionId, error }) => - send('session:jsonl-error', { sessionId, message: String(error.message ?? error) }), - ) - manager.on('process-state', p => send('session:process-state', p)) - // Legacy per-condition channels removed in lockstep with the preload - // listeners + production forwarder — the harness renderer never subscribed - // to them. (Forwarding the unified `session:conditions` snapshot into the - // harness is a separate improvement owned by the conditions-framework - // cluster's `condition-harness-support`.) - manager.on('semantic-event', p => send('session:semantic-event', p)) - manager.on('exit', p => { - flushJsonlFor(p.sessionId) - jsonlPending.delete(p.sessionId) - send('session:exit', p) - }) -} - -// --- Older-history loader (ported from src/main/index.ts) ---------- -// -// Walks the on-disk JSONL backwards from a known marker so the -// renderer can prepend earlier history when the user scrolls up. - -async function findCodexRolloutPathByThreadId( - sessionsDir: string, - threadId: string, -): Promise { - try { - const years = await readdir(sessionsDir) - for (const year of years.sort().reverse()) { - const yearDir = join(sessionsDir, year) - if (!(await stat(yearDir).catch(() => null))?.isDirectory()) continue - const months = await readdir(yearDir) - for (const month of months.sort().reverse()) { - const monthDir = join(yearDir, month) - if (!(await stat(monthDir).catch(() => null))?.isDirectory()) continue - const days = await readdir(monthDir) - for (const day of days.sort().reverse()) { - const dayDir = join(monthDir, day) - if (!(await stat(dayDir).catch(() => null))?.isDirectory()) continue - const files = await readdir(dayDir) - const match = files.find(f => f.includes(threadId) && f.endsWith('.jsonl')) - if (match) return join(dayDir, match) - } - } - } - } catch { - return null - } - return null -} - -function extractClaudeMarker(entry: Record): string | null { - if (typeof entry.uuid === 'string' && entry.uuid.length > 0) return entry.uuid - if (entry.type !== 'progress') return null - const data = entry.data as Record | undefined - const embedded = data?.message as Record | undefined - return typeof embedded?.uuid === 'string' && embedded.uuid.length > 0 ? embedded.uuid : null -} - -function extractCodexMarker(entry: Record): string { - const payload = entry.payload as Record | undefined - return `${String(entry.timestamp ?? '')}:${String( - payload?.id ?? payload?.call_id ?? payload?.type ?? entry.type, - )}` -} - -async function loadOlderHistoryChunk(params: { - kind: AgentKind - cwd: string - providerSessionId: string - beforeMarker: string - limit: number -}): Promise<{ entries: Record[]; hasMore: boolean }> { - const provider = getMainProvider(params.kind) - let filePath: string | null = null - - if (params.kind === 'claude') { - const projectDir = await provider.getProjectDir(params.cwd) - filePath = join(projectDir, `${params.providerSessionId}.jsonl`) - } else { - const sessionsDir = await provider.getProjectDir(params.cwd) - filePath = await findCodexRolloutPathByThreadId(sessionsDir, params.providerSessionId) - } - - if (!filePath) return { entries: [], hasMore: false } - const text = await readFile(filePath, 'utf8').catch(() => null) - if (!text) return { entries: [], hasMore: false } - - const parsed = text - .split('\n') - .map(l => l.trim()) - .filter(l => l.length > 0) - .map(l => { - try { - return JSON.parse(l) as Record - } catch { - return null - } - }) - .filter((e): e is Record => e !== null) - - const markerOf = params.kind === 'claude' ? extractClaudeMarker : extractCodexMarker - const anchorIndex = parsed.findIndex(e => markerOf(e) === params.beforeMarker) - const cutoff = anchorIndex === -1 ? parsed.length : anchorIndex - const older = parsed.slice(0, cutoff) - if (older.length === 0) return { entries: [], hasMore: false } - const start = Math.max(0, older.length - params.limit) - return { entries: older.slice(start), hasMore: start > 0 } -} - -// --- IPC handlers -------------------------------------------------- - -function registerIpc(): void { - // Real handlers — what the rendering pipeline calls. - - ipcMain.handle( - 'session:spawn', - async ( - _evt, - options: { - kind?: AgentKind | 'terminal' - cwd: string - cols?: number - rows?: number - resumeSessionId?: string - dangerousMode?: boolean - useProxy?: boolean - }, - ) => { - const kind: AgentKind = options.kind === 'codex' ? 'codex' : 'claude' - // Force proxy on by default. The whole purpose of the harness is - // to surface the proxy SSE pipeline so we can compare it against - // jsonl + screen. The caller can still pass useProxy:false to - // explicitly opt out (useful when bisecting whether a bug is in - // the proxy adapter vs the screen path). - const useProxy = options.useProxy ?? true - return await manager.spawn({ - kind, - cwd: options.cwd, - cols: options.cols ?? 160, - rows: options.rows ?? 48, - resumeSessionId: options.resumeSessionId, - dangerousMode: options.dangerousMode, - useProxy, - }) - }, - ) - - ipcMain.handle('session:kill', async (_evt, sessionId: string) => { - return await manager.kill(sessionId) - }) - - ipcMain.handle('session:input', (_evt, sessionId: string, data: string) => { - return manager.write(sessionId, data) - }) - - ipcMain.handle( - 'session:resize', - (_evt, sessionId: string, cols: number, rows: number) => { - manager.resize(sessionId, cols, rows) - }, - ) - - ipcMain.handle('session:list-all', async (_evt, limit?: number) => { - const cap = typeof limit === 'number' && limit > 0 ? limit : 200 - const providers: AgentProviderKind[] = ['claude', 'codex'] - const lists = await Promise.all(providers.map(async provider => { - const listAllSessions = getMainProvider(provider).listAllSessions - const sessions = listAllSessions - ? await listAllSessions(cap).catch(() => [] as SessionInfo[]) - : [] - return sessions.map(session => ({ ...session, provider })) - })) - const tagged = lists.flat() - tagged.sort((a, b) => b.lastModified - a.lastModified) - return tagged.slice(0, cap) - }) - - ipcMain.handle( - 'session:load-older-history', - async ( - _evt, - params: { - kind: AgentKind - cwd: string - providerSessionId: string - beforeMarker: string - limit?: number - }, - ) => loadOlderHistoryChunk({ ...params, limit: params.limit ?? 200 }), - ) - - // Stubs — required so the unmodified preload bridge doesn't reject. - // Each returns the most-benign shape its caller expects. - - ipcMain.handle('session:list-for-cwd', async () => []) - ipcMain.handle('session:switch-provider', async () => { - throw new Error('switch-provider is disabled in the rendering harness') - }) - ipcMain.handle('session:terminal-attach', () => '') - - ipcMain.handle('lsp:ensure-legend', async () => null) - ipcMain.handle('lsp:open-document', async () => undefined) - ipcMain.handle('lsp:change-document', async () => undefined) - ipcMain.handle('lsp:close-document', async () => undefined) - ipcMain.handle('lsp:get-semantic-tokens', async () => null) - - ipcMain.handle('workspace:load', async () => null) - ipcMain.handle('workspace:save', async () => undefined) - ipcMain.handle('workspace:defaultCwd', () => homedir()) - - ipcMain.handle('fs:expandCwd', async (_evt, raw: string) => { - if (typeof raw !== 'string' || raw.length === 0) { - return { ok: false, error: 'empty path' } - } - const expanded = raw.startsWith('~') ? join(homedir(), raw.slice(1)) : raw - return { ok: true, path: expanded } - }) - - ipcMain.handle('fs:listDirectory', async () => ({ - ok: true, - entries: [], - expanded: '', - })) - - ipcMain.handle('fs:saveClaudeImage', async () => ({ path: '' })) - - ipcMain.handle('git:status', async () => ({ ok: false })) -} - -// --- Window -------------------------------------------------------- - -function createWindow(): void { - mainWindow = new BrowserWindow({ - width: 1480, - height: 980, - minWidth: 1200, - minHeight: 760, - backgroundColor: '#111111', - title: 'Agent Code rendering debug', - autoHideMenuBar: true, - webPreferences: { - // Reuse the existing preload bridge so the renderer's - // `window.api` shape matches the main app exactly. All channels - // it calls are either real handlers above or stubs. - preload: join(__dirname, '@preload/index.mjs'), - sandbox: false, - contextIsolation: true, - nodeIntegration: false, - }, - }) - - if (process.env['ELECTRON_RENDERER_URL']) { - void mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) - } else { - void mainWindow.loadFile(join(__dirname, '../renderer/index.html')) - } -} - -// --- App lifecycle ------------------------------------------------- - -app.whenReady().then(() => { - wireManager() - registerIpc() - createWindow() - - app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) createWindow() - }) -}) - -app.on('window-all-closed', () => { - if (process.platform !== 'darwin') app.quit() -}) diff --git a/testing/rendering/renderer/RenderingHarnessApp.tsx b/testing/rendering/renderer/RenderingHarnessApp.tsx deleted file mode 100644 index a0854459b..000000000 --- a/testing/rendering/renderer/RenderingHarnessApp.tsx +++ /dev/null @@ -1,1664 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' - -// Vendored Feed — see testing/rendering/renderer/components/. -// The harness owns its own copy of every renderer component so -// padding / margin / row tweaks here can't bleed into Agent Code. -// Data plumbing (transcript types, parsers, mappers, settings) is -// still imported from src/ — those are stable and the harness wants -// to reproduce Agent Code behavior, not diverge from it. -import { Feed } from './components/Feed' -import { - claudeHistoryMarker, - codexHistoryMarker, - extractEmbeddedClaudeProgressEntry, - foldSemanticEvent, - mapCodexRolloutToFeedEntries, -} from '@renderer/workspace/workspaceStore' -import { - emptySemanticRuntime, - type SemanticLiveTurn, - type SemanticRuntimeState, -} from '@renderer/workspace/workspaceState' -import { - isCompactBoundaryEntry, - isCompactSummaryEntry, - isConversationEntry, - type Entry, - type ToolResultBlock, - type ToolUseBlock, -} from '@shared/types/transcript' -import { extractAssistantInProgress } from '@shared/parsers/extractAssistant' -import type { SessionInfo } from '@shared/types/session' -import type { AgentProviderKind } from '@shared/types/providerKind' -import { applyTheme } from '@renderer/app-state/settings/theme' -import { DEFAULT_SETTINGS } from '@renderer/app-state/settings/types' - -// Rendering Debug Harness. -// -// One spawned session at a time, every layer of the rendering pipeline -// visible side by side: rendered Feed | raw terminal | raw jsonl | -// semantic events. The harness mirrors workspaceStore's entry-ingest -// logic so any rendering regression reproduces identically. -// -// REND-1 — bootstrap race (subscriptions must mount BEFORE spawn). -// -// On a resumed session, claude-code-headless / codex-headless fire -// the bootstrapTail (~200 jsonl events) DURING `session.start()`. -// That happens INSIDE the IPC handler that resolves spawnSession() -// in the renderer. If the renderer waits for spawnSession() to -// resolve before subscribing to session events, those bootstrap -// entries are sent to a renderer that has no listener and are -// permanently lost — visually presents as "only the very last -// message rendered after I picked the session". -// -// Agent Code sidesteps this in `workspaceStore` by subscribing at -// module mount, before any spawn happens. The harness mirrors -// the same pattern: subscribe at the OUTER component -// (RenderingHarnessApp), not inside DebugSession which only mounts -// after setSpawned. -// -// Refs hold the bookkeeping (oldest marker, seen uuids, provider -// for entry mapping) so the IPC callbacks always read the current -// session's context without re-binding when state changes. -// -// See README → REND-1. - -// The harness global-list row is the canonical SessionInfo plus the provider -// tag that `session:list-all` adds. Composing it from the shared type (rather -// than re-listing fields) keeps the harness from drifting away from the -// production listing contract it exists to debug. -type HarnessSessionInfo = SessionInfo & { provider: AgentProviderKind } - -type RawJsonlEntry = { entry: Record; file: string } - -type SemanticLogItem = { - id: number - ts: number - type: string - event: Record -} - -// FAT debug stream — one unified log of every signal that affects -// rendering, tagged by layer so you can filter to "show me only -// MAP drops" or "show me everything for turn X". Picks the USEFUL -// signals for finding rendering bugs and explicitly skips the -// noisy ones: -// -// JSONL — raw entry arrived: shape (type, role, content kinds) -// MAP — mapper decision: kept (with output kinds) or dropped -// (with reason — wrong type, dedup, empty content) -// SEM — semantic events except per-token text_delta noise -// (collapsed into "+N delta" rollups) -// STATE — spawn / exit / process-state / history-load lifecycle -// -// Skipped on purpose: -// - per-frame screen snapshots (60Hz, useless to log) -// - per-byte PTY data -// - per-token text_delta (rolled up) -// - duplicate state transitions -// One more layer than the obvious 4: RENDER tells you what UI -// component each new feed entry will dispatch to, mirroring the -// Block dispatcher inside Feed.tsx. The whole point of "what we are -// rendering and where" — without RENDER you can see the data flow -// but not the render decision. -type DebugLayer = 'JSONL' | 'MAP' | 'SEM' | 'STATE' | 'RENDER' - -type DebugLogItem = { - id: number - // ms since spawn — relative timestamps are vastly more readable - // than wall-clock when comparing layer ordering across a single - // session. - tMs: number - layer: DebugLayer - kind: string - summary: string - data?: unknown -} - -type SpawnedSession = { - sessionId: string - provider: 'claude' | 'codex' - cwd: string - resumedFrom: string -} - -type ScreenState = { - plain: string - markdown: string - recent: string - recentMarkdown: string -} - -const EMPTY_SCREEN: ScreenState = { plain: '', markdown: '', recent: '', recentMarkdown: '' } - -// Setup constraint (NOT an Agent Code bug). `process` is not defined -// in the renderer when contextIsolation is on. First version of this -// constant was `process?.env?.HOME ?? '/'` and threw a ReferenceError -// at module load → black screen on first launch. Hardcoded `/` is -// fine: every session that reaches here has a recorded cwd, this is -// just a defensive default. -const DEFAULT_CWD_FALLBACK = '/' - -applyTheme(DEFAULT_SETTINGS) - -export function RenderingHarnessApp() { - const [spawned, setSpawned] = useState(null) - - // Live state for the active session. Lives at this level (not in - // DebugSession) so subscriptions can attach BEFORE the user picks a - // session, eliminating the bootstrap race documented above. - const [screen, setScreen] = useState(EMPTY_SCREEN) - const [entries, setEntries] = useState([]) - const [rawJsonl, setRawJsonl] = useState([]) - const [semantic, setSemantic] = useState([]) - // REND-3 (part 1 of 2) — folded semantic runtime drives the - // SemanticStreamingTurn path inside Feed. Without it Feed falls - // through to , which extracts text from the TUI - // screen — and the screen still contains the previous assistant - // text after the user submits, so it briefly renders below the - // user message until new bytes arrive. Folding the events lets - // Feed render straight from parsed proxy data, no screen scrape. - // See README → REND-3. - const [semanticState, setSemanticState] = useState( - emptySemanticRuntime, - ) - // REND-3 (part 2 of 2) — streaming baseline. Captured at submit - // time as the screen extract that represents the PREVIOUS turn's - // assistant text. Feed's `isStaleStreamingExtract` then suppresses - // while the screen still matches this baseline — - // safety net for sessions where semanticTurn is null (proxy off, - // codex). Mirrors workspaceStore.setStreamingBaseline. See README → - // REND-3. - const [streamingBaseline, setStreamingBaseline] = useState(null) - const [activityStatus, setActivityStatus] = useState(null) - const [awaitingAssistant, setAwaitingAssistant] = useState(false) - // True between semantic turn_started and turn_completed/stopped. - // Used together with awaitingAssistant + activityStatus to compute - // "isLive" so we only mount the streaming preview when something is - // actually streaming. Mirror of TileLeaf.isSessionLive in the main - // app — without this gate, on a pure resume the screen still shows - // the last assistant message AND the JSONL contains it, so Feed - // would render the same message twice (StreamingRow + EntryRow). - const [hasActiveTurn, setHasActiveTurn] = useState(false) - const [exited, setExited] = useState<{ code: number; signal?: number } | null>(null) - const [hasOlderHistory, setHasOlderHistory] = useState(false) - const [loadingOlderHistory, setLoadingOlderHistory] = useState(false) - const [debugLog, setDebugLog] = useState([]) - - // Per-session bookkeeping refs. Reset in onPick BEFORE spawning so - // bootstrap entries land into a clean state. - const providerRef = useRef<'claude' | 'codex'>('claude') - const oldestMarkerRef = useRef(null) - const seenUuidsRef = useRef>(new Set()) - const semanticCounter = useRef(0) - // The active managed sessionId; subscriptions filter on this. Set - // synchronously inside onPick BEFORE awaiting spawn so no events - // arrive while it's still null. spawn returns a sessionId that - // matches — main uses the same id from the start of the spawn flow. - const activeSessionIdRef = useRef(null) - const resumedFromRef = useRef(null) - const cwdRef = useRef('/') - // Synchronous source of truth for the wider scrollback screen text. - // Read at submit time to compute the baseline. Mirrors - // workspaceStore.latestScreenRef. - const latestRecentScreenRef = useRef('') - // Spawn-relative time anchor; all debug log entries report ms since - // this point. Reset in resetForNewSession. - const spawnEpochRef = useRef(Date.now()) - const debugCounterRef = useRef(0) - // text_delta rollup buffer — keep noise out of the debug stream - // without losing the signal that text was streaming. Keyed by - // turnId+blockIndex; flushed on the next non-delta event for that - // block, or every ~250ms. - const textDeltaRollupRef = useRef< - Map | null }> - >(new Map()) - - const pushDebug = useCallback((item: Omit) => { - const next: DebugLogItem = { - id: ++debugCounterRef.current, - tMs: Date.now() - spawnEpochRef.current, - ...item, - } - setDebugLog(prev => { - const out = [...prev, next] - return out.length > 800 ? out.slice(out.length - 800) : out - }) - }, []) - - const flushTextDeltaRollup = useCallback( - (key: string) => { - const buf = textDeltaRollupRef.current.get(key) - if (!buf) return - if (buf.flushTimer) clearTimeout(buf.flushTimer) - textDeltaRollupRef.current.delete(key) - pushDebug({ - layer: 'SEM', - kind: 'text_delta_rollup', - summary: `text_delta ×${buf.deltas} (${buf.chars} chars) [${key}]`, - }) - }, - [pushDebug], - ) - - // ---- Subscriptions: attached ONCE, route via refs ---- - // - // We don't filter by sessionId because the harness only ever has - // one session live at a time. Any leftover event after kill is - // ignored on the next reset. - - useEffect(() => { - const offs: Array<() => void> = [] - - offs.push( - window.api.onSessionScreen(evt => { - latestRecentScreenRef.current = evt.recent - setScreen({ - plain: evt.plain, - markdown: evt.markdown, - recent: evt.recent, - recentMarkdown: evt.recentMarkdown, - }) - }), - ) - - offs.push( - window.api.onSessionJsonlEntries(evt => { - // Filter by active session — main can in theory deliver - // events for a previous session whose kill is still draining. - if (activeSessionIdRef.current && evt.sessionId !== activeSessionIdRef.current) return - - setRawJsonl(prev => [...prev, ...evt.entries]) - - const provider = providerRef.current - const seen = seenUuidsRef.current - const appended: Entry[] = [] - - for (const { entry: raw } of evt.entries) { - // ---- JSONL log: shape only ---- - // We log every raw arrival so MAP drops can be correlated - // back to the input. Summary picks the most identifying - // fields per provider shape. - pushDebug({ - layer: 'JSONL', - kind: typeof raw.type === 'string' ? raw.type : 'unknown', - summary: jsonlShapeSummary(raw, provider), - data: raw, - }) - - if (provider === 'codex') { - const marker = codexHistoryMarker(raw) - const mapped = mapCodexRolloutToFeedEntries(raw) - if (mapped.length > 0 && !oldestMarkerRef.current) { - oldestMarkerRef.current = marker - } - if (mapped.length === 0) { - pushDebug({ - layer: 'MAP', - kind: 'drop', - summary: `codex ${describeCodexRaw(raw)} → 0 entries (mapper returned none)`, - data: raw, - }) - continue - } - let kept = 0 - const dropped: string[] = [] - for (const feedEntry of mapped) { - const uuid = entryUuid(feedEntry) - if (typeof uuid === 'string') { - if (seen.has(uuid)) { - dropped.push('dedup') - continue - } - seen.add(uuid) - } - appended.push(feedEntry) - kept += 1 - } - pushDebug({ - layer: 'MAP', - kind: kept > 0 ? 'keep' : 'drop', - summary: `codex ${describeCodexRaw(raw)} → ${kept} kept${ - dropped.length > 0 ? `, ${dropped.length} dropped (${dropped.join('/')})` : '' - } [${mapped.map(feedEntrySignature).join(' ')}]`, - data: { input: raw, mapped }, - }) - continue - } - - // Claude: progress wrapper unwrap, then filter to known - // entry shapes the Feed knows how to render. - const feedEntry = - extractEmbeddedClaudeProgressEntry(raw) ?? (raw as Entry) - const marker = claudeHistoryMarker(raw) - if (marker && !oldestMarkerRef.current) { - oldestMarkerRef.current = marker - } - if ( - !isConversationEntry(feedEntry) && - !isCompactBoundaryEntry(feedEntry) && - !isCompactSummaryEntry(feedEntry) - ) { - pushDebug({ - layer: 'MAP', - kind: 'drop', - summary: `claude ${describeClaudeRaw(raw)} → dropped (filter: not conversation/compact)`, - data: { input: raw, derived: feedEntry }, - }) - continue - } - const uuid = entryUuid(feedEntry) - if (typeof uuid === 'string') { - if (seen.has(uuid)) { - pushDebug({ - layer: 'MAP', - kind: 'drop', - summary: `claude ${describeClaudeRaw(raw)} → dropped (dedup uuid=${uuid.slice(0, 8)})`, - data: { input: raw, uuid }, - }) - continue - } - seen.add(uuid) - } - appended.push(feedEntry) - pushDebug({ - layer: 'MAP', - kind: 'keep', - summary: `claude ${describeClaudeRaw(raw)} → kept [${feedEntrySignature(feedEntry)}]`, - data: { input: raw, mapped: feedEntry }, - }) - } - - if (appended.length > 0) { - // ---- RENDER log: one line per appended entry, naming the - // exact Feed Block-dispatcher path each block will take. - // Mirrors Feed.tsx Block() so you can see the rendering - // decision without scrolling through React DevTools. ---- - for (const feedEntry of appended) { - pushDebug({ - layer: 'RENDER', - kind: renderKindForEntry(feedEntry), - summary: describeRenderForEntry(feedEntry, provider), - data: feedEntry, - }) - } - setEntries(prev => [...prev, ...appended]) - } - }), - ) - - offs.push( - window.api.onSessionSemanticEvent(evt => { - if (activeSessionIdRef.current && evt.sessionId !== activeSessionIdRef.current) return - const ev = asRecord(evt.event) ?? {} - const type = typeof ev.type === 'string' ? ev.type : 'unknown' - const item: SemanticLogItem = { - id: ++semanticCounter.current, - ts: Date.now(), - type, - event: ev, - } - setSemantic(prev => { - const next = [...prev, item] - return next.length > 400 ? next.slice(next.length - 400) : next - }) - // Fold the event into the live semantic runtime — this is - // what makes Feed render the proper SemanticStreamingTurn - // path instead of falling back to the screen-scraping - // StreamingRow. The fold logic is the same one workspaceStore - // uses, so the harness's streaming surface is byte-for-byte - // identical to the main app's. - setSemanticState(prev => foldSemanticEvent(prev, ev)) - - // ---- SEM debug log ---- - // text_delta is the loud one — proxy emits one per token. Roll - // up by turnId+blockIndex and flush either when the next non- - // delta event arrives for that block, or on a 250ms quiet - // window. This keeps the debug stream readable while still - // showing that streaming happened. - const turnId = typeof ev.turnId === 'string' ? ev.turnId : 'no-turn' - const blockIndex = typeof ev.blockIndex === 'number' ? ev.blockIndex : null - const key = `${turnId}#${blockIndex ?? '?'}` - - if (type === 'text_delta' || type === 'thinking_delta' || type === 'tool_input_delta') { - const delta = typeof ev.delta === 'string' ? ev.delta : '' - let buf = textDeltaRollupRef.current.get(key) - if (!buf) { - buf = { chars: 0, deltas: 0, flushTimer: null } - textDeltaRollupRef.current.set(key, buf) - } - buf.chars += delta.length - buf.deltas += 1 - if (buf.flushTimer) clearTimeout(buf.flushTimer) - buf.flushTimer = setTimeout(() => flushTextDeltaRollup(key), 250) - return - } - - // Any non-delta event for this block flushes the rollup so - // the debug stream stays causally ordered. - if (textDeltaRollupRef.current.has(key)) flushTextDeltaRollup(key) - - // Maintain hasActiveTurn for the streaming-card gate. Only - // turn-level events flip this — block-level events fire - // inside an existing turn and must not reset the flag. - if (type === 'turn_started') { - setHasActiveTurn(true) - } else if (type === 'turn_completed' || type === 'turn_stopped') { - setHasActiveTurn(false) - } - - pushDebug({ - layer: 'SEM', - kind: type, - summary: semanticDebugSummary(type, ev), - data: ev, - }) - }), - ) - - offs.push( - window.api.onSessionProcessState(evt => { - if (activeSessionIdRef.current && evt.sessionId !== activeSessionIdRef.current) return - setActivityStatus(evt.active ? evt.status ?? null : null) - setAwaitingAssistant(evt.active) - pushDebug({ - layer: 'STATE', - kind: evt.active ? 'process_active' : 'process_idle', - summary: evt.active - ? `active${evt.status ? ` — ${evt.status}` : ''}` - : 'idle', - }) - }), - ) - - offs.push( - window.api.onSessionExit(evt => { - if (activeSessionIdRef.current && evt.sessionId !== activeSessionIdRef.current) return - setExited({ code: evt.exitCode, signal: evt.signal }) - pushDebug({ - layer: 'STATE', - kind: 'exit', - summary: `exit code=${evt.exitCode}${evt.signal ? ` signal=${evt.signal}` : ''}`, - }) - }), - ) - - offs.push( - window.api.onSessionStarted(evt => { - if (activeSessionIdRef.current && evt.sessionId !== activeSessionIdRef.current) return - pushDebug({ - layer: 'STATE', - kind: 'started', - summary: `started sessionId=${evt.sessionId.slice(0, 8)} kind=${evt.kind}${ - evt.projectDir ? ` project=${evt.projectDir.split('/').pop()}` : '' - }`, - }) - }), - ) - - return () => offs.forEach(off => off()) - }, [pushDebug, flushTextDeltaRollup]) - - const resetForNewSession = useCallback( - (info: HarnessSessionInfo) => { - providerRef.current = info.provider - oldestMarkerRef.current = null - seenUuidsRef.current = new Set() - semanticCounter.current = 0 - resumedFromRef.current = info.sessionId - cwdRef.current = info.cwd ?? DEFAULT_CWD_FALLBACK - spawnEpochRef.current = Date.now() - debugCounterRef.current = 0 - // Cancel any pending text-delta rollup timers from a prior - // session so they don't fire and write into the new log. - for (const buf of textDeltaRollupRef.current.values()) { - if (buf.flushTimer) clearTimeout(buf.flushTimer) - } - textDeltaRollupRef.current = new Map() - latestRecentScreenRef.current = '' - setScreen(EMPTY_SCREEN) - setEntries([]) - setRawJsonl([]) - setSemantic([]) - setSemanticState(emptySemanticRuntime()) - setStreamingBaseline(null) - setDebugLog([]) - setActivityStatus(null) - setAwaitingAssistant(false) - setHasActiveTurn(false) - setExited(null) - // Optimistic — a resumed session almost always has >200 entries - // of history. The first loadOlder response corrects the flag. - setHasOlderHistory(true) - setLoadingOlderHistory(false) - }, - [], - ) - - const onPick = useCallback( - async (info: HarnessSessionInfo) => { - resetForNewSession(info) - const cwd = info.cwd ?? DEFAULT_CWD_FALLBACK - pushDebug({ - layer: 'STATE', - kind: 'spawn_request', - summary: `spawn ${info.provider} resume=${info.sessionId.slice(0, 8)} cwd=${cwd}`, - }) - const res = await window.api.spawnSession({ - kind: info.provider, - cwd, - rows: 48, - cols: 160, - resumeSessionId: info.sessionId, - }) - activeSessionIdRef.current = res.sessionId - pushDebug({ - layer: 'STATE', - kind: 'spawn_resolved', - summary: `spawn resolved sessionId=${res.sessionId.slice(0, 8)}`, - }) - setSpawned({ - sessionId: res.sessionId, - provider: info.provider, - cwd, - resumedFrom: info.sessionId, - }) - }, - [resetForNewSession, pushDebug], - ) - - const onLoadOlderHistory = useCallback(async () => { - if (!resumedFromRef.current) { - setHasOlderHistory(false) - return - } - if (!oldestMarkerRef.current) { - setHasOlderHistory(false) - return - } - setLoadingOlderHistory(prev => { - if (prev) return prev - // Fire the load asynchronously after the state flip; the gate - // above prevents reentry while in flight. - void (async () => { - const beforeMarker = oldestMarkerRef.current! - pushDebug({ - layer: 'STATE', - kind: 'history_load_start', - summary: `older-history fetch before=${beforeMarker.slice(0, 12)}`, - }) - try { - const chunk = await window.api.loadOlderHistory({ - kind: providerRef.current, - cwd: cwdRef.current, - providerSessionId: resumedFromRef.current!, - beforeMarker: oldestMarkerRef.current!, - limit: 200, - }) - const seen = seenUuidsRef.current - const prepend: Entry[] = [] - let oldestMarker: string | null = oldestMarkerRef.current - const originalMarker = oldestMarkerRef.current - - for (const raw of chunk.entries) { - if (providerRef.current === 'codex') { - const marker = codexHistoryMarker(raw) - const mapped = mapCodexRolloutToFeedEntries(raw) - if (mapped.length > 0 && oldestMarker === originalMarker) { - oldestMarker = marker - } - for (const entry of mapped) { - const uuid = entryUuid(entry) - if (typeof uuid === 'string') { - if (seen.has(uuid)) continue - seen.add(uuid) - } - prepend.push(entry) - } - continue - } - const feedEntry = - extractEmbeddedClaudeProgressEntry(raw) ?? (raw as Entry) - const marker = claudeHistoryMarker(raw) - if ( - !isConversationEntry(feedEntry) && - !isCompactBoundaryEntry(feedEntry) && - !isCompactSummaryEntry(feedEntry) - ) { - continue - } - if (marker && oldestMarker === originalMarker) { - oldestMarker = marker - } - const uuid = entryUuid(feedEntry) - if (typeof uuid === 'string') { - if (seen.has(uuid)) continue - seen.add(uuid) - } - prepend.push(feedEntry) - } - - if (prepend.length > 0) { - // Log RENDER decisions for prepended history too, so the - // debug stream shows what each older entry will become. - for (const feedEntry of prepend) { - pushDebug({ - layer: 'RENDER', - kind: `prepend:${renderKindForEntry(feedEntry)}`, - summary: describeRenderForEntry(feedEntry, providerRef.current), - data: feedEntry, - }) - } - setEntries(prev => [...prepend, ...prev]) - } - oldestMarkerRef.current = oldestMarker ?? oldestMarkerRef.current - setHasOlderHistory(chunk.hasMore && prepend.length > 0) - pushDebug({ - layer: 'STATE', - kind: 'history_load_done', - summary: `older-history +${prepend.length} (raw ${chunk.entries.length}) hasMore=${chunk.hasMore}`, - }) - } catch (err) { - console.warn('[harness] load older failed', err) - pushDebug({ - layer: 'STATE', - kind: 'history_load_error', - summary: `older-history failed: ${String((err as Error).message ?? err)}`, - }) - } finally { - setLoadingOlderHistory(false) - } - })() - return true - }) - }, [pushDebug]) - - const onClose = useCallback(async () => { - if (spawned) { - try { - await window.api.killSession(spawned.sessionId) - } catch { - // best-effort - } - } - activeSessionIdRef.current = null - setSpawned(null) - }, [spawned]) - - // Setup constraint (NOT an Agent Code bug): every hook in this - // component must run on every render. First version of this - // `useCallback` was declared AFTER the `if (!spawned) return …` - // early return — phase-1 render skipped it, phase-2 called it, - // React threw error #310. Symptom was a black screen the moment - // you picked a session. Keep ALL hook calls above the early - // return. - const onBeforeSubmit = useCallback(() => { - const provider = providerRef.current - const baseline = extractAssistantInProgress( - latestRecentScreenRef.current, - provider, - ) - setStreamingBaseline(baseline) - setAwaitingAssistant(true) - pushDebug({ - layer: 'STATE', - kind: 'baseline_captured', - summary: `streamingBaseline ← screen extract (${baseline.length} chars)`, - data: { baseline }, - }) - }, [pushDebug]) - - if (!spawned) { - return - } - - // REND-2 — live-gate suppresses the streaming preview when no - // turn is in flight. Without this, on a pure resume the screen - // still contained the last assistant message AND the JSONL - // bootstrap brought the same message in as an Entry → both - // rendered, "double-rendered last message". Mirror of - // TileLeaf.isSessionLive in Agent Code. See README → REND-2. - const isLive = - activityStatus !== null || - awaitingAssistant || - hasActiveTurn || - semanticState.currentTurn !== null - - return ( - - ) -} - -// ----------------------------------------------------------------------------- -// Phase 1 — session picker -// ----------------------------------------------------------------------------- - -function SessionPicker({ onPick }: { onPick: (info: HarnessSessionInfo) => void }) { - const [sessions, setSessions] = useState([]) - const [loading, setLoading] = useState(true) - const [query, setQuery] = useState('') - const [picking, setPicking] = useState(null) - - useEffect(() => { - let cancelled = false - void (async () => { - setLoading(true) - const list = (await window.api.listAllSessions(500)) as HarnessSessionInfo[] - if (cancelled) return - setSessions(list) - setLoading(false) - })() - return () => { - cancelled = true - } - }, []) - - const filtered = useMemo(() => { - const q = query.trim().toLowerCase() - if (!q) return sessions - return sessions.filter(s => - [s.summary, s.cwd ?? '', s.gitBranch ?? '', s.firstPrompt ?? '', s.sessionId] - .some(field => field.toLowerCase().includes(q)), - ) - }, [sessions, query]) - - return ( -
-
-
Agent Code rendering debug
-
- Pick a session to resume. Spawns the real agent, shows every pipeline - layer side by side. -
- setQuery(e.target.value)} - placeholder="filter by summary / cwd / branch / session id" - className="mt-3 w-full bg-canvas border border-border text-ink text-[12px] font-code px-3 py-2 outline-none placeholder:text-muted" - /> -
- -
- {loading ? ( -
- scanning ~/.claude/projects and ~/.codex/sessions … -
- ) : filtered.length === 0 ? ( -
no sessions match
- ) : ( -
- {filtered.map(s => ( - - ))} -
- )} -
-
- ) -} - -// ----------------------------------------------------------------------------- -// Phase 2 — spawned debug view (pure presentation, all state is hoisted) -// ----------------------------------------------------------------------------- - -type DebugSessionProps = { - session: SpawnedSession - onClose: () => void - screen: ScreenState - entries: Entry[] - rawJsonl: RawJsonlEntry[] - semantic: SemanticLogItem[] - semanticTurn: SemanticLiveTurn | null - streamingBaseline: string | null - debugLog: DebugLogItem[] - activityStatus: string | null - isLive: boolean - exited: { code: number; signal?: number } | null - hasOlderHistory: boolean - loadingOlderHistory: boolean - onLoadOlderHistory: () => Promise - onBeforeSubmit: () => void -} - -function DebugSession({ - session, - onClose, - screen, - entries, - rawJsonl, - semantic, - semanticTurn, - streamingBaseline, - debugLog, - activityStatus, - isLive, - exited, - hasOlderHistory, - loadingOlderHistory, - onLoadOlderHistory, - onBeforeSubmit, -}: DebugSessionProps) { - const [input, setInput] = useState('') - - // Tool indices the Feed expects. Cheap recompute on entry growth — - // this is a debug tool, not a hot path. - const { toolUseIndex, toolResultIndex } = useMemo(() => { - const useIdx = new Map() - const resIdx = new Map() - for (const e of entries) { - if (!isConversationEntry(e)) continue - const content = e.message.content - if (!Array.isArray(content)) continue - for (const b of content) { - if (b.type === 'tool_use') { - const tu = b as ToolUseBlock - useIdx.set(tu.id, tu) - } else if (b.type === 'tool_result') { - const tr = b as ToolResultBlock - resIdx.set(tr.tool_use_id, tr) - } - } - } - return { toolUseIndex: useIdx, toolResultIndex: resIdx } - }, [entries]) - - const onSubmit = useCallback(async () => { - const text = input.trim() - if (!text) return - // Capture the streaming baseline BEFORE sending input so it - // freezes the screen state at the moment of submission. If we - // captured after, the screen could already be transitioning and - // the baseline would miss the previous assistant text. - onBeforeSubmit() - await window.api.sendInput(session.sessionId, `${text}\r`) - setInput('') - }, [input, session.sessionId, onBeforeSubmit]) - - return ( -
- {/* Top bar */} -
- - {session.provider} - - resumed: - {session.resumedFrom.slice(0, 8)} - · - {session.cwd} - - entries {entries.length} - jsonl {rawJsonl.length} - sem {semantic.length} - debug {debugLog.length} - {semanticTurn ? ( - turn:{semanticTurn.turnId.slice(0, 6)} - ) : ( - turn:- - )} - {streamingBaseline ? ( - baseline:{streamingBaseline.length} - ) : ( - baseline:- - )} - {loadingOlderHistory ? loading older… : null} - {exited ? ( - exited ({exited.code}) - ) : isLive ? ( - stream:on - ) : ( - stream:off - )} - - -
- - {/* Setup constraint (NOT an Agent Code bug): split MUST be - * flex-row, not grid-cols. - * - * First implementation used `grid grid-cols-[3fr_2fr]` with no - * explicit grid-template-rows. Single-row grids inherit - * row-height = `auto`, so cells size to content. Feed's - * internal `
` then - * resolves `h-full` against an auto-height parent → the - * scroller gets content-height → nothing to scroll. - * - * flex row + flex-1 + min-h-0 propagates the parent's bounded - * height to both columns. Keep flex. */} -
-
- -
- -
- -
- -
-
- -
-
- -
-
- -
-
- -
-
-
-
- ) -} - -// ----------------------------------------------------------------------------- -// Panels -// ----------------------------------------------------------------------------- - -function PanelHeader({ label, hint }: { label: string; hint?: string }) { - return ( -
- {label} - {hint ? {hint} : null} -
- ) -} - -function TerminalPanel({ plain, markdown }: { plain: string; markdown: string }) { - return ( -
- -
-
{plain || '…'}
-
-
- - screen markdown (bold/italic reconstructed) - -
-          {markdown || '…'}
-        
-
-
- ) -} - -function JsonlPanel({ entries }: { entries: RawJsonlEntry[] }) { - const tail = useMemo(() => entries.slice(-80).reverse(), [entries]) - return ( -
- -
- {tail.length === 0 ? ( -
waiting for jsonl…
- ) : ( - tail.map((item, i) => ) - )} -
-
- ) -} - -function RawEntryRow({ entry }: { entry: Record }) { - const [open, setOpen] = useState(false) - const kind = typeof entry.type === 'string' ? entry.type : 'unknown' - const ts = typeof entry.timestamp === 'string' ? entry.timestamp : '' - return ( -
- - {open && ( -
-          {safeStringify(entry)}
-        
- )} -
- ) -} - -function SemanticPanel({ items }: { items: SemanticLogItem[] }) { - const tail = useMemo(() => items.slice(-80).reverse(), [items]) - return ( -
- -
- {tail.length === 0 ? ( -
- no semantic events yet -
- ) : ( - tail.map(item => ( -
- {item.type} - {semanticSummary(item.event)} -
- )) - )} -
-
- ) -} - -// FAT debug stream panel. -// -// Layer filter chips toggle visibility per layer. Default: all on. -// Tail-locked unless the user scrolls up — same trick as a terminal -// log viewer. -function DebugStreamPanel({ items }: { items: DebugLogItem[] }) { - const [enabled, setEnabled] = useState>({ - JSONL: true, - MAP: true, - SEM: true, - STATE: true, - RENDER: true, - }) - const [expanded, setExpanded] = useState>({}) - const [copyToast, setCopyToast] = useState(null) - const scrollRef = useRef(null) - const stickyRef = useRef(true) - - const filtered = useMemo( - () => items.filter(item => enabled[item.layer]), - [items, enabled], - ) - - const copyItems = useCallback( - async (slice: DebugLogItem[], label: string) => { - const text = slice.map(formatDebugLine).join('\n') - try { - await navigator.clipboard.writeText(text) - setCopyToast(`copied ${slice.length} (${label})`) - } catch (err) { - setCopyToast(`copy failed: ${String((err as Error).message ?? err)}`) - } - setTimeout(() => setCopyToast(null), 1600) - }, - [], - ) - - const copyLastN = useCallback( - (n: number) => { - const start = Math.max(0, filtered.length - n) - void copyItems(filtered.slice(start), `last ${n}`) - }, - [filtered, copyItems], - ) - - const copyAllVisible = useCallback(() => { - void copyItems(filtered, 'all visible') - }, [filtered, copyItems]) - - // Pin to bottom when new items arrive AND the user hasn't scrolled - // up. Same auto-tail behavior as journalctl/tail -f. - useEffect(() => { - const el = scrollRef.current - if (!el) return - if (stickyRef.current) { - el.scrollTop = el.scrollHeight - } - }, [filtered]) - - const onScroll = useCallback(() => { - const el = scrollRef.current - if (!el) return - const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight - stickyRef.current = distFromBottom < 16 - }, []) - - return ( -
-
- debug stream - - {filtered.length}/{items.length} - - {copyToast ? {copyToast} : null} - - - {/* Copy controls. We snapshot the FILTERED view so the - * user gets exactly what's on screen — last 50 visible, - * etc. — instead of having "copy" silently include - * filtered-out layers. */} - - - - - | - - {(['STATE', 'JSONL', 'MAP', 'SEM', 'RENDER'] as DebugLayer[]).map(layer => ( - - ))} - -
- - {/* The list. select-text on every row so the user can drag- - * select log lines and copy them with ⌘C. The chevron column - * stays click-toggleable for expand/collapse. */} -
- {filtered.length === 0 ? ( -
no events
- ) : ( - filtered.map(item => { - const isOpen = expanded[item.id] - return ( -
-
- - - {formatRelativeMs(item.tMs)} - - - {item.layer} - - - {item.kind} - - - {item.summary} - -
- {isOpen && item.data !== undefined && ( -
-                    {safeStringify(item.data)}
-                  
- )} -
- ) - }) - )} -
-
- ) -} - -// One log line as plain text — used by the copy buttons. Tab-aligned -// so it pastes cleanly into a spreadsheet or sticks in a markdown -// code block without losing column alignment. -function formatDebugLine(item: DebugLogItem): string { - return `${formatRelativeMs(item.tMs).padStart(7)}\t${item.layer.padEnd(6)}\t${item.kind.padEnd(20)}\t${item.summary}` -} - -const LAYER_TEXT: Record = { - JSONL: 'text-sky-400', - MAP: 'text-emerald-400', - SEM: 'text-amber-400', - STATE: 'text-fuchsia-400', - RENDER: 'text-rose-400', -} - -const LAYER_BORDER: Record = { - JSONL: 'border-sky-400/60', - MAP: 'border-emerald-400/60', - SEM: 'border-amber-400/60', - STATE: 'border-fuchsia-400/60', - RENDER: 'border-rose-400/60', -} - -function ComposerBar({ - input, - setInput, - onSubmit, -}: { - input: string - setInput: (v: string) => void - onSubmit: () => void -}) { - return ( -
- setInput(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - void onSubmit() - } - }} - placeholder="send input to live session (Enter to submit)" - className="flex-1 bg-canvas border border-border text-ink text-[12px] font-code px-3 py-1.5 outline-none placeholder:text-muted" - /> - -
- ) -} - -// ----------------------------------------------------------------------------- -// Helpers -// ----------------------------------------------------------------------------- - -function asRecord(value: unknown): Record | null { - return value && typeof value === 'object' && !Array.isArray(value) - ? value as Record - : null -} - -function stringField(record: Record | null | undefined, key: string): string | undefined { - const value = record?.[key] - return typeof value === 'string' ? value : undefined -} - -function entryUuid(entry: Entry): string | undefined { - return typeof entry.uuid === 'string' ? entry.uuid : undefined -} - -function safeStringify(value: unknown): string { - try { - return JSON.stringify(value, null, 2) - } catch { - return String(value) - } -} - -function oneLineSummary(entry: Record): string { - const msg = asRecord(entry.message) - if (msg) { - const content = msg.content - if (typeof content === 'string') return content.replace(/\s+/g, ' ').slice(0, 140) - if (Array.isArray(content)) { - for (const block of content) { - const b = asRecord(block) - const text = stringField(b, 'text') - if (text) return text.replace(/\s+/g, ' ').slice(0, 140) - if (b.type === 'tool_use' && typeof b.name === 'string') return `tool_use: ${b.name}` - if (b.type === 'tool_result') return 'tool_result' - } - } - } - const payload = asRecord(entry.payload) - if (payload) { - if (typeof payload.type === 'string') return `payload.${payload.type}` - } - return '' -} - -function semanticSummary(ev: Record): string { - const keys = ['turnId', 'blockIndex', 'toolName', 'text', 'source', 'stopReason'] - for (const key of keys) { - const v = ev[key] - if (typeof v === 'string') return `${key}=${v.slice(0, 80)}` - if (typeof v === 'number') return `${key}=${v}` - } - return safeStringify(ev).slice(0, 140) -} - -function formatRelative(ts: number): string { - const diffSec = Math.max(0, (Date.now() - ts) / 1000) - if (diffSec < 60) return `${Math.floor(diffSec)}s` - if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m` - if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h` - return `${Math.floor(diffSec / 86400)}d` -} - -// ---------- Debug-stream summarizers ---------- -// -// These produce the one-liner rendered in the debug stream. Each one -// picks the FEW fields that identify the event and skip everything -// else. Full payloads are still in `data` for the click-to-expand. - -function formatRelativeMs(ms: number): string { - if (ms < 1000) return `+${ms}ms` - if (ms < 60_000) return `+${(ms / 1000).toFixed(1)}s` - return `+${Math.floor(ms / 60_000)}m${Math.floor((ms % 60_000) / 1000)}s` -} - -function jsonlShapeSummary( - raw: Record, - provider: 'claude' | 'codex', -): string { - if (provider === 'codex') return describeCodexRaw(raw) - return describeClaudeRaw(raw) -} - -function describeClaudeRaw(raw: Record): string { - const t = String(raw.type ?? 'unknown') - if (t === 'progress') { - const data = asRecord(raw.data) - const embedded = asRecord(data?.message) - if (embedded) { - const role = String(embedded.type ?? '') - return `progress[${role}]` - } - return 'progress' - } - const message = asRecord(raw.message) - if (message) { - const role = String(message.role ?? '') - const content = message.content - let blockSummary = '' - if (typeof content === 'string') { - blockSummary = `text(${content.length})` - } else if (Array.isArray(content)) { - const kinds = content.map(b => { - const block = asRecord(b) - if (block?.type === 'text') { - const txt = stringField(block, 'text') ?? '' - return `text(${txt.length})` - } - if (block?.type === 'tool_use') return `tool_use:${String(block.name ?? '?')}` - if (block?.type === 'tool_result') return 'tool_result' - if (block?.type === 'thinking') return 'thinking' - return String(block?.type ?? '?') - }) - blockSummary = kinds.join('+') - } - const isMeta = raw.isMeta === true ? ' meta' : '' - const isSidechain = raw.isSidechain === true ? ' sidechain' : '' - return `${t}[${role}] ${blockSummary}${isMeta}${isSidechain}` - } - if (t === 'summary') { - const sum = typeof raw.summary === 'string' ? raw.summary : '' - return `summary(${sum.length})` - } - return t -} - -function describeCodexRaw(raw: Record): string { - const envelopeType = String(raw.type ?? 'unknown') - const payload = asRecord(raw.payload) - if (!payload) return envelopeType - const payloadType = String(payload.type ?? '?') - if (envelopeType === 'response_item') { - if (payloadType === 'message') { - const role = String(payload.role ?? '?') - const content = Array.isArray(payload.content) ? payload.content : [] - const kinds = content.map(b => { - const block = asRecord(b) - if (block?.type === 'input_text' || block?.type === 'output_text') { - const txt = stringField(block, 'text') ?? '' - return `text(${txt.length})` - } - return String(block?.type ?? '?') - }) - return `response_item.message[${role}] ${kinds.join('+')}` - } - if (payloadType === 'function_call') { - return `response_item.function_call:${String(payload.name ?? '?')}` - } - if (payloadType === 'function_call_output') { - const out = typeof payload.output === 'string' ? payload.output : '' - return `response_item.function_call_output(${out.length})` - } - return `response_item.${payloadType}` - } - if (envelopeType === 'event_msg') { - return `event_msg.${payloadType}` - } - return `${envelopeType}.${payloadType}` -} - -function feedEntrySignature(entry: Entry): string { - if (isCompactBoundaryEntry(entry)) return 'compact_boundary' - if (isCompactSummaryEntry(entry)) return 'compact_summary' - if (!isConversationEntry(entry)) return String((entry as { type?: string }).type ?? '?') - const role = entry.message.role - const content = entry.message.content - let blocks = '' - if (typeof content === 'string') { - blocks = `text(${content.length})` - } else if (Array.isArray(content)) { - blocks = content - .map(b => { - const block = asRecord(b) - if (block?.type === 'text') return `text(${(stringField(block, 'text') ?? '').length})` - if (block?.type === 'tool_use') return `tool_use:${String(block.name ?? '?')}` - if (block?.type === 'tool_result') return 'tool_result' - if (block?.type === 'thinking') return 'thinking' - return String(block?.type ?? '?') - }) - .join('+') - } - return `${role}:${blocks}` -} - -// Mirror of Feed.tsx Block() dispatcher. We compute the renderer name -// per block without actually mounting anything — the harness wants to -// know which UI component WOULD render each block so a regression -// like "this Edit row stopped showing" is visible in the log even if -// the visual surface is broken. -// -// We deliberately don't reach into Feed's customRendering toggle here -// because the harness boots with customRendering=false (DEFAULT_SETTINGS). -// If we ever expose a toggle in the harness UI, thread it through. -function describeRenderForEntry(entry: Entry, provider: 'claude' | 'codex'): string { - if (isCompactBoundaryEntry(entry)) return 'CompactBoundaryRow' - if (isCompactSummaryEntry(entry)) return 'CompactSummaryRow' - if (!isConversationEntry(entry)) { - return `Unknown(type=${(entry as { type?: string }).type ?? '?'})` - } - const role = entry.message.role - const content = entry.message.content - if (typeof content === 'string') { - return `${role}: TextProse(${content.length})` - } - if (!Array.isArray(content) || content.length === 0) { - return `${role}: empty` - } - const renderers: string[] = [] - for (const b of content) { - const block = asRecord(b) - const t = block?.type - if (t === 'text') { - const txt = stringField(block, 'text') ?? '' - renderers.push(`TextProse(${txt.length})`) - } else if (t === 'thinking') { - renderers.push('ThinkingDetails') - } else if (t === 'tool_use') { - const name = String(block.name ?? '?') - if (provider === 'codex') { - renderers.push(`CodexToolRow(${name})`) - continue - } - switch (name) { - case 'Edit': - renderers.push('EditRow') - break - case 'MultiEdit': - renderers.push('MultiEditRow') - break - case 'Write': - renderers.push('WriteRow') - break - case 'TodoWrite': - renderers.push('TodoRow') - break - default: - renderers.push(`ToolUseRow(${name})`) - } - } else if (t === 'tool_result') { - const id = typeof block.tool_use_id === 'string' ? block.tool_use_id.slice(0, 8) : '?' - renderers.push(provider === 'codex' ? `CodexToolResultRow(${id})` : `ToolResultRow(${id})`) - } else { - renderers.push(`UnknownBlock(${String(t ?? '?')})`) - } - } - const uuid = entryUuid(entry)?.slice(0, 8) ?? '?' - return `${role} uuid=${uuid} → ${renderers.join(' + ')}` -} - -function renderKindForEntry(entry: Entry): string { - if (isCompactBoundaryEntry(entry)) return 'compact_boundary' - if (isCompactSummaryEntry(entry)) return 'compact_summary' - if (!isConversationEntry(entry)) return 'unknown' - return entry.message.role -} - -function semanticDebugSummary(type: string, ev: Record): string { - const turn = typeof ev.turnId === 'string' ? ev.turnId.slice(0, 6) : '' - const block = typeof ev.blockIndex === 'number' ? `b${ev.blockIndex}` : '' - const tag = [turn, block].filter(Boolean).join('/') - switch (type) { - case 'turn_started': - return `turn_started ${tag}` - case 'turn_completed': - return `turn_completed ${tag} stop=${ev.stopReason ?? '?'}` - case 'turn_stopped': - return `turn_stopped ${tag} stop=${ev.stopReason ?? '?'}` - case 'block_started': { - const kind = ev.kind ?? '?' - const tool = ev.toolName ? ` tool=${ev.toolName}` : '' - return `block_started ${tag} kind=${kind}${tool}` - } - case 'block_completed': - return `block_completed ${tag} kind=${ev.kind ?? '?'}` - case 'tool_input_finalized': - return `tool_input_finalized ${tag} tool=${ev.toolName ?? '?'}` - case 'tool_result': - return `tool_result ${tag} id=${typeof ev.toolUseId === 'string' ? ev.toolUseId.slice(0, 8) : '?'}${ - ev.isError === true ? ' ERROR' : '' - }` - case 'usage_updated': { - const u = asRecord(ev.usage) - const inTok = u?.['input_tokens'] ?? u?.['inputTokens'] ?? '?' - const outTok = u?.['output_tokens'] ?? u?.['outputTokens'] ?? '?' - return `usage ${tag} in=${inTok} out=${outTok}` - } - case 'flow_selected': - case 'flow_ignored': - return `${type} ${ev.flowId ?? '?'} reason=${ev.reason ?? '?'}` - case 'source_changed': - return `source_changed ${tag} → ${ev.source ?? '?'}` - case 'api_error': - case 'stream_error': - return `${type} ${String(ev.message ?? '').slice(0, 80)}` - default: - return tag ? `${type} ${tag}` : type - } -} diff --git a/testing/rendering/renderer/components/ClaudeRows.tsx b/testing/rendering/renderer/components/ClaudeRows.tsx deleted file mode 100644 index 8644dafb5..000000000 --- a/testing/rendering/renderer/components/ClaudeRows.tsx +++ /dev/null @@ -1,360 +0,0 @@ -// Claude-specific tool row renderers extracted from Feed.tsx. -// -// Each component renders a specific Claude Code tool_use block with -// rich formatting: Edit/MultiEdit show line-level diffs, Write shows -// a green-tinted code slab, TodoWrite shows a checklist. They all -// compose MarkerRow from the shared Feed framework and import -// provider-agnostic helpers (diffLines, DiffLine) from core/parsers. -// -// Lives under feed/claude/ so codex can have its own sibling set -// (feed/codex/CodexRows.tsx) without mixing provider logic. - -import { memo, useContext, useMemo } from 'react' -import hljs from 'highlight.js' - -import { normalizeCodeLanguage } from '@shared/code/language' -import { diffLines, type DiffLine } from '@shared/parsers/lineDiff' -import type { ToolUseBlock } from '@shared/types/transcript' -import { CodeBlock } from './CodeBlock' -import { CodeRenderContext, MarkerRow } from './Feed' - -/* ---------- Shared helpers ---------- */ - -/** Pull a file path and old/new strings out of a shape we don't fully - * trust — the transcript typing is `unknown`. Missing fields become - * empty strings so the diff still renders (as "everything added" - * or "everything removed") without crashing. */ -function editInput( - block: ToolUseBlock, -): { filePath: string; oldString: string; newString: string } { - const input = (block.input ?? {}) as Record - return { - filePath: typeof input.file_path === 'string' ? input.file_path : '', - oldString: typeof input.old_string === 'string' ? input.old_string : '', - newString: typeof input.new_string === 'string' ? input.new_string : '', - } -} - -/** Short filename extracted from an absolute path. */ -function basenameOf(path: string): string { - if (!path) return '' - const parts = path.split('/').filter(Boolean) - return parts[parts.length - 1] ?? path -} - -/** Header row for file-tool blocks: "⏺ Edit " */ -function FileToolHeader({ - name, - filePath, - extra, -}: { - name: string - filePath: string - extra?: string -}) { - const short = basenameOf(filePath) - return ( -
- {name} - {short && ( - {short} - )} - {extra && {extra}} -
- ) -} - -function escapeHtml(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>') -} - -function toHighlightLanguage(language: string): string | null { - if (language === 'javascriptreact') return 'javascript' - if (language === 'typescriptreact') return 'typescript' - return hljs.getLanguage(language) ? language : null -} - -/** - * Render a precomputed DiffLine[] as a flat code-slab with per-line - * red/green tinting plus syntax-highlighted text inside each line. - * - * Why highlight line-by-line instead of sending the whole diff through - * a single highlighter: - * the +/- prefix and background tint live at the ROW level. If we - * tokenized the whole diff blob, we'd lose the easy mapping from "this - * is an added line" to "paint this row green". Per-line tokenization - * keeps the git-style diff semantics and still gives the user syntax - * colors inside each row. - */ -function DiffSlab({ - lines, - filePath, -}: { - lines: DiffLine[] - filePath?: string -}) { - if (lines.length === 0) { - return ( -
- (no changes) -
- ) - } - const highlightLanguage = useMemo(() => { - return toHighlightLanguage(normalizeCodeLanguage(undefined, filePath)) - }, [filePath]) - const renderedLines = useMemo( - () => - lines.map(line => { - if (line.text === '') return '\u200b' - if (!highlightLanguage) return escapeHtml(line.text) - return hljs.highlight(line.text, { language: highlightLanguage }).value - }), - [highlightLanguage, lines], - ) - return ( -
- {lines.map((l, i) => { - const bg = - l.kind === '+' - ? 'bg-diff-add-bg' - : l.kind === '-' - ? 'bg-diff-remove-bg' - : '' - const fg = - l.kind === '+' - ? 'text-diff-add-fg' - : l.kind === '-' - ? 'text-diff-remove-fg' - : 'text-code-ink-dim' - const bodyTone = l.kind === 'ctx' ? 'text-code-ink-dim' : 'text-code-ink' - return ( -
- - -
- ) - })} -
- ) -} - -/* ---------- Edit ---------- */ - -export const EditRow = memo(function EditRow({ block }: { block: ToolUseBlock }) { - const { filePath, oldString, newString } = editInput(block) - const lines = useMemo( - () => diffLines(oldString, newString), - [oldString, newString], - ) - return ( - -
- - -
-
- ) -}) - -/* ---------- MultiEdit ---------- */ - -export const MultiEditRow = memo(function MultiEditRow({ - block, -}: { - block: ToolUseBlock -}) { - const input = (block.input ?? {}) as Record - const filePath = - typeof input.file_path === 'string' ? input.file_path : '' - const edits = Array.isArray(input.edits) - ? (input.edits as Array>) - : [] - const normalized = edits.map(e => ({ - oldString: typeof e.old_string === 'string' ? e.old_string : '', - newString: typeof e.new_string === 'string' ? e.new_string : '', - })) - return ( - -
- -
- {normalized.map((e, i) => ( - - ))} -
-
-
- ) -}) - -const MultiEditChunk = memo(function MultiEditChunk({ - index, - total, - filePath, - edit, -}: { - index: number - total: number - filePath: string - edit: { oldString: string; newString: string } -}) { - const lines = useMemo( - () => diffLines(edit.oldString, edit.newString), - [edit.oldString, edit.newString], - ) - return ( -
- {total > 1 && ( -
- change {index + 1} / {total} -
- )} - -
- ) -}) - -/* ---------- Write ---------- */ - -export const WriteRow = memo(function WriteRow({ block }: { block: ToolUseBlock }) { - const input = (block.input ?? {}) as Record - const filePath = typeof input.file_path === 'string' ? input.file_path : '' - const content = typeof input.content === 'string' ? input.content : '' - const codeContext = useContext(CodeRenderContext) - const lineCount = useMemo(() => { - if (!content) return 0 - const normalized = content.endsWith('\n') ? content.slice(0, -1) : content - return normalized === '' ? 0 : normalized.split('\n').length - }, [content]) - return ( - -
- - -
-
- ) -}) - -/* ---------- TodoWrite ---------- */ - -type TodoItem = { - content: string - status: 'pending' | 'in_progress' | 'completed' - activeForm: string -} - -function parseTodos(block: ToolUseBlock): TodoItem[] { - const input = (block.input ?? {}) as Record - const raw = Array.isArray(input.todos) ? input.todos : [] - return raw.map(t => { - const item = (t ?? {}) as Record - const status = - item.status === 'in_progress' || item.status === 'completed' - ? item.status - : 'pending' - return { - content: typeof item.content === 'string' ? item.content : '', - status, - activeForm: typeof item.activeForm === 'string' ? item.activeForm : '', - } - }) -} - -export const TodoRow = memo(function TodoRow({ block }: { block: ToolUseBlock }) { - const todos = useMemo(() => parseTodos(block), [block]) - const done = todos.filter(t => t.status === 'completed').length - return ( - -
-
- TodoWrite - - {done} / {todos.length} done - -
- {todos.length === 0 ? ( -
(empty list)
- ) : ( -
    - {todos.map((t, i) => ( - - ))} -
- )} -
-
- ) -}) - -const TodoItemRow = memo(function TodoItemRow({ item }: { item: TodoItem }) { - const glyph = - item.status === 'completed' - ? '☑' - : item.status === 'in_progress' - ? '◐' - : '☐' - const textCls = - item.status === 'completed' - ? 'text-muted line-through' - : item.status === 'in_progress' - ? 'text-ink' - : 'text-ink-dim' - const glyphCls = - item.status === 'completed' - ? 'text-accent' - : item.status === 'in_progress' - ? 'text-accent' - : 'text-muted' - const label = - item.status === 'in_progress' && item.activeForm - ? item.activeForm - : item.content - return ( -
  • - - - {label} - -
  • - ) -}) diff --git a/testing/rendering/renderer/components/CodeBlock.tsx b/testing/rendering/renderer/components/CodeBlock.tsx deleted file mode 100644 index 299bceeb1..000000000 --- a/testing/rendering/renderer/components/CodeBlock.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { memo, useEffect, useId, useMemo, useRef } from 'react' -import hljs from 'highlight.js' - -import { - languageFileExtension, - normalizeCodeLanguage, - supportsLsp, -} from '@shared/code/language' - -type Props = { - code: string - language?: string | null - path?: string | null - workspaceRoot?: string | null - codeId?: string - engine?: 'static' | 'monaco' - allowAutoDetect?: boolean -} - -function inferClientUri( - codeId: string, - language: string, - path?: string | null, -): string { - if (path) { - return `agent-code://file/${encodeURIComponent(path)}#${encodeURIComponent(codeId)}` - } - const ext = languageFileExtension(language) - return `agent-code://snippet/${encodeURIComponent(codeId)}.${ext}` -} - -export const CodeBlock = memo(function CodeBlock({ - code, - language, - path, - workspaceRoot, - codeId, - engine = 'static', - allowAutoDetect = false, -}: Props) { - const normalizedLanguage = useMemo( - () => normalizeCodeLanguage(language, path), - [language, path], - ) - const shouldUseStaticFallback = - engine === 'monaco' && allowAutoDetect && normalizedLanguage === 'plaintext' - - // ALL hooks must be called unconditionally — React requires the same - // hooks in the same order on every render. The early-return for static - // rendering used to call useMemo/useRef/useId conditionally, which - // crashed when shouldUseStaticFallback flipped (e.g. Codex sessions - // with allowAutoDetect where the language resolves after first render). - const highlighted = useMemo(() => { - if (!code) return '' - if (normalizedLanguage !== 'plaintext' && hljs.getLanguage(normalizedLanguage)) { - return hljs.highlight(code, { language: normalizedLanguage }).value - } - if (allowAutoDetect) { - return hljs.highlightAuto(code).value - } - return null - }, [code, normalizedLanguage, allowAutoDetect]) - - const containerRef = useRef(null) - const reactId = useId().replace(/:/g, '_') - const clientUri = useMemo( - () => inferClientUri(codeId ?? reactId, normalizedLanguage, path), - [codeId, normalizedLanguage, path, reactId], - ) - - // Hoisted above the static early-return so React sees the same hooks - // on every render. When the static path is active, containerRef.current - // is null (the
    isn't in the DOM) so the - // effect bails immediately — no Monaco editor gets created. - const useMonaco = engine !== 'static' && !shouldUseStaticFallback - useEffect(() => { - if (!useMonaco) return - let disposed = false - // Collect ALL cleanup functions as they're created — even inside - // the async block. The effect cleanup runs them all, regardless - // of how far the async init got before unmount. This fixes the - // MaxListenersExceeded leak: the old code stored cleanupDiagnostics - // in a local that the cleanup closure couldn't reach if the async - // hadn't finished yet, so the IPC listener was never removed. - const cleanups: Array<() => void> = [] - - void (async () => { - const { ensureSemanticProvider, getMonaco } = await import('./monacoRuntime') - const monaco = await getMonaco() - if (disposed || !containerRef.current) return - - await ensureSemanticProvider(monaco, workspaceRoot, normalizedLanguage) - if (disposed) return - - const uri = monaco.Uri.parse(clientUri) - const model = monaco.editor.createModel(code, normalizedLanguage, uri) - cleanups.push(() => model.dispose()) - - const editor = monaco.editor.create(containerRef.current, { - model, - readOnly: true, - domReadOnly: true, - minimap: { enabled: false }, - lineNumbers: 'off', - folding: false, - glyphMargin: false, - scrollBeyondLastLine: false, - renderLineHighlight: 'none', - roundedSelection: false, - overviewRulerBorder: false, - overviewRulerLanes: 0, - lineDecorationsWidth: 0, - wordWrap: 'off', - wrappingIndent: 'none', - automaticLayout: true, - smoothScrolling: true, - contextmenu: false, - links: false, - hover: { enabled: false }, - occurrencesHighlight: 'off', - selectionHighlight: false, - matchBrackets: 'never', - guides: { indentation: false, bracketPairs: false }, - scrollbar: { - vertical: 'auto', - horizontal: 'auto', - verticalScrollbarSize: 8, - horizontalScrollbarSize: 8, - alwaysConsumeMouseWheel: false, - }, - }) - cleanups.push(() => editor.dispose()) - - const syncHeight = () => { - const nextHeight = Math.min(Math.max(editor.getContentHeight(), 48), 360) - if (containerRef.current) { - containerRef.current.style.height = `${nextHeight}px` - editor.layout() - } - } - - syncHeight() - const sizeSub = editor.onDidContentSizeChange(syncHeight) - cleanups.push(() => sizeSub.dispose()) - - if (workspaceRoot && supportsLsp(normalizedLanguage)) { - await window.api.openLspDocument({ - clientUri, - content: code, - language: normalizedLanguage, - workspaceRoot, - filePath: path ?? null, - }) - if (disposed) return - - const unsubDiag = window.api.onLspDiagnostics(event => { - if (event.clientUri !== clientUri) return - monaco.editor.setModelMarkers( - model, - 'agent-code-lsp', - event.diagnostics.map(diagnostic => ({ - message: diagnostic.message, - startLineNumber: diagnostic.startLine + 1, - startColumn: diagnostic.startCharacter + 1, - endLineNumber: diagnostic.endLine + 1, - endColumn: diagnostic.endCharacter + 1, - severity: - diagnostic.severity === 'error' - ? monaco.MarkerSeverity.Error - : diagnostic.severity === 'warning' - ? monaco.MarkerSeverity.Warning - : diagnostic.severity === 'info' - ? monaco.MarkerSeverity.Info - : monaco.MarkerSeverity.Hint, - })), - ) - }) - cleanups.push(unsubDiag) - cleanups.push(() => void window.api.closeLspDocument(clientUri)) - } - - if (disposed) return - - const timer = window.setTimeout(() => syncHeight(), 0) - cleanups.push(() => window.clearTimeout(timer)) - })() - - return () => { - disposed = true - // Run all cleanups in reverse order (LIFO) so resources that - // depend on earlier ones are released first. - for (let i = cleanups.length - 1; i >= 0; i--) { - try { cleanups[i]() } catch { /* best-effort */ } - } - } - }, [useMonaco, clientUri, code, engine, normalizedLanguage, path, workspaceRoot]) - - // Static/fallback early return — placed AFTER all hooks so the hook - // call order is identical on every render regardless of code path. - if (!useMonaco) { - return ( -
    -        {highlighted == null ? (
    -          {code}
    -        ) : (
    -          
    -        )}
    -      
    - ) - } - - return
    -}) diff --git a/testing/rendering/renderer/components/CodexRows.tsx b/testing/rendering/renderer/components/CodexRows.tsx deleted file mode 100644 index f9106afc3..000000000 --- a/testing/rendering/renderer/components/CodexRows.tsx +++ /dev/null @@ -1,224 +0,0 @@ -import { memo, useContext } from 'react' - -import { CodeBlock } from './CodeBlock' -import { CodeRenderContext, MarkerRow } from './Feed' -import type { ToolResultBlock, ToolUseBlock } from '@shared/types/transcript' - -function asRecord(value: unknown): Record | null { - return value && typeof value === 'object' ? value as Record : null -} - -function textFromContent(content: unknown): string { - if (typeof content === 'string') return content - if (Array.isArray(content)) { - return content - .map(item => { - if (typeof item === 'string') return item - const rec = asRecord(item) - return typeof rec?.text === 'string' ? rec.text : JSON.stringify(item, null, 2) - }) - .join('\n') - } - return String(content ?? '') -} - -function summarizePatchTargets(input: unknown): string[] { - const text = - typeof input === 'string' - ? input - : typeof asRecord(input)?.raw === 'string' - ? String(asRecord(input)?.raw) - : '' - if (!text) return [] - const matches = [...text.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm)] - return matches.map(match => match[1]).slice(0, 6) -} - -function headlineForTool(block: ToolUseBlock): string | null { - const input = asRecord(block.input) - if (!input) return null - - if (block.name === 'exec_command') { - const cmd = input.cmd - if (typeof cmd === 'string') return cmd - if (Array.isArray(cmd)) return cmd.join(' ') - } - - if (block.name === 'apply_patch') { - const targets = summarizePatchTargets(block.input) - if (targets.length > 0) return targets.join('\n') - } - - if (typeof input.command === 'string') return input.command - if (typeof input.description === 'string') return input.description - if (typeof input.path === 'string') return input.path - if (typeof input.arguments === 'string') return input.arguments.slice(0, 160) - if (typeof input.raw === 'string' && block.name !== 'apply_patch') return input.raw.slice(0, 160) - return null -} - -function detectDiff(text: string): boolean { - return text.startsWith('diff --git ') || text.startsWith('@@ ') -} - -function parsedCommand(meta: Record | null): Record | null { - const parsed = meta?.parsedCmd - if (!Array.isArray(parsed)) return null - return asRecord(parsed[0]) -} - -function parsedPath(parsed: Record | null): string | null { - if (typeof parsed?.path === 'string') return parsed.path - if (typeof parsed?.name === 'string') return parsed.name - return null -} - -export const CodexToolRow = memo(function CodexToolRow({ - block, -}: { - block: ToolUseBlock -}) { - const headline = headlineForTool(block) - - return ( - -
    -
    - {block.name} -
    - {headline && ( - -
    -              {headline}
    -            
    -
    - )} -
    -
    - ) -}) - -export const CodexToolResultRow = memo(function CodexToolResultRow({ - block, -}: { - block: ToolResultBlock -}) { - const codeContext = useContext(CodeRenderContext) - const text = textFromContent(block.content).replace(/\s+$/, '') - const meta = asRecord(asRecord(block)?.codex) - const kind = typeof meta?.kind === 'string' ? meta.kind : null - const isError = block.is_error === true - - if (kind === 'exec_command_end') { - const parsed = parsedCommand(meta) - const parsedType = typeof parsed?.type === 'string' ? parsed.type : null - const path = parsedPath(parsed) - - if (!text && !isError) return null - - if ( - (parsedType === 'read' || parsedType === 'search') && - path && - text - ) { - return ( - - - - ) - } - - if (detectDiff(text)) { - return ( - - - - ) - } - } - - if (kind === 'patch_apply_end') { - const changes = asRecord(meta?.changes) - const items = changes ? Object.entries(changes) : [] - if (items.length > 0) { - return ( - -
    - {items.map(([filePath, change]) => { - const rec = asRecord(change) - const diff = typeof rec?.unified_diff === 'string' ? rec.unified_diff : '' - return ( -
    -
    - {filePath} -
    - {diff ? ( - - ) : text ? ( - - ) : null} -
    - ) - })} -
    -
    - ) - } - } - - if (!text && !isError) return null - - // Route through CodeBlock for consistent rendering with Claude — - // gives syntax highlighting, Monaco when available, and the same - // visual treatment as Claude's tool result rows. - if (!isError && text) { - return ( - - - - ) - } - - return ( - -
    -        {text || '(no output)'}
    -      
    -
    - ) -}) diff --git a/testing/rendering/renderer/components/Feed.tsx b/testing/rendering/renderer/components/Feed.tsx deleted file mode 100644 index fa486bc55..000000000 --- a/testing/rendering/renderer/components/Feed.tsx +++ /dev/null @@ -1,2321 +0,0 @@ -import { - createContext, - memo, - useContext, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, - type ReactNode, -} from 'react' -import ReactMarkdown from 'react-markdown' -import remarkGfm from 'remark-gfm' -import remarkBreaks from 'remark-breaks' - -import { - EditRow, - MultiEditRow, - WriteRow, - TodoRow, -} from './ClaudeRows' -import { - CodexToolRow, - CodexToolResultRow, -} from './CodexRows' -import { - isCompactBoundaryEntry, - isCompactSummaryEntry, - isConversationEntry, - type ContentBlock, - type CompactSummaryEntry, - type ConversationEntry, - type Entry, - type ToolResultBlock, - type ToolUseBlock, -} from '@shared/types/transcript' -import { CodeBlock } from './CodeBlock' -import { detectGitIntent } from '@shared/git/gitDetect' -import { GitCardRow } from './GitRows' -import { useAppStore } from '@renderer/app-state/hooks' -import { - parseSemanticTodos, - type SemanticLiveTurn, - type SemanticTodoItem, -} from '@renderer/workspace/workspaceState' - -// ----------------------------------------------------------------------------- -// Feed — Claude Code TUI-style inline rendering. -// -// Design rules (discussed with the user): -// 1. No bubbles. No cards. No role labels. Messages flow inline like a -// terminal session — each block gets a single-char marker in the -// accent color, content wraps with a hanging indent beside it. -// 2. User text → `❯`. Assistant text → `⏺`. Tool results and CC's -// sub-items → `⎿`. Same markers CC's own TUI uses. -// 3. System entries (permission-mode, file-history-snapshot, hook -// attachments) are hidden by default and only render when the -// user opts in via settings. -// 4. Sharp everything — no border-radius, no shadows, no pills. The -// visual rhythm comes entirely from the marker + hanging indent. -// -// Hanging indent implementation: -//
    -// ⏺ -//
    ...content wraps here...
    -//
    -// -// Because the marker column is a fixed width and the content column is -// flex-1, long lines wrap under the content column only — they don't -// creep back under the marker. Standard hanging-indent pattern. -// ----------------------------------------------------------------------------- - -// Plugin sets are defined at module scope because react-markdown v10 caches -// parse results keyed on plugin identity — passing fresh array literals on -// every parent render busts the cache and costs real frames. -// -// The two sets differ in ONE plugin: -// -// COMPLETED_REMARK: just `remark-gfm`. Completed assistant text from the -// JSONL is real markdown source with proper paragraph breaks, so -// standard markdown rules apply. -// -// STREAMING_REMARK: `remark-gfm` + `remark-breaks`. The streaming source -// is CC's screen buffer — plain text stripped of ANSI. CC's Ink already -// converted markdown syntax (** _ ` ```) to terminal attributes and -// discarded the characters by the time it hits our buffer, so there's -// no real markdown to parse. But single newlines are load-bearing in -// the streaming text (each line is a genuine line, not a soft wrap), -// and standard markdown collapses single newlines into soft wraps that -// flow together as one paragraph. `remark-breaks` turns each hard -// newline into a
    , preserving the visual line layout. Without it, -// a multi-line response like "There are 19 entries:\n body.txt\n -// claude-501\n…" would collapse into one blob. -// -// Rendering streaming through react-markdown instead of a raw
     also
    -// makes the typography match completed messages exactly: same font, size,
    -// line-height, paragraph rhythm. When the JSONL entry lands and the
    -// structured version takes over, the visual jump is minimal — just
    -// richer formatting on top of the same base layout.
    -const COMPLETED_REMARK = [remarkGfm]
    -const STREAMING_REMARK = [remarkGfm, remarkBreaks]
    -// -----------------------------------------------------------------------------
    -// Tool-use index: a map from tool_use_id → ToolUseBlock, built once per
    -// render pass over the entire feed. Used by ToolResultRow to look up
    -// "what tool produced this result" so it can pick a richer renderer for
    -// known tools (Read → syntax-highlighted code; Bash → plain pre; …).
    -//
    -// Why a side channel instead of pairing at the ConversationRow level:
    -//   tool_use blocks live in an ASSISTANT entry and tool_result blocks
    -//   live in the NEXT USER entry. ConversationRow only sees one entry at
    -//   a time, so it can't pair them structurally without reaching across
    -//   entries. We build the index at Feed level (where we DO have every
    -//   entry) and hand it to result rows via context so the memoed row
    -//   components don't need a new prop.
    -//
    -// Memo behavior: the map reference changes whenever `entries` changes,
    -// which invalidates useContext consumers. That's fine — rows that care
    -// about the map already re-render when entries grow, and rows that
    -// don't call useContext are unaffected. We do NOT include the map in
    -// row memo keys; equality on the map itself would be expensive and the
    -// interesting work (markdown parsing) is cached inside TextProse by
    -// text string, so repeat renders are cheap.
    -// -----------------------------------------------------------------------------
    -
    -const ProviderContext = createContext('claude')
    -const ToolUseIndexContext = createContext>(new Map())
    -// Reverse of ToolUseIndexContext — lets the tool_use dispatcher peek
    -// at the paired result block, so a single combined widget can render
    -// both sides (command + output) on one row. Needed for the git
    -// widgets: the result content lives on a later entry but the widget
    -// wants it available when the tool_use row mounts. When the result
    -// hasn't arrived yet the map returns undefined and the widget renders
    -// a "running…" placeholder; on the next entry wave it re-renders
    -// with the real output.
    -const ToolResultIndexContext =
    -  createContext>(new Map())
    -export const CodeRenderContext = createContext<{
    -  sessionId: string
    -  workspaceRoot: string | null
    -}>({
    -  sessionId: '',
    -  workspaceRoot: null,
    -})
    -
    -function asRecord(value: unknown): Record | null {
    -  return value && typeof value === 'object' && !Array.isArray(value)
    -    ? value as Record
    -    : null
    -}
    -
    -function stringField(record: Record | null | undefined, key: string): string | undefined {
    -  const value = record?.[key]
    -  return typeof value === 'string' ? value : undefined
    -}
    -
    -/**
    - * Custom 
     renderer: strips the default 
     wrapper and lets
    - * our MarkdownCode component handle ALL the rendering for fenced
    - * code blocks. Without this, react-markdown wraps block code in
    - * 
    …
    and our CodeBlock - * would be nested inside the browser's default
     styling.
    - *
    - * Inline code is NOT affected — inline `code` never gets a 
    - * wrapper in react-markdown's output.
    - */
    -function MarkdownPre({
    -  children,
    -  node,
    -}: {
    -  children?: ReactNode
    -  node?: unknown
    -}) {
    -  // Tag the children so MarkdownCode knows this code element came
    -  // from inside a 
     (i.e., it's a fenced/indented code block,
    -  // not an inline backtick). We pass through the children as-is;
    -  // the 
     wrapper is removed.
    -  void node
    -  return <>{children}
    -}
    -
    -/**
    - * Custom  renderer. Handles two distinct cases:
    - *
    - * 1. INLINE code: `variableName` in prose. Detected by the absence
    - *    of a `language-*` className AND single-line text. Renders as a
    - *    plain  element with the existing prose-theme inline-code
    - *    styling (accent color, no background).
    - *
    - * 2. FENCED code blocks: ```language\n...\n```. Detected by the
    - *    presence of a `language-*` className OR multi-line text (which
    - *    means it came through MarkdownPre above). Renders via CodeBlock
    - *    with syntax highlighting.
    - *
    - * Why the className + newline heuristic:
    - *   react-markdown v10 doesn't pass a reliable `inline` prop to the
    - *   code component. The only signals are: (a) fenced blocks get
    - *   className="language-X" when labeled, (b) fenced blocks have
    - *   newlines in their text, (c) inline code has neither. Checking
    - *   both catches labeled fences, unlabeled fences (multi-line), and
    - *   inline backticks.
    - */
    -function MarkdownCode({
    -  className,
    -  children,
    -}: {
    -  className?: string
    -  children?: ReactNode
    -}) {
    -  const { sessionId, workspaceRoot } = useContext(CodeRenderContext)
    -  const text = String(children ?? '').replace(/\n$/, '')
    -  const language = className?.match(/language-([\w-]+)/)?.[1] ?? null
    -
    -  // Inline code: no language class AND no newlines → plain .
    -  // This preserves the existing prose-theme styling where inline
    -  // code is accent-colored with no background chip.
    -  const isInline = !language && !text.includes('\n')
    -  if (isInline) {
    -    return {children}
    -  }
    -
    -  // Fenced/indented code block inside prose → static highlight.js.
    -  // NOT Monaco — Monaco is heavyweight (async loader, canvas
    -  // renderer, explicit layout) and a single assistant turn often
    -  // contains several fenced blocks. When many Monaco editors mount
    -  // into narrow flex cells before the parent layout has resolved,
    -  // they initialise at width=0, paint nothing, and `automaticLayout`
    -  // does not always recover on the follow-up resize — the block
    -  // ends up as the dark `--theme-code-bg` background with no
    -  // visible text (the "black block" bug). Monaco stays reserved for
    -  // surfaces where an editor actually pays off: Read / Grep tool
    -  // results, where LSP and scrollable syntax highlighting matter.
    -  // Prose blocks are "here's a shell command"; static is the right
    -  // fit.
    -  //
    -  // `allowAutoDetect` on unlabeled fences restores the old
    -  // rehype-highlight detect:true behavior.
    -  return (
    -    
    -  )
    -}
    -
    -const MARKDOWN_COMPONENTS: import('react-markdown').Options['components'] = {
    -  pre: MarkdownPre,
    -  code: MarkdownCode,
    -}
    -
    -function buildToolUseIndex(entries: Entry[]): Map {
    -  const map = new Map()
    -  for (const e of entries) {
    -    if (!isConversationEntry(e)) continue
    -    const content = e.message.content
    -    if (!Array.isArray(content)) continue
    -    for (const b of content) {
    -      if (b.type === 'tool_use') {
    -        const tu = b as ToolUseBlock
    -        map.set(tu.id, tu)
    -      }
    -    }
    -  }
    -  return map
    -}
    -
    -/**
    - * Reverse index: tool_use_id -> the paired tool_result block. Built
    - * alongside the forward index but scoped separately so the two maps
    - * can be memoized independently. Agents sometimes emit a result
    - * without a preceding use (rare — synthetic error paths), those get
    - * indexed by their own tool_use_id regardless.
    - */
    -function buildToolResultIndex(entries: Entry[]): Map {
    -  const map = new Map()
    -  for (const e of entries) {
    -    if (!isConversationEntry(e)) continue
    -    const content = e.message.content
    -    if (!Array.isArray(content)) continue
    -    for (const b of content) {
    -      if (b.type === 'tool_result') {
    -        const tr = b as ToolResultBlock
    -        map.set(tr.tool_use_id, tr)
    -      }
    -    }
    -  }
    -  return map
    -}
    -
    -/** Extract the command string from a Bash / exec_command tool_use
    - *  block, normalizing across providers. Claude passes the command
    - *  as `input.command: string`. Codex passes `input.cmd` which may
    - *  be a string OR a pre-split array (for the actual argv form). */
    -function extractToolCommand(block: ToolUseBlock): string | null {
    -  const input = asRecord(block.input)
    -  if (!input) return null
    -  if (typeof input.command === 'string') return input.command
    -  if (typeof input.cmd === 'string') return input.cmd
    -  if (Array.isArray(input.cmd)) return input.cmd.filter(s => typeof s === 'string').join(' ')
    -  return null
    -}
    -
    -/** Flatten a tool_result's content to a plain string — both providers
    - *  use either a string or an array of `{type:'text',text:string}`. */
    -function toolResultText(block: ToolResultBlock): string {
    -  if (typeof block.content === 'string') return block.content
    -  if (Array.isArray(block.content)) {
    -    return block.content
    -      .map(item => typeof item === 'string' ? item
    -                 : typeof (item as { text?: unknown }).text === 'string' ? (item as { text: string }).text
    -                 : '')
    -      .join('\n')
    -  }
    -  return ''
    -}
    -
    -/** Which agent provider this Feed is rendering for. Determines
    - *  which row renderers are used for tool_use blocks. */
    -export type AgentProvider = 'claude' | 'codex'
    -
    -/** Scroll info pushed from Feed to its parent on every scroll tick.
    - *  Used by TileLeaf to render the scroll position indicator. */
    -export type ScrollInfo = {
    -  /** 0 = at bottom, 1 = at top. */
    -  fraction: number
    -}
    -
    -type Props = {
    -  /** Session identity — used as the key for per-session scroll
    -   *  position persistence across Feed unmount/remount (tab switches).
    -   *  See `scrollPositions` below. */
    -  sessionId: string
    -  /** Which provider's row renderers to use. Default 'claude'. */
    -  provider?: AgentProvider
    -  entries: Entry[]
    -  /** Spinner verb text from the headless package ("Cogitating…").
    -   *  Rendered below the feed when no semantic turn is mounted so
    -   *  the user sees the agent is working even if no live text is
    -   *  flowing yet. */
    -  activityStatus?: string | null
    -  tailMode?: boolean
    -  /**
    -   * UUID of the assistant entry currently highlighted by the
    -   * "Copy Assistant Message" picker. Null when the picker is not
    -   * active. Drives a 2px accent outline on the matching row and
    -   * auto-scrolls into view when the value changes.
    -   */
    -  pickerSelectedUuid?: string | null
    -  workspaceRoot?: string | null
    -  /** Called on every scroll tick with the current position. */
    -  onScrollInfo?: (info: ScrollInfo) => void
    -  hasOlderHistory?: boolean
    -  loadingOlderHistory?: boolean
    -  onLoadOlderHistory?: () => Promise
    -  semanticTurn?: SemanticLiveTurn | null
    -  /** True while the owning session is replaying a bulk bootstrap
    -   *  burst. Feed uses it to suspend auto-scroll pinning and the
    -   *  IntersectionObserver-driven lazy mount, avoiding the layout
    -   *  cascade that otherwise makes resume feel like "scrolling
    -   *  through the whole conversation." */
    -  bootstrapping?: boolean
    -  scrollToLatestRequest?: number
    -  /** Incremental tool indices maintained by workspaceStore. Feed
    -   *  used to rebuild these via `useMemo([entries])` per render, which
    -   *  was O(N) per append and O(N²) per bootstrap burst. Passed in
    -   *  now from the runtime — the store grows them at ingest time. */
    -  toolUseIndex?: Map
    -  toolResultIndex?: Map
    -}
    -
    -// ---------------------------------------------------------------------------
    -// Per-session scroll position memory.
    -//
    -// When the user switches tabs, App.tsx unmounts the inactive tab's
    -// TileTree — and with it, every Feed inside. When they switch back,
    -// a fresh Feed mounts with a brand-new scroll container at scrollTop=0.
    -// Without intervention that snaps the viewport to the top (or to a
    -// weird "just after first paint" position), which the user rightly
    -// called out as "weird and stupid."
    -//
    -// The fix is to persist each Feed's scroll state OUTSIDE the React
    -// component tree so it survives unmount. A module-level Map keyed by
    -// sessionId is the simplest possible store: no re-renders, no store
    -// plumbing, no prop noise. We record (scrollTop + stickyBottom flag)
    -// on every scroll tick and read them back in a useLayoutEffect on
    -// mount to restore the viewport BEFORE the browser paints.
    -//
    -// Why not put this in SessionRuntime: scroll position is a pure UI
    -// concern — no IPC, no persistence across app restarts, no other
    -// consumer. Hoisting it into the runtime state would force Feed to
    -// take an extra prop (workspace or a setter) and would invalidate
    -// Feed's React.memo shallow-compare on every scroll tick because the
    -// runtime object reference would change. Module-level Map sidesteps
    -// both problems.
    -//
    -// The Map is not bounded — if a session is killed, its entry sticks
    -// around forever. That's fine: each entry is two numbers and a
    -// boolean, and the Map only grows by the count of sessions EVER
    -// opened in this browser process lifetime (which typically resets
    -// on Electron window reload). Adding a cleanup on session kill
    -// would be a minor optimization if that ever matters.
    -// ---------------------------------------------------------------------------
    -type ScrollPosition = {
    -  scrollTop: number
    -  stickyBottom: boolean
    -}
    -const scrollPositions = new Map()
    -
    -// -----------------------------------------------------------------------------
    -// Memoization strategy — the whole reason this file is fast enough to type in
    -// -----------------------------------------------------------------------------
    -//
    -// Parsing markdown through unified (remark-parse → remark-gfm → rehype-
    -// highlight → highlight.js) is EXPENSIVE. A single assistant message with
    -// a handful of code blocks easily takes 5-15ms per ReactMarkdown call,
    -// and a scrolled-back feed can contain dozens of them. Without memoization,
    -// every keystroke in the composer input (which is a sibling component
    -// living inside the same TileLeaf) triggers a TileLeaf re-render, which
    -// re-renders , which re-parses every single markdown block from
    -// scratch. That's 100ms+ of blocking work between the browser input event
    -// and the next paint — the input literally cannot update until the
    -// markdown finishes, and typing becomes unusable.
    -//
    -// The fix is a two-layer memo:
    -//
    -//   1. `Feed` itself is memoized. When the parent re-renders for a reason
    -//      unrelated to feed content (user typing, focus toggle, split resize,
    -//      picker visibility change), Feed sees the same `entries` reference,
    -//      same `streamingScreen`, etc., and React.memo's default shallow
    -//      compare bails the entire subtree out. Zero markdown work happens.
    -//
    -//   2. Every row component (`EntryRow`, `ConversationRow`, `TextProse`,
    -//      `ToolUseRow`, `ToolResultRow`) is individually memoized. Even when
    -//      Feed DOES need to re-render (new entry lands, streaming frame
    -//      ticks), existing rows receive the exact same entry/block/text
    -//      reference they had last time and skip. Only the genuinely new
    -//      row does parse work. This matters because entries are appended,
    -//      not replaced — we spread `[...current.entries, newOne]`, so the
    -//      array reference is fresh but every existing element is stable.
    -//
    -// TextProse/StreamingProse are the hottest leaf; memoizing them by the
    -// `text` string is the single biggest win because ReactMarkdown itself
    -// has no memo and re-parses on every call.
    -//
    -// What is NOT memoized on purpose: the StreamingRow. It WANTS to re-run
    -// on every screen frame — that's literally the streaming preview. But
    -// even there, `StreamingProse` is memoed by the extracted text string,
    -// so identical consecutive frames (which are common — CC re-renders its
    -// screen buffer without the content changing) are free.
    -
    -export const Feed = memo(FeedImpl)
    -
    -function FeedImpl({
    -  sessionId,
    -  provider = 'claude',
    -  entries,
    -  activityStatus,
    -  tailMode = false,
    -  pickerSelectedUuid = null,
    -  workspaceRoot = null,
    -  onScrollInfo,
    -  hasOlderHistory = false,
    -  loadingOlderHistory = false,
    -  onLoadOlderHistory,
    -  semanticTurn = null,
    -  bootstrapping = false,
    -  scrollToLatestRequest = 0,
    -  toolUseIndex: toolUseIndexProp,
    -  toolResultIndex: toolResultIndexProp,
    -}: Props) {
    -  // Scroll container owned by Feed itself — not by TileLeaf — so the
    -  // sticky-bottom logic below can own its own scroll listener without
    -  // reaching up the tree. TileLeaf's wrapper is just a flex cell and
    -  // no longer sets overflow-auto; see TileLeaf.tsx for the pair.
    -  //
    -  // Why this is load-bearing: during active streaming, `streamingScreen`
    -  // updates at ~60Hz. A naive useEffect([streamingScreen]) that calls
    -  // scrollIntoView({block:'end'}) every time would yank the viewport
    -  // to the bottom on every frame, making it impossible for the user
    -  // to scroll up and read earlier content. Even when NOT streaming,
    -  // the same effect fires whenever the streaming-ish prop flickers —
    -  // which can happen unexpectedly if some other piece of state (e.g.
    -  // the queue handler forcing awaitingAssistant=true from a phantom
    -  // backlog) keeps the prop live. Worth repeating the user's
    -  // diagnosis: "scrolling doesn't even work, it snaps me back to the
    -  // bottom super glitchly." That's exactly the behavior the old
    -  // effect produced.
    -  //
    -  // The fix is two-part:
    -  //   1. Track "is the user near the bottom right now" in a ref
    -  //      that's updated by a scroll listener. "Near" = within 48px
    -  //      of the bottom; the pad absorbs sub-pixel rounding and the
    -  //      natural momentum overshoot when new rows land.
    -  //   2. Only auto-scroll when the ref is true. If the user has
    -  //      scrolled up, stickyBottom becomes false, and subsequent
    -  //      updates stop forcing the viewport down. When the user
    -  //      scrolls back to the bottom, the ref flips true again and
    -  //      auto-scroll resumes.
    -  //
    -  // Using a ref (not state) for stickyBottom is deliberate: we don't
    -  // want a React re-render on every scroll tick, only a read in the
    -  // auto-scroll effect. Scroll events fire on EVERY pixel, so this
    -  // matters.
    -  const scrollerRef = useRef(null)
    -  const endRef = useRef(null)
    -  // Restore stickyBottom from the persisted map on first render so
    -  // the auto-scroll effect below makes the right decision without
    -  // needing to wait for the scroll listener to run. Defaults to
    -  // true for brand-new sessions (no saved position yet).
    -  const stickyBottomRef = useRef(
    -    scrollPositions.get(sessionId)?.stickyBottom ?? true,
    -  )
    -  const loadingOlderRef = useRef(false)
    -  // Previous scrollTop, used to distinguish "the user started
    -  // scrolling upward" from incidental near-bottom jitter. This is
    -  // load-bearing during active turns: with the old "gap < 48"
    -  // heuristic alone, a tiny upward wheel tick still counted as
    -  // sticky, and the next ~60 Hz screen update snapped the feed right
    -  // back down before the user could accumulate enough distance to
    -  // escape. Any real upward movement should break follow
    -  // immediately; re-follow only when the user intentionally returns
    -  // near the bottom.
    -  const lastScrollTopRef = useRef(0)
    -
    -  // Restore the saved scroll position on mount — synchronously, via
    -  // useLayoutEffect, so the browser never paints the scroller at
    -  // scrollTop=0 before we restore. Using useEffect here would flash
    -  // the top of the feed for one frame before the restore landed.
    -  //
    -  // Three cases:
    -  //   1. No saved entry → this is the first time we've mounted for
    -  //      this session (or the map was just cleared). Default to
    -  //      "stuck at bottom" — for a freshly-opened feed the user
    -  //      wants to see the most recent content, just like opening a
    -  //      terminal or a chat window.
    -  //   2. Saved stickyBottom: true → the user was at the bottom when
    -  //      they left. Content may have grown while we were unmounted
    -  //      (new entries appended to runtime.entries even though Feed
    -  //      wasn't rendering), so we pin to the NEW scrollHeight, not
    -  //      the old scrollTop.
    -  //   3. Saved stickyBottom: false → restore the exact saved
    -  //      scrollTop. Content height on remount matches save time
    -  //      (because unmount freezes new entries from growing the
    -  //      unmounted scroller) so this is pixel-accurate.
    -  //
    -  // The sessionId dep is load-bearing: if the user resumes a
    -  // different session in the same pane slot, we need to re-restore
    -  // from the new key. Today it's effectively a mount-only effect.
    -  useLayoutEffect(() => {
    -    const el = scrollerRef.current
    -    if (!el) return
    -    if (tailMode) {
    -      el.scrollTop = el.scrollHeight
    -      stickyBottomRef.current = true
    -      lastScrollTopRef.current = el.scrollTop
    -      return
    -    }
    -    const saved = scrollPositions.get(sessionId)
    -    if (!saved || saved.stickyBottom) {
    -      // Case 1 or 2: pin to bottom. We do this synchronously in
    -      // useLayoutEffect so the browser commits the scrollTop change
    -      // in the SAME paint as the initial content render. Without
    -      // that, the first paint shows scrollTop=0 (top) and the next
    -      // tick scrolls down — visibly a "starts at top, jumps to
    -      // bottom" flash on every tab switch, which is the exact bug
    -      // the user flagged.
    -      el.scrollTop = el.scrollHeight
    -      stickyBottomRef.current = true
    -      lastScrollTopRef.current = el.scrollTop
    -    } else {
    -      // Case 3: restore exact position.
    -      el.scrollTop = saved.scrollTop
    -      stickyBottomRef.current = false
    -      lastScrollTopRef.current = el.scrollTop
    -    }
    -  }, [sessionId, tailMode])
    -
    -  // One scroll listener for the container. Updates stickyBottomRef
    -  // imperatively AND persists the position into the module-level
    -  // map so a later unmount/remount can restore it.
    -  //
    -  // CRITICAL: we DO NOT call onScroll() synchronously on mount.
    -  // That was the original bug — at mount time the scroller has
    -  // scrollTop=0 and scrollHeight=full-content, so gap > 48 and the
    -  // handler would stamp stickyBottom=false INTO THE REF AND THE
    -  // PERSISTED MAP before the layout effect above had a chance to
    -  // scroll to the bottom. Then the auto-scroll effect below would
    -  // see stickyBottom=false and skip, leaving the viewport stuck at
    -  // the top. The layout effect sets stickyBottomRef explicitly, so
    -  // the scroll listener only needs to react to actual user scrolls.
    -  useEffect(() => {
    -    const el = scrollerRef.current
    -    if (!el) return
    -    const onScroll = () => {
    -      if (tailMode) {
    -        el.scrollTop = el.scrollHeight
    -        stickyBottomRef.current = true
    -        lastScrollTopRef.current = el.scrollTop
    -        scrollPositions.set(sessionId, {
    -          scrollTop: el.scrollTop,
    -          stickyBottom: true,
    -        })
    -        if (onScrollInfo) onScrollInfo({ fraction: 0 })
    -        return
    -      }
    -      const gap = el.scrollHeight - (el.scrollTop + el.clientHeight)
    -      const scrollingUp = el.scrollTop < lastScrollTopRef.current
    -      const nearBottom = gap < 48
    -      stickyBottomRef.current =
    -        scrollingUp && gap > 0 ? false : nearBottom
    -      lastScrollTopRef.current = el.scrollTop
    -      scrollPositions.set(sessionId, {
    -        scrollTop: el.scrollTop,
    -        stickyBottom: stickyBottomRef.current,
    -      })
    -      // Push scroll position to parent for the scroll indicator.
    -      // fraction=0 at bottom, fraction=1 at top.
    -      if (onScrollInfo) {
    -        const maxScroll = el.scrollHeight - el.clientHeight
    -        const fraction = maxScroll > 0
    -          ? 1 - (el.scrollTop / maxScroll)
    -          : 0
    -        onScrollInfo({ fraction })
    -      }
    -
    -      if (
    -        el.scrollTop < 160 &&
    -        hasOlderHistory &&
    -        !loadingOlderHistory &&
    -        !loadingOlderRef.current &&
    -        !tailMode &&
    -        onLoadOlderHistory
    -      ) {
    -        loadingOlderRef.current = true
    -        const beforeHeight = el.scrollHeight
    -        const beforeTop = el.scrollTop
    -        void onLoadOlderHistory()
    -          .then(() => {
    -            requestAnimationFrame(() => {
    -              const next = scrollerRef.current
    -              if (!next) return
    -              const delta = next.scrollHeight - beforeHeight
    -              next.scrollTop = beforeTop + Math.max(0, delta)
    -              lastScrollTopRef.current = next.scrollTop
    -            })
    -          })
    -          .finally(() => {
    -            loadingOlderRef.current = false
    -          })
    -      }
    -    }
    -    el.addEventListener('scroll', onScroll, { passive: true })
    -    return () => el.removeEventListener('scroll', onScroll)
    -  }, [
    -    sessionId,
    -    onScrollInfo,
    -    tailMode,
    -    hasOlderHistory,
    -    loadingOlderHistory,
    -    onLoadOlderHistory,
    -  ])
    -
    -  // Auto-scroll on content changes, but ONLY when sticky. The effect
    -  // runs on every change that would grow the feed (a new entry) or
    -  // move the streaming tail (a new streamingScreen snapshot). If the
    -  // user is scrolled up, we skip — they're reading earlier content
    -  // and we don't want to yank them back.
    -  //
    -  // Semantic streaming (the proxy-driven live turn) also grows the
    -  // bottom of the feed without touching `streamingScreen` or
    -  // `entries`. Include a cheap fingerprint of the current semantic
    -  // turn (its text length plus the count of blocks) so the effect
    -  // re-runs when semantic deltas land — otherwise a Codex/proxy
    -  // session that never populates the legacy screen snapshot won't
    -  // follow the tail even when the user is at the bottom.
    -  const semanticTurnSignal = semanticTurn
    -    ? `${semanticTurn.turnId}:${semanticTurn.text.length}:${Object.keys(semanticTurn.blocks).length}`
    -    : ''
    -  useEffect(() => {
    -    // During a bulk bootstrap burst we skip per-append auto-scroll.
    -    // The pin-once-on-transition effect below lands us at the bottom
    -    // in a single operation after the burst ends — otherwise every
    -    // entry appended during the burst would pin-scroll and wake up
    -    // the LazyEntry observer cascade. See docs/superpowers/plans/
    -    // 2026-04-15-bootstrap-replay-perf.md.
    -    if (bootstrapping) return
    -    if (!tailMode && !stickyBottomRef.current) return
    -    // scrollTop = scrollHeight pins to bottom without the smooth-scroll
    -    // overshoot scrollIntoView sometimes produces. Direct, instant,
    -    // no animation frames.
    -    const el = scrollerRef.current
    -    if (el) el.scrollTop = el.scrollHeight
    -  }, [entries.length, tailMode, semanticTurnSignal, bootstrapping])
    -
    -  // Pin-once on the bootstrap → live transition. Runs exactly once per
    -  // transition thanks to the previous-value ref: we read the prior
    -  // value, compare, pin if we just left bootstrap mode, then store the
    -  // new value for next time. No dependency on `entries.length` so the
    -  // effect does not fire on subsequent live appends — those go
    -  // through the regular auto-scroll effect above.
    -  const prevBootstrappingRef = useRef(false)
    -  useEffect(() => {
    -    if (prevBootstrappingRef.current && !bootstrapping) {
    -      const el = scrollerRef.current
    -      if (el && (tailMode || stickyBottomRef.current)) {
    -        el.scrollTop = el.scrollHeight
    -      }
    -    }
    -    prevBootstrappingRef.current = bootstrapping
    -  }, [bootstrapping, tailMode])
    -
    -  // When the picker selection changes, smoothly tween the scroller
    -  // so the highlighted entry centers in the viewport.
    -  //
    -  // We do NOT use native scrollIntoView({behavior:'smooth'}) because:
    -  //   - With block:'nearest' it no-ops when the target is already on
    -  //     screen, so rapid arrow presses after a manual scroll land on
    -  //     already-visible entries and produce zero motion (user report:
    -  //     "my arrow key does nothing after I scroll").
    -  //   - With block:'center' native smooth scroll was observably shaky
    -  //     here — the feed's own scroll listener fires on every frame of
    -  //     the animation and was fighting the browser's scroll queue.
    -  //
    -  // A custom rAF tween is ~20 lines, interrupt-safe (new target
    -  // cancels any in-flight animation), and completely independent of
    -  // whatever the scroll listener is doing.
    -  const scrollAnimFrameRef = useRef(null)
    -  useEffect(() => {
    -    if (!pickerSelectedUuid) return
    -    const root = scrollerRef.current
    -    if (!root) return
    -    const target = root.querySelector(
    -      `[data-entry-uuid="${pickerSelectedUuid}"]`,
    -    ) as HTMLElement | null
    -    if (!target) return
    -
    -    // Compute desired scrollTop so the target's vertical center aligns
    -    // with the scroller's vertical center. Clamp to the scroller's
    -    // scrollable range so we don't try to scroll past start/end.
    -    const targetCenter = target.offsetTop + target.offsetHeight / 2
    -    const desired = targetCenter - root.clientHeight / 2
    -    const maxScroll = root.scrollHeight - root.clientHeight
    -    const to = Math.max(0, Math.min(maxScroll, desired))
    -    const from = root.scrollTop
    -    const distance = to - from
    -    if (Math.abs(distance) < 1) return
    -
    -    // Cancel any in-flight animation so rapid Up/Down presses don't
    -    // compound into a runaway scroll.
    -    if (scrollAnimFrameRef.current !== null) {
    -      cancelAnimationFrame(scrollAnimFrameRef.current)
    -      scrollAnimFrameRef.current = null
    -    }
    -
    -    const duration = 180
    -    const startTime = performance.now()
    -    const ease = (t: number) => 1 - Math.pow(1 - t, 3) // easeOutCubic
    -
    -    const step = (now: number) => {
    -      const elapsed = now - startTime
    -      const t = Math.min(1, elapsed / duration)
    -      root.scrollTop = from + distance * ease(t)
    -      if (t < 1) {
    -        scrollAnimFrameRef.current = requestAnimationFrame(step)
    -      } else {
    -        scrollAnimFrameRef.current = null
    -      }
    -    }
    -    scrollAnimFrameRef.current = requestAnimationFrame(step)
    -
    -    return () => {
    -      if (scrollAnimFrameRef.current !== null) {
    -        cancelAnimationFrame(scrollAnimFrameRef.current)
    -        scrollAnimFrameRef.current = null
    -      }
    -    }
    -  }, [pickerSelectedUuid])
    -
    -  useEffect(() => {
    -    if (scrollToLatestRequest === 0) return
    -    const el = scrollerRef.current
    -    if (!el) return
    -    if (scrollAnimFrameRef.current !== null) {
    -      cancelAnimationFrame(scrollAnimFrameRef.current)
    -      scrollAnimFrameRef.current = null
    -    }
    -    el.scrollTop = el.scrollHeight
    -    stickyBottomRef.current = true
    -    lastScrollTopRef.current = el.scrollTop
    -    scrollPositions.set(sessionId, {
    -      scrollTop: el.scrollTop,
    -      stickyBottom: true,
    -    })
    -    if (onScrollInfo) onScrollInfo({ fraction: 0 })
    -  }, [onScrollInfo, scrollToLatestRequest, sessionId])
    -
    -  // Visible entries skip system/meta noise by default. isMeta entries are
    -  // CC's system-injected user-role
    -  // messages: task-notification results from subagents, auto-continue
    -  // hints ("Continue from where you left off."), system-reminder
    -  // payloads, etc. They carry `isMeta: true` on the entry but pass
    -  // the isConversationEntry check because they have type='user' and
    -  // a real message object. Without this filter, a background agent's
    -  // completion notification would render as a full UserBand in the
    -  // feed with raw  XML visible — exactly the bug
    -  // the user reported.
    -  const visible = entries.filter(e => {
    -    if (isCompactBoundaryEntry(e)) return true
    -    if (isCompactSummaryEntry(e)) return true
    -    if (!isConversationEntry(e)) return false
    -    if (asRecord(e)?.isMeta === true) return false
    -    return true
    -  })
    -
    -  // Index EVERY tool_use block (not just the visible set) so tool_result
    -  // lookups still resolve even when some synthetic entries have been
    -  // filtered out. The index is cheap to
    -  // build (single pass) and the resulting Map is handed to result rows
    -  // via context.
    -  // Incremental indices live on the runtime and grow at entry-ingest
    -  // time (see workspaceStore.indexEntryIntoMaps). When Feed is mounted
    -  // outside the workspace store (tests, future surfaces) the props
    -  // are unset and we fall back to building the maps once from
    -  // `entries`. The fallback useMemo is unconditional — React's rules
    -  // forbid conditional hooks, so we always call it; the `.has(0)`
    -  // shortcut keeps cost negligible when the props are present.
    -  const fallbackToolUseIndex = useMemo(() => buildToolUseIndex(entries), [entries])
    -  const fallbackToolResultIndex = useMemo(() => buildToolResultIndex(entries), [entries])
    -  const toolUseIndex = toolUseIndexProp ?? fallbackToolUseIndex
    -  const toolResultIndex = toolResultIndexProp ?? fallbackToolResultIndex
    -
    -  const hasSemanticStreaming = semanticTurn !== null
    -
    -  if (visible.length === 0 && !hasSemanticStreaming) {
    -    return (
    -      
    -
    - {provider === 'codex' ? 'waiting for Codex…' : 'waiting for Claude Code…'} -
    -
    - ) - } - - return ( - - - - -
    - {/* Bigger pb than Agent Code so the last message doesn't sit - flush against the harness composer bar. Vendored Feed — - Agent Code's copy still uses pb-8. */} -
    - {visible.map((e, i) => { - const uuid = (e as Entry).uuid - const key = uuid ?? `i${i}` - // The last EAGER_TAIL entries render immediately — they're - // in or near the current viewport (the user sees the bottom - // of the feed on load). Everything above starts as a - // lightweight placeholder and mounts when the user scrolls - // up to it. Once mounted, never unmounts — React.memo - // keeps re-renders free and we avoid the re-parse cost - // that virtualization (unmount→remount) would cause. - const eager = i >= visible.length - EAGER_TAIL - const selected = - pickerSelectedUuid != null && uuid === pickerSelectedUuid - // Wrapper carries the uuid attribute (for scrollIntoView - // lookup) and the outline class when selected by the - // Copy Assistant picker. - return ( -
    - - - -
    - ) - })} - {/* ONE owner rule — only the semantic turn (from - claude-code-headless / codex-headless) may render live - assistant text. The previous `` fallback, - which ran its own regex over the raw TUI screen at - render time, is intentionally removed: two producers - for the same feed slot was the structural cause of - REND-2 (resume double-render) and REND-3 (stale - previous turn text leaking into the new turn). Live - text now has exactly one source, `semanticTurn`. - Screen snapshots still reach the harness's raw-terminal - debug panel via the `screen` state — they just don't - participate in rendering. */} - {semanticTurn != null && ( - - )} - {/* Activity indicator: shown when the agent is working but - no semantic turn is in flight (tool execution between - turns, very start of a turn before the first semantic - event). The streaming surface handles its own - "thinking…" state inside the turn; this only appears - when no turn is mounted at all. */} - {semanticTurn == null && activityStatus && ( - - )} -
    -
    -
    - - - - - ) -} - -function countFenceMarkers(text: string): number { - const matches = text.match(/```/g) - return matches ? matches.length : 0 -} - -function splitStreamingCodeFence(text: string): { - prose: string - code: string - language: string | null -} | null { - const lastFence = text.lastIndexOf('```') - if (lastFence === -1) return null - if (countFenceMarkers(text) % 2 === 0) return null - - const openingLine = text.slice(lastFence).split('\n', 1)[0] ?? '' - const language = openingLine.slice(3).trim() || null - const code = text.slice(lastFence + openingLine.length) - .replace(/^\n/, '') - return { - prose: text.slice(0, lastFence).trimEnd(), - code, - language, - } -} - -type SemanticRenderUnit = - | { - type: 'block' - block: SemanticLiveTurn['blocks'][number] - toolState: SemanticLiveTurn['lookups']['toolCallsById'][string] | null - } - | { - type: 'collapsed_activity' - count: number - searchCount: number - readCount: number - listCount: number - bashCount: number - latestHint: string | null - blockIndices: number[] - isRunning: boolean - } - -// Bash classifier helpers. -// -// WHY anchored to command position, not plain \b: -// Earlier version used /\b(ls|tree|du)\b/ — that matched inside -// argv, so `pipx install tree-sitter` classified as "list" and -// `sudo ls` or `cat foo-tree.txt` matched too. We only want to -// fire when the name is the *program being invoked*, i.e. at the -// start of the command or at a pipeline/boundary separator -// (`;`, `|`, `||`, `&&`), optionally preceded by env-var -// assignments (`FOO=bar`) or `sudo`. -// -// The regex below captures: (start | separator) (env-var prefix?) -// (sudo?) (one of the names) (followed by end or whitespace). -// `git grep` is handled as its own leading-position pattern. -const COMMAND_START = /(?:^|[;|&]\s*)(?:[A-Z_][A-Z0-9_]*=\S+\s+)*(?:sudo\s+)?/i - -function atCommandPosition(command: string, names: readonly string[]): boolean { - const alternation = names.map(n => n.replace(/\s+/g, '\\s+')).join('|') - const re = new RegExp(`${COMMAND_START.source}(?:${alternation})(?=\\s|$)`, 'i') - return re.test(command) -} - -function looksLikeSearchCommand(command: string): boolean { - return atCommandPosition(command, ['rg', 'grep', 'find', 'fd', 'ag', 'ack', 'git grep']) -} - -function looksLikeReadCommand(command: string): boolean { - return atCommandPosition(command, ['cat', 'less', 'more', 'head', 'tail', 'sed', 'awk', 'wc']) -} - -function looksLikeListCommand(command: string): boolean { - return atCommandPosition(command, ['ls', 'tree', 'du']) -} - -function classifySemanticToolActivity(block: SemanticLiveTurn['blocks'][number]): { - collapsible: boolean - category: 'search' | 'read' | 'list' | 'bash' | null - hint: string | null -} { - const toolName = block.toolName ?? '' - const parsed = block.parsedInput ?? {} - const pathLike = - typeof parsed.file_path === 'string' - ? parsed.file_path - : typeof parsed.path === 'string' - ? parsed.path - : null - - if (toolName === 'Glob' || toolName === 'Grep') { - const pattern = - typeof parsed.pattern === 'string' - ? parsed.pattern - : typeof parsed.glob === 'string' - ? parsed.glob - : null - return { - collapsible: true, - category: 'search', - hint: pattern ? `"${pattern}"` : pathLike, - } - } - - if (toolName === 'Read' || toolName === 'FileRead') { - return { - collapsible: true, - category: 'read', - hint: pathLike, - } - } - - if (toolName === 'Bash') { - const command = - typeof parsed.command === 'string' - ? parsed.command.trim() - : null - if (!command) return { collapsible: false, category: null, hint: null } - if (looksLikeListCommand(command)) { - return { collapsible: true, category: 'list', hint: command } - } - if (looksLikeSearchCommand(command)) { - return { collapsible: true, category: 'search', hint: command } - } - if (looksLikeReadCommand(command)) { - return { collapsible: true, category: 'read', hint: command } - } - return { collapsible: true, category: 'bash', hint: command } - } - - return { collapsible: false, category: null, hint: null } -} - -function buildSemanticRenderUnits(turn: SemanticLiveTurn): SemanticRenderUnit[] { - const blocks = Object.values(turn.blocks).sort((a, b) => a.blockIndex - b.blockIndex) - const units: SemanticRenderUnit[] = [] - let pending: Extract | null = null - - const flush = () => { - if (!pending) return - units.push(pending) - pending = null - } - - // WHY add a derived render-unit pass before painting semantic blocks: - // - // Claude Code does not render raw transcript/tool rows directly for noisy - // low-signal activity. It groups read/search/tool churn into summary units - // first, then the UI renders those summaries. Agent Code is not at full parity - // yet, but even this narrow pass moves us away from "one semantic block = - // one visual row" and toward the same safer architecture. - for (const block of blocks) { - const toolState = block.toolUseId - ? turn.lookups.toolCallsById[block.toolUseId] ?? null - : null - const activity = classifySemanticToolActivity(block) - const isCollapsibleTool = - (block.kind === 'tool_use' || - block.kind === 'server_tool_use' || - block.kind === 'mcp_tool_use') && - activity.collapsible && - activity.category !== null - - if (!isCollapsibleTool) { - flush() - units.push({ type: 'block', block, toolState }) - continue - } - - if (!pending) { - pending = { - type: 'collapsed_activity', - count: 0, - searchCount: 0, - readCount: 0, - listCount: 0, - bashCount: 0, - latestHint: null, - blockIndices: [], - isRunning: false, - } - } - - pending.count += 1 - pending.blockIndices.push(block.blockIndex) - pending.latestHint = activity.hint ?? pending.latestHint - if (toolState?.status === 'in_progress') pending.isRunning = true - - if (activity.category === 'search') pending.searchCount += 1 - else if (activity.category === 'read') pending.readCount += 1 - else if (activity.category === 'list') pending.listCount += 1 - else if (activity.category === 'bash') pending.bashCount += 1 - } - - flush() - return units -} - -const SemanticStreamingTurn = memo(function SemanticStreamingTurn({ - turn, -}: { - turn: SemanticLiveTurn -}) { - // WHY render the semantic turn block-by-block instead of just dumping - // `turn.text` through markdown: - // - // The proxy stream already tells us where text, thinking, tool_use, - // connector_text, and tool results begin and end. If we collapse all of that - // back into one markdown blob, we recreate the exact failure mode that made - // screen parsing brittle: code fences, todo lists, tool boundaries, and agent - // progress all become heuristics again. The whole point of the semantic path - // is to stop inferring structure from terminal paint when upstream already - // gave us the structure directly. - const blocks = Object.values(turn.blocks).sort((a, b) => a.blockIndex - b.blockIndex) - const units = buildSemanticRenderUnits(turn) - const hasBlocks = blocks.length > 0 - - if (!hasBlocks) { - return ( - - {turn.text ? : null} - - ) - } - - return ( - <> - - {units.map(unit => ( - unit.type === 'collapsed_activity' ? ( - - ) : ( - - ) - ))} - - - ) -}) - -const SemanticCollapsedActivityRow = memo(function SemanticCollapsedActivityRow({ - unit, -}: { - unit: Extract -}) { - const parts: string[] = [] - if (unit.searchCount > 0) parts.push(`${unit.searchCount} search${unit.searchCount === 1 ? '' : 'es'}`) - if (unit.readCount > 0) parts.push(`${unit.readCount} read${unit.readCount === 1 ? '' : 's'}`) - if (unit.listCount > 0) parts.push(`${unit.listCount} list${unit.listCount === 1 ? '' : 's'}`) - if (unit.bashCount > 0) parts.push(`${unit.bashCount} bash`) - const summary = parts.length > 0 ? parts.join(', ') : `${unit.count} tool calls` - - return ( - -
    -
    - {unit.isRunning ? 'working:' : 'worked:'} {summary} -
    - {unit.latestHint ? ( -
    - {unit.latestHint} -
    - ) : null} -
    -
    - ) -}) - -const SemanticTaskSummary = memo(function SemanticTaskSummary({ - turn, -}: { - turn: SemanticLiveTurn -}) { - const hasTodos = turn.task.totalCount > 0 - const activeTools = turn.task.activeToolNames - const completedTools = turn.lookups.resolvedToolUseIds.length - const erroredTools = turn.lookups.erroredToolUseIds.length - if (!hasTodos && activeTools.length === 0 && completedTools === 0) return null - - // WHY keep a compact task summary above the raw blocks: - // - // Upstream Claude renders task/agent progress from dedicated task state, not - // by hoping the user can mentally reconstruct it from scattered tool rows. - // Agent Code is not at full upstream parity yet, but exposing the derived task - // snapshot here gives the feed one stable place to surface "what is the - // session working on right now?" without re-parsing markdown or screen text. - return ( - -
    - {hasTodos ? ( - - todos: {turn.task.doneCount}/{turn.task.totalCount} - - ) : null} - {activeTools.length > 0 ? ( - - active: {activeTools.slice(0, 3).join(', ')} - {activeTools.length > 3 ? ` +${activeTools.length - 3}` : ''} - - ) : null} - {completedTools > 0 ? ( - - tools: {completedTools} done - {erroredTools > 0 ? `, ${erroredTools} failed` : ''} - - ) : null} -
    -
    - ) -}) - -const SemanticTodoList = memo(function SemanticTodoList({ - todos, -}: { - todos: SemanticTodoItem[] -}) { - const done = todos.filter(todo => todo.status === 'completed').length - return ( -
    -
    - TodoWrite - - {done} / {todos.length} done - -
    - {todos.length === 0 ? ( -
    (empty list)
    - ) : ( -
      - {todos.map((todo, index) => { - const glyph = - todo.status === 'completed' - ? '☑' - : todo.status === 'in_progress' - ? '◐' - : '☐' - const glyphCls = - todo.status === 'pending' ? 'text-muted' : 'text-accent' - const textCls = - todo.status === 'completed' - ? 'text-muted line-through' - : todo.status === 'in_progress' - ? 'text-ink' - : 'text-ink-dim' - const label = - todo.status === 'in_progress' && todo.activeForm - ? todo.activeForm - : todo.content - return ( -
    • - - - {label} - -
    • - ) - })} -
    - )} -
    - ) -}) - -const SemanticLiveBlockRow = memo(function SemanticLiveBlockRow({ - block, - toolState, -}: { - block: SemanticLiveTurn['blocks'][number] - toolState: SemanticLiveTurn['lookups']['toolCallsById'][string] | null -}) { - if (block.kind === 'thinking') { - // Live thinking — this is the ONLY time the plaintext is - // available. Anthropic strips `thinking` on the final message - // (only the signature ciphertext persists to disk), so once the - // turn ends this block's text is gone. Matches claude-code's - // AssistantThinkingMessage with `isTranscriptMode=true` — dim - // italic markdown, always open, no
    wrapper. The "…" - // suffix only appears while the turn is still streaming; a - // parent / stop-reason indicates when - // it's done. - const text = block.thinking ?? '' - return ( - -
    -
    ∴ Thinking{text ? '' : '…'}
    - {text ? ( -
    - -
    - ) : null} -
    -
    - ) - } - - if ( - block.kind === 'tool_use' || - block.kind === 'server_tool_use' || - block.kind === 'mcp_tool_use' - ) { - // WHY keep tool results nested under the tool row: - // - // Claude's transcript wire format splits tool_use and tool_result across - // assistant/user turns, but from a reading standpoint they are one unit of - // work. Nesting the result here preserves that mental model during live - // streaming and avoids another round of "find the matching tool later in the - // feed" bookkeeping. - const todos = - block.toolName === 'TodoWrite' - ? parseSemanticTodos(block.parsedInput) - : [] - const hasResult = block.resultAt != null || block.resultContent != null - return ( - -
    -
    - - {block.toolName ?? block.kind} - - {toolState ? ( - - {toolState.status === 'in_progress' - ? 'running' - : toolState.status === 'error' - ? 'failed' - : 'done'} - - ) : null} -
    - {block.toolName === 'TodoWrite' ? ( - - ) : ( - -
    -                {block.inputJson || '(waiting for input…)'}
    -              
    -
    - )} - {block.parseError ? ( - -
    - invalid tool input: {block.parseError} -
    -
    - ) : null} - {hasResult ? ( - -
    -                {block.resultContent || '(empty result)'}
    -              
    -
    - ) : null} -
    -
    - ) - } - - const text = block.text ?? '' - const fence = text ? splitStreamingCodeFence(text) : null - if (fence) { - return ( - -
    - {fence.prose ? : null} - -
    -
    - ) - } - - if (block.citations && block.citations.length > 0) { - return ( - -
    - {text ? : null} -
    - {block.citations.length} citation{block.citations.length === 1 ? '' : 's'} -
    -
    -
    - ) - } - - return ( - - - - ) -}) - -const SemanticTurnFooter = memo(function SemanticTurnFooter({ - turn, -}: { - turn: SemanticLiveTurn -}) { - const usage = turn.usage - const outputTokens = - typeof usage?.output_tokens === 'number' - ? usage.output_tokens - : typeof usage?.['usage.output_tokens'] === 'number' - ? usage['usage.output_tokens'] - : null - const inputTokens = - typeof usage?.input_tokens === 'number' - ? usage.input_tokens - : typeof usage?.['usage.input_tokens'] === 'number' - ? usage['usage.input_tokens'] - : null - const hasUsage = inputTokens !== null || outputTokens !== null - const hasStop = turn.stopReason != null - - if (!hasUsage && !hasStop) return null - - return ( - -
    - {hasStop ? stop: {turn.stopReason} : null} - {inputTokens !== null ? in: {inputTokens} : null} - {outputTokens !== null ? out: {outputTokens} : null} -
    -
    - ) -}) - -// --------------------------------------------------------------------------- -// Lazy entry mounting — the key to rendering fat conversations. -// --------------------------------------------------------------------------- -// -// Problem: a resumed session can have 200+ entries. Mounting all of them -// at once means 200 ReactMarkdown parses + CodeBlock syntax highlights -// in a single blocking render pass. That's multiple seconds of frozen UI, -// and the DOM ends up with thousands of nodes the browser has to lay out -// even though the user only sees the bottom ~20 entries. -// -// Solution: entries in the last EAGER_TAIL positions render immediately -// (they're in/near the viewport). Everything above starts as a thin -// placeholder div. An IntersectionObserver watches each placeholder and -// swaps in the real content when the user scrolls up to it. Once -// mounted, the entry stays mounted forever — React.memo keeps -// subsequent re-renders free, and we avoid the re-parse cost that full -// virtualization (unmount→remount on scroll) would cause. -// -// Why not full virtualization (react-window / tanstack-virtual): -// - Our entry count is low hundreds, not tens of thousands. -// - Virtualization unmounts rows that scroll out of view. Re-mounting -// means re-parsing markdown (React.memo doesn't survive unmount). -// We'd need a separate parsed-output cache. Complexity for no gain. -// - Variable row heights (user prompt = 1 line, assistant with code -// blocks = 500px+) make fixed-size virtualizers useless and -// measured-height virtualizers finicky. -// - The streaming row at the bottom grows continuously during -// generation. Virtualizers don't handle a row whose height changes -// every frame well. -// -// The EAGER_TAIL count is generous: 30 entries covers roughly 2-3 -// screenfuls so the user never sees a placeholder flash on initial -// load or on tab-switch restore. Scrolling up past the eager zone -// triggers lazy mount with a 200px rootMargin (entries mount one -// screenful before they're visible), so the user never sees the swap. - -const EAGER_TAIL = 30 - -/** - * Wraps a single feed entry. If `eager` is true, renders children - * immediately. Otherwise, renders a placeholder and waits for the - * IntersectionObserver to fire before mounting the real content. - * - * Once mounted, stays mounted permanently — the `mounted` state only - * transitions false→true, never back. This is load-bearing: unmounting - * would discard React.memo's cached render tree and force a full - * re-parse of the entry's markdown on the next scroll-into-view. - */ -const LazyEntry = memo(function LazyEntry({ - eager, - suspended = false, - scrollerRef, - children, -}: { - eager: boolean - // True while the owning session is in a bootstrap burst. Suspends - // observer attachment so 200 placeholders don't all mount at once - // during a resume replay. Feed re-enables by flipping this back to - // false after the bootstrap debounce clears (see Feed's - // prevBootstrappingRef pin-once effect for the scroll pairing). - suspended?: boolean - scrollerRef: React.RefObject - children: ReactNode -}) { - const [mounted, setMounted] = useState(eager) - const placeholderRef = useRef(null) - - // If this entry starts lazy but later falls into the eager zone - // (e.g. entries above it were filtered out, shrinking the list), - // mount it immediately. - useEffect(() => { - if (eager && !mounted) setMounted(true) - }, [eager, mounted]) - - useEffect(() => { - if (mounted) return - // While the owning session is in a bootstrap burst, don't attach - // the observer. Without this guard a resume replay pins to the - // bottom and every placeholder within rootMargin flips mounted in - // the same render — a cascade that's the actual "scrolling - // through the whole conversation" symptom. Once the parent clears - // `suspended`, Feed's pin-once effect lands us at the bottom and - // normal scroll-triggered mounts take over from there. - if (suspended) return - const el = placeholderRef.current - if (!el) return - - // Use the scroll container as the intersection root so the - // observer fires relative to the visible scroll area, not the - // document viewport. rootMargin adds 200px of lookahead above - // the viewport so entries mount before the user scrolls to them - // — no visible placeholder flash. - const observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting) { - setMounted(true) - observer.disconnect() - } - }, - { root: scrollerRef.current, rootMargin: '200px 0px 200px 0px' }, - ) - observer.observe(el) - return () => observer.disconnect() - }, [mounted, suspended, scrollerRef]) - - if (!mounted) { - // Placeholder: a fixed-height div that approximates a typical - // entry. The exact height doesn't matter much — it just needs to - // be non-zero so the scroll container has enough height for the - // IntersectionObserver to fire as the user scrolls. Once the real - // content mounts, it takes its natural height and the scrollbar - // adjusts. The 48px estimate is conservative (a single-line tool - // result or system row). Taller entries will cause a small layout - // shift on mount, but that happens off-screen (200px above the - // viewport) so the user never sees it. - return
    - } - - return <>{children} -}) - -// Memoized: entry objects are stable across store updates (we append, -// never mutate), so shallow compare by entry reference skips re-render -// for every row that didn't itself change. -const EntryRow = memo(function EntryRow({ entry }: { entry: Entry }) { - if (isCompactBoundaryEntry(entry)) { - return - } - if (isCompactSummaryEntry(entry)) { - return - } - if (isConversationEntry(entry)) { - return - } - return -}) - -const ConversationRow = memo(function ConversationRow({ - entry, -}: { - entry: ConversationEntry -}) { - const role = entry.message.role - const content = entry.message.content - - // Simple string content — render as a single marker + text line. - // For role==='user' this IS a real user prompt (no tool_result can - // appear here because tool_results are always block-form), so this - // is the one place a top-level UserBand is correct. - if (typeof content === 'string') { - if (role === 'user') { - return ( - - - - - - ) - } - return ( - - - - ) - } - - if (!Array.isArray(content)) return null - - // Multi-block content — render each block with its own layout. - // - // CRITICAL: we do NOT wrap this whole row in a UserBand even when - // role === 'user'. That's because tool_result blocks ride inside - // user-role messages (Anthropic API shape: the user turn that follows - // an assistant tool_use holds the tool_result), and painting the - // user-prompt highlight behind tool output looks identical to saying - // "this file read was a user prompt." Confusing and wrong. - // - // The band lives at the *block* level instead: Block() wraps text - // blocks in a UserBand when role === 'user', and leaves every other - // block type (tool_use, tool_result, thinking) visually untouched. - return ( -
    - {content.map((block, i) => ( - - ))} -
    - ) -}) - -/** - * UserBand — a horizontal highlight band that sits behind a *user - * prompt* so real user turns are easy to spot when scanning a long - * feed. Only ever wraps text content that originated as a user prompt. - * Never wraps tool_result output (even though tool_result blocks live - * under role='user' on the wire) — see the comment in ConversationRow. - * - * Why negative horizontal margin + matching padding: - * Feed rows sit inside a `px-8` centered column. If we just slapped - * bg-user-bg on the row, the highlight would be narrower than the - * column's gutters and look like a tight card. Pulling the band out - * to -mx-8 and compensating with px-8 makes the fill edge-to-edge - * within the column while keeping the text at its original x. - * gap-4 on the parent handles vertical separation between bands. - */ -function UserBand({ children }: { children: React.ReactNode }) { - return ( -
    - {children} -
    - ) -} - -const CompactBoundaryRow = memo(function CompactBoundaryRow() { - return ( - -
    - Conversation compacted -
    -
    - ) -}) - -const CompactSummaryRow = memo(function CompactSummaryRow({ - entry, -}: { - entry: CompactSummaryEntry -}) { - const [expanded, setExpanded] = useState(false) - const text = useMemo(() => compactSummaryText(entry), [entry]) - const compact = text.length > 2400 || text.split('\n').length > 24 - const visibleText = compact && !expanded ? truncateCompactSummary(text) : text - - return ( -
    -
    -
    - Conversation Summary -
    - {compact && ( - - )} -
    -
    - -
    -
    - ) -}) - -function compactSummaryText(entry: CompactSummaryEntry): string { - const content = entry.message.content - if (typeof content === 'string') return content - if (!Array.isArray(content)) return '' - return content - .map(block => { - const item = block as ContentBlock & { text?: string; thinking?: string } - if (item.type === 'text' && typeof item.text === 'string') return item.text - if (item.type === 'thinking' && typeof item.thinking === 'string') return item.thinking - return '' - }) - .filter(Boolean) - .join('\n\n') -} - -function truncateCompactSummary(text: string): string { - const lines = text.split('\n') - if (lines.length > 24) { - return `${lines.slice(0, 24).join('\n')}\n\n[summary truncated]` - } - if (text.length > 2400) { - return `${text.slice(0, 2400).trimEnd()}\n\n[summary truncated]` - } - return text -} - -/** - * Background band for tool output (Read, Grep, Edit results). - * Subtler than UserBand — a faint step away from canvas that groups - * tool output visually without competing with user turns or assistant - * text. Same edge-to-edge trick as UserBand. - */ -function ToolBand({ children }: { children: React.ReactNode }) { - return ( -
    - {children} -
    - ) -} - -/* ---------- Marker layout primitives ---------- */ - -/** - * The core layout: a fixed-width marker column + flex-1 content column - * that enforces hanging indent. Used by every rendered block. - */ -export function MarkerRow({ - marker, - tone = 'accent', - children, - indent = 0, -}: { - marker: string - tone?: 'accent' | 'muted' | 'ink' - children: React.ReactNode - indent?: number -}) { - const toneClass = - tone === 'accent' ? 'text-accent' : tone === 'muted' ? 'text-muted' : 'text-ink-dim' - return ( -
    - -
    {children}
    -
    - ) -} - -/* ---------- Block dispatcher ---------- */ - -// Memoized: blocks inside an assistant/user message are stable objects — -// the entry never mutates, so block identity is a perfect memo key. -const Block = memo(function Block({ - block, - role, -}: { - block: ContentBlock - role: 'user' | 'assistant' -}) { - const currentProvider = useContext(ProviderContext) - const toolUseIndex = useContext(ToolUseIndexContext) - const toolResultIndex = useContext(ToolResultIndexContext) - const customRendering = useAppStore(state => state.settings.customRendering) - switch (block.type) { - case 'text': { - // Only text blocks under a user role represent an actual user - // prompt. A sibling tool_result block in the same message is - // NOT a user prompt (it's tool output), and must not get the - // highlight — that's why the band lives here and not around - // the whole ConversationRow. - const row = ( - - - - ) - return role === 'user' ? {row} : row - } - case 'thinking': { - // Anthropic's extended-thinking feature ships completed blocks - // with `thinking: ""` and a long `signature` ciphertext — the - // plaintext is only available during the live turn via - // `thinking_delta` SSE events; it is NOT persisted to disk. - // The old `
    thinking
    ` disclosure always - // opened to an empty `
    `, which looked like a renderer
    -      // bug (the user clicked, got nothing, and assumed we dropped
    -      // the text). Mirror claude-code's `AssistantThinkingMessage`
    -      // placeholder instead: a single dim italic `∴ Thinking` row,
    -      // no clickable disclosure when there's nothing to reveal.
    -      // Live thinking text is rendered by 
    -      // (reads semanticTurn.blocks[i].thinking), not here.
    -      const text = (block as { thinking?: string }).thinking ?? ''
    -      if (!text) {
    -        return (
    -          
    -            
    -              ∴ Thinking
    -            
    -          
    -        )
    -      }
    -      // Non-empty thinking plaintext on a completed block is rare
    -      // (older sessions, non-Opus-4 models, synthetic entries). Keep
    -      // the expandable surface for those.
    -      return (
    -        
    -          
    - - ∴ Thinking - -
    - -
    -
    -
    - ) - } - case 'tool_use': { - // Dispatch tool_use blocks to provider-specific row renderers. - // Claude has rich renderers for Edit/MultiEdit/Write/TodoWrite; - // codex uses a generic CodexToolRow for now (will grow per-tool - // renderers as we learn codex's tool shapes from recordings). - const tu = block as ToolUseBlock - - // Custom rendering: intercept shell/bash invocations that are - // recognized git commands and render them as a purpose-built - // widget. Claude's tool name is 'Bash'; Codex's is - // 'exec_command' (the function-call name). Both carry the - // command string via extractToolCommand. - // - // We render on the tool_use row. The paired result block is - // looked up from the reverse index; if not yet present (result - // hasn't arrived), the widget shows a "running…" placeholder - // sourced purely from the command. The companion tool_result - // block is suppressed below so the widget is the single - // surface for this command. - if ( - customRendering - && (tu.name === 'Bash' || tu.name === 'exec_command') - ) { - const cmd = extractToolCommand(tu) - const intent = detectGitIntent(cmd) - if (intent && cmd) { - const paired = toolResultIndex.get(tu.id) - const output = paired ? toolResultText(paired) : '' - return ( - - - - ) - } - } - - if (currentProvider === 'codex') { - return - } - // Claude provider — dispatch by tool name. - switch (tu.name) { - case 'Edit': - return - case 'MultiEdit': - return - case 'Write': - return - case 'TodoWrite': - return - default: - return - } - } - case 'tool_result': { - const tr = block as ToolResultBlock - // When custom rendering captured this result's source tool as - // a git command, the tool_use row already rendered the widget - // and consumed the output. Render nothing here so the output - // doesn't duplicate below the card. - if (customRendering) { - const sourceTu = toolUseIndex.get(tr.tool_use_id) - if ( - sourceTu - && (sourceTu.name === 'Bash' || sourceTu.name === 'exec_command') - && detectGitIntent(extractToolCommand(sourceTu)) - ) { - return null - } - } - if (currentProvider === 'codex') { - return - } - return - } - default: - return ( - -
    - {block.type} -
    -
    - ) - } -}) - -/* ---------- Text prose ---------- */ - -// Memoized: `text` is a plain string, so shallow compare is exact -// equality. This is the single biggest win in the file — markdown -// parsing is the expensive part, and by memoing on the text string we -// skip the unified pipeline entirely for every row that didn't change. -const TextProse = memo(function TextProse({ text }: { text: string }) { - if (!text) return null - return ( -
    - - {text} - -
    - ) -}) - -/** - * Same visual surface as TextProse, but uses the streaming plugin set - * (remark-breaks added) so hard newlines from the screen buffer survive - * as
    in the rendered output. See the comment on STREAMING_REMARK - * above for the full reasoning. - * - * Memoized by text string too: CC's screen re-renders fire at ~60Hz - * and the extracted assistant text is usually identical between frames - * (CC is redrawing chrome, not changing content). Memoing here turns - * those redundant frames into free ones. - */ -const StreamingProse = memo(function StreamingProse({ - text, -}: { - text: string -}) { - if (!text) return null - return ( -
    - - {text} - -
    - ) -}) - -/* ---------- Tool use: "⏺ Bash ⎿ $ command" ---------- */ - -// Bash-command display cap, mirroring claude-code's Ink UI: -// claude-code-src/full/tools/BashTool/UI.tsx -// const MAX_COMMAND_DISPLAY_LINES = 2 -// const MAX_COMMAND_DISPLAY_CHARS = 160 -// Long / multiline invocations truncate with a trailing `…`. The -// whole command is still available via the raw JSONL panel in the -// harness and via the transcript on disk, so the collapse is purely -// cosmetic — the feed stays dense. -const MAX_COMMAND_DISPLAY_LINES = 2 -const MAX_COMMAND_DISPLAY_CHARS = 160 - -function truncateBashCommand(cmd: string): { text: string; truncated: boolean } { - const lines = cmd.split('\n') - const needsLineTruncation = lines.length > MAX_COMMAND_DISPLAY_LINES - const needsCharTruncation = cmd.length > MAX_COMMAND_DISPLAY_CHARS - if (!needsLineTruncation && !needsCharTruncation) { - return { text: cmd, truncated: false } - } - let truncated = cmd - if (needsLineTruncation) { - truncated = lines.slice(0, MAX_COMMAND_DISPLAY_LINES).join('\n') - } - if (truncated.length > MAX_COMMAND_DISPLAY_CHARS) { - truncated = truncated.slice(0, MAX_COMMAND_DISPLAY_CHARS) - } - return { text: truncated.trimEnd() + '…', truncated: true } -} - -const ToolUseRow = memo(function ToolUseRow({ block }: { block: ToolUseBlock }) { - // Extract the command / description for Bash-like tools. For tools - // without a `command` field we fall back to stringified input. - const input = asRecord(block.input) - const rawHeadline = - stringField(input, 'command') ?? - stringField(input, 'description') ?? - stringField(input, 'path') ?? - null - - // Bash commands get the same 2-line / 160-char cap claude-code's - // Ink UI enforces. `description` and `path` headlines are already - // one-liner-ish so we only truncate when this is a command. - const headline = (() => { - if (!rawHeadline) return null - if (block.name === 'Bash' && typeof input?.command === 'string') { - return truncateBashCommand(input.command).text - } - return rawHeadline - })() - - return ( - -
    -
    - {block.name} -
    - {headline && ( - -
    -              {headline}
    -            
    -
    - )} -
    -
    - ) -}) - -/* ---------- Tool result: "⎿ (lines of output)" ---------- */ - -/** - * Look at the tool_use this result came from (via the feed-level - * index in context) and decide how to render the result: - * - * Read → strip the "N→" line-number prefix CC's Read tool emits, - * and render the contents as a preformatted code slab. We - * deliberately skip markdown parsing here because source - * code frequently contains triple-backticks and unbalanced - * emphasis that would wreck the markdown AST. For full - * syntax highlighting later we can feed the stripped text - * through highlight.js directly. - * - * Edit / MultiEdit / Write → the diff/content already rendered on - * the preceding tool_use row tells the story. The terse - * "has been updated successfully" message is pure noise - * next to it; suppress for non-errors. - * - * everything else (Bash, Glob, Grep, …) → keep the existing - * plain-pre rendering. The content IS the interesting part - * for those tools. - */ -const ToolResultRow = memo(function ToolResultRow({ - block, -}: { - block: ToolResultBlock -}) { - const toolUseIndex = useContext(ToolUseIndexContext) - const codeContext = useContext(CodeRenderContext) - const sourceTool = toolUseIndex.get(block.tool_use_id)?.name - - const text = - typeof block.content === 'string' - ? block.content - : Array.isArray(block.content) - ? block.content - .map(c => (typeof c === 'string' ? c : c.text ?? '')) - .join('\n') - : String(block.content) - - const isError = block.is_error === true - const trimmed = text.replace(/\s+$/, '') - - // File-write tools AND TodoWrite: the rendered diff/content/checklist - // on the preceding tool_use row already tells the story. The result - // in all four cases is a stub success string that would just clutter - // the feed. Errors still fall through to the normal result renderer - // so failures remain visible. - if ( - !isError && - (sourceTool === 'Edit' || - sourceTool === 'MultiEdit' || - sourceTool === 'Write' || - sourceTool === 'TodoWrite') - ) { - return null - } - - // Read tool result — show a one-line summary only, not the file - // contents. Mirrors claude-code-src/full/tools/FileReadTool/UI.tsx - // renderToolResultMessage which renders "Read lines" / - // "Read image (size)" / "Read cells" with height={1} and - // NEVER echoes the file bytes into the feed. The user already - // knows what file was read (it's on the tool-use row); dumping - // its full contents below is pure noise. - // - // A click-to-expand
    keeps the raw content one interaction - // away so the harness is still useful for debugging Read-result - // parsing regressions. Syntax highlighting happens inside the - // CodeBlock only when expanded. - if (sourceTool === 'Read' && !isError) { - const stripped = stripLineNumberPrefix(trimmed) - const numLines = stripped ? stripped.split('\n').length : 0 - const sourceInput = toolUseIndex.get(block.tool_use_id)?.input as - | Record - | undefined - const filePath = - typeof sourceInput?.file_path === 'string' - ? sourceInput.file_path - : typeof sourceInput?.path === 'string' - ? sourceInput.path - : null - return ( - -
    - - Read {numLines}{' '} - {numLines === 1 ? 'line' : 'lines'} - -
    - -
    -
    -
    - ) - } - - // Everything else — Bash, Grep, Glob, LS, tool errors — truncates - // to the first few lines and offers a click-to-expand for the - // rest. Mirrors claude-code's OutputLine / renderTruncatedContent - // (MAX_LINES_TO_SHOW = 3). The collapsed view keeps the feed dense - // so a long `find .` or a noisy test runner doesn't push the - // assistant's next message below the fold. - return -}) - -// Hoisted outside the memo'd row so each use doesn't recreate the -// component; matches MAX_LINES_TO_SHOW in claude-code-src. -const RESULT_MAX_LINES = 3 - -function TruncatedOutputRow({ - content, - isError, -}: { - content: string - isError: boolean -}) { - const [expanded, setExpanded] = useState(false) - const lines = content.length === 0 ? [] : content.split('\n') - const needsTruncation = lines.length > RESULT_MAX_LINES - const shown = expanded || !needsTruncation - ? content - : lines.slice(0, RESULT_MAX_LINES).join('\n') - const hiddenCount = needsTruncation ? lines.length - RESULT_MAX_LINES : 0 - return ( - -
    -        {shown}
    -      
    - {needsTruncation && ( - - )} -
    - ) -} - -/** - * CC's Read tool emits one line per source line, prefixed with the - * 1-based line number and a tab: - * - * 1\timport foo - * 2\tconst bar = 1 - * - * Strip the "\t" prefix from every line so the user sees the - * raw source. If a line doesn't match the pattern we keep it verbatim - * — defensive against future format tweaks. - */ -function stripLineNumberPrefix(text: string): string { - return text - .split('\n') - .map(line => line.replace(/^\s*\d+\t/, '')) - .join('\n') -} - -/* ---------- System row (hidden by default; shown when toggled on) ---------- */ - -const SystemRow = memo(function SystemRow({ entry }: { entry: Entry }) { - const label = - entry.type === 'attachment' - ? attachmentLabel(entry) - : entry.type === 'permission-mode' - ? `permission mode: ${(entry as { permissionMode?: string }).permissionMode ?? '?'}` - : entry.type === 'file-history-snapshot' - ? 'file history snapshot' - : entry.type - return ( - -
    - {label} -
    -
    - ) -}) - -function attachmentLabel(entry: Entry): string { - const a = asRecord(asRecord(entry)?.attachment) ?? {} - if (a.hookEvent) return `hook: ${stringField(a, 'hookName') ?? stringField(a, 'hookEvent') ?? ''}` - if (a.type) return `attachment: ${String(a.type)}` - return 'attachment' -} - -/* ---------- Streaming row — REMOVED ---------- - * - * The render-time regex extractor over the raw TUI screen used to - * live here. It was the structural cause of REND-2 / REND-3 (two - * producers owning the same feed slot). Live assistant text now has - * exactly one owner: ``, driven by the - * headless package's semantic channel. - * - * Screen-derived streaming still works — the package publishes - * screen-sourced semantic deltas (labelled `source: 'screen'`) on the - * same channel when proxy is off. The renderer never parses the - * screen buffer itself. - */ - -/* ---------- Activity indicator (standalone, outside streaming row) ---------- */ - -// Shown at the bottom of the feed when CC is working but the streaming -// row isn't active (awaitingAssistant is false). Covers tool execution, -// the gap at the very start of a turn, and any state where the spinner -// is visible on CC's screen but we haven't flipped into streaming mode -// yet. Uses the same marker + hanging indent layout as everything else -// in the feed for visual consistency. - -function ActivityIndicator({ status }: { status: string }) { - return ( - -
    - - {status} -
    -
    - ) -} diff --git a/testing/rendering/renderer/components/GitRows.tsx b/testing/rendering/renderer/components/GitRows.tsx deleted file mode 100644 index 49301ec70..000000000 --- a/testing/rendering/renderer/components/GitRows.tsx +++ /dev/null @@ -1,610 +0,0 @@ -// Custom renderers for git invocations in the feed. -// -// These replace the generic Bash/exec_command tool_use + tool_result -// rows with purpose-built widgets when the "Toggle Custom Rendering" -// palette command is on. Gated by CustomRenderingContext; when off, -// the feed falls back to its usual generic rendering. -// -// Design choices: -// -// - We render the widget on the RESULT row (by reading the command -// back out of ToolUseIndexContext). The feed's main dispatcher -// suppresses the paired tool_use row so we don't show a "command -// card" above a "widget card" — the widget already carries the -// command in its header. This mirrors how Claude's Edit tool -// collapses: tool_use renders the diff, tool_result suppresses. -// -// - Each widget parses the raw stdout with the pure parsers in -// shared/git/gitParse.ts. Parsing never throws — on an -// unexpected format the result is a partial/empty shape and we -// fall back to showing the raw stdout. Principle: custom -// rendering must never lose information the user could have -// seen from the generic renderer. -// -// - Reuses the DiffSlab look from ClaudeRows (red/green per-line -// tinting, monospace). I didn't import DiffSlab directly because -// GitDiffHunks have a slightly richer shape (per-hunk headers, -// per-file grouping) and re-implementing the slab inline keeps -// the dependency direction one-way (renderer → shared git). - -import { memo, useMemo, useState } from 'react' -import hljs from 'highlight.js' - -import { MarkerRow } from './Feed' -import { normalizeCodeLanguage } from '@shared/code/language' -import type { GitIntent } from '@shared/git/gitDetect' -import { - parseGitCommit, - parseGitLog, - parseGitPush, - parseGitStatus, - parseUnifiedDiff, - stripAnsi, - type GitDiffFile, - type GitDiffLine, -} from '@shared/git/gitParse' - -// --------------------------------------------------------------------------- -// Shared chrome -// --------------------------------------------------------------------------- - -/** - * Header strip at the top of every card. Renders subcommand, flag - * chips, and any summary badges on the right. Deliberately low- - * chrome: no border around the header itself — the card's own - * surface provides the outline. - */ -function GitCardHeader({ - sub, - flags, - paths, - badges, -}: { - sub: string - flags?: string[] - paths?: string[] - badges?: React.ReactNode -}) { - return ( -
    - git {sub} - {flags?.map(f => ( - - {f} - - ))} - {paths && paths.length > 0 && paths.length <= 3 && ( - - {paths.join(' ')} - - )} -
    - {badges} -
    - ) -} - -function Card({ children }: { children: React.ReactNode }) { - // Paper surface on top of the feed's content column. Slightly - // inset so it doesn't bleed into the marker gutter. - return ( -
    - {children} -
    - ) -} - -function PlusMinus({ added, removed }: { added: number; removed: number }) { - return ( - - +{added} - / - -{removed} - - ) -} - -// --------------------------------------------------------------------------- -// Diff hunk renderer -// --------------------------------------------------------------------------- - -function escapeHtml(text: string): string { - return text.replace(/&/g, '&').replace(//g, '>') -} - -function toHighlightLanguage(language: string): string | null { - if (language === 'javascriptreact') return 'javascript' - if (language === 'typescriptreact') return 'typescript' - return hljs.getLanguage(language) ? language : null -} - -/** - * Render a sequence of {hunk, lines} for one file. Hunk header rows - * are dimmed; +/- lines are colorized per-line with syntax highlighting - * running INSIDE each line. Per-line highlighting (instead of blob - * highlighting) is necessary because we need the +/- tint to land on - * the row background, not on the tokens. - */ -function HunkSlab({ - filePath, - hunks, -}: { - filePath: string - hunks: { header: string; lines: GitDiffLine[] }[] -}) { - const highlightLanguage = useMemo( - () => toHighlightLanguage(normalizeCodeLanguage(undefined, filePath)), - [filePath], - ) - return ( -
    - {hunks.map((h, hi) => ( -
    -
    - {h.header} -
    - {h.lines.map((l, i) => { - const bg = l.kind === '+' - ? 'bg-diff-add-bg' - : l.kind === '-' - ? 'bg-diff-remove-bg' - : '' - const fg = l.kind === '+' - ? 'text-diff-add-fg' - : l.kind === '-' - ? 'text-diff-remove-fg' - : 'text-code-ink-dim' - const body = l.kind === 'ctx' - ? 'text-code-ink-dim' - : 'text-code-ink' - // Highlight each line's text body. Empty strings become - // a zero-width space so the row keeps its height. - const html = l.text === '' - ? '\u200b' - : highlightLanguage - ? hljs.highlight(l.text, { language: highlightLanguage }).value - : escapeHtml(l.text) - return ( -
    - - -
    - ) - })} -
    - ))} -
    - ) -} - -// --------------------------------------------------------------------------- -// Per-file collapsible row -// --------------------------------------------------------------------------- - -/** - * Collapse heuristic: if the file has <20 lines of change, open by - * default. Larger diffs default collapsed so a 400-line change doesn't - * dominate the feed. The user can expand with a click. - */ -function FileBlock({ file }: { file: GitDiffFile }) { - const totalChange = file.added + file.removed - const defaultOpen = totalChange <= 20 && !file.binary - const [open, setOpen] = useState(defaultOpen) - return ( -
    - - {open && !file.binary && file.hunks.length > 0 && ( - - )} -
    - ) -} - -function Tag({ - children, - tone, -}: { - children: React.ReactNode - tone: 'add' | 'remove' | 'muted' -}) { - const cls = tone === 'add' - ? 'text-diff-add-fg' - : tone === 'remove' - ? 'text-diff-remove-fg' - : 'text-muted' - return ( - - {children} - - ) -} - -// --------------------------------------------------------------------------- -// GitDiffCard -// --------------------------------------------------------------------------- - -export const GitDiffCard = memo(function GitDiffCard({ - intent, - output, -}: { - intent: Extract - output: string -}) { - // --name-only and --stat produce non-unified-diff output; render - // those as the raw stdout (still inside a card) instead of trying - // to parse them. It's not worth a custom list widget for a rarely- - // interesting summary; the raw text is already concise. - if (intent.nameOnly || intent.stat) { - return ( - - -
    -          {output.trim() || '(no output)'}
    -        
    -
    - ) - } - - const { files, added, removed } = useMemo( - () => parseUnifiedDiff(stripAnsi(output)), - [output], - ) - - // Empty diff (no changes): often `git diff` with clean workspace. - // Show a small "no changes" muted note instead of a silent card. - if (files.length === 0) { - return ( - - -
    (no changes)
    -
    - ) - } - - return ( - - - - {files.length} file{files.length === 1 ? '' : 's'} - - - - } - /> -
    - {files.map(f => )} -
    -
    - ) -}) - -// --------------------------------------------------------------------------- -// GitCommitCard -// --------------------------------------------------------------------------- - -export const GitCommitCard = memo(function GitCommitCard({ - intent, - output, -}: { - intent: Extract - output: string -}) { - const result = useMemo(() => parseGitCommit(stripAnsi(output)), [output]) - - if (result.noop) { - return ( - - -
    nothing to commit
    -
    - ) - } - - return ( - - - {result.branch && ( - - {result.branch} - - )} - {result.sha && ( - - {result.sha} - - )} - - } - /> - {result.subject && ( -
    - {result.subject} -
    - )} - {result.body && ( -
    -          {result.body}
    -        
    - )} - {(result.filesChanged !== undefined) && ( -
    - {result.filesChanged} file{result.filesChanged === 1 ? '' : 's'} changed - -
    - )} -
    - ) -}) - -// --------------------------------------------------------------------------- -// GitStatusCard -// --------------------------------------------------------------------------- - -export const GitStatusCard = memo(function GitStatusCard({ - output, -}: { - intent: Extract - output: string -}) { - const r = useMemo(() => parseGitStatus(stripAnsi(output)), [output]) - const sections: Array<{ title: string; entries: typeof r.staged }> = [ - { title: 'Staged', entries: r.staged }, - { title: 'Modified', entries: r.modified }, - { title: 'Untracked', entries: r.untracked }, - ] - const anyEntries = r.staged.length + r.modified.length + r.untracked.length > 0 - - return ( - - - {r.branch} - - )} - /> - {!anyEntries && ( -
    working tree clean
    - )} -
    - {sections.map(s => s.entries.length > 0 && ( -
    -
    - {s.title} ({s.entries.length}) -
    -
      - {s.entries.map((e, i) => ( -
    • - - - {e.oldPath ? <>{e.oldPath} → {e.path} : e.path} - -
    • - ))} -
    -
    - ))} -
    -
    - ) -}) - -function StatusCode({ code }: { code: string }) { - const cls = code === 'A' - ? 'text-diff-add-fg' - : code === 'D' - ? 'text-diff-remove-fg' - : code === 'M' - ? 'text-accent' - : code === 'R' || code === 'C' - ? 'text-accent' - : 'text-muted' - return {code} -} - -// --------------------------------------------------------------------------- -// GitAddCard -// --------------------------------------------------------------------------- - -/** - * `git add` typically produces no stdout. The useful info is the - * INTENT (what was staged). If the user passed specific paths, we - * list them. If they used `.` / `-A`, we render a one-liner. - */ -export const GitAddCard = memo(function GitAddCard({ - intent, - output, -}: { - intent: Extract - output: string -}) { - const trimmed = stripAnsi(output).trim() - return ( - - - {intent.all - ?
    staged all changes
    - : intent.paths.length > 0 - ? ( -
      - {intent.paths.map(p => ( -
    • {p}
    • - ))} -
    - ) - :
    (no paths)
    - } - {trimmed && ( - // If git actually produced output (errors, hints), show it so - // we don't hide anything from the user. -
    -          {trimmed}
    -        
    - )} -
    - ) -}) - -// --------------------------------------------------------------------------- -// GitLogCard -// --------------------------------------------------------------------------- - -export const GitLogCard = memo(function GitLogCard({ - intent, - output, -}: { - intent: Extract - output: string -}) { - const entries = useMemo(() => parseGitLog(stripAnsi(output)), [output]) - return ( - - -{intent.limit} - )} - /> - {entries.length === 0 ? ( -
    (no commits)
    - ) : ( -
      - {entries.map(e => ( -
    • - - {e.sha.slice(0, 7)} - - - {e.subject} - - {e.author && ( - - {e.author.replace(/ <[^>]+>$/, '')} - - )} -
    • - ))} -
    - )} -
    - ) -}) - -// --------------------------------------------------------------------------- -// GitPushCard -// --------------------------------------------------------------------------- - -export const GitPushCard = memo(function GitPushCard({ - intent, - output, -}: { - intent: Extract - output: string -}) { - const r = useMemo(() => parseGitPush(stripAnsi(output)), [output]) - return ( - - - {r.remoteUrl} - - )} - /> - {r.upToDate ? ( -
    everything up to date
    - ) : r.refs.length === 0 ? ( -
    -          {stripAnsi(output).trim() || '(no output)'}
    -        
    - ) : ( -
      - {r.refs.map((rf, i) => ( -
    • - {rf.ref} - · - {rf.range} -
    • - ))} -
    - )} -
    - ) -}) - -// --------------------------------------------------------------------------- -// Dispatcher — pick the right card for an intent -// --------------------------------------------------------------------------- - -export function renderGitCard(intent: GitIntent, output: string): React.ReactNode { - switch (intent.kind) { - case 'diff': return - case 'commit': return - case 'status': return - case 'add': return - case 'log': return - case 'push': return - default: return null - } -} - -/** - * Wrap a git card in the marker-band wrapper used for Claude tool - * rows so the card aligns with the feed's marker gutter and gets - * the same left ornament. Keeps the git widgets visually in the - * same lane as Edit/MultiEdit/Write rows. - */ -export function GitCardRow({ intent, output }: { intent: GitIntent; output: string }) { - return ( - - {renderGitCard(intent, output)} - - ) -} diff --git a/testing/rendering/renderer/components/monacoRuntime.ts b/testing/rendering/renderer/components/monacoRuntime.ts deleted file mode 100644 index 17d2d671c..000000000 --- a/testing/rendering/renderer/components/monacoRuntime.ts +++ /dev/null @@ -1,172 +0,0 @@ -import type * as Monaco from 'monaco-editor' - -import { - normalizeCodeLanguage, - supportsLsp, -} from '@shared/code/language' - -import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker' -import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker' -import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker' -import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker' -import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker' - -const semanticLegends = new Map< - string, - { tokenTypes: string[]; tokenModifiers: string[] } ->() -const registeredLanguages = new Set() - -let monacoPromise: Promise | null = null -let themeListenerInstalled = false - -function currentThemeName(): string { - const root = document.documentElement - if (root.dataset.contrast === 'high') { - return root.dataset.mode?.startsWith('light') - ? 'agent-code-high-contrast-light' - : 'agent-code-high-contrast-dark' - } - return root.dataset.mode?.startsWith('light') ? 'agent-code-light' : 'agent-code-dark' -} - -function defineThemes(monaco: typeof Monaco): void { - const styles = getComputedStyle(document.documentElement) - const bg = styles.getPropertyValue('--theme-code-bg').trim() || '#12120f' - const fg = styles.getPropertyValue('--theme-code-ink').trim() || '#e8e8e6' - const muted = - styles.getPropertyValue('--theme-code-ink-dim').trim() || '#a8a8a4' - const border = styles.getPropertyValue('--theme-code-border').trim() || '#262622' - const accent = styles.getPropertyValue('--theme-accent').trim() || '#7dd3a0' - - monaco.editor.defineTheme('agent-code-dark', { - base: 'vs-dark', - inherit: true, - rules: [], - colors: { - 'editor.background': bg, - 'editor.foreground': fg, - 'editorLineNumber.foreground': muted, - 'editorLineNumber.activeForeground': fg, - 'editor.selectionBackground': `${accent}33`, - 'editor.inactiveSelectionBackground': `${accent}22`, - 'editorCursor.foreground': accent, - 'editor.lineHighlightBorder': border, - 'editorIndentGuide.background1': border, - 'editorIndentGuide.activeBackground1': muted, - }, - }) - - monaco.editor.defineTheme('agent-code-light', { - base: 'vs', - inherit: true, - rules: [], - colors: { - 'editor.background': bg, - 'editor.foreground': fg, - 'editorLineNumber.foreground': muted, - 'editorLineNumber.activeForeground': fg, - 'editor.selectionBackground': `${accent}33`, - 'editor.inactiveSelectionBackground': `${accent}22`, - 'editorCursor.foreground': accent, - 'editor.lineHighlightBorder': border, - 'editorIndentGuide.background1': border, - 'editorIndentGuide.activeBackground1': muted, - }, - }) - - monaco.editor.defineTheme('agent-code-high-contrast-dark', { - base: 'hc-black', - inherit: true, - rules: [], - colors: { - 'editor.background': bg, - 'editor.foreground': fg, - 'editorCursor.foreground': accent, - }, - }) - - monaco.editor.defineTheme('agent-code-high-contrast-light', { - base: 'hc-light', - inherit: true, - rules: [], - colors: { - 'editor.background': bg, - 'editor.foreground': fg, - 'editorCursor.foreground': accent, - }, - }) - - monaco.editor.setTheme(currentThemeName()) -} - -function installThemeListener(monaco: typeof Monaco): void { - if (themeListenerInstalled) return - themeListenerInstalled = true - window.addEventListener('agent-code:theme-changed', () => { - defineThemes(monaco) - }) -} - -export async function getMonaco(): Promise { - if (!monacoPromise) { - monacoPromise = import('monaco-editor') - } - const monaco = await monacoPromise - const monacoWindow = window as Window & { - MonacoEnvironment?: { - getWorker: (_moduleId: string, label: string) => Worker - } - } - if (!monacoWindow.MonacoEnvironment) { - monacoWindow.MonacoEnvironment = { - getWorker(_moduleId: string, label: string) { - if (label === 'typescript' || label === 'javascript') { - return new tsWorker() - } - if (label === 'json') return new jsonWorker() - if (label === 'css' || label === 'scss' || label === 'less') { - return new cssWorker() - } - if (label === 'html' || label === 'handlebars' || label === 'razor') { - return new htmlWorker() - } - return new editorWorker() - }, - } - } - defineThemes(monaco) - installThemeListener(monaco) - return monaco -} - -export async function ensureSemanticProvider( - monaco: typeof Monaco, - workspaceRoot: string | null | undefined, - language: string, -): Promise { - const normalized = normalizeCodeLanguage(language) - if (!workspaceRoot || !supportsLsp(normalized)) return - if (registeredLanguages.has(normalized)) return - - const legend = await window.api.ensureLspLegend(workspaceRoot, normalized) - if (!legend) return - - semanticLegends.set(normalized, legend) - registeredLanguages.add(normalized) - monaco.languages.registerDocumentSemanticTokensProvider(normalized, { - getLegend() { - const current = semanticLegends.get(normalized) - return { - tokenTypes: current?.tokenTypes ?? [], - tokenModifiers: current?.tokenModifiers ?? [], - } - }, - async provideDocumentSemanticTokens(model) { - const result = await window.api.getLspSemanticTokens(model.uri.toString()) - if (!result) return null - return { data: Uint32Array.from(result.data) } - }, - releaseDocumentSemanticTokens() {}, - }) -} diff --git a/testing/rendering/renderer/index.html b/testing/rendering/renderer/index.html deleted file mode 100644 index 1f41dbaf1..000000000 --- a/testing/rendering/renderer/index.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - Agent Code Rendering Harness - - -
    - - - diff --git a/testing/rendering/renderer/main.tsx b/testing/rendering/renderer/main.tsx deleted file mode 100644 index 0f15157be..000000000 --- a/testing/rendering/renderer/main.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react' -import { createRoot } from 'react-dom/client' - -import { GlobalToastProvider } from '@renderer/GlobalToast' -import { RenderingHarnessApp } from './RenderingHarnessApp' - -import '@renderer/styles.css' -import 'highlight.js/styles/github-dark.css' -import 'monaco-editor/min/vs/editor/editor.main.css' -import '@xterm/xterm/css/xterm.css' - -createRoot(document.getElementById('root')!).render( - - - - - , -) diff --git a/testing/rendering/renderer/vite-env.d.ts b/testing/rendering/renderer/vite-env.d.ts deleted file mode 100644 index ed7721066..000000000 --- a/testing/rendering/renderer/vite-env.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -/// - diff --git a/tsconfig.node.json b/tsconfig.node.json index 24e8f0a0b..0b26598cd 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -106,14 +106,9 @@ // JSX-free. Without this, tsc emits "Cannot use JSX unless the '--jsx' flag // is provided" on this one file. "src/shared/conditions-core/ConditionOutlet.tsx", - // The rendering sandbox under testing/rendering/ is its own - // electron-vite project (see testing/rendering/electron.vite.config.ts) - // — it has its own tsconfig + its own renderer subtree with JSX - // components. Letting our main node project walk into it emits - // ~420 "Cannot use JSX" errors on files that aren't ours to - // type-check. Exclude the whole subtree. - "testing/rendering/renderer/**/*", - "testing/rendering/out/**/*", + // agent-voice-dictation is its own package with its own tsconfig; its test + // files must not be walked by our node project (they'd emit ~420 JSX + // errors on files that aren't ours to type-check). "packages/agent-voice-dictation/src/**/*.test.ts" ] }