From f8b81b8478f6fa92ad9b9409aaa5b6ffebd6b4e8 Mon Sep 17 00:00:00 2001 From: arkon Date: Wed, 4 Mar 2026 16:01:16 +0100 Subject: [PATCH] fix: eliminate WebGL re-render flicker during tab switch Stop toggling WebGL renderer off/on around large buffer writes in chunkedTerminalWrite(). The dispose+loadAddon cycle caused visible re-render flashes and (before the deferred fix) synchronous GPU stalls from ReadPixels blocking the main thread. Instead, keep WebGL active and rely on 32KB chunked writes to keep per-frame render work under ~5ms. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 6 ++ CLAUDE.md | 4 +- package.json | 2 +- src/web/public/app.js | 24 ++---- tools/remotion/components/IOSKeyboard.tsx | 6 +- tools/remotion/compositions/ZerolagDemo.tsx | 83 ++++++++++++++------- 6 files changed, 76 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53b16898..64d90a27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # aicodeman +## 0.3.3 + +### Patch Changes + +- fix: eliminate WebGL re-render flicker during tab switch by keeping renderer active instead of toggling it off/on around large buffer writes + ## 0.3.2 ### Patch Changes diff --git a/CLAUDE.md b/CLAUDE.md index 04384e39..4a2b6955 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ When user says "COM": 4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json` 5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web` -**Version**: 0.3.2 (must match `package.json`) +**Version**: 0.3.3 (must match `package.json`) ## Project Overview @@ -109,7 +109,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | | | **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | | | **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (13 modules), `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | | -| **Frontend** | `src/web/public/app.js` ★ (~11.7K lines) + 9 JS modules | | +| **Frontend** | `src/web/public/app.js` ★ (~11.8K lines) + 10 JS modules (incl. `sw.js` service worker) | | | **Types** | `src/types/index.ts` → 14 domain files | See `@fileoverview` in index.ts | ★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in. diff --git a/package.json b/package.json index 3244784d..0f2ea3ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicodeman", - "version": "0.3.2", + "version": "0.3.3", "description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/web/public/app.js b/src/web/public/app.js index 59b52f08..3ae718cd 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1346,35 +1346,21 @@ class CodemanApp { // (from historical SSE data that was stored with markers) const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, ''); - // Disable WebGL during large buffer loads to prevent GPU stalls. - // The canvas renderer handles bulk writes without blocking the main thread. - // WebGL's synchronous ReadPixels calls cause "page unresponsive" on dense ANSI buffers. - const isLargeBuffer = cleanBuffer.length > chunkSize; - if (isLargeBuffer && this._webglAddon) { - try { this._webglAddon.dispose(); } catch (_e) { /* already disposed */ } - this._webglAddon = null; - } - const finish = () => { - // Re-enable WebGL after buffer load completes - if (isLargeBuffer && !this._webglAddon && typeof WebglAddon !== 'undefined') { - try { - this._webglAddon = new WebglAddon.WebglAddon(); - this._webglAddon.onContextLoss(() => { this._webglAddon.dispose(); this._webglAddon = null; }); - this.terminal.loadAddon(this._webglAddon); - } catch (_e) { /* WebGL re-init failed — stay on canvas */ } - } this._finishBufferLoad(); resolve(); }; - // For small buffers, write directly (WebGL stays active — small writes are fine) - if (!isLargeBuffer) { + // For small buffers, write directly — single-frame render is fast enough + if (cleanBuffer.length <= chunkSize) { this.terminal.write(cleanBuffer); finish(); return; } + // Large buffers: write in chunks across animation frames. + // Each 32KB chunk keeps per-frame WebGL render work under ~5ms, + // avoiding GPU stalls without needing to toggle the renderer. let offset = 0; const writeChunk = () => { if (offset >= cleanBuffer.length) { diff --git a/tools/remotion/components/IOSKeyboard.tsx b/tools/remotion/components/IOSKeyboard.tsx index 80b8faae..88aecd80 100644 --- a/tools/remotion/components/IOSKeyboard.tsx +++ b/tools/remotion/components/IOSKeyboard.tsx @@ -71,6 +71,10 @@ export const IOSKeyboard: React.FC = ({ activeKey, pressAge = activeKey !== undefined && key.toLowerCase() === activeKey.toLowerCase(); const age = (key: string) => (isActive(key) ? pressAge : 99); + // Backspace/delete key highlight + const deleteHighlight = activeKey === '⌫' && pressAge < 4 ? 1 - pressAge / 4 : 0; + const deleteBg = deleteHighlight > 0 ? lerpColor(SPECIAL_BG, KEY_BG_ACTIVE, deleteHighlight) : SPECIAL_BG; + // Key widths: 10 keys + 9 gaps in ~375px row → each key ~33px const letterKeyW = 33; // Row 2 has 9 keys → same key width but centered with side padding @@ -126,7 +130,7 @@ export const IOSKeyboard: React.FC = ({ activeKey, pressAge = width: 42, height: KEY_H, borderRadius: 5, - background: SPECIAL_BG, + background: deleteBg, display: 'flex', justifyContent: 'center', alignItems: 'center', diff --git a/tools/remotion/compositions/ZerolagDemo.tsx b/tools/remotion/compositions/ZerolagDemo.tsx index 5a7c4c2c..3d4a77a3 100644 --- a/tools/remotion/compositions/ZerolagDemo.tsx +++ b/tools/remotion/compositions/ZerolagDemo.tsx @@ -16,17 +16,17 @@ import { IOSKeyboard } from '../components/IOSKeyboard'; // ─── Scene timing (frames @ 30fps) ─── const TITLE_DUR = 60; const PHONES_DUR = 30; -const TYPING_DUR = 420; +const TYPING_DUR = 468; const HOLD_DUR = 60; const OUTRO_DUR = 45; const TITLE_START = 0; const PHONES_START = TITLE_DUR; // 60 const TYPING_START = PHONES_START + PHONES_DUR; // 90 -const HOLD_START = TYPING_START + TYPING_DUR; // 510 -const OUTRO_START = HOLD_START + HOLD_DUR; // 570 +const HOLD_START = TYPING_START + TYPING_DUR; // 558 +const OUTRO_START = HOLD_START + HOLD_DUR; // 618 -export const ZEROLAG_TOTAL_FRAMES = OUTRO_START + OUTRO_DUR; // 615 +export const ZEROLAG_TOTAL_FRAMES = OUTRO_START + OUTRO_DUR; // 663 // ─── iPhone 17 Pro safe area ─── const SAFE_AREA_TOP = 59; // Below Dynamic Island @@ -42,25 +42,61 @@ const TERMINAL_TOP = SAFE_AREA_TOP + 52; const TERMINAL_LEFT = 14; const TERMINAL_FONT = 22; // Large for video readability -// ─── Typing schedule ─── -const TYPED_TEXT = 'fix the auth bug in the login flow'; +// ─── Typing schedule (with typo + backspace correction) ─── +const CORRECT_TEXT = 'fix the auth bug in the login flow'; const FRAME_GAP = 12; // ~400ms between keystrokes +const TYPO_INDEX = 28; // After "logi", type "m" instead of "n" // Remote connection lag: 600ms-1.2s+ per char (18-36+ frames) const LAGGY_DELAYS = [ 24, 30, 36, 32, 26, 22, 34, 28, 38, 20, 30, 24, 32, 26, 36, 22, 30, 24, 32, 28, 34, 26, 30, 22, 28, 36, 24, 30, 32, 26, 34, 28, 24, 30, + 26, 32, 28, 34, ]; -type KeyEvent = { frame: number; char: string; lagDelay: number }; +type KeyAction = { frame: number; action: 'type' | 'backspace'; char: string; lagDelay: number }; -const TYPING_SCHEDULE: KeyEvent[] = TYPED_TEXT.split('').map((char, i) => ({ - frame: i * FRAME_GAP, - char, - lagDelay: LAGGY_DELAYS[i % LAGGY_DELAYS.length], -})); +const buildSchedule = (): KeyAction[] => { + const actions: KeyAction[] = []; + let idx = 0; + const lag = (i: number) => LAGGY_DELAYS[i % LAGGY_DELAYS.length]; -const CONFIRM_DELAY = 14; + // Type correctly up to typo point: "fix the auth bug in the logi" + for (let i = 0; i < TYPO_INDEX; i++) { + actions.push({ frame: idx * FRAME_GAP, action: 'type', char: CORRECT_TEXT[i], lagDelay: lag(idx) }); + idx++; + } + + // Typo: type "m" instead of "n" + actions.push({ frame: idx * FRAME_GAP, action: 'type', char: 'm', lagDelay: lag(idx) }); + idx++; + + // Backspace to fix it + actions.push({ frame: idx * FRAME_GAP, action: 'backspace', char: '⌫', lagDelay: lag(idx) }); + idx++; + + // Type correct remaining: "n flow" + for (let i = TYPO_INDEX; i < CORRECT_TEXT.length; i++) { + actions.push({ frame: idx * FRAME_GAP, action: 'type', char: CORRECT_TEXT[i], lagDelay: lag(idx) }); + idx++; + } + + return actions; +}; + +const TYPING_SCHEDULE = buildSchedule(); + +/** Replay actions in order up to current frame, computing the visible text buffer */ +const computeVisibleText = (frame: number, withLag: boolean): string => { + let buffer = ''; + for (const a of TYPING_SCHEDULE) { + const threshold = withLag ? a.frame + a.lagDelay : a.frame; + if (frame < threshold) break; // TCP-ordered: stop at first unresolved + if (a.action === 'backspace') buffer = buffer.slice(0, -1); + else buffer += a.char; + } + return buffer; +}; // ─── iOS Status Bar (sits in the safe area, flanking Dynamic Island) ─── const IOSStatusBar: React.FC = () => ( @@ -295,13 +331,8 @@ const OutroScene: React.FC = () => { const TypingDemo: React.FC = () => { const frame = useCurrentFrame(); - const laggyChars = TYPING_SCHEDULE.filter((ev) => frame >= ev.frame + ev.lagDelay).map((ev) => ev.char); - const laggyTyped = laggyChars.join(''); - - const zerolagOverlay = TYPING_SCHEDULE.filter((ev) => frame >= ev.frame).map((ev) => ({ - char: ev.char, - confirmed: frame >= ev.frame + CONFIRM_DELAY, - })); + const laggyTyped = computeVisibleText(frame, true); + const zerolagTyped = computeVisibleText(frame, false); let activeKey: string | undefined; let pressAge = 99; @@ -326,12 +357,12 @@ const TypingDemo: React.FC = () => { }} >
- - + +
- - + +
@@ -359,11 +390,11 @@ const PanelsEntrance: React.FC = () => {
- +
- +