From 5442a5d4d9e5cca82aa5b8735692fd26139ae5cb Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 23 Apr 2026 15:34:09 +0200 Subject: [PATCH 01/27] refactor(main): split index.ts by domain into ipc/, sessions/, storage/, window/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/main/index.ts | 1110 +------------------------- src/main/ipc/debug.ts | 28 + src/main/ipc/fs.ts | 126 +++ src/main/ipc/ghost.ts | 30 + src/main/ipc/git.ts | 199 +++++ src/main/ipc/index.ts | 40 + src/main/ipc/lsp.ts | 50 ++ src/main/ipc/provider.ts | 64 ++ src/main/ipc/session.ts | 149 ++++ src/main/ipc/sessions.ts | 45 ++ src/main/ipc/workspace.ts | 38 + src/main/sessions/forwarder.ts | 53 ++ src/main/sessions/historyLoader.ts | 140 ++++ src/main/sessions/jsonlCoalescer.ts | 75 ++ src/main/storage/claudeImageCache.ts | 99 +++ src/main/storage/feedDebugLog.ts | 64 ++ src/main/storage/paths.ts | 19 + src/main/window/mainWindow.ts | 102 +++ 18 files changed, 1363 insertions(+), 1068 deletions(-) create mode 100644 src/main/ipc/debug.ts create mode 100644 src/main/ipc/fs.ts create mode 100644 src/main/ipc/ghost.ts create mode 100644 src/main/ipc/git.ts create mode 100644 src/main/ipc/index.ts create mode 100644 src/main/ipc/lsp.ts create mode 100644 src/main/ipc/provider.ts create mode 100644 src/main/ipc/session.ts create mode 100644 src/main/ipc/sessions.ts create mode 100644 src/main/ipc/workspace.ts create mode 100644 src/main/sessions/forwarder.ts create mode 100644 src/main/sessions/historyLoader.ts create mode 100644 src/main/sessions/jsonlCoalescer.ts create mode 100644 src/main/storage/claudeImageCache.ts create mode 100644 src/main/storage/feedDebugLog.ts create mode 100644 src/main/storage/paths.ts create mode 100644 src/main/window/mainWindow.ts diff --git a/src/main/index.ts b/src/main/index.ts index 6054cbdac..f76797ca9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,86 +1,48 @@ -import { app, BrowserWindow, ipcMain, shell } from 'electron' -import { randomUUID } from 'crypto' -import { dirname, extname, join, resolve } from 'path' -import { fileURLToPath } from 'url' -import { readdir, readFile, stat, writeFile, mkdir, rm } from 'fs/promises' -import { homedir } from 'os' +import { app, BrowserWindow } from 'electron' +import { readFile } from 'fs/promises' import { SessionManager } from './sessionManager.js' import { LspManager } from './lspManager.js' -import { - GhostJournalRegistry, - readGhostLog, -} from './ghostJournal.js' -import type { GhostEntry } from 'agent-transcript-parser' +import { GhostJournalRegistry } from './ghostJournal.js' import { TmuxRegistry } from './tmux/TmuxRegistry.js' import { reconcile, type PersistedTerminalRef } from './tmux/tmuxRecovery.js' -import { getMainProvider } from '../providers/registry.main.js' -import { switchProvider } from './providerSwitch/switchProvider.js' -import { duplicateSession } from './providerSwitch/duplicateSession.js' -import { - rewindSession, - type RewindSessionRequest, -} from './providerSwitch/rewindSession.js' -import { listAllClaudeSessions } from '../providers/claude/runtime/sessionList.js' -import { listCodexSessions } from '../providers/codex/runtime/sessionList.js' -// Main process — thin Electron host over SessionManager. +import { STATE_FILE } from './storage/paths.js' +import { cleanupClaudeImageCacheDir } from './storage/claudeImageCache.js' +import { createMainWindow } from './window/mainWindow.js' +import { wireSessionForwarder } from './sessions/forwarder.js' +import { registerAllIpc } from './ipc/index.js' + +// Main process — thin Electron host. // -// Responsibilities: -// 1. Create the BrowserWindow. -// 2. Own ONE SessionManager instance for the lifetime of the app. -// 3. Forward every session event to the renderer via sessionId-scoped -// IPC channels. -// 4. Accept spawn / kill / write / resize commands from the renderer. -// 5. Load + save the workspace state JSON (tile tree + cwds). -// 6. Kill all sessions cleanly on app quit. +// Responsibilities kept here (anything that isn't a domain concern of +// its own lives in these few lines): +// 1. Construct the long-lived service singletons (LspManager, +// GhostJournalRegistry) before anything else needs them. +// 2. Detect tmux availability and reconcile persisted terminal +// sessions BEFORE SessionManager is built — spawn recovery +// needs to know which tmux sessions are already alive. +// 3. Build the BrowserWindow, wire the SessionManager → renderer +// forwarder, and register every IPC handler. +// 4. Kill all sessions cleanly on app quit. // -// The tile tree itself lives in the renderer — main has no idea what a -// "tab" or a "split" is. It just manages PTYs and shuffles bytes. - -const __dirname = dirname(fileURLToPath(import.meta.url)) - -// Path to the workspace state file. Follows XDG on Linux but uses -// ~/.config on macOS too (it's simpler than mirroring Electron's -// per-platform userData logic and the file is tiny). -const STATE_DIR = join(homedir(), '.config', 'cc-shell') -const STATE_FILE = join(STATE_DIR, 'workspace.json') -const FEED_DEBUG_DIR = join(STATE_DIR, 'feed-debug') +// Everything else is delegated: +// - IPC handlers: main/ipc/*.ts +// - SessionManager → renderer forwarding: main/sessions/forwarder.ts +// - Window creation + send helper: main/window/mainWindow.ts +// - Disk paths, image cache, feed-debug writer: main/storage/* +// - History chunk loader + jsonl coalescer: main/sessions/* +// +// The tile tree itself lives in the renderer — main has no idea what +// a "tab" or a "split" is. It just manages PTYs and shuffles bytes. -let mainWindow: BrowserWindow | null = null +const lspManager = new LspManager() +// Ghost log writer — one queue per session. Writes are batched at +// 100 ms and persisted under `/ghost-logs/.ghost.jsonl`. +// See `./ghostJournal.ts` for the full rationale; see +// `src/renderer/src/tiles/ghosts.ts` for the renderer side. +const ghostJournals = new GhostJournalRegistry() -/** - * Push the traffic light (close/minimize/zoom) right-edge inset to the - * renderer as a CSS custom property. The renderer uses this to pad the - * tab bar so tabs don't sit under the buttons — zoom-safe, scale-safe, - * no magic pixel values. - * - * On non-macOS platforms or when the position isn't available, falls - * back to 0 (no inset needed — the title bar is separate). - */ -function pushTrafficLightInset(): void { - if (!mainWindow || mainWindow.isDestroyed()) return - // getWindowButtonPosition returns { x, y } of the top-left of the - // FIRST button (close). The three buttons are each ~14px wide with - // ~6px gaps, arranged left-to-right: close, minimize, zoom. The - // right edge of the zoom button is roughly x + 68 CSS pixels at 1x - // scale. But we want to be precise, so we add a comfortable margin - // past the reported x position. The x value already accounts for - // the hiddenInset padding. - try { - const pos = mainWindow.getWindowButtonPosition() - if (pos) { - // pos.x is the left edge of the close button. The three buttons - // span ~54px total, plus we want ~8px breathing room after the - // last button. Round up to avoid sub-pixel clipping. - const inset = Math.ceil(pos.x + 62) - mainWindow.webContents.send('traffic-light-inset', inset) - } - } catch { - // getWindowButtonPosition throws on non-macOS. Silently skip — - // the renderer defaults to 0. - } -} // SessionManager is constructed inside whenReady so we can await // TmuxRegistry.detectAvailability() first — terminal sessions need // to know during spawn whether a tmux backend is available, and @@ -88,990 +50,7 @@ function pushTrafficLightInset(): void { // load-bearing: every other module-scope reference is inside // callbacks that fire after the assignment. let manager: SessionManager = null as unknown as SessionManager -const feedDebugWriteQueues = new Map>() - -type FeedDebugPersistEntry = { - id: number - ts: number - tMs: number - layer: 'STATE' | 'JSONL' | 'SEM' | 'RENDER' - kind: string - summary: string - data?: unknown -} - -function sanitizeSessionIdForPath(sessionId: string): string { - return sessionId.replace(/[^a-zA-Z0-9._-]/g, '_') -} - -function queueFeedDebugAppend( - sessionId: string, - entries: FeedDebugPersistEntry[], -): Promise { - const previous = feedDebugWriteQueues.get(sessionId) ?? Promise.resolve() - const next = previous - .catch(() => {}) - .then(async () => { - if (entries.length === 0) return - await mkdir(FEED_DEBUG_DIR, { recursive: true }) - const filePath = join(FEED_DEBUG_DIR, `${sanitizeSessionIdForPath(sessionId)}.jsonl`) - const text = entries - .map(entry => JSON.stringify({ sessionId, ...entry })) - .join('\n') + '\n' - await writeFile(filePath, text, { encoding: 'utf8', flag: 'a' }) - }) - feedDebugWriteQueues.set(sessionId, next) - return next -} let tmuxRegistry: TmuxRegistry | null = null -const lspManager = new LspManager() -// Ghost log writer — one queue per session. Writes are batched at -// 100 ms and persisted under `/ghost-logs/.ghost.jsonl`. -// See `./ghostJournal.ts` for the full rationale; see -// `src/renderer/src/tiles/ghosts.ts` for the renderer side. -const ghostJournals = new GhostJournalRegistry() -const SUPPORTED_CLAUDE_IMAGE_MEDIA_TYPES = new Set([ - 'image/png', - 'image/jpeg', - 'image/gif', - 'image/webp', -]) -const MAX_CLAUDE_IMAGE_BYTES = 5 * 1024 * 1024 - -function extractClaudeHistoryMarker(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 extractCodexHistoryMarker(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 findCodexRolloutPathByThreadId( - sessionsDir: string, - threadId: string, -): Promise { - try { - const years = await readdir(sessionsDir) - for (const year of years.sort().reverse()) { - const yearDir = join(sessionsDir, year) - const yStat = await stat(yearDir).catch(() => null) - if (!yStat?.isDirectory()) continue - const months = await readdir(yearDir) - for (const month of months.sort().reverse()) { - const monthDir = join(yearDir, month) - const mStat = await stat(monthDir).catch(() => null) - if (!mStat?.isDirectory()) continue - const days = await readdir(monthDir) - for (const day of days.sort().reverse()) { - const dayDir = join(monthDir, day) - const dStat = await stat(dayDir).catch(() => null) - if (!dStat?.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 -} - -async function loadOlderHistoryChunk(params: { - kind: 'claude' | 'codex' - 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(line => line.trim()) - .filter(line => line.length > 0) - .map(line => { - try { - return JSON.parse(line) as Record - } catch { - return null - } - }) - .filter((entry): entry is Record => entry !== null) - - const markerOf = params.kind === 'claude' - ? extractClaudeHistoryMarker - : extractCodexHistoryMarker - - const anchorIndex = parsed.findIndex(entry => markerOf(entry) === 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, - } -} - -function send(channel: string, ...args: unknown[]): void { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(channel, ...args) - } -} - -function extensionForMediaType(mediaType: string, filename?: string): string { - switch (mediaType) { - case 'image/jpeg': - return '.jpg' - case 'image/gif': - return '.gif' - case 'image/webp': - return '.webp' - case 'image/png': - return '.png' - } - const fromName = extname(filename ?? '').trim().toLowerCase() - return fromName || '.png' -} - -function isSupportedClaudeImageMediaType(mediaType: string): boolean { - return SUPPORTED_CLAUDE_IMAGE_MEDIA_TYPES.has(mediaType.toLowerCase()) -} - -function estimateBase64DecodedBytes(base64Data: string): number { - const normalized = base64Data.trim() - if (normalized.length === 0) return 0 - const padding = normalized.endsWith('==') ? 2 : normalized.endsWith('=') ? 1 : 0 - return Math.floor((normalized.length * 3) / 4) - padding -} - -function getClaudeImageCacheDir(): string { - return join(app.getPath('temp'), 'cc-shell', 'claude-images') -} - -async function cleanupClaudeImageCacheDir(): Promise { - await rm(getClaudeImageCacheDir(), { recursive: true, force: true }) -} - -// Per-session jsonl-entry coalescer. -// -// WHY: on a resumed Claude/Codex session, the headless `bootstrapTail` -// parses the last ~200 lines from the JSONL file synchronously and -// emits one `jsonl-entry` event per line. Forwarding each as its own -// IPC `webContents.send` produced ~200 round-trips per pane × N panes -// on restart — which on the renderer side became 200N React renders, -// 200N O(N) spreads, and 200N auto-scroll pins. That's the "feels -// like I'm being scrolled through the whole conversation" bug. -// -// Coalescing: we buffer entries per sessionId, schedule ONE -// setImmediate flush, and deliver the whole burst as a single -// `session:jsonl-entries` payload. setImmediate (not Promise.resolve -// or process.nextTick) runs after the current I/O tick finishes, so -// the whole bootstrapTail loop drains before we schedule a send. Live -// mid-conversation entries land one per tick and are flushed -// immediately after — no added latency for the streaming path. -// -// Singular `session:jsonl-entry` is intentionally kept alive: any -// non-bulk consumer (tests, future single-entry subscribers) can -// still listen, and keeping both channels means this change is -// strictly additive from the renderer's perspective. The renderer -// subscribes to BOTH and picks whichever it prefers. -type PendingJsonlBuffer = { - entries: Array<{ entry: import('claude-code-headless').JsonlEntry; file: string }> - flushScheduled: boolean -} -const jsonlPending = new Map() - -function flushJsonlFor(sessionId: string): void { - const pending = jsonlPending.get(sessionId) - if (!pending || pending.entries.length === 0) return - const payload = { - sessionId, - entries: pending.entries, - } - pending.entries = [] - pending.flushScheduled = false - send('session:jsonl-entries', payload) -} - -function enqueueJsonl( - sessionId: string, - entry: import('claude-code-headless').JsonlEntry, - 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)) - } -} - -// Wire every manager event to a matching IPC channel. Each payload -// carries the sessionId so the renderer can route to the right tile. -// -// Note: terminal-data is a new channel specific to plain shell -// sessions. Claude sessions use screen / jsonl-entry / jsonl-error -// to surface their structured state; terminal sessions just forward -// raw PTY bytes for xterm.js to render. Keeping them on separate -// channels avoids making every Claude pane listener unpack/ignore -// terminal bytes it doesn't care about. -function wireManagerIPC(): void { - manager.on('started', payload => send('session:started', payload)) - manager.on('screen', payload => send('session:screen', payload)) - // Bulk-only forwarding. We used to dual-emit (singular + - // coalesced) for "backward compatibility," but the singular IPC - // queue beat the coalescer to the renderer on every burst — - // 200 singular messages got processed first, the renderer's - // singular handler did 200 separate setRuntimes calls, and by - // the time the bulk message arrived the seenUuidsRef dedupe made - // it a no-op. The user-visible result was the exact "scroll - // through the whole conversation, takes 5 seconds" symptom we - // were trying to fix. - // - // Now: every entry goes through enqueueJsonl. Live single - // entries become 1-element bulk messages with ~1ms latency from - // setImmediate — imperceptible. Bootstrap bursts coalesce into - // one bulk delivery. The renderer subscribes ONLY to the bulk - // channel. - manager.on('jsonl-entry', payload => { - enqueueJsonl(payload.sessionId, payload.entry, payload.file) - }) - manager.on('jsonl-error', ({ sessionId, error }) => - send('session:jsonl-error', { - sessionId, - message: String(error.message ?? error), - }), - ) - manager.on('terminal-data', payload => - send('session:terminal-data', payload), - ) - manager.on('process-state', payload => send('session:process-state', payload)) - manager.on('trust-dialog', payload => send('session:trust-dialog', payload)) - manager.on('resume-prompt', payload => send('session:resume-prompt', payload)) - manager.on('compaction-state', payload => send('session:compaction-state', payload)) - manager.on('semantic-event', payload => send('session:semantic-event', payload)) - manager.on('exit', payload => { - // Final flush — any entries still buffered from the last - // bootstrapTail tick must land before exit so the renderer sees a - // consistent final entries list. - flushJsonlFor(payload.sessionId) - jsonlPending.delete(payload.sessionId) - send('session:exit', payload) - }) - lspManager.on('diagnostics', payload => send('lsp:diagnostics', payload)) -} - -function createWindow(): void { - mainWindow = new BrowserWindow({ - width: 1400, - height: 900, - show: false, - titleBarStyle: 'hiddenInset', - backgroundColor: '#0a0a0a', - webPreferences: { - preload: join(__dirname, '../preload/index.mjs'), - sandbox: false, - contextIsolation: true, - nodeIntegration: false, - }, - }) - - mainWindow.on('ready-to-show', () => { - mainWindow?.show() - pushTrafficLightInset() - }) - - // Recompute the traffic light inset whenever the window geometry - // changes — zoom level, display scale, fullscreen toggle. Electron - // doesn't offer a "traffic light moved" event, but resize covers - // every case that shifts them. - mainWindow.on('resize', pushTrafficLightInset) - - mainWindow.webContents.setWindowOpenHandler(({ url }) => { - shell.openExternal(url) - return { action: 'deny' } - }) - - if (process.env['ELECTRON_RENDERER_URL']) { - mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) - } else { - mainWindow.loadFile(join(__dirname, '../renderer/index.html')) - } -} - -// ---------- IPC handlers ---------- - -function registerIpc(): void { - // --- Session lifecycle --- - ipcMain.handle( - 'session:spawn', - async ( - _evt, - options: { - kind?: 'claude' | 'codex' | 'terminal' - cwd: string - cols?: number - rows?: number - resumeSessionId?: string - dangerousMode?: boolean - useProxy?: boolean - }, - ) => { - return await manager.spawn(options) - }, - ) - - // --- Session listing for the resume picker --- - // - // Called by PathPickerModal when the user types a cwd — returns a - // list of previous sessions in that directory so they can resume one - // instead of starting fresh. Empty array when the cwd has no - // recorded history yet. - ipcMain.handle( - 'session:list-for-cwd', - async ( - _evt, - cwd: string, - limit?: number, - provider: 'claude' | 'codex' = 'claude', - ) => { - try { - // Dispatch session listing through the provider registry. - // Each provider's listSessions handles its own storage format. - const providerConfig = getMainProvider(provider) - return await providerConfig.listSessions(cwd, limit ?? 20) - } catch (err) { - // Don't let a listing error brick the modal — return empty. - // eslint-disable-next-line no-console - console.warn('[session:list-for-cwd] failed:', err) - return [] - } - }, - ) - - // --- Global session listing (used by the rendering-debug harness) --- - // - // Returns every known Claude + Codex session across all project - // dirs, newest first, tagged with provider. The main app routes - // through `session:list-for-cwd` because it filters by the focused - // pane's cwd; the harness needs a global list because it has no - // notion of "current cwd". - ipcMain.handle( - 'session:list-all', - async (_evt, limit?: number) => { - const cap = typeof limit === 'number' && limit > 0 ? limit : 200 - try { - const [claude, codex] = await Promise.all([ - listAllClaudeSessions({ limit: cap }).catch(() => []), - listCodexSessions({ limit: cap }).catch(() => []), - ]) - const tagged = [ - ...claude.map(s => ({ ...s, provider: 'claude' as const })), - ...codex.map(s => ({ ...s, provider: 'codex' as const })), - ] - tagged.sort((a, b) => b.lastModified - a.lastModified) - return tagged.slice(0, cap) - } catch (err) { - // eslint-disable-next-line no-console - console.warn('[session:list-all] failed:', err) - return [] - } - }, - ) - - ipcMain.handle( - 'session:load-older-history', - async ( - _evt, - params: { - kind: 'claude' | 'codex' - cwd: string - providerSessionId: string - beforeMarker: string - limit?: number - }, - ) => { - return await loadOlderHistoryChunk({ - ...params, - limit: params.limit ?? 200, - }) - }, - ) - - ipcMain.handle('session:kill', async (_evt, sessionId: string) => { - return await manager.kill(sessionId) - }) - - ipcMain.handle( - 'session:switch-provider', - async ( - _evt, - params: { - sourceKind: 'claude' | 'codex' - sourceProviderSessionId: string - cwd: string - }, - ) => { - return await switchProvider(params) - }, - ) - - ipcMain.handle( - 'session:duplicate', - async ( - _evt, - params: { - provider: 'claude' | 'codex' - sourceProviderSessionId: string - cwd: string - }, - ) => { - return await duplicateSession(params) - }, - ) - - // Rewind the focused pane's transcript to just before a selected - // user prompt. Produces a NEW provider session id; the source file - // is never touched. The renderer passes the returned id to - // `replaceSession(...)` to re-home the pane, and prefills - // `promptText` as an unsent draft. See - // `src/main/providerSwitch/rewindSession.ts` for the slicing rules. - ipcMain.handle( - 'session:rewind-to-prompt', - async (_evt, params: RewindSessionRequest) => { - return await rewindSession(params) - }, - ) - - // --- Terminal attach/replay --- - // - // Called once by TerminalLeaf on mount. Returns the full buffered - // output of the terminal session so far, AND flips the manager's - // "attached" flag so subsequent PTY data events broadcast live. - // - // The race being fixed: between the spawnSession IPC resolving - // and TerminalLeaf's useEffect running, the shell has already - // printed its prompt. Without this buffered replay the renderer - // would see nothing but a blinking cursor — exactly the bug the - // user reported. See the big block comment on - // SessionManager.terminalBuffers for the full reasoning. - ipcMain.handle('session:terminal-attach', (_evt, sessionId: string) => { - return manager.attachTerminal(sessionId) - }) - - // --- Per-session I/O --- - ipcMain.handle( - 'session:input', - (_evt, sessionId: string, data: string) => { - const ok = manager.write(sessionId, data) - if (!ok) { - // eslint-disable-next-line no-console - console.warn('[session:input] dropped write for missing session', { - sessionId, - dataLength: data.length, - }) - } - return ok - }, - ) - - ipcMain.handle( - 'session:resize', - (_evt, sessionId: string, cols: number, rows: number) => { - manager.resize(sessionId, cols, rows) - }, - ) - - // --- LSP-backed code intelligence for Monaco code blocks --- - ipcMain.handle( - 'lsp:ensure-legend', - async (_evt, workspaceRoot: string, language: string) => { - return await lspManager.ensureSemanticLegend(workspaceRoot, language) - }, - ) - - ipcMain.handle( - 'lsp:open-document', - async ( - _evt, - params: { - clientUri: string - content: string - language: string - workspaceRoot: string - filePath?: string | null - }, - ) => { - await lspManager.openDocument(params) - }, - ) - - ipcMain.handle( - 'lsp:change-document', - async (_evt, clientUri: string, content: string) => { - await lspManager.changeDocument(clientUri, content) - }, - ) - - ipcMain.handle('lsp:close-document', async (_evt, clientUri: string) => { - await lspManager.closeDocument(clientUri) - }) - - ipcMain.handle('lsp:get-semantic-tokens', async (_evt, clientUri: string) => { - return await lspManager.getSemanticTokens(clientUri) - }) - - // --- Directory listing (used by PathInput for completion) --- - // Given a directory path (raw user input that may include ~ or be - // relative), return its entries as { name, isDirectory } for the - // renderer to filter + display as a suggestion dropdown. - // - // Options: - // directoriesOnly — filter out regular files (default true for - // cwd pickers, false for file pickers) - // showHidden — include .dotfile entries (default false) - // - // Returns { ok: true, entries, expanded } on success — `expanded` - // lets the renderer display the resolved path in the UI if it wants. - // On failure (ENOENT, not a directory, EACCES, etc.) returns a - // discriminated error the caller can show inline instead of throwing. - type DirEntry = { name: string; isDirectory: boolean; path: string } - type ListResult = - | { ok: true; entries: DirEntry[]; expanded: string } - | { ok: false; error: string } - - ipcMain.handle( - 'fs:listDirectory', - async ( - _evt, - rawPath: string, - opts?: { directoriesOnly?: boolean; showHidden?: boolean }, - ): Promise => { - const directoriesOnly = opts?.directoriesOnly ?? true - const showHidden = opts?.showHidden ?? false - - // Expand ~ and resolve to absolute. Empty / `.` / `~` all mean - // "home directory" — that's the natural starting point for path - // completion in a picker. - let expanded = rawPath.trim() - if (expanded === '' || expanded === '~') { - expanded = homedir() - } else if (expanded.startsWith('~/')) { - expanded = join(homedir(), expanded.slice(2)) - } - expanded = resolve(expanded) - - try { - const dirents = await readdir(expanded, { withFileTypes: true }) - const entries: DirEntry[] = [] - for (const d of dirents) { - if (!showHidden && d.name.startsWith('.')) continue - const isDirectory = d.isDirectory() - if (directoriesOnly && !isDirectory) continue - entries.push({ - name: d.name, - isDirectory, - path: join(expanded, d.name), - }) - } - // Sort: directories first, then alpha (case-insensitive). - entries.sort((a, b) => { - if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1 - return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) - }) - return { ok: true, entries, expanded } - } catch (err) { - const e = err as NodeJS.ErrnoException - if (e.code === 'ENOENT') return { ok: false, error: 'does not exist' } - if (e.code === 'ENOTDIR') return { ok: false, error: 'not a directory' } - if (e.code === 'EACCES') return { ok: false, error: 'permission denied' } - return { ok: false, error: e.message ?? 'read failed' } - } - }, - ) - - ipcMain.handle( - 'fs:saveClaudeImage', - async ( - _evt, - params: { base64Data: string; mediaType: string; filename?: string }, - ) => { - if (!isSupportedClaudeImageMediaType(params.mediaType)) { - throw new Error(`unsupported Claude image media type: ${params.mediaType}`) - } - if (estimateBase64DecodedBytes(params.base64Data) > MAX_CLAUDE_IMAGE_BYTES) { - throw new Error('Claude image exceeds 5 MB limit') - } - const cacheDir = getClaudeImageCacheDir() - await mkdir(cacheDir, { recursive: true }) - const ext = extensionForMediaType(params.mediaType, params.filename) - const filePath = join(cacheDir, `${randomUUID()}${ext}`) - await writeFile(filePath, Buffer.from(params.base64Data, 'base64')) - return { path: filePath } - }, - ) - - ipcMain.handle( - 'debug:append-feed-log', - async ( - _evt, - params: { sessionId: string; entries: FeedDebugPersistEntry[] }, - ) => { - if (!params?.sessionId || !Array.isArray(params.entries) || params.entries.length === 0) { - return - } - await queueFeedDebugAppend(params.sessionId, params.entries) - }, - ) - - // --- Path expansion + validation --- - // Replaces the native folder picker. Renderer shows a text input - // modal where the user types a path; we expand `~`, resolve to an - // absolute path, and check that it exists and is a directory. If any - // of that fails the renderer keeps the modal open and shows the - // error inline. - // - // Why not the native dialog: user explicitly wanted a path writer, - // not a clicky picker. Keyboard-first is faster for power users and - // matches the rest of cc-shell's terminal-native vibe. - ipcMain.handle( - 'fs:expandCwd', - async ( - _evt, - raw: string, - ): Promise<{ ok: true; path: string } | { ok: false; error: string }> => { - const trimmed = raw.trim() - if (!trimmed) return { ok: false, error: 'path is empty' } - // Tilde expansion. We ONLY expand bare `~` and `~/…` — not `~user`, - // because that requires passwd lookup and nobody uses it. - let expanded: string - if (trimmed === '~') { - expanded = homedir() - } else if (trimmed.startsWith('~/')) { - expanded = join(homedir(), trimmed.slice(2)) - } else { - expanded = trimmed - } - const abs = resolve(expanded) - try { - const s = await stat(abs) - if (!s.isDirectory()) { - return { ok: false, error: 'not a directory' } - } - return { ok: true, path: abs } - } catch (err) { - const e = err as NodeJS.ErrnoException - if (e.code === 'ENOENT') return { ok: false, error: 'does not exist' } - if (e.code === 'EACCES') return { ok: false, error: 'permission denied' } - return { ok: false, error: e.message ?? 'stat failed' } - } - }, - ) - - // --- Session prompt index (for Search Conversation Prompts) --- - // - // Both handlers go through src/main/sessionIndex.ts, which walks - // transcripts on disk and extracts user-prompt tails. Caching is - // internal to that module — repeat calls for the same session - // skip disk + parse when its mtime is unchanged. - // - // Handlers are split by intent: list-recent is the default modal - // view (no query); search is the filtered view (user typed). Both - // accept an optional cwd scope — null means "all workspaces". - ipcMain.handle( - 'sessions:list-recent-with-prompts', - async ( - _evt, - options: { limit?: number; promptsPerSession?: number; cwd?: string | null } = {}, - ) => { - const { listRecentSessionsWithPrompts } = await import('./sessionIndex.js') - return listRecentSessionsWithPrompts(options) - }, - ) - - ipcMain.handle( - 'sessions:search-prompts', - async ( - _evt, - options: { - query: string - limit?: number - promptsPerSession?: number - cwd?: string | null - }, - ) => { - const { searchSessionPrompts } = await import('./sessionIndex.js') - return searchSessionPrompts(options) - }, - ) - - // --- Workspace state persistence --- - // The renderer is the source of truth for the tile tree. We just - // read / write bytes. Main doesn't interpret the JSON — that's the - // renderer's concern. - ipcMain.handle('workspace:load', async () => { - try { - const text = await readFile(STATE_FILE, 'utf8') - return text - } catch (err) { - const e = err as NodeJS.ErrnoException - if (e.code === 'ENOENT') return null // fresh install, no state yet - throw err - } - }) - - ipcMain.handle('workspace:save', async (_evt, json: string) => { - await mkdir(STATE_DIR, { recursive: true }) - // Atomic write pattern: write to a temp sibling then rename. Keeps - // us from corrupting the file if the process dies mid-write. - const tmp = STATE_FILE + '.tmp' - await writeFile(tmp, json, 'utf8') - const { rename } = await import('fs/promises') - await rename(tmp, STATE_FILE) - }) - - // --- Default cwd helper --- - // Renderer calls this on first launch when there's no saved state - // and no user-picked cwd yet. Returns the process's cwd (the project - // the Electron app was launched from). - ipcMain.handle('workspace:defaultCwd', () => { - return process.env.CC_SHELL_CWD || process.cwd() - }) - - // --- Git info (used by GitBar) --- - // - // Runs git commands in a given cwd and returns structured data. - // All commands are read-only — no mutations. Errors return empty - // results so the UI degrades gracefully for non-git directories. - - // --- Ghost journal --- - // - // Renderer calls `ghost:append` with each freshly-produced GhostEntry; - // we enqueue it for the 100 ms batched drain. `ghost:read` is used - // on session mount to replay previously-persisted ghosts so the - // merged view comes back immediately after a reload. - // - // The entry is already a plain JSON object by the time it arrives - // here — atp primitives ran in the renderer. Main is a mail clerk. - ipcMain.handle( - 'ghost:append', - (_evt, sessionId: string, ghost: GhostEntry) => { - ghostJournals.get(sessionId).append(ghost) - }, - ) - - ipcMain.handle( - 'ghost:read', - (_evt, sessionId: string): Promise => - readGhostLog(sessionId), - ) - - ipcMain.handle('git:status', async (_evt, cwd: string) => { - try { - const { execFile } = await import('child_process') - const { promisify } = await import('util') - const exec = promisify(execFile) - - // Centralized git runner. Returns stdout; swallows errors and - // returns '' so callers can treat "no output" as a valid signal - // (e.g. clean worktree, missing HEAD, missing submodule checkout). - // Failing loudly here would cascade a single bad command into an - // overall { ok: false }, losing the rest of the data we CAN compute. - const git = async (gitCwd: string, args: string[]): Promise => { - try { - const { stdout } = await exec('git', args, { cwd: gitCwd, timeout: 5000 }) - return stdout - } catch { - return '' - } - } - - // numstat → [ { file, additions, deletions } ]. Binary files emit - // '-' for both counts; we coerce those to 0. - const parseNumstat = (text: string): Array<{ file: string; additions: number; deletions: number }> => { - const out: Array<{ file: string; additions: number; deletions: number }> = [] - for (const line of text.trim().split('\n')) { - if (!line) continue - const [a, d, f] = line.split('\t') - if (!f) continue - out.push({ - file: f, - additions: a === '-' ? 0 : parseInt(a, 10) || 0, - deletions: d === '-' ? 0 : parseInt(d, 10) || 0, - }) - } - return out - } - - // Parent repo — branch is the cheap "is this a git repo at all" - // probe; if it returns empty we bail to { ok: false } instead of - // returning a half-filled shape. - const branchOut = await git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']) - if (!branchOut.trim()) throw new Error('not a git repo') - - const diffStat = await git(cwd, ['diff', 'HEAD', '--numstat']) - const logOut = await git(cwd, ['log', '--oneline', '--format=%h\t%s\t%an\t%cr', '-5']) - - // ---- Submodule enumeration ------------------------------------------ - // - // WHY this exists: `git diff HEAD --numstat` in the parent shows a - // modified submodule path as a +1/-1 gitlink line — that's the SHA - // pointer change, not the actual code diff. For GitBar to be useful - // when working across submodules we need to walk into each one and - // compute: - // - bumped: parent-registered SHA differs from submodule HEAD - // diff range = registered..HEAD - // - dirty: uncommitted edits inside the submodule - // diff = HEAD (worktree) - // - both: parent bumped AND submodule has extra dirty edits - // diff = registered (comparing against worktree, which spans - // both the committed range and the dirty on-top) - // - // A clean submodule (registered == HEAD, no dirty content) is - // skipped entirely so repos that have submodules but haven't - // touched them this session don't add noise to the bar. - type SubmoduleInfo = { - path: string - state: 'dirty' | 'bumped' | 'both' - files: Array<{ file: string; additions: number; deletions: number }> - range?: { from: string; to: string } - } - const submodules: SubmoduleInfo[] = [] - const submodulePaths: string[] = [] - - // .gitmodules is the source of truth for which paths ARE - // submodules — `git submodule` the CLI needs the working tree - // checked out but `git config -f .gitmodules` just reads the file, - // which is enough for us. Gate on stat() first so we don't spawn - // a subprocess for repos with no submodules at all. - try { - await stat(join(cwd, '.gitmodules')) - const cfgOut = await git(cwd, [ - 'config', '-f', '.gitmodules', - '--get-regexp', '^submodule\\..*\\.path$', - ]) - for (const line of cfgOut.split('\n')) { - // Output line: "submodule..path " - const m = /^submodule\.[^\s]+\.path\s+(.+)$/.exec(line) - if (m) submodulePaths.push(m[1].trim()) - } - } catch { - // no .gitmodules → no submodules → leave list empty - } - - for (const subPath of submodulePaths) { - const subAbs = join(cwd, subPath) - - // Parent-registered SHA. ls-tree line format: - // 160000 commit \t - // If HEAD has no entry (fresh submodule not yet committed to - // parent) we still classify by dirty-ness alone. - const lsTree = await git(cwd, ['ls-tree', 'HEAD', subPath]) - const lsMatch = /\bcommit\s+([0-9a-f]{7,40})\b/.exec(lsTree) - const registered = lsMatch ? lsMatch[1] : null - - const currentHead = (await git(subAbs, ['rev-parse', 'HEAD'])).trim() - if (!currentHead) continue // not checked out → nothing to show - - const isBumped = !!registered && registered !== currentHead - const statusOut = await git(subAbs, ['status', '--porcelain']) - const isDirty = statusOut.trim().length > 0 - if (!isBumped && !isDirty) continue - - // Choose the diff argv based on state. The git semantics: - // diff → compare to worktree - // diff .. → compare two commits, worktree ignored - // diff HEAD → compare HEAD to worktree - // "both" uses `diff ` (single arg, no range) so the - // worktree's dirty-on-top-of-new-commits is captured in one call. - const diffArgs: string[] = isBumped && isDirty - ? ['diff', registered!, '--numstat'] - : isBumped - ? ['diff', `${registered}..HEAD`, '--numstat'] - : ['diff', 'HEAD', '--numstat'] - const files = parseNumstat(await git(subAbs, diffArgs)) - - const state: SubmoduleInfo['state'] = isBumped && isDirty - ? 'both' : isBumped ? 'bumped' : 'dirty' - - submodules.push({ - path: subPath, - state, - files, - range: isBumped && registered - ? { from: registered.slice(0, 7), to: currentHead.slice(0, 7) } - : undefined, - }) - } - - // Parent file list: drop gitlink lines for the submodule paths we - // just surfaced in detail — showing them twice (once as "+1 -1 on - // " and once as a full submodule section) is redundant. - const submoduleSet = new Set(submodulePaths) - const files = parseNumstat(diffStat).filter(f => !submoduleSet.has(f.file)) - - const commits: Array<{ - hash: string - subject: string - author: string - relativeDate: string - }> = [] - for (const line of logOut.trim().split('\n')) { - if (!line) continue - const [hash, subject, author, relativeDate] = line.split('\t') - if (!hash) continue - commits.push({ - hash, - subject: subject ?? '', - author: author ?? '', - relativeDate: relativeDate ?? '', - }) - } - - return { - ok: true as const, - branch: branchOut.trim(), - files, - commits, - // Undefined instead of [] when there are no changed submodules - // so the renderer can cheaply gate on `data.submodules?.length` - // without drawing an empty section heading. - submodules: submodules.length > 0 ? submodules : undefined, - } - } catch { - return { ok: false as const } - } - }) -} // ---------- App lifecycle ---------- @@ -1094,13 +73,8 @@ app.whenReady().then(async () => { // Recovery runs BEFORE SessionManager is constructed so the // renderer's first session-spawn can ask to recover an alive // tmux session by name. Reads the persisted workspace.json - // directly — it's the same file the renderer will load shortly via - // workspace:load IPC, but we need the tmuxName values earlier. - let recoveryReport: { recoverable: PersistedTerminalRef[]; lost: string[]; orphans: string[] } = { - recoverable: [], - lost: [], - orphans: [], - } + // directly — it's the same file the renderer will load shortly + // via workspace:load IPC, but we need the tmuxName values earlier. if (tmuxAvailable) { try { const raw = await readFile(STATE_FILE, 'utf8') @@ -1120,7 +94,7 @@ app.whenReady().then(async () => { ) .filter(([, meta]) => meta?.kind === 'terminal' && typeof meta?.tmuxName === 'string') .map(([sessionId, meta]) => ({ sessionId, tmuxName: meta!.tmuxName! })) - recoveryReport = await reconcile(tmuxRegistry, persisted) + const recoveryReport = await reconcile(tmuxRegistry, persisted) console.log( `[tmux] recovery: ${recoveryReport.recoverable.length} recoverable, ${recoveryReport.lost.length} lost, ${recoveryReport.orphans.length} orphans cleaned`, ) @@ -1135,12 +109,12 @@ app.whenReady().then(async () => { manager = new SessionManager(tmuxAvailable ? tmuxRegistry : null) - wireManagerIPC() - registerIpc() - createWindow() + wireSessionForwarder(manager, lspManager) + registerAllIpc({ manager, lspManager, ghostJournals }) + createMainWindow() app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) createWindow() + if (BrowserWindow.getAllWindows().length === 0) createMainWindow() }) }) diff --git a/src/main/ipc/debug.ts b/src/main/ipc/debug.ts new file mode 100644 index 000000000..48fd38ba4 --- /dev/null +++ b/src/main/ipc/debug.ts @@ -0,0 +1,28 @@ +import { ipcMain } from 'electron' + +import { + queueFeedDebugAppend, + type FeedDebugPersistEntry, +} from '../storage/feedDebugLog.js' + +// Debug-panel IPC. +// +// The renderer's FeedDebugPanel accumulates entries in memory and +// flushes batches here on a timer. We serialize per-session in the +// storage module; this handler just validates the payload shape and +// forwards. + +export function registerDebugIpc(): void { + ipcMain.handle( + 'debug:append-feed-log', + async ( + _evt, + params: { sessionId: string; entries: FeedDebugPersistEntry[] }, + ) => { + if (!params?.sessionId || !Array.isArray(params.entries) || params.entries.length === 0) { + return + } + await queueFeedDebugAppend(params.sessionId, params.entries) + }, + ) +} diff --git a/src/main/ipc/fs.ts b/src/main/ipc/fs.ts new file mode 100644 index 000000000..a76d8d722 --- /dev/null +++ b/src/main/ipc/fs.ts @@ -0,0 +1,126 @@ +import { ipcMain } from 'electron' +import { homedir } from 'os' +import { join, resolve } from 'path' +import { readdir, stat } from 'fs/promises' + +import { saveClaudeImage } from '../storage/claudeImageCache.js' + +// Filesystem-level IPC — path expansion, directory listing, Claude +// image paste cache. +// +// fs:listDirectory + fs:expandCwd both ship `~` / `~/…` expansion in +// the same shape; we deliberately don't expand `~user` (needs passwd +// lookup, nobody uses it in practice). Both return a discriminated +// union so callers can show errors inline without throwing. + +export function registerFsIpc(): void { + // Directory listing for PathInput completion. Returns entries as + // { name, isDirectory } for the renderer to filter + display as a + // suggestion dropdown. + // + // Options: + // directoriesOnly — filter out regular files (default true for + // cwd pickers, false for file pickers) + // showHidden — include .dotfile entries (default false) + type DirEntry = { name: string; isDirectory: boolean; path: string } + type ListResult = + | { ok: true; entries: DirEntry[]; expanded: string } + | { ok: false; error: string } + + ipcMain.handle( + 'fs:listDirectory', + async ( + _evt, + rawPath: string, + opts?: { directoriesOnly?: boolean; showHidden?: boolean }, + ): Promise => { + const directoriesOnly = opts?.directoriesOnly ?? true + const showHidden = opts?.showHidden ?? false + + // Empty / `.` / `~` all mean "home directory" — the natural + // starting point for path completion in a picker. + let expanded = rawPath.trim() + if (expanded === '' || expanded === '~') { + expanded = homedir() + } else if (expanded.startsWith('~/')) { + expanded = join(homedir(), expanded.slice(2)) + } + expanded = resolve(expanded) + + try { + const dirents = await readdir(expanded, { withFileTypes: true }) + const entries: DirEntry[] = [] + for (const d of dirents) { + if (!showHidden && d.name.startsWith('.')) continue + const isDirectory = d.isDirectory() + if (directoriesOnly && !isDirectory) continue + entries.push({ + name: d.name, + isDirectory, + path: join(expanded, d.name), + }) + } + // Directories first, then alpha (case-insensitive). Matches + // the Finder / VS Code / Sublime convention. + entries.sort((a, b) => { + if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1 + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + }) + return { ok: true, entries, expanded } + } catch (err) { + const e = err as NodeJS.ErrnoException + if (e.code === 'ENOENT') return { ok: false, error: 'does not exist' } + if (e.code === 'ENOTDIR') return { ok: false, error: 'not a directory' } + if (e.code === 'EACCES') return { ok: false, error: 'permission denied' } + return { ok: false, error: e.message ?? 'read failed' } + } + }, + ) + + ipcMain.handle( + 'fs:saveClaudeImage', + async ( + _evt, + params: { base64Data: string; mediaType: string; filename?: string }, + ) => { + return await saveClaudeImage(params) + }, + ) + + // Path expansion + validation. Replaces the native folder picker — + // the renderer shows a text input modal where the user types a + // path; we expand `~`, resolve to an absolute path, and check that + // it exists and is a directory. Keyboard-first is faster for power + // users and matches cc-shell's terminal-native vibe. + ipcMain.handle( + 'fs:expandCwd', + async ( + _evt, + raw: string, + ): Promise<{ ok: true; path: string } | { ok: false; error: string }> => { + const trimmed = raw.trim() + if (!trimmed) return { ok: false, error: 'path is empty' } + let expanded: string + if (trimmed === '~') { + expanded = homedir() + } else if (trimmed.startsWith('~/')) { + expanded = join(homedir(), trimmed.slice(2)) + } else { + expanded = trimmed + } + const abs = resolve(expanded) + try { + const s = await stat(abs) + if (!s.isDirectory()) { + return { ok: false, error: 'not a directory' } + } + return { ok: true, path: abs } + } catch (err) { + const e = err as NodeJS.ErrnoException + if (e.code === 'ENOENT') return { ok: false, error: 'does not exist' } + if (e.code === 'EACCES') return { ok: false, error: 'permission denied' } + return { ok: false, error: e.message ?? 'stat failed' } + } + }, + ) +} diff --git a/src/main/ipc/ghost.ts b/src/main/ipc/ghost.ts new file mode 100644 index 000000000..476c15002 --- /dev/null +++ b/src/main/ipc/ghost.ts @@ -0,0 +1,30 @@ +import { ipcMain } from 'electron' +import type { GhostEntry } from 'agent-transcript-parser' + +import type { GhostJournalRegistry } from '../ghostJournal.js' +import { readGhostLog } from '../ghostJournal.js' + +// Ghost journal IPC. +// +// Renderer calls `ghost:append` with each freshly-produced GhostEntry; +// we enqueue it for the 100 ms batched drain. `ghost:read` is used on +// session mount to replay previously-persisted ghosts so the merged +// view comes back immediately after a reload. +// +// The entry is already a plain JSON object by the time it arrives +// here — atp primitives ran in the renderer. Main is a mail clerk. + +export function registerGhostIpc(ghostJournals: GhostJournalRegistry): void { + ipcMain.handle( + 'ghost:append', + (_evt, sessionId: string, ghost: GhostEntry) => { + ghostJournals.get(sessionId).append(ghost) + }, + ) + + ipcMain.handle( + 'ghost:read', + (_evt, sessionId: string): Promise => + readGhostLog(sessionId), + ) +} diff --git a/src/main/ipc/git.ts b/src/main/ipc/git.ts new file mode 100644 index 000000000..3700cae97 --- /dev/null +++ b/src/main/ipc/git.ts @@ -0,0 +1,199 @@ +import { ipcMain } from 'electron' +import { execFile } from 'child_process' +import { promisify } from 'util' +import { join } from 'path' +import { stat } from 'fs/promises' + +// Git status IPC — powers GitBar. +// +// Runs git commands in a given cwd and returns structured data. All +// commands are read-only; errors return { ok: false } so the UI +// degrades gracefully for non-git directories. +// +// Submodule handling: parent `git diff HEAD --numstat` shows a +// modified submodule path as a +1/-1 gitlink line (SHA pointer +// change, not real diff). To be useful when working across +// submodules we walk into each one and compute: +// - bumped: parent-registered SHA differs from submodule HEAD +// diff range = registered..HEAD +// - dirty: uncommitted edits inside the submodule +// diff = HEAD (worktree) +// - both: parent bumped AND submodule has extra dirty edits +// diff = registered (spans both the committed range and the +// dirty on-top) +// A clean submodule is skipped entirely so repos with untouched +// submodules don't add noise to the bar. + +const exec = promisify(execFile) + +type NumstatLine = { file: string; additions: number; deletions: number } + +// Centralized git runner. Returns stdout; swallows errors and returns +// '' so callers can treat "no output" as a valid signal (clean +// worktree, missing HEAD, missing submodule checkout). Failing loudly +// here would cascade a single bad command into an overall +// { ok: false }, losing the rest of the data we CAN compute. +async function git(cwd: string, args: string[]): Promise { + try { + const { stdout } = await exec('git', args, { cwd, timeout: 5000 }) + return stdout + } catch { + return '' + } +} + +// numstat → rows. Binary files emit '-' for both counts; we coerce +// those to 0 so the UI doesn't special-case them. +function parseNumstat(text: string): NumstatLine[] { + const out: NumstatLine[] = [] + for (const line of text.trim().split('\n')) { + if (!line) continue + const [a, d, f] = line.split('\t') + if (!f) continue + out.push({ + file: f, + additions: a === '-' ? 0 : parseInt(a, 10) || 0, + deletions: d === '-' ? 0 : parseInt(d, 10) || 0, + }) + } + return out +} + +type SubmoduleInfo = { + path: string + state: 'dirty' | 'bumped' | 'both' + files: NumstatLine[] + range?: { from: string; to: string } +} + +// .gitmodules is the source of truth for which paths ARE submodules +// — `git submodule` the CLI needs the working tree checked out but +// `git config -f .gitmodules` just reads the file, which is enough +// for us. Gate on stat() first so we don't spawn a subprocess for +// repos with no submodules at all. +async function readSubmodulePaths(cwd: string): Promise { + const paths: string[] = [] + try { + await stat(join(cwd, '.gitmodules')) + const cfgOut = await git(cwd, [ + 'config', '-f', '.gitmodules', + '--get-regexp', '^submodule\\..*\\.path$', + ]) + for (const line of cfgOut.split('\n')) { + // Output line: "submodule..path " + const m = /^submodule\.[^\s]+\.path\s+(.+)$/.exec(line) + if (m) paths.push(m[1].trim()) + } + } catch { + // no .gitmodules → no submodules → leave list empty + } + return paths +} + +async function inspectSubmodule( + parentCwd: string, + subPath: string, +): Promise { + const subAbs = join(parentCwd, subPath) + + // Parent-registered SHA. ls-tree line format: + // 160000 commit \t + // If HEAD has no entry (fresh submodule not yet committed to + // parent) we still classify by dirty-ness alone. + const lsTree = await git(parentCwd, ['ls-tree', 'HEAD', subPath]) + const lsMatch = /\bcommit\s+([0-9a-f]{7,40})\b/.exec(lsTree) + const registered = lsMatch ? lsMatch[1] : null + + const currentHead = (await git(subAbs, ['rev-parse', 'HEAD'])).trim() + if (!currentHead) return null // not checked out → nothing to show + + const isBumped = !!registered && registered !== currentHead + const statusOut = await git(subAbs, ['status', '--porcelain']) + const isDirty = statusOut.trim().length > 0 + if (!isBumped && !isDirty) return null + + // Choose the diff argv based on state. Git semantics: + // diff → compare to worktree + // diff .. → compare two commits, worktree ignored + // diff HEAD → compare HEAD to worktree + // "both" uses `diff ` (single arg, no range) so the + // worktree's dirty-on-top-of-new-commits is captured in one call. + const diffArgs: string[] = isBumped && isDirty + ? ['diff', registered!, '--numstat'] + : isBumped + ? ['diff', `${registered}..HEAD`, '--numstat'] + : ['diff', 'HEAD', '--numstat'] + const files = parseNumstat(await git(subAbs, diffArgs)) + + const state: SubmoduleInfo['state'] = isBumped && isDirty + ? 'both' : isBumped ? 'bumped' : 'dirty' + + return { + path: subPath, + state, + files, + range: isBumped && registered + ? { from: registered.slice(0, 7), to: currentHead.slice(0, 7) } + : undefined, + } +} + +export function registerGitIpc(): void { + ipcMain.handle('git:status', async (_evt, cwd: string) => { + try { + // Parent repo — branch is the cheap "is this a git repo at all" + // probe; if it returns empty we bail to { ok: false } instead of + // returning a half-filled shape. + const branchOut = await git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']) + if (!branchOut.trim()) throw new Error('not a git repo') + + const diffStat = await git(cwd, ['diff', 'HEAD', '--numstat']) + const logOut = await git(cwd, ['log', '--oneline', '--format=%h\t%s\t%an\t%cr', '-5']) + + const submodulePaths = await readSubmodulePaths(cwd) + const submodules: SubmoduleInfo[] = [] + for (const subPath of submodulePaths) { + const info = await inspectSubmodule(cwd, subPath) + if (info) submodules.push(info) + } + + // Parent file list: drop gitlink lines for the submodule paths + // we just surfaced in detail — showing them twice (once as + // "+1 -1 on " and once as a full submodule section) is + // redundant. + const submoduleSet = new Set(submodulePaths) + const files = parseNumstat(diffStat).filter(f => !submoduleSet.has(f.file)) + + const commits: Array<{ + hash: string + subject: string + author: string + relativeDate: string + }> = [] + for (const line of logOut.trim().split('\n')) { + if (!line) continue + const [hash, subject, author, relativeDate] = line.split('\t') + if (!hash) continue + commits.push({ + hash, + subject: subject ?? '', + author: author ?? '', + relativeDate: relativeDate ?? '', + }) + } + + return { + ok: true as const, + branch: branchOut.trim(), + files, + commits, + // Undefined instead of [] when there are no changed + // submodules so the renderer can cheaply gate on + // `data.submodules?.length` without drawing an empty heading. + submodules: submodules.length > 0 ? submodules : undefined, + } + } catch { + return { ok: false as const } + } + }) +} diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts new file mode 100644 index 000000000..e393c7c6a --- /dev/null +++ b/src/main/ipc/index.ts @@ -0,0 +1,40 @@ +import type { SessionManager } from '../sessionManager.js' +import type { LspManager } from '../lspManager.js' +import type { GhostJournalRegistry } from '../ghostJournal.js' + +import { registerSessionIpc } from './session.js' +import { registerProviderIpc } from './provider.js' +import { registerLspIpc } from './lsp.js' +import { registerFsIpc } from './fs.js' +import { registerSessionsIpc } from './sessions.js' +import { registerWorkspaceIpc } from './workspace.js' +import { registerGhostIpc } from './ghost.js' +import { registerDebugIpc } from './debug.js' +import { registerGitIpc } from './git.js' + +// IPC registration aggregator. +// +// main/index.ts calls registerAllIpc(deps) exactly once, right after +// services are constructed. Each domain module does its own +// ipcMain.handle wiring inside its register function. Passing deps +// explicitly (rather than importing singletons into each module) +// keeps the wiring visible here and makes the domain files testable +// in isolation — they don't reach into module-scoped state. + +export type IpcDeps = { + manager: SessionManager + lspManager: LspManager + ghostJournals: GhostJournalRegistry +} + +export function registerAllIpc(deps: IpcDeps): void { + registerSessionIpc(deps.manager) + registerProviderIpc() + registerLspIpc(deps.lspManager) + registerFsIpc() + registerSessionsIpc() + registerWorkspaceIpc() + registerGhostIpc(deps.ghostJournals) + registerDebugIpc() + registerGitIpc() +} diff --git a/src/main/ipc/lsp.ts b/src/main/ipc/lsp.ts new file mode 100644 index 000000000..33957c671 --- /dev/null +++ b/src/main/ipc/lsp.ts @@ -0,0 +1,50 @@ +import { ipcMain } from 'electron' + +import type { LspManager } from '../lspManager.js' + +// LSP-backed code intelligence for Monaco code blocks. +// +// The renderer's CodeBlock component opens a document per visible +// code block, requests semantic tokens for coloring, and keeps the +// LSP diagnostics wired so errors inline. All of that flows through +// LspManager — this file is a pure IPC adapter. + +export function registerLspIpc(lspManager: LspManager): void { + ipcMain.handle( + 'lsp:ensure-legend', + async (_evt, workspaceRoot: string, language: string) => { + return await lspManager.ensureSemanticLegend(workspaceRoot, language) + }, + ) + + ipcMain.handle( + 'lsp:open-document', + async ( + _evt, + params: { + clientUri: string + content: string + language: string + workspaceRoot: string + filePath?: string | null + }, + ) => { + await lspManager.openDocument(params) + }, + ) + + ipcMain.handle( + 'lsp:change-document', + async (_evt, clientUri: string, content: string) => { + await lspManager.changeDocument(clientUri, content) + }, + ) + + ipcMain.handle('lsp:close-document', async (_evt, clientUri: string) => { + await lspManager.closeDocument(clientUri) + }) + + ipcMain.handle('lsp:get-semantic-tokens', async (_evt, clientUri: string) => { + return await lspManager.getSemanticTokens(clientUri) + }) +} diff --git a/src/main/ipc/provider.ts b/src/main/ipc/provider.ts new file mode 100644 index 000000000..8577f1b54 --- /dev/null +++ b/src/main/ipc/provider.ts @@ -0,0 +1,64 @@ +import { ipcMain } from 'electron' + +import { switchProvider } from '../providerSwitch/switchProvider.js' +import { duplicateSession } from '../providerSwitch/duplicateSession.js' +import { + rewindSession, + type RewindSessionRequest, +} from '../providerSwitch/rewindSession.js' + +// Provider-level session transforms. +// +// All three handlers here write a NEW transcript to disk and return a +// new providerSessionId; the source file is never modified. The +// renderer passes the returned id to replaceSession(...) to swap a +// pane onto the transformed conversation without tearing down the +// tile tree. +// +// Why a separate file: these share orchestration shape (read source → +// transform → write clone → return id) but the transforms themselves +// live under main/providerSwitch/. Grouping the IPC handlers here +// keeps session.ts focused on lifecycle + I/O. + +export function registerProviderIpc(): void { + ipcMain.handle( + 'session:switch-provider', + async ( + _evt, + params: { + sourceKind: 'claude' | 'codex' + sourceProviderSessionId: string + cwd: string + }, + ) => { + return await switchProvider(params) + }, + ) + + ipcMain.handle( + 'session:duplicate', + async ( + _evt, + params: { + provider: 'claude' | 'codex' + sourceProviderSessionId: string + cwd: string + }, + ) => { + return await duplicateSession(params) + }, + ) + + // Rewind the focused pane's transcript to just before a selected + // user prompt. Produces a NEW provider session id; the source file + // is never touched. The renderer passes the returned id to + // `replaceSession(...)` to re-home the pane, and prefills + // `promptText` as an unsent draft. See + // `src/main/providerSwitch/rewindSession.ts` for the slicing rules. + ipcMain.handle( + 'session:rewind-to-prompt', + async (_evt, params: RewindSessionRequest) => { + return await rewindSession(params) + }, + ) +} diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts new file mode 100644 index 000000000..5168261f5 --- /dev/null +++ b/src/main/ipc/session.ts @@ -0,0 +1,149 @@ +import { ipcMain } from 'electron' + +import type { SessionManager } from '../sessionManager.js' +import { getMainProvider } from '../../providers/registry.main.js' +import { listAllClaudeSessions } from '../../providers/claude/runtime/sessionList.js' +import { listCodexSessions } from '../../providers/codex/runtime/sessionList.js' +import { loadOlderHistoryChunk } from '../sessions/historyLoader.js' + +// Session lifecycle + I/O IPC. +// +// Every channel here takes a sessionId (or returns one) and operates +// on a single pane's backend process. The manager owns the actual +// SessionManager / ClaudeSession / CodexSession / TerminalSession +// machinery; this file is a thin IPC adapter. +// +// Listing handlers live here too (list-for-cwd, list-all) because +// they're "session lifecycle from the user's POV" — the resume +// picker asks "what sessions could I spawn?" before calling spawn. +// The prompt-indexing handlers (sessions:*) live in ./sessions.ts +// because they're a separate concern with their own cache layer. + +export function registerSessionIpc(manager: SessionManager): void { + ipcMain.handle( + 'session:spawn', + async ( + _evt, + options: { + kind?: 'claude' | 'codex' | 'terminal' + cwd: string + cols?: number + rows?: number + resumeSessionId?: string + dangerousMode?: boolean + useProxy?: boolean + recoverTmuxName?: string + }, + ) => { + return await manager.spawn(options) + }, + ) + + ipcMain.handle('session:kill', async (_evt, sessionId: string) => { + return await manager.kill(sessionId) + }) + + // Terminal attach/replay. Called once by TerminalLeaf on mount. + // Returns the full buffered output of the session so far AND flips + // the manager's "attached" flag so subsequent PTY data events + // broadcast live. See SessionManager.terminalBuffers for the race + // being fixed. + ipcMain.handle('session:terminal-attach', (_evt, sessionId: string) => { + return manager.attachTerminal(sessionId) + }) + + ipcMain.handle( + 'session:input', + (_evt, sessionId: string, data: string) => { + const ok = manager.write(sessionId, data) + if (!ok) { + // eslint-disable-next-line no-console + console.warn('[session:input] dropped write for missing session', { + sessionId, + dataLength: data.length, + }) + } + return ok + }, + ) + + ipcMain.handle( + 'session:resize', + (_evt, sessionId: string, cols: number, rows: number) => { + manager.resize(sessionId, cols, rows) + }, + ) + + // Session listing for the resume picker. + // + // Called by PathPickerModal when the user types a cwd — returns a + // list of previous sessions in that directory so they can resume + // one instead of starting fresh. Empty array when the cwd has no + // recorded history yet. Per-provider listing routes through the + // provider registry so each format reads its own storage. + ipcMain.handle( + 'session:list-for-cwd', + async ( + _evt, + cwd: string, + limit?: number, + provider: 'claude' | 'codex' = 'claude', + ) => { + try { + const providerConfig = getMainProvider(provider) + return await providerConfig.listSessions(cwd, limit ?? 20) + } catch (err) { + // Don't let a listing error brick the modal — return empty. + // eslint-disable-next-line no-console + console.warn('[session:list-for-cwd] failed:', err) + return [] + } + }, + ) + + // Global session listing (used by the rendering-debug harness). + // The main app routes through `session:list-for-cwd` because it + // filters by the focused pane's cwd; the harness has no notion of + // "current cwd" and needs everything tagged with provider. + ipcMain.handle( + 'session:list-all', + async (_evt, limit?: number) => { + const cap = typeof limit === 'number' && limit > 0 ? limit : 200 + try { + const [claude, codex] = await Promise.all([ + listAllClaudeSessions({ limit: cap }).catch(() => []), + listCodexSessions({ limit: cap }).catch(() => []), + ]) + const tagged = [ + ...claude.map(s => ({ ...s, provider: 'claude' as const })), + ...codex.map(s => ({ ...s, provider: 'codex' as const })), + ] + tagged.sort((a, b) => b.lastModified - a.lastModified) + return tagged.slice(0, cap) + } catch (err) { + // eslint-disable-next-line no-console + console.warn('[session:list-all] failed:', err) + return [] + } + }, + ) + + ipcMain.handle( + 'session:load-older-history', + async ( + _evt, + params: { + kind: 'claude' | 'codex' + cwd: string + providerSessionId: string + beforeMarker: string + limit?: number + }, + ) => { + return await loadOlderHistoryChunk({ + ...params, + limit: params.limit ?? 200, + }) + }, + ) +} diff --git a/src/main/ipc/sessions.ts b/src/main/ipc/sessions.ts new file mode 100644 index 000000000..169c60f86 --- /dev/null +++ b/src/main/ipc/sessions.ts @@ -0,0 +1,45 @@ +import { ipcMain } from 'electron' + +// Session prompt index IPC (for Search Conversation Prompts). +// +// Both handlers route through src/main/sessionIndex.ts, which walks +// transcripts on disk and extracts user-prompt tails. Caching is +// internal to that module — repeat calls for the same session skip +// disk + parse when its mtime is unchanged. +// +// Handlers are split by intent: list-recent is the default modal +// view (no query); search is the filtered view (user typed). Both +// accept an optional cwd scope — null means "all workspaces". +// +// The dynamic import of sessionIndex is deliberate: the module loads +// a lazy index on first call and we don't want to pay that bootstrap +// cost at main startup, only when the user actually opens the modal. + +export function registerSessionsIpc(): void { + ipcMain.handle( + 'sessions:list-recent-with-prompts', + async ( + _evt, + options: { limit?: number; promptsPerSession?: number; cwd?: string | null } = {}, + ) => { + const { listRecentSessionsWithPrompts } = await import('../sessionIndex.js') + return listRecentSessionsWithPrompts(options) + }, + ) + + ipcMain.handle( + 'sessions:search-prompts', + async ( + _evt, + options: { + query: string + limit?: number + promptsPerSession?: number + cwd?: string | null + }, + ) => { + const { searchSessionPrompts } = await import('../sessionIndex.js') + return searchSessionPrompts(options) + }, + ) +} diff --git a/src/main/ipc/workspace.ts b/src/main/ipc/workspace.ts new file mode 100644 index 000000000..8789b7b37 --- /dev/null +++ b/src/main/ipc/workspace.ts @@ -0,0 +1,38 @@ +import { ipcMain } from 'electron' +import { mkdir, readFile, writeFile, rename } from 'fs/promises' + +import { STATE_DIR, STATE_FILE } from '../storage/paths.js' + +// Workspace state persistence. +// +// The renderer is the source of truth for the tile tree. Main just +// reads / writes bytes — we don't interpret the JSON here. The +// atomic-write pattern (temp sibling + rename) keeps us from +// corrupting the file if the process dies mid-write. + +export function registerWorkspaceIpc(): void { + ipcMain.handle('workspace:load', async () => { + try { + const text = await readFile(STATE_FILE, 'utf8') + return text + } catch (err) { + const e = err as NodeJS.ErrnoException + if (e.code === 'ENOENT') return null // fresh install, no state yet + throw err + } + }) + + ipcMain.handle('workspace:save', async (_evt, json: string) => { + await mkdir(STATE_DIR, { recursive: true }) + const tmp = STATE_FILE + '.tmp' + await writeFile(tmp, json, 'utf8') + await rename(tmp, STATE_FILE) + }) + + // Renderer calls this on first launch when there's no saved state + // and no user-picked cwd yet. CC_SHELL_CWD overrides — useful in + // dev for launching the app pointed at a specific test project. + ipcMain.handle('workspace:defaultCwd', () => { + return process.env.CC_SHELL_CWD || process.cwd() + }) +} diff --git a/src/main/sessions/forwarder.ts b/src/main/sessions/forwarder.ts new file mode 100644 index 000000000..748549b9c --- /dev/null +++ b/src/main/sessions/forwarder.ts @@ -0,0 +1,53 @@ +import type { SessionManager } from '../sessionManager.js' +import type { LspManager } from '../lspManager.js' + +import { sendToMainWindow } from '../window/mainWindow.js' +import { enqueueJsonl, flushAndDropJsonl } from './jsonlCoalescer.js' + +// Session event forwarder. +// +// Wires every manager event to a matching IPC channel. Each payload +// already carries the sessionId so the renderer can route to the +// right tile. The forwarder's job is the dumbest possible one: tag +// the channel, push the payload. +// +// terminal-data is intentionally a separate channel from screen / +// jsonl-entry — keeps every Claude pane listener from unpacking and +// ignoring raw PTY bytes it doesn't care about. + +export function wireSessionForwarder( + manager: SessionManager, + lspManager: LspManager, +): void { + manager.on('started', payload => sendToMainWindow('session:started', payload)) + manager.on('screen', payload => sendToMainWindow('session:screen', payload)) + + // Bulk-only forwarding. See jsonlCoalescer.ts for the full rationale + // — every jsonl-entry goes through the coalescer; live single + // entries become 1-element bulk messages with imperceptible latency. + manager.on('jsonl-entry', payload => { + enqueueJsonl(payload.sessionId, payload.entry, payload.file) + }) + manager.on('jsonl-error', ({ sessionId, error }) => + sendToMainWindow('session:jsonl-error', { + sessionId, + message: String(error.message ?? error), + }), + ) + manager.on('terminal-data', payload => + sendToMainWindow('session:terminal-data', payload), + ) + manager.on('process-state', payload => sendToMainWindow('session:process-state', payload)) + manager.on('trust-dialog', payload => sendToMainWindow('session:trust-dialog', payload)) + manager.on('resume-prompt', payload => sendToMainWindow('session:resume-prompt', payload)) + manager.on('compaction-state', payload => sendToMainWindow('session:compaction-state', payload)) + manager.on('semantic-event', payload => sendToMainWindow('session:semantic-event', payload)) + manager.on('exit', payload => { + // Final flush — any entries still buffered from the last + // bootstrapTail tick must land before exit so the renderer sees a + // consistent final entries list. + flushAndDropJsonl(payload.sessionId) + sendToMainWindow('session:exit', payload) + }) + lspManager.on('diagnostics', payload => sendToMainWindow('lsp:diagnostics', payload)) +} diff --git a/src/main/sessions/historyLoader.ts b/src/main/sessions/historyLoader.ts new file mode 100644 index 000000000..466727599 --- /dev/null +++ b/src/main/sessions/historyLoader.ts @@ -0,0 +1,140 @@ +import { join } from 'path' +import { readFile, readdir, stat } from 'fs/promises' + +import { getMainProvider } from '../../providers/registry.main.js' + +// Loader for older history chunks. +// +// When the user scrolls up past the bootstrapTail window in a resumed +// session, the renderer asks main for more history via +// `session:load-older-history`. This module walks the provider's +// on-disk transcript format and returns the chunk immediately before +// the `beforeMarker` the renderer already has, up to `limit` entries. +// +// The marker is provider-specific: +// - Claude entries expose a stable `uuid`; progress-wrapped entries +// carry the same uuid on their embedded message. +// - Codex rollouts don't have a uuid, so we synthesize a marker from +// timestamp + a stable field inside the payload (id / call_id / +// type). That's good enough for chunk alignment; the renderer +// never persists these markers across sessions. + +export type HistoryChunkRequest = { + kind: 'claude' | 'codex' + cwd: string + providerSessionId: string + beforeMarker: string + limit: number +} + +export type HistoryChunk = { + entries: Record[] + hasMore: boolean +} + +function extractClaudeHistoryMarker(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 extractCodexHistoryMarker(entry: Record): string { + const payload = entry.payload as Record | undefined + return `${String(entry.timestamp ?? '')}:${String(payload?.id ?? payload?.call_id ?? payload?.type ?? entry.type)}` +} + +/** + * Codex sessions are stored under year/month/day/-.jsonl. + * The renderer hands us only a threadId, so we walk the tree newest- + * first (dirs sorted reverse) and pick the first file whose name + * contains the id. Cheap for any reasonable session age; if we ever + * need a full scan we'll index at boot. + */ +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) + const yStat = await stat(yearDir).catch(() => null) + if (!yStat?.isDirectory()) continue + const months = await readdir(yearDir) + for (const month of months.sort().reverse()) { + const monthDir = join(yearDir, month) + const mStat = await stat(monthDir).catch(() => null) + if (!mStat?.isDirectory()) continue + const days = await readdir(monthDir) + for (const day of days.sort().reverse()) { + const dayDir = join(monthDir, day) + const dStat = await stat(dayDir).catch(() => null) + if (!dStat?.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 +} + +/** + * Read the whole transcript file, locate `beforeMarker`, and return up + * to `limit` entries immediately preceding it. `hasMore: true` means + * there's still earlier history the renderer can request. + */ +export async function loadOlderHistoryChunk( + params: HistoryChunkRequest, +): Promise { + 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(line => line.trim()) + .filter(line => line.length > 0) + .map(line => { + try { + return JSON.parse(line) as Record + } catch { + return null + } + }) + .filter((entry): entry is Record => entry !== null) + + const markerOf = params.kind === 'claude' + ? extractClaudeHistoryMarker + : extractCodexHistoryMarker + + const anchorIndex = parsed.findIndex(entry => markerOf(entry) === 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, + } +} diff --git a/src/main/sessions/jsonlCoalescer.ts b/src/main/sessions/jsonlCoalescer.ts new file mode 100644 index 000000000..683c87959 --- /dev/null +++ b/src/main/sessions/jsonlCoalescer.ts @@ -0,0 +1,75 @@ +import type { JsonlEntry } from 'claude-code-headless' + +import { sendToMainWindow } from '../window/mainWindow.js' + +// Per-session jsonl-entry coalescer. +// +// WHY: on a resumed Claude/Codex session, the headless `bootstrapTail` +// parses the last ~200 lines from the JSONL file synchronously and +// emits one `jsonl-entry` event per line. Forwarding each as its own +// IPC `webContents.send` produced ~200 round-trips per pane × N panes +// on restart — which on the renderer side became 200N React renders, +// 200N O(N) spreads, and 200N auto-scroll pins. That's the "feels +// like I'm being scrolled through the whole conversation" bug. +// +// Coalescing: we buffer entries per sessionId, schedule ONE +// setImmediate flush, and deliver the whole burst as a single +// `session:jsonl-entries` payload. setImmediate (not Promise.resolve +// or process.nextTick) runs after the current I/O tick finishes, so +// the whole bootstrapTail loop drains before we schedule a send. Live +// mid-conversation entries land one per tick and are flushed +// immediately after — no added latency for the streaming path. +// +// Singular `session:jsonl-entry` is intentionally NOT emitted here: +// we used to dual-emit (singular + coalesced) for "backward +// compatibility," but the singular IPC queue beat the coalescer to +// the renderer on every burst and the renderer's dedupe on the bulk +// path made it a no-op. Now everything goes bulk; live single entries +// become 1-element bulk messages with ~1ms latency from setImmediate +// — imperceptible. + +type PendingJsonlBuffer = { + entries: Array<{ entry: JsonlEntry; file: string }> + flushScheduled: boolean +} + +const jsonlPending = new Map() + +function flushJsonlFor(sessionId: string): void { + const pending = jsonlPending.get(sessionId) + if (!pending || pending.entries.length === 0) return + const payload = { + sessionId, + entries: pending.entries, + } + pending.entries = [] + pending.flushScheduled = false + sendToMainWindow('session:jsonl-entries', payload) +} + +export function enqueueJsonl( + sessionId: string, + entry: JsonlEntry, + 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)) + } +} + +/** + * Drop any buffered entries for a session. Called on exit so the + * final bulk message lands before the exit event and the map doesn't + * leak buffers for dead sessions. + */ +export function flushAndDropJsonl(sessionId: string): void { + flushJsonlFor(sessionId) + jsonlPending.delete(sessionId) +} diff --git a/src/main/storage/claudeImageCache.ts b/src/main/storage/claudeImageCache.ts new file mode 100644 index 000000000..46f5d9daf --- /dev/null +++ b/src/main/storage/claudeImageCache.ts @@ -0,0 +1,99 @@ +import { randomUUID } from 'crypto' +import { extname, join } from 'path' +import { mkdir, rm, writeFile } from 'fs/promises' +import { app } from 'electron' + +// Claude image paste cache. +// +// When the user pastes an image into the Claude composer, the +// renderer ships the base64 bytes to main; main writes them to a +// cache dir and returns the absolute path. The renderer then includes +// that path in the prompt text (Claude's CLI resolves `@` as an +// image attachment). Files are disposable — the whole dir is wiped on +// each app startup. +// +// Lives under Electron's temp root so platform cleanup policy (user +// manually clearing /tmp, macOS purging inactive files) handles +// anything we forget to delete. NEVER user-facing; paths are opaque. + +const SUPPORTED_CLAUDE_IMAGE_MEDIA_TYPES = new Set([ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', +]) + +const MAX_CLAUDE_IMAGE_BYTES = 5 * 1024 * 1024 + +export type SaveClaudeImageParams = { + base64Data: string + mediaType: string + filename?: string +} + +export type SavedClaudeImage = { + path: string +} + +function extensionForMediaType(mediaType: string, filename?: string): string { + switch (mediaType) { + case 'image/jpeg': + return '.jpg' + case 'image/gif': + return '.gif' + case 'image/webp': + return '.webp' + case 'image/png': + return '.png' + } + // Fall back to the filename's own extension if the media type is + // somehow unrecognized — safer than fabricating `.bin` which would + // break Claude's file-type detection. + const fromName = extname(filename ?? '').trim().toLowerCase() + return fromName || '.png' +} + +function isSupportedClaudeImageMediaType(mediaType: string): boolean { + return SUPPORTED_CLAUDE_IMAGE_MEDIA_TYPES.has(mediaType.toLowerCase()) +} + +function estimateBase64DecodedBytes(base64Data: string): number { + // Base64 encodes 3 bytes into 4 chars; trailing `=` are padding. + // Tight estimate is enough to reject oversize uploads without + // actually allocating the buffer first. + const normalized = base64Data.trim() + if (normalized.length === 0) return 0 + const padding = normalized.endsWith('==') ? 2 : normalized.endsWith('=') ? 1 : 0 + return Math.floor((normalized.length * 3) / 4) - padding +} + +function getClaudeImageCacheDir(): string { + return join(app.getPath('temp'), 'cc-shell', 'claude-images') +} + +export async function cleanupClaudeImageCacheDir(): Promise { + await rm(getClaudeImageCacheDir(), { recursive: true, force: true }) +} + +/** + * Decode a renderer-submitted base64 image and write it to the cache. + * Returns the absolute path of the written file. Throws on unsupported + * media type or oversized payload — the renderer surfaces those to the + * user as toast errors. + */ +export async function saveClaudeImage( + params: SaveClaudeImageParams, +): Promise { + if (!isSupportedClaudeImageMediaType(params.mediaType)) { + throw new Error(`unsupported Claude image media type: ${params.mediaType}`) + } + if (estimateBase64DecodedBytes(params.base64Data) > MAX_CLAUDE_IMAGE_BYTES) { + throw new Error('Claude image exceeds 5 MB limit') + } + const cacheDir = getClaudeImageCacheDir() + await mkdir(cacheDir, { recursive: true }) + const ext = extensionForMediaType(params.mediaType, params.filename) + const filePath = join(cacheDir, `${randomUUID()}${ext}`) + await writeFile(filePath, Buffer.from(params.base64Data, 'base64')) + return { path: filePath } +} diff --git a/src/main/storage/feedDebugLog.ts b/src/main/storage/feedDebugLog.ts new file mode 100644 index 000000000..ff4a673b9 --- /dev/null +++ b/src/main/storage/feedDebugLog.ts @@ -0,0 +1,64 @@ +import { mkdir, writeFile } from 'fs/promises' +import { join } from 'path' + +import { FEED_DEBUG_DIR } from './paths.js' + +// Per-session feed-debug log writer. +// +// Why a per-session serialized queue instead of fire-and-forget writes: +// The renderer flushes batches of debug entries on a timer. Without +// serialization, two overlapping batches from the same session would +// race at the filesystem and produce interleaved / truncated JSONL. +// Serializing per-session keeps lines whole; serializing per-file +// (a single global queue) would have worked but would starve +// high-traffic sessions waiting behind unrelated writes. +// +// Why writeFile with { flag: 'a' } instead of appendFile: +// They're equivalent, but writeFile makes the "first-write creates +// the dir" bootstrap a single line — we mkdir then open-with-append, +// no branch on existence. + +export type FeedDebugPersistEntry = { + id: number + ts: number + tMs: number + layer: 'STATE' | 'JSONL' | 'SEM' | 'RENDER' + kind: string + summary: string + data?: unknown +} + +const feedDebugWriteQueues = new Map>() + +function sanitizeSessionIdForPath(sessionId: string): string { + // Session ids are user-opaque uuids but the renderer also uses them + // as routing keys; they must be filename-safe. Strip anything that + // isn't [A-Za-z0-9._-] so a malformed id can't escape FEED_DEBUG_DIR + // via path traversal (`../`, etc.). + return sessionId.replace(/[^a-zA-Z0-9._-]/g, '_') +} + +/** + * Append `entries` to the session's feed-debug JSONL, serialized + * behind any in-flight write for the same session. Returns when the + * write is durable. + */ +export function queueFeedDebugAppend( + sessionId: string, + entries: FeedDebugPersistEntry[], +): Promise { + const previous = feedDebugWriteQueues.get(sessionId) ?? Promise.resolve() + const next = previous + .catch(() => {}) + .then(async () => { + if (entries.length === 0) return + await mkdir(FEED_DEBUG_DIR, { recursive: true }) + const filePath = join(FEED_DEBUG_DIR, `${sanitizeSessionIdForPath(sessionId)}.jsonl`) + const text = entries + .map(entry => JSON.stringify({ sessionId, ...entry })) + .join('\n') + '\n' + await writeFile(filePath, text, { encoding: 'utf8', flag: 'a' }) + }) + feedDebugWriteQueues.set(sessionId, next) + return next +} diff --git a/src/main/storage/paths.ts b/src/main/storage/paths.ts new file mode 100644 index 000000000..6574ef15a --- /dev/null +++ b/src/main/storage/paths.ts @@ -0,0 +1,19 @@ +import { join } from 'path' +import { homedir } from 'os' + +// Disk paths for persisted state. +// +// STATE_DIR follows XDG on Linux but uses ~/.config on macOS too — +// it's simpler than mirroring Electron's per-platform userData logic, +// the file is tiny, and the user has explicit control over it. +// Everything persisted by cc-shell lives under this one directory. + +export const STATE_DIR = join(homedir(), '.config', 'cc-shell') + +// Tile tree + session metadata, written atomically by workspace:save. +// The renderer owns the JSON shape; main is a byte mover. +export const STATE_FILE = join(STATE_DIR, 'workspace.json') + +// Per-session feed-debug append-only logs, one JSONL file per session. +// See storage/feedDebugLog.ts for the write-queue discipline. +export const FEED_DEBUG_DIR = join(STATE_DIR, 'feed-debug') diff --git a/src/main/window/mainWindow.ts b/src/main/window/mainWindow.ts new file mode 100644 index 000000000..5fb8b9c39 --- /dev/null +++ b/src/main/window/mainWindow.ts @@ -0,0 +1,102 @@ +import { BrowserWindow, shell } from 'electron' +import { dirname, join } from 'path' +import { fileURLToPath } from 'url' + +// The main BrowserWindow lives here, and so does the one helper +// everything else uses to push messages to it (sendToMainWindow). We +// keep the reference module-scoped and expose it through functions so +// consumers don't accidentally hold a stale BrowserWindow across a +// reload. If we ever add a second window, this module grows a +// per-role registry; for now the single-window assumption is baked in +// and explicit. + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +let mainWindow: BrowserWindow | null = null + +/** + * Push the traffic light (close/minimize/zoom) right-edge inset to the + * renderer as a CSS custom property. The renderer uses this to pad the + * tab bar so tabs don't sit under the buttons — zoom-safe, scale-safe, + * no magic pixel values. + * + * On non-macOS platforms or when the position isn't available, falls + * back to 0 (no inset needed — the title bar is separate). + */ +function pushTrafficLightInset(): void { + if (!mainWindow || mainWindow.isDestroyed()) return + // getWindowButtonPosition returns { x, y } of the top-left of the + // FIRST button (close). The three buttons are each ~14px wide with + // ~6px gaps, arranged left-to-right: close, minimize, zoom. The + // right edge of the zoom button is roughly x + 68 CSS pixels at 1x + // scale. But we want to be precise, so we add a comfortable margin + // past the reported x position. The x value already accounts for + // the hiddenInset padding. + try { + const pos = mainWindow.getWindowButtonPosition() + if (pos) { + // pos.x is the left edge of the close button. The three buttons + // span ~54px total, plus we want ~8px breathing room after the + // last button. Round up to avoid sub-pixel clipping. + const inset = Math.ceil(pos.x + 62) + mainWindow.webContents.send('traffic-light-inset', inset) + } + } catch { + // getWindowButtonPosition throws on non-macOS. Silently skip — + // the renderer defaults to 0. + } +} + +export function getMainWindow(): BrowserWindow | null { + return mainWindow +} + +/** + * Send an IPC message to the renderer. No-op when the window is gone + * — callers shouldn't have to guard lifecycle around every event. + * This is the ONE place the rest of main/ should reach into the + * BrowserWindow; everything else goes through here. + */ +export function sendToMainWindow(channel: string, ...args: unknown[]): void { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(channel, ...args) + } +} + +export function createMainWindow(): void { + mainWindow = new BrowserWindow({ + width: 1400, + height: 900, + show: false, + titleBarStyle: 'hiddenInset', + backgroundColor: '#0a0a0a', + webPreferences: { + preload: join(__dirname, '../preload/index.mjs'), + sandbox: false, + contextIsolation: true, + nodeIntegration: false, + }, + }) + + mainWindow.on('ready-to-show', () => { + mainWindow?.show() + pushTrafficLightInset() + }) + + // Recompute the traffic light inset whenever the window geometry + // changes — zoom level, display scale, fullscreen toggle. Electron + // doesn't offer a "traffic light moved" event, but resize covers + // every case that shifts them. + mainWindow.on('resize', pushTrafficLightInset) + + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + shell.openExternal(url) + return { action: 'deny' } + }) + + if (process.env['ELECTRON_RENDERER_URL']) { + mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) + } else { + mainWindow.loadFile(join(__dirname, '../renderer/index.html')) + } +} From 8b249d4a88f8e8eb2935a267929947b74eaef0c0 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 23 Apr 2026 16:59:49 +0200 Subject: [PATCH 02/27] refactor(preload): split index.ts by API domain into preload/api/* MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/preload/api/debug.ts | 17 + src/preload/api/fs.ts | 36 +++ src/preload/api/ghost.ts | 17 + src/preload/api/git.ts | 34 ++ src/preload/api/index.ts | 40 +++ src/preload/api/ipc.ts | 23 ++ src/preload/api/lsp.ts | 71 +++++ src/preload/api/provider.ts | 95 ++++++ src/preload/api/session.ts | 156 +++++++++ src/preload/api/sessions.ts | 44 +++ src/preload/api/system.ts | 16 + src/preload/api/types.ts | 185 +++++++++++ src/preload/api/workspace.ts | 20 ++ src/preload/index.ts | 599 +++-------------------------------- tsconfig.web.json | 11 + 15 files changed, 806 insertions(+), 558 deletions(-) create mode 100644 src/preload/api/debug.ts create mode 100644 src/preload/api/fs.ts create mode 100644 src/preload/api/ghost.ts create mode 100644 src/preload/api/git.ts create mode 100644 src/preload/api/index.ts create mode 100644 src/preload/api/ipc.ts create mode 100644 src/preload/api/lsp.ts create mode 100644 src/preload/api/provider.ts create mode 100644 src/preload/api/session.ts create mode 100644 src/preload/api/sessions.ts create mode 100644 src/preload/api/system.ts create mode 100644 src/preload/api/types.ts create mode 100644 src/preload/api/workspace.ts diff --git a/src/preload/api/debug.ts b/src/preload/api/debug.ts new file mode 100644 index 000000000..011dc566b --- /dev/null +++ b/src/preload/api/debug.ts @@ -0,0 +1,17 @@ +import { ipcRenderer } from 'electron' + +import type { FeedDebugPersistEntry } from './types.js' + +// Feed-debug log shipper. +// +// The renderer accumulates entries in runtime state and flushes +// batches here on a timer (see workspaceStore's persistFeedDebugLog +// effect). Main serializes per-session and appends to JSONL. + +export const debugApi = { + appendFeedDebugLog: (params: { + sessionId: string + entries: FeedDebugPersistEntry[] + }): Promise => + ipcRenderer.invoke('debug:append-feed-log', params), +} diff --git a/src/preload/api/fs.ts b/src/preload/api/fs.ts new file mode 100644 index 000000000..b7aa23e26 --- /dev/null +++ b/src/preload/api/fs.ts @@ -0,0 +1,36 @@ +import { ipcRenderer } from 'electron' + +import type { SavedClaudeImage } from './types.js' + +// Filesystem bridge — path expansion + directory listing + Claude +// image paste cache. + +export const fsApi = { + // Path expansion (used by the new-tab path modal). + expandCwd: ( + raw: string, + ): Promise<{ ok: true; path: string } | { ok: false; error: string }> => + ipcRenderer.invoke('fs:expandCwd', raw), + + // Directory listing (used by PathInput for completion). Returns up + // to ~thousands of entries for a given directory. Renderer filters + // client-side by the trailing "base" part of the user input. + listDirectory: ( + rawPath: string, + opts?: { directoriesOnly?: boolean; showHidden?: boolean }, + ): Promise< + | { + ok: true + entries: Array<{ name: string; isDirectory: boolean; path: string }> + expanded: string + } + | { ok: false; error: string } + > => ipcRenderer.invoke('fs:listDirectory', rawPath, opts), + + saveClaudeImage: (params: { + base64Data: string + mediaType: string + filename?: string + }): Promise => + ipcRenderer.invoke('fs:saveClaudeImage', params), +} diff --git a/src/preload/api/ghost.ts b/src/preload/api/ghost.ts new file mode 100644 index 000000000..3cd1f2ac9 --- /dev/null +++ b/src/preload/api/ghost.ts @@ -0,0 +1,17 @@ +import { ipcRenderer } from 'electron' + +// Ghost journal bridge. +// +// One file per session under /ghost-logs. Writes are +// fire-and-forget at the renderer level; the main-side queue drains +// every 100 ms. Reads replay the full log so atp's `reduceGhostLog` +// can fold it into current state on mount. The ghost value crosses +// IPC as plain JSON — no atp runtime in main. + +export const ghostApi = { + ghostAppend: (sessionId: string, ghost: unknown): void => { + void ipcRenderer.invoke('ghost:append', sessionId, ghost) + }, + ghostRead: (sessionId: string): Promise => + ipcRenderer.invoke('ghost:read', sessionId), +} diff --git a/src/preload/api/git.ts b/src/preload/api/git.ts new file mode 100644 index 000000000..3fa948ad9 --- /dev/null +++ b/src/preload/api/git.ts @@ -0,0 +1,34 @@ +import { ipcRenderer } from 'electron' + +// Git status bridge — used by GitBar. +// +// Parent-repo branch + numstat + recent log, plus a per-submodule +// section with bumped / dirty / both states. See main/ipc/git.ts for +// the command argv rules. Returns { ok: false } for non-git +// directories so the bar can cleanly degrade. + +export const gitApi = { + gitStatus: (cwd: string): Promise< + | { + ok: true + branch: string + files: Array<{ file: string; additions: number; deletions: number }> + commits: Array<{ + hash: string + subject: string + author: string + relativeDate: string + }> + // Submodules with either a bumped pointer, dirty content, or + // both. Main filters submodule gitlink entries out of `files` + // so the paths shown here never duplicate parent rows. + submodules?: Array<{ + path: string + state: 'dirty' | 'bumped' | 'both' + files: Array<{ file: string; additions: number; deletions: number }> + range?: { from: string; to: string } + }> + } + | { ok: false } + > => ipcRenderer.invoke('git:status', cwd), +} diff --git a/src/preload/api/index.ts b/src/preload/api/index.ts new file mode 100644 index 000000000..be6c14571 --- /dev/null +++ b/src/preload/api/index.ts @@ -0,0 +1,40 @@ +import { sessionApi } from './session.js' +import { providerApi } from './provider.js' +import { sessionsApi } from './sessions.js' +import { lspApi } from './lsp.js' +import { workspaceApi } from './workspace.js' +import { fsApi } from './fs.js' +import { debugApi } from './debug.js' +import { systemApi } from './system.js' +import { gitApi } from './git.js' +import { ghostApi } from './ghost.js' + +// Composed preload API surface. +// +// Every method from every domain module gets flattened onto a single +// `api` object — `window.api.spawnSession(...)`, not +// `window.api.session.spawn(...)`. The flat surface matches every +// existing call site in the renderer and is the shape captured by +// `Api = typeof api` in ../index.ts, which in turn drives the global +// `window.api` augmentation in ../index.d.ts. +// +// Method-name uniqueness across domains is enforced by the spread +// merge: TypeScript would error on a collision. Today there are +// none — domain modules use different name prefixes (`session*`, +// `workspace*`, `lsp*`, etc.) and the registry in main/ipc/ mirrors +// the split one-to-one. + +export const api = { + ...sessionApi, + ...providerApi, + ...sessionsApi, + ...lspApi, + ...workspaceApi, + ...fsApi, + ...debugApi, + ...systemApi, + ...gitApi, + ...ghostApi, +} + +export type Api = typeof api diff --git a/src/preload/api/ipc.ts b/src/preload/api/ipc.ts new file mode 100644 index 000000000..16d3ca44d --- /dev/null +++ b/src/preload/api/ipc.ts @@ -0,0 +1,23 @@ +import { ipcRenderer } from 'electron' + +import type { Unsub } from './types.js' + +// Generic IPC subscription helper. +// +// Each onX method on the bridge delegates to subscribe(channel, cb) +// and returns the resulting Unsub. This keeps domain modules tiny — +// they don't each reimplement "add a listener, return a remover." +// +// Why one listener per caller (not multiplexed): +// Most onX consumers subscribe once at app mount with a single +// callback that dispatches by sessionId. ipcRenderer.on fans the +// event out to every registered listener cheaply; we don't need a +// dedupe layer here. The ONE case that needs multiplexing is LSP +// diagnostics (see ./lsp.ts) — a set-of-subscribers pattern that +// survives hot-module reloads without leaking N IPC listeners. + +export function subscribe(channel: string, cb: (payload: T) => void): Unsub { + const listener = (_evt: unknown, payload: T) => cb(payload) + ipcRenderer.on(channel, listener) + return () => ipcRenderer.removeListener(channel, listener) +} diff --git a/src/preload/api/lsp.ts b/src/preload/api/lsp.ts new file mode 100644 index 000000000..47f57bae5 --- /dev/null +++ b/src/preload/api/lsp.ts @@ -0,0 +1,71 @@ +import { ipcRenderer } from 'electron' + +import type { + LspDiagnosticsEvent, + LspSemanticLegend, + Unsub, +} from './types.js' + +// LSP bridge methods. +// +// Diagnostics use a multiplexed subscription pattern instead of the +// per-caller model the other onX methods use. The reason: +// Monaco-backed code blocks mount and unmount freely as the user +// scrolls a long transcript. If every mount installed its own +// ipcRenderer.on listener, a conversation with 50 code blocks +// would accumulate 50 IPC listeners, then drop back to fewer on +// unmount — thrashing the listener list inside Electron's IPC and +// making memory-leak detection noisy. The Set + single ipcRenderer.on +// pattern installs exactly one listener for the lifetime of the +// renderer, and we add/remove callbacks against an in-memory Set +// that doesn't touch IPC at all. + +const lspDiagnosticsSubscribers = new Set<(payload: LspDiagnosticsEvent) => void>() +let lspDiagnosticsListenerInstalled = false + +function subscribeLspDiagnostics(cb: (payload: LspDiagnosticsEvent) => void): Unsub { + lspDiagnosticsSubscribers.add(cb) + + if (!lspDiagnosticsListenerInstalled) { + lspDiagnosticsListenerInstalled = true + ipcRenderer.on('lsp:diagnostics', (_evt: unknown, payload: LspDiagnosticsEvent) => { + for (const subscriber of lspDiagnosticsSubscribers) { + subscriber(payload) + } + }) + } + + return () => { + lspDiagnosticsSubscribers.delete(cb) + } +} + +export const lspApi = { + ensureLspLegend: ( + workspaceRoot: string, + language: string, + ): Promise => + ipcRenderer.invoke('lsp:ensure-legend', workspaceRoot, language), + + openLspDocument: (params: { + clientUri: string + content: string + language: string + workspaceRoot: string + filePath?: string | null + }): Promise => ipcRenderer.invoke('lsp:open-document', params), + + changeLspDocument: (clientUri: string, content: string): Promise => + ipcRenderer.invoke('lsp:change-document', clientUri, content), + + closeLspDocument: (clientUri: string): Promise => + ipcRenderer.invoke('lsp:close-document', clientUri), + + getLspSemanticTokens: ( + clientUri: string, + ): Promise<{ data: number[] } | null> => + ipcRenderer.invoke('lsp:get-semantic-tokens', clientUri), + + onLspDiagnostics: (cb: (e: LspDiagnosticsEvent) => void): Unsub => + subscribeLspDiagnostics(cb), +} diff --git a/src/preload/api/provider.ts b/src/preload/api/provider.ts new file mode 100644 index 000000000..449eb236a --- /dev/null +++ b/src/preload/api/provider.ts @@ -0,0 +1,95 @@ +import { ipcRenderer } from 'electron' + +// Provider-level session transforms on the bridge. +// +// All three produce a NEW provider session id whose on-disk +// transcript is a transformation of the source's. The source file is +// never touched. The renderer hands the new id to `replaceSession` +// so the focused pane re-homes onto the transformed conversation. +// +// Grouped separately from sessionApi because these are "write a new +// file, return a new id" operations — they don't spawn or kill +// sessions directly. Mirrors the split in main/ipc/provider.ts. + +export const providerApi = { + /** + * Translate the persisted transcript backing a provider session into the + * other provider's on-disk format and return the newly created target + * provider session id. The renderer uses that id with replaceSession(...) + * so the pane stays in place while the backend swaps from Claude<->Codex. + */ + switchProvider: (params: { + sourceKind: 'claude' | 'codex' + sourceProviderSessionId: string + cwd: string + }): Promise<{ + targetKind: 'claude' | 'codex' + targetProviderSessionId: string + targetFilePath: string + }> => ipcRenderer.invoke('session:switch-provider', params), + + /** + * Duplicate a provider session on disk. Reads the source transcript, + * clones it with a fresh session id (and fresh timestamp for + * Codex), writes the clone next to the original, and returns the + * new id. The renderer then passes that id to `spawnSession` / + * `newTab` with `resumeSessionId: newProviderSessionId` to bring + * the duplicate online as an independent conversation. + * + * Idempotent wrt the source file — the source is untouched. Live + * sessions can be duplicated; the clone is a point-in-time + * snapshot (later appends to the live source do not land in it). + */ + duplicateSession: (params: { + provider: 'claude' | 'codex' + sourceProviderSessionId: string + cwd: string + }): Promise<{ + provider: 'claude' | 'codex' + newProviderSessionId: string + newFilePath: string + }> => ipcRenderer.invoke('session:duplicate', params), + + /** + * Rewind a provider session to "just before" a selected user prompt. + * Produces a NEW provider session id whose on-disk transcript + * contains every entry strictly before the anchor (with orphan + * tool_use/call pairing cleaned up). The original file is not + * modified. + * + * The caller then: + * 1. Passes `newProviderSessionId` to `replaceSession(...)` so the + * focused pane re-homes onto the rewound transcript. + * 2. Prefills the pane's composer with `promptText` as an unsent + * draft — the rewound session opens in "continue from here + * with an editable prompt" mode, not "replay this prompt". + * + * Anchor shape is provider-specific: a Claude user entry uuid, or + * a zero-based index among user-role Codex message response_items. + */ + rewindToPrompt: (params: { + provider: 'claude' | 'codex' + sourceProviderSessionId: string + cwd: string + anchor: + | { kind: 'claude'; uuid: string } + | { kind: 'codex'; userMessageIndex: number } + }): Promise<{ + provider: 'claude' | 'codex' + newProviderSessionId: string + newFilePath: string + /** Unwrapped prompt text — `` / `/args>` + * envelopes unpacked, IDE-context tags stripped. Mirrors + * claude-code-src `textForResubmit`. */ + promptText: string + /** `'bash'` when the anchored prompt was a bash-input envelope; + * otherwise `'prompt'`. Caller can prefix `!` on the draft for + * bash mode, same as CC's composer. Codex anchors always report + * `'prompt'`. */ + promptMode: 'prompt' | 'bash' + /** Images pulled from the anchored user entry (Claude only). + * Empty for codex responses since codex rollouts don't carry + * image blocks. */ + promptImages: Array<{ mediaType: string; data: string }> + }> => ipcRenderer.invoke('session:rewind-to-prompt', params), +} diff --git a/src/preload/api/session.ts b/src/preload/api/session.ts new file mode 100644 index 000000000..ea5bf2bbe --- /dev/null +++ b/src/preload/api/session.ts @@ -0,0 +1,156 @@ +import { ipcRenderer } from 'electron' + +import { subscribe } from './ipc.js' +import type { + SessionExitEvent, + SessionHistoryChunk, + SessionInfo, + SessionJsonlEntriesEvent, + SessionJsonlEntryEvent, + SessionJsonlErrorEvent, + SessionKind, + SessionScreenEvent, + SessionSemanticEvent, + SessionStartedEvent, + SessionTerminalDataEvent, + SessionTrustDialogEvent, + SessionResumePromptEvent, + SessionCompactionStateEvent, + Unsub, +} from './types.js' + +// Session lifecycle + I/O bridge methods. +// +// Every channel is sessionId-scoped because the main process can run +// N ClaudeSession / CodexSession / TerminalSession instances in +// parallel and needs to route messages to the right one. The renderer +// subscribes ONCE per event type and dispatches by sessionId in the +// callback — this avoids N×N listener storms as tabs and splits grow. + +export const sessionApi = { + // --- Session lifecycle --- + spawnSession: (options: { + /** Optional. Defaults to 'claude' on the main side so existing + * callers don't need to change. Pass 'terminal' to spawn a + * plain shell session instead. */ + kind?: SessionKind + cwd: string + cols?: number + rows?: number + resumeSessionId?: string + dangerousMode?: boolean + /** Claude only. Opt into proxy-driven semantic streaming. When + * true, the session spawns a per-session mitmproxy, streams + * decrypted Anthropic events through it, and the renderer gets + * per-block semantic events via `onSessionSemanticEvent`. Needs + * mitmproxy installed (see claude-code-headless/PROXY_STREAMING.md). + * Default false — session behavior is unchanged. */ + useProxy?: boolean + /** Terminal + tmux only: when set AND tmux is available, attach + * to this existing tmux session instead of creating a new one. + * Used by the workspace reload path to recover persistent + * terminals. Falls back to fresh spawn if the session no longer + * exists. */ + recoverTmuxName?: string + }): Promise<{ sessionId: string; tmuxName?: string }> => + ipcRenderer.invoke('session:spawn', options), + + killSession: (sessionId: string): Promise => + ipcRenderer.invoke('session:kill', sessionId), + + /** + * Attach this renderer to a terminal session's live data stream. + * Returns the full buffered output so far — every byte the shell + * has written since spawn. Main flips the session's attached flag + * atomically so subsequent PTY data broadcasts live via + * 'session:terminal-data'. + * + * The renderer must subscribe to 'session:terminal-data' BEFORE + * calling this, then queue any live events it receives until the + * attach response arrives — otherwise events between subscribe + * and attach response are silently missed. See TerminalLeaf.tsx + * for the queue pattern. + */ + attachTerminal: (sessionId: string): Promise => + ipcRenderer.invoke('session:terminal-attach', sessionId), + + // --- Per-session I/O --- + sendInput: (sessionId: string, data: string): Promise => + ipcRenderer.invoke('session:input', sessionId, data), + + resize: (sessionId: string, cols: number, rows: number): Promise => + ipcRenderer.invoke('session:resize', sessionId, cols, rows), + + // --- Resume picker: list previous sessions recorded in a cwd --- + listSessionsForCwd: ( + cwd: string, + limit?: number, + provider: 'claude' | 'codex' = 'claude', + ): Promise => + ipcRenderer.invoke('session:list-for-cwd', cwd, limit, provider), + + /** Global session listing for the rendering-debug harness. Returns + * every known Claude + Codex session tagged with provider, sorted + * by lastModified desc. */ + listAllSessions: ( + limit?: number, + ): Promise> => + ipcRenderer.invoke('session:list-all', limit), + + loadOlderHistory: (params: { + kind: 'claude' | 'codex' + cwd: string + providerSessionId: string + beforeMarker: string + limit?: number + }): Promise => + ipcRenderer.invoke('session:load-older-history', params), + + // --- Session events (subscribe once; dispatch by sessionId in the callback) --- + onSessionStarted: (cb: (e: SessionStartedEvent) => void): Unsub => + subscribe('session:started', cb), + + onSessionScreen: (cb: (e: SessionScreenEvent) => void): Unsub => + subscribe('session:screen', cb), + + onSessionJsonlEntry: (cb: (e: SessionJsonlEntryEvent) => void): Unsub => + subscribe('session:jsonl-entry', cb), + // Bulk: the whole burst from one bootstrap flush, or a single live + // entry wrapped in a 1-element array. Renderer can treat them + // identically. See main/sessions/jsonlCoalescer.ts for why the + // bulk channel exists. + onSessionJsonlEntries: (cb: (e: SessionJsonlEntriesEvent) => void): Unsub => + subscribe('session:jsonl-entries', cb), + + onSessionJsonlError: (cb: (e: SessionJsonlErrorEvent) => void): Unsub => + subscribe('session:jsonl-error', cb), + + /** Raw PTY bytes for terminal sessions. Claude sessions do NOT + * emit on this channel — they use screen/jsonl-entry instead. */ + onSessionTerminalData: (cb: (e: SessionTerminalDataEvent) => void): Unsub => + subscribe('session:terminal-data', cb), + + onSessionProcessState: ( + cb: (e: { sessionId: string; active: boolean; status?: string }) => void, + ): Unsub => subscribe('session:process-state', cb), + + onSessionTrustDialog: (cb: (e: SessionTrustDialogEvent) => void): Unsub => + subscribe('session:trust-dialog', cb), + + onSessionResumePrompt: (cb: (e: SessionResumePromptEvent) => void): Unsub => + subscribe('session:resume-prompt', cb), + + onSessionCompactionState: (cb: (e: SessionCompactionStateEvent) => void): Unsub => + subscribe('session:compaction-state', cb), + + /** Subscribe to Claude's semantic event stream. Fires for every + * SemanticEvent emitted by the adapter — the renderer narrows by + * `event.type` and dispatches to the right UI primitive. Called + * once at app mount; dispatch by sessionId inside the callback + * rather than subscribing per-session. */ + onSessionSemanticEvent: (cb: (e: SessionSemanticEvent) => void): Unsub => + subscribe('session:semantic-event', cb), + + onSessionExit: (cb: (e: SessionExitEvent) => void): Unsub => + subscribe('session:exit', cb), +} diff --git a/src/preload/api/sessions.ts b/src/preload/api/sessions.ts new file mode 100644 index 000000000..8748fd155 --- /dev/null +++ b/src/preload/api/sessions.ts @@ -0,0 +1,44 @@ +import { ipcRenderer } from 'electron' + +import type { SessionIndexEntry } from './types.js' + +// Session prompt index — powers the Search Conversation Prompts modal. +// +// Named plural (sessions*) to match main/ipc/sessions.ts, and to +// distinguish from sessionApi's single-session lifecycle methods. +// Both handlers delegate to src/main/sessionIndex.ts which caches +// per-file parse results by mtime. + +export const sessionsApi = { + /** + * List the most-recently-active sessions with their last few user + * prompts attached. Powers the Search Conversation Prompts modal's + * default (empty-query) view. Results sorted by lastModified desc. + * + * cwd === null means "all workspaces"; pass a cwd string to restrict + * to sessions recorded in that cwd. Defaults: limit=10, + * promptsPerSession=4. + */ + listRecentSessionsWithPrompts: (options: { + limit?: number + promptsPerSession?: number + cwd?: string | null + } = {}): Promise => + ipcRenderer.invoke('sessions:list-recent-with-prompts', options), + + /** + * Search every session's user prompts for `query` (substring match, + * case-insensitive). Ranks by match-quality × recency. Returns + * sessions with their matched prompts prioritised, followed by + * context prompts from the same session. + * + * Empty query degrades to listRecentSessionsWithPrompts. + */ + searchSessionPrompts: (options: { + query: string + limit?: number + promptsPerSession?: number + cwd?: string | null + }): Promise => + ipcRenderer.invoke('sessions:search-prompts', options), +} diff --git a/src/preload/api/system.ts b/src/preload/api/system.ts new file mode 100644 index 000000000..45d3792e9 --- /dev/null +++ b/src/preload/api/system.ts @@ -0,0 +1,16 @@ +import { subscribe } from './ipc.js' +import type { Unsub } from './types.js' + +// Window-chrome coordination. +// +// Currently: the macOS traffic-light (close/minimize/zoom) right-edge +// inset pushed from main as a CSS custom property so the tab bar can +// pad itself without magic pixel values. Zoom-safe, scale-safe. +// +// If we ever add more window-chrome concerns (fullscreen transitions, +// vibrancy recomputation), they cluster here. + +export const systemApi = { + onTrafficLightInset: (cb: (insetPx: number) => void): Unsub => + subscribe('traffic-light-inset', cb), +} diff --git a/src/preload/api/types.ts b/src/preload/api/types.ts new file mode 100644 index 000000000..d6c3486b0 --- /dev/null +++ b/src/preload/api/types.ts @@ -0,0 +1,185 @@ +// Shared payload types for the preload IPC bridge. +// +// These are declared once here so every domain module (and the +// renderer) imports the same shape. Types that are specific to a +// single domain (LSP diagnostics, git response) can live beside +// their methods — keep this file for types that cross domain +// boundaries or are referenced from the outside. +// +// Keeping Unsub here too: every onX subscriber returns one, and the +// generic subscribe helper in ./ipc.ts produces them. + +export type Unsub = () => void + +export type JsonlEntry = Record + +export type PickerItem = { + id: string + label: string + description: string + selected: boolean +} + +export type SlashPickerState = { + visible: boolean + items: PickerItem[] +} + +export type ScreenSnapshot = { + /** Visible viewport text — what CC's TUI is showing right now. + * Source of truth for current-state parsers (trust dialog, + * slash picker, activity spinner). */ + plain: string + /** Viewport with bold/italic re-emitted as markdown. */ + markdown: string + /** Wider window (last ~200 rows including scrollback) used by + * the streaming extractor. CC's responses can grow taller than + * the viewport, scrolling the opening `⏺` marker into + * scrollback; without this wider snapshot the streaming card + * stays blank for long replies. */ + recent: string + /** Markdown counterpart of `recent`. */ + recentMarkdown: string + picker: SlashPickerState +} + +export type SessionKind = 'claude' | 'codex' | 'terminal' + +export type SessionStartedEvent = { + sessionId: string + kind: SessionKind + /** Undefined for terminal sessions — they don't have a CC project dir. */ + projectDir?: string +} +export type SessionScreenEvent = { sessionId: string } & ScreenSnapshot +export type SessionJsonlEntryEvent = { + sessionId: string + entry: JsonlEntry + file: string +} +// Bulk variant used by main during bootstrap bursts. Payload is an +// array of {entry, file} tuples for a single session — the renderer +// folds them in one setState instead of paying one render per entry. +// See main/index.ts jsonl coalescer for the WHY. +export type SessionJsonlEntriesEvent = { + sessionId: string + entries: Array<{ entry: JsonlEntry; file: string }> +} +export type SessionJsonlErrorEvent = { sessionId: string; message: string } +/** Raw PTY output for a terminal session — destined for xterm.js. */ +export type SessionTerminalDataEvent = { sessionId: string; data: string } +export type SessionTrustDialogEvent = { + sessionId: string + visible: boolean + workspace?: string +} +export type SessionResumePromptEvent = { + sessionId: string + visible: boolean + sessionAgeText?: string + tokenCountText?: string + options?: string[] + selectedIndex?: number +} +export type SessionCompactionStateEvent = { + sessionId: string + visible: boolean + phase?: 'running' | 'error' | 'done' + statusText?: string + errorText?: string +} + +/** Per-block semantic stream from Claude's proxy adapter (or screen + * fallback when proxy is off). `event` is a `SemanticEvent` from + * claude-code-headless — discriminated by `event.type` (text_delta / + * thinking_delta / tool_input_delta / tool_input_finalized / + * block_started / block_completed / turn_started / turn_stopped / + * turn_delta / turn_completed / usage_updated / api_error / + * stream_error / flow_selected / flow_ignored / source_changed / + * tool_result / signature). We keep `event` as unknown at the + * preload layer so this bridge doesn't need to pin a version of the + * semantic schema — the renderer imports the type from + * claude-code-headless and narrows on `event.type`. */ +export type SessionSemanticEvent = { sessionId: string; event: unknown } + +// --- Session prompt index --------------------------------------------------- +// +// Shape returned by the Search Conversation Prompts modal's IPC +// endpoints. Mirrors src/main/sessionIndex.ts's public exports one-to- +// one; re-declared here because preload/main/renderer are built under +// different tsconfig contexts and we don't share runtime types across +// them by import. +// +// A single entry carries enough metadata for the modal to render a +// row (provider icon, summary, relative time) and show the most +// recent user prompts for visual recognition. `matchCount` is only +// meaningful on search results — zero on the default listing. + +export type SessionIndexPrompt = { + text: string + ts: number | null +} + +export type SessionIndexEntry = { + providerSessionId: string + kind: 'claude' | 'codex' + cwd: string + lastModified: number + summary: string + recentUserPrompts: SessionIndexPrompt[] + matchCount: number +} + +export type SessionHistoryChunk = { + entries: JsonlEntry[] + hasMore: boolean +} + +export type SessionExitEvent = { + sessionId: string + exitCode: number + signal?: number +} + +export type LspSemanticLegend = { + tokenTypes: string[] + tokenModifiers: string[] +} +export type LspDiagnostic = { + message: string + severity: 'error' | 'warning' | 'info' | 'hint' + startLine: number + startCharacter: number + endLine: number + endCharacter: number +} +export type LspDiagnosticsEvent = { + clientUri: string + diagnostics: LspDiagnostic[] +} + +export type SavedClaudeImage = { + path: string +} + +export type FeedDebugPersistEntry = { + id: number + ts: number + tMs: number + layer: 'STATE' | 'JSONL' | 'SEM' | 'RENDER' + kind: string + summary: string + data?: unknown +} + +export type SessionInfo = { + sessionId: string + summary: string + lastModified: number + fileSize: number + customTitle?: string + firstPrompt?: string + gitBranch?: string + cwd?: string + createdAt?: number +} diff --git a/src/preload/api/workspace.ts b/src/preload/api/workspace.ts new file mode 100644 index 000000000..f497775be --- /dev/null +++ b/src/preload/api/workspace.ts @@ -0,0 +1,20 @@ +import { ipcRenderer } from 'electron' + +// Workspace state persistence bridge. +// +// Main just does the disk I/O. The renderer decides the JSON shape +// (see workspaceStore.ts flushSave). Keeping these on the bridge +// instead of inlining fetch() calls from the renderer means one +// serialization path and one place to adjust when the format +// evolves. + +export const workspaceApi = { + loadWorkspace: (): Promise => + ipcRenderer.invoke('workspace:load'), + + saveWorkspace: (json: string): Promise => + ipcRenderer.invoke('workspace:save', json), + + defaultCwd: (): Promise => + ipcRenderer.invoke('workspace:defaultCwd'), +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 62a1c89df..80a528718 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,4 +1,6 @@ -import { contextBridge, ipcRenderer } from 'electron' +import { contextBridge } from 'electron' + +import { api, type Api } from './api/index.js' // Preload bridge — multi-session API. // @@ -11,564 +13,45 @@ import { contextBridge, ipcRenderer } from 'electron' // Legacy note: the pre-tiling API had channels like `pty:screen`, // `pty:input`, `pty:exit`. Those are all gone. The tile tree uses // `session:*` channels that carry `{ sessionId, ... }` payloads. - -export type JsonlEntry = Record -export type PickerItem = { - id: string - label: string - description: string - selected: boolean -} -export type SlashPickerState = { - visible: boolean - items: PickerItem[] -} -export type ScreenSnapshot = { - /** Visible viewport text — what CC's TUI is showing right now. - * Source of truth for current-state parsers (trust dialog, - * slash picker, activity spinner). */ - plain: string - /** Viewport with bold/italic re-emitted as markdown. */ - markdown: string - /** Wider window (last ~200 rows including scrollback) used by - * the streaming extractor. CC's responses can grow taller than - * the viewport, scrolling the opening `⏺` marker into - * scrollback; without this wider snapshot the streaming card - * stays blank for long replies. */ - recent: string - /** Markdown counterpart of `recent`. */ - recentMarkdown: string - picker: SlashPickerState -} - -export type SessionKind = 'claude' | 'codex' | 'terminal' -export type SessionStartedEvent = { - sessionId: string - kind: SessionKind - /** Undefined for terminal sessions — they don't have a CC project dir. */ - projectDir?: string -} -export type SessionScreenEvent = { sessionId: string } & ScreenSnapshot -export type SessionJsonlEntryEvent = { - sessionId: string - entry: JsonlEntry - file: string -} -// Bulk variant used by main during bootstrap bursts. Payload is an -// array of {entry, file} tuples for a single session — the renderer -// folds them in one setState instead of paying one render per entry. -// See main/index.ts jsonl coalescer for the WHY. -export type SessionJsonlEntriesEvent = { - sessionId: string - entries: Array<{ entry: JsonlEntry; file: string }> -} -export type SessionJsonlErrorEvent = { sessionId: string; message: string } -/** Raw PTY output for a terminal session — destined for xterm.js. */ -export type SessionTerminalDataEvent = { sessionId: string; data: string } -export type SessionTrustDialogEvent = { - sessionId: string - visible: boolean - workspace?: string -} -export type SessionResumePromptEvent = { - sessionId: string - visible: boolean - sessionAgeText?: string - tokenCountText?: string - options?: string[] - selectedIndex?: number -} -export type SessionCompactionStateEvent = { - sessionId: string - visible: boolean - phase?: 'running' | 'error' | 'done' - statusText?: string - errorText?: string -} - -/** Per-block semantic stream from Claude's proxy adapter (or screen - * fallback when proxy is off). `event` is a `SemanticEvent` from - * claude-code-headless — discriminated by `event.type` (text_delta / - * thinking_delta / tool_input_delta / tool_input_finalized / - * block_started / block_completed / turn_started / turn_stopped / - * turn_delta / turn_completed / usage_updated / api_error / - * stream_error / flow_selected / flow_ignored / source_changed / - * tool_result / signature). We keep `event` as unknown at the - * preload layer so this bridge doesn't need to pin a version of the - * semantic schema — the renderer imports the type from - * claude-code-headless and narrows on `event.type`. */ -export type SessionSemanticEvent = { sessionId: string; event: unknown } - -// --- Session prompt index --------------------------------------------------- -// -// Shape returned by the Search Conversation Prompts modal's IPC -// endpoints. Mirrors src/main/sessionIndex.ts's public exports one-to- -// one; re-declared here because preload/main/renderer are built under -// different tsconfig contexts and we don't share runtime types across -// them by import. // -// A single entry carries enough metadata for the modal to render a -// row (provider icon, summary, relative time) and show the most -// recent user prompts for visual recognition. `matchCount` is only -// meaningful on search results — zero on the default listing. - -export type SessionIndexPrompt = { - text: string - ts: number | null -} - -export type SessionIndexEntry = { - providerSessionId: string - kind: 'claude' | 'codex' - cwd: string - lastModified: number - summary: string - recentUserPrompts: SessionIndexPrompt[] - matchCount: number -} - -export type SessionHistoryChunk = { - entries: JsonlEntry[] - hasMore: boolean -} -export type SessionExitEvent = { - sessionId: string - exitCode: number - signal?: number -} -export type LspSemanticLegend = { - tokenTypes: string[] - tokenModifiers: string[] -} -export type LspDiagnostic = { - message: string - severity: 'error' | 'warning' | 'info' | 'hint' - startLine: number - startCharacter: number - endLine: number - endCharacter: number -} -export type LspDiagnosticsEvent = { - clientUri: string - diagnostics: LspDiagnostic[] -} - -export type SavedClaudeImage = { - path: string -} - -export type FeedDebugPersistEntry = { - id: number - ts: number - tMs: number - layer: 'STATE' | 'JSONL' | 'SEM' | 'RENDER' - kind: string - summary: string - data?: unknown -} - -type Unsub = () => void - -const lspDiagnosticsSubscribers = new Set<(payload: LspDiagnosticsEvent) => void>() -let lspDiagnosticsListenerInstalled = false - -function subscribeLspDiagnostics(cb: (payload: LspDiagnosticsEvent) => void): Unsub { - lspDiagnosticsSubscribers.add(cb) - - if (!lspDiagnosticsListenerInstalled) { - lspDiagnosticsListenerInstalled = true - ipcRenderer.on('lsp:diagnostics', (_evt: unknown, payload: LspDiagnosticsEvent) => { - for (const subscriber of lspDiagnosticsSubscribers) { - subscriber(payload) - } - }) - } - - return () => { - lspDiagnosticsSubscribers.delete(cb) - } -} - -/** - * Helper to register an IPC listener and return an unsubscriber that - * detaches it. Keeps the api surface below tidy. - */ -function subscribe(channel: string, cb: (payload: T) => void): Unsub { - const listener = (_evt: unknown, payload: T) => cb(payload) - ipcRenderer.on(channel, listener) - return () => ipcRenderer.removeListener(channel, listener) -} - -export type SessionInfo = { - sessionId: string - summary: string - lastModified: number - fileSize: number - customTitle?: string - firstPrompt?: string - gitBranch?: string - cwd?: string - createdAt?: number -} - -const api = { - // --- Session lifecycle --- - spawnSession: (options: { - /** Optional. Defaults to 'claude' on the main side so existing - * callers don't need to change. Pass 'terminal' to spawn a - * plain shell session instead. */ - kind?: SessionKind - cwd: string - cols?: number - rows?: number - resumeSessionId?: string - dangerousMode?: boolean - /** Claude only. Opt into proxy-driven semantic streaming. When - * true, the session spawns a per-session mitmproxy, streams - * decrypted Anthropic events through it, and the renderer gets - * per-block semantic events via `onSessionSemanticEvent`. Needs - * mitmproxy installed (see claude-code-headless/PROXY_STREAMING.md). - * Default false — session behavior is unchanged. */ - useProxy?: boolean - /** Terminal + tmux only: when set AND tmux is available, attach - * to this existing tmux session instead of creating a new one. - * Used by the workspace reload path to recover persistent - * terminals. Falls back to fresh spawn if the session no longer - * exists. */ - recoverTmuxName?: string - }): Promise<{ sessionId: string; tmuxName?: string }> => - ipcRenderer.invoke('session:spawn', options), - - killSession: (sessionId: string): Promise => - ipcRenderer.invoke('session:kill', sessionId), - - /** - * Translate the persisted transcript backing a provider session into the - * other provider's on-disk format and return the newly created target - * provider session id. The renderer uses that id with replaceSession(...) - * so the pane stays in place while the backend swaps from Claude<->Codex. - */ - switchProvider: (params: { - sourceKind: 'claude' | 'codex' - sourceProviderSessionId: string - cwd: string - }): Promise<{ - targetKind: 'claude' | 'codex' - targetProviderSessionId: string - targetFilePath: string - }> => ipcRenderer.invoke('session:switch-provider', params), - - /** - * Duplicate a provider session on disk. Reads the source transcript, - * clones it with a fresh session id (and fresh timestamp for - * Codex), writes the clone next to the original, and returns the - * new id. The renderer then passes that id to `spawnSession` / - * `newTab` with `resumeSessionId: newProviderSessionId` to bring - * the duplicate online as an independent conversation. - * - * Idempotent wrt the source file — the source is untouched. Live - * sessions can be duplicated; the clone is a point-in-time - * snapshot (later appends to the live source do not land in it). - */ - duplicateSession: (params: { - provider: 'claude' | 'codex' - sourceProviderSessionId: string - cwd: string - }): Promise<{ - provider: 'claude' | 'codex' - newProviderSessionId: string - newFilePath: string - }> => ipcRenderer.invoke('session:duplicate', params), - - /** - * Rewind a provider session to "just before" a selected user prompt. - * Produces a NEW provider session id whose on-disk transcript - * contains every entry strictly before the anchor (with orphan - * tool_use/call pairing cleaned up). The original file is not - * modified. - * - * The caller then: - * 1. Passes `newProviderSessionId` to `replaceSession(...)` so the - * focused pane re-homes onto the rewound transcript. - * 2. Prefills the pane's composer with `promptText` as an unsent - * draft — the rewound session opens in "continue from here - * with an editable prompt" mode, not "replay this prompt". - * - * Anchor shape is provider-specific: a Claude user entry uuid, or - * a zero-based index among user-role Codex message response_items. - */ - rewindToPrompt: (params: { - provider: 'claude' | 'codex' - sourceProviderSessionId: string - cwd: string - anchor: - | { kind: 'claude'; uuid: string } - | { kind: 'codex'; userMessageIndex: number } - }): Promise<{ - provider: 'claude' | 'codex' - newProviderSessionId: string - newFilePath: string - /** Unwrapped prompt text — `` / `/args>` - * envelopes unpacked, IDE-context tags stripped. Mirrors - * claude-code-src `textForResubmit`. */ - promptText: string - /** `'bash'` when the anchored prompt was a bash-input envelope; - * otherwise `'prompt'`. Caller can prefix `!` on the draft for - * bash mode, same as CC's composer. Codex anchors always report - * `'prompt'`. */ - promptMode: 'prompt' | 'bash' - /** Images pulled from the anchored user entry (Claude only). - * Empty for codex responses since codex rollouts don't carry - * image blocks. */ - promptImages: Array<{ mediaType: string; data: string }> - }> => ipcRenderer.invoke('session:rewind-to-prompt', params), - - /** - * List the most-recently-active sessions with their last few user - * prompts attached. Powers the Search Conversation Prompts modal's - * default (empty-query) view. Results sorted by lastModified desc. - * - * cwd === null means "all workspaces"; pass a cwd string to restrict - * to sessions recorded in that cwd. Defaults: limit=10, - * promptsPerSession=4. - */ - listRecentSessionsWithPrompts: (options: { - limit?: number - promptsPerSession?: number - cwd?: string | null - } = {}): Promise => - ipcRenderer.invoke('sessions:list-recent-with-prompts', options), - - /** - * Search every session's user prompts for `query` (substring match, - * case-insensitive). Ranks by match-quality × recency. Returns - * sessions with their matched prompts prioritised, followed by - * context prompts from the same session. - * - * Empty query degrades to listRecentSessionsWithPrompts. - */ - searchSessionPrompts: (options: { - query: string - limit?: number - promptsPerSession?: number - cwd?: string | null - }): Promise => - ipcRenderer.invoke('sessions:search-prompts', options), - - /** - * Attach this renderer to a terminal session's live data stream. - * Returns the full buffered output so far — every byte the shell - * has written since spawn. Main flips the session's attached flag - * atomically so subsequent PTY data broadcasts live via - * 'session:terminal-data'. - * - * The renderer must subscribe to 'session:terminal-data' BEFORE - * calling this, then queue any live events it receives until the - * attach response arrives — otherwise events between subscribe - * and attach response are silently missed. See TerminalLeaf.tsx - * for the queue pattern. - */ - attachTerminal: (sessionId: string): Promise => - ipcRenderer.invoke('session:terminal-attach', sessionId), - - // --- Resume picker: list previous sessions recorded in a cwd --- - listSessionsForCwd: ( - cwd: string, - limit?: number, - provider: 'claude' | 'codex' = 'claude', - ): Promise => - ipcRenderer.invoke('session:list-for-cwd', cwd, limit, provider), - - /** Global session listing for the rendering-debug harness. Returns - * every known Claude + Codex session tagged with provider, sorted - * by lastModified desc. */ - listAllSessions: ( - limit?: number, - ): Promise> => - ipcRenderer.invoke('session:list-all', limit), - - loadOlderHistory: (params: { - kind: 'claude' | 'codex' - cwd: string - providerSessionId: string - beforeMarker: string - limit?: number - }): Promise => - ipcRenderer.invoke('session:load-older-history', params), - - // --- Per-session I/O --- - sendInput: (sessionId: string, data: string): Promise => - ipcRenderer.invoke('session:input', sessionId, data), - - resize: (sessionId: string, cols: number, rows: number): Promise => - ipcRenderer.invoke('session:resize', sessionId, cols, rows), - - // --- Session events (subscribe once; dispatch by sessionId in the callback) --- - onSessionStarted: (cb: (e: SessionStartedEvent) => void): Unsub => - subscribe('session:started', cb), - - onSessionScreen: (cb: (e: SessionScreenEvent) => void): Unsub => - subscribe('session:screen', cb), - - onSessionJsonlEntry: (cb: (e: SessionJsonlEntryEvent) => void): Unsub => - subscribe('session:jsonl-entry', cb), - // Bulk: the whole burst from one bootstrap flush, or a single live - // entry wrapped in a 1-element array. Renderer can treat them - // identically. See main/index.ts for why the bulk channel exists. - onSessionJsonlEntries: (cb: (e: SessionJsonlEntriesEvent) => void): Unsub => - subscribe('session:jsonl-entries', cb), - - onSessionJsonlError: (cb: (e: SessionJsonlErrorEvent) => void): Unsub => - subscribe('session:jsonl-error', cb), - - /** Raw PTY bytes for terminal sessions. Claude sessions do NOT - * emit on this channel — they use screen/jsonl-entry instead. */ - onSessionTerminalData: (cb: (e: SessionTerminalDataEvent) => void): Unsub => - subscribe('session:terminal-data', cb), - - onSessionProcessState: ( - cb: (e: { sessionId: string; active: boolean; status?: string }) => void, - ): Unsub => subscribe('session:process-state', cb), - - onSessionTrustDialog: (cb: (e: SessionTrustDialogEvent) => void): Unsub => - subscribe('session:trust-dialog', cb), - - onSessionResumePrompt: (cb: (e: SessionResumePromptEvent) => void): Unsub => - subscribe('session:resume-prompt', cb), - - onSessionCompactionState: (cb: (e: SessionCompactionStateEvent) => void): Unsub => - subscribe('session:compaction-state', cb), - - /** Subscribe to Claude's semantic event stream. Fires for every - * SemanticEvent emitted by the adapter — the renderer narrows by - * `event.type` and dispatches to the right UI primitive. Called - * once at app mount; dispatch by sessionId inside the callback - * rather than subscribing per-session. */ - onSessionSemanticEvent: (cb: (e: SessionSemanticEvent) => void): Unsub => - subscribe('session:semantic-event', cb), - - onSessionExit: (cb: (e: SessionExitEvent) => void): Unsub => - subscribe('session:exit', cb), - - // --- LSP / Monaco code intelligence --- - ensureLspLegend: ( - workspaceRoot: string, - language: string, - ): Promise => - ipcRenderer.invoke('lsp:ensure-legend', workspaceRoot, language), - - openLspDocument: (params: { - clientUri: string - content: string - language: string - workspaceRoot: string - filePath?: string | null - }): Promise => ipcRenderer.invoke('lsp:open-document', params), - - changeLspDocument: (clientUri: string, content: string): Promise => - ipcRenderer.invoke('lsp:change-document', clientUri, content), - - closeLspDocument: (clientUri: string): Promise => - ipcRenderer.invoke('lsp:close-document', clientUri), - - getLspSemanticTokens: ( - clientUri: string, - ): Promise<{ data: number[] } | null> => - ipcRenderer.invoke('lsp:get-semantic-tokens', clientUri), - - onLspDiagnostics: (cb: (e: LspDiagnosticsEvent) => void): Unsub => - subscribeLspDiagnostics(cb), - - // --- Workspace persistence --- - // Main just does the disk I/O. The renderer decides the JSON shape. - loadWorkspace: (): Promise => - ipcRenderer.invoke('workspace:load'), - - saveWorkspace: (json: string): Promise => - ipcRenderer.invoke('workspace:save', json), - - defaultCwd: (): Promise => - ipcRenderer.invoke('workspace:defaultCwd'), - - // --- Path expansion (used by the new-tab path modal) --- - expandCwd: ( - raw: string, - ): Promise<{ ok: true; path: string } | { ok: false; error: string }> => - ipcRenderer.invoke('fs:expandCwd', raw), - - // --- Directory listing (used by PathInput for completion) --- - // Returns up to ~thousands of entries for a given directory. Renderer - // filters client-side by the trailing "base" part of the user input. - listDirectory: ( - rawPath: string, - opts?: { directoriesOnly?: boolean; showHidden?: boolean }, - ): Promise< - | { - ok: true - entries: Array<{ name: string; isDirectory: boolean; path: string }> - expanded: string - } - | { ok: false; error: string } - > => ipcRenderer.invoke('fs:listDirectory', rawPath, opts), - - saveClaudeImage: (params: { - base64Data: string - mediaType: string - filename?: string - }): Promise => - ipcRenderer.invoke('fs:saveClaudeImage', params), - - appendFeedDebugLog: (params: { - sessionId: string - entries: FeedDebugPersistEntry[] - }): Promise => - ipcRenderer.invoke('debug:append-feed-log', params), - - // --- Traffic light inset (macOS) --- - // Main pushes the right-edge X of the traffic light buttons so the - // tab bar can pad itself dynamically. Zoom-safe, scale-safe. - onTrafficLightInset: (cb: (insetPx: number) => void): Unsub => - subscribe('traffic-light-inset', cb), - - // --- Git info (used by GitBar) --- - gitStatus: (cwd: string): Promise< - | { - ok: true - branch: string - files: Array<{ file: string; additions: number; deletions: number }> - commits: Array<{ - hash: string - subject: string - author: string - relativeDate: string - }> - // Submodules with either a bumped pointer, dirty content, or - // both. Main filters submodule gitlink entries out of `files` - // so the paths shown here never duplicate parent rows. - submodules?: Array<{ - path: string - state: 'dirty' | 'bumped' | 'both' - files: Array<{ file: string; additions: number; deletions: number }> - range?: { from: string; to: string } - }> - } - | { ok: false } - > => ipcRenderer.invoke('git:status', cwd), - - // --- Ghost journal --- - // - // One file per session under /ghost-logs. Writes are - // fire-and-forget at the renderer level; the main-side queue - // drains every 100 ms. Reads replay the full log so atp's - // `reduceGhostLog` can fold it into current state on mount. The - // ghost value crosses IPC as plain JSON — no atp runtime in main. - ghostAppend: (sessionId: string, ghost: unknown): void => { - void ipcRenderer.invoke('ghost:append', sessionId, ghost) - }, - ghostRead: (sessionId: string): Promise => - ipcRenderer.invoke('ghost:read', sessionId), -} +// The api object itself is composed in ./api/index.ts by flattening +// per-domain modules (session, provider, lsp, fs, workspace, debug, +// sessions, ghost, git, system). This file stays tiny: bind to the +// global and re-export the types that the renderer imports. contextBridge.exposeInMainWorld('api', api) -export type Api = typeof api +export type { Api } + +// Re-export every payload type. Renderer code that imports types +// from '../preload/index' (or from this file via d.ts) keeps working +// verbatim — the types just moved from inline declarations into +// ./api/types.ts, and this file is still the public face of the +// preload bundle. +export type { + FeedDebugPersistEntry, + JsonlEntry, + LspDiagnostic, + LspDiagnosticsEvent, + LspSemanticLegend, + PickerItem, + SavedClaudeImage, + ScreenSnapshot, + SessionCompactionStateEvent, + SessionExitEvent, + SessionHistoryChunk, + SessionIndexEntry, + SessionIndexPrompt, + SessionInfo, + SessionJsonlEntriesEvent, + SessionJsonlEntryEvent, + SessionJsonlErrorEvent, + SessionKind, + SessionResumePromptEvent, + SessionScreenEvent, + SessionSemanticEvent, + SessionStartedEvent, + SessionTerminalDataEvent, + SessionTrustDialogEvent, + SlashPickerState, +} from './api/types.js' diff --git a/tsconfig.web.json b/tsconfig.web.json index c3a906374..1b590b14a 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -31,5 +31,16 @@ "src/providers/*/parsers/**/*", "src/providers/*/renderer/**/*", "testing/**/*" + ], + // Project reference: tell tsc to resolve preload imports via node's + // emitted declarations, not by walking the source tree. Without + // this, `src/preload/index.d.ts` traces `./index` → `index.ts` → + // `./api/*.ts` and the web project errors TS6307 because the + // api/*.ts files aren't in this project's include list. The + // reference makes `tsc -b` build tsconfig.node.json first, drop + // its declarations in `.tsc-out/node/`, and have this web project + // consume those instead of chasing source files out of scope. + "references": [ + { "path": "./tsconfig.node.json" } ] } From 6c6abf764eaa22edfa38c0aed206dba3a704b5e9 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 23 Apr 2026 17:07:18 +0200 Subject: [PATCH 03/27] refactor(renderer): lift flat-root files into features/, ui/, lib/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//{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) --- src/renderer/src/App.tsx | 12 ++++++------ .../command-palette}/registry.ts | 18 +++++++++--------- .../command-palette}/types.ts | 2 +- .../command-palette/ui}/CommandPalette.tsx | 6 +++--- .../commands/copyAssistantCommands.ts | 2 +- .../src/{ => features/debug/ui}/DebugPanel.tsx | 4 ++-- .../{ => features/debug/ui}/FeedDebugPanel.tsx | 2 +- .../{ => features/debug/ui}/HtmlDebugPanel.tsx | 2 +- .../debug/ui}/ProxyDebugPanel.tsx | 4 ++-- .../src/{ => features/git/ui}/GitBar.tsx | 0 .../src/{git => features/git/ui}/GitRows.tsx | 8 ++++---- .../features/reader/commands/readerCommands.ts | 2 +- .../src/features/reader/ui/ReaderView.tsx | 2 +- .../settings/commands/dangerousActions.ts | 2 +- .../settings/commands/dangerousCommands.ts | 2 +- .../settings/commands/settingsCommands.ts | 2 +- .../spotlight/commands/spotlightCommands.ts | 2 +- .../tile-tabs/commands/tileTabsCommands.ts | 2 +- .../workspace/commands/layoutCommands.ts | 2 +- .../workspace/commands/paneCommands.ts | 4 ++-- .../workspace/commands/sessionCommands.ts | 2 +- .../features/workspace/commands/tabCommands.ts | 2 +- src/renderer/src/feed/Feed.tsx | 2 +- src/renderer/src/{ => lib}/copyAssistant.ts | 4 ++-- src/renderer/src/{ => lib}/sanitizeHtml.ts | 0 src/renderer/src/main.tsx | 2 +- src/renderer/src/tiles/TileLeaf.tsx | 2 +- src/renderer/src/tiles/workspaceStore.ts | 4 ++-- src/renderer/src/{ => ui}/GlobalToast.tsx | 0 29 files changed, 49 insertions(+), 49 deletions(-) rename src/renderer/src/{commands => features/command-palette}/registry.ts (52%) rename src/renderer/src/{commands => features/command-palette}/types.ts (96%) rename src/renderer/src/{ => features/command-palette/ui}/CommandPalette.tsx (98%) rename src/renderer/src/{ => features/debug/ui}/DebugPanel.tsx (98%) rename src/renderer/src/{ => features/debug/ui}/FeedDebugPanel.tsx (99%) rename src/renderer/src/{ => features/debug/ui}/HtmlDebugPanel.tsx (99%) rename src/renderer/src/{ => features/debug/ui}/ProxyDebugPanel.tsx (98%) rename src/renderer/src/{ => features/git/ui}/GitBar.tsx (100%) rename src/renderer/src/{git => features/git/ui}/GitRows.tsx (98%) rename src/renderer/src/{ => lib}/copyAssistant.ts (94%) rename src/renderer/src/{ => lib}/sanitizeHtml.ts (100%) rename src/renderer/src/{ => ui}/GlobalToast.tsx (100%) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 937643c66..42a949a24 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,10 +1,10 @@ import { useCallback, useEffect } from 'react' -import { CommandPalette } from './CommandPalette' -import { DebugPanel } from './DebugPanel' -import { FeedDebugPanel } from './FeedDebugPanel' -import { HtmlDebugPanel } from './HtmlDebugPanel' -import { ProxyDebugPanel } from './ProxyDebugPanel' +import { CommandPalette } from './features/command-palette/ui/CommandPalette' +import { DebugPanel } from './features/debug/ui/DebugPanel' +import { FeedDebugPanel } from './features/debug/ui/FeedDebugPanel' +import { HtmlDebugPanel } from './features/debug/ui/HtmlDebugPanel' +import { ProxyDebugPanel } from './features/debug/ui/ProxyDebugPanel' import { SettingsPage } from './features/settings/ui/SettingsPage' import { SpotlightView } from './features/spotlight/ui/SpotlightView' import { ReaderView } from './features/reader/ui/ReaderView' @@ -16,7 +16,7 @@ import { NewAgentPlacementOverlay } from './features/workspace/ui/NewAgentPlacem import { PromptSearchModal } from './features/workspace/ui/PromptSearchModal' import { RewindToPromptModal } from './features/workspace/ui/RewindToPromptModal' import { ViewPromptsModal } from './features/workspace/ui/ViewPromptsModal' -import { GitBar } from './GitBar' +import { GitBar } from './features/git/ui/GitBar' import { AppearanceMenu } from './feed/AppearanceMenu' import { PathPickerModal } from './tiles/PathPickerModal' import { TabBar } from './tiles/TabBar' diff --git a/src/renderer/src/commands/registry.ts b/src/renderer/src/features/command-palette/registry.ts similarity index 52% rename from src/renderer/src/commands/registry.ts rename to src/renderer/src/features/command-palette/registry.ts index 66f53f5a6..902ed33d0 100644 --- a/src/renderer/src/commands/registry.ts +++ b/src/renderer/src/features/command-palette/registry.ts @@ -1,12 +1,12 @@ -import { layoutCommands } from '../features/workspace/commands/layoutCommands' -import { paneCommands } from '../features/workspace/commands/paneCommands' -import { sessionCommands } from '../features/workspace/commands/sessionCommands' -import { tabCommands } from '../features/workspace/commands/tabCommands' -import { settingsCommands } from '../features/settings/commands/settingsCommands' -import { spotlightCommands } from '../features/spotlight/commands/spotlightCommands' -import { tileTabsCommands } from '../features/tile-tabs/commands/tileTabsCommands' -import { readerCommands } from '../features/reader/commands/readerCommands' -import { copyAssistantCommands } from '../features/copy-assistant/commands/copyAssistantCommands' +import { layoutCommands } from '../workspace/commands/layoutCommands' +import { paneCommands } from '../workspace/commands/paneCommands' +import { sessionCommands } from '../workspace/commands/sessionCommands' +import { tabCommands } from '../workspace/commands/tabCommands' +import { settingsCommands } from '../settings/commands/settingsCommands' +import { spotlightCommands } from '../spotlight/commands/spotlightCommands' +import { tileTabsCommands } from '../tile-tabs/commands/tileTabsCommands' +import { readerCommands } from '../reader/commands/readerCommands' +import { copyAssistantCommands } from '../copy-assistant/commands/copyAssistantCommands' import type { CommandContext, CommandDef, ResolvedCommand } from './types' const commandDefs: CommandDef[] = [ diff --git a/src/renderer/src/commands/types.ts b/src/renderer/src/features/command-palette/types.ts similarity index 96% rename from src/renderer/src/commands/types.ts rename to src/renderer/src/features/command-palette/types.ts index bb251adfb..a2d816206 100644 --- a/src/renderer/src/commands/types.ts +++ b/src/renderer/src/features/command-palette/types.ts @@ -1,4 +1,4 @@ -import type { Workspace } from '../tiles/workspaceStore' +import type { Workspace } from '../../tiles/workspaceStore' export type CommandState = { label: string diff --git a/src/renderer/src/CommandPalette.tsx b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx similarity index 98% rename from src/renderer/src/CommandPalette.tsx rename to src/renderer/src/features/command-palette/ui/CommandPalette.tsx index faad9b805..255134b78 100644 --- a/src/renderer/src/CommandPalette.tsx +++ b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { buildCommandRegistry } from './commands/registry' -import type { CommandContext, ResolvedCommand } from './commands/types' -import type { Workspace } from './tiles/workspaceStore' +import { buildCommandRegistry } from '../registry' +import type { CommandContext, ResolvedCommand } from '../types' +import type { Workspace } from '../../../tiles/workspaceStore' // CommandPalette — VS Code-style ⌘⇧P command menu. // diff --git a/src/renderer/src/features/copy-assistant/commands/copyAssistantCommands.ts b/src/renderer/src/features/copy-assistant/commands/copyAssistantCommands.ts index 45be31582..0f8957899 100644 --- a/src/renderer/src/features/copy-assistant/commands/copyAssistantCommands.ts +++ b/src/renderer/src/features/copy-assistant/commands/copyAssistantCommands.ts @@ -1,4 +1,4 @@ -import type { CommandDef } from '../../../commands/types' +import type { CommandDef } from '../../command-palette/types' // "Copy Assistant Message" — opens the picker on the focused pane. // All navigation (Up/Down/Enter/Esc) is handled by useKeybinds when diff --git a/src/renderer/src/DebugPanel.tsx b/src/renderer/src/features/debug/ui/DebugPanel.tsx similarity index 98% rename from src/renderer/src/DebugPanel.tsx rename to src/renderer/src/features/debug/ui/DebugPanel.tsx index 8aba3a602..53dd173e8 100644 --- a/src/renderer/src/DebugPanel.tsx +++ b/src/renderer/src/features/debug/ui/DebugPanel.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react' -import type { SessionRuntime } from './tiles/workspaceStore' -import type { Entry } from '../../shared/types/transcript' +import type { SessionRuntime } from '../../../tiles/workspaceStore' +import type { Entry } from '../../../../../shared/types/transcript' // DebugPanel — inline diagnostic overlay showing the raw state of the // focused pane. Toggled via "Toggle Debug Panel" in the command palette. diff --git a/src/renderer/src/FeedDebugPanel.tsx b/src/renderer/src/features/debug/ui/FeedDebugPanel.tsx similarity index 99% rename from src/renderer/src/FeedDebugPanel.tsx rename to src/renderer/src/features/debug/ui/FeedDebugPanel.tsx index 6d618b019..f4e88f87f 100644 --- a/src/renderer/src/FeedDebugPanel.tsx +++ b/src/renderer/src/features/debug/ui/FeedDebugPanel.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { FeedDebugEntry, FeedDebugLayer, SessionRuntime } from './tiles/workspaceState' +import type { FeedDebugEntry, FeedDebugLayer, SessionRuntime } from '../../../tiles/workspaceState' type Props = { sessionId: string diff --git a/src/renderer/src/HtmlDebugPanel.tsx b/src/renderer/src/features/debug/ui/HtmlDebugPanel.tsx similarity index 99% rename from src/renderer/src/HtmlDebugPanel.tsx rename to src/renderer/src/features/debug/ui/HtmlDebugPanel.tsx index e0024e96d..79a8a611c 100644 --- a/src/renderer/src/HtmlDebugPanel.tsx +++ b/src/renderer/src/features/debug/ui/HtmlDebugPanel.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' -import { sanitizeHtml } from './sanitizeHtml' +import { sanitizeHtml } from '../../../lib/sanitizeHtml' // HtmlDebugPanel — grabs the focused pane's `outerHTML` and shows // it as copy-pasteable text. Fourth in the debug-panel family diff --git a/src/renderer/src/ProxyDebugPanel.tsx b/src/renderer/src/features/debug/ui/ProxyDebugPanel.tsx similarity index 98% rename from src/renderer/src/ProxyDebugPanel.tsx rename to src/renderer/src/features/debug/ui/ProxyDebugPanel.tsx index 2ef7ada4b..3fae4974e 100644 --- a/src/renderer/src/ProxyDebugPanel.tsx +++ b/src/renderer/src/features/debug/ui/ProxyDebugPanel.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react' -import { useAppStore } from './state/hooks' -import { emptySemanticRuntime } from './tiles/workspaceState' +import { useAppStore } from '../../../state/hooks' +import { emptySemanticRuntime } from '../../../tiles/workspaceState' // ProxyDebugPanel — live inspector for the semantic stream and any // provider transport attribution that survives into the shared runtime. diff --git a/src/renderer/src/GitBar.tsx b/src/renderer/src/features/git/ui/GitBar.tsx similarity index 100% rename from src/renderer/src/GitBar.tsx rename to src/renderer/src/features/git/ui/GitBar.tsx diff --git a/src/renderer/src/git/GitRows.tsx b/src/renderer/src/features/git/ui/GitRows.tsx similarity index 98% rename from src/renderer/src/git/GitRows.tsx rename to src/renderer/src/features/git/ui/GitRows.tsx index d85c861e6..4c2290d15 100644 --- a/src/renderer/src/git/GitRows.tsx +++ b/src/renderer/src/features/git/ui/GitRows.tsx @@ -30,9 +30,9 @@ import { memo, useMemo, useState } from 'react' import hljs from 'highlight.js' -import { MarkerRow } from '../feed/Feed' -import { normalizeCodeLanguage } from '../../../shared/code/language' -import type { GitIntent } from '../../../shared/git/gitDetect' +import { MarkerRow } from '../../../feed/Feed' +import { normalizeCodeLanguage } from '../../../../../shared/code/language' +import type { GitIntent } from '../../../../../shared/git/gitDetect' import { parseGitCommit, parseGitLog, @@ -42,7 +42,7 @@ import { stripAnsi, type GitDiffFile, type GitDiffLine, -} from '../../../shared/git/gitParse' +} from '../../../../../shared/git/gitParse' // --------------------------------------------------------------------------- // Shared chrome diff --git a/src/renderer/src/features/reader/commands/readerCommands.ts b/src/renderer/src/features/reader/commands/readerCommands.ts index 022c49d2b..c2a9f77b5 100644 --- a/src/renderer/src/features/reader/commands/readerCommands.ts +++ b/src/renderer/src/features/reader/commands/readerCommands.ts @@ -1,4 +1,4 @@ -import type { CommandDef } from '../../../commands/types' +import type { CommandDef } from '../../command-palette/types' export const readerCommands: CommandDef[] = [ { diff --git a/src/renderer/src/features/reader/ui/ReaderView.tsx b/src/renderer/src/features/reader/ui/ReaderView.tsx index ebefa225c..782e0ab88 100644 --- a/src/renderer/src/features/reader/ui/ReaderView.tsx +++ b/src/renderer/src/features/reader/ui/ReaderView.tsx @@ -5,7 +5,7 @@ import remarkGfm from 'remark-gfm' import { CodeBlock } from '../../../code/CodeBlock' import { CodeRenderContext } from '../../../feed/Feed' import { extractAssistantInProgress } from '../../../../../shared/parsers/extractAssistant' -import { assistantUuidsWithText, extractAssistantByUuid } from '../../../copyAssistant' +import { assistantUuidsWithText, extractAssistantByUuid } from '../../../lib/copyAssistant' import { collectLeaves } from '../../../tiles/treeOps' import type { SessionId, Workspace } from '../../../tiles/workspaceStore' diff --git a/src/renderer/src/features/settings/commands/dangerousActions.ts b/src/renderer/src/features/settings/commands/dangerousActions.ts index bc09c7e84..304d1cfdc 100644 --- a/src/renderer/src/features/settings/commands/dangerousActions.ts +++ b/src/renderer/src/features/settings/commands/dangerousActions.ts @@ -1,4 +1,4 @@ -import type { CommandContext } from '../../../commands/types' +import type { CommandContext } from '../../command-palette/types' async function setDangerousAgentsAndReload( ctx: CommandContext, diff --git a/src/renderer/src/features/settings/commands/dangerousCommands.ts b/src/renderer/src/features/settings/commands/dangerousCommands.ts index fd2e617c8..5bbf52586 100644 --- a/src/renderer/src/features/settings/commands/dangerousCommands.ts +++ b/src/renderer/src/features/settings/commands/dangerousCommands.ts @@ -1,4 +1,4 @@ -import type { CommandDef } from '../../../commands/types' +import type { CommandDef } from '../../command-palette/types' import { toggleDangerousAgents } from './dangerousActions' export const dangerousCommands: CommandDef[] = [ diff --git a/src/renderer/src/features/settings/commands/settingsCommands.ts b/src/renderer/src/features/settings/commands/settingsCommands.ts index 63b3d710f..81132923c 100644 --- a/src/renderer/src/features/settings/commands/settingsCommands.ts +++ b/src/renderer/src/features/settings/commands/settingsCommands.ts @@ -1,4 +1,4 @@ -import type { CommandDef } from '../../../commands/types' +import type { CommandDef } from '../../command-palette/types' import { dangerousCommands } from './dangerousCommands' export const settingsCommands: CommandDef[] = [ diff --git a/src/renderer/src/features/spotlight/commands/spotlightCommands.ts b/src/renderer/src/features/spotlight/commands/spotlightCommands.ts index 1f6fd1c3c..3ad4bac49 100644 --- a/src/renderer/src/features/spotlight/commands/spotlightCommands.ts +++ b/src/renderer/src/features/spotlight/commands/spotlightCommands.ts @@ -1,4 +1,4 @@ -import type { CommandDef } from '../../../commands/types' +import type { CommandDef } from '../../command-palette/types' export const spotlightCommands: CommandDef[] = [ { diff --git a/src/renderer/src/features/tile-tabs/commands/tileTabsCommands.ts b/src/renderer/src/features/tile-tabs/commands/tileTabsCommands.ts index 1fde0d5c3..9a8f8664b 100644 --- a/src/renderer/src/features/tile-tabs/commands/tileTabsCommands.ts +++ b/src/renderer/src/features/tile-tabs/commands/tileTabsCommands.ts @@ -1,4 +1,4 @@ -import type { CommandDef } from '../../../commands/types' +import type { CommandDef } from '../../command-palette/types' export const tileTabsCommands: CommandDef[] = [ { diff --git a/src/renderer/src/features/workspace/commands/layoutCommands.ts b/src/renderer/src/features/workspace/commands/layoutCommands.ts index d686b345e..908d616c0 100644 --- a/src/renderer/src/features/workspace/commands/layoutCommands.ts +++ b/src/renderer/src/features/workspace/commands/layoutCommands.ts @@ -1,4 +1,4 @@ -import type { CommandDef } from '../../../commands/types' +import type { CommandDef } from '../../command-palette/types' export const layoutCommands: CommandDef[] = [ { diff --git a/src/renderer/src/features/workspace/commands/paneCommands.ts b/src/renderer/src/features/workspace/commands/paneCommands.ts index 624147873..42a2d0df3 100644 --- a/src/renderer/src/features/workspace/commands/paneCommands.ts +++ b/src/renderer/src/features/workspace/commands/paneCommands.ts @@ -1,5 +1,5 @@ -import { extractLastAssistantText } from '../../../copyAssistant' -import type { CommandDef } from '../../../commands/types' +import { extractLastAssistantText } from '../../../lib/copyAssistant' +import type { CommandDef } from '../../command-palette/types' export const paneCommands: CommandDef[] = [ { diff --git a/src/renderer/src/features/workspace/commands/sessionCommands.ts b/src/renderer/src/features/workspace/commands/sessionCommands.ts index bb6ad5e94..5a078d19e 100644 --- a/src/renderer/src/features/workspace/commands/sessionCommands.ts +++ b/src/renderer/src/features/workspace/commands/sessionCommands.ts @@ -1,4 +1,4 @@ -import type { CommandDef } from '../../../commands/types' +import type { CommandDef } from '../../command-palette/types' export const sessionCommands: CommandDef[] = [ { diff --git a/src/renderer/src/features/workspace/commands/tabCommands.ts b/src/renderer/src/features/workspace/commands/tabCommands.ts index 0ca2dfed4..62079dfd5 100644 --- a/src/renderer/src/features/workspace/commands/tabCommands.ts +++ b/src/renderer/src/features/workspace/commands/tabCommands.ts @@ -1,4 +1,4 @@ -import type { CommandDef } from '../../../commands/types' +import type { CommandDef } from '../../command-palette/types' export const tabCommands: CommandDef[] = [ { diff --git a/src/renderer/src/feed/Feed.tsx b/src/renderer/src/feed/Feed.tsx index 21e455cef..0ecc5c125 100644 --- a/src/renderer/src/feed/Feed.tsx +++ b/src/renderer/src/feed/Feed.tsx @@ -36,7 +36,7 @@ import { } from '../../../shared/types/transcript' import { CodeBlock } from '../code/CodeBlock' import { detectGitIntent } from '../../../shared/git/gitDetect' -import { GitCardRow } from '../git/GitRows' +import { GitCardRow } from '../features/git/ui/GitRows' import { useAppStore } from '../state/hooks' import { parseSemanticTodos, diff --git a/src/renderer/src/copyAssistant.ts b/src/renderer/src/lib/copyAssistant.ts similarity index 94% rename from src/renderer/src/copyAssistant.ts rename to src/renderer/src/lib/copyAssistant.ts index c57ea5bf7..b6eccd209 100644 --- a/src/renderer/src/copyAssistant.ts +++ b/src/renderer/src/lib/copyAssistant.ts @@ -17,7 +17,7 @@ // We walk the entries array backward so the first match is the most // recent assistant turn. Returns null if no assistant text is found. -import type { Entry } from '../../shared/types/transcript' +import type { Entry } from '../../../shared/types/transcript' export function extractLastAssistantText( entries: readonly Entry[], @@ -59,4 +59,4 @@ function extractClaude(entries: readonly Entry[]): string | null { export { extractAssistantByUuid, assistantUuidsWithText, -} from './features/copy-assistant/lib/extractAssistantByUuid' +} from '../features/copy-assistant/lib/extractAssistantByUuid' diff --git a/src/renderer/src/sanitizeHtml.ts b/src/renderer/src/lib/sanitizeHtml.ts similarity index 100% rename from src/renderer/src/sanitizeHtml.ts rename to src/renderer/src/lib/sanitizeHtml.ts diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index a67982459..251035d6d 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -1,7 +1,7 @@ import React from 'react' import { createRoot } from 'react-dom/client' import App from './App' -import { GlobalToastProvider } from './GlobalToast' +import { GlobalToastProvider } from './ui/GlobalToast' import './styles.css' import 'highlight.js/styles/github-dark.css' import 'monaco-editor/min/vs/editor/editor.main.css' diff --git a/src/renderer/src/tiles/TileLeaf.tsx b/src/renderer/src/tiles/TileLeaf.tsx index d0e9f295a..a3567f4f7 100644 --- a/src/renderer/src/tiles/TileLeaf.tsx +++ b/src/renderer/src/tiles/TileLeaf.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { extractAssistantInProgress } from '../../../shared/parsers/extractAssistant' import { CodexApprovalModal } from '../../../providers/codex/renderer/CodexApprovalModal' import { ResumePromptModal } from '../../../providers/claude/renderer/ResumePromptModal' -import { useGlobalToast } from '../GlobalToast' +import { useGlobalToast } from '../ui/GlobalToast' import { Feed } from '../feed/Feed' import type { ScrollInfo } from '../feed/Feed' import { TrustDialogModal } from '../../../providers/claude/renderer/TrustDialogModal' diff --git a/src/renderer/src/tiles/workspaceStore.ts b/src/renderer/src/tiles/workspaceStore.ts index 8fc7fd181..722cb17a5 100644 --- a/src/renderer/src/tiles/workspaceStore.ts +++ b/src/renderer/src/tiles/workspaceStore.ts @@ -44,11 +44,11 @@ import { reinsertPane, type ClosedEntry, } from '../lib/undoClose' -import { useGlobalToast } from '../GlobalToast' +import { useGlobalToast } from '../ui/GlobalToast' import { extractAssistantByUuid, assistantUuidsWithText, -} from '../copyAssistant' +} from '../lib/copyAssistant' import { useAppStore } from '../state/hooks' import { emptySemanticRuntime, diff --git a/src/renderer/src/GlobalToast.tsx b/src/renderer/src/ui/GlobalToast.tsx similarity index 100% rename from src/renderer/src/GlobalToast.tsx rename to src/renderer/src/ui/GlobalToast.tsx From 4d4808b4d931bed133484b1f29dc129a77e30bc1 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 23 Apr 2026 17:13:22 +0200 Subject: [PATCH 04/27] =?UTF-8?q?refactor(renderer):=20rename=20tiles/=20?= =?UTF-8?q?=E2=86=92=20workspace/=20with=20tile-tree/=20subdir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/main/ghostJournal.ts | 2 +- src/main/index.ts | 2 +- src/providers/claude/config.ts | 2 +- .../claude/renderer/SlashCommandPicker.tsx | 2 +- src/providers/codex/config.ts | 2 +- src/providers/registry.renderer.ts | 2 +- src/renderer/src/App.tsx | 10 +++---- .../src/features/command-palette/types.ts | 2 +- .../command-palette/ui/CommandPalette.tsx | 2 +- .../src/features/debug/ui/DebugPanel.tsx | 2 +- .../src/features/debug/ui/FeedDebugPanel.tsx | 2 +- .../src/features/debug/ui/ProxyDebugPanel.tsx | 2 +- .../src/features/reader/ui/ReaderView.tsx | 4 +-- .../features/settings/lib/settingsRegistry.ts | 2 +- .../src/features/settings/ui/SettingsPage.tsx | 2 +- .../features/spotlight/ui/SpotlightView.tsx | 6 ++--- .../features/tile-tabs/ui/TileTabsModal.tsx | 2 +- .../features/tile-tabs/ui/TileTabsView.tsx | 4 +-- .../workspace/lib/latestUserPrompts.ts | 2 +- .../workspace/lib/newAgentPlacement.ts | 4 +-- .../workspace/ui/AgentActivityModal.tsx | 6 ++--- .../workspace/ui/NewAgentPlacementOverlay.tsx | 4 +-- .../workspace/ui/PromptSearchModal.tsx | 2 +- .../workspace/ui/RewindToPromptModal.tsx | 4 +-- .../workspace/ui/ViewPromptsModal.tsx | 4 +-- src/renderer/src/feed/Feed.tsx | 8 +++--- src/renderer/src/feed/WorkIndicator.tsx | 2 +- src/renderer/src/feed/workIndicatorHints.ts | 2 +- src/renderer/src/lib/undoClose.ts | 4 +-- src/renderer/src/state/types.ts | 6 ++--- src/renderer/src/state/uiShell/types.ts | 2 +- src/renderer/src/state/workspace/slice.ts | 4 +-- .../{tiles => workspace}/PathPickerModal.tsx | 0 .../src/{tiles => workspace}/ghosts.ts | 0 .../src/{tiles => workspace}/mergedEntries.ts | 0 .../{tiles => workspace/tile-tree}/TabBar.tsx | 4 +-- .../tile-tree}/TerminalLeaf.tsx | 4 +-- .../tile-tree}/TileLeaf.tsx | 26 +++++++++---------- .../tile-tree}/TileTree.tsx | 6 ++--- .../tile-tree}/geometry.ts | 2 +- .../{tiles => workspace/tile-tree}/treeOps.ts | 2 +- .../tile-tree}/useKeybinds.ts | 4 +-- .../src/{tiles => workspace}/types.ts | 0 .../{tiles => workspace}/workspaceState.ts | 0 .../{tiles => workspace}/workspaceStore.ts | 4 +-- testing/ghost-demo/bridge.mts | 4 +-- testing/regression-claude-tool-result.ts | 4 +-- testing/rendering/README.md | 4 +-- .../renderer/RenderingHarnessApp.tsx | 4 +-- .../rendering/renderer/components/Feed.tsx | 2 +- 50 files changed, 88 insertions(+), 88 deletions(-) rename src/renderer/src/{tiles => workspace}/PathPickerModal.tsx (100%) rename src/renderer/src/{tiles => workspace}/ghosts.ts (100%) rename src/renderer/src/{tiles => workspace}/mergedEntries.ts (100%) rename src/renderer/src/{tiles => workspace/tile-tree}/TabBar.tsx (98%) rename src/renderer/src/{tiles => workspace/tile-tree}/TerminalLeaf.tsx (99%) rename src/renderer/src/{tiles => workspace/tile-tree}/TileLeaf.tsx (98%) rename src/renderer/src/{tiles => workspace/tile-tree}/TileTree.tsx (96%) rename src/renderer/src/{tiles => workspace/tile-tree}/geometry.ts (99%) rename src/renderer/src/{tiles => workspace/tile-tree}/treeOps.ts (99%) rename src/renderer/src/{tiles => workspace/tile-tree}/useKeybinds.ts (99%) rename src/renderer/src/{tiles => workspace}/types.ts (100%) rename src/renderer/src/{tiles => workspace}/workspaceState.ts (100%) rename src/renderer/src/{tiles => workspace}/workspaceStore.ts (99%) diff --git a/src/main/ghostJournal.ts b/src/main/ghostJournal.ts index 61a6ca61e..96cd7682f 100644 --- a/src/main/ghostJournal.ts +++ b/src/main/ghostJournal.ts @@ -1,7 +1,7 @@ // Disk writer for ghost entries. // // Ghost entries are minted, updated, superseded, and orphaned in the -// renderer (via agent-transcript-parser + `src/renderer/src/tiles/ghosts.ts`). +// renderer (via agent-transcript-parser + `src/renderer/src/workspace/ghosts.ts`). // This file handles the durable side: append each produced ghost to // a cc-shell-owned JSONL file so the live view survives a crash, a // window reload, or an Electron restart. diff --git a/src/main/index.ts b/src/main/index.ts index f76797ca9..9a6843fec 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -40,7 +40,7 @@ const lspManager = new LspManager() // Ghost log writer — one queue per session. Writes are batched at // 100 ms and persisted under `/ghost-logs/.ghost.jsonl`. // See `./ghostJournal.ts` for the full rationale; see -// `src/renderer/src/tiles/ghosts.ts` for the renderer side. +// `src/renderer/src/workspace/ghosts.ts` for the renderer side. const ghostJournals = new GhostJournalRegistry() // SessionManager is constructed inside whenReady so we can await diff --git a/src/providers/claude/config.ts b/src/providers/claude/config.ts index 542657daf..8ea598976 100644 --- a/src/providers/claude/config.ts +++ b/src/providers/claude/config.ts @@ -14,7 +14,7 @@ import { extractAssistantInProgress } from './parsers/streamingScreen' // For now, re-use the shared TileLeaf which already handles Claude. // When we create a Claude-specific TileLeaf, this import changes to // point at ./renderer/TileLeaf instead. -import { TileLeaf } from '../../renderer/src/tiles/TileLeaf' +import { TileLeaf } from '../../renderer/src/workspace/tile-tree/TileLeaf' export const claudeConfig: ProviderConfig = { id: 'claude', diff --git a/src/providers/claude/renderer/SlashCommandPicker.tsx b/src/providers/claude/renderer/SlashCommandPicker.tsx index 7c46f94c8..a42c3a8a7 100644 --- a/src/providers/claude/renderer/SlashCommandPicker.tsx +++ b/src/providers/claude/renderer/SlashCommandPicker.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react' -import type { SlashPickerState } from '../../../renderer/src/tiles/workspaceStore' +import type { SlashPickerState } from '../../../renderer/src/workspace/workspaceStore' // SlashCommandPicker — React rendering of the slash command dropdown. // diff --git a/src/providers/codex/config.ts b/src/providers/codex/config.ts index 7471efb75..ce2e4c57a 100644 --- a/src/providers/codex/config.ts +++ b/src/providers/codex/config.ts @@ -8,7 +8,7 @@ import { extractCodexAssistantInProgress } from './parsers/streamingScreen' // For now, re-use the shared TileLeaf which already handles Codex. // When we create a Codex-specific TileLeaf, this import changes. -import { TileLeaf } from '../../renderer/src/tiles/TileLeaf' +import { TileLeaf } from '../../renderer/src/workspace/tile-tree/TileLeaf' export const codexConfig: ProviderConfig = { id: 'codex', diff --git a/src/providers/registry.renderer.ts b/src/providers/registry.renderer.ts index acbddaa7b..c504dcbad 100644 --- a/src/providers/registry.renderer.ts +++ b/src/providers/registry.renderer.ts @@ -11,7 +11,7 @@ import type { RendererProviderConfig } from '../shared/types/providerConfig' // no DOM). Direct imports keep the renderer bundle browser-safe. import { extractAssistantInProgress as claudeExtract } from '../shared/parsers/claudeScreen' import { extractCodexAssistantInProgress as codexExtract } from '../shared/parsers/codexScreen' -import { TileLeaf } from '../renderer/src/tiles/TileLeaf' +import { TileLeaf } from '../renderer/src/workspace/tile-tree/TileLeaf' import type { TileLeafProps } from '../shared/types/providerConfig' const claudeRenderer: RendererProviderConfig = { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 42a949a24..f9e3de015 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -18,13 +18,13 @@ import { RewindToPromptModal } from './features/workspace/ui/RewindToPromptModal import { ViewPromptsModal } from './features/workspace/ui/ViewPromptsModal' import { GitBar } from './features/git/ui/GitBar' import { AppearanceMenu } from './feed/AppearanceMenu' -import { PathPickerModal } from './tiles/PathPickerModal' -import { TabBar } from './tiles/TabBar' -import { TileTree } from './tiles/TileTree' +import { PathPickerModal } from './workspace/PathPickerModal' +import { TabBar } from './workspace/tile-tree/TabBar' +import { TileTree } from './workspace/tile-tree/TileTree' import { useAppStore } from './state/hooks' import { applyTheme } from './state/settings/theme' -import { useKeybinds } from './tiles/useKeybinds' -import { useWorkspace } from './tiles/workspaceStore' +import { useKeybinds } from './workspace/tile-tree/useKeybinds' +import { useWorkspace } from './workspace/workspaceStore' // App — thin shell around the workspace hook. // diff --git a/src/renderer/src/features/command-palette/types.ts b/src/renderer/src/features/command-palette/types.ts index a2d816206..1561453ed 100644 --- a/src/renderer/src/features/command-palette/types.ts +++ b/src/renderer/src/features/command-palette/types.ts @@ -1,4 +1,4 @@ -import type { Workspace } from '../../tiles/workspaceStore' +import type { Workspace } from '../../workspace/workspaceStore' export type CommandState = { label: string diff --git a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx index 255134b78..dc0e1d8ae 100644 --- a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx +++ b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { buildCommandRegistry } from '../registry' import type { CommandContext, ResolvedCommand } from '../types' -import type { Workspace } from '../../../tiles/workspaceStore' +import type { Workspace } from '../../../workspace/workspaceStore' // CommandPalette — VS Code-style ⌘⇧P command menu. // diff --git a/src/renderer/src/features/debug/ui/DebugPanel.tsx b/src/renderer/src/features/debug/ui/DebugPanel.tsx index 53dd173e8..6ee64fb86 100644 --- a/src/renderer/src/features/debug/ui/DebugPanel.tsx +++ b/src/renderer/src/features/debug/ui/DebugPanel.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react' -import type { SessionRuntime } from '../../../tiles/workspaceStore' +import type { SessionRuntime } from '../../../workspace/workspaceStore' import type { Entry } from '../../../../../shared/types/transcript' // DebugPanel — inline diagnostic overlay showing the raw state of the diff --git a/src/renderer/src/features/debug/ui/FeedDebugPanel.tsx b/src/renderer/src/features/debug/ui/FeedDebugPanel.tsx index f4e88f87f..cb697774f 100644 --- a/src/renderer/src/features/debug/ui/FeedDebugPanel.tsx +++ b/src/renderer/src/features/debug/ui/FeedDebugPanel.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { FeedDebugEntry, FeedDebugLayer, SessionRuntime } from '../../../tiles/workspaceState' +import type { FeedDebugEntry, FeedDebugLayer, SessionRuntime } from '../../../workspace/workspaceState' type Props = { sessionId: string diff --git a/src/renderer/src/features/debug/ui/ProxyDebugPanel.tsx b/src/renderer/src/features/debug/ui/ProxyDebugPanel.tsx index 3fae4974e..9f22a78e9 100644 --- a/src/renderer/src/features/debug/ui/ProxyDebugPanel.tsx +++ b/src/renderer/src/features/debug/ui/ProxyDebugPanel.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react' import { useAppStore } from '../../../state/hooks' -import { emptySemanticRuntime } from '../../../tiles/workspaceState' +import { emptySemanticRuntime } from '../../../workspace/workspaceState' // ProxyDebugPanel — live inspector for the semantic stream and any // provider transport attribution that survives into the shared runtime. diff --git a/src/renderer/src/features/reader/ui/ReaderView.tsx b/src/renderer/src/features/reader/ui/ReaderView.tsx index 782e0ab88..839b82e7c 100644 --- a/src/renderer/src/features/reader/ui/ReaderView.tsx +++ b/src/renderer/src/features/reader/ui/ReaderView.tsx @@ -6,8 +6,8 @@ import { CodeBlock } from '../../../code/CodeBlock' import { CodeRenderContext } from '../../../feed/Feed' import { extractAssistantInProgress } from '../../../../../shared/parsers/extractAssistant' import { assistantUuidsWithText, extractAssistantByUuid } from '../../../lib/copyAssistant' -import { collectLeaves } from '../../../tiles/treeOps' -import type { SessionId, Workspace } from '../../../tiles/workspaceStore' +import { collectLeaves } from '../../../workspace/tile-tree/treeOps' +import type { SessionId, Workspace } from '../../../workspace/workspaceStore' // ReaderView — single-message read mode for a focused session. // diff --git a/src/renderer/src/features/settings/lib/settingsRegistry.ts b/src/renderer/src/features/settings/lib/settingsRegistry.ts index 5fd75c5dc..35a3a88d7 100644 --- a/src/renderer/src/features/settings/lib/settingsRegistry.ts +++ b/src/renderer/src/features/settings/lib/settingsRegistry.ts @@ -5,7 +5,7 @@ import { type Settings, type ThemeMode, } from '../../../state/settings/types' -import type { Workspace } from '../../../tiles/workspaceStore' +import type { Workspace } from '../../../workspace/workspaceStore' import { SETTING_CATEGORIES, type SettingCategoryId } from './settingsCategories' export type SettingActionContext = { diff --git a/src/renderer/src/features/settings/ui/SettingsPage.tsx b/src/renderer/src/features/settings/ui/SettingsPage.tsx index 0672c22f2..13ba3b0c9 100644 --- a/src/renderer/src/features/settings/ui/SettingsPage.tsx +++ b/src/renderer/src/features/settings/ui/SettingsPage.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from 'react' import type { Settings } from '../../../state/settings/types' -import type { Workspace } from '../../../tiles/workspaceStore' +import type { Workspace } from '../../../workspace/workspaceStore' import { SETTING_CATEGORIES, type SettingCategoryId } from '../lib/settingsCategories' import { getSettingsRegistry, matchesSettingQuery } from '../lib/settingsRegistry' import { SettingsList } from './SettingsList' diff --git a/src/renderer/src/features/spotlight/ui/SpotlightView.tsx b/src/renderer/src/features/spotlight/ui/SpotlightView.tsx index 9e59c7ca1..2276f30d8 100644 --- a/src/renderer/src/features/spotlight/ui/SpotlightView.tsx +++ b/src/renderer/src/features/spotlight/ui/SpotlightView.tsx @@ -1,6 +1,6 @@ -import { renderWorkspaceLeaf } from '../../../tiles/TileTree' -import { collectLeaves } from '../../../tiles/treeOps' -import type { Workspace } from '../../../tiles/workspaceStore' +import { renderWorkspaceLeaf } from '../../../workspace/tile-tree/TileTree' +import { collectLeaves } from '../../../workspace/tile-tree/treeOps' +import type { Workspace } from '../../../workspace/workspaceStore' type Props = { workspace: Workspace diff --git a/src/renderer/src/features/tile-tabs/ui/TileTabsModal.tsx b/src/renderer/src/features/tile-tabs/ui/TileTabsModal.tsx index e014589aa..fc4aca90a 100644 --- a/src/renderer/src/features/tile-tabs/ui/TileTabsModal.tsx +++ b/src/renderer/src/features/tile-tabs/ui/TileTabsModal.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import type { TabId } from '../../../tiles/types' +import type { TabId } from '../../../workspace/types' type TileTabOption = { id: TabId diff --git a/src/renderer/src/features/tile-tabs/ui/TileTabsView.tsx b/src/renderer/src/features/tile-tabs/ui/TileTabsView.tsx index 0c05ff601..578b2036b 100644 --- a/src/renderer/src/features/tile-tabs/ui/TileTabsView.tsx +++ b/src/renderer/src/features/tile-tabs/ui/TileTabsView.tsx @@ -1,7 +1,7 @@ import { Fragment, useEffect, useRef } from 'react' -import { TileTree } from '../../../tiles/TileTree' -import type { Workspace } from '../../../tiles/workspaceStore' +import { TileTree } from '../../../workspace/tile-tree/TileTree' +import type { Workspace } from '../../../workspace/workspaceStore' type Props = { workspace: Workspace diff --git a/src/renderer/src/features/workspace/lib/latestUserPrompts.ts b/src/renderer/src/features/workspace/lib/latestUserPrompts.ts index f3e883079..4fc68d7b9 100644 --- a/src/renderer/src/features/workspace/lib/latestUserPrompts.ts +++ b/src/renderer/src/features/workspace/lib/latestUserPrompts.ts @@ -3,7 +3,7 @@ import { isConversationEntry, type Entry, } from '../../../../../shared/types/transcript' -import type { SessionKind } from '../../../tiles/types' +import type { SessionKind } from '../../../workspace/types' export type LatestUserPrompt = { text: string diff --git a/src/renderer/src/features/workspace/lib/newAgentPlacement.ts b/src/renderer/src/features/workspace/lib/newAgentPlacement.ts index c25964ea0..7cc20c0d6 100644 --- a/src/renderer/src/features/workspace/lib/newAgentPlacement.ts +++ b/src/renderer/src/features/workspace/lib/newAgentPlacement.ts @@ -1,5 +1,5 @@ -import { buildLeafGeometries, sliceRect, type Rect } from '../../../tiles/geometry' -import type { SessionId, SplitDirection, TileNode } from '../../../tiles/types' +import { buildLeafGeometries, sliceRect, type Rect } from '../../../workspace/tile-tree/geometry' +import type { SessionId, SplitDirection, TileNode } from '../../../workspace/types' export type PlacementTarget = | { diff --git a/src/renderer/src/features/workspace/ui/AgentActivityModal.tsx b/src/renderer/src/features/workspace/ui/AgentActivityModal.tsx index c400ec43d..f413104ca 100644 --- a/src/renderer/src/features/workspace/ui/AgentActivityModal.tsx +++ b/src/renderer/src/features/workspace/ui/AgentActivityModal.tsx @@ -1,9 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '../../../state/hooks' -import { collectLeaves } from '../../../tiles/treeOps' -import type { SessionId, Tab } from '../../../tiles/types' -import type { Workspace } from '../../../tiles/workspaceStore' +import { collectLeaves } from '../../../workspace/tile-tree/treeOps' +import type { SessionId, Tab } from '../../../workspace/types' +import type { Workspace } from '../../../workspace/workspaceStore' import type { Entry } from '../../../../../shared/types/transcript' // AgentActivityModal — overview of every visible pane grouped by tab diff --git a/src/renderer/src/features/workspace/ui/NewAgentPlacementOverlay.tsx b/src/renderer/src/features/workspace/ui/NewAgentPlacementOverlay.tsx index 626725ff1..6f20f10a2 100644 --- a/src/renderer/src/features/workspace/ui/NewAgentPlacementOverlay.tsx +++ b/src/renderer/src/features/workspace/ui/NewAgentPlacementOverlay.tsx @@ -6,8 +6,8 @@ import { findNearestPlacementTarget, type PlacementTarget, } from '../lib/newAgentPlacement' -import type { SessionKind } from '../../../tiles/types' -import type { Workspace } from '../../../tiles/workspaceStore' +import type { SessionKind } from '../../../workspace/types' +import type { Workspace } from '../../../workspace/workspaceStore' type Props = { open: boolean diff --git a/src/renderer/src/features/workspace/ui/PromptSearchModal.tsx b/src/renderer/src/features/workspace/ui/PromptSearchModal.tsx index 3f8b6d4fe..0c2f3630d 100644 --- a/src/renderer/src/features/workspace/ui/PromptSearchModal.tsx +++ b/src/renderer/src/features/workspace/ui/PromptSearchModal.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { SessionIndexEntry, SessionIndexPrompt } from '../../../../../preload' -import type { Workspace } from '../../../tiles/workspaceStore' +import type { Workspace } from '../../../workspace/workspaceStore' // PromptSearchModal — cross-session prompt search. // diff --git a/src/renderer/src/features/workspace/ui/RewindToPromptModal.tsx b/src/renderer/src/features/workspace/ui/RewindToPromptModal.tsx index e3877abfc..2c59a9afa 100644 --- a/src/renderer/src/features/workspace/ui/RewindToPromptModal.tsx +++ b/src/renderer/src/features/workspace/ui/RewindToPromptModal.tsx @@ -1,8 +1,8 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { extractAnchoredUserPrompts } from '../lib/latestUserPrompts' -import type { Workspace } from '../../../tiles/workspaceStore' -import type { SessionId } from '../../../tiles/types' +import type { Workspace } from '../../../workspace/workspaceStore' +import type { SessionId } from '../../../workspace/types' // RewindToPromptModal — picker for the rewind-to-prompt flow. // diff --git a/src/renderer/src/features/workspace/ui/ViewPromptsModal.tsx b/src/renderer/src/features/workspace/ui/ViewPromptsModal.tsx index 091f59e69..d3b08e9ad 100644 --- a/src/renderer/src/features/workspace/ui/ViewPromptsModal.tsx +++ b/src/renderer/src/features/workspace/ui/ViewPromptsModal.tsx @@ -1,8 +1,8 @@ import { useEffect, useMemo, useRef } from 'react' import { extractLatestUserPrompts } from '../lib/latestUserPrompts' -import type { Workspace } from '../../../tiles/workspaceStore' -import type { SessionId } from '../../../tiles/types' +import type { Workspace } from '../../../workspace/workspaceStore' +import type { SessionId } from '../../../workspace/types' type Props = { open: boolean diff --git a/src/renderer/src/feed/Feed.tsx b/src/renderer/src/feed/Feed.tsx index 0ecc5c125..c163d8257 100644 --- a/src/renderer/src/feed/Feed.tsx +++ b/src/renderer/src/feed/Feed.tsx @@ -43,7 +43,7 @@ import { type SemanticLiveTurn, type SemanticTodoItem, type StreamPhase, -} from '../tiles/workspaceState' +} from '../workspace/workspaceState' import { WorkIndicator } from './WorkIndicator' import { toolHintFromTurn } from './workIndicatorHints' @@ -421,12 +421,12 @@ function debugLabelForEntry(entry: Entry): string { // live owner. // // That duplicate class is now prevented at its source. The ghost -// reducer (`reconcileUpstream` in src/renderer/src/tiles/ghosts.ts) +// reducer (`reconcileUpstream` in src/renderer/src/workspace/ghosts.ts) // supersedes Codex text ghosts by rollout response id once the // rollout mapper stamps `codexTurnId` on committed entries -// (src/renderer/src/tiles/workspaceStore.ts::codexTurnIdFromRollout +// (src/renderer/src/workspace/workspaceStore.ts::codexTurnIdFromRollout // + stampCodexTurnId). The live view and the merged feed are split -// by turn ownership (src/renderer/src/tiles/mergedEntries.ts), so +// by turn ownership (src/renderer/src/workspace/mergedEntries.ts), so // there is no longer any path by which the same assistant text can // reach both surfaces at once. // diff --git a/src/renderer/src/feed/WorkIndicator.tsx b/src/renderer/src/feed/WorkIndicator.tsx index ccd6229b5..9f5c0e87b 100644 --- a/src/renderer/src/feed/WorkIndicator.tsx +++ b/src/renderer/src/feed/WorkIndicator.tsx @@ -26,7 +26,7 @@ import { memo, useEffect, useState } from 'react' import { MarkerRow } from './Feed' -import type { StreamPhase } from '../tiles/workspaceState' +import type { StreamPhase } from '../workspace/workspaceState' type Props = { phase: StreamPhase diff --git a/src/renderer/src/feed/workIndicatorHints.ts b/src/renderer/src/feed/workIndicatorHints.ts index c0fa060f5..5d69b5869 100644 --- a/src/renderer/src/feed/workIndicatorHints.ts +++ b/src/renderer/src/feed/workIndicatorHints.ts @@ -9,7 +9,7 @@ // visual tests trivial and keeps the component memo-friendly (it only // re-renders when the hint string changes, not on every block delta). -import type { SemanticLiveBlock, SemanticLiveTurn } from '../tiles/workspaceState' +import type { SemanticLiveBlock, SemanticLiveTurn } from '../workspace/workspaceState' /** Pull a short, single-line hint for the pending tool call. When the * tool's parsed input is available (finalised JSON from diff --git a/src/renderer/src/lib/undoClose.ts b/src/renderer/src/lib/undoClose.ts index f161aa5ee..f68f11c55 100644 --- a/src/renderer/src/lib/undoClose.ts +++ b/src/renderer/src/lib/undoClose.ts @@ -4,8 +4,8 @@ import type { SplitDirection, Tab, TileNode, -} from '../tiles/types' -import { collectLeaves } from '../tiles/treeOps' +} from '../workspace/types' +import { collectLeaves } from '../workspace/tile-tree/treeOps' // Undo-close stack — captures enough state to restore a closed pane or // tab exactly where it was in the tile tree. diff --git a/src/renderer/src/state/types.ts b/src/renderer/src/state/types.ts index 69b5ab071..d29ef5400 100644 --- a/src/renderer/src/state/types.ts +++ b/src/renderer/src/state/types.ts @@ -1,13 +1,13 @@ import type { Settings } from './settings/types' import type { UiShellState } from './uiShell/types' -import type { SessionId, TabId } from '../tiles/types' -import type { WorkspaceState } from '../tiles/types' +import type { SessionId, TabId } from '../workspace/types' +import type { WorkspaceState } from '../workspace/types' import type { ReaderModeState, SessionRuntime, SpotlightState, TileTabsState, -} from '../tiles/workspaceState' +} from '../workspace/workspaceState' export type SettingsSlice = { settings: Settings diff --git a/src/renderer/src/state/uiShell/types.ts b/src/renderer/src/state/uiShell/types.ts index 1d1712c8a..8640b3894 100644 --- a/src/renderer/src/state/uiShell/types.ts +++ b/src/renderer/src/state/uiShell/types.ts @@ -1,4 +1,4 @@ -import type { TabId, SessionId } from '../../tiles/types' +import type { TabId, SessionId } from '../../workspace/types' export type UiShellState = { commandPaletteOpen: boolean diff --git a/src/renderer/src/state/workspace/slice.ts b/src/renderer/src/state/workspace/slice.ts index 1857d0552..47c4b6d7c 100644 --- a/src/renderer/src/state/workspace/slice.ts +++ b/src/renderer/src/state/workspace/slice.ts @@ -1,13 +1,13 @@ import type { StateCreator } from 'zustand' import type { AppStore, WorkspaceSlice } from '../types' -import type { WorkspaceState } from '../../tiles/types' +import type { WorkspaceState } from '../../workspace/types' import type { ReaderModeState, SessionRuntime, SpotlightState, TileTabsState, -} from '../../tiles/workspaceState' +} from '../../workspace/workspaceState' function applyUpdater(prev: T, next: T | ((prev: T) => T)): T { return typeof next === 'function' diff --git a/src/renderer/src/tiles/PathPickerModal.tsx b/src/renderer/src/workspace/PathPickerModal.tsx similarity index 100% rename from src/renderer/src/tiles/PathPickerModal.tsx rename to src/renderer/src/workspace/PathPickerModal.tsx diff --git a/src/renderer/src/tiles/ghosts.ts b/src/renderer/src/workspace/ghosts.ts similarity index 100% rename from src/renderer/src/tiles/ghosts.ts rename to src/renderer/src/workspace/ghosts.ts diff --git a/src/renderer/src/tiles/mergedEntries.ts b/src/renderer/src/workspace/mergedEntries.ts similarity index 100% rename from src/renderer/src/tiles/mergedEntries.ts rename to src/renderer/src/workspace/mergedEntries.ts diff --git a/src/renderer/src/tiles/TabBar.tsx b/src/renderer/src/workspace/tile-tree/TabBar.tsx similarity index 98% rename from src/renderer/src/tiles/TabBar.tsx rename to src/renderer/src/workspace/tile-tree/TabBar.tsx index 41b203801..3e4d472a7 100644 --- a/src/renderer/src/tiles/TabBar.tsx +++ b/src/renderer/src/workspace/tile-tree/TabBar.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' -import type { TileNode } from './types' -import type { Workspace } from './workspaceStore' +import type { TileNode } from '../types' +import type { Workspace } from '../workspaceStore' // TabBar — one row of tab chrome at the top of the window. Each tab has // a title, a close button, and activates on click. The `+` button opens diff --git a/src/renderer/src/tiles/TerminalLeaf.tsx b/src/renderer/src/workspace/tile-tree/TerminalLeaf.tsx similarity index 99% rename from src/renderer/src/tiles/TerminalLeaf.tsx rename to src/renderer/src/workspace/tile-tree/TerminalLeaf.tsx index ce7c5cf08..56fcf4172 100644 --- a/src/renderer/src/tiles/TerminalLeaf.tsx +++ b/src/renderer/src/workspace/tile-tree/TerminalLeaf.tsx @@ -2,8 +2,8 @@ import { useEffect, useRef } from 'react' import { Terminal } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' -import type { SessionId } from './types' -import type { Workspace } from './workspaceStore' +import type { SessionId } from '../types' +import type { Workspace } from '../workspaceStore' // TerminalLeaf — one pane that hosts a plain shell session. // diff --git a/src/renderer/src/tiles/TileLeaf.tsx b/src/renderer/src/workspace/tile-tree/TileLeaf.tsx similarity index 98% rename from src/renderer/src/tiles/TileLeaf.tsx rename to src/renderer/src/workspace/tile-tree/TileLeaf.tsx index a3567f4f7..9fccd8475 100644 --- a/src/renderer/src/tiles/TileLeaf.tsx +++ b/src/renderer/src/workspace/tile-tree/TileLeaf.tsx @@ -1,21 +1,21 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { extractAssistantInProgress } from '../../../shared/parsers/extractAssistant' -import { CodexApprovalModal } from '../../../providers/codex/renderer/CodexApprovalModal' -import { ResumePromptModal } from '../../../providers/claude/renderer/ResumePromptModal' -import { useGlobalToast } from '../ui/GlobalToast' -import { Feed } from '../feed/Feed' -import type { ScrollInfo } from '../feed/Feed' -import { TrustDialogModal } from '../../../providers/claude/renderer/TrustDialogModal' -import { SlashCommandPicker } from '../../../providers/claude/renderer/SlashCommandPicker' -import { extractLatestUserPrompts } from '../features/workspace/lib/latestUserPrompts' -import type { SessionRuntime, Workspace } from './workspaceStore' +import { extractAssistantInProgress } from '../../../../shared/parsers/extractAssistant' +import { CodexApprovalModal } from '../../../../providers/codex/renderer/CodexApprovalModal' +import { ResumePromptModal } from '../../../../providers/claude/renderer/ResumePromptModal' +import { useGlobalToast } from '../../ui/GlobalToast' +import { Feed } from '../../feed/Feed' +import type { ScrollInfo } from '../../feed/Feed' +import { TrustDialogModal } from '../../../../providers/claude/renderer/TrustDialogModal' +import { SlashCommandPicker } from '../../../../providers/claude/renderer/SlashCommandPicker' +import { extractLatestUserPrompts } from '../../features/workspace/lib/latestUserPrompts' +import type { SessionRuntime, Workspace } from '../workspaceStore' import { selectMergedEntries, shouldShowSemanticStreaming, -} from './mergedEntries' -import type { SessionId, SessionKind } from './types' -import type { ClaudeDraftImage } from './workspaceState' +} from '../mergedEntries' +import type { SessionId, SessionKind } from '../types' +import type { ClaudeDraftImage } from '../workspaceState' // Claude's TUI has its own paste-state machine. Large input and bracketed // paste do NOT go straight from "bytes arrived" to "submit immediately" — diff --git a/src/renderer/src/tiles/TileTree.tsx b/src/renderer/src/workspace/tile-tree/TileTree.tsx similarity index 96% rename from src/renderer/src/tiles/TileTree.tsx rename to src/renderer/src/workspace/tile-tree/TileTree.tsx index 5e632e3ef..1c910a0b8 100644 --- a/src/renderer/src/tiles/TileTree.tsx +++ b/src/renderer/src/workspace/tile-tree/TileTree.tsx @@ -1,9 +1,9 @@ import { useCallback, useRef } from 'react' -import { getRendererProvider } from '../../../providers/registry.renderer' +import { getRendererProvider } from '../../../../providers/registry.renderer' import { TerminalLeaf } from './TerminalLeaf' -import type { Workspace } from './workspaceStore' -import type { SessionId, TabId, TileNode } from './types' +import type { Workspace } from '../workspaceStore' +import type { SessionId, TabId, TileNode } from '../types' import { collectLeaves } from './treeOps' // TileTree — recursive renderer for a tab's binary-split tree. diff --git a/src/renderer/src/tiles/geometry.ts b/src/renderer/src/workspace/tile-tree/geometry.ts similarity index 99% rename from src/renderer/src/tiles/geometry.ts rename to src/renderer/src/workspace/tile-tree/geometry.ts index 8aa10e31d..7390f1c2d 100644 --- a/src/renderer/src/tiles/geometry.ts +++ b/src/renderer/src/workspace/tile-tree/geometry.ts @@ -1,4 +1,4 @@ -import type { SessionId, SplitDirection, TileNode } from './types' +import type { SessionId, SplitDirection, TileNode } from '../types' export type Rect = { x: number diff --git a/src/renderer/src/tiles/treeOps.ts b/src/renderer/src/workspace/tile-tree/treeOps.ts similarity index 99% rename from src/renderer/src/tiles/treeOps.ts rename to src/renderer/src/workspace/tile-tree/treeOps.ts index 78c1dde61..d2f9aec36 100644 --- a/src/renderer/src/tiles/treeOps.ts +++ b/src/renderer/src/workspace/tile-tree/treeOps.ts @@ -16,7 +16,7 @@ import { type SessionId, type SplitDirection, type TileNode, -} from './types' +} from '../types' /** * Walk a tree and return every leaf's sessionId in depth-first order. diff --git a/src/renderer/src/tiles/useKeybinds.ts b/src/renderer/src/workspace/tile-tree/useKeybinds.ts similarity index 99% rename from src/renderer/src/tiles/useKeybinds.ts rename to src/renderer/src/workspace/tile-tree/useKeybinds.ts index 01686b677..2fb1c3e03 100644 --- a/src/renderer/src/tiles/useKeybinds.ts +++ b/src/renderer/src/workspace/tile-tree/useKeybinds.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react' -import { useAppStore } from '../state/hooks' -import type { Workspace } from './workspaceStore' +import { useAppStore } from '../../state/hooks' +import type { Workspace } from '../workspaceStore' // Keybinds: global window-level listeners. The handler is attached to // `document` in a single useEffect, which captures the key BEFORE the diff --git a/src/renderer/src/tiles/types.ts b/src/renderer/src/workspace/types.ts similarity index 100% rename from src/renderer/src/tiles/types.ts rename to src/renderer/src/workspace/types.ts diff --git a/src/renderer/src/tiles/workspaceState.ts b/src/renderer/src/workspace/workspaceState.ts similarity index 100% rename from src/renderer/src/tiles/workspaceState.ts rename to src/renderer/src/workspace/workspaceState.ts diff --git a/src/renderer/src/tiles/workspaceStore.ts b/src/renderer/src/workspace/workspaceStore.ts similarity index 99% rename from src/renderer/src/tiles/workspaceStore.ts rename to src/renderer/src/workspace/workspaceStore.ts index 722cb17a5..b870a3199 100644 --- a/src/renderer/src/tiles/workspaceStore.ts +++ b/src/renderer/src/workspace/workspaceStore.ts @@ -36,8 +36,8 @@ import { rotateTree, splitLeaf, wrapRootWithLeaf, -} from './treeOps' -import { findBestRemainingFocus, findDirectionalNeighbor } from './geometry' +} from './tile-tree/treeOps' +import { findBestRemainingFocus, findDirectionalNeighbor } from './tile-tree/geometry' import { UndoCloseStack, findParentSplitInfo, diff --git a/testing/ghost-demo/bridge.mts b/testing/ghost-demo/bridge.mts index 7d58aad2d..1c30fcd58 100644 --- a/testing/ghost-demo/bridge.mts +++ b/testing/ghost-demo/bridge.mts @@ -27,11 +27,11 @@ import { ghostsToPersist, orphanStale, reconcileUpstream, -} from '../../src/renderer/src/tiles/ghosts.js' +} from '../../src/renderer/src/workspace/ghosts.js' import type { SemanticLiveTurn, SemanticLiveBlock, -} from '../../src/renderer/src/tiles/workspaceState.js' +} from '../../src/renderer/src/workspace/workspaceState.js' import type { Entry } from '../../src/shared/types/transcript.js' let failed = 0 diff --git a/testing/regression-claude-tool-result.ts b/testing/regression-claude-tool-result.ts index 826e74193..12bb4e5c6 100644 --- a/testing/regression-claude-tool-result.ts +++ b/testing/regression-claude-tool-result.ts @@ -1,7 +1,7 @@ #!/usr/bin/env tsx import assert from 'node:assert/strict' -import type { SemanticRuntimeState } from '../src/renderer/src/tiles/workspaceState.ts' +import type { SemanticRuntimeState } from '../src/renderer/src/workspace/workspaceState.ts' function baseState(): SemanticRuntimeState { const state: SemanticRuntimeState = { @@ -84,7 +84,7 @@ async function main(): Promise { api: {}, }, }) - const { foldSemanticEvent } = await import('../src/renderer/src/tiles/workspaceStore.ts') + const { foldSemanticEvent } = await import('../src/renderer/src/workspace/workspaceStore.ts') const next = foldSemanticEvent( baseState(), diff --git a/testing/rendering/README.md b/testing/rendering/README.md index 1a107fa28..e3a8128ce 100644 --- a/testing/rendering/README.md +++ b/testing/rendering/README.md @@ -28,7 +28,7 @@ every stream visible at once. | 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/tiles/workspaceStore.ts` (pure exports)| 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 | @@ -167,7 +167,7 @@ are pure additions — no existing cc-shell 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/tiles/workspaceStore.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 cc-shell — never duplicate the implementation diff --git a/testing/rendering/renderer/RenderingHarnessApp.tsx b/testing/rendering/renderer/RenderingHarnessApp.tsx index b55c44912..a40db004a 100644 --- a/testing/rendering/renderer/RenderingHarnessApp.tsx +++ b/testing/rendering/renderer/RenderingHarnessApp.tsx @@ -13,12 +13,12 @@ import { extractEmbeddedClaudeProgressEntry, foldSemanticEvent, mapCodexRolloutToFeedEntries, -} from '../../../src/renderer/src/tiles/workspaceStore' +} from '../../../src/renderer/src/workspace/workspaceStore' import { emptySemanticRuntime, type SemanticLiveTurn, type SemanticRuntimeState, -} from '../../../src/renderer/src/tiles/workspaceState' +} from '../../../src/renderer/src/workspace/workspaceState' import { isCompactBoundaryEntry, isCompactSummaryEntry, diff --git a/testing/rendering/renderer/components/Feed.tsx b/testing/rendering/renderer/components/Feed.tsx index 856282976..a0812f466 100644 --- a/testing/rendering/renderer/components/Feed.tsx +++ b/testing/rendering/renderer/components/Feed.tsx @@ -42,7 +42,7 @@ import { parseSemanticTodos, type SemanticLiveTurn, type SemanticTodoItem, -} from '../../../../src/renderer/src/tiles/workspaceState' +} from '../../../../src/renderer/src/workspace/workspaceState' // ----------------------------------------------------------------------------- // Feed — Claude Code TUI-style inline rendering. From cdf0baaa26d7aa1289bdd5a7f83884de0023303b Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 23 Apr 2026 17:41:00 +0200 Subject: [PATCH 05/27] refactor(workspace): extract pure modules out of workspaceStore god-file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/renderer/src/workspace/claude/history.ts | 74 + src/renderer/src/workspace/codex/entries.ts | 174 ++ src/renderer/src/workspace/codex/rollout.ts | 343 +++ src/renderer/src/workspace/entries/utils.ts | 69 + src/renderer/src/workspace/layout/helpers.ts | 116 + src/renderer/src/workspace/persistence.ts | 37 + .../src/workspace/runtime/feedDebug.ts | 58 + .../src/workspace/semantic/foldEvent.ts | 808 +++++++ .../src/workspace/semantic/helpers.ts | 277 +++ .../src/workspace/semantic/summarize.ts | 86 + .../src/workspace/spawn/errorMessage.ts | 30 + src/renderer/src/workspace/workspaceStore.ts | 1867 +---------------- 12 files changed, 2165 insertions(+), 1774 deletions(-) create mode 100644 src/renderer/src/workspace/claude/history.ts create mode 100644 src/renderer/src/workspace/codex/entries.ts create mode 100644 src/renderer/src/workspace/codex/rollout.ts create mode 100644 src/renderer/src/workspace/entries/utils.ts create mode 100644 src/renderer/src/workspace/layout/helpers.ts create mode 100644 src/renderer/src/workspace/persistence.ts create mode 100644 src/renderer/src/workspace/runtime/feedDebug.ts create mode 100644 src/renderer/src/workspace/semantic/foldEvent.ts create mode 100644 src/renderer/src/workspace/semantic/helpers.ts create mode 100644 src/renderer/src/workspace/semantic/summarize.ts create mode 100644 src/renderer/src/workspace/spawn/errorMessage.ts diff --git a/src/renderer/src/workspace/claude/history.ts b/src/renderer/src/workspace/claude/history.ts new file mode 100644 index 000000000..fde27a63d --- /dev/null +++ b/src/renderer/src/workspace/claude/history.ts @@ -0,0 +1,74 @@ +import type { ConversationEntry } from '../../../../shared/types/transcript' + +// Claude transcript markers + progress-wrapper unwrapping. +// +// Claude emits two shapes of conversation entries: flat {type:'user'|'assistant', uuid, …} +// for historical lines, and {type:'progress', data:{message:}} +// for live streaming updates. The ghost reconciler and the older- +// history loader both want to address an entry by its uuid — so we +// look through the progress wrapper, extract the embedded flat +// entry, and use its uuid as the stable marker. +// +// `claudeHistoryMarker` answers "what uuid identifies this transcript +// line?". Returns null for non-conversation entries (system / +// compact_boundary) which don't participate in either path. +// +// `extractEmbeddedClaudeProgressEntry` produces a flat ConversationEntry +// from a progress wrapper — the shape the rest of cc-shell (Feed, +// reconciler, ghost ops) expects. Returns null for anything that +// isn't a progress wrapper holding a conversation entry. + +export function claudeHistoryMarker(entry: Record): string | null { + const embedded = extractEmbeddedClaudeProgressEntry(entry) + if (embedded?.uuid) return embedded.uuid + return typeof entry.uuid === 'string' ? entry.uuid : null +} + +export function extractEmbeddedClaudeProgressEntry( + entry: Record, +): ConversationEntry | null { + if (entry.type !== 'progress') return null + const data = entry.data as Record | undefined + const embedded = data?.message as Record | undefined + if (!embedded) return null + + const type = embedded.type + if (type !== 'assistant' && type !== 'user') return null + if (!embedded.message || typeof embedded.message !== 'object') return null + + return { + type, + uuid: + typeof embedded.uuid === 'string' + ? embedded.uuid + : `${String(entry.timestamp ?? Date.now())}:progress:${type}`, + parentUuid: + typeof embedded.parentUuid === 'string' ? embedded.parentUuid : null, + timestamp: + typeof embedded.timestamp === 'string' + ? embedded.timestamp + : typeof entry.timestamp === 'string' + ? entry.timestamp + : undefined, + sessionId: + typeof embedded.sessionId === 'string' + ? embedded.sessionId + : typeof entry.sessionId === 'string' + ? entry.sessionId + : undefined, + gitBranch: + typeof embedded.gitBranch === 'string' + ? embedded.gitBranch + : typeof entry.gitBranch === 'string' + ? entry.gitBranch + : undefined, + cwd: + typeof embedded.cwd === 'string' + ? embedded.cwd + : typeof entry.cwd === 'string' + ? entry.cwd + : undefined, + isSidechain: embedded.isSidechain === true, + message: embedded.message as ConversationEntry['message'], + } +} diff --git a/src/renderer/src/workspace/codex/entries.ts b/src/renderer/src/workspace/codex/entries.ts new file mode 100644 index 000000000..49a9b558a --- /dev/null +++ b/src/renderer/src/workspace/codex/entries.ts @@ -0,0 +1,174 @@ +import type { Entry } from '../../../../shared/types/transcript' + +// Codex rollout primitives + conversion helpers. +// +// The bulk jsonl-entries ingest path needs to tell Codex rollout +// entries apart from Claude transcript entries (routed differently +// into the Feed), extract the providerSessionId when one lands, and +// synthesize Entry objects from rollout payloads that Claude-shaped +// consumers downstream can process uniformly. The factories here +// produce Claude-shaped Entry objects whose content blocks carry +// a `codex` extension object with the rollout-specific metadata — +// the renderer's row components know when to read `.codex` and fall +// back gracefully when absent. + +/** A rollout-style entry (vs a Claude transcript entry). Used at the + * start of every ingest loop to dispatch between the two shapes. */ +export function isCodexRolloutEntry(entry: Record): boolean { + const type = entry.type + return ( + type === 'session_meta' || + type === 'response_item' || + type === 'event_msg' || + type === 'turn_context' || + type === 'compacted' + ) +} + +/** `session_meta` carries the rollout's Codex providerSessionId in + * `payload.id`. Used by the workspace-state capture pass so the + * session's providerSessionId can be persisted and used to resume + * on next launch. Other entry types don't carry this. */ +export function extractCodexProviderSessionId(entry: Record): string | null { + if (entry.type !== 'session_meta') return null + const payload = entry.payload as Record | undefined + return typeof payload?.id === 'string' ? payload.id : null +} + +/** Tag optimistic user entries we added client-side so the rollout + * reconciler can drop them when the real rollout user message lands. + * The prefix lives inside the uuid so even a hot-swap to a different + * entries array doesn't confuse the check. */ +export function isOptimisticCodexUserEntry(entry: Entry | undefined): boolean { + if (!entry || entry.type !== 'user') return false + return typeof entry.uuid === 'string' && entry.uuid.startsWith('optimistic-codex-user:') +} + +/** Parse a Codex-emitted JSON payload safely. Returns null on any + * failure — callers treat null as "couldn't parse, fall back to raw + * text" rather than throwing. */ +export function parseCodexJson(input: string): Record | null { + try { + const parsed = JSON.parse(input) as unknown + return parsed && typeof parsed === 'object' ? parsed as Record : null + } catch { + return null + } +} + +/** Flatten Codex's `output` payload into a single string. Handles + * strings, arrays of strings, and arrays of typed blocks (the + * latter is what `custom_tool_call_output` tends to produce). */ +export function codexOutputText(output: unknown): string { + if (typeof output === 'string') return output + if (Array.isArray(output)) { + return output + .map(item => { + if (typeof item === 'string') return item + const rec = item as Record + return typeof rec.text === 'string' ? rec.text : JSON.stringify(item, null, 2) + }) + .join('\n') + } + return JSON.stringify(output ?? '', null, 2) +} + +/** Codex's `exec_command_end` wraps its output in a "Chunk ID: …\nOutput:\n" + * envelope. The user never wants to see that wrapper — strip it. */ +export function stripCodexExecWrapper(output: string): string { + const marker = '\nOutput:\n' + const idx = output.indexOf(marker) + if (!output.startsWith('Chunk ID:') || idx === -1) return output + return output.slice(idx + marker.length) +} + +/** True when the output is ONLY the exec wrapper with a trailing + * "Process exited with code …" line and nothing else — i.e. no + * stdout/stderr worth surfacing. Callers filter these out so the + * feed doesn't get cluttered with empty tool-result rows. */ +export function isCodexExecWrapperOutput(output: string): boolean { + return output.startsWith('Chunk ID:') && output.includes('\nProcess exited with code ') +} + +/** Build a Claude-shaped assistant Entry containing a single + * tool_use block. The `id` / `name` / `input` match Claude's + * tool-use contract so downstream row renderers don't need a + * Codex-specific branch. */ +export function codexToolUseEntry( + uuid: string, + timestamp: string | undefined, + id: string, + name: string, + input: unknown, +): Entry { + return { + type: 'assistant', + uuid, + parentUuid: null, + timestamp, + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id, + name, + input, + }, + ], + }, + } +} + +/** Synthesize a Claude-shaped assistant Entry from a single text + * string. Used for rollout events that surface advisory text with + * no tool context (exec_approval_request narrative, etc.). */ +export function codexAssistantTextEntry( + uuid: string, + timestamp: string | undefined, + text: string, +): Entry { + return { + type: 'assistant', + uuid, + parentUuid: null, + timestamp, + message: { + role: 'assistant', + content: [{ type: 'text', text }], + }, + } +} + +/** Build a Claude-shaped tool_result Entry. The `codex` extension + * on the tool_result block carries rollout-specific metadata + * (exec exit code, patch success, parsed command) so the Codex + * custom row renderers can light up richer UI than Claude's + * generic tool-result card. */ +export function codexToolResultEntry( + uuid: string, + timestamp: string | undefined, + toolUseId: string, + content: string, + isError = false, + codex?: Record, +): Entry { + const resultBlock = { + type: 'tool_result', + tool_use_id: toolUseId, + content, + is_error: isError, + codex, + } + + return { + type: 'user', + uuid, + parentUuid: null, + timestamp, + message: { + role: 'user', + content: [resultBlock], + }, + } +} diff --git a/src/renderer/src/workspace/codex/rollout.ts b/src/renderer/src/workspace/codex/rollout.ts new file mode 100644 index 000000000..74ccec115 --- /dev/null +++ b/src/renderer/src/workspace/codex/rollout.ts @@ -0,0 +1,343 @@ +import type { Entry } from '../../../../shared/types/transcript' + +import { + codexAssistantTextEntry, + codexToolResultEntry, + codexToolUseEntry, + codexOutputText, + isCodexExecWrapperOutput, + parseCodexJson, + stripCodexExecWrapper, +} from './entries' + +// Codex rollout → feed entry mapping. +// +// The Codex headless emits rollout entries (session_meta / +// response_item / event_msg / turn_context / compacted) that don't +// look anything like Claude transcript entries. This module +// translates them into Claude-shaped `Entry` objects so the rest of +// the app (Feed, Reader, ghost reconciler, history loader) can +// stay provider-agnostic at the Entry layer. +// +// Design rules: +// 1. Never throw. A malformed payload turns into `return []` — +// the feed degrades gracefully rather than tearing down. +// 2. Uuids are synthesized from `timestamp + stable-id-field` so +// the same rollout entry always maps to the same uuid. The +// dedupe in the bulk ingest path uses that identity to avoid +// re-inserting entries on chunk overlap. +// 3. Codex-specific metadata rides on the `codex` extension of +// tool_result blocks — the row renderers read `.codex` to +// light up richer UI, but the generic Claude path ignores it. +// +// Ordering with `stampCodexTurnId`: Codex rollout doesn't put +// `turn_id` on per-item entries — only on `task_started` / +// `turn_started` and `turn_context`. Without tracking it here the +// ghost reconciler has nothing to match Codex assistant-text ghosts +// against (they don't carry `message.id`, don't carry `tool_use_id`). + +function codexConversationEntryFromMessageItem( + uuid: string, + timestamp: string | undefined, + payload: Record, +): Entry | null { + if ( + payload.type !== 'message' || + (payload.role !== 'user' && payload.role !== 'assistant') + ) { + return null + } + + const role = payload.role + const content = Array.isArray(payload.content) + ? payload.content + .map(block => { + const item = block as Record + const text = typeof item.text === 'string' ? item.text : null + if (!text) return null + if (item.type === 'input_text' || item.type === 'output_text') { + return { type: 'text' as const, text } + } + return null + }) + .filter((block): block is { type: 'text'; text: string } => block !== null) + : [] + + if (content.length === 0) return null + return { + type: role, + uuid, + parentUuid: null, + timestamp, + message: { role, content }, + } +} + +function codexCompactBoundaryEntry( + uuid: string, + payload: Record, +): Entry { + return { + type: 'system', + subtype: 'compact_boundary', + content: 'Conversation compacted', + uuid, + compactMetadata: payload, + } +} + +function codexCompactSummaryEntry( + uuid: string, + timestamp: string | undefined, + text: string, +): Entry { + return { + type: 'user', + uuid, + parentUuid: null, + timestamp, + isCompactSummary: true, + isVisibleInTranscriptOnly: true, + message: { + role: 'user', + content: [{ type: 'text', text }], + }, + } +} + +/** + * Extract the Codex rollout's per-turn response id from a + * `turn_context` side-channel entry. Returns null for any other + * entry type. Call sites that iterate a rollout stream use this to + * keep a rolling "current turn id" that subsequent `response_item` + * entries get stamped with via `stampCodexTurnId`. + * + * WHY: Codex rollout doesn't put `turn_id` on per-item entries — + * only on `task_started`/`turn_started` and `turn_context`. Without + * tracking it here the ghost reconciler in `reconcileUpstream` has + * nothing to match Codex assistant-text ghosts against (they don't + * carry `message.id`, don't carry `tool_use_id`). + */ +export function codexTurnIdFromRollout(entry: Record): string | null { + if (entry.type !== 'turn_context') return null + const payload = entry.payload as Record | undefined + return typeof payload?.turn_id === 'string' ? (payload.turn_id as string) : null +} + +/** + * Stamp a mapped Codex feed entry with the rollout turn id so the + * ghost reconciler can supersede by turn id. The field is added as + * a cc-shell-local extension to the shared `Entry` type via cast — + * consumers that don't care about it ignore it, and + * `reconcileUpstream` reads it defensively. + */ +export function stampCodexTurnId(entry: Entry, turnId: string | null): Entry { + if (turnId === null) return entry + return { ...entry, codexTurnId: turnId } as Entry +} + +export function mapCodexRolloutToFeedEntries(entry: Record): Entry[] { + const uuid = + `${String(entry.timestamp ?? Date.now())}:${String((entry.payload as Record | undefined)?.id ?? (entry.payload as Record | undefined)?.call_id ?? (entry.payload as Record | undefined)?.type ?? entry.type)}` + const timestamp = + typeof entry.timestamp === 'string' ? entry.timestamp : undefined + + const payload = entry.payload as Record | undefined + if (!payload || typeof payload.type !== 'string') return [] + + if (entry.type === 'event_msg') { + const atp = entry._atp as { origin?: string; source?: Record } | undefined + if ( + payload.type === 'user_message' && + atp?.origin === 'claude' && + atp.source?.isCompactSummary === true + ) { + const sourceMessage = atp.source.message as { content?: unknown } | undefined + const sourceText = + typeof sourceMessage?.content === 'string' + ? sourceMessage.content + : typeof payload.message === 'string' + ? payload.message + : '' + return sourceText + ? [codexCompactSummaryEntry(`${uuid}:compact-summary`, timestamp, sourceText)] + : [] + } + + if (payload.type === 'exec_approval_request') { + const command = Array.isArray(payload.command) + ? payload.command.filter((part): part is string => typeof part === 'string') + : [] + const workdir = typeof payload.workdir === 'string' ? payload.workdir : null + const summary = [ + 'Permission required before Codex can run a command.', + command.length > 0 ? `Command: ${command.join(' ')}` : null, + workdir ? `Directory: ${workdir}` : null, + ] + .filter((line): line is string => line !== null) + .join('\n') + return summary ? [codexAssistantTextEntry(uuid, timestamp, summary)] : [] + } + + if ( + payload.type === 'exec_command_end' && + typeof payload.call_id === 'string' + ) { + const output = String( + payload.aggregated_output ?? + payload.formatted_output ?? + payload.stdout ?? + payload.stderr ?? + '', + ) + const exitCode = + typeof payload.exit_code === 'number' ? payload.exit_code : 0 + if (!output.trim() && exitCode === 0) return [] + return [ + codexToolResultEntry( + uuid, + timestamp, + payload.call_id, + output, + exitCode !== 0 || payload.status === 'failed', + { + kind: 'exec_command_end', + parsedCmd: Array.isArray(payload.parsed_cmd) ? payload.parsed_cmd : [], + command: Array.isArray(payload.command) ? payload.command : [], + cwd: typeof payload.cwd === 'string' ? payload.cwd : null, + exitCode, + }, + ), + ] + } + + if ( + payload.type === 'patch_apply_end' && + typeof payload.call_id === 'string' + ) { + const stdout = typeof payload.stdout === 'string' ? payload.stdout : '' + const stderr = typeof payload.stderr === 'string' ? payload.stderr : '' + const content = stdout || stderr + return [ + codexToolResultEntry( + uuid, + timestamp, + payload.call_id, + content, + payload.success !== true, + { + kind: 'patch_apply_end', + success: payload.success === true, + changes: payload.changes && typeof payload.changes === 'object' + ? payload.changes as Record + : {}, + }, + ), + ] + } + + return [] + } + + if (entry.type === 'compacted') { + const out: Entry[] = [ + codexCompactBoundaryEntry(`${uuid}:compact-boundary`, payload), + ] + + const message = typeof payload.message === 'string' ? payload.message.trim() : '' + if (message) { + out.push(codexCompactSummaryEntry(`${uuid}:compact-summary`, timestamp, message)) + } + + const replacementHistory = Array.isArray(payload.replacement_history) + ? payload.replacement_history + : [] + for (let i = 0; i < replacementHistory.length; i += 1) { + const item = replacementHistory[i] as Record + const mapped = codexConversationEntryFromMessageItem( + `${uuid}:replacement:${i}`, + timestamp, + item, + ) + if (mapped) out.push(mapped) + } + + return out + } + + if (entry.type !== 'response_item') return [] + + const conversationEntry = codexConversationEntryFromMessageItem(uuid, timestamp, payload) + if (conversationEntry) return [conversationEntry] + + if ( + payload.type === 'custom_tool_call' && + typeof payload.call_id === 'string' && + typeof payload.name === 'string' + ) { + const input = + typeof payload.input === 'string' + ? parseCodexJson(payload.input) ?? { raw: payload.input } + : { raw: '' } + return [codexToolUseEntry(uuid, timestamp, payload.call_id, payload.name, input)] + } + + if ( + payload.type === 'function_call' && + typeof payload.call_id === 'string' && + typeof payload.name === 'string' + ) { + const input = + typeof payload.arguments === 'string' + ? parseCodexJson(payload.arguments) ?? { arguments: payload.arguments } + : { arguments: payload.arguments } + return [codexToolUseEntry(uuid, timestamp, payload.call_id, payload.name, input)] + } + + if (payload.type === 'function_call_output' && typeof payload.call_id === 'string') { + const output = stripCodexExecWrapper(codexOutputText(payload.output)) + if (!output.trim() || isCodexExecWrapperOutput(codexOutputText(payload.output))) { + return [] + } + return [codexToolResultEntry(uuid, timestamp, payload.call_id, output)] + } + + if (payload.type === 'custom_tool_call_output' && typeof payload.call_id === 'string') { + const output = codexOutputText(payload.output) + const parsed = parseCodexJson(output) + const normalized = + typeof parsed?.output === 'string' ? parsed.output : output + const metadata = parsed?.metadata + const exitCode = + metadata && typeof metadata === 'object' && typeof (metadata as Record).exit_code === 'number' + ? (metadata as Record).exit_code as number + : 0 + if ( + typeof normalized === 'string' && + normalized.startsWith('Success. Updated the following files:') + ) { + return [] + } + return [ + codexToolResultEntry( + uuid, + timestamp, + payload.call_id, + normalized, + exitCode !== 0, + { kind: 'custom_tool_call_output' }, + ), + ] + } + + return [] +} + +/** Build the history marker for a Codex rollout entry. The format is + * `:` — mirrors the + * uuid used by mapCodexRolloutToFeedEntries so the older-history + * loader and the dedup path see the same identity. */ +export function codexHistoryMarker(entry: Record): string { + const payload = entry.payload as Record | undefined + return `${String(entry.timestamp ?? '')}:${String(payload?.id ?? payload?.call_id ?? payload?.type ?? entry.type)}` +} diff --git a/src/renderer/src/workspace/entries/utils.ts b/src/renderer/src/workspace/entries/utils.ts new file mode 100644 index 000000000..9221ac37c --- /dev/null +++ b/src/renderer/src/workspace/entries/utils.ts @@ -0,0 +1,69 @@ +import { + isConversationEntry, + type Entry, + type ToolResultBlock, + type ToolUseBlock, +} from '../../../../shared/types/transcript' + +// Shared entry utilities used by the workspace store, feed-debug log, +// and the bulk jsonl ingest path. Provider-agnostic: Claude and Codex +// conversation entries both use `message.content` arrays, so a single +// reader covers both shapes. + +/** Pull user-visible text out of a conversation entry. Returns null + * when the entry isn't a user/assistant role or has no text block; + * useful for debug summaries and optimistic-reconciliation matching. */ +export function entryTextContent(entry: Entry): string | null { + if (entry.type !== 'user' && entry.type !== 'assistant') return null + const content = (entry as { message?: { content?: unknown } }).message?.content + if (!Array.isArray(content)) return null + const texts = content + .map(block => { + const item = block as Record + return item.type === 'text' && typeof item.text === 'string' ? item.text : null + }) + .filter((text): text is string => text !== null) + return texts.length > 0 ? texts.join('\n') : null +} + +/** One-line summary for the feed-debug log. Truncates long text so the + * log stays readable; the full entry is available in the data field. */ +export function summarizeEntryForDebug(entry: Entry): string { + const text = entryTextContent(entry) + if (text) { + const compact = text.replace(/\s+/g, ' ').trim() + return `${entry.type}: ${compact.slice(0, 96)}` + } + return entry.type +} + +// Mutates `toolUseIndex` and `toolResultIndex` in place, folding one +// feed entry's tool_use / tool_result blocks into the per-session +// lookup maps. Used by both the bulk jsonl-entries ingest path and +// the singular one — keeps the indexing logic in one place so Feed +// never has to rebuild these maps in a useMemo. +// +// WHY in-place mutation of a map stored on runtime state: +// The map reference doesn't change, only its contents — Feed reads +// through context and treats the map as a live lookup rather than +// a diffable prop. React.memo of downstream rows is unaffected +// (they don't depend on the map's reference identity), and we +// avoid allocating a new Map per entry during a bootstrap burst. +export function indexEntryIntoMaps( + entry: Entry, + toolUseIndex: Map, + toolResultIndex: Map, +): void { + if (!isConversationEntry(entry)) return + const content = entry.message.content + if (!Array.isArray(content)) return + for (const b of content) { + if (b.type === 'tool_use') { + const tu = b as ToolUseBlock + toolUseIndex.set(tu.id, tu) + } else if (b.type === 'tool_result') { + const tr = b as ToolResultBlock + toolResultIndex.set(tr.tool_use_id, tr) + } + } +} diff --git a/src/renderer/src/workspace/layout/helpers.ts b/src/renderer/src/workspace/layout/helpers.ts new file mode 100644 index 000000000..18679e6d3 --- /dev/null +++ b/src/renderer/src/workspace/layout/helpers.ts @@ -0,0 +1,116 @@ +import type { SessionId, TileNode } from '../types' +import type { SlashPickerState, TileTabsState } from '../workspaceState' +import { collectLeaves } from '../tile-tree/treeOps' + +// Layout & picker utilities for the workspace store. +// +// These are the small pure helpers the useWorkspace hook calls +// inline during layout mutations — tab titling, ratio math, tile- +// tabs state sanitization, picker equality, split-ratio walking. +// Separated out because they're the most easily unit-testable +// shapes in the whole workspace module and don't need any React +// or session-state context. + +/** Derive a tab title from a cwd — use the last path segment + * ("cc-shell"), falling back to the full cwd if none. */ +export function titleFromCwd(cwd: string): string { + const parts = cwd.split('/').filter(Boolean) + return parts[parts.length - 1] ?? cwd +} + +/** Evenly-divided ratios summing to 1 for `count` tabs. */ +export function equalRatios(count: number): number[] { + if (count <= 0) return [] + return Array.from({ length: count }, () => 1 / count) +} + +/** Renormalize ratios so they sum to exactly 1. Recovers from + * rounding drift or malformed persisted state. */ +export function normalizeRatios(ratios: number[]): number[] { + if (ratios.length === 0) return [] + const total = ratios.reduce((sum, value) => sum + value, 0) + if (total <= 0) return equalRatios(ratios.length) + return ratios.map(value => value / total) +} + +/** Float-tolerant ratio equality — used to skip setState when a + * tile-tab resize hasn't produced a visible change. */ +export function ratiosEqual(a: number[], b: number[]): boolean { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (Math.abs(a[i] - b[i]) > 0.0001) return false + } + return true +} + +/** Sanitize TileTabsState against invariants: + * - At least 2 tile-tabs (single-tab tiled mode is a no-op). + * - Deduplicate tabIds. + * - focusedTabId must be one of tabIds (falls back to head). + * - Ratios count matches tabIds count (rebuild equal ratios + * otherwise). + * Returns null when the state collapses to an invalid shape — the + * caller clears tile-tabs mode. */ +export function sanitizeTileTabsState(tileTabs: TileTabsState): TileTabsState | null { + if (tileTabs.tabIds.length < 2) return null + const tabIds = Array.from(new Set(tileTabs.tabIds)) + if (tabIds.length < 2) return null + const focusedTabId = tabIds.includes(tileTabs.focusedTabId) + ? tileTabs.focusedTabId + : tabIds[0] + const ratios = tileTabs.ratios.length === tabIds.length + ? normalizeRatios(tileTabs.ratios) + : equalRatios(tabIds.length) + return { + ...tileTabs, + tabIds, + focusedTabId, + ratios, + } +} + +/** + * Cheap structural comparison for SlashPickerState. The picker object + * itself is always fresh (parsed anew from each terminal snapshot in + * main), so reference equality never holds — we have to look at the + * visible flag, the item count, and the per-item id + selected bit. + * IDs are short strings, items cap at ~15, so this runs in microseconds + * and is still vastly cheaper than letting a no-op screen frame + * propagate into a React render. + */ +export function pickerEqual( + a: SlashPickerState, + b: SlashPickerState, +): boolean { + if (a.visible !== b.visible) return false + if (a.items.length !== b.items.length) return false + for (let i = 0; i < a.items.length; i++) { + const x = a.items[i] + const y = b.items[i] + if (x.id !== y.id || x.selected !== y.selected) return false + } + return true +} + +/** Walk the tile tree and set the ratio of the split whose `a` + * subtree contains aSession and whose `b` subtree contains + * bSession. Ratios clamp to [0.1, 0.9] so no pane vanishes. Used + * by drag-resize in TileLeaf and the split-ratio palette commands. */ +export function setRatioBetween( + node: TileNode, + aSession: SessionId, + bSession: SessionId, + ratio: number, +): TileNode { + if (node.type === 'leaf') return node + const leavesA = collectLeaves(node.a) + const leavesB = collectLeaves(node.b) + if (leavesA.includes(aSession) && leavesB.includes(bSession)) { + return { ...node, ratio: Math.min(0.9, Math.max(0.1, ratio)) } + } + return { + ...node, + a: setRatioBetween(node.a, aSession, bSession, ratio), + b: setRatioBetween(node.b, aSession, bSession, ratio), + } +} diff --git a/src/renderer/src/workspace/persistence.ts b/src/renderer/src/workspace/persistence.ts new file mode 100644 index 000000000..2ef16609c --- /dev/null +++ b/src/renderer/src/workspace/persistence.ts @@ -0,0 +1,37 @@ +import type { + BuriedPaneRecord, + SessionId, + SessionMeta, + TabId, + TileNode, +} from './types' +import type { TileTabsState } from './workspaceState' + +// --------------------------------------------------------------------------- +// Persisted state shape (serialized to ~/.config/cc-shell/workspace.json) +// --------------------------------------------------------------------------- + +/** + * Persisted workspace shape. Live runtime state is NOT here — we + * respawn sessions on load and their state rebuilds naturally from + * fresh IPC events. + */ +export type PersistedWorkspace = { + // Tab tree with sessionIds that refer to the CURRENT launch's + // sessions. On load we re-spawn and remap ids, so persisted ids are + // just placeholders that get replaced. + tabs: Array<{ + id: TabId + title: string + focusedSessionId: SessionId + root: TileNode + }> + activeTabId: TabId + sessions: Record + buried?: BuriedPaneRecord[] + tileTabs?: TileTabsState | null + /** Draft input text per session, keyed by sessionId. Persisted so + * in-progress prompts survive app crashes and restarts. Only + * non-empty drafts are saved to keep the file small. */ + drafts?: Record +} diff --git a/src/renderer/src/workspace/runtime/feedDebug.ts b/src/renderer/src/workspace/runtime/feedDebug.ts new file mode 100644 index 000000000..cffd35f72 --- /dev/null +++ b/src/renderer/src/workspace/runtime/feedDebug.ts @@ -0,0 +1,58 @@ +import type { + FeedDebugEntry, + FeedDebugLayer, + SessionRuntime, +} from '../workspaceState' + +// Per-session feed-debug log — the runtime-side helper. Every +// mutation to SessionRuntime that crosses an interesting boundary +// (screen_update, process_state, submit, jsonl_entries, SEM, …) +// appends one entry here, capped at FEED_DEBUG_LOG_CAP. The +// FeedDebugPanel renders this in realtime; the same entries are +// shipped to main/storage/feedDebugLog.ts every tick to be written +// to disk (per-session JSONL under ~/.config/cc-shell/feed-debug/). +// +// Why cap the in-memory array: long-running sessions could +// accumulate tens of thousands of entries, bloating the runtime map +// and making FeedDebugPanel pointer-sluggish on scroll. The cap is +// renderer-side; main writes the full series to disk. + +const FEED_DEBUG_LOG_CAP = 500 + +export type FeedDebugInput = { + layer: FeedDebugLayer + kind: string + summary: string + data?: unknown +} + +/** Append a debug entry onto a SessionRuntime, capped at + * FEED_DEBUG_LOG_CAP. Returns a new runtime ref — reference equality + * against the input runtime signals "no-op" to upstream setRuntimes + * short-circuits. */ +export function appendFeedDebugLog( + current: SessionRuntime, + input: FeedDebugInput, +): SessionRuntime { + const ts = Date.now() + const epoch = current.feedDebugEpochMs ?? ts + const nextEntry: FeedDebugEntry = { + id: current.feedDebugNextId, + ts, + tMs: ts - epoch, + layer: input.layer, + kind: input.kind, + summary: input.summary, + data: input.data, + } + const nextLog = + current.feedDebugLog.length >= FEED_DEBUG_LOG_CAP + ? [...current.feedDebugLog.slice(current.feedDebugLog.length - FEED_DEBUG_LOG_CAP + 1), nextEntry] + : [...current.feedDebugLog, nextEntry] + return { + ...current, + feedDebugEpochMs: epoch, + feedDebugNextId: current.feedDebugNextId + 1, + feedDebugLog: nextLog, + } +} diff --git a/src/renderer/src/workspace/semantic/foldEvent.ts b/src/renderer/src/workspace/semantic/foldEvent.ts new file mode 100644 index 000000000..a9e7046e8 --- /dev/null +++ b/src/renderer/src/workspace/semantic/foldEvent.ts @@ -0,0 +1,808 @@ +import type { SessionKind } from '../types' +import type { SemanticLiveBlock, SemanticRuntimeState } from '../workspaceState' + +import { + SEMANTIC_ERROR_CAP, + SEMANTIC_HISTORY_CAP, + SEMANTIC_LOG_CAP, + deriveSemanticTaskSnapshot, + emptySemanticLookupSnapshot, + emptySemanticTaskSnapshot, + flattenSemanticUsage, + hasPendingSemanticTools, + semanticHistoryRow, + semanticToIndex, +} from './helpers' +import { summarizeSemanticEvent } from './summarize' + +// --------------------------------------------------------------------------- +// foldSemanticEvent — the one-session-one-reducer contract +// --------------------------------------------------------------------------- +// +// WHY centralize semantic folding here instead of letting Feed, +// ReaderView, and the proxy debug UI each subscribe separately: +// +// The old architecture created three subtly different truths about the +// same live turn. Feed still derived structure from screen scraping, +// Reader kept a local semantic turn, and the debug panel had yet +// another reducer. That split is exactly how we ended up under-using +// the proxy stream: every UI surface only consumed the subset it +// happened to care about. +// +// The invariant now is "one session => one semantic reducer". Every +// semantic event, including late tool_result and connector/citation +// deltas, must flow through this fold before any UI reads it. If a +// future surface wants live model structure, it should select from +// `runtime.semantic`, not open its own transport subscription. + +export function foldSemanticEvent( + state: SemanticRuntimeState, + ev: Record, + sessionKind: SessionKind, +): SemanticRuntimeState { + const now = Date.now() + + const t = String(ev.type ?? '') + let flows = state.flows + let currentTurn = state.currentTurn + let history = state.history + let errors = state.errors + + switch (t) { + case 'flow_selected': { + const flowId = String(ev.flowId ?? '') + const prev = flows[flowId] + flows = { + ...flows, + [flowId]: { + flowId, + attribution: 'active', + reason: String(ev.reason ?? ''), + turnId: typeof ev.turnId === 'string' ? ev.turnId : null, + firstSeen: prev?.firstSeen ?? now, + lastSeen: now, + bytesEstimate: prev?.bytesEstimate ?? 0, + chunkCount: prev?.chunkCount ?? 0, + }, + } + break + } + case 'flow_ignored': { + const flowId = String(ev.flowId ?? '') + const prev = flows[flowId] + flows = { + ...flows, + [flowId]: { + flowId, + attribution: 'ignored', + reason: String(ev.reason ?? ''), + turnId: typeof ev.turnId === 'string' ? ev.turnId : null, + firstSeen: prev?.firstSeen ?? now, + lastSeen: now, + bytesEstimate: prev?.bytesEstimate ?? 0, + chunkCount: prev?.chunkCount ?? 0, + }, + } + break + } + case 'turn_started': { + const turnId = String(ev.turnId ?? '') + if (!turnId) break + // Provider-gated turn ownership. + // + // Codex (strict): mismatched turnIds are DROPPED because they + // come from racing producers (proxy flow + screen fallback, or + // two concurrent proxy flows). Replacing currentTurn on their + // say-so wipes the block map the live renderer is already + // showing — the 0/1/0/1 flicker documented in + // docs/superpowers/plans/2026-04-17-codex-semantic-flicker-fix.md. + // + // Claude (auto-replace): archive the stuck turn and open the + // new one. Claude legitimately keeps currentTurn alive across + // turn boundaries while a cross-turn tool_result is pending + // (turn_completed below retains the turn when + // hasPendingSemanticTools is true). The NEXT assistant turn's + // message_start carries a fresh msg_id that mismatches the + // pinned turnId; dropping it would silently hide every + // subsequent Claude turn. This restores the reducer's pre-flicker-fix + // behavior for Claude only. See + // docs/superpowers/plans/2026-04-17-claude-semantic-provider-gating.md. + // + // Same-turnId refresh (re-entry / source promotion) is + // identical for both providers. + if (!currentTurn) { + currentTurn = { + turnId, + text: '', + source: typeof ev.source === 'string' ? ev.source : null, + blocks: {}, + blockOrder: [], + stopReason: null, + usage: null, + task: emptySemanticTaskSnapshot(), + lookups: emptySemanticLookupSnapshot(), + startedAt: now, + endedAt: null, + } + } else if (currentTurn.turnId === turnId) { + currentTurn = { + ...currentTurn, + source: typeof ev.source === 'string' ? ev.source : currentTurn.source, + } + } else if (sessionKind === 'claude') { + history = [...history, semanticHistoryRow(currentTurn)].slice(-SEMANTIC_HISTORY_CAP) + currentTurn = { + turnId, + text: '', + source: typeof ev.source === 'string' ? ev.source : null, + blocks: {}, + blockOrder: [], + stopReason: null, + usage: null, + task: emptySemanticTaskSnapshot(), + lookups: emptySemanticLookupSnapshot(), + startedAt: now, + endedAt: null, + } + } + // Codex: mismatched turnId falls through — drop the event. + break + } + case 'source_changed': { + if (!currentTurn) break + currentTurn = { + ...currentTurn, + source: typeof ev.source === 'string' ? ev.source : currentTurn.source, + } + break + } + case 'turn_delta': { + const turnId = typeof ev.turnId === 'string' ? ev.turnId : null + if (!turnId) break + // Soft-open allowed when there's no currentTurn (e.g. Codex's + // rollout agent_message_delta can arrive before task_started). + // + // On turnId mismatch: + // - Claude: archive the pinned old turn and open a new one. + // Same rationale as the turn_started branch above. + // - Codex: drop. Racing producers must not mutate a + // currentTurn that doesn't belong to them (flicker defense). + if (!currentTurn) { + currentTurn = { + turnId, + text: '', + source: typeof ev.source === 'string' ? ev.source : null, + blocks: {}, + blockOrder: [], + stopReason: null, + usage: null, + task: emptySemanticTaskSnapshot(), + lookups: emptySemanticLookupSnapshot(), + startedAt: now, + endedAt: null, + } + } else if (currentTurn.turnId !== turnId) { + if (sessionKind === 'claude') { + history = [...history, semanticHistoryRow(currentTurn)].slice(-SEMANTIC_HISTORY_CAP) + currentTurn = { + turnId, + text: '', + source: typeof ev.source === 'string' ? ev.source : null, + blocks: {}, + blockOrder: [], + stopReason: null, + usage: null, + task: emptySemanticTaskSnapshot(), + lookups: emptySemanticLookupSnapshot(), + startedAt: now, + endedAt: null, + } + } else { + break + } + } + currentTurn = { + ...currentTurn, + text: typeof ev.fullText === 'string' ? ev.fullText : currentTurn.text, + source: typeof ev.source === 'string' ? ev.source : currentTurn.source, + } + break + } + case 'block_started': { + if (!currentTurn) break + const idx = semanticToIndex(ev.blockIndex) + if (idx === null) break + // Codex emits `callId` where Claude emits `toolUseId`. Both feed + // the same downstream tool-result pairing logic — populate + // whichever the upstream sent, and mirror into the other so + // existing consumers (e.g. the tool_result match in this file + // at the toolUseId path) work regardless of source provider. + const callId = typeof ev.callId === 'string' ? ev.callId : undefined + const toolUseId = typeof ev.toolUseId === 'string' ? ev.toolUseId : callId + const messagePhase = + ev.messagePhase === 'commentary' || ev.messagePhase === 'final_answer' + ? (ev.messagePhase as 'commentary' | 'final_answer') + : undefined + currentTurn = { + ...currentTurn, + blocks: { + ...currentTurn.blocks, + [idx]: { + blockIndex: idx, + kind: String(ev.kind ?? 'other'), + toolName: typeof ev.toolName === 'string' ? ev.toolName : undefined, + toolUseId, + callId, + itemId: typeof ev.itemId === 'string' ? ev.itemId : undefined, + messagePhase, + status: typeof ev.status === 'string' ? ev.status : undefined, + text: '', + thinking: '', + inputJson: '', + }, + }, + blockOrder: currentTurn.blockOrder.includes(idx) + ? currentTurn.blockOrder + : [...currentTurn.blockOrder, idx], + } + // Task/lookups derivation intentionally skipped here. The + // trailing `finalCurrentTurn` computation at the bottom of + // this reducer unconditionally re-derives from + // `currentTurn.blocks`, so doing it inline would just be dead + // work overwritten on the same event. The tool_result branch + // DOES need its own inline derive because that branch can + // push the turn to history and set currentTurn=null, skipping + // the trailing computation. + break + } + case 'text_delta': { + if (!currentTurn) break + const idx = semanticToIndex(ev.blockIndex) + if (idx === null) break + const block = currentTurn.blocks[idx] + if (!block) break + currentTurn = { + ...currentTurn, + blocks: { + ...currentTurn.blocks, + [idx]: { + ...block, + text: + typeof ev.textSoFar === 'string' + ? ev.textSoFar + : (block.text ?? '') + String(ev.textDelta ?? ''), + }, + }, + } + break + } + case 'connector_text_delta': { + if (!currentTurn) break + const idx = semanticToIndex(ev.blockIndex) + if (idx === null) break + const block = currentTurn.blocks[idx] + if (!block) break + currentTurn = { + ...currentTurn, + blocks: { + ...currentTurn.blocks, + [idx]: { + ...block, + text: + typeof ev.connectorTextSoFar === 'string' + ? ev.connectorTextSoFar + : (block.text ?? '') + String(ev.connectorTextDelta ?? ''), + }, + }, + } + break + } + case 'thinking_delta': { + if (!currentTurn) break + const idx = semanticToIndex(ev.blockIndex) + if (idx === null) break + const block = currentTurn.blocks[idx] + if (!block) break + currentTurn = { + ...currentTurn, + blocks: { + ...currentTurn.blocks, + [idx]: { + ...block, + thinking: + typeof ev.thinkingSoFar === 'string' + ? ev.thinkingSoFar + : (block.thinking ?? '') + String(ev.thinkingDelta ?? ''), + }, + }, + } + break + } + case 'citations_delta': { + if (!currentTurn) break + const idx = semanticToIndex(ev.blockIndex) + if (idx === null) break + const block = currentTurn.blocks[idx] + if (!block) break + const citations = Array.isArray(ev.citationsSoFar) + ? [...ev.citationsSoFar] + : [...(block.citations ?? []), ev.citationsDelta] + currentTurn = { + ...currentTurn, + blocks: { + ...currentTurn.blocks, + [idx]: { + ...block, + citations, + }, + }, + } + break + } + case 'signature': { + if (!currentTurn) break + const idx = semanticToIndex(ev.blockIndex) + if (idx === null) break + const block = currentTurn.blocks[idx] + if (!block) break + currentTurn = { + ...currentTurn, + blocks: { + ...currentTurn.blocks, + [idx]: { + ...block, + signature: typeof ev.signature === 'string' ? ev.signature : block.signature, + }, + }, + } + break + } + case 'tool_input_delta': { + if (!currentTurn) break + const idx = semanticToIndex(ev.blockIndex) + if (idx === null) break + const block = currentTurn.blocks[idx] + if (!block) break + currentTurn = { + ...currentTurn, + blocks: { + ...currentTurn.blocks, + [idx]: { + ...block, + inputJson: + typeof ev.inputJsonSoFar === 'string' + ? ev.inputJsonSoFar + : (block.inputJson ?? '') + String(ev.partialJson ?? ''), + toolName: block.toolName ?? (typeof ev.toolName === 'string' ? ev.toolName : undefined), + toolUseId: block.toolUseId ?? (typeof ev.toolUseId === 'string' ? ev.toolUseId : undefined), + }, + }, + } + break + } + case 'tool_input_finalized': { + if (!currentTurn) break + const idx = semanticToIndex(ev.blockIndex) + if (idx === null) break + const block = currentTurn.blocks[idx] + if (!block) break + currentTurn = { + ...currentTurn, + blocks: { + ...currentTurn.blocks, + [idx]: { + ...block, + inputJson: typeof ev.inputJson === 'string' ? ev.inputJson : block.inputJson, + inputJsonValid: Boolean(ev.parsed), + parsedInput: + ev.parsed && typeof ev.parsed === 'object' + ? ev.parsed as Record + : block.parsedInput, + parseError: + typeof ev.parseError === 'string' ? ev.parseError : block.parseError, + finalized: true, + }, + }, + } + break + } + case 'block_completed': { + if (!currentTurn) break + const idx = semanticToIndex(ev.blockIndex) + if (idx === null) break + const block = currentTurn.blocks[idx] + if (!block) break + // Codex sends typed fields for each ResponseItem variant; Claude + // sends `parsed` for tool input. Merge both shapes here so the + // renderer doesn't have to branch on provider. `parsed` (Claude) + // and `parsedArguments` (Codex) populate the same `parsedInput` + // slot; `inputJson` (Claude) and `argumentsJson` (Codex) populate + // the same `inputJson` slot. + const parsedObj = + ev.parsed && typeof ev.parsed === 'object' + ? (ev.parsed as Record) + : ev.parsedArguments && typeof ev.parsedArguments === 'object' + ? (ev.parsedArguments as Record) + : block.parsedInput + const argsRaw = + typeof ev.inputJson === 'string' + ? ev.inputJson + : typeof ev.argumentsJson === 'string' + ? ev.argumentsJson + : block.inputJson + const callId = typeof ev.callId === 'string' ? ev.callId : block.callId + currentTurn = { + ...currentTurn, + blocks: { + ...currentTurn.blocks, + [idx]: { + ...block, + kind: typeof ev.kind === 'string' ? ev.kind : block.kind, + text: typeof ev.text === 'string' ? ev.text : block.text, + signature: typeof ev.signature === 'string' ? ev.signature : block.signature, + toolName: typeof ev.toolName === 'string' ? ev.toolName : block.toolName, + toolUseId: + typeof ev.toolUseId === 'string' + ? ev.toolUseId + : (callId ?? block.toolUseId), + callId, + inputJson: argsRaw, + argumentsJson: + typeof ev.argumentsJson === 'string' ? ev.argumentsJson : block.argumentsJson, + inputJsonValid: + parsedObj === block.parsedInput ? block.inputJsonValid : Boolean(parsedObj), + parsedInput: parsedObj, + parseError: + typeof ev.parseError === 'string' ? ev.parseError : block.parseError, + status: typeof ev.status === 'string' ? ev.status : block.status, + finalized: true, + citations: + ev.raw && typeof ev.raw === 'object' && Array.isArray((ev.raw as { citations?: unknown[] }).citations) + ? [...((ev.raw as { citations: unknown[] }).citations)] + : block.citations, + // Codex-specific typed variant payloads. Forward as-is; + // the renderer picks the right one based on `kind`. + output: ev.output !== undefined ? ev.output : block.output, + webSearchAction: + ev.webSearchAction && typeof ev.webSearchAction === 'object' + ? (ev.webSearchAction as SemanticLiveBlock['webSearchAction']) + : block.webSearchAction, + imageGeneration: + ev.imageGeneration && typeof ev.imageGeneration === 'object' + ? (ev.imageGeneration as SemanticLiveBlock['imageGeneration']) + : block.imageGeneration, + localShellCall: + ev.localShellCall && typeof ev.localShellCall === 'object' + ? (ev.localShellCall as SemanticLiveBlock['localShellCall']) + : block.localShellCall, + reasoningSummary: + typeof ev.reasoningSummary === 'string' + ? ev.reasoningSummary + : block.reasoningSummary, + reasoningText: + typeof ev.reasoningText === 'string' + ? ev.reasoningText + : block.reasoningText, + }, + }, + } + break + } + case 'tool_result': { + // WHY attach results onto the originating tool block instead of creating a + // fresh pseudo-entry here: + // + // The semantic stream's job is to preserve the model's live structure. A + // tool result is not a new assistant block; it is the resolution of a + // previous tool_use. Storing it on the tool block keeps the renderer's + // pairing logic trivial and avoids inventing extra ordering rules in the + // store. If we later build a richer agent/task panel, it can still derive + // timeline rows from this normalized shape. + if (!currentTurn || typeof ev.toolUseId !== 'string') break + if (typeof ev.turnId === 'string' && ev.turnId !== currentTurn.turnId) break + const match = Object.entries(currentTurn.blocks).find(([, block]) => block.toolUseId === ev.toolUseId) + if (!match) break + const idx = Number(match[0]) + const block = match[1] + currentTurn = { + ...currentTurn, + blocks: { + ...currentTurn.blocks, + [idx]: { + ...block, + resultContent: typeof ev.content === 'string' ? ev.content : block.resultContent, + resultIsError: ev.isError === true, + resultAt: now, + }, + }, + } + { + const derived = deriveSemanticTaskSnapshot(currentTurn.blocks) + const nextTurn = { + ...currentTurn, + task: derived.task, + lookups: derived.lookups, + } + if (nextTurn.endedAt != null && !hasPendingSemanticTools(nextTurn)) { + history = [ + ...history, + semanticHistoryRow(nextTurn), + ].slice(-SEMANTIC_HISTORY_CAP) + currentTurn = null + } else { + currentTurn = nextTurn + } + } + break + } + case 'tool_started': { + const callId = typeof ev.callId === 'string' ? ev.callId : null + if (!callId) break + const turnId = + typeof ev.turnId === 'string' + ? ev.turnId + : currentTurn?.turnId ?? `codex-${now}` + // Provider-gated turn ownership (see turn_started for full + // rationale). Codex drops on mismatch (flicker defense); + // Claude archives and replaces (self-heals the stuck-pending-tool + // case). tool_started is Codex-only in practice today, but + // gating by provider keeps the policy consistent with the other + // two branches and avoids a subtle divergence for future + // Claude-side emitters. + if (!currentTurn) { + currentTurn = { + turnId, + text: '', + source: typeof ev.source === 'string' ? ev.source : null, + blocks: {}, + blockOrder: [], + stopReason: null, + usage: null, + task: emptySemanticTaskSnapshot(), + lookups: emptySemanticLookupSnapshot(), + startedAt: now, + endedAt: null, + } + } else if (currentTurn.turnId !== turnId) { + if (sessionKind === 'claude') { + history = [...history, semanticHistoryRow(currentTurn)].slice(-SEMANTIC_HISTORY_CAP) + currentTurn = { + turnId, + text: '', + source: typeof ev.source === 'string' ? ev.source : null, + blocks: {}, + blockOrder: [], + stopReason: null, + usage: null, + task: emptySemanticTaskSnapshot(), + lookups: emptySemanticLookupSnapshot(), + startedAt: now, + endedAt: null, + } + } else { + break + } + } + const existing = Object.values(currentTurn.blocks).find(block => block.toolUseId === callId) + if (existing) break + const numericIndices = Object.keys(currentTurn.blocks) + .map(value => Number(value)) + .filter(value => Number.isFinite(value)) + const nextIndex = + numericIndices.length > 0 ? Math.max(...numericIndices) + 1 : 0 + const label = typeof ev.label === 'string' ? ev.label : '' + const toolKind = ev.tool === 'mcp' ? 'mcp_tool_use' : 'tool_use' + const toolName = + ev.tool === 'exec' + ? 'exec_command' + : ev.tool === 'mcp' + ? label || 'mcp' + : label || (typeof ev.tool === 'string' ? ev.tool : 'tool') + currentTurn = { + ...currentTurn, + source: typeof ev.source === 'string' ? ev.source : currentTurn.source, + blocks: { + ...currentTurn.blocks, + [nextIndex]: { + blockIndex: nextIndex, + kind: toolKind, + toolName, + toolUseId: callId, + text: '', + thinking: '', + inputJson: label, + }, + }, + blockOrder: [...currentTurn.blockOrder, nextIndex], + } + break + } + case 'tool_output_delta': { + if (!currentTurn) break + const callId = typeof ev.callId === 'string' ? ev.callId : null + if (!callId) break + const match = Object.entries(currentTurn.blocks).find(([, block]) => block.toolUseId === callId) + if (!match) break + const idx = Number(match[0]) + const block = match[1] + currentTurn = { + ...currentTurn, + blocks: { + ...currentTurn.blocks, + [idx]: { + ...block, + resultContent: + typeof ev.textDelta === 'string' + ? (block.resultContent ?? '') + ev.textDelta + : block.resultContent, + }, + }, + } + break + } + case 'tool_completed': { + if (!currentTurn) break + const callId = typeof ev.callId === 'string' ? ev.callId : null + if (!callId) break + const match = Object.entries(currentTurn.blocks).find(([, block]) => block.toolUseId === callId) + if (!match) break + const idx = Number(match[0]) + const block = match[1] + currentTurn = { + ...currentTurn, + blocks: { + ...currentTurn.blocks, + [idx]: { + ...block, + resultContent: block.resultContent ?? '', + resultIsError: + typeof ev.exitCode === 'number' ? ev.exitCode !== 0 : block.resultIsError, + resultAt: now, + }, + }, + } + break + } + case 'usage_updated': { + if (!currentTurn) break + const usage = ev.usage as Record | undefined + if (!usage) break + currentTurn = { ...currentTurn, usage: flattenSemanticUsage(usage) } + break + } + case 'turn_stopped': { + if (!currentTurn) break + currentTurn = { + ...currentTurn, + stopReason: typeof ev.stopReason === 'string' ? ev.stopReason : null, + endedAt: now, + } + break + } + case 'turn_completed': { + if (!currentTurn) break + const completedTurn = { ...currentTurn, endedAt: currentTurn.endedAt ?? now } + if (hasPendingSemanticTools(completedTurn)) { + currentTurn = completedTurn + } else { + history = [ + ...history, + semanticHistoryRow(completedTurn), + ].slice(-SEMANTIC_HISTORY_CAP) + currentTurn = null + } + break + } + case 'api_error': + case 'stream_error': { + errors = [ + ...errors, + { + ts: now, + kind: t, + message: String(ev.message ?? '(no message)'), + }, + ].slice(-SEMANTIC_ERROR_CAP) + break + } + } + + // WHY the trailing derive is conditional on event type: + // `deriveSemanticTaskSnapshot` is O(n_blocks) and rebuilds + // `toolCallsById` from scratch. At streaming peak we get many + // text_delta / thinking_delta / signature events per second that + // ONLY mutate fields the derivation doesn't read (block.text, + // block.thinking, block.signature). Running the derive on those + // events is pure waste. Events in the allow-list below are the + // only ones that can change what derive sees — block lifecycle, + // tool input (goes into inputJson), finalized parse (TodoWrite + // parsedInput), or completed `turn_completed` where the result + // is pushed to history even if currentTurn survives this tick. + // + // block_started and tool_result already derive inline, so for + // them the trailing run is a second computation — intentional + // here, because their inline branch leaves currentTurn with the + // derived values and the trailing run is a cheap no-op pass that + // keeps this block the single place responsible for derived + // fields when currentTurn remains live. + // + // Codex also emits `tool_started` / `tool_output_delta` / + // `tool_completed` (non-Anthropic tool lifecycle — see the + // `tool_started` / `tool_completed` branches above, which synthesize + // tool_use blocks and stamp resultAt / resultIsError). Those MUST be + // in this allow-list: without them `deriveSemanticTaskSnapshot` + // never sees a new block entering `in_progress`, never sees it + // transition to `completed`/`error`, and `lookups.toolCallsById`, + // `lookups.resolvedToolUseIds`, `task.activeToolNames`, and + // `task.inProgressToolUseIds` go stale until some Anthropic-style + // event happens to retrigger derive. The user-visible symptom was + // Codex tool rows stuck showing "running" forever after completion. + const DERIVE_EVENT_TYPES = new Set([ + 'block_started', + 'tool_input_delta', + 'tool_input_finalized', + 'tool_result', + 'tool_started', + 'tool_output_delta', + 'tool_completed', + ]) + const finalCurrentTurn = currentTurn + ? DERIVE_EVENT_TYPES.has(t) + ? (() => { + const derived = deriveSemanticTaskSnapshot(currentTurn.blocks) + return { + ...currentTurn, + task: derived.task, + lookups: derived.lookups, + } + })() + : currentTurn + : null + + // No-op short-circuit: every event used to allocate a new log array + // and bump nextLogId at the top of the function, which meant the + // returned state was never === state, even for events that changed + // nothing (tool_result without a matching turn, usage_updated with + // unchanged usage, etc.). That fired setRuntimes + the whole + // reactive chain on every dead event — eight of them at bootstrap + // alone per the 2026-04-20 evidence log. + // + // Reference equality on flows/currentTurn/history/errors is the + // correct signal here because every branch that mutates those + // fields rebinds the local to a new reference. If all four locals + // still point at state.* AND finalCurrentTurn is identically + // currentTurn (the derive skipped), this event was a no-op and we + // return state unchanged. The log append for the event is skipped + // too — dead events don't belong in the debug log either. + if ( + flows === state.flows && + finalCurrentTurn === state.currentTurn && + history === state.history && + errors === state.errors + ) { + return state + } + + const summary = summarizeSemanticEvent(ev) + const logEntry = { + id: state.nextLogId, + type: String(ev.type ?? '?'), + ts: now, + summary, + raw: ev, + } + const log = [...state.log, logEntry] + if (log.length > SEMANTIC_LOG_CAP) { + log.splice(0, log.length - SEMANTIC_LOG_CAP) + } + + return { + ...state, + flows, + currentTurn: finalCurrentTurn, + history, + errors, + log, + nextLogId: state.nextLogId + 1, + } +} diff --git a/src/renderer/src/workspace/semantic/helpers.ts b/src/renderer/src/workspace/semantic/helpers.ts new file mode 100644 index 000000000..95440eae7 --- /dev/null +++ b/src/renderer/src/workspace/semantic/helpers.ts @@ -0,0 +1,277 @@ +import { + parseSemanticTodos, + type SemanticLiveTurn, + type SemanticRuntimeState, + type SessionRuntime, + type SessionStatus, + type SessionStatusSource, +} from '../workspaceState' + +// --------------------------------------------------------------------------- +// Size caps for the semantic runtime ring buffers +// --------------------------------------------------------------------------- +// +// Sessions can emit hundreds of semantic events per minute during a +// busy turn. Unbounded accumulation of log/history/error arrays +// bloats memory and makes the proxy debug panel render slower. The +// caps here are generous for interactive use — past these sizes the +// oldest entries drop off the head. See foldSemanticEvent.ts for the +// .slice(-CAP) trim applied after each push. + +export const SEMANTIC_LOG_CAP = 200 +export const SEMANTIC_HISTORY_CAP = 20 +export const SEMANTIC_ERROR_CAP = 20 + +// --------------------------------------------------------------------------- +// Small pure helpers on SemanticEvent payloads +// --------------------------------------------------------------------------- + +/** Narrow an unknown value to a finite number or null. Used when + * reading `blockIndex` off an event payload — the schema says + * number, but payloads are typed `unknown` at the fold boundary + * so we defensively check. */ +export function semanticToIndex(v: unknown): number | null { + return typeof v === 'number' && Number.isFinite(v) ? v : null +} + +/** Truncate a semantic id for display. turn ids and tool-use ids + * can be 24+ chars long; the debug panel wants an at-a-glance + * string, not a full correlation id. */ +export function trimSemanticId(v: unknown): string { + const s = typeof v === 'string' ? v : '' + return s.length > 14 ? s.slice(0, 14) + '…' : s +} + +/** Flatten `usage` — a nested object of token counters — into a + * single-level Record so the debug panel + * can render it as key/value rows without recursive traversal. + * Sub-object keys become dotted paths (`cache.reads`). */ +export function flattenSemanticUsage( + u: Record, +): Record { + const out: Record = {} + for (const [k, v] of Object.entries(u)) { + if (typeof v === 'number' || typeof v === 'string') out[k] = v + else if (v && typeof v === 'object') { + for (const [ck, cv] of Object.entries(v as Record)) { + if (typeof cv === 'number' || typeof cv === 'string') { + out[`${k}.${ck}`] = cv + } + } + } + } + return out +} + +/** Project a completed live turn into a history row — the summary + * shape that survives after the turn archives. History rows lose + * the per-block map to keep memory bounded; consumers that need + * block detail for a past turn must read from the raw transcript. */ +export function semanticHistoryRow( + turn: SemanticLiveTurn, +): Pick { + return { + turnId: turn.turnId, + text: turn.text, + stopReason: turn.stopReason, + startedAt: turn.startedAt, + endedAt: turn.endedAt, + } +} + +/** True when the turn is still live — hasn't received its + * terminal `turn_stopped`/`turn_completed` yet. Used by session- + * status derivation and the foldSemanticEvent branch that decides + * whether to archive a turn or keep it open across a pending + * tool_result. */ +export function isSemanticTurnRunning(turn: SemanticLiveTurn | null): boolean { + return turn !== null && turn.endedAt === null +} + +// --------------------------------------------------------------------------- +// Session status derivation +// --------------------------------------------------------------------------- +// +// Single source of truth for "what phase is this session in". +// Composes five independent signals into one SessionStatus string: +// - exited (session_exit event) +// - semantic (turn actively streaming) +// - process (provider subprocess active) +// - submit (optimistic awaitingAssistant flag) +// - idle (none of the above) +// The earlier a branch fires, the higher its priority — so a dead +// session always reports 'exited' even if a stale process flag +// lingered. + +export function deriveSessionStatus(runtime: SessionRuntime): { + sessionStatus: SessionStatus + sessionStatusSource: SessionStatusSource +} { + if (runtime.exited !== null) { + return { sessionStatus: 'exited', sessionStatusSource: 'exit' } + } + if (isSemanticTurnRunning(runtime.semantic.currentTurn)) { + return { sessionStatus: 'running', sessionStatusSource: 'semantic' } + } + if (runtime.processActive) { + return { sessionStatus: 'running', sessionStatusSource: 'process' } + } + if (runtime.awaitingAssistant) { + return { sessionStatus: 'running', sessionStatusSource: 'submit' } + } + return { sessionStatus: 'idle', sessionStatusSource: 'none' } +} + +/** Merge the derived status fields back onto a runtime patch. + * Every setRuntimes mutation that might affect status goes through + * this so the status slot stays consistent with its inputs. */ +export function withDerivedSessionStatus(runtime: SessionRuntime): SessionRuntime { + return { + ...runtime, + ...deriveSessionStatus(runtime), + } +} + +// --------------------------------------------------------------------------- +// Pending-tool bookkeeping +// --------------------------------------------------------------------------- + +/** True when the turn still has at least one tool_use block that's + * waiting on its tool_result. The foldSemanticEvent `turn_completed` + * branch keeps the turn alive whenever this is true so a late + * tool_result doesn't land in a closed turn. */ +export function hasPendingSemanticTools(turn: SemanticLiveTurn): boolean { + return Object.values(turn.blocks).some(block => { + if ( + block.kind !== 'tool_use' && + block.kind !== 'server_tool_use' && + block.kind !== 'mcp_tool_use' + ) { + return false + } + return block.toolUseId != null && block.resultAt == null + }) +} + +// --------------------------------------------------------------------------- +// Empty-state factories +// --------------------------------------------------------------------------- + +export function emptySemanticTaskSnapshot() { + return { + todos: [], + doneCount: 0, + totalCount: 0, + inProgressToolUseIds: [] as string[], + activeToolNames: [] as string[], + } +} + +export function emptySemanticLookupSnapshot(): SemanticLiveTurn['lookups'] { + return { + toolCallsById: {}, + toolUseIdsInOrder: [], + resolvedToolUseIds: [], + erroredToolUseIds: [], + } +} + +// --------------------------------------------------------------------------- +// Task snapshot derivation +// --------------------------------------------------------------------------- + +/** + * WHY derive a lookup snapshot here instead of teaching Feed to scan + * blocks every render: + * + * Upstream Claude does not let render components rediscover tool state + * from raw transcript rows. It builds a relationship layer first + * (`toolUseByToolUseID`, `toolResultByToolUseID`, `resolvedToolUseIDs`, + * sibling sets, progress maps) and then renders from that. This smaller + * semantic lookup snapshot is the same idea for cc-shell's live turn: + * keep the expensive / correctness-sensitive "which tool is still live, + * which one errored, which tools were siblings in this turn?" logic in + * the shared reducer so every surface reads the same answer. + */ +export function deriveSemanticTaskSnapshot( + blocks: Record, +): { + task: SemanticLiveTurn['task'] + lookups: SemanticLiveTurn['lookups'] +} { + const inProgressToolUseIds: string[] = [] + const activeToolNames: string[] = [] + const resolvedToolUseIds: string[] = [] + const erroredToolUseIds: string[] = [] + const toolUseIdsInOrder: string[] = [] + const toolCallsById: SemanticLiveTurn['lookups']['toolCallsById'] = {} + let todos = emptySemanticTaskSnapshot().todos + + const orderedBlocks = Object.values(blocks).sort((a, b) => a.blockIndex - b.blockIndex) + for (const block of orderedBlocks) { + if ( + block.kind !== 'tool_use' && + block.kind !== 'server_tool_use' && + block.kind !== 'mcp_tool_use' + ) { + continue + } + const hasResult = block.resultAt != null + if (block.toolUseId && !hasResult) { + inProgressToolUseIds.push(block.toolUseId) + if (block.toolName) activeToolNames.push(block.toolName) + } + if (block.toolUseId) { + toolUseIdsInOrder.push(block.toolUseId) + if (hasResult) { + resolvedToolUseIds.push(block.toolUseId) + if (block.resultIsError) erroredToolUseIds.push(block.toolUseId) + } + toolCallsById[block.toolUseId] = { + toolUseId: block.toolUseId, + blockIndex: block.blockIndex, + kind: block.kind, + toolName: block.toolName ?? null, + status: block.resultIsError + ? 'error' + : hasResult + ? 'completed' + : 'in_progress', + inputJson: block.inputJson ?? '', + resultContent: block.resultContent ?? null, + } + } + if (block.toolName === 'TodoWrite' && block.parsedInput) { + todos = parseSemanticTodos(block.parsedInput) + } + } + + const dedupedToolNames = [...new Set(activeToolNames)] + const doneCount = todos.filter(todo => todo.status === 'completed').length + return { + task: { + todos, + doneCount, + totalCount: todos.length, + inProgressToolUseIds, + activeToolNames: dedupedToolNames, + }, + lookups: { + toolCallsById, + toolUseIdsInOrder, + resolvedToolUseIds, + erroredToolUseIds, + }, + } +} + +// Re-export the foundational types so foldEvent.ts can import them +// through this module rather than reaching into workspaceState directly +// (keeps the semantic/* layer coherent). +export type { + SemanticLiveTurn, + SemanticRuntimeState, + SessionRuntime, + SessionStatus, + SessionStatusSource, +} diff --git a/src/renderer/src/workspace/semantic/summarize.ts b/src/renderer/src/workspace/semantic/summarize.ts new file mode 100644 index 000000000..2f4c09d90 --- /dev/null +++ b/src/renderer/src/workspace/semantic/summarize.ts @@ -0,0 +1,86 @@ +import { trimSemanticId } from './helpers' + +// One-line summaries for the proxy debug panel event tail + the +// feed-debug log SEM layer. Both surfaces render tens of lines at +// once; compact, copy/pasteable strings beat raw JSON for triage. +// `summarizeSemanticEvent` is the terse column-oriented view; +// `summarizeSemanticEventForDebug` is the verbose sidebar view that +// includes routing info (turnId / blockIndex / toolName / source / +// stopReason). + +export function summarizeSemanticEvent(ev: Record): string { + const t = String(ev.type ?? '') + switch (t) { + case 'turn_started': + return `turn_started ${trimSemanticId(ev.turnId)} (${ev.source ?? '?'})` + case 'turn_delta': { + const ft = typeof ev.fullText === 'string' ? ev.fullText : '' + return `turn_delta len=${ft.length}` + } + case 'text_delta': + return `text_delta idx=${ev.blockIndex} +${String(ev.textDelta ?? '').length}` + case 'thinking_delta': + return `thinking_delta idx=${ev.blockIndex} +${String(ev.thinkingDelta ?? '').length}` + case 'connector_text_delta': + return `connector_text_delta idx=${ev.blockIndex} +${String(ev.connectorTextDelta ?? '').length}` + case 'citations_delta': + return `citations_delta idx=${ev.blockIndex}` + case 'tool_input_delta': + return `tool_input_delta idx=${ev.blockIndex} ${ev.toolName ?? '?'}` + case 'tool_input_finalized': + return `tool_input_finalized idx=${ev.blockIndex} ${ev.toolName ?? '?'} ${ev.parsed ? '[ok]' : '[bad]'}` + case 'block_started': + return `block_started idx=${ev.blockIndex} ${ev.kind}${ev.toolName ? ` (${ev.toolName})` : ''}` + case 'block_completed': + return `block_completed idx=${ev.blockIndex} ${ev.kind}` + case 'turn_stopped': + return `turn_stopped ${ev.stopReason ?? '?'}` + case 'turn_completed': + return 'turn_completed' + case 'usage_updated': { + const u = ev.usage as Record | undefined + return `usage in=${u?.input_tokens ?? '?'} out=${u?.output_tokens ?? '?'}` + } + case 'flow_selected': + return `flow_selected ${ev.flowId} — ${ev.reason}` + case 'flow_ignored': + return `flow_ignored ${ev.flowId} — ${ev.reason}` + case 'api_error': + return `api_error ${ev.errorType ?? ''} — ${String(ev.message ?? '').slice(0, 60)}` + case 'stream_error': + return `stream_error ${ev.errorType ?? ''} — ${String(ev.message ?? '').slice(0, 60)}` + case 'source_changed': + return `source_changed ${ev.previousSource ?? '?'} → ${ev.source ?? '?'}` + case 'signature': + return `signature idx=${ev.blockIndex}` + case 'tool_result': + return `tool_result ${trimSemanticId(ev.toolUseId)} ${ev.isError ? '[error]' : ''}` + case 'tool_started': + return `tool_started ${trimSemanticId(ev.callId)} ${String(ev.label ?? ev.tool ?? '')}`.trim() + case 'tool_output_delta': + return `tool_output_delta ${trimSemanticId(ev.callId)} +${String(ev.textDelta ?? '').length}` + case 'tool_completed': + return `tool_completed ${trimSemanticId(ev.callId)} exit=${String(ev.exitCode ?? '-')}` + default: + return t + } +} + +/** Verbose summary used in the SEM layer of the feed-debug log. */ +export function summarizeSemanticEventForDebug(event: Record): string { + const type = typeof event.type === 'string' ? event.type : 'unknown' + const turnId = typeof event.turnId === 'string' ? event.turnId : null + const source = typeof event.source === 'string' ? event.source : null + const toolName = typeof event.toolName === 'string' ? event.toolName : null + const blockIndex = + typeof event.blockIndex === 'number' ? event.blockIndex : null + const stopReason = + typeof event.stopReason === 'string' ? event.stopReason : null + const parts = [type] + if (source) parts.push(`src=${source}`) + if (turnId) parts.push(`turn=${turnId.slice(0, 10)}`) + if (blockIndex !== null) parts.push(`block=${blockIndex}`) + if (toolName) parts.push(`tool=${toolName}`) + if (stopReason) parts.push(`stop=${stopReason}`) + return parts.join(' ') +} diff --git a/src/renderer/src/workspace/spawn/errorMessage.ts b/src/renderer/src/workspace/spawn/errorMessage.ts new file mode 100644 index 000000000..5d47e05d0 --- /dev/null +++ b/src/renderer/src/workspace/spawn/errorMessage.ts @@ -0,0 +1,30 @@ +import type { SessionKind } from '../types' + +// Normalize spawn errors so the user-facing toast/showToast has a +// single string to print. When the Claude proxy startup path is the +// source, rewrite the error to actionable text — the raw mitmproxy +// error is useless to an end user and the real fix (turn proxy off +// or rebuild) is what the message should say. + +export function sessionSpawnErrorMessage( + kind: SessionKind, + err: unknown, + useProxy: boolean, +): string { + const raw = + err instanceof Error && err.message.length > 0 + ? err.message + : String(err || `Failed to start ${kind}`) + if ( + kind === 'claude' && + useProxy && + ( + raw.includes('Timed out waiting for mitmproxy') || + raw.includes('Unable to locate mitm') || + raw.includes('mitmdump') + ) + ) { + return 'Claude proxy startup failed. Disable Proxy Streaming in settings or restart the app after rebuilding.' + } + return raw +} diff --git a/src/renderer/src/workspace/workspaceStore.ts b/src/renderer/src/workspace/workspaceStore.ts index b870a3199..755758713 100644 --- a/src/renderer/src/workspace/workspaceStore.ts +++ b/src/renderer/src/workspace/workspaceStore.ts @@ -4,7 +4,6 @@ import { isCompactBoundaryEntry, isCompactSummaryEntry, isConversationEntry, - type ConversationEntry, type Entry, type ToolResultBlock, type ToolUseBlock, @@ -54,7 +53,6 @@ import { emptySemanticRuntime, emptyRuntime, parseSemanticTodos, - type ClaudeDraftImage, type FeedDebugEntry, type FeedDebugLayer, type PickerItem, @@ -85,6 +83,74 @@ import { import { reduceGhostLogSansSuperseded as reduceGhostLog } from 'agent-transcript-parser/ghost' import type { PlacementTarget } from '../features/workspace/lib/newAgentPlacement' +// ----------------------------------------------------------------------------- +// Extracted pure modules (2026-04-23 refactor — see task #5) +// +// Everything below the hook used to live inline in this file. Pure +// functions that the hook calls but doesn't close over got lifted +// into focused sibling modules: +// +// ./semantic/ foldSemanticEvent + helpers + summarizers +// ./codex/ rollout mapper, factories, envelope stripping +// ./claude/ history-marker / progress-entry unwrapping +// ./entries/utils entry text extraction + tool-use indexing +// ./runtime/feedDebug the appendFeedDebugLog helper +// ./spawn/ session-spawn error message normalizer +// ./layout/helpers titleFromCwd / ratio math / pickerEqual / +// setRatioBetween / sanitizeTileTabsState +// ./persistence PersistedWorkspace type +// +// The hook itself stayed here — it owns per-session refs, IPC +// subscriptions, and cross-cutting mutations (spawn+tree+sessions +// happen together), and splitting it further would either require +// sharing refs across modules or routing everything through a new +// state library. The WHY comments for every extracted chunk moved +// with the code they explain. +// ----------------------------------------------------------------------------- + +import { foldSemanticEvent } from './semantic/foldEvent' +import { summarizeSemanticEventForDebug } from './semantic/summarize' +import { + isSemanticTurnRunning, + withDerivedSessionStatus, +} from './semantic/helpers' +import { + extractCodexProviderSessionId, + isCodexRolloutEntry, + isOptimisticCodexUserEntry, +} from './codex/entries' +import { + codexHistoryMarker, + codexTurnIdFromRollout, + mapCodexRolloutToFeedEntries, + stampCodexTurnId, +} from './codex/rollout' +import { + claudeHistoryMarker, + extractEmbeddedClaudeProgressEntry, +} from './claude/history' +import { + entryTextContent, + indexEntryIntoMaps, + summarizeEntryForDebug, +} from './entries/utils' +import { + appendFeedDebugLog, + type FeedDebugInput, +} from './runtime/feedDebug' +import { sessionSpawnErrorMessage } from './spawn/errorMessage' +import { + equalRatios, + normalizeRatios, + pickerEqual, + ratiosEqual, + sanitizeTileTabsState, + setRatioBetween, + titleFromCwd, +} from './layout/helpers' +import type { PersistedWorkspace } from './persistence' + + // Workspace store — single React hook that owns: // - The workspace state (tabs + tile trees + session metadata) // - Live per-session runtime state (screen, entries, awaitingAssistant, …) @@ -99,1687 +165,6 @@ import type { PlacementTarget } from '../features/workspace/lib/newAgentPlacemen // to the live state refs — one hook means one set of refs. // - Persistence is a single serialized blob; one store = one save. // -// If this grows past ~500 lines we split, but for now it's manageable. - -// --------------------------------------------------------------------------- -// Per-session runtime state (live; NOT persisted to disk) -// --------------------------------------------------------------------------- - -const FEED_DEBUG_LOG_CAP = 500 - -type FeedDebugInput = { - layer: FeedDebugLayer - kind: string - summary: string - data?: unknown -} - -function appendFeedDebugLog( - current: SessionRuntime, - input: FeedDebugInput, -): SessionRuntime { - const ts = Date.now() - const epoch = current.feedDebugEpochMs ?? ts - const nextEntry: FeedDebugEntry = { - id: current.feedDebugNextId, - ts, - tMs: ts - epoch, - layer: input.layer, - kind: input.kind, - summary: input.summary, - data: input.data, - } - const nextLog = - current.feedDebugLog.length >= FEED_DEBUG_LOG_CAP - ? [...current.feedDebugLog.slice(current.feedDebugLog.length - FEED_DEBUG_LOG_CAP + 1), nextEntry] - : [...current.feedDebugLog, nextEntry] - return { - ...current, - feedDebugEpochMs: epoch, - feedDebugNextId: current.feedDebugNextId + 1, - feedDebugLog: nextLog, - } -} - -function summarizeSemanticEventForDebug(event: Record): string { - const type = typeof event.type === 'string' ? event.type : 'unknown' - const turnId = typeof event.turnId === 'string' ? event.turnId : null - const source = typeof event.source === 'string' ? event.source : null - const toolName = typeof event.toolName === 'string' ? event.toolName : null - const blockIndex = - typeof event.blockIndex === 'number' ? event.blockIndex : null - const stopReason = - typeof event.stopReason === 'string' ? event.stopReason : null - const parts = [type] - if (source) parts.push(`src=${source}`) - if (turnId) parts.push(`turn=${turnId.slice(0, 10)}`) - if (blockIndex !== null) parts.push(`block=${blockIndex}`) - if (toolName) parts.push(`tool=${toolName}`) - if (stopReason) parts.push(`stop=${stopReason}`) - return parts.join(' ') -} - -function summarizeEntryForDebug(entry: Entry): string { - const text = entryTextContent(entry) - if (text) { - const compact = text.replace(/\s+/g, ' ').trim() - return `${entry.type}: ${compact.slice(0, 96)}` - } - return entry.type -} - - -function isCodexRolloutEntry(entry: Record): boolean { - const type = entry.type - return ( - type === 'session_meta' || - type === 'response_item' || - type === 'event_msg' || - type === 'turn_context' || - type === 'compacted' - ) -} - -function extractCodexProviderSessionId(entry: Record): string | null { - if (entry.type !== 'session_meta') return null - const payload = entry.payload as Record | undefined - return typeof payload?.id === 'string' ? payload.id : null -} - -function entryTextContent(entry: Entry): string | null { - if (entry.type !== 'user' && entry.type !== 'assistant') return null - const content = (entry as { message?: { content?: unknown } }).message?.content - if (!Array.isArray(content)) return null - const texts = content - .map(block => { - const item = block as Record - return item.type === 'text' && typeof item.text === 'string' ? item.text : null - }) - .filter((text): text is string => text !== null) - return texts.length > 0 ? texts.join('\n') : null -} - -function sessionSpawnErrorMessage( - kind: SessionKind, - err: unknown, - useProxy: boolean, -): string { - const raw = - err instanceof Error && err.message.length > 0 - ? err.message - : String(err || `Failed to start ${kind}`) - if ( - kind === 'claude' && - useProxy && - ( - raw.includes('Timed out waiting for mitmproxy') || - raw.includes('Unable to locate mitm') || - raw.includes('mitmdump') - ) - ) { - return 'Claude proxy startup failed. Disable Proxy Streaming in settings or restart the app after rebuilding.' - } - return raw -} - -function isOptimisticCodexUserEntry(entry: Entry | undefined): boolean { - if (!entry || entry.type !== 'user') return false - return typeof entry.uuid === 'string' && entry.uuid.startsWith('optimistic-codex-user:') -} - -function parseCodexJson(input: string): Record | null { - try { - const parsed = JSON.parse(input) as unknown - return parsed && typeof parsed === 'object' ? parsed as Record : null - } catch { - return null - } -} - -function codexOutputText(output: unknown): string { - if (typeof output === 'string') return output - if (Array.isArray(output)) { - return output - .map(item => { - if (typeof item === 'string') return item - const rec = item as Record - return typeof rec.text === 'string' ? rec.text : JSON.stringify(item, null, 2) - }) - .join('\n') - } - return JSON.stringify(output ?? '', null, 2) -} - -function stripCodexExecWrapper(output: string): string { - const marker = '\nOutput:\n' - const idx = output.indexOf(marker) - if (!output.startsWith('Chunk ID:') || idx === -1) return output - return output.slice(idx + marker.length) -} - -function isCodexExecWrapperOutput(output: string): boolean { - return output.startsWith('Chunk ID:') && output.includes('\nProcess exited with code ') -} - -function codexToolUseEntry( - uuid: string, - timestamp: string | undefined, - id: string, - name: string, - input: unknown, -): Entry { - return { - type: 'assistant', - uuid, - parentUuid: null, - timestamp, - message: { - role: 'assistant', - content: [ - { - type: 'tool_use', - id, - name, - input, - }, - ], - }, - } -} - -function codexAssistantTextEntry( - uuid: string, - timestamp: string | undefined, - text: string, -): Entry { - return { - type: 'assistant', - uuid, - parentUuid: null, - timestamp, - message: { - role: 'assistant', - content: [{ type: 'text', text }], - }, - } -} - -function codexToolResultEntry( - uuid: string, - timestamp: string | undefined, - toolUseId: string, - content: string, - isError = false, - codex?: Record, -): Entry { - const resultBlock = { - type: 'tool_result', - tool_use_id: toolUseId, - content, - is_error: isError, - codex, - } - - return { - type: 'user', - uuid, - parentUuid: null, - timestamp, - message: { - role: 'user', - content: [resultBlock], - }, - } -} - -const SEMANTIC_LOG_CAP = 200 -const SEMANTIC_HISTORY_CAP = 20 -const SEMANTIC_ERROR_CAP = 20 - -function semanticToIndex(v: unknown): number | null { - return typeof v === 'number' && Number.isFinite(v) ? v : null -} - -function trimSemanticId(v: unknown): string { - const s = typeof v === 'string' ? v : '' - return s.length > 14 ? s.slice(0, 14) + '…' : s -} - -function flattenSemanticUsage( - u: Record, -): Record { - const out: Record = {} - for (const [k, v] of Object.entries(u)) { - if (typeof v === 'number' || typeof v === 'string') out[k] = v - else if (v && typeof v === 'object') { - for (const [ck, cv] of Object.entries(v as Record)) { - if (typeof cv === 'number' || typeof cv === 'string') { - out[`${k}.${ck}`] = cv - } - } - } - } - return out -} - -function semanticHistoryRow( - turn: SemanticLiveTurn, -): Pick { - return { - turnId: turn.turnId, - text: turn.text, - stopReason: turn.stopReason, - startedAt: turn.startedAt, - endedAt: turn.endedAt, - } -} - -function isSemanticTurnRunning(turn: SemanticLiveTurn | null): boolean { - return turn !== null && turn.endedAt === null -} - -function deriveSessionStatus(runtime: SessionRuntime): { - sessionStatus: SessionStatus - sessionStatusSource: SessionStatusSource -} { - if (runtime.exited !== null) { - return { sessionStatus: 'exited', sessionStatusSource: 'exit' } - } - if (isSemanticTurnRunning(runtime.semantic.currentTurn)) { - return { sessionStatus: 'running', sessionStatusSource: 'semantic' } - } - if (runtime.processActive) { - return { sessionStatus: 'running', sessionStatusSource: 'process' } - } - if (runtime.awaitingAssistant) { - return { sessionStatus: 'running', sessionStatusSource: 'submit' } - } - return { sessionStatus: 'idle', sessionStatusSource: 'none' } -} - -function withDerivedSessionStatus(runtime: SessionRuntime): SessionRuntime { - return { - ...runtime, - ...deriveSessionStatus(runtime), - } -} - -function hasPendingSemanticTools(turn: SemanticLiveTurn): boolean { - return Object.values(turn.blocks).some(block => { - if ( - block.kind !== 'tool_use' && - block.kind !== 'server_tool_use' && - block.kind !== 'mcp_tool_use' - ) { - return false - } - return block.toolUseId != null && block.resultAt == null - }) -} - -function emptySemanticTaskSnapshot() { - return { - todos: [], - doneCount: 0, - totalCount: 0, - inProgressToolUseIds: [] as string[], - activeToolNames: [] as string[], - } -} - -function emptySemanticLookupSnapshot(): SemanticLiveTurn['lookups'] { - return { - toolCallsById: {}, - toolUseIdsInOrder: [], - resolvedToolUseIds: [], - erroredToolUseIds: [], - } -} - -function deriveSemanticTaskSnapshot( - blocks: Record, -): { - task: SemanticLiveTurn['task'] - lookups: SemanticLiveTurn['lookups'] -} { - const inProgressToolUseIds: string[] = [] - const activeToolNames: string[] = [] - const resolvedToolUseIds: string[] = [] - const erroredToolUseIds: string[] = [] - const toolUseIdsInOrder: string[] = [] - const toolCallsById: SemanticLiveTurn['lookups']['toolCallsById'] = {} - let todos = emptySemanticTaskSnapshot().todos - - // WHY derive a lookup snapshot here instead of teaching Feed to scan blocks - // every render: - // - // Upstream Claude does not let render components rediscover tool state from - // raw transcript rows. It builds a relationship layer first - // (`toolUseByToolUseID`, `toolResultByToolUseID`, `resolvedToolUseIDs`, - // sibling sets, progress maps) and then renders from that. This smaller - // semantic lookup snapshot is the same idea for cc-shell's live turn: keep - // the expensive / correctness-sensitive "which tool is still live, which one - // errored, which tools were siblings in this turn?" logic in the shared - // reducer so every surface reads the same answer. - const orderedBlocks = Object.values(blocks).sort((a, b) => a.blockIndex - b.blockIndex) - for (const block of orderedBlocks) { - if ( - block.kind !== 'tool_use' && - block.kind !== 'server_tool_use' && - block.kind !== 'mcp_tool_use' - ) { - continue - } - const hasResult = block.resultAt != null - if (block.toolUseId && !hasResult) { - inProgressToolUseIds.push(block.toolUseId) - if (block.toolName) activeToolNames.push(block.toolName) - } - if (block.toolUseId) { - toolUseIdsInOrder.push(block.toolUseId) - if (hasResult) { - resolvedToolUseIds.push(block.toolUseId) - if (block.resultIsError) erroredToolUseIds.push(block.toolUseId) - } - toolCallsById[block.toolUseId] = { - toolUseId: block.toolUseId, - blockIndex: block.blockIndex, - kind: block.kind, - toolName: block.toolName ?? null, - status: block.resultIsError - ? 'error' - : hasResult - ? 'completed' - : 'in_progress', - inputJson: block.inputJson ?? '', - resultContent: block.resultContent ?? null, - } - } - if (block.toolName === 'TodoWrite' && block.parsedInput) { - todos = parseSemanticTodos(block.parsedInput) - } - } - - const dedupedToolNames = [...new Set(activeToolNames)] - const doneCount = todos.filter(todo => todo.status === 'completed').length - return { - task: { - todos, - doneCount, - totalCount: todos.length, - inProgressToolUseIds, - activeToolNames: dedupedToolNames, - }, - lookups: { - toolCallsById, - toolUseIdsInOrder, - resolvedToolUseIds, - erroredToolUseIds, - }, - } -} - -function summarizeSemanticEvent(ev: Record): string { - const t = String(ev.type ?? '') - switch (t) { - case 'turn_started': - return `turn_started ${trimSemanticId(ev.turnId)} (${ev.source ?? '?'})` - case 'turn_delta': { - const ft = typeof ev.fullText === 'string' ? ev.fullText : '' - return `turn_delta len=${ft.length}` - } - case 'text_delta': - return `text_delta idx=${ev.blockIndex} +${String(ev.textDelta ?? '').length}` - case 'thinking_delta': - return `thinking_delta idx=${ev.blockIndex} +${String(ev.thinkingDelta ?? '').length}` - case 'connector_text_delta': - return `connector_text_delta idx=${ev.blockIndex} +${String(ev.connectorTextDelta ?? '').length}` - case 'citations_delta': - return `citations_delta idx=${ev.blockIndex}` - case 'tool_input_delta': - return `tool_input_delta idx=${ev.blockIndex} ${ev.toolName ?? '?'}` - case 'tool_input_finalized': - return `tool_input_finalized idx=${ev.blockIndex} ${ev.toolName ?? '?'} ${ev.parsed ? '[ok]' : '[bad]'}` - case 'block_started': - return `block_started idx=${ev.blockIndex} ${ev.kind}${ev.toolName ? ` (${ev.toolName})` : ''}` - case 'block_completed': - return `block_completed idx=${ev.blockIndex} ${ev.kind}` - case 'turn_stopped': - return `turn_stopped ${ev.stopReason ?? '?'}` - case 'turn_completed': - return 'turn_completed' - case 'usage_updated': { - const u = ev.usage as Record | undefined - return `usage in=${u?.input_tokens ?? '?'} out=${u?.output_tokens ?? '?'}` - } - case 'flow_selected': - return `flow_selected ${ev.flowId} — ${ev.reason}` - case 'flow_ignored': - return `flow_ignored ${ev.flowId} — ${ev.reason}` - case 'api_error': - return `api_error ${ev.errorType ?? ''} — ${String(ev.message ?? '').slice(0, 60)}` - case 'stream_error': - return `stream_error ${ev.errorType ?? ''} — ${String(ev.message ?? '').slice(0, 60)}` - case 'source_changed': - return `source_changed ${ev.previousSource ?? '?'} → ${ev.source ?? '?'}` - case 'signature': - return `signature idx=${ev.blockIndex}` - case 'tool_result': - return `tool_result ${trimSemanticId(ev.toolUseId)} ${ev.isError ? '[error]' : ''}` - case 'tool_started': - return `tool_started ${trimSemanticId(ev.callId)} ${String(ev.label ?? ev.tool ?? '')}`.trim() - case 'tool_output_delta': - return `tool_output_delta ${trimSemanticId(ev.callId)} +${String(ev.textDelta ?? '').length}` - case 'tool_completed': - return `tool_completed ${trimSemanticId(ev.callId)} exit=${String(ev.exitCode ?? '-')}` - default: - return t - } -} - -export function foldSemanticEvent( - state: SemanticRuntimeState, - ev: Record, - sessionKind: SessionKind, -): SemanticRuntimeState { - // WHY centralize semantic folding here instead of letting Feed, - // ReaderView, and the proxy debug UI each subscribe separately: - // - // The old architecture created three subtly different truths about the same - // live turn. Feed still derived structure from screen scraping, Reader kept a - // local semantic turn, and the debug panel had yet another reducer. That - // split is exactly how we ended up under-using the proxy stream: every UI - // surface only consumed the subset it happened to care about. - // - // The invariant now is "one session => one semantic reducer". Every semantic - // event, including late tool_result and connector/citation deltas, must flow - // through this fold before any UI reads it. If a future surface wants live - // model structure, it should select from `runtime.semantic`, not open its own - // transport subscription. - const now = Date.now() - - const t = String(ev.type ?? '') - let flows = state.flows - let currentTurn = state.currentTurn - let history = state.history - let errors = state.errors - - switch (t) { - case 'flow_selected': { - const flowId = String(ev.flowId ?? '') - const prev = flows[flowId] - flows = { - ...flows, - [flowId]: { - flowId, - attribution: 'active', - reason: String(ev.reason ?? ''), - turnId: typeof ev.turnId === 'string' ? ev.turnId : null, - firstSeen: prev?.firstSeen ?? now, - lastSeen: now, - bytesEstimate: prev?.bytesEstimate ?? 0, - chunkCount: prev?.chunkCount ?? 0, - }, - } - break - } - case 'flow_ignored': { - const flowId = String(ev.flowId ?? '') - const prev = flows[flowId] - flows = { - ...flows, - [flowId]: { - flowId, - attribution: 'ignored', - reason: String(ev.reason ?? ''), - turnId: typeof ev.turnId === 'string' ? ev.turnId : null, - firstSeen: prev?.firstSeen ?? now, - lastSeen: now, - bytesEstimate: prev?.bytesEstimate ?? 0, - chunkCount: prev?.chunkCount ?? 0, - }, - } - break - } - case 'turn_started': { - const turnId = String(ev.turnId ?? '') - if (!turnId) break - // Provider-gated turn ownership. - // - // Codex (strict): mismatched turnIds are DROPPED because they - // come from racing producers (proxy flow + screen fallback, or - // two concurrent proxy flows). Replacing currentTurn on their - // say-so wipes the block map the live renderer is already - // showing — the 0/1/0/1 flicker documented in - // docs/superpowers/plans/2026-04-17-codex-semantic-flicker-fix.md. - // - // Claude (auto-replace): archive the stuck turn and open the - // new one. Claude legitimately keeps currentTurn alive across - // turn boundaries while a cross-turn tool_result is pending - // (turn_completed below retains the turn when - // hasPendingSemanticTools is true). The NEXT assistant turn's - // message_start carries a fresh msg_id that mismatches the - // pinned turnId; dropping it would silently hide every - // subsequent Claude turn. This restores the reducer's pre-flicker-fix - // behavior for Claude only. See - // docs/superpowers/plans/2026-04-17-claude-semantic-provider-gating.md. - // - // Same-turnId refresh (re-entry / source promotion) is - // identical for both providers. - if (!currentTurn) { - currentTurn = { - turnId, - text: '', - source: typeof ev.source === 'string' ? ev.source : null, - blocks: {}, - blockOrder: [], - stopReason: null, - usage: null, - task: emptySemanticTaskSnapshot(), - lookups: emptySemanticLookupSnapshot(), - startedAt: now, - endedAt: null, - } - } else if (currentTurn.turnId === turnId) { - currentTurn = { - ...currentTurn, - source: typeof ev.source === 'string' ? ev.source : currentTurn.source, - } - } else if (sessionKind === 'claude') { - history = [...history, semanticHistoryRow(currentTurn)].slice(-SEMANTIC_HISTORY_CAP) - currentTurn = { - turnId, - text: '', - source: typeof ev.source === 'string' ? ev.source : null, - blocks: {}, - blockOrder: [], - stopReason: null, - usage: null, - task: emptySemanticTaskSnapshot(), - lookups: emptySemanticLookupSnapshot(), - startedAt: now, - endedAt: null, - } - } - // Codex: mismatched turnId falls through — drop the event. - break - } - case 'source_changed': { - if (!currentTurn) break - currentTurn = { - ...currentTurn, - source: typeof ev.source === 'string' ? ev.source : currentTurn.source, - } - break - } - case 'turn_delta': { - const turnId = typeof ev.turnId === 'string' ? ev.turnId : null - if (!turnId) break - // Soft-open allowed when there's no currentTurn (e.g. Codex's - // rollout agent_message_delta can arrive before task_started). - // - // On turnId mismatch: - // - Claude: archive the pinned old turn and open a new one. - // Same rationale as the turn_started branch above. - // - Codex: drop. Racing producers must not mutate a - // currentTurn that doesn't belong to them (flicker defense). - if (!currentTurn) { - currentTurn = { - turnId, - text: '', - source: typeof ev.source === 'string' ? ev.source : null, - blocks: {}, - blockOrder: [], - stopReason: null, - usage: null, - task: emptySemanticTaskSnapshot(), - lookups: emptySemanticLookupSnapshot(), - startedAt: now, - endedAt: null, - } - } else if (currentTurn.turnId !== turnId) { - if (sessionKind === 'claude') { - history = [...history, semanticHistoryRow(currentTurn)].slice(-SEMANTIC_HISTORY_CAP) - currentTurn = { - turnId, - text: '', - source: typeof ev.source === 'string' ? ev.source : null, - blocks: {}, - blockOrder: [], - stopReason: null, - usage: null, - task: emptySemanticTaskSnapshot(), - lookups: emptySemanticLookupSnapshot(), - startedAt: now, - endedAt: null, - } - } else { - break - } - } - currentTurn = { - ...currentTurn, - text: typeof ev.fullText === 'string' ? ev.fullText : currentTurn.text, - source: typeof ev.source === 'string' ? ev.source : currentTurn.source, - } - break - } - case 'block_started': { - if (!currentTurn) break - const idx = semanticToIndex(ev.blockIndex) - if (idx === null) break - // Codex emits `callId` where Claude emits `toolUseId`. Both feed - // the same downstream tool-result pairing logic — populate - // whichever the upstream sent, and mirror into the other so - // existing consumers (e.g. the tool_result match in this file - // at the toolUseId path) work regardless of source provider. - const callId = typeof ev.callId === 'string' ? ev.callId : undefined - const toolUseId = typeof ev.toolUseId === 'string' ? ev.toolUseId : callId - const messagePhase = - ev.messagePhase === 'commentary' || ev.messagePhase === 'final_answer' - ? (ev.messagePhase as 'commentary' | 'final_answer') - : undefined - currentTurn = { - ...currentTurn, - blocks: { - ...currentTurn.blocks, - [idx]: { - blockIndex: idx, - kind: String(ev.kind ?? 'other'), - toolName: typeof ev.toolName === 'string' ? ev.toolName : undefined, - toolUseId, - callId, - itemId: typeof ev.itemId === 'string' ? ev.itemId : undefined, - messagePhase, - status: typeof ev.status === 'string' ? ev.status : undefined, - text: '', - thinking: '', - inputJson: '', - }, - }, - blockOrder: currentTurn.blockOrder.includes(idx) - ? currentTurn.blockOrder - : [...currentTurn.blockOrder, idx], - } - // Task/lookups derivation intentionally skipped here. The - // trailing `finalCurrentTurn` computation at the bottom of - // this reducer unconditionally re-derives from - // `currentTurn.blocks`, so doing it inline would just be dead - // work overwritten on the same event. The tool_result branch - // DOES need its own inline derive because that branch can - // push the turn to history and set currentTurn=null, skipping - // the trailing computation. - break - } - case 'text_delta': { - if (!currentTurn) break - const idx = semanticToIndex(ev.blockIndex) - if (idx === null) break - const block = currentTurn.blocks[idx] - if (!block) break - currentTurn = { - ...currentTurn, - blocks: { - ...currentTurn.blocks, - [idx]: { - ...block, - text: - typeof ev.textSoFar === 'string' - ? ev.textSoFar - : (block.text ?? '') + String(ev.textDelta ?? ''), - }, - }, - } - break - } - case 'connector_text_delta': { - if (!currentTurn) break - const idx = semanticToIndex(ev.blockIndex) - if (idx === null) break - const block = currentTurn.blocks[idx] - if (!block) break - currentTurn = { - ...currentTurn, - blocks: { - ...currentTurn.blocks, - [idx]: { - ...block, - text: - typeof ev.connectorTextSoFar === 'string' - ? ev.connectorTextSoFar - : (block.text ?? '') + String(ev.connectorTextDelta ?? ''), - }, - }, - } - break - } - case 'thinking_delta': { - if (!currentTurn) break - const idx = semanticToIndex(ev.blockIndex) - if (idx === null) break - const block = currentTurn.blocks[idx] - if (!block) break - currentTurn = { - ...currentTurn, - blocks: { - ...currentTurn.blocks, - [idx]: { - ...block, - thinking: - typeof ev.thinkingSoFar === 'string' - ? ev.thinkingSoFar - : (block.thinking ?? '') + String(ev.thinkingDelta ?? ''), - }, - }, - } - break - } - case 'citations_delta': { - if (!currentTurn) break - const idx = semanticToIndex(ev.blockIndex) - if (idx === null) break - const block = currentTurn.blocks[idx] - if (!block) break - const citations = Array.isArray(ev.citationsSoFar) - ? [...ev.citationsSoFar] - : [...(block.citations ?? []), ev.citationsDelta] - currentTurn = { - ...currentTurn, - blocks: { - ...currentTurn.blocks, - [idx]: { - ...block, - citations, - }, - }, - } - break - } - case 'signature': { - if (!currentTurn) break - const idx = semanticToIndex(ev.blockIndex) - if (idx === null) break - const block = currentTurn.blocks[idx] - if (!block) break - currentTurn = { - ...currentTurn, - blocks: { - ...currentTurn.blocks, - [idx]: { - ...block, - signature: typeof ev.signature === 'string' ? ev.signature : block.signature, - }, - }, - } - break - } - case 'tool_input_delta': { - if (!currentTurn) break - const idx = semanticToIndex(ev.blockIndex) - if (idx === null) break - const block = currentTurn.blocks[idx] - if (!block) break - currentTurn = { - ...currentTurn, - blocks: { - ...currentTurn.blocks, - [idx]: { - ...block, - inputJson: - typeof ev.inputJsonSoFar === 'string' - ? ev.inputJsonSoFar - : (block.inputJson ?? '') + String(ev.partialJson ?? ''), - toolName: block.toolName ?? (typeof ev.toolName === 'string' ? ev.toolName : undefined), - toolUseId: block.toolUseId ?? (typeof ev.toolUseId === 'string' ? ev.toolUseId : undefined), - }, - }, - } - break - } - case 'tool_input_finalized': { - if (!currentTurn) break - const idx = semanticToIndex(ev.blockIndex) - if (idx === null) break - const block = currentTurn.blocks[idx] - if (!block) break - currentTurn = { - ...currentTurn, - blocks: { - ...currentTurn.blocks, - [idx]: { - ...block, - inputJson: typeof ev.inputJson === 'string' ? ev.inputJson : block.inputJson, - inputJsonValid: Boolean(ev.parsed), - parsedInput: - ev.parsed && typeof ev.parsed === 'object' - ? ev.parsed as Record - : block.parsedInput, - parseError: - typeof ev.parseError === 'string' ? ev.parseError : block.parseError, - finalized: true, - }, - }, - } - break - } - case 'block_completed': { - if (!currentTurn) break - const idx = semanticToIndex(ev.blockIndex) - if (idx === null) break - const block = currentTurn.blocks[idx] - if (!block) break - // Codex sends typed fields for each ResponseItem variant; Claude - // sends `parsed` for tool input. Merge both shapes here so the - // renderer doesn't have to branch on provider. `parsed` (Claude) - // and `parsedArguments` (Codex) populate the same `parsedInput` - // slot; `inputJson` (Claude) and `argumentsJson` (Codex) populate - // the same `inputJson` slot. - const parsedObj = - ev.parsed && typeof ev.parsed === 'object' - ? (ev.parsed as Record) - : ev.parsedArguments && typeof ev.parsedArguments === 'object' - ? (ev.parsedArguments as Record) - : block.parsedInput - const argsRaw = - typeof ev.inputJson === 'string' - ? ev.inputJson - : typeof ev.argumentsJson === 'string' - ? ev.argumentsJson - : block.inputJson - const callId = typeof ev.callId === 'string' ? ev.callId : block.callId - currentTurn = { - ...currentTurn, - blocks: { - ...currentTurn.blocks, - [idx]: { - ...block, - kind: typeof ev.kind === 'string' ? ev.kind : block.kind, - text: typeof ev.text === 'string' ? ev.text : block.text, - signature: typeof ev.signature === 'string' ? ev.signature : block.signature, - toolName: typeof ev.toolName === 'string' ? ev.toolName : block.toolName, - toolUseId: - typeof ev.toolUseId === 'string' - ? ev.toolUseId - : (callId ?? block.toolUseId), - callId, - inputJson: argsRaw, - argumentsJson: - typeof ev.argumentsJson === 'string' ? ev.argumentsJson : block.argumentsJson, - inputJsonValid: - parsedObj === block.parsedInput ? block.inputJsonValid : Boolean(parsedObj), - parsedInput: parsedObj, - parseError: - typeof ev.parseError === 'string' ? ev.parseError : block.parseError, - status: typeof ev.status === 'string' ? ev.status : block.status, - finalized: true, - citations: - ev.raw && typeof ev.raw === 'object' && Array.isArray((ev.raw as { citations?: unknown[] }).citations) - ? [...((ev.raw as { citations: unknown[] }).citations)] - : block.citations, - // Codex-specific typed variant payloads. Forward as-is; - // the renderer picks the right one based on `kind`. - output: ev.output !== undefined ? ev.output : block.output, - webSearchAction: - ev.webSearchAction && typeof ev.webSearchAction === 'object' - ? (ev.webSearchAction as SemanticLiveBlock['webSearchAction']) - : block.webSearchAction, - imageGeneration: - ev.imageGeneration && typeof ev.imageGeneration === 'object' - ? (ev.imageGeneration as SemanticLiveBlock['imageGeneration']) - : block.imageGeneration, - localShellCall: - ev.localShellCall && typeof ev.localShellCall === 'object' - ? (ev.localShellCall as SemanticLiveBlock['localShellCall']) - : block.localShellCall, - reasoningSummary: - typeof ev.reasoningSummary === 'string' - ? ev.reasoningSummary - : block.reasoningSummary, - reasoningText: - typeof ev.reasoningText === 'string' - ? ev.reasoningText - : block.reasoningText, - }, - }, - } - break - } - case 'tool_result': { - // WHY attach results onto the originating tool block instead of creating a - // fresh pseudo-entry here: - // - // The semantic stream's job is to preserve the model's live structure. A - // tool result is not a new assistant block; it is the resolution of a - // previous tool_use. Storing it on the tool block keeps the renderer's - // pairing logic trivial and avoids inventing extra ordering rules in the - // store. If we later build a richer agent/task panel, it can still derive - // timeline rows from this normalized shape. - if (!currentTurn || typeof ev.toolUseId !== 'string') break - if (typeof ev.turnId === 'string' && ev.turnId !== currentTurn.turnId) break - const match = Object.entries(currentTurn.blocks).find(([, block]) => block.toolUseId === ev.toolUseId) - if (!match) break - const idx = Number(match[0]) - const block = match[1] - currentTurn = { - ...currentTurn, - blocks: { - ...currentTurn.blocks, - [idx]: { - ...block, - resultContent: typeof ev.content === 'string' ? ev.content : block.resultContent, - resultIsError: ev.isError === true, - resultAt: now, - }, - }, - } - { - const derived = deriveSemanticTaskSnapshot(currentTurn.blocks) - const nextTurn = { - ...currentTurn, - task: derived.task, - lookups: derived.lookups, - } - if (nextTurn.endedAt != null && !hasPendingSemanticTools(nextTurn)) { - history = [ - ...history, - semanticHistoryRow(nextTurn), - ].slice(-SEMANTIC_HISTORY_CAP) - currentTurn = null - } else { - currentTurn = nextTurn - } - } - break - } - case 'tool_started': { - const callId = typeof ev.callId === 'string' ? ev.callId : null - if (!callId) break - const turnId = - typeof ev.turnId === 'string' - ? ev.turnId - : currentTurn?.turnId ?? `codex-${now}` - // Provider-gated turn ownership (see turn_started for full - // rationale). Codex drops on mismatch (flicker defense); - // Claude archives and replaces (self-heals the stuck-pending-tool - // case). tool_started is Codex-only in practice today, but - // gating by provider keeps the policy consistent with the other - // two branches and avoids a subtle divergence for future - // Claude-side emitters. - if (!currentTurn) { - currentTurn = { - turnId, - text: '', - source: typeof ev.source === 'string' ? ev.source : null, - blocks: {}, - blockOrder: [], - stopReason: null, - usage: null, - task: emptySemanticTaskSnapshot(), - lookups: emptySemanticLookupSnapshot(), - startedAt: now, - endedAt: null, - } - } else if (currentTurn.turnId !== turnId) { - if (sessionKind === 'claude') { - history = [...history, semanticHistoryRow(currentTurn)].slice(-SEMANTIC_HISTORY_CAP) - currentTurn = { - turnId, - text: '', - source: typeof ev.source === 'string' ? ev.source : null, - blocks: {}, - blockOrder: [], - stopReason: null, - usage: null, - task: emptySemanticTaskSnapshot(), - lookups: emptySemanticLookupSnapshot(), - startedAt: now, - endedAt: null, - } - } else { - break - } - } - const existing = Object.values(currentTurn.blocks).find(block => block.toolUseId === callId) - if (existing) break - const numericIndices = Object.keys(currentTurn.blocks) - .map(value => Number(value)) - .filter(value => Number.isFinite(value)) - const nextIndex = - numericIndices.length > 0 ? Math.max(...numericIndices) + 1 : 0 - const label = typeof ev.label === 'string' ? ev.label : '' - const toolKind = ev.tool === 'mcp' ? 'mcp_tool_use' : 'tool_use' - const toolName = - ev.tool === 'exec' - ? 'exec_command' - : ev.tool === 'mcp' - ? label || 'mcp' - : label || (typeof ev.tool === 'string' ? ev.tool : 'tool') - currentTurn = { - ...currentTurn, - source: typeof ev.source === 'string' ? ev.source : currentTurn.source, - blocks: { - ...currentTurn.blocks, - [nextIndex]: { - blockIndex: nextIndex, - kind: toolKind, - toolName, - toolUseId: callId, - text: '', - thinking: '', - inputJson: label, - }, - }, - blockOrder: [...currentTurn.blockOrder, nextIndex], - } - break - } - case 'tool_output_delta': { - if (!currentTurn) break - const callId = typeof ev.callId === 'string' ? ev.callId : null - if (!callId) break - const match = Object.entries(currentTurn.blocks).find(([, block]) => block.toolUseId === callId) - if (!match) break - const idx = Number(match[0]) - const block = match[1] - currentTurn = { - ...currentTurn, - blocks: { - ...currentTurn.blocks, - [idx]: { - ...block, - resultContent: - typeof ev.textDelta === 'string' - ? (block.resultContent ?? '') + ev.textDelta - : block.resultContent, - }, - }, - } - break - } - case 'tool_completed': { - if (!currentTurn) break - const callId = typeof ev.callId === 'string' ? ev.callId : null - if (!callId) break - const match = Object.entries(currentTurn.blocks).find(([, block]) => block.toolUseId === callId) - if (!match) break - const idx = Number(match[0]) - const block = match[1] - currentTurn = { - ...currentTurn, - blocks: { - ...currentTurn.blocks, - [idx]: { - ...block, - resultContent: block.resultContent ?? '', - resultIsError: - typeof ev.exitCode === 'number' ? ev.exitCode !== 0 : block.resultIsError, - resultAt: now, - }, - }, - } - break - } - case 'usage_updated': { - if (!currentTurn) break - const usage = ev.usage as Record | undefined - if (!usage) break - currentTurn = { ...currentTurn, usage: flattenSemanticUsage(usage) } - break - } - case 'turn_stopped': { - if (!currentTurn) break - currentTurn = { - ...currentTurn, - stopReason: typeof ev.stopReason === 'string' ? ev.stopReason : null, - endedAt: now, - } - break - } - case 'turn_completed': { - if (!currentTurn) break - const completedTurn = { ...currentTurn, endedAt: currentTurn.endedAt ?? now } - if (hasPendingSemanticTools(completedTurn)) { - currentTurn = completedTurn - } else { - history = [ - ...history, - semanticHistoryRow(completedTurn), - ].slice(-SEMANTIC_HISTORY_CAP) - currentTurn = null - } - break - } - case 'api_error': - case 'stream_error': { - errors = [ - ...errors, - { - ts: now, - kind: t, - message: String(ev.message ?? '(no message)'), - }, - ].slice(-SEMANTIC_ERROR_CAP) - break - } - } - - // WHY the trailing derive is conditional on event type: - // `deriveSemanticTaskSnapshot` is O(n_blocks) and rebuilds - // `toolCallsById` from scratch. At streaming peak we get many - // text_delta / thinking_delta / signature events per second that - // ONLY mutate fields the derivation doesn't read (block.text, - // block.thinking, block.signature). Running the derive on those - // events is pure waste. Events in the allow-list below are the - // only ones that can change what derive sees — block lifecycle, - // tool input (goes into inputJson), finalized parse (TodoWrite - // parsedInput), or completed `turn_completed` where the result - // is pushed to history even if currentTurn survives this tick. - // - // block_started and tool_result already derive inline, so for - // them the trailing run is a second computation — intentional - // here, because their inline branch leaves currentTurn with the - // derived values and the trailing run is a cheap no-op pass that - // keeps this block the single place responsible for derived - // fields when currentTurn remains live. - // - // Codex also emits `tool_started` / `tool_output_delta` / - // `tool_completed` (non-Anthropic tool lifecycle — see the - // `tool_started` / `tool_completed` branches above, which synthesize - // tool_use blocks and stamp resultAt / resultIsError). Those MUST be - // in this allow-list: without them `deriveSemanticTaskSnapshot` - // never sees a new block entering `in_progress`, never sees it - // transition to `completed`/`error`, and `lookups.toolCallsById`, - // `lookups.resolvedToolUseIds`, `task.activeToolNames`, and - // `task.inProgressToolUseIds` go stale until some Anthropic-style - // event happens to retrigger derive. The user-visible symptom was - // Codex tool rows stuck showing "running" forever after completion. - const DERIVE_EVENT_TYPES = new Set([ - 'block_started', - 'tool_input_delta', - 'tool_input_finalized', - 'tool_result', - 'tool_started', - 'tool_output_delta', - 'tool_completed', - ]) - const finalCurrentTurn = currentTurn - ? DERIVE_EVENT_TYPES.has(t) - ? (() => { - const derived = deriveSemanticTaskSnapshot(currentTurn.blocks) - return { - ...currentTurn, - task: derived.task, - lookups: derived.lookups, - } - })() - : currentTurn - : null - - // No-op short-circuit: every event used to allocate a new log array - // and bump nextLogId at the top of the function, which meant the - // returned state was never === state, even for events that changed - // nothing (tool_result without a matching turn, usage_updated with - // unchanged usage, etc.). That fired setRuntimes + the whole - // reactive chain on every dead event — eight of them at bootstrap - // alone per the 2026-04-20 evidence log. - // - // Reference equality on flows/currentTurn/history/errors is the - // correct signal here because every branch that mutates those - // fields rebinds the local to a new reference. If all four locals - // still point at state.* AND finalCurrentTurn is identically - // currentTurn (the derive skipped), this event was a no-op and we - // return state unchanged. The log append for the event is skipped - // too — dead events don't belong in the debug log either. - if ( - flows === state.flows && - finalCurrentTurn === state.currentTurn && - history === state.history && - errors === state.errors - ) { - return state - } - - const summary = summarizeSemanticEvent(ev) - const logEntry = { - id: state.nextLogId, - type: String(ev.type ?? '?'), - ts: now, - summary, - raw: ev, - } - const log = [...state.log, logEntry] - if (log.length > SEMANTIC_LOG_CAP) { - log.splice(0, log.length - SEMANTIC_LOG_CAP) - } - - return { - ...state, - flows, - currentTurn: finalCurrentTurn, - history, - errors, - log, - nextLogId: state.nextLogId + 1, - } -} - -function codexConversationEntryFromMessageItem( - uuid: string, - timestamp: string | undefined, - payload: Record, -): Entry | null { - if ( - payload.type !== 'message' || - (payload.role !== 'user' && payload.role !== 'assistant') - ) { - return null - } - - const role = payload.role - const content = Array.isArray(payload.content) - ? payload.content - .map(block => { - const item = block as Record - const text = typeof item.text === 'string' ? item.text : null - if (!text) return null - if (item.type === 'input_text' || item.type === 'output_text') { - return { type: 'text' as const, text } - } - return null - }) - .filter((block): block is { type: 'text'; text: string } => block !== null) - : [] - - if (content.length === 0) return null - return { - type: role, - uuid, - parentUuid: null, - timestamp, - message: { role, content }, - } -} - -function codexCompactBoundaryEntry( - uuid: string, - payload: Record, -): Entry { - return { - type: 'system', - subtype: 'compact_boundary', - content: 'Conversation compacted', - uuid, - compactMetadata: payload, - } -} - -function codexCompactSummaryEntry( - uuid: string, - timestamp: string | undefined, - text: string, -): Entry { - return { - type: 'user', - uuid, - parentUuid: null, - timestamp, - isCompactSummary: true, - isVisibleInTranscriptOnly: true, - message: { - role: 'user', - content: [{ type: 'text', text }], - }, - } -} - -/** - * Extract the Codex rollout's per-turn response id from a - * `turn_context` side-channel entry. Returns null for any other - * entry type. Call sites that iterate a rollout stream use this to - * keep a rolling "current turn id" that subsequent `response_item` - * entries get stamped with via `stampCodexTurnId`. - * - * WHY: Codex rollout doesn't put `turn_id` on per-item entries — - * only on `task_started`/`turn_started` and `turn_context`. Without - * tracking it here the ghost reconciler in `reconcileUpstream` has - * nothing to match Codex assistant-text ghosts against (they don't - * carry `message.id`, don't carry `tool_use_id`). - */ -export function codexTurnIdFromRollout(entry: Record): string | null { - if (entry.type !== 'turn_context') return null - const payload = entry.payload as Record | undefined - return typeof payload?.turn_id === 'string' ? (payload.turn_id as string) : null -} - -/** - * Stamp a mapped Codex feed entry with the rollout turn id so the - * ghost reconciler can supersede by turn id. The field is added as - * a cc-shell-local extension to the shared `Entry` type via cast — - * consumers that don't care about it ignore it, and - * `reconcileUpstream` reads it defensively. - */ -function stampCodexTurnId(entry: Entry, turnId: string | null): Entry { - if (turnId === null) return entry - return { ...entry, codexTurnId: turnId } as Entry -} - -export function mapCodexRolloutToFeedEntries(entry: Record): Entry[] { - const uuid = - `${String(entry.timestamp ?? Date.now())}:${String((entry.payload as Record | undefined)?.id ?? (entry.payload as Record | undefined)?.call_id ?? (entry.payload as Record | undefined)?.type ?? entry.type)}` - const timestamp = - typeof entry.timestamp === 'string' ? entry.timestamp : undefined - - const payload = entry.payload as Record | undefined - if (!payload || typeof payload.type !== 'string') return [] - - if (entry.type === 'event_msg') { - const atp = entry._atp as { origin?: string; source?: Record } | undefined - if ( - payload.type === 'user_message' && - atp?.origin === 'claude' && - atp.source?.isCompactSummary === true - ) { - const sourceMessage = atp.source.message as { content?: unknown } | undefined - const sourceText = - typeof sourceMessage?.content === 'string' - ? sourceMessage.content - : typeof payload.message === 'string' - ? payload.message - : '' - return sourceText - ? [codexCompactSummaryEntry(`${uuid}:compact-summary`, timestamp, sourceText)] - : [] - } - - if (payload.type === 'exec_approval_request') { - const command = Array.isArray(payload.command) - ? payload.command.filter((part): part is string => typeof part === 'string') - : [] - const workdir = typeof payload.workdir === 'string' ? payload.workdir : null - const summary = [ - 'Permission required before Codex can run a command.', - command.length > 0 ? `Command: ${command.join(' ')}` : null, - workdir ? `Directory: ${workdir}` : null, - ] - .filter((line): line is string => line !== null) - .join('\n') - return summary ? [codexAssistantTextEntry(uuid, timestamp, summary)] : [] - } - - if ( - payload.type === 'exec_command_end' && - typeof payload.call_id === 'string' - ) { - const output = String( - payload.aggregated_output ?? - payload.formatted_output ?? - payload.stdout ?? - payload.stderr ?? - '', - ) - const exitCode = - typeof payload.exit_code === 'number' ? payload.exit_code : 0 - if (!output.trim() && exitCode === 0) return [] - return [ - codexToolResultEntry( - uuid, - timestamp, - payload.call_id, - output, - exitCode !== 0 || payload.status === 'failed', - { - kind: 'exec_command_end', - parsedCmd: Array.isArray(payload.parsed_cmd) ? payload.parsed_cmd : [], - command: Array.isArray(payload.command) ? payload.command : [], - cwd: typeof payload.cwd === 'string' ? payload.cwd : null, - exitCode, - }, - ), - ] - } - - if ( - payload.type === 'patch_apply_end' && - typeof payload.call_id === 'string' - ) { - const stdout = typeof payload.stdout === 'string' ? payload.stdout : '' - const stderr = typeof payload.stderr === 'string' ? payload.stderr : '' - const content = stdout || stderr - return [ - codexToolResultEntry( - uuid, - timestamp, - payload.call_id, - content, - payload.success !== true, - { - kind: 'patch_apply_end', - success: payload.success === true, - changes: payload.changes && typeof payload.changes === 'object' - ? payload.changes as Record - : {}, - }, - ), - ] - } - - return [] - } - - if (entry.type === 'compacted') { - const out: Entry[] = [ - codexCompactBoundaryEntry(`${uuid}:compact-boundary`, payload), - ] - - const message = typeof payload.message === 'string' ? payload.message.trim() : '' - if (message) { - out.push(codexCompactSummaryEntry(`${uuid}:compact-summary`, timestamp, message)) - } - - const replacementHistory = Array.isArray(payload.replacement_history) - ? payload.replacement_history - : [] - for (let i = 0; i < replacementHistory.length; i += 1) { - const item = replacementHistory[i] as Record - const mapped = codexConversationEntryFromMessageItem( - `${uuid}:replacement:${i}`, - timestamp, - item, - ) - if (mapped) out.push(mapped) - } - - return out - } - - if (entry.type !== 'response_item') return [] - - const conversationEntry = codexConversationEntryFromMessageItem(uuid, timestamp, payload) - if (conversationEntry) return [conversationEntry] - - if ( - payload.type === 'custom_tool_call' && - typeof payload.call_id === 'string' && - typeof payload.name === 'string' - ) { - const input = - typeof payload.input === 'string' - ? parseCodexJson(payload.input) ?? { raw: payload.input } - : { raw: '' } - return [codexToolUseEntry(uuid, timestamp, payload.call_id, payload.name, input)] - } - - if ( - payload.type === 'function_call' && - typeof payload.call_id === 'string' && - typeof payload.name === 'string' - ) { - const input = - typeof payload.arguments === 'string' - ? parseCodexJson(payload.arguments) ?? { arguments: payload.arguments } - : { arguments: payload.arguments } - return [codexToolUseEntry(uuid, timestamp, payload.call_id, payload.name, input)] - } - - if (payload.type === 'function_call_output' && typeof payload.call_id === 'string') { - const output = stripCodexExecWrapper(codexOutputText(payload.output)) - if (!output.trim() || isCodexExecWrapperOutput(codexOutputText(payload.output))) { - return [] - } - return [codexToolResultEntry(uuid, timestamp, payload.call_id, output)] - } - - if (payload.type === 'custom_tool_call_output' && typeof payload.call_id === 'string') { - const output = codexOutputText(payload.output) - const parsed = parseCodexJson(output) - const normalized = - typeof parsed?.output === 'string' ? parsed.output : output - const metadata = parsed?.metadata - const exitCode = - metadata && typeof metadata === 'object' && typeof (metadata as Record).exit_code === 'number' - ? (metadata as Record).exit_code as number - : 0 - if ( - typeof normalized === 'string' && - normalized.startsWith('Success. Updated the following files:') - ) { - return [] - } - return [ - codexToolResultEntry( - uuid, - timestamp, - payload.call_id, - normalized, - exitCode !== 0, - { kind: 'custom_tool_call_output' }, - ), - ] - } - - return [] -} - -export function codexHistoryMarker(entry: Record): string { - const payload = entry.payload as Record | undefined - return `${String(entry.timestamp ?? '')}:${String(payload?.id ?? payload?.call_id ?? payload?.type ?? entry.type)}` -} - -// Mutates `toolUseIndex` and `toolResultIndex` in place, folding one -// feed entry's tool_use / tool_result blocks into the per-session -// lookup maps. Used by both the bulk jsonl-entries ingest path and -// the singular one — keeps the indexing logic in one place so Feed -// never has to rebuild these maps in a useMemo. -// -// WHY in-place mutation of a map stored on runtime state: -// The map reference doesn't change, only its contents — Feed reads -// through context and treats the map as a live lookup rather than -// a diffable prop. React.memo of downstream rows is unaffected -// (they don't depend on the map's reference identity), and we -// avoid allocating a new Map per entry during a bootstrap burst. -function indexEntryIntoMaps( - entry: Entry, - toolUseIndex: Map, - toolResultIndex: Map, -): void { - if (!isConversationEntry(entry)) return - const content = entry.message.content - if (!Array.isArray(content)) return - for (const b of content) { - if (b.type === 'tool_use') { - const tu = b as ToolUseBlock - toolUseIndex.set(tu.id, tu) - } else if (b.type === 'tool_result') { - const tr = b as ToolResultBlock - toolResultIndex.set(tr.tool_use_id, tr) - } - } -} - -export function claudeHistoryMarker(entry: Record): string | null { - const embedded = extractEmbeddedClaudeProgressEntry(entry) - if (embedded?.uuid) return embedded.uuid - return typeof entry.uuid === 'string' ? entry.uuid : null -} - -export function extractEmbeddedClaudeProgressEntry( - entry: Record, -): ConversationEntry | null { - if (entry.type !== 'progress') return null - const data = entry.data as Record | undefined - const embedded = data?.message as Record | undefined - if (!embedded) return null - - const type = embedded.type - if (type !== 'assistant' && type !== 'user') return null - if (!embedded.message || typeof embedded.message !== 'object') return null - - return { - type, - uuid: - typeof embedded.uuid === 'string' - ? embedded.uuid - : `${String(entry.timestamp ?? Date.now())}:progress:${type}`, - parentUuid: - typeof embedded.parentUuid === 'string' ? embedded.parentUuid : null, - timestamp: - typeof embedded.timestamp === 'string' - ? embedded.timestamp - : typeof entry.timestamp === 'string' - ? entry.timestamp - : undefined, - sessionId: - typeof embedded.sessionId === 'string' - ? embedded.sessionId - : typeof entry.sessionId === 'string' - ? entry.sessionId - : undefined, - gitBranch: - typeof embedded.gitBranch === 'string' - ? embedded.gitBranch - : typeof entry.gitBranch === 'string' - ? entry.gitBranch - : undefined, - cwd: - typeof embedded.cwd === 'string' - ? embedded.cwd - : typeof entry.cwd === 'string' - ? entry.cwd - : undefined, - isSidechain: embedded.isSidechain === true, - message: embedded.message as ConversationEntry['message'], - } -} - -// --------------------------------------------------------------------------- -// Persisted state shape (serialized to ~/.config/cc-shell/workspace.json) -// --------------------------------------------------------------------------- - -/** - * Persisted workspace shape. Live runtime state is NOT here — we - * respawn sessions on load and their state rebuilds naturally from - * fresh IPC events. - */ -type PersistedWorkspace = { - // Tab tree with sessionIds that refer to the CURRENT launch's - // sessions. On load we re-spawn and remap ids, so persisted ids are - // just placeholders that get replaced. - tabs: Array<{ - id: TabId - title: string - focusedSessionId: SessionId - root: TileNode - }> - activeTabId: TabId - sessions: Record - buried?: BuriedPaneRecord[] - tileTabs?: TileTabsState | null - /** Draft input text per session, keyed by sessionId. Persisted so - * in-progress prompts survive app crashes and restarts. Only - * non-empty drafts are saved to keep the file small. */ - drafts?: Record -} - // --------------------------------------------------------------------------- // The store hook // --------------------------------------------------------------------------- @@ -5370,99 +3755,33 @@ export function useWorkspace( } } -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function titleFromCwd(cwd: string): string { - const parts = cwd.split('/').filter(Boolean) - return parts[parts.length - 1] ?? cwd -} - -function equalRatios(count: number): number[] { - if (count <= 0) return [] - return Array.from({ length: count }, () => 1 / count) -} - -function sanitizeTileTabsState(tileTabs: TileTabsState): TileTabsState | null { - if (tileTabs.tabIds.length < 2) return null - const tabIds = Array.from(new Set(tileTabs.tabIds)) - if (tabIds.length < 2) return null - const focusedTabId = tabIds.includes(tileTabs.focusedTabId) - ? tileTabs.focusedTabId - : tabIds[0] - const ratios = tileTabs.ratios.length === tabIds.length - ? normalizeRatios(tileTabs.ratios) - : equalRatios(tabIds.length) - return { - ...tileTabs, - tabIds, - focusedTabId, - ratios, - } -} - -function normalizeRatios(ratios: number[]): number[] { - if (ratios.length === 0) return [] - const total = ratios.reduce((sum, value) => sum + value, 0) - if (total <= 0) return equalRatios(ratios.length) - return ratios.map(value => value / total) -} - -function ratiosEqual(a: number[], b: number[]): boolean { - if (a.length !== b.length) return false - for (let i = 0; i < a.length; i++) { - if (Math.abs(a[i] - b[i]) > 0.0001) return false - } - return true -} - -/** - * Cheap structural comparison for SlashPickerState. The picker object - * itself is always fresh (parsed anew from each terminal snapshot in - * main), so reference equality never holds — we have to look at the - * visible flag, the item count, and the per-item id + selected bit. - * IDs are short strings, items cap at ~15, so this runs in microseconds - * and is still vastly cheaper than letting a no-op screen frame - * propagate into a React render. - */ -function pickerEqual( - a: SlashPickerState, - b: SlashPickerState, -): boolean { - if (a.visible !== b.visible) return false - if (a.items.length !== b.items.length) return false - for (let i = 0; i < a.items.length; i++) { - const x = a.items[i] - const y = b.items[i] - if (x.id !== y.id || x.selected !== y.selected) return false - } - return true -} - -function setRatioBetween( - node: TileNode, - aSession: SessionId, - bSession: SessionId, - ratio: number, -): TileNode { - if (node.type === 'leaf') return node - const leavesA = collectLeaves(node.a) - const leavesB = collectLeaves(node.b) - if (leavesA.includes(aSession) && leavesB.includes(bSession)) { - return { ...node, ratio: Math.min(0.9, Math.max(0.1, ratio)) } - } - return { - ...node, - a: setRatioBetween(node.a, aSession, bSession, ratio), - b: setRatioBetween(node.b, aSession, bSession, ratio), - } -} - // Silence unused-var warning for RATIO_DEFAULT re-export path — used in treeOps. void RATIO_DEFAULT -// Silence for useCallback imports we want explicit. -export { collectLeaves } + +// ----------------------------------------------------------------------------- +// Backward-compat re-exports. +// +// Previous callers imported these symbols from workspaceStore.ts; the +// 2026-04-23 extraction moved the implementations into sibling modules +// but the re-exports keep the import paths in ProxyDebugPanel, +// claudeSession, Feed.tsx, ghosts.ts, and the testing harness +// working verbatim. Prefer importing from the new modules directly +// in new code. +// ----------------------------------------------------------------------------- + +export { foldSemanticEvent } from './semantic/foldEvent' +export { + codexHistoryMarker, + codexTurnIdFromRollout, + mapCodexRolloutToFeedEntries, +} from './codex/rollout' +export { + claudeHistoryMarker, + extractEmbeddedClaudeProgressEntry, +} from './claude/history' + +export { collectLeaves } from './tile-tree/treeOps' + export type { PickerItem, QueuedMessage, From af6fd9d7e9b0edb616cc83c976410a4ea4067b32 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 23 Apr 2026 17:43:19 +0200 Subject: [PATCH 06/27] =?UTF-8?q?refactor(renderer):=20finish=20tree=20res?= =?UTF-8?q?hape=20=E2=80=94=20code/,=20components/,=20PathPickerModal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/providers/claude/renderer/rows/ClaudeRows.tsx | 2 +- src/providers/codex/renderer/rows/CodexRows.tsx | 2 +- src/renderer/src/App.tsx | 2 +- .../{workspace => features/path-picker/ui}/PathPickerModal.tsx | 2 +- src/renderer/src/features/reader/ui/ReaderView.tsx | 2 +- src/renderer/src/feed/Feed.tsx | 2 +- src/renderer/src/{ => lib}/code/CodeBlock.tsx | 2 +- src/renderer/src/{ => lib}/code/monacoRuntime.ts | 2 +- src/renderer/src/{components => ui}/PathInput.tsx | 0 9 files changed, 8 insertions(+), 8 deletions(-) rename src/renderer/src/{workspace => features/path-picker/ui}/PathPickerModal.tsx (99%) rename src/renderer/src/{ => lib}/code/CodeBlock.tsx (99%) rename src/renderer/src/{ => lib}/code/monacoRuntime.ts (99%) rename src/renderer/src/{components => ui}/PathInput.tsx (100%) diff --git a/src/providers/claude/renderer/rows/ClaudeRows.tsx b/src/providers/claude/renderer/rows/ClaudeRows.tsx index 1476f08c9..eaa82ef25 100644 --- a/src/providers/claude/renderer/rows/ClaudeRows.tsx +++ b/src/providers/claude/renderer/rows/ClaudeRows.tsx @@ -16,7 +16,7 @@ import { normalizeCodeLanguage } from '../../../../shared/code/language' import { diffLines, type DiffLine } from '../../../../shared/parsers/lineDiff' import { formatToolFilePath } from '../../../../shared/paths/displayPath' import type { ToolUseBlock } from '../../../../shared/types/transcript' -import { CodeBlock } from '../../../../renderer/src/code/CodeBlock' +import { CodeBlock } from '../../../../renderer/src/lib/code/CodeBlock' import { CodeRenderContext, MarkerRow } from '../../../../renderer/src/feed/Feed' /* ---------- Shared helpers ---------- */ diff --git a/src/providers/codex/renderer/rows/CodexRows.tsx b/src/providers/codex/renderer/rows/CodexRows.tsx index d7030bfde..3a61c1ef8 100644 --- a/src/providers/codex/renderer/rows/CodexRows.tsx +++ b/src/providers/codex/renderer/rows/CodexRows.tsx @@ -1,6 +1,6 @@ import { memo, useContext, useMemo, useState } from 'react' -import { CodeBlock } from '../../../../renderer/src/code/CodeBlock' +import { CodeBlock } from '../../../../renderer/src/lib/code/CodeBlock' import { CodeRenderContext, MarkerRow } from '../../../../renderer/src/feed/Feed' import { formatToolFilePath } from '../../../../shared/paths/displayPath' import type { ToolResultBlock, ToolUseBlock } from '../../../../shared/types/transcript' diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f9e3de015..7559633cb 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -18,7 +18,7 @@ import { RewindToPromptModal } from './features/workspace/ui/RewindToPromptModal import { ViewPromptsModal } from './features/workspace/ui/ViewPromptsModal' import { GitBar } from './features/git/ui/GitBar' import { AppearanceMenu } from './feed/AppearanceMenu' -import { PathPickerModal } from './workspace/PathPickerModal' +import { PathPickerModal } from './features/path-picker/ui/PathPickerModal' import { TabBar } from './workspace/tile-tree/TabBar' import { TileTree } from './workspace/tile-tree/TileTree' import { useAppStore } from './state/hooks' diff --git a/src/renderer/src/workspace/PathPickerModal.tsx b/src/renderer/src/features/path-picker/ui/PathPickerModal.tsx similarity index 99% rename from src/renderer/src/workspace/PathPickerModal.tsx rename to src/renderer/src/features/path-picker/ui/PathPickerModal.tsx index 59536d549..654bfc5c6 100644 --- a/src/renderer/src/workspace/PathPickerModal.tsx +++ b/src/renderer/src/features/path-picker/ui/PathPickerModal.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react' -import { PathInput } from '../components/PathInput' +import { PathInput } from '../../../ui/PathInput' // Duplicate of the SessionInfo shape preload exposes. Inlined here so // the renderer's tsconfig doesn't need to reach across into src/preload diff --git a/src/renderer/src/features/reader/ui/ReaderView.tsx b/src/renderer/src/features/reader/ui/ReaderView.tsx index 839b82e7c..485bc6933 100644 --- a/src/renderer/src/features/reader/ui/ReaderView.tsx +++ b/src/renderer/src/features/reader/ui/ReaderView.tsx @@ -2,7 +2,7 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState, type Rea import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' -import { CodeBlock } from '../../../code/CodeBlock' +import { CodeBlock } from '../../../lib/code/CodeBlock' import { CodeRenderContext } from '../../../feed/Feed' import { extractAssistantInProgress } from '../../../../../shared/parsers/extractAssistant' import { assistantUuidsWithText, extractAssistantByUuid } from '../../../lib/copyAssistant' diff --git a/src/renderer/src/feed/Feed.tsx b/src/renderer/src/feed/Feed.tsx index c163d8257..44990ef4f 100644 --- a/src/renderer/src/feed/Feed.tsx +++ b/src/renderer/src/feed/Feed.tsx @@ -34,7 +34,7 @@ import { type ToolResultBlock, type ToolUseBlock, } from '../../../shared/types/transcript' -import { CodeBlock } from '../code/CodeBlock' +import { CodeBlock } from '../lib/code/CodeBlock' import { detectGitIntent } from '../../../shared/git/gitDetect' import { GitCardRow } from '../features/git/ui/GitRows' import { useAppStore } from '../state/hooks' diff --git a/src/renderer/src/code/CodeBlock.tsx b/src/renderer/src/lib/code/CodeBlock.tsx similarity index 99% rename from src/renderer/src/code/CodeBlock.tsx rename to src/renderer/src/lib/code/CodeBlock.tsx index 96fe8703b..85a9fbe75 100644 --- a/src/renderer/src/code/CodeBlock.tsx +++ b/src/renderer/src/lib/code/CodeBlock.tsx @@ -5,7 +5,7 @@ import { languageFileExtension, normalizeCodeLanguage, supportsLsp, -} from '../../../shared/code/language' +} from '../../../../shared/code/language' type Props = { code: string diff --git a/src/renderer/src/code/monacoRuntime.ts b/src/renderer/src/lib/code/monacoRuntime.ts similarity index 99% rename from src/renderer/src/code/monacoRuntime.ts rename to src/renderer/src/lib/code/monacoRuntime.ts index d83925705..74128f942 100644 --- a/src/renderer/src/code/monacoRuntime.ts +++ b/src/renderer/src/lib/code/monacoRuntime.ts @@ -3,7 +3,7 @@ import type * as Monaco from 'monaco-editor' import { normalizeCodeLanguage, supportsLsp, -} from '../../../shared/code/language' +} 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' diff --git a/src/renderer/src/components/PathInput.tsx b/src/renderer/src/ui/PathInput.tsx similarity index 100% rename from src/renderer/src/components/PathInput.tsx rename to src/renderer/src/ui/PathInput.tsx From 77cf73ffbfa8aace27b537dad89709e0be8e488f Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 23 Apr 2026 17:55:49 +0200 Subject: [PATCH 07/27] refactor(feed): move feed/ into features/feed/, extract MarkerRow, scaffold split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../claude/renderer/rows/ClaudeRows.tsx | 3 +- .../codex/renderer/rows/CodexRows.tsx | 3 +- src/renderer/src/App.tsx | 2 +- .../{ => features}/feed/AppearanceMenu.tsx | 2 +- .../src/{ => features}/feed/WorkIndicator.tsx | 4 +- src/renderer/src/features/feed/context.tsx | 68 ++++ src/renderer/src/features/feed/lib/helpers.ts | 315 ++++++++++++++++++ .../src/features/feed/lib/remark-plugins.ts | 36 ++ src/renderer/src/features/feed/scroll.ts | 34 ++ src/renderer/src/features/feed/types.ts | 59 ++++ .../src/{feed => features/feed/ui}/Feed.tsx | 58 +--- .../src/features/feed/ui/MarkerRow.tsx | 44 +++ .../{ => features}/feed/workIndicatorHints.ts | 2 +- src/renderer/src/features/git/ui/GitRows.tsx | 2 +- .../src/features/reader/ui/ReaderView.tsx | 2 +- .../src/workspace/tile-tree/TileLeaf.tsx | 4 +- 16 files changed, 583 insertions(+), 55 deletions(-) rename src/renderer/src/{ => features}/feed/AppearanceMenu.tsx (99%) rename src/renderer/src/{ => features}/feed/WorkIndicator.tsx (98%) create mode 100644 src/renderer/src/features/feed/context.tsx create mode 100644 src/renderer/src/features/feed/lib/helpers.ts create mode 100644 src/renderer/src/features/feed/lib/remark-plugins.ts create mode 100644 src/renderer/src/features/feed/scroll.ts create mode 100644 src/renderer/src/features/feed/types.ts rename src/renderer/src/{feed => features/feed/ui}/Feed.tsx (98%) create mode 100644 src/renderer/src/features/feed/ui/MarkerRow.tsx rename src/renderer/src/{ => features}/feed/workIndicatorHints.ts (97%) diff --git a/src/providers/claude/renderer/rows/ClaudeRows.tsx b/src/providers/claude/renderer/rows/ClaudeRows.tsx index eaa82ef25..e8c8f8b28 100644 --- a/src/providers/claude/renderer/rows/ClaudeRows.tsx +++ b/src/providers/claude/renderer/rows/ClaudeRows.tsx @@ -17,7 +17,8 @@ import { diffLines, type DiffLine } from '../../../../shared/parsers/lineDiff' import { formatToolFilePath } from '../../../../shared/paths/displayPath' import type { ToolUseBlock } from '../../../../shared/types/transcript' import { CodeBlock } from '../../../../renderer/src/lib/code/CodeBlock' -import { CodeRenderContext, MarkerRow } from '../../../../renderer/src/feed/Feed' +import { CodeRenderContext } from '../../../../renderer/src/features/feed/ui/Feed' +import { MarkerRow } from '../../../../renderer/src/features/feed/ui/MarkerRow' /* ---------- Shared helpers ---------- */ diff --git a/src/providers/codex/renderer/rows/CodexRows.tsx b/src/providers/codex/renderer/rows/CodexRows.tsx index 3a61c1ef8..0f429d51f 100644 --- a/src/providers/codex/renderer/rows/CodexRows.tsx +++ b/src/providers/codex/renderer/rows/CodexRows.tsx @@ -1,7 +1,8 @@ import { memo, useContext, useMemo, useState } from 'react' import { CodeBlock } from '../../../../renderer/src/lib/code/CodeBlock' -import { CodeRenderContext, MarkerRow } from '../../../../renderer/src/feed/Feed' +import { CodeRenderContext } from '../../../../renderer/src/features/feed/ui/Feed' +import { MarkerRow } from '../../../../renderer/src/features/feed/ui/MarkerRow' import { formatToolFilePath } from '../../../../shared/paths/displayPath' import type { ToolResultBlock, ToolUseBlock } from '../../../../shared/types/transcript' diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7559633cb..a568fca3e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -17,7 +17,7 @@ import { PromptSearchModal } from './features/workspace/ui/PromptSearchModal' import { RewindToPromptModal } from './features/workspace/ui/RewindToPromptModal' import { ViewPromptsModal } from './features/workspace/ui/ViewPromptsModal' import { GitBar } from './features/git/ui/GitBar' -import { AppearanceMenu } from './feed/AppearanceMenu' +import { AppearanceMenu } from './features/feed/AppearanceMenu' import { PathPickerModal } from './features/path-picker/ui/PathPickerModal' import { TabBar } from './workspace/tile-tree/TabBar' import { TileTree } from './workspace/tile-tree/TileTree' diff --git a/src/renderer/src/feed/AppearanceMenu.tsx b/src/renderer/src/features/feed/AppearanceMenu.tsx similarity index 99% rename from src/renderer/src/feed/AppearanceMenu.tsx rename to src/renderer/src/features/feed/AppearanceMenu.tsx index 0a2c7b96a..47411505a 100644 --- a/src/renderer/src/feed/AppearanceMenu.tsx +++ b/src/renderer/src/features/feed/AppearanceMenu.tsx @@ -7,7 +7,7 @@ import { THEME_MODES, isDarkThemeMode, type ThemeMode, -} from '../state/settings/types' +} from '../../state/settings/types' type Props = { settings: Settings diff --git a/src/renderer/src/feed/WorkIndicator.tsx b/src/renderer/src/features/feed/WorkIndicator.tsx similarity index 98% rename from src/renderer/src/feed/WorkIndicator.tsx rename to src/renderer/src/features/feed/WorkIndicator.tsx index 9f5c0e87b..c3b715f8d 100644 --- a/src/renderer/src/feed/WorkIndicator.tsx +++ b/src/renderer/src/features/feed/WorkIndicator.tsx @@ -25,8 +25,8 @@ import { memo, useEffect, useState } from 'react' -import { MarkerRow } from './Feed' -import type { StreamPhase } from '../workspace/workspaceState' +import { MarkerRow } from './ui/MarkerRow' +import type { StreamPhase } from '../../workspace/workspaceState' type Props = { phase: StreamPhase diff --git a/src/renderer/src/features/feed/context.tsx b/src/renderer/src/features/feed/context.tsx new file mode 100644 index 000000000..ae4d57963 --- /dev/null +++ b/src/renderer/src/features/feed/context.tsx @@ -0,0 +1,68 @@ +import { createContext } from 'react' + +import type { + ToolResultBlock, + ToolUseBlock, +} from '../../../../shared/types/transcript' + +import type { AgentProvider } from './types' + +// --------------------------------------------------------------------------- +// Feed contexts. +// +// Four contexts propagate state down from the Feed container to the +// per-row renderers without prop-drilling through every level of the +// tree: +// +// ProviderContext — which provider's row renderers to use. +// ToolUseIndexContext — tool_use_id → ToolUseBlock. Used by +// ToolResultRow to look up the originating +// tool for richer rendering (Read → +// syntax-highlighted code; Bash → plain +// pre; …). +// ToolResultIndexContext — reverse of the above. Used by the +// tool_use dispatcher when a single +// combined widget wants to render command +// + output on one row (git widgets). +// CodeRenderContext — sessionId + workspaceRoot, passed to +// fenced code blocks inside prose so the +// CodeBlock can mint stable codeIds and +// wire LSP against the right root. +// +// Why side-channel maps instead of passing props structurally: +// 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 without reaching across entries. +// The Feed level (which DOES have every entry) builds the maps and +// hands them down through context. +// +// Memo behavior: the map references change whenever `entries` change, +// which invalidates useContext consumers. That's fine — rows that +// care about the maps already re-render when entries grow, and rows +// that don't call useContext are unaffected. We do NOT include the +// maps in row memo keys; equality on the maps themselves would be +// expensive and the interesting work (markdown parsing) is cached +// inside TextProse by text string, so repeat renders are cheap. + +export const ProviderContext = createContext('claude') + +export 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. +export const ToolResultIndexContext = + createContext>(new Map()) + +export const CodeRenderContext = createContext<{ + sessionId: string + workspaceRoot: string | null +}>({ + sessionId: '', + workspaceRoot: null, +}) diff --git a/src/renderer/src/features/feed/lib/helpers.ts b/src/renderer/src/features/feed/lib/helpers.ts new file mode 100644 index 000000000..e6bbbf18c --- /dev/null +++ b/src/renderer/src/features/feed/lib/helpers.ts @@ -0,0 +1,315 @@ +import { + isConversationEntry, + type CompactSummaryEntry, + type ContentBlock, + type ConversationEntry, + type Entry, + type ToolResultBlock, + type ToolUseBlock, +} from '../../../../../shared/types/transcript' +import type { SemanticLiveTurn } from '../../../workspace/workspaceState' + +// ----------------------------------------------------------------------------- +// Feed pure helpers — no React, no state, no side effects. +// ----------------------------------------------------------------------------- + +/** Build tool_use_id → ToolUseBlock lookup from an entries list. Used + * as the fallback when Feed isn't being handed incremental indices + * by the workspace store (tests, future surfaces). The index is + * cheap to build (single pass) and the resulting Map is handed to + * result rows via ToolUseIndexContext. */ +export 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. + */ +export 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). */ +export function extractToolCommand(block: ToolUseBlock): string | null { + const input = block.input as Record | undefined + 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}`. */ +export 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 '' +} + +/** Truncate long bash commands for the tool-band header. We keep the + * first line and cut after ~160 chars; the full command is still + * available in the expanded view. Matches Claude Code's own + * truncation style so the UI feels consistent across surfaces. */ +export function truncateBashCommand(cmd: string): string { + const firstLine = cmd.split('\n', 1)[0] ?? cmd + if (firstLine.length <= 160) return firstLine + return firstLine.slice(0, 160) + '…' +} + +/** Strip the leading "nnn→" line-number markers Claude emits in Read + * tool results. Used before we hand the content to a code renderer + * that does its own line numbering — the markers would otherwise + * double up. */ +export function stripLineNumberPrefix(text: string): string { + return text.replace(/^\s*\d+→/gm, '') +} + +/** Build a stable React key for an Entry + its index. Prefers the + * entry's uuid; falls back to a type+index composite so entries + * without a uuid (synthetic system rows) still get a unique key. */ +export function debugKeyForEntry(entry: Entry, index: number): string { + const uuid = (entry as Entry).uuid + return uuid ?? `${entry.type}:${index}` +} + +/** One-line summary for the feed-debug RENDER layer. Prefers the + * first text block's content; falls back to the first block's type + * name when no text is present (tool_use, image, etc.). */ +export function debugLabelForEntry(entry: Entry): string { + if (entry.type === 'user' || entry.type === 'assistant') { + const message = (entry as ConversationEntry).message + const content = Array.isArray(message.content) ? message.content : [] + const first = content[0] as Record | undefined + if (first?.type === 'text' && typeof first.text === 'string') { + return `${entry.type}: ${first.text.replace(/\s+/g, ' ').trim().slice(0, 80)}` + } + if (typeof first?.type === 'string') { + return `${entry.type}: ${String(first.type)}` + } + } + return entry.type +} + +/** Count ``` fence markers in a string. Used to decide whether the + * streaming text currently has an odd number of fences (i.e. is + * mid-fence and should split into prose + code halves). */ +export function countFenceMarkers(text: string): number { + const matches = text.match(/```/g) + return matches ? matches.length : 0 +} + +/** Split streaming text at the LAST ``` marker when the total count + * is odd (i.e. the fence hasn't closed yet). Returns the prose + * before the open fence, the partial code content after it, and + * the detected language (empty trimmed info-string → null). + * Returns null when the text has no fence or an even count + * (fully-closed fences go through normal markdown rendering). */ +export 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, + } +} + +// --------------------------------------------------------------------------- +// Image / content block helpers +// --------------------------------------------------------------------------- + +/** Produce a data: URL for an image content block. Returns null if + * the block isn't an image or its source fields aren't set. */ +export function imageDataUrl(block: ContentBlock): string | null { + if (block.type !== 'image') return null + const src = (block as unknown as { source?: Record }).source + if (!src) return null + const mediaType = typeof src.media_type === 'string' ? src.media_type : null + const data = typeof src.data === 'string' ? src.data : null + if (!mediaType || !data) return null + return `data:${mediaType};base64,${data}` +} + +/** Extract the human text of a compact-summary entry. Both the + * conversation-entry shape (content array) and the raw-string shape + * (older schema) are supported. */ +export function compactSummaryText(entry: CompactSummaryEntry): string { + const message = entry.message + if (!message) return '' + if (typeof message.content === 'string') return message.content + if (Array.isArray(message.content)) { + return message.content + .map(block => { + const item = block as Record + return item.type === 'text' && typeof item.text === 'string' + ? (item.text as string) + : '' + }) + .join('\n') + .trim() + } + return '' +} + +/** Truncate the compact-summary body for the inline preview. */ +export function truncateCompactSummary(text: string): string { + if (text.length <= 400) return text + return text.slice(0, 400).trimEnd() + '…' +} + +/** Label attachment-type entries for the system row (file-history- + * snapshot, hook-attachment, etc.). */ +export function attachmentLabel(entry: Entry): string { + const att = (entry as unknown as { attachment?: Record }).attachment + if (!att) return 'attachment' + const kind = typeof att.type === 'string' ? att.type : 'attachment' + const path = + typeof att.path === 'string' + ? att.path + : typeof att.target === 'string' + ? att.target + : null + return path ? `${kind} · ${path}` : kind +} + +// --------------------------------------------------------------------------- +// Bash classification for semantic collapsed activity +// --------------------------------------------------------------------------- +// +// 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) +} + +export function looksLikeSearchCommand(command: string): boolean { + return atCommandPosition(command, ['rg', 'grep', 'find', 'fd', 'ag', 'ack', 'git grep']) +} + +export function looksLikeReadCommand(command: string): boolean { + return atCommandPosition(command, ['cat', 'less', 'more', 'head', 'tail', 'sed', 'awk', 'wc']) +} + +export function looksLikeListCommand(command: string): boolean { + return atCommandPosition(command, ['ls', 'tree', 'du']) +} + +export 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 } +} diff --git a/src/renderer/src/features/feed/lib/remark-plugins.ts b/src/renderer/src/features/feed/lib/remark-plugins.ts new file mode 100644 index 000000000..801304fd9 --- /dev/null +++ b/src/renderer/src/features/feed/lib/remark-plugins.ts @@ -0,0 +1,36 @@ +import remarkGfm from 'remark-gfm' +import remarkBreaks from 'remark-breaks' + +// Plugin sets used by react-markdown inside the feed. +// +// 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.
+
+export const COMPLETED_REMARK = [remarkGfm]
+export const STREAMING_REMARK = [remarkGfm, remarkBreaks]
diff --git a/src/renderer/src/features/feed/scroll.ts b/src/renderer/src/features/feed/scroll.ts
new file mode 100644
index 000000000..f453423c1
--- /dev/null
+++ b/src/renderer/src/features/feed/scroll.ts
@@ -0,0 +1,34 @@
+import type { ScrollPosition } from './types'
+
+// 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.
+
+export const scrollPositions = new Map()
diff --git a/src/renderer/src/features/feed/types.ts b/src/renderer/src/features/feed/types.ts
new file mode 100644
index 000000000..cc5b3b36c
--- /dev/null
+++ b/src/renderer/src/features/feed/types.ts
@@ -0,0 +1,59 @@
+import type { Entry } from '../../../../shared/types/transcript'
+
+/** 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
+}
+
+// ---------------------------------------------------------------------------
+// Debug row-visibility shapes
+// ---------------------------------------------------------------------------
+//
+// Feed reports every visibility decision back to the feed-debug log
+// so the panel can show "why didn't this entry render?" side by side
+// with the timeline of RENDER-layer row additions. The `reason`
+// enumeration names the check that gated the entry.
+
+export type VisibleDecision = {
+  key: string
+  entry: Entry
+  visible: boolean
+  reason:
+    | 'compact_boundary'
+    | 'compact_summary'
+    | 'conversation'
+    | 'not_conversation'
+    | 'meta_filtered'
+}
+
+export type DebugVisibleRow = {
+  key: string
+  slot: 'entry' | 'semantic' | 'work' | 'empty'
+  label: string
+}
+
+// ---------------------------------------------------------------------------
+// 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. See ./scroll.ts for the
+// module-level Map.
+
+export type ScrollPosition = {
+  scrollTop: number
+  stickyBottom: boolean
+}
diff --git a/src/renderer/src/feed/Feed.tsx b/src/renderer/src/features/feed/ui/Feed.tsx
similarity index 98%
rename from src/renderer/src/feed/Feed.tsx
rename to src/renderer/src/features/feed/ui/Feed.tsx
index 44990ef4f..3c4065cde 100644
--- a/src/renderer/src/feed/Feed.tsx
+++ b/src/renderer/src/features/feed/ui/Feed.tsx
@@ -18,11 +18,11 @@ import {
   MultiEditRow,
   WriteRow,
   TodoRow,
-} from '../../../providers/claude/renderer/rows/ClaudeRows'
+} from '../../../../../providers/claude/renderer/rows/ClaudeRows'
 import {
   CodexToolRow,
   CodexToolResultRow,
-} from '../../../providers/codex/renderer/rows/CodexRows'
+} from '../../../../../providers/codex/renderer/rows/CodexRows'
 import {
   isCompactBoundaryEntry,
   isCompactSummaryEntry,
@@ -33,19 +33,20 @@ import {
   type Entry,
   type ToolResultBlock,
   type ToolUseBlock,
-} from '../../../shared/types/transcript'
-import { CodeBlock } from '../lib/code/CodeBlock'
-import { detectGitIntent } from '../../../shared/git/gitDetect'
-import { GitCardRow } from '../features/git/ui/GitRows'
-import { useAppStore } from '../state/hooks'
+} from '../../../../../shared/types/transcript'
+import { CodeBlock } from '../../../lib/code/CodeBlock'
+import { detectGitIntent } from '../../../../../shared/git/gitDetect'
+import { GitCardRow } from '../../git/ui/GitRows'
+import { useAppStore } from '../../../state/hooks'
 import {
   parseSemanticTodos,
   type SemanticLiveTurn,
   type SemanticTodoItem,
   type StreamPhase,
-} from '../workspace/workspaceState'
-import { WorkIndicator } from './WorkIndicator'
-import { toolHintFromTurn } from './workIndicatorHints'
+} from '../../../workspace/workspaceState'
+import { WorkIndicator } from '../WorkIndicator'
+import { toolHintFromTurn } from '../workIndicatorHints'
+import { MarkerRow } from './MarkerRow'
 
 // -----------------------------------------------------------------------------
 // Feed — Claude Code TUI-style inline rendering.
@@ -2254,40 +2255,9 @@ function ToolBand({ children }: { children: React.ReactNode }) {
   )
 }
 
-/* ---------- 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}
-
- ) -} +// MarkerRow moved to ./MarkerRow.tsx — the `⏺`/`❯`/`⎿` layout primitive +// is now a shared module since features/git/ui/GitRows also imports it. +// Feed itself still imports MarkerRow from the new location at the top. /* ---------- Block dispatcher ---------- */ diff --git a/src/renderer/src/features/feed/ui/MarkerRow.tsx b/src/renderer/src/features/feed/ui/MarkerRow.tsx new file mode 100644 index 000000000..de511a17f --- /dev/null +++ b/src/renderer/src/features/feed/ui/MarkerRow.tsx @@ -0,0 +1,44 @@ +import type { ReactNode } from 'react' + +/** + * The core layout for a feed row: a fixed-width marker column + flex-1 + * content column that enforces hanging indent. Used by every rendered + * block (`⏺` for assistant text, `❯` for user prompts, `⎿` for tool + * results / nested content). + * + * 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. + * + * Exported because the git custom renderers (features/git/ui/GitRows) + * also want the same marker+hanging-indent layout when they render + * git-tool widgets in the feed. + */ +export function MarkerRow({ + marker, + tone = 'accent', + children, + indent = 0, +}: { + marker: string + tone?: 'accent' | 'muted' | 'ink' + children: ReactNode + indent?: number +}) { + const toneClass = + tone === 'accent' ? 'text-accent' : tone === 'muted' ? 'text-muted' : 'text-ink-dim' + return ( +
+ +
{children}
+
+ ) +} diff --git a/src/renderer/src/feed/workIndicatorHints.ts b/src/renderer/src/features/feed/workIndicatorHints.ts similarity index 97% rename from src/renderer/src/feed/workIndicatorHints.ts rename to src/renderer/src/features/feed/workIndicatorHints.ts index 5d69b5869..44e396999 100644 --- a/src/renderer/src/feed/workIndicatorHints.ts +++ b/src/renderer/src/features/feed/workIndicatorHints.ts @@ -9,7 +9,7 @@ // visual tests trivial and keeps the component memo-friendly (it only // re-renders when the hint string changes, not on every block delta). -import type { SemanticLiveBlock, SemanticLiveTurn } from '../workspace/workspaceState' +import type { SemanticLiveBlock, SemanticLiveTurn } from '../../workspace/workspaceState' /** Pull a short, single-line hint for the pending tool call. When the * tool's parsed input is available (finalised JSON from diff --git a/src/renderer/src/features/git/ui/GitRows.tsx b/src/renderer/src/features/git/ui/GitRows.tsx index 4c2290d15..579f272cf 100644 --- a/src/renderer/src/features/git/ui/GitRows.tsx +++ b/src/renderer/src/features/git/ui/GitRows.tsx @@ -30,7 +30,7 @@ import { memo, useMemo, useState } from 'react' import hljs from 'highlight.js' -import { MarkerRow } from '../../../feed/Feed' +import { MarkerRow } from '../../../features/feed/ui/MarkerRow' import { normalizeCodeLanguage } from '../../../../../shared/code/language' import type { GitIntent } from '../../../../../shared/git/gitDetect' import { diff --git a/src/renderer/src/features/reader/ui/ReaderView.tsx b/src/renderer/src/features/reader/ui/ReaderView.tsx index 485bc6933..4b87a9912 100644 --- a/src/renderer/src/features/reader/ui/ReaderView.tsx +++ b/src/renderer/src/features/reader/ui/ReaderView.tsx @@ -3,7 +3,7 @@ import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { CodeBlock } from '../../../lib/code/CodeBlock' -import { CodeRenderContext } from '../../../feed/Feed' +import { CodeRenderContext } from '../../../features/feed/ui/Feed' import { extractAssistantInProgress } from '../../../../../shared/parsers/extractAssistant' import { assistantUuidsWithText, extractAssistantByUuid } from '../../../lib/copyAssistant' import { collectLeaves } from '../../../workspace/tile-tree/treeOps' diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf.tsx b/src/renderer/src/workspace/tile-tree/TileLeaf.tsx index 9fccd8475..d1c7153cb 100644 --- a/src/renderer/src/workspace/tile-tree/TileLeaf.tsx +++ b/src/renderer/src/workspace/tile-tree/TileLeaf.tsx @@ -4,8 +4,8 @@ import { extractAssistantInProgress } from '../../../../shared/parsers/extractAs import { CodexApprovalModal } from '../../../../providers/codex/renderer/CodexApprovalModal' import { ResumePromptModal } from '../../../../providers/claude/renderer/ResumePromptModal' import { useGlobalToast } from '../../ui/GlobalToast' -import { Feed } from '../../feed/Feed' -import type { ScrollInfo } from '../../feed/Feed' +import { Feed } from '../../features/feed/ui/Feed' +import type { ScrollInfo } from '../../features/feed/ui/Feed' import { TrustDialogModal } from '../../../../providers/claude/renderer/TrustDialogModal' import { SlashCommandPicker } from '../../../../providers/claude/renderer/SlashCommandPicker' import { extractLatestUserPrompts } from '../../features/workspace/lib/latestUserPrompts' From c10360e1de978170e397180327bbe2abb41bf499 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 23 Apr 2026 18:49:20 +0200 Subject: [PATCH 08/27] refactor(workspace): decompose useWorkspace into sub-hooks under hook/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/workspace/hook/actions/draft.ts | 65 ++ .../src/workspace/hook/actions/history.ts | 136 +++ .../src/workspace/hook/actions/pane.ts | 689 +++++++++++++ .../src/workspace/hook/actions/picker.ts | 137 +++ .../src/workspace/hook/actions/provider.ts | 253 +++++ .../src/workspace/hook/actions/reader.ts | 57 ++ .../src/workspace/hook/actions/resize.ts | 165 ++++ .../src/workspace/hook/actions/session.ts | 460 +++++++++ .../src/workspace/hook/actions/spotlight.ts | 51 + .../src/workspace/hook/actions/statusMode.ts | 16 + .../src/workspace/hook/actions/streaming.ts | 150 +++ .../src/workspace/hook/actions/tab.ts | 235 +++++ .../src/workspace/hook/actions/tileTabs.ts | 147 +++ .../src/workspace/hook/actions/undoClose.ts | 153 +++ src/renderer/src/workspace/hook/context.ts | 119 +++ src/renderer/src/workspace/hook/helpers.ts | 151 +++ src/renderer/src/workspace/hook/index.ts | 327 ++++++ .../workspace/hook/invalidation/effects.ts | 134 +++ .../workspace/hook/ipc/useIpcSubscriptions.ts | 935 ++++++++++++++++++ .../workspace/hook/persistence/rehydrate.ts | 223 +++++ .../workspace/hook/persistence/useAutoSave.ts | 78 ++ .../hook/persistence/useBootstrap.ts | 74 ++ .../hook/persistence/useFeedDebugPersist.ts | 47 + src/renderer/src/workspace/hook/refs.ts | 110 +++ 24 files changed, 4912 insertions(+) create mode 100644 src/renderer/src/workspace/hook/actions/draft.ts create mode 100644 src/renderer/src/workspace/hook/actions/history.ts create mode 100644 src/renderer/src/workspace/hook/actions/pane.ts create mode 100644 src/renderer/src/workspace/hook/actions/picker.ts create mode 100644 src/renderer/src/workspace/hook/actions/provider.ts create mode 100644 src/renderer/src/workspace/hook/actions/reader.ts create mode 100644 src/renderer/src/workspace/hook/actions/resize.ts create mode 100644 src/renderer/src/workspace/hook/actions/session.ts create mode 100644 src/renderer/src/workspace/hook/actions/spotlight.ts create mode 100644 src/renderer/src/workspace/hook/actions/statusMode.ts create mode 100644 src/renderer/src/workspace/hook/actions/streaming.ts create mode 100644 src/renderer/src/workspace/hook/actions/tab.ts create mode 100644 src/renderer/src/workspace/hook/actions/tileTabs.ts create mode 100644 src/renderer/src/workspace/hook/actions/undoClose.ts create mode 100644 src/renderer/src/workspace/hook/context.ts create mode 100644 src/renderer/src/workspace/hook/helpers.ts create mode 100644 src/renderer/src/workspace/hook/index.ts create mode 100644 src/renderer/src/workspace/hook/invalidation/effects.ts create mode 100644 src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts create mode 100644 src/renderer/src/workspace/hook/persistence/rehydrate.ts create mode 100644 src/renderer/src/workspace/hook/persistence/useAutoSave.ts create mode 100644 src/renderer/src/workspace/hook/persistence/useBootstrap.ts create mode 100644 src/renderer/src/workspace/hook/persistence/useFeedDebugPersist.ts create mode 100644 src/renderer/src/workspace/hook/refs.ts diff --git a/src/renderer/src/workspace/hook/actions/draft.ts b/src/renderer/src/workspace/hook/actions/draft.ts new file mode 100644 index 000000000..28385725e --- /dev/null +++ b/src/renderer/src/workspace/hook/actions/draft.ts @@ -0,0 +1,65 @@ +import { useCallback, type Dispatch, type SetStateAction } from 'react' + +import { emptyRuntime, type SessionRuntime } from '../../workspaceState' +import type { SessionId } from '../../types' +import type { WorkspaceSetRuntimes } from '../context' + +// Draft input + image actions. +// +// Called from TileLeaf on every onChange/onKeyDown that mutates the +// composer text. Lives in runtime so it survives TileLeaf unmount +// when the user switches tabs. See SessionRuntime.draftInput for +// the reasoning. +// +// Draft version counter — bumped on every draft change so the save +// effect picks it up without watching the full runtimes object. + +export function useDraftActions( + setRuntimes: WorkspaceSetRuntimes, + updateRuntime: (sessionId: SessionId, patch: Partial) => void, + setDraftVersion: Dispatch>, +): { + setDraftInput: (sessionId: SessionId, text: string) => void + setDraftImages: ( + sessionId: SessionId, + next: + | SessionRuntime['draftImages'] + | ((prev: SessionRuntime['draftImages']) => SessionRuntime['draftImages']), + ) => void +} { + const setDraftInput = useCallback( + (sessionId: SessionId, text: string) => { + updateRuntime(sessionId, { draftInput: text }) + setDraftVersion(v => v + 1) + }, + [setDraftVersion, updateRuntime], + ) + + const setDraftImages = useCallback( + ( + sessionId: SessionId, + next: + | SessionRuntime['draftImages'] + | ((prev: SessionRuntime['draftImages']) => SessionRuntime['draftImages']), + ) => { + setRuntimes(prev => { + const current = prev[sessionId] ?? emptyRuntime() + const draftImages = + typeof next === 'function' + ? next(current.draftImages) + : next + return { + ...prev, + [sessionId]: { + ...current, + draftImages, + }, + } + }) + setDraftVersion(v => v + 1) + }, + [setDraftVersion, setRuntimes], + ) + + return { setDraftInput, setDraftImages } +} diff --git a/src/renderer/src/workspace/hook/actions/history.ts b/src/renderer/src/workspace/hook/actions/history.ts new file mode 100644 index 000000000..26723005e --- /dev/null +++ b/src/renderer/src/workspace/hook/actions/history.ts @@ -0,0 +1,136 @@ +import { useCallback } from 'react' + +import { emptyRuntime, type SessionRuntime } from '../../workspaceState' +import type { SessionId } from '../../types' +import type { Entry } from '../../../../../shared/types/transcript' +import { + isCompactBoundaryEntry, + isCompactSummaryEntry, + isConversationEntry, +} from '../../../../../shared/types/transcript' +import { + codexHistoryMarker, + codexTurnIdFromRollout, + mapCodexRolloutToFeedEntries, + stampCodexTurnId, +} from '../../codex/rollout' +import { + claudeHistoryMarker, + extractEmbeddedClaudeProgressEntry, +} from '../../claude/history' + +import type { WorkspaceSetRuntimes } from '../context' +import type { WorkspaceRefs } from '../refs' + +// Older history loader — called by Feed's scroll handler when the +// user scrolls near the top. +// +// Walks the rollout stream linearly; the rolling Codex turn id +// maintained by `turn_context` markers still stamps response_items +// that come after it during pagination. + +export function useHistoryActions( + setRuntimes: WorkspaceSetRuntimes, + refs: WorkspaceRefs, + updateRuntime: (sessionId: SessionId, patch: Partial) => void, +): { + loadOlderHistory: (sessionId: SessionId) => Promise +} { + const loadOlderHistory = useCallback( + async (sessionId: SessionId) => { + const currentState = refs.stateRef.current + const meta = currentState.sessions[sessionId] + const runtime = refs.latestRuntimesRef.current[sessionId] ?? emptyRuntime() + if (!meta) return + + const kind = meta.kind ?? 'claude' + if ((kind !== 'claude' && kind !== 'codex') || !meta.providerSessionId) return + if (!runtime.hasOlderHistory || runtime.loadingOlderHistory) return + if (!runtime.historyOldestMarker) { + updateRuntime(sessionId, { hasOlderHistory: false, loadingOlderHistory: false }) + return + } + + updateRuntime(sessionId, { loadingOlderHistory: true }) + + try { + const chunk = await window.api.loadOlderHistory({ + kind, + cwd: meta.cwd, + providerSessionId: meta.providerSessionId, + beforeMarker: runtime.historyOldestMarker, + limit: 200, + }) + + const seen = (refs.seenUuidsRef.current[sessionId] ??= new Set()) + const prepend: Entry[] = [] + let oldestMarker: string | null = runtime.historyOldestMarker + // Same rolling Codex turn id as the live JSONL ingest path + // — loadOlderHistory walks the rollout stream linearly too, + // so a `turn_context` marker seen during pagination still + // stamps the response_items that come after it. See + // codexTurnIdFromRollout. + let codexPaginationTurnId: string | null = null + + for (const rawEntry of chunk.entries) { + if (kind === 'codex') { + const marker = codexHistoryMarker(rawEntry) + const turnContextId = codexTurnIdFromRollout(rawEntry) + if (turnContextId !== null) codexPaginationTurnId = turnContextId + const mappedRaw = mapCodexRolloutToFeedEntries(rawEntry) + const mapped = mappedRaw.map(e => stampCodexTurnId(e, codexPaginationTurnId)) + if (mapped.length > 0 && oldestMarker === runtime.historyOldestMarker) { + oldestMarker = marker + } + for (const entry of mapped) { + const uuid = (entry as { uuid?: string }).uuid + if (uuid && seen.has(uuid)) continue + if (uuid) seen.add(uuid) + prepend.push(entry) + } + continue + } + + const feedEntry = + extractEmbeddedClaudeProgressEntry(rawEntry) ?? + (rawEntry as Entry) + const marker = claudeHistoryMarker(rawEntry) + if (!( + isConversationEntry(feedEntry) || + isCompactBoundaryEntry(feedEntry) || + isCompactSummaryEntry(feedEntry) + )) { + continue + } + if (marker && oldestMarker === runtime.historyOldestMarker) { + oldestMarker = marker + } + const uuid = (feedEntry as { uuid?: string }).uuid + if (uuid && seen.has(uuid)) continue + if (uuid) seen.add(uuid) + prepend.push(feedEntry) + } + + setRuntimes(prev => { + const current = prev[sessionId] ?? emptyRuntime() + return { + ...prev, + [sessionId]: { + ...current, + entries: prepend.length > 0 ? [...prepend, ...current.entries] : current.entries, + historyOldestMarker: oldestMarker ?? current.historyOldestMarker, + hasOlderHistory: chunk.hasMore || prepend.length === 0, + loadingOlderHistory: false, + }, + } + }) + } catch (err) { + console.warn('[history] load older failed', err) + updateRuntime(sessionId, { loadingOlderHistory: false }) + } + }, + [refs.latestRuntimesRef, refs.seenUuidsRef, refs.stateRef, setRuntimes, updateRuntime], + ) + + return { loadOlderHistory } +} diff --git a/src/renderer/src/workspace/hook/actions/pane.ts b/src/renderer/src/workspace/hook/actions/pane.ts new file mode 100644 index 000000000..82acd0b2a --- /dev/null +++ b/src/renderer/src/workspace/hook/actions/pane.ts @@ -0,0 +1,689 @@ +import { useCallback } from 'react' + +import type { + BuriedPaneRecord, + SessionId, + SessionKind, + SessionMeta, + SplitDirection, + Tab, + TileNode, +} from '../../types' +import { RATIO_DEFAULT } from '../../types' +import { + closeLeaf, + collectLeaves, + insertBesideLeaf, + splitLeaf, + wrapRootWithLeaf, +} from '../../tile-tree/treeOps' +import { findBestRemainingFocus, findDirectionalNeighbor } from '../../tile-tree/geometry' +import { findParentSplitInfo } from '../../../lib/undoClose' +import { titleFromCwd } from '../../layout/helpers' +import type { PlacementTarget } from '../../../features/workspace/lib/newAgentPlacement' + +import type { + WorkspaceSetRuntimes, + WorkspaceSetSpotlight, + WorkspaceSetState, + WorkspaceSetTileTabs, +} from '../context' +import type { WorkspaceRefs } from '../refs' +import type { SessionActions } from './session' + +// ----------------------------------------------------------------------------- +// Pane / focus / navigation actions. +// +// Covers: splitFocused, startNewAgentPlacement, commitNewAgentPlacement, +// closeFocused, closeSession, requestBuryFocused, buryFocused, +// reviveBuried, focusSession, focusSessionInTab, navigate. +// ----------------------------------------------------------------------------- + +export function usePaneActions( + state: { activeTabId: string; sessions: Record; tabs: Tab[] }, + setState: WorkspaceSetState, + setRuntimes: WorkspaceSetRuntimes, + setSpotlight: WorkspaceSetSpotlight, + setTileTabs: WorkspaceSetTileTabs, + refs: WorkspaceRefs, + showToast: (message: string, durationMs?: number) => void, + openBuryPrompt: (sessionId: SessionId) => void, + closeBuryPrompt: () => void, + openNewAgentPlacement: () => void, + closeNewAgentPlacement: () => void, + sessionActions: SessionActions, +): { + splitFocused: (direction: SplitDirection, kind?: SessionKind, resumeSessionId?: string) => Promise + startNewAgentPlacement: () => void + commitNewAgentPlacement: (kind: SessionKind, target: PlacementTarget) => Promise + closeFocused: () => Promise + closeSession: (targetId: SessionId) => Promise + requestBuryFocused: () => void + buryFocused: (note?: string, targetSessionId?: SessionId) => void + reviveBuried: (buriedId: string) => void + focusSession: (sessionId: SessionId) => void + focusSessionInTab: (tabId: string, sessionId: SessionId) => void + navigate: (direction: 'left' | 'right' | 'up' | 'down') => void +} { + // Spawns a new session in the parent pane's cwd, inserts a new + // leaf under a fresh split node, makes the new pane focused. + const splitFocused = useCallback( + async ( + direction: SplitDirection, + kind: SessionKind = 'claude', + resumeSessionId?: string, + ) => { + const tab = state.tabs.find(t => t.id === state.activeTabId) + if (!tab) return + const parentSessionId = tab.focusedSessionId + const parentCwd = state.sessions[parentSessionId]?.cwd + if (!parentCwd) return + + let newSessionId: SessionId + try { + newSessionId = await sessionActions.spawn(parentCwd, { kind, resumeSessionId }) + } catch (err) { + showToast( + err instanceof Error && err.message.length > 0 + ? err.message + : 'Failed to split pane', + ) + return + } + + setState(prev => ({ + ...prev, + tabs: prev.tabs.map(t => { + if (t.id !== prev.activeTabId) return t + return { + ...t, + root: splitLeaf(t.root, parentSessionId, direction, newSessionId), + focusedSessionId: newSessionId, + } + }), + })) + }, + [sessionActions, setState, showToast, state.activeTabId, state.sessions, state.tabs], + ) + + const startNewAgentPlacement = useCallback(() => { + const tab = state.tabs.find(t => t.id === state.activeTabId) + if (!tab) return + openNewAgentPlacement() + }, [openNewAgentPlacement, state.activeTabId, state.tabs]) + + const commitNewAgentPlacement = useCallback( + async (kind: SessionKind, target: PlacementTarget) => { + const tab = state.tabs.find(t => t.id === state.activeTabId) + if (!tab) return + const anchorSessionId = tab.focusedSessionId + const cwd = state.sessions[anchorSessionId]?.cwd + if (!cwd) return + + let newSessionId: SessionId + try { + newSessionId = await sessionActions.spawn(cwd, { kind }) + } catch (err) { + showToast( + err instanceof Error && err.message.length > 0 + ? err.message + : 'Failed to create pane', + ) + return + } + setState(prev => ({ + ...prev, + tabs: prev.tabs.map(currentTab => { + if (currentTab.id !== prev.activeTabId) return currentTab + return { + ...currentTab, + root: + target.kind === 'wrap-root' + ? wrapRootWithLeaf( + currentTab.root, + target.direction, + target.side, + newSessionId, + ) + : insertBesideLeaf( + currentTab.root, + target.targetSessionId, + target.direction, + RATIO_DEFAULT, + target.side, + newSessionId, + ), + focusedSessionId: newSessionId, + } + }), + })) + closeNewAgentPlacement() + }, + [ + closeNewAgentPlacement, + sessionActions, + setState, + showToast, + state.activeTabId, + state.sessions, + state.tabs, + ], + ) + + // Removes the leaf from the tree and kills its session. If the + // tree collapses to nothing, closes the whole tab. If that was + // the last tab, leaves the workspace in an empty state — the UI + // shows a welcome screen prompting for a new tab. + // + // Before destroying anything, we capture undo info and push it + // onto the undo-close stack so the user can restore the pane (or + // tab) with a single command within the next 2 minutes. + const closeFocused = useCallback(async () => { + const tab = state.tabs.find(t => t.id === state.activeTabId) + if (!tab) return + const targetId = tab.focusedSessionId + const sessionMeta = state.sessions[targetId] + + // Capture undo info BEFORE mutating the tree. Two cases: + // 1. Pane inside a split → record the parent split's geometry + // and the surviving sibling's anchor leaf so we can + // re-split. + // 2. Last pane in a tab → record the whole tab so we can + // re-insert it at the same index. + const parentInfo = findParentSplitInfo(tab.root, targetId) + if (parentInfo && sessionMeta) { + refs.undoStackRef.current.push({ + type: 'pane', + closedAt: Date.now(), + tabId: tab.id, + sessionMeta, + direction: parentInfo.direction, + ratio: parentInfo.ratio, + side: parentInfo.side, + siblingLeafId: parentInfo.siblingLeafId, + }) + // Pane-level close — show the kind+cwd basename so the user + // can recognize which pane they killed when several look + // alike. + const kindLabel = sessionMeta.kind ?? 'claude' + const cwdBase = sessionMeta.cwd.split('/').filter(Boolean).pop() ?? sessionMeta.cwd + showToast(`Closed ${kindLabel} pane (${cwdBase}) — ⌘⇧T (Undo Close)`) + } else if (!parentInfo && sessionMeta) { + // This pane IS the root — closing it kills the tab. Capture + // the tab-level undo entry. + const tabIdx = state.tabs.findIndex(t => t.id === tab.id) + const allMetas: Record = {} + for (const leafId of collectLeaves(tab.root)) { + if (state.sessions[leafId]) allMetas[leafId] = state.sessions[leafId] + } + refs.undoStackRef.current.push({ + type: 'tab', + closedAt: Date.now(), + tab: { ...tab }, + tabIndex: tabIdx, + sessionMetas: allMetas, + }) + showToast(`Closed “${tab.title}” — ⌘⇧T (Undo Close)`) + } + + await window.api.killSession(targetId) + + setRuntimes(prev => { + const next = { ...prev } + delete next[targetId] + return next + }) + delete refs.seenUuidsRef.current[targetId] + delete refs.latestScreenRef.current[targetId] + + setState(prev => { + const tabs = [...prev.tabs] + const tabIdx = tabs.findIndex(t => t.id === prev.activeTabId) + if (tabIdx === -1) return prev + const currentTab = tabs[tabIdx] + const nextRoot = closeLeaf(currentTab.root, targetId) + + if (nextRoot === null) { + // Tab is now empty — close it and activate another tab. + const remaining = tabs.filter((_, i) => i !== tabIdx) + const sessions = { ...prev.sessions } + delete sessions[targetId] + return { + ...prev, + tabs: remaining, + activeTabId: remaining[Math.max(0, tabIdx - 1)]?.id ?? '', + sessions, + } + } + + const nextFocused = + findBestRemainingFocus(currentTab.root, nextRoot, targetId) ?? + collectLeaves(nextRoot)[0] + tabs[tabIdx] = { + ...currentTab, + root: nextRoot, + focusedSessionId: nextFocused, + } + const sessions = { ...prev.sessions } + delete sessions[targetId] + return { ...prev, tabs, sessions } + }) + }, [ + refs.latestScreenRef, + refs.seenUuidsRef, + refs.undoStackRef, + setRuntimes, + setState, + showToast, + state.activeTabId, + state.sessions, + state.tabs, + ]) + + // Mirrors closeFocused but operates on a caller-specified session + // instead of the active tab's focused pane. Exists so UI surfaces + // that list multiple panes at once (e.g. the Agent Activity + // modal) can close stale sessions without first having to + // focus-then-close, which would jank the visible layout for every + // close and race with React's batched setState. + // + // Uses stateRef.current for the same reason buryFocused does: the + // caller's action isn't bound to whatever happens to be active. + const closeSession = useCallback( + async (targetId: SessionId) => { + const snapshot = refs.stateRef.current + const owningTab = snapshot.tabs.find(t => collectLeaves(t.root).includes(targetId)) + if (!owningTab) return + const sessionMeta = snapshot.sessions[targetId] + + // Same two-case undo capture as closeFocused: pane-in-split + // vs. last-pane-in-tab. Keeps ⌘⇧T working for modal-driven + // closes. + const parentInfo = findParentSplitInfo(owningTab.root, targetId) + if (parentInfo && sessionMeta) { + refs.undoStackRef.current.push({ + type: 'pane', + closedAt: Date.now(), + tabId: owningTab.id, + sessionMeta, + direction: parentInfo.direction, + ratio: parentInfo.ratio, + side: parentInfo.side, + siblingLeafId: parentInfo.siblingLeafId, + }) + const kindLabel = sessionMeta.kind ?? 'claude' + const cwdBase = sessionMeta.cwd.split('/').filter(Boolean).pop() ?? sessionMeta.cwd + showToast(`Closed ${kindLabel} pane (${cwdBase}) — ⌘⇧T (Undo Close)`) + } else if (!parentInfo && sessionMeta) { + const tabIdx = snapshot.tabs.findIndex(t => t.id === owningTab.id) + const allMetas: Record = {} + for (const leafId of collectLeaves(owningTab.root)) { + if (snapshot.sessions[leafId]) allMetas[leafId] = snapshot.sessions[leafId] + } + refs.undoStackRef.current.push({ + type: 'tab', + closedAt: Date.now(), + tab: { ...owningTab }, + tabIndex: tabIdx, + sessionMetas: allMetas, + }) + showToast(`Closed “${owningTab.title}” — ⌘⇧T (Undo Close)`) + } + + await window.api.killSession(targetId) + + setRuntimes(prev => { + const next = { ...prev } + delete next[targetId] + return next + }) + delete refs.seenUuidsRef.current[targetId] + delete refs.latestScreenRef.current[targetId] + + setState(prev => { + const tabs = [...prev.tabs] + const tabIdx = tabs.findIndex(t => t.id === owningTab.id) + // Tab may have been closed between modal-open and confirm. + // Treat that as a no-op — the row will disappear on next + // render anyway via the "visible sessions" selector. + if (tabIdx === -1) return prev + const currentTab = tabs[tabIdx] + const nextRoot = closeLeaf(currentTab.root, targetId) + + if (nextRoot === null) { + const remaining = tabs.filter((_, i) => i !== tabIdx) + const sessions = { ...prev.sessions } + delete sessions[targetId] + // Only retarget activeTabId if we just removed the active + // tab. Closing a pane in a BACKGROUND tab from the modal + // must not yank the user out of the tab they see when the + // modal closes. + const nextActiveTabId = prev.activeTabId === owningTab.id + ? (remaining[Math.max(0, tabIdx - 1)]?.id ?? '') + : prev.activeTabId + return { + ...prev, + tabs: remaining, + activeTabId: nextActiveTabId, + sessions, + } + } + + const nextFocused = + findBestRemainingFocus(currentTab.root, nextRoot, targetId) ?? + collectLeaves(nextRoot)[0] + tabs[tabIdx] = { + ...currentTab, + root: nextRoot, + focusedSessionId: nextFocused, + } + const sessions = { ...prev.sessions } + delete sessions[targetId] + return { ...prev, tabs, sessions } + }) + }, + [ + refs.latestScreenRef, + refs.seenUuidsRef, + refs.stateRef, + refs.undoStackRef, + setRuntimes, + setState, + showToast, + ], + ) + + // Bury: remove the focused pane from the visible layout without + // killing the underlying session. The session keeps running in + // the background and remains eligible for revive. + const requestBuryFocused = useCallback(() => { + const tab = refs.stateRef.current.tabs.find( + t => t.id === refs.stateRef.current.activeTabId, + ) + if (!tab) return + openBuryPrompt(tab.focusedSessionId) + }, [openBuryPrompt, refs.stateRef]) + + const buryFocused = useCallback( + (note?: string, targetSessionId?: SessionId) => { + // The bury prompt is modal on a specific session, not a + // specific tab. It can outlive a tab switch: user opens the + // prompt on pane X in tab A, switches to tab B, then hits + // Enter. Earlier we resolved `tab` via `state.activeTabId`, + // which meant that confirm-after-switch mutated tab B's tree + // even though targetId still pointed at pane X in tab A. + // Resolve the owning tab from the target session instead. + const snapshot = refs.stateRef.current + const activeTab = snapshot.tabs.find(t => t.id === snapshot.activeTabId) + const targetId = targetSessionId ?? activeTab?.focusedSessionId + if (!targetId) return + + const owningTab = snapshot.tabs.find(t => collectLeaves(t.root).includes(targetId)) + if (!owningTab) return + + const sessionMeta = snapshot.sessions[targetId] + if (!sessionMeta) return + + const parentInfo = findParentSplitInfo(owningTab.root, targetId) + const tabIndex = snapshot.tabs.findIndex(t => t.id === owningTab.id) + const buriedRecord: BuriedPaneRecord = { + id: targetId, + sessionId: targetId, + sessionMeta, + buriedAt: Date.now(), + sourceTabId: owningTab.id, + sourceTabTitle: owningTab.title, + sourceTabIndex: tabIndex, + direction: parentInfo?.direction, + ratio: parentInfo?.ratio, + side: parentInfo?.side, + siblingLeafId: parentInfo?.siblingLeafId, + note: note?.trim() ? note.trim() : undefined, + } + + const kindLabel = sessionMeta.kind ?? 'claude' + const cwdBase = sessionMeta.cwd.split('/').filter(Boolean).pop() ?? sessionMeta.cwd + showToast(`Buried ${kindLabel} pane (${cwdBase})`) + + setState(prev => { + const tabs = [...prev.tabs] + const tabIdx = tabs.findIndex(t => t.id === owningTab.id) + // Tab may have been closed between prompt-open and confirm. + // Treat that as a no-op rather than mutating an unrelated tab. + if (tabIdx === -1) return prev + + const currentTab = tabs[tabIdx] + const nextRoot = closeLeaf(currentTab.root, targetId) + if (nextRoot === null) { + const remaining = tabs.filter((_, i) => i !== tabIdx) + // Only retarget activeTabId if we just removed the active + // tab. Burying a pane in a background tab must not yank + // the user out of the tab they're currently looking at. + const nextActiveTabId = prev.activeTabId === owningTab.id + ? (remaining[Math.max(0, tabIdx - 1)]?.id ?? '') + : prev.activeTabId + return { + ...prev, + tabs: remaining, + activeTabId: nextActiveTabId, + buried: [ + ...prev.buried.filter(entry => entry.sessionId !== targetId), + buriedRecord, + ], + } + } + + const nextFocused = + findBestRemainingFocus(currentTab.root, nextRoot, targetId) ?? + collectLeaves(nextRoot)[0] + tabs[tabIdx] = { + ...currentTab, + root: nextRoot, + focusedSessionId: nextFocused, + } + return { + ...prev, + tabs, + buried: [ + ...prev.buried.filter(entry => entry.sessionId !== targetId), + buriedRecord, + ], + } + }) + setSpotlight(prev => (prev?.tabId === owningTab.id ? null : prev)) + closeBuryPrompt() + }, + [closeBuryPrompt, refs.stateRef, setSpotlight, setState, showToast], + ) + + // Restores a buried session into the most plausible visible + // location. First choice is the original sibling anchor, then the + // original tab, then the best current tab by cwd/kind/title + // affinity, and finally a fresh single-pane tab if no good target + // exists. + const reviveBuried = useCallback( + (buriedId: string) => { + const current = refs.stateRef.current + const entry = current.buried.find(item => item.id === buriedId) + if (!entry) return + + const chooseFallbackTab = (): Tab | null => { + const scored = current.tabs + .map(tab => { + let score = 0 + if (tab.id === entry.sourceTabId) score += 100 + if (tab.title === entry.sourceTabTitle) score += 20 + const leafIds = collectLeaves(tab.root) + for (const leafId of leafIds) { + const meta = current.sessions[leafId] + if (!meta) continue + if (meta.cwd === entry.sessionMeta.cwd) score += 15 + if ((meta.kind ?? 'claude') === (entry.sessionMeta.kind ?? 'claude')) score += 5 + } + return { tab, score } + }) + .filter(candidate => candidate.score > 0) + .sort((a, b) => b.score - a.score) + return scored[0]?.tab ?? current.tabs[0] ?? null + } + + const anchorTab = entry.siblingLeafId + ? current.tabs.find(tab => collectLeaves(tab.root).includes(entry.siblingLeafId!)) + : null + const targetTab = anchorTab ?? chooseFallbackTab() + + setState(prev => { + const nextBuried = prev.buried.filter(item => item.id !== buriedId) + + if (!targetTab) { + const tabId = crypto.randomUUID() + const title = titleFromCwd(entry.sessionMeta.cwd) + const revivedTab: Tab = { + id: tabId, + title, + root: { type: 'leaf', sessionId: entry.sessionId }, + focusedSessionId: entry.sessionId, + } + return { + ...prev, + tabs: [...prev.tabs, revivedTab], + activeTabId: tabId, + buried: nextBuried, + } + } + + const target = prev.tabs.find(tab => tab.id === targetTab.id) + if (!target) { + const tabId = crypto.randomUUID() + const title = titleFromCwd(entry.sessionMeta.cwd) + const revivedTab: Tab = { + id: tabId, + title, + root: { type: 'leaf', sessionId: entry.sessionId }, + focusedSessionId: entry.sessionId, + } + return { + ...prev, + tabs: [...prev.tabs, revivedTab], + activeTabId: tabId, + buried: nextBuried, + } + } + + const leafIds = collectLeaves(target.root) + const cwdLeaf = + leafIds.find(leafId => prev.sessions[leafId]?.cwd === entry.sessionMeta.cwd) ?? null + const anchorLeafId = + (entry.siblingLeafId && leafIds.includes(entry.siblingLeafId)) + ? entry.siblingLeafId + : (cwdLeaf ?? target.focusedSessionId ?? leafIds[0] ?? null) + + if (!anchorLeafId) { + const tabId = crypto.randomUUID() + const title = titleFromCwd(entry.sessionMeta.cwd) + const revivedTab: Tab = { + id: tabId, + title, + root: { type: 'leaf', sessionId: entry.sessionId }, + focusedSessionId: entry.sessionId, + } + return { + ...prev, + tabs: [...prev.tabs, revivedTab], + activeTabId: tabId, + buried: nextBuried, + } + } + + const revivedRoot = insertBesideLeaf( + target.root, + anchorLeafId, + entry.direction ?? 'vertical', + entry.ratio ?? RATIO_DEFAULT, + entry.side ?? 'b', + entry.sessionId, + ) + + return { + ...prev, + tabs: prev.tabs.map(tab => + tab.id === target.id + ? { + ...tab, + root: revivedRoot, + focusedSessionId: entry.sessionId, + } + : tab, + ), + activeTabId: target.id, + buried: nextBuried, + } + }) + }, + [refs.stateRef, setState], + ) + + const focusSession = useCallback( + (sessionId: SessionId) => { + setState(prev => ({ + ...prev, + tabs: prev.tabs.map(t => + t.id === prev.activeTabId ? { ...t, focusedSessionId: sessionId } : t, + ), + })) + setSpotlight(prev => ( + prev && prev.tabId === refs.stateRef.current.activeTabId + ? { ...prev, focusedSessionId: sessionId } + : prev + )) + }, + [refs.stateRef, setSpotlight, setState], + ) + + const focusSessionInTab = useCallback( + (tabId: string, sessionId: SessionId) => { + setState(prev => ({ + ...prev, + activeTabId: tabId, + tabs: prev.tabs.map(t => + t.id === tabId ? { ...t, focusedSessionId: sessionId } : t, + ), + })) + setSpotlight(prev => ( + prev && prev.tabId === tabId + ? { ...prev, focusedSessionId: sessionId } + : prev + )) + setTileTabs(prev => ( + prev && prev.tabIds.includes(tabId) + ? { ...prev, focusedTabId: tabId } + : prev + )) + }, + [setSpotlight, setState, setTileTabs], + ) + + const navigate = useCallback( + (direction: 'left' | 'right' | 'up' | 'down') => { + const tab = state.tabs.find(t => t.id === state.activeTabId) + if (!tab) return + const next = findDirectionalNeighbor(tab.root, tab.focusedSessionId, direction) + if (next) focusSession(next) + }, + [focusSession, state.activeTabId, state.tabs], + ) + + return { + splitFocused, + startNewAgentPlacement, + commitNewAgentPlacement, + closeFocused, + closeSession, + requestBuryFocused, + buryFocused, + reviveBuried, + focusSession, + focusSessionInTab, + navigate, + } +} diff --git a/src/renderer/src/workspace/hook/actions/picker.ts b/src/renderer/src/workspace/hook/actions/picker.ts new file mode 100644 index 000000000..f03aeceda --- /dev/null +++ b/src/renderer/src/workspace/hook/actions/picker.ts @@ -0,0 +1,137 @@ +import { useCallback } from 'react' + +import { emptyRuntime } from '../../workspaceState' +import type { SessionId } from '../../types' +import { + assistantUuidsWithText, + extractAssistantByUuid, +} from '../../../lib/copyAssistant' + +import type { WorkspaceSetRuntimes } from '../context' +import type { WorkspaceRefs } from '../refs' + +// ---- Copy Assistant picker actions ---- +// +// pickerEnter — toggles the picker on/off. On entry, picks +// the most-recent assistant entry with text. +// No-op (picker stays null) if the session has +// no assistant entries with text yet. +// pickerMove — direction is +1 (Down → newer) or -1 (Up → +// older). Walks the assistantUuidsWithText +// list; clamps at the ends rather than wrapping +// (less surprising, matches macOS list pickers). +// pickerConfirm — copies the selected entry's text to clipboard, +// shows a pane toast, clears the picker. +// pickerCancel — clears the picker without copying. + +export function usePickerActions( + setRuntimes: WorkspaceSetRuntimes, + refs: WorkspaceRefs, + showPaneToast: (sessionId: SessionId, message: string, durationMs?: number) => void, +): { + pickerEnter: (sessionId: SessionId) => void + pickerMove: (sessionId: SessionId, direction: -1 | 1) => void + pickerCancel: (sessionId: SessionId) => void + pickerConfirm: (sessionId: SessionId) => Promise +} { + const pickerEnter = useCallback( + (sessionId: SessionId) => { + setRuntimes(prev => { + const current = prev[sessionId] ?? emptyRuntime() + if (current.assistantPicker) { + return { + ...prev, + [sessionId]: { ...current, assistantPicker: null }, + } + } + const uuids = assistantUuidsWithText(current.entries) + if (uuids.length === 0) return prev + return { + ...prev, + [sessionId]: { + ...current, + assistantPicker: { selectedUuid: uuids[uuids.length - 1] }, + }, + } + }) + }, + [setRuntimes], + ) + + const pickerMove = useCallback( + (sessionId: SessionId, direction: -1 | 1) => { + setRuntimes(prev => { + const current = prev[sessionId] ?? emptyRuntime() + const picker = current.assistantPicker + if (!picker) return prev + const uuids = assistantUuidsWithText(current.entries) + if (uuids.length === 0) return prev + const idx = uuids.indexOf(picker.selectedUuid) + if (idx === -1) { + // Selected uuid disappeared mid-flight — snap to the + // newest available so the user keeps a stable reference. + return { + ...prev, + [sessionId]: { + ...current, + assistantPicker: { selectedUuid: uuids[uuids.length - 1] }, + }, + } + } + const nextIdx = Math.max(0, Math.min(uuids.length - 1, idx + direction)) + if (nextIdx === idx) return prev + return { + ...prev, + [sessionId]: { + ...current, + assistantPicker: { selectedUuid: uuids[nextIdx] }, + }, + } + }) + }, + [setRuntimes], + ) + + const pickerCancel = useCallback( + (sessionId: SessionId) => { + setRuntimes(prev => { + const c = prev[sessionId] + if (!c?.assistantPicker) return prev + return { ...prev, [sessionId]: { ...c, assistantPicker: null } } + }) + }, + [setRuntimes], + ) + + const pickerConfirm = useCallback( + async (sessionId: SessionId) => { + const current = refs.latestRuntimesRef.current[sessionId] + if (!current?.assistantPicker) return + const text = extractAssistantByUuid( + current.entries, + current.assistantPicker.selectedUuid, + ) + // Clear the picker first so the UI returns to normal even if + // the clipboard write fails (rare — only with a permission + // denial, which we surface via toast). + setRuntimes(prev => { + const c = prev[sessionId] + if (!c) return prev + return { ...prev, [sessionId]: { ...c, assistantPicker: null } } + }) + if (!text) { + showPaneToast(sessionId, 'Nothing to copy') + return + } + try { + await navigator.clipboard.writeText(text) + showPaneToast(sessionId, 'Copied assistant message') + } catch { + showPaneToast(sessionId, 'Clipboard write failed') + } + }, + [refs.latestRuntimesRef, setRuntimes, showPaneToast], + ) + + return { pickerEnter, pickerMove, pickerCancel, pickerConfirm } +} diff --git a/src/renderer/src/workspace/hook/actions/provider.ts b/src/renderer/src/workspace/hook/actions/provider.ts new file mode 100644 index 000000000..9812fcf78 --- /dev/null +++ b/src/renderer/src/workspace/hook/actions/provider.ts @@ -0,0 +1,253 @@ +import { useCallback } from 'react' + +import type { SessionId } from '../../types' +import type { ClaudeDraftImage } from '../../workspaceState' + +import type { WorkspaceSetRuntimes } from '../context' +import type { WorkspaceRefs } from '../refs' +import type { SessionActions } from './session' + +// Provider-level actions on the focused pane. +// +// switchFocusedProvider — Claude ↔ Codex translation of the focused +// session's transcript, then re-home pane +// onto the new provider. +// reloadFocusedAgent — respawn the focused agent session with +// resume so the conversation history replays. +// rewindFocusedToPrompt — user picks a past user prompt; pane +// re-homes onto a truncated transcript with +// the prompt prefilled as an unsent draft. + +export function useProviderActions( + refs: WorkspaceRefs, + setRuntimes: WorkspaceSetRuntimes, + showPaneToast: (sessionId: SessionId, message: string, durationMs?: number) => void, + sessionActions: SessionActions, +): { + switchFocusedProvider: () => Promise + reloadFocusedAgent: () => Promise + rewindFocusedToPrompt: ( + anchor: + | { kind: 'claude'; uuid: string } + | { kind: 'codex'; userMessageIndex: number }, + ) => Promise +} { + const switchFocusedProvider = useCallback(async () => { + const current = refs.stateRef.current + const tab = current.tabs.find(t => t.id === current.activeTabId) + if (!tab) return + + const sourceSessionId = tab.focusedSessionId + const meta = current.sessions[sourceSessionId] + if (!meta) return + + const sourceKind = meta.kind ?? 'claude' + if (sourceKind !== 'claude' && sourceKind !== 'codex') { + showPaneToast(sourceSessionId, 'Only Claude and Codex panes can switch provider') + return + } + if (!meta.providerSessionId) { + showPaneToast(sourceSessionId, 'Provider session id is not ready yet') + return + } + + try { + // The translated target transcript must be created BEFORE we + // replace the live pane. If translation fails, the current + // provider process should stay untouched and the user should + // keep their running session instead of being dropped into a + // dead pane. + const result = await window.api.switchProvider({ + sourceKind, + sourceProviderSessionId: meta.providerSessionId, + cwd: meta.cwd, + }) + + const newSessionId = await sessionActions.replaceSession(meta.cwd, { + kind: result.targetKind, + resumeSessionId: result.targetProviderSessionId, + }) + if (!newSessionId) return + + showPaneToast( + newSessionId, + result.targetKind === 'codex' ? 'Switched to Codex' : 'Switched to Claude', + ) + } catch (err) { + const message = + err instanceof Error && err.message.length > 0 + ? err.message + : 'Provider switch failed' + showPaneToast(sourceSessionId, message) + } + }, [refs.stateRef, sessionActions, showPaneToast]) + + const reloadFocusedAgent = useCallback(async () => { + const current = refs.stateRef.current + const tab = current.tabs.find(t => t.id === current.activeTabId) + if (!tab) return + + const sourceSessionId = tab.focusedSessionId + const meta = current.sessions[sourceSessionId] + if (!meta) return + + const kind = meta.kind ?? 'claude' + if (kind !== 'claude' && kind !== 'codex') { + showPaneToast(sourceSessionId, 'Only Claude and Codex panes can reload') + return + } + if (!meta.providerSessionId) { + showPaneToast(sourceSessionId, 'Provider session id is not ready yet') + return + } + + try { + const newSessionId = await sessionActions.replaceSession(meta.cwd, { + kind, + resumeSessionId: meta.providerSessionId, + }) + if (!newSessionId) return + showPaneToast( + newSessionId, + kind === 'codex' ? 'Codex reloaded' : 'Claude reloaded', + ) + } catch (err) { + const message = + err instanceof Error && err.message.length > 0 + ? err.message + : 'Reload failed' + showPaneToast(sourceSessionId, message) + } + }, [refs.stateRef, sessionActions, showPaneToast]) + + // Rewind the focused pane to a selected earlier user prompt. + // + // Contract (see docs/superpowers/plans for the full rationale): + // 1. The source session's on-disk transcript is NOT touched. + // 2. Main produces a fresh provider session id whose transcript + // ends strictly before the anchor. + // 3. The focused pane is re-homed to the new id via + // `replaceSession(...)`, so the pane stays in place; only its + // backing session swaps. This matches resume semantics. + // 4. The anchored prompt's text is stuffed into the new pane's + // `draftInput` — the rewound session opens with the prompt + // prefilled and UNSENT, so the user can edit or re-send. + // + // Bail-outs: + // - No providerSessionId yet (pane still spawning): toast and + // return. Rewinding a session we can't locate on disk is a + // nonsense operation. + // - Session is mid-turn (processActive or live currentTurn): + // toast and return. Rewinding while a response is streaming + // exercises every race we have around the live-to-committed + // handoff at once; requiring idle is the safe path. + const rewindFocusedToPrompt = useCallback( + async ( + anchor: + | { kind: 'claude'; uuid: string } + | { kind: 'codex'; userMessageIndex: number }, + ) => { + const current = refs.stateRef.current + const tab = current.tabs.find(t => t.id === current.activeTabId) + if (!tab) return + + const sourceSessionId = tab.focusedSessionId + const meta = current.sessions[sourceSessionId] + if (!meta) return + + const kind = meta.kind ?? 'claude' + if (kind !== 'claude' && kind !== 'codex') { + showPaneToast(sourceSessionId, 'Only Claude and Codex panes support rewind') + return + } + if (!meta.providerSessionId) { + showPaneToast(sourceSessionId, 'Provider session id is not ready yet') + return + } + if (kind !== anchor.kind) { + showPaneToast( + sourceSessionId, + `Anchor is ${anchor.kind} but focused pane is ${kind}`, + ) + return + } + + const currentRuntime = refs.latestRuntimesRef.current[sourceSessionId] + if (currentRuntime?.processActive || currentRuntime?.semantic.currentTurn) { + showPaneToast(sourceSessionId, 'Wait for the current turn to finish before rewinding') + return + } + + try { + const result = await window.api.rewindToPrompt({ + provider: kind, + sourceProviderSessionId: meta.providerSessionId, + cwd: meta.cwd, + anchor, + }) + + const newSessionId = await sessionActions.replaceSession(meta.cwd, { + kind, + resumeSessionId: result.newProviderSessionId, + }) + if (!newSessionId) return + + // `replaceSession` copied the PRIOR pane's draft forward. + // For rewind we deliberately clobber that draft with the + // anchored prompt text — the whole feature is "open this + // prompt in unsent form so I can edit/re-send it". + // + // Bash mode: Claude Code exposes a `bash` input mode that + // prefixes `!` when composing. cc-shell's composer doesn't + // have a discrete bash mode yet, but it DOES treat a + // leading `!` as bash. Mirroring CC's behavior means + // "rewinding to a /bash-input prompt rehydrates as + // `!`" so the next Enter submits as bash again. + // + // Images: convert the main-process image records (base64 + + // mediaType) into `ClaudeDraftImage` shape the composer + // already renders and can send. The preview URL uses a + // data: URL so no blob lifecycle is needed. + const draftText = + result.promptMode === 'bash' && result.promptText.length > 0 + ? `!${result.promptText}` + : result.promptText + + const draftImages: ClaudeDraftImage[] = + kind === 'claude' + ? result.promptImages.map((image, index) => ({ + id: `rewind-${Date.now()}-${index}`, + mediaType: image.mediaType, + base64Data: image.data, + previewUrl: `data:${image.mediaType};base64,${image.data}`, + filename: `rewind-${index + 1}`, + })) + : [] + + setRuntimes(prev => { + const runtime = prev[newSessionId] + if (!runtime) return prev + return { + ...prev, + [newSessionId]: { + ...runtime, + draftInput: draftText, + draftImages, + }, + } + }) + + showPaneToast(newSessionId, 'Rewound to prompt') + } catch (err) { + const message = + err instanceof Error && err.message.length > 0 + ? err.message + : 'Rewind failed' + showPaneToast(sourceSessionId, message) + } + }, + [refs.latestRuntimesRef, refs.stateRef, sessionActions, setRuntimes, showPaneToast], + ) + + return { switchFocusedProvider, reloadFocusedAgent, rewindFocusedToPrompt } +} diff --git a/src/renderer/src/workspace/hook/actions/reader.ts b/src/renderer/src/workspace/hook/actions/reader.ts new file mode 100644 index 000000000..e76d97a73 --- /dev/null +++ b/src/renderer/src/workspace/hook/actions/reader.ts @@ -0,0 +1,57 @@ +import { useCallback } from 'react' + +import type { SessionId } from '../../types' + +import type { + WorkspaceSetReaderMode, + WorkspaceSetSpotlight, + WorkspaceSetState, +} from '../context' +import type { WorkspaceRefs } from '../refs' + +// ReaderMode toggle. Mirrors toggleSpotlight: enters with the active +// tab's currently-focused session, exits if already on for the active +// tab. Closes Spotlight on entry; tile-tabs are preserved in state +// and suppressed by App.tsx render precedence. + +export function useReaderActions( + setReaderMode: WorkspaceSetReaderMode, + setSpotlight: WorkspaceSetSpotlight, + setState: WorkspaceSetState, + refs: WorkspaceRefs, +): { + toggleReaderMode: () => void + setReaderModeSession: (sessionId: SessionId) => void +} { + const toggleReaderMode = useCallback(() => { + const current = refs.stateRef.current + const activeTab = current.tabs.find(t => t.id === current.activeTabId) + if (!activeTab) return + setSpotlight(null) + setReaderMode(prev => { + if (prev?.tabId === activeTab.id) return null + return { + tabId: activeTab.id, + focusedSessionId: activeTab.focusedSessionId, + } + }) + }, [refs.stateRef, setReaderMode, setSpotlight]) + + // Switch which session is being read inside ReaderMode. Mirrors + // setSpotlightSession exactly — also updates the tab's + // focusedSessionId so leaving Reader returns to that pane. + const setReaderModeSession = useCallback( + (sessionId: SessionId) => { + setReaderMode(prev => (prev ? { ...prev, focusedSessionId: sessionId } : prev)) + setState(prev => ({ + ...prev, + tabs: prev.tabs.map(t => + t.id === prev.activeTabId ? { ...t, focusedSessionId: sessionId } : t, + ), + })) + }, + [setReaderMode, setState], + ) + + return { toggleReaderMode, setReaderModeSession } +} diff --git a/src/renderer/src/workspace/hook/actions/resize.ts b/src/renderer/src/workspace/hook/actions/resize.ts new file mode 100644 index 000000000..cfc87c7c2 --- /dev/null +++ b/src/renderer/src/workspace/hook/actions/resize.ts @@ -0,0 +1,165 @@ +import { useCallback } from 'react' + +import type { SessionId, TabId } from '../../types' +import { + adjustNearestSplitRatio, + collectLeaves, + equalizeRatios, + normalizeTree, + resizeInDirection, + rotateTree, +} from '../../tile-tree/treeOps' +import { setRatioBetween } from '../../layout/helpers' + +import type { + WorkspaceSetState, + WorkspaceSetTileTabs, +} from '../context' + +// Resize + layout normalization actions. +// +// resizeFocused — adjust the nearest split ratio +// resizeFocusedDirectional — grow focused pane toward a direction (tmux-style) +// setSplitRatio — set a specific split's ratio directly (drag) +// setSplitRatioInTab — same, but targets a specific tab by id +// normalizeLayout — soft: every split ratio = 0.5, tree unchanged +// hardNormalizeLayout — flatten & rebuild as a rows×cols grid +// rotateLayout — flip every split direction + +export function useResizeActions( + setState: WorkspaceSetState, + setTileTabs: WorkspaceSetTileTabs, +): { + resizeFocused: (delta: number) => void + resizeFocusedDirectional: (direction: 'left' | 'right' | 'up' | 'down', delta: number) => void + setSplitRatio: (fromSessionId: SessionId, toSessionId: SessionId, ratio: number) => void + setSplitRatioInTab: (tabId: TabId, fromSessionId: SessionId, toSessionId: SessionId, ratio: number) => void + normalizeLayout: () => void + hardNormalizeLayout: () => void + rotateLayout: () => void +} { + const resizeFocused = useCallback( + (delta: number) => { + setState(prev => ({ + ...prev, + tabs: prev.tabs.map(t => { + if (t.id !== prev.activeTabId) return t + return { + ...t, + root: adjustNearestSplitRatio(t.root, t.focusedSessionId, delta), + } + }), + })) + }, + [setState], + ) + + // Grows the focused pane toward the given direction by `delta`. See + // resizeInDirection in treeOps.ts for the full tmux-style semantics. + const resizeFocusedDirectional = useCallback( + (direction: 'left' | 'right' | 'up' | 'down', delta: number) => { + setState(prev => ({ + ...prev, + tabs: prev.tabs.map(t => { + if (t.id !== prev.activeTabId) return t + return { + ...t, + root: resizeInDirection(t.root, t.focusedSessionId, direction, delta), + } + }), + })) + }, + [setState], + ) + + // Walks the tree and finds the split whose `a` side contains fromId + // and whose `b` side contains toId, then sets its ratio directly. + const setSplitRatio = useCallback( + (fromSessionId: SessionId, toSessionId: SessionId, ratio: number) => { + setState(prev => ({ + ...prev, + tabs: prev.tabs.map(t => { + if (t.id !== prev.activeTabId) return t + return { ...t, root: setRatioBetween(t.root, fromSessionId, toSessionId, ratio) } + }), + })) + }, + [setState], + ) + + const setSplitRatioInTab = useCallback( + (tabId: TabId, fromSessionId: SessionId, toSessionId: SessionId, ratio: number) => { + setState(prev => ({ + ...prev, + activeTabId: tabId, + tabs: prev.tabs.map(t => { + if (t.id !== tabId) return t + return { ...t, root: setRatioBetween(t.root, fromSessionId, toSessionId, ratio) } + }), + })) + setTileTabs(prev => ( + prev && prev.tabIds.includes(tabId) + ? { ...prev, focusedTabId: tabId } + : prev + )) + }, + [setState, setTileTabs], + ) + + // Keep the existing tree structure but set every split ratio to + // 0.5. Equalizes spacing without rearranging panes — if you have + // three vertical panes on the left and one on the right, they stay + // that way but all dividers move to the midpoint. + const normalizeLayout = useCallback(() => { + setState(prev => ({ + ...prev, + tabs: prev.tabs.map(t => + t.id === prev.activeTabId + ? { ...t, root: equalizeRatios(t.root) } + : t, + ), + })) + }, [setState]) + + // Flatten the tree and rebuild as a balanced grid where every pane + // gets equal space. Changes the arrangement — all panes end up in + // a rows × cols grid. No sessions are spawned or killed. + const hardNormalizeLayout = useCallback(() => { + setState(prev => { + const tab = prev.tabs.find(t => t.id === prev.activeTabId) + if (!tab) return prev + const leaves = collectLeaves(tab.root) + if (leaves.length <= 1) return prev + const newRoot = normalizeTree(leaves) + return { + ...prev, + tabs: prev.tabs.map(t => + t.id === prev.activeTabId ? { ...t, root: newRoot } : t, + ), + } + }) + }, [setState]) + + // Flip every split direction in the active tab's tree: vertical + // becomes horizontal and vice versa. Turns rows into columns. + const rotateLayout = useCallback(() => { + setState(prev => ({ + ...prev, + tabs: prev.tabs.map(t => + t.id === prev.activeTabId + ? { ...t, root: rotateTree(t.root) } + : t, + ), + })) + }, [setState]) + + return { + resizeFocused, + resizeFocusedDirectional, + setSplitRatio, + setSplitRatioInTab, + normalizeLayout, + hardNormalizeLayout, + rotateLayout, + } +} diff --git a/src/renderer/src/workspace/hook/actions/session.ts b/src/renderer/src/workspace/hook/actions/session.ts new file mode 100644 index 000000000..9a104d3c8 --- /dev/null +++ b/src/renderer/src/workspace/hook/actions/session.ts @@ -0,0 +1,460 @@ +import { useCallback } from 'react' + +import { emptyRuntime, type SessionRuntime } from '../../workspaceState' +import type { SessionId, SessionKind, SessionMeta, TileNode } from '../../types' +import { closeLeaf, collectLeaves } from '../../tile-tree/treeOps' +import type { Tab } from '../../types' +import { sessionSpawnErrorMessage } from '../../spawn/errorMessage' +import { + ghostsToPersist, + reconcileUpstream, +} from '../../ghosts' +import { reduceGhostLogSansSuperseded as reduceGhostLog } from 'agent-transcript-parser/ghost' + +import type { + WorkspaceSetRuntimes, + WorkspaceSetState, +} from '../context' +import type { WorkspaceRefs } from '../refs' + +// ----------------------------------------------------------------------------- +// Session lifecycle actions. +// +// spawn — main process call to create a new session. Returns the +// new sessionId. Updates state.sessions + runtimes +// synchronously. Schedules ghost-log bootstrap via +// setTimeout(0) so the fresh runtime lands first. +// killSession — main process call to kill a session. Cleans up +// runtime, state.sessions, and per-session refs. +// replaceSession — kill current focused session + spawn a new one in +// the same tile-tree slot. Used by resume picker and +// provider switch. +// reloadAgentSessions — recreate every Claude/Codex session with fresh +// dangerous-mode settings. Remaps panes + buried +// records onto the new ids. +// ----------------------------------------------------------------------------- + +export type SessionActions = { + spawn: ( + cwd: string, + opts?: { + resumeSessionId?: string + kind?: SessionKind + dangerousMode?: boolean + recoverTmuxName?: string + }, + ) => Promise + killSession: (sessionId: SessionId) => Promise + replaceSession: ( + cwd: string, + opts?: { resumeSessionId?: string; kind?: SessionKind }, + ) => Promise + reloadAgentSessions: (dangerousMode?: boolean) => Promise +} + +export function useSessionActions( + state: { activeTabId: string; sessions: Record; tabs: Tab[] }, + setState: WorkspaceSetState, + setRuntimes: WorkspaceSetRuntimes, + refs: WorkspaceRefs, +): SessionActions { + // spawn — wrapped so callers don't have to touch window.api + // directly. Updates state.sessions synchronously after main + // responds with an id. + // + // `resumeSessionId` (optional) triggers a resume: main spawns + // claude with `--resume ` and tails the existing session + // file, so the renderer receives the full session history as + // jsonl-entry events immediately after started. Our own sessionId + // is still fresh — it's a workspace-scoped identifier for routing, + // distinct from CC's session UUID. + const spawn = useCallback( + async ( + cwd: string, + opts?: { + resumeSessionId?: string + kind?: SessionKind + dangerousMode?: boolean + recoverTmuxName?: string + }, + ): Promise => { + const kind: SessionKind = opts?.kind ?? 'claude' + const dangerousMode = + opts?.dangerousMode ?? + (kind !== 'terminal' ? refs.dangerousAgentsRef.current : undefined) + // Agent providers both accept `useProxy`; terminals ignore it. + // Claude uses MITM proxy streaming, Codex uses a local Responses + // proxy via `openai_base_url`. + const useProxy = + kind !== 'terminal' ? refs.useProxyStreamingRef.current : undefined + let sessionId: SessionId + let tmuxName: string | undefined + try { + const result = await window.api.spawnSession({ + kind, + cwd, + resumeSessionId: opts?.resumeSessionId, + dangerousMode, + useProxy, + recoverTmuxName: opts?.recoverTmuxName, + }) + sessionId = result.sessionId + tmuxName = result.tmuxName + } catch (err) { + throw new Error(sessionSpawnErrorMessage(kind, err, useProxy === true)) + } + setState(prev => ({ + ...prev, + sessions: { + ...prev.sessions, + // Persist tmuxName when main returns one — that's the + // signal that this terminal got tmux backing and is + // eligible for cross-restart recovery on next launch. + [sessionId]: { + cwd, + kind, + ...(tmuxName ? { tmuxName } : {}), + ...(kind !== 'terminal' && opts?.resumeSessionId + ? { providerSessionId: opts.resumeSessionId } + : {}), + }, + }, + })) + setRuntimes(prev => ({ + ...prev, + [sessionId]: { + ...emptyRuntime(), + hasOlderHistory: kind !== 'terminal' && Boolean(opts?.resumeSessionId), + }, + })) + + // Ghost log bootstrap — fire-and-forget, no await. If a prior + // run of cc-shell persisted ghosts for this sessionId, replay + // them through the atp reducer and merge into the runtime's + // ghost map. The renderer then sees the same merged feed after + // reload as it saw before. A missing file is not an error. + // + // WHY behind a setTimeout 0: spawnSession above set the fresh + // runtime via setRuntimes(prev => ...) — that update is queued + // and will land on the next tick. Reading the ghost log and + // applying it synchronously would run against the PREVIOUS + // runtime snapshot and its setRuntimes would clobber the + // fresh empty runtime. Deferring by one tick lets the empty + // runtime land first, then the bootstrap merge runs on top. + setTimeout(() => { + void window.api + .ghostRead(sessionId) + .then(rawEntries => { + if (!rawEntries || rawEntries.length === 0) return + const bootstrapped = reduceGhostLog(rawEntries as never[]) + if (bootstrapped.size === 0) return + setRuntimes(prev => { + const current = prev[sessionId] + if (!current) return prev + // Merge — disk ghosts only fill slots the runtime + // hasn't already produced in this session. If a ghost + // for the same uuid exists in-memory (rare; would mean + // a live event beat the bootstrap read), prefer the + // in-memory one because it's strictly fresher. + let merged = new Map(current.ghosts) + for (const [uuid, ghost] of bootstrapped) { + if (!merged.has(uuid)) merged.set(uuid, ghost) + } + // Reconcile against whatever JSONL entries already + // landed during the initial bootstrap burst. Without + // this, ghosts for turns that already have committed + // entries in `current.entries` would stay + // un-superseded forever: the live JSONL ingest already + // ran `reconcileUpstream` against the PREVIOUS (empty) + // ghost map and found no matches; now that the real + // ghosts are landing, nothing re-checks the + // already-ingested entries. This pass fixes the + // "crashed mid-turn, resumed with an orphan ghost that + // actually got committed" case. See Task 7 of the + // 2026-04-20 rendering-fixes plan. + for (const entry of current.entries) { + merged = reconcileUpstream(entry, merged) + } + // Persist any supersedes we just produced so the next + // resume reads the ghosts already in their reconciled + // state. `ghostsToPersist` diffs by updatedAt so it + // only emits ghosts whose state actually changed in + // this pass. + for (const ghost of ghostsToPersist(current.ghosts, merged)) { + window.api.ghostAppend(sessionId, ghost) + } + return { + ...prev, + [sessionId]: { ...current, ghosts: merged }, + } + }) + }) + .catch(err => { + // Ghost bootstrap failures are non-fatal — the session + // still works, we just lose crash-recovered provisional + // state. Log and move on. + console.warn('[ghost] bootstrap read failed:', err) + }) + }, 0) + + return sessionId + }, + [refs.dangerousAgentsRef, refs.useProxyStreamingRef, setRuntimes, setState], + ) + + const killSession = useCallback( + async (sessionId: SessionId) => { + await window.api.killSession(sessionId) + setRuntimes(prev => { + const next = { ...prev } + delete next[sessionId] + return next + }) + setState(prev => { + const nextSessions = { ...prev.sessions } + delete nextSessions[sessionId] + return { ...prev, sessions: nextSessions } + }) + delete refs.seenUuidsRef.current[sessionId] + delete refs.latestScreenRef.current[sessionId] + // If a bootstrap debounce was in flight for this session, + // cancel it — the session is gone; firing the deferred + // bootstrapping→false flip later would be a no-op against a + // missing runtime but it's cleaner to release the timer + // immediately. + const timer = refs.bootstrapTimersRef.current.get(sessionId) + if (timer) { + clearTimeout(timer) + refs.bootstrapTimersRef.current.delete(sessionId) + } + }, + [refs.bootstrapTimersRef, refs.latestScreenRef, refs.seenUuidsRef, setRuntimes, setState], + ) + + // Kills the current session in the focused leaf and spawns a new + // one in the same position. Used by the resume flow to swap a + // session without changing the tile tree structure — the pane + // stays where it is, only its backing session changes. + const replaceSession = useCallback( + async ( + cwd: string, + opts?: { resumeSessionId?: string; kind?: SessionKind }, + ): Promise => { + const tab = state.tabs.find(t => t.id === state.activeTabId) + if (!tab) return + const oldId = tab.focusedSessionId + const nextKind = opts?.kind ?? state.sessions[oldId]?.kind ?? 'claude' + const oldDraft = refs.latestRuntimesRef.current[oldId]?.draftInput ?? '' + const newId = await spawn(cwd, opts) + setRuntimes(prev => ({ + ...prev, + [newId]: { + ...(prev[newId] ?? emptyRuntime()), + draftInput: oldDraft, + }, + })) + + await window.api.killSession(oldId) + setRuntimes(prev => { + const next = { ...prev } + delete next[oldId] + return next + }) + delete refs.seenUuidsRef.current[oldId] + delete refs.latestScreenRef.current[oldId] + + // Swap the sessionId in the tree. Walk the tile tree and + // replace every occurrence of oldId with newId (there should + // be exactly one — the focused leaf). + const remapNode = (n: TileNode): TileNode => { + if (n.type === 'leaf') { + return n.sessionId === oldId + ? { type: 'leaf', sessionId: newId } + : n + } + return { ...n, a: remapNode(n.a), b: remapNode(n.b) } + } + + setState(prev => { + const sessions = { ...prev.sessions } + delete sessions[oldId] + // Persist the replacement provider metadata immediately + // instead of waiting for the first transcript line to + // round-trip back from main. That wait window is usually + // short, but it is still a real race: a workspace save or + // pane action that snapshots SessionMeta in that gap would + // see "new session id, but no providerSessionId yet" and + // could forget how to resume the pane on the next launch. + // + // Keeping the requested resumeSessionId here makes + // replaceSession the single source of truth for "this pane + // now points at provider X's persisted transcript Y", + // whether the trigger was the resume picker or the new + // switch-provider flow. + sessions[newId] = { + ...(sessions[newId] ?? { cwd, kind: nextKind }), + cwd, + kind: nextKind, + ...(opts?.resumeSessionId ? { providerSessionId: opts.resumeSessionId } : {}), + } + return { + ...prev, + tabs: prev.tabs.map(t => { + if (t.id !== prev.activeTabId) return t + return { + ...t, + root: remapNode(t.root), + focusedSessionId: + t.focusedSessionId === oldId ? newId : t.focusedSessionId, + } + }), + sessions, + } + }) + return newId + }, + [ + refs.latestRuntimesRef, + refs.latestScreenRef, + refs.seenUuidsRef, + setRuntimes, + setState, + spawn, + state.activeTabId, + state.sessions, + state.tabs, + ], + ) + + // Recreates every Claude/Codex session with the requested + // dangerous mode, then remaps visible panes and buried records + // onto the fresh session ids. Plain terminal sessions are left + // untouched. + const reloadAgentSessions = useCallback( + async (dangerousMode = refs.dangerousAgentsRef.current) => { + const current = refs.stateRef.current + const agentEntries = Object.entries(current.sessions).filter(([, meta]) => { + const kind = meta.kind ?? 'claude' + return kind === 'claude' || kind === 'codex' + }) + if (agentEntries.length === 0) return + + const oldRuntimes = refs.latestRuntimesRef.current + const idMap = new Map() + const failedIds = new Set() + const freshSessions: Record = {} + + for (const [oldId, meta] of agentEntries) { + try { + await window.api.killSession(oldId) + } catch { + // Kill failures still fall through to respawn — the old + // process may already be gone. + } + + delete refs.seenUuidsRef.current[oldId] + delete refs.latestScreenRef.current[oldId] + + try { + const kind: SessionKind = meta.kind ?? 'claude' + const { sessionId: newId } = await window.api.spawnSession({ + kind, + cwd: meta.cwd, + resumeSessionId: meta.providerSessionId, + dangerousMode, + useProxy: kind !== 'terminal' ? refs.useProxyStreamingRef.current : undefined, + }) + idMap.set(oldId, newId) + freshSessions[newId] = { ...meta } + } catch { + failedIds.add(oldId) + } + } + + if (idMap.size === 0 && failedIds.size === 0) return + + const remapNode = (node: TileNode): TileNode => { + if (node.type === 'leaf') { + const mapped = idMap.get(node.sessionId) + return mapped ? { type: 'leaf', sessionId: mapped } : node + } + return { ...node, a: remapNode(node.a), b: remapNode(node.b) } + } + + setRuntimes(prev => { + const next: Record = { ...prev } + for (const [oldId] of agentEntries) delete next[oldId] + for (const [oldId, newId] of idMap.entries()) { + const restored = emptyRuntime() + restored.draftInput = oldRuntimes[oldId]?.draftInput ?? '' + restored.hasOlderHistory = Boolean(freshSessions[newId]?.providerSessionId) + next[newId] = restored + } + return next + }) + + setState(prev => { + const nextSessions = { ...prev.sessions } + for (const [oldId] of agentEntries) delete nextSessions[oldId] + for (const [newId, meta] of Object.entries(freshSessions)) { + nextSessions[newId] = meta + } + + const nextTabs = prev.tabs + .map(tab => { + let root: TileNode | null = remapNode(tab.root) + for (const failedId of failedIds) { + root = closeLeaf(root!, failedId) + if (root === null) break + } + if (root === null) return null + const leaves = collectLeaves(root) + if (leaves.length === 0) return null + const focusedSessionId = idMap.get(tab.focusedSessionId) + ?? (failedIds.has(tab.focusedSessionId) ? leaves[0] : tab.focusedSessionId) + return { + ...tab, + root, + focusedSessionId, + } satisfies Tab + }) + .filter((tab): tab is Tab => tab !== null) + + const activeTabId = nextTabs.some(tab => tab.id === prev.activeTabId) + ? prev.activeTabId + : (nextTabs[0]?.id ?? '') + + const nextBuried = prev.buried + .filter(entry => !failedIds.has(entry.sessionId)) + .map(entry => ({ + ...entry, + id: idMap.get(entry.id) ?? entry.id, + sessionId: idMap.get(entry.sessionId) ?? entry.sessionId, + siblingLeafId: entry.siblingLeafId + ? (idMap.get(entry.siblingLeafId) ?? entry.siblingLeafId) + : undefined, + })) + + return { + ...prev, + tabs: nextTabs, + activeTabId, + sessions: nextSessions, + buried: nextBuried, + } + }) + }, + [ + refs.dangerousAgentsRef, + refs.latestRuntimesRef, + refs.latestScreenRef, + refs.seenUuidsRef, + refs.stateRef, + refs.useProxyStreamingRef, + setRuntimes, + setState, + ], + ) + + return { spawn, killSession, replaceSession, reloadAgentSessions } +} diff --git a/src/renderer/src/workspace/hook/actions/spotlight.ts b/src/renderer/src/workspace/hook/actions/spotlight.ts new file mode 100644 index 000000000..2af51b369 --- /dev/null +++ b/src/renderer/src/workspace/hook/actions/spotlight.ts @@ -0,0 +1,51 @@ +import { useCallback } from 'react' + +import type { SessionId } from '../../types' + +import type { + WorkspaceSetSpotlight, + WorkspaceSetState, +} from '../context' +import type { WorkspaceRefs } from '../refs' + +// Spotlight mode — focused-pane zoom within the active tab. toggleSpotlight +// enters with the active tab's currently-focused session; exits if already +// on for the active tab. setSpotlightSession switches which session is +// showing inside Spotlight. + +export function useSpotlightActions( + setSpotlight: WorkspaceSetSpotlight, + setState: WorkspaceSetState, + refs: WorkspaceRefs, +): { + toggleSpotlight: () => void + setSpotlightSession: (sessionId: SessionId) => void +} { + const toggleSpotlight = useCallback(() => { + const current = refs.stateRef.current + const activeTab = current.tabs.find(t => t.id === current.activeTabId) + if (!activeTab) return + setSpotlight(prev => { + if (prev?.tabId === activeTab.id) return null + return { + tabId: activeTab.id, + focusedSessionId: activeTab.focusedSessionId, + } + }) + }, [refs.stateRef, setSpotlight]) + + const setSpotlightSession = useCallback( + (sessionId: SessionId) => { + setSpotlight(prev => (prev ? { ...prev, focusedSessionId: sessionId } : prev)) + setState(prev => ({ + ...prev, + tabs: prev.tabs.map(t => + t.id === prev.activeTabId ? { ...t, focusedSessionId: sessionId } : t, + ), + })) + }, + [setSpotlight, setState], + ) + + return { toggleSpotlight, setSpotlightSession } +} diff --git a/src/renderer/src/workspace/hook/actions/statusMode.ts b/src/renderer/src/workspace/hook/actions/statusMode.ts new file mode 100644 index 000000000..e1442dd4e --- /dev/null +++ b/src/renderer/src/workspace/hook/actions/statusMode.ts @@ -0,0 +1,16 @@ +import { useCallback } from 'react' + +import type { WorkspaceSetStatusMode } from '../context' + +// Status mode: color-coded pane headers. Toggled from the command +// palette. The slice lives in zustand — this action just flips it. + +export function useStatusModeActions(setStatusMode: WorkspaceSetStatusMode): { + toggleStatusMode: () => void +} { + const toggleStatusMode = useCallback(() => { + setStatusMode(prev => !prev) + }, [setStatusMode]) + + return { toggleStatusMode } +} diff --git a/src/renderer/src/workspace/hook/actions/streaming.ts b/src/renderer/src/workspace/hook/actions/streaming.ts new file mode 100644 index 000000000..0d99ad8f9 --- /dev/null +++ b/src/renderer/src/workspace/hook/actions/streaming.ts @@ -0,0 +1,150 @@ +import { useCallback } from 'react' + +import { emptyRuntime } from '../../workspaceState' +import type { SessionId } from '../../types' +import type { Entry } from '../../../../../shared/types/transcript' +import { + appendFeedDebugLog, +} from '../../runtime/feedDebug' +import { withDerivedSessionStatus } from '../../semantic/helpers' +import { + entryTextContent, +} from '../../entries/utils' +import { isOptimisticCodexUserEntry } from '../../codex/entries' + +import type { WorkspaceSetRuntimes } from '../context' + +// Streaming baseline + optimistic-codex-user entry. +// +// setStreamingBaseline is called by TileLeaf on submit. It pairs the +// baseline write with a synthetic `submitting` phase and a +// `submittedAt` timestamp. This covers the gap between the user +// pressing Enter and the adapter's first `requesting` event landing +// (can be 100-500ms on a cold proxy). Without it the in-feed +// WorkIndicator would render nothing during that window, making the +// app look unresponsive to the submit. The adapter's first +// stream_phase event will transition phase → 'requesting' and reuse +// `submittedAt` as turnStartedAt. +// +// The optimistic Codex user entry pair exists because Codex live +// rendering is TUI-first, with rollout JSON as a later source of +// truth. That means a broken/missing rollout attach should NOT leave +// the feed blank after submit. We add a local user row immediately +// and reconcile it away when the real rollout user message shows up +// (see ipc/handleBulkJsonl.ts for the reconciliation side). + +export function useStreamingActions(setRuntimes: WorkspaceSetRuntimes): { + setStreamingBaseline: (sessionId: SessionId, baseline: string | null) => void + addOptimisticCodexUserEntry: (sessionId: SessionId, text: string) => void + removeOptimisticCodexUserEntry: (sessionId: SessionId, text: string) => void +} { + const setStreamingBaseline = useCallback( + (sessionId: SessionId, baseline: string | null) => { + const now = Date.now() + setRuntimes(prev => { + const current = prev[sessionId] ?? emptyRuntime() + const next = withDerivedSessionStatus( + appendFeedDebugLog( + { + ...current, + streamingBaseline: baseline, + awaitingAssistant: true, + streamPhase: 'submitting', + submittedAt: now, + phaseChangedAt: now, + turnStartedAt: now, + }, + { + layer: 'STATE', + kind: 'submit', + summary: baseline ? 'submit started with baseline' : 'submit started', + data: { hasBaseline: baseline !== null, baselineLength: baseline?.length ?? 0 }, + }, + ), + ) + return { + ...prev, + [sessionId]: next, + } + }) + }, + [setRuntimes], + ) + + const addOptimisticCodexUserEntry = useCallback( + (sessionId: SessionId, text: string) => { + const trimmed = text.trim() + if (!trimmed) return + setRuntimes(prev => { + const current = prev[sessionId] ?? emptyRuntime() + const last = current.entries[current.entries.length - 1] + if (isOptimisticCodexUserEntry(last) && entryTextContent(last) === trimmed) { + return prev + } + const optimistic: Entry = { + type: 'user', + uuid: `optimistic-codex-user:${Date.now()}`, + parentUuid: null, + timestamp: new Date().toISOString(), + message: { + role: 'user', + content: [{ type: 'text', text: trimmed }], + }, + } + return { + ...prev, + [sessionId]: appendFeedDebugLog( + { + ...current, + entries: [...current.entries, optimistic], + }, + { + layer: 'STATE', + kind: 'optimistic_user_add', + summary: `optimistic user row added · ${trimmed.slice(0, 80)}`, + data: { text: trimmed }, + }, + ), + } + }) + }, + [setRuntimes], + ) + + const removeOptimisticCodexUserEntry = useCallback( + (sessionId: SessionId, text: string) => { + const trimmed = text.trim() + if (!trimmed) return + setRuntimes(prev => { + const current = prev[sessionId] + if (!current || current.entries.length === 0) return prev + const last = current.entries[current.entries.length - 1] + if (!isOptimisticCodexUserEntry(last) || entryTextContent(last) !== trimmed) { + return prev + } + return { + ...prev, + [sessionId]: appendFeedDebugLog( + { + ...current, + entries: current.entries.slice(0, -1), + }, + { + layer: 'STATE', + kind: 'optimistic_user_remove', + summary: `optimistic user row removed · ${trimmed.slice(0, 80)}`, + data: { text: trimmed }, + }, + ), + } + }) + }, + [setRuntimes], + ) + + return { + setStreamingBaseline, + addOptimisticCodexUserEntry, + removeOptimisticCodexUserEntry, + } +} diff --git a/src/renderer/src/workspace/hook/actions/tab.ts b/src/renderer/src/workspace/hook/actions/tab.ts new file mode 100644 index 000000000..c23f1fe63 --- /dev/null +++ b/src/renderer/src/workspace/hook/actions/tab.ts @@ -0,0 +1,235 @@ +import { useCallback } from 'react' + +import type { SessionId, SessionKind, SessionMeta, Tab, TabId } from '../../types' +import { collectLeaves } from '../../tile-tree/treeOps' +import { sanitizeTileTabsState, titleFromCwd } from '../../layout/helpers' + +import type { + WorkspaceSetReaderMode, + WorkspaceSetRuntimes, + WorkspaceSetSpotlight, + WorkspaceSetState, + WorkspaceSetTileTabs, +} from '../context' +import type { WorkspaceRefs } from '../refs' +import type { SessionActions } from './session' + +// Tab actions — open/close + tab-navigation keybinds. + +export function useTabActions( + state: { activeTabId: string; sessions: Record; tabs: Tab[] }, + tileTabs: { tabIds: TabId[]; focusedTabId: TabId } | null, + setState: WorkspaceSetState, + setRuntimes: WorkspaceSetRuntimes, + setTileTabs: WorkspaceSetTileTabs, + setSpotlight: WorkspaceSetSpotlight, + setReaderMode: WorkspaceSetReaderMode, + refs: WorkspaceRefs, + showToast: (message: string, durationMs?: number) => void, + sessionActions: SessionActions, +): { + newTab: (cwd: string, resumeSessionId?: string, kind?: SessionKind) => Promise<{ tabId: TabId; sessionId: SessionId }> + closeTab: (tabId: TabId) => Promise + activateTab: (tabId: TabId) => void + activateTabByIndex: (index: number) => void + nextTab: () => void + prevTab: () => void +} { + // Spawns a new session in the given cwd, creates a tab with one + // leaf, and makes it active. Pass `resumeSessionId` to resume an + // existing CC session rather than starting a fresh one. + const newTab = useCallback( + async (cwd: string, resumeSessionId?: string, kind?: SessionKind) => { + let sessionId: SessionId + try { + sessionId = await sessionActions.spawn(cwd, { resumeSessionId, kind }) + } catch (err) { + showToast( + err instanceof Error && err.message.length > 0 + ? err.message + : 'Failed to create session', + ) + throw err + } + const tabId = crypto.randomUUID() + const title = titleFromCwd(cwd) + setState(prev => { + const tab: Tab = { + id: tabId, + title, + root: { type: 'leaf', sessionId }, + focusedSessionId: sessionId, + } + return { + ...prev, + tabs: [...prev.tabs, tab], + activeTabId: tabId, + } + }) + return { tabId, sessionId } + }, + [sessionActions, setState, showToast], + ) + + const closeTab = useCallback( + async (tabId: TabId) => { + const tab = state.tabs.find(t => t.id === tabId) + if (!tab) return + + // Capture undo info before killing anything. + const tabIdx = state.tabs.findIndex(t => t.id === tabId) + const ids = collectLeaves(tab.root) + const allMetas: Record = {} + for (const id of ids) { + if (state.sessions[id]) allMetas[id] = state.sessions[id] + } + refs.undoStackRef.current.push({ + type: 'tab', + closedAt: Date.now(), + tab: { ...tab }, + tabIndex: tabIdx, + sessionMetas: allMetas, + }) + // Surface a brief undo hint. The label uses the tab title so + // the user can confirm at a glance which thing they killed. + showToast(`Closed “${tab.title}” — ⌘⇧T (Undo Close)`) + + // Kill every session in this tab. + await Promise.all(ids.map(id => window.api.killSession(id))) + setRuntimes(prev => { + const next = { ...prev } + for (const id of ids) delete next[id] + return next + }) + for (const id of ids) { + delete refs.seenUuidsRef.current[id] + delete refs.latestScreenRef.current[id] + } + setState(prev => { + const tabs = prev.tabs.filter(t => t.id !== tabId) + const sessions = { ...prev.sessions } + for (const id of ids) delete sessions[id] + const activeTabId = + prev.activeTabId === tabId + ? (tabs[0]?.id ?? '') + : prev.activeTabId + return { ...prev, tabs, activeTabId, sessions } + }) + setTileTabs(prev => { + if (!prev) return prev + const sanitized = sanitizeTileTabsState({ + ...prev, + tabIds: prev.tabIds.filter(id => id !== tabId), + focusedTabId: prev.focusedTabId === tabId + ? (prev.tabIds.find(id => id !== tabId) ?? prev.focusedTabId) + : prev.focusedTabId, + }) + return sanitized + }) + setSpotlight(prev => (prev?.tabId === tabId ? null : prev)) + setReaderMode(prev => (prev?.tabId === tabId ? null : prev)) + }, + [ + refs.latestScreenRef, + refs.seenUuidsRef, + refs.undoStackRef, + setReaderMode, + setRuntimes, + setSpotlight, + setState, + setTileTabs, + showToast, + state.sessions, + state.tabs, + ], + ) + + const activateTab = useCallback( + (tabId: TabId) => { + setState(prev => ({ ...prev, activeTabId: tabId })) + setSpotlight(null) + // Preserve tile-tabs mode when the activated tab is part of + // the tiled set — just shift the focused tile. If it's NOT + // part of the set, leave tile-tabs ALONE rather than nuking + // the mode. The previous behavior (setting null) caused the + // tile layout to silently collapse whenever the user clicked + // any other tab in the bar, which read as a phantom + // "auto-deselect." + setTileTabs(prev => { + if (!prev) return prev + if (prev.tabIds.includes(tabId)) { + return { ...prev, focusedTabId: tabId } + } + return prev + }) + }, + [setSpotlight, setState, setTileTabs], + ) + + const activateTabByIndex = useCallback( + (index: number) => { + setState(prev => { + const t = prev.tabs[index] + return t ? { ...prev, activeTabId: t.id } : prev + }) + setSpotlight(null) + // Same preservation rule as activateTab — see comment there. + setTileTabs(prev => { + const target = refs.stateRef.current.tabs[index] + if (!prev) return prev + if (!target) return prev + if (prev.tabIds.includes(target.id)) { + return { ...prev, focusedTabId: target.id } + } + return prev + }) + }, + [refs.stateRef, setSpotlight, setState, setTileTabs], + ) + + const nextTab = useCallback(() => { + const tiled = tileTabs + if (tiled && tiled.tabIds.length > 1) { + const idx = tiled.tabIds.indexOf(tiled.focusedTabId) + const nextId = tiled.tabIds[(idx + 1 + tiled.tabIds.length) % tiled.tabIds.length] + setState(prev => ({ ...prev, activeTabId: nextId })) + setTileTabs(prev => (prev ? { ...prev, focusedTabId: nextId } : prev)) + return + } + setState(prev => { + const idx = prev.tabs.findIndex(t => t.id === prev.activeTabId) + if (idx === -1) return prev + const next = prev.tabs[(idx + 1) % prev.tabs.length] + return { ...prev, activeTabId: next.id } + }) + setSpotlight(null) + }, [setSpotlight, setState, setTileTabs, tileTabs]) + + const prevTab = useCallback(() => { + const tiled = tileTabs + if (tiled && tiled.tabIds.length > 1) { + const idx = tiled.tabIds.indexOf(tiled.focusedTabId) + const nextId = + tiled.tabIds[(idx - 1 + tiled.tabIds.length) % tiled.tabIds.length] + setState(prev => ({ ...prev, activeTabId: nextId })) + setTileTabs(prev => (prev ? { ...prev, focusedTabId: nextId } : prev)) + return + } + setState(prev => { + const idx = prev.tabs.findIndex(t => t.id === prev.activeTabId) + if (idx === -1) return prev + const next = prev.tabs[(idx - 1 + prev.tabs.length) % prev.tabs.length] + return { ...prev, activeTabId: next.id } + }) + setSpotlight(null) + }, [setSpotlight, setState, setTileTabs, tileTabs]) + + return { + newTab, + closeTab, + activateTab, + activateTabByIndex, + nextTab, + prevTab, + } +} diff --git a/src/renderer/src/workspace/hook/actions/tileTabs.ts b/src/renderer/src/workspace/hook/actions/tileTabs.ts new file mode 100644 index 000000000..0b3c16098 --- /dev/null +++ b/src/renderer/src/workspace/hook/actions/tileTabs.ts @@ -0,0 +1,147 @@ +import { useCallback } from 'react' + +import type { SessionId, SplitDirection, TabId } from '../../types' +import { equalRatios, normalizeRatios } from '../../layout/helpers' + +import type { + WorkspaceSetSpotlight, + WorkspaceSetState, + WorkspaceSetTileTabs, +} from '../context' +import type { WorkspaceRefs } from '../refs' + +// Tile-tabs actions. Opening a tile-tab set tiles multiple tabs side +// by side inside a single tab view (meta-tabs). Focus + resize here +// operates on the tile-tabs slice, NOT the regular tab bar. + +export function useTileTabsActions( + setTileTabs: WorkspaceSetTileTabs, + setSpotlight: WorkspaceSetSpotlight, + setState: WorkspaceSetState, + refs: WorkspaceRefs, +): { + openTileTabs: (tabIds: TabId[], direction?: SplitDirection) => void + closeTileTabs: () => void + focusTiledTab: (tabId: TabId) => void + focusTiledTabByIndex: (index: number) => void + resizeFocusedTiledTab: (delta: number) => void + resizeTiledTabByIndex: (index: number, delta: number) => void +} { + const openTileTabs = useCallback( + (tabIds: TabId[], direction: SplitDirection = 'vertical') => { + const current = refs.stateRef.current + const valid = tabIds.filter(id => current.tabs.some(t => t.id === id)) + if (valid.length < 2) return + const focusedTabId = valid.includes(current.activeTabId) + ? current.activeTabId + : valid[0] + setSpotlight(null) + setTileTabs({ + tabIds: valid, + focusedTabId, + direction, + ratios: equalRatios(valid.length), + }) + setState(prev => ({ ...prev, activeTabId: focusedTabId })) + }, + [refs.stateRef, setSpotlight, setState, setTileTabs], + ) + + const closeTileTabs = useCallback(() => { + setTileTabs(null) + }, [setTileTabs]) + + const focusTiledTab = useCallback( + (tabId: TabId) => { + setTileTabs(prev => ( + prev && prev.tabIds.includes(tabId) + ? { ...prev, focusedTabId: tabId } + : prev + )) + setState(prev => ({ ...prev, activeTabId: tabId })) + }, + [setState, setTileTabs], + ) + + const focusTiledTabByIndex = useCallback( + (index: number) => { + setTileTabs(prev => { + if (!prev) return prev + const tabId = prev.tabIds[index] + if (!tabId) return prev + setState(statePrev => ({ ...statePrev, activeTabId: tabId })) + return { ...prev, focusedTabId: tabId } + }) + }, + [setState, setTileTabs], + ) + + const resizeFocusedTiledTab = useCallback( + (delta: number) => { + setTileTabs(prev => { + if (!prev || prev.tabIds.length < 2) return prev + const idx = prev.tabIds.indexOf(prev.focusedTabId) + if (idx === -1) return prev + + const leftIndex = idx === prev.tabIds.length - 1 ? idx - 1 : idx + const rightIndex = idx === prev.tabIds.length - 1 ? idx : idx + 1 + if (leftIndex < 0 || rightIndex >= prev.ratios.length) return prev + + const nextRatios = [...prev.ratios] + const signedDelta = idx === prev.tabIds.length - 1 ? -delta : delta + const nextLeft = nextRatios[leftIndex] + signedDelta + const nextRight = nextRatios[rightIndex] - signedDelta + const minRatio = 0.12 + if (nextLeft < minRatio || nextRight < minRatio) return prev + + nextRatios[leftIndex] = nextLeft + nextRatios[rightIndex] = nextRight + return { + ...prev, + ratios: normalizeRatios(nextRatios), + } + }) + }, + [setTileTabs], + ) + + const resizeTiledTabByIndex = useCallback( + (index: number, delta: number) => { + setTileTabs(prev => { + if (!prev || prev.tabIds.length < 2) return prev + if (index < 0 || index >= prev.tabIds.length) return prev + + const leftIndex = index === prev.tabIds.length - 1 ? index - 1 : index + const rightIndex = index === prev.tabIds.length - 1 ? index : index + 1 + if (leftIndex < 0 || rightIndex >= prev.ratios.length) return prev + + const nextRatios = [...prev.ratios] + const signedDelta = index === prev.tabIds.length - 1 ? -delta : delta + const nextLeft = nextRatios[leftIndex] + signedDelta + const nextRight = nextRatios[rightIndex] - signedDelta + const minRatio = 0.12 + if (nextLeft < minRatio || nextRight < minRatio) return prev + + nextRatios[leftIndex] = nextLeft + nextRatios[rightIndex] = nextRight + return { + ...prev, + ratios: normalizeRatios(nextRatios), + } + }) + }, + [setTileTabs], + ) + + // Keep sessionId param reference for downstream hook-level deps. + void (null as unknown as SessionId) + + return { + openTileTabs, + closeTileTabs, + focusTiledTab, + focusTiledTabByIndex, + resizeFocusedTiledTab, + resizeTiledTabByIndex, + } +} diff --git a/src/renderer/src/workspace/hook/actions/undoClose.ts b/src/renderer/src/workspace/hook/actions/undoClose.ts new file mode 100644 index 000000000..30d8aaf1a --- /dev/null +++ b/src/renderer/src/workspace/hook/actions/undoClose.ts @@ -0,0 +1,153 @@ +import { useCallback } from 'react' + +import type { + SessionId, + SessionKind, + SessionMeta, + Tab, + TabId, + TileNode, +} from '../../types' +import { collectLeaves } from '../../tile-tree/treeOps' +import { reinsertPane } from '../../../lib/undoClose' + +import type { WorkspaceSetState } from '../context' +import type { WorkspaceRefs } from '../refs' +import type { SessionActions } from './session' + +// Undo-close action. Pops the most recent entry from the undo stack +// and restores it. +// +// For panes: finds the surviving sibling in the current tree by its +// anchor leaf, re-wraps it in a split with the restored session on +// the correct side, and respawns the session (with --resume for +// Claude sessions so the conversation comes back). +// +// For tabs: respawns every session in the tab, remaps the session +// ids in the tree (since the new spawn produces new ids), and +// re-inserts the tab at its original index (clamped to bounds). + +export function useUndoCloseAction( + state: { tabs: Tab[] }, + setState: WorkspaceSetState, + refs: WorkspaceRefs, + sessionActions: SessionActions, +): { + undoClose: () => Promise + undoCloseCount: number +} { + const undoClose = useCallback(async () => { + const entry = refs.undoStackRef.current.pop() + if (!entry) return + + if (entry.type === 'pane') { + // Find which tab the sibling leaf is in now. + const targetTab = state.tabs.find(t => + collectLeaves(t.root).includes(entry.siblingLeafId), + ) + if (!targetTab) return // sibling was also closed — stale undo + + // Respawn the session. + // - Claude/Codex with providerSessionId → pass --resume so + // the conversation history replays via JSONL. + // - Terminal with tmuxName → pass recoverTmuxName so the + // same tmux session is re-attached, preserving scrollback + // and any running process. Without this, "undo close" on + // a terminal would respawn an empty shell — defeating the + // point of having a tmux backing. + const meta = entry.sessionMeta + const newSessionId = await sessionActions.spawn(meta.cwd, { + kind: meta.kind ?? 'claude', + resumeSessionId: meta.providerSessionId, + recoverTmuxName: meta.kind === 'terminal' ? meta.tmuxName : undefined, + }) + + setState(prev => { + const tabs = prev.tabs.map(t => { + if (t.id !== targetTab.id) return t + const newRoot = reinsertPane( + t.root, + entry.siblingLeafId, + newSessionId, + entry.direction, + entry.ratio, + entry.side, + ) + if (!newRoot) return t // anchor not found — bail + return { + ...t, + root: newRoot, + focusedSessionId: newSessionId, + } + }) + return { ...prev, tabs } + }) + } else { + // Tab undo: respawn every session and remap the tree. + const idMap = new Map() + const freshSessions: Record = {} + + for (const [oldId, meta] of Object.entries(entry.sessionMetas)) { + try { + const kind: SessionKind = meta.kind ?? 'claude' + // Same per-kind recover hint as the pane-undo branch + // above: tmuxName for terminals, providerSessionId for + // agents. + const newId = await sessionActions.spawn(meta.cwd, { + kind, + resumeSessionId: kind !== 'terminal' ? meta.providerSessionId : undefined, + recoverTmuxName: kind === 'terminal' ? meta.tmuxName : undefined, + }) + idMap.set(oldId, newId) + freshSessions[newId] = meta + } catch { + // If one session fails to spawn, skip it — restore what + // we can. + } + } + + if (idMap.size === 0) return // nothing survived + + const remapNode = (n: TileNode): TileNode => { + if (n.type === 'leaf') { + const mapped = idMap.get(n.sessionId) + return mapped ? { type: 'leaf', sessionId: mapped } : n + } + return { ...n, a: remapNode(n.a), b: remapNode(n.b) } + } + + const restoredRoot = remapNode(entry.tab.root) + const leaves = collectLeaves(restoredRoot) + if (leaves.length === 0) return + + const restoredFocused = + idMap.get(entry.tab.focusedSessionId) ?? leaves[0] + const restoredTab: Tab = { + id: crypto.randomUUID(), + title: entry.tab.title, + root: restoredRoot, + focusedSessionId: restoredFocused, + } + + setState(prev => { + const insertIdx = Math.min(entry.tabIndex, prev.tabs.length) + const tabs = [...prev.tabs] + tabs.splice(insertIdx, 0, restoredTab) + return { + ...prev, + tabs, + activeTabId: restoredTab.id, + } + }) + } + }, [refs.undoStackRef, sessionActions, setState, state.tabs]) + + // Peek at the undo stack length — used by the command palette to + // show/hide the "Undo Close" command. + const undoCloseCount = refs.undoStackRef.current.length + + // Silence TabId unused import. + void (null as unknown as TabId) + + return { undoClose, undoCloseCount } +} diff --git a/src/renderer/src/workspace/hook/context.ts b/src/renderer/src/workspace/hook/context.ts new file mode 100644 index 000000000..25ad9ea29 --- /dev/null +++ b/src/renderer/src/workspace/hook/context.ts @@ -0,0 +1,119 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react' + +import type { UndoCloseStack } from '../../lib/undoClose' +import type { + ReaderModeState, + SessionRuntime, + SpotlightState, + TileTabsState, +} from '../workspaceState' +import type { SessionId, WorkspaceState } from '../types' +import type { FeedDebugInput } from '../runtime/feedDebug' + +// ----------------------------------------------------------------------------- +// WorkspaceHookContext +// ----------------------------------------------------------------------------- +// +// Bundle of state, setters, refs, and cross-cutting helpers that every +// sub-hook under ./actions, ./ipc, ./persistence, and ./invalidation +// consumes. Instead of threading 15+ individual arguments through every +// action, we pack them into one object so the sub-hook signatures stay +// manageable. +// +// IDENTITY STABILITY — critical for useCallback deps: +// - All setters (setState, setRuntimes, setSpotlight, …) come from +// zustand slice factories and are stable across renders by design. +// - All refs are useRef outputs — their identity never changes. +// - `updateRuntime`, `appendFeedDebug`, `showPaneToast` are +// useCallback-wrapped with stable deps, also identity-stable. +// - `showToast` comes from GlobalToast context (stable). +// +// The context OBJECT itself is also memoized so `[ctx]` as a useCallback +// dep is equivalent to listing every stable field individually. Live +// zustand slice values (state, runtimes, spotlight, …) are NOT in the +// context — sub-hooks read them through `ctx.stateRef.current` and +// friends. Putting live slice values in ctx would rebuild ctx on every +// render and invalidate every useCallback. + +export type WorkspaceSetState = ( + next: WorkspaceState | ((prev: WorkspaceState) => WorkspaceState), +) => void + +export type WorkspaceSetRuntimes = ( + next: + | Record + | ((prev: Record) => Record), +) => void + +export type WorkspaceSetSpotlight = ( + next: + | SpotlightState + | null + | ((prev: SpotlightState | null) => SpotlightState | null), +) => void + +export type WorkspaceSetTileTabs = ( + next: + | TileTabsState + | null + | ((prev: TileTabsState | null) => TileTabsState | null), +) => void + +export type WorkspaceSetReaderMode = ( + next: + | ReaderModeState + | null + | ((prev: ReaderModeState | null) => ReaderModeState | null), +) => void + +export type WorkspaceSetStatusMode = (next: boolean | ((prev: boolean) => boolean)) => void + +export type WorkspaceHookContext = { + // --- Zustand setters (identity-stable) --- + setState: WorkspaceSetState + setRuntimes: WorkspaceSetRuntimes + setSpotlight: WorkspaceSetSpotlight + setTileTabs: WorkspaceSetTileTabs + setReaderMode: WorkspaceSetReaderMode + setStatusMode: WorkspaceSetStatusMode + + // --- UI shell slice actions (identity-stable) --- + openBuryPrompt: (sessionId: SessionId) => void + closeBuryPrompt: () => void + openNewAgentPlacement: () => void + closeNewAgentPlacement: () => void + + // --- Global toast --- + showToast: (message: string, durationMs?: number) => void + + // --- State mirror refs (state values are live; read via .current) --- + stateRef: MutableRefObject + latestStateRef: MutableRefObject + latestRuntimesRef: MutableRefObject> + latestTileTabsRef: MutableRefObject + + // --- Settings mirror refs --- + dangerousAgentsRef: MutableRefObject + useProxyStreamingRef: MutableRefObject + + // --- Session bookkeeping refs --- + seenUuidsRef: MutableRefObject>> + latestScreenRef: MutableRefObject> + undoStackRef: MutableRefObject + bootstrapTimersRef: MutableRefObject>> + persistedFeedDebugIdRef: MutableRefObject> + + // --- Timer refs --- + paneToastTimers: MutableRefObject>> + saveTimerRef: MutableRefObject | null> + + // --- Once-only guards --- + bootRef: MutableRefObject + + // --- Cross-cutting runtime helpers --- + updateRuntime: (sessionId: SessionId, patch: Partial) => void + appendFeedDebug: (sessionId: SessionId, input: FeedDebugInput) => void + + // --- Draft version counter (bumped to trigger debounced save) --- + setDraftVersion: Dispatch> +} diff --git a/src/renderer/src/workspace/hook/helpers.ts b/src/renderer/src/workspace/hook/helpers.ts new file mode 100644 index 000000000..c6ff7309e --- /dev/null +++ b/src/renderer/src/workspace/hook/helpers.ts @@ -0,0 +1,151 @@ +import { useCallback } from 'react' + +import { emptyRuntime, type SessionRuntime } from '../workspaceState' +import type { SessionId } from '../types' +import { + appendFeedDebugLog, + type FeedDebugInput, +} from '../runtime/feedDebug' +import { withDerivedSessionStatus } from '../semantic/helpers' + +import type { + WorkspaceSetRuntimes, + WorkspaceSetState, +} from './context' +import type { WorkspaceRefs } from './refs' + +// ----------------------------------------------------------------------------- +// Cross-cutting runtime helpers +// +// updateRuntime / appendFeedDebug / getRuntime / toggleTailMode / +// scrollFocusedToLatest are used by many other actions and live here +// so every sub-hook can consume them through the context without +// re-declaring them. +// ----------------------------------------------------------------------------- + +export function useWorkspaceHelpers( + runtimes: Record, + setRuntimes: WorkspaceSetRuntimes, + refs: WorkspaceRefs, +): { + updateRuntime: (sessionId: SessionId, patch: Partial) => void + appendFeedDebug: (sessionId: SessionId, input: FeedDebugInput) => void + getRuntime: (sessionId: SessionId) => SessionRuntime + toggleTailMode: (sessionId: SessionId) => void + scrollFocusedToLatest: () => void +} { + const updateRuntime = useCallback( + (sessionId: SessionId, patch: Partial) => { + setRuntimes(prev => { + const current = prev[sessionId] ?? emptyRuntime() + return { + ...prev, + [sessionId]: withDerivedSessionStatus({ ...current, ...patch }), + } + }) + }, + [setRuntimes], + ) + + const appendFeedDebug = useCallback( + (sessionId: SessionId, input: FeedDebugInput) => { + setRuntimes(prev => { + const current = prev[sessionId] ?? emptyRuntime() + const next = appendFeedDebugLog(current, input) + if (next === current) return prev + return { + ...prev, + [sessionId]: next, + } + }) + }, + [setRuntimes], + ) + + const getRuntime = useCallback( + (sessionId: SessionId): SessionRuntime => { + return runtimes[sessionId] ?? emptyRuntime() + }, + [runtimes], + ) + + const toggleTailMode = useCallback( + (sessionId: SessionId) => { + setRuntimes(prev => { + const current = prev[sessionId] ?? emptyRuntime() + return { + ...prev, + [sessionId]: { + ...current, + tailMode: !current.tailMode, + }, + } + }) + }, + [setRuntimes], + ) + + const scrollFocusedToLatest = useCallback(() => { + const snap = refs.stateRef.current + const tab = snap.tabs.find(t => t.id === snap.activeTabId) + const sessionId = tab?.focusedSessionId + if (!sessionId) return + setRuntimes(prev => { + const current = prev[sessionId] ?? emptyRuntime() + return { + ...prev, + [sessionId]: { + ...current, + scrollToLatestRequest: current.scrollToLatestRequest + 1, + }, + } + }) + }, [refs.stateRef, setRuntimes]) + + return { + updateRuntime, + appendFeedDebug, + getRuntime, + toggleTailMode, + scrollFocusedToLatest, + } +} + +// ----------------------------------------------------------------------------- +// Pane toast — declared here instead of actions/toast.ts because many +// actions want to show a toast and the helpers+toast pair is atomic +// enough to stay together. +// ----------------------------------------------------------------------------- + +export function usePaneToast( + paneToastTimers: WorkspaceRefs['paneToastTimers'], + updateRuntime: (id: SessionId, patch: Partial) => void, +): (sessionId: SessionId, message: string, durationMs?: number) => void { + // Single-slot, auto-dismiss. Calling showPaneToast while a previous + // toast is still visible replaces it and resets the timer. The + // timeout ref lives outside React state so we can clear it without + // causing a re-render. + return useCallback( + (sessionId: SessionId, message: string, durationMs = 2000) => { + // Clear any in-flight timer for this pane. + const prev = paneToastTimers.current[sessionId] + if (prev) clearTimeout(prev) + + updateRuntime(sessionId, { paneToast: message }) + + paneToastTimers.current[sessionId] = setTimeout(() => { + updateRuntime(sessionId, { paneToast: null }) + delete paneToastTimers.current[sessionId] + }, durationMs) + }, + [paneToastTimers, updateRuntime], + ) +} + +// Re-export so callers that want to apply state setters through a +// provided updater without reimporting here can chain cleanly. +export function applyRuntimeUpdate(prev: T, next: T | ((prev: T) => T)): T { + return typeof next === 'function' + ? (next as (prev: T) => T)(prev) + : next +} diff --git a/src/renderer/src/workspace/hook/index.ts b/src/renderer/src/workspace/hook/index.ts new file mode 100644 index 000000000..0294d7766 --- /dev/null +++ b/src/renderer/src/workspace/hook/index.ts @@ -0,0 +1,327 @@ +import { useEffect, useMemo, useState } from 'react' + +import { useAppStore } from '../../state/hooks' +import { useGlobalToast } from '../../ui/GlobalToast' + +import type { WorkspaceHookContext } from './context' +import { useWorkspaceRefs } from './refs' +import { usePaneToast, useWorkspaceHelpers } from './helpers' +import { useStatusModeActions } from './actions/statusMode' +import { useDraftActions } from './actions/draft' +import { useStreamingActions } from './actions/streaming' +import { usePickerActions } from './actions/picker' +import { useSpotlightActions } from './actions/spotlight' +import { useReaderActions } from './actions/reader' +import { useTileTabsActions } from './actions/tileTabs' +import { useResizeActions } from './actions/resize' +import { useSessionActions } from './actions/session' +import { useTabActions } from './actions/tab' +import { usePaneActions } from './actions/pane' +import { useProviderActions } from './actions/provider' +import { useHistoryActions } from './actions/history' +import { useUndoCloseAction } from './actions/undoClose' +import { useAutoSave } from './persistence/useAutoSave' +import { useBootstrap } from './persistence/useBootstrap' +import { useFeedDebugPersist } from './persistence/useFeedDebugPersist' +import { + usePickerSanity, + useReaderModeSanity, + useSpotlightSanity, + useTileTabsSanity, +} from './invalidation/effects' +import { useIpcSubscriptions } from './ipc/useIpcSubscriptions' + +// ----------------------------------------------------------------------------- +// useWorkspace — the composer. +// +// Every piece of functionality the hook used to contain inline now +// lives in its own file under ./actions, ./ipc, ./persistence, or +// ./invalidation. This composer reads state from zustand, sets up +// the WorkspaceHookContext (refs + setters + helpers), and wires +// each sub-hook into a single returned object. +// +// Returning a stable shape is the only hard contract: consumers +// destructure fields off `Workspace` and if a sub-hook moves a +// method's name the caller breaks. Keep the return block alphabetic +// and avoid renames. +// ----------------------------------------------------------------------------- + +export type Workspace = ReturnType + +export function useWorkspace( + dangerousAgentsEnabled = false, + useProxyStreaming = false, +) { + // ---- Zustand subscriptions (these drive re-renders) ---- + const { showToast } = useGlobalToast() + const openBuryPrompt = useAppStore(store => store.openBuryPrompt) + const closeBuryPrompt = useAppStore(store => store.closeBuryPrompt) + const openNewAgentPlacement = useAppStore(store => store.openNewAgentPlacement) + const closeNewAgentPlacement = useAppStore(store => store.closeNewAgentPlacement) + + const state = useAppStore(store => store.workspaceState) + const setState = useAppStore(store => store.setWorkspaceState) + const runtimes = useAppStore(store => store.workspaceRuntimes) + const setRuntimes = useAppStore(store => store.setWorkspaceRuntimes) + const spotlight = useAppStore(store => store.workspaceSpotlight) + const setSpotlight = useAppStore(store => store.setWorkspaceSpotlight) + const tileTabs = useAppStore(store => store.workspaceTileTabs) + const setTileTabs = useAppStore(store => store.setWorkspaceTileTabs) + const readerMode = useAppStore(store => store.workspaceReaderMode) + const setReaderMode = useAppStore(store => store.setWorkspaceReaderMode) + const statusMode = useAppStore(store => store.workspaceStatusMode) + const setStatusMode = useAppStore(store => store.setWorkspaceStatusMode) + + // ---- Refs (identity-stable across renders) ---- + const refs = useWorkspaceRefs( + state, + runtimes, + tileTabs, + dangerousAgentsEnabled, + useProxyStreaming, + ) + + // ---- Keep refs in sync with live values on every render ---- + refs.stateRef.current = state + refs.latestStateRef.current = state + refs.latestRuntimesRef.current = runtimes + refs.latestTileTabsRef.current = tileTabs + refs.dangerousAgentsRef.current = dangerousAgentsEnabled + refs.useProxyStreamingRef.current = useProxyStreaming + + // ---- Draft version counter (React state because the save effect + // reads it as a dep) ---- + const [draftVersion, setDraftVersion] = useState(0) + + // ---- Runtime helpers (updateRuntime / appendFeedDebug / getRuntime / etc) ---- + const { updateRuntime, appendFeedDebug, getRuntime, toggleTailMode, scrollFocusedToLatest } = + useWorkspaceHelpers(runtimes, setRuntimes, refs) + + // ---- Pane toast (needs updateRuntime, so after helpers) ---- + const showPaneToast = usePaneToast(refs.paneToastTimers, updateRuntime) + + // ---- Build the canonical context object ---- + // + // Memoized on identity-stable inputs so ctx itself stays + // reference-stable for the hook's lifetime. That's critical for + // useCallback deps downstream — ctx never churns, so callbacks + // that only depend on ctx stay stable too. + const ctx: WorkspaceHookContext = useMemo( + () => ({ + setState, + setRuntimes, + setSpotlight, + setTileTabs, + setReaderMode, + setStatusMode, + openBuryPrompt, + closeBuryPrompt, + openNewAgentPlacement, + closeNewAgentPlacement, + showToast, + stateRef: refs.stateRef, + latestStateRef: refs.latestStateRef, + latestRuntimesRef: refs.latestRuntimesRef, + latestTileTabsRef: refs.latestTileTabsRef, + dangerousAgentsRef: refs.dangerousAgentsRef, + useProxyStreamingRef: refs.useProxyStreamingRef, + seenUuidsRef: refs.seenUuidsRef, + latestScreenRef: refs.latestScreenRef, + undoStackRef: refs.undoStackRef, + bootstrapTimersRef: refs.bootstrapTimersRef, + persistedFeedDebugIdRef: refs.persistedFeedDebugIdRef, + paneToastTimers: refs.paneToastTimers, + saveTimerRef: refs.saveTimerRef, + bootRef: refs.bootRef, + updateRuntime, + appendFeedDebug, + setDraftVersion, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ) + // Silence the unused-ctx warning — other sub-hooks consume it + // through the destructured args below. Keeping `ctx` built here + // so future sub-hooks that want a single-param signature have a + // stable object to consume. + void ctx + + // ---- Actions ---- + const { toggleStatusMode } = useStatusModeActions(setStatusMode) + const { setDraftInput, setDraftImages } = useDraftActions( + setRuntimes, + updateRuntime, + setDraftVersion, + ) + const { setStreamingBaseline, addOptimisticCodexUserEntry, removeOptimisticCodexUserEntry } = + useStreamingActions(setRuntimes) + const { pickerEnter, pickerMove, pickerCancel, pickerConfirm } = usePickerActions( + setRuntimes, + refs, + showPaneToast, + ) + const { toggleSpotlight, setSpotlightSession } = useSpotlightActions( + setSpotlight, + setState, + refs, + ) + const { toggleReaderMode, setReaderModeSession } = useReaderActions( + setReaderMode, + setSpotlight, + setState, + refs, + ) + const { + openTileTabs, + closeTileTabs, + focusTiledTab, + focusTiledTabByIndex, + resizeFocusedTiledTab, + resizeTiledTabByIndex, + } = useTileTabsActions(setTileTabs, setSpotlight, setState, refs) + const { + resizeFocused, + resizeFocusedDirectional, + setSplitRatio, + setSplitRatioInTab, + normalizeLayout, + hardNormalizeLayout, + rotateLayout, + } = useResizeActions(setState, setTileTabs) + + // Session lifecycle + derivatives that depend on it + const sessionActions = useSessionActions(state, setState, setRuntimes, refs) + const { spawn, killSession, replaceSession, reloadAgentSessions } = sessionActions + + const tabActions = useTabActions( + state, + tileTabs, + setState, + setRuntimes, + setTileTabs, + setSpotlight, + setReaderMode, + refs, + showToast, + sessionActions, + ) + + const paneActions = usePaneActions( + state, + setState, + setRuntimes, + setSpotlight, + setTileTabs, + refs, + showToast, + openBuryPrompt, + closeBuryPrompt, + openNewAgentPlacement, + closeNewAgentPlacement, + sessionActions, + ) + + const { switchFocusedProvider, reloadFocusedAgent, rewindFocusedToPrompt } = + useProviderActions(refs, setRuntimes, showPaneToast, sessionActions) + + const { loadOlderHistory } = useHistoryActions(setRuntimes, refs, updateRuntime) + + const { undoClose, undoCloseCount } = useUndoCloseAction( + state, + setState, + refs, + sessionActions, + ) + + // ---- Side-effects (subscriptions, persistence, invalidation) ---- + useIpcSubscriptions(refs, setState, setRuntimes, updateRuntime, appendFeedDebug) + useAutoSave(state, draftVersion, refs) + useBootstrap(refs, setState, setRuntimes, setTileTabs, tabActions.newTab as unknown as (cwd: string) => Promise) + useFeedDebugPersist(runtimes, refs) + useSpotlightSanity(spotlight, state.tabs, setSpotlight) + useReaderModeSanity(readerMode, state.tabs, setReaderMode) + usePickerSanity(runtimes, pickerCancel) + useTileTabsSanity(tileTabs, state.tabs, setTileTabs) + + // ---- Derived values ---- + const activeTab = useMemo( + () => state.tabs.find(t => t.id === state.activeTabId) ?? null, + [state.activeTabId, state.tabs], + ) + + // Silence unused-var warning for useEffect import (may be + // needed by future sub-hooks added to this composer). + void useEffect + + // ---- Return the stable Workspace shape ---- + return { + state, + runtimes, + activeTab, + spotlight, + tileTabs, + readerMode, + toggleReaderMode, + setReaderModeSession, + latestScreenRef: refs.latestScreenRef, + getRuntime, + statusMode, + toggleStatusMode, + // actions + newTab: tabActions.newTab, + closeTab: tabActions.closeTab, + spawn, + killSession, + splitFocused: paneActions.splitFocused, + startNewAgentPlacement: paneActions.startNewAgentPlacement, + commitNewAgentPlacement: paneActions.commitNewAgentPlacement, + closeFocused: paneActions.closeFocused, + closeSession: paneActions.closeSession, + requestBuryFocused: paneActions.requestBuryFocused, + buryFocused: paneActions.buryFocused, + reviveBuried: paneActions.reviveBuried, + focusSession: paneActions.focusSession, + focusSessionInTab: paneActions.focusSessionInTab, + navigate: paneActions.navigate, + activateTab: tabActions.activateTab, + activateTabByIndex: tabActions.activateTabByIndex, + nextTab: tabActions.nextTab, + prevTab: tabActions.prevTab, + resizeFocused, + resizeFocusedDirectional, + setSplitRatio, + setSplitRatioInTab, + setStreamingBaseline, + appendFeedDebug, + addOptimisticCodexUserEntry, + removeOptimisticCodexUserEntry, + setDraftInput, + setDraftImages, + loadOlderHistory, + showPaneToast, + undoClose, + undoCloseCount, + normalizeLayout, + hardNormalizeLayout, + rotateLayout, + replaceSession, + reloadFocusedAgent, + switchFocusedProvider, + rewindFocusedToPrompt, + reloadAgentSessions, + toggleSpotlight, + setSpotlightSession, + openTileTabs, + closeTileTabs, + focusTiledTab, + focusTiledTabByIndex, + resizeFocusedTiledTab, + resizeTiledTabByIndex, + toggleTailMode, + scrollFocusedToLatest, + pickerEnter, + pickerMove, + pickerConfirm, + pickerCancel, + } +} diff --git a/src/renderer/src/workspace/hook/invalidation/effects.ts b/src/renderer/src/workspace/hook/invalidation/effects.ts new file mode 100644 index 000000000..0f784bb7e --- /dev/null +++ b/src/renderer/src/workspace/hook/invalidation/effects.ts @@ -0,0 +1,134 @@ +import { useEffect } from 'react' + +import type { + ReaderModeState, + SessionRuntime, + SpotlightState, + TileTabsState, +} from '../../workspaceState' +import type { SessionId, Tab } from '../../types' +import { collectLeaves } from '../../tile-tree/treeOps' +import { + assistantUuidsWithText, +} from '../../../lib/copyAssistant' +import { ratiosEqual, sanitizeTileTabsState } from '../../layout/helpers' + +import type { + WorkspaceSetReaderMode, + WorkspaceSetSpotlight, + WorkspaceSetTileTabs, +} from '../context' + +// Invalidation effects — these fire when state changes and adjust +// orthogonal slices so they stay consistent with the tile tree. +// +// Without these, stale slice values linger and produce subtle bugs: +// - Spotlight mode pointing at a tab that was closed. +// - Reader mode pointing at a session whose tree slot was burned +// down. +// - Copy-assistant picker holding a uuid that was cleared from +// entries (conversation reset, etc.). +// - Tile-tabs pointing at tabIds that no longer exist. + +export function useSpotlightSanity( + spotlight: SpotlightState | null, + tabs: Tab[], + setSpotlight: WorkspaceSetSpotlight, +): void { + useEffect(() => { + if (!spotlight) return + const tab = tabs.find(t => t.id === spotlight.tabId) + if (!tab) { + setSpotlight(null) + return + } + const leaves = collectLeaves(tab.root) + if (leaves.length === 0) { + setSpotlight(null) + return + } + if (!leaves.includes(spotlight.focusedSessionId)) { + setSpotlight(prev => (prev ? { ...prev, focusedSessionId: leaves[0] } : prev)) + } + }, [setSpotlight, spotlight, tabs]) +} + +// Same invalidation rule for ReaderMode — if the tab disappears or +// its leaves change so the focused session no longer exists, drop +// out of Reader cleanly. Without this, closing the last pane in the +// tab while Reader is open would leave a dangling focused sessionId +// and a blank screen. +export function useReaderModeSanity( + readerMode: ReaderModeState | null, + tabs: Tab[], + setReaderMode: WorkspaceSetReaderMode, +): void { + useEffect(() => { + if (!readerMode) return + const tab = tabs.find(t => t.id === readerMode.tabId) + if (!tab) { + setReaderMode(null) + return + } + const leaves = collectLeaves(tab.root) + if (leaves.length === 0) { + setReaderMode(null) + return + } + if (!leaves.includes(readerMode.focusedSessionId)) { + setReaderMode(prev => (prev ? { ...prev, focusedSessionId: leaves[0] } : prev)) + } + }, [readerMode, setReaderMode, tabs]) +} + +// Picker invalidation. If the selected uuid is no longer present in +// a session's entries (entries cleared, conversation reset, etc.), +// cancel the picker. Without this the outline silently disappears +// (matching DOM node is gone) but the picker state lingers and keeps +// capturing keystrokes. +export function usePickerSanity( + runtimes: Record, + pickerCancel: (sessionId: SessionId) => void, +): void { + useEffect(() => { + for (const [sessionId, runtime] of Object.entries(runtimes)) { + if (!runtime.assistantPicker) continue + const uuids = assistantUuidsWithText(runtime.entries) + if (!uuids.includes(runtime.assistantPicker.selectedUuid)) { + pickerCancel(sessionId) + } + } + }, [pickerCancel, runtimes]) +} + +export function useTileTabsSanity( + tileTabs: TileTabsState | null, + tabs: Tab[], + setTileTabs: WorkspaceSetTileTabs, +): void { + useEffect(() => { + if (!tileTabs) return + const nextTileTabs = sanitizeTileTabsState(tileTabs) + if (!nextTileTabs) { + setTileTabs(null) + return + } + const validTabIds = nextTileTabs.tabIds.filter(id => tabs.some(t => t.id === id)) + const sanitized = sanitizeTileTabsState({ + ...nextTileTabs, + tabIds: validTabIds, + }) + if (!sanitized) { + setTileTabs(null) + return + } + if ( + sanitized.tabIds.length !== tileTabs.tabIds.length || + sanitized.focusedTabId !== tileTabs.focusedTabId || + sanitized.direction !== tileTabs.direction || + !ratiosEqual(sanitized.ratios, tileTabs.ratios) + ) { + setTileTabs(sanitized) + } + }, [setTileTabs, tabs, tileTabs]) +} diff --git a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts new file mode 100644 index 000000000..b438c7608 --- /dev/null +++ b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts @@ -0,0 +1,935 @@ +import { useEffect } from 'react' + +import type { Entry } from '../../../../../shared/types/transcript' +import { + isCompactBoundaryEntry, + isCompactSummaryEntry, + isConversationEntry, +} from '../../../../../shared/types/transcript' +import { detectCodexApproval } from '../../../../../shared/parsers/codexScreen' +import { emptyRuntime, type SessionRuntime } from '../../workspaceState' +import { appendFeedDebugLog, type FeedDebugInput } from '../../runtime/feedDebug' +import type { SessionId } from '../../types' +import { + isSemanticTurnRunning, + withDerivedSessionStatus, +} from '../../semantic/helpers' +import { foldSemanticEvent } from '../../semantic/foldEvent' +import { summarizeSemanticEventForDebug } from '../../semantic/summarize' +import { + extractCodexProviderSessionId, + isCodexRolloutEntry, + isOptimisticCodexUserEntry, +} from '../../codex/entries' +import { + codexHistoryMarker, + codexTurnIdFromRollout, + mapCodexRolloutToFeedEntries, + stampCodexTurnId, +} from '../../codex/rollout' +import { + claudeHistoryMarker, + extractEmbeddedClaudeProgressEntry, +} from '../../claude/history' +import { + entryTextContent, + indexEntryIntoMaps, + summarizeEntryForDebug, +} from '../../entries/utils' +import { pickerEqual } from '../../layout/helpers' +import { + ghostsFromSemanticTurn, + ghostsToPersist, + reconcileUpstream, +} from '../../ghosts' +import type { StreamPhase } from '../../workspaceState' + +import type { + WorkspaceSetRuntimes, + WorkspaceSetState, +} from '../context' +import type { WorkspaceRefs } from '../refs' + +// ----------------------------------------------------------------------------- +// useIpcSubscriptions — the one big side-effect that wires every +// window.api.onSession* listener. +// +// One listener per event type. The callback looks up the session by +// sessionId from the payload and patches the corresponding runtime. +// Deliberately a single effect so cleanup is one atomic teardown +// (required for HMR / StrictMode compatibility). +// +// Handlers intentionally stay inline here rather than broken into +// one-per-event files because they share refs, setters, and +// bookkeeping state (seenUuidsRef, latestScreenRef, bootstrapTimersRef) +// — breaking them apart would mean passing the whole ctx into each, +// gaining nothing but file count. If a handler ever grows to >200 +// lines AND has no cross-handler state, it's a candidate for +// further extraction. +// ----------------------------------------------------------------------------- + +export function useIpcSubscriptions( + refs: WorkspaceRefs, + setState: WorkspaceSetState, + setRuntimes: WorkspaceSetRuntimes, + updateRuntime: (sessionId: SessionId, patch: Partial) => void, + appendFeedDebug: (sessionId: SessionId, input: FeedDebugInput) => void, +): void { + useEffect(() => { + const offStarted = window.api.onSessionStarted(({ sessionId, projectDir }) => { + updateRuntime(sessionId, { projectDir }) + appendFeedDebug(sessionId, { + layer: 'STATE', + kind: 'session_started', + summary: `session started${projectDir ? ` · ${projectDir}` : ''}`, + data: { projectDir }, + }) + }) + + const offScreen = window.api.onSessionScreen( + ({ sessionId, plain, markdown, recent, recentMarkdown, picker }) => { + // latestScreenRef is the synchronous source of truth for + // the Enter-baseline capture in TileLeaf — always update + // it, even when we bail on React state below. + // Use `recent` (wider window) so the baseline includes any + // assistant text that may have already scrolled out of the + // viewport. The baseline comparison is the basis for "is + // the streaming card stale?", which depends on seeing the + // same marker the streaming extractor will see. + refs.latestScreenRef.current[sessionId] = recent + + setRuntimes(prev => { + const current = prev[sessionId] ?? emptyRuntime() + // Screen snapshots fire continuously (CC redraws its TUI + // at ~60Hz) but the actual text is usually identical + // between frames — CC is redrawing chrome, cursor, or a + // spinner that our parser already strips. Without a + // bail-out, every idle frame triggers a new `runtimes` + // state → useWorkspace re-render → TileTree/TileLeaf + // reconcile → Feed.memo check (which does then skip, but + // reconciliation to that point isn't free). Comparing + // strings by reference first, then by value, and bailing + // before setState entirely saves that whole pass on + // every no-op frame. This is the difference between + // "scheduled work on every frame" and "scheduled work + // only when the screen actually changed". + if ( + current.screen === plain && + current.screenMarkdown === markdown && + current.recentScreen === recent && + current.recentScreenMarkdown === recentMarkdown && + pickerEqual(current.picker, picker) + ) { + return prev + } + const changed: string[] = [] + if (current.screen !== plain) changed.push('screen') + if (current.recentScreen !== recent) changed.push('recent') + if (current.screenMarkdown !== markdown) changed.push('markdown') + if (!pickerEqual(current.picker, picker)) changed.push('picker') + // Detect Codex approval overlay from the screen. Only + // runs for codex sessions — Claude has its own trust + // dialog path. + // + // Two sources feed `pendingApproval`: + // 1. JSONL `exec_approval_request` — authoritative for + // `callId` / `command` / `workdir`. The callId is + // what we match against `exec_command_end` to + // dismiss. + // 2. Screen scrape — authoritative for the dynamic UI + // bits the user actually sees (`reason`, `options`, + // `selectedIndex`) since arrow-key nav only updates + // the TUI, never the JSONL. + // + // Originally this handler clobbered the JSONL fields + // with `callId: null`, which broke dismissal: when + // `exec_command_end` arrived later, the comparison + // `current.pendingApproval?.callId === resolvedCallId` + // saw `null === "real-id"` and the modal stuck forever. + // Now we MERGE — JSONL fields survive screen frames; + // screen fields overwrite their counterparts on every + // frame. + // + // When the screen shows no overlay we PRESERVE a + // JSONL-sourced approval (callId !== null) — the rule is + // "JSONL opens it, JSONL closes it". A screen-only + // approval (callId === null) can be cleared by a stale + // frame because there's no JSONL dismissal event coming. + const sessionKind = refs.stateRef.current.sessions[sessionId]?.kind + const screenApproval = sessionKind === 'codex' + ? detectCodexApproval(plain) + : null + const nextApproval = screenApproval + ? current.pendingApproval?.callId + ? { + ...current.pendingApproval, + reason: screenApproval.reason, + options: screenApproval.options, + selectedIndex: screenApproval.selectedIndex, + } + : { + callId: null, + command: screenApproval.command + ? screenApproval.command.split(/\s+/) + : [], + workdir: null, + reason: screenApproval.reason, + options: screenApproval.options, + selectedIndex: screenApproval.selectedIndex, + } + : current.pendingApproval?.callId + ? current.pendingApproval + : null + + // Screen frames can differ only by transient TUI chrome + // (cursor blink, spinner tick, timestamp) while the + // visible transcript is unchanged. We still commit the + // latest strings so DebugPanel/ReaderView stay faithful, + // but skip the debug-log append for this shape of frame + // to keep feed-debug readable. + const chromeTickOnly = + changed.every(k => k === 'screen' || k === 'recent' || k === 'markdown') && + changed.length > 0 && + recent.length === current.recentScreen.length && + pickerEqual(current.picker, picker) && + nextApproval === current.pendingApproval + + const nextBody = { + ...current, + screen: plain, + screenMarkdown: markdown, + recentScreen: recent, + recentScreenMarkdown: recentMarkdown, + picker, + // activityStatus is owned by the process-state IPC + // handler below — it carries the provider-correct verb + // (Claude's spinner verb, Codex's bottom-row text). + // Recomputing it here from `detectActivity(plain)` was + // Claude-specific and would overwrite Codex's status + // with null on every frame, racing the process-state + // writer. + pendingApproval: nextApproval, + } + const nextCurrent = chromeTickOnly + ? nextBody + : appendFeedDebugLog( + nextBody, + { + layer: 'STATE', + kind: 'screen_update', + summary: `screen update · ${changed.join(', ')}`, + data: { + changed, + pickerVisible: picker.visible, + pickerCount: picker.items.length, + approvalOpen: nextApproval !== null, + recentLength: recent.length, + }, + }, + ) + return { + ...prev, + [sessionId]: nextCurrent, + } + }) + }, + ) + + // The singular session:jsonl-entry IPC handler used to live + // here. It owned: codex providerSessionId capture, codex + // approval request/resolve, claude queue-operation + // bookkeeping, claude providerSessionId capture, + // pendingCompaction clearing, and the entry append itself. + // + // It caused the bootstrap-replay cascade. On a resume Claude / + // Codex emits ~200 jsonl-entry events synchronously; main used + // to dual-emit those as 200 separate session:jsonl-entry IPC + // sends PLUS one coalesced session:jsonl-entries burst. The + // 200 singular messages always reached the renderer first + // (they were enqueued first), and the singular handler did 200 + // separate setRuntimes calls — one full re-render per entry, + // plus the auto-scroll pin and lazy-mount cascade per entry. + // By the time the bulk message arrived, every uuid was already + // in seenUuidsRef and the bulk path no-op'd — the bootstrapping + // flag never asserted. + // + // The fix: drop the singular IPC emit on main entirely (see + // main/sessions/forwarder.ts); make the bulk handler below own + // every side-effect that used to live here. Live single + // entries arrive as 1-element bursts with ~1ms setImmediate + // latency. + // + // If you need to re-introduce a single-entry consumer, route + // it through the bulk channel as a 1-element burst. Do NOT add + // a second IPC channel that races the bulk one. + + const offErr = window.api.onSessionJsonlError(({ sessionId, message }) => { + // eslint-disable-next-line no-console + console.warn(`[jsonl ${sessionId.slice(0, 8)}]`, message) + }) + + const offExit = window.api.onSessionExit(({ sessionId, exitCode }) => { + setRuntimes(prev => { + const current = prev[sessionId] ?? emptyRuntime() + const next = withDerivedSessionStatus( + appendFeedDebugLog( + { + ...current, + exited: exitCode, + awaitingAssistant: false, + queuedMessages: [], + activityStatus: null, + processActive: false, + // Clear phase on exit. The WorkIndicator renders + // nothing for `idle`; letting a pre-exit phase + // linger would leave the in-feed indicator saying + // e.g. "Awaiting Bash" on a dead session. Matching + // the existing activityStatus null. + streamPhase: 'idle', + streamPhasePendingToolName: null, + streamPhasePendingToolUseId: null, + turnStartedAt: null, + phaseChangedAt: null, + submittedAt: null, + semantic: { + ...current.semantic, + currentTurn: null, + }, + }, + { + layer: 'STATE', + kind: 'session_exit', + summary: `session exited code=${exitCode}`, + data: { exitCode }, + }, + ), + ) + return { ...prev, [sessionId]: next } + }) + }) + + // Provider-emitted activity state. Both providers now derive + // this from their own screen spinner detector — Claude's + // rotating-glyph line, Codex's bottom "Working (...)" row — + // and forward the verb/status string alongside the boolean. + // When status arrives we adopt it directly; the renderer used + // to redundantly run Claude's detectActivity on every screen + // frame to derive the verb, which was both wasteful and wrong + // for Codex (the parser is Claude-specific). On idle + // transitions, status is undefined and we clear activityStatus + // too. + const offProcessState = window.api.onSessionProcessState( + ({ sessionId, active, status }) => { + setRuntimes(prev => { + const current = prev[sessionId] ?? emptyRuntime() + const next = withDerivedSessionStatus( + appendFeedDebugLog( + { + ...current, + processActive: active, + activityStatus: active ? (status ?? null) : null, + awaitingAssistant: false, + }, + { + layer: 'STATE', + kind: 'process_state', + summary: active + ? `process active${status ? ` · ${status}` : ''}` + : 'process idle', + data: { active, status: status ?? null }, + }, + ), + ) + return { ...prev, [sessionId]: next } + }) + }, + ) + + const offSemantic = window.api.onSessionSemanticEvent(({ sessionId, event }) => { + const semanticEvent = event as Record + setRuntimes(prev => { + const current = prev[sessionId] ?? emptyRuntime() + // WHY `?? 'claude'` default: Claude is the pre-fix + // behavior (auto-replace / self-heal on turnId mismatch). + // Falling back to the looser behavior during a + // teardown-race where the session meta is momentarily + // absent avoids silently dropping events we'd actually + // want to keep. See + // docs/superpowers/plans/2026-04-17-claude-semantic-provider-gating.md. + const sessionKind = refs.stateRef.current.sessions[sessionId]?.kind ?? 'claude' + const nextSemantic = foldSemanticEvent(current.semantic, semanticEvent, sessionKind) + const eventType = typeof semanticEvent.type === 'string' ? semanticEvent.type : '' + const clearOptimisticAwaiting = + isSemanticTurnRunning(nextSemantic.currentTurn) || + eventType === 'turn_completed' || + eventType === 'turn_stopped' || + eventType === 'api_error' || + eventType === 'stream_error' + + // stream_phase — in-feed indicator state. Overrides the + // optimistic `submitting` pseudo-phase once the adapter's + // first real event lands. Handled inline here (not inside + // foldSemanticEvent) because the field lives on + // SessionRuntime, not SemanticRuntimeState; the fold would + // be a layering violation. + let streamPhase = current.streamPhase + let streamPhasePendingToolName = current.streamPhasePendingToolName + let streamPhasePendingToolUseId = current.streamPhasePendingToolUseId + let turnStartedAt = current.turnStartedAt + let phaseChangedAt = current.phaseChangedAt + let submittedAt = current.submittedAt + + if (eventType === 'stream_phase') { + const rawPhase = + typeof semanticEvent.phase === 'string' ? semanticEvent.phase : 'idle' + const nextPhase = rawPhase as StreamPhase + if (nextPhase !== streamPhase) { + const now = Date.now() + streamPhase = nextPhase + streamPhasePendingToolName = + typeof semanticEvent.toolName === 'string' + ? (semanticEvent.toolName as string) + : null + streamPhasePendingToolUseId = + typeof semanticEvent.toolUseId === 'string' + ? (semanticEvent.toolUseId as string) + : null + phaseChangedAt = now + if (nextPhase === 'idle') { + turnStartedAt = null + submittedAt = null + } else if (turnStartedAt === null) { + // First non-idle phase of this turn — stamp the + // start time. If the optimistic-submit path already + // stamped `submittedAt`, prefer it over `now` so the + // elapsed counter includes the gap between submit + // and first adapter event. + turnStartedAt = submittedAt ?? now + } + } else if ( + // Re-assign pending tool info even on same-phase + // re-emit (turnId upgrade: null → real id is the + // classic case). + streamPhase !== 'idle' + ) { + streamPhasePendingToolName = + typeof semanticEvent.toolName === 'string' + ? (semanticEvent.toolName as string) + : streamPhasePendingToolName + streamPhasePendingToolUseId = + typeof semanticEvent.toolUseId === 'string' + ? (semanticEvent.toolUseId as string) + : streamPhasePendingToolUseId + } + } else if (eventType === 'tool_result') { + // Tool result arrived. If it matches the pending tool + // we're `awaiting-tool` on, move to a neutral + // 'requesting' phase so the indicator doesn't sit amber + // after the tool returned. The adapter's next + // stream_phase event (from the next assistant flow's + // message_start) will overwrite; this is the + // gap-filler. + const resultToolUseId = + typeof semanticEvent.toolUseId === 'string' + ? (semanticEvent.toolUseId as string) + : null + if ( + streamPhase === 'awaiting-tool' && + resultToolUseId !== null && + resultToolUseId === streamPhasePendingToolUseId + ) { + streamPhase = 'requesting' + streamPhasePendingToolName = null + streamPhasePendingToolUseId = null + phaseChangedAt = Date.now() + } + } + + // Ghost bridge — refresh the provisional ghost map from + // the new semantic turn. This runs on every semantic tick; + // `ghostsFromSemanticTurn` is idempotent and + // reference-stable so no-op ticks (e.g. usage_updated + // events) do not churn the map. + // + // WHY here and not inside `foldSemanticEvent`: + // foldSemanticEvent is intentionally agnostic to + // SessionRuntime — it reduces the SemanticRuntimeState + // sub-slice and knows nothing about sessionId or the + // outer runtime. The ghost map lives on SessionRuntime + // because it needs to survive across semantic history + // archival (when `currentTurn` flips to null) and because + // Phase 2 will persist it to disk with session-scoped + // file names. Calling the ghost reducer at this outer + // boundary keeps the layering clean. + const nextGhosts = ghostsFromSemanticTurn( + nextSemantic.currentTurn, + sessionId, + current.ghosts, + ) + + // Persist each changed ghost to disk (append-only JSONL + // under /ghost-logs). Fire-and-forget from the + // renderer; the main-side queue drains every 100 ms. See + // `src/main/ghostJournal.ts` for the writer and + // `../ghosts.ts` `ghostsToPersist` for why this diff is + // safe. + for (const ghost of ghostsToPersist(current.ghosts, nextGhosts)) { + window.api.ghostAppend(sessionId, ghost) + } + + // Full no-op short-circuit. foldSemanticEvent now returns + // `state` unchanged for events that didn't mutate semantic + // state; the ghost and phase paths are reference-stable + // after the 2026-04-20 fixes. If all six signals agree + // this event changed nothing and we're not clearing an + // optimistic wait, bail out before appendFeedDebugLog to + // avoid the SEM log noise bootstrap tool_results produced + // in the evidence timeline (id:1-8). See 2026-04-20 + // rendering-fixes Task 8. + const semanticUnchanged = nextSemantic === current.semantic + const phaseUnchanged = + streamPhase === current.streamPhase && + streamPhasePendingToolName === current.streamPhasePendingToolName && + streamPhasePendingToolUseId === current.streamPhasePendingToolUseId && + turnStartedAt === current.turnStartedAt && + phaseChangedAt === current.phaseChangedAt && + submittedAt === current.submittedAt + const ghostsUnchanged = nextGhosts === current.ghosts + const awaitingUnchanged = clearOptimisticAwaiting + ? current.awaitingAssistant === false + : true + if (semanticUnchanged && phaseUnchanged && ghostsUnchanged && awaitingUnchanged) { + return prev + } + + const nextCurrent = withDerivedSessionStatus( + appendFeedDebugLog( + { + ...current, + awaitingAssistant: clearOptimisticAwaiting ? false : current.awaitingAssistant, + semantic: nextSemantic, + streamPhase, + streamPhasePendingToolName, + streamPhasePendingToolUseId, + turnStartedAt, + phaseChangedAt, + submittedAt, + ghosts: nextGhosts, + }, + { + layer: 'SEM', + kind: eventType || 'semantic', + summary: summarizeSemanticEventForDebug(semanticEvent), + data: semanticEvent, + }, + ), + ) + return { + ...prev, + [sessionId]: nextCurrent, + } + }) + }) + + const offTrustDialog = window.api.onSessionTrustDialog( + ({ sessionId, visible, workspace }) => { + updateRuntime(sessionId, { + pendingTrustDialog: visible ? { workspace } : null, + }) + }, + ) + + const offResumePrompt = window.api.onSessionResumePrompt(({ + sessionId, + visible, + sessionAgeText, + tokenCountText, + options, + selectedIndex, + }) => { + updateRuntime(sessionId, { + pendingResumePrompt: visible + ? { sessionAgeText, tokenCountText, options, selectedIndex } + : null, + }) + }) + + const offCompactionState = window.api.onSessionCompactionState(({ + sessionId, + visible, + phase, + statusText, + errorText, + }) => { + updateRuntime(sessionId, { + pendingCompaction: visible && phase + ? { phase, statusText, errorText } + : null, + }) + }) + + // Bulk jsonl-entry path — the ONLY entry handler now that main + // no longer dual-emits singular events. Folds a whole burst in + // one setRuntimes + at most one setState, grows the tool + // indices incrementally, and sets `bootstrapping = true` for + // the duration so Feed can suspend per-append auto-scroll + + // lazy-mount cascades. See the deleted-handler comment above + // and docs/superpowers/plans/2026-04-15-bootstrap-replay-perf.md + // for the full rationale. + // + // Side-effects absorbed from the old singular handler: + // 1. Codex providerSessionId capture (from session_meta). + // 2. Codex approval request / resolve (per entry). + // 3. Claude queue-operation bookkeeping (per entry). + // 4. Claude providerSessionId capture (from any entry's sessionId). + // 5. pendingCompaction clearing on compact summary entries. + // 6. Optimistic-Codex-user reconciliation against the head row. + const offEntries = window.api.onSessionJsonlEntries(({ sessionId, entries }) => { + if (!entries || entries.length === 0) return + + // Two passes per burst: + // A — accumulate workspace-state captures (providerSessionId). + // Apply via ONE setState if anything changed. + // B — accumulate runtime mutations (entries, queue, + // approval, compaction). Apply via ONE setRuntimes. + // Splitting them keeps workspace.json in sync with new + // providerSessionId on the same tick the entries land, + // without doing N setState calls during a 200-entry burst. + + // ---- Pass A: workspace-state captures ---- + let capturedClaudeId: string | null = null + let capturedCodexId: string | null = null + for (const { entry: raw } of entries) { + if (isCodexRolloutEntry(raw)) { + if (!capturedCodexId) { + const id = extractCodexProviderSessionId(raw) + if (id) capturedCodexId = id + } + continue + } + if (!capturedClaudeId) { + const ccId = (raw as { sessionId?: string }).sessionId + if (typeof ccId === 'string' && ccId.length > 0) { + capturedClaudeId = ccId + } + } + } + if (capturedClaudeId || capturedCodexId) { + setState(prev => { + const meta = prev.sessions[sessionId] + if (!meta) return prev + if (meta.providerSessionId) return prev + const id = capturedClaudeId ?? capturedCodexId + if (!id) return prev + return { + ...prev, + sessions: { + ...prev.sessions, + [sessionId]: { ...meta, providerSessionId: id }, + }, + } + }) + } + + // ---- Pass B: runtime mutations ---- + setRuntimes(prev => { + const current = prev[sessionId] ?? emptyRuntime() + const seen = (refs.seenUuidsRef.current[sessionId] ??= new Set()) + const appended: Entry[] = [] + let oldestMarker: string | null = current.historyOldestMarker + let pendingCompaction = current.pendingCompaction + let pendingApproval = current.pendingApproval + let queuedMessages = current.queuedMessages + let awaitingAssistant = current.awaitingAssistant + // Set when a Codex user entry mapped from rollout matches + // the optimistic head row already in current.entries. We + // can only resolve it after the loop because the optimistic + // row could also live in `appended` if we're processing + // multiple bursts. + let dropOptimisticHead = false + + // Reuse the existing map references so downstream consumers + // that hold them live (Feed contexts) keep working without + // a re-subscribe. The runtime object itself gets a new + // top-level reference below so React re-renders. + const toolUseIndex = current.toolUseIndex + const toolResultIndex = current.toolResultIndex + + // Rolling Codex turn id — updated when a `turn_context` + // rollout entry arrives, then stamped onto every mapped + // response_item in the same turn. Feeds reconcileUpstream's + // Codex supersede branch. See codexTurnIdFromRollout for + // the extraction rule and reconcileUpstream for the match. + let codexCurrentTurnId: string | null = null + + for (const { entry: raw } of entries) { + // ---- Codex rollout branch ---- + if (isCodexRolloutEntry(raw)) { + const payload = raw.payload as Record | undefined + const turnContextId = codexTurnIdFromRollout(raw) + if (turnContextId !== null) codexCurrentTurnId = turnContextId + const mappedRaw = mapCodexRolloutToFeedEntries(raw) + const mapped = mappedRaw.map(e => stampCodexTurnId(e, codexCurrentTurnId)) + const marker = mapped.length > 0 ? codexHistoryMarker(raw) : null + if (marker && !oldestMarker) oldestMarker = marker + + // Codex exec_approval_request opens an approval pane; + // exec_command_end with the same call_id closes it. + if (raw.type === 'event_msg' && payload?.type === 'exec_approval_request') { + pendingApproval = { + callId: typeof payload.call_id === 'string' ? payload.call_id : null, + command: Array.isArray(payload.command) + ? payload.command.filter( + (part): part is string => typeof part === 'string', + ) + : [], + workdir: typeof payload.workdir === 'string' ? payload.workdir : null, + reason: pendingApproval?.reason, + options: pendingApproval?.options, + selectedIndex: pendingApproval?.selectedIndex, + } + } else if ( + raw.type === 'event_msg' && + payload?.type === 'exec_command_end' && + typeof payload.call_id === 'string' && + pendingApproval?.callId === payload.call_id + ) { + pendingApproval = null + } + + // Optimistic-user reconciliation. Match the first + // mapped user entry against either the last entry + // we've already accumulated in this burst, or — if + // nothing is appended yet — the tail of current.entries. + const firstMapped = mapped[0] + if (firstMapped?.type === 'user') { + if (appended.length > 0) { + const lastAppended = appended[appended.length - 1] + if ( + isOptimisticCodexUserEntry(lastAppended) && + entryTextContent(lastAppended) === entryTextContent(firstMapped) + ) { + appended.pop() + } + } else { + const lastExisting = current.entries[current.entries.length - 1] + if ( + isOptimisticCodexUserEntry(lastExisting) && + entryTextContent(lastExisting) === entryTextContent(firstMapped) + ) { + dropOptimisticHead = true + } + } + } + + for (const e of mapped) { + const u = (e as { uuid?: string }).uuid + if (u) { + if (seen.has(u)) continue + seen.add(u) + } + appended.push(e) + indexEntryIntoMaps(e, toolUseIndex, toolResultIndex) + } + continue + } + + // ---- Claude queue-operation branch ---- + // queue-operation entries are CC's internal + // message-queue bookkeeping (see + // claude-code-src/utils/messageQueueManager.ts for the + // emit sites). 'enqueue' / 'dequeue' / 'remove' — the + // latter two are collapsed into "drop head" because we + // don't have identity info to do better. Not pushed + // into `entries` (would render as feed noise). + const entryType = (raw as { type?: string }).type + if (entryType === 'queue-operation') { + const op = raw as { + operation?: 'enqueue' | 'dequeue' | 'remove' + content?: string + timestamp?: string + } + if (op.operation === 'enqueue' && typeof op.content === 'string') { + const ts = op.timestamp ?? String(Date.now()) + const already = queuedMessages.some( + q => q.timestamp === ts && q.content === op.content, + ) + if (!already) { + queuedMessages = [ + ...queuedMessages, + { content: op.content, timestamp: ts }, + ] + } + } else if (op.operation === 'dequeue' || op.operation === 'remove') { + queuedMessages = queuedMessages.slice(1) + } + // Force the streaming flag on whenever the queue has + // items so the streaming card doesn't disappear + // between turns while CC is draining queued work. + if (queuedMessages.length > 0) awaitingAssistant = true + continue + } + + // ---- Claude conversation entry branch ---- + const feedEntry = + extractEmbeddedClaudeProgressEntry(raw as Record) ?? + (raw as Entry) + const marker = claudeHistoryMarker(raw as Record) + if (marker && !oldestMarker) oldestMarker = marker + const uuid = (feedEntry as { uuid?: string }).uuid + if (uuid) { + if (seen.has(uuid)) continue + seen.add(uuid) + } + if ( + !isConversationEntry(feedEntry) && + !isCompactBoundaryEntry(feedEntry) && + !isCompactSummaryEntry(feedEntry) + ) { + continue + } + if (isCompactSummaryEntry(feedEntry)) pendingCompaction = null + appended.push(feedEntry) + indexEntryIntoMaps(feedEntry, toolUseIndex, toolResultIndex) + } + + const baseEntries = dropOptimisticHead + ? current.entries.slice(0, -1) + : current.entries + + // Ghost reconciliation — when authoritative entries land, + // supersede any live ghost whose `(turnId, blockIndex)` + // they replace. Runs per appended entry so ghost→real + // handoff is synchronous with the entry becoming visible; + // the ghost drops out of the merged view in the same + // render as the real entry appears. + // + // `reconcileUpstream` is a no-op when there are no ghosts, + // and returns the same-size Map when no ghost matched, so + // this is cheap in the common case. Non-conversation + // entries (system, compact_boundary) pass through + // untouched. + let nextGhosts = current.ghosts + for (const entry of appended) { + nextGhosts = reconcileUpstream(entry, nextGhosts) + } + + // Persist supersede records. When an upstream entry + // matched a ghost, `reconcileUpstream` produced a new ghost + // snapshot with `supersededBy` set; appending that to disk + // is how crash-recovered state knows "this ghost is no + // longer live." + for (const ghost of ghostsToPersist(current.ghosts, nextGhosts)) { + window.api.ghostAppend(sessionId, ghost) + } + + // Bail only when literally nothing changed. Approval, + // queue, and compaction transitions can fire on bursts + // that don't append any feed entries at all. Include ghost + // reference equality in the no-change check: + // reconcileUpstream preserves the same Map reference when + // no ghost matched, so this only fires setRuntimes when + // ghosts actually changed. Matches the treatment of + // queuedMessages and the rest of this guard. + const ghostsChanged = nextGhosts !== current.ghosts + const noChange = + appended.length === 0 && + !dropOptimisticHead && + pendingCompaction === current.pendingCompaction && + pendingApproval === current.pendingApproval && + queuedMessages === current.queuedMessages && + awaitingAssistant === current.awaitingAssistant && + !ghostsChanged + if (noChange) return prev + + const nextRuntime = withDerivedSessionStatus( + appendFeedDebugLog( + { + ...current, + entries: appended.length > 0 || dropOptimisticHead + ? [...baseEntries, ...appended] + : current.entries, + historyOldestMarker: oldestMarker, + bootstrapping: true, + pendingCompaction, + pendingApproval, + queuedMessages, + awaitingAssistant, + toolUseIndex, + toolResultIndex, + ghosts: nextGhosts, + }, + { + layer: 'JSONL', + kind: 'jsonl_entries', + summary: + appended.length > 0 || dropOptimisticHead + ? `entries +${appended.length}${dropOptimisticHead ? ' · reconciled optimistic user' : ''}` + : 'jsonl side-effects only', + data: { + burstSize: entries.length, + appendedCount: appended.length, + droppedOptimisticHead: dropOptimisticHead, + appended: appended.slice(-8).map(summarizeEntryForDebug), + queuedMessages: queuedMessages.length, + pendingApproval: pendingApproval + ? { + callId: pendingApproval.callId, + command: pendingApproval.command, + } + : null, + }, + }, + ), + ) + return { + ...prev, + [sessionId]: nextRuntime, + } + }) + + // Schedule the bootstrap flip. Each burst resets the debounce + // timer — the phase ends after ~150ms of quiet (long enough + // to cover a laggy resume replay, short enough that the user + // doesn't notice a deferred scroll pin). + const existing = refs.bootstrapTimersRef.current.get(sessionId) + if (existing) clearTimeout(existing) + const timer = setTimeout(() => { + refs.bootstrapTimersRef.current.delete(sessionId) + setRuntimes(prev => { + const current = prev[sessionId] + if (!current || !current.bootstrapping) return prev + return { + ...prev, + [sessionId]: appendFeedDebugLog( + { ...current, bootstrapping: false }, + { + layer: 'STATE', + kind: 'bootstrap_complete', + summary: 'bootstrap replay quiet window elapsed', + }, + ), + } + }) + }, 150) + refs.bootstrapTimersRef.current.set(sessionId, timer) + }) + + return () => { + offStarted() + offScreen() + // No singular offEntry() — see the deleted-handler comment + // above. The bulk path is the only one. + offEntries() + offErr() + offProcessState() + offSemantic() + offTrustDialog() + offResumePrompt() + offCompactionState() + offExit() + for (const t of refs.bootstrapTimersRef.current.values()) clearTimeout(t) + refs.bootstrapTimersRef.current.clear() + } + }, [appendFeedDebug, refs, setRuntimes, setState, updateRuntime]) +} diff --git a/src/renderer/src/workspace/hook/persistence/rehydrate.ts b/src/renderer/src/workspace/hook/persistence/rehydrate.ts new file mode 100644 index 000000000..1ed7ae656 --- /dev/null +++ b/src/renderer/src/workspace/hook/persistence/rehydrate.ts @@ -0,0 +1,223 @@ +import { emptyRuntime, type SessionRuntime, type TileTabsState } from '../../workspaceState' +import type { + BuriedPaneRecord, + SessionId, + SessionKind, + SessionMeta, + Tab, + TileNode, +} from '../../types' +import { collectLeaves } from '../../tile-tree/treeOps' +import { sanitizeTileTabsState } from '../../layout/helpers' +import type { PersistedWorkspace } from '../../persistence' + +import type { + WorkspaceSetRuntimes, + WorkspaceSetState, + WorkspaceSetTileTabs, +} from '../context' +import type { WorkspaceRefs } from '../refs' + +// Remap a persisted tree by replacing every sessionId with a freshly +// spawned one (spawn happens as we walk). Returns the remapped tree +// plus the old→new id mapping. +// +// Resume semantics: if the persisted SessionMeta carries a +// `providerSessionId`, we pass it to the spawn call as +// `resumeSessionId` so claude boots with `--resume ` and the +// full conversation history — tool calls, transcript, queue state, +// the lot — comes back. The cc-shell SessionId we mint here is a +// fresh routing key; CC's own session UUID is the thing we care +// about preserving. +// +// The providerSessionId is ALSO threaded into freshSessions[newId] +// so the runtime meta after rehydrate matches pre-reload state and +// the next save cycle writes it straight back. Without this, the +// first save after a resume would drop providerSessionId and the +// NEXT reload would lose context again. +// +// Failure modes: +// - File missing / corrupted → CC will exit with a non-zero code +// shortly after spawn. Surfaces via the exit event as "exited" +// in the pane status strip. Not retried automatically — the +// user can close the pane and open a fresh one. +// - File locked by another process (rare) → same as above. +// - Spawn itself throws (IPC failure) → caught below and logged; +// the pane is simply missing from the rehydrated tree. + +export async function rehydrateWorkspace( + persisted: PersistedWorkspace, + refs: WorkspaceRefs, + setState: WorkspaceSetState, + setRuntimes: WorkspaceSetRuntimes, + setTileTabs: WorkspaceSetTileTabs, + newTab: (cwd: string) => Promise, +): Promise { + const idMap = new Map() + const freshSessions: Record = {} + + const remapNode = (n: TileNode): TileNode => { + if (n.type === 'leaf') { + const mapped = idMap.get(n.sessionId) + return mapped + ? { type: 'leaf', sessionId: mapped } + : n // shouldn't happen, but fall through rather than crash + } + return { ...n, a: remapNode(n.a), b: remapNode(n.b) } + } + + const sanitizeRemappedNode = (n: TileNode): TileNode | null => { + if (n.type === 'leaf') { + return freshSessions[n.sessionId] != null ? n : null + } + const a = sanitizeRemappedNode(n.a) + const b = sanitizeRemappedNode(n.b) + if (!a && !b) return null + if (!a) return b + if (!b) return a + return { ...n, a, b } + } + + const buildRemappedTabs = (): Tab[] => + persisted.tabs + .map(t => { + const remappedRoot = sanitizeRemappedNode(remapNode(t.root)) + if (!remappedRoot) return null + const leaves = collectLeaves(remappedRoot) + if (leaves.length === 0) return null + const focused = idMap.get(t.focusedSessionId) ?? leaves[0] + return { + id: t.id, + title: t.title, + root: remappedRoot, + focusedSessionId: focused, + } satisfies Tab + }) + .filter((t): t is Tab => t !== null) + + const buildRemappedBuried = (): BuriedPaneRecord[] => + (persisted.buried ?? []) + .map(entry => { + const mappedSessionId = idMap.get(entry.sessionId) + if (!mappedSessionId) return null + return { + ...entry, + id: mappedSessionId, + sessionId: mappedSessionId, + siblingLeafId: entry.siblingLeafId + ? (idMap.get(entry.siblingLeafId) ?? entry.siblingLeafId) + : undefined, + } satisfies BuriedPaneRecord + }) + .filter((entry): entry is BuriedPaneRecord => entry !== null) + + const buildRemappedTileTabs = (tabs: Tab[]): TileTabsState | null => { + const persistedTileTabs = persisted.tileTabs + if (!persistedTileTabs) return null + const validTabIds = persistedTileTabs.tabIds.filter(id => + tabs.some(tab => tab.id === id), + ) + return sanitizeTileTabsState({ + ...persistedTileTabs, + tabIds: validTabIds, + }) + } + + const commitRehydratedState = (): boolean => { + const newTabs = buildRemappedTabs() + if (newTabs.length === 0) return false + + const restoredTileTabs = buildRemappedTileTabs(newTabs) + const activeTabId = restoredTileTabs?.focusedTabId + ?? newTabs.find(t => t.id === persisted.activeTabId)?.id + ?? newTabs[0].id + + setState({ + tabs: newTabs, + activeTabId, + sessions: { ...freshSessions }, + buried: buildRemappedBuried(), + }) + setTileTabs(restoredTileTabs) + // WHY commit runtimes incrementally during rehydrate: + // + // Boot used to await every respawn before publishing *any* + // restored tabs. One slow / wedged session kept `tabs: []`, so + // after restart the user only saw the `+` button even though + // workspace.json contained a full layout. We now publish + // whatever subset has already rehydrated so the shell surfaces + // real tabs immediately and fills in the remaining panes as + // their sessions come back. + // + // We still merge with prev because resume-side transcript + // events can arrive synchronously inside `session.start()` + // before spawnSession() resolves. Replacing the runtime object + // here would clobber those early entries and make restored + // panes open blank. + setRuntimes(prev => { + const out: Record = {} + for (const [oldId, newId] of idMap.entries()) { + const existing = prev[newId] + const base = existing ?? emptyRuntime() + const draft = persisted.drafts?.[oldId] + out[newId] = { + ...base, + ...(draft && !base.draftInput ? { draftInput: draft } : {}), + hasOlderHistory: Boolean(freshSessions[newId]?.providerSessionId), + } + } + for (const id of Object.keys(freshSessions)) { + if (out[id]) continue + const existing = prev[id] + out[id] = { + ...(existing ?? emptyRuntime()), + hasOlderHistory: Boolean(freshSessions[id]?.providerSessionId), + } + } + return out + }) + return true + } + + // Spawn all sessions concurrently instead of serially. A single + // slow respawn must not block the entire tab strip from coming + // back. + await Promise.all( + Object.entries(persisted.sessions).map(async ([oldId, meta]) => { + try { + const kind: SessionKind = meta.kind ?? 'claude' + // For terminal sessions with a persisted tmuxName, pass it + // as recoverTmuxName so main re-attaches the alive tmux + // session (or falls back to fresh spawn if it died). Agents + // ignore recoverTmuxName at the main side; safe to omit. + const { sessionId: newId, tmuxName: nextTmuxName } = await window.api.spawnSession({ + kind, + cwd: meta.cwd, + resumeSessionId: kind !== 'terminal' ? meta.providerSessionId : undefined, + dangerousMode: kind !== 'terminal' ? refs.dangerousAgentsRef.current : undefined, + useProxy: kind !== 'terminal' ? refs.useProxyStreamingRef.current : undefined, + recoverTmuxName: kind === 'terminal' ? meta.tmuxName : undefined, + }) + idMap.set(oldId, newId) + // Carry the full meta forward — kind + providerSessionId + + // tmuxName — so the next save cycle doesn't drop these and + // cause the session to degrade on the NEXT reload. + // tmuxName is replaced with whatever main reported + // (recovered name when alive, fresh name when respawned). + freshSessions[newId] = { + ...meta, + ...(nextTmuxName ? { tmuxName: nextTmuxName } : {}), + } + commitRehydratedState() + } catch (err) { + // eslint-disable-next-line no-console + console.warn(`[workspace] failed to respawn ${meta.cwd}:`, err) + } + }), + ) + + if (!commitRehydratedState()) { + const cwd = await window.api.defaultCwd() + await newTab(cwd) + } +} diff --git a/src/renderer/src/workspace/hook/persistence/useAutoSave.ts b/src/renderer/src/workspace/hook/persistence/useAutoSave.ts new file mode 100644 index 000000000..4bea9259d --- /dev/null +++ b/src/renderer/src/workspace/hook/persistence/useAutoSave.ts @@ -0,0 +1,78 @@ +import { useCallback, useEffect } from 'react' + +import type { PersistedWorkspace } from '../../persistence' +import type { SessionId, WorkspaceState } from '../../types' + +import type { WorkspaceRefs } from '../refs' + +// Debounced workspace-save + beforeunload flush. +// +// The save is extracted into a stable helper so it can be called +// both from the debounce timer AND from the beforeunload flush. We +// keep a ref to the latest state so the beforeunload handler (which +// can't close over React state) always serializes the freshest +// version. +// +// The debounced save has a 400ms window where a mutation hasn't +// been written yet. If the user quits the app during that window, +// the latest state is lost — tabs vanish, sessions can't resume. +// +// Fix: listen for `beforeunload` (fires synchronously before the +// renderer is torn down) and flush immediately. The IPC invoke is +// async but Electron's main process receives the message before the +// window actually closes — the write lands. Same pattern VS Code +// uses for its workspace state. + +export function useAutoSave( + state: WorkspaceState, + draftVersion: number, + refs: WorkspaceRefs, +): void { + const flushSave = useCallback(() => { + const s = refs.latestStateRef.current + // Collect non-empty drafts so in-progress prompts survive crashes. + const drafts: Record = {} + for (const [id, rt] of Object.entries(refs.latestRuntimesRef.current)) { + if (rt.draftInput) drafts[id] = rt.draftInput + } + const persisted: PersistedWorkspace = { + tabs: s.tabs.map(t => ({ + id: t.id, + title: t.title, + focusedSessionId: t.focusedSessionId, + root: t.root, + })), + activeTabId: s.activeTabId, + sessions: s.sessions, + buried: s.buried, + tileTabs: refs.latestTileTabsRef.current, + drafts: Object.keys(drafts).length > 0 ? drafts : undefined, + } + const json = JSON.stringify({ workspace: persisted }, null, 2) + void window.api.saveWorkspace(json).catch(err => { + // eslint-disable-next-line no-console + console.warn('[workspace] save failed:', err) + }) + }, [refs.latestRuntimesRef, refs.latestStateRef, refs.latestTileTabsRef]) + + useEffect(() => { + if (refs.saveTimerRef.current) clearTimeout(refs.saveTimerRef.current) + refs.saveTimerRef.current = setTimeout(flushSave, 400) + return () => { + if (refs.saveTimerRef.current) clearTimeout(refs.saveTimerRef.current) + } + }, [state, draftVersion, flushSave, refs.saveTimerRef]) + + useEffect(() => { + const onBeforeUnload = () => { + // Cancel the debounced timer so we don't double-save. + if (refs.saveTimerRef.current) { + clearTimeout(refs.saveTimerRef.current) + refs.saveTimerRef.current = null + } + flushSave() + } + window.addEventListener('beforeunload', onBeforeUnload) + return () => window.removeEventListener('beforeunload', onBeforeUnload) + }, [flushSave, refs.saveTimerRef]) +} diff --git a/src/renderer/src/workspace/hook/persistence/useBootstrap.ts b/src/renderer/src/workspace/hook/persistence/useBootstrap.ts new file mode 100644 index 000000000..beaaebcfa --- /dev/null +++ b/src/renderer/src/workspace/hook/persistence/useBootstrap.ts @@ -0,0 +1,74 @@ +import { useEffect } from 'react' + +import type { PersistedWorkspace } from '../../persistence' + +import type { + WorkspaceSetRuntimes, + WorkspaceSetState, + WorkspaceSetTileTabs, +} from '../context' +import type { WorkspaceRefs } from '../refs' + +import { rehydrateWorkspace } from './rehydrate' + +// Once-only mount effect. +// +// If there's persisted state, respawn every session in its saved +// cwd (minting fresh sessionIds because main process ids are +// ephemeral) and remap the tree to use the new ids. If there's no +// saved state, spawn one default session in the default cwd. +// +// The bootRef guard makes the effect safe under React 18 StrictMode +// (which intentionally runs mount effects twice in dev). + +export function useBootstrap( + refs: WorkspaceRefs, + setState: WorkspaceSetState, + setRuntimes: WorkspaceSetRuntimes, + setTileTabs: WorkspaceSetTileTabs, + newTab: (cwd: string) => Promise, +): void { + useEffect(() => { + if (refs.bootRef.current) return + refs.bootRef.current = true + void (async () => { + const json = await window.api.loadWorkspace() + if (!json) { + // Fresh install — create one default tab. + const cwd = await window.api.defaultCwd() + try { + await newTab(cwd) + } catch (err) { + console.warn('[workspace] initial session spawn failed:', err) + } + return + } + try { + // Single-user dev app — no schema versioning. If the load + // fails for any reason (corrupt JSON, unexpected shape, + // spawn error during rehydrate) we fall through to the + // catch below and start fresh. No migrations, no version + // gates. + const parsed = JSON.parse(json) as { workspace: PersistedWorkspace } + await rehydrateWorkspace( + parsed.workspace, + refs, + setState, + setRuntimes, + setTileTabs, + newTab, + ) + } catch (err) { + // eslint-disable-next-line no-console + console.warn('[workspace] load failed, starting fresh:', err) + const cwd = await window.api.defaultCwd() + try { + await newTab(cwd) + } catch (spawnErr) { + console.warn('[workspace] fallback session spawn failed:', spawnErr) + } + } + })() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) +} diff --git a/src/renderer/src/workspace/hook/persistence/useFeedDebugPersist.ts b/src/renderer/src/workspace/hook/persistence/useFeedDebugPersist.ts new file mode 100644 index 000000000..3b707ebe7 --- /dev/null +++ b/src/renderer/src/workspace/hook/persistence/useFeedDebugPersist.ts @@ -0,0 +1,47 @@ +import { useEffect } from 'react' + +import type { SessionId } from '../../types' +import type { SessionRuntime } from '../../workspaceState' + +import type { WorkspaceRefs } from '../refs' + +// Ship runtime feed-debug entries to the main process on every +// runtime update. The main-side queue writes them to +// /feed-debug/.jsonl. +// +// The `persistedFeedDebugIdRef` tracks the largest feed-debug entry +// id we've shipped per session so we don't re-ship entries already +// on disk. + +export function useFeedDebugPersist( + runtimes: Record, + refs: WorkspaceRefs, +): void { + useEffect(() => { + for (const [sessionId, runtime] of Object.entries(runtimes)) { + if (runtime.feedDebugLog.length === 0) continue + const lastPersistedId = refs.persistedFeedDebugIdRef.current[sessionId] ?? 0 + const pending = runtime.feedDebugLog.filter(entry => entry.id > lastPersistedId) + if (pending.length === 0) continue + refs.persistedFeedDebugIdRef.current[sessionId] = + pending[pending.length - 1]?.id ?? lastPersistedId + void window.api + .appendFeedDebugLog({ + sessionId, + entries: pending.map(entry => ({ + id: entry.id, + ts: entry.ts, + tMs: entry.tMs, + layer: entry.layer, + kind: entry.kind, + summary: entry.summary, + data: entry.data, + })), + }) + .catch(err => { + // eslint-disable-next-line no-console + console.warn(`[feed-debug ${sessionId.slice(0, 8)}] append failed`, err) + }) + } + }, [refs.persistedFeedDebugIdRef, runtimes]) +} diff --git a/src/renderer/src/workspace/hook/refs.ts b/src/renderer/src/workspace/hook/refs.ts new file mode 100644 index 000000000..95d7c2183 --- /dev/null +++ b/src/renderer/src/workspace/hook/refs.ts @@ -0,0 +1,110 @@ +import { useRef, type MutableRefObject } from 'react' + +import { UndoCloseStack } from '../../lib/undoClose' +import type { + ReaderModeState, + SessionRuntime, + SpotlightState, + TileTabsState, +} from '../workspaceState' +import type { SessionId, WorkspaceState } from '../types' + +// ----------------------------------------------------------------------------- +// Ref factory for the workspace hook. +// +// Every ref the useWorkspace hook maintained inline is declared here. +// The factory returns ALL of them in one call so the hook body doesn't +// need 15 separate useRef lines. Ref identity is stable across renders +// (useRef contract), so putting them together doesn't cost anything. +// +// NOTE: this hook updates `stateRef.current = state` style mirrors in +// the caller's render body (not here) — the mirrors are per-render +// writes, not per-action. That's what lets IPC callbacks close over +// stale React state and still read the live value via .current. +// ----------------------------------------------------------------------------- + +export type WorkspaceRefs = { + stateRef: MutableRefObject + latestStateRef: MutableRefObject + latestRuntimesRef: MutableRefObject> + latestTileTabsRef: MutableRefObject + dangerousAgentsRef: MutableRefObject + useProxyStreamingRef: MutableRefObject + seenUuidsRef: MutableRefObject>> + latestScreenRef: MutableRefObject> + undoStackRef: MutableRefObject + bootstrapTimersRef: MutableRefObject>> + persistedFeedDebugIdRef: MutableRefObject> + paneToastTimers: MutableRefObject>> + saveTimerRef: MutableRefObject | null> + bootRef: MutableRefObject +} + +export function useWorkspaceRefs( + initialState: WorkspaceState, + initialRuntimes: Record, + initialTileTabs: TileTabsState | null, + dangerousAgentsEnabled: boolean, + useProxyStreaming: boolean, +): WorkspaceRefs { + return { + // Ref mirror of state so IPC callbacks (which close over stale + // state) can read the current session metadata (e.g. kind) + // without causing re-subscriptions on every state change. + stateRef: useRef(initialState), + // Parallel to stateRef; the name `latestStateRef` is kept because + // the save-to-disk path refers to it explicitly. Both point at + // the same zustand slice but the caller updates them separately + // so downstream readers can opt into whichever name is clearer. + latestStateRef: useRef(initialState), + // Ref mirror of runtimes so the debounced save callback can read + // current drafts without re-creating the callback on every render. + latestRuntimesRef: useRef(initialRuntimes), + latestTileTabsRef: useRef(initialTileTabs), + + // Settings mirror refs. Ref-mirrored so the spawn callbacks read + // the live value without having to subscribe per-call. + dangerousAgentsRef: useRef(dangerousAgentsEnabled), + useProxyStreamingRef: useRef(useProxyStreaming), + + // Seen uuids per session, for JSONL dedup. Refs because we never + // render against them — they're bookkeeping. + seenUuidsRef: useRef>>({}), + + // Latest screen per session — mirrored from state into a ref so + // the Enter handler in TileLeaf can capture a baseline + // synchronously. + latestScreenRef: useRef>({}), + + // Undo-close stack — mutable ref because the stack is imperative + // (push/pop) and we don't want React re-renders on every close. + // The undoClose action reads it and the command palette peeks at + // .length to show/hide the command. + undoStackRef: useRef(new UndoCloseStack()), + + // Per-session setTimeout ids used to debounce the `bootstrapping` + // flag back to false after a bulk jsonl-entries burst. Keyed by + // sessionId; cleared in the IPC-effect cleanup and in killSession. + // Ref (not state) because the timer handle is irrelevant to + // rendering — we just need it alive across the hook's ticks. + bootstrapTimersRef: useRef>>(new Map()), + + // Tracks the largest feed-debug entry id we've shipped to main + // per session. Prevents re-shipping entries we've already written. + persistedFeedDebugIdRef: useRef>({}), + + // Per-session pane toast timers. Single-slot per session — a + // second toast replaces the first and resets this timer. + paneToastTimers: useRef>>({}), + + // Debounced workspace-save timer. `setTimeout(flushSave, 400)` on + // every mutation; the beforeunload handler cancels this and + // flushes synchronously. + saveTimerRef: useRef | null>(null), + + // Guard for the once-only bootstrap effect. Under React 18 + // StrictMode the effect runs twice; the ref makes the second + // pass a no-op. + bootRef: useRef(false), + } +} From 5953455503050855f3587e4ac30a5bad058fc262 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 23 Apr 2026 18:52:03 +0200 Subject: [PATCH 09/27] =?UTF-8?q?refactor(workspace):=20workspaceStore.ts?= =?UTF-8?q?=20=E2=86=92=2046-line=20re-export=20shell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/renderer/src/workspace/workspaceStore.ts | 3777 +----------------- 1 file changed, 15 insertions(+), 3762 deletions(-) diff --git a/src/renderer/src/workspace/workspaceStore.ts b/src/renderer/src/workspace/workspaceStore.ts index 755758713..d1551dcae 100644 --- a/src/renderer/src/workspace/workspaceStore.ts +++ b/src/renderer/src/workspace/workspaceStore.ts @@ -1,3772 +1,25 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' - -import { - isCompactBoundaryEntry, - isCompactSummaryEntry, - isConversationEntry, - type Entry, - type ToolResultBlock, - type ToolUseBlock, -} from '../../../shared/types/transcript' -// Direct file imports — parser files are pure TypeScript, safe for -// the renderer. Package entry points pull in Node deps (pty, fs). -import { detectCodexApproval } from '../../../shared/parsers/codexScreen' -import { extractAssistantInProgress } from '../../../shared/parsers/extractAssistant' -import { - type BuriedPaneRecord, - RATIO_DEFAULT, - type SessionId, - type SessionKind, - type SessionMeta, - type SplitDirection, - type Tab, - type TabId, - type TileNode, - type WorkspaceState, -} from './types' -import { - adjustNearestSplitRatio, - closeLeaf, - collectLeaves, - equalizeRatios, - insertBesideLeaf, - normalizeTree, - resizeInDirection, - rotateTree, - splitLeaf, - wrapRootWithLeaf, -} from './tile-tree/treeOps' -import { findBestRemainingFocus, findDirectionalNeighbor } from './tile-tree/geometry' -import { - UndoCloseStack, - findParentSplitInfo, - reinsertPane, - type ClosedEntry, -} from '../lib/undoClose' -import { useGlobalToast } from '../ui/GlobalToast' -import { - extractAssistantByUuid, - assistantUuidsWithText, -} from '../lib/copyAssistant' -import { useAppStore } from '../state/hooks' -import { - emptySemanticRuntime, - emptyRuntime, - parseSemanticTodos, - type FeedDebugEntry, - type FeedDebugLayer, - type PickerItem, - type QueuedMessage, - type ReaderModeState, - type SemanticLiveBlock, - type SemanticLiveTurn, - type SemanticRuntimeState, - type SessionRuntime, - type SessionStatus, - type SessionStatusSource, - type SlashPickerState, - type StreamPhase, - type SpotlightState, - type TileTabsState, -} from './workspaceState' -import { - ghostsFromSemanticTurn, - ghostsToPersist, - reconcileUpstream, -} from './ghosts' -// Bootstrap uses the sans-superseded reducer because cc-shell only -// ever wants the provisional-and-still-live ghost set on resume — -// forensic "rendered X but upstream confirmed Y" rows are surfaced -// (if ever) through a dedicated UI, not the feed. Pairs with the -// `trustSupersededFlag` merge option in `./mergedEntries.ts`. See -// atp/src/ghost.ts for the full contract. -import { reduceGhostLogSansSuperseded as reduceGhostLog } from 'agent-transcript-parser/ghost' -import type { PlacementTarget } from '../features/workspace/lib/newAgentPlacement' - -// ----------------------------------------------------------------------------- -// Extracted pure modules (2026-04-23 refactor — see task #5) +// Workspace store public surface. // -// Everything below the hook used to live inline in this file. Pure -// functions that the hook calls but doesn't close over got lifted -// into focused sibling modules: +// The `useWorkspace` hook and its composed sub-hooks live under +// ./hook/*. This file exists only so external callers can keep +// importing from '../workspace/workspaceStore' while the actual +// implementation moved; updating ~30 call sites to the new path +// isn't worth the churn. // -// ./semantic/ foldSemanticEvent + helpers + summarizers -// ./codex/ rollout mapper, factories, envelope stripping -// ./claude/ history-marker / progress-entry unwrapping -// ./entries/utils entry text extraction + tool-use indexing -// ./runtime/feedDebug the appendFeedDebugLog helper -// ./spawn/ session-spawn error message normalizer -// ./layout/helpers titleFromCwd / ratio math / pickerEqual / -// setRatioBetween / sanitizeTileTabsState -// ./persistence PersistedWorkspace type -// -// The hook itself stayed here — it owns per-session refs, IPC -// subscriptions, and cross-cutting mutations (spawn+tree+sessions -// happen together), and splitting it further would either require -// sharing refs across modules or routing everything through a new -// state library. The WHY comments for every extracted chunk moved -// with the code they explain. -// ----------------------------------------------------------------------------- - -import { foldSemanticEvent } from './semantic/foldEvent' -import { summarizeSemanticEventForDebug } from './semantic/summarize' -import { - isSemanticTurnRunning, - withDerivedSessionStatus, -} from './semantic/helpers' -import { - extractCodexProviderSessionId, - isCodexRolloutEntry, - isOptimisticCodexUserEntry, -} from './codex/entries' -import { - codexHistoryMarker, - codexTurnIdFromRollout, - mapCodexRolloutToFeedEntries, - stampCodexTurnId, -} from './codex/rollout' -import { - claudeHistoryMarker, - extractEmbeddedClaudeProgressEntry, -} from './claude/history' -import { - entryTextContent, - indexEntryIntoMaps, - summarizeEntryForDebug, -} from './entries/utils' -import { - appendFeedDebugLog, - type FeedDebugInput, -} from './runtime/feedDebug' -import { sessionSpawnErrorMessage } from './spawn/errorMessage' -import { - equalRatios, - normalizeRatios, - pickerEqual, - ratiosEqual, - sanitizeTileTabsState, - setRatioBetween, - titleFromCwd, -} from './layout/helpers' -import type { PersistedWorkspace } from './persistence' - - -// Workspace store — single React hook that owns: -// - The workspace state (tabs + tile trees + session metadata) -// - Live per-session runtime state (screen, entries, awaitingAssistant, …) -// - IPC subscriptions that dispatch events to the right session -// - All the mutation actions the keybind system calls -// -// We deliberately keep everything in ONE hook instead of splitting into -// multiple stores because: -// - The mutations cross-cut multiple slices (splitting creates a new -// session AND adds to tree AND updates sessions map). -// - The dispatch-by-sessionId event routing needs a stable reference -// to the live state refs — one hook means one set of refs. -// - Persistence is a single serialized blob; one store = one save. -// -// --------------------------------------------------------------------------- -// The store hook -// --------------------------------------------------------------------------- - -export type Workspace = ReturnType - -export function useWorkspace( - dangerousAgentsEnabled = false, - useProxyStreaming = false, -) { - // GlobalToast lives one level up in the provider tree (mounted in - // main.tsx). Reading it here lets close actions surface a brief - // "Closed — ⌘⇧T (Undo Close)" hint without each caller having to know - // about the toast system. The hook returns a stable callback so - // re-renders don't churn close handlers. - const { showToast } = useGlobalToast() - const openBuryPrompt = useAppStore(store => store.openBuryPrompt) - const closeBuryPrompt = useAppStore(store => store.closeBuryPrompt) - const openNewAgentPlacement = useAppStore(store => store.openNewAgentPlacement) - const closeNewAgentPlacement = useAppStore(store => store.closeNewAgentPlacement) - - const state = useAppStore(store => store.workspaceState) - const setState = useAppStore(store => store.setWorkspaceState) - - // Ref mirror of state so IPC callbacks (which close over stale state) - // can read the current session metadata (e.g. kind) without causing - // re-subscriptions on every state change. - const stateRef = useRef(state) - stateRef.current = state - const dangerousAgentsRef = useRef(dangerousAgentsEnabled) - dangerousAgentsRef.current = dangerousAgentsEnabled - // Ref-mirrored so the spawn callbacks below read the live value - // without having to subscribe per-call (same pattern as - // dangerousAgentsRef above). - const useProxyStreamingRef = useRef(useProxyStreaming) - useProxyStreamingRef.current = useProxyStreaming - - // Per-session runtime state. Keyed by sessionId. NOT part of - // persistent state — runtime rebuilds from IPC events after respawn. - const runtimes = useAppStore(store => store.workspaceRuntimes) - const setRuntimes = useAppStore(store => store.setWorkspaceRuntimes) - const spotlight = useAppStore(store => store.workspaceSpotlight) - const setSpotlight = useAppStore(store => store.setWorkspaceSpotlight) - const tileTabs = useAppStore(store => store.workspaceTileTabs) - const setTileTabs = useAppStore(store => store.setWorkspaceTileTabs) - const latestTileTabsRef = useRef(tileTabs) - latestTileTabsRef.current = tileTabs - const readerMode = useAppStore(store => store.workspaceReaderMode) - const setReaderMode = useAppStore(store => store.setWorkspaceReaderMode) - - // Ref mirror of runtimes so the debounced save callback can read - // current drafts without re-creating the callback on every render. - const latestRuntimesRef = useRef(runtimes) - latestRuntimesRef.current = runtimes - const persistedFeedDebugIdRef = useRef>({}) - - // Seen uuids per session, for JSONL dedup. Refs because we never - // render against them — they're bookkeeping. - const seenUuidsRef = useRef>>({}) - - // Latest screen per session — mirrored from state into a ref so the - // Enter handler in TileLeaf can capture a baseline synchronously. - const latestScreenRef = useRef>({}) - - // Undo-close stack — mutable ref because the stack is imperative - // (push/pop) and we don't want React re-renders on every close. - // The undoClose action reads it and the command palette peeks at - // .length to show/hide the command. - const undoStackRef = useRef(new UndoCloseStack()) - - // Per-session setTimeout ids used to debounce the `bootstrapping` - // flag back to false after a bulk jsonl-entries burst. Keyed by - // sessionId; cleared in the IPC-effect cleanup and in killSession. - // Ref (not state) because the timer handle is irrelevant to - // rendering — we just need it alive across the hook's ticks. - const bootstrapTimersRef = useRef>>(new Map()) - - // ---- Helpers ---- - - const updateRuntime = useCallback( - (sessionId: SessionId, patch: Partial) => { - setRuntimes(prev => { - const current = prev[sessionId] ?? emptyRuntime() - return { - ...prev, - [sessionId]: withDerivedSessionStatus({ ...current, ...patch }), - } - }) - }, - [], - ) - - const appendFeedDebug = useCallback( - (sessionId: SessionId, input: FeedDebugInput) => { - setRuntimes(prev => { - const current = prev[sessionId] ?? emptyRuntime() - const next = appendFeedDebugLog(current, input) - if (next === current) return prev - return { - ...prev, - [sessionId]: next, - } - }) - }, - [], - ) - - const getRuntime = useCallback( - (sessionId: SessionId): SessionRuntime => { - return runtimes[sessionId] ?? emptyRuntime() - }, - [runtimes], - ) - - const toggleTailMode = useCallback((sessionId: SessionId) => { - setRuntimes(prev => { - const current = prev[sessionId] ?? emptyRuntime() - return { - ...prev, - [sessionId]: { - ...current, - tailMode: !current.tailMode, - }, - } - }) - }, []) - - const scrollFocusedToLatest = useCallback(() => { - const snap = stateRef.current - const tab = snap.tabs.find(t => t.id === snap.activeTabId) - const sessionId = tab?.focusedSessionId - if (!sessionId) return - setRuntimes(prev => { - const current = prev[sessionId] ?? emptyRuntime() - return { - ...prev, - [sessionId]: { - ...current, - scrollToLatestRequest: current.scrollToLatestRequest + 1, - }, - } - }) - }, []) - - // ---- IPC subscription: dispatch all session events to the right runtime ---- - // - // One listener per event type. The callback looks up the session by - // sessionId from the payload and patches the corresponding runtime. - useEffect(() => { - const offStarted = window.api.onSessionStarted(({ sessionId, projectDir }) => { - updateRuntime(sessionId, { projectDir }) - appendFeedDebug(sessionId, { - layer: 'STATE', - kind: 'session_started', - summary: `session started${projectDir ? ` · ${projectDir}` : ''}`, - data: { projectDir }, - }) - }) - - const offScreen = window.api.onSessionScreen( - ({ sessionId, plain, markdown, recent, recentMarkdown, picker }) => { - // latestScreenRef is the synchronous source of truth for the - // Enter-baseline capture in TileLeaf — always update it, even - // when we bail on React state below. - // Use `recent` (wider window) so the baseline includes any - // assistant text that may have already scrolled out of the - // viewport. The baseline comparison is the basis for "is the - // streaming card stale?", which depends on seeing the same - // marker the streaming extractor will see. - latestScreenRef.current[sessionId] = recent - - setRuntimes(prev => { - const current = prev[sessionId] ?? emptyRuntime() - // Screen snapshots fire continuously (CC redraws its TUI at - // ~60Hz) but the actual text is usually identical between - // frames — CC is redrawing chrome, cursor, or a spinner that - // our parser already strips. Without a bail-out, every idle - // frame triggers a new `runtimes` state → useWorkspace - // re-render → TileTree/TileLeaf reconcile → Feed.memo check - // (which does then skip, but reconciliation to that point - // isn't free). Comparing strings by reference first, then by - // value, and bailing before setState entirely saves that - // whole pass on every no-op frame. This is the difference - // between "scheduled work on every frame" and "scheduled work - // only when the screen actually changed". - if ( - current.screen === plain && - current.screenMarkdown === markdown && - current.recentScreen === recent && - current.recentScreenMarkdown === recentMarkdown && - pickerEqual(current.picker, picker) - ) { - return prev - } - const changed: string[] = [] - if (current.screen !== plain) changed.push('screen') - if (current.recentScreen !== recent) changed.push('recent') - if (current.screenMarkdown !== markdown) changed.push('markdown') - if (!pickerEqual(current.picker, picker)) changed.push('picker') - // Detect Codex approval overlay from the screen. Only runs - // for codex sessions — Claude has its own trust dialog path. - // - // Two sources feed `pendingApproval`: - // 1. JSONL `exec_approval_request` — authoritative for - // `callId` / `command` / `workdir`. The callId is what - // we match against `exec_command_end` to dismiss. - // 2. Screen scrape — authoritative for the dynamic UI bits - // the user actually sees (`reason`, `options`, - // `selectedIndex`) since arrow-key nav only updates the - // TUI, never the JSONL. - // - // Originally this handler clobbered the JSONL fields with - // `callId: null`, which broke dismissal: when - // `exec_command_end` arrived later, the comparison - // `current.pendingApproval?.callId === resolvedCallId` saw - // `null === "real-id"` and the modal stuck forever. Now we - // MERGE — JSONL fields survive screen frames; screen fields - // overwrite their counterparts on every frame. - // - // When the screen shows no overlay we PRESERVE a JSONL-sourced - // approval (callId !== null) — the rule is "JSONL opens it, - // JSONL closes it". A screen-only approval (callId === null) - // can be cleared by a stale frame because there's no JSONL - // dismissal event coming. - const sessionKind = stateRef.current.sessions[sessionId]?.kind - const screenApproval = sessionKind === 'codex' - ? detectCodexApproval(plain) - : null - const nextApproval = screenApproval - ? current.pendingApproval?.callId - ? { - ...current.pendingApproval, - reason: screenApproval.reason, - options: screenApproval.options, - selectedIndex: screenApproval.selectedIndex, - } - : { - callId: null, - command: screenApproval.command - ? screenApproval.command.split(/\s+/) - : [], - workdir: null, - reason: screenApproval.reason, - options: screenApproval.options, - selectedIndex: screenApproval.selectedIndex, - } - : current.pendingApproval?.callId - ? current.pendingApproval - : null - - // Screen frames can differ only by transient TUI chrome - // (cursor blink, spinner tick, timestamp) while the visible - // transcript is unchanged. We still commit the latest strings - // so DebugPanel/ReaderView stay faithful, but skip the debug-log - // append for this shape of frame to keep feed-debug readable. - const chromeTickOnly = - changed.every(k => k === 'screen' || k === 'recent' || k === 'markdown') && - changed.length > 0 && - recent.length === current.recentScreen.length && - pickerEqual(current.picker, picker) && - nextApproval === current.pendingApproval - - const nextBody = { - ...current, - screen: plain, - screenMarkdown: markdown, - recentScreen: recent, - recentScreenMarkdown: recentMarkdown, - picker, - // activityStatus is owned by the process-state IPC handler - // below — it carries the provider-correct verb (Claude's - // spinner verb, Codex's bottom-row text). Recomputing it - // here from `detectActivity(plain)` was Claude-specific - // and would overwrite Codex's status with null on every - // frame, racing the process-state writer. - pendingApproval: nextApproval, - } - const nextCurrent = chromeTickOnly - ? nextBody - : appendFeedDebugLog( - nextBody, - { - layer: 'STATE', - kind: 'screen_update', - summary: `screen update · ${changed.join(', ')}`, - data: { - changed, - pickerVisible: picker.visible, - pickerCount: picker.items.length, - approvalOpen: nextApproval !== null, - recentLength: recent.length, - }, - }, - ) - return { - ...prev, - [sessionId]: nextCurrent, - } - }) - }, - ) - - // The singular session:jsonl-entry IPC handler used to live here. - // It owned: codex providerSessionId capture, codex approval - // request/resolve, claude queue-operation bookkeeping, claude - // providerSessionId capture, pendingCompaction clearing, and the - // entry append itself. - // - // It caused the bootstrap-replay cascade. On a resume Claude / - // Codex emits ~200 jsonl-entry events synchronously; main used to - // dual-emit those as 200 separate session:jsonl-entry IPC sends - // PLUS one coalesced session:jsonl-entries burst. The 200 singular - // messages always reached the renderer first (they were enqueued - // first), and the singular handler did 200 separate setRuntimes - // calls — one full re-render per entry, plus the auto-scroll pin - // and lazy-mount cascade per entry. By the time the bulk message - // arrived, every uuid was already in seenUuidsRef and the bulk - // path no-op'd — the bootstrapping flag never asserted. - // - // The fix: drop the singular IPC emit on main entirely (see - // wireManagerIPC); make the bulk handler below own every - // side-effect that used to live here. Live single entries arrive - // as 1-element bursts with ~1ms setImmediate latency. - // - // If you need to re-introduce a single-entry consumer, route it - // through the bulk channel as a 1-element burst. Do NOT add a - // second IPC channel that races the bulk one. - - const offErr = window.api.onSessionJsonlError(({ sessionId, message }) => { - // eslint-disable-next-line no-console - console.warn(`[jsonl ${sessionId.slice(0, 8)}]`, message) - }) - - const offExit = window.api.onSessionExit(({ sessionId, exitCode }) => { - setRuntimes(prev => { - const current = prev[sessionId] ?? emptyRuntime() - const next = withDerivedSessionStatus( - appendFeedDebugLog( - { - ...current, - exited: exitCode, - awaitingAssistant: false, - queuedMessages: [], - activityStatus: null, - processActive: false, - // Clear phase on exit. The WorkIndicator renders nothing for - // `idle`; letting a pre-exit phase linger would leave the - // in-feed indicator saying e.g. "Awaiting Bash" on a dead - // session. Matching the existing activityStatus null. - streamPhase: 'idle', - streamPhasePendingToolName: null, - streamPhasePendingToolUseId: null, - turnStartedAt: null, - phaseChangedAt: null, - submittedAt: null, - semantic: { - ...current.semantic, - currentTurn: null, - }, - }, - { - layer: 'STATE', - kind: 'session_exit', - summary: `session exited code=${exitCode}`, - data: { exitCode }, - }, - ), - ) - return { ...prev, [sessionId]: next } - }) - }) - - // Provider-emitted activity state. Both providers now derive this - // from their own screen spinner detector — Claude's rotating-glyph - // line, Codex's bottom "Working (...)" row — and forward the - // verb/status string alongside the boolean. When status arrives - // we adopt it directly; the renderer used to redundantly run - // Claude's detectActivity on every screen frame to derive the - // verb, which was both wasteful and wrong for Codex (the parser - // is Claude-specific). On idle transitions, status is undefined - // and we clear activityStatus too. - const offProcessState = window.api.onSessionProcessState(({ sessionId, active, status }) => { - setRuntimes(prev => { - const current = prev[sessionId] ?? emptyRuntime() - const next = withDerivedSessionStatus( - appendFeedDebugLog( - { - ...current, - processActive: active, - activityStatus: active ? (status ?? null) : null, - awaitingAssistant: false, - }, - { - layer: 'STATE', - kind: 'process_state', - summary: active - ? `process active${status ? ` · ${status}` : ''}` - : 'process idle', - data: { active, status: status ?? null }, - }, - ), - ) - return { ...prev, [sessionId]: next } - }) - }) - - const offSemantic = window.api.onSessionSemanticEvent(({ sessionId, event }) => { - const semanticEvent = event as Record - setRuntimes(prev => { - const current = prev[sessionId] ?? emptyRuntime() - // WHY `?? 'claude'` default: Claude is the pre-fix behavior - // (auto-replace / self-heal on turnId mismatch). Falling back to - // the looser behavior during a teardown-race where the session - // meta is momentarily absent avoids silently dropping events - // we'd actually want to keep. See - // docs/superpowers/plans/2026-04-17-claude-semantic-provider-gating.md. - const sessionKind = stateRef.current.sessions[sessionId]?.kind ?? 'claude' - const nextSemantic = foldSemanticEvent(current.semantic, semanticEvent, sessionKind) - const eventType = typeof semanticEvent.type === 'string' ? semanticEvent.type : '' - const clearOptimisticAwaiting = - isSemanticTurnRunning(nextSemantic.currentTurn) || - eventType === 'turn_completed' || - eventType === 'turn_stopped' || - eventType === 'api_error' || - eventType === 'stream_error' - - // stream_phase — in-feed indicator state. Overrides the - // optimistic `submitting` pseudo-phase once the adapter's - // first real event lands. Handled inline here (not inside - // foldSemanticEvent) because the field lives on SessionRuntime, - // not SemanticRuntimeState; the fold would be a layering - // violation. - let streamPhase = current.streamPhase - let streamPhasePendingToolName = current.streamPhasePendingToolName - let streamPhasePendingToolUseId = current.streamPhasePendingToolUseId - let turnStartedAt = current.turnStartedAt - let phaseChangedAt = current.phaseChangedAt - let submittedAt = current.submittedAt - - if (eventType === 'stream_phase') { - const rawPhase = - typeof semanticEvent.phase === 'string' ? semanticEvent.phase : 'idle' - const nextPhase = rawPhase as StreamPhase - if (nextPhase !== streamPhase) { - const now = Date.now() - streamPhase = nextPhase - streamPhasePendingToolName = - typeof semanticEvent.toolName === 'string' - ? (semanticEvent.toolName as string) - : null - streamPhasePendingToolUseId = - typeof semanticEvent.toolUseId === 'string' - ? (semanticEvent.toolUseId as string) - : null - phaseChangedAt = now - if (nextPhase === 'idle') { - turnStartedAt = null - submittedAt = null - } else if (turnStartedAt === null) { - // First non-idle phase of this turn — stamp the start - // time. If the optimistic-submit path already stamped - // `submittedAt`, prefer it over `now` so the elapsed - // counter includes the gap between submit and first - // adapter event. - turnStartedAt = submittedAt ?? now - } - } else if ( - // Re-assign pending tool info even on same-phase re-emit - // (turnId upgrade: null → real id is the classic case). - streamPhase !== 'idle' - ) { - streamPhasePendingToolName = - typeof semanticEvent.toolName === 'string' - ? (semanticEvent.toolName as string) - : streamPhasePendingToolName - streamPhasePendingToolUseId = - typeof semanticEvent.toolUseId === 'string' - ? (semanticEvent.toolUseId as string) - : streamPhasePendingToolUseId - } - } else if (eventType === 'tool_result') { - // Tool result arrived. If it matches the pending tool we're - // `awaiting-tool` on, move to a neutral 'requesting' phase - // so the indicator doesn't sit amber after the tool returned. - // The adapter's next stream_phase event (from the next - // assistant flow's message_start) will overwrite; this is - // the gap-filler. - const resultToolUseId = - typeof semanticEvent.toolUseId === 'string' - ? (semanticEvent.toolUseId as string) - : null - if ( - streamPhase === 'awaiting-tool' && - resultToolUseId !== null && - resultToolUseId === streamPhasePendingToolUseId - ) { - streamPhase = 'requesting' - streamPhasePendingToolName = null - streamPhasePendingToolUseId = null - phaseChangedAt = Date.now() - } - } - - // Ghost bridge — refresh the provisional ghost map from the - // new semantic turn. This runs on every semantic tick; - // `ghostsFromSemanticTurn` is idempotent and reference-stable - // so no-op ticks (e.g. usage_updated events) do not churn the - // map. - // - // WHY here and not inside `foldSemanticEvent`: - // foldSemanticEvent is intentionally agnostic to - // SessionRuntime — it reduces the SemanticRuntimeState - // sub-slice and knows nothing about sessionId or the outer - // runtime. The ghost map lives on SessionRuntime because - // it needs to survive across semantic history archival - // (when `currentTurn` flips to null) and because Phase 2 - // will persist it to disk with session-scoped file names. - // Calling the ghost reducer at this outer boundary keeps - // the layering clean. - const nextGhosts = ghostsFromSemanticTurn( - nextSemantic.currentTurn, - sessionId, - current.ghosts, - ) - - // Persist each changed ghost to disk (append-only JSONL under - // /ghost-logs). Fire-and-forget from the renderer; - // the main-side queue drains every 100 ms. See - // `src/main/ghostJournal.ts` for the writer and - // `./ghosts.ts` `ghostsToPersist` for why this diff is safe. - for (const ghost of ghostsToPersist(current.ghosts, nextGhosts)) { - window.api.ghostAppend(sessionId, ghost) - } - - // Full no-op short-circuit. foldSemanticEvent now returns - // `state` unchanged for events that didn't mutate semantic - // state; the ghost and phase paths are reference-stable after - // the 2026-04-20 fixes. If all six signals agree this event - // changed nothing and we're not clearing an optimistic wait, - // bail out before appendFeedDebugLog to avoid the SEM log - // noise bootstrap tool_results produced in the evidence - // timeline (id:1-8). See 2026-04-20 rendering-fixes Task 8. - const semanticUnchanged = nextSemantic === current.semantic - const phaseUnchanged = - streamPhase === current.streamPhase && - streamPhasePendingToolName === current.streamPhasePendingToolName && - streamPhasePendingToolUseId === current.streamPhasePendingToolUseId && - turnStartedAt === current.turnStartedAt && - phaseChangedAt === current.phaseChangedAt && - submittedAt === current.submittedAt - const ghostsUnchanged = nextGhosts === current.ghosts - const awaitingUnchanged = clearOptimisticAwaiting - ? current.awaitingAssistant === false - : true - if (semanticUnchanged && phaseUnchanged && ghostsUnchanged && awaitingUnchanged) { - return prev - } - - const nextCurrent = withDerivedSessionStatus( - appendFeedDebugLog( - { - ...current, - awaitingAssistant: clearOptimisticAwaiting ? false : current.awaitingAssistant, - semantic: nextSemantic, - streamPhase, - streamPhasePendingToolName, - streamPhasePendingToolUseId, - turnStartedAt, - phaseChangedAt, - submittedAt, - ghosts: nextGhosts, - }, - { - layer: 'SEM', - kind: eventType || 'semantic', - summary: summarizeSemanticEventForDebug(semanticEvent), - data: semanticEvent, - }, - ), - ) - return { - ...prev, - [sessionId]: nextCurrent, - } - }) - }) - - const offTrustDialog = window.api.onSessionTrustDialog(({ sessionId, visible, workspace }) => { - updateRuntime(sessionId, { - pendingTrustDialog: visible ? { workspace } : null, - }) - }) - - const offResumePrompt = window.api.onSessionResumePrompt(({ - sessionId, - visible, - sessionAgeText, - tokenCountText, - options, - selectedIndex, - }) => { - updateRuntime(sessionId, { - pendingResumePrompt: visible - ? { sessionAgeText, tokenCountText, options, selectedIndex } - : null, - }) - }) - - const offCompactionState = window.api.onSessionCompactionState(({ - sessionId, - visible, - phase, - statusText, - errorText, - }) => { - updateRuntime(sessionId, { - pendingCompaction: visible && phase - ? { phase, statusText, errorText } - : null, - }) - }) - - // Bulk jsonl-entry path — the ONLY entry handler now that main - // no longer dual-emits singular events. Folds a whole burst in - // one setRuntimes + at most one setState, grows the tool indices - // incrementally, and sets `bootstrapping = true` for the duration - // so Feed can suspend per-append auto-scroll + lazy-mount - // cascades. See the deleted-handler comment higher in this file - // and docs/superpowers/plans/2026-04-15-bootstrap-replay-perf.md - // for the full rationale. - // - // Side-effects absorbed from the old singular handler: - // 1. Codex providerSessionId capture (from session_meta). - // 2. Codex approval request / resolve (per entry). - // 3. Claude queue-operation bookkeeping (per entry). - // 4. Claude providerSessionId capture (from any entry's sessionId). - // 5. pendingCompaction clearing on compact summary entries. - // 6. Optimistic-Codex-user reconciliation against the head row. - const offEntries = window.api.onSessionJsonlEntries(({ sessionId, entries }) => { - if (!entries || entries.length === 0) return - - // Two passes per burst: - // A — accumulate workspace-state captures (providerSessionId). - // Apply via ONE setState if anything changed. - // B — accumulate runtime mutations (entries, queue, approval, - // compaction). Apply via ONE setRuntimes. - // Splitting them keeps workspace.json in sync with new - // providerSessionId on the same tick the entries land, without - // doing N setState calls during a 200-entry burst. - - // ---- Pass A: workspace-state captures ---- - let capturedClaudeId: string | null = null - let capturedCodexId: string | null = null - for (const { entry: raw } of entries) { - if (isCodexRolloutEntry(raw)) { - if (!capturedCodexId) { - const id = extractCodexProviderSessionId(raw) - if (id) capturedCodexId = id - } - continue - } - if (!capturedClaudeId) { - const ccId = (raw as { sessionId?: string }).sessionId - if (typeof ccId === 'string' && ccId.length > 0) { - capturedClaudeId = ccId - } - } - } - if (capturedClaudeId || capturedCodexId) { - setState(prev => { - const meta = prev.sessions[sessionId] - if (!meta) return prev - if (meta.providerSessionId) return prev - const id = capturedClaudeId ?? capturedCodexId - if (!id) return prev - return { - ...prev, - sessions: { - ...prev.sessions, - [sessionId]: { ...meta, providerSessionId: id }, - }, - } - }) - } - - // ---- Pass B: runtime mutations ---- - setRuntimes(prev => { - const current = prev[sessionId] ?? emptyRuntime() - const seen = (seenUuidsRef.current[sessionId] ??= new Set()) - const appended: Entry[] = [] - let oldestMarker: string | null = current.historyOldestMarker - let pendingCompaction = current.pendingCompaction - let pendingApproval = current.pendingApproval - let queuedMessages = current.queuedMessages - let awaitingAssistant = current.awaitingAssistant - // Set when a Codex user entry mapped from rollout matches the - // optimistic head row already in current.entries. We can only - // resolve it after the loop because the optimistic row could - // also live in `appended` if we're processing multiple bursts. - let dropOptimisticHead = false - - // Reuse the existing map references so downstream consumers - // that hold them live (Feed contexts) keep working without a - // re-subscribe. The runtime object itself gets a new top-level - // reference below so React re-renders. - const toolUseIndex = current.toolUseIndex - const toolResultIndex = current.toolResultIndex - - // Rolling Codex turn id — updated when a `turn_context` - // rollout entry arrives, then stamped onto every mapped - // response_item in the same turn. Feeds reconcileUpstream's - // Codex supersede branch. See codexTurnIdFromRollout for - // the extraction rule and reconcileUpstream for the match. - let codexCurrentTurnId: string | null = null - - for (const { entry: raw } of entries) { - // ---- Codex rollout branch ---- - if (isCodexRolloutEntry(raw)) { - const payload = raw.payload as Record | undefined - const turnContextId = codexTurnIdFromRollout(raw) - if (turnContextId !== null) codexCurrentTurnId = turnContextId - const mappedRaw = mapCodexRolloutToFeedEntries(raw) - const mapped = mappedRaw.map(e => stampCodexTurnId(e, codexCurrentTurnId)) - const marker = mapped.length > 0 ? codexHistoryMarker(raw) : null - if (marker && !oldestMarker) oldestMarker = marker - - // Codex exec_approval_request opens an approval pane; - // exec_command_end with the same call_id closes it. - if (raw.type === 'event_msg' && payload?.type === 'exec_approval_request') { - pendingApproval = { - callId: typeof payload.call_id === 'string' ? payload.call_id : null, - command: Array.isArray(payload.command) - ? payload.command.filter( - (part): part is string => typeof part === 'string', - ) - : [], - workdir: typeof payload.workdir === 'string' ? payload.workdir : null, - reason: pendingApproval?.reason, - options: pendingApproval?.options, - selectedIndex: pendingApproval?.selectedIndex, - } - } else if ( - raw.type === 'event_msg' && - payload?.type === 'exec_command_end' && - typeof payload.call_id === 'string' && - pendingApproval?.callId === payload.call_id - ) { - pendingApproval = null - } - - // Optimistic-user reconciliation. Match the first mapped - // user entry against either the last entry we've already - // accumulated in this burst, or — if nothing is appended - // yet — the tail of current.entries. - const firstMapped = mapped[0] - if (firstMapped?.type === 'user') { - if (appended.length > 0) { - const lastAppended = appended[appended.length - 1] - if ( - isOptimisticCodexUserEntry(lastAppended) && - entryTextContent(lastAppended) === entryTextContent(firstMapped) - ) { - appended.pop() - } - } else { - const lastExisting = current.entries[current.entries.length - 1] - if ( - isOptimisticCodexUserEntry(lastExisting) && - entryTextContent(lastExisting) === entryTextContent(firstMapped) - ) { - dropOptimisticHead = true - } - } - } - - for (const e of mapped) { - const u = (e as { uuid?: string }).uuid - if (u) { - if (seen.has(u)) continue - seen.add(u) - } - appended.push(e) - indexEntryIntoMaps(e, toolUseIndex, toolResultIndex) - } - continue - } - - // ---- Claude queue-operation branch ---- - // queue-operation entries are CC's internal message-queue - // bookkeeping (see claude-code-src/utils/messageQueueManager.ts - // for the emit sites). 'enqueue' / 'dequeue' / 'remove' — - // the latter two are collapsed into "drop head" because we - // don't have identity info to do better. Not pushed into - // `entries` (would render as feed noise). - const entryType = (raw as { type?: string }).type - if (entryType === 'queue-operation') { - const op = raw as { - operation?: 'enqueue' | 'dequeue' | 'remove' - content?: string - timestamp?: string - } - if (op.operation === 'enqueue' && typeof op.content === 'string') { - const ts = op.timestamp ?? String(Date.now()) - const already = queuedMessages.some( - q => q.timestamp === ts && q.content === op.content, - ) - if (!already) { - queuedMessages = [ - ...queuedMessages, - { content: op.content, timestamp: ts }, - ] - } - } else if (op.operation === 'dequeue' || op.operation === 'remove') { - queuedMessages = queuedMessages.slice(1) - } - // Force the streaming flag on whenever the queue has items - // so the streaming card doesn't disappear between turns - // while CC is draining queued work. - if (queuedMessages.length > 0) awaitingAssistant = true - continue - } - - // ---- Claude conversation entry branch ---- - const feedEntry = - extractEmbeddedClaudeProgressEntry(raw as Record) ?? - (raw as Entry) - const marker = claudeHistoryMarker(raw as Record) - if (marker && !oldestMarker) oldestMarker = marker - const uuid = (feedEntry as { uuid?: string }).uuid - if (uuid) { - if (seen.has(uuid)) continue - seen.add(uuid) - } - if ( - !isConversationEntry(feedEntry) && - !isCompactBoundaryEntry(feedEntry) && - !isCompactSummaryEntry(feedEntry) - ) { - continue - } - if (isCompactSummaryEntry(feedEntry)) pendingCompaction = null - appended.push(feedEntry) - indexEntryIntoMaps(feedEntry, toolUseIndex, toolResultIndex) - } - - const baseEntries = dropOptimisticHead - ? current.entries.slice(0, -1) - : current.entries - - // Ghost reconciliation — when authoritative entries land, - // supersede any live ghost whose `(turnId, blockIndex)` they - // replace. Runs per appended entry so ghost→real handoff is - // synchronous with the entry becoming visible; the ghost - // drops out of the merged view in the same render as the - // real entry appears. - // - // `reconcileUpstream` is a no-op when there are no ghosts, - // and returns the same-size Map when no ghost matched, so - // this is cheap in the common case. Non-conversation entries - // (system, compact_boundary) pass through untouched. - let nextGhosts = current.ghosts - for (const entry of appended) { - nextGhosts = reconcileUpstream(entry, nextGhosts) - } - - // Persist supersede records. When an upstream entry matched a - // ghost, `reconcileUpstream` produced a new ghost snapshot - // with `supersededBy` set; appending that to disk is how - // crash-recovered state knows "this ghost is no longer live." - for (const ghost of ghostsToPersist(current.ghosts, nextGhosts)) { - window.api.ghostAppend(sessionId, ghost) - } - - // Bail only when literally nothing changed. Approval, queue, - // and compaction transitions can fire on bursts that don't - // append any feed entries at all. - // Include ghost reference equality in the no-change check: - // reconcileUpstream preserves the same Map reference when no - // ghost matched, so this only fires setRuntimes when ghosts - // actually changed. Matches the treatment of queuedMessages - // and the rest of this guard. - const ghostsChanged = nextGhosts !== current.ghosts - const noChange = - appended.length === 0 && - !dropOptimisticHead && - pendingCompaction === current.pendingCompaction && - pendingApproval === current.pendingApproval && - queuedMessages === current.queuedMessages && - awaitingAssistant === current.awaitingAssistant && - !ghostsChanged - if (noChange) return prev - - const nextRuntime = withDerivedSessionStatus( - appendFeedDebugLog( - { - ...current, - entries: appended.length > 0 || dropOptimisticHead - ? [...baseEntries, ...appended] - : current.entries, - historyOldestMarker: oldestMarker, - bootstrapping: true, - pendingCompaction, - pendingApproval, - queuedMessages, - awaitingAssistant, - toolUseIndex, - toolResultIndex, - ghosts: nextGhosts, - }, - { - layer: 'JSONL', - kind: 'jsonl_entries', - summary: - appended.length > 0 || dropOptimisticHead - ? `entries +${appended.length}${dropOptimisticHead ? ' · reconciled optimistic user' : ''}` - : 'jsonl side-effects only', - data: { - burstSize: entries.length, - appendedCount: appended.length, - droppedOptimisticHead: dropOptimisticHead, - appended: appended.slice(-8).map(summarizeEntryForDebug), - queuedMessages: queuedMessages.length, - pendingApproval: pendingApproval - ? { - callId: pendingApproval.callId, - command: pendingApproval.command, - } - : null, - }, - }, - ), - ) - return { - ...prev, - [sessionId]: nextRuntime, - } - }) - - // Schedule the bootstrap flip. Each burst resets the debounce - // timer — the phase ends after ~150ms of quiet (long enough to - // cover a laggy resume replay, short enough that the user doesn't - // notice a deferred scroll pin). - const existing = bootstrapTimersRef.current.get(sessionId) - if (existing) clearTimeout(existing) - const timer = setTimeout(() => { - bootstrapTimersRef.current.delete(sessionId) - setRuntimes(prev => { - const current = prev[sessionId] - if (!current || !current.bootstrapping) return prev - return { - ...prev, - [sessionId]: appendFeedDebugLog( - { ...current, bootstrapping: false }, - { - layer: 'STATE', - kind: 'bootstrap_complete', - summary: 'bootstrap replay quiet window elapsed', - }, - ), - } - }) - }, 150) - bootstrapTimersRef.current.set(sessionId, timer) - }) - - return () => { - offStarted() - offScreen() - // No singular offEntry() — see the deleted-handler comment - // earlier in this effect. The bulk path is the only one. - offEntries() - offErr() - offProcessState() - offSemantic() - offTrustDialog() - offResumePrompt() - offCompactionState() - offExit() - for (const t of bootstrapTimersRef.current.values()) clearTimeout(t) - bootstrapTimersRef.current.clear() - } - }, [updateRuntime]) - - // ---- Action: spawn a new session (main process call) ---- - // - // Wrapped so callers don't have to touch window.api directly. Updates - // state.sessions synchronously after main responds with an id. - // - // `resumeSessionId` (optional) triggers a resume: main spawns claude - // with `--resume ` and tails the existing session file, so the - // renderer receives the full session history as jsonl-entry events - // immediately after started. Our own sessionId is still fresh — it's - // a workspace-scoped identifier for routing, distinct from CC's - // session UUID. - const spawn = useCallback( - async ( - cwd: string, - opts?: { - resumeSessionId?: string - kind?: SessionKind - dangerousMode?: boolean - /** Forwarded to the spawn IPC. Used by undo-close to attach - * to the same tmux session that backed the closed terminal, - * preserving scrollback and any running process. */ - recoverTmuxName?: string - }, - ): Promise => { - const kind: SessionKind = opts?.kind ?? 'claude' - const dangerousMode = - opts?.dangerousMode ?? (kind !== 'terminal' ? dangerousAgentsRef.current : undefined) - // Agent providers both accept `useProxy`; terminals ignore it. - // Claude uses MITM proxy streaming, Codex uses a local Responses - // proxy via `openai_base_url`. - const useProxy = kind !== 'terminal' ? useProxyStreamingRef.current : undefined - let sessionId: SessionId - let tmuxName: string | undefined - try { - const result = await window.api.spawnSession({ - kind, - cwd, - resumeSessionId: opts?.resumeSessionId, - dangerousMode, - useProxy, - recoverTmuxName: opts?.recoverTmuxName, - }) - sessionId = result.sessionId - tmuxName = result.tmuxName - } catch (err) { - throw new Error(sessionSpawnErrorMessage(kind, err, useProxy === true)) - } - setState(prev => ({ - ...prev, - sessions: { - ...prev.sessions, - // Persist tmuxName when main returns one — that's the - // signal that this terminal got tmux backing and is eligible - // for cross-restart recovery on next launch. - [sessionId]: { - cwd, - kind, - ...(tmuxName ? { tmuxName } : {}), - ...(kind !== 'terminal' && opts?.resumeSessionId - ? { providerSessionId: opts.resumeSessionId } - : {}), - }, - }, - })) - setRuntimes(prev => ({ - ...prev, - [sessionId]: { - ...emptyRuntime(), - hasOlderHistory: kind !== 'terminal' && Boolean(opts?.resumeSessionId), - }, - })) - - // Ghost log bootstrap — fire-and-forget, no await. If a prior - // run of cc-shell persisted ghosts for this sessionId, replay - // them through the atp reducer and merge into the runtime's - // ghost map. The renderer then sees the same merged feed after - // reload as it saw before. A missing file is not an error. - // - // WHY behind a setTimeout 0: spawnSession above set the fresh - // runtime via setRuntimes(prev => ...) — that update is queued - // and will land on the next tick. Reading the ghost log and - // applying it synchronously would run against the PREVIOUS - // runtime snapshot and its setRuntimes would clobber the - // fresh empty runtime. Deferring by one tick lets the empty - // runtime land first, then the bootstrap merge runs on top. - setTimeout(() => { - void window.api.ghostRead(sessionId).then(rawEntries => { - if (!rawEntries || rawEntries.length === 0) return - const bootstrapped = reduceGhostLog(rawEntries as never[]) - if (bootstrapped.size === 0) return - setRuntimes(prev => { - const current = prev[sessionId] - if (!current) return prev - // Merge — disk ghosts only fill slots the runtime hasn't - // already produced in this session. If a ghost for the - // same uuid exists in-memory (rare; would mean a live - // event beat the bootstrap read), prefer the in-memory - // one because it's strictly fresher. - let merged = new Map(current.ghosts) - for (const [uuid, ghost] of bootstrapped) { - if (!merged.has(uuid)) merged.set(uuid, ghost) - } - // Reconcile against whatever JSONL entries already landed - // during the initial bootstrap burst. Without this, ghosts - // for turns that already have committed entries in - // `current.entries` would stay un-superseded forever: - // the live JSONL ingest already ran `reconcileUpstream` - // against the PREVIOUS (empty) ghost map and found no - // matches; now that the real ghosts are landing, nothing - // re-checks the already-ingested entries. This pass fixes - // the "crashed mid-turn, resumed with an orphan ghost - // that actually got committed" case. See Task 7 of the - // 2026-04-20 rendering-fixes plan. - for (const entry of current.entries) { - merged = reconcileUpstream(entry, merged) - } - // Persist any supersedes we just produced so the next - // resume reads the ghosts already in their reconciled - // state. `ghostsToPersist` diffs by updatedAt so it only - // emits ghosts whose state actually changed in this pass. - for (const ghost of ghostsToPersist(current.ghosts, merged)) { - window.api.ghostAppend(sessionId, ghost) - } - return { - ...prev, - [sessionId]: { ...current, ghosts: merged }, - } - }) - }).catch(err => { - // Ghost bootstrap failures are non-fatal — the session - // still works, we just lose crash-recovered provisional - // state. Log and move on. - console.warn('[ghost] bootstrap read failed:', err) - }) - }, 0) - - return sessionId - }, - [], - ) - - // ---- Action: kill a session (main process call) ---- - const killSession = useCallback(async (sessionId: SessionId) => { - await window.api.killSession(sessionId) - setRuntimes(prev => { - const next = { ...prev } - delete next[sessionId] - return next - }) - setState(prev => { - const nextSessions = { ...prev.sessions } - delete nextSessions[sessionId] - return { ...prev, sessions: nextSessions } - }) - delete seenUuidsRef.current[sessionId] - delete latestScreenRef.current[sessionId] - // If a bootstrap debounce was in flight for this session, cancel - // it — the session is gone; firing the deferred bootstrapping→false - // flip later would be a no-op against a missing runtime but it's - // cleaner to release the timer immediately. - const timer = bootstrapTimersRef.current.get(sessionId) - if (timer) { - clearTimeout(timer) - bootstrapTimersRef.current.delete(sessionId) - } - }, []) - - // ---- Action: replace the focused session in-place ---- - // - // Kills the current session in the focused leaf and spawns a new one - // in the same position. Used by the resume flow to swap a session - // without changing the tile tree structure — the pane stays where it - // is, only its backing session changes. - const replaceSession = useCallback( - async ( - cwd: string, - opts?: { resumeSessionId?: string; kind?: SessionKind }, - ) => { - const tab = state.tabs.find(t => t.id === state.activeTabId) - if (!tab) return - const oldId = tab.focusedSessionId - const nextKind = opts?.kind ?? state.sessions[oldId]?.kind ?? 'claude' - const oldDraft = latestRuntimesRef.current[oldId]?.draftInput ?? '' - const newId = await spawn(cwd, opts) - setRuntimes(prev => ({ - ...prev, - [newId]: { - ...(prev[newId] ?? emptyRuntime()), - draftInput: oldDraft, - }, - })) - - await window.api.killSession(oldId) - setRuntimes(prev => { - const next = { ...prev } - delete next[oldId] - return next - }) - delete seenUuidsRef.current[oldId] - delete latestScreenRef.current[oldId] - - // Swap the sessionId in the tree. Walk the tile tree and replace - // every occurrence of oldId with newId (there should be exactly - // one — the focused leaf). - const remapNode = (n: TileNode): TileNode => { - if (n.type === 'leaf') { - return n.sessionId === oldId - ? { type: 'leaf', sessionId: newId } - : n - } - return { ...n, a: remapNode(n.a), b: remapNode(n.b) } - } - - setState(prev => { - const sessions = { ...prev.sessions } - delete sessions[oldId] - // Persist the replacement provider metadata immediately instead of - // waiting for the first transcript line to round-trip back from main. - // That wait window is usually short, but it is still a real race: - // a workspace save or pane action that snapshots SessionMeta in that - // gap would see "new session id, but no providerSessionId yet" and - // could forget how to resume the pane on the next launch. - // - // Keeping the requested resumeSessionId here makes replaceSession the - // single source of truth for "this pane now points at provider X's - // persisted transcript Y", whether the trigger was the resume picker - // or the new switch-provider flow. - sessions[newId] = { - ...(sessions[newId] ?? { cwd, kind: nextKind }), - cwd, - kind: nextKind, - ...(opts?.resumeSessionId ? { providerSessionId: opts.resumeSessionId } : {}), - } - return { - ...prev, - tabs: prev.tabs.map(t => { - if (t.id !== prev.activeTabId) return t - return { - ...t, - root: remapNode(t.root), - focusedSessionId: - t.focusedSessionId === oldId ? newId : t.focusedSessionId, - } - }), - sessions, - } - }) - return newId - }, - [spawn, state.activeTabId, state.sessions, state.tabs], - ) - - // ---- Action: reload all live agent sessions ---- - // - // Recreates every Claude/Codex session with the requested dangerous - // mode, then remaps visible panes and buried records onto the fresh - // session ids. Plain terminal sessions are left untouched. - const reloadAgentSessions = useCallback( - async (dangerousMode = dangerousAgentsRef.current) => { - const current = stateRef.current - const agentEntries = Object.entries(current.sessions).filter(([, meta]) => { - const kind = meta.kind ?? 'claude' - return kind === 'claude' || kind === 'codex' - }) - if (agentEntries.length === 0) return - - const oldRuntimes = latestRuntimesRef.current - const idMap = new Map() - const failedIds = new Set() - const freshSessions: Record = {} - - for (const [oldId, meta] of agentEntries) { - try { - await window.api.killSession(oldId) - } catch { - // Kill failures still fall through to respawn — the old - // process may already be gone. - } - - delete seenUuidsRef.current[oldId] - delete latestScreenRef.current[oldId] - - try { - const kind: SessionKind = meta.kind ?? 'claude' - const { sessionId: newId } = await window.api.spawnSession({ - kind, - cwd: meta.cwd, - resumeSessionId: meta.providerSessionId, - dangerousMode, - useProxy: kind !== 'terminal' ? useProxyStreamingRef.current : undefined, - }) - idMap.set(oldId, newId) - freshSessions[newId] = { ...meta } - } catch { - failedIds.add(oldId) - } - } - - if (idMap.size === 0 && failedIds.size === 0) return - - const remapNode = (node: TileNode): TileNode => { - if (node.type === 'leaf') { - const mapped = idMap.get(node.sessionId) - return mapped ? { type: 'leaf', sessionId: mapped } : node - } - return { ...node, a: remapNode(node.a), b: remapNode(node.b) } - } - - setRuntimes(prev => { - const next = { ...prev } - for (const [oldId] of agentEntries) delete next[oldId] - for (const [oldId, newId] of idMap.entries()) { - const restored = emptyRuntime() - restored.draftInput = oldRuntimes[oldId]?.draftInput ?? '' - restored.hasOlderHistory = Boolean(freshSessions[newId]?.providerSessionId) - next[newId] = restored - } - return next - }) - - setState(prev => { - const nextSessions = { ...prev.sessions } - for (const [oldId] of agentEntries) delete nextSessions[oldId] - for (const [newId, meta] of Object.entries(freshSessions)) { - nextSessions[newId] = meta - } - - const nextTabs = prev.tabs - .map(tab => { - let root = remapNode(tab.root) - for (const failedId of failedIds) { - root = closeLeaf(root, failedId) - if (root === null) break - } - if (root === null) return null - const leaves = collectLeaves(root) - if (leaves.length === 0) return null - const focusedSessionId = idMap.get(tab.focusedSessionId) - ?? (failedIds.has(tab.focusedSessionId) ? leaves[0] : tab.focusedSessionId) - return { - ...tab, - root, - focusedSessionId, - } satisfies Tab - }) - .filter((tab): tab is Tab => tab !== null) - - const activeTabId = nextTabs.some(tab => tab.id === prev.activeTabId) - ? prev.activeTabId - : (nextTabs[0]?.id ?? '') - - const nextBuried = prev.buried - .filter(entry => !failedIds.has(entry.sessionId)) - .map(entry => ({ - ...entry, - id: idMap.get(entry.id) ?? entry.id, - sessionId: idMap.get(entry.sessionId) ?? entry.sessionId, - siblingLeafId: entry.siblingLeafId - ? (idMap.get(entry.siblingLeafId) ?? entry.siblingLeafId) - : undefined, - })) - - return { - ...prev, - tabs: nextTabs, - activeTabId, - sessions: nextSessions, - buried: nextBuried, - } - }) - }, - [], - ) - - // ---- Action: new tab ---- - // - // Spawns a new session in the given cwd, creates a tab with one leaf, - // and makes it active. Pass `resumeSessionId` to resume an existing - // CC session rather than starting a fresh one. - const newTab = useCallback( - async (cwd: string, resumeSessionId?: string, kind?: SessionKind) => { - let sessionId: SessionId - try { - sessionId = await spawn(cwd, { resumeSessionId, kind }) - } catch (err) { - showToast( - err instanceof Error && err.message.length > 0 - ? err.message - : 'Failed to create session', - ) - throw err - } - const tabId = crypto.randomUUID() - const title = titleFromCwd(cwd) - setState(prev => { - const newTab: Tab = { - id: tabId, - title, - root: { type: 'leaf', sessionId }, - focusedSessionId: sessionId, - } - return { - ...prev, - tabs: [...prev.tabs, newTab], - activeTabId: tabId, - } - }) - return { tabId, sessionId } - }, - [showToast, spawn], - ) - - // ---- Action: close tab ---- - const closeTab = useCallback( - async (tabId: TabId) => { - const tab = state.tabs.find(t => t.id === tabId) - if (!tab) return - - // Capture undo info before killing anything. - const tabIdx = state.tabs.findIndex(t => t.id === tabId) - const ids = collectLeaves(tab.root) - const allMetas: Record = {} - for (const id of ids) { - if (state.sessions[id]) allMetas[id] = state.sessions[id] - } - undoStackRef.current.push({ - type: 'tab', - closedAt: Date.now(), - tab: { ...tab }, - tabIndex: tabIdx, - sessionMetas: allMetas, - }) - // Surface a brief undo hint. The label uses the tab title so - // the user can confirm at a glance which thing they killed. - showToast(`Closed “${tab.title}” — ⌘⇧T (Undo Close)`) - - // Kill every session in this tab. - await Promise.all(ids.map(id => window.api.killSession(id))) - setRuntimes(prev => { - const next = { ...prev } - for (const id of ids) delete next[id] - return next - }) - for (const id of ids) { - delete seenUuidsRef.current[id] - delete latestScreenRef.current[id] - } - setState(prev => { - const tabs = prev.tabs.filter(t => t.id !== tabId) - const sessions = { ...prev.sessions } - for (const id of ids) delete sessions[id] - const activeTabId = - prev.activeTabId === tabId - ? (tabs[0]?.id ?? '') - : prev.activeTabId - return { ...prev, tabs, activeTabId, sessions } - }) - setTileTabs(prev => { - if (!prev) return prev - const sanitized = sanitizeTileTabsState({ - ...prev, - tabIds: prev.tabIds.filter(id => id !== tabId), - focusedTabId: prev.focusedTabId === tabId - ? (prev.tabIds.find(id => id !== tabId) ?? prev.focusedTabId) - : prev.focusedTabId, - }) - return sanitized - }) - setSpotlight(prev => (prev?.tabId === tabId ? null : prev)) - setReaderMode(prev => (prev?.tabId === tabId ? null : prev)) - }, - [setReaderMode, setTileTabs, state.tabs, state.sessions], - ) - - // ---- Action: split the focused pane ---- - // - // Spawns a new session in the parent pane's cwd, inserts a new leaf - // under a fresh split node, makes the new pane focused. - const splitFocused = useCallback( - async ( - direction: SplitDirection, - kind: SessionKind = 'claude', - /** Resume this provider session id in the new pane instead of - * starting fresh. Used by the duplicate-agent flow so the - * clone opens as a sibling pane of the source, not as a new - * tab (which would hide the source behind a tab switch). */ - resumeSessionId?: string, - ) => { - const tab = state.tabs.find(t => t.id === state.activeTabId) - if (!tab) return - const parentSessionId = tab.focusedSessionId - const parentCwd = state.sessions[parentSessionId]?.cwd - if (!parentCwd) return - - let newSessionId: SessionId - try { - newSessionId = await spawn(parentCwd, { kind, resumeSessionId }) - } catch (err) { - showToast( - err instanceof Error && err.message.length > 0 - ? err.message - : 'Failed to split pane', - ) - return - } - - setState(prev => ({ - ...prev, - tabs: prev.tabs.map(t => { - if (t.id !== prev.activeTabId) return t - return { - ...t, - root: splitLeaf(t.root, parentSessionId, direction, newSessionId), - focusedSessionId: newSessionId, - } - }), - })) - }, - [showToast, spawn, state.activeTabId, state.sessions, state.tabs], - ) - - const startNewAgentPlacement = useCallback(() => { - const tab = state.tabs.find(t => t.id === state.activeTabId) - if (!tab) return - openNewAgentPlacement() - }, [openNewAgentPlacement, state.activeTabId, state.tabs]) - - const commitNewAgentPlacement = useCallback( - async (kind: SessionKind, target: PlacementTarget) => { - const tab = state.tabs.find(t => t.id === state.activeTabId) - if (!tab) return - const anchorSessionId = tab.focusedSessionId - const cwd = state.sessions[anchorSessionId]?.cwd - if (!cwd) return - - let newSessionId: SessionId - try { - newSessionId = await spawn(cwd, { kind }) - } catch (err) { - showToast( - err instanceof Error && err.message.length > 0 - ? err.message - : 'Failed to create pane', - ) - return - } - setState(prev => ({ - ...prev, - tabs: prev.tabs.map(currentTab => { - if (currentTab.id !== prev.activeTabId) return currentTab - return { - ...currentTab, - root: - target.kind === 'wrap-root' - ? wrapRootWithLeaf( - currentTab.root, - target.direction, - target.side, - newSessionId, - ) - : insertBesideLeaf( - currentTab.root, - target.targetSessionId, - target.direction, - RATIO_DEFAULT, - target.side, - newSessionId, - ), - focusedSessionId: newSessionId, - } - }), - })) - closeNewAgentPlacement() - }, - [closeNewAgentPlacement, setState, showToast, spawn, state.activeTabId, state.sessions, state.tabs], - ) - - // ---- Action: close the focused pane ---- - // - // Removes the leaf from the tree and kills its session. If the tree - // collapses to nothing, closes the whole tab. If that was the last - // tab, leaves the workspace in an empty state — the UI shows a - // welcome screen prompting for a new tab. - // - // Before destroying anything, we capture undo info and push it onto - // the undo-close stack so the user can restore the pane (or tab) - // with a single command within the next 2 minutes. - const closeFocused = useCallback(async () => { - const tab = state.tabs.find(t => t.id === state.activeTabId) - if (!tab) return - const targetId = tab.focusedSessionId - const sessionMeta = state.sessions[targetId] - - // Capture undo info BEFORE mutating the tree. Two cases: - // 1. Pane inside a split → record the parent split's geometry - // and the surviving sibling's anchor leaf so we can re-split. - // 2. Last pane in a tab → record the whole tab so we can - // re-insert it at the same index. - const parentInfo = findParentSplitInfo(tab.root, targetId) - if (parentInfo && sessionMeta) { - undoStackRef.current.push({ - type: 'pane', - closedAt: Date.now(), - tabId: tab.id, - sessionMeta, - direction: parentInfo.direction, - ratio: parentInfo.ratio, - side: parentInfo.side, - siblingLeafId: parentInfo.siblingLeafId, - }) - // Pane-level close — show the kind+cwd basename so the user - // can recognize which pane they killed when several look alike. - const kindLabel = sessionMeta.kind ?? 'claude' - const cwdBase = sessionMeta.cwd.split('/').filter(Boolean).pop() ?? sessionMeta.cwd - showToast(`Closed ${kindLabel} pane (${cwdBase}) — ⌘⇧T (Undo Close)`) - } else if (!parentInfo && sessionMeta) { - // This pane IS the root — closing it kills the tab. Capture - // the tab-level undo entry. - const tabIdx = state.tabs.findIndex(t => t.id === tab.id) - const allMetas: Record = {} - for (const leafId of collectLeaves(tab.root)) { - if (state.sessions[leafId]) allMetas[leafId] = state.sessions[leafId] - } - undoStackRef.current.push({ - type: 'tab', - closedAt: Date.now(), - tab: { ...tab }, - tabIndex: tabIdx, - sessionMetas: allMetas, - }) - // Tab-level close — same shape as closeTab's toast for - // consistency, since the user closed an entire tab here too. - showToast(`Closed “${tab.title}” — ⌘⇧T (Undo Close)`) - } - - await window.api.killSession(targetId) - - setRuntimes(prev => { - const next = { ...prev } - delete next[targetId] - return next - }) - delete seenUuidsRef.current[targetId] - delete latestScreenRef.current[targetId] - - setState(prev => { - const tabs = [...prev.tabs] - const tabIdx = tabs.findIndex(t => t.id === prev.activeTabId) - if (tabIdx === -1) return prev - const currentTab = tabs[tabIdx] - const nextRoot = closeLeaf(currentTab.root, targetId) - - if (nextRoot === null) { - // Tab is now empty — close it and activate another tab. - const remaining = tabs.filter((_, i) => i !== tabIdx) - const sessions = { ...prev.sessions } - delete sessions[targetId] - return { - ...prev, - tabs: remaining, - activeTabId: remaining[Math.max(0, tabIdx - 1)]?.id ?? '', - sessions, - } - } - - const nextFocused = - findBestRemainingFocus(currentTab.root, nextRoot, targetId) ?? - collectLeaves(nextRoot)[0] - tabs[tabIdx] = { - ...currentTab, - root: nextRoot, - focusedSessionId: nextFocused, - } - const sessions = { ...prev.sessions } - delete sessions[targetId] - return { ...prev, tabs, sessions } - }) - }, [state.activeTabId, state.tabs, state.sessions]) - - // ---- Action: close an ARBITRARY session by id ---- - // - // Mirrors closeFocused but operates on a caller-specified session - // instead of the active tab's focused pane. Exists so UI surfaces - // that list multiple panes at once (e.g. the Agent Activity modal) - // can close stale sessions without first having to focus-then- - // close, which would jank the visible layout for every close and - // race with React's batched setState (closeFocused reads from the - // useCallback-captured `state`, so a focus update and a close in - // the same tick sees stale state). - // - // Uses stateRef.current for the same reason buryFocused does: the - // caller's action isn't bound to whatever happens to be active. - const closeSession = useCallback(async (targetId: SessionId) => { - const snapshot = stateRef.current - const owningTab = snapshot.tabs.find(t => collectLeaves(t.root).includes(targetId)) - if (!owningTab) return - const sessionMeta = snapshot.sessions[targetId] - - // Same two-case undo capture as closeFocused: pane-in-split vs. - // last-pane-in-tab. Keeps ⌘⇧T working for modal-driven closes. - const parentInfo = findParentSplitInfo(owningTab.root, targetId) - if (parentInfo && sessionMeta) { - undoStackRef.current.push({ - type: 'pane', - closedAt: Date.now(), - tabId: owningTab.id, - sessionMeta, - direction: parentInfo.direction, - ratio: parentInfo.ratio, - side: parentInfo.side, - siblingLeafId: parentInfo.siblingLeafId, - }) - const kindLabel = sessionMeta.kind ?? 'claude' - const cwdBase = sessionMeta.cwd.split('/').filter(Boolean).pop() ?? sessionMeta.cwd - showToast(`Closed ${kindLabel} pane (${cwdBase}) — ⌘⇧T (Undo Close)`) - } else if (!parentInfo && sessionMeta) { - const tabIdx = snapshot.tabs.findIndex(t => t.id === owningTab.id) - const allMetas: Record = {} - for (const leafId of collectLeaves(owningTab.root)) { - if (snapshot.sessions[leafId]) allMetas[leafId] = snapshot.sessions[leafId] - } - undoStackRef.current.push({ - type: 'tab', - closedAt: Date.now(), - tab: { ...owningTab }, - tabIndex: tabIdx, - sessionMetas: allMetas, - }) - showToast(`Closed “${owningTab.title}” — ⌘⇧T (Undo Close)`) - } - - await window.api.killSession(targetId) - - setRuntimes(prev => { - const next = { ...prev } - delete next[targetId] - return next - }) - delete seenUuidsRef.current[targetId] - delete latestScreenRef.current[targetId] - - setState(prev => { - const tabs = [...prev.tabs] - const tabIdx = tabs.findIndex(t => t.id === owningTab.id) - // Tab may have been closed between modal-open and confirm. - // Treat that as a no-op — the row will disappear on next - // render anyway via the "visible sessions" selector. - if (tabIdx === -1) return prev - const currentTab = tabs[tabIdx] - const nextRoot = closeLeaf(currentTab.root, targetId) - - if (nextRoot === null) { - const remaining = tabs.filter((_, i) => i !== tabIdx) - const sessions = { ...prev.sessions } - delete sessions[targetId] - // Only retarget activeTabId if we just removed the active - // tab. Closing a pane in a BACKGROUND tab from the modal - // must not yank the user out of the tab they see when the - // modal closes. - const nextActiveTabId = prev.activeTabId === owningTab.id - ? (remaining[Math.max(0, tabIdx - 1)]?.id ?? '') - : prev.activeTabId - return { - ...prev, - tabs: remaining, - activeTabId: nextActiveTabId, - sessions, - } - } - - const nextFocused = - findBestRemainingFocus(currentTab.root, nextRoot, targetId) ?? - collectLeaves(nextRoot)[0] - tabs[tabIdx] = { - ...currentTab, - root: nextRoot, - focusedSessionId: nextFocused, - } - const sessions = { ...prev.sessions } - delete sessions[targetId] - return { ...prev, tabs, sessions } - }) - }, [showToast]) - - // ---- Action: bury focused pane ---- - // - // Removes the focused pane from the visible layout without killing - // the underlying session. The session keeps running in the - // background and remains eligible for revive. - const requestBuryFocused = useCallback(() => { - const tab = stateRef.current.tabs.find(t => t.id === stateRef.current.activeTabId) - if (!tab) return - openBuryPrompt(tab.focusedSessionId) - }, [openBuryPrompt]) - - const buryFocused = useCallback((note?: string, targetSessionId?: SessionId) => { - // The bury prompt is modal on a specific session, not a specific - // tab. It can outlive a tab switch: user opens the prompt on - // pane X in tab A, switches to tab B, then hits Enter. Earlier - // we resolved `tab` via `state.activeTabId`, which meant that - // confirm-after-switch mutated tab B's tree even though targetId - // still pointed at pane X in tab A. Resolve the owning tab from - // the target session instead. - const snapshot = stateRef.current - const activeTab = snapshot.tabs.find(t => t.id === snapshot.activeTabId) - const targetId = targetSessionId ?? activeTab?.focusedSessionId - if (!targetId) return - - const owningTab = snapshot.tabs.find(t => collectLeaves(t.root).includes(targetId)) - if (!owningTab) return - - const sessionMeta = snapshot.sessions[targetId] - if (!sessionMeta) return - - const parentInfo = findParentSplitInfo(owningTab.root, targetId) - const tabIndex = snapshot.tabs.findIndex(t => t.id === owningTab.id) - const buriedRecord: BuriedPaneRecord = { - id: targetId, - sessionId: targetId, - sessionMeta, - buriedAt: Date.now(), - sourceTabId: owningTab.id, - sourceTabTitle: owningTab.title, - sourceTabIndex: tabIndex, - direction: parentInfo?.direction, - ratio: parentInfo?.ratio, - side: parentInfo?.side, - siblingLeafId: parentInfo?.siblingLeafId, - note: note?.trim() ? note.trim() : undefined, - } - - const kindLabel = sessionMeta.kind ?? 'claude' - const cwdBase = sessionMeta.cwd.split('/').filter(Boolean).pop() ?? sessionMeta.cwd - showToast(`Buried ${kindLabel} pane (${cwdBase})`) - - setState(prev => { - const tabs = [...prev.tabs] - const tabIdx = tabs.findIndex(t => t.id === owningTab.id) - // Tab may have been closed between prompt-open and confirm. - // Treat that as a no-op rather than mutating an unrelated tab. - if (tabIdx === -1) return prev - - const currentTab = tabs[tabIdx] - const nextRoot = closeLeaf(currentTab.root, targetId) - if (nextRoot === null) { - const remaining = tabs.filter((_, i) => i !== tabIdx) - // Only retarget activeTabId if we just removed the active - // tab. Burying a pane in a background tab must not yank the - // user out of the tab they're currently looking at. - const nextActiveTabId = prev.activeTabId === owningTab.id - ? (remaining[Math.max(0, tabIdx - 1)]?.id ?? '') - : prev.activeTabId - return { - ...prev, - tabs: remaining, - activeTabId: nextActiveTabId, - buried: [ - ...prev.buried.filter(entry => entry.sessionId !== targetId), - buriedRecord, - ], - } - } - - const nextFocused = - findBestRemainingFocus(currentTab.root, nextRoot, targetId) ?? - collectLeaves(nextRoot)[0] - tabs[tabIdx] = { - ...currentTab, - root: nextRoot, - focusedSessionId: nextFocused, - } - return { - ...prev, - tabs, - buried: [ - ...prev.buried.filter(entry => entry.sessionId !== targetId), - buriedRecord, - ], - } - }) - setSpotlight(prev => (prev?.tabId === owningTab.id ? null : prev)) - closeBuryPrompt() - }, [closeBuryPrompt, showToast]) - - // ---- Action: revive buried pane ---- - // - // Restores a buried session into the most plausible visible location. - // First choice is the original sibling anchor, then the original tab, - // then the best current tab by cwd/kind/title affinity, and finally - // a fresh single-pane tab if no good target exists. - const reviveBuried = useCallback((buriedId: string) => { - const current = stateRef.current - const entry = current.buried.find(item => item.id === buriedId) - if (!entry) return - - const chooseFallbackTab = (): Tab | null => { - const scored = current.tabs - .map(tab => { - let score = 0 - if (tab.id === entry.sourceTabId) score += 100 - if (tab.title === entry.sourceTabTitle) score += 20 - const leafIds = collectLeaves(tab.root) - for (const leafId of leafIds) { - const meta = current.sessions[leafId] - if (!meta) continue - if (meta.cwd === entry.sessionMeta.cwd) score += 15 - if ((meta.kind ?? 'claude') === (entry.sessionMeta.kind ?? 'claude')) score += 5 - } - return { tab, score } - }) - .filter(candidate => candidate.score > 0) - .sort((a, b) => b.score - a.score) - return scored[0]?.tab ?? current.tabs[0] ?? null - } - - const anchorTab = entry.siblingLeafId - ? current.tabs.find(tab => collectLeaves(tab.root).includes(entry.siblingLeafId!)) - : null - const targetTab = anchorTab ?? chooseFallbackTab() - - setState(prev => { - const nextBuried = prev.buried.filter(item => item.id !== buriedId) - - if (!targetTab) { - const tabId = crypto.randomUUID() - const title = titleFromCwd(entry.sessionMeta.cwd) - const revivedTab: Tab = { - id: tabId, - title, - root: { type: 'leaf', sessionId: entry.sessionId }, - focusedSessionId: entry.sessionId, - } - return { - ...prev, - tabs: [...prev.tabs, revivedTab], - activeTabId: tabId, - buried: nextBuried, - } - } - - const target = prev.tabs.find(tab => tab.id === targetTab.id) - if (!target) { - const tabId = crypto.randomUUID() - const title = titleFromCwd(entry.sessionMeta.cwd) - const revivedTab: Tab = { - id: tabId, - title, - root: { type: 'leaf', sessionId: entry.sessionId }, - focusedSessionId: entry.sessionId, - } - return { - ...prev, - tabs: [...prev.tabs, revivedTab], - activeTabId: tabId, - buried: nextBuried, - } - } - - const leafIds = collectLeaves(target.root) - const cwdLeaf = - leafIds.find(leafId => prev.sessions[leafId]?.cwd === entry.sessionMeta.cwd) ?? null - const anchorLeafId = - (entry.siblingLeafId && leafIds.includes(entry.siblingLeafId)) - ? entry.siblingLeafId - : (cwdLeaf ?? target.focusedSessionId ?? leafIds[0] ?? null) - - if (!anchorLeafId) { - const tabId = crypto.randomUUID() - const title = titleFromCwd(entry.sessionMeta.cwd) - const revivedTab: Tab = { - id: tabId, - title, - root: { type: 'leaf', sessionId: entry.sessionId }, - focusedSessionId: entry.sessionId, - } - return { - ...prev, - tabs: [...prev.tabs, revivedTab], - activeTabId: tabId, - buried: nextBuried, - } - } - - const revivedRoot = insertBesideLeaf( - target.root, - anchorLeafId, - entry.direction ?? 'vertical', - entry.ratio ?? RATIO_DEFAULT, - entry.side ?? 'b', - entry.sessionId, - ) - - return { - ...prev, - tabs: prev.tabs.map(tab => - tab.id === target.id - ? { - ...tab, - root: revivedRoot, - focusedSessionId: entry.sessionId, - } - : tab, - ), - activeTabId: target.id, - buried: nextBuried, - } - }) - }, []) - - // ---- Action: focus a specific session in the active tab ---- - const focusSession = useCallback((sessionId: SessionId) => { - setState(prev => ({ - ...prev, - tabs: prev.tabs.map(t => - t.id === prev.activeTabId ? { ...t, focusedSessionId: sessionId } : t, - ), - })) - setSpotlight(prev => ( - prev && prev.tabId === stateRef.current.activeTabId - ? { ...prev, focusedSessionId: sessionId } - : prev - )) - }, []) - - const focusSessionInTab = useCallback((tabId: TabId, sessionId: SessionId) => { - setState(prev => ({ - ...prev, - activeTabId: tabId, - tabs: prev.tabs.map(t => - t.id === tabId ? { ...t, focusedSessionId: sessionId } : t, - ), - })) - setSpotlight(prev => ( - prev && prev.tabId === tabId - ? { ...prev, focusedSessionId: sessionId } - : prev - )) - setTileTabs(prev => ( - prev && prev.tabIds.includes(tabId) - ? { ...prev, focusedTabId: tabId } - : prev - )) - }, []) - - // ---- Action: navigate focus geometrically (alt-hjkl) ---- - const navigate = useCallback( - (direction: 'left' | 'right' | 'up' | 'down') => { - const tab = state.tabs.find(t => t.id === state.activeTabId) - if (!tab) return - const next = findDirectionalNeighbor(tab.root, tab.focusedSessionId, direction) - if (next) focusSession(next) - }, - [focusSession, state.activeTabId, state.tabs], - ) - - // ---- Action: activate a tab by id or index ---- - const activateTab = useCallback((tabId: TabId) => { - setState(prev => ({ ...prev, activeTabId: tabId })) - setSpotlight(null) - // Preserve tile-tabs mode when the activated tab is part of the - // tiled set — just shift the focused tile. If it's NOT part of - // the set, leave tile-tabs ALONE rather than nuking the mode. - // The previous behavior (setting null) caused the tile layout to - // silently collapse whenever the user clicked any other tab in - // the bar, which read as a phantom "auto-deselect." - setTileTabs(prev => { - if (!prev) return prev - if (prev.tabIds.includes(tabId)) { - return { ...prev, focusedTabId: tabId } - } - return prev - }) - }, []) - - const activateTabByIndex = useCallback((index: number) => { - setState(prev => { - const t = prev.tabs[index] - return t ? { ...prev, activeTabId: t.id } : prev - }) - setSpotlight(null) - // Same preservation rule as activateTab — see comment there. - setTileTabs(prev => { - const target = stateRef.current.tabs[index] - if (!prev) return prev - if (!target) return prev - if (prev.tabIds.includes(target.id)) { - return { ...prev, focusedTabId: target.id } - } - return prev - }) - }, []) - - const nextTab = useCallback(() => { - const tiled = tileTabs - if (tiled && tiled.tabIds.length > 1) { - const idx = tiled.tabIds.indexOf(tiled.focusedTabId) - const nextId = tiled.tabIds[(idx + 1 + tiled.tabIds.length) % tiled.tabIds.length] - setState(prev => ({ ...prev, activeTabId: nextId })) - setTileTabs(prev => (prev ? { ...prev, focusedTabId: nextId } : prev)) - return - } - setState(prev => { - const idx = prev.tabs.findIndex(t => t.id === prev.activeTabId) - if (idx === -1) return prev - const next = prev.tabs[(idx + 1) % prev.tabs.length] - return { ...prev, activeTabId: next.id } - }) - setSpotlight(null) - }, [tileTabs]) - - const prevTab = useCallback(() => { - const tiled = tileTabs - if (tiled && tiled.tabIds.length > 1) { - const idx = tiled.tabIds.indexOf(tiled.focusedTabId) - const nextId = - tiled.tabIds[(idx - 1 + tiled.tabIds.length) % tiled.tabIds.length] - setState(prev => ({ ...prev, activeTabId: nextId })) - setTileTabs(prev => (prev ? { ...prev, focusedTabId: nextId } : prev)) - return - } - setState(prev => { - const idx = prev.tabs.findIndex(t => t.id === prev.activeTabId) - if (idx === -1) return prev - const next = prev.tabs[(idx - 1 + prev.tabs.length) % prev.tabs.length] - return { ...prev, activeTabId: next.id } - }) - setSpotlight(null) - }, [tileTabs]) - - const toggleSpotlight = useCallback(() => { - const current = stateRef.current - const activeTab = current.tabs.find(t => t.id === current.activeTabId) - if (!activeTab) return - setSpotlight(prev => { - if (prev?.tabId === activeTab.id) return null - return { - tabId: activeTab.id, - focusedSessionId: activeTab.focusedSessionId, - } - }) - }, []) - - const setSpotlightSession = useCallback((sessionId: SessionId) => { - setSpotlight(prev => (prev ? { ...prev, focusedSessionId: sessionId } : prev)) - setState(prev => ({ - ...prev, - tabs: prev.tabs.map(t => - t.id === prev.activeTabId ? { ...t, focusedSessionId: sessionId } : t, - ), - })) - }, []) - - // ReaderMode toggle. Mirrors toggleSpotlight: enters with the - // active tab's currently-focused session, exits if already on for - // the active tab. Closes Spotlight on entry; tile-tabs are preserved - // in state and suppressed by App.tsx render precedence. - const toggleReaderMode = useCallback(() => { - const current = stateRef.current - const activeTab = current.tabs.find(t => t.id === current.activeTabId) - if (!activeTab) return - setSpotlight(null) - setReaderMode(prev => { - if (prev?.tabId === activeTab.id) return null - return { - tabId: activeTab.id, - focusedSessionId: activeTab.focusedSessionId, - } - }) - }, []) - - // Switch which session is being read inside ReaderMode. Mirrors - // setSpotlightSession exactly — also updates the tab's - // focusedSessionId so leaving Reader returns to that pane. - const setReaderModeSession = useCallback((sessionId: SessionId) => { - setReaderMode(prev => (prev ? { ...prev, focusedSessionId: sessionId } : prev)) - setState(prev => ({ - ...prev, - tabs: prev.tabs.map(t => - t.id === prev.activeTabId ? { ...t, focusedSessionId: sessionId } : t, - ), - })) - }, []) - - const openTileTabs = useCallback( - (tabIds: TabId[], direction: SplitDirection = 'vertical') => { - const current = stateRef.current - const valid = tabIds.filter(id => current.tabs.some(t => t.id === id)) - if (valid.length < 2) return - const focusedTabId = valid.includes(current.activeTabId) - ? current.activeTabId - : valid[0] - setSpotlight(null) - setTileTabs({ - tabIds: valid, - focusedTabId, - direction, - ratios: equalRatios(valid.length), - }) - setState(prev => ({ ...prev, activeTabId: focusedTabId })) - }, - [], - ) - - const closeTileTabs = useCallback(() => { - setTileTabs(null) - }, []) - - const focusTiledTab = useCallback((tabId: TabId) => { - setTileTabs(prev => ( - prev && prev.tabIds.includes(tabId) - ? { ...prev, focusedTabId: tabId } - : prev - )) - setState(prev => ({ ...prev, activeTabId: tabId })) - }, []) - - const focusTiledTabByIndex = useCallback((index: number) => { - setTileTabs(prev => { - if (!prev) return prev - const tabId = prev.tabIds[index] - if (!tabId) return prev - setState(statePrev => ({ ...statePrev, activeTabId: tabId })) - return { ...prev, focusedTabId: tabId } - }) - }, []) - - const resizeFocusedTiledTab = useCallback((delta: number) => { - setTileTabs(prev => { - if (!prev || prev.tabIds.length < 2) return prev - const idx = prev.tabIds.indexOf(prev.focusedTabId) - if (idx === -1) return prev - - const leftIndex = idx === prev.tabIds.length - 1 ? idx - 1 : idx - const rightIndex = idx === prev.tabIds.length - 1 ? idx : idx + 1 - if (leftIndex < 0 || rightIndex >= prev.ratios.length) return prev - - const nextRatios = [...prev.ratios] - const signedDelta = idx === prev.tabIds.length - 1 ? -delta : delta - const nextLeft = nextRatios[leftIndex] + signedDelta - const nextRight = nextRatios[rightIndex] - signedDelta - const minRatio = 0.12 - if (nextLeft < minRatio || nextRight < minRatio) return prev - - nextRatios[leftIndex] = nextLeft - nextRatios[rightIndex] = nextRight - return { - ...prev, - ratios: normalizeRatios(nextRatios), - } - }) - }, []) - - const resizeTiledTabByIndex = useCallback((index: number, delta: number) => { - setTileTabs(prev => { - if (!prev || prev.tabIds.length < 2) return prev - if (index < 0 || index >= prev.tabIds.length) return prev - - const leftIndex = index === prev.tabIds.length - 1 ? index - 1 : index - const rightIndex = index === prev.tabIds.length - 1 ? index : index + 1 - if (leftIndex < 0 || rightIndex >= prev.ratios.length) return prev - - const nextRatios = [...prev.ratios] - const signedDelta = index === prev.tabIds.length - 1 ? -delta : delta - const nextLeft = nextRatios[leftIndex] + signedDelta - const nextRight = nextRatios[rightIndex] - signedDelta - const minRatio = 0.12 - if (nextLeft < minRatio || nextRight < minRatio) return prev - - nextRatios[leftIndex] = nextLeft - nextRatios[rightIndex] = nextRight - return { - ...prev, - ratios: normalizeRatios(nextRatios), - } - }) - }, []) - - // ---- Action: adjust the ratio of the split containing the focused pane ---- - const resizeFocused = useCallback((delta: number) => { - setState(prev => ({ - ...prev, - tabs: prev.tabs.map(t => { - if (t.id !== prev.activeTabId) return t - return { - ...t, - root: adjustNearestSplitRatio(t.root, t.focusedSessionId, delta), - } - }), - })) - }, []) - - // ---- Action: directional resize (⌥⇧← → ↑ ↓) ---- - // - // Grows the focused pane toward the given direction by `delta`. See - // resizeInDirection in treeOps.ts for the full tmux-style semantics. - const resizeFocusedDirectional = useCallback( - (direction: 'left' | 'right' | 'up' | 'down', delta: number) => { - setState(prev => ({ - ...prev, - tabs: prev.tabs.map(t => { - if (t.id !== prev.activeTabId) return t - return { - ...t, - root: resizeInDirection(t.root, t.focusedSessionId, direction, delta), - } - }), - })) - }, - [], - ) - - // ---- Action: set the ratio of a specific split (for drag resize) ---- - // Walks the tree and finds the split whose `a` side contains fromId - // and whose `b` side contains toId, then sets its ratio directly. - const setSplitRatio = useCallback( - (fromSessionId: SessionId, toSessionId: SessionId, ratio: number) => { - setState(prev => ({ - ...prev, - tabs: prev.tabs.map(t => { - if (t.id !== prev.activeTabId) return t - return { ...t, root: setRatioBetween(t.root, fromSessionId, toSessionId, ratio) } - }), - })) - }, - [], - ) - - const setSplitRatioInTab = useCallback( - (tabId: TabId, fromSessionId: SessionId, toSessionId: SessionId, ratio: number) => { - setState(prev => ({ - ...prev, - activeTabId: tabId, - tabs: prev.tabs.map(t => { - if (t.id !== tabId) return t - return { ...t, root: setRatioBetween(t.root, fromSessionId, toSessionId, ratio) } - }), - })) - setTileTabs(prev => ( - prev && prev.tabIds.includes(tabId) - ? { ...prev, focusedTabId: tabId } - : prev - )) - }, - [], - ) - - // ---- Update streaming baseline for a session (called from TileLeaf on submit) ---- - const setStreamingBaseline = useCallback( - (sessionId: SessionId, baseline: string | null) => { - // Pair the baseline write with a synthetic `submitting` phase - // and a `submittedAt` timestamp. This covers the gap between - // the user pressing Enter and the adapter's first `requesting` - // event landing (can be 100-500ms on a cold proxy). Without it - // the in-feed WorkIndicator would render nothing during that - // window, making the app look unresponsive to the submit. - // The adapter's first stream_phase event will transition - // phase → 'requesting' and reuse `submittedAt` as turnStartedAt. - const now = Date.now() - setRuntimes(prev => { - const current = prev[sessionId] ?? emptyRuntime() - const next = withDerivedSessionStatus( - appendFeedDebugLog( - { - ...current, - streamingBaseline: baseline, - awaitingAssistant: true, - streamPhase: 'submitting', - submittedAt: now, - phaseChangedAt: now, - turnStartedAt: now, - }, - { - layer: 'STATE', - kind: 'submit', - summary: baseline ? 'submit started with baseline' : 'submit started', - data: { hasBaseline: baseline !== null, baselineLength: baseline?.length ?? 0 }, - }, - ), - ) - return { - ...prev, - [sessionId]: next, - } - }) - }, - [], - ) - - // Codex live rendering is TUI-first, with rollout JSON as a later source - // of truth. That means a broken/missing rollout attach should NOT leave the - // feed blank after submit. We add a local user row immediately and reconcile - // it away when the real rollout user message shows up. - const addOptimisticCodexUserEntry = useCallback((sessionId: SessionId, text: string) => { - const trimmed = text.trim() - if (!trimmed) return - setRuntimes(prev => { - const current = prev[sessionId] ?? emptyRuntime() - const last = current.entries[current.entries.length - 1] - if (isOptimisticCodexUserEntry(last) && entryTextContent(last) === trimmed) { - return prev - } - const optimistic: Entry = { - type: 'user', - uuid: `optimistic-codex-user:${Date.now()}`, - parentUuid: null, - timestamp: new Date().toISOString(), - message: { - role: 'user', - content: [{ type: 'text', text: trimmed }], - }, - } - return { - ...prev, - [sessionId]: appendFeedDebugLog( - { - ...current, - entries: [...current.entries, optimistic], - }, - { - layer: 'STATE', - kind: 'optimistic_user_add', - summary: `optimistic user row added · ${trimmed.slice(0, 80)}`, - data: { text: trimmed }, - }, - ), - } - }) - }, []) - - const removeOptimisticCodexUserEntry = useCallback((sessionId: SessionId, text: string) => { - const trimmed = text.trim() - if (!trimmed) return - setRuntimes(prev => { - const current = prev[sessionId] - if (!current || current.entries.length === 0) return prev - const last = current.entries[current.entries.length - 1] - if (!isOptimisticCodexUserEntry(last) || entryTextContent(last) !== trimmed) { - return prev - } - return { - ...prev, - [sessionId]: appendFeedDebugLog( - { - ...current, - entries: current.entries.slice(0, -1), - }, - { - layer: 'STATE', - kind: 'optimistic_user_remove', - summary: `optimistic user row removed · ${trimmed.slice(0, 80)}`, - data: { text: trimmed }, - }, - ), - } - }) - }, []) - - // ---- Update the per-session draft input (composer text) ---- - // - // Called from TileLeaf on every onChange/onKeyDown that mutates the - // composer text. Lives in runtime so it survives TileLeaf unmount - // when the user switches tabs. See SessionRuntime.draftInput for - // the reasoning. - // Draft version counter — bumped on every draft change so the save - // effect picks it up without watching the full runtimes object. - const [draftVersion, setDraftVersion] = useState(0) - - const setDraftInput = useCallback( - (sessionId: SessionId, text: string) => { - updateRuntime(sessionId, { draftInput: text }) - setDraftVersion(v => v + 1) - }, - [updateRuntime], - ) - - const setDraftImages = useCallback( - ( - sessionId: SessionId, - next: - | SessionRuntime['draftImages'] - | ((prev: SessionRuntime['draftImages']) => SessionRuntime['draftImages']), - ) => { - setRuntimes(prev => { - const current = prev[sessionId] ?? emptyRuntime() - const draftImages = - typeof next === 'function' - ? next(current.draftImages) - : next - return { - ...prev, - [sessionId]: { - ...current, - draftImages, - }, - } - }) - setDraftVersion(v => v + 1) - }, - [], - ) - - // ---- Pane toast: transient feedback above the composer ---- - // - // Single-slot, auto-dismiss. Calling showPaneToast while a previous - // toast is still visible replaces it and resets the timer. The - // timeout ref lives outside React state so we can clear it without - // causing a re-render. - const paneToastTimers = useRef>>({}) - - const showPaneToast = useCallback( - (sessionId: SessionId, message: string, durationMs = 2000) => { - // Clear any in-flight timer for this pane. - const prev = paneToastTimers.current[sessionId] - if (prev) clearTimeout(prev) - - updateRuntime(sessionId, { paneToast: message }) - - paneToastTimers.current[sessionId] = setTimeout(() => { - updateRuntime(sessionId, { paneToast: null }) - delete paneToastTimers.current[sessionId] - }, durationMs) - }, - [updateRuntime], - ) - - const switchFocusedProvider = useCallback(async () => { - const current = stateRef.current - const tab = current.tabs.find(t => t.id === current.activeTabId) - if (!tab) return - - const sourceSessionId = tab.focusedSessionId - const meta = current.sessions[sourceSessionId] - if (!meta) return - - const sourceKind = meta.kind ?? 'claude' - if (sourceKind !== 'claude' && sourceKind !== 'codex') { - showPaneToast(sourceSessionId, 'Only Claude and Codex panes can switch provider') - return - } - if (!meta.providerSessionId) { - showPaneToast(sourceSessionId, 'Provider session id is not ready yet') - return - } - - try { - // The translated target transcript must be created BEFORE we replace the - // live pane. If translation fails, the current provider process should - // stay untouched and the user should keep their running session instead - // of being dropped into a dead pane. - const result = await window.api.switchProvider({ - sourceKind, - sourceProviderSessionId: meta.providerSessionId, - cwd: meta.cwd, - }) - - const newSessionId = await replaceSession(meta.cwd, { - kind: result.targetKind, - resumeSessionId: result.targetProviderSessionId, - }) - if (!newSessionId) return - - showPaneToast( - newSessionId, - result.targetKind === 'codex' ? 'Switched to Codex' : 'Switched to Claude', - ) - } catch (err) { - const message = - err instanceof Error && err.message.length > 0 - ? err.message - : 'Provider switch failed' - showPaneToast(sourceSessionId, message) - } - }, [replaceSession, showPaneToast]) - - const reloadFocusedAgent = useCallback(async () => { - const current = stateRef.current - const tab = current.tabs.find(t => t.id === current.activeTabId) - if (!tab) return - - const sourceSessionId = tab.focusedSessionId - const meta = current.sessions[sourceSessionId] - if (!meta) return - - const kind = meta.kind ?? 'claude' - if (kind !== 'claude' && kind !== 'codex') { - showPaneToast(sourceSessionId, 'Only Claude and Codex panes can reload') - return - } - if (!meta.providerSessionId) { - showPaneToast(sourceSessionId, 'Provider session id is not ready yet') - return - } - - try { - const newSessionId = await replaceSession(meta.cwd, { - kind, - resumeSessionId: meta.providerSessionId, - }) - if (!newSessionId) return - showPaneToast( - newSessionId, - kind === 'codex' ? 'Codex reloaded' : 'Claude reloaded', - ) - } catch (err) { - const message = - err instanceof Error && err.message.length > 0 - ? err.message - : 'Reload failed' - showPaneToast(sourceSessionId, message) - } - }, [replaceSession, showPaneToast]) - - // Rewind the focused pane to a selected earlier user prompt. - // - // Contract (see docs/superpowers/plans for the full rationale): - // 1. The source session's on-disk transcript is NOT touched. - // 2. Main produces a fresh provider session id whose transcript - // ends strictly before the anchor. - // 3. The focused pane is re-homed to the new id via - // `replaceSession(...)`, so the pane stays in place; only its - // backing session swaps. This matches resume semantics. - // 4. The anchored prompt's text is stuffed into the new pane's - // `draftInput` — the rewound session opens with the prompt - // prefilled and UNSENT, so the user can edit or re-send. - // - // Bail-outs: - // - No providerSessionId yet (pane still spawning): toast and - // return. Rewinding a session we can't locate on disk is a - // nonsense operation. - // - Session is mid-turn (processActive or live currentTurn): - // toast and return. Rewinding while a response is streaming - // exercises every race we have around the live-to-committed - // handoff at once; requiring idle is the safe path. - const rewindFocusedToPrompt = useCallback( - async (anchor: - | { kind: 'claude'; uuid: string } - | { kind: 'codex'; userMessageIndex: number }, - ) => { - const current = stateRef.current - const tab = current.tabs.find(t => t.id === current.activeTabId) - if (!tab) return - - const sourceSessionId = tab.focusedSessionId - const meta = current.sessions[sourceSessionId] - if (!meta) return - - const kind = meta.kind ?? 'claude' - if (kind !== 'claude' && kind !== 'codex') { - showPaneToast(sourceSessionId, 'Only Claude and Codex panes support rewind') - return - } - if (!meta.providerSessionId) { - showPaneToast(sourceSessionId, 'Provider session id is not ready yet') - return - } - if (kind !== anchor.kind) { - showPaneToast( - sourceSessionId, - `Anchor is ${anchor.kind} but focused pane is ${kind}`, - ) - return - } - - const currentRuntime = latestRuntimesRef.current[sourceSessionId] - if (currentRuntime?.processActive || currentRuntime?.semantic.currentTurn) { - showPaneToast(sourceSessionId, 'Wait for the current turn to finish before rewinding') - return - } - - try { - const result = await window.api.rewindToPrompt({ - provider: kind, - sourceProviderSessionId: meta.providerSessionId, - cwd: meta.cwd, - anchor, - }) - - const newSessionId = await replaceSession(meta.cwd, { - kind, - resumeSessionId: result.newProviderSessionId, - }) - if (!newSessionId) return - - // `replaceSession` copied the PRIOR pane's draft forward. - // For rewind we deliberately clobber that draft with the - // anchored prompt text — the whole feature is "open this - // prompt in unsent form so I can edit/re-send it". - // - // Bash mode: Claude Code exposes a `bash` input mode that - // prefixes `!` when composing. cc-shell's composer doesn't - // have a discrete bash mode yet, but it DOES treat a leading - // `!` as bash. Mirroring CC's behavior means "rewinding to - // a /bash-input prompt rehydrates as `!`" so the next - // Enter submits as bash again. - // - // Images: convert the main-process image records (base64 + - // mediaType) into `ClaudeDraftImage` shape the composer - // already renders and can send. The preview URL uses a - // data: URL so no blob lifecycle is needed. - const draftText = - result.promptMode === 'bash' && result.promptText.length > 0 - ? `!${result.promptText}` - : result.promptText - - const draftImages: ClaudeDraftImage[] = - kind === 'claude' - ? result.promptImages.map((image, index) => ({ - id: `rewind-${Date.now()}-${index}`, - mediaType: image.mediaType, - base64Data: image.data, - previewUrl: `data:${image.mediaType};base64,${image.data}`, - filename: `rewind-${index + 1}`, - })) - : [] - - setRuntimes(prev => { - const runtime = prev[newSessionId] - if (!runtime) return prev - return { - ...prev, - [newSessionId]: { - ...runtime, - draftInput: draftText, - draftImages, - }, - } - }) - - showPaneToast(newSessionId, 'Rewound to prompt') - } catch (err) { - const message = - err instanceof Error && err.message.length > 0 - ? err.message - : 'Rewind failed' - showPaneToast(sourceSessionId, message) - } - }, - [replaceSession, showPaneToast], - ) - - const loadOlderHistory = useCallback(async (sessionId: SessionId) => { - const currentState = stateRef.current - const meta = currentState.sessions[sessionId] - const runtime = latestRuntimesRef.current[sessionId] ?? emptyRuntime() - if (!meta) return - - const kind = meta.kind ?? 'claude' - if ((kind !== 'claude' && kind !== 'codex') || !meta.providerSessionId) return - if (!runtime.hasOlderHistory || runtime.loadingOlderHistory) return - if (!runtime.historyOldestMarker) { - updateRuntime(sessionId, { hasOlderHistory: false, loadingOlderHistory: false }) - return - } - - updateRuntime(sessionId, { loadingOlderHistory: true }) - - try { - const chunk = await window.api.loadOlderHistory({ - kind, - cwd: meta.cwd, - providerSessionId: meta.providerSessionId, - beforeMarker: runtime.historyOldestMarker, - limit: 200, - }) - - const seen = (seenUuidsRef.current[sessionId] ??= new Set()) - const prepend: Entry[] = [] - let oldestMarker: string | null = runtime.historyOldestMarker - // Same rolling Codex turn id as the live JSONL ingest path — - // loadOlderHistory walks the rollout stream linearly too, so a - // `turn_context` marker seen during pagination still stamps the - // response_items that come after it. See codexTurnIdFromRollout. - let codexPaginationTurnId: string | null = null - - for (const rawEntry of chunk.entries) { - if (kind === 'codex') { - const marker = codexHistoryMarker(rawEntry) - const turnContextId = codexTurnIdFromRollout(rawEntry) - if (turnContextId !== null) codexPaginationTurnId = turnContextId - const mappedRaw = mapCodexRolloutToFeedEntries(rawEntry) - const mapped = mappedRaw.map(e => stampCodexTurnId(e, codexPaginationTurnId)) - if (mapped.length > 0 && oldestMarker === runtime.historyOldestMarker) { - oldestMarker = marker - } - for (const entry of mapped) { - const uuid = (entry as { uuid?: string }).uuid - if (uuid && seen.has(uuid)) continue - if (uuid) seen.add(uuid) - prepend.push(entry) - } - continue - } - - const feedEntry = - extractEmbeddedClaudeProgressEntry(rawEntry) ?? - (rawEntry as Entry) - const marker = claudeHistoryMarker(rawEntry) - if (!( - isConversationEntry(feedEntry) || - isCompactBoundaryEntry(feedEntry) || - isCompactSummaryEntry(feedEntry) - )) { - continue - } - if (marker && oldestMarker === runtime.historyOldestMarker) { - oldestMarker = marker - } - const uuid = (feedEntry as { uuid?: string }).uuid - if (uuid && seen.has(uuid)) continue - if (uuid) seen.add(uuid) - prepend.push(feedEntry) - } - - setRuntimes(prev => { - const current = prev[sessionId] ?? emptyRuntime() - return { - ...prev, - [sessionId]: { - ...current, - entries: prepend.length > 0 ? [...prepend, ...current.entries] : current.entries, - historyOldestMarker: oldestMarker ?? current.historyOldestMarker, - hasOlderHistory: chunk.hasMore || prepend.length === 0, - loadingOlderHistory: false, - }, - } - }) - } catch (err) { - console.warn('[history] load older failed', err) - updateRuntime(sessionId, { loadingOlderHistory: false }) - } - }, [updateRuntime]) - - useEffect(() => { - for (const [sessionId, runtime] of Object.entries(runtimes)) { - if (runtime.feedDebugLog.length === 0) continue - const lastPersistedId = persistedFeedDebugIdRef.current[sessionId] ?? 0 - const pending = runtime.feedDebugLog.filter(entry => entry.id > lastPersistedId) - if (pending.length === 0) continue - persistedFeedDebugIdRef.current[sessionId] = pending[pending.length - 1]?.id ?? lastPersistedId - void window.api - .appendFeedDebugLog({ - sessionId, - entries: pending.map(entry => ({ - id: entry.id, - ts: entry.ts, - tMs: entry.tMs, - layer: entry.layer, - kind: entry.kind, - summary: entry.summary, - data: entry.data, - })), - }) - .catch(err => { - // eslint-disable-next-line no-console - console.warn(`[feed-debug ${sessionId.slice(0, 8)}] append failed`, err) - }) - } - }, [runtimes]) - - // ---- Copy Assistant picker actions ---- - // - // pickerEnter — toggles the picker on/off. On entry, picks - // the most-recent assistant entry with text. - // No-op (picker stays null) if the session has - // no assistant entries with text yet. - // pickerMove — direction is +1 (Down → newer) or -1 (Up → - // older). Walks the assistantUuidsWithText - // list; clamps at the ends rather than wrapping - // (less surprising, matches macOS list pickers). - // pickerConfirm — copies the selected entry's text to clipboard, - // shows a pane toast, clears the picker. - // pickerCancel — clears the picker without copying. - const pickerEnter = useCallback((sessionId: SessionId) => { - setRuntimes(prev => { - const current = prev[sessionId] ?? emptyRuntime() - if (current.assistantPicker) { - return { - ...prev, - [sessionId]: { ...current, assistantPicker: null }, - } - } - const uuids = assistantUuidsWithText(current.entries) - if (uuids.length === 0) return prev - return { - ...prev, - [sessionId]: { - ...current, - assistantPicker: { selectedUuid: uuids[uuids.length - 1] }, - }, - } - }) - }, []) - - const pickerMove = useCallback( - (sessionId: SessionId, direction: -1 | 1) => { - setRuntimes(prev => { - const current = prev[sessionId] ?? emptyRuntime() - const picker = current.assistantPicker - if (!picker) return prev - const uuids = assistantUuidsWithText(current.entries) - if (uuids.length === 0) return prev - const idx = uuids.indexOf(picker.selectedUuid) - if (idx === -1) { - // Selected uuid disappeared mid-flight — snap to the - // newest available so the user keeps a stable reference. - return { - ...prev, - [sessionId]: { - ...current, - assistantPicker: { selectedUuid: uuids[uuids.length - 1] }, - }, - } - } - const nextIdx = Math.max(0, Math.min(uuids.length - 1, idx + direction)) - if (nextIdx === idx) return prev - return { - ...prev, - [sessionId]: { - ...current, - assistantPicker: { selectedUuid: uuids[nextIdx] }, - }, - } - }) - }, - [], - ) - - const pickerCancel = useCallback((sessionId: SessionId) => { - setRuntimes(prev => { - const c = prev[sessionId] - if (!c?.assistantPicker) return prev - return { ...prev, [sessionId]: { ...c, assistantPicker: null } } - }) - }, []) - - const pickerConfirm = useCallback( - async (sessionId: SessionId) => { - const current = latestRuntimesRef.current[sessionId] - if (!current?.assistantPicker) return - const text = extractAssistantByUuid( - current.entries, - current.assistantPicker.selectedUuid, - ) - // Clear the picker first so the UI returns to normal even if - // the clipboard write fails (rare — only with a permission - // denial, which we surface via toast). - setRuntimes(prev => { - const c = prev[sessionId] - if (!c) return prev - return { ...prev, [sessionId]: { ...c, assistantPicker: null } } - }) - if (!text) { - showPaneToast(sessionId, 'Nothing to copy') - return - } - try { - await navigator.clipboard.writeText(text) - showPaneToast(sessionId, 'Copied assistant message') - } catch { - showPaneToast(sessionId, 'Clipboard write failed') - } - }, - [showPaneToast], - ) - - // ---- Persist to disk on every mutation (debounced) ---- - // - // The save is extracted into a stable helper so it can be called both - // from the debounce timer AND from the beforeunload flush below. We - // keep a ref to the latest state so the beforeunload handler (which - // can't close over React state) always serializes the freshest version. - const latestStateRef = useRef(state) - latestStateRef.current = state - - const flushSave = useCallback(() => { - const s = latestStateRef.current - // Collect non-empty drafts so in-progress prompts survive crashes. - const drafts: Record = {} - for (const [id, rt] of Object.entries(latestRuntimesRef.current)) { - if (rt.draftInput) drafts[id] = rt.draftInput - } - const persisted: PersistedWorkspace = { - tabs: s.tabs.map(t => ({ - id: t.id, - title: t.title, - focusedSessionId: t.focusedSessionId, - root: t.root, - })), - activeTabId: s.activeTabId, - sessions: s.sessions, - buried: s.buried, - tileTabs: latestTileTabsRef.current, - drafts: Object.keys(drafts).length > 0 ? drafts : undefined, - } - const json = JSON.stringify({ workspace: persisted }, null, 2) - void window.api.saveWorkspace(json).catch(err => { - // eslint-disable-next-line no-console - console.warn('[workspace] save failed:', err) - }) - }, []) - - const saveTimerRef = useRef | null>(null) - useEffect(() => { - if (saveTimerRef.current) clearTimeout(saveTimerRef.current) - saveTimerRef.current = setTimeout(flushSave, 400) - return () => { - if (saveTimerRef.current) clearTimeout(saveTimerRef.current) - } - }, [state, draftVersion, flushSave]) - - // ---- Flush on window close ---- - // - // The debounced save has a 400ms window where a mutation hasn't been - // written yet. If the user quits the app during that window, the - // latest state is lost — tabs vanish, sessions can't resume. - // - // Fix: listen for `beforeunload` (fires synchronously before the - // renderer is torn down) and flush immediately. The IPC invoke is - // async but Electron's main process receives the message before the - // window actually closes — the write lands. This is the same pattern - // VS Code uses for its workspace state. - useEffect(() => { - const onBeforeUnload = () => { - // Cancel the debounced timer so we don't double-save. - if (saveTimerRef.current) { - clearTimeout(saveTimerRef.current) - saveTimerRef.current = null - } - flushSave() - } - window.addEventListener('beforeunload', onBeforeUnload) - return () => window.removeEventListener('beforeunload', onBeforeUnload) - }, [flushSave]) - - // ---- Load on first mount ---- - // - // If there's persisted state, respawn every session in its saved - // cwd (minting fresh sessionIds because main process ids are - // ephemeral) and remap the tree to use the new ids. If there's no - // saved state, spawn one default session in the default cwd. - const bootRef = useRef(false) - useEffect(() => { - if (bootRef.current) return - bootRef.current = true - void (async () => { - const json = await window.api.loadWorkspace() - if (!json) { - // Fresh install — create one default tab. - const cwd = await window.api.defaultCwd() - try { - await newTab(cwd) - } catch (err) { - console.warn('[workspace] initial session spawn failed:', err) - } - return - } - try { - // Single-user dev app — no schema versioning. If the load - // fails for any reason (corrupt JSON, unexpected shape, - // spawn error during rehydrate) we fall through to the - // catch below and start fresh. No migrations, no version - // gates. - const parsed = JSON.parse(json) as { workspace: PersistedWorkspace } - await rehydrate(parsed.workspace) - } catch (err) { - // eslint-disable-next-line no-console - console.warn('[workspace] load failed, starting fresh:', err) - const cwd = await window.api.defaultCwd() - try { - await newTab(cwd) - } catch (spawnErr) { - console.warn('[workspace] fallback session spawn failed:', spawnErr) - } - } - })() - }, []) // eslint-disable-line react-hooks/exhaustive-deps - - /** - * Remap a persisted tree by replacing every sessionId with a freshly - * spawned one (spawn happens as we walk). Returns the remapped tree - * plus the old→new id mapping. - * - * Resume semantics: if the persisted SessionMeta carries a - * `providerSessionId`, we pass it to the spawn call as `resumeSessionId` - * so claude boots with `--resume ` and the full conversation - * history — tool calls, transcript, queue state, the lot — comes - * back. The cc-shell SessionId we mint here is a fresh routing - * key; CC's own session UUID is the thing we care about preserving. - * - * The providerSessionId is ALSO threaded into freshSessions[newId] so - * the runtime meta after rehydrate matches pre-reload state and - * the next save cycle writes it straight back. Without this, the - * first save after a resume would drop providerSessionId and the NEXT - * reload would lose context again. - * - * Failure modes: - * - File missing / corrupted → CC will exit with a non-zero code - * shortly after spawn. Surfaces via the exit event as "exited" - * in the pane status strip. Not retried automatically — the - * user can close the pane and open a fresh one. - * - File locked by another process (rare) → same as above. - * - Spawn itself throws (IPC failure) → caught below and logged; - * the pane is simply missing from the rehydrated tree. - */ - const rehydrate = useCallback(async (persisted: PersistedWorkspace) => { - const idMap = new Map() - const freshSessions: Record = {} - - const remapNode = (n: TileNode): TileNode => { - if (n.type === 'leaf') { - const mapped = idMap.get(n.sessionId) - return mapped - ? { type: 'leaf', sessionId: mapped } - : n // shouldn't happen, but fall through rather than crash - } - return { ...n, a: remapNode(n.a), b: remapNode(n.b) } - } - - const sanitizeRemappedNode = (n: TileNode): TileNode | null => { - if (n.type === 'leaf') { - return freshSessions[n.sessionId] != null ? n : null - } - const a = sanitizeRemappedNode(n.a) - const b = sanitizeRemappedNode(n.b) - if (!a && !b) return null - if (!a) return b - if (!b) return a - return { ...n, a, b } - } - - const buildRemappedTabs = (): Tab[] => - persisted.tabs - .map(t => { - const remappedRoot = sanitizeRemappedNode(remapNode(t.root)) - if (!remappedRoot) return null - const leaves = collectLeaves(remappedRoot) - if (leaves.length === 0) return null - const focused = idMap.get(t.focusedSessionId) ?? leaves[0] - return { - id: t.id, - title: t.title, - root: remappedRoot, - focusedSessionId: focused, - } satisfies Tab - }) - .filter((t): t is Tab => t !== null) - - const buildRemappedBuried = (): BuriedPaneRecord[] => - (persisted.buried ?? []) - .map(entry => { - const mappedSessionId = idMap.get(entry.sessionId) - if (!mappedSessionId) return null - return { - ...entry, - id: mappedSessionId, - sessionId: mappedSessionId, - siblingLeafId: entry.siblingLeafId - ? (idMap.get(entry.siblingLeafId) ?? entry.siblingLeafId) - : undefined, - } satisfies BuriedPaneRecord - }) - .filter((entry): entry is BuriedPaneRecord => entry !== null) - - const buildRemappedTileTabs = (tabs: Tab[]): TileTabsState | null => { - const persistedTileTabs = persisted.tileTabs - if (!persistedTileTabs) return null - const validTabIds = persistedTileTabs.tabIds.filter(id => - tabs.some(tab => tab.id === id), - ) - return sanitizeTileTabsState({ - ...persistedTileTabs, - tabIds: validTabIds, - }) - } - - const commitRehydratedState = () => { - const newTabs = buildRemappedTabs() - if (newTabs.length === 0) return false - - const restoredTileTabs = buildRemappedTileTabs(newTabs) - const activeTabId = restoredTileTabs?.focusedTabId - ?? newTabs.find(t => t.id === persisted.activeTabId)?.id - ?? newTabs[0].id - - setState({ - tabs: newTabs, - activeTabId, - sessions: { ...freshSessions }, - buried: buildRemappedBuried(), - }) - setTileTabs(restoredTileTabs) - // WHY commit runtimes incrementally during rehydrate: - // - // Boot used to await every respawn before publishing *any* restored - // tabs. One slow / wedged session kept `tabs: []`, so after restart the - // user only saw the `+` button even though workspace.json contained a - // full layout. We now publish whatever subset has already rehydrated so - // the shell surfaces real tabs immediately and fills in the remaining - // panes as their sessions come back. - // - // We still merge with prev because resume-side transcript events can - // arrive synchronously inside `session.start()` before spawnSession() - // resolves. Replacing the runtime object here would clobber those early - // entries and make restored panes open blank. - setRuntimes(prev => { - const out: Record = {} - for (const [oldId, newId] of idMap.entries()) { - const existing = prev[newId] - const base = existing ?? emptyRuntime() - const draft = persisted.drafts?.[oldId] - out[newId] = { - ...base, - ...(draft && !base.draftInput ? { draftInput: draft } : {}), - hasOlderHistory: Boolean(freshSessions[newId]?.providerSessionId), - } - } - for (const id of Object.keys(freshSessions)) { - if (out[id]) continue - const existing = prev[id] - out[id] = { - ...(existing ?? emptyRuntime()), - hasOlderHistory: Boolean(freshSessions[id]?.providerSessionId), - } - } - return out - }) - return true - } - - // Spawn all sessions concurrently instead of serially. A single slow - // respawn must not block the entire tab strip from coming back. - await Promise.all( - Object.entries(persisted.sessions).map(async ([oldId, meta]) => { - try { - const kind: SessionKind = meta.kind ?? 'claude' - // For terminal sessions with a persisted tmuxName, pass it as - // recoverTmuxName so main re-attaches the alive tmux session - // (or falls back to fresh spawn if it died). Agents ignore - // recoverTmuxName at the main side; safe to omit for them. - const { sessionId: newId, tmuxName: nextTmuxName } = await window.api.spawnSession({ - kind, - cwd: meta.cwd, - resumeSessionId: kind !== 'terminal' ? meta.providerSessionId : undefined, - dangerousMode: kind !== 'terminal' ? dangerousAgentsRef.current : undefined, - useProxy: kind !== 'terminal' ? useProxyStreamingRef.current : undefined, - recoverTmuxName: kind === 'terminal' ? meta.tmuxName : undefined, - }) - idMap.set(oldId, newId) - // Carry the full meta forward — kind + providerSessionId + - // tmuxName — so the next save cycle doesn't drop these and - // cause the session to degrade on the NEXT reload. tmuxName - // is replaced with whatever main reported (recovered name - // when alive, fresh name when respawned). - freshSessions[newId] = { - ...meta, - ...(nextTmuxName ? { tmuxName: nextTmuxName } : {}), - } - commitRehydratedState() - } catch (err) { - // eslint-disable-next-line no-console - console.warn(`[workspace] failed to respawn ${meta.cwd}:`, err) - } - }), - ) - - if (!commitRehydratedState()) { - const cwd = await window.api.defaultCwd() - await newTab(cwd) - } - }, [newTab, setTileTabs]) - - // ---- Action: undo close ---- - // - // Pops the most recent entry from the undo stack and restores it. - // For panes: finds the surviving sibling in the current tree by its - // anchor leaf, re-wraps it in a split with the restored session on - // the correct side, and respawns the session (with --resume for - // Claude sessions so the conversation comes back). - // - // For tabs: respawns every session in the tab, remaps the session - // ids in the tree (since the new spawn produces new ids), and - // re-inserts the tab at its original index (clamped to bounds). - const undoClose = useCallback(async () => { - const entry = undoStackRef.current.pop() - if (!entry) return - - if (entry.type === 'pane') { - // Find which tab the sibling leaf is in now. - const targetTab = state.tabs.find(t => - collectLeaves(t.root).includes(entry.siblingLeafId), - ) - if (!targetTab) return // sibling was also closed — stale undo - - // Respawn the session. - // - Claude/Codex with providerSessionId → pass --resume so - // the conversation history replays via JSONL. - // - Terminal with tmuxName → pass recoverTmuxName so the - // same tmux session is re-attached, preserving scrollback - // and any running process. Without this, "undo close" on - // a terminal would respawn an empty shell — defeating the - // point of having a tmux backing. - const meta = entry.sessionMeta - const newSessionId = await spawn(meta.cwd, { - kind: meta.kind ?? 'claude', - resumeSessionId: meta.providerSessionId, - recoverTmuxName: meta.kind === 'terminal' ? meta.tmuxName : undefined, - }) - - setState(prev => { - const tabs = prev.tabs.map(t => { - if (t.id !== targetTab.id) return t - const newRoot = reinsertPane( - t.root, - entry.siblingLeafId, - newSessionId, - entry.direction, - entry.ratio, - entry.side, - ) - if (!newRoot) return t // anchor not found — bail - return { - ...t, - root: newRoot, - focusedSessionId: newSessionId, - } - }) - return { ...prev, tabs } - }) - } else { - // Tab undo: respawn every session and remap the tree. - const idMap = new Map() - const freshSessions: Record = {} - - for (const [oldId, meta] of Object.entries(entry.sessionMetas)) { - try { - const kind: SessionKind = meta.kind ?? 'claude' - // Same per-kind recover hint as the pane-undo branch above: - // tmuxName for terminals, providerSessionId for agents. - const newId = await spawn(meta.cwd, { - kind, - resumeSessionId: kind !== 'terminal' ? meta.providerSessionId : undefined, - recoverTmuxName: kind === 'terminal' ? meta.tmuxName : undefined, - }) - idMap.set(oldId, newId) - freshSessions[newId] = meta - } catch { - // If one session fails to spawn, skip it — restore what we can. - } - } - - if (idMap.size === 0) return // nothing survived - - const remapNode = (n: TileNode): TileNode => { - if (n.type === 'leaf') { - const mapped = idMap.get(n.sessionId) - return mapped ? { type: 'leaf', sessionId: mapped } : n - } - return { ...n, a: remapNode(n.a), b: remapNode(n.b) } - } - - const restoredRoot = remapNode(entry.tab.root) - const leaves = collectLeaves(restoredRoot) - if (leaves.length === 0) return - - const restoredFocused = - idMap.get(entry.tab.focusedSessionId) ?? leaves[0] - const restoredTab: Tab = { - id: crypto.randomUUID(), - title: entry.tab.title, - root: restoredRoot, - focusedSessionId: restoredFocused, - } - - setState(prev => { - const insertIdx = Math.min(entry.tabIndex, prev.tabs.length) - const tabs = [...prev.tabs] - tabs.splice(insertIdx, 0, restoredTab) - return { - ...prev, - tabs, - activeTabId: restoredTab.id, - } - }) - } - }, [spawn, state.tabs]) - - // ---- Action: normalize layout (soft) ---- - // - // Keep the existing tree structure but set every split ratio to 0.5. - // Equalizes spacing without rearranging panes — if you have three - // vertical panes on the left and one on the right, they stay that - // way but all dividers move to the midpoint. - const normalizeLayout = useCallback(() => { - setState(prev => ({ - ...prev, - tabs: prev.tabs.map(t => - t.id === prev.activeTabId - ? { ...t, root: equalizeRatios(t.root) } - : t, - ), - })) - }, []) - - // ---- Action: hard normalize layout ---- - // - // Flatten the tree and rebuild as a balanced grid where every pane - // gets equal space. Changes the arrangement — all panes end up in - // a rows × cols grid. No sessions are spawned or killed. - const hardNormalizeLayout = useCallback(() => { - setState(prev => { - const tab = prev.tabs.find(t => t.id === prev.activeTabId) - if (!tab) return prev - const leaves = collectLeaves(tab.root) - if (leaves.length <= 1) return prev - const newRoot = normalizeTree(leaves) - return { - ...prev, - tabs: prev.tabs.map(t => - t.id === prev.activeTabId ? { ...t, root: newRoot } : t, - ), - } - }) - }, []) - - // ---- Action: rotate layout ---- - // - // Flip every split direction in the active tab's tree: vertical - // becomes horizontal and vice versa. Turns rows into columns. - const rotateLayout = useCallback(() => { - setState(prev => ({ - ...prev, - tabs: prev.tabs.map(t => - t.id === prev.activeTabId - ? { ...t, root: rotateTree(t.root) } - : t, - ), - })) - }, []) - - /** Peek at the undo stack length — used by the command palette to - * show/hide the "Undo Close" command. */ - const undoCloseCount = undoStackRef.current.length - - const activeTab = useMemo( - () => state.tabs.find(t => t.id === state.activeTabId) ?? null, - [state.activeTabId, state.tabs], - ) - - useEffect(() => { - if (!spotlight) return - const tab = state.tabs.find(t => t.id === spotlight.tabId) - if (!tab) { - setSpotlight(null) - return - } - const leaves = collectLeaves(tab.root) - if (leaves.length === 0) { - setSpotlight(null) - return - } - if (!leaves.includes(spotlight.focusedSessionId)) { - setSpotlight(prev => (prev ? { ...prev, focusedSessionId: leaves[0] } : prev)) - } - }, [spotlight, state.tabs]) - - // Same invalidation rule for ReaderMode — if the tab disappears or - // its leaves change so the focused session no longer exists, drop - // out of Reader cleanly. Without this, closing the last pane in - // the tab while Reader is open would leave a dangling focused - // sessionId and a blank screen. - useEffect(() => { - if (!readerMode) return - const tab = state.tabs.find(t => t.id === readerMode.tabId) - if (!tab) { - setReaderMode(null) - return - } - const leaves = collectLeaves(tab.root) - if (leaves.length === 0) { - setReaderMode(null) - return - } - if (!leaves.includes(readerMode.focusedSessionId)) { - setReaderMode(prev => (prev ? { ...prev, focusedSessionId: leaves[0] } : prev)) - } - }, [readerMode, state.tabs]) - - // Picker invalidation. If the selected uuid is no longer present in - // a session's entries (entries cleared, conversation reset, etc.), - // cancel the picker. Without this the outline silently disappears - // (matching DOM node is gone) but the picker state lingers and - // keeps capturing keystrokes. - useEffect(() => { - for (const [sessionId, runtime] of Object.entries(runtimes)) { - if (!runtime.assistantPicker) continue - const uuids = assistantUuidsWithText(runtime.entries) - if (!uuids.includes(runtime.assistantPicker.selectedUuid)) { - pickerCancel(sessionId) - } - } - }, [runtimes, pickerCancel]) - - useEffect(() => { - if (!tileTabs) return - const nextTileTabs = sanitizeTileTabsState(tileTabs) - if (!nextTileTabs) { - setTileTabs(null) - return - } - const validTabIds = nextTileTabs.tabIds.filter(id => state.tabs.some(t => t.id === id)) - const sanitized = sanitizeTileTabsState({ - ...nextTileTabs, - tabIds: validTabIds, - }) - if (!sanitized) { - setTileTabs(null) - return - } - if ( - sanitized.tabIds.length !== tileTabs.tabIds.length || - sanitized.focusedTabId !== tileTabs.focusedTabId || - sanitized.direction !== tileTabs.direction || - !ratiosEqual(sanitized.ratios, tileTabs.ratios) - ) { - setTileTabs(sanitized) - } - }, [tileTabs, state.tabs]) - - // ---- Status mode: color-coded pane headers ---- - const statusMode = useAppStore(store => store.workspaceStatusMode) - const setStatusMode = useAppStore(store => store.setWorkspaceStatusMode) - const toggleStatusMode = useCallback(() => { - setStatusMode(prev => !prev) - }, []) - - return { - state, - runtimes, - activeTab, - spotlight, - tileTabs, - readerMode, - toggleReaderMode, - setReaderModeSession, - latestScreenRef, - getRuntime, - statusMode, - toggleStatusMode, - // actions - newTab, - closeTab, - spawn, - killSession, - splitFocused, - startNewAgentPlacement, - commitNewAgentPlacement, - closeFocused, - closeSession, - requestBuryFocused, - buryFocused, - reviveBuried, - focusSession, - focusSessionInTab, - navigate, - activateTab, - activateTabByIndex, - nextTab, - prevTab, - resizeFocused, - resizeFocusedDirectional, - setSplitRatio, - setSplitRatioInTab, - setStreamingBaseline, - appendFeedDebug, - addOptimisticCodexUserEntry, - removeOptimisticCodexUserEntry, - setDraftInput, - setDraftImages, - loadOlderHistory, - showPaneToast, - undoClose, - undoCloseCount, - normalizeLayout, - hardNormalizeLayout, - rotateLayout, - replaceSession, - reloadFocusedAgent, - switchFocusedProvider, - rewindFocusedToPrompt, - reloadAgentSessions, - toggleSpotlight, - setSpotlightSession, - openTileTabs, - closeTileTabs, - focusTiledTab, - focusTiledTabByIndex, - resizeFocusedTiledTab, - resizeTiledTabByIndex, - toggleTailMode, - scrollFocusedToLatest, - pickerEnter, - pickerMove, - pickerConfirm, - pickerCancel, - } -} +// Prefer importing from './hook' directly in new code. -// Silence unused-var warning for RATIO_DEFAULT re-export path — used in treeOps. -void RATIO_DEFAULT +export { useWorkspace, type Workspace } from './hook' // ----------------------------------------------------------------------------- // Backward-compat re-exports. // -// Previous callers imported these symbols from workspaceStore.ts; the -// 2026-04-23 extraction moved the implementations into sibling modules -// but the re-exports keep the import paths in ProxyDebugPanel, -// claudeSession, Feed.tsx, ghosts.ts, and the testing harness -// working verbatim. Prefer importing from the new modules directly -// in new code. +// These symbols used to be declared inline in this file; extractions +// across the 2026-04-23 refactor split them into sibling modules. +// External consumers (ProxyDebugPanel, claudeSession, Feed.tsx, +// ghosts.ts, testing harness) continue to import from +// workspaceStore.ts via this re-export block. Prefer the new modules +// in new code — these re-exports exist to avoid churning the import +// graph. // ----------------------------------------------------------------------------- export { foldSemanticEvent } from './semantic/foldEvent' From e78b85663ab7119e2407bfc3018efea7277e4ea4 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 23 Apr 2026 20:06:35 +0200 Subject: [PATCH 10/27] refactor(imports): switch cross-domain imports to path aliases (@shared, @renderer, ...) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//'` 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) --- electron.vite.config.ts | 17 +++++++-- src/main/ipc/session.ts | 6 ++-- src/main/lspManager.ts | 2 +- src/main/providerSwitch/shared.ts | 4 +-- src/main/sessionIndex.ts | 6 ++-- src/main/sessionManager.ts | 6 ++-- src/main/sessions/historyLoader.ts | 2 +- src/main/window/mainWindow.ts | 2 +- src/preload/index.ts | 2 +- src/providers/claude/config.ts | 6 ++-- .../claude/renderer/SlashCommandPicker.tsx | 2 +- .../claude/renderer/rows/ClaudeRows.tsx | 14 ++++---- src/providers/claude/runtime/claudeSession.ts | 2 +- src/providers/claude/runtime/sessionList.ts | 2 +- src/providers/codex/config.ts | 4 +-- .../codex/renderer/rows/CodexRows.tsx | 10 +++--- src/providers/codex/runtime/codexSession.ts | 2 +- src/providers/registry.main.ts | 2 +- src/providers/registry.renderer.ts | 10 +++--- src/providers/registry.ts | 2 +- .../lib/extractAssistantByUuid.ts | 2 +- .../src/features/debug/ui/DebugPanel.tsx | 2 +- src/renderer/src/features/feed/context.tsx | 2 +- src/renderer/src/features/feed/lib/helpers.ts | 2 +- src/renderer/src/features/feed/types.ts | 2 +- src/renderer/src/features/feed/ui/Feed.tsx | 35 +++++++++++++++---- src/renderer/src/features/git/ui/GitRows.tsx | 6 ++-- .../src/features/reader/ui/ReaderView.tsx | 2 +- .../workspace/lib/latestUserPrompts.ts | 2 +- .../workspace/ui/AgentActivityModal.tsx | 2 +- src/renderer/src/lib/code/CodeBlock.tsx | 2 +- src/renderer/src/lib/code/monacoRuntime.ts | 2 +- src/renderer/src/lib/copyAssistant.ts | 2 +- src/renderer/src/workspace/claude/history.ts | 2 +- src/renderer/src/workspace/codex/entries.ts | 2 +- src/renderer/src/workspace/codex/rollout.ts | 2 +- src/renderer/src/workspace/entries/utils.ts | 2 +- src/renderer/src/workspace/ghosts.ts | 4 +-- .../src/workspace/hook/actions/history.ts | 4 +-- .../src/workspace/hook/actions/streaming.ts | 2 +- .../workspace/hook/ipc/useIpcSubscriptions.ts | 6 ++-- src/renderer/src/workspace/mergedEntries.ts | 2 +- .../src/workspace/tile-tree/TileLeaf.tsx | 10 +++--- .../src/workspace/tile-tree/TileTree.tsx | 2 +- src/renderer/src/workspace/workspaceState.ts | 2 +- src/shared/types/transcript.ts | 4 +-- testing/ghost-demo/bridge.mts | 6 ++-- testing/regression-claude-tool-result.ts | 4 +-- testing/rendering/main.ts | 10 +++--- .../renderer/RenderingHarnessApp.tsx | 12 +++---- .../renderer/components/ClaudeRows.tsx | 6 ++-- .../renderer/components/CodeBlock.tsx | 2 +- .../renderer/components/CodexRows.tsx | 2 +- .../rendering/renderer/components/Feed.tsx | 8 ++--- .../rendering/renderer/components/GitRows.tsx | 6 ++-- .../renderer/components/monacoRuntime.ts | 2 +- testing/rendering/renderer/main.tsx | 4 +-- tsconfig.node.json | 19 +++++++++- tsconfig.web.json | 13 ++++++- 59 files changed, 184 insertions(+), 120 deletions(-) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 878f15f71..d799eb973 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -13,12 +13,24 @@ const headlessAlias = { 'agent-transcript-parser': resolve(__dirname, 'agent-transcript-parser/src/index.ts'), } +// Project-wide absolute-import aliases. MUST match tsconfig.node.json +// + tsconfig.web.json `paths` — the two resolvers must agree or tsc +// will green-light an import the runtime can't load. Keep the alias +// names and targets in sync across all three configs. +const projectAlias = { + '@main': resolve(__dirname, 'src/main'), + '@preload': resolve(__dirname, 'src/preload'), + '@renderer': resolve(__dirname, 'src/renderer/src'), + '@shared': resolve(__dirname, 'src/shared'), + '@providers': resolve(__dirname, 'src/providers'), +} + const headlessExclude = ['claude-code-headless', 'codex-headless', 'agent-transcript-parser'] export default defineConfig({ main: { plugins: [externalizeDepsPlugin({ exclude: headlessExclude })], - resolve: { alias: headlessAlias }, + resolve: { alias: { ...headlessAlias, ...projectAlias } }, build: { rollupOptions: { input: resolve(__dirname, 'src/main/index.ts') @@ -27,7 +39,7 @@ export default defineConfig({ }, preload: { plugins: [externalizeDepsPlugin({ exclude: headlessExclude })], - resolve: { alias: headlessAlias }, + resolve: { alias: { ...headlessAlias, ...projectAlias } }, build: { rollupOptions: { input: resolve(__dirname, 'src/preload/index.ts') @@ -36,6 +48,7 @@ export default defineConfig({ }, renderer: { root: resolve(__dirname, 'src/renderer'), + resolve: { alias: projectAlias }, build: { rollupOptions: { input: resolve(__dirname, 'src/renderer/index.html') diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index 5168261f5..0fdb4d467 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -1,9 +1,9 @@ import { ipcMain } from 'electron' import type { SessionManager } from '../sessionManager.js' -import { getMainProvider } from '../../providers/registry.main.js' -import { listAllClaudeSessions } from '../../providers/claude/runtime/sessionList.js' -import { listCodexSessions } from '../../providers/codex/runtime/sessionList.js' +import { getMainProvider } from '@providers/registry.main.js' +import { listAllClaudeSessions } from '@providers/claude/runtime/sessionList.js' +import { listCodexSessions } from '@providers/codex/runtime/sessionList.js' import { loadOlderHistoryChunk } from '../sessions/historyLoader.js' // Session lifecycle + I/O IPC. diff --git a/src/main/lspManager.ts b/src/main/lspManager.ts index b71401aa9..f50cf693b 100644 --- a/src/main/lspManager.ts +++ b/src/main/lspManager.ts @@ -22,7 +22,7 @@ import { import { languageFileExtension, supportsLsp, -} from '../shared/code/language.js' +} from '@shared/code/language.js' const require = createRequire(import.meta.url) diff --git a/src/main/providerSwitch/shared.ts b/src/main/providerSwitch/shared.ts index 6fa4c9a8c..26543ed9f 100644 --- a/src/main/providerSwitch/shared.ts +++ b/src/main/providerSwitch/shared.ts @@ -12,8 +12,8 @@ import { join } from 'path' import type { ClaudeEntry, CodexRolloutLine } from 'agent-transcript-parser' -import { getProjectDirForCwd } from '../../shared/runtime/projectDir.js' -import { getCodexSessionsDir } from '../../providers/codex/runtime/projectDir.js' +import { getProjectDirForCwd } from '@shared/runtime/projectDir.js' +import { getCodexSessionsDir } from '@providers/codex/runtime/projectDir.js' // --------------------------------------------------------------------------- // JSONL io diff --git a/src/main/sessionIndex.ts b/src/main/sessionIndex.ts index 09ff80fa1..221119ff9 100644 --- a/src/main/sessionIndex.ts +++ b/src/main/sessionIndex.ts @@ -1,9 +1,9 @@ import { readdir, readFile, stat } from 'fs/promises' import { join } from 'path' -import { listSessionsForCwd } from '../providers/claude/runtime/sessionList.js' -import { getProjectDirForCwd } from '../shared/runtime/projectDir.js' -import { getCodexSessionsDir } from '../providers/codex/runtime/projectDir.js' +import { listSessionsForCwd } from '@providers/claude/runtime/sessionList.js' +import { getProjectDirForCwd } from '@shared/runtime/projectDir.js' +import { getCodexSessionsDir } from '@providers/codex/runtime/projectDir.js' // Session Prompt Index — power source for the "Search Conversation // Prompts" command. diff --git a/src/main/sessionManager.ts b/src/main/sessionManager.ts index e40722e2c..76973a6b9 100644 --- a/src/main/sessionManager.ts +++ b/src/main/sessionManager.ts @@ -1,9 +1,9 @@ import { randomUUID } from 'crypto' import { EventEmitter } from 'events' -import { getMainProvider } from '../providers/registry.main.js' -import type { ScreenSnapshot } from '../providers/claude/runtime/claudeSession.js' -import { TerminalSession } from '../shared/runtime/terminalSession.js' +import { getMainProvider } from '@providers/registry.main.js' +import type { ScreenSnapshot } from '@providers/claude/runtime/claudeSession.js' +import { TerminalSession } from '@shared/runtime/terminalSession.js' import type { JsonlEntry } from 'claude-code-headless' import { TmuxRegistry } from './tmux/TmuxRegistry.js' diff --git a/src/main/sessions/historyLoader.ts b/src/main/sessions/historyLoader.ts index 466727599..b3290799a 100644 --- a/src/main/sessions/historyLoader.ts +++ b/src/main/sessions/historyLoader.ts @@ -1,7 +1,7 @@ import { join } from 'path' import { readFile, readdir, stat } from 'fs/promises' -import { getMainProvider } from '../../providers/registry.main.js' +import { getMainProvider } from '@providers/registry.main.js' // Loader for older history chunks. // diff --git a/src/main/window/mainWindow.ts b/src/main/window/mainWindow.ts index 5fb8b9c39..7b3267736 100644 --- a/src/main/window/mainWindow.ts +++ b/src/main/window/mainWindow.ts @@ -71,7 +71,7 @@ export function createMainWindow(): void { titleBarStyle: 'hiddenInset', backgroundColor: '#0a0a0a', webPreferences: { - preload: join(__dirname, '../preload/index.mjs'), + preload: join(__dirname, '@preload/index.mjs'), sandbox: false, contextIsolation: true, nodeIntegration: false, diff --git a/src/preload/index.ts b/src/preload/index.ts index 80a528718..5753c7233 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -24,7 +24,7 @@ contextBridge.exposeInMainWorld('api', api) export type { Api } // Re-export every payload type. Renderer code that imports types -// from '../preload/index' (or from this file via d.ts) keeps working +// from '@preload/index' (or from this file via d.ts) keeps working // verbatim — the types just moved from inline declarations into // ./api/types.ts, and this file is still the public face of the // preload bundle. diff --git a/src/providers/claude/config.ts b/src/providers/claude/config.ts index 8ea598976..2195c68e6 100644 --- a/src/providers/claude/config.ts +++ b/src/providers/claude/config.ts @@ -5,16 +5,16 @@ // TileTree; everything else (slash picker, trust dialog, Claude-specific // rows) lives inside that component and the shell never knows about it. -import type { ProviderConfig } from '../../shared/types/providerConfig' +import type { ProviderConfig } from '@shared/types/providerConfig' import { ClaudeSession } from './runtime/claudeSession' import { listSessionsForCwd } from './runtime/sessionList' -import { getProjectDirForCwd } from '../../shared/runtime/projectDir' +import { getProjectDirForCwd } from '@shared/runtime/projectDir' import { extractAssistantInProgress } from './parsers/streamingScreen' // For now, re-use the shared TileLeaf which already handles Claude. // When we create a Claude-specific TileLeaf, this import changes to // point at ./renderer/TileLeaf instead. -import { TileLeaf } from '../../renderer/src/workspace/tile-tree/TileLeaf' +import { TileLeaf } from '@renderer/workspace/tile-tree/TileLeaf' export const claudeConfig: ProviderConfig = { id: 'claude', diff --git a/src/providers/claude/renderer/SlashCommandPicker.tsx b/src/providers/claude/renderer/SlashCommandPicker.tsx index a42c3a8a7..7aa0941ee 100644 --- a/src/providers/claude/renderer/SlashCommandPicker.tsx +++ b/src/providers/claude/renderer/SlashCommandPicker.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react' -import type { SlashPickerState } from '../../../renderer/src/workspace/workspaceStore' +import type { SlashPickerState } from '@renderer/workspace/workspaceStore' // SlashCommandPicker — React rendering of the slash command dropdown. // diff --git a/src/providers/claude/renderer/rows/ClaudeRows.tsx b/src/providers/claude/renderer/rows/ClaudeRows.tsx index e8c8f8b28..faa910e4d 100644 --- a/src/providers/claude/renderer/rows/ClaudeRows.tsx +++ b/src/providers/claude/renderer/rows/ClaudeRows.tsx @@ -12,13 +12,13 @@ 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 { formatToolFilePath } from '../../../../shared/paths/displayPath' -import type { ToolUseBlock } from '../../../../shared/types/transcript' -import { CodeBlock } from '../../../../renderer/src/lib/code/CodeBlock' -import { CodeRenderContext } from '../../../../renderer/src/features/feed/ui/Feed' -import { MarkerRow } from '../../../../renderer/src/features/feed/ui/MarkerRow' +import { normalizeCodeLanguage } from '@shared/code/language' +import { diffLines, type DiffLine } from '@shared/parsers/lineDiff' +import { formatToolFilePath } from '@shared/paths/displayPath' +import type { ToolUseBlock } from '@shared/types/transcript' +import { CodeBlock } from '@renderer/lib/code/CodeBlock' +import { CodeRenderContext } from '@renderer/features/feed/ui/Feed' +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' /* ---------- Shared helpers ---------- */ diff --git a/src/providers/claude/runtime/claudeSession.ts b/src/providers/claude/runtime/claudeSession.ts index 1ecc3dd0a..ac56588b7 100644 --- a/src/providers/claude/runtime/claudeSession.ts +++ b/src/providers/claude/runtime/claudeSession.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'events' import { spawn as ptySpawn } from 'node-pty' -import type { SlashPickerState } from '../../../preload/index.js' +import type { SlashPickerState } from '@preload/index.js' import { ClaudeCodeHeadless, createProxyServer, diff --git a/src/providers/claude/runtime/sessionList.ts b/src/providers/claude/runtime/sessionList.ts index 1af95609b..638ae8cfb 100644 --- a/src/providers/claude/runtime/sessionList.ts +++ b/src/providers/claude/runtime/sessionList.ts @@ -1,7 +1,7 @@ import { readdir, stat, open } from 'fs/promises' import { basename, join } from 'path' -import { getProjectDirForCwd, getProjectsDir } from '../../../shared/runtime/projectDir.js' +import { getProjectDirForCwd, getProjectsDir } from '@shared/runtime/projectDir.js' // Session lister — reimplements the minimal subset of CC's // utils/listSessionsImpl.ts needed to power the cc-shell resume UI. diff --git a/src/providers/codex/config.ts b/src/providers/codex/config.ts index ce2e4c57a..a0939ff48 100644 --- a/src/providers/codex/config.ts +++ b/src/providers/codex/config.ts @@ -1,6 +1,6 @@ // Codex provider config — the shell's only import path into Codex code. -import type { ProviderConfig } from '../../shared/types/providerConfig' +import type { ProviderConfig } from '@shared/types/providerConfig' import { CodexSession } from './runtime/codexSession' import { listCodexSessions } from './runtime/sessionList' import { getCodexSessionsDir } from './runtime/projectDir' @@ -8,7 +8,7 @@ import { extractCodexAssistantInProgress } from './parsers/streamingScreen' // For now, re-use the shared TileLeaf which already handles Codex. // When we create a Codex-specific TileLeaf, this import changes. -import { TileLeaf } from '../../renderer/src/workspace/tile-tree/TileLeaf' +import { TileLeaf } from '@renderer/workspace/tile-tree/TileLeaf' export const codexConfig: ProviderConfig = { id: 'codex', diff --git a/src/providers/codex/renderer/rows/CodexRows.tsx b/src/providers/codex/renderer/rows/CodexRows.tsx index 0f429d51f..4acb88dea 100644 --- a/src/providers/codex/renderer/rows/CodexRows.tsx +++ b/src/providers/codex/renderer/rows/CodexRows.tsx @@ -1,10 +1,10 @@ import { memo, useContext, useMemo, useState } from 'react' -import { CodeBlock } from '../../../../renderer/src/lib/code/CodeBlock' -import { CodeRenderContext } from '../../../../renderer/src/features/feed/ui/Feed' -import { MarkerRow } from '../../../../renderer/src/features/feed/ui/MarkerRow' -import { formatToolFilePath } from '../../../../shared/paths/displayPath' -import type { ToolResultBlock, ToolUseBlock } from '../../../../shared/types/transcript' +import { CodeBlock } from '@renderer/lib/code/CodeBlock' +import { CodeRenderContext } from '@renderer/features/feed/ui/Feed' +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' +import { formatToolFilePath } from '@shared/paths/displayPath' +import type { ToolResultBlock, ToolUseBlock } from '@shared/types/transcript' function asRecord(value: unknown): Record | null { return value && typeof value === 'object' ? value as Record : null diff --git a/src/providers/codex/runtime/codexSession.ts b/src/providers/codex/runtime/codexSession.ts index 357bf3007..8fc09bf06 100644 --- a/src/providers/codex/runtime/codexSession.ts +++ b/src/providers/codex/runtime/codexSession.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'events' import { spawn as ptySpawn } from 'node-pty' -import type { SlashPickerState } from '../../../preload/index.js' +import type { SlashPickerState } from '@preload/index.js' import { CodexHeadless, CodexResponsesAdapter, diff --git a/src/providers/registry.main.ts b/src/providers/registry.main.ts index c3e8db169..71a3bfffc 100644 --- a/src/providers/registry.main.ts +++ b/src/providers/registry.main.ts @@ -2,7 +2,7 @@ // // sessionManager and IPC handlers import from HERE. -import type { MainProviderConfig } from '../shared/types/providerConfig' +import type { MainProviderConfig } from '@shared/types/providerConfig' import { ClaudeSession } from './claude/runtime/claudeSession' import { listSessionsForCwd, getProjectDirForCwd } from 'claude-code-headless' import { CodexSession } from './codex/runtime/codexSession' diff --git a/src/providers/registry.renderer.ts b/src/providers/registry.renderer.ts index c504dcbad..6a8e583f1 100644 --- a/src/providers/registry.renderer.ts +++ b/src/providers/registry.renderer.ts @@ -4,15 +4,15 @@ // This file only imports renderer configs which contain TileLeaf // components and parser functions — no session factories, no fs, no pty. -import type { RendererProviderConfig } from '../shared/types/providerConfig' +import type { RendererProviderConfig } from '@shared/types/providerConfig' // Import parser functions by direct file path — NOT through the package // entry point. The headless packages pull in Node deps (pty, fs) through // their main export, but the parser files are pure TypeScript (no Node, // no DOM). Direct imports keep the renderer bundle browser-safe. -import { extractAssistantInProgress as claudeExtract } from '../shared/parsers/claudeScreen' -import { extractCodexAssistantInProgress as codexExtract } from '../shared/parsers/codexScreen' -import { TileLeaf } from '../renderer/src/workspace/tile-tree/TileLeaf' -import type { TileLeafProps } from '../shared/types/providerConfig' +import { extractAssistantInProgress as claudeExtract } from '@shared/parsers/claudeScreen' +import { extractCodexAssistantInProgress as codexExtract } from '@shared/parsers/codexScreen' +import { TileLeaf } from '@renderer/workspace/tile-tree/TileLeaf' +import type { TileLeafProps } from '@shared/types/providerConfig' const claudeRenderer: RendererProviderConfig = { id: 'claude', diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 2bb3e4f06..1a72fbf2e 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -6,7 +6,7 @@ // provider's directory physically cannot break the other provider or // the shell — the import graph enforces it. -import type { ProviderConfig } from '../shared/types/providerConfig' +import type { ProviderConfig } from '@shared/types/providerConfig' import { claudeConfig } from './claude/config' import { codexConfig } from './codex/config' diff --git a/src/renderer/src/features/copy-assistant/lib/extractAssistantByUuid.ts b/src/renderer/src/features/copy-assistant/lib/extractAssistantByUuid.ts index 9803141a7..e9f3ea415 100644 --- a/src/renderer/src/features/copy-assistant/lib/extractAssistantByUuid.ts +++ b/src/renderer/src/features/copy-assistant/lib/extractAssistantByUuid.ts @@ -8,7 +8,7 @@ // // Pure: no React, no DOM, no IO. -import type { Entry } from '../../../../../shared/types/transcript' +import type { Entry } from '@shared/types/transcript' type AssistantMessage = { role?: string diff --git a/src/renderer/src/features/debug/ui/DebugPanel.tsx b/src/renderer/src/features/debug/ui/DebugPanel.tsx index 6ee64fb86..f52f8a498 100644 --- a/src/renderer/src/features/debug/ui/DebugPanel.tsx +++ b/src/renderer/src/features/debug/ui/DebugPanel.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react' import type { SessionRuntime } from '../../../workspace/workspaceStore' -import type { Entry } from '../../../../../shared/types/transcript' +import type { Entry } from '@shared/types/transcript' // DebugPanel — inline diagnostic overlay showing the raw state of the // focused pane. Toggled via "Toggle Debug Panel" in the command palette. diff --git a/src/renderer/src/features/feed/context.tsx b/src/renderer/src/features/feed/context.tsx index ae4d57963..95e68d00d 100644 --- a/src/renderer/src/features/feed/context.tsx +++ b/src/renderer/src/features/feed/context.tsx @@ -3,7 +3,7 @@ import { createContext } from 'react' import type { ToolResultBlock, ToolUseBlock, -} from '../../../../shared/types/transcript' +} from '@shared/types/transcript' import type { AgentProvider } from './types' diff --git a/src/renderer/src/features/feed/lib/helpers.ts b/src/renderer/src/features/feed/lib/helpers.ts index e6bbbf18c..63e6798d7 100644 --- a/src/renderer/src/features/feed/lib/helpers.ts +++ b/src/renderer/src/features/feed/lib/helpers.ts @@ -6,7 +6,7 @@ import { type Entry, type ToolResultBlock, type ToolUseBlock, -} from '../../../../../shared/types/transcript' +} from '@shared/types/transcript' import type { SemanticLiveTurn } from '../../../workspace/workspaceState' // ----------------------------------------------------------------------------- diff --git a/src/renderer/src/features/feed/types.ts b/src/renderer/src/features/feed/types.ts index cc5b3b36c..d25f53ed0 100644 --- a/src/renderer/src/features/feed/types.ts +++ b/src/renderer/src/features/feed/types.ts @@ -1,4 +1,4 @@ -import type { Entry } from '../../../../shared/types/transcript' +import type { Entry } from '@shared/types/transcript' /** Which agent provider this Feed is rendering for. Determines * which row renderers are used for tool_use blocks. */ diff --git a/src/renderer/src/features/feed/ui/Feed.tsx b/src/renderer/src/features/feed/ui/Feed.tsx index 3c4065cde..a217aa260 100644 --- a/src/renderer/src/features/feed/ui/Feed.tsx +++ b/src/renderer/src/features/feed/ui/Feed.tsx @@ -18,11 +18,11 @@ import { MultiEditRow, WriteRow, TodoRow, -} from '../../../../../providers/claude/renderer/rows/ClaudeRows' +} from '@providers/claude/renderer/rows/ClaudeRows' import { CodexToolRow, CodexToolResultRow, -} from '../../../../../providers/codex/renderer/rows/CodexRows' +} from '@providers/codex/renderer/rows/CodexRows' import { isCompactBoundaryEntry, isCompactSummaryEntry, @@ -33,9 +33,9 @@ import { type Entry, type ToolResultBlock, type ToolUseBlock, -} from '../../../../../shared/types/transcript' +} from '@shared/types/transcript' import { CodeBlock } from '../../../lib/code/CodeBlock' -import { detectGitIntent } from '../../../../../shared/git/gitDetect' +import { detectGitIntent } from '@shared/git/gitDetect' import { GitCardRow } from '../../git/ui/GitRows' import { useAppStore } from '../../../state/hooks' import { @@ -581,6 +581,12 @@ function FeedImpl({ scrollPositions.get(sessionId)?.stickyBottom ?? true, ) const loadingOlderRef = useRef(false) + // Was there an existing saved scroll position for this session when + // this Feed instance mounted? Used to distinguish "restore the + // user's deliberate scrolled-up position" from "brand-new/resumed + // session should land at latest content even if stickyBottom got + // transiently knocked false during bootstrap." + const hadSavedPositionOnMountRef = useRef(scrollPositions.has(sessionId)) // 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" @@ -619,6 +625,7 @@ function FeedImpl({ useLayoutEffect(() => { const el = scrollerRef.current if (!el) return + hadSavedPositionOnMountRef.current = scrollPositions.has(sessionId) if (tailMode) { el.scrollTop = el.scrollHeight stickyBottomRef.current = true @@ -772,12 +779,28 @@ function FeedImpl({ useEffect(() => { if (prevBootstrappingRef.current && !bootstrapping) { const el = scrollerRef.current - if (el && (tailMode || stickyBottomRef.current)) { + // Fresh/resumed sessions with no saved scroll position should + // ALWAYS land on the latest content after the bootstrap burst. + // Relying purely on stickyBottomRef here is fragile because the + // initial mount/placeholder/lazy-load sequence can transiently + // mark the feed non-sticky before the first real user scroll. + // That leaves the viewport stranded above the eager tail: the + // exact "blank until I scroll down a couple pages" symptom. + const shouldForceInitialBottom = + !hadSavedPositionOnMountRef.current && !tailMode + if (el && (tailMode || stickyBottomRef.current || shouldForceInitialBottom)) { el.scrollTop = el.scrollHeight + stickyBottomRef.current = true + lastScrollTopRef.current = el.scrollTop + scrollPositions.set(sessionId, { + scrollTop: el.scrollTop, + stickyBottom: true, + }) + if (onScrollInfo) onScrollInfo({ fraction: 0 }) } } prevBootstrappingRef.current = bootstrapping - }, [bootstrapping, tailMode]) + }, [bootstrapping, onScrollInfo, sessionId, tailMode]) // When the picker selection changes, smoothly tween the scroller // so the highlighted entry centers in the viewport. diff --git a/src/renderer/src/features/git/ui/GitRows.tsx b/src/renderer/src/features/git/ui/GitRows.tsx index 579f272cf..55783d4f7 100644 --- a/src/renderer/src/features/git/ui/GitRows.tsx +++ b/src/renderer/src/features/git/ui/GitRows.tsx @@ -31,8 +31,8 @@ import { memo, useMemo, useState } from 'react' import hljs from 'highlight.js' import { MarkerRow } from '../../../features/feed/ui/MarkerRow' -import { normalizeCodeLanguage } from '../../../../../shared/code/language' -import type { GitIntent } from '../../../../../shared/git/gitDetect' +import { normalizeCodeLanguage } from '@shared/code/language' +import type { GitIntent } from '@shared/git/gitDetect' import { parseGitCommit, parseGitLog, @@ -42,7 +42,7 @@ import { stripAnsi, type GitDiffFile, type GitDiffLine, -} from '../../../../../shared/git/gitParse' +} from '@shared/git/gitParse' // --------------------------------------------------------------------------- // Shared chrome diff --git a/src/renderer/src/features/reader/ui/ReaderView.tsx b/src/renderer/src/features/reader/ui/ReaderView.tsx index 4b87a9912..512e4a628 100644 --- a/src/renderer/src/features/reader/ui/ReaderView.tsx +++ b/src/renderer/src/features/reader/ui/ReaderView.tsx @@ -4,7 +4,7 @@ import remarkGfm from 'remark-gfm' import { CodeBlock } from '../../../lib/code/CodeBlock' import { CodeRenderContext } from '../../../features/feed/ui/Feed' -import { extractAssistantInProgress } from '../../../../../shared/parsers/extractAssistant' +import { extractAssistantInProgress } from '@shared/parsers/extractAssistant' import { assistantUuidsWithText, extractAssistantByUuid } from '../../../lib/copyAssistant' import { collectLeaves } from '../../../workspace/tile-tree/treeOps' import type { SessionId, Workspace } from '../../../workspace/workspaceStore' diff --git a/src/renderer/src/features/workspace/lib/latestUserPrompts.ts b/src/renderer/src/features/workspace/lib/latestUserPrompts.ts index 4fc68d7b9..319d48dbf 100644 --- a/src/renderer/src/features/workspace/lib/latestUserPrompts.ts +++ b/src/renderer/src/features/workspace/lib/latestUserPrompts.ts @@ -2,7 +2,7 @@ import { isCompactSummaryEntry, isConversationEntry, type Entry, -} from '../../../../../shared/types/transcript' +} from '@shared/types/transcript' import type { SessionKind } from '../../../workspace/types' export type LatestUserPrompt = { diff --git a/src/renderer/src/features/workspace/ui/AgentActivityModal.tsx b/src/renderer/src/features/workspace/ui/AgentActivityModal.tsx index f413104ca..88a5f340a 100644 --- a/src/renderer/src/features/workspace/ui/AgentActivityModal.tsx +++ b/src/renderer/src/features/workspace/ui/AgentActivityModal.tsx @@ -4,7 +4,7 @@ import { useAppStore } from '../../../state/hooks' import { collectLeaves } from '../../../workspace/tile-tree/treeOps' import type { SessionId, Tab } from '../../../workspace/types' import type { Workspace } from '../../../workspace/workspaceStore' -import type { Entry } from '../../../../../shared/types/transcript' +import type { Entry } from '@shared/types/transcript' // AgentActivityModal — overview of every visible pane grouped by tab // with a last-activity indicator per row. diff --git a/src/renderer/src/lib/code/CodeBlock.tsx b/src/renderer/src/lib/code/CodeBlock.tsx index 85a9fbe75..d65e55bd2 100644 --- a/src/renderer/src/lib/code/CodeBlock.tsx +++ b/src/renderer/src/lib/code/CodeBlock.tsx @@ -5,7 +5,7 @@ import { languageFileExtension, normalizeCodeLanguage, supportsLsp, -} from '../../../../shared/code/language' +} from '@shared/code/language' type Props = { code: string diff --git a/src/renderer/src/lib/code/monacoRuntime.ts b/src/renderer/src/lib/code/monacoRuntime.ts index 74128f942..3ac51b2e3 100644 --- a/src/renderer/src/lib/code/monacoRuntime.ts +++ b/src/renderer/src/lib/code/monacoRuntime.ts @@ -3,7 +3,7 @@ import type * as Monaco from 'monaco-editor' import { normalizeCodeLanguage, supportsLsp, -} from '../../../../shared/code/language' +} 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' diff --git a/src/renderer/src/lib/copyAssistant.ts b/src/renderer/src/lib/copyAssistant.ts index b6eccd209..c6dcf66b0 100644 --- a/src/renderer/src/lib/copyAssistant.ts +++ b/src/renderer/src/lib/copyAssistant.ts @@ -17,7 +17,7 @@ // We walk the entries array backward so the first match is the most // recent assistant turn. Returns null if no assistant text is found. -import type { Entry } from '../../../shared/types/transcript' +import type { Entry } from '@shared/types/transcript' export function extractLastAssistantText( entries: readonly Entry[], diff --git a/src/renderer/src/workspace/claude/history.ts b/src/renderer/src/workspace/claude/history.ts index fde27a63d..cf2724723 100644 --- a/src/renderer/src/workspace/claude/history.ts +++ b/src/renderer/src/workspace/claude/history.ts @@ -1,4 +1,4 @@ -import type { ConversationEntry } from '../../../../shared/types/transcript' +import type { ConversationEntry } from '@shared/types/transcript' // Claude transcript markers + progress-wrapper unwrapping. // diff --git a/src/renderer/src/workspace/codex/entries.ts b/src/renderer/src/workspace/codex/entries.ts index 49a9b558a..69379fcae 100644 --- a/src/renderer/src/workspace/codex/entries.ts +++ b/src/renderer/src/workspace/codex/entries.ts @@ -1,4 +1,4 @@ -import type { Entry } from '../../../../shared/types/transcript' +import type { Entry } from '@shared/types/transcript' // Codex rollout primitives + conversion helpers. // diff --git a/src/renderer/src/workspace/codex/rollout.ts b/src/renderer/src/workspace/codex/rollout.ts index 74ccec115..83b903ea6 100644 --- a/src/renderer/src/workspace/codex/rollout.ts +++ b/src/renderer/src/workspace/codex/rollout.ts @@ -1,4 +1,4 @@ -import type { Entry } from '../../../../shared/types/transcript' +import type { Entry } from '@shared/types/transcript' import { codexAssistantTextEntry, diff --git a/src/renderer/src/workspace/entries/utils.ts b/src/renderer/src/workspace/entries/utils.ts index 9221ac37c..38820da6f 100644 --- a/src/renderer/src/workspace/entries/utils.ts +++ b/src/renderer/src/workspace/entries/utils.ts @@ -3,7 +3,7 @@ import { type Entry, type ToolResultBlock, type ToolUseBlock, -} from '../../../../shared/types/transcript' +} from '@shared/types/transcript' // Shared entry utilities used by the workspace store, feed-debug log, // and the bulk jsonl ingest path. Provider-agnostic: Claude and Codex diff --git a/src/renderer/src/workspace/ghosts.ts b/src/renderer/src/workspace/ghosts.ts index ab19ba6a3..24dd26255 100644 --- a/src/renderer/src/workspace/ghosts.ts +++ b/src/renderer/src/workspace/ghosts.ts @@ -71,8 +71,8 @@ import { type GhostEntry, } from 'agent-transcript-parser/ghost' -import type { Entry } from '../../../shared/types/transcript' -import { isConversationEntry } from '../../../shared/types/transcript' +import type { Entry } from '@shared/types/transcript' +import { isConversationEntry } from '@shared/types/transcript' import type { SemanticLiveBlock, SemanticLiveTurn, diff --git a/src/renderer/src/workspace/hook/actions/history.ts b/src/renderer/src/workspace/hook/actions/history.ts index 26723005e..f30b88673 100644 --- a/src/renderer/src/workspace/hook/actions/history.ts +++ b/src/renderer/src/workspace/hook/actions/history.ts @@ -2,12 +2,12 @@ import { useCallback } from 'react' import { emptyRuntime, type SessionRuntime } from '../../workspaceState' import type { SessionId } from '../../types' -import type { Entry } from '../../../../../shared/types/transcript' +import type { Entry } from '@shared/types/transcript' import { isCompactBoundaryEntry, isCompactSummaryEntry, isConversationEntry, -} from '../../../../../shared/types/transcript' +} from '@shared/types/transcript' import { codexHistoryMarker, codexTurnIdFromRollout, diff --git a/src/renderer/src/workspace/hook/actions/streaming.ts b/src/renderer/src/workspace/hook/actions/streaming.ts index 0d99ad8f9..1b308a397 100644 --- a/src/renderer/src/workspace/hook/actions/streaming.ts +++ b/src/renderer/src/workspace/hook/actions/streaming.ts @@ -2,7 +2,7 @@ import { useCallback } from 'react' import { emptyRuntime } from '../../workspaceState' import type { SessionId } from '../../types' -import type { Entry } from '../../../../../shared/types/transcript' +import type { Entry } from '@shared/types/transcript' import { appendFeedDebugLog, } from '../../runtime/feedDebug' diff --git a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts index b438c7608..1ac59f4ff 100644 --- a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts +++ b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts @@ -1,12 +1,12 @@ import { useEffect } from 'react' -import type { Entry } from '../../../../../shared/types/transcript' +import type { Entry } from '@shared/types/transcript' import { isCompactBoundaryEntry, isCompactSummaryEntry, isConversationEntry, -} from '../../../../../shared/types/transcript' -import { detectCodexApproval } from '../../../../../shared/parsers/codexScreen' +} from '@shared/types/transcript' +import { detectCodexApproval } from '@shared/parsers/codexScreen' import { emptyRuntime, type SessionRuntime } from '../../workspaceState' import { appendFeedDebugLog, type FeedDebugInput } from '../../runtime/feedDebug' import type { SessionId } from '../../types' diff --git a/src/renderer/src/workspace/mergedEntries.ts b/src/renderer/src/workspace/mergedEntries.ts index 56fbf5c90..30dc3c32c 100644 --- a/src/renderer/src/workspace/mergedEntries.ts +++ b/src/renderer/src/workspace/mergedEntries.ts @@ -16,7 +16,7 @@ // atp doc). This file adds nothing to the merge rules; it only // chooses atp's default options and threads the runtime's state. -import type { Entry } from '../../../shared/types/transcript' +import type { Entry } from '@shared/types/transcript' import type { SessionRuntime } from './workspaceState' /** diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf.tsx b/src/renderer/src/workspace/tile-tree/TileLeaf.tsx index d1c7153cb..60720a90f 100644 --- a/src/renderer/src/workspace/tile-tree/TileLeaf.tsx +++ b/src/renderer/src/workspace/tile-tree/TileLeaf.tsx @@ -1,13 +1,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { extractAssistantInProgress } from '../../../../shared/parsers/extractAssistant' -import { CodexApprovalModal } from '../../../../providers/codex/renderer/CodexApprovalModal' -import { ResumePromptModal } from '../../../../providers/claude/renderer/ResumePromptModal' +import { extractAssistantInProgress } from '@shared/parsers/extractAssistant' +import { CodexApprovalModal } from '@providers/codex/renderer/CodexApprovalModal' +import { ResumePromptModal } from '@providers/claude/renderer/ResumePromptModal' import { useGlobalToast } from '../../ui/GlobalToast' import { Feed } from '../../features/feed/ui/Feed' import type { ScrollInfo } from '../../features/feed/ui/Feed' -import { TrustDialogModal } from '../../../../providers/claude/renderer/TrustDialogModal' -import { SlashCommandPicker } from '../../../../providers/claude/renderer/SlashCommandPicker' +import { TrustDialogModal } from '@providers/claude/renderer/TrustDialogModal' +import { SlashCommandPicker } from '@providers/claude/renderer/SlashCommandPicker' import { extractLatestUserPrompts } from '../../features/workspace/lib/latestUserPrompts' import type { SessionRuntime, Workspace } from '../workspaceStore' import { diff --git a/src/renderer/src/workspace/tile-tree/TileTree.tsx b/src/renderer/src/workspace/tile-tree/TileTree.tsx index 1c910a0b8..e4a48059b 100644 --- a/src/renderer/src/workspace/tile-tree/TileTree.tsx +++ b/src/renderer/src/workspace/tile-tree/TileTree.tsx @@ -1,6 +1,6 @@ import { useCallback, useRef } from 'react' -import { getRendererProvider } from '../../../../providers/registry.renderer' +import { getRendererProvider } from '@providers/registry.renderer' import { TerminalLeaf } from './TerminalLeaf' import type { Workspace } from '../workspaceStore' import type { SessionId, TabId, TileNode } from '../types' diff --git a/src/renderer/src/workspace/workspaceState.ts b/src/renderer/src/workspace/workspaceState.ts index 06f521b91..546fc4712 100644 --- a/src/renderer/src/workspace/workspaceState.ts +++ b/src/renderer/src/workspace/workspaceState.ts @@ -7,7 +7,7 @@ import type { Entry, ToolResultBlock, ToolUseBlock, -} from '../../../shared/types/transcript' +} from '@shared/types/transcript' import type { GhostEntry } from 'agent-transcript-parser/ghost' export type PickerItem = { diff --git a/src/shared/types/transcript.ts b/src/shared/types/transcript.ts index ae5cfbe90..f25359198 100644 --- a/src/shared/types/transcript.ts +++ b/src/shared/types/transcript.ts @@ -2,5 +2,5 @@ // Each provider's transcript types live in their own file; // this file re-exports the union so provider-agnostic code // (Feed framework, workspaceStore) can import a single Entry type. -export * from '../../providers/claude/types/claudeTranscript.js' -export * from '../../providers/codex/types/codexTranscript.js' +export * from '@providers/claude/types/claudeTranscript.js' +export * from '@providers/codex/types/codexTranscript.js' diff --git a/testing/ghost-demo/bridge.mts b/testing/ghost-demo/bridge.mts index 1c30fcd58..cadb8e0ef 100644 --- a/testing/ghost-demo/bridge.mts +++ b/testing/ghost-demo/bridge.mts @@ -27,12 +27,12 @@ import { ghostsToPersist, orphanStale, reconcileUpstream, -} from '../../src/renderer/src/workspace/ghosts.js' +} from '@renderer/workspace/ghosts.js' import type { SemanticLiveTurn, SemanticLiveBlock, -} from '../../src/renderer/src/workspace/workspaceState.js' -import type { Entry } from '../../src/shared/types/transcript.js' +} from '@renderer/workspace/workspaceState.js' +import type { Entry } from '@shared/types/transcript.js' let failed = 0 function check(label: string, ok: boolean, detail?: string): void { diff --git a/testing/regression-claude-tool-result.ts b/testing/regression-claude-tool-result.ts index 12bb4e5c6..af1012a35 100644 --- a/testing/regression-claude-tool-result.ts +++ b/testing/regression-claude-tool-result.ts @@ -1,7 +1,7 @@ #!/usr/bin/env tsx import assert from 'node:assert/strict' -import type { SemanticRuntimeState } from '../src/renderer/src/workspace/workspaceState.ts' +import type { SemanticRuntimeState } from '@renderer/workspace/workspaceState.ts' function baseState(): SemanticRuntimeState { const state: SemanticRuntimeState = { @@ -84,7 +84,7 @@ async function main(): Promise { api: {}, }, }) - const { foldSemanticEvent } = await import('../src/renderer/src/workspace/workspaceStore.ts') + const { foldSemanticEvent } = await import('@renderer/workspace/workspaceStore.ts') const next = foldSemanticEvent( baseState(), diff --git a/testing/rendering/main.ts b/testing/rendering/main.ts index d6a3c4644..d97a290b9 100644 --- a/testing/rendering/main.ts +++ b/testing/rendering/main.ts @@ -44,16 +44,16 @@ import { fileURLToPath } from 'url' import { homedir } from 'os' import { readdir, readFile, stat } from 'fs/promises' -import { SessionManager } from '../../src/main/sessionManager.js' +import { SessionManager } from '@main/sessionManager.js' import { listAllClaudeSessions, type SessionInfo as ClaudeSessionInfo, -} from '../../src/providers/claude/runtime/sessionList.js' +} from '@providers/claude/runtime/sessionList.js' import { listCodexSessions, type CodexSessionInfo, -} from '../../src/providers/codex/runtime/sessionList.js' -import { getMainProvider } from '../../src/providers/registry.main.js' +} from '@providers/codex/runtime/sessionList.js' +import { getMainProvider } from '@providers/registry.main.js' type AgentKind = 'claude' | 'codex' @@ -363,7 +363,7 @@ function createWindow(): void { // 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'), + preload: join(__dirname, '@preload/index.mjs'), sandbox: false, contextIsolation: true, nodeIntegration: false, diff --git a/testing/rendering/renderer/RenderingHarnessApp.tsx b/testing/rendering/renderer/RenderingHarnessApp.tsx index a40db004a..2908327bc 100644 --- a/testing/rendering/renderer/RenderingHarnessApp.tsx +++ b/testing/rendering/renderer/RenderingHarnessApp.tsx @@ -13,12 +13,12 @@ import { extractEmbeddedClaudeProgressEntry, foldSemanticEvent, mapCodexRolloutToFeedEntries, -} from '../../../src/renderer/src/workspace/workspaceStore' +} from '@renderer/workspace/workspaceStore' import { emptySemanticRuntime, type SemanticLiveTurn, type SemanticRuntimeState, -} from '../../../src/renderer/src/workspace/workspaceState' +} from '@renderer/workspace/workspaceState' import { isCompactBoundaryEntry, isCompactSummaryEntry, @@ -26,10 +26,10 @@ import { type Entry, type ToolResultBlock, type ToolUseBlock, -} from '../../../src/shared/types/transcript' -import { extractAssistantInProgress } from '../../../src/shared/parsers/extractAssistant' -import { applyTheme } from '../../../src/renderer/src/state/settings/theme' -import { DEFAULT_SETTINGS } from '../../../src/renderer/src/state/settings/types' +} from '@shared/types/transcript' +import { extractAssistantInProgress } from '@shared/parsers/extractAssistant' +import { applyTheme } from '@renderer/state/settings/theme' +import { DEFAULT_SETTINGS } from '@renderer/state/settings/types' // Rendering Debug Harness. // diff --git a/testing/rendering/renderer/components/ClaudeRows.tsx b/testing/rendering/renderer/components/ClaudeRows.tsx index 94a18850c..8644dafb5 100644 --- a/testing/rendering/renderer/components/ClaudeRows.tsx +++ b/testing/rendering/renderer/components/ClaudeRows.tsx @@ -12,9 +12,9 @@ import { memo, useContext, useMemo } from 'react' import hljs from 'highlight.js' -import { normalizeCodeLanguage } from '../../../../src/shared/code/language' -import { diffLines, type DiffLine } from '../../../../src/shared/parsers/lineDiff' -import type { ToolUseBlock } from '../../../../src/shared/types/transcript' +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' diff --git a/testing/rendering/renderer/components/CodeBlock.tsx b/testing/rendering/renderer/components/CodeBlock.tsx index 88b2ba271..d65e55bd2 100644 --- a/testing/rendering/renderer/components/CodeBlock.tsx +++ b/testing/rendering/renderer/components/CodeBlock.tsx @@ -5,7 +5,7 @@ import { languageFileExtension, normalizeCodeLanguage, supportsLsp, -} from '../../../../src/shared/code/language' +} from '@shared/code/language' type Props = { code: string diff --git a/testing/rendering/renderer/components/CodexRows.tsx b/testing/rendering/renderer/components/CodexRows.tsx index e3dd32131..f9106afc3 100644 --- a/testing/rendering/renderer/components/CodexRows.tsx +++ b/testing/rendering/renderer/components/CodexRows.tsx @@ -2,7 +2,7 @@ import { memo, useContext } from 'react' import { CodeBlock } from './CodeBlock' import { CodeRenderContext, MarkerRow } from './Feed' -import type { ToolResultBlock, ToolUseBlock } from '../../../../src/shared/types/transcript' +import type { ToolResultBlock, ToolUseBlock } from '@shared/types/transcript' function asRecord(value: unknown): Record | null { return value && typeof value === 'object' ? value as Record : null diff --git a/testing/rendering/renderer/components/Feed.tsx b/testing/rendering/renderer/components/Feed.tsx index a0812f466..33cd286fc 100644 --- a/testing/rendering/renderer/components/Feed.tsx +++ b/testing/rendering/renderer/components/Feed.tsx @@ -33,16 +33,16 @@ import { type Entry, type ToolResultBlock, type ToolUseBlock, -} from '../../../../src/shared/types/transcript' +} from '@shared/types/transcript' import { CodeBlock } from './CodeBlock' -import { detectGitIntent } from '../../../../src/shared/git/gitDetect' +import { detectGitIntent } from '@shared/git/gitDetect' import { GitCardRow } from './GitRows' -import { useAppStore } from '../../../../src/renderer/src/state/hooks' +import { useAppStore } from '@renderer/state/hooks' import { parseSemanticTodos, type SemanticLiveTurn, type SemanticTodoItem, -} from '../../../../src/renderer/src/workspace/workspaceState' +} from '@renderer/workspace/workspaceState' // ----------------------------------------------------------------------------- // Feed — Claude Code TUI-style inline rendering. diff --git a/testing/rendering/renderer/components/GitRows.tsx b/testing/rendering/renderer/components/GitRows.tsx index 82ba4a5bd..49301ec70 100644 --- a/testing/rendering/renderer/components/GitRows.tsx +++ b/testing/rendering/renderer/components/GitRows.tsx @@ -31,8 +31,8 @@ import { memo, useMemo, useState } from 'react' import hljs from 'highlight.js' import { MarkerRow } from './Feed' -import { normalizeCodeLanguage } from '../../../../src/shared/code/language' -import type { GitIntent } from '../../../../src/shared/git/gitDetect' +import { normalizeCodeLanguage } from '@shared/code/language' +import type { GitIntent } from '@shared/git/gitDetect' import { parseGitCommit, parseGitLog, @@ -42,7 +42,7 @@ import { stripAnsi, type GitDiffFile, type GitDiffLine, -} from '../../../../src/shared/git/gitParse' +} from '@shared/git/gitParse' // --------------------------------------------------------------------------- // Shared chrome diff --git a/testing/rendering/renderer/components/monacoRuntime.ts b/testing/rendering/renderer/components/monacoRuntime.ts index 32866c262..3ac51b2e3 100644 --- a/testing/rendering/renderer/components/monacoRuntime.ts +++ b/testing/rendering/renderer/components/monacoRuntime.ts @@ -3,7 +3,7 @@ import type * as Monaco from 'monaco-editor' import { normalizeCodeLanguage, supportsLsp, -} from '../../../../src/shared/code/language' +} 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' diff --git a/testing/rendering/renderer/main.tsx b/testing/rendering/renderer/main.tsx index e7dc688ab..0f15157be 100644 --- a/testing/rendering/renderer/main.tsx +++ b/testing/rendering/renderer/main.tsx @@ -1,10 +1,10 @@ import React from 'react' import { createRoot } from 'react-dom/client' -import { GlobalToastProvider } from '../../../src/renderer/src/GlobalToast' +import { GlobalToastProvider } from '@renderer/GlobalToast' import { RenderingHarnessApp } from './RenderingHarnessApp' -import '../../../src/renderer/src/styles.css' +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' diff --git a/tsconfig.node.json b/tsconfig.node.json index fa0212ef2..af6073316 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -19,10 +19,27 @@ "resolveJsonModule": true, "isolatedModules": true, "types": ["node"], + // baseUrl + paths give us absolute imports (`@shared/types/transcript` + // instead of `../../../shared/types/transcript`). Deep relative paths + // were the #1 source of move-breakage during the refactor — they + // encode each file's relation to its target, so any move on either + // side invalidates them. Absolute paths only break when the TARGET + // moves, cutting the rewrite cost of future refactors in half. + // + // Vite needs a parallel `resolve.alias` config — see + // electron.vite.config.ts. TSC and Vite agree on the alias map; if + // they diverge, tsc will green-light something the runtime can't + // actually load. + "baseUrl": ".", "paths": { "claude-code-headless": ["./claude-code-headless/src/index.ts"], "codex-headless": ["./codex-headless/src/index.ts"], - "agent-transcript-parser": ["./agent-transcript-parser/src/index.ts"] + "agent-transcript-parser": ["./agent-transcript-parser/src/index.ts"], + "@main/*": ["./src/main/*"], + "@preload/*": ["./src/preload/*"], + "@renderer/*": ["./src/renderer/src/*"], + "@shared/*": ["./src/shared/*"], + "@providers/*": ["./src/providers/*"] } }, "include": [ diff --git a/tsconfig.web.json b/tsconfig.web.json index 1b590b14a..721344790 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -20,7 +20,18 @@ "resolveJsonModule": true, "isolatedModules": true, "useDefineForClassFields": true, - "types": ["node"] + "types": ["node"], + // See tsconfig.node.json for the full rationale. Both projects + // declare the same alias map so imports resolve identically + // whether tsc is checking the main/preload or the renderer half. + "baseUrl": ".", + "paths": { + "@main/*": ["./src/main/*"], + "@preload/*": ["./src/preload/*"], + "@renderer/*": ["./src/renderer/src/*"], + "@shared/*": ["./src/shared/*"], + "@providers/*": ["./src/providers/*"] + } }, "include": [ "src/renderer/**/*", From 27a28cd02a0de7fd5aca005294c5d97fe018a017 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 23 Apr 2026 20:19:36 +0200 Subject: [PATCH 11/27] fix(workspace): memoize refs factory to stop IPC sub-hook thrash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/renderer/src/workspace/hook/refs.ts | 79 +++++++++++++++++++------ 1 file changed, 62 insertions(+), 17 deletions(-) diff --git a/src/renderer/src/workspace/hook/refs.ts b/src/renderer/src/workspace/hook/refs.ts index 95d7c2183..b44949134 100644 --- a/src/renderer/src/workspace/hook/refs.ts +++ b/src/renderer/src/workspace/hook/refs.ts @@ -1,4 +1,4 @@ -import { useRef, type MutableRefObject } from 'react' +import { useMemo, useRef, type MutableRefObject } from 'react' import { UndoCloseStack } from '../../lib/undoClose' import type { @@ -47,64 +47,109 @@ export function useWorkspaceRefs( dangerousAgentsEnabled: boolean, useProxyStreaming: boolean, ): WorkspaceRefs { - return { + // Each `useRef(...)` already hands back a stable ref OBJECT across + // renders. But the surrounding `{ ... }` literal does NOT — without + // memoization this factory returns a fresh object every render, so + // any sub-hook that lists `refs` in a useEffect / useCallback dep + // array (notably useIpcSubscriptions) would tear down + re-attach + // its subscriptions on every workspace re-render. The IPC effect's + // cleanup specifically does: + // + // for (const t of refs.bootstrapTimersRef.current.values()) clearTimeout(t) + // refs.bootstrapTimersRef.current.clear() + // + // …so the constant teardown was actively killing the 150 ms + // debounce timer 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, and Feed's + // auto-scroll + LazyEntry observers stayed gated forever. The + // user-visible result was the resume "starts above the eager tail + // and scrolling up never lazy-loads" regression. + // + // The pre-decomposition god-hook had this same effect with + // `useEffect(..., [updateRuntime])` (a stable callback), so it + // never tripped. Memoizing the wrapper restores stable identity + // for the new sub-hook composition without touching every + // downstream dep array. + const stateRef = useRef(initialState) + const latestStateRef = useRef(initialState) + const latestRuntimesRef = useRef(initialRuntimes) + const latestTileTabsRef = useRef(initialTileTabs) + const dangerousAgentsRef = useRef(dangerousAgentsEnabled) + const useProxyStreamingRef = useRef(useProxyStreaming) + const seenUuidsRef = useRef>>({}) + const latestScreenRef = useRef>({}) + const undoStackRef = useRef(new UndoCloseStack()) + const bootstrapTimersRef = useRef>>(new Map()) + const persistedFeedDebugIdRef = useRef>({}) + const paneToastTimers = useRef>>({}) + const saveTimerRef = useRef | null>(null) + const bootRef = useRef(false) + return useMemo( + () => ({ // Ref mirror of state so IPC callbacks (which close over stale // state) can read the current session metadata (e.g. kind) // without causing re-subscriptions on every state change. - stateRef: useRef(initialState), + stateRef, // Parallel to stateRef; the name `latestStateRef` is kept because // the save-to-disk path refers to it explicitly. Both point at // the same zustand slice but the caller updates them separately // so downstream readers can opt into whichever name is clearer. - latestStateRef: useRef(initialState), + latestStateRef, // Ref mirror of runtimes so the debounced save callback can read // current drafts without re-creating the callback on every render. - latestRuntimesRef: useRef(initialRuntimes), - latestTileTabsRef: useRef(initialTileTabs), + latestRuntimesRef, + latestTileTabsRef, // Settings mirror refs. Ref-mirrored so the spawn callbacks read // the live value without having to subscribe per-call. - dangerousAgentsRef: useRef(dangerousAgentsEnabled), - useProxyStreamingRef: useRef(useProxyStreaming), + dangerousAgentsRef, + useProxyStreamingRef, // Seen uuids per session, for JSONL dedup. Refs because we never // render against them — they're bookkeeping. - seenUuidsRef: useRef>>({}), + seenUuidsRef, // Latest screen per session — mirrored from state into a ref so // the Enter handler in TileLeaf can capture a baseline // synchronously. - latestScreenRef: useRef>({}), + latestScreenRef, // Undo-close stack — mutable ref because the stack is imperative // (push/pop) and we don't want React re-renders on every close. // The undoClose action reads it and the command palette peeks at // .length to show/hide the command. - undoStackRef: useRef(new UndoCloseStack()), + undoStackRef, // Per-session setTimeout ids used to debounce the `bootstrapping` // flag back to false after a bulk jsonl-entries burst. Keyed by // sessionId; cleared in the IPC-effect cleanup and in killSession. // Ref (not state) because the timer handle is irrelevant to // rendering — we just need it alive across the hook's ticks. - bootstrapTimersRef: useRef>>(new Map()), + bootstrapTimersRef, // Tracks the largest feed-debug entry id we've shipped to main // per session. Prevents re-shipping entries we've already written. - persistedFeedDebugIdRef: useRef>({}), + persistedFeedDebugIdRef, // Per-session pane toast timers. Single-slot per session — a // second toast replaces the first and resets this timer. - paneToastTimers: useRef>>({}), + paneToastTimers, // Debounced workspace-save timer. `setTimeout(flushSave, 400)` on // every mutation; the beforeunload handler cancels this and // flushes synchronously. - saveTimerRef: useRef | null>(null), + saveTimerRef, // Guard for the once-only bootstrap effect. Under React 18 // StrictMode the effect runs twice; the ref makes the second // pass a no-op. - bootRef: useRef(false), - } + bootRef, + }), + // Empty deps — every property is a ref whose identity never + // changes across renders, so the wrapper object can be frozen + // at first render too. + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ) } From a199b783c993c023015c17316a1600d5964f1f0a Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 23 Apr 2026 20:19:55 +0200 Subject: [PATCH 12/27] refactor(build): fix node/renderer tsc boundary, delete dead providers config bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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) --- src/providers/claude/config.ts | 27 ------- src/providers/codex/config.ts | 21 ------ src/providers/registry.ts | 26 ------- src/renderer/src/workspace/workspaceStore.ts | 6 ++ src/shared/markdown/StreamingProse.tsx | 29 -------- src/shared/markdown/TextProse.tsx | 31 -------- src/shared/markdown/index.ts | 3 - src/shared/markdown/plugins.ts | 14 ---- src/shared/types/providerConfig.ts | 21 ++++-- src/shared/ui/LazyEntry.tsx | 74 -------------------- src/shared/ui/MarkerRow.tsx | 36 ---------- src/shared/ui/UserBand.tsx | 13 ---- src/shared/ui/index.ts | 9 --- tsconfig.node.json | 33 +++++++++ tsconfig.web.json | 7 ++ 15 files changed, 63 insertions(+), 287 deletions(-) delete mode 100644 src/providers/claude/config.ts delete mode 100644 src/providers/codex/config.ts delete mode 100644 src/providers/registry.ts delete mode 100644 src/shared/markdown/StreamingProse.tsx delete mode 100644 src/shared/markdown/TextProse.tsx delete mode 100644 src/shared/markdown/index.ts delete mode 100644 src/shared/markdown/plugins.ts delete mode 100644 src/shared/ui/LazyEntry.tsx delete mode 100644 src/shared/ui/MarkerRow.tsx delete mode 100644 src/shared/ui/UserBand.tsx delete mode 100644 src/shared/ui/index.ts diff --git a/src/providers/claude/config.ts b/src/providers/claude/config.ts deleted file mode 100644 index 2195c68e6..000000000 --- a/src/providers/claude/config.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Claude provider config — the shell's only import path into Claude code. -// -// Wires up Claude's session factory, session lister, screen parser, -// and TileLeaf component. The shell mounts config.TileLeaf inside -// TileTree; everything else (slash picker, trust dialog, Claude-specific -// rows) lives inside that component and the shell never knows about it. - -import type { ProviderConfig } from '@shared/types/providerConfig' -import { ClaudeSession } from './runtime/claudeSession' -import { listSessionsForCwd } from './runtime/sessionList' -import { getProjectDirForCwd } from '@shared/runtime/projectDir' -import { extractAssistantInProgress } from './parsers/streamingScreen' - -// For now, re-use the shared TileLeaf which already handles Claude. -// When we create a Claude-specific TileLeaf, this import changes to -// point at ./renderer/TileLeaf instead. -import { TileLeaf } from '@renderer/workspace/tile-tree/TileLeaf' - -export const claudeConfig: ProviderConfig = { - id: 'claude', - name: 'Claude Code', - createSession: (opts) => new ClaudeSession(opts), - listSessions: listSessionsForCwd, - getProjectDir: getProjectDirForCwd, - extractAssistantInProgress, - TileLeaf: TileLeaf as ProviderConfig['TileLeaf'], -} diff --git a/src/providers/codex/config.ts b/src/providers/codex/config.ts deleted file mode 100644 index a0939ff48..000000000 --- a/src/providers/codex/config.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Codex provider config — the shell's only import path into Codex code. - -import type { ProviderConfig } from '@shared/types/providerConfig' -import { CodexSession } from './runtime/codexSession' -import { listCodexSessions } from './runtime/sessionList' -import { getCodexSessionsDir } from './runtime/projectDir' -import { extractCodexAssistantInProgress } from './parsers/streamingScreen' - -// For now, re-use the shared TileLeaf which already handles Codex. -// When we create a Codex-specific TileLeaf, this import changes. -import { TileLeaf } from '@renderer/workspace/tile-tree/TileLeaf' - -export const codexConfig: ProviderConfig = { - id: 'codex', - name: 'Codex', - createSession: (opts) => new CodexSession(opts), - listSessions: (cwd, limit) => listCodexSessions(cwd, limit), - getProjectDir: async () => getCodexSessionsDir(), - extractAssistantInProgress: extractCodexAssistantInProgress, - TileLeaf: TileLeaf as ProviderConfig['TileLeaf'], -} diff --git a/src/providers/registry.ts b/src/providers/registry.ts deleted file mode 100644 index 1a72fbf2e..000000000 --- a/src/providers/registry.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Provider registry — the ONLY file the shell imports from src/providers/. -// -// This is the firewall between provider code and shell code. The shell -// calls getProvider(id) to look up a config; it never imports from -// ./claude/ or ./codex/ directly. That means a change inside one -// provider's directory physically cannot break the other provider or -// the shell — the import graph enforces it. - -import type { ProviderConfig } from '@shared/types/providerConfig' -import { claudeConfig } from './claude/config' -import { codexConfig } from './codex/config' - -const providers: Record = { - claude: claudeConfig, - codex: codexConfig, -} - -export function getProvider(id: string): ProviderConfig { - const p = providers[id] - if (!p) throw new Error(`Unknown provider: ${id}`) - return p -} - -export function getAllProviders(): ProviderConfig[] { - return Object.values(providers) -} diff --git a/src/renderer/src/workspace/workspaceStore.ts b/src/renderer/src/workspace/workspaceStore.ts index d1551dcae..d90869b4d 100644 --- a/src/renderer/src/workspace/workspaceStore.ts +++ b/src/renderer/src/workspace/workspaceStore.ts @@ -44,3 +44,9 @@ export type { SpotlightState, TileTabsState, } from './workspaceState' + +// Re-exported from ./types so external callers (ReaderView, etc.) +// can keep importing from '../workspace/workspaceStore' without +// reaching into the internal types module. SessionId is a plain +// string alias but callers use the name for documentation. +export type { SessionId } from './types' diff --git a/src/shared/markdown/StreamingProse.tsx b/src/shared/markdown/StreamingProse.tsx deleted file mode 100644 index 37205907b..000000000 --- a/src/shared/markdown/StreamingProse.tsx +++ /dev/null @@ -1,29 +0,0 @@ -// StreamingProse — memo'd ReactMarkdown for screen-buffer streaming text. -// -// Same as TextProse but uses remark-breaks so hard newlines from the -// screen buffer survive as
. CC's screen re-renders fire at ~60Hz -// and the extracted text is usually identical between frames — memoing -// by text string makes those redundant frames free. - -import { memo } from 'react' -import ReactMarkdown from 'react-markdown' -import { STREAMING_REMARK } from './plugins' - -type Props = { - text: string - components?: import('react-markdown').Options['components'] -} - -export const StreamingProse = memo(function StreamingProse({ text, components }: Props) { - if (!text) return null - return ( -
- - {text} - -
- ) -}) diff --git a/src/shared/markdown/TextProse.tsx b/src/shared/markdown/TextProse.tsx deleted file mode 100644 index 14a97ba34..000000000 --- a/src/shared/markdown/TextProse.tsx +++ /dev/null @@ -1,31 +0,0 @@ -// TextProse — memo'd ReactMarkdown wrapper for completed JSONL entries. -// -// The single biggest perf win in the feed: 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. - -import { memo } from 'react' -import ReactMarkdown from 'react-markdown' -import { COMPLETED_REMARK } from './plugins' - -// MARKDOWN_COMPONENTS is provider-specific (it depends on CodeBlock and -// CodeRenderContext). Callers pass it in so both providers can supply -// their own code renderer while sharing the text prose wrapper. -type Props = { - text: string - components?: import('react-markdown').Options['components'] -} - -export const TextProse = memo(function TextProse({ text, components }: Props) { - if (!text) return null - return ( -
- - {text} - -
- ) -}) diff --git a/src/shared/markdown/index.ts b/src/shared/markdown/index.ts deleted file mode 100644 index 2ad775ed5..000000000 --- a/src/shared/markdown/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { TextProse } from './TextProse' -export { StreamingProse } from './StreamingProse' -export { COMPLETED_REMARK, STREAMING_REMARK } from './plugins' diff --git a/src/shared/markdown/plugins.ts b/src/shared/markdown/plugins.ts deleted file mode 100644 index 3773cb2a2..000000000 --- a/src/shared/markdown/plugins.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Markdown plugin sets — defined at module scope so react-markdown v10 -// caches parse results keyed on plugin identity. Fresh array literals -// on every render would bust the cache. - -import remarkGfm from 'remark-gfm' -import remarkBreaks from 'remark-breaks' - -// Completed text from JSONL: real markdown, standard rules. -export const COMPLETED_REMARK = [remarkGfm] - -// Streaming text from screen buffer: single newlines are load-bearing -// (each line is a genuine line, not a soft wrap). remark-breaks turns -// each hard newline into a
. -export const STREAMING_REMARK = [remarkGfm, remarkBreaks] diff --git a/src/shared/types/providerConfig.ts b/src/shared/types/providerConfig.ts index 91237f7e7..1f3e49d67 100644 --- a/src/shared/types/providerConfig.ts +++ b/src/shared/types/providerConfig.ts @@ -2,7 +2,16 @@ // // The renderer bundle can't import Node modules (node-pty, chokidar, // fs). The main process can't import React components. So the config -// is two interfaces, each with its own registry. +// is two interfaces, each with its own registry. Hard split — there +// is NO combined `ProviderConfig` type. A previous version of this +// file exported one, and also shipped `providers//config.ts` +// files that implemented it by importing both `ClaudeSession` +// (Node-only) and `TileLeaf` (React-only). That was the bridge that +// caused the node tsconfig to walk into renderer files and emit the +// JSX-not-set cascade across ~500 lines of error output. Both halves +// and their registries are now strictly separate; `registry.main.ts` +// builds MainProviderConfig, `registry.renderer.ts` builds +// RendererProviderConfig, and nothing re-joins them. import type { ComponentType } from 'react' import type { SessionOptions, SessionInfo } from './session.js' @@ -44,6 +53,10 @@ export type MainProviderConfig = { getProjectDir: (cwd: string) => Promise } -/** Full config — union of both halves. Used in provider config files - * that export both sides. */ -export type ProviderConfig = RendererProviderConfig & MainProviderConfig +// The combined `ProviderConfig = RendererProviderConfig & MainProviderConfig` +// type used to live here. It was only ever used by +// `providers//config.ts` files that implemented the full +// surface, which forced those files to import BOTH TileLeaf (React) +// AND ClaudeSession (Node). That cross-boundary import was the +// source of the renderer-in-node tsc cascade. Type removed; use the +// one-sided types above. diff --git a/src/shared/ui/LazyEntry.tsx b/src/shared/ui/LazyEntry.tsx deleted file mode 100644 index ef80dc8df..000000000 --- a/src/shared/ui/LazyEntry.tsx +++ /dev/null @@ -1,74 +0,0 @@ -// LazyEntry — IntersectionObserver-based deferred mounting for feed entries. -// -// Entries above the viewport start as a thin placeholder. When the user -// scrolls up to them, the real content mounts. Once mounted, stays -// mounted permanently — React.memo's cached render tree survives, -// avoiding re-parse costs that virtualization (unmount/remount) would cause. -// -// `suspended` is set by Feed while the owning session is in a bootstrap -// burst. The rationale: during bootstrap we are about to land ~200 -// entries at once, and every placeholder within rootMargin of the -// viewport would mount in the SAME render — which is the lazy-mount -// cascade that makes restart feel like you're being scrolled through -// the whole transcript. While suspended, the observer isn't attached -// and the placeholder stays a 48px stub. When the parent flips the -// prop back to false, the observer attaches and the normal flow -// resumes. - -import { memo, useEffect, useRef, useState, type ReactNode } from 'react' - -// The last EAGER_TAIL entries render immediately — they're in/near the -// viewport. Everything above starts lazy. 30 entries covers ~2-3 -// screenfuls so the user never sees a placeholder flash on initial load. -export const EAGER_TAIL = 30 - -export const LazyEntry = memo(function LazyEntry({ - eager, - suspended = false, - scrollerRef, - children, -}: { - eager: boolean - 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, - // mount it immediately. - useEffect(() => { - if (eager && !mounted) setMounted(true) - }, [eager, mounted]) - - useEffect(() => { - if (mounted) return - // Skip observer attachment while the session is in a bootstrap - // burst — see component block comment for the cascade rationale. - // When `suspended` flips back to false Feed will have pinned to - // the bottom once, and normal scroll-triggered lazy mounts - // resume from there. - if (suspended) return - const el = placeholderRef.current - if (!el) return - - 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) { - return
- } - - return <>{children} -}) diff --git a/src/shared/ui/MarkerRow.tsx b/src/shared/ui/MarkerRow.tsx deleted file mode 100644 index 8e07f48cb..000000000 --- a/src/shared/ui/MarkerRow.tsx +++ /dev/null @@ -1,36 +0,0 @@ -// MarkerRow — the core hanging-indent layout used by every feed entry. -// -// A fixed-width marker column (❯, ⏺, ⎿) + a flex-1 content column. -// Long lines wrap under the content column only, never creeping back -// under the marker. Both providers compose this for every block type. - -import type { ReactNode } from 'react' - -export function MarkerRow({ - marker, - tone = 'accent', - children, - indent = 0, -}: { - marker: string - tone?: 'accent' | 'muted' | 'ink' - children: ReactNode - indent?: number -}) { - const toneClass = - tone === 'accent' ? 'text-accent' : tone === 'muted' ? 'text-muted' : 'text-ink-dim' - return ( -
- -
{children}
-
- ) -} diff --git a/src/shared/ui/UserBand.tsx b/src/shared/ui/UserBand.tsx deleted file mode 100644 index e1523a355..000000000 --- a/src/shared/ui/UserBand.tsx +++ /dev/null @@ -1,13 +0,0 @@ -// UserBand — subtle background highlight behind user prompt entries. -// Both providers use this to visually distinguish user messages from -// assistant output without bubbles or cards. - -import type { ReactNode } from 'react' - -export function UserBand({ children }: { children: ReactNode }) { - return ( -
- {children} -
- ) -} diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts deleted file mode 100644 index dcc5c5c3b..000000000 --- a/src/shared/ui/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { MarkerRow } from './MarkerRow' -export { LazyEntry, EAGER_TAIL } from './LazyEntry' -export { UserBand } from './UserBand' -// `ActivityIndicator` removed 2026-04-18 — replaced in-feed by -// `src/renderer/src/feed/WorkIndicator.tsx`, driven by -// `runtime.streamPhase`. The old export was never imported anywhere -// (the Feed had its own local duplicate); this comment stands in so -// future archaeologists see the rename instead of wondering why -// `ActivityIndicator` vanished. diff --git a/tsconfig.node.json b/tsconfig.node.json index af6073316..d5d9b5542 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -53,5 +53,38 @@ "agent-transcript-parser/src/**/*", "claude-code-headless/src/**/*", "codex-headless/src/**/*" + ], + "exclude": [ + // Provider renderer halves live under the node project's + // `src/providers/**/*` include (so the *.ts runtime files type- + // check), but the *.tsx row + modal components inside them are + // renderer-only. Without this exclude, tsc walks into + // `providers//renderer/**/*.tsx` and emits ~530 + // "Cannot use JSX unless the '--jsx' flag is provided" errors, + // plus cascade "`window.api` doesn't exist" noise as it drags + // in renderer imports transitively. + // + // These files ARE type-checked by the web project, which has + // `src/providers/*/renderer/**/*` in its include and `jsx: + // react-jsx` in its compiler options. Excluding them here keeps + // the node project strictly node-typed and eliminates the + // single biggest source of pre-existing tsc noise in this + // codebase. + "src/providers/*/renderer/**/*", + // registry.renderer.ts lives at providers/ root (not under + // providers//renderer/), so the glob above doesn't catch + // it. It imports TileLeaf from @renderer/workspace/tile-tree/, + // which transitively pulls the entire renderer subtree into + // the node program. Explicit exclude stops that walk at the + // file. + "src/providers/registry.renderer.ts", + // 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/**/*" ] } diff --git a/tsconfig.web.json b/tsconfig.web.json index 721344790..e46bc1ccb 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -41,6 +41,13 @@ "src/shared/parsers/**/*", "src/providers/*/parsers/**/*", "src/providers/*/renderer/**/*", + // registry.renderer.ts lives at providers/ root, outside the + // `*/renderer/**/*` glob above. TileTree imports it to mount + // provider-specific TileLeaf components, so the web project + // needs the file explicitly. (The node project *excludes* this + // file — see tsconfig.node.json — because it transitively pulls + // the entire renderer subtree in.) + "src/providers/registry.renderer.ts", "testing/**/*" ], // Project reference: tell tsc to resolve preload imports via node's From e221260a747ccbfc183c4a61338053f4808022b1 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 23 Apr 2026 20:28:02 +0200 Subject: [PATCH 13/27] refactor(feed): wire Feed.tsx to extracted context/types/helpers/scroll/remark modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/renderer/src/features/feed/lib/helpers.ts | 152 ++++-- src/renderer/src/features/feed/ui/Feed.tsx | 477 +++--------------- 2 files changed, 164 insertions(+), 465 deletions(-) diff --git a/src/renderer/src/features/feed/lib/helpers.ts b/src/renderer/src/features/feed/lib/helpers.ts index 63e6798d7..e8ef117b3 100644 --- a/src/renderer/src/features/feed/lib/helpers.ts +++ b/src/renderer/src/features/feed/lib/helpers.ts @@ -84,22 +84,45 @@ export function toolResultText(block: ToolResultBlock): string { return '' } -/** Truncate long bash commands for the tool-band header. We keep the - * first line and cut after ~160 chars; the full command is still - * available in the expanded view. Matches Claude Code's own - * truncation style so the UI feels consistent across surfaces. */ +// Max lines + chars for the inline bash command display. The two +// caps cut different failure modes: very long single-line pipelines +// (the char cap) and multi-line heredocs (the line cap). Matches +// Claude Code's own TUI truncation so the hover preview feels +// consistent across surfaces. +const MAX_COMMAND_DISPLAY_LINES = 2 +const MAX_COMMAND_DISPLAY_CHARS = 160 + +/** Truncate a bash command string for the tool-band header. Applies + * the LINES cap first, then the CHARS cap against whatever's left, + * and suffixes `…` if anything was dropped. Keeping both caps means + * a heredoc collapses to its first two lines AND a one-liner hex + * dump is truncated mid-line — both happen often enough in practice + * that one cap alone isn't enough. */ export function truncateBashCommand(cmd: string): string { - const firstLine = cmd.split('\n', 1)[0] ?? cmd - if (firstLine.length <= 160) return firstLine - return firstLine.slice(0, 160) + '…' + 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 cmd + 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 truncated.trimEnd() + '…' } -/** Strip the leading "nnn→" line-number markers Claude emits in Read - * tool results. Used before we hand the content to a code renderer - * that does its own line numbering — the markers would otherwise - * double up. */ +/** 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. Used before + * we hand Read-tool content to a code renderer that does its own + * line numbering; otherwise the markers would double up. */ export function stripLineNumberPrefix(text: string): string { - return text.replace(/^\s*\d+→/gm, '') + return text + .split('\n') + .map(line => line.replace(/^\s*\d+\t/, '')) + .join('\n') } /** Build a stable React key for an Entry + its index. Prefers the @@ -167,57 +190,82 @@ export function splitStreamingCodeFence(text: string): { // --------------------------------------------------------------------------- /** Produce a data: URL for an image content block. Returns null if - * the block isn't an image or its source fields aren't set. */ + * the source is missing or not a base64 payload. Gates on + * `source.type === 'base64'` because Anthropic's spec allows both + * "base64" (inline data) and "url" shapes; only the base64 shape + * has the `data` field we need to embed. mediaType defaults to + * 'image/png' when the block omits it — that's the most common + * case for CC-pasted screenshots. */ export function imageDataUrl(block: ContentBlock): string | null { - if (block.type !== 'image') return null - const src = (block as unknown as { source?: Record }).source - if (!src) return null - const mediaType = typeof src.media_type === 'string' ? src.media_type : null - const data = typeof src.data === 'string' ? src.data : null - if (!mediaType || !data) return null + const source = (block as { source?: unknown }).source + if (!source || typeof source !== 'object') return null + const rec = source as Record + if (rec.type !== 'base64') return null + const mediaType = typeof rec.media_type === 'string' ? rec.media_type : 'image/png' + const data = typeof rec.data === 'string' ? rec.data : null + if (!data) return null return `data:${mediaType};base64,${data}` } -/** Extract the human text of a compact-summary entry. Both the - * conversation-entry shape (content array) and the raw-string shape - * (older schema) are supported. */ +/** Extract the human text of a compact-summary entry. Both `text` + * and `thinking` blocks contribute — summaries often start with a + * thinking block that captures the planning step before the final + * text. We join with a blank line between blocks so the two + * render as separate paragraphs. */ export function compactSummaryText(entry: CompactSummaryEntry): string { - const message = entry.message - if (!message) return '' - if (typeof message.content === 'string') return message.content - if (Array.isArray(message.content)) { - return message.content - .map(block => { - const item = block as Record - return item.type === 'text' && typeof item.text === 'string' - ? (item.text as string) - : '' - }) - .join('\n') - .trim() - } - return '' + 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') } -/** Truncate the compact-summary body for the inline preview. */ +/** Truncate the compact-summary body for the inline preview. Two + * caps run in order: + * + * 1. Line cap (>24): collapse to the first 24 lines. Summaries + * can legitimately be long with a tight line budget — a wall + * of 200 one-word lines is still >2400 chars but reads as + * "too long" long before the char cap fires. + * 2. Char cap (>2400): truncate the already-line-capped text at + * 2400 chars, trimming trailing whitespace. + * + * Either truncation appends `\n\n[summary truncated]` as an inline + * signal. The full summary is always available on the expanded + * compact boundary. */ export function truncateCompactSummary(text: string): string { - if (text.length <= 400) return text - return text.slice(0, 400).trimEnd() + '…' + 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 } -/** Label attachment-type entries for the system row (file-history- - * snapshot, hook-attachment, etc.). */ +/** Label attachment-type entries for the system row. Handles three + * shapes: + * - hook attachments: `a.hookEvent` drives the label (prefer + * `hookName` if present, else the event name). + * - typed attachments: `a.type` directly. + * - fallback: literal "attachment". + * + * The hook label uses a "hook: " prefix because the system + * row is a one-line summary and the event name alone ("PreToolUse") + * is opaque — prefixing makes it obvious this came from a hook. */ export function attachmentLabel(entry: Entry): string { - const att = (entry as unknown as { attachment?: Record }).attachment - if (!att) return 'attachment' - const kind = typeof att.type === 'string' ? att.type : 'attachment' - const path = - typeof att.path === 'string' - ? att.path - : typeof att.target === 'string' - ? att.target - : null - return path ? `${kind} · ${path}` : kind + const a = (entry as { attachment?: Record }).attachment ?? {} + if (a.hookEvent) return `hook: ${(a.hookName as string) ?? (a.hookEvent as string)}` + if (a.type) return `attachment: ${a.type as string}` + return 'attachment' } // --------------------------------------------------------------------------- diff --git a/src/renderer/src/features/feed/ui/Feed.tsx b/src/renderer/src/features/feed/ui/Feed.tsx index a217aa260..fbd25bb0d 100644 --- a/src/renderer/src/features/feed/ui/Feed.tsx +++ b/src/renderer/src/features/feed/ui/Feed.tsx @@ -1,5 +1,4 @@ import { - createContext, memo, useContext, useEffect, @@ -10,8 +9,6 @@ import { type ReactNode, } from 'react' import ReactMarkdown from 'react-markdown' -import remarkGfm from 'remark-gfm' -import remarkBreaks from 'remark-breaks' import { EditRow, @@ -47,6 +44,43 @@ import { import { WorkIndicator } from '../WorkIndicator' import { toolHintFromTurn } from '../workIndicatorHints' import { MarkerRow } from './MarkerRow' +import { + ProviderContext, + ToolUseIndexContext, + ToolResultIndexContext, + CodeRenderContext, +} from '../context' +import { + type AgentProvider, + type ScrollInfo, + type VisibleDecision, + type DebugVisibleRow, +} from '../types' +import { scrollPositions } from '../scroll' +import { COMPLETED_REMARK, STREAMING_REMARK } from '../lib/remark-plugins' +import { + buildToolUseIndex, + buildToolResultIndex, + extractToolCommand, + toolResultText, + truncateBashCommand, + stripLineNumberPrefix, + debugKeyForEntry, + debugLabelForEntry, + countFenceMarkers, + splitStreamingCodeFence, + imageDataUrl, + compactSummaryText, + truncateCompactSummary, + attachmentLabel, + classifySemanticToolActivity, +} from '../lib/helpers' + +// Re-export — many external callers import these types from Feed +// directly rather than reaching into ../types/../context. Keep the +// alias stable until the sweep is over. +export type { AgentProvider, ScrollInfo } from '../types' +export { CodeRenderContext } from '../context' // ----------------------------------------------------------------------------- // Feed — Claude Code TUI-style inline rendering. @@ -74,77 +108,11 @@ import { MarkerRow } from './MarkerRow' // 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,
-})
+// COMPLETED_REMARK / STREAMING_REMARK moved to ../lib/remark-plugins.ts
+// ProviderContext + ToolUseIndexContext + ToolResultIndexContext +
+// CodeRenderContext moved to ../context.tsx — see those files for
+// the full rationale. Feed.tsx re-exports CodeRenderContext above
+// to keep external import paths stable.
 
 /**
  * Custom 
 renderer: strips the default 
 wrapper and lets
@@ -244,82 +212,10 @@ const MARKDOWN_COMPONENTS: import('react-markdown').Options['components'] = {
   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 = block.input as Record | undefined
-  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
-}
+// buildToolUseIndex / buildToolResultIndex / extractToolCommand /
+// toolResultText moved to ../lib/helpers.ts. AgentProvider and
+// ScrollInfo types moved to ../types.ts; re-exported at the top of
+// this file.
 
 type Props = {
   /** Session identity — used as the key for per-session scroll
@@ -375,43 +271,8 @@ type Props = {
   }) => void
 }
 
-type VisibleDecision = {
-  key: string
-  entry: Entry
-  visible: boolean
-  reason:
-    | 'compact_boundary'
-    | 'compact_summary'
-    | 'conversation'
-    | 'not_conversation'
-    | 'meta_filtered'
-}
-
-type DebugVisibleRow = {
-  key: string
-  slot: 'entry' | 'semantic' | 'work' | 'empty'
-  label: string
-}
-
-function debugKeyForEntry(entry: Entry, index: number): string {
-  const uuid = (entry as Entry).uuid
-  return uuid ?? `${entry.type}:${index}`
-}
-
-function debugLabelForEntry(entry: Entry): string {
-  if (entry.type === 'user' || entry.type === 'assistant') {
-    const message = (entry as ConversationEntry).message
-    const content = Array.isArray(message.content) ? message.content : []
-    const first = content[0] as Record | undefined
-    if (first?.type === 'text' && typeof first.text === 'string') {
-      return `${entry.type}: ${first.text.replace(/\s+/g, ' ').trim().slice(0, 80)}`
-    }
-    if (typeof first?.type === 'string') {
-      return `${entry.type}: ${String(first.type)}`
-    }
-  }
-  return entry.type
-}
+// VisibleDecision + DebugVisibleRow moved to ../types.ts.
+// debugKeyForEntry + debugLabelForEntry moved to ../lib/helpers.ts.
 
 // 2026-04-20: shouldSuppressSemanticTurnForCommittedTail and its two
 // helpers (textFromConversationEntry, normalizeRenderableText) were
@@ -433,43 +294,9 @@ function debugLabelForEntry(entry: Entry): string {
 //
 // See docs/superpowers/plans/2026-04-20-rendering-fixes.md Task 6.
 
-// ---------------------------------------------------------------------------
-// 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()
+// ScrollPosition type + scrollPositions map moved to ../scroll.ts
+// and ../types.ts respectively — see those files for the "why persist
+// scroll state outside the component tree" rationale.
 
 // -----------------------------------------------------------------------------
 // Memoization strategy — the whole reason this file is fast enough to type in
@@ -1171,30 +998,7 @@ function FeedImpl({
   )
 }
 
-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,
-  }
-}
+// countFenceMarkers + splitStreamingCodeFence moved to ../lib/helpers.ts.
 
 type SemanticRenderUnit =
   | {
@@ -1214,96 +1018,9 @@ type SemanticRenderUnit =
       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 }
-}
+// Bash classifier helpers (COMMAND_START, atCommandPosition,
+// looksLikeSearchCommand/ReadCommand/ListCommand) and
+// classifySemanticToolActivity moved to ../lib/helpers.ts.
 
 function buildSemanticRenderUnits(turn: SemanticLiveTurn): SemanticRenderUnit[] {
   const blocks = Object.values(turn.blocks).sort((a, b) => a.blockIndex - b.blockIndex)
@@ -2150,16 +1867,7 @@ function UserBand({ children }: { children: React.ReactNode }) {
   )
 }
 
-function imageDataUrl(block: ContentBlock): string | null {
-  const source = (block as { source?: unknown }).source
-  if (!source || typeof source !== 'object') return null
-  const rec = source as Record
-  if (rec.type !== 'base64') return null
-  const mediaType = typeof rec.media_type === 'string' ? rec.media_type : 'image/png'
-  const data = typeof rec.data === 'string' ? rec.data : null
-  if (!data) return null
-  return `data:${mediaType};base64,${data}`
-}
+// imageDataUrl moved to ../lib/helpers.ts.
 
 const ImageBlockRow = memo(function ImageBlockRow({
   block,
@@ -2238,31 +1946,9 @@ const CompactSummaryRow = memo(function CompactSummaryRow({
   )
 })
 
-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
-}
+// compactSummaryText + truncateCompactSummary moved to
+// ../lib/helpers.ts. See those for the thinking-block + two-cap
+// truncation rationale.
 
 /**
  * Background band for tool output (Read, Grep, Edit results).
@@ -2490,23 +2176,8 @@ const StreamingProse = memo(function StreamingProse({
 // whole command remains in the transcript; the collapse is purely
 // a density choice so a 20-line heredoc doesn't push the next
 // assistant message below the fold.
-const MAX_COMMAND_DISPLAY_LINES = 2
-const MAX_COMMAND_DISPLAY_CHARS = 160
-
-function truncateBashCommand(cmd: string): string {
-  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 cmd
-  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 truncated.trimEnd() + '…'
-}
+// MAX_COMMAND_DISPLAY_* constants + truncateBashCommand moved to
+// ../lib/helpers.ts.
 
 const ToolUseRow = memo(function ToolUseRow({ block }: { block: ToolUseBlock }) {
   // Extract the command / description for Bash-like tools. For tools
@@ -2733,23 +2404,8 @@ function TruncatedOutputRow({
   )
 }
 
-/**
- * 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')
-}
+// stripLineNumberPrefix moved to ../lib/helpers.ts — see there for
+// the "CC emits 'n\t' in Read tool results" rationale.
 
 /* ---------- System row (hidden by default; shown when toggled on) ---------- */
 
@@ -2771,12 +2427,7 @@ const SystemRow = memo(function SystemRow({ entry }: { entry: Entry }) {
   )
 })
 
-function attachmentLabel(entry: Entry): string {
-  const a = (entry as { attachment?: Record }).attachment ?? {}
-  if (a.hookEvent) return `hook: ${(a.hookName as string) ?? (a.hookEvent as string)}`
-  if (a.type) return `attachment: ${a.type as string}`
-  return 'attachment'
-}
+// attachmentLabel moved to ../lib/helpers.ts.
 
 /* ---------- Streaming row — REMOVED ----------
  *

From 342acce8503cd5e7ffa7dda40cec91b6a8a22b3c Mon Sep 17 00:00:00 2001
From: Julius Olsson 
Date: Thu, 23 Apr 2026 20:31:25 +0200
Subject: [PATCH 14/27] refactor(feed): extract markdown renderers into
 ui/markdown/
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

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) 
---
 src/renderer/src/features/feed/ui/Feed.tsx    | 163 ++----------------
 .../feed/ui/markdown/MarkdownComponents.tsx   | 121 +++++++++++++
 .../src/features/feed/ui/markdown/Prose.tsx   |  63 +++++++
 .../src/features/feed/ui/markdown/index.ts    |   6 +
 4 files changed, 205 insertions(+), 148 deletions(-)
 create mode 100644 src/renderer/src/features/feed/ui/markdown/MarkdownComponents.tsx
 create mode 100644 src/renderer/src/features/feed/ui/markdown/Prose.tsx
 create mode 100644 src/renderer/src/features/feed/ui/markdown/index.ts

diff --git a/src/renderer/src/features/feed/ui/Feed.tsx b/src/renderer/src/features/feed/ui/Feed.tsx
index fbd25bb0d..48faf5b05 100644
--- a/src/renderer/src/features/feed/ui/Feed.tsx
+++ b/src/renderer/src/features/feed/ui/Feed.tsx
@@ -8,7 +8,6 @@ import {
   useState,
   type ReactNode,
 } from 'react'
-import ReactMarkdown from 'react-markdown'
 
 import {
   EditRow,
@@ -57,7 +56,6 @@ import {
   type DebugVisibleRow,
 } from '../types'
 import { scrollPositions } from '../scroll'
-import { COMPLETED_REMARK, STREAMING_REMARK } from '../lib/remark-plugins'
 import {
   buildToolUseIndex,
   buildToolResultIndex,
@@ -75,6 +73,11 @@ import {
   attachmentLabel,
   classifySemanticToolActivity,
 } from '../lib/helpers'
+import {
+  MARKDOWN_COMPONENTS,
+  TextProse,
+  StreamingProse,
+} from './markdown'
 
 // Re-export — many external callers import these types from Feed
 // directly rather than reaching into ../types/../context. Keep the
@@ -114,104 +117,13 @@ export { CodeRenderContext } from '../context'
 // the full rationale. Feed.tsx re-exports CodeRenderContext above
 // to keep external import paths stable.
 
-/**
- * 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,
-}
-
+// MarkdownPre / MarkdownCode / MARKDOWN_COMPONENTS moved to
+// ./markdown/MarkdownComponents.tsx. TextProse and StreamingProse
+// moved to ./markdown/Prose.tsx. Both are re-exported from
+// ./markdown/index.ts — see those for the full rationale on why
+// we override react-markdown's default 
/ renderers and
+// why two remark plugin sets exist.
+//
 // buildToolUseIndex / buildToolResultIndex / extractToolCommand /
 // toolResultText moved to ../lib/helpers.ts. AgentProvider and
 // ScrollInfo types moved to ../types.ts; re-exported at the top of
@@ -2117,54 +2029,9 @@ const Block = memo(function Block({
   }
 })
 
-/* ---------- 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} - -
- ) -}) +// TextProse + StreamingProse moved to ./markdown/Prose.tsx — see +// there for the text-string memo rationale and why TextProse uses +// COMPLETED_REMARK while StreamingProse adds remark-breaks. /* ---------- Tool use: "⏺ Bash ⎿ $ command" ---------- */ diff --git a/src/renderer/src/features/feed/ui/markdown/MarkdownComponents.tsx b/src/renderer/src/features/feed/ui/markdown/MarkdownComponents.tsx new file mode 100644 index 000000000..aad1f5c4f --- /dev/null +++ b/src/renderer/src/features/feed/ui/markdown/MarkdownComponents.tsx @@ -0,0 +1,121 @@ +import { useContext, type ReactNode } from 'react' + +import { CodeBlock } from '../../../../lib/code/CodeBlock' + +import { CodeRenderContext } from '../../context' + +// react-markdown component overrides used inside the feed. +// +// Two custom renderers (pre + code) handle the three cases +// react-markdown v10 emits, which are not uniquely identified by +// any single prop: +// +// 1. Inline code (`variableName` in prose) → plain , styled +// by the prose-theme. +// 2. Unlabeled fenced code (```\ncontent\n```) → passes through +// MarkdownPre to flatten away the default
 wrapper, then
+//      through MarkdownCode which detects "fenced" by the presence
+//      of a newline.
+//   3. Labeled fenced code (```lang\ncontent\n```) → same as above,
+//      but the language comes from the className.
+
+/**
+ * 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.
+ */
+export 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.
+ */
+export 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 (
+    
+  )
+}
+
+// Single frozen object passed to every ReactMarkdown instance in
+// the feed. Module-scope so the reference is stable across renders
+// — react-markdown v10 caches on components-object identity.
+export const MARKDOWN_COMPONENTS: import('react-markdown').Options['components'] = {
+  pre: MarkdownPre,
+  code: MarkdownCode,
+}
diff --git a/src/renderer/src/features/feed/ui/markdown/Prose.tsx b/src/renderer/src/features/feed/ui/markdown/Prose.tsx
new file mode 100644
index 000000000..06e5e5707
--- /dev/null
+++ b/src/renderer/src/features/feed/ui/markdown/Prose.tsx
@@ -0,0 +1,63 @@
+import { memo } from 'react'
+import ReactMarkdown from 'react-markdown'
+
+import { COMPLETED_REMARK, STREAMING_REMARK } from '../../lib/remark-plugins'
+
+import { MARKDOWN_COMPONENTS } from './MarkdownComponents'
+
+// Two prose renderers with the same visual surface but different
+// remark plugin sets. TextProse is for committed JSONL assistant
+// text (real markdown, standard paragraph/line rules). StreamingProse
+// is for the live screen-buffer extract (ANSI-stripped plain text
+// where each newline is a genuine line — `remark-breaks` added so
+// those newlines render as 
). See ../lib/remark-plugins.ts for +// the full rationale on why these two sets differ. + +/* ---------- 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. +export 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 + * in ../lib/remark-plugins.ts 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. + */ +export const StreamingProse = memo(function StreamingProse({ + text, +}: { + text: string +}) { + if (!text) return null + return ( +
+ + {text} + +
+ ) +}) diff --git a/src/renderer/src/features/feed/ui/markdown/index.ts b/src/renderer/src/features/feed/ui/markdown/index.ts new file mode 100644 index 000000000..093642594 --- /dev/null +++ b/src/renderer/src/features/feed/ui/markdown/index.ts @@ -0,0 +1,6 @@ +// Barrel for the feed's markdown-rendering primitives. Keeps Feed.tsx +// and the row components pointing at one path (`./markdown`) rather +// than reaching for individual files. + +export { MarkdownPre, MarkdownCode, MARKDOWN_COMPONENTS } from './MarkdownComponents' +export { TextProse, StreamingProse } from './Prose' From c813d1661254347afa8303052ea1e493d677287f Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 23 Apr 2026 20:36:46 +0200 Subject: [PATCH 15/27] refactor(feed): extract semantic-streaming components into ui/semantic/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/renderer/src/features/feed/ui/Feed.tsx | 674 +----------------- .../features/feed/ui/semantic/BlockRow.tsx | 341 +++++++++ .../feed/ui/semantic/CollapsedActivityRow.tsx | 42 ++ .../feed/ui/semantic/StreamingTurn.tsx | 87 +++ .../features/feed/ui/semantic/TodoList.tsx | 68 ++ .../src/features/feed/ui/semantic/index.ts | 15 + .../features/feed/ui/semantic/renderUnits.ts | 79 ++ .../src/features/feed/ui/semantic/types.ts | 28 + 8 files changed, 670 insertions(+), 664 deletions(-) create mode 100644 src/renderer/src/features/feed/ui/semantic/BlockRow.tsx create mode 100644 src/renderer/src/features/feed/ui/semantic/CollapsedActivityRow.tsx create mode 100644 src/renderer/src/features/feed/ui/semantic/StreamingTurn.tsx create mode 100644 src/renderer/src/features/feed/ui/semantic/TodoList.tsx create mode 100644 src/renderer/src/features/feed/ui/semantic/index.ts create mode 100644 src/renderer/src/features/feed/ui/semantic/renderUnits.ts create mode 100644 src/renderer/src/features/feed/ui/semantic/types.ts diff --git a/src/renderer/src/features/feed/ui/Feed.tsx b/src/renderer/src/features/feed/ui/Feed.tsx index 48faf5b05..f255d253e 100644 --- a/src/renderer/src/features/feed/ui/Feed.tsx +++ b/src/renderer/src/features/feed/ui/Feed.tsx @@ -34,11 +34,9 @@ import { CodeBlock } from '../../../lib/code/CodeBlock' import { detectGitIntent } from '@shared/git/gitDetect' import { GitCardRow } from '../../git/ui/GitRows' import { useAppStore } from '../../../state/hooks' -import { - parseSemanticTodos, - type SemanticLiveTurn, - type SemanticTodoItem, - type StreamPhase, +import type { + SemanticLiveTurn, + StreamPhase, } from '../../../workspace/workspaceState' import { WorkIndicator } from '../WorkIndicator' import { toolHintFromTurn } from '../workIndicatorHints' @@ -66,18 +64,16 @@ import { debugKeyForEntry, debugLabelForEntry, countFenceMarkers, - splitStreamingCodeFence, imageDataUrl, compactSummaryText, truncateCompactSummary, attachmentLabel, - classifySemanticToolActivity, } from '../lib/helpers' import { - MARKDOWN_COMPONENTS, TextProse, StreamingProse, } from './markdown' +import { SemanticStreamingTurn } from './semantic' // Re-export — many external callers import these types from Feed // directly rather than reaching into ../types/../context. Keep the @@ -910,662 +906,12 @@ function FeedImpl({ ) } -// countFenceMarkers + splitStreamingCodeFence moved to ../lib/helpers.ts. - -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 (COMMAND_START, atCommandPosition, -// looksLikeSearchCommand/ReadCommand/ListCommand) and -// classifySemanticToolActivity moved to ../lib/helpers.ts. - -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. cc-shell 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) { - // WHY collapse to null instead of rendering an empty MarkerRow: - // - // For Codex the rollout stream does not emit per-block events, so - // `turn.blocks` stays empty and this branch owns the entire live - // view. `turn.text` is cleared to '' by the Codex adapter the - // moment a `response_item` commits the current assistant message, - // because the committed `:message` entry in the feed then owns - // display of that text. During that transient — and between two - // messages in the same Codex turn more broadly — there is - // genuinely nothing the ghost should paint. An empty MarkerRow - // would still render a solitary ⏺ bullet under the committed row, - // which reads as a second speaker to the user. - // - // Returning null is correct because WorkIndicator below the feed - // carries the "agent is working" signal on its own. See - // docs/superpowers/plans/2026-04-18-thinking-indicator-rework.md. - if (!turn.text) return null - return ( - - - - ) - } - - return ( - <> - {/* SemanticTaskSummary was removed 2026-04-18. - * - * The old chrome row printed `todos: X/Y · active: Bash, Grep · - * tools: 3 done`. It competed with WorkIndicator for "is the - * agent working" attention without answering that question — - * todos render via the TodoWrite tool's own SemanticLiveBlockRow, - * active-tool names are visible in the tool rows themselves, and - * done-count is not useful chat content. - * - * See docs/superpowers/plans/2026-04-18-thinking-indicator-rework.md. */} - {units.map(unit => ( - unit.type === 'collapsed_activity' ? ( - - ) : ( - - ) - ))} - {/* SemanticTurnFooter was removed 2026-04-18. - * - * It printed `stop: tool_use · in: 1234 · out: 567` — diagnostic - * chatter that lives in DebugPanel now. If we ever want a - * per-turn receipt card in the chat it belongs as its own - * surface, not as a tail on every turn. - * - * See docs/superpowers/plans/2026-04-18-thinking-indicator-rework.md. */} - - ) -}) - -const SemanticCollapsedActivityRow = memo(function SemanticCollapsedActivityRow({ - unit, -}: { - unit: Extract -}) { - // Running-ness is the WorkIndicator's job now. When the group is - // still accumulating we render nothing and let the indicator below - // carry the "agent is working" signal. Only the `worked:` variant - // stays — it's a genuinely useful history compaction of a finished - // batch of reads/searches. See - // docs/superpowers/plans/2026-04-18-thinking-indicator-rework.md. - if (unit.isRunning) return null - - 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 ( - -
-
- 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. - // cc-shell 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' || block.kind === 'reasoning') { - // Live thinking — for Claude this is the ONLY time the plaintext is - // available (`thinking` is stripped on the final message before - // persisting; only signature ciphertext survives). For Codex the - // `reasoning` block works similarly, and plaintext is frequently - // empty because ChatGPT delivers reasoning encrypted. - // - // Design (2026-04-18 rework): - // - Empty thinking → render NOTHING. The WorkIndicator at the - // foot of the feed already shows "Thinking · Ns" with a - // pulsing dot, so the old static `∴ Thinking…` row was - // redundant noise that actively looked "hung" when encrypted. - // - Non-empty thinking → collapsed `
` (closed by - // default). Users who want to read reasoning click to expand; - // nobody sees a flood of italic prose they didn't ask for. - // - // See docs/superpowers/plans/2026-04-18-thinking-indicator-rework.md. - const text = - block.thinking || - block.reasoningSummary || - block.reasoningText || - '' - if (!text) return null - const isStreaming = !block.finalized - return ( - -
- - ∴ Thinking{isStreaming ? '…' : ''} - - (click to expand) - - -
- -
-
-
- ) - } - - // Codex-specific variants — minimal first-class rendering so tool - // calls, searches, shell commands, and image generations show up - // live from the proxy stream instead of waiting for rollout to - // catch up. Each variant shows what it IS (tool name / command / - // query / status) without trying to reinvent the full rollout- - // rendered card; rollout's reducer writes the canonical final - // version to the feed, and these live rows fill in the "right now" - // gap. Ordered from highest-frequency (function_call) to lowest. - - if (block.kind === 'function_call' || block.kind === 'custom_tool_call') { - const label = block.toolName ?? block.kind - const argsText = - block.argumentsJson ?? block.inputJson ?? '(no arguments yet)' - const statusBadge = block.status - ? block.status.replace(/_/g, ' ') - : block.finalized - ? 'done' - : 'running' - return ( - -
-
- {label} - - {statusBadge} - -
- -
-              {argsText || '(waiting for input…)'}
-            
-
- {block.parseError ? ( - -
- invalid tool input: {block.parseError} -
-
- ) : null} -
-
- ) - } - - if ( - block.kind === 'function_call_output' || - block.kind === 'custom_tool_call_output' || - block.kind === 'tool_search_output' - ) { - // Output blocks land as separate output_items on the SSE wire - // (the function_call emits one item, the function_call_output - // emits another — paired only by call_id). Render as a - // standalone output row; downstream Feed rendering can associate - // it with the call via the shared callId if the renderer wants to. - const raw = block.output - const outputText = - typeof raw === 'string' - ? raw - : raw === undefined - ? '(no output)' - : JSON.stringify(raw, null, 2) - return ( - -
-          {outputText}
-        
-
- ) - } - - if (block.kind === 'web_search_call') { - const action = block.webSearchAction - const label = - action?.kind === 'search' - ? `Search: ${action.query ?? action.queries?.join(', ') ?? '…'}` - : action?.kind === 'open_page' - ? `Open: ${action.url ?? '?'}` - : action?.kind === 'find_in_page' - ? `Find "${action.pattern ?? '?'}" in ${action.url ?? '?'}` - : 'Web search' - return ( - -
- 🌐 {label} - {block.status ? ( - - {block.status.replace(/_/g, ' ')} - - ) : null} -
-
- ) - } - - if (block.kind === 'image_generation_call') { - const img = block.imageGeneration - return ( - -
-
- 🖼 Image generation - - {img?.status ?? block.status ?? 'running'} - -
- {img?.revisedPrompt ? ( - -
- {img.revisedPrompt} -
-
- ) : null} -
-
- ) - } - - if (block.kind === 'local_shell_call') { - const shell = block.localShellCall - const command = shell?.command.join(' ') ?? '(no command)' - return ( - -
-
- $ Shell - - {shell?.status ?? block.status ?? 'running'} - -
- -
-              {command}
-            
-
-
-
- ) - } - - if (block.kind === 'tool_search_call') { - const label = block.toolName ?? 'Tool search' - return ( - -
- 🔎 {label} - {block.status ? ( - - {block.status.replace(/_/g, ' ')} - - ) : 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} -
-
- ) -}) +// Semantic streaming section moved to ./semantic/ — see those files for the +// full rationale on each component. Feed.tsx now imports only +// SemanticStreamingTurn (the orchestrator) via ./semantic/index.ts. +// SemanticTaskSummary + SemanticTurnFooter were deleted in the +// 2026-04-18 thinking-indicator rework (dead code; see the comment +// inside StreamingTurn.tsx for why). // --------------------------------------------------------------------------- // Lazy entry mounting — the key to rendering fat conversations. diff --git a/src/renderer/src/features/feed/ui/semantic/BlockRow.tsx b/src/renderer/src/features/feed/ui/semantic/BlockRow.tsx new file mode 100644 index 000000000..23af228dc --- /dev/null +++ b/src/renderer/src/features/feed/ui/semantic/BlockRow.tsx @@ -0,0 +1,341 @@ +import { memo } from 'react' + +import { CodeBlock } from '../../../../lib/code/CodeBlock' +import { + parseSemanticTodos, + type SemanticLiveTurn, +} from '../../../../workspace/workspaceState' + +import { splitStreamingCodeFence } from '../../lib/helpers' +import { MarkerRow } from '../MarkerRow' +import { StreamingProse } from '../markdown' + +import { SemanticTodoList } from './TodoList' + +// Single live-block renderer — this is the big dispatch for the +// semantic streaming path. Each SemanticLiveTurn block is one of a +// dozen kinds (thinking, function_call, tool_use, web_search_call, +// etc.), and this component picks the right tiny presentational +// shape for whichever kind it received. +// +// The branches roughly mirror Codex's upstream event taxonomy but +// stay intentionally minimal: the goal of the live view is to show +// that SOMETHING is happening and WHAT it is — the fuller, final +// version of each turn comes from the committed transcript entries +// rendered by the regular feed row path. Live rows fill the "right +// now" gap without trying to reinvent the finished transcript card. +export const SemanticLiveBlockRow = memo(function SemanticLiveBlockRow({ + block, + toolState, +}: { + block: SemanticLiveTurn['blocks'][number] + toolState: SemanticLiveTurn['lookups']['toolCallsById'][string] | null +}) { + if (block.kind === 'thinking' || block.kind === 'reasoning') { + // Live thinking — for Claude this is the ONLY time the plaintext is + // available (`thinking` is stripped on the final message before + // persisting; only signature ciphertext survives). For Codex the + // `reasoning` block works similarly, and plaintext is frequently + // empty because ChatGPT delivers reasoning encrypted. + // + // Design (2026-04-18 rework): + // - Empty thinking → render NOTHING. The WorkIndicator at the + // foot of the feed already shows "Thinking · Ns" with a + // pulsing dot, so the old static `∴ Thinking…` row was + // redundant noise that actively looked "hung" when encrypted. + // - Non-empty thinking → collapsed `
` (closed by + // default). Users who want to read reasoning click to expand; + // nobody sees a flood of italic prose they didn't ask for. + // + // See docs/superpowers/plans/2026-04-18-thinking-indicator-rework.md. + const text = + block.thinking || + block.reasoningSummary || + block.reasoningText || + '' + if (!text) return null + const isStreaming = !block.finalized + return ( + +
+ + ∴ Thinking{isStreaming ? '…' : ''} + + (click to expand) + + +
+ +
+
+
+ ) + } + + // Codex-specific variants — minimal first-class rendering so tool + // calls, searches, shell commands, and image generations show up + // live from the proxy stream instead of waiting for rollout to + // catch up. Each variant shows what it IS (tool name / command / + // query / status) without trying to reinvent the full rollout- + // rendered card; rollout's reducer writes the canonical final + // version to the feed, and these live rows fill in the "right now" + // gap. Ordered from highest-frequency (function_call) to lowest. + + if (block.kind === 'function_call' || block.kind === 'custom_tool_call') { + const label = block.toolName ?? block.kind + const argsText = + block.argumentsJson ?? block.inputJson ?? '(no arguments yet)' + const statusBadge = block.status + ? block.status.replace(/_/g, ' ') + : block.finalized + ? 'done' + : 'running' + return ( + +
+
+ {label} + + {statusBadge} + +
+ +
+              {argsText || '(waiting for input…)'}
+            
+
+ {block.parseError ? ( + +
+ invalid tool input: {block.parseError} +
+
+ ) : null} +
+
+ ) + } + + if ( + block.kind === 'function_call_output' || + block.kind === 'custom_tool_call_output' || + block.kind === 'tool_search_output' + ) { + // Output blocks land as separate output_items on the SSE wire + // (the function_call emits one item, the function_call_output + // emits another — paired only by call_id). Render as a + // standalone output row; downstream Feed rendering can associate + // it with the call via the shared callId if the renderer wants to. + const raw = block.output + const outputText = + typeof raw === 'string' + ? raw + : raw === undefined + ? '(no output)' + : JSON.stringify(raw, null, 2) + return ( + +
+          {outputText}
+        
+
+ ) + } + + if (block.kind === 'web_search_call') { + const action = block.webSearchAction + const label = + action?.kind === 'search' + ? `Search: ${action.query ?? action.queries?.join(', ') ?? '…'}` + : action?.kind === 'open_page' + ? `Open: ${action.url ?? '?'}` + : action?.kind === 'find_in_page' + ? `Find "${action.pattern ?? '?'}" in ${action.url ?? '?'}` + : 'Web search' + return ( + +
+ 🌐 {label} + {block.status ? ( + + {block.status.replace(/_/g, ' ')} + + ) : null} +
+
+ ) + } + + if (block.kind === 'image_generation_call') { + const img = block.imageGeneration + return ( + +
+
+ 🖼 Image generation + + {img?.status ?? block.status ?? 'running'} + +
+ {img?.revisedPrompt ? ( + +
+ {img.revisedPrompt} +
+
+ ) : null} +
+
+ ) + } + + if (block.kind === 'local_shell_call') { + const shell = block.localShellCall + const command = shell?.command.join(' ') ?? '(no command)' + return ( + +
+
+ $ Shell + + {shell?.status ?? block.status ?? 'running'} + +
+ +
+              {command}
+            
+
+
+
+ ) + } + + if (block.kind === 'tool_search_call') { + const label = block.toolName ?? 'Tool search' + return ( + +
+ 🔎 {label} + {block.status ? ( + + {block.status.replace(/_/g, ' ')} + + ) : 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 ( + + + + ) +}) diff --git a/src/renderer/src/features/feed/ui/semantic/CollapsedActivityRow.tsx b/src/renderer/src/features/feed/ui/semantic/CollapsedActivityRow.tsx new file mode 100644 index 000000000..665211cda --- /dev/null +++ b/src/renderer/src/features/feed/ui/semantic/CollapsedActivityRow.tsx @@ -0,0 +1,42 @@ +import { memo } from 'react' + +import { MarkerRow } from '../MarkerRow' + +import type { SemanticRenderUnit } from './types' + +// Compact "worked: 3 reads, 1 search" tail for a finished batch of +// low-signal tool calls. Running-ness is the WorkIndicator's job +// now — when the group is still accumulating we render nothing and +// let the indicator below carry the "agent is working" signal. +// Only the `worked:` variant stays; it's a genuinely useful history +// compaction of a finished batch of reads/searches. See +// docs/superpowers/plans/2026-04-18-thinking-indicator-rework.md. +export const SemanticCollapsedActivityRow = memo(function SemanticCollapsedActivityRow({ + unit, +}: { + unit: Extract +}) { + if (unit.isRunning) return null + + 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 ( + +
+
+ worked: {summary} +
+ {unit.latestHint ? ( +
+ {unit.latestHint} +
+ ) : null} +
+
+ ) +}) diff --git a/src/renderer/src/features/feed/ui/semantic/StreamingTurn.tsx b/src/renderer/src/features/feed/ui/semantic/StreamingTurn.tsx new file mode 100644 index 000000000..269d8e9e7 --- /dev/null +++ b/src/renderer/src/features/feed/ui/semantic/StreamingTurn.tsx @@ -0,0 +1,87 @@ +import { memo } from 'react' + +import type { SemanticLiveTurn } from '../../../../workspace/workspaceState' + +import { MarkerRow } from '../MarkerRow' +import { StreamingProse } from '../markdown' + +import { SemanticCollapsedActivityRow } from './CollapsedActivityRow' +import { SemanticLiveBlockRow } from './BlockRow' +import { buildSemanticRenderUnits } from './renderUnits' + +// 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. +// +// Two previously-rendered siblings (SemanticTaskSummary, +// SemanticTurnFooter) were removed in the 2026-04-18 thinking- +// indicator rework — SemanticTaskSummary competed with WorkIndicator +// for "is the agent working" attention without answering that +// question (todos render via TodoWrite's own block row; active tool +// names are visible on the tool rows themselves). SemanticTurnFooter +// printed `stop: tool_use · in: 1234 · out: 567` — diagnostic chatter +// that lives in DebugPanel now. Both components are gone; if we ever +// want a per-turn receipt card in the chat it belongs as its own +// surface, not as a tail on every turn. +// See docs/superpowers/plans/2026-04-18-thinking-indicator-rework.md. +export const SemanticStreamingTurn = memo(function SemanticStreamingTurn({ + turn, +}: { + turn: SemanticLiveTurn +}) { + const blocks = Object.values(turn.blocks).sort((a, b) => a.blockIndex - b.blockIndex) + const units = buildSemanticRenderUnits(turn) + const hasBlocks = blocks.length > 0 + + if (!hasBlocks) { + // WHY collapse to null instead of rendering an empty MarkerRow: + // + // For Codex the rollout stream does not emit per-block events, so + // `turn.blocks` stays empty and this branch owns the entire live + // view. `turn.text` is cleared to '' by the Codex adapter the + // moment a `response_item` commits the current assistant message, + // because the committed `:message` entry in the feed then owns + // display of that text. During that transient — and between two + // messages in the same Codex turn more broadly — there is + // genuinely nothing the ghost should paint. An empty MarkerRow + // would still render a solitary ⏺ bullet under the committed row, + // which reads as a second speaker to the user. + // + // Returning null is correct because WorkIndicator below the feed + // carries the "agent is working" signal on its own. See + // docs/superpowers/plans/2026-04-18-thinking-indicator-rework.md. + if (!turn.text) return null + return ( + + + + ) + } + + return ( + <> + {units.map(unit => ( + unit.type === 'collapsed_activity' ? ( + + ) : ( + + ) + ))} + + ) +}) diff --git a/src/renderer/src/features/feed/ui/semantic/TodoList.tsx b/src/renderer/src/features/feed/ui/semantic/TodoList.tsx new file mode 100644 index 000000000..5ef23197e --- /dev/null +++ b/src/renderer/src/features/feed/ui/semantic/TodoList.tsx @@ -0,0 +1,68 @@ +import { memo } from 'react' + +import type { SemanticTodoItem } from '../../../../workspace/workspaceState' + +// Inline todo-list rendering for the semantic-streaming TodoWrite +// tool row. Shows the list with status glyphs (☑ done, ◐ in-progress, +// ☐ pending) and a "N/M done" header. The `activeForm` override for +// in-progress todos matches upstream Claude's "I am doing X" live +// phrasing — the todo's permanent content is a planning-tense +// sentence ("Fix the login flow"), the activeForm is the live-tense +// progress sentence ("Fixing the login flow"), and the latter reads +// better while a turn is in-flight. +export 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} + +
  • + ) + })} +
+ )} +
+ ) +}) diff --git a/src/renderer/src/features/feed/ui/semantic/index.ts b/src/renderer/src/features/feed/ui/semantic/index.ts new file mode 100644 index 000000000..818385a67 --- /dev/null +++ b/src/renderer/src/features/feed/ui/semantic/index.ts @@ -0,0 +1,15 @@ +// Barrel for the feed's semantic-streaming components. +// +// Only SemanticStreamingTurn is consumed by Feed.tsx directly — the +// rest of the exports are here because they're addressable from +// WorkIndicator / debug surfaces and from each other through this +// barrel. Keeping Feed's import surface to one module means the +// whole semantic section can grow/shrink without Feed having to +// track per-file paths. + +export { SemanticStreamingTurn } from './StreamingTurn' +export { SemanticLiveBlockRow } from './BlockRow' +export { SemanticCollapsedActivityRow } from './CollapsedActivityRow' +export { SemanticTodoList } from './TodoList' +export { buildSemanticRenderUnits } from './renderUnits' +export type { SemanticRenderUnit } from './types' diff --git a/src/renderer/src/features/feed/ui/semantic/renderUnits.ts b/src/renderer/src/features/feed/ui/semantic/renderUnits.ts new file mode 100644 index 000000000..d6d97d2cb --- /dev/null +++ b/src/renderer/src/features/feed/ui/semantic/renderUnits.ts @@ -0,0 +1,79 @@ +import type { SemanticLiveTurn } from '../../../../workspace/workspaceState' + +import { classifySemanticToolActivity } from '../../lib/helpers' + +import type { SemanticRenderUnit } from './types' + +// 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. +// cc-shell 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. +// +// The loop walks blocks in blockIndex order and maintains a single +// `pending` collapsed_activity bucket. Every non-collapsible block +// flushes the bucket (committing it as a unit) and then emits its +// own 'block' unit. Runs of collapsible blocks accumulate into the +// bucket, tallying per-category counts. The terminal flush() after +// the loop commits the last bucket if the turn ended while +// accumulating. +export 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 + } + + 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 +} diff --git a/src/renderer/src/features/feed/ui/semantic/types.ts b/src/renderer/src/features/feed/ui/semantic/types.ts new file mode 100644 index 000000000..2861573e7 --- /dev/null +++ b/src/renderer/src/features/feed/ui/semantic/types.ts @@ -0,0 +1,28 @@ +import type { SemanticLiveTurn } from '../../../../workspace/workspaceState' + +// Derived render-unit shape used by buildSemanticRenderUnits. +// +// A semantic turn's raw `turn.blocks` is a bag of everything the +// proxy saw: text, thinking, tool_use, tool_result, citations, etc. +// We run one derivation pass over that bag to produce a tighter +// render unit list, where runs of low-signal tool churn +// (search/read/list/bash) collapse into a single "collapsed_activity" +// group. See renderUnits.ts for the flush/group logic and Feed.tsx's +// "WHY add a derived render-unit pass" note for the design rationale. +export 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 + } From ac74c724f47a676fdb997e3c416ead8baca94aab Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 23 Apr 2026 20:43:59 +0200 Subject: [PATCH 16/27] refactor(feed): extract row components into ui/rows/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/renderer/src/features/feed/ui/Feed.tsx | 819 +----------------- .../src/features/feed/ui/rows/Block.tsx | 196 +++++ .../feed/ui/rows/CompactBoundaryRow.tsx | 18 + .../feed/ui/rows/CompactSummaryRow.tsx | 49 ++ .../features/feed/ui/rows/ConversationRow.tsx | 69 ++ .../src/features/feed/ui/rows/EntryRow.tsx | 35 + .../features/feed/ui/rows/ImageBlockRow.tsx | 50 ++ .../src/features/feed/ui/rows/LazyEntry.tsx | 122 +++ .../src/features/feed/ui/rows/SystemRow.tsx | 30 + .../features/feed/ui/rows/ToolResultRow.tsx | 152 ++++ .../src/features/feed/ui/rows/ToolUseRow.tsx | 63 ++ .../feed/ui/rows/TruncatedOutputRow.tsx | 54 ++ .../src/features/feed/ui/rows/index.ts | 21 + .../src/features/feed/ui/rows/primitives.tsx | 41 + 14 files changed, 921 insertions(+), 798 deletions(-) create mode 100644 src/renderer/src/features/feed/ui/rows/Block.tsx create mode 100644 src/renderer/src/features/feed/ui/rows/CompactBoundaryRow.tsx create mode 100644 src/renderer/src/features/feed/ui/rows/CompactSummaryRow.tsx create mode 100644 src/renderer/src/features/feed/ui/rows/ConversationRow.tsx create mode 100644 src/renderer/src/features/feed/ui/rows/EntryRow.tsx create mode 100644 src/renderer/src/features/feed/ui/rows/ImageBlockRow.tsx create mode 100644 src/renderer/src/features/feed/ui/rows/LazyEntry.tsx create mode 100644 src/renderer/src/features/feed/ui/rows/SystemRow.tsx create mode 100644 src/renderer/src/features/feed/ui/rows/ToolResultRow.tsx create mode 100644 src/renderer/src/features/feed/ui/rows/ToolUseRow.tsx create mode 100644 src/renderer/src/features/feed/ui/rows/TruncatedOutputRow.tsx create mode 100644 src/renderer/src/features/feed/ui/rows/index.ts create mode 100644 src/renderer/src/features/feed/ui/rows/primitives.tsx diff --git a/src/renderer/src/features/feed/ui/Feed.tsx b/src/renderer/src/features/feed/ui/Feed.tsx index f255d253e..70581dbde 100644 --- a/src/renderer/src/features/feed/ui/Feed.tsx +++ b/src/renderer/src/features/feed/ui/Feed.tsx @@ -1,46 +1,25 @@ import { memo, - useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, - type ReactNode, } from 'react' -import { - EditRow, - MultiEditRow, - WriteRow, - TodoRow, -} from '@providers/claude/renderer/rows/ClaudeRows' -import { - CodexToolRow, - CodexToolResultRow, -} from '@providers/codex/renderer/rows/CodexRows' import { isCompactBoundaryEntry, isCompactSummaryEntry, isConversationEntry, - type ContentBlock, - type CompactSummaryEntry, - type ConversationEntry, type Entry, - type ToolResultBlock, - type ToolUseBlock, } from '@shared/types/transcript' -import { CodeBlock } from '../../../lib/code/CodeBlock' -import { detectGitIntent } from '@shared/git/gitDetect' -import { GitCardRow } from '../../git/ui/GitRows' -import { useAppStore } from '../../../state/hooks' + import type { SemanticLiveTurn, StreamPhase, } from '../../../workspace/workspaceState' import { WorkIndicator } from '../WorkIndicator' import { toolHintFromTurn } from '../workIndicatorHints' -import { MarkerRow } from './MarkerRow' import { ProviderContext, ToolUseIndexContext, @@ -57,23 +36,16 @@ import { scrollPositions } from '../scroll' import { buildToolUseIndex, buildToolResultIndex, - extractToolCommand, - toolResultText, - truncateBashCommand, - stripLineNumberPrefix, debugKeyForEntry, debugLabelForEntry, - countFenceMarkers, - imageDataUrl, - compactSummaryText, - truncateCompactSummary, - attachmentLabel, } from '../lib/helpers' -import { - TextProse, - StreamingProse, -} from './markdown' import { SemanticStreamingTurn } from './semantic' +import { + EAGER_TAIL, + EntryRow, + LazyEntry, +} from './rows' +import type { ToolResultBlock, ToolUseBlock } from '@shared/types/transcript' // Re-export — many external callers import these types from Feed // directly rather than reaching into ../types/../context. Keep the @@ -914,768 +886,19 @@ function FeedImpl({ // inside StreamingTurn.tsx for why). // --------------------------------------------------------------------------- -// Lazy entry mounting — the key to rendering fat conversations. +// Row components moved to ./rows/ // --------------------------------------------------------------------------- // -// 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} -
- ) -} - -// imageDataUrl moved to ../lib/helpers.ts. - -const ImageBlockRow = memo(function ImageBlockRow({ - block, - role, -}: { - block: ContentBlock - role: 'user' | 'assistant' -}) { - const src = imageDataUrl(block) - const mediaType = - typeof (block as { source?: { media_type?: unknown } }).source?.media_type === 'string' - ? (block as { source: { media_type: string } }).source.media_type - : 'image' - const alt = role === 'user' ? 'Pasted image' : 'Image' - const row = ( - -
- {src ? ( - {alt} - ) : ( -
- image -
- )} -
-
- ) - return role === 'user' ? {row} : row -}) - -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 && ( - - )} -
-
- -
-
- ) -}) - -// compactSummaryText + truncateCompactSummary moved to -// ../lib/helpers.ts. See those for the thinking-block + two-cap -// truncation rationale. - -/** - * 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} -
- ) -} - -// MarkerRow moved to ./MarkerRow.tsx — the `⏺`/`❯`/`⎿` layout primitive -// is now a shared module since features/git/ui/GitRows also imports it. -// Feed itself still imports MarkerRow from the new location at the top. - -/* ---------- 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': { - // Persisted thinking block. Anthropic strips the plaintext from - // the final message (only `signature` ciphertext survives), so - // text is ALMOST ALWAYS empty in committed transcripts. Old - // behaviour was to render a placeholder `∴ Thinking` row; now - // we render nothing and let the WorkIndicator (while live) and - // the absence of content (after the fact) speak for themselves. - // - // Non-empty thinking on a committed block does still exist - // (older sessions, non-Opus-4 models, synthetic entries). Keep - // the expandable surface for those — aligned with the live - // branch above, `
` closed by default. - // - // See docs/superpowers/plans/2026-04-18-thinking-indicator-rework.md. - const text = (block as { thinking?: string }).thinking ?? '' - if (!text) return null - return ( - -
- - ∴ Thinking - - (click to expand) - - -
- -
-
-
- ) - } - case 'image': { - return - } - 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} -
-
- ) - } -}) - -// TextProse + StreamingProse moved to ./markdown/Prose.tsx — see -// there for the text-string memo rationale and why TextProse uses -// COMPLETED_REMARK while StreamingProse adds remark-breaks. - -/* ---------- 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 remains in the transcript; the collapse is purely -// a density choice so a 20-line heredoc doesn't push the next -// assistant message below the fold. -// MAX_COMMAND_DISPLAY_* constants + truncateBashCommand moved to -// ../lib/helpers.ts. - -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 = block.input as Record | undefined - const rawHeadline = typeof input?.command === 'string' - ? input.command - : typeof input?.description === 'string' - ? input.description - : typeof input?.path === 'string' - ? input.path - : null - - // Bash commands get the 2-line / 160-char cap claude-code's Ink UI - // enforces. `description` and `path` headlines are already one-line- - // ish so we only truncate when the headline came from `command`. - const headline = (() => { - if (!rawHeadline) return null - if (block.name === 'Bash' && typeof input?.command === 'string') { - return truncateBashCommand(input.command) - } - 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" at - // height={1} and never echoes the file bytes into the feed. The - // user already knows which file was read (it's on the tool-use - // row); dumping its contents below pushes the next assistant - // message off-screen for no gain. - // - // A click-to-expand
keeps the raw content one - // interaction away for when you actually need it (debugging, - // code review). Syntax highlighting happens inside 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'} - -
- -
-
-
- ) - } - - // Grep tool result: render with CodeBlock so results get syntax - // highlighting based on the file pattern / path. Grep output is - // already formatted text but benefits from language-aware coloring. - if (sourceTool === 'Grep' && !isError) { - const sourceInput = toolUseIndex.get(block.tool_use_id)?.input as - | Record - | undefined - const filePath = - typeof sourceInput?.path === 'string' - ? sourceInput.path - : null - return ( - - - - - - ) - } - - // Everything else — Bash, 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 noisy test run doesn't push the assistant's next message off. - return -}) - -// MAX_LINES_TO_SHOW in claude-code-src. Hoisted so the memo'd row -// component doesn't re-create the constant every render. -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 && ( - - )} -
- ) -} - -// stripLineNumberPrefix moved to ../lib/helpers.ts — see there for -// the "CC emits 'n\t' in Read tool results" rationale. - -/* ---------- 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} -
-
- ) -}) - -// attachmentLabel moved to ../lib/helpers.ts. - -/* ---------- Streaming row — REMOVED ---------- - * - * The render-time regex extractor over the raw TUI screen used to - * live here (`StreamingRow` + `isStaleStreamingExtract` helpers). - * Its removal is the structural fix for the "double-render last - * message on resume" and "previous assistant text rendered below - * new user prompt after submit" bugs. - * - * Rationale: two producers were competing for the same feed slot — - * `` (authoritative, from JSONL) and `` - * (inferred, regex over raw TUI). Dropping the screen scraper leaves - * exactly one owner for live text: ``, fed by - * the headless packages' semantic channel. Screen-derived live text - * still works — the packages publish it as semantic deltas tagged - * `source: 'screen'`, gated by a baseline so they can't leak the - * previous turn's buffered bytes as the first delta of a new turn. - */ - -/* ---------- Activity indicator REMOVED — replaced by ---------- - * - * The local `ActivityIndicator` function used to live here. It was the - * "agent is working" pulse-dot + verb row, rendered at the foot of the - * feed and gated on `semanticTurn == null`. That gate was the bug: the - * indicator vanished the moment a semantic turn mounted, leaving a - * visual blind spot during tool execution and the mid-turn gaps - * between blocks. - * - * The replacement is `` in `./WorkIndicator.tsx`, driven - * by the adapter-derived `runtime.streamPhase` field instead of the - * TUI spinner verb. See - * docs/superpowers/plans/2026-04-18-thinking-indicator-rework.md and - * docs/superpowers/plans/2026-04-18-thinking-phase-in-headless.md. - * - * The duplicate component at `src/shared/ui/ActivityIndicator.tsx` is - * also being removed in the same pass; it was dead code (exported but - * never imported). - */ +// The entire row surface (LazyEntry, EntryRow, ConversationRow, Block, +// ImageBlockRow, CompactBoundaryRow, CompactSummaryRow, SystemRow, +// ToolUseRow, ToolResultRow, TruncatedOutputRow, UserBand, ToolBand, +// plus the EAGER_TAIL constant) moved to ./rows/. Each component lives +// in its own file, and the long WHY comments (lazy mount rationale, +// the "CRITICAL: don't wrap tool_results in UserBand" gotcha, the +// Read/Grep/Edit result-rendering taxonomy, the bash headline cap, +// etc.) travelled with the code. Feed.tsx now imports EAGER_TAIL + +// EntryRow + LazyEntry through ./rows/index.ts — the rest are internal +// to the rows tree. The "Streaming row REMOVED" + "Activity indicator +// REMOVED" rationale blocks that used to live at the tail of this +// file are folded into ./semantic/StreamingTurn.tsx + ./WorkIndicator.tsx +// where those replacements actually live. diff --git a/src/renderer/src/features/feed/ui/rows/Block.tsx b/src/renderer/src/features/feed/ui/rows/Block.tsx new file mode 100644 index 000000000..7590059d9 --- /dev/null +++ b/src/renderer/src/features/feed/ui/rows/Block.tsx @@ -0,0 +1,196 @@ +import { memo, useContext } from 'react' + +import { + EditRow, + MultiEditRow, + WriteRow, + TodoRow, +} from '@providers/claude/renderer/rows/ClaudeRows' +import { + CodexToolRow, + CodexToolResultRow, +} from '@providers/codex/renderer/rows/CodexRows' +import type { + ContentBlock, + ToolResultBlock, + ToolUseBlock, +} from '@shared/types/transcript' +import { detectGitIntent } from '@shared/git/gitDetect' + +import { useAppStore } from '../../../../state/hooks' +import { GitCardRow } from '../../../git/ui/GitRows' + +import { extractToolCommand, toolResultText } from '../../lib/helpers' +import { + ProviderContext, + ToolResultIndexContext, + ToolUseIndexContext, +} from '../../context' +import { MarkerRow } from '../MarkerRow' +import { TextProse } from '../markdown' + +import { ImageBlockRow } from './ImageBlockRow' +import { ToolBand, UserBand } from './primitives' +import { ToolResultRow } from './ToolResultRow' +import { ToolUseRow } from './ToolUseRow' + +/* ---------- Block dispatcher ---------- */ + +// Memoized: blocks inside an assistant/user message are stable objects — +// the entry never mutates, so block identity is a perfect memo key. +// +// This is the main per-block dispatcher. ConversationRow hands it a +// ContentBlock and a role, and Block picks the right renderer: +// - text under role='user' → UserBand + TextProse +// - text under role='assistant' → TextProse with `⏺` marker +// - thinking → collapsed
if non-empty, else nothing +// - image → ImageBlockRow +// - tool_use → provider-specific renderer (Claude: Edit/MultiEdit/ +// Write/TodoWrite rich rows; Codex: CodexToolRow; everything else: +// generic ToolUseRow). Plus the git-widget interception. +// - tool_result → provider-specific result renderer, with the git +// widget suppression mirrored here. +export 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': { + // Persisted thinking block. Anthropic strips the plaintext from + // the final message (only `signature` ciphertext survives), so + // text is ALMOST ALWAYS empty in committed transcripts. Old + // behaviour was to render a placeholder `∴ Thinking` row; now + // we render nothing and let the WorkIndicator (while live) and + // the absence of content (after the fact) speak for themselves. + // + // Non-empty thinking on a committed block does still exist + // (older sessions, non-Opus-4 models, synthetic entries). Keep + // the expandable surface for those — aligned with the live + // branch above, `
` closed by default. + // + // See docs/superpowers/plans/2026-04-18-thinking-indicator-rework.md. + const text = (block as { thinking?: string }).thinking ?? '' + if (!text) return null + return ( + +
+ + ∴ Thinking + + (click to expand) + + +
+ +
+
+
+ ) + } + case 'image': { + return + } + 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} +
+
+ ) + } +}) diff --git a/src/renderer/src/features/feed/ui/rows/CompactBoundaryRow.tsx b/src/renderer/src/features/feed/ui/rows/CompactBoundaryRow.tsx new file mode 100644 index 000000000..97ea86758 --- /dev/null +++ b/src/renderer/src/features/feed/ui/rows/CompactBoundaryRow.tsx @@ -0,0 +1,18 @@ +import { memo } from 'react' + +import { MarkerRow } from '../MarkerRow' + +// The single-line "Conversation compacted" divider that appears at +// the boundary between a pre-compact and post-compact run of entries. +// Stateless — no props, no variants — it's a literal marker so the +// reader knows the conversation history was summarised here and the +// next message may not have context from above. +export const CompactBoundaryRow = memo(function CompactBoundaryRow() { + return ( + +
+ Conversation compacted +
+
+ ) +}) diff --git a/src/renderer/src/features/feed/ui/rows/CompactSummaryRow.tsx b/src/renderer/src/features/feed/ui/rows/CompactSummaryRow.tsx new file mode 100644 index 000000000..1108c6312 --- /dev/null +++ b/src/renderer/src/features/feed/ui/rows/CompactSummaryRow.tsx @@ -0,0 +1,49 @@ +import { memo, useMemo, useState } from 'react' + +import type { CompactSummaryEntry } from '@shared/types/transcript' + +import { compactSummaryText, truncateCompactSummary } from '../../lib/helpers' +import { TextProse } from '../markdown' + +// Compact-summary entry renderer — the card that replaces a +// compacted run of turns with a human-readable summary. Shown as a +// full-width bordered card (deliberately NOT a MarkerRow) to signal +// this is meta-content derived from the original transcript, not +// another message in the conversation. +// +// Truncation: summaries can be long. If the body is >24 lines or +// >2400 chars we render a collapsed version with an "expand" +// toggle; the uncollapsed view is always available on demand. See +// truncateCompactSummary in lib/helpers.ts for the two-cap logic. +export 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 && ( + + )} +
+
+ +
+
+ ) +}) diff --git a/src/renderer/src/features/feed/ui/rows/ConversationRow.tsx b/src/renderer/src/features/feed/ui/rows/ConversationRow.tsx new file mode 100644 index 000000000..c89b068cc --- /dev/null +++ b/src/renderer/src/features/feed/ui/rows/ConversationRow.tsx @@ -0,0 +1,69 @@ +import { memo } from 'react' + +import type { ConversationEntry } from '@shared/types/transcript' + +import { MarkerRow } from '../MarkerRow' +import { TextProse } from '../markdown' + +import { Block } from './Block' +import { UserBand } from './primitives' + +// The main renderer for a single assistant or user conversation +// entry. Branches on content shape: +// - string content (user prompts only — tool_results are always +// block-form) → single MarkerRow, UserBand wrapper for role='user'. +// - array content → one Block per element; the UserBand wrapper +// lives INSIDE Block at the text-block level, not around the +// whole row (see the CRITICAL comment below for the tool_result +// + role='user' gotcha). +export 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) => ( + + ))} +
+ ) +}) diff --git a/src/renderer/src/features/feed/ui/rows/EntryRow.tsx b/src/renderer/src/features/feed/ui/rows/EntryRow.tsx new file mode 100644 index 000000000..3b71ccfb5 --- /dev/null +++ b/src/renderer/src/features/feed/ui/rows/EntryRow.tsx @@ -0,0 +1,35 @@ +import { memo } from 'react' + +import { + isCompactBoundaryEntry, + isCompactSummaryEntry, + isConversationEntry, + type Entry, +} from '@shared/types/transcript' + +import { CompactBoundaryRow } from './CompactBoundaryRow' +import { CompactSummaryRow } from './CompactSummaryRow' +import { ConversationRow } from './ConversationRow' +import { SystemRow } from './SystemRow' + +// 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. +// +// This is the per-entry dispatcher called by Feed's render loop. It +// picks the right row component based on the entry's shape. Order +// matters slightly: compact-boundary and compact-summary entries are +// also "conversation-like" in the broad sense, so the type-guard +// checks happen in most-specific-first order. +export const EntryRow = memo(function EntryRow({ entry }: { entry: Entry }) { + if (isCompactBoundaryEntry(entry)) { + return + } + if (isCompactSummaryEntry(entry)) { + return + } + if (isConversationEntry(entry)) { + return + } + return +}) diff --git a/src/renderer/src/features/feed/ui/rows/ImageBlockRow.tsx b/src/renderer/src/features/feed/ui/rows/ImageBlockRow.tsx new file mode 100644 index 000000000..8c3d38936 --- /dev/null +++ b/src/renderer/src/features/feed/ui/rows/ImageBlockRow.tsx @@ -0,0 +1,50 @@ +import { memo } from 'react' + +import type { ContentBlock } from '@shared/types/transcript' + +import { imageDataUrl } from '../../lib/helpers' +import { MarkerRow } from '../MarkerRow' + +import { UserBand } from './primitives' + +// Image content-block renderer. Handles both directions of the +// conversation: an inline image pasted by the user (`❯` marker + +// UserBand highlight) or an image emitted by an assistant tool call +// (`⏺` marker, no band). The image itself is rendered from a base64 +// data: URL — the transcript wire format never carries remote URLs +// for inline images, only inline bytes. When the data is missing +// (malformed block), we render a small "image" placeholder pill +// instead of failing silently. +export const ImageBlockRow = memo(function ImageBlockRow({ + block, + role, +}: { + block: ContentBlock + role: 'user' | 'assistant' +}) { + const src = imageDataUrl(block) + const mediaType = + typeof (block as { source?: { media_type?: unknown } }).source?.media_type === 'string' + ? (block as { source: { media_type: string } }).source.media_type + : 'image' + const alt = role === 'user' ? 'Pasted image' : 'Image' + const row = ( + +
+ {src ? ( + {alt} + ) : ( +
+ image +
+ )} +
+
+ ) + return role === 'user' ? {row} : row +}) diff --git a/src/renderer/src/features/feed/ui/rows/LazyEntry.tsx b/src/renderer/src/features/feed/ui/rows/LazyEntry.tsx new file mode 100644 index 000000000..1d267b2c0 --- /dev/null +++ b/src/renderer/src/features/feed/ui/rows/LazyEntry.tsx @@ -0,0 +1,122 @@ +import { memo, useEffect, useRef, useState, type ReactNode } from 'react' + +// --------------------------------------------------------------------------- +// 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. + +export 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. + */ +export 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} +}) diff --git a/src/renderer/src/features/feed/ui/rows/SystemRow.tsx b/src/renderer/src/features/feed/ui/rows/SystemRow.tsx new file mode 100644 index 000000000..38b505a20 --- /dev/null +++ b/src/renderer/src/features/feed/ui/rows/SystemRow.tsx @@ -0,0 +1,30 @@ +import { memo } from 'react' + +import type { Entry } from '@shared/types/transcript' + +import { attachmentLabel } from '../../lib/helpers' +import { MarkerRow } from '../MarkerRow' + +// System-entry renderer — the hidden-by-default row that shows +// meta-entries from the transcript (permission-mode switches, +// file-history snapshots, hook attachments). These don't +// contribute to the conversation; they're diagnostic surfaces the +// user opts into via settings. Rendered muted with the `·` marker +// so when visible they're clearly secondary to real turns. +export 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} +
+
+ ) +}) diff --git a/src/renderer/src/features/feed/ui/rows/ToolResultRow.tsx b/src/renderer/src/features/feed/ui/rows/ToolResultRow.tsx new file mode 100644 index 000000000..daf27e83e --- /dev/null +++ b/src/renderer/src/features/feed/ui/rows/ToolResultRow.tsx @@ -0,0 +1,152 @@ +import { memo, useContext } from 'react' + +import type { ToolResultBlock } from '@shared/types/transcript' + +import { CodeBlock } from '../../../../lib/code/CodeBlock' + +import { stripLineNumberPrefix } from '../../lib/helpers' +import { CodeRenderContext, ToolUseIndexContext } from '../../context' +import { MarkerRow } from '../MarkerRow' + +import { ToolBand } from './primitives' +import { TruncatedOutputRow } from './TruncatedOutputRow' + +/* ---------- 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. + */ +export 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" at + // height={1} and never echoes the file bytes into the feed. The + // user already knows which file was read (it's on the tool-use + // row); dumping its contents below pushes the next assistant + // message off-screen for no gain. + // + // A click-to-expand
keeps the raw content one + // interaction away for when you actually need it (debugging, + // code review). Syntax highlighting happens inside 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'} + +
+ +
+
+
+ ) + } + + // Grep tool result: render with CodeBlock so results get syntax + // highlighting based on the file pattern / path. Grep output is + // already formatted text but benefits from language-aware coloring. + if (sourceTool === 'Grep' && !isError) { + const sourceInput = toolUseIndex.get(block.tool_use_id)?.input as + | Record + | undefined + const filePath = + typeof sourceInput?.path === 'string' + ? sourceInput.path + : null + return ( + + + + + + ) + } + + // Everything else — Bash, 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 noisy test run doesn't push the assistant's next message off. + return +}) diff --git a/src/renderer/src/features/feed/ui/rows/ToolUseRow.tsx b/src/renderer/src/features/feed/ui/rows/ToolUseRow.tsx new file mode 100644 index 000000000..1fa44d76d --- /dev/null +++ b/src/renderer/src/features/feed/ui/rows/ToolUseRow.tsx @@ -0,0 +1,63 @@ +import { memo } from 'react' + +import type { ToolUseBlock } from '@shared/types/transcript' + +import { truncateBashCommand } from '../../lib/helpers' +import { MarkerRow } from '../MarkerRow' + +/* ---------- Tool use: "⏺ Bash ⎿ $ command" ---------- */ + +// Generic tool_use row — the fallback renderer when the tool name +// doesn't match one of the per-tool rich rows (Edit / MultiEdit / +// Write / TodoWrite for Claude; provider dispatchers for Codex). +// Shows "⏺ " followed by a muted `⎿ ` line with +// the command / description / path pulled from the tool's input +// object. +// +// Bash commands get the 2-line / 160-char cap claude-code's Ink UI +// enforces (see claude-code-src/full/tools/BashTool/UI.tsx). The +// truncateBashCommand helper applies the same caps so the feed reads +// like the upstream TUI — long / multiline invocations collapse with +// a trailing `…`. The whole command remains in the transcript; the +// collapse is purely a density choice so a 20-line heredoc doesn't +// push the next assistant message below the fold. +export 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 = block.input as Record | undefined + const rawHeadline = typeof input?.command === 'string' + ? input.command + : typeof input?.description === 'string' + ? input.description + : typeof input?.path === 'string' + ? input.path + : null + + // Bash commands get the 2-line / 160-char cap claude-code's Ink UI + // enforces. `description` and `path` headlines are already one-line- + // ish so we only truncate when the headline came from `command`. + const headline = (() => { + if (!rawHeadline) return null + if (block.name === 'Bash' && typeof input?.command === 'string') { + return truncateBashCommand(input.command) + } + return rawHeadline + })() + + return ( + +
+
+ {block.name} +
+ {headline && ( + +
+              {headline}
+            
+
+ )} +
+
+ ) +}) diff --git a/src/renderer/src/features/feed/ui/rows/TruncatedOutputRow.tsx b/src/renderer/src/features/feed/ui/rows/TruncatedOutputRow.tsx new file mode 100644 index 000000000..91419d892 --- /dev/null +++ b/src/renderer/src/features/feed/ui/rows/TruncatedOutputRow.tsx @@ -0,0 +1,54 @@ +import { useState } from 'react' + +import { MarkerRow } from '../MarkerRow' + +// MAX_LINES_TO_SHOW in claude-code-src. Hoisted so the memo'd row +// component doesn't re-create the constant every render. +const RESULT_MAX_LINES = 3 + +// Collapsed output view for tool_result content that isn't worth a +// full dump (Bash, Glob, LS, error stacks). Shows the first 3 lines +// and offers a click-to-expand button revealing the full output +// inside a 360px scroll cap. Mirrors claude-code's OutputLine + +// renderTruncatedContent from claude-code-src — keeping the feed +// dense so a long `find .` or noisy test run doesn't push the +// assistant's next message off-screen. +export 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 && ( + + )} +
+ ) +} diff --git a/src/renderer/src/features/feed/ui/rows/index.ts b/src/renderer/src/features/feed/ui/rows/index.ts new file mode 100644 index 000000000..72eeb356e --- /dev/null +++ b/src/renderer/src/features/feed/ui/rows/index.ts @@ -0,0 +1,21 @@ +// Barrel for the feed's row components. +// +// Feed.tsx imports EntryRow + LazyEntry + EAGER_TAIL directly; the +// rest of these exports are here because rows cross-reference each +// other through this barrel (EntryRow → Conversation/Compact/System, +// ConversationRow → Block, Block → ImageBlockRow/ToolUseRow/...) and +// keeping the barrel stable means future Feed changes never have to +// update individual per-component paths. + +export { EAGER_TAIL, LazyEntry } from './LazyEntry' +export { EntryRow } from './EntryRow' +export { ConversationRow } from './ConversationRow' +export { Block } from './Block' +export { CompactBoundaryRow } from './CompactBoundaryRow' +export { CompactSummaryRow } from './CompactSummaryRow' +export { SystemRow } from './SystemRow' +export { ImageBlockRow } from './ImageBlockRow' +export { ToolUseRow } from './ToolUseRow' +export { ToolResultRow } from './ToolResultRow' +export { TruncatedOutputRow } from './TruncatedOutputRow' +export { UserBand, ToolBand } from './primitives' diff --git a/src/renderer/src/features/feed/ui/rows/primitives.tsx b/src/renderer/src/features/feed/ui/rows/primitives.tsx new file mode 100644 index 000000000..c396a101c --- /dev/null +++ b/src/renderer/src/features/feed/ui/rows/primitives.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from 'react' + +// Layout primitives used by multiple row components. +// +// UserBand and ToolBand are horizontal "bands" that tint the full +// width of the feed column behind their children. They both use +// the same `-mx-8 px-8` negative-margin + compensating-padding +// trick: feed rows sit inside a `px-8` centered column, so a naive +// bg-color on the row 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. + +/** + * 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. + */ +export function UserBand({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ) +} + +/** + * 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. + */ +export function ToolBand({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ) +} From 614feaa9d5bc4ad9e2e9c0a5e3948bdc7b4a6232 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 23 Apr 2026 20:56:42 +0200 Subject: [PATCH 17/27] =?UTF-8?q?fix(main):=20restore=20relative=20preload?= =?UTF-8?q?=20path=20=E2=80=94=20not=20an=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/main/window/mainWindow.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/window/mainWindow.ts b/src/main/window/mainWindow.ts index 7b3267736..b23174377 100644 --- a/src/main/window/mainWindow.ts +++ b/src/main/window/mainWindow.ts @@ -71,7 +71,16 @@ export function createMainWindow(): void { titleBarStyle: 'hiddenInset', backgroundColor: '#0a0a0a', webPreferences: { - preload: join(__dirname, '@preload/index.mjs'), + // This is a RUNTIME filesystem path, not an import — `@preload/…` + // path aliases are resolved by vite at build time only, and don't + // intercept Node's `path.join`. At runtime __dirname is `out/main/` + // (the built main bundle sits there) and the preload bundle + // electron-vite emits lives at `out/preload/index.mjs`, so the + // relative hop is unavoidable. If this ever gets sed-rewritten to + // '@preload/index.mjs' again, Electron fails to load the preload, + // `window.api` is undefined in the renderer, every IPC call + // throws on startup, and the window ends up a black rectangle. + preload: join(__dirname, '../preload/index.mjs'), sandbox: false, contextIsolation: true, nodeIntegration: false, From 6873387a91e82e88e5fcb77d33d5ff3ef64ae2f6 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 23 Apr 2026 21:10:42 +0200 Subject: [PATCH 18/27] refactor imports to domain aliases --- electron.vite.config.ts | 19 ++++--- src/main/index.ts | 20 ++++---- src/main/ipc/debug.ts | 2 +- src/main/ipc/fs.ts | 2 +- src/main/ipc/ghost.ts | 4 +- src/main/ipc/index.ts | 24 ++++----- src/main/ipc/lsp.ts | 2 +- src/main/ipc/provider.ts | 6 +-- src/main/ipc/session.ts | 4 +- src/main/ipc/sessions.ts | 4 +- src/main/ipc/workspace.ts | 2 +- src/main/providerSwitch/duplicateSession.ts | 2 +- src/main/providerSwitch/rewindSession.ts | 2 +- src/main/providerSwitch/switchProvider.ts | 2 +- src/main/sessionManager.ts | 2 +- src/main/sessions/forwarder.ts | 8 +-- src/main/sessions/jsonlCoalescer.ts | 2 +- src/main/storage/feedDebugLog.ts | 2 +- src/main/tmux/TmuxRegistry.ts | 2 +- src/main/tmux/tmuxRecovery.ts | 2 +- src/main/tmux/verify-tmux.ts | 4 +- src/preload/api/debug.ts | 2 +- src/preload/api/fs.ts | 2 +- src/preload/api/index.ts | 20 ++++---- src/preload/api/ipc.ts | 2 +- src/preload/api/lsp.ts | 2 +- src/preload/api/session.ts | 4 +- src/preload/api/sessions.ts | 2 +- src/preload/api/system.ts | 4 +- src/preload/index.d.ts | 2 +- src/preload/index.ts | 4 +- src/providers/codex/runtime/sessionList.ts | 2 +- src/providers/registry.main.ts | 6 +-- src/renderer/src/App.tsx | 50 +++++++++---------- .../src/features/command-palette/registry.ts | 20 ++++---- .../src/features/command-palette/types.ts | 2 +- .../command-palette/ui/CommandPalette.tsx | 6 +-- .../commands/copyAssistantCommands.ts | 2 +- .../src/features/debug/ui/DebugPanel.tsx | 2 +- .../src/features/debug/ui/FeedDebugPanel.tsx | 2 +- .../src/features/debug/ui/HtmlDebugPanel.tsx | 2 +- .../src/features/debug/ui/ProxyDebugPanel.tsx | 4 +- .../src/features/feed/AppearanceMenu.tsx | 2 +- .../src/features/feed/WorkIndicator.tsx | 4 +- src/renderer/src/features/feed/context.tsx | 2 +- src/renderer/src/features/feed/lib/helpers.ts | 2 +- src/renderer/src/features/feed/scroll.ts | 2 +- src/renderer/src/features/feed/ui/Feed.tsx | 22 ++++---- .../feed/ui/markdown/MarkdownComponents.tsx | 4 +- .../src/features/feed/ui/markdown/Prose.tsx | 4 +- .../src/features/feed/ui/markdown/index.ts | 4 +- .../src/features/feed/ui/rows/Block.tsx | 20 ++++---- .../feed/ui/rows/CompactBoundaryRow.tsx | 2 +- .../feed/ui/rows/CompactSummaryRow.tsx | 4 +- .../features/feed/ui/rows/ConversationRow.tsx | 8 +-- .../src/features/feed/ui/rows/EntryRow.tsx | 8 +-- .../features/feed/ui/rows/ImageBlockRow.tsx | 6 +-- .../src/features/feed/ui/rows/SystemRow.tsx | 4 +- .../features/feed/ui/rows/ToolResultRow.tsx | 12 ++--- .../src/features/feed/ui/rows/ToolUseRow.tsx | 4 +- .../feed/ui/rows/TruncatedOutputRow.tsx | 2 +- .../src/features/feed/ui/rows/index.ts | 24 ++++----- .../features/feed/ui/semantic/BlockRow.tsx | 12 ++--- .../feed/ui/semantic/CollapsedActivityRow.tsx | 4 +- .../feed/ui/semantic/StreamingTurn.tsx | 12 ++--- .../features/feed/ui/semantic/TodoList.tsx | 2 +- .../src/features/feed/ui/semantic/index.ts | 12 ++--- .../features/feed/ui/semantic/renderUnits.ts | 6 +-- .../src/features/feed/ui/semantic/types.ts | 2 +- .../src/features/feed/workIndicatorHints.ts | 2 +- src/renderer/src/features/git/ui/GitRows.tsx | 2 +- .../path-picker/ui/PathPickerModal.tsx | 2 +- .../reader/commands/readerCommands.ts | 2 +- .../src/features/reader/ui/ReaderView.tsx | 10 ++-- .../settings/commands/dangerousActions.ts | 2 +- .../settings/commands/dangerousCommands.ts | 4 +- .../settings/commands/settingsCommands.ts | 4 +- .../features/settings/lib/settingsRegistry.ts | 6 +-- .../src/features/settings/ui/SettingsList.tsx | 6 +-- .../src/features/settings/ui/SettingsPage.tsx | 14 +++--- .../features/settings/ui/SettingsSidebar.tsx | 2 +- .../spotlight/commands/spotlightCommands.ts | 2 +- .../features/spotlight/ui/SpotlightView.tsx | 6 +-- .../tile-tabs/commands/tileTabsCommands.ts | 2 +- .../features/tile-tabs/ui/TileTabsModal.tsx | 2 +- .../features/tile-tabs/ui/TileTabsView.tsx | 4 +- .../workspace/commands/layoutCommands.ts | 2 +- .../workspace/commands/paneCommands.ts | 4 +- .../workspace/commands/sessionCommands.ts | 2 +- .../workspace/commands/tabCommands.ts | 2 +- .../workspace/lib/latestUserPrompts.ts | 2 +- .../workspace/lib/newAgentPlacement.ts | 4 +- .../workspace/ui/AgentActivityModal.tsx | 8 +-- .../workspace/ui/NewAgentPlacementOverlay.tsx | 6 +-- .../workspace/ui/PromptSearchModal.tsx | 4 +- .../workspace/ui/RewindToPromptModal.tsx | 6 +-- .../workspace/ui/ViewPromptsModal.tsx | 6 +-- src/renderer/src/lib/code/CodeBlock.tsx | 2 +- src/renderer/src/lib/copyAssistant.ts | 2 +- src/renderer/src/lib/undoClose.ts | 4 +- src/renderer/src/main.tsx | 6 +-- src/renderer/src/state/hooks.ts | 2 +- .../src/state/settings/persistence.ts | 2 +- src/renderer/src/state/settings/slice.ts | 8 +-- src/renderer/src/state/settings/theme.ts | 2 +- src/renderer/src/state/store.ts | 10 ++-- src/renderer/src/state/types.ts | 10 ++-- src/renderer/src/state/uiShell/slice.ts | 2 +- src/renderer/src/state/uiShell/types.ts | 2 +- src/renderer/src/state/workspace/slice.ts | 6 +-- src/renderer/src/workspace/codex/rollout.ts | 2 +- src/renderer/src/workspace/ghosts.ts | 2 +- .../src/workspace/hook/actions/draft.ts | 6 +-- .../src/workspace/hook/actions/history.ts | 12 ++--- .../src/workspace/hook/actions/pane.ts | 20 ++++---- .../src/workspace/hook/actions/picker.ts | 10 ++-- .../src/workspace/hook/actions/provider.ts | 10 ++-- .../src/workspace/hook/actions/reader.ts | 6 +-- .../src/workspace/hook/actions/resize.ts | 8 +-- .../src/workspace/hook/actions/session.ts | 16 +++--- .../src/workspace/hook/actions/spotlight.ts | 6 +-- .../src/workspace/hook/actions/statusMode.ts | 2 +- .../src/workspace/hook/actions/streaming.ts | 14 +++--- .../src/workspace/hook/actions/tab.ts | 12 ++--- .../src/workspace/hook/actions/tileTabs.ts | 8 +-- .../src/workspace/hook/actions/undoClose.ts | 12 ++--- src/renderer/src/workspace/hook/context.ts | 8 +-- src/renderer/src/workspace/hook/helpers.ts | 12 ++--- src/renderer/src/workspace/hook/index.ts | 48 +++++++++--------- .../workspace/hook/invalidation/effects.ts | 12 ++--- .../workspace/hook/ipc/useIpcSubscriptions.ts | 30 +++++------ .../workspace/hook/persistence/rehydrate.ts | 14 +++--- .../workspace/hook/persistence/useAutoSave.ts | 6 +-- .../hook/persistence/useBootstrap.ts | 8 +-- .../hook/persistence/useFeedDebugPersist.ts | 6 +-- src/renderer/src/workspace/hook/refs.ts | 6 +-- src/renderer/src/workspace/layout/helpers.ts | 6 +-- src/renderer/src/workspace/mergedEntries.ts | 2 +- src/renderer/src/workspace/persistence.ts | 4 +- .../src/workspace/runtime/feedDebug.ts | 2 +- .../src/workspace/semantic/foldEvent.ts | 8 +-- .../src/workspace/semantic/helpers.ts | 2 +- .../src/workspace/semantic/summarize.ts | 2 +- .../src/workspace/spawn/errorMessage.ts | 2 +- .../src/workspace/tile-tree/TabBar.tsx | 4 +- .../src/workspace/tile-tree/TerminalLeaf.tsx | 4 +- .../src/workspace/tile-tree/TileLeaf.tsx | 16 +++--- .../src/workspace/tile-tree/TileTree.tsx | 8 +-- .../src/workspace/tile-tree/geometry.ts | 2 +- .../src/workspace/tile-tree/treeOps.ts | 2 +- .../src/workspace/tile-tree/useKeybinds.ts | 4 +- src/renderer/src/workspace/workspaceState.ts | 2 +- src/renderer/src/workspace/workspaceStore.ts | 14 +++--- src/shared/parsers/claudeScreen.ts | 6 +-- src/shared/parsers/codexScreen.ts | 4 +- src/shared/parsers/extractAssistant.ts | 4 +- src/shared/types/providerConfig.ts | 2 +- testing/ghost-demo/bridge.mts | 11 ++-- tsconfig.node.json | 3 ++ tsconfig.web.json | 6 +++ 160 files changed, 527 insertions(+), 514 deletions(-) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index d799eb973..5d48ff937 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -7,11 +7,14 @@ import { resolve } from 'path' // Only main + preload use these (they import Node APIs like // EventEmitter, chokidar, fs). The renderer CANNOT import them — // Vite would try to bundle Node modules for the browser and fail. -const headlessAlias = { - 'claude-code-headless': resolve(__dirname, 'claude-code-headless/src/index.ts'), - 'codex-headless': resolve(__dirname, 'codex-headless/src/index.ts'), - 'agent-transcript-parser': resolve(__dirname, 'agent-transcript-parser/src/index.ts'), -} +const headlessAlias = [ + { find: /^claude-code-headless\/(.+)$/, replacement: `${resolve(__dirname, 'claude-code-headless/src')}/$1` }, + { find: 'claude-code-headless', replacement: resolve(__dirname, 'claude-code-headless/src/index.ts') }, + { find: /^codex-headless\/(.+)$/, replacement: `${resolve(__dirname, 'codex-headless/src')}/$1` }, + { find: 'codex-headless', replacement: resolve(__dirname, 'codex-headless/src/index.ts') }, + { find: /^agent-transcript-parser\/(.+)$/, replacement: `${resolve(__dirname, 'agent-transcript-parser/src')}/$1` }, + { find: 'agent-transcript-parser', replacement: resolve(__dirname, 'agent-transcript-parser/src/index.ts') }, +] // Project-wide absolute-import aliases. MUST match tsconfig.node.json // + tsconfig.web.json `paths` — the two resolvers must agree or tsc @@ -30,7 +33,7 @@ const headlessExclude = ['claude-code-headless', 'codex-headless', 'agent-transc export default defineConfig({ main: { plugins: [externalizeDepsPlugin({ exclude: headlessExclude })], - resolve: { alias: { ...headlessAlias, ...projectAlias } }, + resolve: { alias: [...headlessAlias, ...Object.entries(projectAlias).map(([find, replacement]) => ({ find, replacement }))] }, build: { rollupOptions: { input: resolve(__dirname, 'src/main/index.ts') @@ -39,7 +42,7 @@ export default defineConfig({ }, preload: { plugins: [externalizeDepsPlugin({ exclude: headlessExclude })], - resolve: { alias: { ...headlessAlias, ...projectAlias } }, + resolve: { alias: [...headlessAlias, ...Object.entries(projectAlias).map(([find, replacement]) => ({ find, replacement }))] }, build: { rollupOptions: { input: resolve(__dirname, 'src/preload/index.ts') @@ -48,7 +51,7 @@ export default defineConfig({ }, renderer: { root: resolve(__dirname, 'src/renderer'), - resolve: { alias: projectAlias }, + resolve: { alias: [...headlessAlias, ...Object.entries(projectAlias).map(([find, replacement]) => ({ find, replacement }))] }, build: { rollupOptions: { input: resolve(__dirname, 'src/renderer/index.html') diff --git a/src/main/index.ts b/src/main/index.ts index 9a6843fec..22bd337ea 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,17 +1,17 @@ import { app, BrowserWindow } from 'electron' import { readFile } from 'fs/promises' -import { SessionManager } from './sessionManager.js' -import { LspManager } from './lspManager.js' -import { GhostJournalRegistry } from './ghostJournal.js' -import { TmuxRegistry } from './tmux/TmuxRegistry.js' -import { reconcile, type PersistedTerminalRef } from './tmux/tmuxRecovery.js' +import { SessionManager } from '@main/sessionManager.js' +import { LspManager } from '@main/lspManager.js' +import { GhostJournalRegistry } from '@main/ghostJournal.js' +import { TmuxRegistry } from '@main/tmux/TmuxRegistry.js' +import { reconcile, type PersistedTerminalRef } from '@main/tmux/tmuxRecovery.js' -import { STATE_FILE } from './storage/paths.js' -import { cleanupClaudeImageCacheDir } from './storage/claudeImageCache.js' -import { createMainWindow } from './window/mainWindow.js' -import { wireSessionForwarder } from './sessions/forwarder.js' -import { registerAllIpc } from './ipc/index.js' +import { STATE_FILE } from '@main/storage/paths.js' +import { cleanupClaudeImageCacheDir } from '@main/storage/claudeImageCache.js' +import { createMainWindow } from '@main/window/mainWindow.js' +import { wireSessionForwarder } from '@main/sessions/forwarder.js' +import { registerAllIpc } from '@main/ipc/index.js' // Main process — thin Electron host. // diff --git a/src/main/ipc/debug.ts b/src/main/ipc/debug.ts index 48fd38ba4..c0bdbbfa7 100644 --- a/src/main/ipc/debug.ts +++ b/src/main/ipc/debug.ts @@ -3,7 +3,7 @@ import { ipcMain } from 'electron' import { queueFeedDebugAppend, type FeedDebugPersistEntry, -} from '../storage/feedDebugLog.js' +} from '@main/storage/feedDebugLog.js' // Debug-panel IPC. // diff --git a/src/main/ipc/fs.ts b/src/main/ipc/fs.ts index a76d8d722..3db3f8808 100644 --- a/src/main/ipc/fs.ts +++ b/src/main/ipc/fs.ts @@ -3,7 +3,7 @@ import { homedir } from 'os' import { join, resolve } from 'path' import { readdir, stat } from 'fs/promises' -import { saveClaudeImage } from '../storage/claudeImageCache.js' +import { saveClaudeImage } from '@main/storage/claudeImageCache.js' // Filesystem-level IPC — path expansion, directory listing, Claude // image paste cache. diff --git a/src/main/ipc/ghost.ts b/src/main/ipc/ghost.ts index 476c15002..3c8e6b67f 100644 --- a/src/main/ipc/ghost.ts +++ b/src/main/ipc/ghost.ts @@ -1,8 +1,8 @@ import { ipcMain } from 'electron' import type { GhostEntry } from 'agent-transcript-parser' -import type { GhostJournalRegistry } from '../ghostJournal.js' -import { readGhostLog } from '../ghostJournal.js' +import type { GhostJournalRegistry } from '@main/ghostJournal.js' +import { readGhostLog } from '@main/ghostJournal.js' // Ghost journal IPC. // diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index e393c7c6a..e8b5a1ee2 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -1,16 +1,16 @@ -import type { SessionManager } from '../sessionManager.js' -import type { LspManager } from '../lspManager.js' -import type { GhostJournalRegistry } from '../ghostJournal.js' +import type { SessionManager } from '@main/sessionManager.js' +import type { LspManager } from '@main/lspManager.js' +import type { GhostJournalRegistry } from '@main/ghostJournal.js' -import { registerSessionIpc } from './session.js' -import { registerProviderIpc } from './provider.js' -import { registerLspIpc } from './lsp.js' -import { registerFsIpc } from './fs.js' -import { registerSessionsIpc } from './sessions.js' -import { registerWorkspaceIpc } from './workspace.js' -import { registerGhostIpc } from './ghost.js' -import { registerDebugIpc } from './debug.js' -import { registerGitIpc } from './git.js' +import { registerSessionIpc } from '@main/ipc/session.js' +import { registerProviderIpc } from '@main/ipc/provider.js' +import { registerLspIpc } from '@main/ipc/lsp.js' +import { registerFsIpc } from '@main/ipc/fs.js' +import { registerSessionsIpc } from '@main/ipc/sessions.js' +import { registerWorkspaceIpc } from '@main/ipc/workspace.js' +import { registerGhostIpc } from '@main/ipc/ghost.js' +import { registerDebugIpc } from '@main/ipc/debug.js' +import { registerGitIpc } from '@main/ipc/git.js' // IPC registration aggregator. // diff --git a/src/main/ipc/lsp.ts b/src/main/ipc/lsp.ts index 33957c671..3cdf01d83 100644 --- a/src/main/ipc/lsp.ts +++ b/src/main/ipc/lsp.ts @@ -1,6 +1,6 @@ import { ipcMain } from 'electron' -import type { LspManager } from '../lspManager.js' +import type { LspManager } from '@main/lspManager.js' // LSP-backed code intelligence for Monaco code blocks. // diff --git a/src/main/ipc/provider.ts b/src/main/ipc/provider.ts index 8577f1b54..eb2566779 100644 --- a/src/main/ipc/provider.ts +++ b/src/main/ipc/provider.ts @@ -1,11 +1,11 @@ import { ipcMain } from 'electron' -import { switchProvider } from '../providerSwitch/switchProvider.js' -import { duplicateSession } from '../providerSwitch/duplicateSession.js' +import { switchProvider } from '@main/providerSwitch/switchProvider.js' +import { duplicateSession } from '@main/providerSwitch/duplicateSession.js' import { rewindSession, type RewindSessionRequest, -} from '../providerSwitch/rewindSession.js' +} from '@main/providerSwitch/rewindSession.js' // Provider-level session transforms. // diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index 0fdb4d467..7471ab08d 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -1,10 +1,10 @@ import { ipcMain } from 'electron' -import type { SessionManager } from '../sessionManager.js' +import type { SessionManager } from '@main/sessionManager.js' import { getMainProvider } from '@providers/registry.main.js' import { listAllClaudeSessions } from '@providers/claude/runtime/sessionList.js' import { listCodexSessions } from '@providers/codex/runtime/sessionList.js' -import { loadOlderHistoryChunk } from '../sessions/historyLoader.js' +import { loadOlderHistoryChunk } from '@main/sessions/historyLoader.js' // Session lifecycle + I/O IPC. // diff --git a/src/main/ipc/sessions.ts b/src/main/ipc/sessions.ts index 169c60f86..c34899a29 100644 --- a/src/main/ipc/sessions.ts +++ b/src/main/ipc/sessions.ts @@ -22,7 +22,7 @@ export function registerSessionsIpc(): void { _evt, options: { limit?: number; promptsPerSession?: number; cwd?: string | null } = {}, ) => { - const { listRecentSessionsWithPrompts } = await import('../sessionIndex.js') + const { listRecentSessionsWithPrompts } = await import('@main/sessionIndex.js') return listRecentSessionsWithPrompts(options) }, ) @@ -38,7 +38,7 @@ export function registerSessionsIpc(): void { cwd?: string | null }, ) => { - const { searchSessionPrompts } = await import('../sessionIndex.js') + const { searchSessionPrompts } = await import('@main/sessionIndex.js') return searchSessionPrompts(options) }, ) diff --git a/src/main/ipc/workspace.ts b/src/main/ipc/workspace.ts index 8789b7b37..603ed135c 100644 --- a/src/main/ipc/workspace.ts +++ b/src/main/ipc/workspace.ts @@ -1,7 +1,7 @@ import { ipcMain } from 'electron' import { mkdir, readFile, writeFile, rename } from 'fs/promises' -import { STATE_DIR, STATE_FILE } from '../storage/paths.js' +import { STATE_DIR, STATE_FILE } from '@main/storage/paths.js' // Workspace state persistence. // diff --git a/src/main/providerSwitch/duplicateSession.ts b/src/main/providerSwitch/duplicateSession.ts index 2fd27d4fe..0430377c3 100644 --- a/src/main/providerSwitch/duplicateSession.ts +++ b/src/main/providerSwitch/duplicateSession.ts @@ -33,7 +33,7 @@ import { readJsonlFile, writeClaudeSessionFile, writeCodexRolloutFile, -} from './shared.js' +} from '@main/providerSwitch/shared.js' export type DuplicateSessionRequest = { provider: 'claude' | 'codex' diff --git a/src/main/providerSwitch/rewindSession.ts b/src/main/providerSwitch/rewindSession.ts index e61c6b482..2af3f269c 100644 --- a/src/main/providerSwitch/rewindSession.ts +++ b/src/main/providerSwitch/rewindSession.ts @@ -31,7 +31,7 @@ import { readJsonlFile, writeClaudeSessionFile, writeCodexRolloutFile, -} from './shared.js' +} from '@main/providerSwitch/shared.js' export type RewindSessionAnchor = | ({ kind: 'claude' } & RewindClaudeAnchor) diff --git a/src/main/providerSwitch/switchProvider.ts b/src/main/providerSwitch/switchProvider.ts index e30d2ab44..67ed29522 100644 --- a/src/main/providerSwitch/switchProvider.ts +++ b/src/main/providerSwitch/switchProvider.ts @@ -9,7 +9,7 @@ import { readJsonlFile, writeClaudeSessionFile, writeCodexRolloutFile, -} from './shared.js' +} from '@main/providerSwitch/shared.js' export type SwitchProviderRequest = { sourceKind: 'claude' | 'codex' diff --git a/src/main/sessionManager.ts b/src/main/sessionManager.ts index 76973a6b9..76d0b9229 100644 --- a/src/main/sessionManager.ts +++ b/src/main/sessionManager.ts @@ -5,7 +5,7 @@ import { getMainProvider } from '@providers/registry.main.js' import type { ScreenSnapshot } from '@providers/claude/runtime/claudeSession.js' import { TerminalSession } from '@shared/runtime/terminalSession.js' import type { JsonlEntry } from 'claude-code-headless' -import { TmuxRegistry } from './tmux/TmuxRegistry.js' +import { TmuxRegistry } from '@main/tmux/TmuxRegistry.js' // SessionManager: a thin registry on top of ClaudeSession / TerminalSession // that lets the main process run N sessions in parallel. Every event diff --git a/src/main/sessions/forwarder.ts b/src/main/sessions/forwarder.ts index 748549b9c..bc84af29c 100644 --- a/src/main/sessions/forwarder.ts +++ b/src/main/sessions/forwarder.ts @@ -1,8 +1,8 @@ -import type { SessionManager } from '../sessionManager.js' -import type { LspManager } from '../lspManager.js' +import type { SessionManager } from '@main/sessionManager.js' +import type { LspManager } from '@main/lspManager.js' -import { sendToMainWindow } from '../window/mainWindow.js' -import { enqueueJsonl, flushAndDropJsonl } from './jsonlCoalescer.js' +import { sendToMainWindow } from '@main/window/mainWindow.js' +import { enqueueJsonl, flushAndDropJsonl } from '@main/sessions/jsonlCoalescer.js' // Session event forwarder. // diff --git a/src/main/sessions/jsonlCoalescer.ts b/src/main/sessions/jsonlCoalescer.ts index 683c87959..31c0b9708 100644 --- a/src/main/sessions/jsonlCoalescer.ts +++ b/src/main/sessions/jsonlCoalescer.ts @@ -1,6 +1,6 @@ import type { JsonlEntry } from 'claude-code-headless' -import { sendToMainWindow } from '../window/mainWindow.js' +import { sendToMainWindow } from '@main/window/mainWindow.js' // Per-session jsonl-entry coalescer. // diff --git a/src/main/storage/feedDebugLog.ts b/src/main/storage/feedDebugLog.ts index ff4a673b9..744ae38d6 100644 --- a/src/main/storage/feedDebugLog.ts +++ b/src/main/storage/feedDebugLog.ts @@ -1,7 +1,7 @@ import { mkdir, writeFile } from 'fs/promises' import { join } from 'path' -import { FEED_DEBUG_DIR } from './paths.js' +import { FEED_DEBUG_DIR } from '@main/storage/paths.js' // Per-session feed-debug log writer. // diff --git a/src/main/tmux/TmuxRegistry.ts b/src/main/tmux/TmuxRegistry.ts index 4e58b09b0..ddf90bd7f 100644 --- a/src/main/tmux/TmuxRegistry.ts +++ b/src/main/tmux/TmuxRegistry.ts @@ -13,7 +13,7 @@ import { spawn as childSpawn } from 'node:child_process' import { randomUUID } from 'node:crypto' -import { TMUX_SESSION_FLAGS } from './tmuxConfig.js' +import { TMUX_SESSION_FLAGS } from '@main/tmux/tmuxConfig.js' export type TmuxRegistryOptions = { /** All session names this registry manages will start with this diff --git a/src/main/tmux/tmuxRecovery.ts b/src/main/tmux/tmuxRecovery.ts index 2c9352936..230711066 100644 --- a/src/main/tmux/tmuxRecovery.ts +++ b/src/main/tmux/tmuxRecovery.ts @@ -10,7 +10,7 @@ // We do NOT touch sessions outside our prefix — those belong to the // user and are none of our business. -import type { TmuxRegistry } from './TmuxRegistry.js' +import type { TmuxRegistry } from '@main/tmux/TmuxRegistry.js' export type PersistedTerminalRef = { sessionId: string diff --git a/src/main/tmux/verify-tmux.ts b/src/main/tmux/verify-tmux.ts index 9f0d50778..e8caf9b5f 100644 --- a/src/main/tmux/verify-tmux.ts +++ b/src/main/tmux/verify-tmux.ts @@ -2,8 +2,8 @@ // Exits non-zero on any failure. No test framework — this matches // the verify scripts in the headless packages. -import { TmuxRegistry } from './TmuxRegistry.js' -import { reconcile } from './tmuxRecovery.js' +import { TmuxRegistry } from '@main/tmux/TmuxRegistry.js' +import { reconcile } from '@main/tmux/tmuxRecovery.js' const PREFIX = process.env.TMUX_TEST_PREFIX ?? 'ccshell-verify-' let failed = 0 diff --git a/src/preload/api/debug.ts b/src/preload/api/debug.ts index 011dc566b..b15693b23 100644 --- a/src/preload/api/debug.ts +++ b/src/preload/api/debug.ts @@ -1,6 +1,6 @@ import { ipcRenderer } from 'electron' -import type { FeedDebugPersistEntry } from './types.js' +import type { FeedDebugPersistEntry } from '@preload/api/types.js' // Feed-debug log shipper. // diff --git a/src/preload/api/fs.ts b/src/preload/api/fs.ts index b7aa23e26..c556d12df 100644 --- a/src/preload/api/fs.ts +++ b/src/preload/api/fs.ts @@ -1,6 +1,6 @@ import { ipcRenderer } from 'electron' -import type { SavedClaudeImage } from './types.js' +import type { SavedClaudeImage } from '@preload/api/types.js' // Filesystem bridge — path expansion + directory listing + Claude // image paste cache. diff --git a/src/preload/api/index.ts b/src/preload/api/index.ts index be6c14571..fda94747b 100644 --- a/src/preload/api/index.ts +++ b/src/preload/api/index.ts @@ -1,13 +1,13 @@ -import { sessionApi } from './session.js' -import { providerApi } from './provider.js' -import { sessionsApi } from './sessions.js' -import { lspApi } from './lsp.js' -import { workspaceApi } from './workspace.js' -import { fsApi } from './fs.js' -import { debugApi } from './debug.js' -import { systemApi } from './system.js' -import { gitApi } from './git.js' -import { ghostApi } from './ghost.js' +import { sessionApi } from '@preload/api/session.js' +import { providerApi } from '@preload/api/provider.js' +import { sessionsApi } from '@preload/api/sessions.js' +import { lspApi } from '@preload/api/lsp.js' +import { workspaceApi } from '@preload/api/workspace.js' +import { fsApi } from '@preload/api/fs.js' +import { debugApi } from '@preload/api/debug.js' +import { systemApi } from '@preload/api/system.js' +import { gitApi } from '@preload/api/git.js' +import { ghostApi } from '@preload/api/ghost.js' // Composed preload API surface. // diff --git a/src/preload/api/ipc.ts b/src/preload/api/ipc.ts index 16d3ca44d..7ccfd3516 100644 --- a/src/preload/api/ipc.ts +++ b/src/preload/api/ipc.ts @@ -1,6 +1,6 @@ import { ipcRenderer } from 'electron' -import type { Unsub } from './types.js' +import type { Unsub } from '@preload/api/types.js' // Generic IPC subscription helper. // diff --git a/src/preload/api/lsp.ts b/src/preload/api/lsp.ts index 47f57bae5..54e6f32cc 100644 --- a/src/preload/api/lsp.ts +++ b/src/preload/api/lsp.ts @@ -4,7 +4,7 @@ import type { LspDiagnosticsEvent, LspSemanticLegend, Unsub, -} from './types.js' +} from '@preload/api/types.js' // LSP bridge methods. // diff --git a/src/preload/api/session.ts b/src/preload/api/session.ts index ea5bf2bbe..9e7cdb158 100644 --- a/src/preload/api/session.ts +++ b/src/preload/api/session.ts @@ -1,6 +1,6 @@ import { ipcRenderer } from 'electron' -import { subscribe } from './ipc.js' +import { subscribe } from '@preload/api/ipc.js' import type { SessionExitEvent, SessionHistoryChunk, @@ -17,7 +17,7 @@ import type { SessionResumePromptEvent, SessionCompactionStateEvent, Unsub, -} from './types.js' +} from '@preload/api/types.js' // Session lifecycle + I/O bridge methods. // diff --git a/src/preload/api/sessions.ts b/src/preload/api/sessions.ts index 8748fd155..9b1e27095 100644 --- a/src/preload/api/sessions.ts +++ b/src/preload/api/sessions.ts @@ -1,6 +1,6 @@ import { ipcRenderer } from 'electron' -import type { SessionIndexEntry } from './types.js' +import type { SessionIndexEntry } from '@preload/api/types.js' // Session prompt index — powers the Search Conversation Prompts modal. // diff --git a/src/preload/api/system.ts b/src/preload/api/system.ts index 45d3792e9..dba8814ee 100644 --- a/src/preload/api/system.ts +++ b/src/preload/api/system.ts @@ -1,5 +1,5 @@ -import { subscribe } from './ipc.js' -import type { Unsub } from './types.js' +import { subscribe } from '@preload/api/ipc.js' +import type { Unsub } from '@preload/api/types.js' // Window-chrome coordination. // diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index e32f9e52d..2ec02313a 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,4 +1,4 @@ -import type { Api } from './index' +import type { Api } from '@preload/index' declare global { interface Window { diff --git a/src/preload/index.ts b/src/preload/index.ts index 5753c7233..148907a63 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,6 +1,6 @@ import { contextBridge } from 'electron' -import { api, type Api } from './api/index.js' +import { api, type Api } from '@preload/api/index.js' // Preload bridge — multi-session API. // @@ -54,4 +54,4 @@ export type { SessionTerminalDataEvent, SessionTrustDialogEvent, SlashPickerState, -} from './api/types.js' +} from '@preload/api/types.js' diff --git a/src/providers/codex/runtime/sessionList.ts b/src/providers/codex/runtime/sessionList.ts index cf9dafa3c..d40f77dd6 100644 --- a/src/providers/codex/runtime/sessionList.ts +++ b/src/providers/codex/runtime/sessionList.ts @@ -1,7 +1,7 @@ import { readdir, stat, open } from 'fs/promises' import { join } from 'path' -import { getCodexSessionsDir } from './projectDir.js' +import { getCodexSessionsDir } from '@providers/codex/runtime/projectDir.js' // Codex session lister — walks ~/.codex/sessions/YYYY/MM/DD/ for // rollout-*.jsonl files and extracts summary metadata for the resume diff --git a/src/providers/registry.main.ts b/src/providers/registry.main.ts index 71a3bfffc..0ec2ae314 100644 --- a/src/providers/registry.main.ts +++ b/src/providers/registry.main.ts @@ -3,16 +3,16 @@ // sessionManager and IPC handlers import from HERE. import type { MainProviderConfig } from '@shared/types/providerConfig' -import { ClaudeSession } from './claude/runtime/claudeSession' +import { ClaudeSession } from '@providers/claude/runtime/claudeSession' import { listSessionsForCwd, getProjectDirForCwd } from 'claude-code-headless' -import { CodexSession } from './codex/runtime/codexSession' +import { CodexSession } from '@providers/codex/runtime/codexSession' import { listCodexSessions, getCodexSessionsDir } from 'codex-headless' const claudeMain: MainProviderConfig = { id: 'claude', name: 'Claude Code', createSession: (opts) => new ClaudeSession(opts), - listSessions: listSessionsForCwd, + listSessions: (cwd, limit) => listSessionsForCwd(cwd, { limit }), getProjectDir: getProjectDirForCwd, } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a568fca3e..c6264e979 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,30 +1,30 @@ import { useCallback, useEffect } from 'react' -import { CommandPalette } from './features/command-palette/ui/CommandPalette' -import { DebugPanel } from './features/debug/ui/DebugPanel' -import { FeedDebugPanel } from './features/debug/ui/FeedDebugPanel' -import { HtmlDebugPanel } from './features/debug/ui/HtmlDebugPanel' -import { ProxyDebugPanel } from './features/debug/ui/ProxyDebugPanel' -import { SettingsPage } from './features/settings/ui/SettingsPage' -import { SpotlightView } from './features/spotlight/ui/SpotlightView' -import { ReaderView } from './features/reader/ui/ReaderView' -import { TileTabsModal } from './features/tile-tabs/ui/TileTabsModal' -import { TileTabsView } from './features/tile-tabs/ui/TileTabsView' -import { AgentActivityModal } from './features/workspace/ui/AgentActivityModal' -import { BuryPanePrompt } from './features/workspace/ui/BuryPanePrompt' -import { NewAgentPlacementOverlay } from './features/workspace/ui/NewAgentPlacementOverlay' -import { PromptSearchModal } from './features/workspace/ui/PromptSearchModal' -import { RewindToPromptModal } from './features/workspace/ui/RewindToPromptModal' -import { ViewPromptsModal } from './features/workspace/ui/ViewPromptsModal' -import { GitBar } from './features/git/ui/GitBar' -import { AppearanceMenu } from './features/feed/AppearanceMenu' -import { PathPickerModal } from './features/path-picker/ui/PathPickerModal' -import { TabBar } from './workspace/tile-tree/TabBar' -import { TileTree } from './workspace/tile-tree/TileTree' -import { useAppStore } from './state/hooks' -import { applyTheme } from './state/settings/theme' -import { useKeybinds } from './workspace/tile-tree/useKeybinds' -import { useWorkspace } from './workspace/workspaceStore' +import { CommandPalette } from '@renderer/features/command-palette/ui/CommandPalette' +import { DebugPanel } from '@renderer/features/debug/ui/DebugPanel' +import { FeedDebugPanel } from '@renderer/features/debug/ui/FeedDebugPanel' +import { HtmlDebugPanel } from '@renderer/features/debug/ui/HtmlDebugPanel' +import { ProxyDebugPanel } from '@renderer/features/debug/ui/ProxyDebugPanel' +import { SettingsPage } from '@renderer/features/settings/ui/SettingsPage' +import { SpotlightView } from '@renderer/features/spotlight/ui/SpotlightView' +import { ReaderView } from '@renderer/features/reader/ui/ReaderView' +import { TileTabsModal } from '@renderer/features/tile-tabs/ui/TileTabsModal' +import { TileTabsView } from '@renderer/features/tile-tabs/ui/TileTabsView' +import { AgentActivityModal } from '@renderer/features/workspace/ui/AgentActivityModal' +import { BuryPanePrompt } from '@renderer/features/workspace/ui/BuryPanePrompt' +import { NewAgentPlacementOverlay } from '@renderer/features/workspace/ui/NewAgentPlacementOverlay' +import { PromptSearchModal } from '@renderer/features/workspace/ui/PromptSearchModal' +import { RewindToPromptModal } from '@renderer/features/workspace/ui/RewindToPromptModal' +import { ViewPromptsModal } from '@renderer/features/workspace/ui/ViewPromptsModal' +import { GitBar } from '@renderer/features/git/ui/GitBar' +import { AppearanceMenu } from '@renderer/features/feed/AppearanceMenu' +import { PathPickerModal } from '@renderer/features/path-picker/ui/PathPickerModal' +import { TabBar } from '@renderer/workspace/tile-tree/TabBar' +import { TileTree } from '@renderer/workspace/tile-tree/TileTree' +import { useAppStore } from '@renderer/state/hooks' +import { applyTheme } from '@renderer/state/settings/theme' +import { useKeybinds } from '@renderer/workspace/tile-tree/useKeybinds' +import { useWorkspace } from '@renderer/workspace/workspaceStore' // App — thin shell around the workspace hook. // diff --git a/src/renderer/src/features/command-palette/registry.ts b/src/renderer/src/features/command-palette/registry.ts index 902ed33d0..a27371cdb 100644 --- a/src/renderer/src/features/command-palette/registry.ts +++ b/src/renderer/src/features/command-palette/registry.ts @@ -1,13 +1,13 @@ -import { layoutCommands } from '../workspace/commands/layoutCommands' -import { paneCommands } from '../workspace/commands/paneCommands' -import { sessionCommands } from '../workspace/commands/sessionCommands' -import { tabCommands } from '../workspace/commands/tabCommands' -import { settingsCommands } from '../settings/commands/settingsCommands' -import { spotlightCommands } from '../spotlight/commands/spotlightCommands' -import { tileTabsCommands } from '../tile-tabs/commands/tileTabsCommands' -import { readerCommands } from '../reader/commands/readerCommands' -import { copyAssistantCommands } from '../copy-assistant/commands/copyAssistantCommands' -import type { CommandContext, CommandDef, ResolvedCommand } from './types' +import { layoutCommands } from '@renderer/features/workspace/commands/layoutCommands' +import { paneCommands } from '@renderer/features/workspace/commands/paneCommands' +import { sessionCommands } from '@renderer/features/workspace/commands/sessionCommands' +import { tabCommands } from '@renderer/features/workspace/commands/tabCommands' +import { settingsCommands } from '@renderer/features/settings/commands/settingsCommands' +import { spotlightCommands } from '@renderer/features/spotlight/commands/spotlightCommands' +import { tileTabsCommands } from '@renderer/features/tile-tabs/commands/tileTabsCommands' +import { readerCommands } from '@renderer/features/reader/commands/readerCommands' +import { copyAssistantCommands } from '@renderer/features/copy-assistant/commands/copyAssistantCommands' +import type { CommandContext, CommandDef, ResolvedCommand } from '@renderer/features/command-palette/types' const commandDefs: CommandDef[] = [ ...tabCommands, diff --git a/src/renderer/src/features/command-palette/types.ts b/src/renderer/src/features/command-palette/types.ts index 1561453ed..4ee8cc31d 100644 --- a/src/renderer/src/features/command-palette/types.ts +++ b/src/renderer/src/features/command-palette/types.ts @@ -1,4 +1,4 @@ -import type { Workspace } from '../../workspace/workspaceStore' +import type { Workspace } from '@renderer/workspace/workspaceStore' export type CommandState = { label: string diff --git a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx index dc0e1d8ae..f93c1ae62 100644 --- a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx +++ b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { buildCommandRegistry } from '../registry' -import type { CommandContext, ResolvedCommand } from '../types' -import type { Workspace } from '../../../workspace/workspaceStore' +import { buildCommandRegistry } from '@renderer/features/command-palette/registry' +import type { CommandContext, ResolvedCommand } from '@renderer/features/command-palette/types' +import type { Workspace } from '@renderer/workspace/workspaceStore' // CommandPalette — VS Code-style ⌘⇧P command menu. // diff --git a/src/renderer/src/features/copy-assistant/commands/copyAssistantCommands.ts b/src/renderer/src/features/copy-assistant/commands/copyAssistantCommands.ts index 0f8957899..38c29c0a6 100644 --- a/src/renderer/src/features/copy-assistant/commands/copyAssistantCommands.ts +++ b/src/renderer/src/features/copy-assistant/commands/copyAssistantCommands.ts @@ -1,4 +1,4 @@ -import type { CommandDef } from '../../command-palette/types' +import type { CommandDef } from '@renderer/features/command-palette/types' // "Copy Assistant Message" — opens the picker on the focused pane. // All navigation (Up/Down/Enter/Esc) is handled by useKeybinds when diff --git a/src/renderer/src/features/debug/ui/DebugPanel.tsx b/src/renderer/src/features/debug/ui/DebugPanel.tsx index f52f8a498..2fe2c7315 100644 --- a/src/renderer/src/features/debug/ui/DebugPanel.tsx +++ b/src/renderer/src/features/debug/ui/DebugPanel.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react' -import type { SessionRuntime } from '../../../workspace/workspaceStore' +import type { SessionRuntime } from '@renderer/workspace/workspaceStore' import type { Entry } from '@shared/types/transcript' // DebugPanel — inline diagnostic overlay showing the raw state of the diff --git a/src/renderer/src/features/debug/ui/FeedDebugPanel.tsx b/src/renderer/src/features/debug/ui/FeedDebugPanel.tsx index cb697774f..4b1f1e323 100644 --- a/src/renderer/src/features/debug/ui/FeedDebugPanel.tsx +++ b/src/renderer/src/features/debug/ui/FeedDebugPanel.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { FeedDebugEntry, FeedDebugLayer, SessionRuntime } from '../../../workspace/workspaceState' +import type { FeedDebugEntry, FeedDebugLayer, SessionRuntime } from '@renderer/workspace/workspaceState' type Props = { sessionId: string diff --git a/src/renderer/src/features/debug/ui/HtmlDebugPanel.tsx b/src/renderer/src/features/debug/ui/HtmlDebugPanel.tsx index 79a8a611c..37b3ae936 100644 --- a/src/renderer/src/features/debug/ui/HtmlDebugPanel.tsx +++ b/src/renderer/src/features/debug/ui/HtmlDebugPanel.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' -import { sanitizeHtml } from '../../../lib/sanitizeHtml' +import { sanitizeHtml } from '@renderer/lib/sanitizeHtml' // HtmlDebugPanel — grabs the focused pane's `outerHTML` and shows // it as copy-pasteable text. Fourth in the debug-panel family diff --git a/src/renderer/src/features/debug/ui/ProxyDebugPanel.tsx b/src/renderer/src/features/debug/ui/ProxyDebugPanel.tsx index 9f22a78e9..2332d7fd1 100644 --- a/src/renderer/src/features/debug/ui/ProxyDebugPanel.tsx +++ b/src/renderer/src/features/debug/ui/ProxyDebugPanel.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react' -import { useAppStore } from '../../../state/hooks' -import { emptySemanticRuntime } from '../../../workspace/workspaceState' +import { useAppStore } from '@renderer/state/hooks' +import { emptySemanticRuntime } from '@renderer/workspace/workspaceState' // ProxyDebugPanel — live inspector for the semantic stream and any // provider transport attribution that survives into the shared runtime. diff --git a/src/renderer/src/features/feed/AppearanceMenu.tsx b/src/renderer/src/features/feed/AppearanceMenu.tsx index 47411505a..f923c338a 100644 --- a/src/renderer/src/features/feed/AppearanceMenu.tsx +++ b/src/renderer/src/features/feed/AppearanceMenu.tsx @@ -7,7 +7,7 @@ import { THEME_MODES, isDarkThemeMode, type ThemeMode, -} from '../../state/settings/types' +} from '@renderer/state/settings/types' type Props = { settings: Settings diff --git a/src/renderer/src/features/feed/WorkIndicator.tsx b/src/renderer/src/features/feed/WorkIndicator.tsx index c3b715f8d..2ff935cef 100644 --- a/src/renderer/src/features/feed/WorkIndicator.tsx +++ b/src/renderer/src/features/feed/WorkIndicator.tsx @@ -25,8 +25,8 @@ import { memo, useEffect, useState } from 'react' -import { MarkerRow } from './ui/MarkerRow' -import type { StreamPhase } from '../../workspace/workspaceState' +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' +import type { StreamPhase } from '@renderer/workspace/workspaceState' type Props = { phase: StreamPhase diff --git a/src/renderer/src/features/feed/context.tsx b/src/renderer/src/features/feed/context.tsx index 95e68d00d..09a625561 100644 --- a/src/renderer/src/features/feed/context.tsx +++ b/src/renderer/src/features/feed/context.tsx @@ -5,7 +5,7 @@ import type { ToolUseBlock, } from '@shared/types/transcript' -import type { AgentProvider } from './types' +import type { AgentProvider } from '@renderer/features/feed/types' // --------------------------------------------------------------------------- // Feed contexts. diff --git a/src/renderer/src/features/feed/lib/helpers.ts b/src/renderer/src/features/feed/lib/helpers.ts index e8ef117b3..1024bacb6 100644 --- a/src/renderer/src/features/feed/lib/helpers.ts +++ b/src/renderer/src/features/feed/lib/helpers.ts @@ -7,7 +7,7 @@ import { type ToolResultBlock, type ToolUseBlock, } from '@shared/types/transcript' -import type { SemanticLiveTurn } from '../../../workspace/workspaceState' +import type { SemanticLiveTurn } from '@renderer/workspace/workspaceState' // ----------------------------------------------------------------------------- // Feed pure helpers — no React, no state, no side effects. diff --git a/src/renderer/src/features/feed/scroll.ts b/src/renderer/src/features/feed/scroll.ts index f453423c1..777c18d1c 100644 --- a/src/renderer/src/features/feed/scroll.ts +++ b/src/renderer/src/features/feed/scroll.ts @@ -1,4 +1,4 @@ -import type { ScrollPosition } from './types' +import type { ScrollPosition } from '@renderer/features/feed/types' // Per-session scroll position memory. // diff --git a/src/renderer/src/features/feed/ui/Feed.tsx b/src/renderer/src/features/feed/ui/Feed.tsx index 70581dbde..10b7bb93d 100644 --- a/src/renderer/src/features/feed/ui/Feed.tsx +++ b/src/renderer/src/features/feed/ui/Feed.tsx @@ -17,41 +17,41 @@ import { import type { SemanticLiveTurn, StreamPhase, -} from '../../../workspace/workspaceState' -import { WorkIndicator } from '../WorkIndicator' -import { toolHintFromTurn } from '../workIndicatorHints' +} from '@renderer/workspace/workspaceState' +import { WorkIndicator } from '@renderer/features/feed/WorkIndicator' +import { toolHintFromTurn } from '@renderer/features/feed/workIndicatorHints' import { ProviderContext, ToolUseIndexContext, ToolResultIndexContext, CodeRenderContext, -} from '../context' +} from '@renderer/features/feed/context' import { type AgentProvider, type ScrollInfo, type VisibleDecision, type DebugVisibleRow, -} from '../types' -import { scrollPositions } from '../scroll' +} from '@renderer/features/feed/types' +import { scrollPositions } from '@renderer/features/feed/scroll' import { buildToolUseIndex, buildToolResultIndex, debugKeyForEntry, debugLabelForEntry, -} from '../lib/helpers' -import { SemanticStreamingTurn } from './semantic' +} from '@renderer/features/feed/lib/helpers' +import { SemanticStreamingTurn } from '@renderer/features/feed/ui/semantic' import { EAGER_TAIL, EntryRow, LazyEntry, -} from './rows' +} from '@renderer/features/feed/ui/rows' import type { ToolResultBlock, ToolUseBlock } from '@shared/types/transcript' // Re-export — many external callers import these types from Feed // directly rather than reaching into ../types/../context. Keep the // alias stable until the sweep is over. -export type { AgentProvider, ScrollInfo } from '../types' -export { CodeRenderContext } from '../context' +export type { AgentProvider, ScrollInfo } from '@renderer/features/feed/types' +export { CodeRenderContext } from '@renderer/features/feed/context' // ----------------------------------------------------------------------------- // Feed — Claude Code TUI-style inline rendering. diff --git a/src/renderer/src/features/feed/ui/markdown/MarkdownComponents.tsx b/src/renderer/src/features/feed/ui/markdown/MarkdownComponents.tsx index aad1f5c4f..949f297b1 100644 --- a/src/renderer/src/features/feed/ui/markdown/MarkdownComponents.tsx +++ b/src/renderer/src/features/feed/ui/markdown/MarkdownComponents.tsx @@ -1,8 +1,8 @@ import { useContext, type ReactNode } from 'react' -import { CodeBlock } from '../../../../lib/code/CodeBlock' +import { CodeBlock } from '@renderer/lib/code/CodeBlock' -import { CodeRenderContext } from '../../context' +import { CodeRenderContext } from '@renderer/features/feed/context' // react-markdown component overrides used inside the feed. // diff --git a/src/renderer/src/features/feed/ui/markdown/Prose.tsx b/src/renderer/src/features/feed/ui/markdown/Prose.tsx index 06e5e5707..02a15e797 100644 --- a/src/renderer/src/features/feed/ui/markdown/Prose.tsx +++ b/src/renderer/src/features/feed/ui/markdown/Prose.tsx @@ -1,9 +1,9 @@ import { memo } from 'react' import ReactMarkdown from 'react-markdown' -import { COMPLETED_REMARK, STREAMING_REMARK } from '../../lib/remark-plugins' +import { COMPLETED_REMARK, STREAMING_REMARK } from '@renderer/features/feed/lib/remark-plugins' -import { MARKDOWN_COMPONENTS } from './MarkdownComponents' +import { MARKDOWN_COMPONENTS } from '@renderer/features/feed/ui/markdown/MarkdownComponents' // Two prose renderers with the same visual surface but different // remark plugin sets. TextProse is for committed JSONL assistant diff --git a/src/renderer/src/features/feed/ui/markdown/index.ts b/src/renderer/src/features/feed/ui/markdown/index.ts index 093642594..bc4498045 100644 --- a/src/renderer/src/features/feed/ui/markdown/index.ts +++ b/src/renderer/src/features/feed/ui/markdown/index.ts @@ -2,5 +2,5 @@ // and the row components pointing at one path (`./markdown`) rather // than reaching for individual files. -export { MarkdownPre, MarkdownCode, MARKDOWN_COMPONENTS } from './MarkdownComponents' -export { TextProse, StreamingProse } from './Prose' +export { MarkdownPre, MarkdownCode, MARKDOWN_COMPONENTS } from '@renderer/features/feed/ui/markdown/MarkdownComponents' +export { TextProse, StreamingProse } from '@renderer/features/feed/ui/markdown/Prose' diff --git a/src/renderer/src/features/feed/ui/rows/Block.tsx b/src/renderer/src/features/feed/ui/rows/Block.tsx index 7590059d9..65f304a9c 100644 --- a/src/renderer/src/features/feed/ui/rows/Block.tsx +++ b/src/renderer/src/features/feed/ui/rows/Block.tsx @@ -17,22 +17,22 @@ import type { } from '@shared/types/transcript' import { detectGitIntent } from '@shared/git/gitDetect' -import { useAppStore } from '../../../../state/hooks' -import { GitCardRow } from '../../../git/ui/GitRows' +import { useAppStore } from '@renderer/state/hooks' +import { GitCardRow } from '@renderer/features/git/ui/GitRows' -import { extractToolCommand, toolResultText } from '../../lib/helpers' +import { extractToolCommand, toolResultText } from '@renderer/features/feed/lib/helpers' import { ProviderContext, ToolResultIndexContext, ToolUseIndexContext, -} from '../../context' -import { MarkerRow } from '../MarkerRow' -import { TextProse } from '../markdown' +} from '@renderer/features/feed/context' +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' +import { TextProse } from '@renderer/features/feed/ui/markdown' -import { ImageBlockRow } from './ImageBlockRow' -import { ToolBand, UserBand } from './primitives' -import { ToolResultRow } from './ToolResultRow' -import { ToolUseRow } from './ToolUseRow' +import { ImageBlockRow } from '@renderer/features/feed/ui/rows/ImageBlockRow' +import { ToolBand, UserBand } from '@renderer/features/feed/ui/rows/primitives' +import { ToolResultRow } from '@renderer/features/feed/ui/rows/ToolResultRow' +import { ToolUseRow } from '@renderer/features/feed/ui/rows/ToolUseRow' /* ---------- Block dispatcher ---------- */ diff --git a/src/renderer/src/features/feed/ui/rows/CompactBoundaryRow.tsx b/src/renderer/src/features/feed/ui/rows/CompactBoundaryRow.tsx index 97ea86758..6820da5d2 100644 --- a/src/renderer/src/features/feed/ui/rows/CompactBoundaryRow.tsx +++ b/src/renderer/src/features/feed/ui/rows/CompactBoundaryRow.tsx @@ -1,6 +1,6 @@ import { memo } from 'react' -import { MarkerRow } from '../MarkerRow' +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' // The single-line "Conversation compacted" divider that appears at // the boundary between a pre-compact and post-compact run of entries. diff --git a/src/renderer/src/features/feed/ui/rows/CompactSummaryRow.tsx b/src/renderer/src/features/feed/ui/rows/CompactSummaryRow.tsx index 1108c6312..1c362114a 100644 --- a/src/renderer/src/features/feed/ui/rows/CompactSummaryRow.tsx +++ b/src/renderer/src/features/feed/ui/rows/CompactSummaryRow.tsx @@ -2,8 +2,8 @@ import { memo, useMemo, useState } from 'react' import type { CompactSummaryEntry } from '@shared/types/transcript' -import { compactSummaryText, truncateCompactSummary } from '../../lib/helpers' -import { TextProse } from '../markdown' +import { compactSummaryText, truncateCompactSummary } from '@renderer/features/feed/lib/helpers' +import { TextProse } from '@renderer/features/feed/ui/markdown' // Compact-summary entry renderer — the card that replaces a // compacted run of turns with a human-readable summary. Shown as a diff --git a/src/renderer/src/features/feed/ui/rows/ConversationRow.tsx b/src/renderer/src/features/feed/ui/rows/ConversationRow.tsx index c89b068cc..c8b2ee571 100644 --- a/src/renderer/src/features/feed/ui/rows/ConversationRow.tsx +++ b/src/renderer/src/features/feed/ui/rows/ConversationRow.tsx @@ -2,11 +2,11 @@ import { memo } from 'react' import type { ConversationEntry } from '@shared/types/transcript' -import { MarkerRow } from '../MarkerRow' -import { TextProse } from '../markdown' +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' +import { TextProse } from '@renderer/features/feed/ui/markdown' -import { Block } from './Block' -import { UserBand } from './primitives' +import { Block } from '@renderer/features/feed/ui/rows/Block' +import { UserBand } from '@renderer/features/feed/ui/rows/primitives' // The main renderer for a single assistant or user conversation // entry. Branches on content shape: diff --git a/src/renderer/src/features/feed/ui/rows/EntryRow.tsx b/src/renderer/src/features/feed/ui/rows/EntryRow.tsx index 3b71ccfb5..1fefc1ac5 100644 --- a/src/renderer/src/features/feed/ui/rows/EntryRow.tsx +++ b/src/renderer/src/features/feed/ui/rows/EntryRow.tsx @@ -7,10 +7,10 @@ import { type Entry, } from '@shared/types/transcript' -import { CompactBoundaryRow } from './CompactBoundaryRow' -import { CompactSummaryRow } from './CompactSummaryRow' -import { ConversationRow } from './ConversationRow' -import { SystemRow } from './SystemRow' +import { CompactBoundaryRow } from '@renderer/features/feed/ui/rows/CompactBoundaryRow' +import { CompactSummaryRow } from '@renderer/features/feed/ui/rows/CompactSummaryRow' +import { ConversationRow } from '@renderer/features/feed/ui/rows/ConversationRow' +import { SystemRow } from '@renderer/features/feed/ui/rows/SystemRow' // Memoized: entry objects are stable across store updates (we append, // never mutate), so shallow compare by entry reference skips re-render diff --git a/src/renderer/src/features/feed/ui/rows/ImageBlockRow.tsx b/src/renderer/src/features/feed/ui/rows/ImageBlockRow.tsx index 8c3d38936..2d59e1e7d 100644 --- a/src/renderer/src/features/feed/ui/rows/ImageBlockRow.tsx +++ b/src/renderer/src/features/feed/ui/rows/ImageBlockRow.tsx @@ -2,10 +2,10 @@ import { memo } from 'react' import type { ContentBlock } from '@shared/types/transcript' -import { imageDataUrl } from '../../lib/helpers' -import { MarkerRow } from '../MarkerRow' +import { imageDataUrl } from '@renderer/features/feed/lib/helpers' +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' -import { UserBand } from './primitives' +import { UserBand } from '@renderer/features/feed/ui/rows/primitives' // Image content-block renderer. Handles both directions of the // conversation: an inline image pasted by the user (`❯` marker + diff --git a/src/renderer/src/features/feed/ui/rows/SystemRow.tsx b/src/renderer/src/features/feed/ui/rows/SystemRow.tsx index 38b505a20..a2413ad9c 100644 --- a/src/renderer/src/features/feed/ui/rows/SystemRow.tsx +++ b/src/renderer/src/features/feed/ui/rows/SystemRow.tsx @@ -2,8 +2,8 @@ import { memo } from 'react' import type { Entry } from '@shared/types/transcript' -import { attachmentLabel } from '../../lib/helpers' -import { MarkerRow } from '../MarkerRow' +import { attachmentLabel } from '@renderer/features/feed/lib/helpers' +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' // System-entry renderer — the hidden-by-default row that shows // meta-entries from the transcript (permission-mode switches, diff --git a/src/renderer/src/features/feed/ui/rows/ToolResultRow.tsx b/src/renderer/src/features/feed/ui/rows/ToolResultRow.tsx index daf27e83e..21af2ffa1 100644 --- a/src/renderer/src/features/feed/ui/rows/ToolResultRow.tsx +++ b/src/renderer/src/features/feed/ui/rows/ToolResultRow.tsx @@ -2,14 +2,14 @@ import { memo, useContext } from 'react' import type { ToolResultBlock } from '@shared/types/transcript' -import { CodeBlock } from '../../../../lib/code/CodeBlock' +import { CodeBlock } from '@renderer/lib/code/CodeBlock' -import { stripLineNumberPrefix } from '../../lib/helpers' -import { CodeRenderContext, ToolUseIndexContext } from '../../context' -import { MarkerRow } from '../MarkerRow' +import { stripLineNumberPrefix } from '@renderer/features/feed/lib/helpers' +import { CodeRenderContext, ToolUseIndexContext } from '@renderer/features/feed/context' +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' -import { ToolBand } from './primitives' -import { TruncatedOutputRow } from './TruncatedOutputRow' +import { ToolBand } from '@renderer/features/feed/ui/rows/primitives' +import { TruncatedOutputRow } from '@renderer/features/feed/ui/rows/TruncatedOutputRow' /* ---------- Tool result: "⎿ (lines of output)" ---------- */ diff --git a/src/renderer/src/features/feed/ui/rows/ToolUseRow.tsx b/src/renderer/src/features/feed/ui/rows/ToolUseRow.tsx index 1fa44d76d..4bea7cc2b 100644 --- a/src/renderer/src/features/feed/ui/rows/ToolUseRow.tsx +++ b/src/renderer/src/features/feed/ui/rows/ToolUseRow.tsx @@ -2,8 +2,8 @@ import { memo } from 'react' import type { ToolUseBlock } from '@shared/types/transcript' -import { truncateBashCommand } from '../../lib/helpers' -import { MarkerRow } from '../MarkerRow' +import { truncateBashCommand } from '@renderer/features/feed/lib/helpers' +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' /* ---------- Tool use: "⏺ Bash ⎿ $ command" ---------- */ diff --git a/src/renderer/src/features/feed/ui/rows/TruncatedOutputRow.tsx b/src/renderer/src/features/feed/ui/rows/TruncatedOutputRow.tsx index 91419d892..cff734be9 100644 --- a/src/renderer/src/features/feed/ui/rows/TruncatedOutputRow.tsx +++ b/src/renderer/src/features/feed/ui/rows/TruncatedOutputRow.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' -import { MarkerRow } from '../MarkerRow' +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' // MAX_LINES_TO_SHOW in claude-code-src. Hoisted so the memo'd row // component doesn't re-create the constant every render. diff --git a/src/renderer/src/features/feed/ui/rows/index.ts b/src/renderer/src/features/feed/ui/rows/index.ts index 72eeb356e..9bc551b9a 100644 --- a/src/renderer/src/features/feed/ui/rows/index.ts +++ b/src/renderer/src/features/feed/ui/rows/index.ts @@ -7,15 +7,15 @@ // keeping the barrel stable means future Feed changes never have to // update individual per-component paths. -export { EAGER_TAIL, LazyEntry } from './LazyEntry' -export { EntryRow } from './EntryRow' -export { ConversationRow } from './ConversationRow' -export { Block } from './Block' -export { CompactBoundaryRow } from './CompactBoundaryRow' -export { CompactSummaryRow } from './CompactSummaryRow' -export { SystemRow } from './SystemRow' -export { ImageBlockRow } from './ImageBlockRow' -export { ToolUseRow } from './ToolUseRow' -export { ToolResultRow } from './ToolResultRow' -export { TruncatedOutputRow } from './TruncatedOutputRow' -export { UserBand, ToolBand } from './primitives' +export { EAGER_TAIL, LazyEntry } from '@renderer/features/feed/ui/rows/LazyEntry' +export { EntryRow } from '@renderer/features/feed/ui/rows/EntryRow' +export { ConversationRow } from '@renderer/features/feed/ui/rows/ConversationRow' +export { Block } from '@renderer/features/feed/ui/rows/Block' +export { CompactBoundaryRow } from '@renderer/features/feed/ui/rows/CompactBoundaryRow' +export { CompactSummaryRow } from '@renderer/features/feed/ui/rows/CompactSummaryRow' +export { SystemRow } from '@renderer/features/feed/ui/rows/SystemRow' +export { ImageBlockRow } from '@renderer/features/feed/ui/rows/ImageBlockRow' +export { ToolUseRow } from '@renderer/features/feed/ui/rows/ToolUseRow' +export { ToolResultRow } from '@renderer/features/feed/ui/rows/ToolResultRow' +export { TruncatedOutputRow } from '@renderer/features/feed/ui/rows/TruncatedOutputRow' +export { UserBand, ToolBand } from '@renderer/features/feed/ui/rows/primitives' diff --git a/src/renderer/src/features/feed/ui/semantic/BlockRow.tsx b/src/renderer/src/features/feed/ui/semantic/BlockRow.tsx index 23af228dc..29dc689df 100644 --- a/src/renderer/src/features/feed/ui/semantic/BlockRow.tsx +++ b/src/renderer/src/features/feed/ui/semantic/BlockRow.tsx @@ -1,16 +1,16 @@ import { memo } from 'react' -import { CodeBlock } from '../../../../lib/code/CodeBlock' +import { CodeBlock } from '@renderer/lib/code/CodeBlock' import { parseSemanticTodos, type SemanticLiveTurn, -} from '../../../../workspace/workspaceState' +} from '@renderer/workspace/workspaceState' -import { splitStreamingCodeFence } from '../../lib/helpers' -import { MarkerRow } from '../MarkerRow' -import { StreamingProse } from '../markdown' +import { splitStreamingCodeFence } from '@renderer/features/feed/lib/helpers' +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' +import { StreamingProse } from '@renderer/features/feed/ui/markdown' -import { SemanticTodoList } from './TodoList' +import { SemanticTodoList } from '@renderer/features/feed/ui/semantic/TodoList' // Single live-block renderer — this is the big dispatch for the // semantic streaming path. Each SemanticLiveTurn block is one of a diff --git a/src/renderer/src/features/feed/ui/semantic/CollapsedActivityRow.tsx b/src/renderer/src/features/feed/ui/semantic/CollapsedActivityRow.tsx index 665211cda..70a962283 100644 --- a/src/renderer/src/features/feed/ui/semantic/CollapsedActivityRow.tsx +++ b/src/renderer/src/features/feed/ui/semantic/CollapsedActivityRow.tsx @@ -1,8 +1,8 @@ import { memo } from 'react' -import { MarkerRow } from '../MarkerRow' +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' -import type { SemanticRenderUnit } from './types' +import type { SemanticRenderUnit } from '@renderer/features/feed/ui/semantic/types' // Compact "worked: 3 reads, 1 search" tail for a finished batch of // low-signal tool calls. Running-ness is the WorkIndicator's job diff --git a/src/renderer/src/features/feed/ui/semantic/StreamingTurn.tsx b/src/renderer/src/features/feed/ui/semantic/StreamingTurn.tsx index 269d8e9e7..ba479211e 100644 --- a/src/renderer/src/features/feed/ui/semantic/StreamingTurn.tsx +++ b/src/renderer/src/features/feed/ui/semantic/StreamingTurn.tsx @@ -1,13 +1,13 @@ import { memo } from 'react' -import type { SemanticLiveTurn } from '../../../../workspace/workspaceState' +import type { SemanticLiveTurn } from '@renderer/workspace/workspaceState' -import { MarkerRow } from '../MarkerRow' -import { StreamingProse } from '../markdown' +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' +import { StreamingProse } from '@renderer/features/feed/ui/markdown' -import { SemanticCollapsedActivityRow } from './CollapsedActivityRow' -import { SemanticLiveBlockRow } from './BlockRow' -import { buildSemanticRenderUnits } from './renderUnits' +import { SemanticCollapsedActivityRow } from '@renderer/features/feed/ui/semantic/CollapsedActivityRow' +import { SemanticLiveBlockRow } from '@renderer/features/feed/ui/semantic/BlockRow' +import { buildSemanticRenderUnits } from '@renderer/features/feed/ui/semantic/renderUnits' // WHY render the semantic turn block-by-block instead of just dumping // `turn.text` through markdown: diff --git a/src/renderer/src/features/feed/ui/semantic/TodoList.tsx b/src/renderer/src/features/feed/ui/semantic/TodoList.tsx index 5ef23197e..455ae8fb1 100644 --- a/src/renderer/src/features/feed/ui/semantic/TodoList.tsx +++ b/src/renderer/src/features/feed/ui/semantic/TodoList.tsx @@ -1,6 +1,6 @@ import { memo } from 'react' -import type { SemanticTodoItem } from '../../../../workspace/workspaceState' +import type { SemanticTodoItem } from '@renderer/workspace/workspaceState' // Inline todo-list rendering for the semantic-streaming TodoWrite // tool row. Shows the list with status glyphs (☑ done, ◐ in-progress, diff --git a/src/renderer/src/features/feed/ui/semantic/index.ts b/src/renderer/src/features/feed/ui/semantic/index.ts index 818385a67..b13397b5d 100644 --- a/src/renderer/src/features/feed/ui/semantic/index.ts +++ b/src/renderer/src/features/feed/ui/semantic/index.ts @@ -7,9 +7,9 @@ // whole semantic section can grow/shrink without Feed having to // track per-file paths. -export { SemanticStreamingTurn } from './StreamingTurn' -export { SemanticLiveBlockRow } from './BlockRow' -export { SemanticCollapsedActivityRow } from './CollapsedActivityRow' -export { SemanticTodoList } from './TodoList' -export { buildSemanticRenderUnits } from './renderUnits' -export type { SemanticRenderUnit } from './types' +export { SemanticStreamingTurn } from '@renderer/features/feed/ui/semantic/StreamingTurn' +export { SemanticLiveBlockRow } from '@renderer/features/feed/ui/semantic/BlockRow' +export { SemanticCollapsedActivityRow } from '@renderer/features/feed/ui/semantic/CollapsedActivityRow' +export { SemanticTodoList } from '@renderer/features/feed/ui/semantic/TodoList' +export { buildSemanticRenderUnits } from '@renderer/features/feed/ui/semantic/renderUnits' +export type { SemanticRenderUnit } from '@renderer/features/feed/ui/semantic/types' diff --git a/src/renderer/src/features/feed/ui/semantic/renderUnits.ts b/src/renderer/src/features/feed/ui/semantic/renderUnits.ts index d6d97d2cb..ad37b9a1e 100644 --- a/src/renderer/src/features/feed/ui/semantic/renderUnits.ts +++ b/src/renderer/src/features/feed/ui/semantic/renderUnits.ts @@ -1,8 +1,8 @@ -import type { SemanticLiveTurn } from '../../../../workspace/workspaceState' +import type { SemanticLiveTurn } from '@renderer/workspace/workspaceState' -import { classifySemanticToolActivity } from '../../lib/helpers' +import { classifySemanticToolActivity } from '@renderer/features/feed/lib/helpers' -import type { SemanticRenderUnit } from './types' +import type { SemanticRenderUnit } from '@renderer/features/feed/ui/semantic/types' // WHY add a derived render-unit pass before painting semantic blocks: // diff --git a/src/renderer/src/features/feed/ui/semantic/types.ts b/src/renderer/src/features/feed/ui/semantic/types.ts index 2861573e7..136ab0dea 100644 --- a/src/renderer/src/features/feed/ui/semantic/types.ts +++ b/src/renderer/src/features/feed/ui/semantic/types.ts @@ -1,4 +1,4 @@ -import type { SemanticLiveTurn } from '../../../../workspace/workspaceState' +import type { SemanticLiveTurn } from '@renderer/workspace/workspaceState' // Derived render-unit shape used by buildSemanticRenderUnits. // diff --git a/src/renderer/src/features/feed/workIndicatorHints.ts b/src/renderer/src/features/feed/workIndicatorHints.ts index 44e396999..62be0f690 100644 --- a/src/renderer/src/features/feed/workIndicatorHints.ts +++ b/src/renderer/src/features/feed/workIndicatorHints.ts @@ -9,7 +9,7 @@ // visual tests trivial and keeps the component memo-friendly (it only // re-renders when the hint string changes, not on every block delta). -import type { SemanticLiveBlock, SemanticLiveTurn } from '../../workspace/workspaceState' +import type { SemanticLiveBlock, SemanticLiveTurn } from '@renderer/workspace/workspaceState' /** Pull a short, single-line hint for the pending tool call. When the * tool's parsed input is available (finalised JSON from diff --git a/src/renderer/src/features/git/ui/GitRows.tsx b/src/renderer/src/features/git/ui/GitRows.tsx index 55783d4f7..acfb9d6de 100644 --- a/src/renderer/src/features/git/ui/GitRows.tsx +++ b/src/renderer/src/features/git/ui/GitRows.tsx @@ -30,7 +30,7 @@ import { memo, useMemo, useState } from 'react' import hljs from 'highlight.js' -import { MarkerRow } from '../../../features/feed/ui/MarkerRow' +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' import { normalizeCodeLanguage } from '@shared/code/language' import type { GitIntent } from '@shared/git/gitDetect' import { diff --git a/src/renderer/src/features/path-picker/ui/PathPickerModal.tsx b/src/renderer/src/features/path-picker/ui/PathPickerModal.tsx index 654bfc5c6..18b6525a6 100644 --- a/src/renderer/src/features/path-picker/ui/PathPickerModal.tsx +++ b/src/renderer/src/features/path-picker/ui/PathPickerModal.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react' -import { PathInput } from '../../../ui/PathInput' +import { PathInput } from '@renderer/ui/PathInput' // Duplicate of the SessionInfo shape preload exposes. Inlined here so // the renderer's tsconfig doesn't need to reach across into src/preload diff --git a/src/renderer/src/features/reader/commands/readerCommands.ts b/src/renderer/src/features/reader/commands/readerCommands.ts index c2a9f77b5..7d4f5fd76 100644 --- a/src/renderer/src/features/reader/commands/readerCommands.ts +++ b/src/renderer/src/features/reader/commands/readerCommands.ts @@ -1,4 +1,4 @@ -import type { CommandDef } from '../../command-palette/types' +import type { CommandDef } from '@renderer/features/command-palette/types' export const readerCommands: CommandDef[] = [ { diff --git a/src/renderer/src/features/reader/ui/ReaderView.tsx b/src/renderer/src/features/reader/ui/ReaderView.tsx index 512e4a628..9f956f1ae 100644 --- a/src/renderer/src/features/reader/ui/ReaderView.tsx +++ b/src/renderer/src/features/reader/ui/ReaderView.tsx @@ -2,12 +2,12 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState, type Rea import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' -import { CodeBlock } from '../../../lib/code/CodeBlock' -import { CodeRenderContext } from '../../../features/feed/ui/Feed' +import { CodeBlock } from '@renderer/lib/code/CodeBlock' +import { CodeRenderContext } from '@renderer/features/feed/ui/Feed' import { extractAssistantInProgress } from '@shared/parsers/extractAssistant' -import { assistantUuidsWithText, extractAssistantByUuid } from '../../../lib/copyAssistant' -import { collectLeaves } from '../../../workspace/tile-tree/treeOps' -import type { SessionId, Workspace } from '../../../workspace/workspaceStore' +import { assistantUuidsWithText, extractAssistantByUuid } from '@renderer/lib/copyAssistant' +import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps' +import type { SessionId, Workspace } from '@renderer/workspace/workspaceStore' // ReaderView — single-message read mode for a focused session. // diff --git a/src/renderer/src/features/settings/commands/dangerousActions.ts b/src/renderer/src/features/settings/commands/dangerousActions.ts index 304d1cfdc..24b82b168 100644 --- a/src/renderer/src/features/settings/commands/dangerousActions.ts +++ b/src/renderer/src/features/settings/commands/dangerousActions.ts @@ -1,4 +1,4 @@ -import type { CommandContext } from '../../command-palette/types' +import type { CommandContext } from '@renderer/features/command-palette/types' async function setDangerousAgentsAndReload( ctx: CommandContext, diff --git a/src/renderer/src/features/settings/commands/dangerousCommands.ts b/src/renderer/src/features/settings/commands/dangerousCommands.ts index 5bbf52586..92ff4c97d 100644 --- a/src/renderer/src/features/settings/commands/dangerousCommands.ts +++ b/src/renderer/src/features/settings/commands/dangerousCommands.ts @@ -1,5 +1,5 @@ -import type { CommandDef } from '../../command-palette/types' -import { toggleDangerousAgents } from './dangerousActions' +import type { CommandDef } from '@renderer/features/command-palette/types' +import { toggleDangerousAgents } from '@renderer/features/settings/commands/dangerousActions' export const dangerousCommands: CommandDef[] = [ { diff --git a/src/renderer/src/features/settings/commands/settingsCommands.ts b/src/renderer/src/features/settings/commands/settingsCommands.ts index 81132923c..55bb3c581 100644 --- a/src/renderer/src/features/settings/commands/settingsCommands.ts +++ b/src/renderer/src/features/settings/commands/settingsCommands.ts @@ -1,5 +1,5 @@ -import type { CommandDef } from '../../command-palette/types' -import { dangerousCommands } from './dangerousCommands' +import type { CommandDef } from '@renderer/features/command-palette/types' +import { dangerousCommands } from '@renderer/features/settings/commands/dangerousCommands' export const settingsCommands: CommandDef[] = [ { diff --git a/src/renderer/src/features/settings/lib/settingsRegistry.ts b/src/renderer/src/features/settings/lib/settingsRegistry.ts index 35a3a88d7..cec7bf601 100644 --- a/src/renderer/src/features/settings/lib/settingsRegistry.ts +++ b/src/renderer/src/features/settings/lib/settingsRegistry.ts @@ -4,9 +4,9 @@ import { type AccentId, type Settings, type ThemeMode, -} from '../../../state/settings/types' -import type { Workspace } from '../../../workspace/workspaceStore' -import { SETTING_CATEGORIES, type SettingCategoryId } from './settingsCategories' +} from '@renderer/state/settings/types' +import type { Workspace } from '@renderer/workspace/workspaceStore' +import { SETTING_CATEGORIES, type SettingCategoryId } from '@renderer/features/settings/lib/settingsCategories' export type SettingActionContext = { workspace: Workspace diff --git a/src/renderer/src/features/settings/ui/SettingsList.tsx b/src/renderer/src/features/settings/ui/SettingsList.tsx index 0afd0c0d5..da7bb207b 100644 --- a/src/renderer/src/features/settings/ui/SettingsList.tsx +++ b/src/renderer/src/features/settings/ui/SettingsList.tsx @@ -1,9 +1,9 @@ -import type { Settings } from '../../../state/settings/types' +import type { Settings } from '@renderer/state/settings/types' import type { SettingActionContext, SettingDefinition, -} from '../lib/settingsRegistry' -import { SETTING_CATEGORIES } from '../lib/settingsCategories' +} from '@renderer/features/settings/lib/settingsRegistry' +import { SETTING_CATEGORIES } from '@renderer/features/settings/lib/settingsCategories' type Props = { definitions: SettingDefinition[] diff --git a/src/renderer/src/features/settings/ui/SettingsPage.tsx b/src/renderer/src/features/settings/ui/SettingsPage.tsx index 13ba3b0c9..4d071da73 100644 --- a/src/renderer/src/features/settings/ui/SettingsPage.tsx +++ b/src/renderer/src/features/settings/ui/SettingsPage.tsx @@ -1,12 +1,12 @@ import { useMemo, useState } from 'react' -import type { Settings } from '../../../state/settings/types' -import type { Workspace } from '../../../workspace/workspaceStore' -import { SETTING_CATEGORIES, type SettingCategoryId } from '../lib/settingsCategories' -import { getSettingsRegistry, matchesSettingQuery } from '../lib/settingsRegistry' -import { SettingsList } from './SettingsList' -import { SettingsSearch } from './SettingsSearch' -import { SettingsSidebar } from './SettingsSidebar' +import type { Settings } from '@renderer/state/settings/types' +import type { Workspace } from '@renderer/workspace/workspaceStore' +import { SETTING_CATEGORIES, type SettingCategoryId } from '@renderer/features/settings/lib/settingsCategories' +import { getSettingsRegistry, matchesSettingQuery } from '@renderer/features/settings/lib/settingsRegistry' +import { SettingsList } from '@renderer/features/settings/ui/SettingsList' +import { SettingsSearch } from '@renderer/features/settings/ui/SettingsSearch' +import { SettingsSidebar } from '@renderer/features/settings/ui/SettingsSidebar' type Props = { onClose: () => void diff --git a/src/renderer/src/features/settings/ui/SettingsSidebar.tsx b/src/renderer/src/features/settings/ui/SettingsSidebar.tsx index dbfa829bb..274e8179c 100644 --- a/src/renderer/src/features/settings/ui/SettingsSidebar.tsx +++ b/src/renderer/src/features/settings/ui/SettingsSidebar.tsx @@ -1,4 +1,4 @@ -import { SETTING_CATEGORIES, type SettingCategoryId } from '../lib/settingsCategories' +import { SETTING_CATEGORIES, type SettingCategoryId } from '@renderer/features/settings/lib/settingsCategories' type Props = { selectedCategory: SettingCategoryId | 'all' diff --git a/src/renderer/src/features/spotlight/commands/spotlightCommands.ts b/src/renderer/src/features/spotlight/commands/spotlightCommands.ts index 3ad4bac49..d1d2f43dc 100644 --- a/src/renderer/src/features/spotlight/commands/spotlightCommands.ts +++ b/src/renderer/src/features/spotlight/commands/spotlightCommands.ts @@ -1,4 +1,4 @@ -import type { CommandDef } from '../../command-palette/types' +import type { CommandDef } from '@renderer/features/command-palette/types' export const spotlightCommands: CommandDef[] = [ { diff --git a/src/renderer/src/features/spotlight/ui/SpotlightView.tsx b/src/renderer/src/features/spotlight/ui/SpotlightView.tsx index 2276f30d8..d8d9bb460 100644 --- a/src/renderer/src/features/spotlight/ui/SpotlightView.tsx +++ b/src/renderer/src/features/spotlight/ui/SpotlightView.tsx @@ -1,6 +1,6 @@ -import { renderWorkspaceLeaf } from '../../../workspace/tile-tree/TileTree' -import { collectLeaves } from '../../../workspace/tile-tree/treeOps' -import type { Workspace } from '../../../workspace/workspaceStore' +import { renderWorkspaceLeaf } from '@renderer/workspace/tile-tree/TileTree' +import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps' +import type { Workspace } from '@renderer/workspace/workspaceStore' type Props = { workspace: Workspace diff --git a/src/renderer/src/features/tile-tabs/commands/tileTabsCommands.ts b/src/renderer/src/features/tile-tabs/commands/tileTabsCommands.ts index 9a8f8664b..33ed75083 100644 --- a/src/renderer/src/features/tile-tabs/commands/tileTabsCommands.ts +++ b/src/renderer/src/features/tile-tabs/commands/tileTabsCommands.ts @@ -1,4 +1,4 @@ -import type { CommandDef } from '../../command-palette/types' +import type { CommandDef } from '@renderer/features/command-palette/types' export const tileTabsCommands: CommandDef[] = [ { diff --git a/src/renderer/src/features/tile-tabs/ui/TileTabsModal.tsx b/src/renderer/src/features/tile-tabs/ui/TileTabsModal.tsx index fc4aca90a..b2d0a4552 100644 --- a/src/renderer/src/features/tile-tabs/ui/TileTabsModal.tsx +++ b/src/renderer/src/features/tile-tabs/ui/TileTabsModal.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import type { TabId } from '../../../workspace/types' +import type { TabId } from '@renderer/workspace/types' type TileTabOption = { id: TabId diff --git a/src/renderer/src/features/tile-tabs/ui/TileTabsView.tsx b/src/renderer/src/features/tile-tabs/ui/TileTabsView.tsx index 578b2036b..cd18ec5b7 100644 --- a/src/renderer/src/features/tile-tabs/ui/TileTabsView.tsx +++ b/src/renderer/src/features/tile-tabs/ui/TileTabsView.tsx @@ -1,7 +1,7 @@ import { Fragment, useEffect, useRef } from 'react' -import { TileTree } from '../../../workspace/tile-tree/TileTree' -import type { Workspace } from '../../../workspace/workspaceStore' +import { TileTree } from '@renderer/workspace/tile-tree/TileTree' +import type { Workspace } from '@renderer/workspace/workspaceStore' type Props = { workspace: Workspace diff --git a/src/renderer/src/features/workspace/commands/layoutCommands.ts b/src/renderer/src/features/workspace/commands/layoutCommands.ts index 908d616c0..75273f198 100644 --- a/src/renderer/src/features/workspace/commands/layoutCommands.ts +++ b/src/renderer/src/features/workspace/commands/layoutCommands.ts @@ -1,4 +1,4 @@ -import type { CommandDef } from '../../command-palette/types' +import type { CommandDef } from '@renderer/features/command-palette/types' export const layoutCommands: CommandDef[] = [ { diff --git a/src/renderer/src/features/workspace/commands/paneCommands.ts b/src/renderer/src/features/workspace/commands/paneCommands.ts index 42a2d0df3..b74c0d77e 100644 --- a/src/renderer/src/features/workspace/commands/paneCommands.ts +++ b/src/renderer/src/features/workspace/commands/paneCommands.ts @@ -1,5 +1,5 @@ -import { extractLastAssistantText } from '../../../lib/copyAssistant' -import type { CommandDef } from '../../command-palette/types' +import { extractLastAssistantText } from '@renderer/lib/copyAssistant' +import type { CommandDef } from '@renderer/features/command-palette/types' export const paneCommands: CommandDef[] = [ { diff --git a/src/renderer/src/features/workspace/commands/sessionCommands.ts b/src/renderer/src/features/workspace/commands/sessionCommands.ts index 5a078d19e..4d7ea7a9f 100644 --- a/src/renderer/src/features/workspace/commands/sessionCommands.ts +++ b/src/renderer/src/features/workspace/commands/sessionCommands.ts @@ -1,4 +1,4 @@ -import type { CommandDef } from '../../command-palette/types' +import type { CommandDef } from '@renderer/features/command-palette/types' export const sessionCommands: CommandDef[] = [ { diff --git a/src/renderer/src/features/workspace/commands/tabCommands.ts b/src/renderer/src/features/workspace/commands/tabCommands.ts index 62079dfd5..1ab40b6c3 100644 --- a/src/renderer/src/features/workspace/commands/tabCommands.ts +++ b/src/renderer/src/features/workspace/commands/tabCommands.ts @@ -1,4 +1,4 @@ -import type { CommandDef } from '../../command-palette/types' +import type { CommandDef } from '@renderer/features/command-palette/types' export const tabCommands: CommandDef[] = [ { diff --git a/src/renderer/src/features/workspace/lib/latestUserPrompts.ts b/src/renderer/src/features/workspace/lib/latestUserPrompts.ts index 319d48dbf..200335cdb 100644 --- a/src/renderer/src/features/workspace/lib/latestUserPrompts.ts +++ b/src/renderer/src/features/workspace/lib/latestUserPrompts.ts @@ -3,7 +3,7 @@ import { isConversationEntry, type Entry, } from '@shared/types/transcript' -import type { SessionKind } from '../../../workspace/types' +import type { SessionKind } from '@renderer/workspace/types' export type LatestUserPrompt = { text: string diff --git a/src/renderer/src/features/workspace/lib/newAgentPlacement.ts b/src/renderer/src/features/workspace/lib/newAgentPlacement.ts index 7cc20c0d6..c77275aba 100644 --- a/src/renderer/src/features/workspace/lib/newAgentPlacement.ts +++ b/src/renderer/src/features/workspace/lib/newAgentPlacement.ts @@ -1,5 +1,5 @@ -import { buildLeafGeometries, sliceRect, type Rect } from '../../../workspace/tile-tree/geometry' -import type { SessionId, SplitDirection, TileNode } from '../../../workspace/types' +import { buildLeafGeometries, sliceRect, type Rect } from '@renderer/workspace/tile-tree/geometry' +import type { SessionId, SplitDirection, TileNode } from '@renderer/workspace/types' export type PlacementTarget = | { diff --git a/src/renderer/src/features/workspace/ui/AgentActivityModal.tsx b/src/renderer/src/features/workspace/ui/AgentActivityModal.tsx index 88a5f340a..c3799e335 100644 --- a/src/renderer/src/features/workspace/ui/AgentActivityModal.tsx +++ b/src/renderer/src/features/workspace/ui/AgentActivityModal.tsx @@ -1,9 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useAppStore } from '../../../state/hooks' -import { collectLeaves } from '../../../workspace/tile-tree/treeOps' -import type { SessionId, Tab } from '../../../workspace/types' -import type { Workspace } from '../../../workspace/workspaceStore' +import { useAppStore } from '@renderer/state/hooks' +import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps' +import type { SessionId, Tab } from '@renderer/workspace/types' +import type { Workspace } from '@renderer/workspace/workspaceStore' import type { Entry } from '@shared/types/transcript' // AgentActivityModal — overview of every visible pane grouped by tab diff --git a/src/renderer/src/features/workspace/ui/NewAgentPlacementOverlay.tsx b/src/renderer/src/features/workspace/ui/NewAgentPlacementOverlay.tsx index 6f20f10a2..da769d015 100644 --- a/src/renderer/src/features/workspace/ui/NewAgentPlacementOverlay.tsx +++ b/src/renderer/src/features/workspace/ui/NewAgentPlacementOverlay.tsx @@ -5,9 +5,9 @@ import { defaultPlacementTargetId, findNearestPlacementTarget, type PlacementTarget, -} from '../lib/newAgentPlacement' -import type { SessionKind } from '../../../workspace/types' -import type { Workspace } from '../../../workspace/workspaceStore' +} from '@renderer/features/workspace/lib/newAgentPlacement' +import type { SessionKind } from '@renderer/workspace/types' +import type { Workspace } from '@renderer/workspace/workspaceStore' type Props = { open: boolean diff --git a/src/renderer/src/features/workspace/ui/PromptSearchModal.tsx b/src/renderer/src/features/workspace/ui/PromptSearchModal.tsx index 0c2f3630d..79e521763 100644 --- a/src/renderer/src/features/workspace/ui/PromptSearchModal.tsx +++ b/src/renderer/src/features/workspace/ui/PromptSearchModal.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { SessionIndexEntry, SessionIndexPrompt } from '../../../../../preload' -import type { Workspace } from '../../../workspace/workspaceStore' +import type { SessionIndexEntry, SessionIndexPrompt } from '@preload/index' +import type { Workspace } from '@renderer/workspace/workspaceStore' // PromptSearchModal — cross-session prompt search. // diff --git a/src/renderer/src/features/workspace/ui/RewindToPromptModal.tsx b/src/renderer/src/features/workspace/ui/RewindToPromptModal.tsx index 2c59a9afa..a31c89b60 100644 --- a/src/renderer/src/features/workspace/ui/RewindToPromptModal.tsx +++ b/src/renderer/src/features/workspace/ui/RewindToPromptModal.tsx @@ -1,8 +1,8 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { extractAnchoredUserPrompts } from '../lib/latestUserPrompts' -import type { Workspace } from '../../../workspace/workspaceStore' -import type { SessionId } from '../../../workspace/types' +import { extractAnchoredUserPrompts } from '@renderer/features/workspace/lib/latestUserPrompts' +import type { Workspace } from '@renderer/workspace/workspaceStore' +import type { SessionId } from '@renderer/workspace/types' // RewindToPromptModal — picker for the rewind-to-prompt flow. // diff --git a/src/renderer/src/features/workspace/ui/ViewPromptsModal.tsx b/src/renderer/src/features/workspace/ui/ViewPromptsModal.tsx index d3b08e9ad..7aaa21d6a 100644 --- a/src/renderer/src/features/workspace/ui/ViewPromptsModal.tsx +++ b/src/renderer/src/features/workspace/ui/ViewPromptsModal.tsx @@ -1,8 +1,8 @@ import { useEffect, useMemo, useRef } from 'react' -import { extractLatestUserPrompts } from '../lib/latestUserPrompts' -import type { Workspace } from '../../../workspace/workspaceStore' -import type { SessionId } from '../../../workspace/types' +import { extractLatestUserPrompts } from '@renderer/features/workspace/lib/latestUserPrompts' +import type { Workspace } from '@renderer/workspace/workspaceStore' +import type { SessionId } from '@renderer/workspace/types' type Props = { open: boolean diff --git a/src/renderer/src/lib/code/CodeBlock.tsx b/src/renderer/src/lib/code/CodeBlock.tsx index d65e55bd2..43e0405de 100644 --- a/src/renderer/src/lib/code/CodeBlock.tsx +++ b/src/renderer/src/lib/code/CodeBlock.tsx @@ -85,7 +85,7 @@ export const CodeBlock = memo(function CodeBlock({ const cleanups: Array<() => void> = [] void (async () => { - const { ensureSemanticProvider, getMonaco } = await import('./monacoRuntime') + const { ensureSemanticProvider, getMonaco } = await import('@renderer/lib/code/monacoRuntime') const monaco = await getMonaco() if (disposed || !containerRef.current) return diff --git a/src/renderer/src/lib/copyAssistant.ts b/src/renderer/src/lib/copyAssistant.ts index c6dcf66b0..c9fb0da47 100644 --- a/src/renderer/src/lib/copyAssistant.ts +++ b/src/renderer/src/lib/copyAssistant.ts @@ -59,4 +59,4 @@ function extractClaude(entries: readonly Entry[]): string | null { export { extractAssistantByUuid, assistantUuidsWithText, -} from '../features/copy-assistant/lib/extractAssistantByUuid' +} from '@renderer/features/copy-assistant/lib/extractAssistantByUuid' diff --git a/src/renderer/src/lib/undoClose.ts b/src/renderer/src/lib/undoClose.ts index f68f11c55..e59bd4e8f 100644 --- a/src/renderer/src/lib/undoClose.ts +++ b/src/renderer/src/lib/undoClose.ts @@ -4,8 +4,8 @@ import type { SplitDirection, Tab, TileNode, -} from '../workspace/types' -import { collectLeaves } from '../workspace/tile-tree/treeOps' +} from '@renderer/workspace/types' +import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps' // Undo-close stack — captures enough state to restore a closed pane or // tab exactly where it was in the tile tree. diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 251035d6d..330e86575 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -1,8 +1,8 @@ import React from 'react' import { createRoot } from 'react-dom/client' -import App from './App' -import { GlobalToastProvider } from './ui/GlobalToast' -import './styles.css' +import App from '@renderer/App' +import { GlobalToastProvider } from '@renderer/ui/GlobalToast' +import '@renderer/styles.css' import 'highlight.js/styles/github-dark.css' import 'monaco-editor/min/vs/editor/editor.main.css' // xterm.js base CSS — required for the TerminalLeaf component to render diff --git a/src/renderer/src/state/hooks.ts b/src/renderer/src/state/hooks.ts index bc3826cda..d347158c7 100644 --- a/src/renderer/src/state/hooks.ts +++ b/src/renderer/src/state/hooks.ts @@ -1 +1 @@ -export { useAppStore } from './store' +export { useAppStore } from '@renderer/state/store' diff --git a/src/renderer/src/state/settings/persistence.ts b/src/renderer/src/state/settings/persistence.ts index 2c96aa659..9c195c3b3 100644 --- a/src/renderer/src/state/settings/persistence.ts +++ b/src/renderer/src/state/settings/persistence.ts @@ -1,4 +1,4 @@ -import { ACCENTS, DEFAULT_SETTINGS, THEME_MODES, type AccentId, type Settings } from './types' +import { ACCENTS, DEFAULT_SETTINGS, THEME_MODES, type AccentId, type Settings } from '@renderer/state/settings/types' const LEGACY_STORAGE_KEY = 'cc-shell:settings' diff --git a/src/renderer/src/state/settings/slice.ts b/src/renderer/src/state/settings/slice.ts index 629ab6a4e..704d41e2f 100644 --- a/src/renderer/src/state/settings/slice.ts +++ b/src/renderer/src/state/settings/slice.ts @@ -1,9 +1,9 @@ import type { StateCreator } from 'zustand' -import { loadInitialSettings } from './persistence' -import { applyTheme } from './theme' -import { DEFAULT_SETTINGS } from './types' -import type { AppStore, SettingsSlice } from '../types' +import { loadInitialSettings } from '@renderer/state/settings/persistence' +import { applyTheme } from '@renderer/state/settings/theme' +import { DEFAULT_SETTINGS } from '@renderer/state/settings/types' +import type { AppStore, SettingsSlice } from '@renderer/state/types' const initialSettings = loadInitialSettings() applyTheme(initialSettings) diff --git a/src/renderer/src/state/settings/theme.ts b/src/renderer/src/state/settings/theme.ts index 968ab506f..3143ed403 100644 --- a/src/renderer/src/state/settings/theme.ts +++ b/src/renderer/src/state/settings/theme.ts @@ -1,4 +1,4 @@ -import { ACCENTS, isDarkThemeMode, type Settings } from './types' +import { ACCENTS, isDarkThemeMode, type Settings } from '@renderer/state/settings/types' export function applyTheme(settings: Settings): void { const root = document.documentElement diff --git a/src/renderer/src/state/store.ts b/src/renderer/src/state/store.ts index 357231382..b0d7f8d3e 100644 --- a/src/renderer/src/state/store.ts +++ b/src/renderer/src/state/store.ts @@ -1,11 +1,11 @@ import { create } from 'zustand' import { createJSONStorage, devtools, persist, subscribeWithSelector } from 'zustand/middleware' -import { createSettingsSlice } from './settings/slice' -import { createUiShellSlice } from './uiShell/slice' -import { createWorkspaceSlice } from './workspace/slice' -import type { AppStore } from './types' -import { DEFAULT_SETTINGS, THEME_MODES, type Settings } from './settings/types' +import { createSettingsSlice } from '@renderer/state/settings/slice' +import { createUiShellSlice } from '@renderer/state/uiShell/slice' +import { createWorkspaceSlice } from '@renderer/state/workspace/slice' +import type { AppStore } from '@renderer/state/types' +import { DEFAULT_SETTINGS, THEME_MODES, type Settings } from '@renderer/state/settings/types' export const useAppStore = create()( devtools( diff --git a/src/renderer/src/state/types.ts b/src/renderer/src/state/types.ts index d29ef5400..da9b24c66 100644 --- a/src/renderer/src/state/types.ts +++ b/src/renderer/src/state/types.ts @@ -1,13 +1,13 @@ -import type { Settings } from './settings/types' -import type { UiShellState } from './uiShell/types' -import type { SessionId, TabId } from '../workspace/types' -import type { WorkspaceState } from '../workspace/types' +import type { Settings } from '@renderer/state/settings/types' +import type { UiShellState } from '@renderer/state/uiShell/types' +import type { SessionId, TabId } from '@renderer/workspace/types' +import type { WorkspaceState } from '@renderer/workspace/types' import type { ReaderModeState, SessionRuntime, SpotlightState, TileTabsState, -} from '../workspace/workspaceState' +} from '@renderer/workspace/workspaceState' export type SettingsSlice = { settings: Settings diff --git a/src/renderer/src/state/uiShell/slice.ts b/src/renderer/src/state/uiShell/slice.ts index e8db129e5..6e755341e 100644 --- a/src/renderer/src/state/uiShell/slice.ts +++ b/src/renderer/src/state/uiShell/slice.ts @@ -1,6 +1,6 @@ import type { StateCreator } from 'zustand' -import type { AppStore, UiShellSlice } from '../types' +import type { AppStore, UiShellSlice } from '@renderer/state/types' export const createUiShellSlice: StateCreator< AppStore, diff --git a/src/renderer/src/state/uiShell/types.ts b/src/renderer/src/state/uiShell/types.ts index 8640b3894..65e992eb0 100644 --- a/src/renderer/src/state/uiShell/types.ts +++ b/src/renderer/src/state/uiShell/types.ts @@ -1,4 +1,4 @@ -import type { TabId, SessionId } from '../../workspace/types' +import type { TabId, SessionId } from '@renderer/workspace/types' export type UiShellState = { commandPaletteOpen: boolean diff --git a/src/renderer/src/state/workspace/slice.ts b/src/renderer/src/state/workspace/slice.ts index 47c4b6d7c..872cf2386 100644 --- a/src/renderer/src/state/workspace/slice.ts +++ b/src/renderer/src/state/workspace/slice.ts @@ -1,13 +1,13 @@ import type { StateCreator } from 'zustand' -import type { AppStore, WorkspaceSlice } from '../types' -import type { WorkspaceState } from '../../workspace/types' +import type { AppStore, WorkspaceSlice } from '@renderer/state/types' +import type { WorkspaceState } from '@renderer/workspace/types' import type { ReaderModeState, SessionRuntime, SpotlightState, TileTabsState, -} from '../../workspace/workspaceState' +} from '@renderer/workspace/workspaceState' function applyUpdater(prev: T, next: T | ((prev: T) => T)): T { return typeof next === 'function' diff --git a/src/renderer/src/workspace/codex/rollout.ts b/src/renderer/src/workspace/codex/rollout.ts index 83b903ea6..eeef67795 100644 --- a/src/renderer/src/workspace/codex/rollout.ts +++ b/src/renderer/src/workspace/codex/rollout.ts @@ -8,7 +8,7 @@ import { isCodexExecWrapperOutput, parseCodexJson, stripCodexExecWrapper, -} from './entries' +} from '@renderer/workspace/codex/entries' // Codex rollout → feed entry mapping. // diff --git a/src/renderer/src/workspace/ghosts.ts b/src/renderer/src/workspace/ghosts.ts index 24dd26255..176cb3fec 100644 --- a/src/renderer/src/workspace/ghosts.ts +++ b/src/renderer/src/workspace/ghosts.ts @@ -76,7 +76,7 @@ import { isConversationEntry } from '@shared/types/transcript' import type { SemanticLiveBlock, SemanticLiveTurn, -} from './workspaceState' +} from '@renderer/workspace/workspaceState' // ----------------------------------------------------------------------------- // Semantic block → Claude content blocks diff --git a/src/renderer/src/workspace/hook/actions/draft.ts b/src/renderer/src/workspace/hook/actions/draft.ts index 28385725e..2228946c5 100644 --- a/src/renderer/src/workspace/hook/actions/draft.ts +++ b/src/renderer/src/workspace/hook/actions/draft.ts @@ -1,8 +1,8 @@ import { useCallback, type Dispatch, type SetStateAction } from 'react' -import { emptyRuntime, type SessionRuntime } from '../../workspaceState' -import type { SessionId } from '../../types' -import type { WorkspaceSetRuntimes } from '../context' +import { emptyRuntime, type SessionRuntime } from '@renderer/workspace/workspaceState' +import type { SessionId } from '@renderer/workspace/types' +import type { WorkspaceSetRuntimes } from '@renderer/workspace/hook/context' // Draft input + image actions. // diff --git a/src/renderer/src/workspace/hook/actions/history.ts b/src/renderer/src/workspace/hook/actions/history.ts index f30b88673..a5b335e15 100644 --- a/src/renderer/src/workspace/hook/actions/history.ts +++ b/src/renderer/src/workspace/hook/actions/history.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react' -import { emptyRuntime, type SessionRuntime } from '../../workspaceState' -import type { SessionId } from '../../types' +import { emptyRuntime, type SessionRuntime } from '@renderer/workspace/workspaceState' +import type { SessionId } from '@renderer/workspace/types' import type { Entry } from '@shared/types/transcript' import { isCompactBoundaryEntry, @@ -13,14 +13,14 @@ import { codexTurnIdFromRollout, mapCodexRolloutToFeedEntries, stampCodexTurnId, -} from '../../codex/rollout' +} from '@renderer/workspace/codex/rollout' import { claudeHistoryMarker, extractEmbeddedClaudeProgressEntry, -} from '../../claude/history' +} from '@renderer/workspace/claude/history' -import type { WorkspaceSetRuntimes } from '../context' -import type { WorkspaceRefs } from '../refs' +import type { WorkspaceSetRuntimes } from '@renderer/workspace/hook/context' +import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' // Older history loader — called by Feed's scroll handler when the // user scrolls near the top. diff --git a/src/renderer/src/workspace/hook/actions/pane.ts b/src/renderer/src/workspace/hook/actions/pane.ts index 82acd0b2a..f0495a8a8 100644 --- a/src/renderer/src/workspace/hook/actions/pane.ts +++ b/src/renderer/src/workspace/hook/actions/pane.ts @@ -8,28 +8,28 @@ import type { SplitDirection, Tab, TileNode, -} from '../../types' -import { RATIO_DEFAULT } from '../../types' +} from '@renderer/workspace/types' +import { RATIO_DEFAULT } from '@renderer/workspace/types' import { closeLeaf, collectLeaves, insertBesideLeaf, splitLeaf, wrapRootWithLeaf, -} from '../../tile-tree/treeOps' -import { findBestRemainingFocus, findDirectionalNeighbor } from '../../tile-tree/geometry' -import { findParentSplitInfo } from '../../../lib/undoClose' -import { titleFromCwd } from '../../layout/helpers' -import type { PlacementTarget } from '../../../features/workspace/lib/newAgentPlacement' +} from '@renderer/workspace/tile-tree/treeOps' +import { findBestRemainingFocus, findDirectionalNeighbor } from '@renderer/workspace/tile-tree/geometry' +import { findParentSplitInfo } from '@renderer/lib/undoClose' +import { titleFromCwd } from '@renderer/workspace/layout/helpers' +import type { PlacementTarget } from '@renderer/features/workspace/lib/newAgentPlacement' import type { WorkspaceSetRuntimes, WorkspaceSetSpotlight, WorkspaceSetState, WorkspaceSetTileTabs, -} from '../context' -import type { WorkspaceRefs } from '../refs' -import type { SessionActions } from './session' +} from '@renderer/workspace/hook/context' +import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' +import type { SessionActions } from '@renderer/workspace/hook/actions/session' // ----------------------------------------------------------------------------- // Pane / focus / navigation actions. diff --git a/src/renderer/src/workspace/hook/actions/picker.ts b/src/renderer/src/workspace/hook/actions/picker.ts index f03aeceda..f53091286 100644 --- a/src/renderer/src/workspace/hook/actions/picker.ts +++ b/src/renderer/src/workspace/hook/actions/picker.ts @@ -1,14 +1,14 @@ import { useCallback } from 'react' -import { emptyRuntime } from '../../workspaceState' -import type { SessionId } from '../../types' +import { emptyRuntime } from '@renderer/workspace/workspaceState' +import type { SessionId } from '@renderer/workspace/types' import { assistantUuidsWithText, extractAssistantByUuid, -} from '../../../lib/copyAssistant' +} from '@renderer/lib/copyAssistant' -import type { WorkspaceSetRuntimes } from '../context' -import type { WorkspaceRefs } from '../refs' +import type { WorkspaceSetRuntimes } from '@renderer/workspace/hook/context' +import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' // ---- Copy Assistant picker actions ---- // diff --git a/src/renderer/src/workspace/hook/actions/provider.ts b/src/renderer/src/workspace/hook/actions/provider.ts index 9812fcf78..203a1624f 100644 --- a/src/renderer/src/workspace/hook/actions/provider.ts +++ b/src/renderer/src/workspace/hook/actions/provider.ts @@ -1,11 +1,11 @@ import { useCallback } from 'react' -import type { SessionId } from '../../types' -import type { ClaudeDraftImage } from '../../workspaceState' +import type { SessionId } from '@renderer/workspace/types' +import type { ClaudeDraftImage } from '@renderer/workspace/workspaceState' -import type { WorkspaceSetRuntimes } from '../context' -import type { WorkspaceRefs } from '../refs' -import type { SessionActions } from './session' +import type { WorkspaceSetRuntimes } from '@renderer/workspace/hook/context' +import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' +import type { SessionActions } from '@renderer/workspace/hook/actions/session' // Provider-level actions on the focused pane. // diff --git a/src/renderer/src/workspace/hook/actions/reader.ts b/src/renderer/src/workspace/hook/actions/reader.ts index e76d97a73..e53906ebf 100644 --- a/src/renderer/src/workspace/hook/actions/reader.ts +++ b/src/renderer/src/workspace/hook/actions/reader.ts @@ -1,13 +1,13 @@ import { useCallback } from 'react' -import type { SessionId } from '../../types' +import type { SessionId } from '@renderer/workspace/types' import type { WorkspaceSetReaderMode, WorkspaceSetSpotlight, WorkspaceSetState, -} from '../context' -import type { WorkspaceRefs } from '../refs' +} from '@renderer/workspace/hook/context' +import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' // ReaderMode toggle. Mirrors toggleSpotlight: enters with the active // tab's currently-focused session, exits if already on for the active diff --git a/src/renderer/src/workspace/hook/actions/resize.ts b/src/renderer/src/workspace/hook/actions/resize.ts index cfc87c7c2..9bb87ea97 100644 --- a/src/renderer/src/workspace/hook/actions/resize.ts +++ b/src/renderer/src/workspace/hook/actions/resize.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react' -import type { SessionId, TabId } from '../../types' +import type { SessionId, TabId } from '@renderer/workspace/types' import { adjustNearestSplitRatio, collectLeaves, @@ -8,13 +8,13 @@ import { normalizeTree, resizeInDirection, rotateTree, -} from '../../tile-tree/treeOps' -import { setRatioBetween } from '../../layout/helpers' +} from '@renderer/workspace/tile-tree/treeOps' +import { setRatioBetween } from '@renderer/workspace/layout/helpers' import type { WorkspaceSetState, WorkspaceSetTileTabs, -} from '../context' +} from '@renderer/workspace/hook/context' // Resize + layout normalization actions. // diff --git a/src/renderer/src/workspace/hook/actions/session.ts b/src/renderer/src/workspace/hook/actions/session.ts index 9a104d3c8..15a887eeb 100644 --- a/src/renderer/src/workspace/hook/actions/session.ts +++ b/src/renderer/src/workspace/hook/actions/session.ts @@ -1,21 +1,21 @@ import { useCallback } from 'react' -import { emptyRuntime, type SessionRuntime } from '../../workspaceState' -import type { SessionId, SessionKind, SessionMeta, TileNode } from '../../types' -import { closeLeaf, collectLeaves } from '../../tile-tree/treeOps' -import type { Tab } from '../../types' -import { sessionSpawnErrorMessage } from '../../spawn/errorMessage' +import { emptyRuntime, type SessionRuntime } from '@renderer/workspace/workspaceState' +import type { SessionId, SessionKind, SessionMeta, TileNode } from '@renderer/workspace/types' +import { closeLeaf, collectLeaves } from '@renderer/workspace/tile-tree/treeOps' +import type { Tab } from '@renderer/workspace/types' +import { sessionSpawnErrorMessage } from '@renderer/workspace/spawn/errorMessage' import { ghostsToPersist, reconcileUpstream, -} from '../../ghosts' +} from '@renderer/workspace/ghosts' import { reduceGhostLogSansSuperseded as reduceGhostLog } from 'agent-transcript-parser/ghost' import type { WorkspaceSetRuntimes, WorkspaceSetState, -} from '../context' -import type { WorkspaceRefs } from '../refs' +} from '@renderer/workspace/hook/context' +import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' // ----------------------------------------------------------------------------- // Session lifecycle actions. diff --git a/src/renderer/src/workspace/hook/actions/spotlight.ts b/src/renderer/src/workspace/hook/actions/spotlight.ts index 2af51b369..51435f67c 100644 --- a/src/renderer/src/workspace/hook/actions/spotlight.ts +++ b/src/renderer/src/workspace/hook/actions/spotlight.ts @@ -1,12 +1,12 @@ import { useCallback } from 'react' -import type { SessionId } from '../../types' +import type { SessionId } from '@renderer/workspace/types' import type { WorkspaceSetSpotlight, WorkspaceSetState, -} from '../context' -import type { WorkspaceRefs } from '../refs' +} from '@renderer/workspace/hook/context' +import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' // Spotlight mode — focused-pane zoom within the active tab. toggleSpotlight // enters with the active tab's currently-focused session; exits if already diff --git a/src/renderer/src/workspace/hook/actions/statusMode.ts b/src/renderer/src/workspace/hook/actions/statusMode.ts index e1442dd4e..716ac0262 100644 --- a/src/renderer/src/workspace/hook/actions/statusMode.ts +++ b/src/renderer/src/workspace/hook/actions/statusMode.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react' -import type { WorkspaceSetStatusMode } from '../context' +import type { WorkspaceSetStatusMode } from '@renderer/workspace/hook/context' // Status mode: color-coded pane headers. Toggled from the command // palette. The slice lives in zustand — this action just flips it. diff --git a/src/renderer/src/workspace/hook/actions/streaming.ts b/src/renderer/src/workspace/hook/actions/streaming.ts index 1b308a397..e3053869c 100644 --- a/src/renderer/src/workspace/hook/actions/streaming.ts +++ b/src/renderer/src/workspace/hook/actions/streaming.ts @@ -1,18 +1,18 @@ import { useCallback } from 'react' -import { emptyRuntime } from '../../workspaceState' -import type { SessionId } from '../../types' +import { emptyRuntime } from '@renderer/workspace/workspaceState' +import type { SessionId } from '@renderer/workspace/types' import type { Entry } from '@shared/types/transcript' import { appendFeedDebugLog, -} from '../../runtime/feedDebug' -import { withDerivedSessionStatus } from '../../semantic/helpers' +} from '@renderer/workspace/runtime/feedDebug' +import { withDerivedSessionStatus } from '@renderer/workspace/semantic/helpers' import { entryTextContent, -} from '../../entries/utils' -import { isOptimisticCodexUserEntry } from '../../codex/entries' +} from '@renderer/workspace/entries/utils' +import { isOptimisticCodexUserEntry } from '@renderer/workspace/codex/entries' -import type { WorkspaceSetRuntimes } from '../context' +import type { WorkspaceSetRuntimes } from '@renderer/workspace/hook/context' // Streaming baseline + optimistic-codex-user entry. // diff --git a/src/renderer/src/workspace/hook/actions/tab.ts b/src/renderer/src/workspace/hook/actions/tab.ts index c23f1fe63..3c8592191 100644 --- a/src/renderer/src/workspace/hook/actions/tab.ts +++ b/src/renderer/src/workspace/hook/actions/tab.ts @@ -1,8 +1,8 @@ import { useCallback } from 'react' -import type { SessionId, SessionKind, SessionMeta, Tab, TabId } from '../../types' -import { collectLeaves } from '../../tile-tree/treeOps' -import { sanitizeTileTabsState, titleFromCwd } from '../../layout/helpers' +import type { SessionId, SessionKind, SessionMeta, Tab, TabId } from '@renderer/workspace/types' +import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps' +import { sanitizeTileTabsState, titleFromCwd } from '@renderer/workspace/layout/helpers' import type { WorkspaceSetReaderMode, @@ -10,9 +10,9 @@ import type { WorkspaceSetSpotlight, WorkspaceSetState, WorkspaceSetTileTabs, -} from '../context' -import type { WorkspaceRefs } from '../refs' -import type { SessionActions } from './session' +} from '@renderer/workspace/hook/context' +import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' +import type { SessionActions } from '@renderer/workspace/hook/actions/session' // Tab actions — open/close + tab-navigation keybinds. diff --git a/src/renderer/src/workspace/hook/actions/tileTabs.ts b/src/renderer/src/workspace/hook/actions/tileTabs.ts index 0b3c16098..ebc8e9a5d 100644 --- a/src/renderer/src/workspace/hook/actions/tileTabs.ts +++ b/src/renderer/src/workspace/hook/actions/tileTabs.ts @@ -1,14 +1,14 @@ import { useCallback } from 'react' -import type { SessionId, SplitDirection, TabId } from '../../types' -import { equalRatios, normalizeRatios } from '../../layout/helpers' +import type { SessionId, SplitDirection, TabId } from '@renderer/workspace/types' +import { equalRatios, normalizeRatios } from '@renderer/workspace/layout/helpers' import type { WorkspaceSetSpotlight, WorkspaceSetState, WorkspaceSetTileTabs, -} from '../context' -import type { WorkspaceRefs } from '../refs' +} from '@renderer/workspace/hook/context' +import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' // Tile-tabs actions. Opening a tile-tab set tiles multiple tabs side // by side inside a single tab view (meta-tabs). Focus + resize here diff --git a/src/renderer/src/workspace/hook/actions/undoClose.ts b/src/renderer/src/workspace/hook/actions/undoClose.ts index 30d8aaf1a..2db80003b 100644 --- a/src/renderer/src/workspace/hook/actions/undoClose.ts +++ b/src/renderer/src/workspace/hook/actions/undoClose.ts @@ -7,13 +7,13 @@ import type { Tab, TabId, TileNode, -} from '../../types' -import { collectLeaves } from '../../tile-tree/treeOps' -import { reinsertPane } from '../../../lib/undoClose' +} from '@renderer/workspace/types' +import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps' +import { reinsertPane } from '@renderer/lib/undoClose' -import type { WorkspaceSetState } from '../context' -import type { WorkspaceRefs } from '../refs' -import type { SessionActions } from './session' +import type { WorkspaceSetState } from '@renderer/workspace/hook/context' +import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' +import type { SessionActions } from '@renderer/workspace/hook/actions/session' // Undo-close action. Pops the most recent entry from the undo stack // and restores it. diff --git a/src/renderer/src/workspace/hook/context.ts b/src/renderer/src/workspace/hook/context.ts index 25ad9ea29..85f710d82 100644 --- a/src/renderer/src/workspace/hook/context.ts +++ b/src/renderer/src/workspace/hook/context.ts @@ -1,14 +1,14 @@ import type { Dispatch, MutableRefObject, SetStateAction } from 'react' -import type { UndoCloseStack } from '../../lib/undoClose' +import type { UndoCloseStack } from '@renderer/lib/undoClose' import type { ReaderModeState, SessionRuntime, SpotlightState, TileTabsState, -} from '../workspaceState' -import type { SessionId, WorkspaceState } from '../types' -import type { FeedDebugInput } from '../runtime/feedDebug' +} from '@renderer/workspace/workspaceState' +import type { SessionId, WorkspaceState } from '@renderer/workspace/types' +import type { FeedDebugInput } from '@renderer/workspace/runtime/feedDebug' // ----------------------------------------------------------------------------- // WorkspaceHookContext diff --git a/src/renderer/src/workspace/hook/helpers.ts b/src/renderer/src/workspace/hook/helpers.ts index c6ff7309e..a569e5288 100644 --- a/src/renderer/src/workspace/hook/helpers.ts +++ b/src/renderer/src/workspace/hook/helpers.ts @@ -1,18 +1,18 @@ import { useCallback } from 'react' -import { emptyRuntime, type SessionRuntime } from '../workspaceState' -import type { SessionId } from '../types' +import { emptyRuntime, type SessionRuntime } from '@renderer/workspace/workspaceState' +import type { SessionId } from '@renderer/workspace/types' import { appendFeedDebugLog, type FeedDebugInput, -} from '../runtime/feedDebug' -import { withDerivedSessionStatus } from '../semantic/helpers' +} from '@renderer/workspace/runtime/feedDebug' +import { withDerivedSessionStatus } from '@renderer/workspace/semantic/helpers' import type { WorkspaceSetRuntimes, WorkspaceSetState, -} from './context' -import type { WorkspaceRefs } from './refs' +} from '@renderer/workspace/hook/context' +import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' // ----------------------------------------------------------------------------- // Cross-cutting runtime helpers diff --git a/src/renderer/src/workspace/hook/index.ts b/src/renderer/src/workspace/hook/index.ts index 0294d7766..7f0a6bf35 100644 --- a/src/renderer/src/workspace/hook/index.ts +++ b/src/renderer/src/workspace/hook/index.ts @@ -1,35 +1,35 @@ import { useEffect, useMemo, useState } from 'react' -import { useAppStore } from '../../state/hooks' -import { useGlobalToast } from '../../ui/GlobalToast' +import { useAppStore } from '@renderer/state/hooks' +import { useGlobalToast } from '@renderer/ui/GlobalToast' -import type { WorkspaceHookContext } from './context' -import { useWorkspaceRefs } from './refs' -import { usePaneToast, useWorkspaceHelpers } from './helpers' -import { useStatusModeActions } from './actions/statusMode' -import { useDraftActions } from './actions/draft' -import { useStreamingActions } from './actions/streaming' -import { usePickerActions } from './actions/picker' -import { useSpotlightActions } from './actions/spotlight' -import { useReaderActions } from './actions/reader' -import { useTileTabsActions } from './actions/tileTabs' -import { useResizeActions } from './actions/resize' -import { useSessionActions } from './actions/session' -import { useTabActions } from './actions/tab' -import { usePaneActions } from './actions/pane' -import { useProviderActions } from './actions/provider' -import { useHistoryActions } from './actions/history' -import { useUndoCloseAction } from './actions/undoClose' -import { useAutoSave } from './persistence/useAutoSave' -import { useBootstrap } from './persistence/useBootstrap' -import { useFeedDebugPersist } from './persistence/useFeedDebugPersist' +import type { WorkspaceHookContext } from '@renderer/workspace/hook/context' +import { useWorkspaceRefs } from '@renderer/workspace/hook/refs' +import { usePaneToast, useWorkspaceHelpers } from '@renderer/workspace/hook/helpers' +import { useStatusModeActions } from '@renderer/workspace/hook/actions/statusMode' +import { useDraftActions } from '@renderer/workspace/hook/actions/draft' +import { useStreamingActions } from '@renderer/workspace/hook/actions/streaming' +import { usePickerActions } from '@renderer/workspace/hook/actions/picker' +import { useSpotlightActions } from '@renderer/workspace/hook/actions/spotlight' +import { useReaderActions } from '@renderer/workspace/hook/actions/reader' +import { useTileTabsActions } from '@renderer/workspace/hook/actions/tileTabs' +import { useResizeActions } from '@renderer/workspace/hook/actions/resize' +import { useSessionActions } from '@renderer/workspace/hook/actions/session' +import { useTabActions } from '@renderer/workspace/hook/actions/tab' +import { usePaneActions } from '@renderer/workspace/hook/actions/pane' +import { useProviderActions } from '@renderer/workspace/hook/actions/provider' +import { useHistoryActions } from '@renderer/workspace/hook/actions/history' +import { useUndoCloseAction } from '@renderer/workspace/hook/actions/undoClose' +import { useAutoSave } from '@renderer/workspace/hook/persistence/useAutoSave' +import { useBootstrap } from '@renderer/workspace/hook/persistence/useBootstrap' +import { useFeedDebugPersist } from '@renderer/workspace/hook/persistence/useFeedDebugPersist' import { usePickerSanity, useReaderModeSanity, useSpotlightSanity, useTileTabsSanity, -} from './invalidation/effects' -import { useIpcSubscriptions } from './ipc/useIpcSubscriptions' +} from '@renderer/workspace/hook/invalidation/effects' +import { useIpcSubscriptions } from '@renderer/workspace/hook/ipc/useIpcSubscriptions' // ----------------------------------------------------------------------------- // useWorkspace — the composer. diff --git a/src/renderer/src/workspace/hook/invalidation/effects.ts b/src/renderer/src/workspace/hook/invalidation/effects.ts index 0f784bb7e..e7a6bf141 100644 --- a/src/renderer/src/workspace/hook/invalidation/effects.ts +++ b/src/renderer/src/workspace/hook/invalidation/effects.ts @@ -5,19 +5,19 @@ import type { SessionRuntime, SpotlightState, TileTabsState, -} from '../../workspaceState' -import type { SessionId, Tab } from '../../types' -import { collectLeaves } from '../../tile-tree/treeOps' +} from '@renderer/workspace/workspaceState' +import type { SessionId, Tab } from '@renderer/workspace/types' +import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps' import { assistantUuidsWithText, -} from '../../../lib/copyAssistant' -import { ratiosEqual, sanitizeTileTabsState } from '../../layout/helpers' +} from '@renderer/lib/copyAssistant' +import { ratiosEqual, sanitizeTileTabsState } from '@renderer/workspace/layout/helpers' import type { WorkspaceSetReaderMode, WorkspaceSetSpotlight, WorkspaceSetTileTabs, -} from '../context' +} from '@renderer/workspace/hook/context' // Invalidation effects — these fire when state changes and adjust // orthogonal slices so they stay consistent with the tile tree. diff --git a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts index 1ac59f4ff..6b9760e81 100644 --- a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts +++ b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts @@ -7,48 +7,48 @@ import { isConversationEntry, } from '@shared/types/transcript' import { detectCodexApproval } from '@shared/parsers/codexScreen' -import { emptyRuntime, type SessionRuntime } from '../../workspaceState' -import { appendFeedDebugLog, type FeedDebugInput } from '../../runtime/feedDebug' -import type { SessionId } from '../../types' +import { emptyRuntime, type SessionRuntime } from '@renderer/workspace/workspaceState' +import { appendFeedDebugLog, type FeedDebugInput } from '@renderer/workspace/runtime/feedDebug' +import type { SessionId } from '@renderer/workspace/types' import { isSemanticTurnRunning, withDerivedSessionStatus, -} from '../../semantic/helpers' -import { foldSemanticEvent } from '../../semantic/foldEvent' -import { summarizeSemanticEventForDebug } from '../../semantic/summarize' +} from '@renderer/workspace/semantic/helpers' +import { foldSemanticEvent } from '@renderer/workspace/semantic/foldEvent' +import { summarizeSemanticEventForDebug } from '@renderer/workspace/semantic/summarize' import { extractCodexProviderSessionId, isCodexRolloutEntry, isOptimisticCodexUserEntry, -} from '../../codex/entries' +} from '@renderer/workspace/codex/entries' import { codexHistoryMarker, codexTurnIdFromRollout, mapCodexRolloutToFeedEntries, stampCodexTurnId, -} from '../../codex/rollout' +} from '@renderer/workspace/codex/rollout' import { claudeHistoryMarker, extractEmbeddedClaudeProgressEntry, -} from '../../claude/history' +} from '@renderer/workspace/claude/history' import { entryTextContent, indexEntryIntoMaps, summarizeEntryForDebug, -} from '../../entries/utils' -import { pickerEqual } from '../../layout/helpers' +} from '@renderer/workspace/entries/utils' +import { pickerEqual } from '@renderer/workspace/layout/helpers' import { ghostsFromSemanticTurn, ghostsToPersist, reconcileUpstream, -} from '../../ghosts' -import type { StreamPhase } from '../../workspaceState' +} from '@renderer/workspace/ghosts' +import type { StreamPhase } from '@renderer/workspace/workspaceState' import type { WorkspaceSetRuntimes, WorkspaceSetState, -} from '../context' -import type { WorkspaceRefs } from '../refs' +} from '@renderer/workspace/hook/context' +import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' // ----------------------------------------------------------------------------- // useIpcSubscriptions — the one big side-effect that wires every diff --git a/src/renderer/src/workspace/hook/persistence/rehydrate.ts b/src/renderer/src/workspace/hook/persistence/rehydrate.ts index 1ed7ae656..9252ea986 100644 --- a/src/renderer/src/workspace/hook/persistence/rehydrate.ts +++ b/src/renderer/src/workspace/hook/persistence/rehydrate.ts @@ -1,4 +1,4 @@ -import { emptyRuntime, type SessionRuntime, type TileTabsState } from '../../workspaceState' +import { emptyRuntime, type SessionRuntime, type TileTabsState } from '@renderer/workspace/workspaceState' import type { BuriedPaneRecord, SessionId, @@ -6,17 +6,17 @@ import type { SessionMeta, Tab, TileNode, -} from '../../types' -import { collectLeaves } from '../../tile-tree/treeOps' -import { sanitizeTileTabsState } from '../../layout/helpers' -import type { PersistedWorkspace } from '../../persistence' +} from '@renderer/workspace/types' +import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps' +import { sanitizeTileTabsState } from '@renderer/workspace/layout/helpers' +import type { PersistedWorkspace } from '@renderer/workspace/persistence' import type { WorkspaceSetRuntimes, WorkspaceSetState, WorkspaceSetTileTabs, -} from '../context' -import type { WorkspaceRefs } from '../refs' +} from '@renderer/workspace/hook/context' +import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' // Remap a persisted tree by replacing every sessionId with a freshly // spawned one (spawn happens as we walk). Returns the remapped tree diff --git a/src/renderer/src/workspace/hook/persistence/useAutoSave.ts b/src/renderer/src/workspace/hook/persistence/useAutoSave.ts index 4bea9259d..dbfcf1995 100644 --- a/src/renderer/src/workspace/hook/persistence/useAutoSave.ts +++ b/src/renderer/src/workspace/hook/persistence/useAutoSave.ts @@ -1,9 +1,9 @@ import { useCallback, useEffect } from 'react' -import type { PersistedWorkspace } from '../../persistence' -import type { SessionId, WorkspaceState } from '../../types' +import type { PersistedWorkspace } from '@renderer/workspace/persistence' +import type { SessionId, WorkspaceState } from '@renderer/workspace/types' -import type { WorkspaceRefs } from '../refs' +import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' // Debounced workspace-save + beforeunload flush. // diff --git a/src/renderer/src/workspace/hook/persistence/useBootstrap.ts b/src/renderer/src/workspace/hook/persistence/useBootstrap.ts index beaaebcfa..cbd54eefd 100644 --- a/src/renderer/src/workspace/hook/persistence/useBootstrap.ts +++ b/src/renderer/src/workspace/hook/persistence/useBootstrap.ts @@ -1,15 +1,15 @@ import { useEffect } from 'react' -import type { PersistedWorkspace } from '../../persistence' +import type { PersistedWorkspace } from '@renderer/workspace/persistence' import type { WorkspaceSetRuntimes, WorkspaceSetState, WorkspaceSetTileTabs, -} from '../context' -import type { WorkspaceRefs } from '../refs' +} from '@renderer/workspace/hook/context' +import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' -import { rehydrateWorkspace } from './rehydrate' +import { rehydrateWorkspace } from '@renderer/workspace/hook/persistence/rehydrate' // Once-only mount effect. // diff --git a/src/renderer/src/workspace/hook/persistence/useFeedDebugPersist.ts b/src/renderer/src/workspace/hook/persistence/useFeedDebugPersist.ts index 3b707ebe7..35ebd8a19 100644 --- a/src/renderer/src/workspace/hook/persistence/useFeedDebugPersist.ts +++ b/src/renderer/src/workspace/hook/persistence/useFeedDebugPersist.ts @@ -1,9 +1,9 @@ import { useEffect } from 'react' -import type { SessionId } from '../../types' -import type { SessionRuntime } from '../../workspaceState' +import type { SessionId } from '@renderer/workspace/types' +import type { SessionRuntime } from '@renderer/workspace/workspaceState' -import type { WorkspaceRefs } from '../refs' +import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' // Ship runtime feed-debug entries to the main process on every // runtime update. The main-side queue writes them to diff --git a/src/renderer/src/workspace/hook/refs.ts b/src/renderer/src/workspace/hook/refs.ts index b44949134..fd75fc3ae 100644 --- a/src/renderer/src/workspace/hook/refs.ts +++ b/src/renderer/src/workspace/hook/refs.ts @@ -1,13 +1,13 @@ import { useMemo, useRef, type MutableRefObject } from 'react' -import { UndoCloseStack } from '../../lib/undoClose' +import { UndoCloseStack } from '@renderer/lib/undoClose' import type { ReaderModeState, SessionRuntime, SpotlightState, TileTabsState, -} from '../workspaceState' -import type { SessionId, WorkspaceState } from '../types' +} from '@renderer/workspace/workspaceState' +import type { SessionId, WorkspaceState } from '@renderer/workspace/types' // ----------------------------------------------------------------------------- // Ref factory for the workspace hook. diff --git a/src/renderer/src/workspace/layout/helpers.ts b/src/renderer/src/workspace/layout/helpers.ts index 18679e6d3..9dfe08549 100644 --- a/src/renderer/src/workspace/layout/helpers.ts +++ b/src/renderer/src/workspace/layout/helpers.ts @@ -1,6 +1,6 @@ -import type { SessionId, TileNode } from '../types' -import type { SlashPickerState, TileTabsState } from '../workspaceState' -import { collectLeaves } from '../tile-tree/treeOps' +import type { SessionId, TileNode } from '@renderer/workspace/types' +import type { SlashPickerState, TileTabsState } from '@renderer/workspace/workspaceState' +import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps' // Layout & picker utilities for the workspace store. // diff --git a/src/renderer/src/workspace/mergedEntries.ts b/src/renderer/src/workspace/mergedEntries.ts index 30dc3c32c..368604f46 100644 --- a/src/renderer/src/workspace/mergedEntries.ts +++ b/src/renderer/src/workspace/mergedEntries.ts @@ -17,7 +17,7 @@ // chooses atp's default options and threads the runtime's state. import type { Entry } from '@shared/types/transcript' -import type { SessionRuntime } from './workspaceState' +import type { SessionRuntime } from '@renderer/workspace/workspaceState' /** * Merged entry list for rendering. Superseded ghosts are dropped; diff --git a/src/renderer/src/workspace/persistence.ts b/src/renderer/src/workspace/persistence.ts index 2ef16609c..12a767510 100644 --- a/src/renderer/src/workspace/persistence.ts +++ b/src/renderer/src/workspace/persistence.ts @@ -4,8 +4,8 @@ import type { SessionMeta, TabId, TileNode, -} from './types' -import type { TileTabsState } from './workspaceState' +} from '@renderer/workspace/types' +import type { TileTabsState } from '@renderer/workspace/workspaceState' // --------------------------------------------------------------------------- // Persisted state shape (serialized to ~/.config/cc-shell/workspace.json) diff --git a/src/renderer/src/workspace/runtime/feedDebug.ts b/src/renderer/src/workspace/runtime/feedDebug.ts index cffd35f72..d36abd4ab 100644 --- a/src/renderer/src/workspace/runtime/feedDebug.ts +++ b/src/renderer/src/workspace/runtime/feedDebug.ts @@ -2,7 +2,7 @@ import type { FeedDebugEntry, FeedDebugLayer, SessionRuntime, -} from '../workspaceState' +} from '@renderer/workspace/workspaceState' // Per-session feed-debug log — the runtime-side helper. Every // mutation to SessionRuntime that crosses an interesting boundary diff --git a/src/renderer/src/workspace/semantic/foldEvent.ts b/src/renderer/src/workspace/semantic/foldEvent.ts index a9e7046e8..6b37383a6 100644 --- a/src/renderer/src/workspace/semantic/foldEvent.ts +++ b/src/renderer/src/workspace/semantic/foldEvent.ts @@ -1,5 +1,5 @@ -import type { SessionKind } from '../types' -import type { SemanticLiveBlock, SemanticRuntimeState } from '../workspaceState' +import type { SessionKind } from '@renderer/workspace/types' +import type { SemanticLiveBlock, SemanticRuntimeState } from '@renderer/workspace/workspaceState' import { SEMANTIC_ERROR_CAP, @@ -12,8 +12,8 @@ import { hasPendingSemanticTools, semanticHistoryRow, semanticToIndex, -} from './helpers' -import { summarizeSemanticEvent } from './summarize' +} from '@renderer/workspace/semantic/helpers' +import { summarizeSemanticEvent } from '@renderer/workspace/semantic/summarize' // --------------------------------------------------------------------------- // foldSemanticEvent — the one-session-one-reducer contract diff --git a/src/renderer/src/workspace/semantic/helpers.ts b/src/renderer/src/workspace/semantic/helpers.ts index 95440eae7..14ce5bddf 100644 --- a/src/renderer/src/workspace/semantic/helpers.ts +++ b/src/renderer/src/workspace/semantic/helpers.ts @@ -5,7 +5,7 @@ import { type SessionRuntime, type SessionStatus, type SessionStatusSource, -} from '../workspaceState' +} from '@renderer/workspace/workspaceState' // --------------------------------------------------------------------------- // Size caps for the semantic runtime ring buffers diff --git a/src/renderer/src/workspace/semantic/summarize.ts b/src/renderer/src/workspace/semantic/summarize.ts index 2f4c09d90..15c02ebbd 100644 --- a/src/renderer/src/workspace/semantic/summarize.ts +++ b/src/renderer/src/workspace/semantic/summarize.ts @@ -1,4 +1,4 @@ -import { trimSemanticId } from './helpers' +import { trimSemanticId } from '@renderer/workspace/semantic/helpers' // One-line summaries for the proxy debug panel event tail + the // feed-debug log SEM layer. Both surfaces render tens of lines at diff --git a/src/renderer/src/workspace/spawn/errorMessage.ts b/src/renderer/src/workspace/spawn/errorMessage.ts index 5d47e05d0..e2a4bdb14 100644 --- a/src/renderer/src/workspace/spawn/errorMessage.ts +++ b/src/renderer/src/workspace/spawn/errorMessage.ts @@ -1,4 +1,4 @@ -import type { SessionKind } from '../types' +import type { SessionKind } from '@renderer/workspace/types' // Normalize spawn errors so the user-facing toast/showToast has a // single string to print. When the Claude proxy startup path is the diff --git a/src/renderer/src/workspace/tile-tree/TabBar.tsx b/src/renderer/src/workspace/tile-tree/TabBar.tsx index 3e4d472a7..5be52d139 100644 --- a/src/renderer/src/workspace/tile-tree/TabBar.tsx +++ b/src/renderer/src/workspace/tile-tree/TabBar.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' -import type { TileNode } from '../types' -import type { Workspace } from '../workspaceStore' +import type { TileNode } from '@renderer/workspace/types' +import type { Workspace } from '@renderer/workspace/workspaceStore' // TabBar — one row of tab chrome at the top of the window. Each tab has // a title, a close button, and activates on click. The `+` button opens diff --git a/src/renderer/src/workspace/tile-tree/TerminalLeaf.tsx b/src/renderer/src/workspace/tile-tree/TerminalLeaf.tsx index 56fcf4172..db8113634 100644 --- a/src/renderer/src/workspace/tile-tree/TerminalLeaf.tsx +++ b/src/renderer/src/workspace/tile-tree/TerminalLeaf.tsx @@ -2,8 +2,8 @@ import { useEffect, useRef } from 'react' import { Terminal } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' -import type { SessionId } from '../types' -import type { Workspace } from '../workspaceStore' +import type { SessionId } from '@renderer/workspace/types' +import type { Workspace } from '@renderer/workspace/workspaceStore' // TerminalLeaf — one pane that hosts a plain shell session. // diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf.tsx b/src/renderer/src/workspace/tile-tree/TileLeaf.tsx index 60720a90f..51ba777a7 100644 --- a/src/renderer/src/workspace/tile-tree/TileLeaf.tsx +++ b/src/renderer/src/workspace/tile-tree/TileLeaf.tsx @@ -3,19 +3,19 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { extractAssistantInProgress } from '@shared/parsers/extractAssistant' import { CodexApprovalModal } from '@providers/codex/renderer/CodexApprovalModal' import { ResumePromptModal } from '@providers/claude/renderer/ResumePromptModal' -import { useGlobalToast } from '../../ui/GlobalToast' -import { Feed } from '../../features/feed/ui/Feed' -import type { ScrollInfo } from '../../features/feed/ui/Feed' +import { useGlobalToast } from '@renderer/ui/GlobalToast' +import { Feed } from '@renderer/features/feed/ui/Feed' +import type { ScrollInfo } from '@renderer/features/feed/ui/Feed' import { TrustDialogModal } from '@providers/claude/renderer/TrustDialogModal' import { SlashCommandPicker } from '@providers/claude/renderer/SlashCommandPicker' -import { extractLatestUserPrompts } from '../../features/workspace/lib/latestUserPrompts' -import type { SessionRuntime, Workspace } from '../workspaceStore' +import { extractLatestUserPrompts } from '@renderer/features/workspace/lib/latestUserPrompts' +import type { SessionRuntime, Workspace } from '@renderer/workspace/workspaceStore' import { selectMergedEntries, shouldShowSemanticStreaming, -} from '../mergedEntries' -import type { SessionId, SessionKind } from '../types' -import type { ClaudeDraftImage } from '../workspaceState' +} from '@renderer/workspace/mergedEntries' +import type { SessionId, SessionKind } from '@renderer/workspace/types' +import type { ClaudeDraftImage } from '@renderer/workspace/workspaceState' // Claude's TUI has its own paste-state machine. Large input and bracketed // paste do NOT go straight from "bytes arrived" to "submit immediately" — diff --git a/src/renderer/src/workspace/tile-tree/TileTree.tsx b/src/renderer/src/workspace/tile-tree/TileTree.tsx index e4a48059b..470ba315c 100644 --- a/src/renderer/src/workspace/tile-tree/TileTree.tsx +++ b/src/renderer/src/workspace/tile-tree/TileTree.tsx @@ -1,10 +1,10 @@ import { useCallback, useRef } from 'react' import { getRendererProvider } from '@providers/registry.renderer' -import { TerminalLeaf } from './TerminalLeaf' -import type { Workspace } from '../workspaceStore' -import type { SessionId, TabId, TileNode } from '../types' -import { collectLeaves } from './treeOps' +import { TerminalLeaf } from '@renderer/workspace/tile-tree/TerminalLeaf' +import type { Workspace } from '@renderer/workspace/workspaceStore' +import type { SessionId, TabId, TileNode } from '@renderer/workspace/types' +import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps' // TileTree — recursive renderer for a tab's binary-split tree. // diff --git a/src/renderer/src/workspace/tile-tree/geometry.ts b/src/renderer/src/workspace/tile-tree/geometry.ts index 7390f1c2d..2bf81d723 100644 --- a/src/renderer/src/workspace/tile-tree/geometry.ts +++ b/src/renderer/src/workspace/tile-tree/geometry.ts @@ -1,4 +1,4 @@ -import type { SessionId, SplitDirection, TileNode } from '../types' +import type { SessionId, SplitDirection, TileNode } from '@renderer/workspace/types' export type Rect = { x: number diff --git a/src/renderer/src/workspace/tile-tree/treeOps.ts b/src/renderer/src/workspace/tile-tree/treeOps.ts index d2f9aec36..ebdfd7e6d 100644 --- a/src/renderer/src/workspace/tile-tree/treeOps.ts +++ b/src/renderer/src/workspace/tile-tree/treeOps.ts @@ -16,7 +16,7 @@ import { type SessionId, type SplitDirection, type TileNode, -} from '../types' +} from '@renderer/workspace/types' /** * Walk a tree and return every leaf's sessionId in depth-first order. diff --git a/src/renderer/src/workspace/tile-tree/useKeybinds.ts b/src/renderer/src/workspace/tile-tree/useKeybinds.ts index 2fb1c3e03..9781d1a68 100644 --- a/src/renderer/src/workspace/tile-tree/useKeybinds.ts +++ b/src/renderer/src/workspace/tile-tree/useKeybinds.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react' -import { useAppStore } from '../../state/hooks' -import type { Workspace } from '../workspaceStore' +import { useAppStore } from '@renderer/state/hooks' +import type { Workspace } from '@renderer/workspace/workspaceStore' // Keybinds: global window-level listeners. The handler is attached to // `document` in a single useEffect, which captures the key BEFORE the diff --git a/src/renderer/src/workspace/workspaceState.ts b/src/renderer/src/workspace/workspaceState.ts index 546fc4712..bd727d310 100644 --- a/src/renderer/src/workspace/workspaceState.ts +++ b/src/renderer/src/workspace/workspaceState.ts @@ -2,7 +2,7 @@ import type { SessionId, SplitDirection, TabId, -} from './types' +} from '@renderer/workspace/types' import type { Entry, ToolResultBlock, diff --git a/src/renderer/src/workspace/workspaceStore.ts b/src/renderer/src/workspace/workspaceStore.ts index d90869b4d..f11bdcc12 100644 --- a/src/renderer/src/workspace/workspaceStore.ts +++ b/src/renderer/src/workspace/workspaceStore.ts @@ -8,7 +8,7 @@ // // Prefer importing from './hook' directly in new code. -export { useWorkspace, type Workspace } from './hook' +export { useWorkspace, type Workspace } from '@renderer/workspace/hook' // ----------------------------------------------------------------------------- // Backward-compat re-exports. @@ -22,18 +22,18 @@ export { useWorkspace, type Workspace } from './hook' // graph. // ----------------------------------------------------------------------------- -export { foldSemanticEvent } from './semantic/foldEvent' +export { foldSemanticEvent } from '@renderer/workspace/semantic/foldEvent' export { codexHistoryMarker, codexTurnIdFromRollout, mapCodexRolloutToFeedEntries, -} from './codex/rollout' +} from '@renderer/workspace/codex/rollout' export { claudeHistoryMarker, extractEmbeddedClaudeProgressEntry, -} from './claude/history' +} from '@renderer/workspace/claude/history' -export { collectLeaves } from './tile-tree/treeOps' +export { collectLeaves } from '@renderer/workspace/tile-tree/treeOps' export type { PickerItem, @@ -43,10 +43,10 @@ export type { SlashPickerState, SpotlightState, TileTabsState, -} from './workspaceState' +} from '@renderer/workspace/workspaceState' // Re-exported from ./types so external callers (ReaderView, etc.) // can keep importing from '../workspace/workspaceStore' without // reaching into the internal types module. SessionId is a plain // string alias but callers use the name for documentation. -export type { SessionId } from './types' +export type { SessionId } from '@renderer/workspace/types' diff --git a/src/shared/parsers/claudeScreen.ts b/src/shared/parsers/claudeScreen.ts index 688d05c7f..637f65906 100644 --- a/src/shared/parsers/claudeScreen.ts +++ b/src/shared/parsers/claudeScreen.ts @@ -5,14 +5,14 @@ export { extractAssistantInProgress, extractStreamingText, detectActivity, -} from '../../../claude-code-headless/src/parsers/ScreenParser' +} from 'claude-code-headless/parsers/ScreenParser' export { detectTrustDialog, type TrustDialogState, -} from '../../../claude-code-headless/src/parsers/TrustDialogParser' +} from 'claude-code-headless/parsers/TrustDialogParser' export { detectResumePrompt, type ResumePromptState, -} from '../../../claude-code-headless/src/parsers/ResumePromptParser' +} from 'claude-code-headless/parsers/ResumePromptParser' diff --git a/src/shared/parsers/codexScreen.ts b/src/shared/parsers/codexScreen.ts index da79e84b1..3486a0020 100644 --- a/src/shared/parsers/codexScreen.ts +++ b/src/shared/parsers/codexScreen.ts @@ -5,10 +5,10 @@ export { extractCodexAssistantInProgress, extractCodexStreamingText, detectCodexActivity, -} from '../../../codex-headless/src/parsers/ScreenParser' +} from 'codex-headless/parsers/ScreenParser' export { detectCodexApproval, isApprovalOverlayVisible, type ScreenApproval, -} from '../../../codex-headless/src/parsers/ApprovalParser' +} from 'codex-headless/parsers/ApprovalParser' diff --git a/src/shared/parsers/extractAssistant.ts b/src/shared/parsers/extractAssistant.ts index 7e18bf046..5f71efeaa 100644 --- a/src/shared/parsers/extractAssistant.ts +++ b/src/shared/parsers/extractAssistant.ts @@ -10,8 +10,8 @@ // Direct file imports — the parser files are pure TypeScript, safe for // the renderer bundle. The headless package entry points pull in Node // deps so we can't import through them in browser context. -import { extractAssistantInProgress as claudeExtract } from './claudeScreen' -import { extractCodexAssistantInProgress as codexExtract } from './codexScreen' +import { extractAssistantInProgress as claudeExtract } from '@shared/parsers/claudeScreen' +import { extractCodexAssistantInProgress as codexExtract } from '@shared/parsers/codexScreen' export type AgentProvider = 'claude' | 'codex' diff --git a/src/shared/types/providerConfig.ts b/src/shared/types/providerConfig.ts index 1f3e49d67..ec6279532 100644 --- a/src/shared/types/providerConfig.ts +++ b/src/shared/types/providerConfig.ts @@ -14,7 +14,7 @@ // RendererProviderConfig, and nothing re-joins them. import type { ComponentType } from 'react' -import type { SessionOptions, SessionInfo } from './session.js' +import type { SessionOptions, SessionInfo } from '@shared/types/session.js' // Props the shell passes to every provider's TileLeaf. export type TileLeafProps = { diff --git a/testing/ghost-demo/bridge.mts b/testing/ghost-demo/bridge.mts index cadb8e0ef..c9b120db1 100644 --- a/testing/ghost-demo/bridge.mts +++ b/testing/ghost-demo/bridge.mts @@ -19,6 +19,7 @@ import { mergeWithUpstream, reduceGhostLog, toCodex, + type ClaudeEntry, type GhostEntry, } from '../../agent-transcript-parser/dist/index.js' @@ -161,15 +162,15 @@ check( // 2. Merge — ghosts appear after upstream entries // --------------------------------------------------------------------------- -const upstreamBefore: Entry[] = [] -const merged = mergeWithUpstream(upstreamBefore, ghostsV2) as Entry[] +const upstreamBefore: ClaudeEntry[] = [] +const merged = mergeWithUpstream(upstreamBefore, ghostsV2) as ClaudeEntry[] check('merged list contains both ghosts when no upstream', merged.length === 2) // --------------------------------------------------------------------------- // 3. reconcileUpstream — supersede by message.id // --------------------------------------------------------------------------- -const upstreamAssistant: Entry = { +const upstreamAssistant: ClaudeEntry = { type: 'assistant', uuid: 'real-uuid-42', parentUuid: null, @@ -188,7 +189,7 @@ const upstreamAssistant: Entry = { }, ], }, -} as Entry +} as ClaudeEntry const reconciled = reconcileUpstream(upstreamAssistant, ghostsV2) @@ -197,7 +198,7 @@ check( [...reconciled.values()].every(g => g._atp.supersededBy === 'real-uuid-42'), ) -const mergedAfter = mergeWithUpstream([upstreamAssistant], reconciled) as Entry[] +const mergedAfter = mergeWithUpstream([upstreamAssistant], reconciled) as ClaudeEntry[] check( 'merged tail no longer contains ghosts after supersede', mergedAfter.length === 1 && mergedAfter[0]!.uuid === 'real-uuid-42', diff --git a/tsconfig.node.json b/tsconfig.node.json index d5d9b5542..06dd340b0 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -33,8 +33,11 @@ "baseUrl": ".", "paths": { "claude-code-headless": ["./claude-code-headless/src/index.ts"], + "claude-code-headless/*": ["./claude-code-headless/src/*"], "codex-headless": ["./codex-headless/src/index.ts"], + "codex-headless/*": ["./codex-headless/src/*"], "agent-transcript-parser": ["./agent-transcript-parser/src/index.ts"], + "agent-transcript-parser/*": ["./agent-transcript-parser/src/*"], "@main/*": ["./src/main/*"], "@preload/*": ["./src/preload/*"], "@renderer/*": ["./src/renderer/src/*"], diff --git a/tsconfig.web.json b/tsconfig.web.json index e46bc1ccb..67abaf0fe 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -26,6 +26,12 @@ // whether tsc is checking the main/preload or the renderer half. "baseUrl": ".", "paths": { + "claude-code-headless": ["./claude-code-headless/src/index.ts"], + "claude-code-headless/*": ["./claude-code-headless/src/*"], + "codex-headless": ["./codex-headless/src/index.ts"], + "codex-headless/*": ["./codex-headless/src/*"], + "agent-transcript-parser": ["./agent-transcript-parser/src/index.ts"], + "agent-transcript-parser/*": ["./agent-transcript-parser/src/*"], "@main/*": ["./src/main/*"], "@preload/*": ["./src/preload/*"], "@renderer/*": ["./src/renderer/src/*"], From ae87af09115a9e96ae85ff877c30e52c83a5e190 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 23 Apr 2026 21:19:20 +0200 Subject: [PATCH 19/27] feat(debug): add Save Debug Logs palette command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/main/ipc/debug.ts | 30 +- src/main/storage/debugBundle.ts | 123 +++++++ src/main/storage/paths.ts | 13 + src/preload/api/debug.ts | 21 +- src/preload/api/types.ts | 17 + .../src/features/debug/saveDebugBundle.ts | 303 ++++++++++++++++++ .../workspace/commands/sessionCommands.ts | 36 +++ 7 files changed, 534 insertions(+), 9 deletions(-) create mode 100644 src/main/storage/debugBundle.ts create mode 100644 src/renderer/src/features/debug/saveDebugBundle.ts diff --git a/src/main/ipc/debug.ts b/src/main/ipc/debug.ts index c0bdbbfa7..b6e5e861a 100644 --- a/src/main/ipc/debug.ts +++ b/src/main/ipc/debug.ts @@ -4,13 +4,24 @@ import { queueFeedDebugAppend, type FeedDebugPersistEntry, } from '@main/storage/feedDebugLog.js' +import { + saveDebugBundle, + type SaveDebugBundleParams, + type SaveDebugBundleResult, +} from '@main/storage/debugBundle.js' // Debug-panel IPC. // -// The renderer's FeedDebugPanel accumulates entries in memory and -// flushes batches here on a timer. We serialize per-session in the -// storage module; this handler just validates the payload shape and -// forwards. +// Two endpoints: +// - debug:append-feed-log — streaming, fire-and-forget batches from +// the FeedDebugPanel flush timer. Serialized per-session in the +// storage module; this handler just validates shape and forwards. +// - debug:save-bundle — one-shot, user-triggered from the "Save +// Debug Logs" command palette entry. Renderer assembles the bundle +// (state + feed-debug + proxy semantic + html raw/clean) and we +// persist it as a timestamped folder under DEBUG_BUNDLE_DIR. The +// return value is the absolute path so the renderer can display +// it and copy it to the clipboard. export function registerDebugIpc(): void { ipcMain.handle( @@ -25,4 +36,15 @@ export function registerDebugIpc(): void { await queueFeedDebugAppend(params.sessionId, params.entries) }, ) + + ipcMain.handle( + 'debug:save-bundle', + async (_evt, params: SaveDebugBundleParams): Promise => { + // Let errors propagate. The renderer catches and shows a + // `save failed: ` toast — the user triggered this + // explicitly, so silent failure is strictly worse than a + // surfaced one. + return saveDebugBundle(params) + }, + ) } diff --git a/src/main/storage/debugBundle.ts b/src/main/storage/debugBundle.ts new file mode 100644 index 000000000..98d884504 --- /dev/null +++ b/src/main/storage/debugBundle.ts @@ -0,0 +1,123 @@ +import { mkdir, writeFile } from 'fs/promises' +import { join } from 'path' + +import { DEBUG_BUNDLE_DIR } from '@main/storage/paths.js' + +// Debug-bundle writer. +// +// The renderer assembles the bundle (it has every diagnostic source: +// runtime.semantic, runtime.feedDebugLog, the DOM outerHTML, and can +// run sanitizeHtml locally) and ships the pre-serialized files here +// in a single IPC call. Main owns filesystem layout + atomicity and +// returns the absolute bundle path so the renderer can surface it. +// +// Why the renderer assembles vs. main assembling: +// The data lives in three different places in the renderer +// (workspace runtime store, the live DOM, sanitizeHtml in +// @renderer/lib). Forwarding all of that through IPC in structured +// form would recreate those representations in main just to +// stringify them back out. Shipping the already-stringified files +// is one round-trip, no schema duplication, and keeps main a thin +// byte-mover — same discipline as workspace.json (see paths.ts). +// +// Why per-invocation folders instead of a single shared dir: +// A bundle is a coherent snapshot — all files were captured at the +// same instant from the same pane. Mixing multiple invocations +// into one folder would make it ambiguous which proxy-semantic.json +// goes with which feed-debug.jsonl. Timestamped folders also double +// as a history: the user can compare "before / after" bundles +// without manual renaming. + +export type DebugBundleFile = { + /** Filename relative to the bundle folder. Validated: no path + * separators allowed — the renderer only ships leaf names. */ + name: string + /** Text content. Binary files are not supported because none of + * the debug surfaces produce binary data today (HTML capture is + * a DOM string, everything else is JSON/JSONL). */ + content: string +} + +export type SaveDebugBundleParams = { + /** Session id the bundle is for. Used only to build the folder + * name — the payload itself already encodes what session it + * came from inside manifest.json. */ + sessionId: string + /** Opaque files list. Main does not look inside `content`. */ + files: DebugBundleFile[] +} + +export type SaveDebugBundleResult = { + /** Absolute path of the created bundle folder. Returned so the + * renderer can show it in a toast and copy to clipboard. */ + bundlePath: string +} + +// Same regex as sanitizeSessionIdForPath in feedDebugLog.ts — +// deliberately narrow to prevent path traversal via a malformed +// session id and to keep folder names portable across macOS/Windows. +function sanitizeForPath(value: string): string { + return value.replace(/[^a-zA-Z0-9._-]/g, '_') +} + +// Bundle folder naming: `-`. +// +// Why colon-free ISO: macOS tolerates ':' in file names but many +// tools (and Windows) don't, and the user WILL be copy-pasting this +// path into a terminal. `2026-04-23T14-32-07` reads unambiguously +// as a date+time and sorts lexicographically. +// +// Why include the 8-char session id: if the user saves two bundles +// from two different panes in the same second, bare timestamp would +// collide. Short prefix keeps the folder name readable while +// disambiguating. +function buildBundleFolderName(sessionId: string, now: Date): string { + const iso = now.toISOString() + // `2026-04-23T14:32:07.842Z` → `2026-04-23T14-32-07` + const stamp = iso.replace(/\..+$/, '').replace(/:/g, '-') + const short = sanitizeForPath(sessionId.slice(0, 8)) + return `${stamp}-${short}` +} + +// Validate that a file name is a plain leaf and doesn't try to +// escape the bundle folder. The renderer is in-process and trusted, +// but the discipline costs one line and makes future refactors +// (e.g. surfacing this IPC to a plugin) safe by default. +function isSafeFileName(name: string): boolean { + if (!name || name.length > 255) return false + if (name.includes('/') || name.includes('\\')) return false + if (name === '.' || name === '..') return false + return true +} + +export async function saveDebugBundle( + params: SaveDebugBundleParams, +): Promise { + if (!params?.sessionId || !Array.isArray(params.files) || params.files.length === 0) { + throw new Error('saveDebugBundle: missing sessionId or empty files list') + } + for (const file of params.files) { + if (!isSafeFileName(file.name)) { + throw new Error(`saveDebugBundle: unsafe file name: ${JSON.stringify(file.name)}`) + } + } + + const bundleFolderName = buildBundleFolderName(params.sessionId, new Date()) + const bundlePath = join(DEBUG_BUNDLE_DIR, bundleFolderName) + + // mkdir recursive handles both the DEBUG_BUNDLE_DIR root (first + // invocation ever) and the new per-bundle folder in one call. + await mkdir(bundlePath, { recursive: true }) + + // Sequential writes. Could parallelize with Promise.all but bundles + // are small (a few files, typically < 1MB total) and a serial loop + // keeps error messages unambiguous — a failure names the file that + // actually failed, not an aggregate rejection that's harder to act + // on when debugging cc-shell itself (which is the whole point of + // this feature). + for (const file of params.files) { + await writeFile(join(bundlePath, file.name), file.content, 'utf8') + } + + return { bundlePath } +} diff --git a/src/main/storage/paths.ts b/src/main/storage/paths.ts index 6574ef15a..a0af3b702 100644 --- a/src/main/storage/paths.ts +++ b/src/main/storage/paths.ts @@ -17,3 +17,16 @@ export const STATE_FILE = join(STATE_DIR, 'workspace.json') // Per-session feed-debug append-only logs, one JSONL file per session. // See storage/feedDebugLog.ts for the write-queue discipline. export const FEED_DEBUG_DIR = join(STATE_DIR, 'feed-debug') + +// One subfolder per explicit "Save Debug Logs" palette invocation. +// Each invocation creates a timestamped folder inside this root with a +// snapshot of the focused pane's diagnostic state (state + feed-debug + +// proxy semantic + HTML capture). Parallel to FEED_DEBUG_DIR but writes +// are user-triggered rather than streaming. +// +// Lives under STATE_DIR (not ~/Downloads or the project cwd) so bundles +// are colocated with the rest of cc-shell's on-disk state — one place to +// purge, one place to back up. The Save command shows the resulting +// path in a toast AND copies it to the clipboard, so discoverability +// doesn't depend on the user knowing the filesystem layout. +export const DEBUG_BUNDLE_DIR = join(STATE_DIR, 'debug-bundles') diff --git a/src/preload/api/debug.ts b/src/preload/api/debug.ts index b15693b23..2d826bada 100644 --- a/src/preload/api/debug.ts +++ b/src/preload/api/debug.ts @@ -1,12 +1,20 @@ import { ipcRenderer } from 'electron' -import type { FeedDebugPersistEntry } from '@preload/api/types.js' +import type { + FeedDebugPersistEntry, + SaveDebugBundleParams, + SaveDebugBundleResult, +} from '@preload/api/types.js' -// Feed-debug log shipper. +// Debug IPC surface. // -// The renderer accumulates entries in runtime state and flushes -// batches here on a timer (see workspaceStore's persistFeedDebugLog -// effect). Main serializes per-session and appends to JSONL. +// appendFeedDebugLog — streaming flush of FeedDebugPanel entries +// (called on a timer by useFeedDebugPersist). Main serializes +// per-session and appends to the per-session JSONL. +// +// saveDebugBundle — one-shot snapshot invoked by the "Save Debug +// Logs" palette command. Renderer assembles the file list; main +// writes a timestamped folder and returns its absolute path. export const debugApi = { appendFeedDebugLog: (params: { @@ -14,4 +22,7 @@ export const debugApi = { entries: FeedDebugPersistEntry[] }): Promise => ipcRenderer.invoke('debug:append-feed-log', params), + + saveDebugBundle: (params: SaveDebugBundleParams): Promise => + ipcRenderer.invoke('debug:save-bundle', params), } diff --git a/src/preload/api/types.ts b/src/preload/api/types.ts index d6c3486b0..b56d8da8c 100644 --- a/src/preload/api/types.ts +++ b/src/preload/api/types.ts @@ -172,6 +172,23 @@ export type FeedDebugPersistEntry = { data?: unknown } +// Debug bundle — opaque file list shipped from renderer to main. +// See main/storage/debugBundle.ts for the layout rationale. Types +// are duplicated here (not imported) because preload/main/renderer +// build under different tsconfig contexts, same convention as +// SessionIndexEntry above. +export type DebugBundleFile = { + name: string + content: string +} +export type SaveDebugBundleParams = { + sessionId: string + files: DebugBundleFile[] +} +export type SaveDebugBundleResult = { + bundlePath: string +} + export type SessionInfo = { sessionId: string summary: string diff --git a/src/renderer/src/features/debug/saveDebugBundle.ts b/src/renderer/src/features/debug/saveDebugBundle.ts new file mode 100644 index 000000000..215d88bd0 --- /dev/null +++ b/src/renderer/src/features/debug/saveDebugBundle.ts @@ -0,0 +1,303 @@ +import type { Workspace } from '@renderer/workspace/workspaceStore' +import type { SessionRuntime } from '@renderer/workspace/workspaceState' +import { sanitizeHtml } from '@renderer/lib/sanitizeHtml' + +// saveDebugBundle — assemble-and-ship side of the "Save Debug Logs" +// command. Runs in the renderer because every data source the +// bundle needs is renderer-local: +// - DebugPanel's state view → workspace.getRuntime(sid) +// - FeedDebugPanel's log → runtime.feedDebugLog (in-memory +// cap 500; the full series is +// already on disk in feed-debug/, +// but we still write the current +// in-memory slice because it's +// the exact data the panel was +// showing at save time) +// - ProxyDebugPanel's semantic → runtime.semantic +// - HtmlDebugPanel's capture → document.querySelector by +// data-pane-id +// - Sanitized HTML → @renderer/lib/sanitizeHtml +// +// Shipping that cross-process would mean exporting the Zustand store +// shape, the DOM, and sanitizeHtml's detached-document logic into +// main — a huge amount of code movement for a one-shot feature. This +// module stringifies everything locally and hands main a flat list +// of files. Main owns filesystem layout + returns the absolute path. + +const BUNDLE_SCHEMA_VERSION = 1 + +// Files present in every bundle. Declared as a typed const so the +// manifest can list them without duplication, and so any downstream +// tool that wants to machine-read a bundle has a stable contract. +const FILE_NAMES = { + manifest: 'manifest.json', + state: 'state-snapshot.json', + feedDebug: 'feed-debug.jsonl', + semantic: 'proxy-semantic.json', + htmlRaw: 'html-raw.html', + htmlClean: 'html-clean.html', +} as const + +// Screen text can be tens of KB of ANSI-stripped terminal buffer. +// For the state snapshot we keep only the tail — the DebugPanel UI +// itself only ever shows the last 20 lines (DebugPanel.tsx:29), and +// the broader screen buffer is already available to the user via +// copy-assistant or the reader. 200 lines is generous enough to +// include recent tool output context but stops the snapshot from +// ballooning on very long-lived panes. +const SCREEN_TAIL_LINES = 200 + +function tailLines(text: string, count: number): string { + const lines = text.split('\n') + if (lines.length <= count) return text + return lines.slice(lines.length - count).join('\n') +} + +type BundleFile = { name: string; content: string } + +// Build a state snapshot from the runtime with non-serializable and +// overly-heavy fields removed. WHY each exclusion: +// +// entries / toolUseIndex / toolResultIndex / ghosts +// Maps don't JSON-serialize, and `entries` can be thousands of +// long records — the raw JSONL is the source of truth, available +// on disk and via Claude/Codex's own session files. Duplicating +// it here would bloat bundles for zero debugging value. +// +// feedDebugLog / semantic +// Shipped as their own files (feed-debug.jsonl and +// proxy-semantic.json). Double-embedding them in state-snapshot +// would create drift risk if the format ever changes. +// +// screen / screenMarkdown / recentScreen / recentScreenMarkdown +// Tail-truncated to SCREEN_TAIL_LINES. +function buildStateSnapshot(runtime: SessionRuntime): Record { + const { + entries: _entries, + toolUseIndex: _toolUseIndex, + toolResultIndex: _toolResultIndex, + ghosts: _ghosts, + feedDebugLog: _feedDebugLog, + semantic: _semantic, + screen, + screenMarkdown, + recentScreen, + recentScreenMarkdown, + ...rest + } = runtime + void _entries + void _toolUseIndex + void _toolResultIndex + void _ghosts + void _feedDebugLog + void _semantic + + return { + ...rest, + screen: tailLines(screen, SCREEN_TAIL_LINES), + screenMarkdown: tailLines(screenMarkdown, SCREEN_TAIL_LINES), + recentScreen: tailLines(recentScreen, SCREEN_TAIL_LINES), + recentScreenMarkdown: tailLines(recentScreenMarkdown, SCREEN_TAIL_LINES), + // Counts are preserved so you can see "there were 2145 entries + // in the feed" without having to open the JSONL. + _counts: { + entries: runtime.entries.length, + feedDebugLog: runtime.feedDebugLog.length, + queuedMessages: runtime.queuedMessages.length, + toolUseIndex: runtime.toolUseIndex.size, + toolResultIndex: runtime.toolResultIndex.size, + ghosts: runtime.ghosts.size, + semanticLog: runtime.semantic.log.length, + semanticErrors: runtime.semantic.errors.length, + }, + } +} + +// Feed-debug entries already carry `ts` (wall-clock ms) and `tMs` +// (offset from epoch of the session's first entry). The user asked +// for "timestamps" explicitly — we add `tsIso` so the file is +// grep-able without shell date arithmetic. Order of keys is chosen +// to put the human-readable timestamp first, then the numeric ones, +// then the payload — makes `less` / `jq -c .` readable. +function serializeFeedDebugJsonl(runtime: SessionRuntime): string { + const lines = runtime.feedDebugLog.map(entry => + JSON.stringify({ + tsIso: new Date(entry.ts).toISOString(), + ts: entry.ts, + tMs: entry.tMs, + id: entry.id, + layer: entry.layer, + kind: entry.kind, + summary: entry.summary, + data: entry.data, + }), + ) + // Trailing newline so `cat` / `tail -f` play nice, and so appending + // (should we ever extend this) doesn't require a pre-write newline + // check. Empty case: still emit an empty file rather than no file — + // the manifest.files listing should always match what's on disk. + return lines.length > 0 ? lines.join('\n') + '\n' : '' +} + +// Proxy semantic state is richer than feed-debug (nested turns, +// blocks, flows). Rather than transform it aggressively, we dump it +// as-is and add an ISO-augmented mirror of the two timestamped +// arrays (`log` and `errors`) alongside the originals. Consumers +// who want the raw numeric ts still have it; consumers who want to +// skim in a text editor have the ISO version. +function serializeSemanticJson(runtime: SessionRuntime): string { + const s = runtime.semantic + const payload = { + _timestampHint: + 'All `ts` fields are Unix ms (Date.now()). `logWithIso` and ' + + '`errorsWithIso` mirror `log` and `errors` with an extra tsIso ' + + 'field injected for readability.', + currentTurn: s.currentTurn, + history: s.history, + flows: s.flows, + errors: s.errors, + errorsWithIso: s.errors.map(err => ({ + ...err, + tsIso: new Date(err.ts).toISOString(), + })), + log: s.log, + logWithIso: s.log.map(entry => ({ + ...entry, + tsIso: new Date(entry.ts).toISOString(), + })), + nextLogId: s.nextLogId, + } + return JSON.stringify(payload, null, 2) +} + +// Capture the focused pane's DOM. Same anchor as HtmlDebugPanel — +// TileLeaf stamps `data-pane-id` on its root so no React ref +// plumbing is needed. If the pane isn't mounted (e.g. capture +// fired during a tree rebuild), we still emit empty files rather +// than failing the whole bundle — the other four files are +// independently useful and the user can re-run the command. +function capturePaneHtml(sessionId: string): { raw: string; clean: string } { + const node = document.querySelector(`[data-pane-id="${sessionId}"]`) + if (!(node instanceof HTMLElement)) { + return { raw: '', clean: '' } + } + const raw = node.outerHTML + const clean = sanitizeHtml(raw) + return { raw, clean } +} + +function buildManifest( + sessionId: string, + runtime: SessionRuntime, + kind: string, + capturedAt: number, +): string { + const manifest = { + schemaVersion: BUNDLE_SCHEMA_VERSION, + sessionId, + kind, + projectDir: runtime.projectDir, + capturedAt, + capturedAtIso: new Date(capturedAt).toISOString(), + files: Object.values(FILE_NAMES), + // Small bits of runtime state that help orient a consumer + // without parsing state-snapshot.json first. Deliberately a + // flat summary, not a duplicate. + summary: { + sessionStatus: runtime.sessionStatus, + streamPhase: runtime.streamPhase, + activityStatus: runtime.activityStatus, + entries: runtime.entries.length, + feedDebugLog: runtime.feedDebugLog.length, + semanticLog: runtime.semantic.log.length, + semanticErrors: runtime.semantic.errors.length, + }, + } + return JSON.stringify(manifest, null, 2) +} + +/** + * Entry point called by the palette command. Builds the files, + * ships them to main, then hands the path back so the caller can + * surface it (copy + toast). + * + * Throws on IPC failure; the command handler is responsible for + * catching and showing a user-visible error. + */ +export async function assembleAndSaveDebugBundle(params: { + sessionId: string + runtime: SessionRuntime + kind: string +}): Promise<{ bundlePath: string }> { + const { sessionId, runtime, kind } = params + const capturedAt = Date.now() + + const html = capturePaneHtml(sessionId) + + const files: BundleFile[] = [ + { + name: FILE_NAMES.manifest, + content: buildManifest(sessionId, runtime, kind, capturedAt), + }, + { + name: FILE_NAMES.state, + content: JSON.stringify(buildStateSnapshot(runtime), null, 2), + }, + { + name: FILE_NAMES.feedDebug, + content: serializeFeedDebugJsonl(runtime), + }, + { + name: FILE_NAMES.semantic, + content: serializeSemanticJson(runtime), + }, + { name: FILE_NAMES.htmlRaw, content: html.raw }, + { name: FILE_NAMES.htmlClean, content: html.clean }, + ] + + return window.api.saveDebugBundle({ sessionId, files }) +} + +// Thin wrapper that resolves the focused pane, calls the assembler, +// copies the resulting path to the clipboard, and routes user +// feedback through showPaneToast. Kept here (not in sessionCommands) +// so the command file stays focused on palette registration and so +// this logic is reusable if we ever surface the command elsewhere +// (e.g. a keybinding or a right-click menu). +export async function runSaveDebugBundleCommand(workspace: Workspace): Promise { + const tab = workspace.activeTab + if (!tab) return + const sessionId = tab.focusedSessionId + const meta = workspace.state.sessions[sessionId] + const runtime = workspace.getRuntime(sessionId) + const kind = meta?.kind ?? 'claude' + + try { + const { bundlePath } = await assembleAndSaveDebugBundle({ sessionId, runtime, kind }) + + // Best-effort clipboard copy. A denied clipboard permission + // shouldn't hide the fact that the save succeeded — we still + // toast the path so the user can select it from the pane. + let clipboardOk = true + try { + await navigator.clipboard.writeText(bundlePath) + } catch { + clipboardOk = false + } + + // Toast duration overrides the default 2000ms. The user needs + // time to read a full filesystem path; 6 seconds is long enough + // to copy-by-eye even on a slow reader, short enough not to + // stick around forever. The path IS the message — including it + // verbatim (not truncated) matters because the user might need + // to paste into a terminal that wraps differently from the + // toast layout. + const prefix = clipboardOk ? 'saved · copied path · ' : 'saved (clipboard blocked) · ' + workspace.showPaneToast(sessionId, `${prefix}${bundlePath}`, 6000) + } catch (err) { + const msg = (err as Error)?.message ?? String(err) + workspace.showPaneToast(sessionId, `save failed: ${msg}`, 4000) + // eslint-disable-next-line no-console + console.warn('[save-debug-bundle] failed', err) + } +} diff --git a/src/renderer/src/features/workspace/commands/sessionCommands.ts b/src/renderer/src/features/workspace/commands/sessionCommands.ts index 4d7ea7a9f..8489ce52d 100644 --- a/src/renderer/src/features/workspace/commands/sessionCommands.ts +++ b/src/renderer/src/features/workspace/commands/sessionCommands.ts @@ -1,4 +1,5 @@ import type { CommandDef } from '@renderer/features/command-palette/types' +import { runSaveDebugBundleCommand } from '@renderer/features/debug/saveDebugBundle' export const sessionCommands: CommandDef[] = [ { @@ -240,6 +241,41 @@ export const sessionCommands: CommandDef[] = [ }), run: ({ ui }) => ui.toggleProxyDebugPanel(), }, + { + // Save Debug Logs — one-shot action (not a toggle). Snapshots the + // focused pane's state/feed-debug/proxy-semantic/HTML into a single + // timestamped folder under ~/.config/cc-shell/debug-bundles/ and + // copies the path to the clipboard. Purpose is dev-time + // diagnostics of cc-shell itself — the four debug panels read the + // same data live, this command preserves it for after-the-fact + // inspection. + // + // Requires a focused pane (any kind) — the bundle is pane-scoped. + // Wide keyword net because the user might remember "save", "dump", + // "export", "snapshot", or the name of any one panel. + id: 'save-debug-logs', + title: 'Save Debug Logs', + keywords: [ + 'save', + 'debug', + 'logs', + 'bundle', + 'dump', + 'export', + 'snapshot', + 'proxy', + 'feed', + 'html', + 'diagnostics', + ], + when: ({ workspace }) => Boolean(workspace.activeTab), + run: ({ workspace, ui }) => { + // closePalette immediately so the toast (which lands in the + // pane, not the palette) is visible right after trigger. + ui.closePalette() + void runSaveDebugBundleCommand(workspace) + }, + }, { id: 'toggle-html-debug-panel', title: 'HTML Debug Panel', From 6faf7eecd9988d84968340bc96e3bf19d3b7b940 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 23 Apr 2026 21:28:04 +0200 Subject: [PATCH 20/27] refactor(renderer): move App.tsx + main.tsx into app/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/renderer/index.html | 2 +- src/renderer/src/{ => app}/App.tsx | 4 ++-- src/renderer/src/{ => app}/main.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) rename src/renderer/src/{ => app}/App.tsx (99%) rename src/renderer/src/{ => app}/main.tsx (94%) diff --git a/src/renderer/index.html b/src/renderer/index.html index ef6bb8b7f..3c36a8247 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -11,6 +11,6 @@
- + diff --git a/src/renderer/src/App.tsx b/src/renderer/src/app/App.tsx similarity index 99% rename from src/renderer/src/App.tsx rename to src/renderer/src/app/App.tsx index c6264e979..1158fcc38 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -21,8 +21,8 @@ import { AppearanceMenu } from '@renderer/features/feed/AppearanceMenu' import { PathPickerModal } from '@renderer/features/path-picker/ui/PathPickerModal' import { TabBar } from '@renderer/workspace/tile-tree/TabBar' import { TileTree } from '@renderer/workspace/tile-tree/TileTree' -import { useAppStore } from '@renderer/state/hooks' -import { applyTheme } from '@renderer/state/settings/theme' +import { useAppStore } from '@renderer/app-state/hooks' +import { applyTheme } from '@renderer/app-state/settings/theme' import { useKeybinds } from '@renderer/workspace/tile-tree/useKeybinds' import { useWorkspace } from '@renderer/workspace/workspaceStore' diff --git a/src/renderer/src/main.tsx b/src/renderer/src/app/main.tsx similarity index 94% rename from src/renderer/src/main.tsx rename to src/renderer/src/app/main.tsx index 330e86575..d875745eb 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/app/main.tsx @@ -1,6 +1,6 @@ import React from 'react' import { createRoot } from 'react-dom/client' -import App from '@renderer/App' +import App from '@renderer/app/App' import { GlobalToastProvider } from '@renderer/ui/GlobalToast' import '@renderer/styles.css' import 'highlight.js/styles/github-dark.css' From f896b25a224f921374c3bfd9f843d8975efa9e81 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 23 Apr 2026 21:28:14 +0200 Subject: [PATCH 21/27] refactor(renderer): rename state/ -> app-state/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/renderer/src/app-state/hooks.ts | 1 + .../src/{state => app-state}/settings/persistence.ts | 2 +- .../src/{state => app-state}/settings/slice.ts | 8 ++++---- .../src/{state => app-state}/settings/theme.ts | 2 +- .../src/{state => app-state}/settings/types.ts | 0 src/renderer/src/{state => app-state}/store.ts | 10 +++++----- src/renderer/src/{state => app-state}/types.ts | 4 ++-- src/renderer/src/{state => app-state}/uiShell/slice.ts | 2 +- src/renderer/src/{state => app-state}/uiShell/types.ts | 0 .../src/{state => app-state}/workspace/slice.ts | 2 +- src/renderer/src/features/debug/ui/ProxyDebugPanel.tsx | 2 +- src/renderer/src/features/feed/AppearanceMenu.tsx | 2 +- src/renderer/src/features/feed/ui/rows/Block.tsx | 2 +- .../src/features/settings/lib/settingsRegistry.ts | 2 +- src/renderer/src/features/settings/ui/SettingsList.tsx | 2 +- src/renderer/src/features/settings/ui/SettingsPage.tsx | 2 +- .../src/features/workspace/ui/AgentActivityModal.tsx | 2 +- src/renderer/src/state/hooks.ts | 1 - src/renderer/src/workspace/hook/index.ts | 2 +- src/renderer/src/workspace/tile-tree/useKeybinds.ts | 2 +- testing/rendering/renderer/RenderingHarnessApp.tsx | 4 ++-- testing/rendering/renderer/components/Feed.tsx | 2 +- 22 files changed, 28 insertions(+), 28 deletions(-) create mode 100644 src/renderer/src/app-state/hooks.ts rename src/renderer/src/{state => app-state}/settings/persistence.ts (94%) rename src/renderer/src/{state => app-state}/settings/slice.ts (76%) rename src/renderer/src/{state => app-state}/settings/theme.ts (95%) rename src/renderer/src/{state => app-state}/settings/types.ts (100%) rename src/renderer/src/{state => app-state}/store.ts (83%) rename src/renderer/src/{state => app-state}/types.ts (94%) rename src/renderer/src/{state => app-state}/uiShell/slice.ts (98%) rename src/renderer/src/{state => app-state}/uiShell/types.ts (100%) rename src/renderer/src/{state => app-state}/workspace/slice.ts (96%) delete mode 100644 src/renderer/src/state/hooks.ts diff --git a/src/renderer/src/app-state/hooks.ts b/src/renderer/src/app-state/hooks.ts new file mode 100644 index 000000000..da3187505 --- /dev/null +++ b/src/renderer/src/app-state/hooks.ts @@ -0,0 +1 @@ +export { useAppStore } from '@renderer/app-state/store' diff --git a/src/renderer/src/state/settings/persistence.ts b/src/renderer/src/app-state/settings/persistence.ts similarity index 94% rename from src/renderer/src/state/settings/persistence.ts rename to src/renderer/src/app-state/settings/persistence.ts index 9c195c3b3..0fed5566d 100644 --- a/src/renderer/src/state/settings/persistence.ts +++ b/src/renderer/src/app-state/settings/persistence.ts @@ -1,4 +1,4 @@ -import { ACCENTS, DEFAULT_SETTINGS, THEME_MODES, type AccentId, type Settings } from '@renderer/state/settings/types' +import { ACCENTS, DEFAULT_SETTINGS, THEME_MODES, type AccentId, type Settings } from '@renderer/app-state/settings/types' const LEGACY_STORAGE_KEY = 'cc-shell:settings' diff --git a/src/renderer/src/state/settings/slice.ts b/src/renderer/src/app-state/settings/slice.ts similarity index 76% rename from src/renderer/src/state/settings/slice.ts rename to src/renderer/src/app-state/settings/slice.ts index 704d41e2f..7990cecc3 100644 --- a/src/renderer/src/state/settings/slice.ts +++ b/src/renderer/src/app-state/settings/slice.ts @@ -1,9 +1,9 @@ import type { StateCreator } from 'zustand' -import { loadInitialSettings } from '@renderer/state/settings/persistence' -import { applyTheme } from '@renderer/state/settings/theme' -import { DEFAULT_SETTINGS } from '@renderer/state/settings/types' -import type { AppStore, SettingsSlice } from '@renderer/state/types' +import { loadInitialSettings } from '@renderer/app-state/settings/persistence' +import { applyTheme } from '@renderer/app-state/settings/theme' +import { DEFAULT_SETTINGS } from '@renderer/app-state/settings/types' +import type { AppStore, SettingsSlice } from '@renderer/app-state/types' const initialSettings = loadInitialSettings() applyTheme(initialSettings) diff --git a/src/renderer/src/state/settings/theme.ts b/src/renderer/src/app-state/settings/theme.ts similarity index 95% rename from src/renderer/src/state/settings/theme.ts rename to src/renderer/src/app-state/settings/theme.ts index 3143ed403..6b6ac02c3 100644 --- a/src/renderer/src/state/settings/theme.ts +++ b/src/renderer/src/app-state/settings/theme.ts @@ -1,4 +1,4 @@ -import { ACCENTS, isDarkThemeMode, type Settings } from '@renderer/state/settings/types' +import { ACCENTS, isDarkThemeMode, type Settings } from '@renderer/app-state/settings/types' export function applyTheme(settings: Settings): void { const root = document.documentElement diff --git a/src/renderer/src/state/settings/types.ts b/src/renderer/src/app-state/settings/types.ts similarity index 100% rename from src/renderer/src/state/settings/types.ts rename to src/renderer/src/app-state/settings/types.ts diff --git a/src/renderer/src/state/store.ts b/src/renderer/src/app-state/store.ts similarity index 83% rename from src/renderer/src/state/store.ts rename to src/renderer/src/app-state/store.ts index b0d7f8d3e..0f8f253f3 100644 --- a/src/renderer/src/state/store.ts +++ b/src/renderer/src/app-state/store.ts @@ -1,11 +1,11 @@ import { create } from 'zustand' import { createJSONStorage, devtools, persist, subscribeWithSelector } from 'zustand/middleware' -import { createSettingsSlice } from '@renderer/state/settings/slice' -import { createUiShellSlice } from '@renderer/state/uiShell/slice' -import { createWorkspaceSlice } from '@renderer/state/workspace/slice' -import type { AppStore } from '@renderer/state/types' -import { DEFAULT_SETTINGS, THEME_MODES, type Settings } from '@renderer/state/settings/types' +import { createSettingsSlice } from '@renderer/app-state/settings/slice' +import { createUiShellSlice } from '@renderer/app-state/uiShell/slice' +import { createWorkspaceSlice } from '@renderer/app-state/workspace/slice' +import type { AppStore } from '@renderer/app-state/types' +import { DEFAULT_SETTINGS, THEME_MODES, type Settings } from '@renderer/app-state/settings/types' export const useAppStore = create()( devtools( diff --git a/src/renderer/src/state/types.ts b/src/renderer/src/app-state/types.ts similarity index 94% rename from src/renderer/src/state/types.ts rename to src/renderer/src/app-state/types.ts index da9b24c66..d76adcbf9 100644 --- a/src/renderer/src/state/types.ts +++ b/src/renderer/src/app-state/types.ts @@ -1,5 +1,5 @@ -import type { Settings } from '@renderer/state/settings/types' -import type { UiShellState } from '@renderer/state/uiShell/types' +import type { Settings } from '@renderer/app-state/settings/types' +import type { UiShellState } from '@renderer/app-state/uiShell/types' import type { SessionId, TabId } from '@renderer/workspace/types' import type { WorkspaceState } from '@renderer/workspace/types' import type { diff --git a/src/renderer/src/state/uiShell/slice.ts b/src/renderer/src/app-state/uiShell/slice.ts similarity index 98% rename from src/renderer/src/state/uiShell/slice.ts rename to src/renderer/src/app-state/uiShell/slice.ts index 6e755341e..1b70f4789 100644 --- a/src/renderer/src/state/uiShell/slice.ts +++ b/src/renderer/src/app-state/uiShell/slice.ts @@ -1,6 +1,6 @@ import type { StateCreator } from 'zustand' -import type { AppStore, UiShellSlice } from '@renderer/state/types' +import type { AppStore, UiShellSlice } from '@renderer/app-state/types' export const createUiShellSlice: StateCreator< AppStore, diff --git a/src/renderer/src/state/uiShell/types.ts b/src/renderer/src/app-state/uiShell/types.ts similarity index 100% rename from src/renderer/src/state/uiShell/types.ts rename to src/renderer/src/app-state/uiShell/types.ts diff --git a/src/renderer/src/state/workspace/slice.ts b/src/renderer/src/app-state/workspace/slice.ts similarity index 96% rename from src/renderer/src/state/workspace/slice.ts rename to src/renderer/src/app-state/workspace/slice.ts index 872cf2386..f11496527 100644 --- a/src/renderer/src/state/workspace/slice.ts +++ b/src/renderer/src/app-state/workspace/slice.ts @@ -1,6 +1,6 @@ import type { StateCreator } from 'zustand' -import type { AppStore, WorkspaceSlice } from '@renderer/state/types' +import type { AppStore, WorkspaceSlice } from '@renderer/app-state/types' import type { WorkspaceState } from '@renderer/workspace/types' import type { ReaderModeState, diff --git a/src/renderer/src/features/debug/ui/ProxyDebugPanel.tsx b/src/renderer/src/features/debug/ui/ProxyDebugPanel.tsx index 2332d7fd1..a06864bf9 100644 --- a/src/renderer/src/features/debug/ui/ProxyDebugPanel.tsx +++ b/src/renderer/src/features/debug/ui/ProxyDebugPanel.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import { useAppStore } from '@renderer/state/hooks' +import { useAppStore } from '@renderer/app-state/hooks' import { emptySemanticRuntime } from '@renderer/workspace/workspaceState' // ProxyDebugPanel — live inspector for the semantic stream and any diff --git a/src/renderer/src/features/feed/AppearanceMenu.tsx b/src/renderer/src/features/feed/AppearanceMenu.tsx index f923c338a..dc57d2a49 100644 --- a/src/renderer/src/features/feed/AppearanceMenu.tsx +++ b/src/renderer/src/features/feed/AppearanceMenu.tsx @@ -7,7 +7,7 @@ import { THEME_MODES, isDarkThemeMode, type ThemeMode, -} from '@renderer/state/settings/types' +} from '@renderer/app-state/settings/types' type Props = { settings: Settings diff --git a/src/renderer/src/features/feed/ui/rows/Block.tsx b/src/renderer/src/features/feed/ui/rows/Block.tsx index 65f304a9c..f3ca3a745 100644 --- a/src/renderer/src/features/feed/ui/rows/Block.tsx +++ b/src/renderer/src/features/feed/ui/rows/Block.tsx @@ -17,7 +17,7 @@ import type { } from '@shared/types/transcript' import { detectGitIntent } from '@shared/git/gitDetect' -import { useAppStore } from '@renderer/state/hooks' +import { useAppStore } from '@renderer/app-state/hooks' import { GitCardRow } from '@renderer/features/git/ui/GitRows' import { extractToolCommand, toolResultText } from '@renderer/features/feed/lib/helpers' diff --git a/src/renderer/src/features/settings/lib/settingsRegistry.ts b/src/renderer/src/features/settings/lib/settingsRegistry.ts index cec7bf601..7c43b6e65 100644 --- a/src/renderer/src/features/settings/lib/settingsRegistry.ts +++ b/src/renderer/src/features/settings/lib/settingsRegistry.ts @@ -4,7 +4,7 @@ import { type AccentId, type Settings, type ThemeMode, -} from '@renderer/state/settings/types' +} from '@renderer/app-state/settings/types' import type { Workspace } from '@renderer/workspace/workspaceStore' import { SETTING_CATEGORIES, type SettingCategoryId } from '@renderer/features/settings/lib/settingsCategories' diff --git a/src/renderer/src/features/settings/ui/SettingsList.tsx b/src/renderer/src/features/settings/ui/SettingsList.tsx index da7bb207b..c54209ed9 100644 --- a/src/renderer/src/features/settings/ui/SettingsList.tsx +++ b/src/renderer/src/features/settings/ui/SettingsList.tsx @@ -1,4 +1,4 @@ -import type { Settings } from '@renderer/state/settings/types' +import type { Settings } from '@renderer/app-state/settings/types' import type { SettingActionContext, SettingDefinition, diff --git a/src/renderer/src/features/settings/ui/SettingsPage.tsx b/src/renderer/src/features/settings/ui/SettingsPage.tsx index 4d071da73..454f76826 100644 --- a/src/renderer/src/features/settings/ui/SettingsPage.tsx +++ b/src/renderer/src/features/settings/ui/SettingsPage.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react' -import type { Settings } from '@renderer/state/settings/types' +import type { Settings } from '@renderer/app-state/settings/types' import type { Workspace } from '@renderer/workspace/workspaceStore' import { SETTING_CATEGORIES, type SettingCategoryId } from '@renderer/features/settings/lib/settingsCategories' import { getSettingsRegistry, matchesSettingQuery } from '@renderer/features/settings/lib/settingsRegistry' diff --git a/src/renderer/src/features/workspace/ui/AgentActivityModal.tsx b/src/renderer/src/features/workspace/ui/AgentActivityModal.tsx index c3799e335..96c25d039 100644 --- a/src/renderer/src/features/workspace/ui/AgentActivityModal.tsx +++ b/src/renderer/src/features/workspace/ui/AgentActivityModal.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useAppStore } from '@renderer/state/hooks' +import { useAppStore } from '@renderer/app-state/hooks' import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps' import type { SessionId, Tab } from '@renderer/workspace/types' import type { Workspace } from '@renderer/workspace/workspaceStore' diff --git a/src/renderer/src/state/hooks.ts b/src/renderer/src/state/hooks.ts deleted file mode 100644 index d347158c7..000000000 --- a/src/renderer/src/state/hooks.ts +++ /dev/null @@ -1 +0,0 @@ -export { useAppStore } from '@renderer/state/store' diff --git a/src/renderer/src/workspace/hook/index.ts b/src/renderer/src/workspace/hook/index.ts index 7f0a6bf35..2d1450ef8 100644 --- a/src/renderer/src/workspace/hook/index.ts +++ b/src/renderer/src/workspace/hook/index.ts @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react' -import { useAppStore } from '@renderer/state/hooks' +import { useAppStore } from '@renderer/app-state/hooks' import { useGlobalToast } from '@renderer/ui/GlobalToast' import type { WorkspaceHookContext } from '@renderer/workspace/hook/context' diff --git a/src/renderer/src/workspace/tile-tree/useKeybinds.ts b/src/renderer/src/workspace/tile-tree/useKeybinds.ts index 9781d1a68..ea21f2082 100644 --- a/src/renderer/src/workspace/tile-tree/useKeybinds.ts +++ b/src/renderer/src/workspace/tile-tree/useKeybinds.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react' -import { useAppStore } from '@renderer/state/hooks' +import { useAppStore } from '@renderer/app-state/hooks' import type { Workspace } from '@renderer/workspace/workspaceStore' // Keybinds: global window-level listeners. The handler is attached to diff --git a/testing/rendering/renderer/RenderingHarnessApp.tsx b/testing/rendering/renderer/RenderingHarnessApp.tsx index 2908327bc..1f225cd30 100644 --- a/testing/rendering/renderer/RenderingHarnessApp.tsx +++ b/testing/rendering/renderer/RenderingHarnessApp.tsx @@ -28,8 +28,8 @@ import { type ToolUseBlock, } from '@shared/types/transcript' import { extractAssistantInProgress } from '@shared/parsers/extractAssistant' -import { applyTheme } from '@renderer/state/settings/theme' -import { DEFAULT_SETTINGS } from '@renderer/state/settings/types' +import { applyTheme } from '@renderer/app-state/settings/theme' +import { DEFAULT_SETTINGS } from '@renderer/app-state/settings/types' // Rendering Debug Harness. // diff --git a/testing/rendering/renderer/components/Feed.tsx b/testing/rendering/renderer/components/Feed.tsx index 33cd286fc..4734df564 100644 --- a/testing/rendering/renderer/components/Feed.tsx +++ b/testing/rendering/renderer/components/Feed.tsx @@ -37,7 +37,7 @@ import { import { CodeBlock } from './CodeBlock' import { detectGitIntent } from '@shared/git/gitDetect' import { GitCardRow } from './GitRows' -import { useAppStore } from '@renderer/state/hooks' +import { useAppStore } from '@renderer/app-state/hooks' import { parseSemanticTodos, type SemanticLiveTurn, From 4b3772c8dd7e8c55fa5a8b1cea0119a59a94d7c2 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 23 Apr 2026 21:32:38 +0200 Subject: [PATCH 22/27] refactor(tile-leaf): extract pure helpers into TileLeaf/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/workspace/tile-tree/TileLeaf.tsx | 226 ++---------------- .../tile-tree/TileLeaf/claudeImages.ts | 135 +++++++++++ .../tile-tree/TileLeaf/claudePaste.ts | 75 ++++++ .../workspace/tile-tree/TileLeaf/labels.ts | 32 +++ 4 files changed, 267 insertions(+), 201 deletions(-) create mode 100644 src/renderer/src/workspace/tile-tree/TileLeaf/claudeImages.ts create mode 100644 src/renderer/src/workspace/tile-tree/TileLeaf/claudePaste.ts create mode 100644 src/renderer/src/workspace/tile-tree/TileLeaf/labels.ts diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf.tsx b/src/renderer/src/workspace/tile-tree/TileLeaf.tsx index 51ba777a7..964488f95 100644 --- a/src/renderer/src/workspace/tile-tree/TileLeaf.tsx +++ b/src/renderer/src/workspace/tile-tree/TileLeaf.tsx @@ -14,191 +14,32 @@ import { selectMergedEntries, shouldShowSemanticStreaming, } from '@renderer/workspace/mergedEntries' -import type { SessionId, SessionKind } from '@renderer/workspace/types' +import type { SessionId } from '@renderer/workspace/types' import type { ClaudeDraftImage } from '@renderer/workspace/workspaceState' +import { + CLAUDE_PASTE_THRESHOLD, + CLAUDE_PASTE_SUBMIT_DELAY_MS, + CLAUDE_IMAGE_PATH_SUBMIT_DELAY_MS, + buildClaudeImagePastePayload, + sendBracketedPasteThenSubmit, + sendClaudeDraftText, +} from '@renderer/workspace/tile-tree/TileLeaf/claudePaste' +import { + SUPPORTED_CLAUDE_IMAGE_FORMATS_TEXT, + exceedsClaudeImageSizeLimit, + filesToDraftImages, + isSupportedClaudeImageMediaType, + parseImagesFromHtml, + readImagesFromClipboard, +} from '@renderer/workspace/tile-tree/TileLeaf/claudeImages' +import { providerLabel, shortenCwd } from '@renderer/workspace/tile-tree/TileLeaf/labels' -// Claude's TUI has its own paste-state machine. Large input and bracketed -// paste do NOT go straight from "bytes arrived" to "submit immediately" — -// Claude first buffers the paste, may collapse it into a `[Pasted text #N]` -// placeholder in the visible composer, and only later expands it back out -// during submit. If we send the paste payload AND the trailing Enter in the -// same PTY write, that Enter can land while Claude still considers the paste -// "in flight". The result is the exact user-facing bug we saw: -// -// 1. the pasted prompt appears in Claude's composer, -// 2. Claude flips into a "working" looking state, -// 3. but no real turn starts, -// 4. and the NEXT Enter finally submits the already-buffered prompt. -// -// We fix this at the shell boundary instead of forking Claude's paste logic: -// for Claude only, any prompt that is likely to be treated as a paste gets -// delivered in TWO phases — first the payload, then (after Claude's own paste -// debounce window has elapsed) the submit key. This keeps us aligned with -// Claude's existing placeholder/expansion model instead of fighting it. -// -// Why these numbers: -// - 800 chars matches Claude's current PASTE_THRESHOLD upstream. -// - 125ms is slightly above Claude's 100ms paste completion timeout. -// Going lower risks reintroducing the race; going much higher makes -// submit feel laggy on every long paste. -const CLAUDE_PASTE_THRESHOLD = 800 -const CLAUDE_PASTE_SUBMIT_DELAY_MS = 125 -const CLAUDE_IMAGE_PATH_SUBMIT_DELAY_MS = 750 -const MAX_CLAUDE_IMAGE_BYTES = 5 * 1024 * 1024 -const SUPPORTED_CLAUDE_IMAGE_MEDIA_TYPES = new Set([ - 'image/png', - 'image/jpeg', - 'image/gif', - 'image/webp', -]) -const SUPPORTED_CLAUDE_IMAGE_FORMATS_TEXT = 'PNG, JPEG, GIF, WebP' - -function isSupportedClaudeImageMediaType(mediaType: string): boolean { - return SUPPORTED_CLAUDE_IMAGE_MEDIA_TYPES.has(mediaType.toLowerCase()) -} - -function exceedsClaudeImageSizeLimit(image: ClaudeDraftImage): boolean { - return estimateBase64DecodedBytes(image.base64Data) > MAX_CLAUDE_IMAGE_BYTES -} - -function wait(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)) -} - -function fileToDataUrl(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onerror = () => reject(reader.error ?? new Error('failed to read file')) - reader.onload = () => { - if (typeof reader.result === 'string') resolve(reader.result) - else reject(new Error('file reader returned non-string result')) - } - reader.readAsDataURL(file) - }) -} - -function parseDataUrl(dataUrl: string): { mediaType: string; base64Data: string } { - const match = /^data:([^;]+);base64,(.+)$/.exec(dataUrl) - if (!match) { - throw new Error('unsupported image data url') - } - return { - mediaType: match[1] ?? 'image/png', - base64Data: match[2] ?? '', - } -} - -function estimateBase64DecodedBytes(base64Data: string): number { - const normalized = base64Data.trim() - if (normalized.length === 0) return 0 - const padding = normalized.endsWith('==') ? 2 : normalized.endsWith('=') ? 1 : 0 - return Math.floor((normalized.length * 3) / 4) - padding -} - -function parseImagesFromHtml(html: string): ClaudeDraftImage[] { - if (!html.trim()) return [] - // Some browsers copy web images as an HTML fragment containing an - // embedded data URL rather than exposing a File in clipboardData. - // We only parse this into a detached Document and read `img.src` — - // the HTML is never injected into the live DOM. - const doc = new DOMParser().parseFromString(html, 'text/html') - const images: ClaudeDraftImage[] = [] - for (const img of Array.from(doc.images)) { - const src = img.getAttribute('src')?.trim() ?? '' - if (!src.startsWith('data:image/')) continue - try { - const { mediaType, base64Data } = parseDataUrl(src) - images.push({ - id: crypto.randomUUID(), - mediaType, - base64Data, - previewUrl: src, - filename: img.getAttribute('alt')?.trim() || 'Pasted image', - }) - } catch { - // Ignore malformed HTML image sources and keep scanning. - } - } - return images -} - -async function filesToDraftImages(files: File[]): Promise { - return Promise.all( - files.map(async file => { - const previewUrl = await fileToDataUrl(file) - const { mediaType, base64Data } = parseDataUrl(previewUrl) - return { - id: crypto.randomUUID(), - mediaType, - base64Data, - previewUrl, - filename: file.name || 'Pasted image', - } satisfies ClaudeDraftImage - }), - ) -} - -async function readImagesFromClipboard(): Promise { - if (!navigator.clipboard?.read) return [] - const items = await navigator.clipboard.read() - const images: ClaudeDraftImage[] = [] - for (const item of items) { - const imageType = item.types.find(type => type.startsWith('image/')) - if (!imageType) continue - const blob = await item.getType(imageType) - const previewUrl = await fileToDataUrl(new File([blob], 'Pasted image', { type: imageType })) - const { mediaType, base64Data } = parseDataUrl(previewUrl) - images.push({ - id: crypto.randomUUID(), - mediaType, - base64Data, - previewUrl, - filename: 'Pasted image', - }) - } - return images -} - -function buildClaudeImagePastePayload(text: string, imagePaths: string[]): string { - if (imagePaths.length === 0) return text - if (text.trim().length === 0) return imagePaths.join('\n') - return `${imagePaths.join('\n')}\n${text}` -} - -async function sendBracketedPaste( - send: (data: string) => Promise, - payload: string, -): Promise { - await send(`\x1b[200~${payload}\x1b[201~`) -} - -async function sendBracketedPasteThenSubmit( - send: (data: string) => Promise, - payload: string, - delayMs = 0, -): Promise { - if (delayMs <= 0) { - await send(`\x1b[200~${payload}\x1b[201~\r`) - return - } - await sendBracketedPaste(send, payload) - await wait(delayMs) - await send('\r') -} - -async function sendClaudeDraftText( - send: (data: string) => Promise, - text: string, -): Promise { - if (text.length === 0) return - const isPasteLike = text.includes('\n') || text.length > CLAUDE_PASTE_THRESHOLD - if (isPasteLike) { - await sendBracketedPaste(send, text) - await wait(CLAUDE_PASTE_SUBMIT_DELAY_MS) - return - } - await send(text) -} +// Claude paste-state-machine constants + helpers moved to +// ./TileLeaf/claudePaste.ts. Image helpers moved to +// ./TileLeaf/claudeImages.ts. Label helpers moved to +// ./TileLeaf/labels.ts. See those files for the full rationale on +// the paste debounce, the image size/format gates, and the pane +// header shortening. // TileLeaf — one pane. A "mini cc-shell" self-contained in a box: // header strip (project dir + status) @@ -1229,21 +1070,4 @@ export function TileLeaf({ ) } -function shortenCwd(cwd: string | null): string { - if (!cwd) return '—' - const parts = cwd.split('/').filter(Boolean) - if (parts.length <= 2) return '/' + parts.join('/') - return '…/' + parts.slice(-2).join('/') -} - -function providerLabel(kind: SessionKind | undefined): string { - switch (kind) { - case 'codex': - return 'Codex' - case 'terminal': - return 'Terminal' - case 'claude': - default: - return 'Claude Code' - } -} +// shortenCwd + providerLabel moved to ./TileLeaf/labels.ts. diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf/claudeImages.ts b/src/renderer/src/workspace/tile-tree/TileLeaf/claudeImages.ts new file mode 100644 index 000000000..c405a938c --- /dev/null +++ b/src/renderer/src/workspace/tile-tree/TileLeaf/claudeImages.ts @@ -0,0 +1,135 @@ +import type { ClaudeDraftImage } from '@renderer/workspace/workspaceState' + +// Helpers for turning pasted / dropped / clipboard images into the +// `ClaudeDraftImage` payload shape the workspace runtime stores. +// +// Three ingress paths: +// 1. Files in `clipboardData.items` (the common case for a +// copy-image-from-app → paste flow). +// 2. An `` embedded in an HTML fragment +// (some browsers copy web images as HTML + data URL rather than +// a real File). +// 3. `navigator.clipboard.read()` for cases (1) and (2) when +// `clipboardData` came up empty (Electron picks a clipboard +// representation per OS and some combinations only surface +// through the async API). +// +// All three return the same `ClaudeDraftImage[]` so the caller can +// concat + dedupe uniformly. + +export const MAX_CLAUDE_IMAGE_BYTES = 5 * 1024 * 1024 + +export const SUPPORTED_CLAUDE_IMAGE_MEDIA_TYPES = new Set([ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', +]) + +export const SUPPORTED_CLAUDE_IMAGE_FORMATS_TEXT = 'PNG, JPEG, GIF, WebP' + +export function isSupportedClaudeImageMediaType(mediaType: string): boolean { + return SUPPORTED_CLAUDE_IMAGE_MEDIA_TYPES.has(mediaType.toLowerCase()) +} + +export function exceedsClaudeImageSizeLimit(image: ClaudeDraftImage): boolean { + return estimateBase64DecodedBytes(image.base64Data) > MAX_CLAUDE_IMAGE_BYTES +} + +export function fileToDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onerror = () => reject(reader.error ?? new Error('failed to read file')) + reader.onload = () => { + if (typeof reader.result === 'string') resolve(reader.result) + else reject(new Error('file reader returned non-string result')) + } + reader.readAsDataURL(file) + }) +} + +export function parseDataUrl(dataUrl: string): { mediaType: string; base64Data: string } { + const match = /^data:([^;]+);base64,(.+)$/.exec(dataUrl) + if (!match) { + throw new Error('unsupported image data url') + } + return { + mediaType: match[1] ?? 'image/png', + base64Data: match[2] ?? '', + } +} + +// Rough byte-count estimate: 4 base64 chars encode 3 bytes, minus +// 1 byte per `=` pad char. We only use this for the size-limit gate, +// so approximate is fine — a 5 MB cap enforced at ±16 bytes is still +// effectively a 5 MB cap. +export function estimateBase64DecodedBytes(base64Data: string): number { + const normalized = base64Data.trim() + if (normalized.length === 0) return 0 + const padding = normalized.endsWith('==') ? 2 : normalized.endsWith('=') ? 1 : 0 + return Math.floor((normalized.length * 3) / 4) - padding +} + +export function parseImagesFromHtml(html: string): ClaudeDraftImage[] { + if (!html.trim()) return [] + // Some browsers copy web images as an HTML fragment containing an + // embedded data URL rather than exposing a File in clipboardData. + // We only parse this into a detached Document and read `img.src` — + // the HTML is never injected into the live DOM. + const doc = new DOMParser().parseFromString(html, 'text/html') + const images: ClaudeDraftImage[] = [] + for (const img of Array.from(doc.images)) { + const src = img.getAttribute('src')?.trim() ?? '' + if (!src.startsWith('data:image/')) continue + try { + const { mediaType, base64Data } = parseDataUrl(src) + images.push({ + id: crypto.randomUUID(), + mediaType, + base64Data, + previewUrl: src, + filename: img.getAttribute('alt')?.trim() || 'Pasted image', + }) + } catch { + // Ignore malformed HTML image sources and keep scanning. + } + } + return images +} + +export async function filesToDraftImages(files: File[]): Promise { + return Promise.all( + files.map(async file => { + const previewUrl = await fileToDataUrl(file) + const { mediaType, base64Data } = parseDataUrl(previewUrl) + return { + id: crypto.randomUUID(), + mediaType, + base64Data, + previewUrl, + filename: file.name || 'Pasted image', + } satisfies ClaudeDraftImage + }), + ) +} + +export async function readImagesFromClipboard(): Promise { + if (!navigator.clipboard?.read) return [] + const items = await navigator.clipboard.read() + const images: ClaudeDraftImage[] = [] + for (const item of items) { + const imageType = item.types.find(type => type.startsWith('image/')) + if (!imageType) continue + const blob = await item.getType(imageType) + const previewUrl = await fileToDataUrl(new File([blob], 'Pasted image', { type: imageType })) + const { mediaType, base64Data } = parseDataUrl(previewUrl) + images.push({ + id: crypto.randomUUID(), + mediaType, + base64Data, + previewUrl, + filename: 'Pasted image', + }) + } + return images +} diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf/claudePaste.ts b/src/renderer/src/workspace/tile-tree/TileLeaf/claudePaste.ts new file mode 100644 index 000000000..9d12992f9 --- /dev/null +++ b/src/renderer/src/workspace/tile-tree/TileLeaf/claudePaste.ts @@ -0,0 +1,75 @@ +// Claude's TUI has its own paste-state machine. Large input and +// bracketed paste do NOT go straight from "bytes arrived" to "submit +// immediately" — Claude first buffers the paste, may collapse it into +// a `[Pasted text #N]` placeholder in the visible composer, and only +// later expands it back out during submit. If we send the paste +// payload AND the trailing Enter in the same PTY write, that Enter +// can land while Claude still considers the paste "in flight". The +// result is the exact user-facing bug we saw: +// +// 1. the pasted prompt appears in Claude's composer, +// 2. Claude flips into a "working" looking state, +// 3. but no real turn starts, +// 4. and the NEXT Enter finally submits the already-buffered prompt. +// +// We fix this at the shell boundary instead of forking Claude's paste +// logic: for Claude only, any prompt that is likely to be treated as +// a paste gets delivered in TWO phases — first the payload, then +// (after Claude's own paste debounce window has elapsed) the submit +// key. This keeps us aligned with Claude's existing placeholder / +// expansion model instead of fighting it. +// +// Why these numbers: +// - 800 chars matches Claude's current PASTE_THRESHOLD upstream. +// - 125ms is slightly above Claude's 100ms paste completion timeout. +// Going lower risks reintroducing the race; going much higher +// makes submit feel laggy on every long paste. + +export const CLAUDE_PASTE_THRESHOLD = 800 +export const CLAUDE_PASTE_SUBMIT_DELAY_MS = 125 +export const CLAUDE_IMAGE_PATH_SUBMIT_DELAY_MS = 750 + +function wait(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +export async function sendBracketedPaste( + send: (data: string) => Promise, + payload: string, +): Promise { + await send(`\x1b[200~${payload}\x1b[201~`) +} + +export async function sendBracketedPasteThenSubmit( + send: (data: string) => Promise, + payload: string, + delayMs = 0, +): Promise { + if (delayMs <= 0) { + await send(`\x1b[200~${payload}\x1b[201~\r`) + return + } + await sendBracketedPaste(send, payload) + await wait(delayMs) + await send('\r') +} + +export async function sendClaudeDraftText( + send: (data: string) => Promise, + text: string, +): Promise { + if (text.length === 0) return + const isPasteLike = text.includes('\n') || text.length > CLAUDE_PASTE_THRESHOLD + if (isPasteLike) { + await sendBracketedPaste(send, text) + await wait(CLAUDE_PASTE_SUBMIT_DELAY_MS) + return + } + await send(text) +} + +export function buildClaudeImagePastePayload(text: string, imagePaths: string[]): string { + if (imagePaths.length === 0) return text + if (text.trim().length === 0) return imagePaths.join('\n') + return `${imagePaths.join('\n')}\n${text}` +} diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf/labels.ts b/src/renderer/src/workspace/tile-tree/TileLeaf/labels.ts new file mode 100644 index 000000000..09f6794a1 --- /dev/null +++ b/src/renderer/src/workspace/tile-tree/TileLeaf/labels.ts @@ -0,0 +1,32 @@ +import type { SessionKind } from '@renderer/workspace/types' + +// Pane-header label helpers. Pure string transforms — split out so +// TileLeaf's render body doesn't carry two ad-hoc utility funcs at +// the bottom. + +/** Shorten a cwd to at most its last two path segments for the pane + * header strip. The header is a 10px font on a narrow strip and + * long absolute paths make the session indistinguishable from its + * siblings when three panes are tiled across the same row. */ +export function shortenCwd(cwd: string | null): string { + if (!cwd) return '—' + const parts = cwd.split('/').filter(Boolean) + if (parts.length <= 2) return '/' + parts.join('/') + return '…/' + parts.slice(-2).join('/') +} + +/** Human label for the provider kind, shown next to the scroll + * position badge above the composer. Unknown kinds default to + * 'Claude Code' — historically every session was Claude, so a + * missing kind is treated as claude for back-compat. */ +export function providerLabel(kind: SessionKind | undefined): string { + switch (kind) { + case 'codex': + return 'Codex' + case 'terminal': + return 'Terminal' + case 'claude': + default: + return 'Claude Code' + } +} From 3606bbc183cc106d849b78e4787667bcab9d316f Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 23 Apr 2026 21:35:49 +0200 Subject: [PATCH 23/27] refactor(tile-leaf): extract UI subcomponents into TileLeaf/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/workspace/tile-tree/TileLeaf.tsx | 150 +++--------------- .../tile-tree/TileLeaf/CompactionStrip.tsx | 41 +++++ .../tile-tree/TileLeaf/PaneHeader.tsx | 34 ++++ .../tile-tree/TileLeaf/PaneToast.tsx | 20 +++ .../tile-tree/TileLeaf/QueueStrip.tsx | 54 +++++++ .../tile-tree/TileLeaf/ScrollIndicator.tsx | 47 ++++++ 6 files changed, 215 insertions(+), 131 deletions(-) create mode 100644 src/renderer/src/workspace/tile-tree/TileLeaf/CompactionStrip.tsx create mode 100644 src/renderer/src/workspace/tile-tree/TileLeaf/PaneHeader.tsx create mode 100644 src/renderer/src/workspace/tile-tree/TileLeaf/PaneToast.tsx create mode 100644 src/renderer/src/workspace/tile-tree/TileLeaf/QueueStrip.tsx create mode 100644 src/renderer/src/workspace/tile-tree/TileLeaf/ScrollIndicator.tsx diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf.tsx b/src/renderer/src/workspace/tile-tree/TileLeaf.tsx index 964488f95..3cc12bb15 100644 --- a/src/renderer/src/workspace/tile-tree/TileLeaf.tsx +++ b/src/renderer/src/workspace/tile-tree/TileLeaf.tsx @@ -32,7 +32,11 @@ import { parseImagesFromHtml, readImagesFromClipboard, } from '@renderer/workspace/tile-tree/TileLeaf/claudeImages' -import { providerLabel, shortenCwd } from '@renderer/workspace/tile-tree/TileLeaf/labels' +import { PaneHeader } from '@renderer/workspace/tile-tree/TileLeaf/PaneHeader' +import { QueueStrip } from '@renderer/workspace/tile-tree/TileLeaf/QueueStrip' +import { CompactionStrip } from '@renderer/workspace/tile-tree/TileLeaf/CompactionStrip' +import { PaneToast } from '@renderer/workspace/tile-tree/TileLeaf/PaneToast' +import { ScrollIndicator } from '@renderer/workspace/tile-tree/TileLeaf/ScrollIndicator' // Claude paste-state-machine constants + helpers moved to // ./TileLeaf/claudePaste.ts. Image helpers moved to @@ -741,23 +745,11 @@ export function TileLeaf({ `} onMouseDown={onFocusRequest} > - {/* Pane header: compact status strip. - In status mode, working panes paint with the theme accent; - idle/exited panes get no fill — the absence of color is the - signal, so a glance across the grid highlights only the - panes that still want attention. Previous design used - green/red, but red read as "error" for merely idle panes. */} -
- - {shortenCwd(runtime.projectDir)} - -
+ {/* Feed — overflow-auto lives inside Feed itself so it can own its own scroll listener for the sticky-bottom logic @@ -835,47 +827,7 @@ export function TileLeaf({ />
- {/* Pending queue strip. Renders only when CC's internal message - queue has items — i.e. the user submitted prompts while CC - was still generating a previous turn. Lives between Feed and - composer so it sits in the natural "about to happen" region - of the screen, and so the user can see their queued text - without it getting mixed into the feed proper (where it - would show as either phantom future user rows or as real - rows that then duplicate themselves when the actual user - entry materializes in the transcript). - - Feature-gated on queuedMessages.length so the strip is - zero-DOM when nothing is queued — no layout shift for the - common path. */} - {runtime.queuedMessages.length > 0 && ( -
-
- {runtime.queuedMessages.length} queued -
-
    - {runtime.queuedMessages.map(q => ( -
  • - - - {q.content} - -
  • - ))} -
-
- )} + {/* Codex approval prompt — rendered inline in the pane, matching how Codex's TUI draws it. Sits between feed and composer. */} @@ -889,80 +841,16 @@ export function TileLeaf({ onSend={send} /> - {runtime.pendingCompaction && runtime.pendingCompaction.phase !== 'done' && ( -
-
- {runtime.pendingCompaction.phase === 'error' - ? 'Compaction failed' - : 'Compacting conversation'} -
- {((runtime.pendingCompaction.phase === 'error' - ? runtime.pendingCompaction.errorText - : runtime.pendingCompaction.statusText)) && ( -
- {runtime.pendingCompaction.phase === 'error' - ? runtime.pendingCompaction.errorText - : runtime.pendingCompaction.statusText} -
- )} -
- )} + - {/* Pane toast — transient single-slot feedback (e.g. "Copied to clipboard"). - Renders above the composer so it's contextually tied to this pane, - not floating over the feed content. Auto-dismissed by the store - timeout; we just render when non-null. */} - {runtime.paneToast && ( -
- - {runtime.paneToast} - -
- )} + - {/* Scroll position indicator — sits just above the composer, - right-aligned. Shows which entry you're looking at out of - the total. Feed emits `fraction = 1 - scrollTop/maxScroll`, - so fraction=0 at the bottom (newest entry visible) and - fraction=1 at the top (oldest). We invert before display so - the badge reads natural-order: latest = N/N, oldest = 1/N. - Floor at 1 so the badge never reads 0/N — even at the - extreme bottom there's still an entry on screen. */} - {runtime.entries.length > 0 && ( -
-
- - {providerLabel(workspace.state.sessions[sessionId]?.kind)} - - {runtime.tailMode && ( - - TAIL - - )} - - {Math.max( - 1, - Math.ceil((1 - scrollFraction) * runtime.entries.length), - )}/{runtime.entries.length} - -
-
- )} + {/* Composer */}
diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf/CompactionStrip.tsx b/src/renderer/src/workspace/tile-tree/TileLeaf/CompactionStrip.tsx new file mode 100644 index 000000000..684d8c9a4 --- /dev/null +++ b/src/renderer/src/workspace/tile-tree/TileLeaf/CompactionStrip.tsx @@ -0,0 +1,41 @@ +// Compaction progress strip. Renders the live status of a /compact +// run — either the running status text (CC's own progress message +// reflected from the TUI) or an error message. 'done' is the +// terminal state the store clears on the next event; if we ever see +// `pendingCompaction.phase === 'done'` we render nothing, same as +// the null case. +export function CompactionStrip({ + pendingCompaction, +}: { + pendingCompaction: + | { + phase: 'running' | 'error' | 'done' + statusText?: string + errorText?: string + } + | null +}) { + if (!pendingCompaction || pendingCompaction.phase === 'done') return null + const isError = pendingCompaction.phase === 'error' + const message = isError + ? pendingCompaction.errorText + : pendingCompaction.statusText + return ( +
+
+ {isError ? 'Compaction failed' : 'Compacting conversation'} +
+ {message && ( +
+ {message} +
+ )} +
+ ) +} diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf/PaneHeader.tsx b/src/renderer/src/workspace/tile-tree/TileLeaf/PaneHeader.tsx new file mode 100644 index 000000000..c08240976 --- /dev/null +++ b/src/renderer/src/workspace/tile-tree/TileLeaf/PaneHeader.tsx @@ -0,0 +1,34 @@ +import { shortenCwd } from '@renderer/workspace/tile-tree/TileLeaf/labels' + +// Pane header: compact status strip. +// +// In status mode, working panes paint with the theme accent; +// idle/exited panes get no fill — the absence of color is the +// signal, so a glance across the grid highlights only the +// panes that still want attention. Previous design used +// green/red, but red read as "error" for merely idle panes. +export function PaneHeader({ + projectDir, + statusMode, + isSessionLive, +}: { + projectDir: string | null + statusMode: boolean + isSessionLive: boolean +}) { + return ( +
+ + {shortenCwd(projectDir)} + +
+ ) +} diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf/PaneToast.tsx b/src/renderer/src/workspace/tile-tree/TileLeaf/PaneToast.tsx new file mode 100644 index 000000000..5ae978d5f --- /dev/null +++ b/src/renderer/src/workspace/tile-tree/TileLeaf/PaneToast.tsx @@ -0,0 +1,20 @@ +// Pane toast — transient single-slot feedback (e.g. "Copied to +// clipboard"). Renders above the composer so it's contextually tied +// to this pane, not floating over the feed content. Auto-dismissed +// by the store timeout; we just render when non-null. +// +// The `toast-enter` class is a keyframed fade+slide-in animation +// declared in styles.css; it runs once per fresh toast text. +// Because the component is gated on truthy `message`, React +// unmounts+remounts the node when the message flips from null → +// value → null, which restarts the animation cleanly. +export function PaneToast({ message }: { message: string | null }) { + if (!message) return null + return ( +
+ + {message} + +
+ ) +} diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf/QueueStrip.tsx b/src/renderer/src/workspace/tile-tree/TileLeaf/QueueStrip.tsx new file mode 100644 index 000000000..9f3afb7cd --- /dev/null +++ b/src/renderer/src/workspace/tile-tree/TileLeaf/QueueStrip.tsx @@ -0,0 +1,54 @@ +import type { QueuedMessage } from '@renderer/workspace/workspaceStore' + +// Pending queue strip. Renders only when CC's internal message +// queue has items — i.e. the user submitted prompts while CC +// was still generating a previous turn. Lives between Feed and +// composer so it sits in the natural "about to happen" region +// of the screen, and so the user can see their queued text +// without it getting mixed into the feed proper (where it +// would show as either phantom future user rows or as real +// rows that then duplicate themselves when the actual user +// entry materializes in the transcript). +// +// Feature-gated on queuedMessages.length so the strip is +// zero-DOM when nothing is queued — no layout shift for the +// common path. The caller does the length check before +// rendering this component so early-return here would be +// redundant; we still guard because QueueStrip might be called +// directly by a future surface (debug view, storybook) where +// the caller forgets. +export function QueueStrip({ + queuedMessages, +}: { + queuedMessages: QueuedMessage[] +}) { + if (queuedMessages.length === 0) return null + return ( +
+
+ {queuedMessages.length} queued +
+
    + {queuedMessages.map(q => ( +
  • + + + {q.content} + +
  • + ))} +
+
+ ) +} diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf/ScrollIndicator.tsx b/src/renderer/src/workspace/tile-tree/TileLeaf/ScrollIndicator.tsx new file mode 100644 index 000000000..8b8bc87c4 --- /dev/null +++ b/src/renderer/src/workspace/tile-tree/TileLeaf/ScrollIndicator.tsx @@ -0,0 +1,47 @@ +import { providerLabel } from '@renderer/workspace/tile-tree/TileLeaf/labels' +import type { SessionKind } from '@renderer/workspace/types' + +// Scroll position indicator — sits just above the composer, +// right-aligned. Shows which entry you're looking at out of the +// total. Feed emits `fraction = 1 - scrollTop/maxScroll`, so +// fraction=0 at the bottom (newest entry visible) and fraction=1 +// at the top (oldest). We invert before display so the badge reads +// natural-order: latest = N/N, oldest = 1/N. Floor at 1 so the +// badge never reads 0/N — even at the extreme bottom there's still +// an entry on screen. +// +// Provider label next to it (Claude Code / Codex / Terminal) is +// purely informational so a user can tell at a glance which +// provider owns the pane without reading the project dir. +// TAIL pill lights when tail-mode is active; suppresses sticky- +// bottom tracking but the indicator itself still works. +export function ScrollIndicator({ + entryCount, + scrollFraction, + tailMode, + sessionKind, +}: { + entryCount: number + scrollFraction: number + tailMode: boolean + sessionKind: SessionKind | undefined +}) { + if (entryCount === 0) return null + return ( +
+
+ + {providerLabel(sessionKind)} + + {tailMode && ( + + TAIL + + )} + + {Math.max(1, Math.ceil((1 - scrollFraction) * entryCount))}/{entryCount} + +
+
+ ) +} From 934acf6df2edf902082717d48fc89d5a2062307e Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 23 Apr 2026 21:40:40 +0200 Subject: [PATCH 24/27] refactor(tile-leaf): extract hooks into TileLeaf/use* MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/workspace/tile-tree/TileLeaf.tsx | 306 +++--------------- .../tile-tree/TileLeaf/useClaudeImagePaste.ts | 122 +++++++ .../tile-tree/TileLeaf/useComposerAutoGrow.ts | 27 ++ .../tile-tree/TileLeaf/usePromptHistory.ts | 118 +++++++ .../tile-tree/TileLeaf/useTypeToFocus.ts | 94 ++++++ 5 files changed, 397 insertions(+), 270 deletions(-) create mode 100644 src/renderer/src/workspace/tile-tree/TileLeaf/useClaudeImagePaste.ts create mode 100644 src/renderer/src/workspace/tile-tree/TileLeaf/useComposerAutoGrow.ts create mode 100644 src/renderer/src/workspace/tile-tree/TileLeaf/usePromptHistory.ts create mode 100644 src/renderer/src/workspace/tile-tree/TileLeaf/useTypeToFocus.ts diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf.tsx b/src/renderer/src/workspace/tile-tree/TileLeaf.tsx index 3cc12bb15..500c461f7 100644 --- a/src/renderer/src/workspace/tile-tree/TileLeaf.tsx +++ b/src/renderer/src/workspace/tile-tree/TileLeaf.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { extractAssistantInProgress } from '@shared/parsers/extractAssistant' import { CodexApprovalModal } from '@providers/codex/renderer/CodexApprovalModal' @@ -8,14 +8,12 @@ import { Feed } from '@renderer/features/feed/ui/Feed' import type { ScrollInfo } from '@renderer/features/feed/ui/Feed' import { TrustDialogModal } from '@providers/claude/renderer/TrustDialogModal' import { SlashCommandPicker } from '@providers/claude/renderer/SlashCommandPicker' -import { extractLatestUserPrompts } from '@renderer/features/workspace/lib/latestUserPrompts' import type { SessionRuntime, Workspace } from '@renderer/workspace/workspaceStore' import { selectMergedEntries, shouldShowSemanticStreaming, } from '@renderer/workspace/mergedEntries' import type { SessionId } from '@renderer/workspace/types' -import type { ClaudeDraftImage } from '@renderer/workspace/workspaceState' import { CLAUDE_PASTE_THRESHOLD, CLAUDE_PASTE_SUBMIT_DELAY_MS, @@ -24,19 +22,15 @@ import { sendBracketedPasteThenSubmit, sendClaudeDraftText, } from '@renderer/workspace/tile-tree/TileLeaf/claudePaste' -import { - SUPPORTED_CLAUDE_IMAGE_FORMATS_TEXT, - exceedsClaudeImageSizeLimit, - filesToDraftImages, - isSupportedClaudeImageMediaType, - parseImagesFromHtml, - readImagesFromClipboard, -} from '@renderer/workspace/tile-tree/TileLeaf/claudeImages' import { PaneHeader } from '@renderer/workspace/tile-tree/TileLeaf/PaneHeader' import { QueueStrip } from '@renderer/workspace/tile-tree/TileLeaf/QueueStrip' import { CompactionStrip } from '@renderer/workspace/tile-tree/TileLeaf/CompactionStrip' import { PaneToast } from '@renderer/workspace/tile-tree/TileLeaf/PaneToast' import { ScrollIndicator } from '@renderer/workspace/tile-tree/TileLeaf/ScrollIndicator' +import { useComposerAutoGrow } from '@renderer/workspace/tile-tree/TileLeaf/useComposerAutoGrow' +import { useTypeToFocus } from '@renderer/workspace/tile-tree/TileLeaf/useTypeToFocus' +import { usePromptHistory } from '@renderer/workspace/tile-tree/TileLeaf/usePromptHistory' +import { useClaudeImagePaste } from '@renderer/workspace/tile-tree/TileLeaf/useClaudeImagePaste' // Claude paste-state-machine constants + helpers moved to // ./TileLeaf/claudePaste.ts. Image helpers moved to @@ -117,25 +111,10 @@ export function TileLeaf({ const provider: 'claude' | 'codex' = workspace.state.sessions[sessionId]?.kind === 'codex' ? 'codex' : 'claude' - // Auto-grow the composer textarea to fit its content. We keep a single - // line by default, but as the user types (or pastes) a long prompt the - // box extends downward so every character is visible without scrolling - // inside the input itself. The reflow is driven off `input` so paste, - // programmatic setInputText, and typed keystrokes all converge on the - // same measurement pass. - // - // Why manual measurement instead of CSS `field-sizing: content`? - // - Safari/older Chromium don't support it yet and Electron ships a - // pinned Chromium we don't want to track. - // - Setting height to 'auto' first forces layout to forget the - // previous height, so scrollHeight reflects ONLY the current - // content — without the reset we'd ratchet taller and never shrink. - useEffect(() => { - const el = inputRef.current - if (!el) return - el.style.height = 'auto' - el.style.height = `${el.scrollHeight}px` - }, [input]) + // Auto-grow the composer textarea to fit its content — hook lives + // in ./TileLeaf/useComposerAutoGrow.ts, see there for the + // "why manual measurement instead of field-sizing:content" story. + useComposerAutoGrow(inputRef, input) // True while we're forwarding keystrokes to the PTY for a slash // command. Controls key routing in onKeyDown and render of the // picker dropdown (we still render the dropdown from runtime.picker, @@ -151,186 +130,32 @@ export function TileLeaf({ setScrollFraction(info.fraction) }, []) - // ---- Prompt history state ---- - // - // cc-shell keeps its own bash-style history for the composer instead - // of forwarding Up/Down to CC. Two reasons: - // 1. CC's own history updates CC's own input box in the terminal - // buffer, but our composer is a React textarea — the two states - // never reconcile, so pressing Up in our composer and letting - // CC handle it produced no visible change for the user. The - // whole thing looked broken. - // 2. We already have every past user prompt in runtime.entries, - // pulled from the JSONL transcript. Deriving a history list - // from that is nearly free. - // - // `historyIndex` is null when the user is NOT cycling (fresh draft, - // or just typed something), and a number in [0, history.length - 1] - // while cycling. 0 = most recent historic prompt, 1 = one before - // that, etc. When cycling is active the composer displays the - // history[historyIndex] string, not the live draft. - // - // `historyAnchor` stores whatever was in the composer the moment - // the user first pressed Up to enter the cycle. Pressing Down past - // the newest historic prompt (i.e. historyIndex back to -1) restores - // this string so the user doesn't lose mid-typed work. - // - // Both pieces of state are component-local (not runtime). They don't - // need to survive tab-switch because the moment the user comes back - // to the tab, they start fresh — cycling mid-tab-switch isn't a - // real workflow. Keeping them local avoids cluttering SessionRuntime - // and avoids the re-render cost of threading through the store. - const [historyIndex, setHistoryIndex] = useState(null) - const [historyAnchor, setHistoryAnchor] = useState('') - - // Derive the history list from the transcript. We walk every - // ConversationEntry, pull USER-role text content (either from a - // plain string content or from the first `text` block of an array - // content), and collect them in REVERSE chronological order so - // index 0 is the most recent prompt. - // - // Filter: the user-role slot in CC's JSONL is used for MANY things - // besides real typed prompts. Without proper filtering our history - // picks up strings the user never actually typed, which feels - // exactly like "another prompt got injected into my input" — the - // exact bug that forced us to revert the first cut of this feature. - // - // Concrete noise we've seen in real transcripts (see commit - // message for the companion fix commit for the full survey): - // - // 1. `isMeta: true` entries like "Continue from where you left - // off." — CC's auto-continue hint. User never typed it. - // 2. `…` and `/clear…` — - // system markers for local-command invocations. Caught by - // the old startsWith('<') filter but still in this list for - // the audit record. - // 3. "Unknown skill: resumeOne" and similar — CC's error - // response to bad slash-command invocations. Logged as a - // user-role entry, plain-text content, doesn't start with '<'. - // THE main offender that made it into the first cut. - // 4. Tool-result-only user-role entries — no text blocks at - // all, just the results for the previous assistant turn's - // tool_use blocks. Harmless with our "has text" check but - // noted for completeness. - // - // Positive signal for "this was a real prompt the user typed": - // the entry has a `permissionMode` field set. Empirically every - // real user prompt in the current transcript (27/27) carries it; - // every synthetic entry (isMeta, error responses, local-command - // markers) lacks it. `isMeta === true` is a redundant secondary - // defense in case CC ever starts writing permissionMode into - // synthetic meta entries too. startsWith('<') stays as a tertiary - // catch-all for any future tag-shaped synthetics. - // - // Dedup: adjacent identical prompts (the "oops, meant to add - // detail" resubmit pattern) collapse into one entry. Distant - // duplicates stay, matching bash history behavior. Memoed on - // entries reference so normal re-renders don't rebuild the list. + // Prompt history — state + derivation live in + // ./TileLeaf/usePromptHistory.ts. Returns the history list, the + // cycle cursor/anchor, and endHistoryCycle(). See that hook for + // the transcript-filter rationale (why `permissionMode` is the + // positive signal and what kinds of noise we had to filter out). const sessionKind = workspace.state.sessions[sessionId]?.kind - const history = useMemo(() => { - return extractLatestUserPrompts(runtime.entries, sessionKind).map(prompt => prompt.text) - }, [runtime.entries, sessionKind]) - - // Helper: are we currently in history-cycling mode? Multiple key - // handlers consult this and a named check reads cleaner than - // `historyIndex !== null` repeated everywhere. - const cyclingHistory = historyIndex !== null - - // Helper: cancel history cycling without changing the composer text. - // Called whenever the user makes ANY edit that isn't Up/Down — the - // invariant is "once you start typing over a recalled prompt, it's - // yours, and the next Up starts from the newest entry again." - const endHistoryCycle = () => { - if (historyIndex !== null) setHistoryIndex(null) - } - - // (cursor-row helpers removed — the new history gate fires only - // when the composer is entirely empty, so multi-line caret - // navigation is moot.) + const { + history, + historyIndex, + historyAnchor, + cyclingHistory, + setHistoryIndex, + setHistoryAnchor, + endHistoryCycle, + } = usePromptHistory({ entries: runtime.entries, sessionKind }) // When focus flips to this pane, move the DOM caret into its input. useEffect(() => { if (focused) inputRef.current?.focus() }, [focused]) - // Type-to-focus: when the user starts typing anywhere in the - // focused pane — feed area, a stray click on a button, whatever — - // the keystroke routes to the composer without them having to - // click on it first. - // - // Why this is needed even with the focus-on-focus effect above: - // DOM focus wanders. Clicking a feed button, interacting with - // the tab bar, switching apps and coming back, hitting a keybind - // that focuses something else — all of these leave DOM focus - // somewhere other than the composer textarea. The focus-on-pane- - // focus effect only fires when `focused` CHANGES, which doesn't - // happen in any of those cases. So a fresh keystroke can land - // nowhere useful. - // - // The fix: listen at document level (scoped to the currently - // focused pane via the `focused` guard), and when a printable - // key comes in while DOM focus is NOT on an editable target, - // redirect it to the composer. - // - // Filter list (all of these are cases where we must NOT steal - // the key): - // - `defaultPrevented`: a keybind or earlier handler already - // handled this. Stay out of their way. - // - Any modifier (cmd / ctrl / alt / meta): modifier combos - // are global keybinds, not text input. - // - Non-printable key (e.key.length !== 1): arrow keys, - // Escape, Enter, Backspace, Tab, function keys. Those all - // have multi-char names. - // - Target is already an input / textarea / contentEditable: - // the character is already going somewhere legitimate; don't - // intercept it and double-type. - // - A modal with role="dialog" is open: PathPickerModal, - // TrustDialogModal, or any future modal. Those own keyboard - // focus while visible. - // - // Injection path: we write directly to SessionRuntime.draftInput - // via workspace.setDraftInput (same setter the composer's - // onChange uses), then focus() the textarea, then move the - // cursor to end on the next frame after React re-renders with - // the new value. The rAF is load-bearing: setting selectionStart - // synchronously on a textarea whose React-bound `value` hasn't - // re-rendered yet targets the OLD value and puts the cursor - // at a stale index. - useEffect(() => { - if (!focused) return - const onKey = (e: KeyboardEvent) => { - if (e.defaultPrevented) return - if (e.metaKey || e.ctrlKey || e.altKey) return - if (e.key.length !== 1) return - const target = e.target as HTMLElement | null - if (target) { - const tag = target.tagName - if (tag === 'INPUT' || tag === 'TEXTAREA') return - if (target.isContentEditable) return - } - if (document.querySelector('[role="dialog"]')) return - - const el = inputRef.current - if (!el) return - e.preventDefault() - const next = el.value + e.key - setDraftInput(sessionId, next) - el.focus() - requestAnimationFrame(() => { - const el2 = inputRef.current - if (!el2) return - el2.selectionStart = el2.value.length - el2.selectionEnd = el2.value.length - }) - } - document.addEventListener('keydown', onKey) - return () => document.removeEventListener('keydown', onKey) - // setDraftInput is a stable useCallback from the workspace hook - // so re-destructuring it every render is a no-op for this dep - // array. If workspace ever stops memoing it, this effect would - // re-subscribe on every render and we'd add/remove a document - // listener every frame — so keep setDraftInput memoed upstream. - }, [focused, sessionId, setDraftInput]) + // Type-to-focus — document-level key listener that routes printable + // keys into the composer when the pane is focused but DOM focus + // drifted elsewhere. Hook in ./TileLeaf/useTypeToFocus.ts owns + // the full filter/injection logic. + useTypeToFocus({ focused, sessionId, inputRef, setDraftInput }) const send = async (data: string) => { const ok = await window.api.sendInput(sessionId, data) @@ -339,73 +164,14 @@ export function TileLeaf({ } } - const appendDraftImages = useCallback( - (images: ClaudeDraftImage[]) => { - if (images.length === 0) return - setDraftImages(sessionId, prev => [...prev, ...images]) - }, - [sessionId, setDraftImages], - ) - - const removeDraftImage = useCallback( - (imageId: string) => { - setDraftImages(sessionId, prev => prev.filter(image => image.id !== imageId)) - }, - [sessionId, setDraftImages], - ) - - const handlePaste = useCallback( - async (e: React.ClipboardEvent) => { - if (provider !== 'claude') return - - try { - const itemFiles = Array.from(e.clipboardData.items) - .map(item => item.type.startsWith('image/') ? item.getAsFile() : null) - .filter((file): file is File => Boolean(file && file.type.startsWith('image/'))) - const htmlImages = parseImagesFromHtml(e.clipboardData.getData('text/html')) - - let images: ClaudeDraftImage[] = htmlImages - if (itemFiles.length > 0) { - e.preventDefault() - const fileImages = await filesToDraftImages(itemFiles) - const existing = new Set(images.map(image => image.previewUrl)) - images = [ - ...images, - ...fileImages.filter(image => !existing.has(image.previewUrl)), - ] - } else if (images.length === 0) { - images = await readImagesFromClipboard() - if (images.length > 0) { - e.preventDefault() - } - } else { - e.preventDefault() - } - if (images.length === 0) return - const unsupported = images.find( - image => !isSupportedClaudeImageMediaType(image.mediaType), - ) - if (unsupported) { - showToast( - `Unsupported image format: ${unsupported.mediaType}. Claude supports ${SUPPORTED_CLAUDE_IMAGE_FORMATS_TEXT}.`, - ) - return - } - const oversized = images.find(exceedsClaudeImageSizeLimit) - if (oversized) { - showToast( - `Image is too large. Claude supports pasted images up to 5 MB.`, - ) - return - } - appendDraftImages(images) - } catch (err) { - console.warn('[TileLeaf] image paste failed', err) - showToast('Image paste failed.') - } - }, - [appendDraftImages, provider, showToast], - ) + // Claude image-paste flow — three clipboard ingress paths, media- + // type gate, 5 MB size cap. Hook in ./TileLeaf/useClaudeImagePaste.ts. + const { handlePaste, removeDraftImage } = useClaudeImagePaste({ + provider, + sessionId, + setDraftImages, + showToast, + }) const exitSlashMode = () => { setSlashMode(false) diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf/useClaudeImagePaste.ts b/src/renderer/src/workspace/tile-tree/TileLeaf/useClaudeImagePaste.ts new file mode 100644 index 000000000..53cb4b193 --- /dev/null +++ b/src/renderer/src/workspace/tile-tree/TileLeaf/useClaudeImagePaste.ts @@ -0,0 +1,122 @@ +import { useCallback } from 'react' + +import type { SessionId } from '@renderer/workspace/types' +import type { ClaudeDraftImage } from '@renderer/workspace/workspaceState' +import { + SUPPORTED_CLAUDE_IMAGE_FORMATS_TEXT, + exceedsClaudeImageSizeLimit, + filesToDraftImages, + isSupportedClaudeImageMediaType, + parseImagesFromHtml, + readImagesFromClipboard, +} from '@renderer/workspace/tile-tree/TileLeaf/claudeImages' + +// Image-paste handler for the composer. Claude-only: Codex doesn't +// accept inline image content, so we fall through to the browser's +// default paste handling for that provider. +// +// Three clipboard ingress paths live here (pluralised from what a +// naive `clipboardData.items` check would give us): +// +// 1. `e.clipboardData.items` File objects (the typical copy-image- +// from-app flow on macOS / Windows). +// 2. `` embedded in `text/html` (some +// browsers copy web images as HTML fragments rather than real +// Files). +// 3. `navigator.clipboard.read()` fallback (covers the case where +// Electron picks a clipboard representation that doesn't +// surface through synchronous `clipboardData`). +// +// We also gate on media type (Claude supports PNG/JPEG/GIF/WebP) +// and a 5 MB size cap — both enforced with a toast on violation +// and a short-circuit return. The caller owns `appendDraftImages` +// (usually the draft-images hook); we hand the validated payload +// over at the end. +// +// `e.preventDefault()` is called whenever we handle the paste so +// the default "paste as text" behavior doesn't leak data URL blobs +// into the textarea. +export function useClaudeImagePaste({ + provider, + sessionId, + setDraftImages, + showToast, +}: { + provider: 'claude' | 'codex' + sessionId: SessionId + setDraftImages: ( + sessionId: SessionId, + next: (prev: ClaudeDraftImage[]) => ClaudeDraftImage[], + ) => void + showToast: (message: string) => void +}) { + const appendDraftImages = useCallback( + (images: ClaudeDraftImage[]) => { + if (images.length === 0) return + setDraftImages(sessionId, prev => [...prev, ...images]) + }, + [sessionId, setDraftImages], + ) + + const removeDraftImage = useCallback( + (imageId: string) => { + setDraftImages(sessionId, prev => prev.filter(image => image.id !== imageId)) + }, + [sessionId, setDraftImages], + ) + + const handlePaste = useCallback( + async (e: React.ClipboardEvent) => { + if (provider !== 'claude') return + + try { + const itemFiles = Array.from(e.clipboardData.items) + .map(item => item.type.startsWith('image/') ? item.getAsFile() : null) + .filter((file): file is File => Boolean(file && file.type.startsWith('image/'))) + const htmlImages = parseImagesFromHtml(e.clipboardData.getData('text/html')) + + let images: ClaudeDraftImage[] = htmlImages + if (itemFiles.length > 0) { + e.preventDefault() + const fileImages = await filesToDraftImages(itemFiles) + const existing = new Set(images.map(image => image.previewUrl)) + images = [ + ...images, + ...fileImages.filter(image => !existing.has(image.previewUrl)), + ] + } else if (images.length === 0) { + images = await readImagesFromClipboard() + if (images.length > 0) { + e.preventDefault() + } + } else { + e.preventDefault() + } + if (images.length === 0) return + const unsupported = images.find( + image => !isSupportedClaudeImageMediaType(image.mediaType), + ) + if (unsupported) { + showToast( + `Unsupported image format: ${unsupported.mediaType}. Claude supports ${SUPPORTED_CLAUDE_IMAGE_FORMATS_TEXT}.`, + ) + return + } + const oversized = images.find(exceedsClaudeImageSizeLimit) + if (oversized) { + showToast( + `Image is too large. Claude supports pasted images up to 5 MB.`, + ) + return + } + appendDraftImages(images) + } catch (err) { + console.warn('[TileLeaf] image paste failed', err) + showToast('Image paste failed.') + } + }, + [appendDraftImages, provider, showToast], + ) + + return { handlePaste, removeDraftImage, appendDraftImages } +} diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerAutoGrow.ts b/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerAutoGrow.ts new file mode 100644 index 000000000..87e4b2424 --- /dev/null +++ b/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerAutoGrow.ts @@ -0,0 +1,27 @@ +import { useEffect, type RefObject } from 'react' + +// Auto-grow the composer textarea to fit its content. We keep a +// single line by default, but as the user types (or pastes) a long +// prompt the box extends downward so every character is visible +// without scrolling inside the input itself. The reflow is driven +// off `value` so paste, programmatic setInputText, and typed +// keystrokes all converge on the same measurement pass. +// +// Why manual measurement instead of CSS `field-sizing: content`? +// - Safari/older Chromium don't support it yet and Electron ships +// a pinned Chromium we don't want to track. +// - Setting height to 'auto' first forces layout to forget the +// previous height, so scrollHeight reflects ONLY the current +// content — without the reset we'd ratchet taller and never +// shrink. +export function useComposerAutoGrow( + ref: RefObject, + value: string, +): void { + useEffect(() => { + const el = ref.current + if (!el) return + el.style.height = 'auto' + el.style.height = `${el.scrollHeight}px` + }, [ref, value]) +} diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf/usePromptHistory.ts b/src/renderer/src/workspace/tile-tree/TileLeaf/usePromptHistory.ts new file mode 100644 index 000000000..d5f95015c --- /dev/null +++ b/src/renderer/src/workspace/tile-tree/TileLeaf/usePromptHistory.ts @@ -0,0 +1,118 @@ +import { useMemo, useState } from 'react' + +import { extractLatestUserPrompts } from '@renderer/features/workspace/lib/latestUserPrompts' +import type { Entry } from '@shared/types/transcript' +import type { SessionKind } from '@renderer/workspace/types' + +// ---- Prompt history state ---- +// +// cc-shell keeps its own bash-style history for the composer +// instead of forwarding Up/Down to CC. Two reasons: +// 1. CC's own history updates CC's own input box in the terminal +// buffer, but our composer is a React textarea — the two +// states never reconcile, so pressing Up in our composer and +// letting CC handle it produced no visible change for the +// user. The whole thing looked broken. +// 2. We already have every past user prompt in runtime.entries, +// pulled from the JSONL transcript. Deriving a history list +// from that is nearly free. +// +// `historyIndex` is null when the user is NOT cycling (fresh +// draft, or just typed something), and a number in +// [0, history.length - 1] while cycling. 0 = most recent historic +// prompt, 1 = one before that, etc. When cycling is active the +// composer displays the history[historyIndex] string, not the +// live draft. +// +// `historyAnchor` stores whatever was in the composer the moment +// the user first pressed Up to enter the cycle. Pressing Down past +// the newest historic prompt (i.e. historyIndex back to -1) +// restores this string so the user doesn't lose mid-typed work. +// +// Both pieces of state are component-local (not runtime). They +// don't need to survive tab-switch because the moment the user +// comes back to the tab, they start fresh — cycling mid-tab- +// switch isn't a real workflow. Keeping them local avoids +// cluttering SessionRuntime and avoids the re-render cost of +// threading through the store. +// +// ---- History derivation ---- +// +// Filter: the user-role slot in CC's JSONL is used for MANY things +// besides real typed prompts. Without proper filtering our history +// picks up strings the user never actually typed, which feels +// exactly like "another prompt got injected into my input" — the +// exact bug that forced us to revert the first cut of this +// feature. +// +// Concrete noise we've seen in real transcripts: +// +// 1. `isMeta: true` entries like "Continue from where you left +// off." — CC's auto-continue hint. User never typed it. +// 2. `…` and `/clear…` — +// system markers for local-command invocations. +// 3. "Unknown skill: resumeOne" and similar — CC's error +// response to bad slash-command invocations. Logged as a +// user-role entry, plain-text content, doesn't start with +// '<'. THE main offender that made it into the first cut. +// 4. Tool-result-only user-role entries — no text blocks at +// all, just the results for the previous assistant turn's +// tool_use blocks. +// +// Positive signal for "this was a real prompt the user typed": +// the entry has a `permissionMode` field set. Empirically every +// real user prompt in the current transcript carries it; every +// synthetic entry (isMeta, error responses, local-command +// markers) lacks it. The filter applied here lives in +// `extractLatestUserPrompts`; this hook just consumes its output. +// +// Dedup: adjacent identical prompts (the "oops, meant to add +// detail" resubmit pattern) collapse into one entry. Distant +// duplicates stay, matching bash history behavior. Memoed on +// entries reference so normal re-renders don't rebuild the list. +export type PromptHistory = { + /** Most-recent-first list of historic user prompts. */ + history: string[] + /** null when not cycling; index into `history` when cycling. */ + historyIndex: number | null + /** Composer text captured on cycle entry; restored on Down + * past the newest entry. */ + historyAnchor: string + /** True while a cycle is in progress. */ + cyclingHistory: boolean + setHistoryIndex: (index: number | null) => void + setHistoryAnchor: (text: string) => void + /** Cancel the cycle without changing composer text. Called when + * any non-Up/Down edit (typing, paste, delete, submit) fires. */ + endHistoryCycle: () => void +} + +export function usePromptHistory({ + entries, + sessionKind, +}: { + entries: Entry[] + sessionKind: SessionKind | undefined +}): PromptHistory { + const [historyIndex, setHistoryIndex] = useState(null) + const [historyAnchor, setHistoryAnchor] = useState('') + + const history = useMemo(() => { + return extractLatestUserPrompts(entries, sessionKind).map(prompt => prompt.text) + }, [entries, sessionKind]) + + const cyclingHistory = historyIndex !== null + const endHistoryCycle = () => { + if (historyIndex !== null) setHistoryIndex(null) + } + + return { + history, + historyIndex, + historyAnchor, + cyclingHistory, + setHistoryIndex, + setHistoryAnchor, + endHistoryCycle, + } +} diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf/useTypeToFocus.ts b/src/renderer/src/workspace/tile-tree/TileLeaf/useTypeToFocus.ts new file mode 100644 index 000000000..d53405931 --- /dev/null +++ b/src/renderer/src/workspace/tile-tree/TileLeaf/useTypeToFocus.ts @@ -0,0 +1,94 @@ +import { useEffect, type RefObject } from 'react' + +import type { SessionId } from '@renderer/workspace/types' + +// Type-to-focus: when the user starts typing anywhere in the +// focused pane — feed area, a stray click on a button, whatever — +// the keystroke routes to the composer without them having to +// click on it first. +// +// Why this is needed even with the focus-on-pane-focus effect (the +// one that runs `inputRef.current?.focus()` when `focused` +// changes): DOM focus wanders. Clicking a feed button, interacting +// with the tab bar, switching apps and coming back, hitting a +// keybind that focuses something else — all of these leave DOM +// focus somewhere other than the composer textarea. The focus-on- +// pane-focus effect only fires when `focused` CHANGES, which +// doesn't happen in any of those cases. So a fresh keystroke can +// land nowhere useful. +// +// The fix: listen at document level (scoped to the currently +// focused pane via the `focused` guard), and when a printable key +// comes in while DOM focus is NOT on an editable target, redirect +// it to the composer. +// +// Filter list (all of these are cases where we must NOT steal the +// key): +// - `defaultPrevented`: a keybind or earlier handler already +// handled this. Stay out of their way. +// - Any modifier (cmd / ctrl / alt / meta): modifier combos are +// global keybinds, not text input. +// - Non-printable key (e.key.length !== 1): arrow keys, Escape, +// Enter, Backspace, Tab, function keys. Those all have multi- +// char names. +// - Target is already an input / textarea / contentEditable: the +// character is already going somewhere legitimate; don't +// intercept it and double-type. +// - A modal with role="dialog" is open: PathPickerModal, +// TrustDialogModal, or any future modal. Those own keyboard +// focus while visible. +// +// Injection path: we write directly to SessionRuntime.draftInput +// via setDraftInput (same setter the composer's onChange uses), +// then focus() the textarea, then move the cursor to end on the +// next frame after React re-renders with the new value. The rAF is +// load-bearing: setting selectionStart synchronously on a textarea +// whose React-bound `value` hasn't re-rendered yet targets the OLD +// value and puts the cursor at a stale index. +export function useTypeToFocus({ + focused, + sessionId, + inputRef, + setDraftInput, +}: { + focused: boolean + sessionId: SessionId + inputRef: RefObject + setDraftInput: (sessionId: SessionId, next: string) => void +}): void { + useEffect(() => { + if (!focused) return + const onKey = (e: KeyboardEvent) => { + if (e.defaultPrevented) return + if (e.metaKey || e.ctrlKey || e.altKey) return + if (e.key.length !== 1) return + const target = e.target as HTMLElement | null + if (target) { + const tag = target.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + if (target.isContentEditable) return + } + if (document.querySelector('[role="dialog"]')) return + + const el = inputRef.current + if (!el) return + e.preventDefault() + const next = el.value + e.key + setDraftInput(sessionId, next) + el.focus() + requestAnimationFrame(() => { + const el2 = inputRef.current + if (!el2) return + el2.selectionStart = el2.value.length + el2.selectionEnd = el2.value.length + }) + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + // setDraftInput is a stable useCallback from the workspace hook + // so re-destructuring it every render is a no-op for this dep + // array. If workspace ever stops memoing it, this effect would + // re-subscribe on every render and we'd add/remove a document + // listener every frame — so keep setDraftInput memoed upstream. + }, [focused, sessionId, inputRef, setDraftInput]) +} From 91f3ec74d7e487c5b66db27a6ef2a9366a652372 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 23 Apr 2026 21:46:15 +0200 Subject: [PATCH 25/27] refactor(tile-leaf): extract ComposerInput + useComposerKeybinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/workspace/tile-tree/TileLeaf.tsx | 470 ++---------------- .../tile-tree/TileLeaf/ComposerInput.tsx | 159 ++++++ .../tile-tree/TileLeaf/useComposerKeybinds.ts | 383 ++++++++++++++ 3 files changed, 581 insertions(+), 431 deletions(-) create mode 100644 src/renderer/src/workspace/tile-tree/TileLeaf/ComposerInput.tsx create mode 100644 src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.ts diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf.tsx b/src/renderer/src/workspace/tile-tree/TileLeaf.tsx index 500c461f7..f9cc59d30 100644 --- a/src/renderer/src/workspace/tile-tree/TileLeaf.tsx +++ b/src/renderer/src/workspace/tile-tree/TileLeaf.tsx @@ -1,33 +1,25 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { extractAssistantInProgress } from '@shared/parsers/extractAssistant' import { CodexApprovalModal } from '@providers/codex/renderer/CodexApprovalModal' import { ResumePromptModal } from '@providers/claude/renderer/ResumePromptModal' import { useGlobalToast } from '@renderer/ui/GlobalToast' import { Feed } from '@renderer/features/feed/ui/Feed' import type { ScrollInfo } from '@renderer/features/feed/ui/Feed' import { TrustDialogModal } from '@providers/claude/renderer/TrustDialogModal' -import { SlashCommandPicker } from '@providers/claude/renderer/SlashCommandPicker' import type { SessionRuntime, Workspace } from '@renderer/workspace/workspaceStore' import { selectMergedEntries, shouldShowSemanticStreaming, } from '@renderer/workspace/mergedEntries' import type { SessionId } from '@renderer/workspace/types' -import { - CLAUDE_PASTE_THRESHOLD, - CLAUDE_PASTE_SUBMIT_DELAY_MS, - CLAUDE_IMAGE_PATH_SUBMIT_DELAY_MS, - buildClaudeImagePastePayload, - sendBracketedPasteThenSubmit, - sendClaudeDraftText, -} from '@renderer/workspace/tile-tree/TileLeaf/claudePaste' import { PaneHeader } from '@renderer/workspace/tile-tree/TileLeaf/PaneHeader' import { QueueStrip } from '@renderer/workspace/tile-tree/TileLeaf/QueueStrip' import { CompactionStrip } from '@renderer/workspace/tile-tree/TileLeaf/CompactionStrip' import { PaneToast } from '@renderer/workspace/tile-tree/TileLeaf/PaneToast' import { ScrollIndicator } from '@renderer/workspace/tile-tree/TileLeaf/ScrollIndicator' +import { ComposerInput } from '@renderer/workspace/tile-tree/TileLeaf/ComposerInput' import { useComposerAutoGrow } from '@renderer/workspace/tile-tree/TileLeaf/useComposerAutoGrow' +import { useComposerKeybinds } from '@renderer/workspace/tile-tree/TileLeaf/useComposerKeybinds' import { useTypeToFocus } from '@renderer/workspace/tile-tree/TileLeaf/useTypeToFocus' import { usePromptHistory } from '@renderer/workspace/tile-tree/TileLeaf/usePromptHistory' import { useClaudeImagePaste } from '@renderer/workspace/tile-tree/TileLeaf/useClaudeImagePaste' @@ -115,13 +107,6 @@ export function TileLeaf({ // in ./TileLeaf/useComposerAutoGrow.ts, see there for the // "why manual measurement instead of field-sizing:content" story. useComposerAutoGrow(inputRef, input) - // True while we're forwarding keystrokes to the PTY for a slash - // command. Controls key routing in onKeyDown and render of the - // picker dropdown (we still render the dropdown from runtime.picker, - // but we use slashMode to decide whether keys should be forwarded - // vs. stored locally). - const [slashMode, setSlashMode] = useState(false) - // Scroll position for the indicator above the composer. Updated on // every scroll tick via onScrollInfo callback from Feed. fraction=0 // means at bottom, fraction=1 means at top. @@ -173,322 +158,26 @@ export function TileLeaf({ showToast, }) - const exitSlashMode = () => { - setSlashMode(false) - setInputText('') - } - - const onKeyDown = async (e: React.KeyboardEvent) => { - // Global keybinds bubble up to the document-level listener in - // useKeybinds; if a modifier-combo handler called preventDefault - // already, skip processing here to avoid routing pane-management - // keys into the PTY as text. - if (e.defaultPrevented) return - - // ---- Slash mode entry ---- - // - // Only when input is empty AND the user types `/`. That way normal - // text containing a `/` in the middle (a URL, a path) doesn't - // accidentally flip us into slash mode. - if (!slashMode && input === '' && e.key === '/') { - e.preventDefault() - await send('/') - setInputText('/') - setSlashMode(true) - return - } - - // ---- Slash mode: forward every key to PTY ---- - if (slashMode) { - e.preventDefault() - - if (e.key === 'Escape') { - // Send ESC to CC (dismisses the picker) and exit slash mode. - // slashMode is intentionally flipped off BEFORE the picker's - // next screen update arrives — we don't want to wait for CC - // to confirm the dismissal before letting the user type. - await send('\x1b') - exitSlashMode() - return - } - if (e.key === 'Enter') { - // Commit whatever CC has highlighted. If there's no highlight - // CC will just send the literal text as a regular prompt. - await send('\r') - exitSlashMode() - return - } - if (e.key === 'Backspace') { - await send('\x7f') - const next = input.slice(0, -1) - setInputText(next) - // If the user backspaces all the way out, we're no longer in - // slash mode — fall back to the normal composer. - if (next === '') setSlashMode(false) - return - } - if (e.key === 'ArrowUp') { - await send('\x1b[A') - return - } - if (e.key === 'ArrowDown') { - await send('\x1b[B') - return - } - if (e.key === 'ArrowLeft') { - await send('\x1b[D') - return - } - if (e.key === 'ArrowRight') { - await send('\x1b[C') - return - } - if (e.key === 'Tab') { - await send('\t') - return - } - // Single printable char: forward + mirror into local state so - // the React input visibly tracks what CC has in its buffer. - if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { - await send(e.key) - setInputText(input + e.key) - return - } - // Ignore shift, ctrl, meta, function keys, etc. while in slash mode. - return - } - - // ---- Normal mode ---- - // - // Shift+Enter: insert a literal newline in the composer (normal - // textarea behavior). We DON'T preventDefault so the browser handles - // the insertion, and we don't forward anything to the PTY — the - // newline only becomes visible to CC when the user commits with a - // bare Enter below. This gives multi-line prompt editing without - // touching the PTY until the user actually wants to send. - if (e.key === 'Enter' && e.shiftKey) { - return - } - if (e.key === 'Enter') { - e.preventDefault() - const draftImages = runtime.draftImages - if (input.trim().length === 0 && draftImages.length === 0) { - return - } - // Capture streaming baseline from the very freshest screen text - // so the streaming card can detect "this is the old response" - // reliably. latestScreenRef is mutated synchronously on every - // IPC screen event so this is always current. - const screen = workspace.latestScreenRef.current[sessionId] ?? '' - const provider = workspace.state.sessions[sessionId]?.kind === 'codex' ? 'codex' : 'claude' - const baseline = extractAssistantInProgress(screen, provider) - workspace.setStreamingBaseline(sessionId, baseline) - if (provider === 'codex') { - // Codex does not reliably give us a structured user message at submit - // time the way Claude does. Seed the feed immediately from the local - // composer state so "submit" is visible even if rollout JSON is late. - workspace.addOptimisticCodexUserEntry(sessionId, input) - } - - try { - const hasClaudeImages = provider === 'claude' && draftImages.length > 0 - // Three submit modes live here because the two providers' - // input stacks are similar but NOT equivalent: - // - // 1. Codex: always bracketed-paste, always trailing Enter - // outside the paste block, both in one write. This is the - // path that fixed Codex swallowing `\r` as pasted text. - // - // 2. Claude, normal text: raw text + `\r` in one write. Fast - // path for the overwhelmingly common case. - // - // 3. Claude, paste-like text (multiline OR long enough to hit - // Claude's own paste path): bracketed paste first, THEN a - // delayed `\r` in a second write. This is the critical fix - // for the "first Enter populates the prompt but does not - // actually submit; second Enter finally sends it" bug. - const isClaudePasteLike = - provider === 'claude' && - (input.includes('\n') || input.length > CLAUDE_PASTE_THRESHOLD) - - if (provider === 'codex') { - await sendBracketedPasteThenSubmit(send, input) - } else if (hasClaudeImages) { - const savedImages = await Promise.all( - draftImages.map(image => - window.api.saveClaudeImage({ - base64Data: image.base64Data, - mediaType: image.mediaType, - filename: image.filename, - }), - ), - ) - const imagePaths = savedImages.map(image => image.path) - if (input.length > 0) { - await sendClaudeDraftText(send, input) - // Claude collapses the following path paste into image pills. - // If the user's prompt ends in a non-whitespace character, - // inject one separator so the final prompt text does not run - // directly into the first `[Image #N]` placeholder. - if (!/\s$/.test(input)) { - await send(' ') - } - } - const payload = buildClaudeImagePastePayload('', imagePaths) - await sendBracketedPasteThenSubmit(send, payload, CLAUDE_IMAGE_PATH_SUBMIT_DELAY_MS) - } else if (isClaudePasteLike) { - // Keep the submit key OUT of the bracketed-paste write and - // wait past Claude's paste debounce. Sending `\r` in the same - // PTY chunk races Claude's paste accumulator and can leave the - // prompt sitting in the composer until a later keypress nudges - // it through the normal submit path. - await sendBracketedPasteThenSubmit(send, input, CLAUDE_PASTE_SUBMIT_DELAY_MS) - } else { - await send(input + '\r') - } - setInputText('') - if (provider === 'claude' && draftImages.length > 0) { - setDraftImages(sessionId, []) - } - } catch (err) { - // Keep the draft visible if main no longer has a live session for this - // pane. Clearing the composer on a dropped write makes the failure look - // like Codex ignored the prompt when it never received it. - if (provider === 'codex') { - workspace.removeOptimisticCodexUserEntry(sessionId, input) - } - console.warn('[TileLeaf] submit failed', err) - } - // Any submit exits history cycling — the prompt is committed - // and the next Up should start a fresh walk from the (now - // updated) newest entry, not continue from wherever we were. - endHistoryCycle() - return - } - if (e.key === 'Escape') { - e.preventDefault() - await send('\x1b') - return - } - if (e.ctrlKey && e.key.toLowerCase() === 'c') { - e.preventDefault() - await send('\x03') - setInputText('') - return - } - if (e.ctrlKey && e.key.toLowerCase() === 'd') { - e.preventDefault() - await send('\x04') - return - } - // ---- Prompt history: Up cycles BACKWARD into past prompts ---- - // - // Entry gate: we ONLY enter cycling when the composer is - // currently empty. That's deliberately more restrictive than - // bash's "cycle even with a partial draft and restore on Down". - // The permissive version caused real confusion — users pressing - // Up with a mid-typed prompt would watch their draft get - // replaced with a random historic prompt and think something - // was injecting text into their input. The anchor-restore - // mechanism was there but non-obvious. Requiring an empty - // composer makes the feature discoverable and non-destructive: - // you have to actively clear your input before you can cycle. - // - // Once cycling has STARTED (historyIndex !== null), subsequent - // Up/Down steps don't re-check emptiness — the composer is - // showing a historic prompt, not user typing, so stepping - // further is obviously safe. - // - // Modifier combos (Shift/Ctrl/Meta/Alt+Up) fall through to the - // PTY-forward path so OS line-navigation shortcuts still reach - // CC when the user wants them. - // Skip history cycling when the approval overlay is visible — - // arrow keys need to reach the PTY so Codex can navigate its - // selection list. - if ( - e.key === 'ArrowUp' && - !e.shiftKey && - !e.ctrlKey && - !e.metaKey && - !e.altKey && - !runtime.pendingApproval && - !runtime.pendingTrustDialog && - !runtime.pendingResumePrompt && - history.length > 0 && - (cyclingHistory || input === '') - ) { - e.preventDefault() - if (!cyclingHistory) { - // First entry into cycling: the composer is empty (per the - // gate above), so the anchor is also empty. Storing it - // anyway keeps the Down-past-newest restore path uniform. - setHistoryAnchor('') - setHistoryIndex(0) - setInputText(history[0]) - } else { - const next = Math.min(historyIndex! + 1, history.length - 1) - if (next !== historyIndex) { - setHistoryIndex(next) - setInputText(history[next]) - } - } - return - } - - // ---- Prompt history: Down cycles FORWARD toward the anchor ---- - // - // Only meaningful while we're already cycling. Pressing Down - // outside a cycle shouldn't do anything (no forward history to - // cycle to). We also don't need the cursorOnBottomRow check the - // old version had, because the composer's content during - // cycling is a historic prompt the user hasn't touched — there's - // no multi-line-draft caret navigation to preserve. - if ( - e.key === 'ArrowDown' && - !e.shiftKey && - !e.ctrlKey && - !e.metaKey && - !e.altKey && - !runtime.pendingApproval && - !runtime.pendingTrustDialog && - !runtime.pendingResumePrompt && - cyclingHistory - ) { - e.preventDefault() - const next = historyIndex! - 1 - if (next < 0) { - // Past the newest historic prompt — restore the anchor - // (empty, since the Up gate only lets us in from an empty - // composer) and exit cycle mode so the next Up starts fresh. - setHistoryIndex(null) - setInputText(historyAnchor) - } else { - setHistoryIndex(next) - setInputText(history[next]) - } - return - } - - // Fallback: any other Up/Down (not cycling, not at top/bottom - // row, or with a modifier) falls through to the old PTY-forward - // path so CC's own history / caret navigation still reaches it - // when appropriate. - if (e.key === 'ArrowUp') { - e.preventDefault() - await send('\x1b[A') - return - } - if (e.key === 'ArrowDown') { - e.preventDefault() - await send('\x1b[B') - return - } - if (e.key === 'Tab') { - e.preventDefault() - await send('\t') - return - } - } + // Composer keybinds — slash-mode + normal-mode + prompt-history + // cycling. Hook in ./TileLeaf/useComposerKeybinds.ts; returns + // the onKeyDown handler plus the slashMode flag that the + // ComposerInput uses to gate its own onChange logic. + const { onKeyDown, slashMode } = useComposerKeybinds({ + sessionId, + provider, + runtime, + workspace, + input, + setInputText, + send, + history, + historyIndex, + historyAnchor, + cyclingHistory, + setHistoryIndex, + setHistoryAnchor, + endHistoryCycle, + }) const running = runtime.exited === null const isSessionLive = runtime.sessionStatus === 'running' @@ -618,104 +307,23 @@ export function TileLeaf({ sessionKind={workspace.state.sessions[sessionId]?.kind} /> - {/* Composer */} -
- {/* SlashCommandPicker is absolutely positioned relative to this - composer container so it floats above the input without - shifting layout. */} - - - {/* The composer is a