diff --git a/src/web/public/app.js b/src/web/public/app.js index 8abf8bec..172625e1 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -5783,28 +5783,7 @@ class CodemanApp { if (ta) ta.dispatchEvent(new CompositionEvent('compositionend', { data: '' })); } } catch {} - // Flush local echo text to PTY before switching tabs. - // Send as a single batch (no Enter) so it lands in the session's readline - // input buffer — avoids "old text resent on Enter" and overlay render bugs. - // Track flushed length so _render() offsets the overlay correctly even before - // the PTY echo arrives in the terminal buffer. - if (this.activeSessionId) { - const echoText = this._localEchoOverlay?.pendingText || ''; - // Include buffer-detected flushed text (from Tab completion, etc.) - // so it's preserved across tab switches. - const existingFlushed = this._localEchoOverlay?.getFlushed()?.count || 0; - const existingFlushedText = this._localEchoOverlay?.getFlushed()?.text || ''; - if (echoText) { - this._sendInputAsync(this.activeSessionId, echoText); - } - const totalOffset = existingFlushed + echoText.length; - if (totalOffset > 0) { - if (!this._flushedOffsets) this._flushedOffsets = new Map(); - if (!this._flushedTexts) this._flushedTexts = new Map(); - this._flushedOffsets.set(this.activeSessionId, totalOffset); - this._flushedTexts.set(this.activeSessionId, existingFlushedText + echoText); - } - } + this._flushLocalEchoTo(this.activeSessionId); this._localEchoOverlay?.clear(); // Predictions are ephemeral + already sent: nothing to save/restore // across a tab switch (unlike the buffer overlay's setFlushed machinery) @@ -5819,6 +5798,45 @@ class CodemanApp { } } + /** + * Hand the local-echo overlay's unsent text to `sessionId` before anything + * clears it, and record what has now been flushed so `_render()` offsets the + * overlay correctly even before the PTY echo comes back. + * + * On a touch device the characters the user has typed live ONLY here until + * Enter — they have never reached the PTY — so whoever clears the overlay + * owes them a flush first. It is sent as one batch with no Enter, so it lands + * in the session's readline buffer rather than submitting a line the user has + * not finished. + * + * ⚠️ The session is a PARAMETER because the two callers are looking at + * different ones. `_cleanupPreviousSession` flushes to the tab being left, + * which is still `activeSessionId` when it runs. The `forceReload` branch in + * `selectSession` flushes to the tab being RELOADED, and must do it before it + * nulls `activeSessionId`: reading the field after that null is what silently + * dropped the text, since the guard here then saw no session and the + * unconditional `clear()` that follows took the characters with it. + * @param {string|null} sessionId + */ + _flushLocalEchoTo(sessionId) { + if (!sessionId) return; + const echoText = this._localEchoOverlay?.pendingText || ''; + // Include buffer-detected flushed text (from Tab completion, etc.) + // so it's preserved across tab switches. + const existingFlushed = this._localEchoOverlay?.getFlushed()?.count || 0; + const existingFlushedText = this._localEchoOverlay?.getFlushed()?.text || ''; + if (echoText) { + this._sendInputAsync(sessionId, echoText); + } + const totalOffset = existingFlushed + echoText.length; + if (totalOffset > 0) { + if (!this._flushedOffsets) this._flushedOffsets = new Map(); + if (!this._flushedTexts) this._flushedTexts = new Map(); + this._flushedOffsets.set(sessionId, totalOffset); + this._flushedTexts.set(sessionId, existingFlushedText + echoText); + } + } + _resetTerminalForReplay() { this.terminal.reset(); this.terminal.write('\x1b[3J\x1b[H\x1b[2J'); @@ -6093,6 +6111,13 @@ class CodemanApp { this._loadBufferQueue = null; this._terminalRefreshOwner = null; this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1; + // Anything typed but not yet submitted lives in the local-echo overlay and + // has never reached the PTY. `_cleanupPreviousSession` below flushes it, + // but only for a session it can still see, and the null on the next line + // hides this one from it. Flush first or the characters are cleared + // unread. The geometry replay re-enters here with no gesture behind it, + // so on a touch device this fires while the user is still typing. + this._flushLocalEchoTo(sessionId); this.activeSessionId = null; } // Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors diff --git a/test/capture-geometry-retry.browser.test.ts b/test/capture-geometry-retry.browser.test.ts index 5c5e6419..df209e8a 100644 --- a/test/capture-geometry-retry.browser.test.ts +++ b/test/capture-geometry-retry.browser.test.ts @@ -341,6 +341,78 @@ describe('a capture bigger than the terminal', () => { await context.close(); }, 60_000); + it('hands over text typed but not yet submitted before it replays', async () => { + // On a touch device the characters the user has typed live ONLY in the + // local-echo overlay until Enter; they have never reached the PTY. The + // replay re-enters `selectSession` with `forceReload` on the session that + // is still active, and that branch used to null `activeSessionId` before + // `_cleanupPreviousSession` ran, so the flush there saw no session and the + // unconditional `clear()` afterwards took the characters with it. Nothing + // the user did triggered that: the replay fires on its own the moment a + // tab switch finishes, which is exactly when someone typing into a + // still-loading terminal has text in the overlay. + context = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + page = await context.newPage(); + const sessionId = await openSession(page); + + const fetches = { n: 0, urls: [] as string[] }; + await stubTerminal(page, 200, fetches); + await consumeFullHistory(page, sessionId, fetches); + + // Headless chromium reports `isTouchDevice()` false even with `hasTouch`, + // so the overlay would stay off and the whole case would pass vacuously. + // The setting is what `_updateLocalEchoState()` reads, so it survives the + // recompute that every select runs; the flag is forced too, for the window + // before the next recompute. Record what crosses into the delivery layer, + // which is the seam the text failed to cross. + await page.evaluate(() => { + const w = window as unknown as { + app: { + _localEchoEnabled: boolean; + _sendInputAsync: (id: string, text: string, opts?: unknown) => void; + terminal?: { focus: () => void }; + loadAppSettingsFromStorage: () => Record; + }; + __sentInputs: { id: string; text: string }[]; + }; + const settings = w.app.loadAppSettingsFromStorage(); + settings.localEchoEnabled = true; + localStorage.setItem('codeman-app-settings', JSON.stringify(settings)); + w.app._localEchoEnabled = true; + w.__sentInputs = []; + const original = w.app._sendInputAsync.bind(w.app); + w.app._sendInputAsync = (id: string, text: string, opts?: unknown) => { + w.__sentInputs.push({ id, text }); + return original(id, text, opts); + }; + w.app.terminal?.focus(); + }); + + await page.keyboard.type('hello-unsent'); + // The premise: the characters really are sitting in the overlay, unsent. + // Without this the case would pass on a build where typing goes straight + // to the PTY and there is nothing to lose. + const pendingBefore = await page.evaluate( + () => + (window as unknown as { app: { _localEchoOverlay?: { pendingText: string } } }).app._localEchoOverlay + ?.pendingText ?? '' + ); + expect(pendingBefore).toBe('hello-unsent'); + + // The captured pane is taller than the terminal, so this select replays. + await select(page, sessionId, { forceReload: true }); + expect(fetches.n).toBe(2); + + const sent = await page.evaluate( + () => (window as unknown as { __sentInputs: { id: string; text: string }[] }).__sentInputs + ); + expect(sent.map((s) => s.text)).toContain('hello-unsent'); + expect(sent.find((s) => s.text === 'hello-unsent')?.id).toBe(sessionId); + + await closeSession(page, sessionId); + await context.close(); + }, 60_000); + it('does not replay a pane already at the size the client asked for', async () => { // `getTerminalDimensions()` floors at 40x10 while `fitAddon.fit()` does // not, so a viewport this small leaves the terminal shorter than the size