From 97cb5b5799d032b2a44334c6d7c8197fb8b6caa8 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 3 Oct 2026 08:44:40 -0400 Subject: [PATCH 1/7] feat(tabs): edit groups in the vertical rail The grouped vertical rail can now be edited from the browser: groups are created, renamed, reordered and deleted, and tabs are moved between them, by menu, keyboard or pointer drag. Every edit is saved through the existing PUT /api/tab-layout; there are no server changes. Saving (tab-layout-browser.js, pure): - Edits are named operations (createGroup, renameGroup, deleteGroup, reorderGroup, moveRef) applied to the rail at once, mirroring the server model: a moved session takes the sessions that still follow it, and a hand-moved child is marked placement 'manual'. normalizeLayout now keeps placement and updatedAt, since whole layouts are written back. - createEditCoordinator keeps ONE PUT {baseVersion, layout} in flight. Edits made in the same turn share a write; edits made while one is in flight go out on the version it returns. A 409 replays the operations onto the layout the server returned and retries (bounded); an operation that no longer applies is dropped and reported. A 400 re-reads first; any other failure reports and re-reads. - dropOperation maps a finished drag to one operation, or null for a drop that changes nothing. Wiring (app.js, tab-rail-resize.js): - The session row menu gains Move up/down, Move to , Move to Ungrouped and Move to new group in the vertical rail. Before the first group exists it offers only "Move to new group", which is how a flat rail becomes grouped; the header strip's menu is unchanged. - A group header opens its menu with Shift+F10 / ContextMenu, right-click or a hover glyph (a non-focusable aria-hidden span, so the treeitem still holds no interactive child): Rename, New group, Move group up/down, Delete. F2 renames inline. A web tab row's Shift+F10 opens its settings plus the same moves. - The menu closes on Escape (consumed before the global Escape handler, focus back to its row or header), a pointer outside, Tab, focus leaving it, a resize, a second open and any full re-render. - Inline group rename shares the session rename's ownership handle, so only the current editor releases the render guard. Enter or blur commits, Escape cancels, IME composition keys are left to the IME, and the label becomes a flex slot so the editor gets the full width while typing. - Pointer drag (mouse and pen) in the grouped rail only: rows before/after a row or into a group, a header drag reorders groups. Escape cancels; the click that ends a drag neither selects nor toggles. The flat rail and the header strip keep their HTML5 drag untouched. - A tab:layoutChanged read is deferred while a write is in flight and run once it settles; a read otherwise rebases unsaved edits. On pagehide, unconfirmed edits go out in a keepalive PUT and into sessionStorage, and replay after reload (a no-op when the keepalive landed). - New strings have zh-CN entries; group names reach the DOM only as text. Unchanged: the flat rail's markup when no group exists, the tree semantics and single roving tab stop, sessionOrder and Alt+N. Tests: test/tab-layout-editing.test.ts (operations, coordinator, drop mapping, menus, rename, dismissal, SSE deferral, reload recovery, flat-rail identity) and test/tab-layout-editing.browser.test.ts (real pointer drags, editor paint, menu Escape), listed in BROWSER_TEST_GLOBS. --- CLAUDE.md | 2 +- config/test-suites.ts | 1 + docs/architecture-invariants.md | 6 +- src/web/public/app.js | 612 ++++++++++++++++- src/web/public/i18n.js | 23 + src/web/public/styles.css | 72 ++ src/web/public/tab-layout-browser.js | 396 ++++++++++- src/web/public/tab-rail-resize.js | 2 + test/tab-layout-browser.test.ts | 12 +- test/tab-layout-editing.browser.test.ts | 259 +++++++ test/tab-layout-editing.test.ts | 867 ++++++++++++++++++++++++ test/tab-layout-rail.test.ts | 7 + 12 files changed, 2239 insertions(+), 20 deletions(-) create mode 100644 test/tab-layout-editing.browser.test.ts create mode 100644 test/tab-layout-editing.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 1ab436033..5ea2984c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -257,7 +257,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history and transcript files into one deduped list (pure core `src/services/unified-session-service.ts`), backing the Cmd+K Session Manager, pinning and cross-device tab order (`PUT /api/session-order`, `src/session-order.ts`). ⚠️ Transcript history is THREE stores (`~/.claude/projects`, `~/.omp/agent/sessions`, `~/.codex/sessions`), folded via the `claudeSessionId → Codeman id` alias map (not Claude-only despite the name). ⚠️ `resumeId` is set by a SCANNER row only, never a live session; every surface that re-projects these rows (phone overview included) must carry it through, or a tap silently starts a second conversation. → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager) -**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend only READS it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. No frontend WRITES the layout yet. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts) +**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend reads AND edits it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. Groups are created, renamed, reordered and deleted, and rows moved between them, from the row/group menus (Shift+F10 on a header too) and by pointer drag in the grouped rail. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. ⚠️ Every browser write is a named operation through ONE serialized `PUT /api/tab-layout` at a time (`createEditCoordinator`): a 409 replays the operations onto the server's layout and retries (bounded), and an SSE reload is deferred while a write is in flight. Never PUT the layout from anywhere else in the frontend. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts) **Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()`, gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation) diff --git a/config/test-suites.ts b/config/test-suites.ts index 004af4625..010edd707 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -21,6 +21,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/tab-rail-resize.browser.test.ts', 'test/tab-activation.browser.test.ts', + 'test/tab-layout-editing.browser.test.ts', 'test/session-sidebar-ux.browser.test.ts', 'test/session-options-responsive.browser.test.ts', 'test/inline-rename.test.ts', diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 16a75d3d4..472c868ad 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -296,14 +296,16 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t **Owner tab layouts** (COD-359, `tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat tab strip, scoped per owner (`SINGLE_USER_LAYOUT_OWNER` = `@single` when multi-user is off), persisted under the `tabLayouts` key in state.json. The pure model is `tab-layout.ts`, `tab-layout-service.ts` is the sole mutation boundary, plus `tab-layout-persistence.ts` and `tab-layout-legacy-order.ts`. A layout is `{version, groups[], ungrouped[], updatedAt}` whose refs point at either a session or a saved webview (`TabRefKind`), capped at 32 groups / 512 refs. -⚠️ **The frontend READS the layout; nothing writes it yet.** `tab-layout-browser.js` (pure, loaded before app.js) projects it onto what is live in the page, and app.js's grouped-rail block draws the VERTICAL rail as collapsible group sections. The rules that keep it safe: +⚠️ **The frontend READS and EDITS the layout.** `tab-layout-browser.js` (pure, loaded before app.js) projects it onto what is live in the page, and app.js's grouped-rail block draws the VERTICAL rail as collapsible group sections and edits it. The rules that keep it safe: - **Grouped iff vertical AND the owner has at least one group.** No layout, a failed `GET` (newest-wins via `createLoadCoordinator`, retried at 5/10/20/40 s and then left to the next SSE init or `tab:layoutChanged`) or zero groups renders the flat rail unchanged; the horizontal strip, phones and the sidebar never group. ⚠️ Adopting a layout rebuilds the strip ONLY when the structure key changed (`_applyTabLayout`): the server announces a change on every session create/close and order PUT, and the key deliberately leaves the layout version out, so those announcements cost a flat rail nothing. - **A render layer, never an order source.** `sessionOrder` (the server-projected global order), Alt+N, Ctrl+Tab and the palette are untouched; a grouped session row is the flat row's markup, so its badge still names its Alt+N slot. Web tabs keep their slot after every session wherever their group puts them (`renderWebviewTab`). - **Collapse is per-device** (`codeman:tab-groups-collapsed` in localStorage, ids of deleted groups garbage-collected on adoption). A store that throws means all-expanded; a malformed stored VALUE reads as empty and is rewritten, so it can never disable collapse on that device for good. A collapsed group still SHOWS the active row, and `_updateActiveTabImmediate` falls through to a full render whenever the structure key changes, since a class toggle cannot reveal a hidden row. - **A collapsed header carries the most urgent alert it hides** (`hiddenGroupAlerts()`, applied by `_syncTabGroupHeaderAlerts` on BOTH render paths, since alerts change without a rebuild), in the tab alert language: `tab-alert-action` red, `tab-alert-idle` yellow. A permission prompt behind a collapse must never be invisible. - **Lineage arcs to a collapse-hidden session anchor to its group header** (`lineage-line--proxied`); two endpoints proxied to one header draw nothing. -- **Drag-reorder is off in the grouped rail** until grouped editing lands: a flat-order drop cannot express a group move, and the server re-ranks within the old group. For the same reason Ctrl+Shift+{ / } only swaps with a neighbour in the active session's own section (`_canSwapActiveTabWith`, reading the projection's `sectionByRef`): a cross-group swap moves nothing on the server, gets no `session:orderChanged` back, and would leave this client's `sessionOrder` and Alt+N targets out of step with every other device. +- **The upstream HTML5 drag stays off in the grouped rail**: a flat-order drop cannot express a group move, and the server re-ranks within the old group. The grouped rail has its OWN pointer drag instead (`_bindTabLayoutPointerDrag`, mouse/pen only, bound once on the container): rows move before/after a row or into a group, a header drag reorders groups, and the drop maps to ONE operation through the pure `dropOperation()`. Escape cancels a drag, and the click that ends one is swallowed. The flat rail and the header strip keep the HTML5 drag untouched. For the same reason Ctrl+Shift+{ / } only swaps with a neighbour in the active session's own section (`_canSwapActiveTabWith`, reading the projection's `sectionByRef`): a cross-group swap moves nothing on the server, gets no `session:orderChanged` back, and would leave this client's `sessionOrder` and Alt+N targets out of step with every other device. +- **Edits are named operations, saved serially.** `createEditCoordinator` applies `createGroup` / `renameGroup` / `deleteGroup` / `reorderGroup` / `moveRef` to the rail at once, then sends ONE `PUT /api/tab-layout {baseVersion, layout}` at a time; edits made meanwhile wait and go out on the version that write returns. A 409 carries the server's layout: the in-flight operations are replayed onto it (an operation that no longer applies is dropped and reported) and re-sent, at most `maxAttempts` times; a 400 re-reads first; anything else reports and re-reads. A `moveRef` moves the session together with the sessions that still follow it and marks a hand-moved child `placement: 'manual'`, mirroring the server's `moveRef`, and `normalizeLayout` keeps `placement` because whole layouts are written back. `_onTabLayoutChanged` / `_applyTabLayout` defer a read while a write is in flight and rebase unsaved edits onto a read otherwise. On `pagehide`, unconfirmed operations go out in a `keepalive` PUT AND into sessionStorage; after reload they replay onto the fresh layout, which is a no-op when the keepalive landed. +- **Every way in has a keyboard path.** The session row menu (Shift+F10 in the tree, the rail's overflow button) gains Move up/down, Move to , Move to Ungrouped and Move to new group in the vertical rail (only "new group" before the first group exists; nothing on the strip). A group header opens its menu with Shift+F10 / ContextMenu, right-click or its hover glyph (a non-focusable, `aria-hidden` span: a treeitem holds no interactive children), and F2 renames it inline. The menu closes on Escape (which it consumes before the global Escape handler), a pointer outside, Tab, focus leaving it, a resize, a second open and any full re-render. The inline group editor shares `_activeRename` with the session rename, so only the CURRENT editor may release `_inlineRenameActive`. - **Only the grouped rail is a tree.** `#sessionTabs` ships as `role=tablist` with `role=tab` rows, and the header strip, sidebar and flat rail keep exactly that. While grouped, `_applyTabListRole` makes it `role=tree` (and restores `tablist` + its label when grouping ends), named-group headers are level-1 `treeitem`s that `aria-owns` their rows' `role=group` (rows sit beside the header, not inside it), and ungrouped rows plus a collapsed group's kept selection are level-1 items. A collapsed header owns nothing, a group with no open rows is a leaf (no `aria-expanded`, no owned group), and the "Ungrouped" heading is `aria-hidden`. Rows are re-roled in the DOM by `_applyTabTreeSemantics` after render, never by rewriting their markup, so a grouped row's content stays the flat row's. - **One tab stop in the tree.** Exactly one treeitem carries `tabindex=0` (the focused or selected item); every control inside a row drops to `-1`, which is why Shift+F10 / ContextMenu open a row's actions from the keyboard. Focus survives a full re-render by identity (`group:`/`session:`/`webview:`; a row a collapse just hid hands focus to its header), but only when focus was already inside the rail. The tree walk (`_tabTreeItems`) follows painted order WITHIN each group when the rail is sorted; the flat list keeps its own whole-list computed-order walk. `aria-posinset`/`aria-setsize` follow painted order too, so the incremental render path re-runs `_applyTabTreePositions` after it re-sorts rows in place. ⚠️ `_handleTabTreeKeydown` acts only when the key lands on the treeitem ITSELF: a key on a focused in-row control (close, overflow, the rename input) is that control's, or Enter on the overflow button re-selects the row instead of reopening its menu. ⚠️ The roving `tabindex=-1` also hides every item but the stop from the keyboard-dismiss selector's `[tabindex]` arm, which is why `MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR` lists `[role="treeitem"]` (see Dismissing the on-screen keyboard). diff --git a/src/web/public/app.js b/src/web/public/app.js index fbb03342c..00fc2ec42 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -866,6 +866,8 @@ class CodemanApp { // Flush the durable queue synchronously when the page is hidden/closed — // debounced persistence may have a pending write we mustn't lose on reload. window.addEventListener('pagehide', () => this._persistReliableNow()); + // Tab group edits not yet confirmed by the server survive a reload. + window.addEventListener('pagehide', () => this._persistPendingTabLayoutEdits()); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') this._persistReliableNow(); // A background tab's timers are throttled, so the 5s watchdog may not @@ -1267,6 +1269,16 @@ class CodemanApp { // Escape - close panels and modals (different logic: no preventDefault, no return) if (e.key === 'Escape') { + // An open group menu (or a grouped-rail drag) owns this Escape: close + // just that, not every panel behind it. + if (this._tabGroupMenu && this._tabGroupMenuKeydown) { + this._tabGroupMenuKeydown(e); + return; + } + if (this._tabLayoutDrag?.active && this._tabLayoutDragKeydown) { + this._tabLayoutDragKeydown(e); + return; + } this.closeAllPanels(); this.closeHelp(); if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory(); @@ -5695,6 +5707,8 @@ class CodemanApp { _fullRenderSessionTabs() { this.closeTabRailActionMenu?.(); if (this._inlineRenameActive) return; + // The group menu's trigger is about to be replaced. + this.closeTabGroupMenu(); const container = this.$('sessionTabs'); // Sidebar rows are always tall (name + folder) and never wrap. Re-assert it @@ -5724,9 +5738,11 @@ class CodemanApp { // The rebuild below destroys the focused row. In the grouped tree, put focus // back on the same item (by identity) so a background render or a keyboard // collapse does not drop a keyboard user to . - const focusWasInside = container.contains(document.activeElement); + // An edit made from a menu (focus now on ) asks to land back in the rail. + const focusWasInside = container.contains(document.activeElement) || this._tabRefocusAfterEdit === true; const focusIdentity = this._tabFocusIdentity || (focusWasInside ? this._tabTreeIdentity(document.activeElement) : null); this._tabFocusIdentity = null; + this._tabRefocusAfterEdit = false; // Build tabs HTML using array for better string concatenation performance. // Iterate in sessionOrder to respect the user's custom tab arrangement, on @@ -5887,6 +5903,9 @@ class CodemanApp { // Set up drag-and-drop handlers for tab reordering this.setupTabDragHandlers(); + // The grouped rail drags with its own pointer model (rows across groups, + // group reorder); bound once, inert unless the rail is grouped. + this._bindTabLayoutPointerDrag(container); // Set up keyboard navigation for tabs this.setupTabKeyboardNavigation(container); @@ -6211,18 +6230,22 @@ class CodemanApp { if (groupId) toggle(); else this._activateTabRow(current); break; + case 'F2': + if (!groupId || !this.startTabGroupRename(groupId)) return; + break; case 'F10': - case 'ContextMenu': + case 'ContextMenu': { if (e.key === 'F10' && !e.shiftKey) return; - if (current.dataset.id) { - this.openTabRailActionMenu?.( - { preventDefault() {}, stopPropagation() {}, currentTarget: current }, - current.dataset.id - ); + const synthetic = { preventDefault() {}, stopPropagation() {}, currentTarget: current }; + if (groupId) { + this.openTabGroupMenu(synthetic, groupId); + } else if (current.dataset.id) { + this.openTabRailActionMenu?.(synthetic, current.dataset.id); } else if (current.dataset.webviewId) { - this.showWebviewModal?.(current.dataset.webviewId); + this.openTabWebviewMenu(synthetic, current.dataset.webviewId); } else return; break; + } default: return; } @@ -6356,6 +6379,12 @@ class CodemanApp { // owner-scoped server-side, so this is a saved request, not a guard.) if (me?.multiUser && typeof data?.owner === 'string' && data.owner !== me.username) return; if (Number.isSafeInteger(data?.version) && this.tabLayout && data.version <= this.tabLayout.version) return; + // Our own write is in flight: its response is the newer truth, and a read + // racing it could repaint the pre-edit layout. Re-read once it settles. + if (this._tabLayoutEditor?.hasPending()) { + this._tabLayoutReloadPending = true; + return; + } this._loadTabLayout(); } @@ -6372,6 +6401,19 @@ class CodemanApp { // An overtaken response is already dropped by the coordinator; this guards a // reordering between the coordinator and an SSE-triggered reload. if (next && this.tabLayout && next.version < this.tabLayout.version) return; + const editor = this._tabLayoutEditor; + if (editor) { + if (next && editor.isWriting()) { + // The write's own response decides; read again after it. + this._tabLayoutReloadPending = true; + return; + } + // Unsaved edits are rebased onto the read (adoptExternal repaints); with + // none, the editor is simply rebuilt from the new layout on next use. + if (next && editor.hasPending() && editor.adoptExternal(next)) return; + editor.dispose(); + this._tabLayoutEditor = null; + } this.tabLayout = next; const storage = this._getTabCollapseStorage(); const collapsed = storage && next @@ -6384,6 +6426,11 @@ class CodemanApp { // rail (always so on the flat rail, which is every owner without groups). // Rebuild only when what the rail would draw actually changed. if (this._isTabGroupStructureStale()) this._fullRenderSessionTabs(); + // Edits left unsaved by the previous page (see _persistPendingTabLayoutEdits). + if (next && !this._tabLayoutRestoreChecked) { + this._tabLayoutRestoreChecked = true; + this._restorePendingTabLayoutEdits(); + } } /** localStorage, or null once it has failed (collapse then stays all-expanded). */ @@ -6446,6 +6493,551 @@ class CodemanApp { return this.collapsedTabGroupIds.has(groupId) === shouldCollapse; } + // ═══════════════════════════════════════════════════════════════ + // Owner tab layout: editing groups from the vertical rail + // ═══════════════════════════════════════════════════════════════ + // + // Every edit is a named operation (tab-layout-browser.js) applied to the rail + // at once and saved by ONE serialized PUT /api/tab-layout at a time, with the + // version the server last returned. A 409 is rebased onto the server's layout + // and retried; a failure re-reads. Editing is a vertical-rail feature: the + // header strip, the sidebar and phones never offer it. + + _tabLayoutEditable() { + return !!(this.tabLayout && window.CodemanTabLayout && this._tabOrientation() === 'vertical'); + } + + /** child session id -> parent session id, so a moved session takes the sessions that follow it. */ + _tabLayoutParents() { + const parents = {}; + for (const session of this.sessions.values()) { + if (session?.parentSessionId && session.parentSessionId !== session.id) parents[session.id] = session.parentSessionId; + } + return parents; + } + + async _putTabLayout({ baseVersion, layout }) { + const body = { baseVersion, layout: { ...layout, updatedAt: layout.updatedAt || new Date().toISOString() } }; + const response = await this._api('/api/tab-layout', { method: 'PUT', body }); + if (!response) return { ok: false, status: 0, layout: null }; + let data = null; + try { + data = await response.json(); + } catch {} + return { ok: response.ok, status: response.status, layout: data?.data?.layout || null }; + } + + _ensureTabLayoutEditor() { + if (this._tabLayoutEditor || !this.tabLayout) return this._tabLayoutEditor || null; + this._tabLayoutEditor = window.CodemanTabLayout.createEditCoordinator({ + initialLayout: this.tabLayout, + put: (request) => this._putTabLayout(request), + fetchLayout: async () => { + const data = await this._apiJson('/api/tab-layout'); + if (!data?.layout) throw new Error('Tab layout unavailable'); + return data.layout; + }, + applyLayout: (layout) => this._adoptEditedTabLayout(layout), + reportError: (message) => this.showToast?.(message, 'error'), + onFailure: () => { + // The rail may still show an edit the server refused: read the truth. + this._tabLayoutReloadPending = true; + }, + onSettled: () => { + if (!this._tabLayoutReloadPending) return; + this._tabLayoutReloadPending = false; + this._loadTabLayout(); + }, + }); + return this._tabLayoutEditor; + } + + /** The editor's view of the layout (optimistic or confirmed) becomes the rail. */ + _adoptEditedTabLayout(layout) { + this.tabLayout = layout; + const storage = this._getTabCollapseStorage(); + if (storage) { + // Forget collapse state for groups that no longer exist. + const collapsed = window.CodemanTabLayout.loadCollapsedGroupIds(storage, layout.groups.map((group) => group.id)); + if (collapsed.ok) this.collapsedTabGroupIds = new Set(collapsed.ids); + } + this._fullRenderSessionTabs(); + } + + /** + * Apply one edit. `focusIdentity` names the tree item that should hold focus + * afterwards (the moved row, the renamed group), so a keyboard user who acted + * from a menu lands back in the rail rather than on . + */ + editTabLayout(operation, focusIdentity) { + if (!this._tabLayoutEditable()) return false; + const active = document.activeElement; + const rail = this.$('sessionTabs'); + if (focusIdentity && (!active || active === document.body || rail?.contains(active))) { + this._tabFocusIdentity = focusIdentity; + this._tabRefocusAfterEdit = true; + } + try { + this._ensureTabLayoutEditor().enqueue(operation); + return true; + } catch (error) { + this._tabRefocusAfterEdit = false; + this.showToast?.(error?.message || 'Could not save tab groups.', 'error'); + return false; + } + } + + _newTabGroupId() { + return globalThis.crypto?.randomUUID?.() || `group-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + } + + /** New group (optionally holding `ref`), then straight into renaming it. */ + createTabGroup({ ref = null, index } = {}) { + if (!this._tabLayoutEditable()) return false; + const id = this._newTabGroupId(); + const name = window.CodemanI18n?.t?.('New group') || 'New group'; + if (!this.editTabLayout({ type: 'createGroup', id, name, ...(Number.isInteger(index) ? { index } : {}) }, `group:${id}`)) { + return false; + } + if (ref) this.editTabLayout({ type: 'moveRef', ref, groupId: id, index: 0, parents: this._tabLayoutParents() }); + this.startTabGroupRename(id); + return true; + } + + deleteTabGroup(groupId) { + const groups = this.tabLayout?.groups || []; + const index = groups.findIndex((group) => group.id === groupId); + if (index < 0) return false; + if (!window.confirm(`Delete group "${groups[index].name}"? Its tabs move to Ungrouped.`)) return false; + const neighbour = groups[index + 1] || groups[index - 1]; + return this.editTabLayout({ type: 'deleteGroup', groupId }, neighbour ? `group:${neighbour.id}` : null); + } + + moveTabGroup(groupId, delta) { + const groups = this.tabLayout?.groups || []; + const from = groups.findIndex((group) => group.id === groupId); + const to = from + delta; + if (from < 0 || to < 0 || to >= groups.length) return false; + return this.editTabLayout({ type: 'reorderGroup', groupId, index: to }, `group:${groupId}`); + } + + /** Where a ref is stored: { groupId (null = Ungrouped), refs, index } or null. */ + _tabRefLocation(ref) { + const same = (candidate) => candidate.kind === ref.kind && candidate.id === ref.id; + for (const group of this.tabLayout?.groups || []) { + const index = group.refs.findIndex(same); + if (index >= 0) return { groupId: group.id, refs: group.refs, index }; + } + const index = this.tabLayout?.ungrouped?.findIndex(same) ?? -1; + return index >= 0 ? { groupId: null, refs: this.tabLayout.ungrouped, index } : null; + } + + moveTabRef(ref, groupId, anchor = null, placement = 'before') { + const parents = this._tabLayoutParents(); + let destination; + try { + destination = window.CodemanTabLayout.moveDestination(this.tabLayout, ref, groupId, anchor, placement, parents); + } catch { + return false; + } + return this.editTabLayout({ type: 'moveRef', ref, ...destination, parents }, `${ref.kind}:${ref.id}`); + } + + /** + * Group placement actions for a row's action menu: reorder within its + * container, move to another group, out to Ungrouped, or into a new group. + * Empty outside the vertical rail, so the header strip's menu is unchanged. + */ + _tabRefMoveActions(ref) { + if (!this._tabLayoutEditable()) return []; + const location = this._tabRefLocation(ref); + if (!location) return []; + const actions = []; + const grouped = this.tabLayout.groups.length > 0; + // Up/down follow the STORED order, which is what the rail paints unless a + // sort is on (then the sort decides and there is nothing to reorder). + if (grouped && !this.isTabRailSorted()) { + // The sessions that follow this one move with it, so "down" means past + // the first row that is not part of that block. + const moving = new Set(window.CodemanTabLayout.movingRefKeys(this.tabLayout, ref, this._tabLayoutParents())); + const previous = location.refs[location.index - 1]; + const next = location.refs.slice(location.index + 1).find((candidate) => !moving.has(`${candidate.kind}:${candidate.id}`)); + if (previous) actions.push({ label: 'Move up', run: () => this.moveTabRef(ref, location.groupId, previous, 'before') }); + if (next) actions.push({ label: 'Move down', run: () => this.moveTabRef(ref, location.groupId, next, 'after') }); + } + for (const group of this.tabLayout.groups) { + if (group.id === location.groupId) continue; + actions.push({ label: `Move to ${group.name}`, run: () => this.moveTabRef(ref, group.id) }); + } + if (location.groupId !== null) actions.push({ label: 'Move to Ungrouped', run: () => this.moveTabRef(ref, null) }); + actions.push({ label: 'Move to new group', run: () => this.createTabGroup({ ref }) }); + return actions; + } + + // ─── Group and web-tab menus (right-click, the header's ⋯, Shift+F10) ── + + /** + * Close the open group / web-tab menu. Every dismissal path lands here: + * Escape, a pointer outside it, focus leaving it, Tab, a viewport resize, an + * action, and any full re-render of the rail (which would orphan its trigger). + */ + closeTabGroupMenu({ restoreFocus = false } = {}) { + const menu = this._tabGroupMenu; + if (!menu) return; + const trigger = this._tabGroupMenuTrigger; + const identity = this._tabGroupMenuKey; + this._tabGroupMenu = null; + this._tabGroupMenuTrigger = null; + this._tabGroupMenuKey = null; + document.removeEventListener('pointerdown', this._tabGroupMenuOutside, true); + document.removeEventListener('keydown', this._tabGroupMenuKeydown, true); + window.removeEventListener('resize', this._tabGroupMenuResize); + this._tabGroupMenuOutside = this._tabGroupMenuKeydown = this._tabGroupMenuResize = null; + menu.remove(); + if (!restoreFocus) return; + // The trigger may have been re-rendered while the menu was open; find the + // live tree item by identity. + const rail = this.$('sessionTabs'); + const item = + (trigger?.isConnected && trigger.closest('[role="treeitem"]')) || + [...(rail?.querySelectorAll('[role="treeitem"]') || [])].find((el) => this._tabTreeIdentity(el) === identity); + item?.focus(); + } + + openTabGroupMenu(event, groupId) { + const groups = this.tabLayout?.groups || []; + const index = groups.findIndex((group) => group.id === groupId); + if (index < 0) return false; + return this._openTabLayoutMenu(event, `group:${groupId}`, 'Group actions', [ + { label: 'Rename group', run: () => this.startTabGroupRename(groupId) }, + { label: 'New group', run: () => this.createTabGroup({ index: index + 1 }) }, + ...(index > 0 ? [{ label: 'Move group up', run: () => this.moveTabGroup(groupId, -1) }] : []), + ...(index < groups.length - 1 ? [{ label: 'Move group down', run: () => this.moveTabGroup(groupId, 1) }] : []), + { label: 'Delete group', className: 'danger', run: () => this.deleteTabGroup(groupId) }, + ]); + } + + /** Keyboard actions for a web-tab row in the vertical rail: its settings plus group moves. */ + openTabWebviewMenu(event, webviewId) { + const moves = this._tabRefMoveActions({ kind: 'webview', id: webviewId }); + if (!moves.length) { + this.showWebviewModal?.(webviewId); + return false; + } + return this._openTabLayoutMenu(event, `webview:${webviewId}`, 'Web tab actions', [ + { label: 'Web tab settings', run: () => this.showWebviewModal?.(webviewId) }, + ...moves, + ]); + } + + _openTabLayoutMenu(event, identity, ariaLabel, actions) { + event?.preventDefault?.(); + event?.stopPropagation?.(); + const trigger = event?.currentTarget || null; + // Opening the same menu again closes it (a toggle, like the row menu). + if (this._tabGroupMenu && this._tabGroupMenuKey === identity) { + this.closeTabGroupMenu(); + return false; + } + this.closeTabGroupMenu(); + this.closeTabRailActionMenu?.(); + if (!this._tabLayoutEditable()) return false; + const menu = document.createElement('div'); + menu.className = 'tab-rail-action-menu tab-layout-group-action-menu'; + menu.setAttribute('role', 'menu'); + menu.setAttribute('aria-label', ariaLabel); + for (const action of actions) { + const button = document.createElement('button'); + button.type = 'button'; + button.setAttribute('role', 'menuitem'); + button.textContent = action.label; + if (action.className) button.className = action.className; + button.addEventListener('click', () => { + this.closeTabGroupMenu(); + action.run(); + }); + menu.appendChild(button); + } + document.body.appendChild(menu); + const anchor = (trigger?.getBoundingClientRect ? trigger : null) || this.$('sessionTabs'); + const rect = anchor?.getBoundingClientRect?.() || { left: 8, bottom: 8, right: 8 }; + const menuRect = menu.getBoundingClientRect(); + const left = event?.clientX && event.type === 'contextmenu' ? event.clientX : rect.left; + menu.style.left = `${Math.max(8, Math.min(left, window.innerWidth - menuRect.width - 8))}px`; + menu.style.top = `${Math.max(8, Math.min(rect.bottom + 4, window.innerHeight - menuRect.height - 8))}px`; + + this._tabGroupMenu = menu; + this._tabGroupMenuTrigger = trigger; + this._tabGroupMenuKey = identity; + this._tabGroupMenuOutside = (pointerEvent) => { + if (menu.contains(pointerEvent.target) || (trigger && trigger.contains?.(pointerEvent.target))) return; + this.closeTabGroupMenu(); + }; + // Capture on document, so Escape closes THIS menu and nothing else (the + // global Escape handler defers to it, see the keydown listener in init). + this._tabGroupMenuKeydown = (keyEvent) => { + if (keyEvent.key !== 'Escape') return; + keyEvent.preventDefault(); + keyEvent.stopImmediatePropagation(); + this.closeTabGroupMenu({ restoreFocus: true }); + }; + this._tabGroupMenuResize = () => this.closeTabGroupMenu(); + document.addEventListener('pointerdown', this._tabGroupMenuOutside, true); + document.addEventListener('keydown', this._tabGroupMenuKeydown, true); + window.addEventListener('resize', this._tabGroupMenuResize); + menu.addEventListener('keydown', (keyEvent) => { + const buttons = [...menu.querySelectorAll('button')]; + const at = buttons.indexOf(document.activeElement); + if (keyEvent.key === 'ArrowDown' || keyEvent.key === 'ArrowUp') { + keyEvent.preventDefault(); + buttons[(at + (keyEvent.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length]?.focus(); + } else if (keyEvent.key === 'Home' || keyEvent.key === 'End') { + keyEvent.preventDefault(); + buttons[keyEvent.key === 'Home' ? 0 : buttons.length - 1]?.focus(); + } else if (keyEvent.key === 'Tab') { + // Tab would walk out and leave the popup on screen: dismiss to the row. + keyEvent.preventDefault(); + this.closeTabGroupMenu({ restoreFocus: true }); + } + }); + // Focus leaving by any other route (a click elsewhere, a programmatic move). + // Hops between the menu's own items are not a departure. + menu.addEventListener('focusout', (focusEvent) => { + if (focusEvent.relatedTarget && menu.contains(focusEvent.relatedTarget)) return; + if (this._tabGroupMenu === menu) this.closeTabGroupMenu(); + }); + menu.querySelector('button')?.focus(); + return true; + } + + // ─── Inline group rename ─────────────────────────────────────────── + + /** + * Rename a group in place. Shares the session rename's ownership handle + * (`_activeRename`), so starting one cancels the other and only the CURRENT + * editor may release the render guard. Enter or blur commits, Escape cancels, + * IME composition keys belong to the IME. The commit goes through the edit + * coordinator, so it is serialized behind any write already in flight. + */ + startTabGroupRename(groupId) { + if (!this.tabLayout?.groups?.some((candidate) => candidate.id === groupId)) return false; + // Cancelling another editor re-renders the rail, so look the header up after. + this._activeRename?.cancel(); + const group = this.tabLayout?.groups?.find((candidate) => candidate.id === groupId); + const header = this.$('sessionTabs')?.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`); + const label = header?.querySelector('.tab-layout-group-name'); + if (!group || !label) return false; + this._inlineRenameActive = true; + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'tab-layout-group-rename-input'; + input.value = group.name; + input.maxLength = 60; + input.setAttribute('aria-label', 'Group name'); + label.classList.add('tab-layout-group-name--renaming'); + label.replaceChildren(input); + // The header toggles collapse on click and opens its menu on right-click; + // neither may fire from inside the editor. + for (const type of ['click', 'contextmenu', 'pointerdown']) input.addEventListener(type, (e) => e.stopPropagation()); + + let settled = false; + const handle = { groupId, cancel: () => settle(false) }; + const settle = (commit) => { + if (settled) return; + settled = true; + const name = input.value.trim(); + // Only the current editor owns the guard: a newer rename keeps it. + if (this._activeRename !== handle) return; + this._activeRename = null; + this._inlineRenameActive = false; + const current = this.tabLayout?.groups?.find((candidate) => candidate.id === groupId); + if (commit && current && name && name !== current.name) { + if (this.editTabLayout({ type: 'renameGroup', groupId, name }, `group:${groupId}`)) return; + } + this._tabFocusIdentity = `group:${groupId}`; + this._tabRefocusAfterEdit = true; + this._fullRenderSessionTabs(); + }; + this._activeRename = handle; + input.addEventListener('keydown', (e) => { + e.stopPropagation(); + if (e.isComposing || e.keyCode === 229) return; + if (e.key === 'Enter') { + e.preventDefault(); + settle(true); + } else if (e.key === 'Escape') { + e.preventDefault(); + settle(false); + } + }); + input.addEventListener('blur', () => settle(true)); + input.focus(); + input.select(); + return true; + } + + // ─── Pointer drag in the grouped rail ────────────────────────────── + + /** + * Drag rows between groups and reorder groups, in the GROUPED rail only. + * Pointer Events (mouse and pen; touch keeps scrolling the rail), bound once + * on the container, which survives every re-render. The flat rail and the + * header strip keep the HTML5 drag in setupTabDragHandlers() untouched. + * Keyboard equivalents live in the row and group menus. + */ + _bindTabLayoutPointerDrag(container) { + if (!container || container._tabLayoutDragBound) return; + container._tabLayoutDragBound = true; + container.addEventListener('pointerdown', (e) => this._onTabLayoutPointerDown(e, container)); + container.addEventListener('pointermove', (e) => this._onTabLayoutPointerMove(e, container)); + container.addEventListener('pointerup', (e) => this._finishTabLayoutPointerDrag(e, container)); + container.addEventListener('pointercancel', () => this._cancelTabLayoutPointerDrag(container)); + container.addEventListener('lostpointercapture', () => this._cancelTabLayoutPointerDrag(container)); + } + + _onTabLayoutPointerDown(e, container) { + if (e.button !== 0 || e.pointerType === 'touch' || !container.classList.contains('session-tabs--grouped')) return; + if (this._inlineRenameActive || !this._tabLayoutEditable()) return; + // Controls keep their own click; only the row body or the header drags. + if (e.target.closest('.tab-actions, .tab-badge, .tab-layout-group-menu, button, input, [onclick*="stopPropagation"]')) return; + const header = e.target.closest('[data-tab-group-header]'); + const row = header ? null : e.target.closest('.session-tab'); + let source = null; + if (header) source = { type: 'group', groupId: header.dataset.tabGroupHeader }; + else if (row?.dataset.webviewId) source = { type: 'ref', ref: { kind: 'webview', id: row.dataset.webviewId } }; + else if (row?.dataset.id) source = { type: 'ref', ref: { kind: 'session', id: row.dataset.id } }; + if (!source) return; + this._tabLayoutDrag = { pointerId: e.pointerId, x: e.clientX, y: e.clientY, source, origin: header || row, active: false, target: null }; + } + + /** What a pointer at (x, y) would drop onto, from the rendered rail. */ + _tabLayoutDropTarget(x, y, container) { + const hit = document.elementFromPoint(x, y); + if (!hit || !container.contains(hit)) return null; + const row = hit.closest('.session-tab'); + const section = hit.closest('.tab-layout-group'); + const sectionGroup = section ? section.dataset.tabGroupId || null : undefined; + // A sorted rail paints its own order, so a row can only be dropped INTO a + // group, never between two rows. + if (row && section && !this.isTabRailSorted()) { + const ref = row.dataset.webviewId ? { kind: 'webview', id: row.dataset.webviewId } : { kind: 'session', id: row.dataset.id }; + const rect = row.getBoundingClientRect(); + return { type: 'ref', ref, groupId: sectionGroup, placement: y >= rect.top + rect.height / 2 ? 'after' : 'before', element: row }; + } + if (sectionGroup === undefined) return null; + const element = section.querySelector(':scope > .tab-layout-group-header'); + return sectionGroup === null ? { type: 'ungrouped', element } : { type: 'group', groupId: sectionGroup, element }; + } + + _clearTabLayoutDropMarks(container) { + container.querySelectorAll('.tab-layout-drop-before, .tab-layout-drop-after, .tab-layout-drop-into').forEach((el) => + el.classList.remove('tab-layout-drop-before', 'tab-layout-drop-after', 'tab-layout-drop-into') + ); + } + + _onTabLayoutPointerMove(e, container) { + const drag = this._tabLayoutDrag; + if (!drag || drag.pointerId !== e.pointerId) return; + if (!drag.active) { + if (Math.hypot(e.clientX - drag.x, e.clientY - drag.y) < 6) return; + drag.active = true; + drag.origin.classList.add('tab-layout-dragging'); + container.classList.add('tab-layout-drag-active'); + this.closeTabGroupMenu(); + this.closeTabRailActionMenu?.(); + try { + container.setPointerCapture(e.pointerId); + } catch {} + this._tabLayoutDragKeydown = (keyEvent) => { + if (keyEvent.key !== 'Escape') return; + keyEvent.preventDefault(); + keyEvent.stopImmediatePropagation(); + this._cancelTabLayoutPointerDrag(container); + }; + document.addEventListener('keydown', this._tabLayoutDragKeydown, true); + } + e.preventDefault(); + const target = this._tabLayoutDropTarget(e.clientX, e.clientY, container); + this._clearTabLayoutDropMarks(container); + drag.target = target; + if (!target?.element) return; + const cls = target.type === 'ref' && drag.source.type === 'ref' ? `tab-layout-drop-${target.placement}` : 'tab-layout-drop-into'; + target.element.classList.add(cls); + } + + _cancelTabLayoutPointerDrag(container) { + const drag = this._tabLayoutDrag; + if (!drag) return; + this._tabLayoutDrag = null; + drag.origin?.classList.remove('tab-layout-dragging'); + container?.classList.remove('tab-layout-drag-active'); + if (container) this._clearTabLayoutDropMarks(container); + if (this._tabLayoutDragKeydown) document.removeEventListener('keydown', this._tabLayoutDragKeydown, true); + this._tabLayoutDragKeydown = null; + if (drag.active) { + // The click that ends a drag must not also select the row or toggle the header. + const swallow = (clickEvent) => { + clickEvent.stopPropagation(); + clickEvent.preventDefault(); + }; + window.addEventListener('click', swallow, { capture: true, once: true }); + setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 0); + } + } + + _finishTabLayoutPointerDrag(e, container) { + const drag = this._tabLayoutDrag; + if (!drag || drag.pointerId !== e.pointerId) return; + const target = drag.active ? this._tabLayoutDropTarget(e.clientX, e.clientY, container) || drag.target : null; + this._cancelTabLayoutPointerDrag(container); + if (!target) return; + const operation = window.CodemanTabLayout.dropOperation(this.tabLayout, drag.source, target, this._tabLayoutParents()); + if (!operation) return; + const identity = drag.source.type === 'group' ? `group:${drag.source.groupId}` : `${drag.source.ref.kind}:${drag.source.ref.id}`; + this.editTabLayout(operation, identity); + } + + // ─── Unsaved edits across a reload ───────────────────────────────── + + /** + * The page is going away with edits not yet confirmed: send them with a + * keepalive PUT (it outlives the page) AND keep a copy in sessionStorage. If + * the keepalive lands, the copy replays to no change after reload; if it lost + * a race, the copy is rebased onto the fresh layout and saved properly. + */ + _persistPendingTabLayoutEdits() { + const editor = this._tabLayoutEditor; + const operations = editor?.pendingOperations?.() || []; + if (!operations.length) return false; + try { + sessionStorage.setItem('codeman:tab-layout-pending', JSON.stringify({ operations })); + } catch {} + try { + const layout = editor.getLayout(); + void fetch('/api/tab-layout', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ baseVersion: editor.baseVersion(), layout: { ...layout, updatedAt: layout.updatedAt || new Date().toISOString() } }), + keepalive: true, + }).catch(() => {}); + } catch {} + return true; + } + + _restorePendingTabLayoutEdits() { + let operations; + try { + const raw = sessionStorage.getItem('codeman:tab-layout-pending'); + if (!raw) return false; + sessionStorage.removeItem('codeman:tab-layout-pending'); + operations = JSON.parse(raw)?.operations; + } catch { + return false; + } + if (!Array.isArray(operations) || !operations.length || !this.tabLayout || !window.CodemanTabLayout) return false; + return this._ensureTabLayoutEditor().restore(operations); + } + // Set up drag-and-drop handlers on tab elements setupTabDragHandlers() { const container = this.$('sessionTabs'); @@ -6557,7 +7149,9 @@ class CodemanApp { * each group on its own (`putLegacyOrder`), so nothing moves there, no * session:orderChanged comes back, and this client would keep a swapped * sessionOrder (and Alt+N targets) that no other device shares. Same reason - * drag is off in the grouped rail. Any other layout: always allowed. + * the HTML5 flat-order drag is off in the grouped rail (its own pointer drag + * and the row menu's moves go through moveRef instead, which can cross a + * group). Any other layout: always allowed. */ _canSwapActiveTabWith(neighbourId) { const projection = this._projectTabGroups(); diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index b908552bb..3b749394c 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -68,6 +68,23 @@ 'Session Manager': '会话管理器', 'Session actions': '会话操作', Ungrouped: '未分组', + 'Group actions': '分组操作', + 'Group name': '分组名称', + 'Web tab actions': '网页标签操作', + 'Web tab settings': '网页标签设置', + 'New group': '新建分组', + 'Rename group': '重命名分组', + 'Move group up': '上移分组', + 'Move group down': '下移分组', + 'Delete group': '删除分组', + 'Move up': '上移', + 'Move down': '下移', + 'Move to Ungrouped': '移到未分组', + 'Move to new group': '移到新分组', + 'Could not save tab groups.': '无法保存标签分组。', + 'Tab groups changed elsewhere; part of your edit no longer applies.': + '标签分组已在别处更改;你的部分编辑已不再适用。', + 'Tab groups kept changing elsewhere; your edit was not saved.': '标签分组在别处持续更改;你的编辑未保存。', 'Open session manager': '打开会话管理器', Attachments: '附件', 'Open attachment history': '打开附件历史', @@ -934,6 +951,12 @@ [/^Update available: v(.+)$/, (_m, version) => `有可用更新:v${version}`], [/^Selected: (.+)$/, (_m, value) => `已选择:${value}`], [/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`], + // Group names are user text: they pass through untranslated. + [/^Move to (.+)$/, (_m, group) => `移到 ${group}`], + [ + /^Delete group "(.+)"\? Its tabs move to Ungrouped\.$/, + (_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`, + ], ]; for (const [pattern, replacement] of patterns) { const match = source.match(pattern); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 81b171181..b69ba0bcb 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -736,6 +736,78 @@ html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle:focus-v content: '\25B8'; } +/* Group editing: the header's menu glyph, the inline rename editor, and the + pointer-drag marks. All of it lives inside the grouped rail only. */ +html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-menu { + flex: 0 0 auto; + padding: 0 4px; + border-radius: 3px; + cursor: pointer; + opacity: 0; + letter-spacing: 0; +} + +html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header:hover .tab-layout-group-menu, +html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header:focus-visible .tab-layout-group-menu { + opacity: 1; +} + +html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-menu:hover { + color: var(--text); + background: var(--bg-tertiary, var(--bg-hover)); +} + +/* The label is a nowrap ellipsis box; while it holds the editor it becomes a + plain flex slot so the input gets the whole width and repaints as you type. */ +html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-name--renaming { + display: flex; + overflow: visible; + text-overflow: clip; +} + +html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-rename-input { + flex: 1 1 0; + width: auto; + min-width: 0; + padding: 1px 4px; + border: 1px solid var(--accent); + border-radius: 3px; + outline: none; + color: var(--text); + background: var(--bg-input); + font: inherit; + font-size: 11px; + font-weight: 600; + letter-spacing: normal; + user-select: text; +} + +html[data-tab-orientation='vertical'] .tab-rail .session-tabs--grouped .session-tab, +html[data-tab-orientation='vertical'] .tab-rail .session-tabs--grouped .tab-layout-group-header { + user-select: none; +} + +html[data-tab-orientation='vertical'] .tab-rail .tab-layout-drag-active { + cursor: grabbing; +} + +html[data-tab-orientation='vertical'] .tab-rail .tab-layout-dragging { + opacity: 0.45; +} + +html[data-tab-orientation='vertical'] .tab-rail .tab-layout-drop-before { + box-shadow: inset 0 2px 0 var(--accent); +} + +html[data-tab-orientation='vertical'] .tab-rail .tab-layout-drop-after { + box-shadow: inset 0 -2px 0 var(--accent); +} + +html[data-tab-orientation='vertical'] .tab-rail .tab-layout-drop-into { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + html[data-tab-orientation='vertical'] .header-right { margin-left: auto; } diff --git a/src/web/public/tab-layout-browser.js b/src/web/public/tab-layout-browser.js index de5b529da..c14fdb4e7 100644 --- a/src/web/public/tab-layout-browser.js +++ b/src/web/public/tab-layout-browser.js @@ -1,9 +1,9 @@ /** - * @fileoverview Read-only browser projection of the owner tab layout. + * @fileoverview Browser projection and editing of the owner tab layout. * * `GET /api/tab-layout` returns the owner's named tab GROUPS (`src/tab-layout.ts` * is the server model). Browser assets cannot import that TypeScript, so this - * module is a small, dependency-free mirror that owns three things: + * module is a small, dependency-free mirror that owns four things: * * 1. Projection: which live sessions and open web tabs land in which group, * and which rows a collapsed group hides. @@ -12,6 +12,10 @@ * byte-identical to the flat rail's row. * 3. Load sequencing: concurrent layout reads settle newest-wins, and a failed * read degrades to the flat rail with a capped, backed-off retry. + * 4. Editing: named operations (create/rename/delete/reorder a group, move a + * row) applied optimistically and saved through ONE serialized + * `PUT /api/tab-layout` at a time, rebased onto the server's layout on a + * version conflict. * * The server stays the only authority for layout content. Collapse is a * per-device view preference and lives in localStorage only. @@ -34,8 +38,16 @@ !!ref && (ref.kind === 'session' || ref.kind === 'webview') && typeof ref.id === 'string' && ref.id.length > 0; const asIds = (value) => (Array.isArray(value) ? value.filter((id) => typeof id === 'string' && id) : []); const stableIds = (value) => [...new Set(asIds(value))]; - const copyRefs = (value) => - Array.isArray(value) ? value.filter(validRef).map((r) => ({ kind: r.kind, id: r.id })) : []; + // `placement: 'manual'` must survive the round trip: the browser writes whole + // layouts back, and dropping it would re-attach a hand-placed child session to + // its parent's subtree on the next save. + const copyRef = (r) => + r.placement === 'manual' ? { kind: r.kind, id: r.id, placement: 'manual' } : { kind: r.kind, id: r.id }; + const copyRefs = (value) => (Array.isArray(value) ? value.filter(validRef).map(copyRef) : []); + + /** Server limits (src/tab-layout.ts), mirrored so a bad edit fails before the PUT. */ + const MAX_GROUPS = 32; + const MAX_NAME_LENGTH = 60; /** * Defensive copy of a server layout. Unknown fields are dropped, so a newer @@ -46,6 +58,7 @@ const groups = Array.isArray(value.groups) ? value.groups : []; return { version: Number.isSafeInteger(value.version) && value.version >= 0 ? value.version : 0, + updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : '', groups: groups .filter((group) => group && typeof group.id === 'string' && group.id.length > 0) .map((group) => ({ @@ -276,10 +289,17 @@ const expandedAttr = leaf ? '' : ` aria-expanded="${expanded ? 'true' : 'false'}"`; return ( `` ); }) @@ -340,6 +360,364 @@ }; } + // ─── Editing ──────────────────────────────────────────────────────────── + // + // The browser edits through NAMED operations, not by diffing arrays: a write + // that loses a version race (409) is rebased by replaying the same operations + // on the layout the server returned, so a concurrent edit elsewhere survives. + // The server stays the authority: it re-validates and normalizes every PUT. + + function editError(message) { + throw new Error(`Tab layout edit failed: ${message}`); + } + + const clampIndex = (value, length) => (Number.isInteger(value) ? Math.max(0, Math.min(value, length)) : length); + + function groupName(value) { + const name = typeof value === 'string' ? value.trim() : ''; + if (!name || name.length > MAX_NAME_LENGTH) editError(`group name must be 1-${MAX_NAME_LENGTH} characters`); + return name; + } + + function refLocations(layout) { + return [ + ...layout.groups.flatMap((group) => group.refs.map((ref) => ({ groupId: group.id, ref }))), + ...layout.ungrouped.map((ref) => ({ groupId: null, ref })), + ]; + } + + function containerRefs(layout, groupId) { + if (groupId === null) return layout.ungrouped; + const group = layout.groups.find((candidate) => candidate.id === groupId); + if (!group) editError('unknown group'); + return group.refs; + } + + /** + * The rows that move together with `ref`: the session plus every descendant + * that still follows its parent (non-manual, parent stored). Mirrors the + * server's moveRef block so the optimistic rail matches what it will store. + * `parents` maps a session id to its parent session id. + */ + function lineageBlock(layout, ref, parents) { + const stored = new Map(refLocations(layout).map((item) => [refKey(item.ref), item.ref])); + const children = new Map(); + for (const [childId, parentId] of Object.entries(parents || {})) { + const child = stored.get(`session:${childId}`); + if (!child || child.placement === 'manual' || !stored.has(`session:${parentId}`)) continue; + if (!children.has(parentId)) children.set(parentId, []); + children.get(parentId).push(childId); + } + const keys = new Set(); + const visit = (key) => { + if (keys.has(key)) return; + keys.add(key); + if (key.startsWith('session:')) for (const id of children.get(key.slice(8)) || []) visit(`session:${id}`); + }; + visit(refKey(ref)); + return keys; + } + + /** + * Where a moved row lands, as the server's `index` (counted AFTER the moved + * block is taken out): before or after `anchor` in that container, or at its + * end when there is no anchor. + */ + function moveDestination(layoutInput, ref, groupId, anchor, placement, parents) { + const layout = normalizeLayout(layoutInput); + const block = lineageBlock(layout, ref, parents); + const remaining = containerRefs(layout, groupId).filter((candidate) => !block.has(refKey(candidate))); + if (!anchor) return { groupId, index: remaining.length }; + const at = remaining.findIndex((candidate) => refKey(candidate) === refKey(anchor)); + if (at < 0) return { groupId, index: remaining.length }; + return { groupId, index: placement === 'after' ? at + 1 : at }; + } + + /** + * Map a finished drag to ONE operation (or null for a drop that changes + * nothing). Pure, so the drop -> PUT mapping is testable without a pointer. + * + * source: { type: 'ref', ref } | { type: 'group', groupId } + * target: { type: 'ref', ref, groupId, placement: 'before' | 'after' } + * | { type: 'group', groupId } (a named group's header or empty body) + * | { type: 'ungrouped' } + * + * A group dropped on another group (or any row in it) takes that group's slot; + * dropped on the Ungrouped section it goes last. A row dropped on a row lands + * before/after it, on a header it is appended to that group. + */ + function dropOperation(layoutInput, source, target, parents) { + const layout = normalizeLayout(layoutInput); + if (!source || !target) return null; + if (source.type === 'group') { + const from = layout.groups.findIndex((group) => group.id === source.groupId); + if (from < 0) return null; + const targetId = target.type === 'ungrouped' ? null : (target.groupId ?? null); + const to = targetId === null ? layout.groups.length - 1 : layout.groups.findIndex((g) => g.id === targetId); + if (to < 0 || to === from) return null; + return { type: 'reorderGroup', groupId: source.groupId, index: to }; + } + if (source.type !== 'ref' || !validRef(source.ref)) return null; + const location = refLocations(layout).find((item) => refKey(item.ref) === refKey(source.ref)); + if (!location) return null; + let groupId; + let anchor = null; + let placement = 'before'; + if (target.type === 'ref' && validRef(target.ref)) { + // Onto itself or onto a row that moves with it: nowhere to go. + if (lineageBlock(layout, source.ref, parents).has(refKey(target.ref))) return null; + groupId = target.groupId ?? null; + anchor = target.ref; + placement = target.placement === 'after' ? 'after' : 'before'; + } else if (target.type === 'group') { + groupId = target.groupId ?? null; + if (groupId === location.groupId) return null; + } else if (target.type === 'ungrouped') { + groupId = null; + if (location.groupId === null) return null; + } else return null; + if (groupId !== null && !layout.groups.some((group) => group.id === groupId)) return null; + const destination = moveDestination(layout, source.ref, groupId, anchor, placement, parents); + const operation = { + type: 'moveRef', + ref: { kind: source.ref.kind, id: source.ref.id }, + groupId: destination.groupId, + index: destination.index, + parents: parents || {}, + }; + return contentKey(applyOperation(layout, operation)) === contentKey(layout) ? null : operation; + } + + /** + * Apply one operation to a copy of the layout. Throws when the operation no + * longer makes sense (an unknown group or row); a rebase drops that one + * operation and keeps the rest. Replays are idempotent where it matters for + * recovery: creating a group that already exists is a no-op. + */ + function applyOperation(layoutInput, operation) { + const layout = normalizeLayout(layoutInput); + const op = operation || {}; + const groupIndex = layout.groups.findIndex((group) => group.id === op.groupId); + switch (op.type) { + case 'createGroup': { + if (typeof op.id !== 'string' || !op.id) editError('invalid group id'); + const name = groupName(op.name); + if (layout.groups.some((group) => group.id === op.id)) return layout; + if (layout.groups.length >= MAX_GROUPS) editError('group limit reached'); + layout.groups.splice(clampIndex(op.index, layout.groups.length), 0, { id: op.id, name, refs: [] }); + return layout; + } + case 'renameGroup': + if (groupIndex < 0) editError('unknown group'); + layout.groups[groupIndex].name = groupName(op.name); + return layout; + case 'deleteGroup': { + // Already gone (deleted elsewhere): nothing left to do. + if (groupIndex < 0) return layout; + const [removed] = layout.groups.splice(groupIndex, 1); + layout.ungrouped.push(...removed.refs); + return layout; + } + case 'reorderGroup': { + if (groupIndex < 0) editError('unknown group'); + const [moved] = layout.groups.splice(groupIndex, 1); + layout.groups.splice(clampIndex(op.index, layout.groups.length), 0, moved); + return layout; + } + case 'moveRef': { + if (!validRef(op.ref)) editError('invalid row'); + const targetKey = refKey(op.ref); + if (!refLocations(layout).some((item) => refKey(item.ref) === targetKey)) editError('unknown row'); + const destinationId = op.groupId ?? null; + containerRefs(layout, destinationId); + const keys = lineageBlock(layout, op.ref, op.parents); + const block = refLocations(layout) + .filter((item) => keys.has(refKey(item.ref))) + .map((item) => copyRef(item.ref)); + // A hand-moved child stops following its parent (server moveRef does the same). + const head = block.find((item) => refKey(item) === targetKey); + if (op.ref.kind === 'session' && op.parents?.[op.ref.id]) head.placement = 'manual'; + block.sort((a, b) => (a === head ? -1 : b === head ? 1 : 0)); + for (const group of layout.groups) group.refs = group.refs.filter((ref) => !keys.has(refKey(ref))); + layout.ungrouped = layout.ungrouped.filter((ref) => !keys.has(refKey(ref))); + const destination = containerRefs(layout, destinationId); + destination.splice(clampIndex(op.index, destination.length), 0, ...block); + return layout; + } + default: + return editError(`unknown operation ${op.type}`); + } + } + + /** Layout content without version metadata: equal keys mean "nothing to save". */ + function contentKey(layoutInput) { + const layout = normalizeLayout(layoutInput); + return JSON.stringify([layout.groups, layout.ungrouped]); + } + + /** Replay operations, dropping (and counting) the ones that no longer apply. */ + function replayOperations(base, operations) { + let layout = normalizeLayout(base); + const kept = []; + let dropped = 0; + for (const operation of operations) { + try { + layout = applyOperation(layout, operation); + kept.push(operation); + } catch (_error) { + dropped++; + } + } + return { layout, kept, dropped }; + } + + /** + * Serialized, optimistic writer for `PUT /api/tab-layout`. + * + * - enqueue() applies an operation at once (the rail repaints optimistically) + * and schedules a flush; operations enqueued in the same turn share a PUT. + * - Exactly ONE write is in flight. Operations enqueued meanwhile wait and are + * sent on top of the version that write returns. + * - A 409 carries the server's current layout: the in-flight operations are + * replayed onto it and re-sent with its version (bounded attempts). A 400 + * (a row vanished between read and write) re-reads and rebases the same way. + * - Anything else, or attempts exhausted, drops the batch and reports it; the + * caller re-reads so the rail shows the server's truth. + * + * options: { initialLayout, put({ baseVersion, layout }) -> { ok, status, + * layout }, fetchLayout?(), applyLayout(layout, meta), reportError?(message), + * onSettled?(), onFailure?(), schedule?(fn), cancel?(handle), maxAttempts? } + */ + function createEditCoordinator(options) { + let authoritative = normalizeLayout(options.initialLayout); + let optimistic = authoritative; + let pending = []; + let inFlight = []; + let writing = false; + let timer = null; + let disposed = false; + const schedule = options.schedule || ((fn) => setTimeout(fn, 0)); + const cancel = options.cancel || ((handle) => clearTimeout(handle)); + const maxAttempts = options.maxAttempts || 3; + const report = (message) => options.reportError?.(message); + const publish = (meta) => options.applyLayout(normalizeLayout(optimistic), meta); + const queue = () => { + if (timer === null) timer = schedule(flush); + }; + + async function flush() { + timer = null; + if (disposed || writing || pending.length === 0) return; + writing = true; + inFlight = pending; + pending = []; + let failed = false; + let reportedDrop = false; + try { + for (let attempt = 0; attempt < maxAttempts && inFlight.length; attempt++) { + const desired = replayOperations(authoritative, inFlight); + inFlight = desired.kept; + if (desired.dropped && !reportedDrop) { + reportedDrop = true; + report('Tab groups changed elsewhere; part of your edit no longer applies.'); + } + // Nothing left to change (dropped, or already true on the server). + if (!inFlight.length || contentKey(desired.layout) === contentKey(authoritative)) { + inFlight = []; + break; + } + const response = await options.put({ baseVersion: authoritative.version, layout: desired.layout }); + if (disposed) return; + if (response?.ok && response.layout) { + authoritative = normalizeLayout(response.layout); + inFlight = []; + } else if (response?.status === 409 && response.layout) { + authoritative = normalizeLayout(response.layout); + } else if (response?.status === 400 && options.fetchLayout) { + authoritative = normalizeLayout(await options.fetchLayout()); + if (disposed) return; + } else { + throw new Error('Tab layout save failed'); + } + } + if (inFlight.length) { + failed = true; + report('Tab groups kept changing elsewhere; your edit was not saved.'); + } + } catch (_error) { + failed = true; + report('Could not save tab groups.'); + } finally { + inFlight = []; + writing = false; + if (!disposed) { + const rebased = replayOperations(authoritative, pending); + pending = rebased.kept; + optimistic = rebased.layout; + publish({ authoritative: true }); + if (failed) options.onFailure?.(); + if (pending.length) queue(); + else options.onSettled?.(); + } + } + } + + return { + /** Apply now, save soon. Throws (and changes nothing) for an invalid edit. */ + enqueue(operation) { + optimistic = applyOperation(optimistic, operation); + pending.push(operation); + publish({ optimistic: true }); + queue(); + return normalizeLayout(optimistic); + }, + /** + * Re-apply operations recovered after a reload. Returns false (and queues + * nothing) when the layout already reflects them, e.g. the keepalive save + * landed before the page went away. + */ + restore(operations) { + const replayed = replayOperations(optimistic, Array.isArray(operations) ? operations : []); + if (!replayed.kept.length || contentKey(replayed.layout) === contentKey(optimistic)) return false; + optimistic = replayed.layout; + pending.push(...replayed.kept); + publish({ optimistic: true }); + queue(); + return true; + }, + /** + * Adopt a layout read from the server (SSE reload). Pending operations are + * rebased onto it. Refused while a write is in flight (its result decides) + * and for a layout older than the one already held. + */ + adoptExternal(layout) { + if (disposed || writing) return false; + const next = normalizeLayout(layout); + if (next.version < authoritative.version) return false; + authoritative = next; + const rebased = replayOperations(next, pending); + if (rebased.dropped) report('Tab groups changed elsewhere; part of your edit no longer applies.'); + pending = rebased.kept; + optimistic = rebased.layout; + publish({ authoritative: true, external: true }); + return true; + }, + flush, + isWriting: () => writing, + hasPending: () => writing || pending.length > 0, + /** Every operation not yet confirmed by the server, oldest first. */ + pendingOperations: () => JSON.parse(JSON.stringify([...inFlight, ...pending])), + baseVersion: () => authoritative.version, + getLayout: () => normalizeLayout(optimistic), + dispose() { + disposed = true; + if (timer !== null) cancel(timer); + timer = null; + pending = []; + }, + }; + } + global.CodemanTabLayout = { normalizeLayout, hasGroups, @@ -347,6 +725,12 @@ hiddenGroupAlerts, structureKey, renderProjection, + applyOperation, + moveDestination, + movingRefKeys: (layout, ref, parents) => [...lineageBlock(normalizeLayout(layout), ref, parents)], + dropOperation, + contentKey, + createEditCoordinator, createLoadCoordinator, loadCollapsedGroupIds, saveCollapsedGroupIds, diff --git a/src/web/public/tab-rail-resize.js b/src/web/public/tab-rail-resize.js index 17e99ea53..514296754 100644 --- a/src/web/public/tab-rail-resize.js +++ b/src/web/public/tab-rail-resize.js @@ -325,6 +325,8 @@ Object.assign(CodemanApp.prototype, { const settings = this.loadAppSettingsFromStorage(); const actions = [ { label: 'Session options', run: () => this.openSessionOptions(sessionId) }, + // Group placement (vertical rail with a tab layout only; [] elsewhere). + ...(this._tabRefMoveActions?.({ kind: 'session', id: sessionId }) || []), ...(settings.showTabDetachButton || this.detachedSessions?.has(sessionId) ? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }] : []), diff --git a/test/tab-layout-browser.test.ts b/test/tab-layout-browser.test.ts index a97c6b009..3fd7b85ae 100644 --- a/test/tab-layout-browser.test.ts +++ b/test/tab-layout-browser.test.ts @@ -451,8 +451,16 @@ describe('browser wiring', () => { expect(APP_SOURCE).toContain("[SSE_EVENTS.TAB_LAYOUT_CHANGED, '_onTabLayoutChanged']"); }); - it('never writes the layout from the browser in this slice', () => { - expect(APP_SOURCE).not.toMatch(/['"`]PUT['"`][^\n]*tab-layout|tab-layout[^\n]*['"`]PUT['"`]/); + it('writes the layout only through the edit coordinator (and its keepalive twin)', () => { + // Exactly two PUT sites: _putTabLayout (the coordinator's transport) and the + // pagehide keepalive. Anything else would bypass serialization. + const writes = APP_SOURCE.split('\n').filter( + (line) => line.includes("'/api/tab-layout'") && !line.includes("_apiJson('/api/tab-layout')") + ); + expect(writes).toHaveLength(2); + expect(APP_SOURCE).toContain("this._api('/api/tab-layout', { method: 'PUT', body })"); + expect(APP_SOURCE).toContain("void fetch('/api/tab-layout', {"); + // The pure module never does IO itself. expect(SOURCE).not.toContain('fetch('); }); }); diff --git a/test/tab-layout-editing.browser.test.ts b/test/tab-layout-editing.browser.test.ts new file mode 100644 index 000000000..7cd535311 --- /dev/null +++ b/test/tab-layout-editing.browser.test.ts @@ -0,0 +1,259 @@ +/** + * @fileoverview Real-Chromium coverage for editing the grouped vertical rail. + * + * What DOM emulation cannot answer: a pointer drag (hit testing, capture, the + * click that ends a drag), whether the inline group editor actually paints + * inside the rail's nowrap/ellipsis header, and whether Escape on an open group + * menu reaches the menu first. The shipping app.js, tab-layout-browser.js, + * tab-rail-resize.js, api-client.js, webview-tabs.js and styles.css are loaded + * into a page; PUT /api/tab-layout is answered by a route that records bodies. + * + * Port: none (page.route on a fake origin, no server). + */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { chromium, type Browser, type Page } from 'playwright'; + +const publicDir = resolve(import.meta.dirname, '../src/web/public'); +const read = (name: string) => readFileSync(resolve(publicDir, name), 'utf8'); + +const LAYOUT = { + version: 4, + updatedAt: '2026-10-01T00:00:00.000Z', + groups: [ + { + id: 'gx', + name: 'Core', + refs: [ + { kind: 'session', id: 'two' }, + { kind: 'webview', id: 'web' }, + ], + }, + { id: 'gy', name: 'Later', refs: [{ kind: 'session', id: 'three' }] }, + ], + ungrouped: [{ kind: 'session', id: 'one' }], +}; + +describe('grouped rail editing in Chromium', () => { + let browser: Browser; + let page: Page; + let puts: any[] = []; + + beforeAll(async () => { + browser = await chromium.launch({ headless: true }); + page = await browser.newPage({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 }); + await page.route('http://codeman.test/', (route) => + route.fulfill({ contentType: 'text/html', body: '' }) + ); + await page.route('http://codeman.test/api/tab-layout', async (route) => { + const request = route.request(); + if (request.method() !== 'PUT') return route.fulfill({ status: 404, body: '' }); + const body = JSON.parse(request.postData() || '{}'); + puts.push(body); + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ success: true, data: { layout: { ...body.layout, version: body.baseVersion + 1 } } }), + }); + }); + await page.goto('http://codeman.test/'); + await page.setContent(` + + + +
+ + +
+ + `); + await page.addScriptTag({ + content: + 'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' + + 'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' + + read('constants.js') + + '\n' + + read('tab-layout-browser.js') + + '\n' + + read('app.js') + + '\nwindow.CodemanApp = CodemanApp; window.__setApp = (value) => { app = value; };', + }); + await page.addScriptTag({ content: read('tab-rail-resize.js') }); + await page.addScriptTag({ content: read('api-client.js') }); + await page.addScriptTag({ content: read('webview-tabs.js') }); + await page.evaluate(() => { + const w = window as any; + const app = Object.create(w.CodemanApp.prototype); + app.$ = (id: string) => document.getElementById(id); + app.sessions = new Map([ + ['one', { id: 'one', name: 'One', status: 'idle' }], + ['two', { id: 'two', name: 'Two', status: 'idle' }], + ['three', { id: 'three', name: 'Three', status: 'idle' }], + ]); + app.sessionOrder = ['one', 'two', 'three']; + app.webviews = new Map([['web', { id: 'web', name: 'Web', url: 'https://example.test', icon: 'W' }]]); + app.webviewOrder = ['web']; + app.collapsedTabGroupIds = new Set(); + app._hiddenTabGroupByRef = new Map(); + app._inlineRenameActive = false; + app.tabAlerts = new Map(); + app.terminalLoadStates = new Map(); + app.minimizedSubagents = new Map(); + app.hasTabDetachOverride = () => false; + app.renderSubagentTabBadge = () => ''; + app.cancelHideSubagentDropdown = () => undefined; + app.updateTabOverflowMode = () => undefined; + app.updateConnectionLines = () => undefined; + app._applyTabEntrances = () => undefined; + app._scrollActiveTabIntoView = () => undefined; + app.applySidebarFilter = () => undefined; + app.isSessionSidebarActive = () => false; + app._startSidebarRichClock = () => undefined; + app._stopSidebarRichClock = () => undefined; + app.loadAppSettingsFromStorage = () => ({}); + app.showToast = () => undefined; + app.closeAllPanels = () => { + w.__panelsClosed = true; + }; + app.selectSession = (id: string) => { + w.__activation = `session:${id}`; + }; + app.openWebview = (id: string) => { + w.__activation = `webview:${id}`; + }; + w.__setApp(app); + w.__app = app; + }); + }); + + afterAll(async () => browser.close()); + + beforeEach(async () => { + puts = []; + await page.evaluate((layout) => { + const w = window as any; + document.getElementById('sessionTabs')?.remove(); + document + .getElementById('tabRail')! + .insertAdjacentHTML( + 'afterbegin', + '
' + ); + w.__app._tabKeydownHandler = null; + w.__app._tabLayoutEditor?.dispose(); + w.__app._tabLayoutEditor = null; + w.__app.tabLayout = null; + w.__app.activeSessionId = 'one'; + w.__app.activeWebviewId = null; + // The rail was just replaced: force the rebuild a skipped no-op would miss. + w.__app._lastTabGroupStructureKey = null; + w.__app._applyTabLayout(layout); + w.__activation = null; + w.__panelsClosed = false; + }, LAYOUT); + await page.mouse.move(1, 1); + }); + + const box = async (selector: string) => (await page.locator(selector).boundingBox())!; + async function drag(from: string, to: string, yFraction = 0.5) { + const a = await box(from); + const b = await box(to); + await page.mouse.move(a.x + 20, a.y + a.height / 2); + await page.mouse.down(); + await page.mouse.move(a.x + 24, a.y + a.height / 2 + 8, { steps: 3 }); + await page.mouse.move(b.x + 30, b.y + b.height * yFraction, { steps: 6 }); + await page.mouse.up(); + } + const settled = async () => { + await page.waitForTimeout(80); + await page.evaluate(() => new Promise((r) => setTimeout(r, 20))); + }; + + it('drags a row from Ungrouped onto a group header: one PUT appending it there', async () => { + await drag('.session-tab[data-id="one"]', '[data-tab-group-header="gy"]'); + await settled(); + expect(puts).toHaveLength(1); + expect(puts[0].baseVersion).toBe(4); + expect(puts[0].layout.groups[1].refs).toEqual([ + { kind: 'session', id: 'three' }, + { kind: 'session', id: 'one' }, + ]); + expect(puts[0].layout.ungrouped).toEqual([]); + // The click that ends a drag neither selected the row nor toggled the header. + expect(await page.evaluate(() => (window as any).__activation)).toBeNull(); + expect(await page.evaluate(() => (window as any).__app.collapsedTabGroupIds.size)).toBe(0); + expect(await page.locator('.tab-layout-dragging, .tab-layout-drop-into').count()).toBe(0); + }); + + it('drags a row between two rows of another group (upper half = before)', async () => { + await drag('.session-tab[data-id="three"]', '.session-tab[data-webview-id="web"]', 0.25); + await settled(); + expect(puts).toHaveLength(1); + expect(puts[0].layout.groups[0].refs.map((r: any) => r.id)).toEqual(['two', 'three', 'web']); + expect(puts[0].layout.groups[1].refs).toEqual([]); + }); + + it('drags a group header above another group: a reorder', async () => { + await drag('[data-tab-group-header="gy"]', '[data-tab-group-header="gx"]'); + await settled(); + expect(puts).toHaveLength(1); + expect(puts[0].layout.groups.map((g: any) => g.id)).toEqual(['gy', 'gx']); + expect(await page.locator('.tab-layout-group').first().getAttribute('data-tab-group-id')).toBe('gy'); + }); + + it('Escape mid-drag cancels it without a write; a plain click still selects', async () => { + const a = await box('.session-tab[data-id="one"]'); + const b = await box('[data-tab-group-header="gy"]'); + await page.mouse.move(a.x + 20, a.y + a.height / 2); + await page.mouse.down(); + await page.mouse.move(b.x + 30, b.y + b.height / 2, { steps: 6 }); + expect(await page.locator('.tab-layout-drop-into').count()).toBe(1); + await page.keyboard.press('Escape'); + await page.mouse.up(); + await settled(); + expect(puts).toHaveLength(0); + + await page.locator('.session-tab[data-id="two"] .tab-name').click(); + expect(await page.evaluate(() => (window as any).__activation)).toBe('session:two'); + }); + + it('paints the inline group editor as you type, then saves the trimmed name', async () => { + await page.evaluate(() => (window as any).__app.startTabGroupRename('gx')); + const input = page.locator('.tab-layout-group-rename-input'); + await expect.poll(() => input.evaluate((el) => el === document.activeElement)).toBe(true); + await page.keyboard.press('Control+A'); + await page.keyboard.type('Front end'); + const paint = await input.evaluate((el: HTMLInputElement) => ({ + value: el.value, + width: el.getBoundingClientRect().width, + label: getComputedStyle(el.parentElement!).display, + scrollWidth: el.scrollWidth, + })); + expect(paint.value).toBe('Front end'); + // The editor gets the header's free width, not a collapsed ellipsis slot. + expect(paint.width).toBeGreaterThan(120); + expect(paint.label).toBe('flex'); + await page.keyboard.press('Enter'); + await settled(); + expect(puts.at(-1).layout.groups[0].name).toBe('Front end'); + expect(await page.locator('[data-tab-group-header="gx"] .tab-layout-group-name').textContent()).toBe('Front end'); + expect(await page.evaluate(() => (document.activeElement as HTMLElement)?.dataset?.tabGroupHeader)).toBe('gx'); + }); + + it('opens the group menu from the header glyph and Escape closes only it', async () => { + await page.locator('[data-tab-group-header="gy"]').hover(); + await page.locator('[data-tab-group-header="gy"] .tab-layout-group-menu').click(); + expect(await page.locator('.tab-layout-group-action-menu').count()).toBe(1); + // The glyph opened the menu without toggling the group. + expect(await page.evaluate(() => (window as any).__app.collapsedTabGroupIds.size)).toBe(0); + await page.keyboard.press('Escape'); + expect(await page.locator('.tab-layout-group-action-menu').count()).toBe(0); + expect(await page.evaluate(() => (document.activeElement as HTMLElement)?.dataset?.tabGroupHeader)).toBe('gy'); + + await page.locator('[data-tab-group-header="gy"]').click({ button: 'right' }); + expect(await page.locator('.tab-layout-group-action-menu').count()).toBe(1); + await page.locator('#elsewhere').click(); + expect(await page.locator('.tab-layout-group-action-menu').count()).toBe(0); + }); +}); diff --git a/test/tab-layout-editing.test.ts b/test/tab-layout-editing.test.ts new file mode 100644 index 000000000..b612709e6 --- /dev/null +++ b/test/tab-layout-editing.test.ts @@ -0,0 +1,867 @@ +/** + * @fileoverview Editing the grouped vertical rail from the browser. + * + * Two halves: + * - the pure operation mirror + edit coordinator in tab-layout-browser.js + * (vm-loaded): each named operation, the PUT payload and version, ONE write + * in flight at a time, a 409 rebased onto the server's layout, and the + * drag-drop -> operation mapping; + * - the app.js wiring driven through the shipping CodemanApp inside JSDOM: + * row and group menus, inline group rename, menu dismissal, SSE deferral + * while a write is in flight, recovery of unsaved edits across a reload, and + * the flat rail staying byte-identical when no group exists. + * + * Port: none. + */ + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const PUBLIC = join(process.cwd(), 'src/web/public'); +const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8'); + +type Ref = { kind: 'session' | 'webview'; id: string; placement?: 'manual' }; +type Layout = { + version: number; + updatedAt: string; + groups: Array<{ id: string; name: string; refs: Ref[] }>; + ungrouped: Ref[]; +}; + +function loadHelper() { + const context = vm.createContext({ window: {}, globalThis: {}, setTimeout, clearTimeout }); + vm.runInContext(read('tab-layout-browser.js'), context, { filename: 'tab-layout-browser.js' }); + return (context.window as any).CodemanTabLayout; +} + +const s = (id: string): Ref => ({ kind: 'session', id }); +const w = (id: string): Ref => ({ kind: 'webview', id }); +const base = (version = 5): Layout => ({ + version, + updatedAt: '2026-10-01T00:00:00.000Z', + groups: [ + { id: 'g1', name: 'Core', refs: [s('a'), s('b')] }, + { id: 'g2', name: 'Ops', refs: [w('web')] }, + ], + ungrouped: [s('c'), s('d')], +}); +const plain = (value: unknown) => JSON.parse(JSON.stringify(value)); +const keys = (refs: Ref[]) => refs.map((ref) => `${ref.kind}:${ref.id}`); + +describe('operations', () => { + const h = loadHelper(); + + it('creates, renames, reorders and deletes groups without mutating the input', () => { + const input = base(); + let next = h.applyOperation(input, { type: 'createGroup', id: 'g3', name: ' New ', index: 1 }); + expect(next.groups.map((g: any) => [g.id, g.name])).toEqual([ + ['g1', 'Core'], + ['g3', 'New'], + ['g2', 'Ops'], + ]); + // Replaying a create (recovery after reload) is a no-op, not a duplicate. + expect(plain(h.applyOperation(next, { type: 'createGroup', id: 'g3', name: 'New' }))).toEqual(plain(next)); + next = h.applyOperation(next, { type: 'renameGroup', groupId: 'g3', name: 'Renamed' }); + expect(next.groups[1].name).toBe('Renamed'); + next = h.applyOperation(next, { type: 'reorderGroup', groupId: 'g1', index: 2 }); + expect(next.groups.map((g: any) => g.id)).toEqual(['g3', 'g2', 'g1']); + next = h.applyOperation(next, { type: 'deleteGroup', groupId: 'g1' }); + expect(next.groups.map((g: any) => g.id)).toEqual(['g3', 'g2']); + // Its rows return to Ungrouped, in order, after what was already there. + expect(keys(next.ungrouped)).toEqual(['session:c', 'session:d', 'session:a', 'session:b']); + expect(plain(input)).toEqual(plain(base())); + }); + + it('rejects an operation the server would refuse, before any PUT', () => { + expect(() => h.applyOperation(base(), { type: 'createGroup', id: 'x', name: ' ' })).toThrow(); + expect(() => h.applyOperation(base(), { type: 'createGroup', id: 'x', name: 'n'.repeat(61) })).toThrow(); + expect(() => h.applyOperation(base(), { type: 'renameGroup', groupId: 'nope', name: 'x' })).toThrow(); + expect(() => h.applyOperation(base(), { type: 'moveRef', ref: s('zz'), groupId: null, index: 0 })).toThrow(); + expect(() => h.applyOperation(base(), { type: 'moveRef', ref: s('a'), groupId: 'nope', index: 0 })).toThrow(); + const full = { ...base(), groups: Array.from({ length: 32 }, (_, i) => ({ id: `g${i}`, name: 'x', refs: [] })) }; + expect(() => h.applyOperation(full, { type: 'createGroup', id: 'one-more', name: 'x' })).toThrow(/limit/); + }); + + it('moves a row with the sessions that follow it, and marks a hand-moved child manual', () => { + const layout = { ...base(), ungrouped: [s('c'), s('child'), s('d')] }; + const parents = { child: 'c' }; + const moved = h.applyOperation(layout, { type: 'moveRef', ref: s('c'), groupId: 'g1', index: 1, parents }); + expect(keys(moved.groups[0].refs)).toEqual(['session:a', 'session:c', 'session:child', 'session:b']); + expect(keys(moved.ungrouped)).toEqual(['session:d']); + const child = h.applyOperation(layout, { type: 'moveRef', ref: s('child'), groupId: 'g2', index: 0, parents }); + expect(child.groups[1].refs[0]).toEqual({ kind: 'session', id: 'child', placement: 'manual' }); + // ...and manual placement survives normalization (it is written back whole). + expect(h.normalizeLayout(child).groups[1].refs[0].placement).toBe('manual'); + }); +}); + +describe('drop -> operation', () => { + const h = loadHelper(); + + it('maps a row dropped before/after a row, onto a header, and onto Ungrouped', () => { + expect( + h.dropOperation( + base(), + { type: 'ref', ref: s('c') }, + { type: 'ref', ref: s('b'), groupId: 'g1', placement: 'before' }, + {} + ) + ).toEqual({ + type: 'moveRef', + ref: s('c'), + groupId: 'g1', + index: 1, + parents: {}, + }); + expect( + h.dropOperation( + base(), + { type: 'ref', ref: s('c') }, + { type: 'ref', ref: s('b'), groupId: 'g1', placement: 'after' }, + {} + ).index + ).toBe(2); + // Within the same container the index counts AFTER the moved row is taken out. + expect( + h.dropOperation( + base(), + { type: 'ref', ref: s('a') }, + { type: 'ref', ref: s('b'), groupId: 'g1', placement: 'after' }, + {} + ) + ).toMatchObject({ + groupId: 'g1', + index: 1, + }); + expect(h.dropOperation(base(), { type: 'ref', ref: w('web') }, { type: 'group', groupId: 'g1' }, {})).toMatchObject( + { + ref: w('web'), + groupId: 'g1', + index: 2, + } + ); + expect(h.dropOperation(base(), { type: 'ref', ref: s('a') }, { type: 'ungrouped' }, {})).toMatchObject({ + groupId: null, + index: 2, + }); + }); + + it('maps a group dropped on another group (or a row in it) to a reorder', () => { + expect(h.dropOperation(base(), { type: 'group', groupId: 'g2' }, { type: 'group', groupId: 'g1' }, {})).toEqual({ + type: 'reorderGroup', + groupId: 'g2', + index: 0, + }); + expect( + h.dropOperation( + base(), + { type: 'group', groupId: 'g1' }, + { type: 'ref', ref: w('web'), groupId: 'g2', placement: 'before' }, + {} + ) + ).toEqual({ + type: 'reorderGroup', + groupId: 'g1', + index: 1, + }); + expect(h.dropOperation(base(), { type: 'group', groupId: 'g1' }, { type: 'ungrouped' }, {})).toMatchObject({ + index: 1, + }); + }); + + it('returns null for a drop that changes nothing', () => { + expect( + h.dropOperation( + base(), + { type: 'ref', ref: s('a') }, + { type: 'ref', ref: s('a'), groupId: 'g1', placement: 'after' }, + {} + ) + ).toBeNull(); + expect( + h.dropOperation( + base(), + { type: 'ref', ref: s('a') }, + { type: 'ref', ref: s('b'), groupId: 'g1', placement: 'before' }, + {} + ) + ).toBeNull(); + expect(h.dropOperation(base(), { type: 'ref', ref: s('a') }, { type: 'group', groupId: 'g1' }, {})).toBeNull(); + expect(h.dropOperation(base(), { type: 'ref', ref: s('c') }, { type: 'ungrouped' }, {})).toBeNull(); + expect(h.dropOperation(base(), { type: 'group', groupId: 'g1' }, { type: 'group', groupId: 'g1' }, {})).toBeNull(); + // Onto its own following child: the child moves with it, so there is no slot. + const layout = { ...base(), ungrouped: [s('c'), s('child')] }; + expect( + h.dropOperation( + layout, + { type: 'ref', ref: s('c') }, + { type: 'ref', ref: s('child'), groupId: null, placement: 'after' }, + { child: 'c' } + ) + ).toBeNull(); + }); +}); + +describe('edit coordinator', () => { + const h = loadHelper(); + + /** A put() whose responses the test resolves by hand, in order. */ + function controlledPut() { + const calls: Array<{ request: any; resolve: (value: any) => void }> = []; + const put = vi.fn( + (request: any) => + new Promise((resolve) => { + calls.push({ request: plain(request), resolve }); + }) + ); + return { put, calls }; + } + const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + + function makeEditor(put: any, extra: Record = {}) { + const applied: any[] = []; + const editor = h.createEditCoordinator({ + initialLayout: base(), + put, + applyLayout: (layout: any, meta: any) => applied.push({ layout: plain(layout), meta }), + schedule: (fn: () => void) => setTimeout(fn, 0), + cancel: (handle: any) => clearTimeout(handle), + ...extra, + }); + return { editor, applied }; + } + + it('applies at once and PUTs exactly { baseVersion, layout } with the held version', async () => { + const { put, calls } = controlledPut(); + const { editor, applied } = makeEditor(put); + editor.enqueue({ type: 'renameGroup', groupId: 'g1', name: 'Front' }); + expect(applied.at(-1).layout.groups[0].name).toBe('Front'); + expect(applied.at(-1).meta).toEqual({ optimistic: true }); + await settle(); + expect(calls).toHaveLength(1); + expect(Object.keys(calls[0].request).sort()).toEqual(['baseVersion', 'layout']); + expect(calls[0].request.baseVersion).toBe(5); + expect(calls[0].request.layout.groups[0].name).toBe('Front'); + calls[0].resolve({ + ok: true, + status: 200, + layout: { ...base(6), groups: [{ ...base().groups[0], name: 'Front' }, base().groups[1]] }, + }); + await settle(); + expect(editor.hasPending()).toBe(false); + expect(editor.baseVersion()).toBe(6); + }); + + it('batches edits made in one turn into a single PUT', async () => { + const { put, calls } = controlledPut(); + const { editor } = makeEditor(put); + editor.enqueue({ type: 'createGroup', id: 'g3', name: 'Three' }); + editor.enqueue({ type: 'moveRef', ref: s('c'), groupId: 'g3', index: 0 }); + await settle(); + expect(calls).toHaveLength(1); + expect(keys(calls[0].request.layout.groups[2].refs)).toEqual(['session:c']); + }); + + it('keeps ONE write in flight and sends later edits on the version it returns', async () => { + const { put, calls } = controlledPut(); + const { editor } = makeEditor(put); + editor.enqueue({ type: 'renameGroup', groupId: 'g1', name: 'First' }); + await settle(); + editor.enqueue({ type: 'renameGroup', groupId: 'g2', name: 'Second' }); + await settle(); + await settle(); + // The second edit waits: no concurrent PUT racing the first. + expect(calls).toHaveLength(1); + const confirmed = plain(calls[0].request.layout); + calls[0].resolve({ ok: true, status: 200, layout: { ...confirmed, version: 6 } }); + await settle(); + await settle(); + expect(calls).toHaveLength(2); + expect(calls[1].request.baseVersion).toBe(6); + expect(calls[1].request.layout.groups.map((g: any) => g.name)).toEqual(['First', 'Second']); + calls[1].resolve({ ok: true, status: 200, layout: { ...plain(calls[1].request.layout), version: 7 } }); + await settle(); + expect(editor.hasPending()).toBe(false); + }); + + it('rebases a 409 onto the server layout, keeping the concurrent edit, and retries once', async () => { + const { put, calls } = controlledPut(); + const { editor, applied } = makeEditor(put); + editor.enqueue({ type: 'moveRef', ref: s('c'), groupId: 'g1', index: 2 }); + await settle(); + expect(calls[0].request.baseVersion).toBe(5); + // Elsewhere, someone created a group and renamed Ops (version 9). + const server: Layout = { + ...base(9), + groups: [ + ...base().groups.map((g) => (g.id === 'g2' ? { ...g, name: 'Ops!' } : g)), + { id: 'gx', name: 'Theirs', refs: [] }, + ], + }; + calls[0].resolve({ ok: false, status: 409, layout: server }); + await settle(); + expect(calls).toHaveLength(2); + expect(calls[1].request.baseVersion).toBe(9); + const retried = calls[1].request.layout; + expect(retried.groups.map((g: any) => g.name)).toEqual(['Core', 'Ops!', 'Theirs']); + expect(keys(retried.groups[0].refs)).toEqual(['session:a', 'session:b', 'session:c']); + calls[1].resolve({ ok: true, status: 200, layout: { ...retried, version: 10 } }); + await settle(); + expect(applied.at(-1).meta).toEqual({ authoritative: true }); + expect(applied.at(-1).layout.version).toBe(10); + expect(editor.hasPending()).toBe(false); + }); + + it('drops (and reports) an edit the conflicting layout no longer supports', async () => { + const { put, calls } = controlledPut(); + const reportError = vi.fn(); + const { editor, applied } = makeEditor(put, { reportError }); + editor.enqueue({ type: 'renameGroup', groupId: 'g2', name: 'Mine' }); + await settle(); + const server: Layout = { ...base(9), groups: [base().groups[0]], ungrouped: [...base().ungrouped, w('web')] }; + calls[0].resolve({ ok: false, status: 409, layout: server }); + await settle(); + expect(calls).toHaveLength(1); + expect(reportError).toHaveBeenCalledTimes(1); + expect(applied.at(-1).layout.groups.map((g: any) => g.id)).toEqual(['g1']); + expect(editor.hasPending()).toBe(false); + }); + + it('gives up after bounded conflicts and asks the caller to re-read', async () => { + const { put, calls } = controlledPut(); + const onFailure = vi.fn(); + const reportError = vi.fn(); + const { editor } = makeEditor(put, { onFailure, reportError, maxAttempts: 2 }); + editor.enqueue({ type: 'renameGroup', groupId: 'g1', name: 'Mine' }); + await settle(); + calls[0].resolve({ ok: false, status: 409, layout: base(7) }); + await settle(); + calls[1].resolve({ ok: false, status: 409, layout: base(8) }); + await settle(); + expect(calls).toHaveLength(2); + expect(onFailure).toHaveBeenCalledTimes(1); + expect(reportError).toHaveBeenCalledTimes(1); + expect(editor.getLayout().groups[0].name).toBe('Core'); + }); + + it('re-reads and rebases on a 400, and reports a network failure', async () => { + const { put, calls } = controlledPut(); + const fetchLayout = vi.fn(async () => base(11)); + const onFailure = vi.fn(); + const { editor } = makeEditor(put, { fetchLayout, onFailure, reportError: vi.fn() }); + editor.enqueue({ type: 'renameGroup', groupId: 'g1', name: 'Mine' }); + await settle(); + calls[0].resolve({ ok: false, status: 400, layout: null }); + await settle(); + await settle(); + expect(fetchLayout).toHaveBeenCalledTimes(1); + expect(calls[1].request.baseVersion).toBe(11); + calls[1].resolve({ ok: false, status: 0, layout: null }); + await settle(); + expect(onFailure).toHaveBeenCalledTimes(1); + }); + + it('refuses an external layout while writing, rebases pending edits onto one otherwise', async () => { + const { put, calls } = controlledPut(); + const { editor, applied } = makeEditor(put); + editor.enqueue({ type: 'renameGroup', groupId: 'g1', name: 'Mine' }); + await settle(); + expect(editor.adoptExternal(base(20))).toBe(false); + calls[0].resolve({ ok: true, status: 200, layout: { ...plain(calls[0].request.layout), version: 6 } }); + await settle(); + expect(editor.adoptExternal(base(4))).toBe(false); // older than what we hold + const external = { ...base(21), groups: [...base().groups, { id: 'gx', name: 'X', refs: [] }] }; + expect(editor.adoptExternal(external)).toBe(true); + expect(applied.at(-1).layout.groups.map((g: any) => g.id)).toEqual(['g1', 'g2', 'gx']); + }); + + it('restores recovered edits only when they still change something', async () => { + const { put, calls } = controlledPut(); + const { editor } = makeEditor(put); + expect(editor.restore([{ type: 'renameGroup', groupId: 'g1', name: 'Core' }])).toBe(false); + expect(editor.restore([{ type: 'renameGroup', groupId: 'gone', name: 'x' }])).toBe(false); + expect(editor.restore([{ type: 'renameGroup', groupId: 'g1', name: 'Again' }])).toBe(true); + await settle(); + expect(calls).toHaveLength(1); + expect(editor.pendingOperations()).toEqual([{ type: 'renameGroup', groupId: 'g1', name: 'Again' }]); + }); +}); + +// ─── app.js wiring ───────────────────────────────────────────────────── + +let CodemanApp: { prototype: Record }; +let win: any; +let document: Document; + +beforeAll(async () => { + const dom = new JSDOM('', { + url: 'https://localhost/', + runScripts: 'outside-only', + }); + if (dom.window.document.readyState !== 'complete') { + await new Promise((resolve) => dom.window.addEventListener('load', resolve)); + } + win = dom.window; + document = win.document; + win.setInterval = () => 0; + win.requestAnimationFrame = () => 0; + win.CSS = { escape: (value: string) => value }; + win.eval( + 'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' + + 'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' + + read('constants.js') + + '\n' + + read('tab-layout-browser.js') + + '\n' + + read('app.js') + + '\n' + + read('tab-rail-resize.js') + + '\n' + + read('api-client.js') + + '\n' + + read('webview-tabs.js') + + '\n;window.__EditCodemanApp = CodemanApp;' + ); + CodemanApp = win.__EditCodemanApp; +}); + +const serverLayout = (version = 8): Layout => ({ + version, + updatedAt: '2026-10-01T00:00:00.000Z', + groups: [ + { id: 'gx', name: '', refs: [s('s2'), w('w1')] }, + { id: 'gy', name: 'Later', refs: [] }, + ], + ungrouped: [s('s1'), s('s3')], +}); + +/** fetch stub: records PUT bodies, answers each with the next queued response. */ +function installFetch(responses: Array<(body: any) => { status: number; body: unknown }> = []) { + const puts: any[] = []; + win.fetch = vi.fn(async (_url: string, init: any) => { + const body = init?.body ? JSON.parse(init.body) : null; + if (init?.method === 'PUT') puts.push(body); + const next = + responses.shift() || + ((b: any) => ({ + status: 200, + body: { success: true, data: { layout: { ...b.layout, version: b.baseVersion + 1 } } }, + })); + const { status, body: payload } = next(body); + return { ok: status >= 200 && status < 300, status, json: async () => payload }; + }); + return puts; +} + +function makeApp(layout: unknown = serverLayout()) { + const app = Object.create(CodemanApp.prototype) as Record; + document.documentElement.setAttribute('data-tab-orientation', 'vertical'); + document.documentElement.dataset.tabRailSort = 'manual'; + document.body.innerHTML = '
'; + app.$ = (id: string) => document.getElementById(id); + app.sessions = new Map([ + ['s1', { id: 's1', name: 'One', status: 'idle' }], + ['s2', { id: 's2', name: 'Two', status: 'busy' }], + ['s3', { id: 's3', name: 'Three', status: 'idle' }], + ]); + app.sessionOrder = ['s1', 's2', 's3']; + app.webviews = new Map([['w1', { id: 'w1', name: 'Dashboard', url: 'https://example.test' }]]); + app.webviewOrder = ['w1']; + app.activeSessionId = 's2'; + app.activeWebviewId = null; + app.tabLayout = null; + app.collapsedTabGroupIds = new Set(); + app._hiddenTabGroupByRef = new Map(); + app._lastTabGroupStructureKey = null; + app._tabCollapseStorageFailed = false; + app._inlineRenameActive = false; + app.tabAlerts = new Map(); + app.terminalLoadStates = new Map(); + app.minimizedSubagents = new Map(); + app.hasTabDetachOverride = () => false; + app.renderSubagentTabBadge = () => ''; + app.cancelHideSubagentDropdown = () => {}; + app.updateTabOverflowMode = () => {}; + app.updateConnectionLines = () => {}; + app._applyTabEntrances = () => {}; + app._scrollActiveTabIntoView = () => {}; + app.applySidebarFilter = () => {}; + app.isSessionSidebarActive = () => false; + app._startSidebarRichClock = () => {}; + app._stopSidebarRichClock = () => {}; + app.loadAppSettingsFromStorage = () => ({}); + app.openSessionOptions = vi.fn(); + app.requestCloseSession = vi.fn(); + app.selectSession = vi.fn(); + app.showWebviewModal = vi.fn(); + app.showToast = vi.fn(); + app.closeAllPanels = vi.fn(); + // The page renders the flat rail before the first layout read lands. + app._fullRenderSessionTabs(); + if (layout) app._applyTabLayout(layout); + return app; +} + +const tabs = () => document.getElementById('sessionTabs')!; +const header = (id: string) => document.querySelector(`[data-tab-group-header="${id}"]`)!; +const row = (id: string) => document.querySelector(`.session-tab[data-id="${id}"]`)!; +const menuLabels = () => [...document.querySelectorAll('.tab-rail-action-menu button')].map((b) => b.textContent); +const clickMenu = (label: string) => + [...document.querySelectorAll('.tab-rail-action-menu button')] + .find((b) => b.textContent === label)! + .click(); +const key = (target: Element, k: string, init: Record = {}) => + target.dispatchEvent(new win.KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true, ...init })); +const flush = async () => { + for (let i = 0; i < 6; i++) await new Promise((resolve) => setTimeout(resolve, 0)); +}; + +beforeEach(() => { + win.localStorage.clear(); + win.sessionStorage.clear(); + document.body.innerHTML = ''; +}); + +afterEach(() => { + document.querySelectorAll('.tab-rail-action-menu').forEach((menu) => menu.remove()); +}); + +describe('row actions in the vertical rail', () => { + it('offers group moves (and only "new group" before any group exists), never on the strip', () => { + installFetch(); + const flat = makeApp({ ...serverLayout(), groups: [], ungrouped: [s('s1'), s('s2'), s('s3')] }); + flat.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s1') }, 's1'); + expect(menuLabels()).toEqual(['Session options', 'Move to new group', 'Close session']); + flat.closeTabRailActionMenu(); + + const app = makeApp(); + app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s2') }, 's2'); + expect(menuLabels()).toEqual([ + 'Session options', + 'Move down', + 'Move to Later', + 'Move to Ungrouped', + 'Move to new group', + 'Close session', + ]); + app.closeTabRailActionMenu(); + + document.documentElement.setAttribute('data-tab-orientation', 'horizontal'); + app._fullRenderSessionTabs(); + app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s2') }, 's2'); + expect(menuLabels()).toEqual(['Session options', 'Close session']); + }); + + it('moves a row into another group with one PUT carrying the held version', async () => { + const puts = installFetch(); + const app = makeApp(); + app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s1') }, 's1'); + clickMenu('Move to Later'); + // Optimistic: the rail already shows it there. + expect(row('s1').closest('.tab-layout-group')!.getAttribute('data-tab-group-id')).toBe('gy'); + await flush(); + expect(puts).toHaveLength(1); + expect(puts[0].baseVersion).toBe(8); + expect(puts[0].layout.groups[1].refs).toEqual([s('s1')]); + expect(keys(puts[0].layout.ungrouped)).toEqual(['session:s3']); + expect(typeof puts[0].layout.updatedAt).toBe('string'); + expect(app.tabLayout.version).toBe(9); + // Focus lands on the moved row, so a keyboard user stays in the rail. + expect(document.activeElement).toBe(row('s1')); + }); + + it('creates the first group from a flat rail and goes straight into renaming it', async () => { + const puts = installFetch(); + const app = makeApp({ ...serverLayout(), groups: [], ungrouped: [s('s1'), s('s2'), s('s3')] }); + expect(tabs().getAttribute('role')).toBe('tablist'); + app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s3') }, 's3'); + clickMenu('Move to new group'); + // The rail is now grouped, with an editor in the new header. + expect(tabs().getAttribute('role')).toBe('tree'); + const input = document.querySelector('.tab-layout-group-rename-input')!; + expect(input).not.toBeNull(); + expect(document.activeElement).toBe(input); + expect(input.value).toBe('New group'); + input.value = ' Build '; + key(input, 'Enter'); + await flush(); + // Create, move and the rename made in the same turn share ONE write. + expect(puts).toHaveLength(1); + expect(puts[0].baseVersion).toBe(8); + expect(puts[0].layout.groups).toHaveLength(1); + expect(puts[0].layout.groups[0].name).toBe('Build '); + expect(puts[0].layout.groups[0].refs).toEqual([s('s3')]); + // The name is text, never markup. + const name = header(puts[0].layout.groups[0].id).querySelector('.tab-layout-group-name')!; + expect(name.textContent).toBe('Build '); + expect(name.children).toHaveLength(0); + // A later rename goes out on the version that write returned. + app.startTabGroupRename(puts[0].layout.groups[0].id); + const again = document.querySelector('.tab-layout-group-rename-input')!; + again.value = 'Build'; + key(again, 'Enter'); + await flush(); + expect(puts).toHaveLength(2); + expect(puts[1].baseVersion).toBe(9); + expect(puts[1].layout.groups[0].name).toBe('Build'); + expect(app._inlineRenameActive).toBe(false); + }); +}); + +describe('web tab rows', () => { + it('Shift+F10 on a web tab offers its settings and the same group moves', async () => { + const puts = installFetch(); + const app = makeApp(); + const web = document.querySelector('.session-tab[data-webview-id="w1"]')!; + web.focus(); + key(web, 'F10', { shiftKey: true }); + expect(menuLabels()).toEqual([ + 'Web tab settings', + 'Move up', + 'Move to Later', + 'Move to Ungrouped', + 'Move to new group', + ]); + clickMenu('Move to Ungrouped'); + await flush(); + expect(keys(puts[0].layout.ungrouped)).toEqual(['session:s1', 'session:s3', 'webview:w1']); + expect(document.activeElement).toBe(document.querySelector('.session-tab[data-webview-id="w1"]')); + expect(app.showWebviewModal).not.toHaveBeenCalled(); + }); + + it('moves down past the sessions that follow the moved one', async () => { + const puts = installFetch(); + const app = makeApp({ + ...serverLayout(), + groups: [{ id: 'gx', name: 'G', refs: [s('s1'), s('s2'), s('s3')] }], + ungrouped: [], + }); + app.sessions.get('s2').parentSessionId = 's1'; + app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s1') }, 's1'); + clickMenu('Move down'); + await flush(); + expect(keys(puts[0].layout.groups[0].refs)).toEqual(['session:s3', 'session:s1', 'session:s2']); + }); +}); + +describe('group menu', () => { + it('opens from Shift+F10 / ContextMenu on a header and runs each group operation', async () => { + const puts = installFetch(); + const app = makeApp(); + win.confirm = vi.fn(() => true); + header('gx').focus(); + key(header('gx'), 'F10', { shiftKey: true }); + expect(menuLabels()).toEqual(['Rename group', 'New group', 'Move group down', 'Delete group']); + expect(document.activeElement?.textContent).toBe('Rename group'); + clickMenu('Move group down'); + await flush(); + expect(puts.at(-1).layout.groups.map((g: any) => g.id)).toEqual(['gy', 'gx']); + expect(document.activeElement).toBe(header('gx')); + + key(header('gx'), 'ContextMenu'); + expect(menuLabels()).toEqual(['Rename group', 'New group', 'Move group up', 'Delete group']); + clickMenu('Delete group'); + expect(win.confirm).toHaveBeenCalledWith('Delete group ""? Its tabs move to Ungrouped.'); + await flush(); + const last = puts.at(-1).layout; + expect(last.groups.map((g: any) => g.id)).toEqual(['gy']); + expect(keys(last.ungrouped)).toEqual(['session:s1', 'session:s3', 'session:s2', 'webview:w1']); + }); + + it('renames from F2 and cancels on Escape without a write', async () => { + const puts = installFetch(); + const app = makeApp(); + header('gy').focus(); + key(header('gy'), 'F2'); + const input = document.querySelector('.tab-layout-group-rename-input')!; + input.value = 'Changed'; + key(input, 'Escape'); + await flush(); + expect(puts).toHaveLength(0); + expect(header('gy').querySelector('.tab-layout-group-name')!.textContent).toBe('Later'); + expect(document.activeElement).toBe(header('gy')); + expect(app._inlineRenameActive).toBe(false); + }); + + it('ignores IME composition keys and blocks re-renders while editing', async () => { + const puts = installFetch(); + const app = makeApp(); + app.startTabGroupRename('gy'); + const input = document.querySelector('.tab-layout-group-rename-input')!; + input.value = '组'; + key(input, 'Enter', { isComposing: true }); + expect(document.querySelector('.tab-layout-group-rename-input')).toBe(input); + app._fullRenderSessionTabs(); // a background render must not destroy the editor + expect(input.isConnected).toBe(true); + input.blur(); + await flush(); + expect(puts.at(-1).layout.groups[1].name).toBe('组'); + }); + + it('a session rename takes the editor over and a stale group editor cannot release the guard', () => { + installFetch(); + const app = makeApp(); + app.startTabGroupRename('gy'); + const handle = app._activeRename; + expect(handle.groupId).toBe('gy'); + const other = { cancel: vi.fn() }; + app._activeRename = other; // someone newer owns the guard + handle.cancel(); + expect(app._inlineRenameActive).toBe(true); + }); + + describe('dismissal', () => { + const open = (app: Record) => { + header('gx').focus(); + key(header('gx'), 'F10', { shiftKey: true }); + expect(document.querySelector('.tab-layout-group-action-menu')).not.toBeNull(); + }; + const isOpen = () => document.querySelector('.tab-layout-group-action-menu') !== null; + + it('Escape closes only the menu and returns focus to its header', () => { + installFetch(); + const app = makeApp(); + open(app); + const escape = new win.KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }); + document.activeElement!.dispatchEvent(escape); + expect(isOpen()).toBe(false); + expect(document.activeElement).toBe(header('gx')); + expect(app._tabGroupMenuKeydown).toBeNull(); + }); + + it('a pointer outside, Tab, focus leaving, a resize, a second open and a re-render all close it', () => { + installFetch(); + const app = makeApp(); + open(app); + document.body.dispatchEvent(new win.Event('pointerdown', { bubbles: true })); + expect(isOpen()).toBe(false); + + open(app); + // A pointer INSIDE the menu does not close it. + document + .querySelector('.tab-layout-group-action-menu button')! + .dispatchEvent(new win.Event('pointerdown', { bubbles: true })); + expect(isOpen()).toBe(true); + key(document.activeElement!, 'Tab'); + expect(isOpen()).toBe(false); + expect(document.activeElement).toBe(header('gx')); + + open(app); + const buttons = document.querySelectorAll('.tab-layout-group-action-menu button'); + buttons[0].dispatchEvent(new win.FocusEvent('focusout', { bubbles: true, relatedTarget: buttons[1] })); + expect(isOpen()).toBe(true); + buttons[0].dispatchEvent(new win.FocusEvent('focusout', { bubbles: true, relatedTarget: document.body })); + expect(isOpen()).toBe(false); + + open(app); + win.dispatchEvent(new win.Event('resize')); + expect(isOpen()).toBe(false); + + open(app); + app.openTabGroupMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: header('gx') }, 'gx'); + expect(isOpen()).toBe(false); + + open(app); + app._fullRenderSessionTabs(); + expect(isOpen()).toBe(false); + // No listener is left behind by any of the paths. + expect(app._tabGroupMenuOutside).toBeNull(); + }); + }); +}); + +describe('server echoes and reloads', () => { + it('defers an SSE-triggered read while its own write is in flight, then re-reads once', async () => { + let releasePut: (() => void) | null = null; + const gets: number[] = []; + const puts: any[] = []; + win.fetch = vi.fn(async (_url: string, init: any) => { + if (init?.method === 'PUT') { + const body = JSON.parse(init.body); + puts.push(body); + await new Promise((resolve) => (releasePut = resolve)); + return { + ok: true, + status: 200, + json: async () => ({ success: true, data: { layout: { ...body.layout, version: 9 } } }), + }; + } + gets.push(1); + return { + ok: true, + status: 200, + json: async () => ({ success: true, data: { layout: { ...serverLayout(9), groups: puts[0].layout.groups } } }), + }; + }); + const app = makeApp(); + app.editTabLayout({ type: 'renameGroup', groupId: 'gy', name: 'Mine' }); + await flush(); + expect(puts).toHaveLength(1); + app._onTabLayoutChanged({ owner: '@single', version: 9 }); + await flush(); + expect(gets).toHaveLength(0); + releasePut!(); + await flush(); + expect(app.tabLayout.groups[1].name).toBe('Mine'); + expect(gets).toHaveLength(1); + }); + + it('keeps unsaved edits across a reload: keepalive PUT now, rebased replay after', async () => { + const puts = installFetch(); + const app = makeApp(); + app.editTabLayout({ type: 'renameGroup', groupId: 'gy', name: 'Unsaved' }); + // Page goes away before the flush ran (dispose stands in for the unload). + app._persistPendingTabLayoutEdits(); + app._tabLayoutEditor.dispose(); + expect(win.fetch).toHaveBeenCalledWith( + '/api/tab-layout', + expect.objectContaining({ method: 'PUT', keepalive: true }) + ); + expect(puts.at(-1).baseVersion).toBe(8); + expect(puts.at(-1).layout.groups[1].name).toBe('Unsaved'); + expect(JSON.parse(win.sessionStorage.getItem('codeman:tab-layout-pending')).operations).toEqual([ + { type: 'renameGroup', groupId: 'gy', name: 'Unsaved' }, + ]); + + // Next page: the keepalive lost a race; the layout moved on to version 12. + const next = installFetch(); + const reloaded = makeApp({ + ...serverLayout(12), + groups: [...serverLayout().groups, { id: 'gz', name: 'Z', refs: [] }], + }); + expect(reloaded.tabLayout.groups[1].name).toBe('Unsaved'); + await flush(); + expect(next).toHaveLength(1); + expect(next[0].baseVersion).toBe(12); + expect(next[0].layout.groups.map((g: any) => g.name)).toEqual(['', 'Unsaved', 'Z']); + expect(win.sessionStorage.getItem('codeman:tab-layout-pending')).toBeNull(); + + // And when the keepalive DID land, nothing is re-sent. + win.sessionStorage.setItem( + 'codeman:tab-layout-pending', + JSON.stringify({ operations: [{ type: 'renameGroup', groupId: 'gy', name: 'Later' }] }) + ); + const none = installFetch(); + makeApp(); + await flush(); + expect(none).toHaveLength(0); + }); + + it('leaves the flat rail byte-identical when the layout has no groups, and never edits off the rail', () => { + installFetch(); + const noLayout = makeApp(null); + noLayout._fullRenderSessionTabs(); + const flat = tabs().innerHTML; + makeApp({ ...serverLayout(), groups: [], ungrouped: [s('s1'), s('s2'), s('s3')] }); + expect(tabs().innerHTML).toBe(flat); + expect(tabs().getAttribute('role')).toBe('tablist'); + document.documentElement.setAttribute('data-tab-orientation', 'horizontal'); + const strip = makeApp(); + document.documentElement.setAttribute('data-tab-orientation', 'horizontal'); + expect(strip.editTabLayout({ type: 'renameGroup', groupId: 'gy', name: 'x' })).toBe(false); + expect(win.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/test/tab-layout-rail.test.ts b/test/tab-layout-rail.test.ts index 86d2ffc0d..f43cf229d 100644 --- a/test/tab-layout-rail.test.ts +++ b/test/tab-layout-rail.test.ts @@ -727,10 +727,17 @@ describe('grouped rail tree semantics', () => { row('s2').focus(); press('F10', { shiftKey: true }); expect(app.openTabRailActionMenu).toHaveBeenCalledWith(expect.objectContaining({ currentTarget: row('s2') }), 's2'); + // A web tab's keys open its menu (settings + group moves); settings is one item. row('w1').focus(); press('ContextMenu'); + const settings = [...document.querySelectorAll('.tab-layout-group-action-menu button')].find( + (button) => button.textContent === 'Web tab settings' + )!; + settings.click(); expect(app.showWebviewModal).toHaveBeenCalledWith('w1'); + row('w1').focus(); press('F10'); + expect(document.querySelector('.tab-layout-group-action-menu')).toBeNull(); expect(app.showWebviewModal).toHaveBeenCalledTimes(1); }); From bd4a1e98869898c25b7bcad56ac26156ff5d497f Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sun, 4 Oct 2026 20:21:44 -0400 Subject: [PATCH 2/7] fix(tabs): grouped rail editing review fixes - Pointer drag: a press released outside the rail no longer lingers. The release is heard on window while a press is pending, a move with the primary button up cancels it, a new press cancels any previous drag, and an existing Escape listener is removed before another is added, so no orphaned capture listener can swallow Escape before the terminal. - Inline group rename: a commit by blur leaves focus where the user put it; Enter and Escape still return focus to the header. - A failed layout read while edits are pending keeps the held layout and the editor and re-reads once the write settles, so a 409 is still rebased. Dropping unsaved work now always says so in a toast. - "Move to " quotes the group name (with a matching zh-CN pattern), so a group named "New group" or "ungrouped" no longer reads or translates like the fixed entries. - The group menu glyph stays visible under (hover: none). - The sessionStorage replay copy carries { owner, baseVersion, savedAt } and is ignored for another owner, after 60 s, or against an older layout. A move with no anchor carries no index, so a replay keeps the row last. - A 400 that survives the re-read is reported as "Could not save tab groups." - closeTabRailActionMenu() no longer removes the group menu's DOM. - Cancelling "Delete group" returns focus to the header. - Stale comments updated. --- docs/architecture-invariants.md | 6 +- src/web/public/app.js | 121 +++++++++++-- src/web/public/i18n.js | 3 +- src/web/public/styles.css | 8 + src/web/public/tab-layout-browser.js | 14 +- src/web/public/tab-rail-resize.js | 4 +- test/i18n-branding.test.ts | 12 ++ test/tab-layout-editing.browser.test.ts | 105 +++++++++++ test/tab-layout-editing.test.ts | 223 ++++++++++++++++++++++-- 9 files changed, 452 insertions(+), 44 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 472c868ad..7fe8e59c1 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -303,9 +303,9 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t - **Collapse is per-device** (`codeman:tab-groups-collapsed` in localStorage, ids of deleted groups garbage-collected on adoption). A store that throws means all-expanded; a malformed stored VALUE reads as empty and is rewritten, so it can never disable collapse on that device for good. A collapsed group still SHOWS the active row, and `_updateActiveTabImmediate` falls through to a full render whenever the structure key changes, since a class toggle cannot reveal a hidden row. - **A collapsed header carries the most urgent alert it hides** (`hiddenGroupAlerts()`, applied by `_syncTabGroupHeaderAlerts` on BOTH render paths, since alerts change without a rebuild), in the tab alert language: `tab-alert-action` red, `tab-alert-idle` yellow. A permission prompt behind a collapse must never be invisible. - **Lineage arcs to a collapse-hidden session anchor to its group header** (`lineage-line--proxied`); two endpoints proxied to one header draw nothing. -- **The upstream HTML5 drag stays off in the grouped rail**: a flat-order drop cannot express a group move, and the server re-ranks within the old group. The grouped rail has its OWN pointer drag instead (`_bindTabLayoutPointerDrag`, mouse/pen only, bound once on the container): rows move before/after a row or into a group, a header drag reorders groups, and the drop maps to ONE operation through the pure `dropOperation()`. Escape cancels a drag, and the click that ends one is swallowed. The flat rail and the header strip keep the HTML5 drag untouched. For the same reason Ctrl+Shift+{ / } only swaps with a neighbour in the active session's own section (`_canSwapActiveTabWith`, reading the projection's `sectionByRef`): a cross-group swap moves nothing on the server, gets no `session:orderChanged` back, and would leave this client's `sessionOrder` and Alt+N targets out of step with every other device. -- **Edits are named operations, saved serially.** `createEditCoordinator` applies `createGroup` / `renameGroup` / `deleteGroup` / `reorderGroup` / `moveRef` to the rail at once, then sends ONE `PUT /api/tab-layout {baseVersion, layout}` at a time; edits made meanwhile wait and go out on the version that write returns. A 409 carries the server's layout: the in-flight operations are replayed onto it (an operation that no longer applies is dropped and reported) and re-sent, at most `maxAttempts` times; a 400 re-reads first; anything else reports and re-reads. A `moveRef` moves the session together with the sessions that still follow it and marks a hand-moved child `placement: 'manual'`, mirroring the server's `moveRef`, and `normalizeLayout` keeps `placement` because whole layouts are written back. `_onTabLayoutChanged` / `_applyTabLayout` defer a read while a write is in flight and rebase unsaved edits onto a read otherwise. On `pagehide`, unconfirmed operations go out in a `keepalive` PUT AND into sessionStorage; after reload they replay onto the fresh layout, which is a no-op when the keepalive landed. -- **Every way in has a keyboard path.** The session row menu (Shift+F10 in the tree, the rail's overflow button) gains Move up/down, Move to , Move to Ungrouped and Move to new group in the vertical rail (only "new group" before the first group exists; nothing on the strip). A group header opens its menu with Shift+F10 / ContextMenu, right-click or its hover glyph (a non-focusable, `aria-hidden` span: a treeitem holds no interactive children), and F2 renames it inline. The menu closes on Escape (which it consumes before the global Escape handler), a pointer outside, Tab, focus leaving it, a resize, a second open and any full re-render. The inline group editor shares `_activeRename` with the session rename, so only the CURRENT editor may release `_inlineRenameActive`. +- **The upstream HTML5 drag stays off in the grouped rail**: a flat-order drop cannot express a group move, and the server re-ranks within the old group. The grouped rail has its OWN pointer drag instead (`_bindTabLayoutPointerDrag`, mouse/pen only, bound once on the container): rows move before/after a row or into a group, a header drag reorders groups, and the drop maps to ONE operation through the pure `dropOperation()`. Escape cancels a drag, and the click that ends one is swallowed. ⚠️ A press is only captured once it moves 6 px, so its release can land outside the rail: `pointerup`/`pointercancel` are heard on `window` while a press is pending, a move with the primary button up cancels it, and a new press cancels any previous one. Without that a stale press became a phantom drag on the next hover, and a replaced drag left its capture-phase Escape listener behind, swallowing every Escape before the terminal saw it. The flat rail and the header strip keep the HTML5 drag untouched. For the same reason Ctrl+Shift+{ / } only swaps with a neighbour in the active session's own section (`_canSwapActiveTabWith`, reading the projection's `sectionByRef`): a cross-group swap moves nothing on the server, gets no `session:orderChanged` back, and would leave this client's `sessionOrder` and Alt+N targets out of step with every other device. +- **Edits are named operations, saved serially.** `createEditCoordinator` applies `createGroup` / `renameGroup` / `deleteGroup` / `reorderGroup` / `moveRef` to the rail at once, then sends ONE `PUT /api/tab-layout {baseVersion, layout}` at a time; edits made meanwhile wait and go out on the version that write returns. A 409 carries the server's layout: the in-flight operations are replayed onto it (an operation that no longer applies is dropped and reported) and re-sent, at most `maxAttempts` times; a 400 re-reads once (a second 400 is reported as a failed save, not as a race); anything else reports and re-reads. A `moveRef` moves the session together with the sessions that still follow it and marks a hand-moved child `placement: 'manual'`, mirroring the server's `moveRef`, and `normalizeLayout` keeps `placement` because whole layouts are written back. `_onTabLayoutChanged` / `_applyTabLayout` defer a read while a write is in flight and rebase unsaved edits onto a read otherwise. ⚠️ A FAILED read (`_applyTabLayout(null)`) while edits are pending keeps the held layout and the editor and re-reads after the write settles: disposing there would orphan the in-flight write, whose 409 then never gets its rebase. Any path that does drop unsaved work says so in a toast. On `pagehide`, unconfirmed operations go out in a `keepalive` PUT AND into sessionStorage as `{ owner, baseVersion, savedAt, operations }`; after reload they replay onto the fresh layout (a no-op when the keepalive landed), but only for the same owner, within `TAB_LAYOUT_PENDING_MAX_AGE_MS` (60 s), and never onto a layout older than the copy's base. A "Move to " with no anchor carries no `index`, so a replay still puts the row last. +- **Every way in has a keyboard path.** The session row menu (Shift+F10 in the tree, the rail's overflow button) gains Move up/down, Move to , Move to Ungrouped and Move to new group in the vertical rail (only "new group" before the first group exists; nothing on the strip). A group header opens its menu with Shift+F10 / ContextMenu, right-click or its hover glyph (a non-focusable, `aria-hidden` span: a treeitem holds no interactive children), and F2 renames it inline (Enter commits and refocuses the header; a commit by BLUR leaves focus where it went, since refocusing from inside the blur handler overrides the user's click). The glyph stays visible under `@media (hover: none)`: a touch tablet has no hover and no long-press `contextmenu`. Group names in "Move to" labels are quoted, so a group named "New group" or "ungrouped" cannot read (or translate, case-insensitively) like the fixed entries. The menu closes on Escape (which it consumes before the global Escape handler), a pointer outside, Tab, focus leaving it, a resize, a second open and any full re-render. It borrows the `.tab-rail-action-menu` class for its look only: `closeTabRailActionMenu()` excludes `.tab-layout-group-action-menu`, so closing the row menu (every `session:deleted` does) cannot strand the group menu's listeners. The inline group editor shares `_activeRename` with the session rename, so only the CURRENT editor may release `_inlineRenameActive`. - **Only the grouped rail is a tree.** `#sessionTabs` ships as `role=tablist` with `role=tab` rows, and the header strip, sidebar and flat rail keep exactly that. While grouped, `_applyTabListRole` makes it `role=tree` (and restores `tablist` + its label when grouping ends), named-group headers are level-1 `treeitem`s that `aria-owns` their rows' `role=group` (rows sit beside the header, not inside it), and ungrouped rows plus a collapsed group's kept selection are level-1 items. A collapsed header owns nothing, a group with no open rows is a leaf (no `aria-expanded`, no owned group), and the "Ungrouped" heading is `aria-hidden`. Rows are re-roled in the DOM by `_applyTabTreeSemantics` after render, never by rewriting their markup, so a grouped row's content stays the flat row's. - **One tab stop in the tree.** Exactly one treeitem carries `tabindex=0` (the focused or selected item); every control inside a row drops to `-1`, which is why Shift+F10 / ContextMenu open a row's actions from the keyboard. Focus survives a full re-render by identity (`group:`/`session:`/`webview:`; a row a collapse just hid hands focus to its header), but only when focus was already inside the rail. The tree walk (`_tabTreeItems`) follows painted order WITHIN each group when the rail is sorted; the flat list keeps its own whole-list computed-order walk. `aria-posinset`/`aria-setsize` follow painted order too, so the incremental render path re-runs `_applyTabTreePositions` after it re-sorts rows in place. ⚠️ `_handleTabTreeKeydown` acts only when the key lands on the treeitem ITSELF: a key on a focused in-row control (close, overflow, the rename input) is that control's, or Enter on the overflow button re-selects the row instead of reopening its menu. ⚠️ The roving `tabindex=-1` also hides every item but the stop from the keyboard-dismiss selector's `[tabindex]` arm, which is why `MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR` lists `[role="treeitem"]` (see Dismissing the on-screen keyboard). diff --git a/src/web/public/app.js b/src/web/public/app.js index 00fc2ec42..b2688793e 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -577,6 +577,13 @@ const SIDEBAR_RICH_CLOCK_MS = 20000; */ const URL_SESSION_WAIT_MS = 30000; +/** + * How old the sessionStorage copy of unsaved tab-group edits may be when the + * next page replays it (see _restorePendingTabLayoutEdits). A reload takes + * seconds; an older copy is from a tab that sat closed or a different visit. + */ +const TAB_LAYOUT_PENDING_MAX_AGE_MS = 60000; + class CodemanApp { constructor() { this.sessions = new Map(); @@ -6335,7 +6342,7 @@ class CodemanApp { } // ═══════════════════════════════════════════════════════════════ - // Owner tab layout: grouped vertical rail (read-only) + // Owner tab layout: grouped vertical rail (reading and drawing) // ═══════════════════════════════════════════════════════════════ // // The server owns named tab groups (GET /api/tab-layout, tab-layout*.ts) and @@ -6343,7 +6350,7 @@ class CodemanApp { // Ctrl+Tab and every other order consumer are untouched here. This layer only // decides how the VERTICAL rail draws rows: in sections, with per-device // collapse. With no groups (or any read failure) the rail is the flat list it - // has always been. + // has always been. Editing the groups is the next block. _ensureTabLayoutCoordinator() { if (this._tabLayoutCoordinator) return this._tabLayoutCoordinator; @@ -6403,6 +6410,13 @@ class CodemanApp { if (next && this.tabLayout && next.version < this.tabLayout.version) return; const editor = this._tabLayoutEditor; if (editor) { + // A failed read says nothing about the layout, and dropping the editor now + // would lose the edit outright: a write in flight would never get its 409 + // rebased. Keep the held layout and the editor; read again once it settles. + if (!next && editor.hasPending()) { + this._tabLayoutReloadPending = true; + return; + } if (next && editor.isWriting()) { // The write's own response decides; read again after it. this._tabLayoutReloadPending = true; @@ -6411,6 +6425,7 @@ class CodemanApp { // Unsaved edits are rebased onto the read (adoptExternal repaints); with // none, the editor is simply rebuilt from the new layout on next use. if (next && editor.hasPending() && editor.adoptExternal(next)) return; + if (editor.hasPending()) this.showToast?.('Your tab group edit was not saved.', 'error'); editor.dispose(); this._tabLayoutEditor = null; } @@ -6608,7 +6623,11 @@ class CodemanApp { const groups = this.tabLayout?.groups || []; const index = groups.findIndex((group) => group.id === groupId); if (index < 0) return false; - if (!window.confirm(`Delete group "${groups[index].name}"? Its tabs move to Ungrouped.`)) return false; + if (!window.confirm(`Delete group "${groups[index].name}"? Its tabs move to Ungrouped.`)) { + // The menu that asked is gone; put the keyboard back on the group. + this.$('sessionTabs')?.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`)?.focus(); + return false; + } const neighbour = groups[index + 1] || groups[index - 1]; return this.editTabLayout({ type: 'deleteGroup', groupId }, neighbour ? `group:${neighbour.id}` : null); } @@ -6667,7 +6686,10 @@ class CodemanApp { } for (const group of this.tabLayout.groups) { if (group.id === location.groupId) continue; - actions.push({ label: `Move to ${group.name}`, run: () => this.moveTabRef(ref, group.id) }); + // Quoted: a group may be NAMED "New group" or "ungrouped", which unquoted + // would read (and, case-insensitively, translate) exactly like the + // fixed "Move to new group" / "Move to Ungrouped" entries next to it. + actions.push({ label: `Move to "${group.name}"`, run: () => this.moveTabRef(ref, group.id) }); } if (location.groupId !== null) actions.push({ label: 'Move to Ungrouped', run: () => this.moveTabRef(ref, null) }); actions.push({ label: 'Move to new group', run: () => this.createTabGroup({ ref }) }); @@ -6842,7 +6864,10 @@ class CodemanApp { let settled = false; const handle = { groupId, cancel: () => settle(false) }; - const settle = (commit) => { + // `fromBlur`: focus already moved somewhere the user chose (the terminal, + // another control). Pulling it back to the header from inside the blur + // handler wins over that move, so a blur commit never asks for refocus. + const settle = (commit, { fromBlur = false } = {}) => { if (settled) return; settled = true; const name = input.value.trim(); @@ -6851,11 +6876,14 @@ class CodemanApp { this._activeRename = null; this._inlineRenameActive = false; const current = this.tabLayout?.groups?.find((candidate) => candidate.id === groupId); + const focusIdentity = fromBlur ? null : `group:${groupId}`; if (commit && current && name && name !== current.name) { - if (this.editTabLayout({ type: 'renameGroup', groupId, name }, `group:${groupId}`)) return; + if (this.editTabLayout({ type: 'renameGroup', groupId, name }, focusIdentity)) return; + } + if (focusIdentity) { + this._tabFocusIdentity = focusIdentity; + this._tabRefocusAfterEdit = true; } - this._tabFocusIdentity = `group:${groupId}`; - this._tabRefocusAfterEdit = true; this._fullRenderSessionTabs(); }; this._activeRename = handle; @@ -6870,7 +6898,7 @@ class CodemanApp { settle(false); } }); - input.addEventListener('blur', () => settle(true)); + input.addEventListener('blur', () => settle(true, { fromBlur: true })); input.focus(); input.select(); return true; @@ -6896,6 +6924,11 @@ class CodemanApp { } _onTabLayoutPointerDown(e, container) { + // A press whose release never reached us (let go outside the rail, or + // outside the window) must not survive into this one: a stale pending + // press turned into a phantom drag on the next hover, and a replaced drag + // left its Escape listener behind for good. + if (this._tabLayoutDrag) this._cancelTabLayoutPointerDrag(container); if (e.button !== 0 || e.pointerType === 'touch' || !container.classList.contains('session-tabs--grouped')) return; if (this._inlineRenameActive || !this._tabLayoutEditable()) return; // Controls keep their own click; only the row body or the header drags. @@ -6907,7 +6940,14 @@ class CodemanApp { else if (row?.dataset.webviewId) source = { type: 'ref', ref: { kind: 'webview', id: row.dataset.webviewId } }; else if (row?.dataset.id) source = { type: 'ref', ref: { kind: 'session', id: row.dataset.id } }; if (!source) return; - this._tabLayoutDrag = { pointerId: e.pointerId, x: e.clientX, y: e.clientY, source, origin: header || row, active: false, target: null }; + const drag = { pointerId: e.pointerId, x: e.clientX, y: e.clientY, source, origin: header || row, active: false, target: null }; + // Capture is only taken once the press becomes a drag, so until then the + // release can land outside the rail: hear it on window. + drag.windowUp = (upEvent) => this._finishTabLayoutPointerDrag(upEvent, container); + drag.windowCancel = () => this._cancelTabLayoutPointerDrag(container); + window.addEventListener('pointerup', drag.windowUp, true); + window.addEventListener('pointercancel', drag.windowCancel, true); + this._tabLayoutDrag = drag; } /** What a pointer at (x, y) would drop onto, from the rendered rail. */ @@ -6938,6 +6978,11 @@ class CodemanApp { _onTabLayoutPointerMove(e, container) { const drag = this._tabLayoutDrag; if (!drag || drag.pointerId !== e.pointerId) return; + // The primary button is up, so the release went somewhere we never heard. + if ((e.buttons & 1) === 0) { + this._cancelTabLayoutPointerDrag(container); + return; + } if (!drag.active) { if (Math.hypot(e.clientX - drag.x, e.clientY - drag.y) < 6) return; drag.active = true; @@ -6948,6 +6993,7 @@ class CodemanApp { try { container.setPointerCapture(e.pointerId); } catch {} + if (this._tabLayoutDragKeydown) document.removeEventListener('keydown', this._tabLayoutDragKeydown, true); this._tabLayoutDragKeydown = (keyEvent) => { if (keyEvent.key !== 'Escape') return; keyEvent.preventDefault(); @@ -6969,6 +7015,8 @@ class CodemanApp { const drag = this._tabLayoutDrag; if (!drag) return; this._tabLayoutDrag = null; + if (drag.windowUp) window.removeEventListener('pointerup', drag.windowUp, true); + if (drag.windowCancel) window.removeEventListener('pointercancel', drag.windowCancel, true); drag.origin?.classList.remove('tab-layout-dragging'); container?.classList.remove('tab-layout-drag-active'); if (container) this._clearTabLayoutDropMarks(container); @@ -7010,7 +7058,10 @@ class CodemanApp { const operations = editor?.pendingOperations?.() || []; if (!operations.length) return false; try { - sessionStorage.setItem('codeman:tab-layout-pending', JSON.stringify({ operations })); + sessionStorage.setItem( + 'codeman:tab-layout-pending', + JSON.stringify({ owner: this._tabLayoutOwnerKey(), baseVersion: editor.baseVersion(), savedAt: Date.now(), operations }) + ); } catch {} try { const layout = editor.getLayout(); @@ -7024,17 +7075,53 @@ class CodemanApp { return true; } + /** Whose layout this page edits, as the server keys it (`@single` without multi-user). */ + _tabLayoutOwnerKey() { + const me = window.__codemanUser; + if (!me) return null; + return me.multiUser ? me.username : '@single'; + } + + /** + * Replay the previous page's unsaved edits, but only that page's: the copy is + * ignored when it belongs to another owner (a different login in this tab), + * is older than a reload could explain, or names a layout newer than the one + * just read (a different server behind the same origin). + */ _restorePendingTabLayoutEdits() { - let operations; + let saved; try { const raw = sessionStorage.getItem('codeman:tab-layout-pending'); if (!raw) return false; - sessionStorage.removeItem('codeman:tab-layout-pending'); - operations = JSON.parse(raw)?.operations; + saved = JSON.parse(raw); } catch { return false; } + const owner = this._tabLayoutOwnerKey(); + if (owner === null) { + // Who we are is not known yet (/api/me still loading): decide once it is. + if (!this._tabLayoutRestoreWaiting) { + this._tabLayoutRestoreWaiting = true; + document.addEventListener( + 'codeman:me', + () => { + this._tabLayoutRestoreWaiting = false; + this._restorePendingTabLayoutEdits(); + }, + { once: true } + ); + } + return false; + } + try { + sessionStorage.removeItem('codeman:tab-layout-pending'); + } catch {} + const operations = saved?.operations; if (!Array.isArray(operations) || !operations.length || !this.tabLayout || !window.CodemanTabLayout) return false; + if (saved.owner !== owner) return false; + const age = Date.now() - saved.savedAt; + if (!Number.isFinite(age) || age < 0 || age > TAB_LAYOUT_PENDING_MAX_AGE_MS) return false; + if (!Number.isSafeInteger(saved.baseVersion) || saved.baseVersion > this.tabLayout.version) return false; return this._ensureTabLayoutEditor().restore(operations); } @@ -7049,9 +7136,9 @@ class CodemanApp { // affordance instead of lying about it — `tabRailSort: 'manual'` is the way // back to drag-reordering, and Alt+N / Ctrl+Shift+{ } still walk the strip // order this list is no longer showing. - // The grouped rail is read-only for now: a flat-order drag cannot express - // "move into that group", and the server would re-rank it within its old - // group anyway. Grouped editing comes with its own drag model. + // The grouped rail opts out too: a flat-order drag cannot express "move + // into that group", and the server would re-rank it within its old group + // anyway. It has its own pointer drag (_bindTabLayoutPointerDrag). if (this.isTabRailSorted() || container.classList.contains('session-tabs--grouped')) { tabs.forEach((tab) => tab.setAttribute('draggable', 'false')); return; diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 3b749394c..cee646c82 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -85,6 +85,7 @@ 'Tab groups changed elsewhere; part of your edit no longer applies.': '标签分组已在别处更改;你的部分编辑已不再适用。', 'Tab groups kept changing elsewhere; your edit was not saved.': '标签分组在别处持续更改;你的编辑未保存。', + 'Your tab group edit was not saved.': '你的标签分组编辑未保存。', 'Open session manager': '打开会话管理器', Attachments: '附件', 'Open attachment history': '打开附件历史', @@ -952,7 +953,7 @@ [/^Selected: (.+)$/, (_m, value) => `已选择:${value}`], [/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`], // Group names are user text: they pass through untranslated. - [/^Move to (.+)$/, (_m, group) => `移到 ${group}`], + [/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`], [ /^Delete group "(.+)"\? Its tabs move to Ungrouped\.$/, (_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`, diff --git a/src/web/public/styles.css b/src/web/public/styles.css index b69ba0bcb..6dc388b37 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -752,6 +752,14 @@ html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header:focus-v opacity: 1; } +/* No hover on a touch tablet, and a long press there is not a contextmenu + event: the glyph is the only way into the group menu, so keep it shown. */ +@media (hover: none) { + html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-menu { + opacity: 1; + } +} + html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-menu:hover { color: var(--text); background: var(--bg-tertiary, var(--bg-hover)); diff --git a/src/web/public/tab-layout-browser.js b/src/web/public/tab-layout-browser.js index c14fdb4e7..6aa489396 100644 --- a/src/web/public/tab-layout-browser.js +++ b/src/web/public/tab-layout-browser.js @@ -427,9 +427,11 @@ const layout = normalizeLayout(layoutInput); const block = lineageBlock(layout, ref, parents); const remaining = containerRefs(layout, groupId).filter((candidate) => !block.has(refKey(candidate))); - if (!anchor) return { groupId, index: remaining.length }; + // No anchor means "at the end", and an operation with no index keeps + // meaning that when it is replayed onto a layout that has changed since. + if (!anchor) return { groupId }; const at = remaining.findIndex((candidate) => refKey(candidate) === refKey(anchor)); - if (at < 0) return { groupId, index: remaining.length }; + if (at < 0) return { groupId }; return { groupId, index: placement === 'after' ? at + 1 : at }; } @@ -482,7 +484,7 @@ type: 'moveRef', ref: { kind: source.ref.kind, id: source.ref.id }, groupId: destination.groupId, - index: destination.index, + ...(destination.index === undefined ? {} : { index: destination.index }), parents: parents || {}, }; return contentKey(applyOperation(layout, operation)) === contentKey(layout) ? null : operation; @@ -613,6 +615,7 @@ pending = []; let failed = false; let reportedDrop = false; + let rereadFor400 = false; try { for (let attempt = 0; attempt < maxAttempts && inFlight.length; attempt++) { const desired = replayOperations(authoritative, inFlight); @@ -633,7 +636,10 @@ inFlight = []; } else if (response?.status === 409 && response.layout) { authoritative = normalizeLayout(response.layout); - } else if (response?.status === 400 && options.fetchLayout) { + } else if (response?.status === 400 && options.fetchLayout && !rereadFor400) { + // Maybe our base was stale in a way the server reports as invalid: + // re-read once. A 400 that survives that is a refusal, not a race. + rereadFor400 = true; authoritative = normalizeLayout(await options.fetchLayout()); if (disposed) return; } else { diff --git a/src/web/public/tab-rail-resize.js b/src/web/public/tab-rail-resize.js index 514296754..ae9066410 100644 --- a/src/web/public/tab-rail-resize.js +++ b/src/web/public/tab-rail-resize.js @@ -298,7 +298,9 @@ Object.assign(CodemanApp.prototype, { }, closeTabRailActionMenu(options = {}) { - const menu = document.querySelector('.tab-rail-action-menu'); + // The group menu borrows this class for its look but has its own owner + // (closeTabGroupMenu); removing its DOM here would strand its listeners. + const menu = document.querySelector('.tab-rail-action-menu:not(.tab-layout-group-action-menu)'); const trigger = this._tabRailActionMenuTrigger; menu?.remove(); if (this._tabRailActionMenuOutside) { diff --git a/test/i18n-branding.test.ts b/test/i18n-branding.test.ts index 45f959086..8ffff7623 100644 --- a/test/i18n-branding.test.ts +++ b/test/i18n-branding.test.ts @@ -91,6 +91,18 @@ describe('custom display name and browser localization', () => { dom.window.close(); }); + it('keeps a quoted group name apart from the fixed "Move to" entries in zh-CN', () => { + const dom = makeDom(''); + const api = dom.window.CodemanI18n; + api.configure({ language: 'zh-CN' }); + const labels = ['Move to "New group"', 'Move to new group', 'Move to "ungrouped"', 'Move to Ungrouped'].map((label) => + api.t(label) + ); + expect(labels).toEqual(['移到“New group”', '移到新分组', '移到“ungrouped”', '移到未分组']); + expect(new Set(labels).size).toBe(4); + dom.window.close(); + }); + it('renders hostile-looking names as text rather than HTML', () => { const dom = makeDom(''); const api = dom.window.CodemanI18n; diff --git a/test/tab-layout-editing.browser.test.ts b/test/tab-layout-editing.browser.test.ts index 7cd535311..3e1f66da5 100644 --- a/test/tab-layout-editing.browser.test.ts +++ b/test/tab-layout-editing.browser.test.ts @@ -65,6 +65,7 @@ describe('grouped rail editing in Chromium', () => {
+
`); @@ -124,6 +125,11 @@ describe('grouped rail editing in Chromium', () => { }; w.__setApp(app); w.__app = app; + // Escapes that reach the stand-in terminal (xterm listens on its textarea). + w.__termEscapes = 0; + document.getElementById('term')!.addEventListener('keydown', (e) => { + if (e.key === 'Escape') w.__termEscapes++; + }); }); }); @@ -151,6 +157,7 @@ describe('grouped rail editing in Chromium', () => { w.__app._applyTabLayout(layout); w.__activation = null; w.__panelsClosed = false; + w.__termEscapes = 0; }, LAYOUT); await page.mouse.move(1, 1); }); @@ -218,6 +225,104 @@ describe('grouped rail editing in Chromium', () => { expect(await page.evaluate(() => (window as any).__activation)).toBe('session:two'); }); + it('a press released outside the rail leaves nothing behind, and Escape still reaches the terminal', async () => { + const a = await box('.session-tab[data-id="one"]'); + // Press on a row and flick out of the rail in ONE move, releasing out there: + // the rail never sees the move or the release. + await page.mouse.move(a.x + 20, a.y + a.height / 2); + await page.mouse.down(); + await page.mouse.move(760, 600); + await page.mouse.up(); + // The release was heard on window: the press is gone before any hover. + expect(await page.evaluate(() => (window as any).__app._tabLayoutDrag)).toBeNull(); + // Hovering back with no button down must not turn into a phantom drag. + const b = await box('[data-tab-group-header="gy"]'); + await page.mouse.move(b.x + 30, b.y + b.height / 2, { steps: 6 }); + expect(await page.locator('.tab-layout-dragging, .tab-layout-drop-into, .tab-layout-drag-active').count()).toBe(0); + expect(await page.evaluate(() => (window as any).__app._tabLayoutDrag)).toBeNull(); + + // A real drag after that, cancelled with Escape, then one more completed. + await page.mouse.move(a.x + 20, a.y + a.height / 2); + await page.mouse.down(); + await page.mouse.move(b.x + 30, b.y + b.height / 2, { steps: 6 }); + await page.keyboard.press('Escape'); + await page.mouse.up(); + await drag('.session-tab[data-id="one"]', '[data-tab-group-header="gy"]'); + await settled(); + expect(puts).toHaveLength(1); + + // No drag listener is left in document capture swallowing Escape. + await page.locator('#term').focus(); + await page.keyboard.press('Escape'); + await page.keyboard.press('Escape'); + expect(await page.evaluate(() => (window as any).__termEscapes)).toBe(2); + expect(await page.evaluate(() => (window as any).__app._tabLayoutDragKeydown)).toBeNull(); + }); + + it('each guard holds on its own: buttons-up move, a second press, no stacked Escape listener', async () => { + // Synthetic pointer events reach the cases a real mouse cannot isolate (a + // release outside the WINDOW never reaches any listener of ours). + const result = await page.evaluate(() => { + const w = window as any; + const rail = document.getElementById('sessionTabs')!; + const at = (el: Element) => { + const r = el.getBoundingClientRect(); + return { clientX: r.left + 20, clientY: r.top + r.height / 2 }; + }; + const fire = (target: Element, type: string, init: PointerEventInit) => + target.dispatchEvent( + new PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 7, pointerType: 'mouse', ...init }) + ); + const one = rail.querySelector('.session-tab[data-id="one"] .tab-name')!; + const three = rail.querySelector('.session-tab[data-id="three"] .tab-name')!; + const out: Record = {}; + + // 1. A pending press, then a move with no button down: cancelled, no drag. + fire(one, 'pointerdown', { button: 0, buttons: 1, ...at(one) }); + fire(three, 'pointermove', { buttons: 0, ...at(three) }); + out.afterButtonsUp = w.__app._tabLayoutDrag; + out.dragClass = rail.querySelectorAll('.tab-layout-dragging').length; + + // 2. An active drag whose release never arrived, then a new press. + fire(one, 'pointerdown', { button: 0, buttons: 1, ...at(one) }); + fire(three, 'pointermove', { buttons: 1, ...at(three) }); + out.firstActive = w.__app._tabLayoutDrag?.active === true; + const firstListener = w.__app._tabLayoutDragKeydown; + fire(three, 'pointerdown', { button: 0, buttons: 1, ...at(three) }); + out.staleOrigin = rail.querySelectorAll('.tab-layout-dragging').length; + out.firstListenerKept = w.__app._tabLayoutDragKeydown === firstListener; + fire(one, 'pointermove', { buttons: 1, ...at(one) }); + fire(one, 'pointerup', { button: 0, buttons: 0, ...at(one) }); + out.leftover = w.__app._tabLayoutDragKeydown; + return out; + }); + expect(result.afterButtonsUp).toBeNull(); + expect(result.dragClass).toBe(0); + expect(result.firstActive).toBe(true); + expect(result.staleOrigin).toBe(0); + expect(result.firstListenerKept).toBe(false); + expect(result.leftover).toBeNull(); + await page.locator('#term').focus(); + await page.keyboard.press('Escape'); + expect(await page.evaluate(() => (window as any).__termEscapes)).toBe(1); + await settled(); + }); + + it('committing a group rename by clicking elsewhere leaves focus where the click put it', async () => { + await page.evaluate(() => (window as any).__app.startTabGroupRename('gy')); + const input = page.locator('.tab-layout-group-rename-input'); + await expect.poll(() => input.evaluate((el) => el === document.activeElement)).toBe(true); + await page.keyboard.press('Control+A'); + await page.keyboard.type('Elsewhere'); + await page.locator('#term').click(); + await settled(); + expect(puts.at(-1).layout.groups[1].name).toBe('Elsewhere'); + expect(await page.evaluate(() => document.activeElement?.id)).toBe('term'); + // So Enter goes to the terminal, not to the header (which would collapse it). + await page.keyboard.press('Enter'); + expect(await page.evaluate(() => (window as any).__app.collapsedTabGroupIds.size)).toBe(0); + }); + it('paints the inline group editor as you type, then saves the trimmed name', async () => { await page.evaluate(() => (window as any).__app.startTabGroupRename('gx')); const input = page.locator('.tab-layout-group-rename-input'); diff --git a/test/tab-layout-editing.test.ts b/test/tab-layout-editing.test.ts index b612709e6..2e0182db1 100644 --- a/test/tab-layout-editing.test.ts +++ b/test/tab-layout-editing.test.ts @@ -136,17 +136,17 @@ describe('drop -> operation', () => { groupId: 'g1', index: 1, }); - expect(h.dropOperation(base(), { type: 'ref', ref: w('web') }, { type: 'group', groupId: 'g1' }, {})).toMatchObject( - { - ref: w('web'), - groupId: 'g1', - index: 2, - } - ); - expect(h.dropOperation(base(), { type: 'ref', ref: s('a') }, { type: 'ungrouped' }, {})).toMatchObject({ - groupId: null, - index: 2, - }); + // Onto a header or Ungrouped means "at the end": no index, so a replay onto + // a layout that gained rows meanwhile still lands it last. + const onHeader = h.dropOperation(base(), { type: 'ref', ref: w('web') }, { type: 'group', groupId: 'g1' }, {}); + expect(onHeader).toMatchObject({ ref: w('web'), groupId: 'g1' }); + expect(onHeader).not.toHaveProperty('index'); + const onUngrouped = h.dropOperation(base(), { type: 'ref', ref: s('a') }, { type: 'ungrouped' }, {}); + expect(onUngrouped).toMatchObject({ groupId: null }); + expect(onUngrouped).not.toHaveProperty('index'); + const crowded = { ...base(), groups: [{ ...base().groups[0], refs: [...base().groups[0].refs, s('late')] }, base().groups[1]] }; + const replayed = h.applyOperation(crowded, onHeader); + expect(replayed.groups[0].refs.at(-1)).toEqual(w('web')); }); it('maps a group dropped on another group (or a row in it) to a reorder', () => { @@ -364,6 +364,26 @@ describe('edit coordinator', () => { expect(onFailure).toHaveBeenCalledTimes(1); }); + it('reports a 400 that survives the re-read as a failed save, not as a race', async () => { + const { put, calls } = controlledPut(); + const fetchLayout = vi.fn(async () => base(11)); + const reportError = vi.fn(); + const onFailure = vi.fn(); + const { editor } = makeEditor(put, { fetchLayout, onFailure, reportError, maxAttempts: 3 }); + editor.enqueue({ type: 'renameGroup', groupId: 'g1', name: 'Mine' }); + await settle(); + calls[0].resolve({ ok: false, status: 400, layout: null }); + await settle(); + await settle(); + calls[1].resolve({ ok: false, status: 400, layout: null }); + await settle(); + expect(calls).toHaveLength(2); + expect(fetchLayout).toHaveBeenCalledTimes(1); + expect(reportError).toHaveBeenCalledWith('Could not save tab groups.'); + expect(reportError).not.toHaveBeenCalledWith('Tab groups kept changing elsewhere; your edit was not saved.'); + expect(onFailure).toHaveBeenCalledTimes(1); + }); + it('refuses an external layout while writing, rebases pending edits onto one otherwise', async () => { const { put, calls } = controlledPut(); const { editor, applied } = makeEditor(put); @@ -523,6 +543,7 @@ beforeEach(() => { win.localStorage.clear(); win.sessionStorage.clear(); document.body.innerHTML = ''; + win.__codemanUser = { username: 'admin', role: 'admin', multiUser: false }; }); afterEach(() => { @@ -542,7 +563,7 @@ describe('row actions in the vertical rail', () => { expect(menuLabels()).toEqual([ 'Session options', 'Move down', - 'Move to Later', + 'Move to "Later"', 'Move to Ungrouped', 'Move to new group', 'Close session', @@ -555,11 +576,34 @@ describe('row actions in the vertical rail', () => { expect(menuLabels()).toEqual(['Session options', 'Close session']); }); + it('quotes group names, so a group called "New group" or "ungrouped" reads apart from the fixed entries', () => { + installFetch(); + const app = makeApp({ + ...serverLayout(), + groups: [ + { id: 'gn', name: 'New group', refs: [s('s2')] }, + { id: 'gu', name: 'ungrouped', refs: [] }, + ], + }); + app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s2') }, 's2'); + expect(menuLabels()).toEqual([ + 'Session options', + 'Move to "ungrouped"', + 'Move to Ungrouped', + 'Move to new group', + 'Close session', + ]); + app.closeTabRailActionMenu(); + app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s1') }, 's1'); + expect(menuLabels()).toContain('Move to "New group"'); + expect(menuLabels()).toContain('Move to new group'); + }); + it('moves a row into another group with one PUT carrying the held version', async () => { const puts = installFetch(); const app = makeApp(); app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s1') }, 's1'); - clickMenu('Move to Later'); + clickMenu('Move to "Later"'); // Optimistic: the rail already shows it there. expect(row('s1').closest('.tab-layout-group')!.getAttribute('data-tab-group-id')).toBe('gy'); await flush(); @@ -621,7 +665,7 @@ describe('web tab rows', () => { expect(menuLabels()).toEqual([ 'Web tab settings', 'Move up', - 'Move to Later', + 'Move to "Later"', 'Move to Ungrouped', 'Move to new group', ]); @@ -671,6 +715,35 @@ describe('group menu', () => { expect(keys(last.ungrouped)).toEqual(['session:s1', 'session:s3', 'session:s2', 'webview:w1']); }); + it('cancelling "Delete group" returns focus to the header, with no write', async () => { + const puts = installFetch(); + const app = makeApp(); + win.confirm = vi.fn(() => false); + header('gy').focus(); + key(header('gy'), 'F10', { shiftKey: true }); + clickMenu('Delete group'); + expect(win.confirm).toHaveBeenCalledTimes(1); + await flush(); + expect(puts).toHaveLength(0); + expect(document.activeElement).toBe(header('gy')); + expect(app.tabLayout.groups).toHaveLength(2); + }); + + it("closing the row menu (as session:deleted does) leaves an open group menu and its listeners alone", () => { + installFetch(); + const app = makeApp(); + header('gx').focus(); + key(header('gx'), 'F10', { shiftKey: true }); + const menu = document.querySelector('.tab-layout-group-action-menu'); + expect(menu).not.toBeNull(); + app.closeTabRailActionMenu(); + expect(menu!.isConnected).toBe(true); + expect(app._tabGroupMenu).toBe(menu); + app.closeTabGroupMenu(); + expect(menu!.isConnected).toBe(false); + expect(app._tabGroupMenuKeydown).toBeNull(); + }); + it('renames from F2 and cancels on Escape without a write', async () => { const puts = installFetch(); const app = makeApp(); @@ -809,6 +882,107 @@ describe('server echoes and reloads', () => { expect(gets).toHaveLength(1); }); + it('a failed read during a write keeps the edit and its editor, and the 409 still rebases', async () => { + const pending: Array<{ body: any; resolve: (r: any) => void }> = []; + const gets: number[] = []; + win.fetch = vi.fn((_url: string, init: any) => { + if (init?.method === 'PUT') { + const body = JSON.parse(init.body); + return new Promise((resolve) => pending.push({ body, resolve })); + } + gets.push(1); + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ success: true, data: { layout: app.tabLayout } }), + }); + }); + const app = makeApp(); + app.editTabLayout({ type: 'renameGroup', groupId: 'gy', name: 'Mine' }); + await flush(); + expect(pending).toHaveLength(1); + const editor = app._tabLayoutEditor; + // The layout read failed (the load coordinator's fallback) while the PUT is out. + app._applyTabLayout(null); + expect(app._tabLayoutEditor).toBe(editor); + expect(app.tabLayout.groups[1].name).toBe('Mine'); + expect(tabs().getAttribute('role')).toBe('tree'); + // Someone else wrote first: the 409 is rebased and retried, not lost. + const theirs = { ...serverLayout(9), groups: [...serverLayout().groups, { id: 'gz', name: 'Z', refs: [] }] }; + pending[0].resolve({ ok: false, status: 409, json: async () => ({ success: false, data: { layout: theirs } }) }); + await flush(); + expect(pending).toHaveLength(2); + expect(pending[1].body.baseVersion).toBe(9); + expect(pending[1].body.layout.groups.map((g: any) => g.name)).toEqual(['', 'Mine', 'Z']); + pending[1].resolve({ + ok: true, + status: 200, + json: async () => ({ success: true, data: { layout: { ...pending[1].body.layout, version: 10 } } }), + }); + await flush(); + expect(app.tabLayout.version).toBe(10); + expect(app.showToast).not.toHaveBeenCalled(); + // The read the failure deferred runs once the write settles. + expect(gets).toHaveLength(1); + }); + + it('replays only its own owner\'s recent copy of unsaved edits', async () => { + const copy = (extra: Record) => + win.sessionStorage.setItem( + 'codeman:tab-layout-pending', + JSON.stringify({ + owner: '@single', + baseVersion: 8, + savedAt: Date.now(), + operations: [{ type: 'renameGroup', groupId: 'gy', name: 'Replayed' }], + ...extra, + }) + ); + const replays = async (extra: Record) => { + copy(extra); + const puts = installFetch(); + makeApp(); + await flush(); + expect(win.sessionStorage.getItem('codeman:tab-layout-pending')).toBeNull(); + return puts.length; + }; + expect(await replays({})).toBe(1); + expect(await replays({ owner: 'alice' })).toBe(0); + expect(await replays({ savedAt: Date.now() - 5 * 60_000 })).toBe(0); + expect(await replays({ savedAt: undefined })).toBe(0); + expect(await replays({ baseVersion: 30 })).toBe(0); + + // Multi-user: the copy is keyed by the user name. + win.__codemanUser = { username: 'alice', role: 'user', multiUser: true }; + expect(await replays({ owner: 'alice' })).toBe(1); + expect(await replays({ owner: '@single' })).toBe(0); + + // Identity not known yet: hold the copy until /api/me answers. + win.__codemanUser = undefined; + copy({}); + const late = installFetch(); + makeApp(); + await flush(); + expect(late).toHaveLength(0); + expect(win.sessionStorage.getItem('codeman:tab-layout-pending')).not.toBeNull(); + win.__codemanUser = { username: 'admin', role: 'admin', multiUser: false }; + document.dispatchEvent(new win.CustomEvent('codeman:me')); + await flush(); + expect(late).toHaveLength(1); + expect(late[0].layout.groups[1].name).toBe('Replayed'); + }); + + it('says so when a read has to drop edits it could not rebase', () => { + installFetch(); + const app = makeApp(); + app.editTabLayout({ type: 'renameGroup', groupId: 'gy', name: 'Unsent' }); + // Not yet flushed (no write in flight), and the rebase refuses the read. + app._tabLayoutEditor.adoptExternal = () => false; + app._applyTabLayout(serverLayout(9)); + expect(app.showToast).toHaveBeenCalledWith('Your tab group edit was not saved.', 'error'); + expect(app._tabLayoutEditor).toBeNull(); + }); + it('keeps unsaved edits across a reload: keepalive PUT now, rebased replay after', async () => { const puts = installFetch(); const app = makeApp(); @@ -822,9 +996,11 @@ describe('server echoes and reloads', () => { ); expect(puts.at(-1).baseVersion).toBe(8); expect(puts.at(-1).layout.groups[1].name).toBe('Unsaved'); - expect(JSON.parse(win.sessionStorage.getItem('codeman:tab-layout-pending')).operations).toEqual([ - { type: 'renameGroup', groupId: 'gy', name: 'Unsaved' }, - ]); + const stored = JSON.parse(win.sessionStorage.getItem('codeman:tab-layout-pending')); + expect(stored.operations).toEqual([{ type: 'renameGroup', groupId: 'gy', name: 'Unsaved' }]); + expect(stored.owner).toBe('@single'); + expect(stored.baseVersion).toBe(8); + expect(Math.abs(Date.now() - stored.savedAt)).toBeLessThan(5000); // Next page: the keepalive lost a race; the layout moved on to version 12. const next = installFetch(); @@ -842,7 +1018,12 @@ describe('server echoes and reloads', () => { // And when the keepalive DID land, nothing is re-sent. win.sessionStorage.setItem( 'codeman:tab-layout-pending', - JSON.stringify({ operations: [{ type: 'renameGroup', groupId: 'gy', name: 'Later' }] }) + JSON.stringify({ + owner: '@single', + baseVersion: 8, + savedAt: Date.now(), + operations: [{ type: 'renameGroup', groupId: 'gy', name: 'Later' }], + }) ); const none = installFetch(); makeApp(); @@ -850,6 +1031,12 @@ describe('server echoes and reloads', () => { expect(none).toHaveLength(0); }); + it('keeps the group menu glyph visible where there is no hover (touch tablets)', () => { + const css = read('styles.css'); + const block = css.match(/@media \(hover: none\) \{\s*html\[data-tab-orientation='vertical'\] \.tab-rail \.tab-layout-group-menu \{([^}]*)\}/); + expect(block?.[1]).toMatch(/opacity:\s*1/); + }); + it('leaves the flat rail byte-identical when the layout has no groups, and never edits off the rail', () => { installFetch(); const noLayout = makeApp(null); From ea80c5f471760a4c1b48296d661029eed258ca22 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Mon, 5 Oct 2026 09:45:06 -0400 Subject: [PATCH 3/7] docs(tabs): correct the tab-layout constructor comment --- src/web/public/app.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index b2688793e..3bfdd38ba 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -590,8 +590,10 @@ class CodemanApp { this._shortIdCache = new Map(); // Cache session ID .slice(0, 8) results this.sessionOrder = []; // Track tab order for drag-and-drop reordering this.draggedTabId = null; // Currently dragged tab session ID - // Owner tab layout (GET /api/tab-layout), read-only here: it only changes how - // the vertical rail GROUPS rows. sessionOrder above stays the tab order. + // Owner tab layout: read via GET /api/tab-layout and edited from the vertical + // rail via PUT /api/tab-layout (editTabLayout). It decides how the rail GROUPS + // rows; the server projects it onto the session order, so sessionOrder above + // stays the tab order. this.tabLayout = null; this.collapsedTabGroupIds = new Set(); // per-device, localStorage-backed this._hiddenTabGroupByRef = new Map(); // 'session:' -> collapsed group id From 06aba94ef199bf05ccfa26e98bb9a7eebf6584e3 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 3 Oct 2026 13:18:22 -0400 Subject: [PATCH 4/7] 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- 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. --- src/web/public/session-ui.js | 50 ++++- src/web/public/styles.css | 41 +++- test/inline-rename.test.ts | 399 +++++++++++++++++++++++++++++++---- 3 files changed, 440 insertions(+), 50 deletions(-) diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 27a74ec8b..4bce22736 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -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 diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 6dc388b37..f3ef5797d 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -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-` 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; diff --git a/test/inline-rename.test.ts b/test/inline-rename.test.ts index edee0564e..82e7db405 100644 --- a/test/inline-rename.test.ts +++ b/test/inline-rename.test.ts @@ -13,7 +13,8 @@ * Strategy: stub a synthetic .tab-name node and a fake session entry, then * drive the rename function directly via page.evaluate(). No real PTY/tmux. * - * Port: 3164 (per MEMORY.md, ports 3150+ for tests) + * Ports: 3164, plus 3165 and 3192 for the two server-backed describes below + * (per MEMORY.md, ports 3150+ for tests) */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -21,6 +22,8 @@ import { chromium, type Browser, type Page } from 'playwright'; import { WebServer } from '../src/web/server.js'; const PORT = 3164; +const ORDERING_PORT = 3165; +const LONG_PREFIX_PORT = 3192; const BASE_URL = `http://localhost:${PORT}`; describe('Inline rename input', () => { @@ -581,54 +584,372 @@ describe('Inline rename input', () => { expect(await input.evaluate((node) => node.getBoundingClientRect().width)).toBeGreaterThan(0); }); - it('Vertical rail paints typing in an unclamped editor and restores the clamp on cancel', async () => { - await resetState(); - const id = 'vertical-live-input'; + // The rail has two row variants, and the detailed one clamps the name to + // three lines instead of two: the editor must come out unclamped in both, + // and cancelling must put back the clamp of the variant it was opened in. + it.each([ + { detail: 'simple', restoredClamp: '2' }, + { detail: 'rich', restoredClamp: '3' }, + ])( + 'Vertical rail ($detail rows) paints typing in an unclamped editor and restores the clamp on cancel', + async ({ detail, restoredClamp }) => { + await resetState(); + const id = `vertical-live-input-${detail}`; + + await page.evaluate( + ({ sessionId, detail }) => { + const app = ( + window as unknown as { + app: { + sessions: Map; + startInlineRename: (id: string) => void; + }; + } + ).app; + document.documentElement.dataset.tabOrientation = 'vertical'; + document.documentElement.dataset.tabRailDetail = detail; + const rail = document.getElementById('tabRail') as HTMLElement; + const tab = document.createElement('div'); + tab.setAttribute('data-test-tab', '1'); + tab.className = 'session-tab'; + tab.innerHTML = + `` + + 'w9-case: old'; + rail.appendChild(tab); + app.sessions.set(sessionId, { id: sessionId, name: 'w9-case: old' }); + app.startInlineRename(sessionId); + }, + { sessionId: id, detail } + ); + + const label = page.locator(`.tab-name[data-session-id="${id}"]`); + const input = label.locator('input.tab-rename-input'); + await input.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A'); + await page.keyboard.type('edited title'); + + expect(await input.inputValue()).toBe('edited title'); + expect(await input.evaluate((node) => document.activeElement === node)).toBe(true); + expect(await label.evaluate((node) => node.classList.contains('tab-name-renaming'))).toBe(true); + expect(await label.evaluate((node) => getComputedStyle(node).webkitLineClamp)).toBe('none'); + expect(await input.evaluate((node) => node.getBoundingClientRect().width)).toBeGreaterThan(0); + + const settled = await page.evaluate((sessionId) => { + const app = (window as unknown as { app: { _activeRename: { cancel: () => void } | null } }).app; + app._activeRename?.cancel(); + const label = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`) as HTMLElement; + const result = { + classActive: label.classList.contains('tab-name-renaming'), + inputPresent: !!label.querySelector('input.tab-rename-input'), + webkitLineClamp: getComputedStyle(label).webkitLineClamp, + }; + document.documentElement.dataset.tabOrientation = 'horizontal'; + return result; + }, id); + + expect(settled).toEqual({ classActive: false, inputPresent: false, webkitLineClamp: restoredClamp }); + } + ); +}); - await page.evaluate((sessionId) => { +/** + * Two renames of one session can be in flight at once: commit, reopen the + * editor before the PUT answers, then commit or cancel again (or start a group + * rename, which cancels the session editor). The writes go out one at a time + * in the order they were made, and a confirmed write is applied locally even + * if the editor that made it has since been cancelled, so the tab never shows + * a name the server no longer holds. + */ +describe('Inline rename write ordering', () => { + let server: WebServer; + let browser: Browser; + let page: Page; + + type Pending = { body: string; resolve: (response: Response) => void }; + + beforeAll(async () => { + server = new WebServer(ORDERING_PORT, false, true); + await server.start(); + browser = await chromium.launch({ headless: true }); + page = await browser.newPage(); + await page.goto(`http://localhost:${ORDERING_PORT}`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => + typeof (window as { app?: unknown }).app !== 'undefined' && + !!(window as { app?: { sessions?: Map } }).app?.sessions + ); + }, 60000); + + afterAll(async () => { + if (browser) await browser.close(); + if (server) await server.stop(); + }, 60000); + + /** Mount a header-strip row for `id`, hold every PUT open, and open its editor. */ + async function mount(id: string, name: string): Promise { + await page.evaluate( + ({ id, name }) => { + const w = window as unknown as { + app: { + _activeRename: { cancel: () => void } | null; + sessions: Map; + sessionOrder: string[]; + }; + __pending: Array<{ body: string; resolve: (response: Response) => void }>; + __origFetch?: typeof window.fetch; + }; + w.app._activeRename?.cancel(); + w.app.sessions.clear(); + document.querySelectorAll('[data-test-tab]').forEach((n) => n.remove()); + w.app.sessions.set(id, { id, name, status: 'idle' }); + w.app.sessionOrder = [id]; + const tab = document.createElement('div'); + tab.setAttribute('data-test-tab', '1'); + tab.className = 'session-tab'; + tab.dataset.id = id; + tab.innerHTML = `${name}`; + (document.getElementById('sessionTabs') as HTMLElement).appendChild(tab); + w.__pending = []; + w.__origFetch ??= window.fetch; + const passThrough = w.__origFetch; + window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method !== 'PUT' || !String(input).endsWith('/name')) return passThrough(input, init); + return new Promise((resolve) => { + w.__pending.push({ body: String(init?.body ?? ''), resolve }); + }); + }) as typeof window.fetch; + }, + { id, name } + ); + } + + async function restoreFetch(): Promise { + await page.evaluate(() => { + const w = window as unknown as { __origFetch?: typeof window.fetch }; + if (w.__origFetch) window.fetch = w.__origFetch; + }); + } + + async function commit(id: string, value: string | null): Promise { + await page.evaluate( + async ({ id, value }) => { + const app = (window as unknown as { app: { startInlineRename: (id: string) => void } }).app; + if (!document.querySelector(`.tab-name[data-session-id="${id}"] input.tab-rename-input`)) { + app.startInlineRename(id); + } + const input = document.querySelector( + `.tab-name[data-session-id="${id}"] input.tab-rename-input` + ) as HTMLInputElement; + if (value !== null) input.value = value; + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + }, + { id, value } + ); + } + + async function answer(index: number, name: string): Promise { + await page.evaluate( + async ({ index, body }) => { + const w = window as unknown as { __pending: Pending[] }; + w.__pending[index]?.resolve( + new Response(body, { status: 200, headers: { 'Content-Type': 'application/json' } }) + ); + await new Promise((resolve) => setTimeout(resolve, 30)); + }, + { index, body: JSON.stringify({ success: true, data: { name } }) } + ); + } + + async function state(id: string) { + return page.evaluate((id) => { + const w = window as unknown as { + app: { sessions: Map; _activeRename: unknown }; + __pending: Pending[]; + }; + return { + bodies: w.__pending.map(({ body }) => JSON.parse(body).name), + mapName: w.app.sessions.get(id)?.name ?? null, + renameActive: !!w.app._activeRename, + }; + }, id); + } + + it('sends successive renames of one session one at a time, in the order they were made', async () => { + await mount('order', 'Old'); + await commit('order', 'First'); + await commit('order', 'Second'); + expect((await state('order')).bodies).toEqual(['First']); + + await answer(0, 'First'); + expect((await state('order')).bodies).toEqual(['First', 'Second']); + await answer(1, 'Second'); + await restoreFetch(); + expect(await state('order')).toEqual({ bodies: ['First', 'Second'], mapName: 'Second', renameActive: false }); + }); + + it('keeps a confirmed rename when the editor reopened over it is cancelled', async () => { + await mount('reopen', 'Old'); + await commit('reopen', 'First'); + await page.evaluate(() => { + const app = ( + window as unknown as { app: { startInlineRename: (id: string) => void; _activeRename: { cancel: () => void } } } + ).app; + app.startInlineRename('reopen'); + app._activeRename.cancel(); + }); + await answer(0, 'First'); + await restoreFetch(); + expect(await state('reopen')).toEqual({ bodies: ['First'], mapName: 'First', renameActive: false }); + }); + + it('re-sends the shown name when it is confirmed unchanged over a rename still in flight', async () => { + await mount('stale', 'Old'); + await commit('stale', 'First'); + // The reopened editor still shows "Old" (the PUT has not answered), and + // Enter confirms that: the user's last word is "Old", not "First". + await commit('stale', null); + await answer(0, 'First'); + await answer(1, 'Old'); + await restoreFetch(); + expect(await state('stale')).toEqual({ bodies: ['First', 'Old'], mapName: 'Old', renameActive: false }); + }); + + it('keeps a confirmed session rename when a group rename takes over the editor', async () => { + await mount('to-group', 'Old'); + await commit('to-group', 'Saved'); + const groupStarted = await page.evaluate(() => { const app = ( window as unknown as { app: { - sessions: Map; - startInlineRename: (id: string) => void; + tabLayout: unknown; + startTabGroupRename: (groupId: string) => boolean; }; } ).app; - document.documentElement.dataset.tabOrientation = 'vertical'; - const rail = document.getElementById('tabRail') as HTMLElement; - const tab = document.createElement('div'); - tab.setAttribute('data-test-tab', '1'); - tab.className = 'session-tab'; - tab.innerHTML = - `` + - 'w9-case: old'; - rail.appendChild(tab); - app.sessions.set(sessionId, { id: sessionId, name: 'w9-case: old' }); - app.startInlineRename(sessionId); - }, id); + const section = document.createElement('section'); + section.setAttribute('data-test-tab', '1'); + section.innerHTML = + '
' + + 'Group
'; + (document.getElementById('sessionTabs') as HTMLElement).appendChild(section); + (window as unknown as { __origLayout: unknown }).__origLayout = app.tabLayout; + app.tabLayout = { version: 1, groups: [{ id: 'g1', name: 'Group', refs: [] }], ungrouped: [] }; + return app.startTabGroupRename('g1'); + }); + expect(groupStarted).toBe(true); + + await answer(0, 'Saved'); + const after = await page.evaluate(() => { + const w = window as unknown as { + app: { + sessions: Map; + _inlineRenameActive: boolean; + _activeRename: { cancel: () => void } | null; + tabLayout: unknown; + }; + __origLayout: unknown; + }; + const groupInput = document.querySelector('.tab-layout-group-rename-input'); + const result = { + mapName: w.app.sessions.get('to-group')?.name ?? null, + groupEditorOpen: !!groupInput?.isConnected, + guardHeld: w.app._inlineRenameActive, + }; + w.app._activeRename?.cancel(); + w.app.tabLayout = w.__origLayout; + return result; + }); + await restoreFetch(); + expect(after).toEqual({ mapName: 'Saved', groupEditorOpen: true, guardHeld: true }); + }); +}); - const label = page.locator(`.tab-name[data-session-id="${id}"]`); - const input = label.locator('input.tab-rename-input'); - await input.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A'); - await page.keyboard.type('edited title'); +/** + * Real rows, rendered by the app from a live session: a long `w-` + * prefix must not push the editor (or the prefix itself) out of the row in any + * rail variant. The prefix gives way first, with an ellipsis, and the input + * always keeps a usable width. + */ +describe('Vertical rail rename editor with a long prefix', () => { + let server: WebServer; + let browser: Browser; + const port = LONG_PREFIX_PORT; + const NAME = 'w3-this_is_a_very_long_valid_prefix: charlie'; + let sessionId = ''; - expect(await input.inputValue()).toBe('edited title'); - expect(await input.evaluate((node) => document.activeElement === node)).toBe(true); - expect(await label.evaluate((node) => node.classList.contains('tab-name-renaming'))).toBe(true); - expect(await label.evaluate((node) => getComputedStyle(node).webkitLineClamp)).toBe('none'); - expect(await input.evaluate((node) => node.getBoundingClientRect().width)).toBeGreaterThan(0); + beforeAll(async () => { + server = new WebServer(port, false, true); + await server.start(); + const res = await fetch(`http://localhost:${port}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: NAME, mode: 'shell' }), + }); + expect(res.ok).toBe(true); + const created = (await res.json()) as { data?: { id?: string; session?: { id?: string } } }; + sessionId = created.data?.session?.id ?? created.data?.id ?? ''; + expect(sessionId).not.toBe(''); + browser = await chromium.launch({ headless: true }); + }, 60000); - const settled = await page.evaluate((sessionId) => { - const app = (window as unknown as { app: { _activeRename: { cancel: () => void } | null } }).app; - app._activeRename?.cancel(); - const label = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`) as HTMLElement; - return { - classActive: label.classList.contains('tab-name-renaming'), - inputPresent: !!label.querySelector('input.tab-rename-input'), - webkitLineClamp: getComputedStyle(label).webkitLineClamp, - }; - }, id); + afterAll(async () => { + if (browser) await browser.close(); + if (server) await server.stop(); + }, 60000); - expect(settled).toEqual({ classActive: false, inputPresent: false, webkitLineClamp: '2' }); + it.each([ + { variant: 'simple rows', settings: { tabOrientation: 'vertical', tabRailDetail: 'simple' }, compact: false }, + { variant: 'detailed rows', settings: { tabOrientation: 'vertical', tabRailDetail: 'rich' }, compact: false }, + { variant: 'compact rail', settings: { tabOrientation: 'vertical', tabRailWidth: 208 }, compact: true }, + ])('keeps the prefix and a usable input inside the row ($variant)', async ({ settings, compact }) => { + const context = await browser.newContext({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1 }); + try { + await context.addInitScript( + (value) => localStorage.setItem('codeman-app-settings', JSON.stringify(value)), + settings + ); + const page = await context.newPage(); + await page.goto(`http://localhost:${port}`, { waitUntil: 'domcontentloaded' }); + const row = page.locator(`#tabRail .session-tab[data-id="${sessionId}"]`); + await row.waitFor({ state: 'visible', timeout: 15000 }); + expect(await page.evaluate(() => document.documentElement.classList.contains('tab-rail-compact'))).toBe(compact); + + await row.click({ button: 'right' }); + const input = row.locator('input.tab-rename-input'); + await input.press('Control+A'); + await page.keyboard.type('typed live text'); + expect(await input.inputValue()).toBe('typed live text'); + + const geometry = await row.evaluate((tab) => { + const box = (el: Element) => el.getBoundingClientRect(); + const within = (inner: DOMRect, outer: DOMRect) => + inner.left >= outer.left - 0.5 && + inner.right <= outer.right + 0.5 && + inner.top >= outer.top - 0.5 && + inner.bottom <= outer.bottom + 0.5; + const input = tab.querySelector('input.tab-rename-input') as HTMLInputElement; + const prefix = tab.querySelector('.tab-rename-prefix') as HTMLElement; + const info = tab.querySelector('.tab-info') as HTMLElement; + return { + focused: document.activeElement === input, + inputWidth: box(input).width, + prefixWidth: box(prefix).width, + inputInsideRow: within(box(input), box(info)), + prefixInsideRow: within(box(prefix), box(info)), + prefixEllipsis: getComputedStyle(prefix).textOverflow, + }; + }); + expect(geometry.focused).toBe(true); + expect(geometry.inputWidth).toBeGreaterThanOrEqual(64); + expect(geometry.prefixWidth).toBeGreaterThanOrEqual(24); + expect(geometry.inputInsideRow).toBe(true); + expect(geometry.prefixInsideRow).toBe(true); + expect(geometry.prefixEllipsis).toBe('ellipsis'); + + await input.press('Escape'); + expect(await row.locator('input.tab-rename-input').count()).toBe(0); + } finally { + await context.close(); + } }); }); From 92f51fa6194240802a1625367b75221925aba146 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sun, 4 Oct 2026 20:12:36 -0400 Subject: [PATCH 5/7] fix(tabs): inline rename review fixes Reopening the editor over a rename still in flight filled it from the name the server had not replaced yet, so dismissing it (blur commits) queued the old name behind the new one and undid the rename. The queue now records the newest queued name per session (_inlineRenamePending, cleared with the queue entry), and a reopened editor takes its prefix, input and "unchanged" comparison from it. An untouched confirm sends nothing more. A failed write only toasted while its editor was still current. The queue reports the failure itself now, and the editor only puts its label back. One rejected task blocked every later rename of that session until reload. Each task now chains from a settled predecessor, the local apply after a successful PUT is guarded, and the queue entry is cleaned up on either outcome. The rail and sidebar editor's 4rem floor moves from a stylesheet `!important` into the inline min-width startInlineRename already writes per layout (0 in the header strip, 4rem in the rail and sidebar). Tests: the reopened-editor case now expects only "First" to be sent; new cases cover a 500 answered after the editor is gone and a throw in updateSubagentParentNames; the long-prefix check runs in the sidebar and detailed sidebar too and asserts the inline floor; the header strip editor keeps min-width 0. --- src/web/public/session-ui.js | 62 +++++++++++----- src/web/public/styles.css | 5 +- test/inline-rename.test.ts | 135 +++++++++++++++++++++++++++++++++-- 3 files changed, 175 insertions(+), 27 deletions(-) diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 4bce22736..a5036056b 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -2596,12 +2596,22 @@ Object.assign(CodemanApp.prototype, { * 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. + * Resolves { status: 'confirmed' | 'failed' | 'deleted' }; never rejects, + * and reports a failed write itself, since its editor may be gone by then. + * `_inlineRenamePending` holds the newest queued name per session, so an + * editor reopened over a write in flight starts from that name rather than + * the one the server has not replaced yet. */ _queueInlineSessionName(sessionId, desiredName) { this._inlineRenameWrites ??= new Map(); + this._inlineRenamePending ??= new Map(); const writes = this._inlineRenameWrites; - const task = (writes.get(sessionId) || Promise.resolve()).then(async () => { + const pending = this._inlineRenamePending; + pending.set(sessionId, desiredName); + // Chained from a settled promise, so one rejected write cannot stop the + // writes queued behind it. + const prev = (writes.get(sessionId) || Promise.resolve()).catch(() => {}); + const task = prev.then(async () => { const session = this.sessions.get(sessionId); if (!session) return { status: 'deleted' }; if (session.name === desiredName) return { status: 'confirmed' }; @@ -2612,15 +2622,26 @@ Object.assign(CodemanApp.prototype, { // 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(); + if (confirmed === null) { + this.showToast('Failed to rename', 'error'); + return { status: 'failed' }; + } + try { + this._applyLocalSessionName(sessionId, confirmed); + this.renderSessionTabs(); + } catch (err) { + // The server holds the name; a local repaint failing is not a failed write. + console.error('[rename] applying the confirmed name failed', err); + } return { status: 'confirmed' }; }); writes.set(sessionId, task); - task.then(() => { - if (writes.get(sessionId) === task) writes.delete(sessionId); - }); + const cleanup = () => { + if (writes.get(sessionId) !== task) return; + writes.delete(sessionId); + pending.delete(sessionId); + }; + task.then(cleanup, cleanup); return task; }, @@ -2912,7 +2933,10 @@ Object.assign(CodemanApp.prototype, { tabName.classList.add('tab-name-renaming'); const currentName = this.getSessionName(session); - const parsed = parseSessionPrefix(session.name); + // A rename still in flight is the user's last word, not the name the + // server has yet to replace: start from it, and compare against it below. + const shownName = this._inlineRenamePending?.get(sessionId) ?? session.name; + const parsed = parseSessionPrefix(shownName); const originalContent = tabName.textContent; const originalChildren = [...tabName.childNodes].map((node) => node.cloneNode(true)); const restoreOriginalChildren = () => { @@ -2933,13 +2957,17 @@ Object.assign(CodemanApp.prototype, { const input = document.createElement('input'); input.type = 'text'; - input.value = parsed ? parsed.suffix : (session.name || ''); + input.value = parsed ? parsed.suffix : (shownName || ''); input.placeholder = parsed ? 'Add description...' : currentName; input.className = 'tab-rename-input'; // 80px is tuned for the narrow header tab; a full-width sidebar row can and - // should give the whole line to the input. - const renameWidth = tabName.closest('.tab-rail') ? 'auto' : this.isSessionSidebarActive?.() ? '100%' : '80px'; - input.style.cssText = `width: ${renameWidth}; min-width: 0; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;`; + // should give the whole line to the input. The header editor may shrink to + // nothing, while a rail or sidebar row always keeps room to type. + const inRail = !!tabName.closest('.tab-rail'); + const inSidebar = !inRail && !!this.isSessionSidebarActive?.(); + const renameWidth = inRail ? 'auto' : inSidebar ? '100%' : '80px'; + const renameMinWidth = inRail || inSidebar ? '4rem' : '0'; + input.style.cssText = `width: ${renameWidth}; min-width: ${renameMinWidth}; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;`; tabName.appendChild(input); input.focus(); @@ -2989,7 +3017,7 @@ Object.assign(CodemanApp.prototype, { const suffix = input.value.trim(); const fullName = parsed ? parsed.prefix + (suffix ? ': ' + suffix : '') : suffix; - if (fullName === session.name) restoreOriginalChildren(); + if (fullName === shownName) restoreOriginalChildren(); else tabName.textContent = fullName || originalContent; // Skip the API call if the session vanished between focus and blur. The @@ -2998,10 +3026,8 @@ Object.assign(CodemanApp.prototype, { if (this.sessions.has(sessionId)) { const result = await this._queueInlineSessionName(sessionId, fullName); if (invalidated || this._activeRename !== renameHandle || !this.sessions.has(sessionId)) return; - if (result.status === 'failed') { - restoreOriginalChildren(); - this.showToast('Failed to rename', 'error'); - } + // The queue reports a failure itself; the editor only puts its label back. + if (result.status === 'failed') restoreOriginalChildren(); } // Re-render tabs to restore full tab structure completeCurrentRename(); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index f3ef5797d..8ba70b264 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -1867,15 +1867,14 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix { 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. */ +/* The input takes the rest of the row. Its 4rem floor is the editor's own + inline min-width (startInlineRename picks it per layout). */ :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: 4rem !important; } /* A compact rail row has no room for both the editor and its adornments, so diff --git a/test/inline-rename.test.ts b/test/inline-rename.test.ts index 82e7db405..e851f3103 100644 --- a/test/inline-rename.test.ts +++ b/test/inline-rename.test.ts @@ -802,16 +802,133 @@ describe('Inline rename write ordering', () => { expect(await state('reopen')).toEqual({ bodies: ['First'], mapName: 'First', renameActive: false }); }); - it('re-sends the shown name when it is confirmed unchanged over a rename still in flight', async () => { + it('reopens the editor on the name still in flight, so confirming it unchanged keeps the rename', async () => { await mount('stale', 'Old'); await commit('stale', 'First'); - // The reopened editor still shows "Old" (the PUT has not answered), and - // Enter confirms that: the user's last word is "Old", not "First". + // The PUT for "First" has not answered, so app.sessions still says "Old". + // The reopened editor must show "First", the user's last word, and an + // untouched confirm must not queue "Old" behind it. + const reopenedValue = await page.evaluate(() => { + (window as unknown as { app: { startInlineRename: (id: string) => void } }).app.startInlineRename('stale'); + return (document.querySelector('.tab-name[data-session-id="stale"] input.tab-rename-input') as HTMLInputElement) + .value; + }); + expect(reopenedValue).toBe('First'); await commit('stale', null); await answer(0, 'First'); - await answer(1, 'Old'); await restoreFetch(); - expect(await state('stale')).toEqual({ bodies: ['First', 'Old'], mapName: 'Old', renameActive: false }); + expect(await state('stale')).toEqual({ bodies: ['First'], mapName: 'First', renameActive: false }); + expect( + await page.evaluate( + () => + (window as unknown as { app: { _inlineRenamePending?: Map } }).app._inlineRenamePending?.has( + 'stale' + ) ?? false + ) + ).toBe(false); + }); + + it('reports a failed write even after its editor is gone', async () => { + await mount('fail-late', 'Old'); + await page.evaluate(() => { + const w = window as unknown as { + app: { showToast: (message: string, type?: string) => void }; + __toasts: string[]; + __origToast?: (message: string, type?: string) => void; + }; + w.__toasts = []; + w.__origToast = w.app.showToast; + w.app.showToast = (message: string) => { + w.__toasts.push(message); + }; + }); + await commit('fail-late', 'First'); + // Reopen and dismiss: the editor that made the write is gone. + await page.evaluate(() => { + const app = ( + window as unknown as { app: { startInlineRename: (id: string) => void; _activeRename: { cancel: () => void } } } + ).app; + app.startInlineRename('fail-late'); + app._activeRename.cancel(); + }); + await page.evaluate(async () => { + const w = window as unknown as { __pending: Pending[] }; + w.__pending[0]?.resolve( + new Response(JSON.stringify({ success: false, error: 'boom' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + ); + await new Promise((resolve) => setTimeout(resolve, 30)); + }); + await restoreFetch(); + const toasts = await page.evaluate(() => { + const w = window as unknown as { + app: { showToast: unknown }; + __toasts: string[]; + __origToast?: unknown; + }; + w.app.showToast = w.__origToast; + return w.__toasts; + }); + expect(toasts).toEqual(['Failed to rename']); + expect(await state('fail-late')).toEqual({ bodies: ['First'], mapName: 'Old', renameActive: false }); + }); + + it('keeps sending a session renames after the work following a PUT throws', async () => { + await mount('throws', 'Old'); + await page.evaluate(() => { + const w = window as unknown as { + app: { updateSubagentParentNames?: (id: string) => void }; + __origParentNames?: (id: string) => void; + __throwOnce: boolean; + }; + w.__origParentNames = w.app.updateSubagentParentNames; + w.__throwOnce = true; + w.app.updateSubagentParentNames = (id: string) => { + if (w.__throwOnce) { + w.__throwOnce = false; + throw new Error('forced'); + } + w.__origParentNames?.call(w.app, id); + }; + window.addEventListener('unhandledrejection', (event) => event.preventDefault(), { once: true }); + }); + await commit('throws', 'First'); + await answer(0, 'First'); + await commit('throws', 'Second'); + await answer(1, 'Second'); + await restoreFetch(); + const leftover = await page.evaluate(() => { + const w = window as unknown as { + app: { updateSubagentParentNames?: unknown; _inlineRenameWrites?: Map }; + __origParentNames?: unknown; + }; + w.app.updateSubagentParentNames = w.__origParentNames; + return w.app._inlineRenameWrites?.has('throws') ?? false; + }); + expect(await state('throws')).toEqual({ bodies: ['First', 'Second'], mapName: 'Second', renameActive: false }); + expect(leftover).toBe(false); + }); + + it('lets the header strip editor shrink (inline min-width 0)', async () => { + await mount('header-width', 'Old'); + const minWidth = await page.evaluate(() => { + const app = ( + window as unknown as { + app: { startInlineRename: (id: string) => void; _activeRename: { cancel: () => void } | null }; + } + ).app; + app.startInlineRename('header-width'); + const input = document.querySelector( + '.tab-name[data-session-id="header-width"] input.tab-rename-input' + ) as HTMLInputElement; + const value = input.style.minWidth; + app._activeRename?.cancel(); + return value; + }); + await restoreFetch(); + expect(minWidth).toBe('0px'); }); it('keeps a confirmed session rename when a group rename takes over the editor', async () => { @@ -901,6 +1018,8 @@ describe('Vertical rail rename editor with a long prefix', () => { { variant: 'simple rows', settings: { tabOrientation: 'vertical', tabRailDetail: 'simple' }, compact: false }, { variant: 'detailed rows', settings: { tabOrientation: 'vertical', tabRailDetail: 'rich' }, compact: false }, { variant: 'compact rail', settings: { tabOrientation: 'vertical', tabRailWidth: 208 }, compact: true }, + { variant: 'sidebar', settings: { sessionListLayout: 'sidebar' }, compact: false }, + { variant: 'detailed sidebar', settings: { sessionListLayout: 'sidebar-rich' }, compact: false }, ])('keeps the prefix and a usable input inside the row ($variant)', async ({ settings, compact }) => { const context = await browser.newContext({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1 }); try { @@ -910,7 +1029,8 @@ describe('Vertical rail rename editor with a long prefix', () => { ); const page = await context.newPage(); await page.goto(`http://localhost:${port}`, { waitUntil: 'domcontentloaded' }); - const row = page.locator(`#tabRail .session-tab[data-id="${sessionId}"]`); + // One #sessionTabs list, moved into the rail or the sidebar by the layout. + const row = page.locator(`#sessionTabs .session-tab[data-id="${sessionId}"]`); await row.waitFor({ state: 'visible', timeout: 15000 }); expect(await page.evaluate(() => document.documentElement.classList.contains('tab-rail-compact'))).toBe(compact); @@ -937,6 +1057,7 @@ describe('Vertical rail rename editor with a long prefix', () => { inputInsideRow: within(box(input), box(info)), prefixInsideRow: within(box(prefix), box(info)), prefixEllipsis: getComputedStyle(prefix).textOverflow, + inlineMinWidth: input.style.minWidth, }; }); expect(geometry.focused).toBe(true); @@ -945,6 +1066,8 @@ describe('Vertical rail rename editor with a long prefix', () => { expect(geometry.inputInsideRow).toBe(true); expect(geometry.prefixInsideRow).toBe(true); expect(geometry.prefixEllipsis).toBe('ellipsis'); + // The floor is the editor's own inline style, not a stylesheet override. + expect(geometry.inlineMinWidth).toBe('4rem'); await input.press('Escape'); expect(await row.locator('input.tab-rename-input').count()).toBe(0); From 3e768e1b5bb321a4795a6641410462fb8a2c1e30 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Mon, 5 Oct 2026 09:47:08 -0400 Subject: [PATCH 6/7] fix(tabs): show the in-flight name when an unchanged rename is confirmed --- src/web/public/session-ui.js | 6 +++++- test/inline-rename.test.ts | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index a5036056b..e8e84dc3c 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -2936,6 +2936,7 @@ Object.assign(CodemanApp.prototype, { // A rename still in flight is the user's last word, not the name the // server has yet to replace: start from it, and compare against it below. const shownName = this._inlineRenamePending?.get(sessionId) ?? session.name; + const renameInFlight = shownName !== session.name; const parsed = parseSessionPrefix(shownName); const originalContent = tabName.textContent; const originalChildren = [...tabName.childNodes].map((node) => node.cloneNode(true)); @@ -3017,7 +3018,10 @@ Object.assign(CodemanApp.prototype, { const suffix = input.value.trim(); const fullName = parsed ? parsed.prefix + (suffix ? ': ' + suffix : '') : suffix; - if (fullName === shownName) restoreOriginalChildren(); + // An unchanged confirm puts the old label back, unless the editor opened + // over a rename in flight: that label was repainted from the server's + // older name, so show the in-flight name rather than make it look lost. + if (fullName === shownName && !renameInFlight) restoreOriginalChildren(); else tabName.textContent = fullName || originalContent; // Skip the API call if the session vanished between focus and blur. The diff --git a/test/inline-rename.test.ts b/test/inline-rename.test.ts index e851f3103..aa8c7c170 100644 --- a/test/inline-rename.test.ts +++ b/test/inline-rename.test.ts @@ -828,6 +828,26 @@ describe('Inline rename write ordering', () => { ).toBe(false); }); + it('shows the in-flight name when a reopened editor is confirmed unchanged, before the PUT lands', async () => { + await mount('shown', 'Old'); + await commit('shown', 'First'); + // Reopen while the PUT for "First" is held, then confirm it untouched. The + // label must read "First" now, not the "Old" the cancelled editor + // repainted from app.sessions. + await page.evaluate(() => + (window as unknown as { app: { startInlineRename: (id: string) => void } }).app.startInlineRename('shown') + ); + await commit('shown', null); + const label = await page.evaluate( + () => (document.querySelector('.tab-name[data-session-id="shown"]') as HTMLElement).textContent + ); + expect((await state('shown')).bodies).toEqual(['First']); + expect(label).toBe('First'); + await answer(0, 'First'); + await restoreFetch(); + expect(await state('shown')).toEqual({ bodies: ['First'], mapName: 'First', renameActive: false }); + }); + it('reports a failed write even after its editor is gone', async () => { await mount('fail-late', 'Old'); await page.evaluate(() => { From 287321e66ec6e76bdf451361cc8f80e8352a0d0b Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 3 Oct 2026 18:06:08 -0400 Subject: [PATCH 7/7] feat(tabs): place new, child and web tabs into groups New sessions, spawned child sessions and saved web tabs now land in tab groups instead of always starting in Ungrouped. New session in a group: - The group menu gains "New session" (first item). It expands a collapsed group and runs ONE launch with the current case and run mode; a launch already in flight is reported, not queued. - run({ count, tabGroupId }) threads the group through every launch path (local POST /api/sessions, remote/docker and external-CLI /api/quick-start). The count overrides the steppers for that launch only. - Server: POST /api/sessions and /api/quick-start accept an optional tabGroupId (Zod, 1-100 chars; null/empty is a 400). The routes pass it to addSession(), and TabLayoutService.sessionCreated() places the session at the end of that group in the SAME versioned write that first records it. The session is added to the live map inside the owner lock (with a rollback when the write fails), so no queued layout edit can commit it first. The group is looked up in the creating owner's own layout, so an unknown, deleted or foreign id is ignored instead of failing the spawn. An explicit group is a hand placement: a child placed this way is manual. - The create response carries the committed tabLayout (only when tabGroupId was sent), adopted before the new tab is drawn. Responses without the field are unchanged. Child sessions (parentSessionId / X-Codeman-Parent-Session): - A child nobody placed follows its parent: the server's existing normalization already puts it after the parent's rows; the browser now draws a not-yet-stored child there too (project({ parents })) and, while groups exist, adopts the server's order instead of echoing its own append back through PUT /api/session-order (which pinned every child manual). - The row menu says where a child sits (follows, placed by hand, parent closed, parent loop) and offers "Follow parent again" only to a hand-placed child. A child whose parent is gone stands on its own and is never re-adopted. Saved web tabs: - Web tabs are layout refs like sessions; openness stays per device. A grouped web tab shows (and takes an Alt+N slot) only while open here and reopens where the layout keeps it. Pinned by tests; no behaviour change. Unchanged: the flat rail and header strip (markup, HTML5 drag, append and save on create), the tree semantics and single tab stop, sessionOrder and Alt+N, and every request that does not send tabGroupId. --- CLAUDE.md | 2 +- docs/api-reference.md | 11 + docs/architecture-invariants.md | 1 + src/tab-layout-service.ts | 76 +++++- src/web/ports/session-port.ts | 9 +- src/web/public/app.js | 79 +++++- src/web/public/i18n.js | 8 + src/web/public/session-ui.js | 60 +++-- src/web/public/styles.css | 11 + src/web/public/tab-layout-browser.js | 112 +++++++- src/web/public/tab-rail-resize.js | 6 + src/web/routes/session-routes.ts | 12 +- src/web/schemas.ts | 13 + src/web/server.ts | 25 +- test/routes/session-routes-tab-group.test.ts | 131 +++++++++ test/run-mode-dispatch.test.ts | 4 +- test/run-mode-ui.test.ts | 4 +- test/tab-group-launch.test.ts | 135 ++++++++++ test/tab-layout-browser.test.ts | 33 +++ test/tab-layout-editing.test.ts | 263 ++++++++++++++++++- test/tab-layout-grouped-create.test.ts | 148 +++++++++++ test/tab-layout-service.test.ts | 141 ++++++++++ 22 files changed, 1228 insertions(+), 56 deletions(-) create mode 100644 test/routes/session-routes-tab-group.test.ts create mode 100644 test/tab-group-launch.test.ts create mode 100644 test/tab-layout-grouped-create.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5ea2984c2..28df4edfc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -257,7 +257,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history and transcript files into one deduped list (pure core `src/services/unified-session-service.ts`), backing the Cmd+K Session Manager, pinning and cross-device tab order (`PUT /api/session-order`, `src/session-order.ts`). ⚠️ Transcript history is THREE stores (`~/.claude/projects`, `~/.omp/agent/sessions`, `~/.codex/sessions`), folded via the `claudeSessionId → Codeman id` alias map (not Claude-only despite the name). ⚠️ `resumeId` is set by a SCANNER row only, never a live session; every surface that re-projects these rows (phone overview included) must carry it through, or a tap silently starts a second conversation. → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager) -**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend reads AND edits it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. Groups are created, renamed, reordered and deleted, and rows moved between them, from the row/group menus (Shift+F10 on a header too) and by pointer drag in the grouped rail. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. ⚠️ Every browser write is a named operation through ONE serialized `PUT /api/tab-layout` at a time (`createEditCoordinator`): a 409 replays the operations onto the server's layout and retries (bounded), and an SSE reload is deferred while a write is in flight. Never PUT the layout from anywhere else in the frontend. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts) +**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend reads AND edits it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. Groups are created, renamed, reordered and deleted, and rows moved between them, from the row/group menus (Shift+F10 on a header too) and by pointer drag in the grouped rail. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. ⚠️ Every browser write is a named operation through ONE serialized `PUT /api/tab-layout` at a time (`createEditCoordinator`): a 409 replays the operations onto the server's layout and retries (bounded), and an SSE reload is deferred while a write is in flight. Never PUT the layout from anywhere else in the frontend. ⚠️ Placing a NEW session in a group rides its create request (`tabGroupId` on `POST /api/sessions` / `/api/quick-start`) into `TabLayoutService.sessionCreated()`, which registers the session inside the owner lock and places it in the same write; never place a created session with a follow-up PUT. A child session follows its parent's group until moved by hand (`placement: 'manual'`); web-tab OPENNESS stays per device. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts) **Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()`, gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation) diff --git a/docs/api-reference.md b/docs/api-reference.md index 7b534ddc2..e83f5d6a6 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -470,6 +470,17 @@ also pure decoration: it confers no permission, and a child is unaffected by its parent exiting. It appears on session state as `parentSessionId` (absent when unresolved) and survives a server restart. +## Placing a new session in a tab group (`tabGroupId`) + +`POST /api/v1/sessions` and `POST /api/v1/quick-start` accept an optional +`tabGroupId` (1 to 100 characters): the id of a group in the caller's tab layout +(`GET /api/v1/tab-layout`). The session is created as usual and placed at the end +of that group in the same layout write; the response then also carries the +committed layout as `tabLayout`. An id the caller's layout does not contain (a +deleted group, another user's group) is ignored and the session lands where it +otherwise would, never a `400`. A malformed value (`null`, an empty string, a +non-string) is a `400 INVALID_INPUT`. Requests without the field are unchanged. + ## Approvals Inbox Cross-session queue of prompts waiting on a human (permission dialogs, diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 7fe8e59c1..0e65caaf5 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -306,6 +306,7 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t - **The upstream HTML5 drag stays off in the grouped rail**: a flat-order drop cannot express a group move, and the server re-ranks within the old group. The grouped rail has its OWN pointer drag instead (`_bindTabLayoutPointerDrag`, mouse/pen only, bound once on the container): rows move before/after a row or into a group, a header drag reorders groups, and the drop maps to ONE operation through the pure `dropOperation()`. Escape cancels a drag, and the click that ends one is swallowed. ⚠️ A press is only captured once it moves 6 px, so its release can land outside the rail: `pointerup`/`pointercancel` are heard on `window` while a press is pending, a move with the primary button up cancels it, and a new press cancels any previous one. Without that a stale press became a phantom drag on the next hover, and a replaced drag left its capture-phase Escape listener behind, swallowing every Escape before the terminal saw it. The flat rail and the header strip keep the HTML5 drag untouched. For the same reason Ctrl+Shift+{ / } only swaps with a neighbour in the active session's own section (`_canSwapActiveTabWith`, reading the projection's `sectionByRef`): a cross-group swap moves nothing on the server, gets no `session:orderChanged` back, and would leave this client's `sessionOrder` and Alt+N targets out of step with every other device. - **Edits are named operations, saved serially.** `createEditCoordinator` applies `createGroup` / `renameGroup` / `deleteGroup` / `reorderGroup` / `moveRef` to the rail at once, then sends ONE `PUT /api/tab-layout {baseVersion, layout}` at a time; edits made meanwhile wait and go out on the version that write returns. A 409 carries the server's layout: the in-flight operations are replayed onto it (an operation that no longer applies is dropped and reported) and re-sent, at most `maxAttempts` times; a 400 re-reads once (a second 400 is reported as a failed save, not as a race); anything else reports and re-reads. A `moveRef` moves the session together with the sessions that still follow it and marks a hand-moved child `placement: 'manual'`, mirroring the server's `moveRef`, and `normalizeLayout` keeps `placement` because whole layouts are written back. `_onTabLayoutChanged` / `_applyTabLayout` defer a read while a write is in flight and rebase unsaved edits onto a read otherwise. ⚠️ A FAILED read (`_applyTabLayout(null)`) while edits are pending keeps the held layout and the editor and re-reads after the write settles: disposing there would orphan the in-flight write, whose 409 then never gets its rebase. Any path that does drop unsaved work says so in a toast. On `pagehide`, unconfirmed operations go out in a `keepalive` PUT AND into sessionStorage as `{ owner, baseVersion, savedAt, operations }`; after reload they replay onto the fresh layout (a no-op when the keepalive landed), but only for the same owner, within `TAB_LAYOUT_PENDING_MAX_AGE_MS` (60 s), and never onto a layout older than the copy's base. A "Move to " with no anchor carries no `index`, so a replay still puts the row last. - **Every way in has a keyboard path.** The session row menu (Shift+F10 in the tree, the rail's overflow button) gains Move up/down, Move to , Move to Ungrouped and Move to new group in the vertical rail (only "new group" before the first group exists; nothing on the strip). A group header opens its menu with Shift+F10 / ContextMenu, right-click or its hover glyph (a non-focusable, `aria-hidden` span: a treeitem holds no interactive children), and F2 renames it inline (Enter commits and refocuses the header; a commit by BLUR leaves focus where it went, since refocusing from inside the blur handler overrides the user's click). The glyph stays visible under `@media (hover: none)`: a touch tablet has no hover and no long-press `contextmenu`. Group names in "Move to" labels are quoted, so a group named "New group" or "ungrouped" cannot read (or translate, case-insensitively) like the fixed entries. The menu closes on Escape (which it consumes before the global Escape handler), a pointer outside, Tab, focus leaving it, a resize, a second open and any full re-render. It borrows the `.tab-rail-action-menu` class for its look only: `closeTabRailActionMenu()` excludes `.tab-layout-group-action-menu`, so closing the row menu (every `session:deleted` does) cannot strand the group menu's listeners. The inline group editor shares `_activeRename` with the session rename, so only the CURRENT editor may release `_inlineRenameActive`. +- **New, child and web tabs land in groups.** A group menu's "New session" (`createSessionInTabGroup`) runs ONE launch with `run({ count: 1, tabGroupId })`; every launch path puts `tabGroupId` on its create request (`POST /api/sessions` or `/api/quick-start`), and `TabLayoutService.sessionCreated(owner, id, { tabGroupId }, register)` places the session at the end of that group in the SAME versioned write that first records it. The session enters the live map inside the owner lock (`register`, rolled back if the write fails), so no queued layout edit can commit it first. The group is looked up in the creating owner's own layout, so an unknown, deleted or foreign id is ignored rather than failing the spawn, and an explicit group is a hand placement (a child placed this way is `manual`). The create response carries the committed `tabLayout`, adopted before the tab is drawn. A session spawned by another one (`parentSessionId`) and placed by nobody FOLLOWS its parent: the server's normalization puts it after the parent's rows in the parent's container, and `project({ parents })` draws a not-yet-stored child there at once. A hand move makes it `manual`; the row menu says where a child sits (following, placed by hand, parent closed, parent loop) and offers "Follow parent again" only to a `manual` child, which clears `placement` and is re-derived by the server on save. A child whose parent is gone stands on its own and is never re-adopted. Saved web tabs are layout refs like sessions, but OPENNESS stays per device: a grouped web tab renders (and takes an Alt+N slot) only while it is open on this device, and reopens where the layout keeps it. - **Only the grouped rail is a tree.** `#sessionTabs` ships as `role=tablist` with `role=tab` rows, and the header strip, sidebar and flat rail keep exactly that. While grouped, `_applyTabListRole` makes it `role=tree` (and restores `tablist` + its label when grouping ends), named-group headers are level-1 `treeitem`s that `aria-owns` their rows' `role=group` (rows sit beside the header, not inside it), and ungrouped rows plus a collapsed group's kept selection are level-1 items. A collapsed header owns nothing, a group with no open rows is a leaf (no `aria-expanded`, no owned group), and the "Ungrouped" heading is `aria-hidden`. Rows are re-roled in the DOM by `_applyTabTreeSemantics` after render, never by rewriting their markup, so a grouped row's content stays the flat row's. - **One tab stop in the tree.** Exactly one treeitem carries `tabindex=0` (the focused or selected item); every control inside a row drops to `-1`, which is why Shift+F10 / ContextMenu open a row's actions from the keyboard. Focus survives a full re-render by identity (`group:`/`session:`/`webview:`; a row a collapse just hid hands focus to its header), but only when focus was already inside the rail. The tree walk (`_tabTreeItems`) follows painted order WITHIN each group when the rail is sorted; the flat list keeps its own whole-list computed-order walk. `aria-posinset`/`aria-setsize` follow painted order too, so the incremental render path re-runs `_applyTabTreePositions` after it re-sorts rows in place. ⚠️ `_handleTabTreeKeydown` acts only when the key lands on the treeitem ITSELF: a key on a focused in-row control (close, overflow, the rename input) is that control's, or Enter on the overflow button re-selects the row instead of reopening its menu. ⚠️ The roving `tabindex=-1` also hides every item but the stop from the keyboard-dismiss selector's `[tabindex]` arm, which is why `MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR` lists `[role="treeitem"]` (see Dismissing the on-screen keyboard). diff --git a/src/tab-layout-service.ts b/src/tab-layout-service.ts index fe2967c36..c7f96729a 100644 --- a/src/tab-layout-service.ts +++ b/src/tab-layout-service.ts @@ -11,6 +11,7 @@ import { applyLegacySessionRank, recomposeGlobalSessionOrder } from './tab-layou import { flattenOwnerSessionOrder, materializeOrphans, + moveRef, normalizeTabLayout, TabLayoutValidationError, validateTabLayout, @@ -67,6 +68,18 @@ export interface RemovedTabLayoutSession { owner?: string; } +/** Where a newly created session asked to be placed. Advisory: an unknown group is ignored. */ +export interface SessionPlacementHint { + tabGroupId?: string; +} + +/** + * Adds the created session to the live map. Runs INSIDE the owner lock, so no + * other layout mutation can observe (and commit) the session first; the returned + * function undoes it when the layout write fails. + */ +export type RegisterCreatedSession = () => void | (() => void); + interface PreparedOwnerLayout { current: TabLayout | null; authoritative: TabLayout; @@ -97,6 +110,25 @@ const sameLayout = (a: TabLayout, b: TabLayout): boolean => JSON.stringify(a) == const sameOrder = (a: readonly string[], b: readonly string[]): boolean => a.length === b.length && a.every((id, index) => id === b[index]); +/** Move a just-created session to the end of `groupId`, or leave the layout alone. */ +function placeCreatedSession( + layout: TabLayout, + sessionId: string, + groupId: string, + metadata: readonly TabRefMetadata[] +): TabLayout { + const group = layout.groups.find((candidate) => candidate.id === groupId); + if (!group) return layout; + const index = group.refs.filter((ref) => ref.kind !== 'session' || ref.id !== sessionId).length; + try { + return moveRef(layout, { kind: 'session', id: sessionId }, { groupId, index }, metadata); + } catch (error) { + // Not an owner-valid ref of this layout: the hint does not apply. + if (error instanceof TabLayoutValidationError) return layout; + throw error; + } +} + export class TabLayoutService { private restorationState: 'pending' | 'complete' | 'failed' | 'skipped' = 'pending'; private readonly ownerQueues = new Map>(); @@ -320,9 +352,13 @@ export class TabLayoutService { }; } - private async getUnlocked(owner: string): Promise { + private async getUnlocked( + owner: string, + adjust?: (layout: TabLayout, metadata: readonly TabRefMetadata[]) => TabLayout + ): Promise { const prepared = await this.prepareUnlocked(owner); - if (!prepared.needsReconciliationCommit) { + const next = adjust ? adjust(prepared.authoritative, prepared.metadata) : prepared.authoritative; + if (!prepared.needsReconciliationCommit && sameLayout(next, prepared.authoritative)) { const publication = { owner, previous: prepared.current, @@ -335,7 +371,7 @@ export class TabLayoutService { return prepared.authoritative; } const base = prepared.current ?? { ...prepared.authoritative, version: -1 }; - return this.commit(owner, base, prepared.authoritative, prepared.metadata); + return this.commit(owner, base, next, prepared.metadata, prepared.current); } async get(owner: string): Promise { @@ -442,9 +478,37 @@ export class TabLayoutService { } } - /** Reconcile one completed session creation into one versioned mutation. */ - async sessionCreated(owner: string): Promise { - return this.get(owner); + /** + * Reconcile one completed session creation into one versioned mutation. + * + * `placement.tabGroupId` puts the new session at the end of that group in the + * same write. The group is looked up in THIS owner's layout only, so a group id + * from another owner (or one deleted meanwhile) is simply not found and the + * session keeps its normal placement: after its parent when it has one, + * otherwise at the end of Ungrouped. An explicit group is a hand placement, so + * a child session placed this way is marked `manual` and stops following. + */ + async sessionCreated( + owner: string, + sessionId?: string, + placement: SessionPlacementHint = {}, + register?: RegisterCreatedSession + ): Promise { + return this.withOwner(owner, async () => { + const rollback = register?.(); + try { + const groupId = placement.tabGroupId; + return await this.getUnlocked( + owner, + sessionId && groupId + ? (layout, metadata) => placeCreatedSession(layout, sessionId, groupId, metadata) + : undefined + ); + } catch (error) { + if (typeof rollback === 'function') rollback(); + throw error; + } + }); } /** Reconcile one completed saved-webview creation into one versioned mutation. */ diff --git a/src/web/ports/session-port.ts b/src/web/ports/session-port.ts index 78e1fbbdd..adb4e85d9 100644 --- a/src/web/ports/session-port.ts +++ b/src/web/ports/session-port.ts @@ -5,10 +5,17 @@ import type { Session } from '../../session.js'; import type { SessionState } from '../../types.js'; +import type { TabLayout } from '../../tab-layout.js'; +import type { SessionPlacementHint } from '../../tab-layout-service.js'; export interface SessionPort { readonly sessions: ReadonlyMap; - addSession(session: Session): Promise; + /** + * Register a created session with the owner's tab layout. `placement.tabGroupId` + * puts it at the end of that group in the same layout write (ignored when the + * group is not in the owner's layout). Resolves to the committed layout. + */ + addSession(session: Session, placement?: SessionPlacementHint): Promise; cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise; setupSessionListeners(session: Session): Promise; persistSessionState(session: Session): void; diff --git a/src/web/public/app.js b/src/web/public/app.js index 3bfdd38ba..f854de54b 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2053,10 +2053,17 @@ class CodemanApp { _onSessionCreated(data) { this.sessions.set(data.id, data); - // Add new session to end of tab order if (!this.sessionOrder.includes(data.id)) { - this.sessionOrder.push(data.id); - this.saveSessionOrder(); + if (this._serverPlacesSessions()) { + // The server already placed it (a child after its parent, a grouped + // creation in its group) and broadcast that order; adopt it. Echoing a + // local append back as a hand order would pin every child in place. + this.syncSessionOrder(); + } else { + // Add new session to end of tab order + this.sessionOrder.push(data.id); + this.saveSessionOrder(); + } } // Idempotent per id: the POST response and the session:created event both // land here, and a batch launched together cascades in creation order. @@ -6476,6 +6483,8 @@ class CodemanApp { collapsedGroupIds: [...this.collapsedTabGroupIds], activeSessionId: this.activeSessionId, activeWebviewId: this.activeWebviewId, + // A session spawned by another one shows up in its parent's group at once. + parents: this._tabLayoutParents(), }); } @@ -6524,6 +6533,18 @@ class CodemanApp { return !!(this.tabLayout && window.CodemanTabLayout && this._tabOrientation() === 'vertical'); } + /** + * True while the owner has tab groups: session creation and deletion are then + * placed by the server (TabLayoutService), which broadcasts the resulting + * order. The browser must not echo its own guess back through + * PUT /api/session-order, which the server reads as a hand arrangement and + * answers by pinning every child session where it stands. Without groups the + * strip keeps its long-standing append-and-save behaviour. + */ + _serverPlacesSessions() { + return !!(this.tabLayout && window.CodemanTabLayout?.hasGroups(this.tabLayout)); + } + /** child session id -> parent session id, so a moved session takes the sessions that follow it. */ _tabLayoutParents() { const parents = {}; @@ -6673,8 +6694,8 @@ class CodemanApp { if (!this._tabLayoutEditable()) return []; const location = this._tabRefLocation(ref); if (!location) return []; - const actions = []; const grouped = this.tabLayout.groups.length > 0; + const actions = grouped ? this._tabRefPlacementActions(ref) : []; // Up/down follow the STORED order, which is what the rail paints unless a // sort is on (then the sort decides and there is nothing to reorder). if (grouped && !this.isTabRailSorted()) { @@ -6698,6 +6719,52 @@ class CodemanApp { return actions; } + /** + * A child session's placement, for its row menu: an informational line (where + * it sits and why, never actionable) and, for a child placed by hand, "Follow + * parent again". A child follows its parent into the parent's group until it + * is moved by hand; a child whose parent is gone stands on its own and is + * never re-adopted automatically. + */ + _tabRefPlacementActions(ref) { + const parents = this._tabLayoutParents(); + const placement = window.CodemanTabLayout.placementState(this.tabLayout, ref, parents); + if (!placement) return []; + const parent = this.sessions.get(placement.parentId); + const parentName = parent ? this.getSessionName(parent) : placement.parentId; + const summary = { + inherited: `Follows ${parentName}`, + manual: `Placed by hand (parent: ${parentName})`, + dangling: 'Parent closed; placed on its own', + cycle: 'Parent loop; placed on its own', + }[placement.state]; + const actions = [{ label: summary, disabled: true }]; + if (placement.canFollow) { + actions.push({ + label: 'Follow parent again', + run: () => this.editTabLayout({ type: 'followParent', ref, parents: this._tabLayoutParents() }, `${ref.kind}:${ref.id}`), + }); + } + return actions; + } + + /** + * The group menu's "New session": launch ONE session with the current case and + * run mode, placed at the end of this group by the server in the same write + * that records it (the group id rides the create request). A collapsed group + * expands first so the new tab is visible when it arrives. + */ + async createSessionInTabGroup(groupId) { + if (!this._tabLayoutEditable() || !this.tabLayout.groups.some((group) => group.id === groupId)) return false; + if (this._runInFlight) { + this.showToast?.('A session is already starting.', 'info'); + return false; + } + this.toggleTabGroupCollapsed(groupId, false); + await this.run({ count: 1, tabGroupId: groupId }); + return true; + } + // ─── Group and web-tab menus (right-click, the header's ⋯, Shift+F10) ── /** @@ -6733,6 +6800,7 @@ class CodemanApp { const index = groups.findIndex((group) => group.id === groupId); if (index < 0) return false; return this._openTabLayoutMenu(event, `group:${groupId}`, 'Group actions', [ + { label: 'New session', run: () => this.createSessionInTabGroup(groupId) }, { label: 'Rename group', run: () => this.startTabGroupRename(groupId) }, { label: 'New group', run: () => this.createTabGroup({ index: index + 1 }) }, ...(index > 0 ? [{ label: 'Move group up', run: () => this.moveTabGroup(groupId, -1) }] : []), @@ -8653,7 +8721,8 @@ class CodemanApp { const orderIndex = this.sessionOrder.indexOf(sessionId); if (orderIndex !== -1) { this.sessionOrder.splice(orderIndex, 1); - this.saveSessionOrder(); + // With groups, the server's deletion already re-projected the order. + if (!this._serverPlacesSessions()) this.saveSessionOrder(); } this.terminalBuffers.delete(sessionId); this.terminalBufferCache.delete(sessionId); diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index cee646c82..543af99ae 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -81,6 +81,11 @@ 'Move down': '下移', 'Move to Ungrouped': '移到未分组', 'Move to new group': '移到新分组', + 'New session': '新建会话', + 'A session is already starting.': '已有会话正在启动。', + 'Follow parent again': '重新跟随父会话', + 'Parent closed; placed on its own': '父会话已关闭;单独放置', + 'Parent loop; placed on its own': '父会话形成循环;单独放置', 'Could not save tab groups.': '无法保存标签分组。', 'Tab groups changed elsewhere; part of your edit no longer applies.': '标签分组已在别处更改;你的部分编辑已不再适用。', @@ -954,6 +959,9 @@ [/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`], // Group names are user text: they pass through untranslated. [/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`], + // Session names are user text too. + [/^Follows (.+)$/, (_m, parent) => `跟随 ${parent}`], + [/^Placed by hand \(parent: (.+)\)$/, (_m, parent) => `已手动放置(父会话:${parent})`], [ /^Delete group "(.+)"\? Its tabs move to Ungrouped\.$/, (_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`, diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index e8e84dc3c..0752399b1 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -560,9 +560,12 @@ Object.assign(CodemanApp.prototype, { /** Ensure a newly-created session is visible without waiting for the SSE event. * The POST response and session:created can arrive in either order, so the - * normal idempotent SSE handler remains the single state-upsert path. */ - async _ensureCreatedSessionVisible(sessionId, sessionSnapshot) { + * normal idempotent SSE handler remains the single state-upsert path. + * `tabLayout` is the layout a grouped creation returns (see run()'s + * `tabGroupId`): adopting it first draws the new tab inside its group. */ + async _ensureCreatedSessionVisible(sessionId, sessionSnapshot, tabLayout) { if (!sessionId) return; + if (tabLayout) this._applyTabLayout?.(tabLayout); let session = sessionSnapshot; if (!session && !this.sessions?.has(sessionId)) { @@ -578,8 +581,13 @@ Object.assign(CodemanApp.prototype, { this._renderSessionTabsImmediate?.(); }, - /** Run using the selected mode (Claude Code, OpenCode, Codex, Gemini, or Antigravity) */ - async run() { + /** + * Run using the selected mode (Claude Code, OpenCode, Codex, Gemini, or Antigravity). + * `options` comes from the tab group menu's "New session": `count` overrides + * the instance steppers for this launch only, `tabGroupId` rides every create + * request so the server places the session in that group. + */ + async run(options = {}) { if (this._runInFlight) return; const startedAt = Date.now(); @@ -594,12 +602,12 @@ Object.assign(CodemanApp.prototype, { try { const mode = this._runMode || 'claude'; if (mode === 'shell') { - return await this.runShell(); + return await this.runShell(options); } if (mode === 'claude' || !isExternalCliRunMode(mode)) { - return await this.runClaude(); + return await this.runClaude(options); } - return await this._runCliMode(mode); + return await this._runCliMode(mode, options); } finally { const remaining = minLockMs - (Date.now() - startedAt); if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining)); @@ -1858,9 +1866,10 @@ Object.assign(CodemanApp.prototype, { } }, - async runClaude() { + async runClaude(options = {}) { const caseName = document.getElementById('quickStartCase').value || 'testcase'; - const tabCount = this._readTabCount(); + const tabCount = this._readTabCount(options.count); + const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {}; const ownsLaunchTerminal = this._beginSessionLaunchStatus( `Starting ${tabCount} Claude session(s) in ${caseName}...` @@ -1916,6 +1925,7 @@ Object.assign(CodemanApp.prototype, { for (let i = 0; i < tabCount; i++) { const quickStartBody = JSON.stringify({ caseName, mode: 'claude', sessionName: `w${startNumber + i}-${caseName}`, + ...group, ...(dockerModelOverride !== undefined ? { modelOverride: dockerModelOverride } : {}) }); const doQuickStart = async () => { @@ -1944,7 +1954,7 @@ Object.assign(CodemanApp.prototype, { } } if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session'); - await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); + await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session, data.data.tabLayout); remoteIds.push(data.data.sessionId); } this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} remote session(s) ready`); @@ -1990,6 +2000,7 @@ Object.assign(CodemanApp.prototype, { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workingDir, name, + ...group, ...(hasEnvOverrides ? { envOverrides } : {}), ...(effort ? { effort } : {}), ...(advisorModel ? { advisorModel } : {}), @@ -2003,7 +2014,7 @@ Object.assign(CodemanApp.prototype, { const sessionIds = []; for (const result of createResults) { if (!result.success) throw new Error(result.error); - await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session); + await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session, result.data.tabLayout); sessionIds.push(result.data.session.id); } firstSessionId = sessionIds[0]; @@ -2072,9 +2083,13 @@ Object.assign(CodemanApp.prototype, { } }, - async runShell() { + async runShell(options = {}) { const caseName = document.getElementById('quickStartCase').value || 'testcase'; - const shellCount = Math.min(20, Math.max(1, parseInt(document.getElementById('shellCount').value) || 1)); + const shellCount = Math.min( + 20, + Math.max(1, options.count ?? (parseInt(document.getElementById('shellCount').value) || 1)) + ); + const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {}; const ownsLaunchTerminal = this._beginSessionLaunchStatus( `Starting ${shellCount} Shell session(s) in ${caseName}...`, @@ -2116,11 +2131,11 @@ Object.assign(CodemanApp.prototype, { const res = await fetch('/api/quick-start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caseName, mode: 'shell', sessionName: `s${startNumber + i}-${caseName}` }) + body: JSON.stringify({ caseName, mode: 'shell', sessionName: `s${startNumber + i}-${caseName}`, ...group }) }); const data = await res.json(); if (!data.success) throw new Error(data.error || 'Failed to start remote shell session'); - await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); + await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session, data.data.tabLayout); remoteIds.push(data.data.sessionId); } if (remoteIds[0]) { @@ -2146,7 +2161,7 @@ Object.assign(CodemanApp.prototype, { fetch('/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...(isRemoteCase ? { caseName } : { workingDir }), mode: 'shell', name }) + body: JSON.stringify({ ...(isRemoteCase ? { caseName } : { workingDir }), mode: 'shell', name, ...group }) }).then(r => r.json()) ); const createResults = await Promise.all(createPromises); @@ -2154,7 +2169,7 @@ Object.assign(CodemanApp.prototype, { const sessionIds = []; for (const result of createResults) { if (!result.success) throw new Error(result.error); - await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session); + await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session, result.data.tabLayout); sessionIds.push(result.data.session.id); } @@ -2195,8 +2210,8 @@ Object.assign(CodemanApp.prototype, { * their try block, to put the count in the opening banner: `#tabCount` ships * unconditionally today, but a throw here would escape the launch-error path. */ - _readTabCount() { - return Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount')?.value) || 1)); + _readTabCount(override) { + return Math.min(20, Math.max(1, override ?? (parseInt(document.getElementById('tabCount')?.value) || 1))); }, /** @@ -2229,7 +2244,7 @@ Object.assign(CodemanApp.prototype, { ); if (!data.success) throw new Error(data.error || `Failed to start ${label}`); customModelAnswered = true; - await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); + await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session, data.data.tabLayout); if (!firstSessionId) firstSessionId = data.data.sessionId; } if (tabCount > 1) { @@ -2247,7 +2262,7 @@ Object.assign(CodemanApp.prototype, { * menu call them directly by name (`app.runOpenCode()` etc.), and several * tests assert on that name directly too. */ - async _runCliMode(mode) { + async _runCliMode(mode, options = {}) { const catalogEntry = registryCliById(mode); const entry = RUN_MODE_LAUNCH[mode] || (catalogEntry && { @@ -2264,7 +2279,7 @@ Object.assign(CodemanApp.prototype, { const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; - const tabCount = this._readTabCount(); + const tabCount = this._readTabCount(options.count); const ownsLaunchTerminal = this._beginSessionLaunchStatus( `Starting ${tabCount} ${entry.label} session(s) in ${caseName}...` ); @@ -2302,6 +2317,7 @@ Object.assign(CodemanApp.prototype, { caseName, mode, sessionName, + ...(options.tabGroupId ? { tabGroupId: options.tabGroupId } : {}), ...(isRemote ? {} : { ...(entry.buildConfig(globalSettings) || {}), ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 8ba70b264..433b86944 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2804,6 +2804,17 @@ html[data-tab-orientation='vertical'].tab-rail-compact .session-tab .tab-actions color: var(--red); } +/* A child session's placement line: information, not an action. */ +.tab-rail-action-menu button.tab-rail-action-menu-note { + color: var(--text-muted); + font-size: 0.85em; + cursor: default; +} + +.tab-rail-action-menu button.tab-rail-action-menu-note:hover { + background: transparent; +} + :is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-range-field { display: grid; grid-template-columns: minmax(112px, 1fr) 44px; diff --git a/src/web/public/tab-layout-browser.js b/src/web/public/tab-layout-browser.js index 6aa489396..c41adeea6 100644 --- a/src/web/public/tab-layout-browser.js +++ b/src/web/public/tab-layout-browser.js @@ -131,6 +131,12 @@ * tab, else the active session), so selecting a hidden session by keyboard, * palette or Alt+N never leaves the user with no visible selection. * + * `options.parents` (child session id -> parent session id) places a live + * session the layout has not stored yet right after its parent's rows when + * the parent IS stored: that is where the server puts a child that follows its + * parent, so a spawned session appears in its parent's group at once instead + * of jumping there from Ungrouped once the layout re-read lands. + * * @returns {null | { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef }} * null when the layout has no groups: the caller renders the flat rail * unchanged. Each section lists the rows it shows (`refs`) and the rows its @@ -152,6 +158,7 @@ ? `session:${options.activeSessionId}` : ''; const renderable = (ref) => (ref.kind === 'session' ? live.has(ref.id) : open.has(ref.id)); + const withFollowers = followingChildren(layout, liveSessionIds, options.parents); const placed = new Set(); const visibleRefs = []; const hiddenTabGroupByRef = {}; @@ -182,14 +189,14 @@ for (const group of layout.groups) { const isCollapsed = collapsed.has(group.id); - const { shown, hidden, count } = place(group.refs, group.id, isCollapsed); + const { shown, hidden, count } = place(withFollowers(group.refs), group.id, isCollapsed); sections.push({ id: group.id, name: group.name, refs: shown, hidden, count, collapsed: isCollapsed }); } const omissions = [ ...liveSessionIds.map((id) => ({ kind: 'session', id })), ...openWebviewIds.map((id) => ({ kind: 'webview', id })), ]; - const ungrouped = place([...layout.ungrouped, ...omissions], null, false); + const ungrouped = place([...withFollowers(layout.ungrouped), ...omissions], null, false); if (ungrouped.count > 0) { sections.push({ id: null, @@ -227,6 +234,47 @@ return result; } + /** + * For project(): returns `expand(refs)`, which inserts each live, not-yet-stored + * child session right after its parent's rows in the parent's container. A + * child of such a child follows it the same way. Without `parents` it is the + * identity, so callers that pass no lineage see no change. + */ + function followingChildren(layout, liveSessionIds, parents) { + if (!parents || typeof parents !== 'object') return (refs) => refs; + const stored = new Set(refLocations(layout).map((item) => refKey(item.ref))); + const accepted = new Set(); + const pending = new Map(); + // Accept parents before their children (bounded: one pass per accepted child). + for (let changed = true; changed; ) { + changed = false; + for (const id of liveSessionIds) { + const key = `session:${id}`; + const parentId = parents[id]; + if (stored.has(key) || accepted.has(key) || typeof parentId !== 'string' || parentId === id) continue; + const parentKey = `session:${parentId}`; + if (!stored.has(parentKey) && !accepted.has(parentKey)) continue; + accepted.add(key); + if (!pending.has(parentKey)) pending.set(parentKey, []); + pending.get(parentKey).push({ kind: 'session', id }); + changed = true; + } + } + if (!pending.size) return (refs) => refs; + return (refs) => { + const out = [...refs]; + for (const [parentKey, children] of pending) { + const block = lineageBlock(layout, { kind: 'session', id: parentKey.slice(8) }, parents); + let at = -1; + out.forEach((ref, index) => { + if (block.has(refKey(ref))) at = index; + }); + if (at >= 0) out.splice(at + 1, 0, ...children); + } + return out; + }; + } + /** * Everything that changes the grouped rail's STRUCTURE (which rows exist and * where, the headers' names, what a collapse hides), as opposed to a row's own @@ -418,6 +466,35 @@ return keys; } + /** + * How a child session is placed, for the row menu. null for a session with no + * parent (or not stored). Otherwise `{ state, parentId, canFollow }`: + * - inherited: it follows its parent (moves with it). + * - manual: placed by hand; "Follow parent again" puts it back. + * - dangling: its parent is not in this layout (closed, or another owner's), + * so it stands on its own; nothing re-adopts it automatically. + * - cycle: the parent chain loops back to it; it stands on its own. + */ + function placementState(layoutInput, ref, parents) { + const layout = normalizeLayout(layoutInput); + if (!validRef(ref) || ref.kind !== 'session') return null; + const parentId = parents?.[ref.id]; + if (typeof parentId !== 'string' || !parentId || parentId === ref.id) return null; + const stored = new Map(refLocations(layout).map((item) => [refKey(item.ref), item.ref])); + const own = stored.get(refKey(ref)); + if (!own) return null; + if (!stored.has(`session:${parentId}`)) return { state: 'dangling', parentId, canFollow: false }; + // Walk up the stored ancestors; meeting this session again is a loop. + const seen = new Set([ref.id]); + for (let id = parentId; id && stored.has(`session:${id}`); id = parents[id]) { + if (seen.has(id)) return { state: 'cycle', parentId, canFollow: false }; + seen.add(id); + } + return own.placement === 'manual' + ? { state: 'manual', parentId, canFollow: true } + : { state: 'inherited', parentId, canFollow: false }; + } + /** * Where a moved row lands, as the server's `index` (counted AFTER the moved * block is taken out): before or after `anchor` in that container, or at its @@ -546,6 +623,36 @@ destination.splice(clampIndex(op.index, destination.length), 0, ...block); return layout; } + case 'followParent': { + // Clear a hand placement: the child (with the sessions that follow it) + // goes back after its parent's rows. The server re-derives the exact + // order on save; this only has to put the row in the right group now. + const state = placementState(layout, op.ref, op.parents); + if (!state || state.state === 'dangling' || state.state === 'cycle') editError('no parent to follow'); + if (state.state !== 'manual') return layout; + const targetKey = refKey(op.ref); + const clear = (ref) => (refKey(ref) === targetKey ? { kind: ref.kind, id: ref.id } : ref); + for (const group of layout.groups) group.refs = group.refs.map(clear); + layout.ungrouped = layout.ungrouped.map(clear); + const childKeys = new Set(); + for (const key of lineageBlock(layout, op.ref, op.parents)) childKeys.add(key); + const block = refLocations(layout) + .filter((item) => childKeys.has(refKey(item.ref))) + .map((item) => copyRef(item.ref)); + block.sort((a, b) => (refKey(a) === targetKey ? -1 : refKey(b) === targetKey ? 1 : 0)); + for (const group of layout.groups) group.refs = group.refs.filter((ref) => !childKeys.has(refKey(ref))); + layout.ungrouped = layout.ungrouped.filter((ref) => !childKeys.has(refKey(ref))); + const parentRef = { kind: 'session', id: state.parentId }; + const home = refLocations(layout).find((item) => refKey(item.ref) === refKey(parentRef)); + const container = containerRefs(layout, home.groupId); + const parentBlock = lineageBlock(layout, parentRef, op.parents); + let at = -1; + container.forEach((ref, index) => { + if (parentBlock.has(refKey(ref))) at = index; + }); + container.splice(at + 1, 0, ...block); + return layout; + } default: return editError(`unknown operation ${op.type}`); } @@ -734,6 +841,7 @@ applyOperation, moveDestination, movingRefKeys: (layout, ref, parents) => [...lineageBlock(normalizeLayout(layout), ref, parents)], + placementState, dropOperation, contentKey, createEditCoordinator, diff --git a/src/web/public/tab-rail-resize.js b/src/web/public/tab-rail-resize.js index ae9066410..bbf54531b 100644 --- a/src/web/public/tab-rail-resize.js +++ b/src/web/public/tab-rail-resize.js @@ -340,7 +340,13 @@ Object.assign(CodemanApp.prototype, { button.setAttribute('role', 'menuitem'); button.textContent = action.label; if (action.className) button.className = action.className; + // An informational line (a child session's placement): readable, never acts. + if (action.disabled) { + button.setAttribute('aria-disabled', 'true'); + button.classList.add('tab-rail-action-menu-note'); + } button.addEventListener('click', () => { + if (action.disabled) return; this.closeTabRailActionMenu(); action.run(); }); diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index bb4bdbe0c..fafc49a1f 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -1164,7 +1164,9 @@ export function registerSessionRoutes( parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner), }); - await ctx.addSession(session); + const tabLayout = body.tabGroupId + ? await ctx.addSession(session, { tabGroupId: body.tabGroupId }) + : await ctx.addSession(session); ctx.store.incrementSessionsCreated(); ctx.persistSessionState(session); await ctx.setupSessionListeners(session); @@ -1181,7 +1183,9 @@ export function registerSessionRoutes( // Avoids serializing 2-3MB of terminal+text buffers per session creation. const lightState = ctx.getSessionStateWithRespawn(session); ctx.broadcast(SseEvent.SessionCreated, lightState); - return { session: lightState }; + // A grouped creation also returns the layout that placed it, so the browser + // draws the tab inside its group at once instead of after a re-read. + return { session: lightState, ...(body.tabGroupId ? { tabLayout } : {}) }; }); // ========== Rename Session ========== @@ -3432,6 +3436,7 @@ export function registerSessionRoutes( parentSessionId, agentOrigin, customModel, + tabGroupId, } = parseBody(QuickStartSchema, req.body); // Resolved ONCE here: the same value labels a case directory this request creates @@ -4060,7 +4065,7 @@ export function registerSessionRoutes( } } - await ctx.addSession(session); + const tabLayout = tabGroupId ? await ctx.addSession(session, { tabGroupId }) : await ctx.addSession(session); ctx.store.incrementSessionsCreated(); ctx.persistSessionState(session); await ctx.setupSessionListeners(session); @@ -4145,6 +4150,7 @@ export function registerSessionRoutes( casePath: resolvedCasePath, caseName, ...(customModel ? { modelSwapInProgress: qsCustomModelSwapInProgress } : {}), + ...(tabGroupId ? { tabLayout } : {}), }; } catch (err) { // Clean up session on error to prevent orphaned resources diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 48845fd00..62876d6a4 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -533,12 +533,23 @@ export const DeepSeekWebStartSchema = z */ const parentSessionIdSchema = z.string().max(100).optional(); +/** + * Tab group the new session should be placed in (the group's "New session" + * action). Advisory like parentSessionId: the layout service looks the id up in + * the creating owner's own layout and ignores one it cannot find, so a stale or + * foreign id never fails a spawn. A malformed value (null, empty, over-long) is + * still a 400, because only the browser sends it. + */ +const tabGroupIdSchema = z.string().min(1).max(100).optional(); + export const CreateSessionSchema = z.object({ workingDir: safePathSchema.optional(), mode: sessionModeSchema().optional(), name: z.string().max(100).optional(), /** Session that spawned this one — see parentSessionIdSchema. */ parentSessionId: parentSessionIdSchema, + /** Tab group to place the new session in: see tabGroupIdSchema. */ + tabGroupId: tabGroupIdSchema, envOverrides: safeEnvOverridesSchema, /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ effort: effortLevelSchema, @@ -1073,6 +1084,8 @@ export const QuickStartSchema = z.object({ sessionName: z.string().max(128).optional(), /** Session that spawned this one — see parentSessionIdSchema. */ parentSessionId: parentSessionIdSchema, + /** Tab group to place the new session in: see tabGroupIdSchema. */ + tabGroupId: tabGroupIdSchema, /** Model override written to /.claude/settings.local.json (e.g. "opus[1m]"). * Empty string clears. Applied for local AND docker cases (the docker workspace is * a real host dir, so the settings file crosses the bind mount); rejected for diff --git a/src/web/server.ts b/src/web/server.ts index b9f405562..89db1a115 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -59,7 +59,8 @@ import { RespawnController, RespawnConfig } from '../respawn-controller.js'; import type { TerminalMultiplexer } from '../mux-interface.js'; import { createMultiplexer } from '../mux-factory.js'; import { getStore } from '../state-store.js'; -import { TabLayoutService } from '../tab-layout-service.js'; +import { TabLayoutService, type SessionPlacementHint } from '../tab-layout-service.js'; +import type { TabLayout } from '../tab-layout.js'; import { ownerLayoutKey } from '../tab-layout-persistence.js'; import { readWebviews } from '../webview-store.js'; import { extractCompletionPhrase } from '../ralph-config.js'; @@ -698,15 +699,19 @@ export class WebServer extends EventEmitter { } } - /** Add a tentative session only after its owner layout accepts the creation. */ - private async registerSessionWithLayout(session: Session): Promise { - this.sessions.set(session.id, session); - try { - await this.tabLayouts.sessionCreated(ownerLayoutKey(session.owner)); - } catch (error) { - this.sessions.delete(session.id); - throw error; - } + /** + * Add a tentative session only after its owner layout accepts the creation. + * The session enters the live map inside the layout service's owner lock, so + * the placement it asked for (`tabGroupId`) lands in the same versioned write + * that first records it. + */ + private async registerSessionWithLayout(session: Session, placement?: SessionPlacementHint): Promise { + return this.tabLayouts.sessionCreated(ownerLayoutKey(session.owner), session.id, placement, () => { + this.sessions.set(session.id, session); + return () => { + if (this.sessions.get(session.id) === session) this.sessions.delete(session.id); + }; + }); } /** diff --git a/test/routes/session-routes-tab-group.test.ts b/test/routes/session-routes-tab-group.test.ts new file mode 100644 index 000000000..cca196902 --- /dev/null +++ b/test/routes/session-routes-tab-group.test.ts @@ -0,0 +1,131 @@ +/** + * @fileoverview `tabGroupId` on the create routes: a session created from a + * group's "New session" action asks to be placed in that group. + * + * The routes only validate the field and hand it to `ctx.addSession()`; the + * placement itself (and the rule that an unknown or foreign group is ignored) + * lives in TabLayoutService, covered by test/tab-layout-service.test.ts. What is + * pinned here is the wire contract: the field is validated (a null or an empty + * string is a 400, never a silent drop), it reaches addSession with the new + * session, and the committed layout comes back on the response so the browser + * can draw the tab in its group without waiting for a re-read. Requests without + * the field keep their exact response shape. + * + * Uses app.inject(), so no real HTTP port is needed. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; +import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; +import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; +import { Session } from '../../src/session.js'; + +const LAYOUT = { + version: 4, + updatedAt: '2026-10-01T00:00:00.000Z', + groups: [{ id: 'g1', name: 'Core', refs: [] as Array<{ kind: 'session'; id: string }> }], + ungrouped: [] as Array<{ kind: 'session'; id: string }>, +}; + +describe('create routes: tabGroupId', () => { + let app: FastifyInstance; + let ctx: MockRouteContext; + let workingDir: string; + + beforeEach(async () => { + vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined); + vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined); + workingDir = await mkdtemp(join(tmpdir(), 'codeman-tab-group-')); + app = Fastify({ logger: false }); + await app.register(fastifyCookie); + ctx = createMockRouteContext(); + ctx.addSession.mockImplementation(async (session: { id: string }, placement?: { tabGroupId?: string }) => { + ctx.sessions.set(session.id, session as never); + const refs = [{ kind: 'session' as const, id: session.id }]; + return placement?.tabGroupId === 'g1' + ? { ...LAYOUT, version: 5, groups: [{ ...LAYOUT.groups[0], refs }] } + : { ...LAYOUT, version: 5, ungrouped: refs }; + }); + registerSessionRoutes(app, ctx); + installRouteErrorHandler(app); + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + await rm(workingDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + const body = (raw: string) => { + const parsed = JSON.parse(raw); + return parsed.data ?? parsed; + }; + + it('POST /api/sessions hands the group to addSession and returns the committed layout', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/sessions', + payload: { name: 'w1-x', mode: 'shell', workingDir, tabGroupId: 'g1' }, + }); + expect(res.statusCode).toBe(200); + const data = body(res.body); + expect(ctx.addSession).toHaveBeenCalledTimes(1); + expect(ctx.addSession.mock.calls[0][0].id).toBe(data.session.id); + expect(ctx.addSession.mock.calls[0][1]).toEqual({ tabGroupId: 'g1' }); + expect(data.tabLayout.version).toBe(5); + expect(data.tabLayout.groups[0].refs).toEqual([{ kind: 'session', id: data.session.id }]); + }); + + it('POST /api/sessions without a group keeps its response shape', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/sessions', + payload: { name: 'w1-x', mode: 'shell', workingDir }, + }); + expect(res.statusCode).toBe(200); + expect(Object.keys(body(res.body))).toEqual(['session']); + expect(ctx.addSession.mock.calls[0][1]).toBeUndefined(); + }); + + it('POST /api/quick-start carries the group the same way', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/quick-start', + payload: { caseName: 'tabgroupcase', mode: 'shell', tabGroupId: 'g1' }, + }); + expect(res.statusCode).toBe(200); + const data = body(res.body); + expect(ctx.addSession.mock.calls[0][1]).toEqual({ tabGroupId: 'g1' }); + expect(data.tabLayout.groups[0].refs).toEqual([{ kind: 'session', id: data.sessionId }]); + + const plain = await app.inject({ + method: 'POST', + url: '/api/quick-start', + payload: { caseName: 'tabgroupcase', mode: 'shell' }, + }); + expect(body(plain.body).tabLayout).toBeUndefined(); + }); + + it.each([ + ['null', null], + ['an empty string', ''], + ['a number', 7], + ['an over-long id', 'g'.repeat(101)], + ])('rejects %s as a tabGroupId with a 400 and creates nothing', async (_label, tabGroupId) => { + for (const [url, payload] of [ + ['/api/sessions', { name: 'w1-x', mode: 'shell', workingDir, tabGroupId }], + ['/api/quick-start', { caseName: 'tabgroupcase', mode: 'shell', tabGroupId }], + ] as const) { + const res = await app.inject({ method: 'POST', url, payload }); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).errorCode).toBe('INVALID_INPUT'); + } + expect(ctx.addSession).not.toHaveBeenCalled(); + }); +}); diff --git a/test/run-mode-dispatch.test.ts b/test/run-mode-dispatch.test.ts index 0301147ed..73bd21208 100644 --- a/test/run-mode-dispatch.test.ts +++ b/test/run-mode-dispatch.test.ts @@ -102,7 +102,7 @@ describe('run() dispatch (session-ui.js)', () => { app._runMode = id; await expect(app.run()).resolves.toBe(`cli:${id}`); expect(app._runCliMode).toHaveBeenCalledTimes(1); - expect(app._runCliMode).toHaveBeenCalledWith(id); + expect(app._runCliMode).toHaveBeenCalledWith(id, {}); expect(app.runClaude).not.toHaveBeenCalled(); expect(app.runShell).not.toHaveBeenCalled(); }); @@ -113,7 +113,7 @@ describe('run() dispatch (session-ui.js)', () => { app._runMode = 'custom-agent'; await expect(app.run()).resolves.toBe('cli:custom-agent'); expect(app._runCliMode).toHaveBeenCalledTimes(1); - expect(app._runCliMode).toHaveBeenCalledWith('custom-agent'); + expect(app._runCliMode).toHaveBeenCalledWith('custom-agent', {}); expect(app.runClaude).not.toHaveBeenCalled(); }); diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index 7f8224791..45f3df5fc 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -177,7 +177,9 @@ describe('Run launch synchronization', () => { // once. Confirmed live: adding `this.terminal.clear()` to `_runCliMode` // left this test 32/32 green under the old pattern. const bodies = new Map(); - const header = /^ {2}async (_?run[A-Za-z]*)\(\w*\) \{$/gm; + // Any parameter list (`(mode, options = {})` included), never `\(\w*\)` + // alone, or a launcher that grew a second parameter silently drops out. + const header = /^ {2}async (_?run[A-Za-z]*)\([^)\n]*\) \{$/gm; for (let m = header.exec(src); m; m = header.exec(src)) { const start = m.index + m[0].length; const end = src.indexOf('\n },', start); diff --git a/test/tab-group-launch.test.ts b/test/tab-group-launch.test.ts new file mode 100644 index 000000000..c0ffe846c --- /dev/null +++ b/test/tab-group-launch.test.ts @@ -0,0 +1,135 @@ +/** + * @fileoverview Launching a session INTO a tab group (the group menu's "New + * session"): session-ui.js run paths with `{ count, tabGroupId }`. + * + * Pins that the group id rides the create request of every launch path (local + * /api/sessions, remote/docker and external-CLI /api/quick-start), that the + * one-session count overrides the toolbar steppers without touching them, that + * the layout returned by the create response is adopted BEFORE the new tab is + * drawn (so it is drawn inside its group), and that an ordinary Run sends no + * group at all. + * + * Port: none (vm sandbox, stubbed fetch). + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +const LAYOUT = { version: 9, updatedAt: '', groups: [{ id: 'g1', name: 'Core', refs: [] }], ungrouped: [] }; + +function harness(caseData: Record, extra: { mode?: string } = {}) { + const elements: Record = { + quickStartCase: { value: 'proj' }, + tabCount: { value: '4' }, + shellCount: { value: '3' }, + }; + const requests: Array<{ url: string; body?: any }> = []; + const events: string[] = []; + let counter = 0; + const CodemanApp = function CodemanApp(this: any) {}; + const context = vm.createContext({ + CodemanApp, + localStorage: { getItem: () => null, setItem: () => {} }, + document: { getElementById: (id: string) => elements[id] ?? null }, + fetch: async (url: string, init?: { body?: string }) => { + const body = init?.body ? JSON.parse(init.body) : undefined; + requests.push({ url, body }); + const json = (value: unknown) => ({ json: async () => value }); + if (url === '/api/cases/proj') return json({ success: true, data: caseData }); + if (url.endsWith('/status')) return json({ success: true, data: { available: true, runnable: true } }); + if (url === '/api/sessions') { + const id = `sess-${++counter}`; + return json({ + success: true, + data: { session: { id, name: body.name }, ...(body.tabGroupId ? { tabLayout: LAYOUT } : {}) }, + }); + } + if (url === '/api/quick-start') { + const id = `sess-${++counter}`; + return json({ + success: true, + data: { sessionId: id, session: { id }, ...(body.tabGroupId ? { tabLayout: LAYOUT } : {}) }, + }); + } + return json({ success: true, data: {} }); + }, + console, + }); + for (const file of ['settings-ui.js', 'session-ui.js']) { + vm.runInContext(readFileSync(resolve(import.meta.dirname, '../src/web/public', file), 'utf8'), context, { + filename: file, + }); + } + const app = new (CodemanApp as any)(); + app._runMinLockMs = 0; + app._runMode = extra.mode ?? 'claude'; + app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {}, cols: 80, rows: 24 }; + app.sessions = new Map(); + app.cases = [{ name: 'proj', location: caseData.location }]; + app.loadAppSettingsFromStorage = () => ({}); + app.getCaseSettings = () => ({}); + app.buildEnvOverrides = () => ({}); + app.getEffortSetting = () => undefined; + app.isRalphTrackerEnabledByDefault = () => false; + app.getTerminalDimensions = () => null; + app.isCliAvailable = () => true; + app.loadQuickStartCases = () => {}; + app.selectSession = vi.fn(async () => {}); + app._applyTabLayout = vi.fn(() => events.push('layout')); + app._onSessionCreated = (session: any) => { + events.push(`created:${session.id}`); + app.sessions.set(session.id, session); + }; + app._renderSessionTabsImmediate = () => events.push('render'); + return { app, elements, requests, events }; +} + +const creates = (requests: Array<{ url: string; body?: any }>) => + requests.filter((req) => req.url === '/api/sessions' || req.url === '/api/quick-start'); + +describe('launching into a tab group', () => { + it('local Claude: one session with the group id, and the returned layout adopted before the tab renders', async () => { + const { app, elements, requests, events } = harness({ name: 'proj', path: '/tmp/proj' }); + await app.run({ count: 1, tabGroupId: 'g1' }); + const made = creates(requests); + expect(made).toHaveLength(1); + expect(made[0].url).toBe('/api/sessions'); + expect(made[0].body.tabGroupId).toBe('g1'); + expect(elements.tabCount.value).toBe('4'); + expect(app._applyTabLayout).toHaveBeenCalledWith(LAYOUT); + expect(events.slice(0, 3)).toEqual(['layout', 'created:sess-1', 'render']); + }); + + it('local Shell honours the same one-session override', async () => { + const { app, elements, requests } = harness({ name: 'proj', path: '/tmp/proj' }, { mode: 'shell' }); + await app.run({ count: 1, tabGroupId: 'g1' }); + const made = creates(requests); + expect(made).toHaveLength(1); + expect(made[0].body).toMatchObject({ mode: 'shell', tabGroupId: 'g1' }); + expect(elements.shellCount.value).toBe('3'); + }); + + it.each([ + ['remote Claude', { name: 'proj', path: 'u@h:/w', location: 'remote' }, 'claude'], + ['remote Shell', { name: 'proj', path: 'u@h:/w', location: 'remote' }, 'shell'], + ['an external CLI', { name: 'proj', path: '/tmp/proj' }, 'gemini'], + ])('%s carries the group on its quick-start request', async (_label, caseData, mode) => { + const { app, requests } = harness(caseData, { mode }); + await app.run({ count: 1, tabGroupId: 'g1' }); + const made = creates(requests); + expect(made).toHaveLength(1); + expect(made[0].url).toBe('/api/quick-start'); + expect(made[0].body.tabGroupId).toBe('g1'); + expect(app._applyTabLayout).toHaveBeenCalledWith(LAYOUT); + }); + + it('an ordinary Run sends no group and keeps the stepper count', async () => { + const { app, requests } = harness({ name: 'proj', path: '/tmp/proj' }); + await app.run(); + const made = creates(requests); + expect(made).toHaveLength(4); + expect(made.every((req) => !('tabGroupId' in req.body))).toBe(true); + expect(app._applyTabLayout).not.toHaveBeenCalled(); + }); +}); diff --git a/test/tab-layout-browser.test.ts b/test/tab-layout-browser.test.ts index 3fd7b85ae..36cdf1c6e 100644 --- a/test/tab-layout-browser.test.ts +++ b/test/tab-layout-browser.test.ts @@ -121,6 +121,39 @@ describe('projection', () => { }); }); +describe('projection: sessions spawned by another session', () => { + const grouped = () => ({ + version: 3, + updatedAt: '', + groups: [{ id: 'g1', name: 'Core', refs: [s('p'), s('p-kid'), s('x')] }], + ungrouped: [s('u')], + }); + const s = (id: string) => ({ kind: 'session', id }); + + it("draws a child the layout has not stored yet inside its parent's group, after the parent's rows", () => { + const h = loadHelper(); + const result = h.project(grouped(), { + liveSessionIds: ['p', 'p-kid', 'x', 'u', 'new', 'grandkid', 'orphan'], + parents: { 'p-kid': 'p', new: 'p', grandkid: 'new', orphan: 'gone' }, + }); + expect(result.sections.map((section: any) => [section.id, ids(section.refs)])).toEqual([ + ['g1', ['session:p', 'session:p-kid', 'session:new', 'session:grandkid', 'session:x']], + // A child whose parent is not in the layout has nothing to follow. + [null, ['session:u', 'session:orphan']], + ]); + }); + + it('leaves a stored child where the layout put it, and changes nothing without lineage', () => { + const h = loadHelper(); + const layout = { ...grouped(), ungrouped: [s('u'), s('manual-kid')] }; + const live = ['p', 'p-kid', 'x', 'u', 'manual-kid']; + const withParents = h.project(layout, { liveSessionIds: live, parents: { 'manual-kid': 'p' } }); + expect(ids(withParents.sections[1].refs)).toEqual(['session:u', 'session:manual-kid']); + const without = h.project(layout, { liveSessionIds: [...live, 'new'] }); + expect(ids(without.sections[1].refs)).toEqual(['session:u', 'session:manual-kid', 'session:new']); + }); +}); + describe('structure key', () => { it('changes with every structural input and ignores everything else', () => { const h = loadHelper(); diff --git a/test/tab-layout-editing.test.ts b/test/tab-layout-editing.test.ts index 2e0182db1..a4efac95a 100644 --- a/test/tab-layout-editing.test.ts +++ b/test/tab-layout-editing.test.ts @@ -98,6 +98,52 @@ describe('operations', () => { }); }); +describe('child sessions: follow and hand placement', () => { + const h = loadHelper(); + const lineage = (): Layout => ({ + version: 5, + updatedAt: '', + groups: [ + { id: 'g1', name: 'Core', refs: [s('p'), s('kid'), s('x')] }, + { id: 'g2', name: 'Ops', refs: [{ kind: 'session', id: 'moved', placement: 'manual' }, s('moved-kid')] }, + ], + ungrouped: [s('u'), { kind: 'session', id: 'orphan', placement: 'manual' }], + }); + const parents = { kid: 'p', moved: 'p', 'moved-kid': 'moved', orphan: 'gone' }; + + it('describes each child: following, placed by hand, or with no parent to follow', () => { + const state = (id: string, extra = {}) => plain(h.placementState(lineage(), s(id), { ...parents, ...extra })); + expect(state('kid')).toEqual({ state: 'inherited', parentId: 'p', canFollow: false }); + expect(state('moved')).toEqual({ state: 'manual', parentId: 'p', canFollow: true }); + expect(state('orphan')).toEqual({ state: 'dangling', parentId: 'gone', canFollow: false }); + expect(state('p')).toBeNull(); + expect(state('x', { x: 'u', u: 'x' })).toMatchObject({ state: 'cycle', canFollow: false }); + }); + + it('Follow parent again clears the hand placement and puts the child (and its followers) after its parent', () => { + const next = h.applyOperation(lineage(), { type: 'followParent', ref: s('moved'), parents }); + expect(keys(next.groups[0].refs)).toEqual([ + 'session:p', + 'session:kid', + 'session:moved', + 'session:moved-kid', + 'session:x', + ]); + expect(next.groups[0].refs.find((ref: Ref) => ref.id === 'moved').placement).toBeUndefined(); + expect(next.groups[1].refs).toEqual([]); + }); + + it('refuses to follow a parent that is gone or loops, and leaves a following child as it is', () => { + expect(() => h.applyOperation(lineage(), { type: 'followParent', ref: s('orphan'), parents })).toThrow(); + expect(() => + h.applyOperation(lineage(), { type: 'followParent', ref: s('x'), parents: { x: 'u', u: 'x' } }) + ).toThrow(); + expect(plain(h.applyOperation(lineage(), { type: 'followParent', ref: s('kid'), parents }))).toEqual( + plain(h.normalizeLayout(lineage())) + ); + }); +}); + describe('drop -> operation', () => { const h = loadHelper(); @@ -698,15 +744,15 @@ describe('group menu', () => { win.confirm = vi.fn(() => true); header('gx').focus(); key(header('gx'), 'F10', { shiftKey: true }); - expect(menuLabels()).toEqual(['Rename group', 'New group', 'Move group down', 'Delete group']); - expect(document.activeElement?.textContent).toBe('Rename group'); + expect(menuLabels()).toEqual(['New session', 'Rename group', 'New group', 'Move group down', 'Delete group']); + expect(document.activeElement?.textContent).toBe('New session'); clickMenu('Move group down'); await flush(); expect(puts.at(-1).layout.groups.map((g: any) => g.id)).toEqual(['gy', 'gx']); expect(document.activeElement).toBe(header('gx')); key(header('gx'), 'ContextMenu'); - expect(menuLabels()).toEqual(['Rename group', 'New group', 'Move group up', 'Delete group']); + expect(menuLabels()).toEqual(['New session', 'Rename group', 'New group', 'Move group up', 'Delete group']); clickMenu('Delete group'); expect(win.confirm).toHaveBeenCalledWith('Delete group ""? Its tabs move to Ungrouped.'); await flush(); @@ -846,6 +892,217 @@ describe('group menu', () => { }); }); +describe('new session in a group', () => { + it('expands a collapsed group and launches ONE session targeted at it', async () => { + installFetch(); + const app = makeApp(); + app.toggleTabGroupCollapsed('gx', true); + app.run = vi.fn(async () => {}); + header('gx').focus(); + key(header('gx'), 'F10', { shiftKey: true }); + clickMenu('New session'); + await flush(); + expect(app.run).toHaveBeenCalledTimes(1); + expect(app.run).toHaveBeenCalledWith({ count: 1, tabGroupId: 'gx' }); + expect(app.collapsedTabGroupIds.has('gx')).toBe(false); + expect(JSON.parse(win.localStorage.getItem('codeman:tab-groups-collapsed'))).toEqual([]); + expect(document.querySelector('.tab-layout-group-action-menu')).toBeNull(); + }); + + it('says so, and starts nothing, while another launch is in flight', async () => { + installFetch(); + const app = makeApp(); + app.toggleTabGroupCollapsed('gx', true); + app.run = vi.fn(async () => {}); + app._runInFlight = true; + await app.createSessionInTabGroup('gx'); + expect(app.run).not.toHaveBeenCalled(); + expect(app.collapsedTabGroupIds.has('gx')).toBe(true); + expect(app.showToast).toHaveBeenCalledWith('A session is already starting.', 'info'); + // A group that no longer exists starts nothing either. + app._runInFlight = false; + expect(await app.createSessionInTabGroup('deleted')).toBe(false); + expect(app.run).not.toHaveBeenCalled(); + }); +}); + +describe('child session placement in the row menu', () => { + const lineageLayout = (): Layout => ({ + version: 8, + updatedAt: '2026-10-01T00:00:00.000Z', + groups: [ + { id: 'gx', name: 'Core', refs: [s('s1'), s('s2')] }, + { id: 'gy', name: 'Later', refs: [{ kind: 'session', id: 's3', placement: 'manual' }] }, + ], + ungrouped: [], + }); + const openMenu = (app: Record, id: string) => + app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row(id) }, id); + + it('shows where a child sits and offers Follow parent again only to a hand-placed child', async () => { + const puts = installFetch(); + const app = makeApp(lineageLayout()); + app.sessions.get('s2').parentSessionId = 's1'; + app.sessions.get('s3').parentSessionId = 's1'; + + openMenu(app, 's2'); + expect(menuLabels()).toContain('Follows One'); + expect(menuLabels()).not.toContain('Follow parent again'); + const summary = [...document.querySelectorAll('.tab-rail-action-menu button')].find( + (button) => button.textContent === 'Follows One' + )!; + expect(summary.getAttribute('aria-disabled')).toBe('true'); + summary.click(); // informational: does nothing, keeps the menu open + expect(document.querySelector('.tab-rail-action-menu')).not.toBeNull(); + app.closeTabRailActionMenu(); + + openMenu(app, 's3'); + expect(menuLabels()).toContain('Placed by hand (parent: One)'); + clickMenu('Follow parent again'); + // Optimistic: the child is back under its parent at once. + expect(row('s3').closest('.tab-layout-group')!.getAttribute('data-tab-group-id')).toBe('gx'); + await flush(); + expect(puts).toHaveLength(1); + expect(puts[0].layout.groups[0].refs).toEqual([s('s1'), s('s2'), s('s3')]); + expect(puts[0].layout.groups[1].refs).toEqual([]); + }); + + it('a child whose parent is gone says so and cannot follow', () => { + installFetch(); + const app = makeApp({ + ...lineageLayout(), + groups: [{ id: 'gx', name: 'Core', refs: [{ kind: 'session', id: 's2', placement: 'manual' }] }], + ungrouped: [s('s3')], + }); + app.sessions.delete('s1'); + app.sessions.get('s2').parentSessionId = 's1'; + openMenu(app, 's2'); + expect(menuLabels()).toContain('Parent closed; placed on its own'); + expect(menuLabels()).not.toContain('Follow parent again'); + }); + + it('a hand move makes a following child manual, so it stays when its parent moves', async () => { + const puts = installFetch(); + const app = makeApp(lineageLayout()); + app.sessions.get('s2').parentSessionId = 's1'; + openMenu(app, 's2'); + clickMenu('Move to "Later"'); + await flush(); + expect(puts[0].layout.groups[1].refs).toEqual([ + { kind: 'session', id: 's3', placement: 'manual' }, + { kind: 'session', id: 's2', placement: 'manual' }, + ]); + openMenu(app, 's1'); + clickMenu('Move to Ungrouped'); + await flush(); + expect(puts[1].layout.ungrouped).toEqual([s('s1')]); + expect(keys(puts[1].layout.groups[1].refs)).toEqual(['session:s3', 'session:s2']); + }); + + it('adds nothing to the menu for a session without a parent, or outside a grouped rail', () => { + installFetch(); + const app = makeApp(lineageLayout()); + openMenu(app, 's1'); + expect(menuLabels()).toEqual([ + 'Session options', + 'Move down', + 'Move to "Later"', + 'Move to Ungrouped', + 'Move to new group', + 'Close session', + ]); + }); +}); + +describe('the server places created and closed sessions while groups exist', () => { + const orderPuts = () => + (win.fetch as any).mock.calls.filter(([url]: [string]) => url === '/api/session-order').length; + const stub = (app: Record) => { + app._debounceTimers = {}; + app.markSessionTabEntering = () => {}; + app.markTerminalEntering = () => {}; + app.markConnectionLineEntering = () => {}; + app.renderSessionTabs = () => app._fullRenderSessionTabs(); + app.updateCost = () => {}; + app.startSystemStatsPolling = () => {}; + }; + + it('a spawned child is drawn after its parent and NOT echoed back as a hand order (which would pin it)', async () => { + installFetch(); + const app = makeApp(); + stub(app); + // The server already broadcast its order with the child after its parent. + win.localStorage.setItem('codeman-session-order', JSON.stringify(['s1', 's2', 'kid', 's3'])); + app._onSessionCreated({ id: 'kid', name: 'Kid', status: 'idle', parentSessionId: 's2' }); + expect(row('kid').closest('.tab-layout-group')!.getAttribute('data-tab-group-id')).toBe('gx'); + expect(app.sessionOrder).toEqual(['s1', 's2', 'kid', 's3']); + await new Promise((resolve) => setTimeout(resolve, 450)); + expect(orderPuts()).toBe(0); + + // The order bookkeeping runs first in _cleanupSessionData; the buffer and + // timer maps it clears afterwards are not part of this harness. + try { + app._cleanupSessionData('kid'); + } catch {} + expect(app.sessionOrder).toEqual(['s1', 's2', 's3']); + await new Promise((resolve) => setTimeout(resolve, 450)); + expect(orderPuts()).toBe(0); + }); + + it('keeps the flat rail exactly as before: a new session is appended and its order saved', async () => { + installFetch(); + const app = makeApp({ ...serverLayout(), groups: [], ungrouped: [s('s1'), s('s2'), s('s3')] }); + stub(app); + app._onSessionCreated({ id: 'kid', name: 'Kid', status: 'idle', parentSessionId: 's2' }); + expect(app.sessionOrder).toEqual(['s1', 's2', 's3', 'kid']); + await new Promise((resolve) => setTimeout(resolve, 450)); + expect(orderPuts()).toBe(1); + }); +}); + +describe('saved web tabs in groups (openness is per device)', () => { + const webLayout = (): Layout => ({ + version: 8, + updatedAt: '2026-10-01T00:00:00.000Z', + groups: [{ id: 'gx', name: 'Dash', refs: [s('s1'), w('w1'), s('s2')] }], + ungrouped: [s('s3'), w('w2')], + }); + const webRows = () => + [...document.querySelectorAll('.session-tab[data-webview-id]')].map((el) => [ + el.closest('.tab-layout-group')!.getAttribute('data-tab-group-id'), + el.dataset.webviewId, + el.querySelector('.tab-number')?.textContent ?? null, + ]); + + it('shows a grouped web tab only while it is open here, and reopens it where the layout keeps it', () => { + installFetch(); + const app = makeApp(null); + app.webviews = new Map([ + ['w1', { id: 'w1', name: 'One', url: 'https://one.test' }], + ['w2', { id: 'w2', name: 'Two', url: 'https://two.test' }], + ]); + app.webviewOrder = ['w1']; + app._applyTabLayout(webLayout()); + // w2 is saved and placed, but not open on this device: no row, no number. + expect(webRows()).toEqual([['gx', 'w1', '4']]); + expect(header('gx').querySelector('.tab-layout-group-count')!.textContent).toBe('3'); + + app.webviewOrder = []; + app._fullRenderSessionTabs(); + expect(webRows()).toEqual([]); + // The group survives with its sessions; the layout still holds the web tab. + expect(header('gx').querySelector('.tab-layout-group-count')!.textContent).toBe('2'); + expect(keys(app.tabLayout.groups[0].refs)).toEqual(['session:s1', 'webview:w1', 'session:s2']); + + app.webviewOrder = ['w2', 'w1']; + app._fullRenderSessionTabs(); + const order = [...tabs().querySelectorAll('.session-tab')].map( + (el) => el.dataset.webviewId || el.dataset.id + ); + expect(order).toEqual(['s1', 'w1', 's2', 's3', 'w2']); + }); +}); + describe('server echoes and reloads', () => { it('defers an SSE-triggered read while its own write is in flight, then re-reads once', async () => { let releasePut: (() => void) | null = null; diff --git a/test/tab-layout-grouped-create.test.ts b/test/tab-layout-grouped-create.test.ts new file mode 100644 index 000000000..9fcd8ae21 --- /dev/null +++ b/test/tab-layout-grouped-create.test.ts @@ -0,0 +1,148 @@ +/** + * @fileoverview End to end through the real WebServer: a session created with a + * `tabGroupId` lands in that group in ONE layout version, and a session spawned + * by another one (the X-Codeman-Parent-Session header) follows its parent into + * the parent's group until it is moved by hand. + * + * The service and route halves have their own unit tests; this pins the wiring + * in server.ts (the session joins the live map inside the layout owner lock and + * the placement commits with it) against the real routes and StateStore. + */ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { WebServer } from '../src/web/server.js'; + +const TEST_PORT = 3301; + +type Ref = { kind: string; id: string; placement?: string }; +type Layout = { + version: number; + updatedAt: string; + groups: Array<{ id: string; name: string; refs: Ref[] }>; + ungrouped: Ref[]; +}; + +describe('grouped session creation through the server', () => { + let server: WebServer; + const base = `http://localhost:${TEST_PORT}`; + const workingDir = mkdtempSync(join(tmpdir(), 'codeman-grouped-create-')); + + const api = async ( + path: string, + init: { method?: string; body?: unknown; headers?: Record } = {} + ) => { + const res = await fetch(`${base}${path}`, { + method: init.method ?? 'GET', + headers: { 'Content-Type': 'application/json', ...(init.headers ?? {}) }, + ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }), + }); + return { status: res.status, json: (await res.json()) as any }; + }; + const layout = async (): Promise => (await api('/api/tab-layout')).json.data.layout; + const create = async (body: Record, headers?: Record) => { + const res = await api('/api/sessions', { method: 'POST', body: { workingDir, mode: 'shell', ...body }, headers }); + expect(res.status).toBe(200); + return res.json.data as { session: { id: string }; tabLayout?: Layout }; + }; + const ids = (refs: Ref[]) => refs.map((ref) => ref.id); + + beforeAll(async () => { + server = new WebServer(TEST_PORT, false, true); + await server.start(); + }); + + afterAll(async () => { + await server.stop(); + }, 60000); + + it('places the new session at the end of the group in the same write that records it', async () => { + const first = await create({ name: 'first' }); + const current = await layout(); + const put = await api('/api/tab-layout', { + method: 'PUT', + body: { + baseVersion: current.version, + layout: { + ...current, + groups: [{ id: 'g1', name: 'Core', refs: [{ kind: 'session', id: first.session.id }] }], + ungrouped: [], + }, + }, + }); + expect(put.status).toBe(200); + const before = put.json.data.layout as Layout; + + const made = await create({ name: 'grouped', tabGroupId: 'g1' }); + const after = await layout(); + expect(after.version).toBe(before.version + 1); + expect(ids(after.groups[0].refs)).toEqual([first.session.id, made.session.id]); + expect(made.tabLayout).toEqual(after); + + // An unknown group is a hint that does not apply: the session is still created. + const loose = await create({ name: 'loose', tabGroupId: 'no-such-group' }); + expect(ids((await layout()).ungrouped)).toContain(loose.session.id); + }); + + it('a spawned child follows its parent into the group, and stays put once moved by hand', async () => { + const start = await layout(); + const parentId = start.groups[0].refs[0].id; + const child = await create({ name: 'child' }, { 'X-Codeman-Parent-Session': parentId }); + const followed = await layout(); + expect(ids(followed.groups[0].refs).slice(0, 2)).toEqual([parentId, child.session.id]); + + // Hand-move the child to Ungrouped (what the browser sends for a moveRef). + const moved = await api('/api/tab-layout', { + method: 'PUT', + body: { + baseVersion: followed.version, + layout: { + ...followed, + groups: [ + { ...followed.groups[0], refs: followed.groups[0].refs.filter((ref) => ref.id !== child.session.id) }, + ], + ungrouped: [...followed.ungrouped, { kind: 'session', id: child.session.id, placement: 'manual' }], + }, + }, + }); + expect(moved.status).toBe(200); + const after = await layout(); + expect(after.ungrouped.find((ref) => ref.id === child.session.id)).toEqual({ + kind: 'session', + id: child.session.id, + placement: 'manual', + }); + expect(ids(after.groups[0].refs)).not.toContain(child.session.id); + }); +}); + +describe('server.ts addSession wiring', () => { + it("keys the placement on the SESSION's owner and registers it only inside the layout call", async () => { + const sessions = new Map(); + let registered: (() => void | (() => void)) | undefined; + const sessionCreated = vi.fn(async (_owner: string, _id: string, _placement: unknown, register: () => unknown) => { + expect(sessions.has('bob-new')).toBe(false); + registered = register as never; + const rollback = register() as () => void; + expect(sessions.get('bob-new')).toBe(session); + rollback(); + return { version: 1 }; + }); + const server = Object.create(WebServer.prototype) as any; + server.sessions = sessions; + server.tabLayouts = { sessionCreated }; + const session = { id: 'bob-new', owner: 'bob' }; + + await server.registerSessionWithLayout(session, { tabGroupId: 'g1' }); + + expect(sessionCreated).toHaveBeenCalledWith('bob', 'bob-new', { tabGroupId: 'g1' }, expect.any(Function)); + // The rollback removed it; a rollback never removes a DIFFERENT session under that id. + expect(sessions.has('bob-new')).toBe(false); + const rollback = registered!() as () => void; + const replacement = { id: 'bob-new', owner: 'bob' }; + sessions.set('bob-new', replacement); + rollback(); + expect(sessions.get('bob-new')).toBe(replacement); + }); +}); diff --git a/test/tab-layout-service.test.ts b/test/tab-layout-service.test.ts index 2b08d8c0f..aa07fe1e1 100644 --- a/test/tab-layout-service.test.ts +++ b/test/tab-layout-service.test.ts @@ -1273,3 +1273,144 @@ describe('TabLayoutService', () => { expect(Object.hasOwn(h.store.commitTabLayoutProjection.mock.calls.at(-1)![0], 'bob')).toBe(true); }); }); + +describe('TabLayoutService grouped session creation', () => { + const grouped = (version = 7): TabLayout => ({ + version, + groups: [ + { id: 'g1', name: 'Core', refs: [{ kind: 'session', id: 'a' }] }, + { id: 'g2', name: 'Ops', refs: [] }, + ], + ungrouped: [{ kind: 'session', id: 'b' }], + updatedAt: '2026-08-15T00:00:00.000Z', + }); + const facts = (owner = 'alice') => [ + { id: 'a', owner, createdAt: 1 }, + { id: 'b', owner, createdAt: 2 }, + ]; + /** Registers `id` the way server.ts does: inside the owner lock, with a rollback. */ + const register = (h: ReturnType, fact: SessionFact) => + vi.fn(() => { + h.live.set(fact.id, fact as never); + return () => h.live.delete(fact.id); + }); + + it('places a new session at the end of the requested group in ONE versioned write', async () => { + const h = createHarness({ layouts: { alice: grouped() }, live: facts() }); + const added = register(h, { id: 'new', owner: 'alice', createdAt: 3 }); + + const layout = await h.service.sessionCreated('alice', 'new', { tabGroupId: 'g1' }, added); + + expect(added).toHaveBeenCalledOnce(); + expect(layout.version).toBe(8); + expect(layout.groups[0].refs).toEqual([ + { kind: 'session', id: 'a' }, + { kind: 'session', id: 'new' }, + ]); + expect(layout.ungrouped).toEqual([{ kind: 'session', id: 'b' }]); + expect(h.layouts.alice).toEqual(layout); + expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1); + expect(h.broadcast.mock.calls.filter(([event]) => event === SseEvent.TabLayoutChanged)).toEqual([ + [SseEvent.TabLayoutChanged, { owner: 'alice', version: 8 }], + ]); + }); + + it('registers the session INSIDE the owner lock, so a queued edit cannot commit it first', async () => { + const h = createHarness({ layouts: { alice: grouped() }, live: facts(), order: ['a', 'b'] }); + let release!: () => void; + const blocked = new Promise((resolve) => (release = resolve)); + h.readWebviews.mockImplementationOnce(async () => { + await blocked; + return []; + }); + const occupied = h.service.get('alice'); + await vi.waitFor(() => expect(h.readWebviews).toHaveBeenCalledOnce()); + const added = register(h, { id: 'new', owner: 'alice', createdAt: 3 }); + + const created = h.service.sessionCreated('alice', 'new', { tabGroupId: 'g2' }, added); + await Promise.resolve(); + expect(added).not.toHaveBeenCalled(); + release(); + await occupied; + const layout = await created; + + expect(layout.groups[1].refs).toEqual([{ kind: 'session', id: 'new' }]); + // The queued read committed nothing; the creation is the only write. + expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1); + expect(layout.version).toBe(8); + }); + + it('treats an unknown group as a hint, not an error: the session lands where it normally would', async () => { + const h = createHarness({ layouts: { alice: grouped() }, live: facts() }); + const layout = await h.service.sessionCreated( + 'alice', + 'new', + { tabGroupId: 'gone' }, + register(h, { id: 'new', owner: 'alice', createdAt: 3 }) + ); + expect(layout.ungrouped.map((ref) => ref.id)).toEqual(['b', 'new']); + expect(layout.groups.flatMap((group) => group.refs.map((ref) => ref.id))).toEqual(['a']); + }); + + it("never places a session into another owner's group", async () => { + const h = createHarness({ + layouts: { alice: grouped(), bob: existing([{ kind: 'session', id: 'bob-1' }], 3) }, + live: [...facts(), { id: 'bob-1', owner: 'bob', createdAt: 4 }], + }); + const layout = await h.service.sessionCreated( + 'bob', + 'bob-new', + { tabGroupId: 'g1' }, + register(h, { id: 'bob-new', owner: 'bob', createdAt: 5 }) + ); + expect(layout.groups).toEqual([]); + expect(layout.ungrouped.map((ref) => ref.id)).toEqual(['bob-1', 'bob-new']); + expect(h.layouts.alice).toEqual(grouped()); + }); + + it("a child follows its parent into the parent's group; an explicit group pins it manual", async () => { + const h = createHarness({ layouts: { alice: grouped() }, live: facts() }); + const followed = await h.service.sessionCreated( + 'alice', + 'child', + {}, + register(h, { id: 'child', owner: 'alice', createdAt: 3, parentSessionId: 'a' }) + ); + expect(followed.groups[0].refs).toEqual([ + { kind: 'session', id: 'a' }, + { kind: 'session', id: 'child' }, + ]); + + const pinned = await h.service.sessionCreated( + 'alice', + 'child-2', + { tabGroupId: 'g2' }, + register(h, { id: 'child-2', owner: 'alice', createdAt: 4, parentSessionId: 'a' }) + ); + expect(pinned.groups[1].refs).toEqual([{ kind: 'session', id: 'child-2', placement: 'manual' }]); + // Moving the parent later leaves the hand-placed child where it was. + const moved = await h.service.put( + 'alice', + { + ...pinned, + groups: [{ ...pinned.groups[0], refs: [] }, pinned.groups[1]], + ungrouped: [...pinned.ungrouped, ...pinned.groups[0].refs], + }, + pinned.version + ); + expect(moved.layout.groups[1].refs).toEqual([{ kind: 'session', id: 'child-2', placement: 'manual' }]); + expect(moved.layout.ungrouped.map((ref) => ref.id)).toEqual(['b', 'a', 'child']); + }); + + it('rolls the registration back when the layout write fails', async () => { + const h = createHarness({ layouts: { alice: grouped() }, live: facts() }); + h.store.commitTabLayoutProjection.mockImplementationOnce(() => { + throw new Error('disk full'); + }); + const added = register(h, { id: 'new', owner: 'alice', createdAt: 3 }); + await expect(h.service.sessionCreated('alice', 'new', { tabGroupId: 'g1' }, added)).rejects.toThrow('disk full'); + expect(added).toHaveBeenCalledOnce(); + expect(h.live.has('new')).toBe(false); + expect(h.layouts.alice).toEqual(grouped()); + }); +});