From e214691429dac34acae07406b1fda2bbb3ab7b4b Mon Sep 17 00:00:00 2001 From: DodgyBadger <4453217+DodgyBadger@users.noreply.github.com> Date: Sun, 20 Sep 2026 03:09:04 +0000 Subject: [PATCH] fix(mobile): preserve composer delivery after replay --- src/web/public/keyboard-accessory.js | 42 +++++++++++++++++------ src/web/public/styles.css | 8 +++++ test/mobile-prompt-composer.test.ts | 50 ++++++++++++++++++++-------- 3 files changed, 76 insertions(+), 24 deletions(-) diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index c428bd37..80c9f25d 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -777,6 +777,7 @@ const KeyboardAccessoryBar = { // The ⇧←/⇧→ keys are Codex bindings: same shape, gated on the active // session's mode instead of a setting. this.syncCodexKeys(); + this._syncComposerDraftIndicator(); // Add click handlers — preventDefault stops event from reaching terminal this.element.addEventListener('click', (e) => { @@ -824,6 +825,7 @@ const KeyboardAccessoryBar = { } this._applyLayout(this._resolveMode()); this.syncCodexKeys(); + this._syncComposerDraftIndicator(); }, /** Which layout the current state calls for. */ @@ -851,6 +853,20 @@ const KeyboardAccessoryBar = { this.clearCtrl(); this.element.innerHTML = mode === 'shell' ? this._shellButtons : mode === 'extended' ? this._extendedButtons : this._simpleButtons; + this._syncComposerDraftIndicator(); + }, + + /** Show when the active session has a prompt parked in memory. The marker + * keeps non-Send closes visible without copying the draft back into the PTY + * and creating a second source of truth. */ + _syncComposerDraftIndicator() { + const button = this.element?.querySelector('[data-action="compose"]'); + if (!button) return; + const sessionId = typeof app !== 'undefined' ? app.activeSessionId : null; + const hasDraft = !!(sessionId && this._composerDrafts.get(sessionId)); + button.classList.toggle('has-draft', hasDraft); + button.title = hasDraft ? 'Resume saved prompt draft' : 'Compose prompt'; + button.setAttribute('aria-label', hasDraft ? 'Compose prompt, draft saved' : 'Compose prompt'); }, // ── One-shot Ctrl modifier (shell bar) ────────────────────────────────── @@ -1166,19 +1182,18 @@ const KeyboardAccessoryBar = { textarea.dispatchEvent(new Event('input', { bubbles: true })); }, - /** Deliver one complete prompt through xterm's paste path. terminal.paste() - * preserves bracketed-paste markers and multiline content; Enter is a - * separate delayed durable write because Codex drops keys sharing a PTY read - * with a bracketed paste. Capture the session so a fast tab switch cannot - * submit the prompt in a different pane. */ + /** Deliver one complete prompt as an explicit bracketed paste. xterm loses + * its DECSET 2004 mirror after terminal replay, even though the CLI still + * expects bracketed input, so build the byte-identical sequence directly on + * the durable session-bound path. Enter stays a separate delayed write + * because Codex drops keys sharing a PTY read with a bracketed paste. */ _sendComposedPrompt(sessionId, text) { - if (!sessionId || !text || !app.terminal?.paste) return false; - if (app.terminal.modes?.bracketedPasteMode !== true) { - app.showToast?.('Prompt composer is waiting for the agent input to become ready', 'info'); - return false; - } + if (!sessionId || !text || typeof app._sendInputAsync !== 'function') return false; app._predictiveEcho?.clearPredictions(); - app.terminal.paste(text); + // Match xterm's prepareTextForTerminal(): CR keeps embedded newlines inside + // the single-line input transport and is what terminal.paste() emitted. + const pasteText = text.replace(/\r?\n/g, '\r'); + app._sendInputAsync(sessionId, `\x1b[200~${pasteText}\x1b[201~`, { useMux: true }); setTimeout(() => app._sendInputAsync(sessionId, '\r', { useMux: true }), 120); return true; }, @@ -1187,6 +1202,7 @@ const KeyboardAccessoryBar = { discardComposerDraft(sessionId) { this._composerDrafts.delete(sessionId); this._composerUploads.delete(sessionId); + this._syncComposerDraftIndicator(); if (this._composerOverlay?.dataset.sessionId === sessionId) { this._composerOverlay._closeComposer?.({ preserveDraft: false }); } @@ -1238,6 +1254,7 @@ const KeyboardAccessoryBar = { const focusTrap = new FocusTrap(overlay); textarea.value = initial; if (initial) this._composerDrafts.set(sessionId, initial); + this._syncComposerDraftIndicator(); const initialUploads = this._composerUploads.get(sessionId) || 0; imageButton.disabled = initialUploads > 0; sendButton.disabled = initialUploads > 0; @@ -1246,6 +1263,7 @@ const KeyboardAccessoryBar = { const saveDraft = () => { if (textarea.value) this._composerDrafts.set(sessionId, textarea.value); else this._composerDrafts.delete(sessionId); + this._syncComposerDraftIndicator(); }; const close = ({ focusTerminal = false, preserveDraft = true, restoreFocus = true } = {}) => { if (preserveDraft) saveDraft(); @@ -1259,6 +1277,7 @@ const KeyboardAccessoryBar = { const text = textarea.value; if (!text || !this._sendComposedPrompt(sessionId, text)) return; this._composerDrafts.delete(sessionId); + this._syncComposerDraftIndicator(); close({ preserveDraft: false }); }; const handleImages = async (files) => { @@ -1293,6 +1312,7 @@ const KeyboardAccessoryBar = { else if (app.sessions?.has(sessionId)) { const draft = this._composerDrafts.get(sessionId) || ''; this._composerDrafts.set(sessionId, `${draft}${draft && !/\s$/.test(draft) ? ' ' : ''}${paths.join(' ')}`); + this._syncComposerDraftIndicator(); } } } finally { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index f56c921d..b53b2ccd 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -13613,6 +13613,14 @@ body.touch-device.cjk-input-visible .main { background: var(--control-bg-hover); } +.accessory-btn-compose.has-draft::after { + width: 6px; + height: 6px; + background: var(--yellow); + border-radius: 50%; + content: ''; +} + .accessory-btn svg { width: 14px; height: 14px; diff --git a/test/mobile-prompt-composer.test.ts b/test/mobile-prompt-composer.test.ts index f1b862db..8a827c36 100644 --- a/test/mobile-prompt-composer.test.ts +++ b/test/mobile-prompt-composer.test.ts @@ -92,6 +92,14 @@ function textarea(document: Document): HTMLTextAreaElement { return document.querySelector('.prompt-composer-textarea') as HTMLTextAreaElement; } +function mountComposeButton(bar: any, document: Document): HTMLButtonElement { + bar.element = document.createElement('div'); + bar.element.innerHTML = bar._simpleButtons; + document.body.appendChild(bar.element); + bar._syncComposerDraftIndicator(); + return bar.element.querySelector('[data-action="compose"]') as HTMLButtonElement; +} + describe('mobile prompt composer', () => { beforeEach(() => vi.restoreAllMocks()); @@ -145,13 +153,16 @@ describe('mobile prompt composer', () => { it('closes on tab switch and keeps drafts isolated by session', () => { const { app, bar, document } = loadComposer(); + const composeButton = mountComposeButton(bar, document); bar.composePrompt(); textarea(document).value = 'first session draft'; textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true })); + expect(composeButton.classList.contains('has-draft')).toBe(true); app.activeSessionId = 'session-2'; bar.refreshForActiveSession(); expect(document.querySelector('.prompt-composer-overlay')).toBeNull(); + expect(composeButton.classList.contains('has-draft')).toBe(false); expect(() => bar.refreshForActiveSession()).not.toThrow(); bar.composePrompt(); expect(textarea(document).value).toBe(''); @@ -161,19 +172,24 @@ describe('mobile prompt composer', () => { app.activeSessionId = 'session-1'; bar.refreshForActiveSession(); + expect(composeButton.classList.contains('has-draft')).toBe(true); + expect(composeButton.getAttribute('aria-label')).toBe('Compose prompt, draft saved'); bar.composePrompt(); expect(textarea(document).value).toBe('first session draft'); }); it('drops a draft and closes its composer when the session is deleted', () => { const { bar, document } = loadComposer(); + const composeButton = mountComposeButton(bar, document); bar.composePrompt(); textarea(document).value = 'temporary secret'; textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true })); + expect(composeButton.classList.contains('has-draft')).toBe(true); bar.discardComposerDraft('session-1'); expect(document.querySelector('.prompt-composer-overlay')).toBeNull(); + expect(composeButton.classList.contains('has-draft')).toBe(false); bar.composePrompt(); expect(textarea(document).value).toBe(''); }); @@ -187,6 +203,7 @@ describe('mobile prompt composer', () => { it('keeps Enter as a newline and sends multiline text once via bracketed paste plus delayed Enter', () => { const { app, bar, document, timers, runTimers } = loadComposer(); + const composeButton = mountComposeButton(bar, document); bar.composePrompt(); const input = textarea(document); input.value = 'first line\nsecond line'; @@ -197,44 +214,51 @@ describe('mobile prompt composer', () => { expect(app._sendInputAsync).not.toHaveBeenCalled(); (document.querySelector('.paste-send') as HTMLButtonElement).click(); - expect(app.terminal.paste).toHaveBeenCalledOnce(); - expect(app.terminal.paste).toHaveBeenCalledWith('first line\nsecond line'); - expect(app._sendInputAsync).not.toHaveBeenCalled(); + expect(app.terminal.paste).not.toHaveBeenCalled(); + expect(app._sendInputAsync).toHaveBeenCalledOnce(); + expect(app._sendInputAsync).toHaveBeenNthCalledWith(1, 'session-1', '\x1b[200~first line\rsecond line\x1b[201~', { + useMux: true, + }); + expect(composeButton.classList.contains('has-draft')).toBe(false); expect(timers).toContainEqual(expect.objectContaining({ delay: 120 })); runTimers(); - expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '\r', { useMux: true }); + expect(app._sendInputAsync).toHaveBeenNthCalledWith(2, 'session-1', '\r', { useMux: true }); expect(document.querySelector('.prompt-composer-overlay')).toBeNull(); bar.composePrompt(); expect(textarea(document).value).toBe(''); }); - it('keeps the draft open when the agent has not enabled bracketed paste', () => { - const { app, bar, document } = loadComposer(); + it('sends after replay resets xterm’s mirrored bracketed-paste mode', () => { + const { app, bar, document, runTimers } = loadComposer(); app.terminal.modes.bracketedPasteMode = false; bar.composePrompt(); - textarea(document).value = 'do not lose this'; + textarea(document).value = 'still\nmultiline'; textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true })); (document.querySelector('.paste-send') as HTMLButtonElement).click(); expect(app.terminal.paste).not.toHaveBeenCalled(); - expect(app._sendInputAsync).not.toHaveBeenCalled(); - expect(textarea(document).value).toBe('do not lose this'); - expect(app.showToast).toHaveBeenCalledWith( - 'Prompt composer is waiting for the agent input to become ready', - 'info' - ); + expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '\x1b[200~still\rmultiline\x1b[201~', { + useMux: true, + }); + expect(document.querySelector('.prompt-composer-overlay')).toBeNull(); + expect(app.showToast).not.toHaveBeenCalled(); + runTimers(); + expect(app._sendInputAsync).toHaveBeenLastCalledWith('session-1', '\r', { useMux: true }); }); it('preserves the draft and focuses xterm when Use terminal keyboard is chosen', () => { const { app, bar, document, localEcho, runTimers } = loadComposer(); + const composeButton = mountComposeButton(bar, document); bar.composePrompt(); textarea(document).value = 'keep this'; textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true })); (document.querySelector('.prompt-composer-terminal') as HTMLButtonElement).click(); expect(app.terminal.focus).toHaveBeenCalledOnce(); + expect(composeButton.classList.contains('has-draft')).toBe(true); + expect(composeButton.title).toBe('Resume saved prompt draft'); runTimers(); localEcho.pendingText = '; then continue'; bar.composePrompt();