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);