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) <noreply@anthropic.com>
This commit is contained in:
arkon
2026-03-04 16:01:16 +01:00
co-authored by Claude Opus 4.6
parent d79e25d5d8
commit f8b81b8478
6 changed files with 76 additions and 49 deletions
+6
View File
@@ -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
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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",
+5 -19
View File
@@ -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) {
+5 -1
View File
@@ -71,6 +71,10 @@ export const IOSKeyboard: React.FC<IOSKeyboardProps> = ({ 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<IOSKeyboardProps> = ({ activeKey, pressAge =
width: 42,
height: KEY_H,
borderRadius: 5,
background: SPECIAL_BG,
background: deleteBg,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
+57 -26
View File
@@ -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 = () => {
}}
>
<div>
<MobileCodeman typed={laggyTyped} cursorVisible activeKey={activeKey} pressAge={pressAge} noAnimation />
<PhoneLabel title="Without Zerolag" detail="600ms–1.2s delay" dotColor={colors.accent.red} detailColor={colors.accent.red} />
<MobileCodeman typed={zerolagTyped} cursorVisible activeKey={activeKey} pressAge={pressAge} noAnimation />
<PhoneLabel title="With Zerolag" detail="0ms delay" dotColor={colors.accent.green} detailColor={colors.accent.green} />
</div>
<div>
<MobileCodeman typed="" overlayChars={zerolagOverlay} cursorVisible activeKey={activeKey} pressAge={pressAge} noAnimation />
<PhoneLabel title="With Zerolag" detail="0ms delay" dotColor={colors.accent.green} detailColor={colors.accent.green} />
<MobileCodeman typed={laggyTyped} cursorVisible activeKey={activeKey} pressAge={pressAge} noAnimation />
<PhoneLabel title="Without Zerolag" detail="600ms–1.2s delay" dotColor={colors.accent.red} detailColor={colors.accent.red} />
</div>
</div>
</AbsoluteFill>
@@ -359,11 +390,11 @@ const PanelsEntrance: React.FC = () => {
<div style={{ display: 'flex', gap: 50, alignItems: 'flex-start' }}>
<div>
<MobileCodeman typed="" cursorVisible noAnimation />
<PhoneLabel title="Without Zerolag" detail="600ms–1.2s delay" dotColor={colors.accent.red} detailColor={colors.accent.red} />
<PhoneLabel title="With Zerolag" detail="0ms delay" dotColor={colors.accent.green} detailColor={colors.accent.green} />
</div>
<div>
<MobileCodeman typed="" cursorVisible noAnimation />
<PhoneLabel title="With Zerolag" detail="0ms delay" dotColor={colors.accent.green} detailColor={colors.accent.green} />
<PhoneLabel title="Without Zerolag" detail="600ms–1.2s delay" dotColor={colors.accent.red} detailColor={colors.accent.red} />
</div>
</div>
</AbsoluteFill>