mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 16:09:43 +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
|
# 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
|
## 0.3.2
|
||||||
|
|
||||||
### Patch Changes
|
### 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`
|
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`
|
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
|
## 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` | |
|
| **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` | |
|
| **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` | |
|
| **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 |
|
| **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.
|
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"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",
|
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
+5
-19
@@ -1346,35 +1346,21 @@ class CodemanApp {
|
|||||||
// (from historical SSE data that was stored with markers)
|
// (from historical SSE data that was stored with markers)
|
||||||
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, '');
|
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 = () => {
|
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();
|
this._finishBufferLoad();
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
// For small buffers, write directly (WebGL stays active — small writes are fine)
|
// For small buffers, write directly — single-frame render is fast enough
|
||||||
if (!isLargeBuffer) {
|
if (cleanBuffer.length <= chunkSize) {
|
||||||
this.terminal.write(cleanBuffer);
|
this.terminal.write(cleanBuffer);
|
||||||
finish();
|
finish();
|
||||||
return;
|
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;
|
let offset = 0;
|
||||||
const writeChunk = () => {
|
const writeChunk = () => {
|
||||||
if (offset >= cleanBuffer.length) {
|
if (offset >= cleanBuffer.length) {
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ export const IOSKeyboard: React.FC<IOSKeyboardProps> = ({ activeKey, pressAge =
|
|||||||
activeKey !== undefined && key.toLowerCase() === activeKey.toLowerCase();
|
activeKey !== undefined && key.toLowerCase() === activeKey.toLowerCase();
|
||||||
const age = (key: string) => (isActive(key) ? pressAge : 99);
|
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
|
// Key widths: 10 keys + 9 gaps in ~375px row → each key ~33px
|
||||||
const letterKeyW = 33;
|
const letterKeyW = 33;
|
||||||
// Row 2 has 9 keys → same key width but centered with side padding
|
// 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,
|
width: 42,
|
||||||
height: KEY_H,
|
height: KEY_H,
|
||||||
borderRadius: 5,
|
borderRadius: 5,
|
||||||
background: SPECIAL_BG,
|
background: deleteBg,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -16,17 +16,17 @@ import { IOSKeyboard } from '../components/IOSKeyboard';
|
|||||||
// ─── Scene timing (frames @ 30fps) ───
|
// ─── Scene timing (frames @ 30fps) ───
|
||||||
const TITLE_DUR = 60;
|
const TITLE_DUR = 60;
|
||||||
const PHONES_DUR = 30;
|
const PHONES_DUR = 30;
|
||||||
const TYPING_DUR = 420;
|
const TYPING_DUR = 468;
|
||||||
const HOLD_DUR = 60;
|
const HOLD_DUR = 60;
|
||||||
const OUTRO_DUR = 45;
|
const OUTRO_DUR = 45;
|
||||||
|
|
||||||
const TITLE_START = 0;
|
const TITLE_START = 0;
|
||||||
const PHONES_START = TITLE_DUR; // 60
|
const PHONES_START = TITLE_DUR; // 60
|
||||||
const TYPING_START = PHONES_START + PHONES_DUR; // 90
|
const TYPING_START = PHONES_START + PHONES_DUR; // 90
|
||||||
const HOLD_START = TYPING_START + TYPING_DUR; // 510
|
const HOLD_START = TYPING_START + TYPING_DUR; // 558
|
||||||
const OUTRO_START = HOLD_START + HOLD_DUR; // 570
|
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 ───
|
// ─── iPhone 17 Pro safe area ───
|
||||||
const SAFE_AREA_TOP = 59; // Below Dynamic Island
|
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_LEFT = 14;
|
||||||
const TERMINAL_FONT = 22; // Large for video readability
|
const TERMINAL_FONT = 22; // Large for video readability
|
||||||
|
|
||||||
// ─── Typing schedule ───
|
// ─── Typing schedule (with typo + backspace correction) ───
|
||||||
const TYPED_TEXT = 'fix the auth bug in the login flow';
|
const CORRECT_TEXT = 'fix the auth bug in the login flow';
|
||||||
const FRAME_GAP = 12; // ~400ms between keystrokes
|
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)
|
// Remote connection lag: 600ms-1.2s+ per char (18-36+ frames)
|
||||||
const LAGGY_DELAYS = [
|
const LAGGY_DELAYS = [
|
||||||
24, 30, 36, 32, 26, 22, 34, 28, 38, 20, 30, 24, 32, 26, 36, 22,
|
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,
|
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) => ({
|
const buildSchedule = (): KeyAction[] => {
|
||||||
frame: i * FRAME_GAP,
|
const actions: KeyAction[] = [];
|
||||||
char,
|
let idx = 0;
|
||||||
lagDelay: LAGGY_DELAYS[i % LAGGY_DELAYS.length],
|
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) ───
|
// ─── iOS Status Bar (sits in the safe area, flanking Dynamic Island) ───
|
||||||
const IOSStatusBar: React.FC = () => (
|
const IOSStatusBar: React.FC = () => (
|
||||||
@@ -295,13 +331,8 @@ const OutroScene: React.FC = () => {
|
|||||||
const TypingDemo: React.FC = () => {
|
const TypingDemo: React.FC = () => {
|
||||||
const frame = useCurrentFrame();
|
const frame = useCurrentFrame();
|
||||||
|
|
||||||
const laggyChars = TYPING_SCHEDULE.filter((ev) => frame >= ev.frame + ev.lagDelay).map((ev) => ev.char);
|
const laggyTyped = computeVisibleText(frame, true);
|
||||||
const laggyTyped = laggyChars.join('');
|
const zerolagTyped = computeVisibleText(frame, false);
|
||||||
|
|
||||||
const zerolagOverlay = TYPING_SCHEDULE.filter((ev) => frame >= ev.frame).map((ev) => ({
|
|
||||||
char: ev.char,
|
|
||||||
confirmed: frame >= ev.frame + CONFIRM_DELAY,
|
|
||||||
}));
|
|
||||||
|
|
||||||
let activeKey: string | undefined;
|
let activeKey: string | undefined;
|
||||||
let pressAge = 99;
|
let pressAge = 99;
|
||||||
@@ -326,12 +357,12 @@ const TypingDemo: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<MobileCodeman typed={laggyTyped} cursorVisible activeKey={activeKey} pressAge={pressAge} noAnimation />
|
<MobileCodeman typed={zerolagTyped} cursorVisible activeKey={activeKey} pressAge={pressAge} 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>
|
||||||
<div>
|
<div>
|
||||||
<MobileCodeman typed="" overlayChars={zerolagOverlay} cursorVisible activeKey={activeKey} pressAge={pressAge} noAnimation />
|
<MobileCodeman typed={laggyTyped} cursorVisible activeKey={activeKey} pressAge={pressAge} 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>
|
||||||
</div>
|
</div>
|
||||||
</AbsoluteFill>
|
</AbsoluteFill>
|
||||||
@@ -359,11 +390,11 @@ const PanelsEntrance: React.FC = () => {
|
|||||||
<div style={{ display: 'flex', gap: 50, alignItems: 'flex-start' }}>
|
<div style={{ display: 'flex', gap: 50, alignItems: 'flex-start' }}>
|
||||||
<div>
|
<div>
|
||||||
<MobileCodeman typed="" cursorVisible noAnimation />
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<MobileCodeman typed="" cursorVisible noAnimation />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</AbsoluteFill>
|
</AbsoluteFill>
|
||||||
|
|||||||
Reference in New Issue
Block a user