diff --git a/scripts/capture-readme-gifs.mjs b/scripts/capture-readme-gifs.mjs deleted file mode 100644 index 2c58e750..00000000 --- a/scripts/capture-readme-gifs.mjs +++ /dev/null @@ -1,482 +0,0 @@ -#!/usr/bin/env node - -/** - * capture-readme-gifs.mjs - * - * Deterministic README GIFs — no real server, Claude CLI, or tmux. Reuses the - * mock-injection pipeline from capture-readme-screenshots.mjs (static file - * server + page.route mocks), drives a scripted timeline in the page, records - * it with Playwright video, and converts to GIF via ffmpeg palette encoding. - * - * Scenes: - * 1. subagent-demo.gif — terminal spawns 3 parallel agents; floating agent - * windows open one by one and stream tool-call activity live (driven - * through the real _onSubagentDiscovered/_onSubagentToolCall handlers). - * 2. zerolag-demo.gif — side-by-side typing: instant local echo (zerolag) - * vs bursty ~350 ms server echo, rendered with the vendored xterm. - * - * Usage: node scripts/capture-readme-gifs.mjs - * SCREENSHOT_OUT_DIR=/path/to/review node scripts/capture-readme-gifs.mjs - * Output: docs/images/ (or flat into SCREENSHOT_OUT_DIR) - * Requires: ffmpeg - */ - -import { chromium } from 'playwright'; -import { execSync } from 'child_process'; -import { mkdtempSync, rmSync } from 'fs'; -import { tmpdir } from 'os'; -import { join } from 'path'; -import { - PORT, - SESSION_IDS, - STANDARD_SESSIONS, - buildInitPayload, - startStaticServer, - setupRoutes, - injectState, - outPath, - RST, GRN, YEL, MAG, CYN, GRY, BOLD, -} from './capture-readme-screenshots.mjs'; - -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - -const GIF_COLORS = 192; - -// ─── ffmpeg conversion (palette recipe from capture-subagent-gif.mjs) ──────── - -function webmToGif(videoPath, gifPath, { ss, duration, width, fps }) { - // One GLOBAL palette (default stats_mode=full) + ordered dither: per-frame - // palettes (stats_mode=single:new=1) make dirty rectangles visibly mismatch - // on flat dark UI, and error-diffusion dither shimmers between frames. - const filters = `fps=${fps},scale=${width}:-1:flags=lanczos`; - execSync( - `ffmpeg -y -loglevel error -ss ${ss.toFixed(2)} -t ${duration} -i "${videoPath}" ` + - `-vf "${filters},split[s0][s1];[s0]palettegen=max_colors=${GIF_COLORS}:reserve_transparent=0[p];` + - `[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" "${gifPath}"`, - { stdio: 'inherit' } - ); -} - -// ─── Scene 1: subagent demo ────────────────────────────────────────────────── - -const SUBAGENT_VIEWPORT = { width: 1440, height: 810 }; - -// Terminal content visible before the agents spawn -const TERMINAL_PRESPAWN = [ - '', - `${GRN}●${RST} Working on ${CYN}/home/arkon/codeman-cases/testcase${RST} - I'll use the ${BOLD}Task tool${RST} to spawn parallel agents.`, - '', - `${GRN}●${RST} ${BOLD}Read${RST}(/home/arkon/codeman-cases/testcase/CLAUDE.md)`, - ` ${GRY}░${RST} Read ${BOLD}127${RST} lines ${GRY}│${RST} ${CYN}1.2KB${RST}`, - '', - `${GRN}●${RST} ${BOLD}Bash${RST}(find . -name "*.ts" -not -path "*/node_modules/*" | head -20)`, - ` ${GRY}░${RST} ./src/index.ts`, - ` ${GRY}░${RST} ./src/session.ts`, - ` ${GRY}░${RST} ./src/web/server.ts`, - ` ${GRY}░${RST} ${GRY}... (17 more)${RST}`, - '', - `${GRN}●${RST} I'll spawn 3 parallel research agents to analyze different parts of the codebase simultaneously.`, - '', -].join('\r\n'); - -function makeAgent(agentId, description, startedOffsetMs) { - return { - agentId, - sessionId: 'claude-sess-w1-0001', - projectHash: 'abc123', - filePath: `/tmp/${agentId}.jsonl`, - startedAt: new Date(Date.now() - startedOffsetMs).toISOString(), - lastActivityAt: Date.now(), - status: 'active', - toolCallCount: 0, - entryCount: 0, - fileSize: 4000, - description, - model: 'claude-haiku-4-5-20251001', - modelShort: 'haiku', - totalInputTokens: 0, - totalOutputTokens: 0, - parentSessionId: SESSION_IDS.w1, - }; -} - -// Timeline events: t (ms from scene start) + kind -// term — write raw data to the session terminal -// discover — register subagent + open + position its floating window -// tool — stream a tool call into an agent window -// msg — stream an assistant message into an agent window -// complete — flip an agent to completed -function buildSubagentTimeline() { - const T = (lines) => lines.join('\r\n') + '\r\n'; - const tool = (t, agentId, name, input) => ({ t, kind: 'tool', agentId, tool: name, input }); - const msg = (t, agentId, text) => ({ t, kind: 'msg', agentId, text }); - - return [ - { - t: 600, - kind: 'term', - data: T([ - `${GRN}●${RST} ${BOLD}Task${RST}(Find and document all API endpoints in src/)`, - ` ${GRY}░${RST} Spawned ${CYN}agent-001${RST} ${GRY}(haiku)${RST}`, - '', - ]), - }, - { - t: 1000, - kind: 'discover', - agent: makeAgent('agent-001', 'Find and document all API endpoints in src/', 2000), - x: 440, y: 45, - }, - tool(1500, 'agent-001', 'Glob', { pattern: 'src/**/*.ts' }), - { - t: 2000, - kind: 'term', - data: T([ - `${GRN}●${RST} ${BOLD}Task${RST}(Explore and understand test structure in test/)`, - ` ${GRY}░${RST} Spawned ${CYN}agent-002${RST} ${GRY}(haiku)${RST}`, - '', - ]), - }, - tool(2200, 'agent-001', 'Read', { file_path: '/home/arkon/codeman/src/web/server.ts' }), - { - t: 2500, - kind: 'discover', - agent: makeAgent('agent-002', 'Explore and understand test structure in test/', 1200), - x: 880, y: 45, - }, - tool(3000, 'agent-002', 'Glob', { pattern: 'test/**/*.test.ts' }), - { - t: 3300, - kind: 'term', - data: T([ - `${GRN}●${RST} ${BOLD}Task${RST}(Analyze TypeScript type definitions in src/types.ts)`, - ` ${GRY}░${RST} Spawned ${CYN}agent-003${RST} ${GRY}(haiku)${RST}`, - '', - ]), - }, - tool(3500, 'agent-001', 'Grep', { pattern: 'app\\.get|app\\.post|app\\.delete', path: 'src/' }), - { - t: 3800, - kind: 'discover', - agent: makeAgent('agent-003', 'Analyze TypeScript type definitions in src/types.ts', 400), - x: 660, y: 400, - }, - tool(4100, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/test/respawn-test-utils.ts' }), - { - t: 4500, - kind: 'term', - data: T([ - `${MAG}✻${RST} ${YEL}Waiting for agents...${RST} ${GRY}(${BOLD}esc${RST}${GRY} to interrupt · 32s · ↓ 1.7k tokens · thinking)${RST}`, - '', - ]), - }, - tool(4700, 'agent-003', 'Read', { file_path: '/home/arkon/codeman/src/types.ts' }), - tool(5200, 'agent-001', 'Read', { file_path: '/home/arkon/codeman/src/web/schemas.ts' }), - tool(5600, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/config/vitest.config.ts' }), - tool(6100, 'agent-003', 'Grep', { pattern: 'export (interface|type)', path: 'src/types/' }), - msg(6700, 'agent-001', 'Found 47 API endpoints across server.ts. Documenting REST paths...'), - tool(7100, 'agent-002', 'Grep', { pattern: 'const PORT =', path: 'test/' }), - msg(7700, 'agent-002', 'Analyzing test patterns: MockSession, unique ports, fileParallelism: false...'), - tool(8100, 'agent-003', 'Read', { file_path: '/home/arkon/codeman/src/types/index.ts' }), - msg(8700, 'agent-003', 'Mapped 38 exported interfaces across 15 domain files. Building summary...'), - { - t: 9300, - kind: 'term', - data: T([ - `${GRN}●${RST} ${CYN}agent-001${RST}: ${GRY}12 tool calls — Glob, Read(server.ts), Grep(endpoints)...${RST}`, - `${GRN}●${RST} ${CYN}agent-002${RST}: ${GRY}8 tool calls — Glob, Read(test-utils), Read(vitest.config)...${RST}`, - `${GRN}●${RST} ${CYN}agent-003${RST}: ${GRY}7 tool calls — Read(types.ts), Grep(interface)...${RST}`, - '', - ]), - }, - tool(10100, 'agent-001', 'Glob', { pattern: 'src/web/routes/*.ts' }), - tool(10600, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/test/setup.ts' }), - tool(11100, 'agent-003', 'Grep', { pattern: 'assertNever', path: 'src/' }), - { - t: 11600, - kind: 'term', - data: T([`${GRN}●${RST} ${GRY}171.8k, 13s${RST} ${GRY}│${RST} ${GRY}1.7k tokens${RST} ${GRY}│${RST} ${GRY}thinking${RST}`, '']), - }, - ]; -} - -const SUBAGENT_TAIL_HOLD = 2500; // hold the final frame - -async function recordSubagentScene(browser, videoDir) { - console.log('\n1/2 Recording subagent-demo...'); - - const context = await browser.newContext({ - viewport: SUBAGENT_VIEWPORT, - deviceScaleFactor: 1, - recordVideo: { dir: videoDir, size: SUBAGENT_VIEWPORT }, - }); - const recStart = Date.now(); - const page = await context.newPage(); - page.setDefaultTimeout(30000); - - // Start with NO subagents — they appear during the recording - const initPayload = buildInitPayload(STANDARD_SESSIONS); - await setupRoutes(page, initPayload, TERMINAL_PRESPAWN); - await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); - await injectState(page, initPayload, TERMINAL_PRESPAWN, SESSION_IDS.w1); - - await page.evaluate(() => { - try { window.app?.fitAddon?.fit(); } catch {} - window.app?.terminal?.scrollToBottom(); - }); - await sleep(500); - - const timeline = buildSubagentTimeline(); - const totalMs = Math.max(...timeline.map((e) => e.t)) + SUBAGENT_TAIL_HOLD; - const sceneStart = Date.now(); - - // Run the whole timeline inside the page so events interleave naturally - await page.evaluate((events) => { - const app = window.app; - for (const ev of events) { - setTimeout(() => { - try { - if (ev.kind === 'term') { - app.terminal.write(ev.data); - app.terminal.scrollToBottom(); - } else if (ev.kind === 'discover') { - app._onSubagentDiscovered(ev.agent); - app.openSubagentWindow(ev.agent.agentId); - // The spawn animation (400ms) lands on the auto-grid; glide to our tile after it - setTimeout(() => { - const win = app.subagentWindows.get(ev.agent.agentId); - if (win?.element) { - win.element.style.transition = 'left 0.25s ease, top 0.25s ease'; - win.element.style.left = `${ev.x}px`; - win.element.style.top = `${ev.y}px`; - } - }, 520); - setTimeout(() => { - const win = app.subagentWindows.get(ev.agent.agentId); - if (win?.element) win.element.style.transition = ''; - app.updateConnectionLines(); - }, 850); - } else if (ev.kind === 'tool') { - app._onSubagentToolCall({ - agentId: ev.agentId, - tool: ev.tool, - input: ev.input, - timestamp: new Date().toISOString(), - }); - } else if (ev.kind === 'msg') { - app._onSubagentMessage({ - agentId: ev.agentId, - role: 'assistant', - text: ev.text, - timestamp: new Date().toISOString(), - }); - } else if (ev.kind === 'complete') { - app._onSubagentCompleted({ agentId: ev.agentId, timestamp: new Date().toISOString() }); - } - } catch (err) { - console.error('timeline event failed', ev, err); - } - }, ev.t); - } - }, timeline); - - await sleep(totalMs + 500); - - await page.close(); - const videoPath = await page.video().path(); - await context.close(); - - return { - videoPath, - ss: (sceneStart - recStart) / 1000 - 0.4, - duration: (totalMs + 400) / 1000, - }; -} - -// ─── Scene 2: zerolag typing comparison ────────────────────────────────────── - -const ZEROLAG_VIEWPORT = { width: 1280, height: 470 }; -const TYPED_TEXT = 'echo "zero lag typing from anywhere"'; -const TYPE_INTERVAL_MS = 110; -const REMOTE_FLUSH_MS = 350; // server-echo pane flushes queued chars in bursts -const ZEROLAG_TAIL_HOLD = 1800; - -const ZEROLAG_HTML = ` - - - - - - - -
-
-
- - With zerolag-input - instant local echo -
-
-
-
keystrokes echo in 0 ms
-
-
-
-
- - Without - server round-trip echo -
-
-
-
keystrokes echo after ~350 ms
-
- -`; - -async function recordZerolagScene(browser, videoDir) { - console.log('\n2/2 Recording zerolag-demo...'); - - const context = await browser.newContext({ - viewport: ZEROLAG_VIEWPORT, - deviceScaleFactor: 1, - recordVideo: { dir: videoDir, size: ZEROLAG_VIEWPORT }, - }); - const recStart = Date.now(); - const page = await context.newPage(); - page.setDefaultTimeout(30000); - - await page.setContent(ZEROLAG_HTML, { waitUntil: 'load' }); - await page.waitForFunction(() => typeof Terminal !== 'undefined'); - - await page.evaluate(() => { - const theme = { - background: '#131316', - foreground: '#e8e8ea', - cursor: '#22c55e', - cursorAccent: '#131316', - }; - const mk = (id) => { - const term = new Terminal({ - cols: 44, - rows: 5, - fontSize: 20, - fontFamily: "'SF Mono', 'Cascadia Code', Menlo, monospace", - cursorBlink: true, - cursorStyle: 'block', - theme, - }); - term.open(document.getElementById(id)); - term.write('\x1b[32m❯\x1b[0m '); - return term; - }; - window.termLeft = mk('termLeft'); - window.termRight = mk('termRight'); - }); - await sleep(600); - - const sceneStart = Date.now(); - const typingMs = TYPED_TEXT.length * TYPE_INTERVAL_MS; - const totalMs = typingMs + REMOTE_FLUSH_MS + ZEROLAG_TAIL_HOLD; - - await page.evaluate( - ({ text, interval, flushEvery }) => { - let i = 0; - const remoteQueue = []; - const typer = setInterval(() => { - if (i >= text.length) { clearInterval(typer); return; } - const ch = text[i++]; - window.termLeft.write(ch); // local echo: instant - remoteQueue.push(ch); // server echo: waits for the round-trip - }, interval); - const flusher = setInterval(() => { - if (remoteQueue.length) window.termRight.write(remoteQueue.splice(0).join('')); - if (i >= text.length && remoteQueue.length === 0) clearInterval(flusher); - }, flushEvery); - }, - { text: TYPED_TEXT, interval: TYPE_INTERVAL_MS, flushEvery: REMOTE_FLUSH_MS } - ); - - await sleep(totalMs + 400); - - await page.close(); - const videoPath = await page.video().path(); - await context.close(); - - return { - videoPath, - ss: (sceneStart - recStart) / 1000 - 0.6, // small lead-in with idle cursors - duration: (totalMs + 600) / 1000, - }; -} - -// ─── Main ──────────────────────────────────────────────────────────────────── - -async function main() { - console.log('='.repeat(60)); - console.log('Codeman README GIF Capture'); - console.log('='.repeat(60)); - - const server = await startStaticServer(); - const videoDir = mkdtempSync(join(tmpdir(), 'codeman-gifs-')); - let browser; - - try { - browser = await chromium.launch({ - headless: true, - args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'], - }); - - const sub = await recordSubagentScene(browser, videoDir); - const subGif = outPath('images', 'subagent-demo.gif'); - webmToGif(sub.videoPath, subGif, { ss: Math.max(0, sub.ss), duration: sub.duration, width: 960, fps: 8 }); - console.log(` Saved: ${subGif}`); - - const zl = await recordZerolagScene(browser, videoDir); - const zlGif = outPath('images', 'zerolag-demo.gif'); - webmToGif(zl.videoPath, zlGif, { ss: Math.max(0, zl.ss), duration: zl.duration, width: 900, fps: 10 }); - console.log(` Saved: ${zlGif}`); - - console.log('\nDone.'); - } catch (err) { - console.error('\nFatal error:', err.message); - console.error(err.stack); - process.exitCode = 1; - } finally { - if (browser) await browser.close().catch(() => {}); - server.close(); - rmSync(videoDir, { recursive: true, force: true }); - } -} - -process.on('SIGINT', () => process.exit(1)); - -main(); diff --git a/scripts/capture-readme-real.mjs b/scripts/capture-readme-real.mjs deleted file mode 100644 index 2c4c8bf5..00000000 --- a/scripts/capture-readme-real.mjs +++ /dev/null @@ -1,253 +0,0 @@ -#!/usr/bin/env node -/** - * capture-readme-real.mjs - * - * Captures README desktop scenes (multi-session dashboard, monitor, subagent - * windows) from a REAL Codeman instance — intended to run against an ISOLATED - * dev/beta instance (CODEMAN_INSTANCE=beta on :5000) seeded from prod's settings, - * NOT prod itself (never touch prod's live sessions). - * - * Reuses the high-quality capture recipe proven in capture-real-overview.mjs: - * - DSF=2 + ?nowebgl → crisp retina at the TRUE font size (WebGL doubles - * glyphs under DSF=2; the DOM renderer respects devicePixelRatio). - * - per-device localStorage seeding so the capture matches a real device. - * - * SCENE=dashboard|monitor|subagent|all BASE=http://localhost:5000 \ - * OUT=screenshots-readme-real/desktop node scripts/capture-readme-real.mjs - */ -import { chromium } from 'playwright'; -import { mkdirSync } from 'fs'; -import { join } from 'path'; - -const BASE = process.env.BASE || 'http://localhost:5000'; -const OUT = process.env.OUT || 'screenshots-readme-real/desktop'; -const SKIN = process.env.SKIN || 'daylight-blue'; -const SCENE = process.env.SCENE || 'all'; -const FONT = Math.max(10, Math.min(24, Number(process.env.FONT || 13))); -const VIEWPORT = { width: Number(process.env.VW || 1280), height: Number(process.env.VH || 720) }; -const DSF = Number(process.env.DSF || 2); -const PLAN_USAGE = process.env.PLAN_USAGE !== '0'; - -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -const url = (extra = '') => { - const sep = BASE.includes('?') ? '&' : '?'; - const params = []; - if (DSF > 1) params.push('nowebgl'); // DOM renderer → correct font size at DSF>1 - if (extra) params.push(extra); - return params.length ? `${BASE}${sep}${params.join('&')}` : BASE; -}; - -async function newCtx(browser) { - const context = await browser.newContext({ - viewport: VIEWPORT, - deviceScaleFactor: DSF, - ignoreHTTPSErrors: BASE.startsWith('https'), - }); - const page = await context.newPage(); - page.setDefaultTimeout(30000); - await page.addInitScript( - ([skin, planUsage, font]) => { - try { - localStorage.setItem('codeman:skin', skin); - localStorage.setItem('codeman-font-size', String(font)); - const blob = { skin, showFileBrowser: false, showProjectInsights: false, showTokenCount: false }; - // Don't auto-hide subagent windows that belong to a non-active tab — the - // subagent scene re-homes agents and needs both windows visible at once. - blob.subagentActiveTabOnly = false; - if (planUsage) blob.showPlanUsageLimits = true; - localStorage.setItem('codeman-app-settings', JSON.stringify(blob)); - } catch { - /* ignore */ - } - }, - [SKIN, PLAN_USAGE, FONT] - ); - return { context, page }; -} - -async function bootstrap(page) { - await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 20000 }); - await sleep(1200); -} - -async function listSessions(page) { - return page.evaluate(() => - Array.from(window.app.sessions.values()).map((s) => ({ id: s.id, name: s.name, mode: s.mode })) - ); -} - -async function shoot(page, name) { - const out = join(OUT, name); - await page.evaluate((f) => { - try { - if (window.app.setFontSize) window.app.setFontSize(f); - } catch {} - try { - window.app.fitAddon && window.app.fitAddon.fit(); - } catch {} - try { - window.app.applyHeaderVisibilitySettings && window.app.applyHeaderVisibilitySettings(); - } catch {} - }, FONT); - await sleep(1500); - await page.screenshot({ path: out, fullPage: false }); - console.log(' Saved: ' + out); -} - -async function sceneDashboard(browser) { - console.log('Scene: dashboard'); - const { context, page } = await newCtx(browser); - await page.goto(url(), { waitUntil: 'domcontentloaded' }); - await bootstrap(page); - const sessions = await listSessions(page); - // Select a claude session so the active terminal shows rich content; all tabs render. - const target = sessions.find((s) => s.mode === 'claude') || sessions[0]; - if (target) await page.evaluate((id) => window.app.selectSession(id), target.id); - await sleep(4000); - await shoot(page, 'multi-session-dashboard.png'); - await context.close(); -} - -async function sceneMonitor(browser) { - console.log('Scene: monitor'); - const { context, page } = await newCtx(browser); - await page.goto(url(), { waitUntil: 'domcontentloaded' }); - await bootstrap(page); - const sessions = await listSessions(page); - const target = sessions.find((s) => s.mode === 'claude') || sessions[0]; - if (target) await page.evaluate((id) => window.app.selectSession(id), target.id); - await sleep(2500); - // toggleMonitorPanel() opens the panel, clears the hidden state, loads REAL - // mux sessions (/api/mux), starts stats, and renders the task panel. - await page.evaluate(async () => { - try { - await window.app.toggleMonitorPanel(); - } catch {} - }); - await sleep(3000); - await shoot(page, 'multi-session-monitor.png'); - await context.close(); -} - -async function sceneSubagent(browser) { - console.log('Scene: subagent'); - const { context, page } = await newCtx(browser); - await page.goto(url(), { waitUntil: 'domcontentloaded' }); - await bootstrap(page); - // Select the session whose subagents we want (subagentActiveTabOnly means - // app.subagents only fills for the active tab). Prefer SUBAGENT_SID env. - const sessions = await listSessions(page); - const targetId = process.env.SUBAGENT_SID || (sessions.find((s) => s.mode === 'claude') || sessions[0])?.id; - if (targetId) await page.evaluate((id) => window.app.selectSession(id), targetId); - // Wait (up to ~45s) for live subagents to arrive via SSE into app.subagents. - let agents = []; - for (let i = 0; i < 45; i++) { - agents = await page.evaluate(() => - Array.from(window.app.subagents?.entries?.() || []).map(([id, a]) => ({ id, name: a.name ?? a.agentType ?? '' })) - ); - if (agents.length >= 1) break; - await sleep(1000); - } - console.log(' live in-browser subagents:', JSON.stringify(agents)); - if (agents.length === 0) { - console.log(' NO live subagents — skipping (stage a longer subagent task and run this while it runs).'); - await context.close(); - return; - } - // The window body renders from app.subagentActivity, which fills ONLY from live - // SSE tool-call/progress events — a fresh client never gets past activity replayed. - // So sit connected and wait for live activity to accumulate, then open the two - // agents that actually have content (otherwise the windows read "No activity yet"). - let active = []; - for (let i = 0; i < 100; i++) { - active = await page.evaluate(() => - Array.from(window.app.subagentActivity?.entries?.() || []) - .filter(([, arr]) => Array.isArray(arr) && arr.length >= 1) - .map(([id, arr]) => ({ id, n: arr.length })) - .sort((a, b) => b.n - a.n) - ); - if (active.length >= 2) break; - // xhigh-effort agents churn in bursts between long thinking pauses, so be - // patient (~150s); accept a single populated window after ~45s if that's all. - if (i >= 30 && active.length >= 1) break; - await sleep(1500); - } - console.log(' agents with live activity:', JSON.stringify(active)); - const openIds = (active.length ? active : agents).map((a) => a.id); - // Capture-only DOM nudge: on fresh dev sessions, a tab's claudeSessionId stays the - // Codeman id and never becomes the real Claude conversation UUID, so the window - // open-gate (claudeSessionId === agent.sessionId) + the activeTabOnly hide rule both - // fail. Re-home the chosen agents onto the active tab and align its claudeSessionId - // to the agents' (shared) sessionId so the windows open AND show their live activity. - await page.evaluate( - (ids) => { - const activeId = window.app.activeSessionId; - const tab = window.app.sessions.get(activeId); - ids.slice(0, 2).forEach((id) => { - const a = window.app.subagents.get(id); - if (!a) return; - a.parentSessionId = activeId; - if (tab && a.sessionId) tab.claudeSessionId = a.sessionId; - }); - }, - openIds - ); - await page.evaluate( - (ids) => { - ids.slice(0, 2).forEach((id) => { - try { - window.app.openSubagentWindow(id); - } catch {} - }); - }, - openIds - ); - await sleep(2000); - await page.evaluate(() => { - // Viewport-relative tiling: center two subagent windows over the terminal so - // the layout adapts to whatever VW/VH the capture uses (e.g. the HQ 1100×650 - // recipe) instead of overflowing at narrower widths. - const wins = Array.from(window.app.subagentWindows.values()); - const W = window.innerWidth; - const H = window.innerHeight; - const winW = Math.min(440, Math.floor((W - 60) / 2 - 10)); - const winH = Math.min(360, Math.floor(H * 0.56)); - const top = Math.floor(H * 0.16); - const gap = 16; - const totalW = winW * 2 + gap; - const startLeft = Math.max(16, Math.floor((W - totalW) / 2)); - wins.slice(0, 2).forEach((win, i) => { - const el = win.element; - // Force visible: a freshly opened window may be hidden by the activeTabOnly - // rule before we override it (we also seed subagentActiveTabOnly:false). - win.hidden = false; - win.minimized = false; - el.style.display = 'flex'; - el.style.left = startLeft + i * (winW + gap) + 'px'; - el.style.top = top + 'px'; - el.style.width = winW + 'px'; - el.style.height = winH + 'px'; - }); - }); - await sleep(1500); - await shoot(page, 'subagent-spawn.png'); - await context.close(); -} - -async function main() { - mkdirSync(OUT, { recursive: true }); - const browser = await chromium.launch({ - headless: true, - args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'], - }); - console.log(`BASE=${BASE} SKIN=${SKIN} DSF=${DSF} VIEWPORT=${VIEWPORT.width}x${VIEWPORT.height} SCENE=${SCENE}`); - if (SCENE === 'dashboard' || SCENE === 'all') await sceneDashboard(browser); - if (SCENE === 'monitor' || SCENE === 'all') await sceneMonitor(browser); - if (SCENE === 'subagent' || SCENE === 'all') await sceneSubagent(browser); - await browser.close(); -} - -main().catch((e) => { - console.error('FATAL', e.message); - process.exit(1); -}); diff --git a/scripts/capture-readme-skin.mjs b/scripts/capture-readme-skin.mjs deleted file mode 100644 index 0c1f0c17..00000000 --- a/scripts/capture-readme-skin.mjs +++ /dev/null @@ -1,1074 +0,0 @@ -#!/usr/bin/env node - -/** - * capture-readme-screenshots.mjs - * - * Captures deterministic README screenshots using Playwright + page.route() mock injection. - * No real Claude CLI or server needed — all API responses are mocked. - * - * Usage: node scripts/capture-readme-screenshots.mjs - * Port: 3199 (static file server) - * Output: docs/images/ and docs/screenshots/ - */ - -import { chromium } from 'playwright'; -import { createServer } from 'http'; -import { readFileSync, existsSync } from 'fs'; -import { join, extname } from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = fileURLToPath(new URL('.', import.meta.url)); -const PROJECT_ROOT = join(__dirname, '..'); -const PUBLIC_DIR = join(PROJECT_ROOT, 'src', 'web', 'public'); - -// Non-destructive review run: which skin to force + where to write. -// Does NOT touch docs/images or docs/screenshots. Override via env. -const SKIN = process.env.SKIN || 'daylight-blue'; -const OUT_DIR = process.env.SCREENSHOT_OUT_DIR || join(PROJECT_ROOT, 'screenshots-readme', SKIN); -const PORT = 3199; -const VIEWPORT = { width: 1280, height: 720 }; -const DEVICE_SCALE_FACTOR = 1; - -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - -// ─── MIME Types ────────────────────────────────────────────────────────────── - -const MIME_TYPES = { - '.html': 'text/html', - '.js': 'text/javascript', - '.css': 'text/css', - '.json': 'application/json', - '.png': 'image/png', - '.svg': 'image/svg+xml', - '.ico': 'image/x-icon', - '.woff2': 'font/woff2', - '.woff': 'font/woff', - '.ttf': 'font/ttf', -}; - -// ─── Static File Server ────────────────────────────────────────────────────── - -function startStaticServer() { - return new Promise((resolve) => { - const server = createServer((req, res) => { - let urlPath = req.url.split('?')[0]; - if (urlPath === '/') urlPath = '/index.html'; - - const filePath = join(PUBLIC_DIR, urlPath); - - if (!existsSync(filePath)) { - res.writeHead(404); - res.end('Not Found'); - return; - } - - try { - const data = readFileSync(filePath); - const ext = extname(filePath); - res.writeHead(200, { - 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream', - 'Cache-Control': 'no-cache', - }); - res.end(data); - } catch { - res.writeHead(500); - res.end('Internal Server Error'); - } - }); - - server.listen(PORT, () => { - console.log(`Static server on http://localhost:${PORT}`); - resolve(server); - }); - }); -} - -// ─── Mock Data ─────────────────────────────────────────────────────────────── - -const SESSION_IDS = { - w1: 'sess-w1-0001', - w3: 'sess-w3-0003', - w4: 'sess-w4-0004', - w5: 'sess-w5-0005', - s1: 'sess-s1-0006', - s2: 'sess-s2-0007', - s3: 'sess-s3-0008', -}; - -const RALPH_SESSION_ID = 'sess-ralph-demo'; -const RALPH_SHELL_ID = 'sess-ralph-shell'; - -function makeSession(id, name, mode, status, extra = {}) { - return { - id, - pid: status === 'idle' ? null : 12345 + Math.floor(Math.random() * 10000), - status, - workingDir: '/home/arkon/codeman-cases/testcase', - currentTaskId: null, - createdAt: Date.now() - 3600000, - lastActivityAt: Date.now() - (status === 'idle' ? 60000 : 5000), - name, - mode, - autoClearEnabled: false, - autoClearThreshold: 140000, - autoCompactEnabled: false, - autoCompactThreshold: 110000, - autoCompactPrompt: '', - imageWatcherEnabled: false, - totalCost: mode === 'claude' ? 0.12 : 0, - inputTokens: mode === 'claude' ? 18000 : 0, - outputTokens: mode === 'claude' ? 11500 : 0, - ralphEnabled: false, - niceEnabled: false, - niceValue: 10, - color: 'default', - flickerFilterEnabled: false, - cliVersion: '2.1.177', - cliModel: 'Opus 4.8', - cliAccountType: 'Claude Max', - cliLatestVersion: '2.1.177', - messageCount: mode === 'claude' ? 15 : 0, - isWorking: status === 'busy', - lastPromptTime: Date.now() - 30000, - bufferStats: { - terminalBufferSize: 4096, - textOutputSize: 2048, - messageCount: 15, - maxTerminalBuffer: 2097152, - maxTextOutput: 1048576, - maxMessages: 1000, - }, - taskStats: { total: 0, running: 0, completed: 0, failed: 0 }, - taskTree: [], - tokens: { - input: mode === 'claude' ? 18000 : 0, - output: mode === 'claude' ? 11500 : 0, - total: mode === 'claude' ? 29500 : 0, - }, - autoClear: { enabled: false, threshold: 140000 }, - nice: { enabled: false, niceValue: 10 }, - ralphLoop: null, - ralphTodos: [], - ralphTodoStats: { total: 0, completed: 0, percentComplete: 0 }, - respawnEnabled: false, - respawnConfig: null, - respawn: null, - claudeSessionId: `claude-${id}`, - ...extra, - }; -} - -// Standard session set — kept small (4 tabs) so the header isn't crowded. -// w1/w3/w4 are the sessions the scenes select; s1 adds a shell tab for variety. -const STANDARD_SESSIONS = [ - makeSession(SESSION_IDS.w1, 'w1-testcase', 'claude', 'busy'), - makeSession(SESSION_IDS.w3, 'w3-testcase', 'claude', 'busy'), - makeSession(SESSION_IDS.w4, 'w4-testcase', 'claude', 'idle'), - makeSession(SESSION_IDS.s1, 's1-testcase', 'shell', 'busy'), -]; - -// Ralph demo sessions (2 tabs) -const RALPH_SESSIONS = [ - makeSession(RALPH_SESSION_ID, 'ralph-8tasks-demo', 'claude', 'busy', { - ralphEnabled: true, - tokens: { input: 22000, output: 15300, total: 37300 }, - inputTokens: 22000, - outputTokens: 15300, - totalCost: 0.28, - ralphLoop: { - enabled: true, - active: true, - completionPhrase: 'ALL_TASKS_DONE', - startedAt: Date.now() - 60000, - cycleCount: 3, - maxIterations: null, - elapsedHours: null, - }, - ralphTodos: [ - { id: '1', text: 'Add TypeScript types to all functions', status: 'completed' }, - { id: '2', text: 'Add input validation with proper error messages', status: 'completed' }, - { id: '3', text: 'Add JSDoc documentation to each function', status: 'completed' }, - { id: '4', text: 'Add unit tests for formatDate and parseJSON', status: 'in_progress' }, - { id: '5', text: 'Add unit tests for debounce and deepClone', status: 'pending' }, - { id: '6', text: 'Add unit tests for slugify and truncate', status: 'pending' }, - { id: '7', text: 'Add unit tests for randomId and groupBy', status: 'pending' }, - { id: '8', text: 'Create an index.ts that exports all utilities', status: 'pending' }, - { id: '9', text: 'Add TypeScript types to all functions', status: 'completed' }, - ], - ralphTodoStats: { total: 9, completed: 4, percentComplete: 44 }, - }), - makeSession(RALPH_SHELL_ID, 's1-demo-testing', 'shell', 'busy'), -]; - -// Mock mux (tmux) sessions for Monitor panel -const MUX_SESSIONS = [ - { - sessionId: 'mux-w1', - name: 'w1-testcase', - muxName: 'w1-testcase', - mode: 'claude', - pid: 292239, - stats: { memoryMB: 2.3, cpuPercent: 0, childCount: 1 }, - }, - { - sessionId: 'mux-w3', - name: 'w3-testcase', - muxName: 'w3-testcase', - mode: 'claude', - pid: 292394, - stats: { memoryMB: 2.4, cpuPercent: 0.1, childCount: 1 }, - }, - { - sessionId: 'mux-w4', - name: 'w4-testcase', - muxName: 'w4-testcase', - mode: 'claude', - pid: 292497, - stats: { memoryMB: 2.4, cpuPercent: 0, childCount: 1 }, - }, -]; - -// Mock subagents for subagent-spawn screenshot -const MOCK_SUBAGENTS = [ - { - agentId: 'agent-001', - sessionId: 'claude-sess-w1-0001', - projectHash: 'abc123', - filePath: '/tmp/agent-001.jsonl', - startedAt: new Date(Date.now() - 120000).toISOString(), - lastActivityAt: Date.now() - 5000, - status: 'active', - toolCallCount: 12, - entryCount: 45, - fileSize: 32000, - description: 'Find and document all API endpoints in src/', - model: 'claude-haiku-4-5-20251001', - modelShort: 'haiku', - totalInputTokens: 15000, - totalOutputTokens: 8000, - parentSessionId: SESSION_IDS.w1, - }, - { - agentId: 'agent-002', - sessionId: 'claude-sess-w1-0001', - projectHash: 'abc123', - filePath: '/tmp/agent-002.jsonl', - startedAt: new Date(Date.now() - 90000).toISOString(), - lastActivityAt: Date.now() - 3000, - status: 'active', - toolCallCount: 8, - entryCount: 30, - fileSize: 22000, - description: 'Explore and understand test structure in test/', - model: 'claude-haiku-4-5-20251001', - modelShort: 'haiku', - totalInputTokens: 12000, - totalOutputTokens: 6000, - parentSessionId: SESSION_IDS.w1, - }, - { - agentId: 'agent-003', - sessionId: 'claude-sess-w1-0001', - projectHash: 'abc123', - filePath: '/tmp/agent-003.jsonl', - startedAt: new Date(Date.now() - 60000).toISOString(), - lastActivityAt: Date.now() - 8000, - status: 'active', - toolCallCount: 5, - entryCount: 18, - fileSize: 14000, - description: 'Analyze TypeScript type definitions in src/types.ts', - model: 'claude-haiku-4-5-20251001', - modelShort: 'haiku', - totalInputTokens: 8000, - totalOutputTokens: 4000, - parentSessionId: SESSION_IDS.w1, - }, -]; - -function buildInitPayload(sessions, subagents = []) { - const respawnStatus = {}; - for (const s of sessions) { - respawnStatus[s.id] = { - state: 'idle', - cycleCount: 0, - lastActivityTime: Date.now(), - timeSinceActivity: 0, - promptDetected: false, - workingDetected: false, - detection: {}, - config: null, - }; - } - - return { - version: '0.1556', - sessions, - scheduledRuns: [], - respawnStatus, - globalStats: { - totalInputTokens: 145000, - totalOutputTokens: 87000, - totalCost: 1.82, - totalSessionsCreated: 12, - firstRecordedAt: Date.now() - 86400000, - lastUpdatedAt: Date.now(), - }, - subagents, - timestamp: Date.now(), - }; -} - -// ─── Terminal Content (ANSI) ───────────────────────────────────────────────── - -// Colors -const RST = '\x1b[0m'; -const RED = '\x1b[31m'; -const GRN = '\x1b[32m'; -const YEL = '\x1b[33m'; -const BLU = '\x1b[34m'; -const MAG = '\x1b[35m'; -const CYN = '\x1b[36m'; -const GRY = '\x1b[90m'; -const WHT = '\x1b[37m'; -const BOLD = '\x1b[1m'; -const DIM = '\x1b[2m'; - -// Claude Code init banner (matching the overview screenshot) -// Claude TUI input-box border — spans nearly the full terminal width (~158 cols -// at 1280px) so the input box and status reach the right edge like the wrapped -// conversation above. A short rule leaves an empty bottom-right corner. -const BOX = '─'.repeat(157); - -// Right-align `right` against `left` across `width` cols, ignoring ANSI SGR codes -// for length — used to push token/version status to the right edge of the footer. -// 157 ≈ the terminal's full col count (158 at 1280px), so the rule + status reach -// the same right edge the wrapped conversation does (no lower-right sliver). -const visibleLen = (s) => s.replace(/\x1b\[[0-9;]*m/g, '').length; -const spread = (left, right, width = 157) => - left + ' '.repeat(Math.max(2, width - visibleLen(left) - visibleLen(right))) + right; - -// Claude scenes are { body, footer }: `body` scrolls from the top, `footer` -// (the input box + status bar) is pinned to the bottom of the terminal by -// injectState(), with blank padding filling the gap — exactly like the real -// Claude Code TUI. This keeps every screenshot looking full-height regardless -// of how short the scripted conversation is. -const TERMINAL_INIT = { - body: [ - '', - `${GRN}●${RST} I'll implement the utility module end-to-end — strict TypeScript types on every export, runtime input validation with clear error messages, JSDoc with examples, and a full Vitest suite. Let me read the current file and the test setup first so I match the existing conventions.`, - '', - `${GRN}●${RST} ${BOLD}Read${RST}(src/utils.ts)`, - ` ${GRY}░${RST} Read ${BOLD}84${RST} lines ${GRY}·${RST} exports: formatDate, parseJSON, debounce, deepClone, slugify, truncate, randomId, groupBy`, - '', - `${GRN}●${RST} ${BOLD}Read${RST}(config/vitest.config.ts)`, - ` ${GRY}░${RST} Read ${BOLD}41${RST} lines ${GRY}·${RST} globals enabled, node environment, coverage via v8`, - '', - `${GRN}●${RST} The functions currently accept ${BOLD}any${RST} and assume well-formed input. I'll add explicit parameter and return types, guard every one against null/undefined with descriptive errors, and document the expected shapes with JSDoc examples.`, - '', - `${GRN}●${RST} ${BOLD}Edit${RST}(src/utils.ts)`, - ` ${GRY}░${RST} Updated with ${GRN}+42${RST} ${GRY}/${RST} ${RED}-6${RST} ${GRY}·${RST} src/utils.ts is now ${BOLD}126${RST} lines, fully typed and validated`, - '', - `${GRN}●${RST} ${BOLD}Write${RST}(src/index.ts)`, - ` ${GRY}░${RST} Wrote ${BOLD}11${RST} lines ${GRY}·${RST} barrel file re-exporting every utility for one clean import surface`, - '', - `${GRN}●${RST} ${BOLD}Write${RST}(test/utils.test.ts)`, - ` ${GRY}░${RST} Wrote ${BOLD}96${RST} lines ${GRY}·${RST} 4 describe blocks, 18 assertions covering happy paths and edge cases`, - '', - `${GRN}●${RST} ${BOLD}Bash${RST}(npm test -- utils.test.ts)`, - ` ${GRY}░${RST} ${GRN}✓${RST} formatDate ${GRY}(3ms)${RST} ${GRN}✓${RST} parseJSON ${GRY}(1ms)${RST} ${GRN}✓${RST} debounce ${GRY}(12ms)${RST} ${GRN}✓${RST} deepClone ${GRY}(2ms)${RST} ${GRN}✓${RST} slugify ${GRY}(1ms)${RST}`, - ` ${GRY}░${RST} ${GRN}✓${RST} truncate ${GRY}(1ms)${RST} ${GRN}✓${RST} randomId ${GRY}(1ms)${RST} ${GRN}✓${RST} groupBy ${GRY}(2ms)${RST} ${GRY}·${RST} ${BOLD}Tests${RST} ${GRN}8 passed (8)${RST} ${GRY}·${RST} ${GRY}Duration 312ms${RST}`, - '', - `${GRN}●${RST} All green. The utilities now have strict types across all 8 exports, runtime validation with descriptive errors, JSDoc usage examples, a barrel export in src/index.ts, and 8 passing unit tests.`, - '', - `${GRN}●${RST} Done — fully typed, validated, documented, re-exported, and covered by green tests. Want me to wire these into the existing modules next, or add property-based tests with fast-check?`, - ], - footer: [ - BOX, - `${GRY}❯${RST} now add property-based tests with fast-check for parseJSON and deepClone█`, - BOX, - ` ${GRY}Opus 4.8 (1M context)${RST} ${DIM}·${RST} ${GRY}in: 84,712 out: 1,203 ctx: 21%${RST}`, - spread( - ` ${YEL}»»${RST} ${BOLD}bypass permissions on${RST} ${GRY}(shift+tab to cycle)${RST}`, - `${BOLD}84.7k tokens${RST}` - ), - spread('', `${GRY}current: 2.1.177${RST} ${DIM}·${RST} ${GRY}latest: 2.1.177${RST}`), - ], -}; - -// Claude working output (for multi-session-dashboard / subagent-spawn) -const TERMINAL_WORKING = { - body: [ - '', - `${GRN}●${RST} The codebase is essentially a template/scaffold with just a CLAUDE.md file and empty ${BOLD}src/${RST} directory. Since there's already a comprehensive`, - `CLAUDE.md file present, let me read it fully and suggest improvements based on the /init command requirements:`, - '', - `${GRN}●${RST} ${BOLD}Read${RST}(/home/arkon/codeman-cases/testcase/CLAUDE.md)`, - ` ${GRY}░${RST} Read ${BOLD}407${RST} lines`, - '', - `${GRN}●${RST} ${BOLD}Bash${RST}(find . -maxdepth 2 -type f -not -path "*/node_modules/*" | head)`, - ` ${GRY}░${RST} ./CLAUDE.md ${GRY}·${RST} ./package.json ${GRY}·${RST} ./tsconfig.json ${GRY}·${RST} ${GRY}(src/ is empty)${RST}`, - '', - `${GRN}●${RST} Now I understand the situation. This is a ${BOLD}template CLAUDE.md file${RST} designed for the Claude Code framework with the "Ralph Loop" autonomous work`, - `mode. The current file is very comprehensive for workflow instructions but lacks the key technical information that /init is supposed to generate:`, - '', - `1. ${BOLD}No build/test/lint commands${RST} - The file references npm test, npm run lint, npm run build but these are generic examples, not project-specific`, - `2. ${BOLD}No architecture documentation${RST} - The session log mentions files that don't exist in the empty repo`, - `3. ${BOLD}Placeholder tech stack${RST} - Still shows [TECHNOLOGIES_USED]`, - `4. ${BOLD}Too much workflow/process documentation${RST} - Much of this duplicates Claude Code's built-in behavior`, - '', - `The /init command is asking me to create a CLAUDE.md focused on ${BOLD}codebase-specific guidance${RST} (commands, architecture), not workflow processes. Since`, - `this is essentially an empty project, I should create a minimal CLAUDE.md that:`, - `1. Has the required header`, - `2. Notes that the project needs initialization`, - `3. Removes the verbose workflow documentation that's either built-in to Claude Code or should be in separate docs`, - '', - `Let me create an improved, streamlined CLAUDE.md appropriate for this empty project state.`, - '', - `${MAG}✻${RST} ${YEL}Caramelizing...${RST} ${GRY}(${BOLD}esc${RST}${GRY} to interrupt · 54s · ↓ 2.1k tokens)${RST}`, - ], - footer: [ - BOX, - `${GRY}❯${RST} █`, - BOX, - ` ${GRY}Opus 4.8 (1M context)${RST} ${DIM}·${RST} ${GRY}in: 28,163 out: 2,104 ctx: 14%${RST}`, - spread( - ` ${YEL}»»${RST} ${BOLD}bypass permissions on${RST} ${GRY}(shift+tab to cycle)${RST}`, - `${BOLD}28.1k tokens${RST}` - ), - spread('', `${GRY}current: 2.1.177${RST} ${DIM}·${RST} ${GRY}latest: 2.1.177${RST}`), - ], -}; - -// Ralph terminal content (matching ralph-tracker screenshot) -const TERMINAL_RALPH = { - body: [ - '', - `${GRN}●${RST} ${BOLD}Search${RST}(pattern: "**/*.test.ts")`, - ` ${GRY}░${RST} Found ${BOLD}0${RST} files`, - '', - `${GRN}●${RST} ${BOLD}Search${RST}(pattern: "**/test/**")`, - ` ${GRY}░${RST} Found ${BOLD}0${RST} files`, - '', - `${GRN}●${RST} ${BOLD}Search${RST}(pattern: "**/package.json")`, - ` ${GRY}░${RST} Found ${BOLD}0${RST} files`, - '', - `${MAG}✻${RST} ${YEL}Adding unit tests for formatDate and parseJSON...${RST} ${GRY}(${BOLD}esc${RST}${GRY} to interrupt · ${BOLD}ctrl+t${RST}${GRY} to hide todos · 1m 27s · ↓ 5.8k tokens · thinking)${RST}`, - ` ${GRY}░${RST} ${GRY}☒${RST} Add TypeScript types to all functions`, - ` ${GRY}☒${RST} Add input validation with proper error messages`, - ` ${GRY}☒${RST} Add JSDoc documentation to each function`, - ` ${GRY}░${RST} ${BOLD}Add unit tests for formatDate and parseJSON${RST}`, - ` ${GRY}░${RST} ☐ Add unit tests for debounce and deepClone`, - ` ${GRY}░${RST} ☐ Add unit tests for slugify and truncate`, - ` ${GRY}░${RST} ☐ Add unit tests for randomId and groupBy`, - ` ${GRY}░${RST} ☐ Create an index.ts that exports all utilities`, - ], - footer: [ - BOX, - `${GRY}❯${RST} █`, - '', - BOX, - ` ${YEL}»»${RST} ${BOLD}bypass permissions on${RST} ${GRY}(shift+tab to cycle)${RST}`, - ` ${BOLD}37267 tokens${RST}`, - ` ${GRY}current: 2.1.177${RST} ${DIM}·${RST} ${GRY}latest: 2.1.177${RST}`, - ], -}; - -// Subagent window content (tool call activity) -const SUBAGENT_ACTIVITY = { - 'agent-001': [ - { type: 'tool', tool: 'Glob', input: { pattern: 'src/**/*.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-001' }, - { type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/src/web/server.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-001' }, - { type: 'tool', tool: 'Grep', input: { pattern: 'app\\.get|app\\.post|app\\.put|app\\.delete', path: 'src/' }, timestamp: new Date().toISOString(), agentId: 'agent-001' }, - { type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/src/web/schemas.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-001' }, - { type: 'message', role: 'assistant', text: 'Found 47 API endpoints across server.ts. Documenting REST paths...', timestamp: new Date().toISOString(), agentId: 'agent-001' }, - ], - 'agent-002': [ - { type: 'tool', tool: 'Glob', input: { pattern: 'test/**/*.test.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' }, - { type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/test/respawn-test-utils.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' }, - { type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/config/vitest.config.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' }, - { type: 'message', role: 'assistant', text: 'Analyzing test patterns: MockSession, unique ports, fileParallelism: false...', timestamp: new Date().toISOString(), agentId: 'agent-002' }, - ], -}; - -// Subagent spawn terminal content -const TERMINAL_SUBAGENT = { - body: [ - '', - `${GRN}●${RST} Working on ${CYN}/home/arkon/codeman-cases/testcase${RST} - I'll use the ${BOLD}Task tool${RST} to spawn parallel agents.`, - '', - `${GRN}●${RST} ${BOLD}Read${RST}(/home/arkon/codeman-cases/testcase/CLAUDE.md)`, - ` ${GRY}░${RST} Read ${BOLD}127${RST} lines ${GRY}│${RST} ${CYN}1.2KB${RST}`, - '', - `${GRN}●${RST} ${BOLD}Bash${RST}(find . -name "*.ts" -not -path "*/node_modules/*" | head -20)`, - ` ${GRY}░${RST} ./src/index.ts`, - ` ${GRY}░${RST} ./src/types.ts`, - ` ${GRY}░${RST} ./src/session.ts`, - ` ${GRY}░${RST} ./src/web/server.ts`, - ` ${GRY}░${RST} ./src/web/schemas.ts`, - ` ${GRY}░${RST} ./test/session.test.ts`, - ` ${GRY}░${RST} ${GRY}... (14 more)${RST}`, - '', - `${GRN}●${RST} I'll spawn 3 parallel research agents to analyze different parts of the codebase simultaneously.`, - '', - `${GRN}●${RST} ${BOLD}Task${RST}(Find and document all API endpoints in src/)`, - ` ${GRY}░${RST} Spawned ${CYN}agent-001${RST} ${GRY}(haiku)${RST}`, - '', - `${GRN}●${RST} ${BOLD}Task${RST}(Explore and understand test structure in test/)`, - ` ${GRY}░${RST} Spawned ${CYN}agent-002${RST} ${GRY}(haiku)${RST}`, - '', - `${GRN}●${RST} ${BOLD}Task${RST}(Analyze TypeScript type definitions in src/types.ts)`, - ` ${GRY}░${RST} Spawned ${CYN}agent-003${RST} ${GRY}(haiku)${RST}`, - '', - `${MAG}✻${RST} ${YEL}Waiting for agents...${RST} ${GRY}(${BOLD}esc${RST}${GRY} to interrupt · 32s · ↓ 1.7k tokens · thinking)${RST}`, - '', - ` ${GRN}●${RST} ${CYN}agent-001${RST}: ${GRY}12 tool calls${RST} — Glob, Read(server.ts), Grep(endpoints)...`, - ` ${GRN}●${RST} ${CYN}agent-002${RST}: ${GRY}8 tool calls${RST} — Glob, Read(test-utils), Read(vitest.config)...`, - ` ${GRN}●${RST} ${CYN}agent-003${RST}: ${GRY}5 tool calls${RST} — Read(types.ts), Grep(interface)...`, - '', - `${GRN}●${RST} ${DIM}171.8k, 13s │ 1.7k tokens │ thinking${RST}`, - ], - footer: [ - spread( - ` ${YEL}»»${RST} ${BOLD}bypass permissions on${RST} ${GRY}(shift+tab to cycle)${RST}`, - `${BOLD}171.8k tokens${RST}` - ), - spread('', `${GRY}current: 2.1.177${RST} ${DIM}·${RST} ${GRY}latest: 2.1.177${RST}`), - ], -}; - -// Flatten a { body, footer } scene into the raw buffer the mocked /terminal -// fetch returns. injectState() re-renders it bottom-anchored once the terminal -// exists and its real row count is known; this naive join is only the fallback -// the fetch path needs before that happens. -function joinScene(scene) { - return [...scene.body, '', '', ...scene.footer].join('\r\n'); -} - -// ─── Route Interceptors ────────────────────────────────────────────────────── - -async function setupRoutes(page, initPayload, terminalContent) { - const terminalBuffer = joinScene(terminalContent); - // CRITICAL: Block SSE entirely to prevent reconnection loops that clear state. - // We'll inject data directly via page.evaluate() instead. - await page.route('**/api/events', async (route) => { - await route.abort(); - }); - - // Terminal buffer endpoint — used by selectSession() - await page.route('**/api/sessions/*/terminal**', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - terminalBuffer, - status: 'busy', - fullSize: terminalBuffer.length, - truncated: false, - }), - }); - }); - - // Mux sessions (subpath routes before base route) - await page.route('**/api/mux-sessions/**', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ success: true }), - }); - }); - - await page.route('**/api/mux-sessions', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ sessions: MUX_SESSIONS, muxAvailable: true }), - }); - }); - - // Settings - await page.route('**/api/settings', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - showSubagents: true, - subagentTrackingEnabled: true, - subagentActiveTabOnly: false, - showMonitor: true, - }), - }); - }); - - // Subagent window states (restore) - await page.route('**/api/subagent-window-states', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({}), - }); - }); - - // Subagent parents (restore) - await page.route('**/api/subagent-parents', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({}), - }); - }); - - // Session-specific subagents - await page.route('**/api/sessions/*/subagents', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ success: true, data: initPayload.subagents || [] }), - }); - }); - - // Interactive attach (no-op) - await page.route('**/api/sessions/*/interactive', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ success: true }), - }); - }); - - // Resize (no-op) - await page.route('**/api/sessions/*/resize', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ success: true }), - }); - }); - - // Catch-all for any remaining API endpoints - await page.route('**/api/**', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ success: true }), - }); - }); -} - -/** - * Inject mock state into the app and render. - * Bypasses SSE entirely — calls handleInit directly, then selects a session - * and writes terminal content. - */ -async function injectState(page, initPayload, terminalContent, activeSessionId) { - // Wait for app to be ready - await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 15000 }); - await sleep(1000); - - // Cancel the SSE fallback timer and inject state directly - await page.evaluate((payload) => { - const app = window.app; - // Cancel the init fallback timer (prevents double handleInit) - if (app._initFallbackTimer) { - clearTimeout(app._initFallbackTimer); - app._initFallbackTimer = null; - } - // Inject state - app.handleInit(payload); - }, initPayload); - - await sleep(1500); - - // Select the target session (this triggers terminal fetch via our mocked route) - if (activeSessionId) { - await page.evaluate((sid) => { - window.app.activeSessionId = null; // Force re-select - window.app.selectSession(sid); - }, activeSessionId); - await sleep(2000); - } - - await sleep(1000); - - // Clean up UI elements that look wrong in screenshots - await page.evaluate(() => { - // Kill SSE reconnection entirely and hide the connection indicator - const app2 = window.app; - if (app2) { - // Stop reconnection timers - if (app2.sseReconnectTimeout) clearTimeout(app2.sseReconnectTimeout); - if (app2.eventSource) { app2.eventSource.close(); app2.eventSource = null; } - app2._connectionStatus = 'connected'; - // Monkey-patch so it never re-shows - app2._updateConnectionIndicator = () => {}; - app2.connectSSE = () => {}; - app2.setConnectionStatus = () => {}; - } - // Remove the indicator element from the DOM entirely - const indicator = document.getElementById('connectionIndicator'); - if (indicator) indicator.remove(); - - // Hide respawn banner (shows "idle" state which isn't needed in screenshots) - const respawnBanner = document.getElementById('respawnBanner'); - if (respawnBanner) respawnBanner.style.display = 'none'; - - // Fix system stats display (CPU/MEM) — inject mock values - const statsEl = document.getElementById('headerSystemStats'); - if (statsEl) { - statsEl.innerHTML = ` - CPU -
- 24% - MEM -
- 16.9G - `; - } - - // Hide subagents panel by default (will be shown per-scenario) - const subagentsPanel = document.getElementById('subagentsPanel'); - if (subagentsPanel) subagentsPanel.style.display = 'none'; - - // Ensure monitor panel is closed by default (will be opened per-scenario) - const monitorPanel = document.getElementById('monitorPanel'); - if (monitorPanel) monitorPanel.classList.remove('open'); - - // Slim the header for a cleaner screenshot: keep ONLY CPU/MEM stats and the - // settings gear. Hide the token count and the other header affordances - // (font A-/A+ controls, lifecycle-log doc icon). The response-viewer / - // multi-monitor / notification buttons are already hidden by default. - const headerHide = [ - '#headerTokens', - '.header-font-controls', - '.btn-lifecycle-log', - '.btn-response-viewer-header', - '.btn-multimonitor', - '.btn-notifications', - ]; - for (const sel of headerHide) { - document.querySelectorAll(sel).forEach((el) => { - el.style.setProperty('display', 'none', 'important'); - }); - } - - // Make the terminal / Claude pane dominate the window: slim the header and - // toolbar (default 36px + 42px ≈ 13% chrome) down to ~7%, so the terminal - // fills ~93% of the height. injectState re-fits the terminal to the enlarged - // container right after this, so the extra space becomes real terminal rows. - const slim = document.createElement('style'); - slim.id = 'codeman-screenshot-slim'; - slim.textContent = ` - :root { --header-height: 22px !important; --toolbar-height: 28px !important; } - .header { min-height: 22px !important; padding: 1px 10px !important; align-items: center !important; } - .session-tab { padding-top: 1px !important; padding-bottom: 1px !important; } - .toolbar { height: 28px !important; min-height: 28px !important; padding: 0 10px !important; } - .btn-toolbar { padding: 2px 8px !important; } - .toolbar-select, .toolbar-input { padding-top: 2px !important; padding-bottom: 2px !important; } - `; - document.head.appendChild(slim); - }); - - // Let the layout reflow to its FINAL height before sizing the terminal. The - // cleanup above hides the respawn banner (~48px), so the terminal container - // only grows to its full height now — fitting earlier under-counts rows and - // leaves a dead strip below the last row. - await sleep(700); - - // Render the scene bottom-anchored at the terminal's true full size: re-fit to - // the settled container, size rows to fill it exactly (fit() under-counts on - // initial load — the WebGL cell metric lags — which is the "switch tabs/reload - // to fix it" symptom), then neutralize later re-fits. `body` scrolls from the - // top; `footer` (input box + status bar) is pinned to the bottom with blank - // padding between — exactly like the real Claude Code TUI. Runs unconditionally - // so it overrides whatever the mocked /terminal fetch wrote. - await page.evaluate((scene) => { - const app = window.app; - const term = app.terminal; - if (!term) return; - try { - app.fitAddon?.fit(); - } catch { - /* ignore */ - } - try { - const cont = document.getElementById('terminalContainer'); - const cellH = term._core?._renderService?.dimensions?.css?.cell?.height || 21; - if (cont && cellH) { - const fitRows = Math.max(1, Math.floor(cont.clientHeight / cellH)); - if (fitRows !== term.rows) term.resize(term.cols, fitRows); - } - } catch { - /* ignore */ - } - if (app.fitAddon) app.fitAddon.fit = () => {}; - const rows = term.rows || 27; - const cols = term.cols || 158; - const body = scene.body || []; - const footer = scene.footer || []; - // Count how many TERMINAL ROWS a line array actually occupies, accounting for - // line wrapping — a long paragraph wraps to 2+ rows. Using the raw array - // length under-counts wrapped lines and leaves a dead band above the footer - // (and term.write() is async so measuring the live cursor afterward is - // unreliable). Strip ANSI SGR codes first so only VISIBLE width counts. - const rowsFor = (arr) => - arr.reduce((sum, line) => { - const visible = line.replace(/\x1b\[[0-9;]*m/g, ''); - return sum + Math.max(1, Math.ceil(visible.length / cols)); - }, 0); - const pad = Math.max(0, rows - rowsFor(body) - rowsFor(footer)); - const lines = [...body, ...Array(pad).fill(''), ...footer]; - term.clear(); - term.reset(); - term.write(lines.join('\r\n')); - term.scrollToBottom(); - }, terminalContent); - - // Final settle — let xterm.js WebGL renderer, fonts, and layout stabilize - await sleep(1500); -} - -// ─── Screenshot Scenarios ──────────────────────────────────────────────────── - -async function captureOverview(page) { - console.log('\n1/4 Capturing claude-overview.png...'); - - const initPayload = buildInitPayload(STANDARD_SESSIONS); - await setupRoutes(page, initPayload, TERMINAL_INIT); - - await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); - await injectState(page, initPayload, TERMINAL_INIT, SESSION_IDS.w4); - - // Open Monitor panel and populate mux sessions - await page.evaluate((muxSessions) => { - const app = window.app; - if (!app) return; - app.muxSessions = muxSessions; - const panel = document.getElementById('monitorPanel'); - if (panel) { - panel.classList.add('open'); - app._renderMuxSessionsImmediate(); - } - }, MUX_SESSIONS); - await sleep(1500); - - await page.screenshot({ - path: join(OUT_DIR, 'claude-overview.png'), - fullPage: false, - }); - console.log(' Saved: ' + join(OUT_DIR, 'claude-overview.png')); -} - -async function captureSubagentSpawn(page) { - console.log('\n2/4 Capturing subagent-spawn.png...'); - - const initPayload = buildInitPayload(STANDARD_SESSIONS, MOCK_SUBAGENTS); - await setupRoutes(page, initPayload, TERMINAL_SUBAGENT); - - await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); - await injectState(page, initPayload, TERMINAL_SUBAGENT, SESSION_IDS.w1); - - // Open subagent windows and populate activity - await page.evaluate((activity) => { - const app = window.app; - if (!app) return; - - // Populate subagent activity data - for (const [agentId, entries] of Object.entries(activity)) { - app.subagentActivity.set(agentId, entries); - } - - // Open subagent windows (first two for cleaner screenshot) - app.openSubagentWindow('agent-001'); - app.openSubagentWindow('agent-002'); - }, SUBAGENT_ACTIVITY); - await sleep(2000); - - // Position windows nicely for 1280x720 viewport - await page.evaluate(() => { - const app = window.app; - if (!app) return; - - const windows = Array.from(app.subagentWindows.values()); - if (windows.length >= 2) { - const w1 = windows[0].element; - w1.style.left = '420px'; - w1.style.top = '40px'; - w1.style.width = '420px'; - w1.style.height = '320px'; - - const w2 = windows[1].element; - w2.style.left = '850px'; - w2.style.top = '40px'; - w2.style.width = '420px'; - w2.style.height = '320px'; - } - }); - await sleep(1500); - - await page.screenshot({ - path: join(OUT_DIR, 'subagent-spawn.png'), - fullPage: false, - }); - console.log(' Saved: ' + join(OUT_DIR, 'subagent-spawn.png')); -} - -async function captureRalphTracker(page) { - console.log('\n3/5 Capturing ralph-tracker-8tasks-44percent.png...'); - - const initPayload = buildInitPayload(RALPH_SESSIONS); - await setupRoutes(page, initPayload, TERMINAL_RALPH); - - await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); - await injectState(page, initPayload, TERMINAL_RALPH, RALPH_SESSION_ID); - - // Ensure Ralph state panel is visible and expanded - await page.evaluate((sessionId) => { - const app = window.app; - if (!app) return; - - const session = app.sessions.get(sessionId); - if (session) { - app.ralphStates.set(sessionId, { - loop: session.ralphLoop, - todos: session.ralphTodos || [], - }); - app.ralphStatePanelCollapsed = false; - app.ralphClosedSessions.delete(sessionId); - app._renderRalphStatePanelImmediate(); - } - }, RALPH_SESSION_ID); - await sleep(1500); - - await page.screenshot({ - path: join(OUT_DIR, 'ralph-tracker-8tasks-44percent.png'), - fullPage: false, - }); - console.log(' Saved: ' + join(OUT_DIR, 'ralph-tracker-8tasks-44percent.png')); -} - -async function captureMultiSessionDashboard(page) { - console.log('\n3/4 Capturing multi-session-dashboard.png...'); - - const initPayload = buildInitPayload(STANDARD_SESSIONS); - await setupRoutes(page, initPayload, TERMINAL_WORKING); - - await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); - await injectState(page, initPayload, TERMINAL_WORKING, SESSION_IDS.w3); - - await page.screenshot({ - path: join(OUT_DIR, 'multi-session-dashboard.png'), - fullPage: false, - }); - console.log(' Saved: ' + join(OUT_DIR, 'multi-session-dashboard.png')); -} - -async function captureMultiSessionMonitor(page) { - console.log('\n4/4 Capturing multi-session-monitor.png...'); - - const initPayload = buildInitPayload(STANDARD_SESSIONS); - await setupRoutes(page, initPayload, TERMINAL_INIT); - - await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); - await injectState(page, initPayload, TERMINAL_INIT, SESSION_IDS.w4); - - // Open Monitor panel and populate mux sessions - await page.evaluate((muxSessions) => { - const app = window.app; - if (!app) return; - app.muxSessions = muxSessions; - const panel = document.getElementById('monitorPanel'); - if (panel) { - panel.classList.add('open'); - app._renderMuxSessionsImmediate(); - } - }, MUX_SESSIONS); - await sleep(1500); - - await page.screenshot({ - path: join(OUT_DIR, 'multi-session-monitor.png'), - fullPage: false, - }); - console.log(' Saved: ' + join(OUT_DIR, 'multi-session-monitor.png')); -} - -// ─── Main ──────────────────────────────────────────────────────────────────── - -async function main() { - console.log('='.repeat(60)); - console.log(`Codeman README Screenshot Capture — skin: ${SKIN}`); - console.log('='.repeat(60)); - console.log(`Port: ${PORT} | Viewport: ${VIEWPORT.width}x${VIEWPORT.height}`); - console.log(`Output: ${OUT_DIR}`); - console.log(''); - - const server = await startStaticServer(); - let browser; - - try { - browser = await chromium.launch({ - headless: true, - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-dev-shm-usage', - '--disable-gpu', - ], - }); - - // Each scenario gets its own fresh page to avoid route conflicts. - // (Ralph tracker intentionally omitted — de-prioritized feature.) - for (const scenario of [ - captureOverview, - captureSubagentSpawn, - captureMultiSessionDashboard, - captureMultiSessionMonitor, - ]) { - const context = await browser.newContext({ viewport: VIEWPORT, deviceScaleFactor: DEVICE_SCALE_FACTOR }); - const page = await context.newPage(); - page.setDefaultTimeout(30000); - - // Force the chosen theme skin before any page script runs (matches the - // pre-paint contract in index.html: localStorage 'codeman:skin'). - await page.addInitScript((skin) => { - try { - localStorage.setItem('codeman:skin', skin); - } catch { - /* ignore */ - } - }, SKIN); - - try { - await scenario(page); - } catch (err) { - console.error(` ERROR: ${err.message}`); - } finally { - await context.close(); - } - } - - console.log('\n' + '='.repeat(60)); - console.log('All screenshots captured!'); - console.log('='.repeat(60)); - console.log(`\nOutput files (in ${OUT_DIR}):`); - console.log(' claude-overview.png'); - console.log(' subagent-spawn.png'); - console.log(' multi-session-dashboard.png'); - console.log(' multi-session-monitor.png'); - } catch (err) { - console.error('\nFatal error:', err.message); - console.error(err.stack); - process.exitCode = 1; - } finally { - if (browser) await browser.close().catch(() => {}); - server.close(); - console.log('\nDone.'); - } -} - -// Handle interrupts -process.on('SIGINT', () => { - console.log('\nInterrupted.'); - process.exit(1); -}); - -main(); diff --git a/scripts/capture-video-screenshots.mjs b/scripts/capture-video-screenshots.mjs deleted file mode 100644 index 172e332c..00000000 --- a/scripts/capture-video-screenshots.mjs +++ /dev/null @@ -1,731 +0,0 @@ -#!/usr/bin/env node - -/** - * capture-video-screenshots.mjs - * - * Captures real Codeman UI screenshots for the Remotion demo video. - * Uses Playwright + page.route() mock injection (same pattern as capture-readme-screenshots.mjs). - * No real Claude CLI or server needed — all API responses are mocked. - * - * Usage: node scripts/capture-video-screenshots.mjs - * Port: 3198 (static file server) - * Output: scripts/scripts/remotion/public/ (6 PNGs) - */ - -import { chromium } from 'playwright'; -import { createServer } from 'http'; -import { readFileSync, existsSync } from 'fs'; -import { join, extname } from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = fileURLToPath(new URL('.', import.meta.url)); -const PROJECT_ROOT = join(__dirname, '..'); -const PUBLIC_DIR = join(PROJECT_ROOT, 'src', 'web', 'public'); -const OUTPUT_DIR = join(PROJECT_ROOT, 'scripts', 'remotion', 'public'); -const PORT = 3198; - -const DESKTOP_VIEWPORT = { width: 1920, height: 1080 }; -const MOBILE_VIEWPORT = { width: 390, height: 844 }; - -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - -// ─── MIME Types ────────────────────────────────────────────────────────────── - -const MIME_TYPES = { - '.html': 'text/html', - '.js': 'text/javascript', - '.css': 'text/css', - '.json': 'application/json', - '.png': 'image/png', - '.svg': 'image/svg+xml', - '.ico': 'image/x-icon', - '.woff2': 'font/woff2', - '.woff': 'font/woff', - '.ttf': 'font/ttf', -}; - -// ─── Static File Server ────────────────────────────────────────────────────── - -function startStaticServer() { - return new Promise((resolve) => { - const server = createServer((req, res) => { - let urlPath = req.url.split('?')[0]; - if (urlPath === '/') urlPath = '/index.html'; - - const filePath = join(PUBLIC_DIR, urlPath); - - if (!existsSync(filePath)) { - res.writeHead(404); - res.end('Not Found'); - return; - } - - try { - const data = readFileSync(filePath); - const ext = extname(filePath); - res.writeHead(200, { - 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream', - 'Cache-Control': 'no-cache', - }); - res.end(data); - } catch { - res.writeHead(500); - res.end('Internal Server Error'); - } - }); - - server.listen(PORT, () => { - console.log(`Static server on http://localhost:${PORT}`); - resolve(server); - }); - }); -} - -// ─── Mock Data ─────────────────────────────────────────────────────────────── - -function makeSession(id, name, mode, status, extra = {}) { - return { - id, - pid: status === 'idle' ? null : 12345 + Math.floor(Math.random() * 10000), - status, - workingDir: '/home/user/my-project', - currentTaskId: null, - createdAt: Date.now() - 3600000, - lastActivityAt: Date.now() - (status === 'idle' ? 60000 : 5000), - name, - mode, - autoClearEnabled: false, - autoClearThreshold: 140000, - autoCompactEnabled: false, - autoCompactThreshold: 110000, - autoCompactPrompt: '', - imageWatcherEnabled: false, - totalCost: mode === 'claude' ? 0.12 : 0, - inputTokens: mode === 'claude' ? 18000 : 0, - outputTokens: mode === 'claude' ? 11500 : 0, - ralphEnabled: false, - niceEnabled: false, - niceValue: 10, - color: 'default', - flickerFilterEnabled: false, - cliVersion: '2.1.61', - cliModel: mode === 'claude' ? 'Opus 4.6' : 'GPT-4o', - cliAccountType: mode === 'claude' ? 'Claude Max' : 'API Key', - cliLatestVersion: '2.1.61', - messageCount: 15, - isWorking: status === 'busy', - lastPromptTime: Date.now() - 30000, - bufferStats: { - terminalBufferSize: 4096, - textOutputSize: 2048, - messageCount: 15, - maxTerminalBuffer: 2097152, - maxTextOutput: 1048576, - maxMessages: 1000, - }, - taskStats: { total: 0, running: 0, completed: 0, failed: 0 }, - taskTree: [], - tokens: { - input: mode === 'claude' ? 18000 : 0, - output: mode === 'claude' ? 11500 : 0, - total: mode === 'claude' ? 29500 : 0, - }, - autoClear: { enabled: false, threshold: 140000 }, - nice: { enabled: false, niceValue: 10 }, - ralphLoop: null, - ralphTodos: [], - ralphTodoStats: { total: 0, completed: 0, percentComplete: 0 }, - respawnEnabled: false, - respawnConfig: null, - respawn: null, - claudeSessionId: `claude-${id}`, - ...extra, - }; -} - -function buildInitPayload(sessions) { - const respawnStatus = {}; - for (const s of sessions) { - respawnStatus[s.id] = { - state: 'idle', - cycleCount: 0, - lastActivityTime: Date.now(), - timeSinceActivity: 0, - promptDetected: false, - workingDetected: false, - detection: {}, - config: null, - }; - } - - return { - version: '0.1651', - sessions, - scheduledRuns: [], - respawnStatus, - globalStats: { - totalInputTokens: 145000, - totalOutputTokens: 87000, - totalCost: 1.82, - totalSessionsCreated: 12, - firstRecordedAt: Date.now() - 86400000, - lastUpdatedAt: Date.now(), - }, - subagents: [], - timestamp: Date.now(), - }; -} - -// ─── Terminal Content (ANSI) ───────────────────────────────────────────────── - -const RST = '\x1b[0m'; -const RED = '\x1b[31m'; -const GRN = '\x1b[32m'; -const YEL = '\x1b[33m'; -const MAG = '\x1b[35m'; -const GRY = '\x1b[90m'; -const BOLD = '\x1b[1m'; -const DIM = '\x1b[2m'; - -// Claude Code init banner -const TERMINAL_CLAUDE = [ - '', - ` ${RED}████${RST} ${BOLD}Claude Code${RST} v2.1.61`, - ` ${RED}████${RST} ${GRY}Opus 4.6${RST} ${DIM}·${RST} ${GRY}Claude Max${RST}`, - ` ${RED}██${RST} ${RED}██${RST} ${GRY}~/my-project${RST}`, - '', - '', - `${GRY}❯ Try "how do I log an error?"${RST}`, - '', - '', - `${YEL}»»${RST} ${BOLD}bypass permissions on${RST} ${GRY}(shift+tab to cycle)${RST}`, - ` ${BOLD}0 tokens${RST}`, - ` ${GRY}current: 2.1.61${RST} ${DIM}·${RST} ${GRY}latest: 2.1.61${RST}`, -].join('\r\n'); - -// OpenCode init banner -const TERMINAL_OPENCODE = [ - '', - ` ${GRN}████${RST} ${BOLD}OpenCode${RST} v0.3.12`, - ` ${GRN}████${RST} ${GRY}GPT-4o${RST} ${DIM}·${RST} ${GRY}API Key${RST}`, - ` ${GRN}██${RST} ${GRN}██${RST} ${GRY}~/my-project${RST}`, - '', - '', - `${GRY}❯ Try "explain this codebase"${RST}`, - '', - '', - `${YEL}»»${RST} ${BOLD}bypass permissions on${RST}`, - ` ${BOLD}0 tokens${RST}`, -].join('\r\n'); - -// ─── Route Interceptors ────────────────────────────────────────────────────── - -async function setupRoutes(page, initPayload, terminalContent) { - // Block SSE to prevent reconnection loops - await page.route('**/api/events', async (route) => { - await route.abort(); - }); - - // Terminal buffer endpoint - await page.route('**/api/sessions/*/terminal**', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - terminalBuffer: terminalContent, - status: 'busy', - fullSize: terminalContent.length, - truncated: false, - }), - }); - }); - - // Mux sessions - await page.route('**/api/mux-sessions/**', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ success: true }), - }); - }); - - await page.route('**/api/mux-sessions', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ sessions: [], muxAvailable: true }), - }); - }); - - // Settings - await page.route('**/api/settings', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - showSubagents: false, - subagentTrackingEnabled: false, - subagentActiveTabOnly: false, - showMonitor: false, - }), - }); - }); - - // Subagent window states - await page.route('**/api/subagent-window-states', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({}), - }); - }); - - // Subagent parents - await page.route('**/api/subagent-parents', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({}), - }); - }); - - // Session-specific subagents - await page.route('**/api/sessions/*/subagents', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ success: true, data: [] }), - }); - }); - - // Interactive attach - await page.route('**/api/sessions/*/interactive', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ success: true }), - }); - }); - - // Resize - await page.route('**/api/sessions/*/resize', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ success: true }), - }); - }); - - // Catch-all API - await page.route('**/api/**', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ success: true }), - }); - }); -} - -/** - * Inject mock state and render the app. - */ -async function injectState(page, initPayload, terminalContent, activeSessionId) { - await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 15000 }); - await sleep(2000); - - // Cancel SSE fallback timer and inject state - await page.evaluate((payload) => { - const app = window.app; - if (app._initFallbackTimer) { - clearTimeout(app._initFallbackTimer); - app._initFallbackTimer = null; - } - app.handleInit(payload); - }, initPayload); - - await sleep(3000); - - // Select the target session - if (activeSessionId) { - await page.evaluate((sid) => { - window.app.activeSessionId = null; - window.app.selectSession(sid); - }, activeSessionId); - await sleep(4000); - } - - // Write terminal content directly as backup - if (terminalContent && activeSessionId) { - await page.evaluate((content) => { - const app = window.app; - if (app.terminal) { - const buf = app.terminal.buffer?.active; - const hasContent = buf && buf.length > 2 && buf.getLine(1)?.translateToString().trim(); - if (!hasContent) { - app.terminal.clear(); - app.terminal.reset(); - app.terminal.write(content); - app.terminal.scrollToBottom(); - } - } - }, terminalContent); - await sleep(3000); - } - - // Clean up UI artifacts - await page.evaluate(() => { - const app = window.app; - if (app) { - if (app.sseReconnectTimeout) clearTimeout(app.sseReconnectTimeout); - if (app.eventSource) { app.eventSource.close(); app.eventSource = null; } - app._connectionStatus = 'connected'; - app._updateConnectionIndicator = () => {}; - app.connectSSE = () => {}; - app.setConnectionStatus = () => {}; - } - - // Remove connection indicator - const indicator = document.getElementById('connectionIndicator'); - if (indicator) indicator.remove(); - - // Hide respawn banner - const respawnBanner = document.getElementById('respawnBanner'); - if (respawnBanner) respawnBanner.style.display = 'none'; - - // Mock CPU/MEM stats - const statsEl = document.getElementById('headerSystemStats'); - if (statsEl) { - statsEl.innerHTML = ` - CPU -
- 18% - MEM -
- 13.7G - `; - } - - // Hide subagents panel entirely - const subagentsPanel = document.getElementById('subagentsPanel'); - if (subagentsPanel) subagentsPanel.style.display = 'none'; - - // Close and hide monitor panel - const monitorPanel = document.getElementById('monitorPanel'); - if (monitorPanel) { - monitorPanel.classList.remove('open'); - monitorPanel.style.display = 'none'; - } - - // Hide monitor toggle button in toolbar - const monitorBtn = document.getElementById('monitorToggle'); - if (monitorBtn) monitorBtn.style.display = 'none'; - - // Update token display - if (app && app.activeSessionId) { - const session = app.sessions.get(app.activeSessionId); - if (session) { - const tokens = (session.tokens?.total || 0); - const tokenEl = document.getElementById('headerTokens'); - if (tokenEl) { - const formatted = tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens); - tokenEl.innerHTML = `⊙ ${formatted} tokens`; - } - } - } - }); - - // Final settle — let xterm.js WebGL renderer, fonts, and layout fully stabilize - await sleep(4000); -} - -// ─── Screenshot Scenarios ──────────────────────────────────────────────────── - -// 1. Desktop welcome — no sessions, welcome overlay visible -async function captureDesktopWelcome(browser) { - console.log('\n1/6 Capturing desktop-welcome.png...'); - - const context = await browser.newContext({ - viewport: DESKTOP_VIEWPORT, - deviceScaleFactor: 1, - }); - const page = await context.newPage(); - page.setDefaultTimeout(30000); - - try { - const initPayload = buildInitPayload([]); - await setupRoutes(page, initPayload, ''); - - await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); - await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 15000 }); - await sleep(2000); - - // Inject empty state — welcome overlay should show automatically - await page.evaluate((payload) => { - const app = window.app; - if (app._initFallbackTimer) { - clearTimeout(app._initFallbackTimer); - app._initFallbackTimer = null; - } - app.handleInit(payload); - }, initPayload); - await sleep(3000); - - // Clean up UI - await page.evaluate(() => { - const app = window.app; - if (app) { - if (app.sseReconnectTimeout) clearTimeout(app.sseReconnectTimeout); - if (app.eventSource) { app.eventSource.close(); app.eventSource = null; } - app._connectionStatus = 'connected'; - app._updateConnectionIndicator = () => {}; - app.connectSSE = () => {}; - app.setConnectionStatus = () => {}; - } - const indicator = document.getElementById('connectionIndicator'); - if (indicator) indicator.remove(); - const respawnBanner = document.getElementById('respawnBanner'); - if (respawnBanner) respawnBanner.style.display = 'none'; - - // Mock stats - const statsEl = document.getElementById('headerSystemStats'); - if (statsEl) { - statsEl.innerHTML = ` - CPU -
- 18% - MEM -
- 13.7G - `; - } - - // Hide monitor and subagent panels - const subagentsPanel = document.getElementById('subagentsPanel'); - if (subagentsPanel) subagentsPanel.style.display = 'none'; - const monitorPanel = document.getElementById('monitorPanel'); - if (monitorPanel) { - monitorPanel.classList.remove('open'); - monitorPanel.style.display = 'none'; - } - const monitorBtn = document.getElementById('monitorToggle'); - if (monitorBtn) monitorBtn.style.display = 'none'; - }); - await sleep(3000); - - await page.screenshot({ - path: join(OUTPUT_DIR, 'desktop-welcome.png'), - fullPage: false, - }); - console.log(' Saved: scripts/remotion/public/desktop-welcome.png'); - } finally { - await context.close(); - } -} - -// 2. Desktop with single Claude tab active -async function captureDesktopClaude(browser) { - console.log('\n2/6 Capturing desktop-claude.png...'); - - const claudeSession = makeSession('sess-claude-1', 'w1-my-project', 'claude', 'busy'); - const initPayload = buildInitPayload([claudeSession]); - - const context = await browser.newContext({ - viewport: DESKTOP_VIEWPORT, - deviceScaleFactor: 1, - }); - const page = await context.newPage(); - page.setDefaultTimeout(30000); - - try { - await setupRoutes(page, initPayload, TERMINAL_CLAUDE); - await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); - await injectState(page, initPayload, TERMINAL_CLAUDE, 'sess-claude-1'); - - await page.screenshot({ - path: join(OUTPUT_DIR, 'desktop-claude.png'), - fullPage: false, - }); - console.log(' Saved: scripts/remotion/public/desktop-claude.png'); - } finally { - await context.close(); - } -} - -// 3. Desktop with 2 tabs (Claude + OpenCode), Claude active -async function captureDesktopBothClaude(browser) { - console.log('\n3/6 Capturing desktop-both-claude.png...'); - - const claudeSession = makeSession('sess-claude-2', 'w1-my-project', 'claude', 'busy'); - const opencodeSession = makeSession('sess-opencode-2', 'w2-my-project', 'opencode', 'busy'); - const initPayload = buildInitPayload([claudeSession, opencodeSession]); - - const context = await browser.newContext({ - viewport: DESKTOP_VIEWPORT, - deviceScaleFactor: 1, - }); - const page = await context.newPage(); - page.setDefaultTimeout(30000); - - try { - await setupRoutes(page, initPayload, TERMINAL_CLAUDE); - await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); - await injectState(page, initPayload, TERMINAL_CLAUDE, 'sess-claude-2'); - - await page.screenshot({ - path: join(OUTPUT_DIR, 'desktop-both-claude.png'), - fullPage: false, - }); - console.log(' Saved: scripts/remotion/public/desktop-both-claude.png'); - } finally { - await context.close(); - } -} - -// 4. Desktop with 2 tabs (Claude + OpenCode), OpenCode active -async function captureDesktopBothOpencode(browser) { - console.log('\n4/6 Capturing desktop-both-opencode.png...'); - - const claudeSession = makeSession('sess-claude-3', 'w1-my-project', 'claude', 'busy'); - const opencodeSession = makeSession('sess-opencode-3', 'w2-my-project', 'opencode', 'busy'); - const initPayload = buildInitPayload([claudeSession, opencodeSession]); - - const context = await browser.newContext({ - viewport: DESKTOP_VIEWPORT, - deviceScaleFactor: 1, - }); - const page = await context.newPage(); - page.setDefaultTimeout(30000); - - try { - await setupRoutes(page, initPayload, TERMINAL_OPENCODE); - await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); - await injectState(page, initPayload, TERMINAL_OPENCODE, 'sess-opencode-3'); - - await page.screenshot({ - path: join(OUTPUT_DIR, 'desktop-both-opencode.png'), - fullPage: false, - }); - console.log(' Saved: scripts/remotion/public/desktop-both-opencode.png'); - } finally { - await context.close(); - } -} - -// 5. Mobile with Claude tab -async function captureMobileClaude(browser) { - console.log('\n5/6 Capturing mobile-claude.png...'); - - const claudeSession = makeSession('sess-claude-m1', 'w1-my-project', 'claude', 'busy'); - const initPayload = buildInitPayload([claudeSession]); - - const context = await browser.newContext({ - viewport: MOBILE_VIEWPORT, - deviceScaleFactor: 2, - }); - const page = await context.newPage(); - page.setDefaultTimeout(30000); - - try { - await setupRoutes(page, initPayload, TERMINAL_CLAUDE); - await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); - await injectState(page, initPayload, TERMINAL_CLAUDE, 'sess-claude-m1'); - - await page.screenshot({ - path: join(OUTPUT_DIR, 'mobile-claude.png'), - fullPage: false, - }); - console.log(' Saved: scripts/remotion/public/mobile-claude.png'); - } finally { - await context.close(); - } -} - -// 6. Mobile with OpenCode tab -async function captureMobileOpencode(browser) { - console.log('\n6/6 Capturing mobile-opencode.png...'); - - const opencodeSession = makeSession('sess-opencode-m1', 'w1-my-project', 'opencode', 'busy'); - const initPayload = buildInitPayload([opencodeSession]); - - const context = await browser.newContext({ - viewport: MOBILE_VIEWPORT, - deviceScaleFactor: 2, - }); - const page = await context.newPage(); - page.setDefaultTimeout(30000); - - try { - await setupRoutes(page, initPayload, TERMINAL_OPENCODE); - await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); - await injectState(page, initPayload, TERMINAL_OPENCODE, 'sess-opencode-m1'); - - await page.screenshot({ - path: join(OUTPUT_DIR, 'mobile-opencode.png'), - fullPage: false, - }); - console.log(' Saved: scripts/remotion/public/mobile-opencode.png'); - } finally { - await context.close(); - } -} - -// ─── Main ──────────────────────────────────────────────────────────────────── - -async function main() { - console.log('='.repeat(60)); - console.log('Codeman Video Screenshot Capture'); - console.log('='.repeat(60)); - console.log(`Port: ${PORT}`); - console.log(`Desktop: ${DESKTOP_VIEWPORT.width}x${DESKTOP_VIEWPORT.height}`); - console.log(`Mobile: ${MOBILE_VIEWPORT.width}x${MOBILE_VIEWPORT.height} @2x`); - console.log(`Output: ${OUTPUT_DIR}`); - console.log(''); - - const server = await startStaticServer(); - let browser; - - try { - browser = await chromium.launch({ - headless: true, - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-dev-shm-usage', - '--disable-gpu', - ], - }); - - await captureDesktopWelcome(browser); - await captureDesktopClaude(browser); - await captureDesktopBothClaude(browser); - await captureDesktopBothOpencode(browser); - await captureMobileClaude(browser); - await captureMobileOpencode(browser); - - console.log('\n' + '='.repeat(60)); - console.log('All 6 screenshots captured!'); - console.log('='.repeat(60)); - console.log('\nOutput files:'); - console.log(' scripts/remotion/public/desktop-welcome.png'); - console.log(' scripts/remotion/public/desktop-claude.png'); - console.log(' scripts/remotion/public/desktop-both-claude.png'); - console.log(' scripts/remotion/public/desktop-both-opencode.png'); - console.log(' scripts/remotion/public/mobile-claude.png'); - console.log(' scripts/remotion/public/mobile-opencode.png'); - } catch (err) { - console.error('\nFatal error:', err.message); - console.error(err.stack); - process.exitCode = 1; - } finally { - if (browser) await browser.close().catch(() => {}); - server.close(); - console.log('\nDone.'); - } -} - -process.on('SIGINT', () => { - console.log('\nInterrupted.'); - process.exit(1); -}); - -main(); diff --git a/scripts/verify-session-sidebar.mts b/scripts/verify-session-sidebar.mts deleted file mode 100644 index 6d31e5ff..00000000 --- a/scripts/verify-session-sidebar.mts +++ /dev/null @@ -1,179 +0,0 @@ -/** - * Manual verification harness for the session-sidebar feature. - * - * Renders the real UI in headless Chromium against a testMode WebServer, - * injects a synthetic 25-session fleet, and screenshots every layout state. - * Not part of the automated suite — run it by hand: - * - * npx tsx scripts/verify-session-sidebar.mts - * - * SAFETY: uses the repo's own test harness (temp HOME, testMode server) on a - * dedicated port. It never touches a real Codeman instance or tmux socket. - */ -import { chromium } from 'playwright'; -import { WebServer } from '../src/web/server.js'; -import { mkdirSync } from 'node:fs'; -import { mkdtempSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -// Mirror test/setup.ts: isolate HOME before the app modules touch state. -process.env.HOME = mkdtempSync(join(tmpdir(), 'codeman-sidebar-verify-')); -process.env.VITEST = 'true'; - -const PORT = 3299; -const OUT = process.env.SIDEBAR_SHOTS_DIR ?? join(tmpdir(), 'codeman-sidebar-shots'); -mkdirSync(OUT, { recursive: true }); - -// Generic on purpose: these names end up in the harness screenshots, so they -// should not carry one contributor's project list into everyone else's review. -// The mix of CLI modes matters (each renders a different badge); the names do not. -const PROJECTS = [ - ['api-server', 'claude'], - ['web-client', 'claude'], - ['mobile-app', 'codex'], - ['data-pipeline', 'claude'], - ['shared-lib', 'gemini'], - ['codeman', 'claude'], - ['docs-site', 'claude'], - ['batch-jobs', 'opencode'], - ['search-index', 'claude'], -]; -const STATUSES = ['idle', 'busy', 'idle', 'busy', 'error', 'idle']; - -function fleet(n: number) { - const out: any[] = []; - for (let i = 0; i < n; i++) { - const [proj, mode] = PROJECTS[i % PROJECTS.length]; - const status = STATUSES[i % STATUSES.length]; - out.push({ - id: `sess-${String(i).padStart(4, '0')}-aaaa-bbbb-cccc-dddddddddddd`, - pid: 10000 + i, - status, - workingDir: `${tmpdir()}/projects/${proj}`, - name: `${proj}${i > 8 ? '-' + Math.floor(i / 9) : ''}`, - mode, - currentTaskId: null, - createdAt: Date.now() - i * 60000, - lastActivityAt: Date.now() - i * 1000, - isWorking: status === 'busy', - messageCount: i * 3, - totalCost: 0, - inputTokens: 0, - outputTokens: 0, - color: 'default', - taskStats: { total: i % 4, running: i % 3 === 0 ? 2 : 0, completed: 0, failed: 0 }, - taskTree: [], - tokens: { input: 0, output: 0, total: 0 }, - bufferStats: { terminalBufferSize: 0, textOutputSize: 0, messageCount: 0 }, - }); - } - return out; -} - -const SESSIONS = fleet(25); - -async function main() { - const server = new WebServer(PORT, false, true); - await server.start(); - const browser = await chromium.launch({ headless: true }); - const results: string[] = []; - - async function shot( - name: string, - opts: { layout: 'header' | 'sidebar'; collapsed?: boolean; width: number; height: number; touch?: boolean } - ) { - const ctx = await browser.newContext({ - viewport: { width: opts.width, height: opts.height }, - hasTouch: !!opts.touch, - isMobile: !!opts.touch, - deviceScaleFactor: 2, - }); - const page = await ctx.newPage(); - const settings = JSON.stringify({ sessionListLayout: opts.layout }); - const collapsed = opts.collapsed === undefined ? null : opts.collapsed ? '1' : '0'; - await page.addInitScript( - ([s, c]) => { - localStorage.setItem('codeman-app-settings', s as string); - localStorage.setItem('codeman-app-settings-mobile', s as string); - if (c !== null) localStorage.setItem('codeman-sidebar-collapsed', c as string); - else localStorage.removeItem('codeman-sidebar-collapsed'); - }, - [settings, collapsed] - ); - await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(1500); - - await page.evaluate((list) => { - const app = (window as any).app; - if (!app) throw new Error('no window.app'); - app.sessions.clear(); - for (const s of list as any[]) app.sessions.set(s.id, s); - // The renderer iterates sessionOrder, not the map. - app.sessionOrder = (list as any[]).map((s) => s.id); - app.activeSessionId = (list as any[])[3].id; - // renderSessionTabs() is debounced; drive the immediate path directly. - (app._fullRenderSessionTabs ?? app._renderSessionTabsImmediate)?.call(app); - app.applySessionListLayout?.(); - }, SESSIONS as any); - await page.waitForTimeout(600); - - const info = await page.evaluate(() => { - const root = document.documentElement; - const aside = document.getElementById('sessionSidebar'); - const tabsEl = document.getElementById('sessionTabs'); - const asideBox = aside?.getBoundingClientRect(); - const cs = aside ? getComputedStyle(aside) : null; - return { - dataSessionList: root.dataset.sessionList ?? null, - dataSidebar: root.dataset.sidebar ?? null, - rows: document.querySelectorAll('.session-tab').length, - tabsParent: tabsEl?.parentElement?.id || tabsEl?.parentElement?.className || null, - asideWidth: asideBox ? Math.round(asideBox.width) : null, - asideVisible: cs ? cs.display !== 'none' && cs.visibility !== 'hidden' : null, - asideInert: aside?.hasAttribute('inert') ?? null, - ariaHidden: aside?.getAttribute('aria-hidden') ?? null, - toggleAriaExpanded: document.getElementById('sidebarToggleBtn')?.getAttribute('aria-expanded') ?? null, - firstRowText: - (document.querySelector('.session-tab') as HTMLElement | null)?.innerText - ?.trim() - .replace(/\s+/g, ' ') - .slice(0, 40) ?? null, - listScrollable: (() => { - const el = document.getElementById('sessionTabs'); - return el ? el.scrollHeight > el.clientHeight + 2 : null; - })(), - }; - }); - - await page.waitForTimeout(400); - const file = join(OUT, `${name}.png`); - await page.screenshot({ path: file }); - results.push(`${name.padEnd(28)} ${JSON.stringify(info)}`); - await ctx.close(); - return info; - } - - await shot('01-header-desktop', { layout: 'header', width: 1600, height: 900 }); - await shot('02-sidebar-expanded', { layout: 'sidebar', collapsed: false, width: 1600, height: 900 }); - await shot('03-sidebar-collapsed-rail', { layout: 'sidebar', collapsed: true, width: 1600, height: 900 }); - await shot('04-sidebar-narrow-1000', { layout: 'sidebar', collapsed: true, width: 1000, height: 800 }); - await shot('05-sidebar-drawer-open-1000', { layout: 'sidebar', collapsed: false, width: 1000, height: 800 }); - await shot('06-sidebar-phone-closed', { layout: 'sidebar', collapsed: true, width: 393, height: 852, touch: true }); - await shot('07-sidebar-phone-open', { layout: 'sidebar', collapsed: false, width: 393, height: 852, touch: true }); - - console.log('\n=== RESULTS ==='); - for (const r of results) console.log(r); - console.log(`\nScreenshots in ${OUT}`); - - await browser.close(); - await server.stop(); -} - -main().then( - () => process.exit(0), - (e) => { - console.error(e); - process.exit(1); - } -);