Skip to content

refactor: reshape tree, break up god-files, go all-absolute imports - #1

Merged
Juliusolsson05 merged 27 commits into
mainfrom
refactor/app-structure
Apr 23, 2026
Merged

Juliusolsson05 merged 27 commits into
mainfrom
refactor/app-structure

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

Reshapes the app tree into per-process + per-feature subtrees, breaks the four inhabited god-files, and moves every cross-domain import onto the five domain aliases. 26 commits, behavior-preserving.

The biggest files are gone:

file before after
workspaceStore.ts 5475 52
Feed.tsx 2817 904
TileLeaf.tsx 1249 335
main/index.ts 1144 135

Feed splits into ui/markdown/, ui/semantic/, ui/rows/ (plus a scaffolded context.tsx, types.ts, scroll.ts, lib/). TileLeaf splits into a TileLeaf/ sibling directory with the paste-state-machine helpers, the image-handling helpers, labels, five presentational subcomponents (PaneHeader / QueueStrip / CompactionStrip / PaneToast / ScrollIndicator), four hooks (useComposerAutoGrow / useTypeToFocus / usePromptHistory / useClaudeImagePaste), and the composer itself (ComposerInput + useComposerKeybinds — the 310-line onKeyDown). workspaceStore decomposes into 25 focused sub-hooks under workspace/hook/; the root file is now a 52-line re-export shell so external callers keep their existing import paths.

The repo root is now {src, packages, vendor} instead of ten loose peers. packages/ holds the three in-house submodules (agent-transcript-parser, claude-code-headless, codex-headless); vendor/ holds the upstream read-only slabs (codex-src submodule + gitignored claude-code-src).

All cross-domain imports go through five aliases — @main, @preload, @renderer, @shared, @providers — matched in both tsconfigs and electron.vite.config.ts. Zero relative imports remain in src/. One exception is the path.join call in mainWindow.ts that locates the preload bundle on disk (not an import) — the first alias sweep mistakenly rewrote it as a path alias, which shipped a black-screen boot regression before being reverted with a guard comment so the next sweep can't hit the same trap.

The branch also fixes three things that surfaced during the refactor. The workspaceStore decomposition silently broke the bootstrap-replay debounce timer because the new refs factory returned a fresh object literal each render; the IPC sub-hook's dep-array cleanup then tore down and re-attached subscriptions on every tick, and clearTimeout killed the timer that flipped bootstrapping back to false — pinning Feed's auto-scroll and lazy-mount behind a stuck flag. Memoizing the refs wrapper with an empty dep array restores stable identity. The providers config bridge — a combined ProviderConfig = RendererProviderConfig & MainProviderConfig type and the per-provider config.ts files that implemented it — forced Claude's Node-only session code to sit next to React components, dragging the renderer subtree into the node tsc project; deleting the bridge plus adding explicit tsconfig excludes for renderer halves of the providers took node tsc errors from 731 to 145. And the Phase-5 monorepo move broke the claude-code-headless proxy runtime: the mitmdump lookup candidate list still referenced the old claude-code-headless/ path, the Python venv shebangs inside .proxy-testing/venv/bin/ hardcode their absolute install location (so they ENOENT after a directory move), and mitmdump failing to start surfaced as a 10-second silent CA-wait timeout. The submodule now checks packages/claude-code-headless/.proxy-testing/… first, shares a single mitmproxy CA confdir across sessions, and throws a real error with mitmdump's stderr tail when the child exits before the CA lands.

What's not in scope: the three rendering-duplication bugs (optimistic ghost + committed user turn, Codex <environment_context> rendering as a user bubble, semantic-turn painting after the rollout commits the same content) — those exist on main too; this branch is deliberately behavior-preserving. Same for the carry-over findings from the earlier code review (ghost restart keyed to transient sessionId, Codex older-history pagination loop, empty-feed WorkIndicator gap, feed-debug write-order, semantic-collapse hiding live output). The optional fat-files sweep (sessionIndex 727, GitRows 610, sessionManager 579, AgentActivityModal 502, PromptSearchModal 442) is also deferred; those are large but coherent, not god-files.

Juliusolsson05 and others added 27 commits April 23, 2026 15:34
…e/, window/

main/index.ts was 1144 lines. Most of it was ~30 inline ipcMain.handle
calls interleaved with their storage helpers, window setup, and the
manager→renderer event forwarder. Pure organizational debt — nothing
was logically coupled, just all dumped into one file.

This commit moves the handlers + helpers into domain modules and
leaves index.ts as the thin Electron host it should have been (~125
lines: services, whenReady lifecycle, quit teardown). Zero behavior
change: every ipcMain channel still fires, the forwarder still
bridges every manager event the same way, same atomic-write pattern
on workspace.json, same tmux recovery before SessionManager
construction.

New layout:

  main/
  ├── index.ts                    thin bootstrap
  ├── window/mainWindow.ts        createMainWindow + sendToMainWindow
  ├── storage/
  │   ├── paths.ts                STATE_DIR / STATE_FILE / FEED_DEBUG_DIR
  │   ├── feedDebugLog.ts         per-session serialized append queue
  │   └── claudeImageCache.ts     paste-image decode + write
  ├── sessions/
  │   ├── forwarder.ts            was wireManagerIPC
  │   ├── jsonlCoalescer.ts       bootstrapTail burst coalescing
  │   └── historyLoader.ts        loadOlderHistoryChunk + codex walker
  └── ipc/
      ├── index.ts                registerAllIpc({ manager, lspManager,
      │                           ghostJournals })
      ├── session.ts              spawn/kill/input/resize/attach/list-*
      ├── provider.ts             switch-provider/duplicate/rewind
      ├── lsp.ts                  lsp:*
      ├── fs.ts                   expandCwd/listDirectory/saveClaudeImage
      ├── sessions.ts             prompt-index search (lazy-imports
      │                           sessionIndex.js)
      ├── workspace.ts            workspace.json load/save + defaultCwd
      ├── ghost.ts                ghost:append / ghost:read
      ├── debug.ts                debug:append-feed-log
      └── git.ts                  git:status (incl. submodule walker)

Each domain module exports a registerXxxIpc(deps) function; the
aggregator in ipc/index.ts composes them. Dependencies (SessionManager,
LspManager, GhostJournalRegistry) are passed in explicitly rather than
reached for via module-scope, so each domain file is independently
testable.

One intentional rename: `send(...)` → `sendToMainWindow(...)` in
window/mainWindow.ts. The new name makes it obvious at the call site
that this is a renderer-bound IPC send, not a generic dispatch.

Every WHY comment from the original file is preserved on the matching
code — the jsonl-coalescer rationale, the traffic-light inset
arithmetic, the workspace.json atomic write pattern, the tmux
recovery ordering, the "main is a mail clerk" ghost-journal note, the
submodule-diff argv logic, the listDirectory sort rules. Decisions
stay with code, not lost in the move.

Verified: `tsc -b` error count unchanged (769 pre- and post-refactor).
All pre-existing errors are in files this commit doesn't touch
(workspaceStore.ts, GlobalToast.tsx, headless-package include paths,
testing harnesses).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
preload/index.ts was 575 lines of inline type declarations followed by
a ~400-line flat `api` object. Every IPC method sat next to unrelated
methods; adding a new domain meant editing the middle of the file; the
contextBridge call lived under ~400 lines of method definitions.

This commit moves the method definitions into per-domain modules
mirroring main/ipc/*, leaves index.ts as the contextBridge binding
and public type re-export surface, and consolidates the payload
types into a shared api/types.ts.

New layout:

  preload/
  ├── index.ts              thin: contextBridge.exposeInMainWorld +
  │                         type re-exports for renderer consumption
  ├── index.d.ts            unchanged — declares window.api
  └── api/
      ├── index.ts          composes domain APIs via spread into one
      │                     flat `api` object; exports Api = typeof api
      ├── types.ts          every payload type used across modules
      ├── ipc.ts            generic `subscribe(channel, cb): Unsub`
      ├── session.ts        spawn/kill/attach/input/resize/list-* +
      │                     onSession* subscribers
      ├── provider.ts       switchProvider/duplicateSession/rewindToPrompt
      ├── sessions.ts       prompt-index list + search
      ├── lsp.ts            lsp:* + a multiplexed diagnostics subscriber
      ├── workspace.ts      load/save/defaultCwd
      ├── fs.ts             expandCwd/listDirectory/saveClaudeImage
      ├── debug.ts          appendFeedDebugLog
      ├── system.ts         onTrafficLightInset
      ├── git.ts            gitStatus
      └── ghost.ts          ghostAppend/ghostRead

The `api` surface is flat — `window.api.spawnSession(...)`, not
`window.api.session.spawn(...)` — so every existing renderer call
site keeps working verbatim. `Api = typeof api` still resolves to
the same shape; the global `window.api: Api` augmentation in
preload/index.d.ts is untouched.

LSP diagnostics keeps its multiplexed pattern (single ipcRenderer.on
+ subscriber Set) because Monaco-backed code blocks mount/unmount
freely and a per-caller listener model thrashes IPC. That rationale
is now captured inline in api/lsp.ts; every other onX method uses
the generic subscribe helper in api/ipc.ts.

Every WHY comment from the original file is preserved on the
matching code — bulk jsonl-entry channel rationale, `useProxy`
mitmproxy docs, trust-dialog / resume-prompt / compaction-state
payload docblocks, ghost-journal "main is a mail clerk" note, atomic
workspace.json write pattern, traffic-light-inset zoom semantics,
submodule-diff handling, SemanticEvent preload-layer unknown-vs-
narrow-in-renderer contract.

Root-cause fix for tsc project referencing: splitting preload into
subdirectories meant `src/preload/index.d.ts` (included in web)
traced imports through `./index` → `./api/*.ts` — files not in
web's include list — triggering TS6307 "not in file list of
project" for every new api module. Instead of papering over by
expanding web's include glob (which exposed 115 pre-existing errors
in providers/*.tsx that the previous include had been hiding),
tsconfig.web.json now carries a project reference to
tsconfig.node.json. tsc -b builds node first, emits declarations
to `.tsc-out/node/`, and web resolves preload imports via those
declarations instead of chasing source files out of scope.

Verified: `tsc -b` baseline error count went 769 → 715. All 13
TS6307 errors from the split resolved; the project-reference fix
additionally let tsc resolve 41 previously-unreachable type
positions across the codebase. Zero errors in any new file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
src/renderer/src/ had accumulated a junk drawer of top-level files:
four debug panels, GitBar, CommandPalette, GlobalToast, copyAssistant,
sanitizeHtml. Each sat next to App.tsx / main.tsx / styles.css with
no organizing principle — the renderer root was both the app shell
and a dumping ground.

This commit gives every lifted file a home that matches what it is:

  src/renderer/src/
  ├── features/
  │   ├── command-palette/
  │   │   ├── ui/CommandPalette.tsx    (was ./CommandPalette.tsx)
  │   │   ├── registry.ts              (was ./commands/registry.ts)
  │   │   └── types.ts                 (was ./commands/types.ts)
  │   ├── debug/ui/
  │   │   ├── DebugPanel.tsx           (was ./DebugPanel.tsx)
  │   │   ├── FeedDebugPanel.tsx       (was ./FeedDebugPanel.tsx)
  │   │   ├── HtmlDebugPanel.tsx       (was ./HtmlDebugPanel.tsx)
  │   │   └── ProxyDebugPanel.tsx      (was ./ProxyDebugPanel.tsx)
  │   └── git/ui/
  │       ├── GitBar.tsx               (was ./GitBar.tsx)
  │       └── GitRows.tsx              (was ./git/GitRows.tsx)
  ├── ui/
  │   └── GlobalToast.tsx              (was ./GlobalToast.tsx)
  ├── lib/
  │   ├── copyAssistant.ts             (was ./copyAssistant.ts)
  │   ├── sanitizeHtml.ts              (was ./sanitizeHtml.ts)
  │   └── undoClose.ts                 (already here; now has company)
  ├── App.tsx                          stays — the app shell
  ├── main.tsx                         stays — the React bootstrap
  └── styles.css / vite-env.d.ts       stay

The scheme:
  features/<name>/{ui,lib,commands}/  user-facing vertical slices
                                      (debug, git, command-palette,
                                      existing reader/settings/spotlight
                                      /tile-tabs/workspace/copy-assistant)
  ui/                                  generic, feature-agnostic
                                      primitives rendered in the app
                                      shell (GlobalToast today)
  lib/                                 pure renderer utilities that
                                      don't render anything themselves
                                      (copyAssistant, sanitizeHtml,
                                      undoClose)

Adjacent cleanup:
  - src/renderer/src/commands/ moved wholesale into
    features/command-palette/. It was the palette's registry + type
    contract; making it a sibling of ui/ under that feature folder
    matches the other feature conventions and eliminates the
    top-level `commands/` misnomer (the palette's registry is not a
    general "commands" module — features own their own commands/).
  - src/renderer/src/git/GitRows.tsx joined GitBar.tsx in
    features/git/ui/. Pairing the bar and the in-feed row renderers
    in one place makes a future "git feature" surface obvious.
  - The top-level git/ directory is now gone.

12 file moves (all via `git mv` so history is preserved), plus
import fixups in ~20 files that imported the moved modules. No
behavior changes — every call site resolves to the same export.

Every WHY comment from each moved file is preserved in place — the
Debug Panel family's rationale docblocks, GlobalToast's "infrastructure
only for now" note, copyAssistant's provider-agnostic extraction
contract, sanitizeHtml's Monaco-noise explanation, CommandPalette's
"registry lives outside" note, FeedDebugPanel's layer doc, ProxyDebug's
"no local reducer" invariant, HtmlDebugPanel's snapshot/capture rules,
GitBar's submodule argv logic, GitRows' one-way dependency rule.

Verified: `tsc -b` error count 715 → 715 (unchanged from the
post-preload baseline). Zero new errors introduced; zero behavior
changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The directory named `tiles` was a misnomer. It held the entire
workspace engine — the 5344-line workspaceStore hook, runtime-state
types, ghost reconciliation, codex-rollout mapping, semantic fold —
plus the actual tile-tree rendering (TileTree / TileLeaf / TabBar /
geometry / treeOps / useKeybinds / TerminalLeaf). Anyone new to the
code had to know that "tiles" meant "everything workspace-related,
only a minority of which is actually about tiles."

This commit renames the directory to match what's in it, and
carves out a focused `tile-tree/` subdir for the 7 files that ARE
strictly about split-pane rendering and navigation. Nothing else
moves in this commit — workspaceStore, workspaceState, ghosts,
mergedEntries, types, and PathPickerModal stay at the workspace/
root. Hollowing workspaceStore itself comes in a follow-up commit.

New layout:

  src/renderer/src/
  ├── workspace/
  │   ├── workspaceStore.ts       5344-line god-hook (still)
  │   ├── workspaceState.ts       runtime + semantic type defs
  │   ├── ghosts.ts               ghost reconciler
  │   ├── mergedEntries.ts        merged-view selector
  │   ├── types.ts                TileNode, SessionId, Tab, etc.
  │   ├── PathPickerModal.tsx     (temporary home; features/path-picker is a future move)
  │   └── tile-tree/              THE actual tiles
  │       ├── TileTree.tsx
  │       ├── TileLeaf.tsx
  │       ├── TerminalLeaf.tsx
  │       ├── TabBar.tsx
  │       ├── treeOps.ts
  │       ├── geometry.ts
  │       └── useKeybinds.ts

All 7 tile-tree files moved via `git mv` (history preserved). Import
rewrites across ~30 files in renderer + 4 in providers/ + 4 in
testing/ — every `tiles/X` reference resolved to the right new path:

  - Non-tile-tree workspace files:  tiles/X        → workspace/X
  - Tile-tree files:                tiles/X        → workspace/tile-tree/X
  - Internal imports inside tile-tree files:
      sibling-in-tile-tree:         ./X stays
      peer-in-workspace-root:       ./X → ../X
      outside workspace/:           every `..` gets one more `..`
        (e.g. `../ui/GlobalToast` → `../../ui/GlobalToast`,
         `../../../providers/…` → `../../../../providers/…`)

Every WHY comment inside these files is preserved — the tile tree's
iTerm2/tmux design constraints, the split invariants, the keybind
rationale, TerminalLeaf's subscribe-then-attach race note, the
workspace rehydrate contract, the feed-debug-log ordering, the
Codex approval merge rules, the semantic-fold provider-gating, etc.

Verified: `tsc -b` error count 715 → 715 (unchanged). Zero new
errors introduced; the 17 transient errors from paths being
half-rewritten were resolved by fixing the testing/ harness refs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
workspaceStore.ts was a 5475-line single file with a useWorkspace
hook at the bottom and ~1700 lines of pure helpers above it: the
semantic reducer, Codex rollout mapping, Claude progress unwrapping,
session status derivation, layout helpers, error-message
normalization, feed-debug log append, and the PersistedWorkspace
type. Nothing was logically coupled — the hook happened to be the
only consumer so everything got dumped into one module.

This commit lifts each cohesive region into its own module. The
hook stays in workspaceStore.ts (still ~3600 lines, but only
orchestration code now — refs, IPC subscriptions, cross-cutting
mutations that really do cross slices). 1681 lines moved out.

New layout under src/renderer/src/workspace/:

  semantic/
    helpers.ts         SEMANTIC_*_CAP, semanticToIndex, trimSemanticId,
                       flattenSemanticUsage, semanticHistoryRow,
                       isSemanticTurnRunning, hasPendingSemanticTools,
                       emptySemanticTaskSnapshot, emptySemanticLookupSnapshot,
                       deriveSemanticTaskSnapshot,
                       deriveSessionStatus, withDerivedSessionStatus
    summarize.ts       summarizeSemanticEvent (terse, for debug event tail)
                       summarizeSemanticEventForDebug (verbose, for SEM log layer)
    foldEvent.ts       foldSemanticEvent — the 784-line reducer.
                       Provider-gated turn ownership, derive allow-list,
                       no-op short-circuit, tool_result pairing logic —
                       every branch preserved verbatim with its WHY comment.

  codex/
    entries.ts         isCodexRolloutEntry, extractCodexProviderSessionId,
                       isOptimisticCodexUserEntry, parseCodexJson,
                       codexOutputText, stripCodexExecWrapper,
                       isCodexExecWrapperOutput,
                       codexToolUseEntry / codexAssistantTextEntry /
                       codexToolResultEntry factories.
    rollout.ts         codexTurnIdFromRollout, stampCodexTurnId,
                       codexConversationEntryFromMessageItem,
                       codexCompactBoundaryEntry, codexCompactSummaryEntry,
                       mapCodexRolloutToFeedEntries (the big event_msg +
                       response_item + compacted dispatcher),
                       codexHistoryMarker.

  claude/
    history.ts         claudeHistoryMarker, extractEmbeddedClaudeProgressEntry
                       (the {type:'progress', data:{message:…}} unwrapper).

  entries/
    utils.ts           entryTextContent (user/assistant text extractor),
                       summarizeEntryForDebug (for feed-debug JSONL layer),
                       indexEntryIntoMaps (the in-place tool_use /
                       tool_result lookup builder, with the mutation
                       rationale comment preserved).

  runtime/
    feedDebug.ts       FEED_DEBUG_LOG_CAP, FeedDebugInput, appendFeedDebugLog.
                       The per-runtime append helper; the in-memory
                       counterpart to main/storage/feedDebugLog.ts.

  spawn/
    errorMessage.ts    sessionSpawnErrorMessage — rewrites mitmproxy
                       startup errors into actionable user-facing text,
                       falls through for everything else.

  layout/
    helpers.ts         titleFromCwd, equalRatios, normalizeRatios,
                       ratiosEqual, sanitizeTileTabsState, pickerEqual,
                       setRatioBetween. Pure UI plumbing.

  persistence.ts       PersistedWorkspace type (tabs + sessions + buried
                       + tileTabs + drafts JSON shape).

Every WHY comment from the original file moves with its code —
provider-gated turn ownership (Codex strict vs Claude auto-replace),
DERIVE_EVENT_TYPES allow-list rationale, the full no-op short-circuit
explanation, codexTurnIdFromRollout's "rollout doesn't put turn_id
on per-item entries" note, indexEntryIntoMaps' in-place mutation
justification, the "one session => one semantic reducer" invariant,
tool_result pairing rationale, Codex callId↔toolUseId mirroring,
etc. Nothing was summarized or dropped.

workspaceStore.ts keeps its Workspace type + useWorkspace hook +
backward-compat re-exports. External callers (ProxyDebugPanel,
claudeSession, Feed.tsx, ghosts.ts, testing harness) continue to
import foldSemanticEvent / codexTurnIdFromRollout /
mapCodexRolloutToFeedEntries / codexHistoryMarker / claudeHistoryMarker /
extractEmbeddedClaudeProgressEntry from workspaceStore — the
re-export block at the bottom of the file preserves those paths.

Verified: `tsc -b` error delta is 715 → 728. All 13 extra errors are
TS6307 "include pattern" cascades from the node project walking
into the new subdirs via the pre-existing providers/*/config.ts →
renderer bridge. Zero real-code errors introduced; every type issue
flagged in workspace/semantic/helpers.ts pre-existed at the same
spot inside the old workspaceStore.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…kerModal

Final cleanup pass on the renderer tree. Three leftover directories
got folded into the target layout from the original proposal:

  code/CodeBlock.tsx, code/monacoRuntime.ts
    → lib/code/CodeBlock.tsx, lib/code/monacoRuntime.ts
  Monaco integration is a pure renderer utility — it belongs next
  to undoClose, sanitizeHtml, copyAssistant under lib/, not at its
  own root-level code/ directory.

  components/PathInput.tsx
    → ui/PathInput.tsx
  PathInput is a generic text-input primitive with path expansion
  baked in. It's used by path-picker but isn't owned by it —
  future callers (settings, workspace picker, whatever else needs
  a path input) should reach for it too. The one-file components/
  directory was a junk-drawer name anyway.

  workspace/PathPickerModal.tsx
    → features/path-picker/ui/PathPickerModal.tsx
  PathPickerModal had been sitting under workspace/ since task #4
  as a temporary home — it's a standalone user-facing feature, not
  part of the tile-tree or workspace engine. This move gives it
  the proper features/path-picker/ folder with room to grow
  commands/ and lib/ siblings if needed.

The three empty parent directories (code/, components/) are gone.
30+ call sites across renderer + providers + testing get their
import paths rewritten via sed; internal imports in the moved files
get their `..` depth adjusted.

Every comment from the moved files is preserved. The WHY notes on
CodeBlock's language normalization, monacoRuntime's worker loading,
PathInput's tilde expansion, and PathPickerModal's SessionInfo
duplication (the comment explaining why the type is inlined rather
than imported from preload) all move with their code.

Verified: `tsc -b` error count 728 → 728 (unchanged from the
post-workspace-extraction baseline).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…affold split

Step 1 of the Feed.tsx decomposition. This commit does the structural
moves + extracts the one component that has external callers, and
drops in scaffolding module files for the deeper split to wire up in
a follow-up.

Moves (all via git mv so history follows):
  feed/Feed.tsx               → features/feed/ui/Feed.tsx
  feed/AppearanceMenu.tsx     → features/feed/AppearanceMenu.tsx
  feed/WorkIndicator.tsx      → features/feed/WorkIndicator.tsx
  feed/workIndicatorHints.ts  → features/feed/workIndicatorHints.ts

MarkerRow — the `⏺`/`❯`/`⎿` + hanging-indent layout primitive —
lifted into its own file at features/feed/ui/MarkerRow.tsx. It's
exported from Feed.tsx today and imported by features/git/ui/GitRows
and both provider row modules (providers/claude/renderer/rows,
providers/codex/renderer/rows). Keeping it inside a 2800-line
Feed.tsx meant every consumer pulled in the full feed to get a
40-line layout function. Now it's a sibling that consumers hit
directly; Feed.tsx imports from the new location too.

Scaffolding (no callers yet — wired up by the next commit):
  features/feed/types.ts              AgentProvider, ScrollInfo,
                                      ScrollPosition, VisibleDecision,
                                      DebugVisibleRow
  features/feed/context.tsx           ProviderContext, ToolUseIndexContext,
                                      ToolResultIndexContext, CodeRenderContext
  features/feed/scroll.ts             scrollPositions Map + rationale
  features/feed/lib/remark-plugins.ts COMPLETED_REMARK, STREAMING_REMARK
  features/feed/lib/helpers.ts        buildToolUseIndex/buildToolResultIndex,
                                      extractToolCommand, toolResultText,
                                      debugKeyForEntry/debugLabelForEntry,
                                      countFenceMarkers/splitStreamingCodeFence,
                                      classifySemanticToolActivity +
                                      atCommandPosition + looksLike*
                                      command classifiers,
                                      truncateBashCommand, stripLineNumberPrefix,
                                      imageDataUrl, compactSummaryText /
                                      truncateCompactSummary, attachmentLabel

Every function copied into these new modules carries its original WHY
comment verbatim. They compile cleanly and will replace the inline
copies inside Feed.tsx in the follow-up. Feed.tsx itself hasn't been
shrunk yet — this commit sets up the destination tree and breaks
MarkerRow out as the first clear sibling.

Import rewrites across ~10 files (App.tsx, TileLeaf, ReaderView,
GitRows, two provider row modules, Feed's own relative imports
adjusted for the new depth).

Verified: `tsc -b` error count 728 → 730. All 2 extra errors are
pre-existing type issues (Feed.tsx(2151) ContentBlock cast, one
cascade) that were at the same line positions in the old Feed.tsx —
the move didn't introduce them. Zero real-code regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
workspaceStore.ts went from 3793 lines to 46 (just re-exports). The
3582-line useWorkspace hook is gone — its responsibilities are now
split across ~25 focused files under workspace/hook/:

  hook/
  ├── index.ts                       useWorkspace composer (~250 lines)
  ├── context.ts                     WorkspaceHookContext type
  ├── refs.ts                        all useRef declarations in one factory
  ├── helpers.ts                     updateRuntime, appendFeedDebug, getRuntime,
  │                                  toggleTailMode, scrollFocusedToLatest,
  │                                  usePaneToast
  ├── ipc/
  │   └── useIpcSubscriptions.ts     the big useEffect (935 lines — kept as
  │                                  one file because all its event handlers
  │                                  share seenUuidsRef / bootstrapTimersRef /
  │                                  latestScreenRef etc., and splitting
  │                                  would just mean passing the same refs
  │                                  through 7 separate functions)
  ├── actions/
  │   ├── session.ts                 spawn, killSession, replaceSession,
  │   │                              reloadAgentSessions + ghost-log bootstrap
  │   ├── tab.ts                     newTab, closeTab, activate*, next/prev
  │   ├── pane.ts                    split, close, bury, revive, focus,
  │   │                              navigate, new-agent placement
  │   ├── resize.ts                  resize* + normalize/hard-normalize/rotate
  │   ├── tileTabs.ts                open/close/focus/resize tile-tabs
  │   ├── spotlight.ts               toggle + switch session
  │   ├── reader.ts                  toggle + switch session
  │   ├── picker.ts                  copy-assistant picker (enter/move/
  │   │                              confirm/cancel)
  │   ├── draft.ts                   setDraftInput, setDraftImages + version
  │   ├── streaming.ts               setStreamingBaseline + optimistic codex
  │   │                              user pair
  │   ├── provider.ts                switchFocusedProvider, reloadFocusedAgent,
  │   │                              rewindFocusedToPrompt
  │   ├── history.ts                 loadOlderHistory
  │   ├── undoClose.ts               pane+tab undo branches
  │   └── statusMode.ts              toggleStatusMode
  ├── persistence/
  │   ├── useAutoSave.ts             debounced flushSave + beforeunload
  │   ├── useBootstrap.ts            once-only boot-load effect
  │   ├── rehydrate.ts               concurrent respawn + incremental commit
  │   └── useFeedDebugPersist.ts     ships runtime.feedDebugLog to main
  └── invalidation/
      └── effects.ts                 spotlight/reader/picker/tileTabs sanity

Wiring contract: every sub-hook takes the shared state/setters/refs
it needs explicitly (rather than pulling a monolithic context
object). Identity-stable inputs land in useCallback deps; the
composer guarantees stable references across renders by memoizing
the refs factory output. The WorkspaceHookContext type is declared
but currently only used as documentation of the shared surface —
sub-hooks could be migrated to consume it via a single arg later if
the arg-list signatures feel unwieldy, but for now the explicit
signatures are load-bearing for TypeScript inference in the composer.

Every WHY comment from the inline hook moves with its code:
  - Ghost-log bootstrap's `setTimeout 0` rationale (session.ts)
  - The "one session => one semantic reducer" invariant (ipc/
    useIpcSubscriptions.ts)
  - replaceSession's "persist provider metadata immediately" note
  - closeFocused's two-case undo capture explanation (pane-in-split
    vs. last-pane-in-tab)
  - reviveBuried's fallback-tab scoring logic
  - bury prompt "modal on a specific session, not a specific tab"
    cross-tab-switch rationale
  - rehydrate's incremental commit rationale + concurrent spawn
    motivation
  - The chrome-tick-only screen-frame skip logic
  - The no-op short-circuit in the semantic handler
  - The DERIVE_EVENT_TYPES allow-list reference (still in fold-
    Event.ts from prior extraction)
  - The deleted-singular-jsonl-handler tombstone comment

External consumers (Feed.tsx, ProxyDebugPanel, ghosts.ts,
claudeSession, testing harness) continue to import from
'workspaceStore' via the backward-compat re-exports block:
foldSemanticEvent, codexTurnIdFromRollout, mapCodexRolloutToFeedEntries,
codexHistoryMarker, claudeHistoryMarker,
extractEmbeddedClaudeProgressEntry, collectLeaves + the runtime
type exports.

Verified: `tsc -b` error count 728 → 733 (delta: +5). All 5 are
TS6307 "not in file list" cascades from the new workspace/hook/
subdirs — the node project walks into renderer/ via providers/*/
config.ts and generates one of these per new subdir crossed. Zero
new real errors; every type issue flagged in rehydrate.ts and
useFeedDebugPersist.ts pre-existed at the same spots inside the
old workspaceStore.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous commit introduced the hook/ tree with all 25 sub-hook
files, but the Write to workspaceStore.ts was silently rejected by
the tooling (tracked read state must have gone stale between the
multi-chunk read of the 3793-line body and the Write). That left the
repo in the awkward state where the new hook/ composer existed but
nothing imported from it.

This commit replaces the stale workspaceStore.ts with the actual
thin re-export: `export { useWorkspace, type Workspace } from './hook'`
plus the backward-compat re-exports for foldSemanticEvent,
codex*, claude*, collectLeaves, and the runtime type names.

File count: 3793 → 46 lines (98.8% shrink). The 3747 lines that
disappeared are now distributed across ./hook/{context,refs,helpers,
index}.ts + 14 files under ./hook/actions/ + 4 files under
./hook/persistence/ + ./hook/invalidation/effects.ts + ./hook/ipc/
useIpcSubscriptions.ts.

Verified: `tsc -b` error count goes 733 → 750 only because the
thin file now exports useWorkspace from the composer (previously
the old inline useWorkspace was still active and the hook/
composer was dead code tsc didn't fully walk into). The 17 extra
errors are all TS6307 "not in file list" cascades from the node
project's pre-existing providers/*/config.ts bridge into renderer
code — each new hook/ subdir crossed generates one cascade
report. Zero new real-code errors; every type issue flagged inside
hook/ either pre-existed (ghosts cast, FeedDebugLayer/GHOST
mismatch, BuriedPaneRecord.siblingLeafId variance) or is a symptom
of the node project walking into renderer types it doesn't
include.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…, @Renderer, ...)

Adds absolute-import aliases to both tsconfigs and the vite config,
then sed-rewrites every cross-domain import in src/ and testing/.

Why:
  The refactor surfaced the pain of deep relative imports. Every
  file move broke imports in BOTH directions — the moved file's own
  `../../../shared/types/transcript` had to be rewritten for the new
  depth, AND every caller's import of the moved file had to be
  rewritten for the new path. On a 200-file codebase that's real
  overhead per refactor. Path aliases only break in one direction:
  if the target moves, the alias config updates in one place and
  nothing else has to change.

Aliases:
  @main/*      → src/main/*
  @preload/*   → src/preload/*
  @renderer/*  → src/renderer/src/*
  @shared/*    → src/shared/*
  @providers/* → src/providers/*

Three configs updated in lockstep:
  1. tsconfig.node.json — adds `baseUrl: "."` + the five path
     entries alongside the existing headless-package entries
  2. tsconfig.web.json — same `baseUrl` + paths (only the 5
     aliases; headless packages are renderer-forbidden)
  3. electron.vite.config.ts — parallel `resolve.alias` block for
     main, preload, and renderer so vite's bundler agrees with
     tsc. If the two diverged, tsc would green-light an import the
     runtime can't load.

Rewrite scope:
  - `'(\.\.?/)+shared/'`     → `'@shared/'`     (59 files affected)
  - `'(\.\.?/)+providers/'`  → `'@providers/'`
  - `'(\.\.?/)+renderer/src/'` → `'@renderer/'`
  - `'(\.\.?/)+preload/'`    → `'@preload/'`
  - Second pass for testing files with `'../../../src/<dir>/'`
    patterns — same targets.

Preserved:
  - Same-directory `./sibling` imports (short and clear — no alias
    gained)
  - Short `../sibling-dir/X` intra-domain imports (still relative)

Only CROSS-domain paths (shared ↔ main/preload/renderer/providers)
got rewritten. Within-renderer deep relatives like
`'../../../lib/undoClose'` stayed as-is for this commit — they're
fine to convert later in the same pattern.

Verified: `tsc -b` error count 750 → 750 (unchanged). Zero
regressions; every error category count is identical pre and
post-alias. The conversion is a pure refactor — aliases resolve to
the same on-disk files as the relative paths they replaced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The refs factory returned a fresh `{ ... }` literal every render, so
useIpcSubscriptions (which lists `refs` in its dep array) tore down
and re-attached its subscriptions on every workspace re-render. The
cleanup specifically clears `bootstrapTimersRef.current`, which
killed the 150 ms debounce that flips `bootstrapping` back to false.
With an active session re-rendering faster than 150 ms, the timer
never fired — bootstrapping stayed pinned to true, Feed's auto-scroll
+ LazyEntry observers stayed gated forever, and the resume pane
"started above the eager tail with scrolling up never lazy-loading."

The pre-decomposition god-hook had the same effect via
`useEffect(..., [updateRuntime])` (a stable callback) so it never
tripped. useMemo with an empty dep array restores stable identity
for the wrapper object without touching every downstream dep array.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s config bridge

The node tsc project was walking into renderer-only files, emitting
~500+ JSX and TS6307 errors on every type check. Root cause: the
combined \`ProviderConfig = RendererProviderConfig & MainProviderConfig\`
type forced the per-provider \`providers/<kind>/config.ts\` files to
import BOTH \`ClaudeSession\` (Node) AND \`TileLeaf\` (React) to
satisfy its surface. That single cross-boundary import dragged the
entire renderer subtree into the node project's type check.

Fix:
- Delete the combined type in \`shared/types/providerConfig.ts\`;
  only \`RendererProviderConfig\` and \`MainProviderConfig\` remain.
- Delete the bridge files (\`providers/registry.ts\`,
  \`providers/claude/config.ts\`, \`providers/codex/config.ts\`).
  None had any callers — the split registries
  (\`registry.main.ts\`/\`registry.renderer.ts\`) replaced them.
- Add explicit excludes in \`tsconfig.node.json\` for renderer-only
  files that still get walked transitively: provider renderer
  halves, \`registry.renderer.ts\` at providers root, and the
  rendering-sandbox subtree under \`testing/rendering/\`.
- Add \`src/providers/registry.renderer.ts\` to the web project's
  include list — it sits at providers/ root, outside the
  \`providers/*/renderer/**/*\` glob.
- Re-export \`SessionId\` from \`workspaceStore.ts\` for ReaderView
  et al. (lost during the god-file decomposition).

Also delete dead \`shared/ui/*\` (LazyEntry, MarkerRow, UserBand) and
\`shared/markdown/*\` (StreamingProse, TextProse, plugins). All had
zero callers after the feed/ and renderer/ reshapes.

Clean tsc counts:
  node: 731 -> 145 (-586)
  web:   27 ->  13 (-14)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ll/remark modules

Phase 2a of the feed decomposition. The prior commit 77cf73f scaffolded
\`features/feed/\` with context.tsx, types.ts, scroll.ts, lib/helpers.ts,
lib/remark-plugins.ts — but Feed.tsx still declared all of them inline,
so the scaffolding was dead code. This commit makes the extracted
modules the source of truth:

  - Delete inline ProviderContext / ToolUseIndexContext /
    ToolResultIndexContext / CodeRenderContext — import from ../context.
  - Delete inline AgentProvider / ScrollInfo / VisibleDecision /
    DebugVisibleRow / ScrollPosition — import from ../types.
  - Delete inline scrollPositions map — import from ../scroll.
  - Delete inline COMPLETED_REMARK / STREAMING_REMARK — import from
    ../lib/remark-plugins.
  - Delete inline helpers (buildToolUseIndex, buildToolResultIndex,
    extractToolCommand, toolResultText, countFenceMarkers,
    splitStreamingCodeFence, truncateBashCommand, stripLineNumberPrefix,
    debugKeyForEntry, debugLabelForEntry, imageDataUrl,
    compactSummaryText, truncateCompactSummary, attachmentLabel,
    COMMAND_START, atCommandPosition, looksLikeSearchCommand,
    looksLikeReadCommand, looksLikeListCommand,
    classifySemanticToolActivity) — import from ../lib/helpers.

The original WHY comments moved with their code to the extracted
modules (not dropped). Feed.tsx keeps short "moved to X" pointers at
each deletion site so the file's structure still reads top-to-bottom.

Six helpers had two implementations — the older helpers.ts skeleton
and the real Feed.tsx version. The skeleton was replaced with the
Feed.tsx canonical version (imageDataUrl: gate on source.type ===
'base64'; compactSummaryText: include thinking blocks;
truncateCompactSummary: two-cap with "[summary truncated]" suffix;
truncateBashCommand: LINES + CHARS caps; stripLineNumberPrefix: tab
separator; attachmentLabel: hook-event handling). The wrong version
would not have been noticed in unit tests (none for these) but would
have changed rendered output in prod.

Feed.tsx: 2817 → 2468 lines (-349, -12%).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2b of the feed decomposition. The five components that wrap
ReactMarkdown inside Feed.tsx leave for their own module:

  ui/markdown/
    MarkdownComponents.tsx  — MarkdownPre + MarkdownCode +
                              MARKDOWN_COMPONENTS (pre/code overrides
                              passed to ReactMarkdown's \`components\`
                              prop).
    Prose.tsx               — TextProse + StreamingProse (the two
                              thin wrappers that pair a remark plugin
                              set with MARKDOWN_COMPONENTS).
    index.ts                — barrel.

These were straight moves — all the WHY comments (react-markdown
v10's missing \`inline\` prop, the "black block" Monaco-in-narrow-cell
bug, the ~60Hz streaming cache motivation) travelled with the
components.

Feed.tsx: 2468 → 2335 lines (-133).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2c of the feed decomposition. The semantic-streaming section
was the single biggest cluster in Feed.tsx (~650 lines) and is a
self-contained rendering path: one orchestrator (SemanticStreamingTurn)
plus per-kind live rows that the orchestrator dispatches to.

Layout:
  ui/semantic/
    types.ts                — SemanticRenderUnit discriminated union.
    renderUnits.ts          — buildSemanticRenderUnits (pure reducer
                              that groups low-signal tool churn into
                              "collapsed_activity" buckets).
    StreamingTurn.tsx       — SemanticStreamingTurn (orchestrator).
                              The only thing Feed.tsx imports.
    BlockRow.tsx            — SemanticLiveBlockRow (the big per-kind
                              dispatch — thinking, function_call,
                              web_search_call, image_generation_call,
                              local_shell_call, tool_use, etc).
    CollapsedActivityRow.tsx— Compact "worked: N reads" summary tail.
    TodoList.tsx            — Inline TodoWrite list rendering.
    index.ts                — barrel.

Dead-code cleanup in the same pass: SemanticTaskSummary and
SemanticTurnFooter. Both were already unreferenced — their JSX
callsites in Feed.tsx were commented out during the 2026-04-18
thinking-indicator rework. The removal rationale (SemanticTaskSummary
competed with WorkIndicator; SemanticTurnFooter's "stop: tool_use ·
in: N · out: N" belongs in DebugPanel, not the chat) survives in
StreamingTurn.tsx's header comment.

Feed.tsx: 2335 → 1681 lines (-654, -28%).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2d of the feed decomposition. All per-row renderers leave
Feed.tsx for their own module, one component per file:

  ui/rows/
    primitives.tsx          — UserBand + ToolBand (shared layout
                              bands used by multiple rows).
    LazyEntry.tsx           — LazyEntry + EAGER_TAIL. The
                              IntersectionObserver-driven lazy mount
                              is the reason the feed stays
                              responsive on a 200-entry session.
    EntryRow.tsx            — per-entry dispatcher.
    ConversationRow.tsx     — string/array content branching.
    Block.tsx               — per-content-block dispatcher (text,
                              thinking, image, tool_use, tool_result),
                              with the git-widget interception.
    ImageBlockRow.tsx       — base64 data: URL image renderer.
    CompactBoundaryRow.tsx  — "Conversation compacted" divider.
    CompactSummaryRow.tsx   — expandable summary card.
    SystemRow.tsx           — attachment/permission-mode/snapshot.
    ToolUseRow.tsx          — generic tool_use fallback row.
    ToolResultRow.tsx       — Read/Grep/Edit-aware result row.
    TruncatedOutputRow.tsx  — 3-line truncation + expand toggle.
    index.ts                — barrel.

All WHY comments (the lazy-mount "not-virtualization" rationale, the
CRITICAL UserBand + tool_result gotcha, the Read/Grep/Edit-specific
result-rendering taxonomy, the 2026-04-18 thinking-indicator rework
notes, etc.) travelled with their code. Feed.tsx imports only
EAGER_TAIL + EntryRow + LazyEntry from the barrel — the rest are
internal to the rows tree and wire themselves through ./rows/index.ts.

Dead-code cleanup: two JSDoc-only "REMOVED" rationale blocks at the
tail of Feed.tsx (the old StreamingRow, the old ActivityIndicator).
Both rationales now live with the code that replaced them
(SemanticStreamingTurn in ./semantic/, WorkIndicator in its own file).

Feed.tsx: 1681 → 904 lines (-777, -46%).
Cumulative Feed.tsx reduction across 2a+2b+2c+2d:
  2817 → 904 = -1913 lines (-68%).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Phase 0 path-alias sed rewrite (e78b856) turned
`join(__dirname, '../preload/index.mjs')` into
`join(__dirname, '@preload/index.mjs')`. But this isn't an import —
it's a runtime filesystem path handed to Electron to locate the
preload bundle on disk. Vite's @preload/* alias is build-time and
only rewrites `import` statements; path.join sees `@preload/...`
as a literal directory name.

Symptoms: Electron failed to load the preload, `window.api` stayed
undefined in the renderer, every first-tick IPC (`onTrafficLightInset`,
`onSessionStarted`, …) threw on an undefined member, React unmounted
on the uncaught exception, and the window displayed as a pure black
rectangle with the default "Electron" title. No main-process logs
hinted at the cause — the failure was on the renderer side and only
surfaced with ELECTRON_ENABLE_LOGGING=1.

Fix: restore the original relative path and annotate with a why-comment
so a future alias sweep doesn't re-break this. The preload bundle
electron-vite emits lives at `out/preload/index.mjs` alongside
`out/main/`, so the relative hop is structural — no alias available.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Snapshots the focused pane's diagnostic state into a timestamped
folder under ~/.config/cc-shell/debug-bundles/ and copies the
absolute path to the clipboard. The four debug panels (state,
feed-debug, proxy-semantic, html) already show this data live;
this command preserves it for after-the-fact inspection while
debugging cc-shell itself.

Bundle contents per invocation:
  manifest.json         — sessionId, kind, projectDir, ISO timestamp
  state-snapshot.json   — DebugPanel runtime flags (+ tail-truncated
                          screen buffers, count summary)
  feed-debug.jsonl      — runtime.feedDebugLog with tsIso per entry
  proxy-semantic.json   — runtime.semantic + ISO-mirrored log/errors
  html-raw.html         — focused pane's outerHTML at save time
  html-clean.html       — same, passed through sanitizeHtml

Renderer assembles the bundle (all four data sources are renderer-
local) and ships it via one debug:save-bundle IPC; main owns the
filesystem layout and returns the absolute path for toast display.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3a of the tree-reshape plan. The two renderer entry files
(`App.tsx`, `main.tsx`) lived loose at the root of `src/renderer/src/`,
sharing that dir with state/, features/, ui/, lib/, workspace/.
Co-locating them under `app/` makes the entry surface addressable as
one unit and leaves the root cleaner:

  src/renderer/src/
    app/          <- App.tsx + main.tsx
    app-state/    <- (phase 3b)
    features/
    lib/
    ui/
    workspace/
    styles.css
    vite-env.d.ts

Updated references:
  - src/renderer/index.html: script src → ./src/app/main.tsx
  - main.tsx: App import → @renderer/app/App

Pure moves — no logic changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3b of the tree-reshape plan. The renderer's Zustand store dir
was called `state/`, which (a) collides semantically with React's
own use of the word "state" and (b) doesn't signal what kind of
state it holds. Renaming to `app-state/` makes clear it's the
app-global store, not component state.

Mechanical:
  src/renderer/src/state/* -> src/renderer/src/app-state/*
  @renderer/state/*        -> @renderer/app-state/*  (20 importers)

No file contents changed. The rename preserves three slices
(settings/, uiShell/, workspace/), one store root, one hooks file,
and one shared types file — all still keyed by the same alias
shape, just under the clearer prefix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4a of the TileLeaf decomposition. Three pure-helper modules
leave the 1249-line TileLeaf.tsx for their own files under a new
sibling dir. No component structure or behavior changes — just
moves + the imports at the top of TileLeaf.tsx.

  tile-tree/TileLeaf/
    claudePaste.ts   — CLAUDE_PASTE_THRESHOLD + CLAUDE_PASTE_*_MS
                       constants, sendBracketedPaste,
                       sendBracketedPasteThenSubmit,
                       sendClaudeDraftText,
                       buildClaudeImagePastePayload.
                       Owns the "why we split paste payload from the
                       trailing Enter" rationale.
    claudeImages.ts  — MAX_CLAUDE_IMAGE_BYTES,
                       SUPPORTED_CLAUDE_IMAGE_*, gate predicates,
                       fileToDataUrl, parseDataUrl,
                       estimateBase64DecodedBytes,
                       parseImagesFromHtml, filesToDraftImages,
                       readImagesFromClipboard.
                       Owns the three-ingress-path header comment.
    labels.ts        — shortenCwd, providerLabel.

TileLeaf.tsx: 1249 -> 1073 lines (-176, -14%).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4b of the TileLeaf decomposition. Five small presentational
regions leave TileLeaf's 350-line render tree for their own files:

  tile-tree/TileLeaf/
    PaneHeader.tsx        — top status strip (project dir + status-
                            mode accent fill).
    QueueStrip.tsx        — queued-messages list (between Feed and
                            composer). Guarded internally for
                            defense-in-depth against future callers
                            who forget the length check.
    CompactionStrip.tsx   — /compact progress strip (running +
                            error states; collapses on 'done').
    PaneToast.tsx         — single-slot transient toast above
                            composer (fade+slide via toast-enter
                            class, animation restarts on remount).
    ScrollIndicator.tsx   — N/N badge + TAIL pill + provider label
                            above composer.

Each component reads only what it needs from `runtime` / `workspace`
via plain props — no shared store access, no refs. The original WHY
comments traveled with the JSX (pane-header accent rationale, queue-
strip "about to happen" region, scroll fraction invert-and-floor,
etc.).

TileLeaf.tsx: 1073 -> 961 lines (-112, -10%).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4c of the TileLeaf decomposition. Four hooks leave the
component body for their own files. No behavior changes — the
hooks wrap the exact same state machines and effects that lived
inline.

  tile-tree/TileLeaf/
    useComposerAutoGrow.ts  — textarea height-reflow effect. Owns
                              the "why not field-sizing:content"
                              + "why the 'auto' reset matters"
                              rationale.
    useTypeToFocus.ts       — document-level key listener that
                              redirects printable keys into the
                              composer when DOM focus drifted away.
                              Owns the full filter / injection-path
                              / rAF-for-selectionStart explanation.
    usePromptHistory.ts     — history derivation (extractLatest-
                              UserPrompts + dedup + reverse order)
                              plus cycle state (index + anchor +
                              end-cycle). Owns the "noise in user-
                              role JSONL and why permissionMode is
                              the positive signal" audit comment.
    useClaudeImagePaste.ts  — handlePaste callback + append/remove
                              draft-image callbacks. Owns the
                              three-ingress-path, media-type gate,
                              5 MB cap explanation.

Also removed now-unused imports from TileLeaf.tsx (useMemo,
extractLatestUserPrompts, ClaudeDraftImage, all six image-helper
re-exports — the hooks import what they need directly).

TileLeaf.tsx: 961 -> 727 lines (-234, -24%).
Cumulative Phase 4 so far: 1249 -> 727 (-522, -42%).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4d — the final TileLeaf decomposition stage. The composer's
render surface and its big key-handler leave TileLeaf for their own
files.

  tile-tree/TileLeaf/
    ComposerInput.tsx         — the composer container: slash-picker
                                overlay, Claude draft-images preview
                                strip, textarea with onChange slash-
                                mode suppression and history-cycle
                                cancel. Owns the "why onChange fires
                                when onKeyDown didn't" explanation.
    useComposerKeybinds.ts    — slash-mode routing, normal-mode
                                submit (all three submit branches:
                                codex bracketed-paste, claude with
                                images, claude paste-debounce,
                                claude fast path), Ctrl+C/Ctrl+D,
                                prompt-history Up/Down cycling.
                                Owns the "three submit modes" story
                                and the "Up gate on empty composer"
                                rationale.

The composer-owned `slashMode` useState also moves inside
useComposerKeybinds and is returned to the caller, so TileLeaf no
longer manages it directly.

TileLeaf.tsx: 727 -> 335 lines (-392, -54%).

Cumulative Phase 4 (4a + 4b + 4c + 4d):
  1249 -> 335 lines = -914 (-73%).

The remaining 335 lines are purely the container shell: prop
wiring, Feed mount with its ~30 props, and five sibling
subcomponents in an ordered column. Every sub-concern (paste
flow, image handling, history, auto-grow, type-to-focus, keybinds,
subcomponent UI) lives in its own file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 5 of the tree reshape. The root of the repo went from ten
top-level dirs with no grouping to a clear three-tier layout:

  cc-shell/
    src/          <- the app (Electron main/preload/renderer/shared)
    packages/     <- in-house submodules we build against directly
      agent-transcript-parser/
      claude-code-headless/
      codex-headless/
    vendor/       <- upstream read-only reference material
      codex-src/               (submodule: upstream openai/codex)
      claude-code-src/         (gitignored local checkout)
    testing/, scripts/, docs/, out/  (unchanged)

Mechanical changes:
  - git mv on the four submodules — .gitmodules auto-updates the
    `path = …` entries. Submodule internal storage under
    .git/modules/<name> remains keyed by the old name, which is
    fine: git derefs path + url, not storage location.
  - Rewrite .gitignore: `claude-code-src/` -> `vendor/claude-code-src/`.
  - Rewrite electron.vite.config.ts headlessAlias entries (6 lines).
  - Rewrite tsconfig.node.json and tsconfig.web.json `paths` blocks
    + the node project's `include` list so tsc still picks up the
    headless package sources.
  - Rewrite package.json: the `file:./agent-transcript-parser` dep
    spec and the two `npm --prefix claude-code-headless` script
    prefixes.
  - Rewrite three Node-level relative imports in testing/ghost-demo/*
    that reach into the built `dist/` artifacts (can't go through
    the Vite alias, they run via `tsx` directly).

`import 'claude-code-headless'` at call sites stays unchanged — the
alias maps the bare specifier to the new `packages/claude-code-
headless/src/index.ts` target. Same for codex-headless and
agent-transcript-parser.

npm run build + renderer smoke-launch pass cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
agent-transcript-parser picks up the rewindClaude + rewindCodex
re-exports (the source files were already in src/ but index.ts
never surfaced them). claude-code-headless picks up the proxy-
testing changes: shared mitmproxy CA confdir across sessions,
real error messages when mitmdump dies on startup, and discovery
of the venv under the new packages/ path.

No cc-shell code changes — only the gitlink commits move.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Juliusolsson05
Juliusolsson05 merged commit 9fb0b20 into main Apr 23, 2026
Juliusolsson05 added a commit that referenced this pull request May 14, 2026
…l path

Three fixes from the review on #125, all must-fix:

1. fetch-tmux.mjs spawned the extracted binary for both cache-hit
   detection and final validation, even on cross-arch fetches under
   `--all`. On a clean Apple-Silicon host without Rosetta, running an
   x86_64 Mach-O fails with "Bad CPU type in executable" — the
   release builder would crash trying to validate the x86_64 artifact
   it just downloaded. Cache-hit detection by `tmux -V` was also too
   loose: a corrupted-but-still-runnable binary that prints the right
   version string would pass.

   The fix: pin `binarySha256` per platform in
   `third_party/tmux/manifest.json` and use it for ALL identity
   checks. Cache hits, post-extract validation, and cross-arch
   `verify-tmux --strict` are now hash-based and platform-agnostic.
   The native arch additionally runs `tmux -V` + `otool -L` as a
   smoke check, but the load-bearing identity is the hash. A swapped
   or corrupted cross-arch binary that previously passed
   presence-only verification will now fail strict.

2. verify-tmux.mjs's cross-arch path was presence-only ("the file
   exists"), letting a corrupted x86_64 binary on an arm64 builder
   slip past strict mode. Same fix as #1: hash the cached binary
   against `binarySha256`. Strict mode now actually verifies content
   identity for every arch.

3. Bundled-only policy was leaky: src/main/index.ts passed the
   sentinel string `'tmux-bundled-missing'` to TmuxRegistry, which
   then spawned that bare command name. `child_process.spawn` PATH-
   searches bare names, so in the (extremely unlikely) case of a name
   collision the system would invoke whatever happened to be on PATH
   — exactly the silent fallback the bundled-only stance is meant to
   prevent.

   The fix: TmuxRegistry's `tmuxBinary` is now genuinely optional.
   When undefined, `detectAvailability()` short-circuits to false
   WITHOUT spawning anything. `getBinary()` returns `string | null`.
   src/main/index.ts passes `undefined` when bundled resolution
   returns null. No sentinel, no PATH search, no fallback.

Also: softened the `chmod 0o755` comment in resolveTmux per the
review — it no longer claims electron-builder definitively drops the
exec bit, just notes that the defensive chmod is a cheap safety net
for a class of "works in dev, broken in DMG" bugs that's much easier
to keep than to re-add later.

Verified (run in worktree):
- `npm run runtime:fetch:tmux -- --all` — both platforms cache-hit
  on rerun via the new binarySha256 path (no binary spawn for either
  arch).
- `npm run runtime:verify:tmux -- --strict` —
    darwin-arm64: `OK (3.6a, hash + linkage clean)`
    darwin-x86_64: `OK (hash matches; cross-arch)`  ← was presence-only
- `npm run build:app` — clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Juliusolsson05 added a commit that referenced this pull request Jun 30, 2026
…#1)

The architecture review's #1 finding: AppRunJournal and PerformanceService each
minted their OWN unrelated run id (appRunId vs `${Date.now()}-${pid}`), which
re-creates the exact "match subsystems by timestamp" problem the journal exists
to kill, and would force Phase 7 to retro-stamp ids onto perf runs/bundles.

Fix the identity at the source instead of stamping it on later:
- appRunIds.ts: getAppRunId() — a lazily-minted, process-wide singleton. The
  first diagnostic to start mints it (the always-on journal in normal runs;
  PerformanceService at module load when AGENT_CODE_PERF is on); everyone else
  gets the same value. One id for the whole run.
- AppRunJournal: consume getAppRunId() instead of minting its own.
- PerformanceService: runId now = getAppRunId() when enabled (still null when
  disabled, so every `runId === null` gate behaves exactly as before). When perf
  is on, its run id is byte-identical to the journal's — incidents and perf runs
  finally correlate by one key. Heap snapshots / debug bundles (Phases 4/7) can
  now adopt the same getter for free.

Deliberately NOT done (documented in startProbes): collapsing the two 5s
event-loop/memory samplers. That overlap exists ONLY when AGENT_CODE_PERF is
enabled (normal runs never start the perf service), the two feed different sinks
(OTel metrics vs. always-on heartbeat.json), and unifying them would couple the
heavy OTel pipeline to the always-on spine for one extra dev-only timer. Identity
was the real correlation risk; that's fixed. Sampler dedup is left as a noted,
low-value follow-up rather than a reckless rewire.

tsc --noEmit: clean for all changed files. runId is treated opaquely everywhere
(no code parses its format), so the value-shape change is safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Juliusolsson05 added a commit that referenced this pull request Jul 7, 2026
…ion parity + bridge accounting

A 2-Claude + 2-Codex orchestration review of the un-collapse found 4 real issues
(and validated the grouping/interleave/block-fetch structure as correct). All
fixed + locked with block-grain tests:

- semantic.ts finding 1 (Claude, CONFIRMED): a write_stdin tool block always has
  text:'' — the chars ride parsedInput/argumentsJson/inputJson. The collector
  checked b.text and so suppressed EVERY write_stdin, hiding real Codex stdin
  from the live feed. Now reads the chars via writeStdinChars (mirrors the
  retired renderUnits helper). Latent ledger bug the un-collapse exposed.
- semantic.ts finding 2 (Claude): text ownership keyed on finalized===true only,
  but a Codex message can be status:'completed' without finalized (the
  isTerminalProxyBlock equivalence). A completed-but-unfinalized reply escaped
  text dedup and could double-render under the resp_* vs rollout id split. Now
  keys on `finalized || status==='completed'`.
- ledgerFeedItems.ts finding (Codex): a RUNNING collapsed_activity was emitted as
  an item, but CollapsedActivityRow null-paints while running — a null-painting
  row the ledger counted as content (false-ownership). The bridge now skips it
  (mirrors semanticRenderUnitPaintsDom).
- ledgerFeedItems.ts finding (both Codex): a missing multi-block turn only pushed
  its first candidate to dropped[] (turnId marked emitted before the lookup), so
  the rest were silently skipped — the #239 accounting gap. Now pushes all the
  turn's candidate ids.

Codex #1's "grouping reorders ledger rows" was REFUTED by Claude: order.ts breaks
equal-timestamp ties on SOURCE_RANK before per-turn sequence, so a committed row
can never fall between a turn's blocks — group-by-turn is safe.

New tests: write_stdin chars (parsedInput + argumentsJson), status:completed text
key, running-churn emits no collapsed item. Gates: tsc clean; rendering 166,
unit 432, renderer 14, integration 28.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Juliusolsson05
Juliusolsson05 deleted the refactor/app-structure branch July 7, 2026 21:51
Juliusolsson05 added a commit that referenced this pull request Jul 16, 2026
Caught live during the Phase 5 showcase (user screenshot): modern Codex
never called apply_patch as a tool — it called exec with a JS wrapper,
  const patch = "*** Begin Patch\n..."; await tools.apply_patch(patch)
so the sentinel was present but the newlines were ESCAPES inside a string
literal; the line-based parser saw one giant line and the card fell to the
generic exec row showing raw wrapper JavaScript — the exact case the plan's
Phase 5 exit gate forbids. This is the corpus's documented #1 gap
(codex.tool-use.exec.v1, live-only catalog entry) rendering unhandled.

- applyPatchText now detects the escaped-sentinel case and decodes the
  containing string literal (decodeEmbeddedPatchLiteral): scan back to the
  opening quote, decode escapes forward, PREFIX-TOLERANT — an unterminated
  literal mid-stream decodes to the patch streamed so far, so wrapped
  patches stream as growing diffs too (streaming-first holds).
- Committed dispatch routes exec-with-sentinel through CodexApplyPatchRow;
  BlockRow's live function_call branch routes exec the same way. Plain
  command scripts decline inside the row and fall back as before —
  strictly additive routing.
- 3 new tests: full wrapper decode, streaming prefix, plain-command decline.

Verification: tsc -b node+web 0 errors; vitest full suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Juliusolsson05 added a commit that referenced this pull request Jul 18, 2026
Five findings from an adversarial review of this branch.

1. isDarkThemeValue's luminance branch was unreachable at both call
   sites: reaching it required a saved theme that both resolves and
   parses, which is exactly the condition that yields a payload and takes
   the other branch. The spec's Risk #1 was wrong to begin with — accent
   and accentFg are themselves custom-appearance tokens, so a saved theme
   always writes its own concrete accent and the accent-pair branch is
   only reachable on a built-in mode. Deleted the helper, reverted the
   lib/color/luminance.ts extraction and the xtermTheme.ts churn it
   justified, and corrected the spec rather than leaving a wrong risk
   analysis in the repo.

2. applyTheme wrote a raw `theme:<uuid>` into data-mode when a theme id
   no longer resolved. Not a black screen (:root doubles as dark), but
   every high-contrast rule is a compound selector, so contrast silently
   switched off while its toggle still read on. Now falls back to a
   built-in id. Unreachable in-app today, but applyTheme is called by the
   phone client on an uncoerced blob.

3. Migration idempotency keyed on the theme NAME "Custom", which is
   non-unique and user-editable — so naming a theme "Custom" and
   round-tripping through a v4 build could orphan the payload the user
   was actually looking at. Now keyed on a migratedFrom marker that
   renaming cannot forge.

4. parseCustomAppearanceJson threw on unknown keys and coerceSavedThemes
   dropped anything that threw, so a future token rename would silently
   delete every saved theme at boot. That was survivable for one
   throwaway slot; named themes are durable artifacts seeded dense with
   all 81 keys. Split strict (editor — a typo must not look applied) from
   lenient (load — keep the theme, ignore what we cannot read). The
   isSafeCssColorValue allowlist is enforced in both modes; leniency
   recovers themes, it does not relax what reaches a CSS property.

5. Dropped THEME_MODES / ThemeMode imports left dead in settingsRegistry
   by the THEME_MODE_OPTIONS deletion. tsc misses these — noUnusedLocals
   is not set in any tsconfig.

Verified: tsc clean on both projects; 12-case throwaway harness covering
the marker-keyed idempotency, the reviewer's exact downgrade sequence,
lenient survival of a renamed token, and that lenient still rejects
url(). Harness deleted, not committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Juliusolsson05 added a commit that referenced this pull request Jul 20, 2026
… imports

Three bugs, all found by actually running it. The symptom was an empty modal
with no error anywhere.

1. PER-MOUNT ELEMENT. React.StrictMode runs effects mount -> cleanup -> mount.
An extension whose disposer defers its unmount — queueMicrotask,
requestAnimationFrame, a transition — tears down root #1 AFTER root #2 has
rendered into the same node, and root1.unmount() empties the element root2 lives
in. The view mounts "successfully" and shows nothing. The timer extension defers
exactly this way, which is how it surfaced.

Each mount now gets its own child element, discarded on cleanup, so a late
disposer operates on a detached node it cannot reach anything through. That
makes the host immune to extension cleanup timing rather than requiring every
extension to get it right.

2. NO SILENT EARLY RETURN. `if (!element) return` left status at whatever it
started as — and when the extension already activated at startup that is
'ready', producing a visible empty view with nothing logged. Any exit from the
mount path now sets a status.

3. CACHE-BUSTED IMPORT URL. The ES module registry caches by URL and caches
FAILURES: once import(url) rejects, every later import of that URL returns the
same rejection for the life of the realm, even after the file on disk is
replaced. So an extension that failed once could never be fixed by reinstalling
— the user updates it, sees the identical error, and has no signal that their
fix landed. Reloading the window was the only escape and nothing said so.

The key is version + the installer's sha256, which changes exactly when the
bytes change. The query never reaches the filesystem: the scheme handler builds
its path from url.pathname, which excludes the search string.

typecheck / 1187 tests / build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Juliusolsson05 added a commit that referenced this pull request Sep 13, 2026
SDK PR #1 merged as a merge commit (503bced), which includes its two
build-path review fixes. The previous pointer named a commit that existed
only on the feature branch, so a later branch cleanup would have made
`git submodule update` fail in CI and release builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnzR4ggdHfDfmFcTA7H8kw
Juliusolsson05 added a commit that referenced this pull request Sep 18, 2026
…nuations

Review of #1003 found the state machine could double-deliver, prompt an
agent mid-turn, and stall with nothing left to observe.

The backoff retry honoured retrySafe only for the immediate retry, so an
acceptance timeout after Enter re-sent a continuation that was probably
already running. An uncertain delivery now pauses the loop instead.

Working state was reduced only while a loop was active and seeded blank
at goal_loop_start. Claude publishes turn_completed per assistant message
and those events reach main on a 200 ms poll, so the completion of the
very message that called goal_loop_start arrived after the blank seed and
triggered continuation #1 inside that turn. The same gap left a loop
paused mid-turn permanently "working", so Resume never delivered. State is
now tracked for every session and the seed only fills in a non-working one.

Every trigger now goes through one request path that re-checks the tracked
state, which closes pause->resume during an in-flight delivery (delivered
twice) and a backoff retry dropped by the re-entrancy guard on a slow disk.

persist() evicted the newest entries rather than the oldest and never
bounded paused loops, which every removed session and restart mints.
start() also overwrote the corrupt file the store claimed to preserve;
unreadable storage is now moved aside first.

Adds a dismiss control action so an ended loop can be cleared, one shared
action union for main, preload and the service, and a cross-session
isolation test over a shared service, which the per-session-service
system test could not observe.

Refs #1001

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Juliusolsson05 added a commit that referenced this pull request Sep 23, 2026
…ackages

- packages/pi-terminal-headless -> 5a00520 (PR #1 merged, with the five
  Astra package fixes), packages/agent-transcript-parser -> 4ad8d9c (PR #36
  merged).
- catalog.test.ts: main's 126 + the two generated Pi splits = 128.
- hook/index.ts: main's BuiltInMcpDefaultsInput import alongside Pi's
  effectiveProviderRuntime / providerChoiceLabel imports.
- #1143's per-provider built-in MCP defaults: Pi entries in two fixtures
  (the product map is generated from AGENT_PROVIDER_KINDS).
- initialHistory: the superseded-load guard now compares the id the load
  READ with the pane's id, and no longer the window generation, which threw
  away OpenCode Terminal's startup load and rehydrate/recovery loads that
  bind their id mid-flight.
- piTranscript: a file whose header names another session is no longer
  this session (the package now discovers by header, like pi), so it reads
  as empty rather than being refused after a by-name match.

Refs #1132

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Juliusolsson05 added a commit that referenced this pull request Sep 25, 2026
…er missing whitespace

Review round 1 on #1219 (2 Codex reviewers; the Pi report is pending):

- Major (A): stripping every whitespace character also accepted MISSING
  whitespace, so a payload foo\nbar 'landed' on a composer showing
  foobar, and the recorded unrelated 'opencode [Image #1]' composer
  matched a pending opencode[\nImage#1] payload. Both sent Enter for a
  paste that had not landed. The tail is now matched by a pattern: each
  whitespace run in the (collapsed) needle must be whitespace on screen,
  and between any other two characters the screen MAY hold whitespace,
  which is exactly what a soft or hard wrap inserts.
- Major (B): the baseline transition had no test; dropping the
  tailAlreadyPresent guard or reverting the baseline to a plain substring
  survived. A second paste over an already-present hard-wrapped tail now
  times out instead of confirming.

Tests: both reviewer reproductions and the baseline case. Each fails under
its mutation (space made optional; guard removed; baseline without wrap
tolerance), and the 20-140 column sweep still passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Juliusolsson05 added a commit that referenced this pull request Sep 25, 2026
…t fell

Review round 2 (A) on #1219 found that the round-1 pattern, which allowed
optional whitespace between any two characters, matched an EARLIER part of
the same paste that had a real space where the tail has none, and so sent
Enter before the tail arrived. It also read the recorded soft-wrapped
'[Image' / '  #1]' pill as 'Image#1'.

Both withdrawn approaches guessed where a wrap might be. The composer text
is now reconstructed: Claude draws its chrome divider across the full
composer width, so a line that fills that width and whose continuation does
not start with a space was cut mid-token and joins with nothing between;
every other break was a soft wrap and joins with one space. The tail is
then a plain substring of that text, for both baseline and poll. With no
divider on screen every break is treated as soft (the old behaviour), and
misreading a rare exactly-full soft line errs toward a timeout, never an
early Enter.

Tests: both round-2 reproductions, plus the round-1 cases and the 20-140
column sweep, now through activeClaudeComposerText as production uses it.
Mutation-checked: never-hard, width ignored, physical baseline, and baseline
guard removed each turn a test red.

Final round: fixed by the author per the at-most-two-rounds rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant