fix(client): harden inline rename against CJK, mid-rename deletion, and double-fire

Three follow-up fixes to the inline rename input introduced in #81:

1. IME composition guard. Pressing Enter to confirm a Chinese pinyin
   candidate (or any IME composition) was committing the half-composed
   text as the session name. Skip the keydown handler when isComposing
   is true or when keyCode is the legacy 229 sentinel that older
   Safari/Edge versions report on the Enter that triggers compositionend.

2. Ghost tab on mid-rename deletion. If a session was deleted via SSE
   while its tab was being renamed, the render-skip flag suppressed
   _renderSessionTabs() and the orphaned <input> stayed on screen until
   blur — at which point the rename PUT 404'd against the dead session.
   Replace the boolean _inlineRenameActive with a _activeRename
   {sessionId, cancel} object so _cleanupSessionData can abort an
   in-flight rename targeting the deleted session, and finishRename
   skips the API call when the session is gone.

3. Stuck-flag risk. Move the settle-once guard into a closure-local
   `settled` boolean so blur / Enter / Escape / external cancel all
   converge to a single idempotent path. Register _activeRename only
   after the input is fully wired so a throw earlier in setup can't
   strand state.

Adds test/inline-rename.test.ts with 7 Playwright tests that drive
startInlineRename via page.evaluate() against a stubbed session and
synthetic .tab-name node — no real PTY/tmux needed, runs in ~1.3s.

Also fixes test/mobile/helpers/server.ts which imported the WebServer
via a path one directory short of the repo root, breaking the entire
mobile test suite under the main vitest config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
arkon
2026-05-12 09:57:08 +02:00
co-authored by Claude Opus 4.7
parent dcc814f40c
commit 7beec7194a
4 changed files with 320 additions and 16 deletions
+7 -2
View File
@@ -1845,7 +1845,7 @@ class CodemanApp {
renderSessionTabs() {
// Don't re-render while user is typing in the inline rename input
if (this._inlineRenameActive) return;
if (this._activeRename) return;
this._debouncedCall('sessionTabs', this._renderSessionTabsImmediate);
}
@@ -1990,7 +1990,7 @@ class CodemanApp {
}
_fullRenderSessionTabs() {
if (this._inlineRenameActive) return;
if (this._activeRename) return;
const container = this.$('sessionTabs');
// Clean up any orphaned dropdowns before re-rendering
@@ -2694,6 +2694,11 @@ class CodemanApp {
// Shared cleanup for all session data — called from both closeSession() and session:deleted handler
_cleanupSessionData(sessionId) {
// If the deleted session is currently being renamed, abort the rename
// so the inline <input> doesn't ghost as a stale tab on screen.
if (this._activeRename?.sessionId === sessionId) {
this._activeRename.cancel();
}
this.sessions.delete(sessionId);
// Remove from tab order
const orderIndex = this.sessionOrder.indexOf(sessionId);
+31 -13
View File
@@ -912,8 +912,9 @@ Object.assign(CodemanApp.prototype, {
const tabName = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`);
if (!tabName) return;
// Prevent tab re-renders from destroying the input while renaming
this._inlineRenameActive = true;
// If a previous rename somehow leaked (shouldn't happen, but defends against
// future code paths that throw before cleanup), abort it before starting fresh.
if (this._activeRename) this._activeRename.cancel();
const currentName = this.getSessionName(session);
const parsed = parseSessionPrefix(session.name);
@@ -941,19 +942,26 @@ Object.assign(CodemanApp.prototype, {
input.focus();
input.select();
const finishRename = async () => {
if (!this._inlineRenameActive) return; // prevent double-fire
this._inlineRenameActive = false;
const suffix = input.value.trim();
let fullName;
if (parsed) {
fullName = parsed.prefix + (suffix ? ': ' + suffix : '');
} else {
fullName = suffix;
let settled = false;
const finishRename = async ({ commit }) => {
if (settled) return;
settled = true;
this._activeRename = null;
// Aborted (e.g. session was deleted mid-rename): just re-render so any
// ghost DOM left behind is replaced with the canonical tab list.
if (!commit) {
this.renderSessionTabs();
return;
}
const suffix = input.value.trim();
const fullName = parsed ? parsed.prefix + (suffix ? ': ' + suffix : '') : suffix;
tabName.textContent = fullName || originalContent;
if (fullName !== session.name) {
// Skip the API call if the session vanished between focus and blur.
const stillExists = this.sessions.has(sessionId);
if (stillExists && fullName !== session.name) {
try {
await fetch(`/api/sessions/${sessionId}/name`, {
method: 'PUT',
@@ -969,8 +977,18 @@ Object.assign(CodemanApp.prototype, {
this.renderSessionTabs();
};
input.addEventListener('blur', finishRename);
// Register only after the input is wired so a throw above can't strand state.
this._activeRename = {
sessionId,
cancel: () => finishRename({ commit: false }),
};
input.addEventListener('blur', () => finishRename({ commit: true }));
input.addEventListener('keydown', (e) => {
// Enter/Escape during IME composition belong to the IME (e.g. confirming
// a Chinese pinyin candidate). keyCode 229 is the legacy signal for the
// same condition on browsers that don't set isComposing reliably.
if (e.isComposing || e.keyCode === 229) return;
if (e.key === 'Enter') {
e.preventDefault();
input.blur();