diff --git a/CLAUDE.md b/CLAUDE.md index 7278266d..4690fcab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -298,7 +298,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ### Frontend -Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. +Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. **Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on ``. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. ⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart. ⚠️ The terminal-pane styles may animate **transform / opacity / clip-path only**, xterm's FitAddon derives rows+cols from `getComputedStyle(parent).width/height`, so animating width/height/padding there would resize the PTY; `test/entrance-animations.test.ts` pins that property allowlist, plus the rule→keyframes→theme-option chain a style silently does nothing without. ⚠️ **`blur` is the ONE style that puts a `filter` on the terminal container**, against the standing rule, because every alternative was measured against a live xterm and does not work: a `backdrop-filter` veil on `::before` blurs perfectly while STATIC and Chrome silently drops the backdrop the moment ANY animation runs on that pseudo-element (the veil computes `blur(15.3px)` and the text behind it stays razor sharp), and driving the radius from rAF buys the same full-screen blur per frame plus main-thread work. The cost the rule exists to avoid is inherent to blurring a terminal, so the style buys it knowingly: opt-in, OFF by default, one ~520ms run per session open, class straight back off, `will-change` still unset. Worst-case price, headless SwiftShader with no GPU: frame deltas 16.7ms → 33.3ms for the run, against 16.7ms flat for `fade`. Do not generalise it — a second filtered terminal style needs its own measurement. ⚠️ The `blur` connection line animates `filter` too, so both kinds of line hold their glow in **`--line-glow`** and both of its keyframes say `blur(N) var(--line-glow)`: the function lists then match and interpolate, instead of the glow vanishing for the run and popping back (a lineage line's glow is a different colour entirely, set per element). Its 100% frame deliberately omits `opacity` so the endpoint comes from the element's own resting value — 0.9 subagent, 0.72 lineage, 0.95 working — which is what `line-enter-fade`'s hardcoded 0.9 gets wrong. ⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings → Appearance, full per-surface lab at `?animlab=1`. diff --git a/test/terminal-keycode229-recovery.browser.test.ts b/test/terminal-keycode229-recovery.browser.test.ts index 8f980fc0..18b08d1e 100644 --- a/test/terminal-keycode229-recovery.browser.test.ts +++ b/test/terminal-keycode229-recovery.browser.test.ts @@ -9,7 +9,10 @@ * (stopPropagation, not stopImmediatePropagation) does not silence it; * - a `composed: true` insertText preceded by a keydown — the shape Chrome on * Android delivers — is dropped by xterm and recovered by us, exactly once; - * - a keystroke xterm DOES handle is delivered exactly once, not twice. + * - a keystroke xterm DOES handle is delivered exactly once, not twice; + * - a character committed in the SAME page task as Enter reaches the send + * path ahead of the `\r`, which is the ordering the zero-delay timer + * alone cannot produce. * * Browser-driven, so it is excluded from `npm run test:ci` like the other * Playwright suites. Run locally: @@ -146,6 +149,84 @@ describe('orphaned terminal input recovery wiring', () => { expect(second.sent.join('')).toBe('z'); }); + /** + * The batched shape an Android soft keyboard actually delivers when the user + * taps the last character and then Enter: the character's keydown, its + * `composed: true` insertText, and Enter's keydown all land in ONE page task, + * before any zero-delay timer can run. + * + * This is the ordering half of the fix, and the half the unit harness cannot + * reach: the unit tests prove WHICH candidate is forwarded, this proves WHEN. + * Resolving the pending candidate only on its 0 ms timer loses the character + * outright here, because by the time that timer runs xterm has already + * emitted the `\r` and bumped the canonical counter past the candidate's + * snapshot, so it stands down. Draining at the next keydown, from xterm's + * custom key handler (which runs before xterm processes that key), puts the + * character on the wire ahead of the `\r`. + */ + async function batchedCommitThenEnter(data: string) { + return page.evaluate(async (text) => { + const app = (window as any).app; + const textarea = document.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement; + const originalSessionId = app.activeSessionId; + const originalLocalEcho = app._localEchoEnabled; + const originalSendInput = app._sendInputAsync; + const originalPendingInput = app._pendingInput; + const originalLastKeystrokeTime = app._lastKeystrokeTime; + const sent: string[] = []; + + try { + app.activeSessionId = 'cod388-browser-batched'; + app._localEchoEnabled = false; + app._pendingInput = ''; + app._lastKeystrokeTime = 0; + app._sendInputAsync = (_sessionId: string, chunk: string) => sent.push(chunk); + textarea.focus(); + + // One task, no awaits between the three dispatches. + const charDown = new KeyboardEvent('keydown', { + key: 'Unidentified', + bubbles: true, + cancelable: true, + composed: true, + }); + Object.defineProperties(charDown, { keyCode: { value: 65 }, which: { value: 65 } }); + textarea.dispatchEvent(charDown); + + textarea.value = text; + textarea.dispatchEvent( + new InputEvent('input', { data: text, inputType: 'insertText', bubbles: true, composed: true }) + ); + + const enterDown = new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + bubbles: true, + cancelable: true, + composed: true, + }); + Object.defineProperties(enterDown, { keyCode: { value: 13 }, which: { value: 13 } }); + textarea.dispatchEvent(enterDown); + + await new Promise((resolve) => setTimeout(resolve, 80)); + return { wire: sent.join('') }; + } finally { + app.activeSessionId = originalSessionId; + app._localEchoEnabled = originalLocalEcho; + app._sendInputAsync = originalSendInput; + app._pendingInput = originalPendingInput; + app._lastKeystrokeTime = originalLastKeystrokeTime; + textarea.value = ''; + } + }, data); + } + + it('delivers a character committed in the same task as Enter BEFORE the carriage return', async () => { + const { wire } = await batchedCommitThenEnter('o'); + // Not '\r' (character lost, the defect) and not '\ro' (recovered too late). + expect(wire).toBe('o\r'); + }); + it('sends nothing for a keydown that produces no input event', async () => { const { sent } = await keystroke({ data: 'q', dispatchInput: false, keyCode: 65 }); expect(sent).toEqual([]);