fix(tabs): grouped rail interaction fixes for inline rename

Two problems with renaming a tab in the vertical rail, both easier to hit now
that the grouped rail has its own inline editor beside the session one.

Writes. A committed rename PUT its name and only applied the answer if the
same editor was still open when it came back. Reopening the editor before the
PUT answered (F2 or right-click again, or starting a group rename, which
cancels the session editor) threw the confirmed name away, so the tab kept
showing the old name until an SSE frame happened to repaint it. Two quick
renames also raced as two concurrent PUTs. Inline renames now go through a
per-session queue: one PUT at a time in the order they were made, the
confirmed name applied to app.sessions whatever happened to the editor, and
the "already that name" check made when the write runs rather than when Enter
is pressed, so confirming the name still on screen over a write in flight is
a real write.

Layout. The editor (a flex row) could not shrink below the input's intrinsic
width, so a long w<n>-<case> prefix pushed the label past its row: the prefix
slid out of view in the detailed rows and the input was clipped mid-word in
the compact rail. The label now has min-width 0, the prefix gives way first
(down to 2rem, with an ellipsis), the input keeps 4rem, and in the compact
rail the row's adornments step aside while the name is edited. The detailed
rows' three-line clamp also outranked the shared unclamp rule, which is what
the existing "unclamped editor" browser test caught; it is restated there.

Header strip, sidebar and flat-rail markup are unchanged.

Tests (test/inline-rename.test.ts, browser suite): the unclamp check runs for
simple and detailed rows; a write-ordering describe covers ordering, a
reopened editor cancelled over a confirmed write, a re-sent unchanged name and
a group rename taking over; a long-prefix describe drives real rows from a
live session in simple, detailed and compact rails.
This commit is contained in:
Aamer Akhter
2026-10-05 09:45:24 -04:00
parent ea80c5f471
commit 06aba94ef1
3 changed files with 441 additions and 51 deletions
+41 -9
View File
@@ -2589,6 +2589,41 @@ Object.assign(CodemanApp.prototype, {
return typeof confirmed === 'string' ? confirmed : name;
},
/**
* Write an inline rename, one PUT per session at a time, in the order the
* user made them. The editor can be reopened (or cancelled, or replaced by a
* group rename) while a PUT is in flight, so the write lives here rather than
* in the editor: a confirmed name is applied locally even after its editor is
* gone, and the "already that name" check runs only once the earlier writes
* have landed, so confirming the name still on screen is a real write.
* Resolves { status: 'confirmed' | 'failed' | 'deleted' }; never rejects.
*/
_queueInlineSessionName(sessionId, desiredName) {
this._inlineRenameWrites ??= new Map();
const writes = this._inlineRenameWrites;
const task = (writes.get(sessionId) || Promise.resolve()).then(async () => {
const session = this.sessions.get(sessionId);
if (!session) return { status: 'deleted' };
if (session.name === desiredName) return { status: 'confirmed' };
let confirmed = null;
try {
confirmed = await this._putSessionName(sessionId, desiredName);
} catch {
// A failure is a value, so a later write in the chain still runs.
}
if (!this.sessions.has(sessionId)) return { status: 'deleted' };
if (confirmed === null) return { status: 'failed' };
this._applyLocalSessionName(sessionId, confirmed);
this.renderSessionTabs();
return { status: 'confirmed' };
});
writes.set(sessionId, task);
task.then(() => {
if (writes.get(sessionId) === task) writes.delete(sessionId);
});
return task;
},
async saveSessionName() {
if (!this.editingSessionId) return;
// Captured: the modal can be closed (or switched to another session) while
@@ -2957,18 +2992,15 @@ Object.assign(CodemanApp.prototype, {
if (fullName === session.name) restoreOriginalChildren();
else tabName.textContent = fullName || originalContent;
// Skip the API call if the session vanished between focus and blur.
const stillExists = this.sessions.has(sessionId);
if (stillExists && fullName !== session.name) {
const confirmed = await this._putSessionName(sessionId, fullName);
// Skip the API call if the session vanished between focus and blur. The
// queue applies the confirmed name to this.sessions before the re-render
// below repaints from it (see _applyLocalSessionName()).
if (this.sessions.has(sessionId)) {
const result = await this._queueInlineSessionName(sessionId, fullName);
if (invalidated || this._activeRename !== renameHandle || !this.sessions.has(sessionId)) return;
if (confirmed === null) {
if (result.status === 'failed') {
restoreOriginalChildren();
this.showToast('Failed to rename', 'error');
} else {
// The re-render below repaints from this.sessions, so the new name has
// to be in the map before it runs (see _applyLocalSessionName()).
this._applyLocalSessionName(sessionId, confirmed);
}
}
// Re-render tabs to restore full tab structure
+39 -2
View File
@@ -1848,22 +1848,52 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
-webkit-line-clamp: unset;
line-clamp: unset;
overflow: visible;
/* An input's intrinsic width counts toward the label's min-content, so with
the default min-width:auto a long `w<n>-<case>` prefix plus the input
pushed the label past its row: the prefix slid out of view and, in the
compact rail, the input was clipped mid-word. */
min-width: 0;
}
/* The prefix gives way first, down to a stub with an ellipsis. */
:is(
html[data-tab-orientation='vertical'] .tab-rail,
html[data-session-list='sidebar'] .session-sidebar
) .tab-name-renaming .tab-rename-prefix {
flex: 0 0 auto;
flex: 0 1 auto;
min-width: 2rem;
max-width: 50%;
overflow: hidden;
text-overflow: ellipsis;
}
/* The input always keeps room to type. `!important` beats the editor's inline
`min-width: 0`, which the header strip's fixed-width editor relies on. */
:is(
html[data-tab-orientation='vertical'] .tab-rail,
html[data-session-list='sidebar'] .session-sidebar
) .tab-name-renaming .tab-rename-input {
flex: 1 1 0;
width: auto;
min-width: 0;
min-width: 4rem !important;
}
/* A compact rail row has no room for both the editor and its adornments, so
they step aside while the name is being edited (the re-render that ends the
edit brings them back). */
html[data-tab-orientation='vertical'].tab-rail-compact
.tab-rail
.session-tab:has(.tab-name-renaming)
:is(
.tab-mode,
.tab-exited-badge,
.tab-detached-badge,
.tab-badge,
.tab-subagent-badge,
.tab-ultracode-badge,
.tab-actions
) {
display: none;
}
/* Tab folder path — hidden by default, shown via .tabs-show-folder on container */
@@ -18895,6 +18925,13 @@ html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail
line-clamp: 3;
}
/* ...except while it is being edited: this rule outranks the shared renaming
rule's unclamp, so restate it (the editor is a flex row, never clamped). */
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-name.tab-name-renaming {
-webkit-line-clamp: unset;
line-clamp: unset;
}
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-folder {
font-size: 0.66rem;
margin-top: 0.1rem;