Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 32 additions & 8 deletions src/web/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@
const _crashDiag = {
_entries: [],
_maxEntries: 50,
// Per-page-load id: the server keys beacons by it, so a reload (fresh id)
// archives the previous page's trail instead of overwriting it, and
// concurrent clients (desktop + phone) don't clobber each other.
_pageId: Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8),
log(msg) {
const entry = `${new Date().toISOString().slice(11,23)} ${msg}`;
this._entries.push(entry);
Expand All @@ -69,22 +73,36 @@ const _crashDiag = {
}
};

// Log previous crash breadcrumbs on startup
// Log previous crash breadcrumbs on startup, and re-beacon them under a
// distinct page id — an iOS PWA reload wipes the in-memory trail, and without
// this the fresh page's first beacon would be all the server ever sees.
try {
const prev = localStorage.getItem('codeman-crash-diag');
if (prev) console.log('[CRASH-DIAG] Previous session breadcrumbs:\n' + prev);
if (prev) {
console.log('[CRASH-DIAG] Previous session breadcrumbs:\n' + prev);
navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: prev, id: _crashDiag._pageId + '-prev' }));
}
} catch {}
_crashDiag.log('PAGE LOAD');

// Heartbeat: send breadcrumbs to server every 2s so they survive tab freezes.
setInterval(() => {
function _crashDiagBeacon() {
try {
localStorage.setItem('codeman-crash-heartbeat', String(Date.now()));
if (_crashDiag._entries.length > 0) {
navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: _crashDiag._entries.join('\n') }));
navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: _crashDiag._entries.join('\n'), id: _crashDiag._pageId }));
}
} catch {}
}
setInterval(() => {
try { localStorage.setItem('codeman-crash-heartbeat', String(Date.now())); } catch {}
_crashDiagBeacon();
}, 2000);
// iOS suspends JS the instant the app is backgrounded — entries logged since
// the last 2s tick would sit unsent until (if ever) the page resumes. Flush
// immediately on hide so a repro right before an app switch is never lost.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') _crashDiagBeacon();
});

window.addEventListener('error', (e) => {
_crashDiag.log(`ERROR: ${e.message} at ${e.filename}:${e.lineno}`);
Expand Down Expand Up @@ -3390,9 +3408,15 @@ class CodemanApp {
// Close WebSocket for previous session (new one opens after buffer load)
this._disconnectWs();

// Clear CJK textarea to prevent sending stale text to the wrong session
const cjkEl = document.getElementById('cjkInput');
if (cjkEl) cjkEl.value = '';
// Clear CJK input to prevent sending stale text to the wrong session.
// Must go through CjkInput.clear() — a raw value wipe leaves the module's
// pending flush timers armed and drops the phantom char it relies on.
if (typeof CjkInput !== 'undefined') {
CjkInput.clear();
} else {
const cjkEl = document.getElementById('cjkInput');
if (cjkEl) cjkEl.value = '';
}

// Clean up flicker filter state when switching sessions
this._clearTimer('flickerFilterTimeout');
Expand Down
129 changes: 124 additions & 5 deletions src/web/public/input-cjk.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,36 @@ const CjkInput = (() => {
let _initialized = false;
let _composing = false;
let _flushTimer = null;
let _compositionFlushTimer = null;
let _dictationActive = false;
let _dictationDecayTimer = null;
let _keydownSentAt = 0;
let _keydownSentText = '';
const _listeners = {};

const PHANTOM = '​';

// ── Diagnostic trace (intermittent CJK-loss investigation) ──
// In-memory ring buffer of every IME event + flush decision. Mirrored into
// the crash-diag breadcrumbs (app.js), which beacon to the server every 2s —
// after a repro, `GET /api/crash-diag` shows the exact event sequence.
const TRACE_MAX = 200;
const _trace = [];
function _esc(v) {
const s = String(v == null ? '' : v).replace(/​/g, '∅');
return JSON.stringify(s.length > 24 ? s.slice(0, 24) + '…' + s.length : s);
}
function _t(msg) {
_trace.push(`${Date.now() % 1000000} ${msg}`);
if (_trace.length > TRACE_MAX) _trace.shift();
try {
// eslint-disable-next-line no-undef
if (typeof _crashDiag !== 'undefined') _crashDiag.log('CJK ' + msg);
} catch {
/* crash-diag unavailable (tests) — ring buffer still records */
}
}

// Two-tier debounce for non-composition input:
// - KEYBOARD: short debounce (third-party IMEs like Doubao may not fire
// composition events even for keyboard CJK typing)
Expand Down Expand Up @@ -93,6 +116,16 @@ const CjkInput = (() => {
}

function _resetToPhantom() {
// Skip redundant writes: every programmatic value/selection mutation can
// desync an Android IME's input session (InputConnection) — after which
// the keyboard composes in its own UI but NO events ever reach the page.
// Only touch the DOM when the content actually differs.
if (_textarea.value === PHANTOM) {
if (_textarea.selectionStart !== 1 || _textarea.selectionEnd !== 1) {
_textarea.setSelectionRange(1, 1);
}
return;
}
_textarea.value = PHANTOM;
_textarea.setSelectionRange(1, 1);
}
Expand All @@ -103,7 +136,17 @@ const CjkInput = (() => {

/** Flush textarea: send real text to PTY and reset to phantom */
function _flush() {
// Never flush mid-composition: reading the value would send the IME's
// provisional text, and resetting the textarea cancels the in-progress
// composition on iOS Safari — silently eating the character being typed.
// Any committed-but-unflushed text stays in the textarea and is sent
// together by the next compositionend flush.
if (_composing) {
_t('flush SKIP composing');
return;
}
const val = _strip(_textarea.value);
_t(`flush ${val ? 'send ' + _esc(val) : 'empty'}`);
if (val) {
_send(val);
}
Expand Down Expand Up @@ -150,12 +193,31 @@ const CjkInput = (() => {

_resetToPhantom();

_t('init v2-trace');

_listeners.mousedown = (e) => { e.stopPropagation(); };

// ── Wedged-IME recovery (Android) ──
// Some Android IMEs (esp. 9-key Sogou/Xiaomi/Baidu) can wedge their
// InputConnection: the keyboard composes in its own candidate bar but
// delivers ZERO DOM events to the focused textarea. JS cannot detect
// this (nothing fires) — but re-tapping the already-focused empty field
// is the user's natural "it's stuck" gesture. A blur→focus cycle forces
// the browser to restart the IME input session, which un-wedges it.
_listeners.pointerdown = () => {
if (document.activeElement === _textarea && !_composing && _isEffectivelyEmpty()) {
_t('ime-reset (retap)');
_textarea.blur();
setTimeout(() => _textarea.focus(), 0);
}
};
_listeners.focus = () => {
_t(`focus val=${_esc(_textarea.value)}`);
window.cjkActive = true;
if (!_textarea.value) _resetToPhantom();
};
_listeners.blur = () => {
_t(`blur composing=${_composing} val=${_esc(_textarea.value)}`);
// Keep cjkActive while CJK input is visible — iOS dictation and system
// UI may steal focus temporarily, and clearing the flag during that
// window lets xterm's onData process duplicated input.
Expand All @@ -168,28 +230,38 @@ const CjkInput = (() => {
_composing = false;
};
_textarea.addEventListener('mousedown', _listeners.mousedown);
_textarea.addEventListener('pointerdown', _listeners.pointerdown);
_textarea.addEventListener('focus', _listeners.focus);
_textarea.addEventListener('blur', _listeners.blur);

// ── Composition tracking (keyboard IME — works for CJK typing) ──
_listeners.compositionstart = () => {
_t(`compstart val=${_esc(_textarea.value)}`);
_composing = true;
_cancelDebouncedFlush();
// Leave textarea.value untouched — programmatic changes during
// compositionstart cancel the IME composition on iOS Safari.
};
_listeners.compositionend = () => {
_t(`compend val=${_esc(_textarea.value)}`);
_composing = false;
_cancelDebouncedFlush();
// Defer flush: some Android IMEs haven't committed text to textarea
// when compositionend fires. setTimeout(0) ensures we read the final value.
setTimeout(_flush, 0);
// Tracked so destroy() can cancel it; if the next composition starts
// before it runs, _flush's _composing guard turns it into a no-op.
clearTimeout(_compositionFlushTimer);
_compositionFlushTimer = setTimeout(() => {
_compositionFlushTimer = null;
_flush();
}, 0);
};
_textarea.addEventListener('compositionstart', _listeners.compositionstart);
_textarea.addEventListener('compositionend', _listeners.compositionend);

// ── Keydown: special keys work REGARDLESS of composition state ──
_listeners.keydown = (e) => {
_t(`keydown ${_esc(e.key)} kc=${e.keyCode} ic=${e.isComposing} c=${_composing}`);
if (e.key === 'Enter') {
e.preventDefault();
_composing = false;
Expand Down Expand Up @@ -246,6 +318,7 @@ const CjkInput = (() => {
e.preventDefault();
_send(e.key);
_keydownSentAt = performance.now();
_keydownSentText = e.key;
_resetToPhantom();
return;
}
Expand All @@ -254,6 +327,23 @@ const CjkInput = (() => {

// ── Input event: primary path for virtual keyboards + dictation ──
_listeners.input = (e) => {
_t(`input ${e.inputType || '?'} ic=${e.isComposing} c=${_composing} val=${_esc(_textarea.value)}`);
// ── Stuck-composition recovery ──
// Some IMEs (WeChat/Sogou keyboards) fire compositionstart without a
// matching compositionend. A stale _composing=true blocks every flush
// below — committed CJK text piles up in the textarea and never
// reaches the PTY. When the event itself says composition is over
// (isComposing false AND a non-composition inputType), trust it.
if (
_composing &&
e.isComposing === false &&
e.inputType !== 'insertCompositionText' &&
e.inputType !== 'deleteCompositionText'
) {
_t('UNSTICK composing');
_composing = false;
}

// ── Backspace / delete detection ──
if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteWordBackward') {
if (_composing) return;
Expand Down Expand Up @@ -283,11 +373,18 @@ const CjkInput = (() => {

if (_composing) return;

// Keydown handler already sent this character — just clear the
// textarea echo that the IME inserted despite preventDefault.
// Keydown handler already sent this character — clear the textarea
// echo that the IME inserted despite preventDefault. Content-checked:
// only a value matching the sent char is an echo. Anything else (e.g.
// an IME committing CJK text right after a keydown-sent char) is real
// input and must flow through to the debounced flush, not be dropped.
if (performance.now() - _keydownSentAt < 100) {
_resetToPhantom();
return;
const cur = _strip(_textarea.value);
if (cur === '' || cur === _keydownSentText) {
_t('echo-drop');
_resetToPhantom();
return;
}
}

// Outside composition: keyboard typing or voice dictation.
Expand All @@ -301,8 +398,30 @@ const CjkInput = (() => {
return this;
},

/**
* Discard pending text and timers (e.g. on session switch, so stale text
* can't flush into the wrong session). Restores the phantom so backspace
* forwarding keeps working — unlike a raw `textarea.value = ''`.
*/
clear() {
if (!_initialized || !_textarea) return;
_t('clear (external)');
_cancelDebouncedFlush();
clearTimeout(_compositionFlushTimer);
_compositionFlushTimer = null;
_composing = false;
_resetToPhantom();
},

/** Diagnostic: recent IME event trace (ring buffer). */
getTrace() {
return _trace.slice();
},

destroy() {
_cancelDebouncedFlush();
clearTimeout(_compositionFlushTimer);
_compositionFlushTimer = null;
clearTimeout(_dictationDecayTimer);
_dictationActive = false;
if (_textarea) {
Expand Down
39 changes: 37 additions & 2 deletions src/web/public/terminal-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,25 @@ Object.assign(CodemanApp.prototype, {
});
}

// ── Focus router ──
// While the CJK field is visible, EVERY terminal.focus() call must land on
// the CJK field instead. Focusing xterm's hidden textarea in CJK mode sends
// the IME's output into a black hole: the keyboard composes normally, but
// onData is gated by cjkActive, so nothing reaches the field OR the PTY.
// Session select / SSE-reconnect restore paths call terminal.focus() and
// were silently stealing focus after every app switch on mobile (the
// intermittent "Chinese input goes nowhere" bug). One chokepoint here
// covers all ~15 call sites plus any future ones.
const _xtermFocus = this.terminal.focus.bind(this.terminal);
this.terminal.focus = () => {
const cjkEl = document.getElementById('cjkInput');
if (cjkEl?.classList.contains('cjk-input-visible')) {
cjkEl.focus();
} else {
_xtermFocus();
}
};

// On mobile Safari, delay initial fit() to allow layout to settle
// This prevents 0-column terminals caused by fit() running before container is sized
const isMobileSafari =
Expand Down Expand Up @@ -627,7 +646,19 @@ Object.assign(CodemanApp.prototype, {
// is on, because cjkActive stays true the whole time the field is visible.
const isMouseReport = /^\x1b\[<\d+;\d+;\d+[Mm]$/.test(data);
// CJK input has focus — block xterm from sending keystrokes to PTY
if (!isMouseReport && (window.cjkActive || document.activeElement?.id === 'cjkInput')) return;
if (!isMouseReport && (window.cjkActive || document.activeElement?.id === 'cjkInput')) {
// Self-heal: if the CJK field is visible but focus drifted to xterm's
// hidden textarea (e.g. something called terminal.focus()), everything
// typed lands HERE and is swallowed — keyboard shows the IME composing
// while both the CJK field and the terminal stay empty. Route focus
// back so the very next keystroke lands in the CJK field again.
const cjkEl = document.getElementById('cjkInput');
if (cjkEl?.classList.contains('cjk-input-visible') && document.activeElement !== cjkEl) {
_crashDiag.log('CJK regain-focus (onData swallowed input)');
cjkEl.focus();
}
return;
}
if (this.activeSessionId) {
// Filter terminal query replies generated by xterm.js itself.
// Forwarding them through the WebSocket injects DA/DSR/CPR replies
Expand Down Expand Up @@ -1622,7 +1653,11 @@ Object.assign(CodemanApp.prototype, {
// CJK textarea already provides visual feedback — bypass local echo
// buffering so each composed word reaches the PTY immediately.
_handleCjkInput(text) {
if (!this.activeSessionId) return;
if (!this.activeSessionId) {
_crashDiag.log(`CJK send DROP no-session len=${text.length}`);
return;
}
_crashDiag.log(`CJK send→${this.activeSessionId.slice(0, 8)} len=${text.length}`);
this._sendInputAsync(this.activeSessionId, text);
},

Expand Down
Loading