mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user