diff --git a/src/web/public/app.js b/src/web/public/app.js index 0498bb22..88d8ab22 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -3290,6 +3290,13 @@ class CodemanApp { } _renderSessionTabsImmediate() { + // Same guard as renderSessionTabs()/_fullRenderSessionTabs(): the incremental + // branch below rewrites .tab-name's innerHTML, which destroys the inline rename + // mid-keystroke. Guarding only the scheduler is not enough: a render + // debounced just BEFORE the rename opened still fires ~100ms later and lands + // here directly. finishRename() re-renders on both commit and cancel, so a + // render dropped here is picked back up when the rename settles. + if (this._inlineRenameActive) return; const container = this.$('sessionTabs'); const existingTabs = container.querySelectorAll('.session-tab[data-id]'); const existingIds = new Set([...existingTabs].map(t => t.dataset.id)); diff --git a/src/web/public/respawn-ui.js b/src/web/public/respawn-ui.js index fbdb9fcd..726d526a 100644 --- a/src/web/public/respawn-ui.js +++ b/src/web/public/respawn-ui.js @@ -848,6 +848,18 @@ Object.assign(CodemanApp.prototype, { }, closeSessionOptions() { + // Commit the field the user was still editing BEFORE editingSessionId is + // cleared. The Session Name input saves on blur (and the auto-compact prompt + // on change), and every autosave handler bails out on `!this.editingSessionId`. + // Hiding the modal blurs the focused input on its own, but that happens after + // the id is gone, so Escape / backdrop-click silently dropped what was typed. + // (Clicking the X worked only because mousedown blurs the input first.) + const modal = document.getElementById('sessionOptionsModal'); + const focused = document.activeElement; + if (focused && modal && modal.contains(focused) && typeof focused.blur === 'function') { + focused.blur(); + } + this.editingSessionId = null; // Stop run summary auto-refresh if it was running this.stopRunSummaryAutoRefresh(); diff --git a/test/inline-rename.test.ts b/test/inline-rename.test.ts index df19daf0..62155575 100644 --- a/test/inline-rename.test.ts +++ b/test/inline-rename.test.ts @@ -245,6 +245,119 @@ describe('Inline rename input', () => { expect(result.threw).toBe(false); }); + it('Render guard: _renderSessionTabsImmediate() does not destroy an open rename input', async () => { + await resetState(); + + // The debounced tab render is scheduled by renderSessionTabs() but EXECUTED by + // _renderSessionTabsImmediate(). A render queued just before the rename opened + // still fires ~100ms later and lands in the executor directly, so the guard has + // to live there too, otherwise the incremental branch rewrites .tab-name's + // innerHTML and the user's half-typed description is lost. + // + // The tab MUST live inside the real #sessionTabs container and be the only + // session in app.sessions: the renderer walks that container, so a synthetic + // node parked on would make this test pass with the guard removed. + const result = await page.evaluate(() => { + const app = ( + window as unknown as { + app: { + sessions: Map; + sessionOrder: string[]; + startInlineRename: (id: string) => void; + _renderSessionTabsImmediate: () => void; + _activeRename: unknown; + }; + } + ).app; + const id = 'render-race'; + app.sessions.set(id, { id, name: 'w9-case', status: 'idle' }); + app.sessionOrder = [id]; + + const container = document.getElementById('sessionTabs') as HTMLElement; + const tab = document.createElement('div'); + tab.setAttribute('data-test-tab', '1'); + tab.className = 'session-tab'; + tab.dataset.id = id; + tab.innerHTML = + '' + + `w9-case` + + ''; + container.appendChild(tab); + + app.startInlineRename(id); + const input = document.querySelector('input.tab-rename-input') as HTMLInputElement | null; + if (!input) return { opened: false }; + input.value = 'half-typed'; + + // Exactly what a debounce timer queued before the rename would do. + app._renderSessionTabsImmediate(); + + const after = document.querySelector('input.tab-rename-input') as HTMLInputElement | null; + return { + opened: true, + stillInDom: !!after && document.body.contains(after), + value: after?.value ?? null, + renameStillActive: !!app._activeRename, + }; + }); + + expect(result.opened).toBe(true); + expect(result.stillInDom).toBe(true); + expect(result.value).toBe('half-typed'); + expect(result.renameStillActive).toBe(true); + }); + + it('Modal: closeSessionOptions() commits the Session Name field before clearing the id', async () => { + await resetState(); + + // Every autosave handler in the session-options modal bails on a null + // editingSessionId, and hiding the modal blurs the focused input. If the id is + // cleared first, the blur-driven save is dropped and the typed name vanishes, + // which is what Escape and backdrop-click used to do. + const result = await page.evaluate(async () => { + const app = ( + window as unknown as { + app: { + editingSessionId: string | null; + sessions: Map; + closeSessionOptions: () => void; + }; + } + ).app; + app.sessions.set('modal-id', { id: 'modal-id', name: 'w9-case' }); + app.editingSessionId = 'modal-id'; + + const nameInput = document.getElementById('modalSessionName') as HTMLInputElement; + const modal = document.getElementById('sessionOptionsModal') as HTMLElement; + modal.classList.add('active'); + // The Session Name field lives on the modal's Context tab, which is hidden + // until selected: a hidden input cannot take focus. + document.getElementById('context-tab')?.classList.remove('hidden'); + nameInput.value = 'mydesc'; + nameInput.focus(); + const wasFocused = document.activeElement === nameInput; + + let putBody: string | null = null; + const origFetch = window.fetch; + window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).includes('/api/sessions/modal-id/name')) putBody = String(init?.body ?? ''); + return new Response('{"success":true}', { status: 200 }); + }) as typeof window.fetch; + + app.closeSessionOptions(); + await new Promise((r) => setTimeout(r, 30)); + window.fetch = origFetch; + modal.classList.remove('active'); + + return { wasFocused, putBody, editingAfter: app.editingSessionId }; + }); + + expect(result.wasFocused).toBe(true); + // Prefixed session: the suffix the user typed is appended to the w9-case prefix. + expect(result.putBody).toContain('w9-case: mydesc'); + expect(result.editingAfter).toBe(null); + }); + it('Re-entry: starting rename while one is active aborts the previous one', async () => { await resetState(); expect(await startRename('first-id', 'First')).toBe(true);