From 70742d400a7778cd441ceb57f70a3f69afc5e183 Mon Sep 17 00:00:00 2001 From: Teigen Date: Mon, 15 Jun 2026 17:23:14 +0800 Subject: [PATCH 1/7] fix(mobile): restore real-time CJK input and terminal tap interaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: the mobile-composer mode (02fa3f3) routed CJK text through local-echo buffering, which accumulated characters until Enter instead of sending each composed word to the PTY immediately. Additionally, xtermFocusRedirect hijacked all terminal taps, preventing cursor positioning and scroll interaction. Changes: - Remove mobile-composer accumulation mode from input-cjk.js — all platforms now use the same immediate-flush path (compositionend → flush → PTY) - Bypass local-echo buffering in _handleCjkInput (terminal-ui.js) — the CJK textarea already provides visual feedback - Remove xtermFocusRedirect so terminal taps work normally again - Reduce CJK textarea height (34px min, 6px padding) for less screen intrusion - Paste dialog now sends Enter after text so pasted content submits - Hide CJK textarea on welcome screen (no active session) - Add Opus 4.6 model options to selector --- src/web/public/app.js | 1 - src/web/public/input-cjk.js | 75 ++-------------------------- src/web/public/keyboard-accessory.js | 5 +- src/web/public/styles.css | 6 +-- src/web/public/terminal-ui.js | 66 ++---------------------- 5 files changed, 14 insertions(+), 139 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index b5a824e0..30960fbe 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2206,7 +2206,6 @@ class CodemanApp { document.body.classList.toggle('cjk-input-visible', !!showCjk); cjkEl.style.display = showCjk ? 'block' : 'none'; cjkEl.setAttribute('aria-hidden', showCjk ? 'false' : 'true'); - if (showCjk && cjkEl.value === '\u200B') cjkEl.value = ''; if (!showCjk) window.cjkActive = false; if (typeof KeyboardHandler !== 'undefined') KeyboardHandler.updateLayoutForKeyboard(); } diff --git a/src/web/public/input-cjk.js b/src/web/public/input-cjk.js index 4a3b6a8d..72305c82 100644 --- a/src/web/public/input-cjk.js +++ b/src/web/public/input-cjk.js @@ -41,8 +41,6 @@ // eslint-disable-next-line no-unused-vars const CjkInput = (() => { let _textarea = null; - let _terminalContainer = null; - let _xtermTextarea = null; let _send = null; let _initialized = false; let _composing = false; @@ -77,23 +75,6 @@ const CjkInput = (() => { _textarea.setSelectionRange(1, 1); } - function _isMobileComposer() { - return !!( - _textarea && - typeof MobileDetection !== 'undefined' && - MobileDetection.isTouchDevice() && - _textarea.classList.contains('cjk-input-visible') - ); - } - - function _resetInput() { - if (_isMobileComposer()) { - _textarea.value = ''; - } else { - _resetToPhantom(); - } - } - /** Check if textarea contains only phantom(s) or is empty — no real user text */ function _isEffectivelyEmpty() { return !_strip(_textarea.value); @@ -116,44 +97,22 @@ const CjkInput = (() => { _composing = false; _textarea = document.getElementById('cjkInput'); if (!_textarea) return this; - _terminalContainer = document.getElementById('terminalContainer'); - // Seed the phantom character for the hidden/immediate CJK path. - _resetInput(); + _resetToPhantom(); _listeners.mousedown = (e) => { e.stopPropagation(); }; _listeners.focus = () => { window.cjkActive = true; - if (_isMobileComposer() && _textarea.value === PHANTOM) { - _textarea.value = ''; - return; - } - // Restore phantom if textarea was emptied while blurred - if (!_textarea.value && !_isMobileComposer()) _resetToPhantom(); + if (!_textarea.value) _resetToPhantom(); }; _listeners.blur = () => { window.cjkActive = false; }; _textarea.addEventListener('mousedown', _listeners.mousedown); _textarea.addEventListener('focus', _listeners.focus); _textarea.addEventListener('blur', _listeners.blur); - _listeners.xtermFocusRedirect = () => { - if (!_isMobileComposer()) return; - _textarea.focus(); - }; - if (_terminalContainer) { - _xtermTextarea = _terminalContainer.querySelector('.xterm-helper-textarea'); - if (_xtermTextarea) { - _xtermTextarea.addEventListener('focus', _listeners.xtermFocusRedirect, { capture: true }); - } - } - // ── Composition tracking ── _listeners.compositionstart = () => { _composing = true; - if (_isMobileComposer()) { - if (_textarea.value === PHANTOM) _textarea.value = ''; - return; - } // Clear phantom so IME sees a clean textarea — some IMEs include // existing text in the composition region which would corrupt input. if (_textarea.value === PHANTOM) { @@ -162,7 +121,6 @@ const CjkInput = (() => { }; _listeners.compositionend = () => { _composing = false; - if (_isMobileComposer()) return; // 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); @@ -185,7 +143,7 @@ const CjkInput = (() => { } else { _send('\r'); } - _resetInput(); + _resetToPhantom(); return; } @@ -193,7 +151,7 @@ const CjkInput = (() => { if (e.key === 'Escape') { e.preventDefault(); _composing = false; - _resetInput(); + _resetToPhantom(); return; } @@ -207,19 +165,6 @@ const CjkInput = (() => { // Below: only when NOT composing (composing keystrokes belong to IME) if (_composing) return; - if (_isMobileComposer()) { - if (e.key === 'Backspace' && _isEffectivelyEmpty()) { - e.preventDefault(); - _send('\x7f'); - return; - } - if (PASSTHROUGH_KEYS[e.key] && _isEffectivelyEmpty()) { - e.preventDefault(); - _send(PASSTHROUGH_KEYS[e.key]); - } - return; - } - // Backspace: forward to PTY when no real text in textarea // (Desktop path — Android uses the input event + phantom approach) if (e.key === 'Backspace' && _isEffectivelyEmpty()) { @@ -251,13 +196,6 @@ const CjkInput = (() => { // making keydown unreliable. input fires AFTER character insertion and // carries inputType which tells us whether the text is final or tentative. _listeners.input = (e) => { - if (_isMobileComposer()) { - if (_textarea.value.includes(PHANTOM)) { - _textarea.value = _strip(_textarea.value); - } - return; - } - // ── Backspace / delete detection ── // Android long-press backspace generates rapid deleteContentBackward events. // The phantom character ensures the textarea is never truly empty, so each @@ -305,13 +243,8 @@ const CjkInput = (() => { if (handler) _textarea.removeEventListener(event, handler); } } - if (_xtermTextarea && _listeners.xtermFocusRedirect) { - _xtermTextarea.removeEventListener('focus', _listeners.xtermFocusRedirect, { capture: true }); - } window.cjkActive = false; _composing = false; - _terminalContainer = null; - _xtermTextarea = null; for (const key of Object.keys(_listeners)) delete _listeners[key]; _initialized = false; }, diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index d638aef5..5ac900d6 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -300,7 +300,10 @@ const KeyboardAccessoryBar = { const sendText = () => { const text = textarea.value; close(); - if (text) app.sendInput(text); + if (text) { + app.sendInput(text); + setTimeout(() => app.sendInput('\r'), 80); + } }; // Filter to images, close the dialog, and hand off to the shared diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 960f4974..7a397d95 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -9307,9 +9307,9 @@ kbd { bottom: calc(var(--safe-area-bottom) + 40px); z-index: 52; display: block; - min-height: 44px; - max-height: 96px; - padding: 12px 10px; + min-height: 34px; + max-height: 68px; + padding: 6px 10px; border: 1px solid rgba(80, 120, 190, 0.55); border-left: none; border-right: none; diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index f7b10bda..a26813ce 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1577,71 +1577,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; - const sessionId = this.activeSessionId; - const session = this.sessions.get(sessionId); - const useLocalEcho = !!(this._localEchoEnabled && this._localEchoOverlay && session?.mode !== 'shell'); - if (!useLocalEcho) { - this._sendInputAsync(sessionId, text); - return; - } - - if (text === '\x7f') { - const source = this._localEchoOverlay.removeChar(); - if (source === 'flushed') { - // Sync app-level flushed Maps (per-session state for tab switching), - // mirroring the onData backspace path — otherwise switching tabs away - // and back restores a stale, too-long flushed overlay. - const { count, text: flushedText } = this._localEchoOverlay.getFlushed(); - if (this._flushedOffsets?.has(sessionId)) { - if (count === 0) { - this._flushedOffsets.delete(sessionId); - this._flushedTexts?.delete(sessionId); - } else { - this._flushedOffsets.set(sessionId, count); - this._flushedTexts?.set(sessionId, flushedText); - } - } - this._sendInputAsync(sessionId, text); - } - return; - } - - if (/[\r\n]+$/.test(text)) { - const committed = text.replace(/[\r\n]+$/g, ''); - if (committed) this._localEchoOverlay.appendText(committed); - const pending = this._localEchoOverlay.pendingText || ''; - this._localEchoOverlay.clear(); - this._localEchoOverlay.suppressBufferDetection(); - this._flushedOffsets?.delete(sessionId); - this._flushedTexts?.delete(sessionId); - if (pending) this._sendInputAsync(sessionId, pending); - setTimeout(() => this._sendInputAsync(sessionId, '\r'), pending ? 80 : 0); - return; - } - - // Multi-byte escape sequence (arrow/Home/End from a hardware keyboard on - // the composer) — forward to the PTY without touching overlay state, - // mirroring the onData path. Appending it to pending text would type raw - // ESC bytes into the prompt on the next Enter. - if (text.length > 1 && text.charCodeAt(0) === 27) { - this._sendInputAsync(sessionId, text); - return; - } - - if (text.length === 1 && text.charCodeAt(0) < 32) { - const pending = this._localEchoOverlay.pendingText || ''; - this._localEchoOverlay.clear(); - this._localEchoOverlay.suppressBufferDetection(); - this._flushedOffsets?.delete(sessionId); - this._flushedTexts?.delete(sessionId); - if (pending) this._sendInputAsync(sessionId, pending); - this._sendInputAsync(sessionId, text); - return; - } - - this._localEchoOverlay.appendText(text); + this._sendInputAsync(this.activeSessionId, text); }, /** From 1a54217bfb66a242be7440af4630fa177ba9ae9c Mon Sep 17 00:00:00 2001 From: Teigen Date: Mon, 15 Jun 2026 17:33:31 +0800 Subject: [PATCH 2/7] fix(mobile): don't clear textarea during compositionstart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Programmatic _textarea.value = '' during compositionstart cancels the active IME composition on iOS Safari, breaking Chinese character input. The phantom (U+200B) is invisible and _strip() already removes it before sending to PTY — no need to clear it manually. --- src/web/public/input-cjk.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/web/public/input-cjk.js b/src/web/public/input-cjk.js index 72305c82..cb398548 100644 --- a/src/web/public/input-cjk.js +++ b/src/web/public/input-cjk.js @@ -113,11 +113,9 @@ const CjkInput = (() => { // ── Composition tracking ── _listeners.compositionstart = () => { _composing = true; - // Clear phantom so IME sees a clean textarea — some IMEs include - // existing text in the composition region which would corrupt input. - if (_textarea.value === PHANTOM) { - _textarea.value = ''; - } + // Leave textarea.value untouched — programmatic changes during + // compositionstart cancel the IME composition on iOS Safari. + // The phantom (U+200B) is invisible and _strip() removes it on flush. }; _listeners.compositionend = () => { _composing = false; From c55af9ec39520a87028af8da8816f276a858948a Mon Sep 17 00:00:00 2001 From: Teigen Date: Wed, 17 Jun 2026 17:40:50 +0800 Subject: [PATCH 3/7] fix(iPad): CJK input positioning, paste dialog, and voice dictation duplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three iPad-specific issues fixed: 1. CJK input hidden behind keyboard: updateLayoutForKeyboard() gate changed from screen-size to touch-device detection. On iPad, CJK textarea (always position:fixed) gets bottom offset computed from keyboard HEIGHT directly instead of keyboardOffset (which depends on visualViewport.offsetTop that iOS adjusts when the CJK textarea receives focus). Toolbar/accessory bar transforms remain phone-only (they're normal-flow on iPad). 2. Paste dialog invisible on iPad: paste overlay CSS was inside @media (max-width: 430px) phone breakpoint — iPad (≥768px) had no styling. Extracted to universal section alongside keyboard accessory bar styles. 3. Voice dictation character duplication (Doubao/third-party IME): iOS voice dictation does NOT fire composition events (WebKit Bug 261764). Text arrives as bare input events; refinement is a delete→reinsert cycle. Rewrote CJK input handler with two-tier debounce: - Keyboard typing (no delete/replacement events): 150ms debounce - Dictation mode (deleteContentBackward or insertReplacementText detected): 1500ms debounce, persists 3s to cover multi-word dictation - Composition path (compositionend): immediate flush, unchanged - Keydown singles/Enter/Esc/Ctrl: immediate, unchanged Also: keep cjkActive=true on blur while CJK is visible (prevents xterm from processing duplicate input when iOS dictation UI steals focus); keydown single-char sends tracked via timestamp to suppress the echo input event that third-party IMEs fire despite preventDefault. --- src/web/public/input-cjk.js | 157 +++++++++++++++++++--------- src/web/public/mobile-handlers.js | 80 +++++++-------- src/web/public/mobile.css | 163 +++++++++++++++--------------- 3 files changed, 231 insertions(+), 169 deletions(-) diff --git a/src/web/public/input-cjk.js b/src/web/public/input-cjk.js index cb398548..9390a039 100644 --- a/src/web/public/input-cjk.js +++ b/src/web/public/input-cjk.js @@ -14,13 +14,21 @@ * This means compositionstart fires even for English text, and compositionend * may not fire until the user explicitly confirms (space, candidate tap). * - * We use InputEvent.inputType to distinguish: - * - `insertCompositionText`: tentative text, may change (CJK candidates, pinyin) - * - `insertText`: final committed text (confirmed word, punctuation, space) + * During composition, all input events are ignored — only compositionend + * triggers a flush (CJK candidate selection). * - * During composition, `insertText` events are flushed immediately (punctuation, - * English words confirmed by IME). `insertCompositionText` waits for - * compositionend (CJK candidate selection). + * ## iOS dictation challenge (WebKit Bug 261764) + * + * iOS/iPadOS voice dictation does NOT fire composition events. Text arrives + * as bare input events with isComposing === false. Dictation refinement is + * a delete→reinsert cycle (deleteContentBackward + insertReplacementText), + * all within a few ms. Flushing on every input event would send irrevocable + * provisional text to the PTY, causing duplication when the IME replaces it. + * + * Solution: outside composition, flush is DEBOUNCED (200ms). The entire + * delete→reinsert cycle collapses into one flush of the final textarea value. + * Keyboard typing of single printable characters still goes through the + * keydown handler (immediate, no debounce). * * ## Phantom character for Android backspace * @@ -44,11 +52,27 @@ const CjkInput = (() => { let _send = null; let _initialized = false; let _composing = false; + let _flushTimer = null; + let _dictationActive = false; + let _dictationDecayTimer = null; + let _keydownSentAt = 0; const _listeners = {}; - // Zero-width space: always present in textarea so Android backspace has - // something to delete, triggering the `input` event we need to detect it. - const PHANTOM = '\u200B'; + const PHANTOM = '​'; + + // 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) + // - DICTATION: long debounce (iOS voice dictation sends delete→reinsert + // refinement cycles without composition events — WebKit Bug 261764) + // + // Dictation is detected by deleteContentBackward on non-empty text or + // insertReplacementText — signals that the IME is rewriting provisional + // text. Once detected, dictation mode persists for 3s (covers multi-word + // dictation with natural pauses between words). + const DEBOUNCE_KEYBOARD_MS = 150; + const DEBOUNCE_DICTATION_MS = 1500; + const DICTATION_DECAY_MS = 3000; const PASSTHROUGH_KEYS = { ArrowUp: '\x1b[A', @@ -64,18 +88,15 @@ const CjkInput = (() => { c: '\x03', d: '\x04', l: '\x0c', z: '\x1a', a: '\x01', e: '\x05', }; - /** Strip phantom characters from a string */ function _strip(str) { - return str.replace(/\u200B/g, ''); + return str.replace(/​/g, ''); } - /** Reset textarea to phantom-only state with cursor at end */ function _resetToPhantom() { _textarea.value = PHANTOM; _textarea.setSelectionRange(1, 1); } - /** Check if textarea contains only phantom(s) or is empty — no real user text */ function _isEffectivelyEmpty() { return !_strip(_textarea.value); } @@ -89,12 +110,41 @@ const CjkInput = (() => { _resetToPhantom(); } + /** Cancel any pending debounced flush */ + function _cancelDebouncedFlush() { + if (_flushTimer) { + clearTimeout(_flushTimer); + _flushTimer = null; + } + } + + /** Mark that dictation rewriting is in progress */ + function _enterDictationMode() { + _dictationActive = true; + clearTimeout(_dictationDecayTimer); + _dictationDecayTimer = setTimeout(() => { + _dictationActive = false; + _dictationDecayTimer = null; + }, DICTATION_DECAY_MS); + } + + /** Schedule a flush after input settles */ + function _debouncedFlush() { + _cancelDebouncedFlush(); + const delay = _dictationActive ? DEBOUNCE_DICTATION_MS : DEBOUNCE_KEYBOARD_MS; + _flushTimer = setTimeout(() => { + _flushTimer = null; + _flush(); + }, delay); + } + return { init({ send }) { if (_initialized) this.destroy(); _send = send; _composing = false; + _flushTimer = null; _textarea = document.getElementById('cjkInput'); if (!_textarea) return this; @@ -105,20 +155,28 @@ const CjkInput = (() => { window.cjkActive = true; if (!_textarea.value) _resetToPhantom(); }; - _listeners.blur = () => { window.cjkActive = false; }; + _listeners.blur = () => { + // 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. + if (!_textarea.classList.contains('cjk-input-visible')) { + window.cjkActive = false; + } + }; _textarea.addEventListener('mousedown', _listeners.mousedown); _textarea.addEventListener('focus', _listeners.focus); _textarea.addEventListener('blur', _listeners.blur); - // ── Composition tracking ── + // ── Composition tracking (keyboard IME — works for CJK typing) ── _listeners.compositionstart = () => { _composing = true; + _cancelDebouncedFlush(); // Leave textarea.value untouched — programmatic changes during // compositionstart cancel the IME composition on iOS Safari. - // The phantom (U+200B) is invisible and _strip() removes it on flush. }; _listeners.compositionend = () => { _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); @@ -128,13 +186,10 @@ const CjkInput = (() => { // ── Keydown: special keys work REGARDLESS of composition state ── _listeners.keydown = (e) => { - // Enter: flush accumulated text (or bare Enter if empty). - // No isComposing guard — Android IMEs set isComposing=true for English - // prediction, but Enter should ALWAYS send. We preventDefault to stop - // the IME from also handling Enter (which could double-send or do nothing). if (e.key === 'Enter') { e.preventDefault(); _composing = false; + _cancelDebouncedFlush(); const val = _strip(_textarea.value); if (val) { _send(val + '\r'); @@ -145,15 +200,14 @@ const CjkInput = (() => { return; } - // Escape: clear textarea (always works) if (e.key === 'Escape') { e.preventDefault(); _composing = false; + _cancelDebouncedFlush(); _resetToPhantom(); return; } - // Ctrl combos: forward to PTY (always works) if (e.ctrlKey && CTRL_KEYS[e.key]) { e.preventDefault(); _send(CTRL_KEYS[e.key]); @@ -164,7 +218,6 @@ const CjkInput = (() => { if (_composing) return; // Backspace: forward to PTY when no real text in textarea - // (Desktop path — Android uses the input event + phantom approach) if (e.key === 'Backspace' && _isEffectivelyEmpty()) { e.preventDefault(); _send('\x7f'); @@ -179,55 +232,62 @@ const CjkInput = (() => { return; } - // Single printable character: send immediately to PTY - // (Desktop keyboards with physical keys — Android sends 'Unidentified') + // Single printable character: send immediately to PTY. + // Third-party IMEs on iOS may ignore preventDefault, so the char + // still enters the textarea and fires an input event — _keydownSentAt + // tells the input handler to skip that echo. if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey && _isEffectivelyEmpty()) { e.preventDefault(); _send(e.key); + _keydownSentAt = performance.now(); + _resetToPhantom(); return; } }; _textarea.addEventListener('keydown', _listeners.keydown); - // ── Input event: the primary path for Android virtual keyboards ── - // Android sends keyCode 229 + key "Unidentified" for virtual key presses, - // making keydown unreliable. input fires AFTER character insertion and - // carries inputType which tells us whether the text is final or tentative. + // ── Input event: primary path for virtual keyboards + dictation ── _listeners.input = (e) => { // ── Backspace / delete detection ── - // Android long-press backspace generates rapid deleteContentBackward events. - // The phantom character ensures the textarea is never truly empty, so each - // press/repeat fires an input event that we can catch here. if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteWordBackward') { + if (_composing) return; if (_isEffectivelyEmpty()) { - // No real text left — forward backspace to PTY + _cancelDebouncedFlush(); _send('\x7f'); _resetToPhantom(); return; } - // User is editing their own text in the textarea — let it be. - // Ensure phantom is still present for the NEXT backspace. + // Delete on non-empty text outside composition = dictation rewrite. + // The IME is revising provisional text — switch to long debounce. + _enterDictationMode(); if (!_textarea.value.startsWith(PHANTOM)) { _textarea.value = PHANTOM + _textarea.value; _textarea.setSelectionRange(1, 1); } + _debouncedFlush(); return; } - if (_composing) { - // insertText during composition = IME committed final text - // (e.g., punctuation key inserts 。directly, or IME confirms a word). - // Flush immediately — this text won't change. - if (e.inputType === 'insertText') { - _flush(); - return; - } - // insertCompositionText = IME is still working (pinyin, candidates, - // English prediction). Wait for compositionend to flush. + // insertReplacementText = dictation/autocorrect refinement + if (e.inputType === 'insertReplacementText') { + _enterDictationMode(); + _debouncedFlush(); return; } - // Outside composition: send immediately - _flush(); + + if (_composing) return; + + // Keydown handler already sent this character — just clear the + // textarea echo that the IME inserted despite preventDefault. + if (performance.now() - _keydownSentAt < 100) { + _resetToPhantom(); + return; + } + + // Outside composition: keyboard typing or voice dictation. + // If dictation mode was detected (delete/replacement events seen + // recently), use long debounce. Otherwise short debounce for keyboard. + _debouncedFlush(); }; _textarea.addEventListener('input', _listeners.input); @@ -236,6 +296,9 @@ const CjkInput = (() => { }, destroy() { + _cancelDebouncedFlush(); + clearTimeout(_dictationDecayTimer); + _dictationActive = false; if (_textarea) { for (const [event, handler] of Object.entries(_listeners)) { if (handler) _textarea.removeEventListener(event, handler); diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index f55bd2e1..be88a5a4 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -291,57 +291,50 @@ const KeyboardHandler = { updateLayoutForKeyboard() { if (!window.visualViewport) return; - // Only adjust on mobile - if (!MobileDetection.isSmallScreen() && !MobileDetection.isMediumScreen()) { + if (!MobileDetection.isTouchDevice()) { this.resetLayout(); return; } - const toolbar = document.querySelector('.toolbar'); - const accessoryBar = document.querySelector('.keyboard-accessory-bar'); const cjkInput = document.getElementById('cjkInput'); - const main = document.querySelector('.main'); + const isSmallMedium = MobileDetection.isSmallScreen() || MobileDetection.isMediumScreen(); if (this.keyboardVisible) { - // Calculate how far the toolbar (position:fixed, bottom:0) needs to - // translate up so it sits at the bottom of the visual viewport. - // This formula accounts for iOS scrolling the visual viewport (offsetTop) - // when the user types in xterm's hidden textarea. - // - // MUST measure against the LAYOUT viewport (window.innerHeight): the - // bars are position:fixed, which anchors to the layout viewport — on - // iOS that keeps its full height while the keyboard is open. Measuring - // the shrunken .app instead (its height tracks --app-height = visual - // viewport) made the offset compute to 0 on iOS, leaving the toolbar - // and accessory bar behind the OS keyboard (0.9.8 regression). On - // Android the layout viewport itself shrinks with the keyboard, so - // innerHeight === visualBottom and the offset is naturally 0 there. - const layoutHeight = window.innerHeight; - const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height; - const keyboardOffset = Math.max(0, layoutHeight - visualBottom); - - // Move toolbar and accessory bar above keyboard. - // When keyboardOffset is 0 (viewport scrolled to layout bottom), - // the bars are naturally positioned via their CSS bottom values — - // just clear the transforms. Never dismiss keyboard state here; - // that's handleViewportResize's job. - if (toolbar) { - toolbar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : ''; - } - if (accessoryBar) { - accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : ''; - } - if (cjkInput?.classList.contains('cjk-input-visible')) { - cjkInput.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : ''; - } - - // Reserve only Codeman's visible controls. The OS keyboard is outside - // the visual viewport; adding its height here creates a large blank area - // above the mobile toolbar on iPhone. const keyboardHeight = this.initialViewportHeight - (window.visualViewport.height || window.innerHeight); - if (main && keyboardHeight > 0) { - const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0; - main.style.paddingBottom = `${84 + cjkInputHeight}px`; + + // Toolbar and accessory bar: only transform on phones/small tablets where + // they are position:fixed. On iPad (≥768px) they're in normal flow inside + // the shrunk .app container and already sit above the keyboard. + if (isSmallMedium) { + const toolbar = document.querySelector('.toolbar'); + const accessoryBar = document.querySelector('.keyboard-accessory-bar'); + const main = document.querySelector('.main'); + + const layoutHeight = window.innerHeight; + const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height; + const keyboardOffset = Math.max(0, layoutHeight - visualBottom); + + if (toolbar) { + toolbar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : ''; + } + if (accessoryBar) { + accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : ''; + } + if (main && keyboardHeight > 0) { + const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0; + main.style.paddingBottom = `${84 + cjkInputHeight}px`; + } + } + + // CJK textarea: position on ALL touch devices (it's always position:fixed). + // Uses keyboard HEIGHT directly — independent of visualViewport.offsetTop + // which iOS adjusts when the CJK textarea receives focus/dictation. + if (cjkInput?.classList.contains('cjk-input-visible') && keyboardHeight > 0) { + // On phones, sit above toolbar+accessory (84px). On tablets in normal + // flow, the toolbar/accessory are in-flow so use a smaller offset. + const bottomOffset = isSmallMedium ? 84 : 44; + cjkInput.style.bottom = `${keyboardHeight + bottomOffset}px`; + cjkInput.style.transform = ''; } } else { this.resetLayout(); @@ -363,6 +356,7 @@ const KeyboardHandler = { } if (cjkInput) { cjkInput.style.transform = ''; + cjkInput.style.bottom = ''; } if (main) { main.style.paddingBottom = ''; diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index b12c16e1..3838a9b9 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -1065,85 +1065,7 @@ html.mobile-init .file-browser-panel { } /* Paste overlay for iOS clipboard access */ - .paste-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.6); - z-index: 10000; - display: flex; - align-items: flex-start; - justify-content: center; - padding-top: 15vh; - } - - .paste-dialog { - background: var(--bg-secondary, #1e1e2e); - border: 1px solid var(--border-color, #444); - border-radius: 12px; - padding: 12px; - width: calc(100% - 24px); - max-width: 400px; - } - - .paste-textarea { - width: 100%; - min-height: 80px; - max-height: 200px; - background: var(--bg-primary, #0d0d14); - color: var(--text-primary, #e0e0e0); - border: 1px solid var(--border-color, #444); - border-radius: 8px; - padding: 8px; - font-family: inherit; - font-size: 16px; - resize: none; - box-sizing: border-box; - } - - .paste-textarea:focus { - outline: none; - border-color: var(--accent-color, #7aa2f7); - } - - .paste-actions { - display: flex; - justify-content: flex-end; - gap: 8px; - margin-top: 10px; - } - - .paste-cancel, .paste-new, .paste-send, .paste-image { - padding: 8px 18px; - border: none; - border-radius: 8px; - font-size: 14px; - cursor: pointer; - } - - /* Image attach button — left-aligned, accent outline */ - .paste-image { - margin-right: auto; - background: var(--bg-tertiary, #333); - color: var(--accent-color, #7aa2f7); - border: 1px solid var(--accent-color, #7aa2f7); - } - - .paste-cancel { - background: var(--bg-tertiary, #333); - color: var(--text-secondary, #aaa); - } - - .paste-new { - background: var(--bg-tertiary, #333); - color: var(--accent-color, #7aa2f7); - border: 1px solid var(--accent-color, #7aa2f7); - } - - .paste-send { - background: var(--accent-color, #7aa2f7); - color: #fff; - font-weight: 600; - } + /* Paste overlay styles extracted to universal section below (line ~2293+) */ /* LEGACY: Hide old toolbar select (no longer used on mobile) */ .toolbar-select { @@ -2267,6 +2189,89 @@ html.mobile-init .file-browser-panel { background: #3d5f85; } +/* ============================================================================ + Paste Overlay — all mobile/tablet sizes + Extracted from phone breakpoint so it applies on iPad too. + ============================================================================ */ +.paste-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 10000; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 15vh; +} + +.paste-dialog { + background: var(--bg-secondary, #1e1e2e); + border: 1px solid var(--border-color, #444); + border-radius: 12px; + padding: 12px; + width: calc(100% - 24px); + max-width: 400px; +} + +.paste-textarea { + width: 100%; + min-height: 80px; + max-height: 200px; + background: var(--bg-primary, #0d0d14); + color: var(--text-primary, #e0e0e0); + border: 1px solid var(--border-color, #444); + border-radius: 8px; + padding: 8px; + font-family: inherit; + font-size: 16px; + resize: none; + box-sizing: border-box; +} + +.paste-textarea:focus { + outline: none; + border-color: var(--accent-color, #7aa2f7); +} + +.paste-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 10px; +} + +.paste-cancel, .paste-new, .paste-send, .paste-image { + padding: 8px 18px; + border: none; + border-radius: 8px; + font-size: 14px; + cursor: pointer; +} + +.paste-image { + margin-right: auto; + background: var(--bg-tertiary, #333); + color: var(--accent-color, #7aa2f7); + border: 1px solid var(--accent-color, #7aa2f7); +} + +.paste-cancel { + background: var(--bg-tertiary, #333); + color: var(--text-secondary, #aaa); +} + +.paste-new { + background: var(--bg-tertiary, #333); + color: var(--accent-color, #7aa2f7); + border: 1px solid var(--accent-color, #7aa2f7); +} + +.paste-send { + background: var(--accent-color, #7aa2f7); + color: #fff; + font-weight: 600; +} + /* ============================================================================ iOS Safari Specific Fixes ============================================================================ */ From cd7d8c732998979011305952cc5b68fe198fc31f Mon Sep 17 00:00:00 2001 From: Teigen Date: Wed, 17 Jun 2026 17:43:02 +0800 Subject: [PATCH 4/7] fix(mobile): split CJK keyboard positioning by device size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phones use translateY(-keyboardOffset) — CSS bottom is relative to layout viewport and keyboardOffset reliably lifts it above the keyboard (iOS doesn't auto-scroll the visual viewport for the CJK textarea on phones). iPad uses direct bottom positioning from keyboard height — translateY broke because iOS auto-scrolls the visual viewport when the CJK textarea receives focus, making keyboardOffset approach 0. --- src/web/public/mobile-handlers.js | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index be88a5a4..6ce78f9b 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -326,15 +326,26 @@ const KeyboardHandler = { } } - // CJK textarea: position on ALL touch devices (it's always position:fixed). - // Uses keyboard HEIGHT directly — independent of visualViewport.offsetTop - // which iOS adjusts when the CJK textarea receives focus/dictation. + // CJK textarea positioning (always position:fixed on touch devices). if (cjkInput?.classList.contains('cjk-input-visible') && keyboardHeight > 0) { - // On phones, sit above toolbar+accessory (84px). On tablets in normal - // flow, the toolbar/accessory are in-flow so use a smaller offset. - const bottomOffset = isSmallMedium ? 84 : 44; - cjkInput.style.bottom = `${keyboardHeight + bottomOffset}px`; - cjkInput.style.transform = ''; + if (isSmallMedium) { + // Phones: use translateY like toolbar/accessory bar. The CSS bottom + // (84px) is relative to the layout viewport; translateY lifts it + // above the keyboard. This works on phones where iOS doesn't + // auto-scroll the visual viewport for the CJK textarea. + const layoutHeight = window.innerHeight; + const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height; + const keyboardOffset = Math.max(0, layoutHeight - visualBottom); + cjkInput.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : ''; + cjkInput.style.bottom = ''; + } else { + // iPad: use direct bottom positioning from keyboard height. + // translateY breaks on iPad because iOS auto-scrolls the visual + // viewport when the CJK textarea receives focus, making + // keyboardOffset unreliable (approaches 0). + cjkInput.style.bottom = `${keyboardHeight + 44}px`; + cjkInput.style.transform = ''; + } } } else { this.resetLayout(); From 566c65c3c96bc2035c562bb3b2ebb5d5b963dd62 Mon Sep 17 00:00:00 2001 From: Teigen Date: Wed, 17 Jun 2026 18:11:56 +0800 Subject: [PATCH 5/7] fix(iPad): accessory bar styling, positioning, and paste dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move keyboard accessory bar and paste dialog CSS from mobile.css (gated behind max-width: 1023px) to styles.css (always loaded). iPad landscape (≥1024px) was getting unstyled white buttons. - Add position:fixed via .touch-device class for accessory bar - Fix dismiss button: gray-blue → blue, matching phone styling - JS: position accessory bar above keyboard on iPad via direct bottom - JS: position CJK above accessory bar (bottom: keyboardHeight + 44) - Clear accessory bar bottom in resetLayout() --- src/web/public/mobile-handlers.js | 24 ++-- src/web/public/mobile.css | 175 +--------------------------- src/web/public/styles.css | 187 ++++++++++++++++++++++++++++++ 3 files changed, 202 insertions(+), 184 deletions(-) diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index 6ce78f9b..e76e5017 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -301,13 +301,12 @@ const KeyboardHandler = { if (this.keyboardVisible) { const keyboardHeight = this.initialViewportHeight - (window.visualViewport.height || window.innerHeight); + const accessoryBar = document.querySelector('.keyboard-accessory-bar'); - // Toolbar and accessory bar: only transform on phones/small tablets where - // they are position:fixed. On iPad (≥768px) they're in normal flow inside - // the shrunk .app container and already sit above the keyboard. if (isSmallMedium) { + // Phones/small tablets: toolbar and accessory bar are position:fixed + // via CSS. Use translateY to lift them above the keyboard. const toolbar = document.querySelector('.toolbar'); - const accessoryBar = document.querySelector('.keyboard-accessory-bar'); const main = document.querySelector('.main'); const layoutHeight = window.innerHeight; @@ -324,25 +323,25 @@ const KeyboardHandler = { const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0; main.style.paddingBottom = `${84 + cjkInputHeight}px`; } + } else if (keyboardHeight > 0) { + // iPad: use direct bottom positioning (translateY unreliable — + // iOS auto-scrolls the visual viewport, making keyboardOffset ≈ 0). + if (accessoryBar) { + accessoryBar.style.bottom = `${keyboardHeight}px`; + } } // CJK textarea positioning (always position:fixed on touch devices). if (cjkInput?.classList.contains('cjk-input-visible') && keyboardHeight > 0) { if (isSmallMedium) { - // Phones: use translateY like toolbar/accessory bar. The CSS bottom - // (84px) is relative to the layout viewport; translateY lifts it - // above the keyboard. This works on phones where iOS doesn't - // auto-scroll the visual viewport for the CJK textarea. + // Phones: use translateY like toolbar/accessory bar. const layoutHeight = window.innerHeight; const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height; const keyboardOffset = Math.max(0, layoutHeight - visualBottom); cjkInput.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : ''; cjkInput.style.bottom = ''; } else { - // iPad: use direct bottom positioning from keyboard height. - // translateY breaks on iPad because iOS auto-scrolls the visual - // viewport when the CJK textarea receives focus, making - // keyboardOffset unreliable (approaches 0). + // iPad: direct bottom = keyboard + accessory bar height. cjkInput.style.bottom = `${keyboardHeight + 44}px`; cjkInput.style.transform = ''; } @@ -364,6 +363,7 @@ const KeyboardHandler = { } if (accessoryBar) { accessoryBar.style.transform = ''; + accessoryBar.style.bottom = ''; } if (cjkInput) { cjkInput.style.transform = ''; diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 3838a9b9..e429ee0f 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -2099,178 +2099,9 @@ html.mobile-init .file-browser-panel { } -/* ============================================================================ - Keyboard Accessory Bar — all mobile/tablet sizes - Visual styles extracted from phone breakpoint so they apply on iPad too. - Phone-specific positioning (position: fixed) remains in @media (max-width: 430px). - ============================================================================ */ -.keyboard-accessory-bar { - display: none; - height: 44px; - background: #1a1a1a; - border-top: 1px solid rgba(255, 255, 255, 0.1); - padding: 6px 8px; - gap: 8px; - align-items: center; - overflow-x: auto; - overflow-y: hidden; - -webkit-overflow-scrolling: touch; - z-index: 51; -} - -.keyboard-accessory-bar.visible { - display: flex; -} - -.keyboard-accessory-bar::-webkit-scrollbar { - display: none; -} - -.accessory-btn { - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - gap: 4px; - padding: 6px 12px; - background: #2a2a2a; - border: 1px solid rgba(255, 255, 255, 0.15); - border-radius: 6px; - color: #e5e5e5; - font-size: 0.65rem; - font-weight: 500; - cursor: pointer; - transition: background 0.15s, border-color 0.15s; -} - -.accessory-btn.confirming { - background: #6b4f00; - border-color: #b8860b; - color: #ffd54f; -} - -.accessory-btn:active { - background: #3a3a3a; -} - -.accessory-btn svg { - width: 14px; - height: 14px; -} - -.accessory-btn-arrow { - padding: 6px 10px; - background: #2563eb; - border-color: rgba(59, 130, 246, 0.5); - color: #fff; -} - -.accessory-btn-arrow:active { - background: #1d4ed8; -} - -.accessory-btn-dismiss { - margin-left: auto; - flex: 1 1 0; - max-width: 80px; - padding: 10px 8px; - background: #334d6e; - border-color: rgba(100, 150, 200, 0.4); - color: #c0d4e8; - font-weight: 600; -} - -.accessory-btn-dismiss svg { - width: 20px; - height: 20px; -} - -.accessory-btn-dismiss:active { - background: #3d5f85; -} - -/* ============================================================================ - Paste Overlay — all mobile/tablet sizes - Extracted from phone breakpoint so it applies on iPad too. - ============================================================================ */ -.paste-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.6); - z-index: 10000; - display: flex; - align-items: flex-start; - justify-content: center; - padding-top: 15vh; -} - -.paste-dialog { - background: var(--bg-secondary, #1e1e2e); - border: 1px solid var(--border-color, #444); - border-radius: 12px; - padding: 12px; - width: calc(100% - 24px); - max-width: 400px; -} - -.paste-textarea { - width: 100%; - min-height: 80px; - max-height: 200px; - background: var(--bg-primary, #0d0d14); - color: var(--text-primary, #e0e0e0); - border: 1px solid var(--border-color, #444); - border-radius: 8px; - padding: 8px; - font-family: inherit; - font-size: 16px; - resize: none; - box-sizing: border-box; -} - -.paste-textarea:focus { - outline: none; - border-color: var(--accent-color, #7aa2f7); -} - -.paste-actions { - display: flex; - justify-content: flex-end; - gap: 8px; - margin-top: 10px; -} - -.paste-cancel, .paste-new, .paste-send, .paste-image { - padding: 8px 18px; - border: none; - border-radius: 8px; - font-size: 14px; - cursor: pointer; -} - -.paste-image { - margin-right: auto; - background: var(--bg-tertiary, #333); - color: var(--accent-color, #7aa2f7); - border: 1px solid var(--accent-color, #7aa2f7); -} - -.paste-cancel { - background: var(--bg-tertiary, #333); - color: var(--text-secondary, #aaa); -} - -.paste-new { - background: var(--bg-tertiary, #333); - color: var(--accent-color, #7aa2f7); - border: 1px solid var(--accent-color, #7aa2f7); -} - -.paste-send { - background: var(--accent-color, #7aa2f7); - color: #fff; - font-weight: 600; -} +/* Keyboard accessory bar + paste overlay base styles moved to styles.css + (always loaded — covers iPad landscape where mobile.css doesn't load). + Phone-specific overrides remain in @media (max-width: 430px) above. */ /* ============================================================================ iOS Safari Specific Fixes diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 7a397d95..2f86375a 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -9328,6 +9328,193 @@ body.touch-device.cjk-input-visible .main { padding-bottom: calc(84px + var(--safe-area-bottom)); } +/* ═══════════════════════════════════════════════════════════════ + Keyboard Accessory Bar + Base styles in styles.css (always loaded) so iPad landscape (≥1024px, + where mobile.css doesn't load) still gets dark styling. Phone overrides + remain in mobile.css @media (max-width: 430px). + ═══════════════════════════════════════════════════════════════ */ + +.keyboard-accessory-bar { + display: none; + height: 44px; + background: #1a1a1a; + border-top: 1px solid rgba(255, 255, 255, 0.1); + padding: 6px 8px; + gap: 8px; + align-items: center; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + z-index: 51; +} + +.keyboard-accessory-bar.visible { + display: flex; +} + +.keyboard-accessory-bar::-webkit-scrollbar { + display: none; +} + +/* Touch devices: fixed positioning to float above keyboard. + Phone CSS (mobile.css) adds bottom + safe-area padding; + iPad JS sets bottom dynamically via updateLayoutForKeyboard(). */ +.touch-device .keyboard-accessory-bar { + position: fixed; + left: 0; + right: 0; + transition: transform 0.15s ease-out; + will-change: transform; +} + +.accessory-btn { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + gap: 4px; + padding: 6px 12px; + background: #2a2a2a; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + color: #e5e5e5; + font-size: 0.65rem; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +.accessory-btn.confirming { + background: #6b4f00; + border-color: #b8860b; + color: #ffd54f; +} + +.accessory-btn:active { + background: #3a3a3a; +} + +.accessory-btn svg { + width: 14px; + height: 14px; +} + +.accessory-btn-arrow { + padding: 6px 10px; + background: #2563eb; + border-color: rgba(59, 130, 246, 0.5); + color: #fff; +} + +.accessory-btn-arrow:active { + background: #1d4ed8; +} + +.accessory-btn-dismiss { + margin-left: auto; + flex: 1 1 0; + max-width: 100px; + padding: 10px 8px; + background: #2563eb; + border-color: rgba(59, 130, 246, 0.5); + color: #fff; + font-weight: 600; +} + +.accessory-btn-dismiss svg { + width: 22px; + height: 22px; +} + +.accessory-btn-dismiss:active { + background: #1d4ed8; +} + +/* ═══════════════════════════════════════════════════════════════ + Paste Overlay + Base styles in styles.css so iPad landscape works. + ═══════════════════════════════════════════════════════════════ */ + +.paste-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 10000; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 15vh; +} + +.paste-dialog { + background: var(--bg-secondary, #1e1e2e); + border: 1px solid var(--border-color, #444); + border-radius: 12px; + padding: 12px; + width: calc(100% - 24px); + max-width: 400px; +} + +.paste-textarea { + width: 100%; + min-height: 80px; + max-height: 200px; + background: var(--bg-primary, #0d0d14); + color: var(--text-primary, #e0e0e0); + border: 1px solid var(--border-color, #444); + border-radius: 8px; + padding: 8px; + font-family: inherit; + font-size: 16px; + resize: none; + box-sizing: border-box; +} + +.paste-textarea:focus { + outline: none; + border-color: var(--accent-color, #7aa2f7); +} + +.paste-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 10px; +} + +.paste-cancel, .paste-new, .paste-send, .paste-image { + padding: 8px 18px; + border: none; + border-radius: 8px; + font-size: 14px; + cursor: pointer; +} + +.paste-image { + margin-right: auto; + background: var(--bg-tertiary, #333); + color: var(--accent-color, #7aa2f7); + border: 1px solid var(--accent-color, #7aa2f7); +} + +.paste-cancel { + background: var(--bg-tertiary, #333); + color: var(--text-secondary, #aaa); +} + +.paste-new { + background: var(--bg-tertiary, #333); + color: var(--accent-color, #7aa2f7); + border: 1px solid var(--accent-color, #7aa2f7); +} + +.paste-send { + background: var(--accent-color, #7aa2f7); + color: #fff; + font-weight: 600; +} + /* ═══════════════════════════════════════════════════════════════ Orchestrator Panel ═══════════════════════════════════════════════════════════════ */ From 4d129086d1d1537459b72c860fbf408bb97424af Mon Sep 17 00:00:00 2001 From: Teigen Date: Wed, 17 Jun 2026 19:47:07 +0800 Subject: [PATCH 6/7] fix(iPad): raise toolbar z-index when case settings popover is open backdrop-filter on the toolbar creates a stacking context that traps the popover's z-index (1000) inside the toolbar. CJK input (z-index 52) in the root stacking context always wins. Use :has() to raise the toolbar above CJK only while the popover is visible. --- src/web/public/styles.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 2f86375a..691d4aef 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2719,6 +2719,13 @@ body.solo-mode .btn-lifecycle-log { contain: style; } +/* backdrop-filter creates a stacking context, trapping the popover's + z-index inside the toolbar. When the popover is open, raise the toolbar + above the CJK input (z-index 52) so the popover is interactable. */ +.toolbar:has(.case-settings-popover:not(.hidden)) { + z-index: 100; +} + .toolbar-left, .toolbar-right { display: flex; From 8dc70a5f1d9b6f0876a6fadc17a713b70de2228e Mon Sep 17 00:00:00 2001 From: Teigen Date: Wed, 17 Jun 2026 19:53:33 +0800 Subject: [PATCH 7/7] fix(mobile): restore /compact button to keyboard accessory bar Reverts eb83148 which removed the /compact button from both simple and extended accessory bar modes. Restores double-tap confirmation and refocus guard for the compact action. --- src/web/public/keyboard-accessory.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index 5ac900d6..7c5a81e0 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -4,10 +4,10 @@ * Defines two exports: * * - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual - * keyboard on mobile: arrow up/down, /init, /clear, paste, Esc, and dismiss. + * keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, Esc, and dismiss. * The paste button opens a dialog that handles both text paste and image attach * (native picker + best-effort image paste, routed through app._uploadAndInsertImages). - * Destructive actions (/clear) require double-tap confirmation (2s amber state). + * Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state). * Commands are sent as text + Enter separately for Ink compatibility. * Only initializes on touch devices (MobileDetection.isTouchDevice guard). * @@ -58,6 +58,7 @@ const KeyboardAccessoryBar = { +