diff --git a/CLAUDE.md b/CLAUDE.md index 1ab43603..5ea2984c 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 004af462..010edd70 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 16a75d3d..7fe8e59c 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. ⚠️ 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 fbb03342..b2688793 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(); @@ -866,6 +873,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 +1276,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 +5714,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 +5745,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 +5910,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 +6237,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; } @@ -6312,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 @@ -6320,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; @@ -6356,6 +6386,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 +6408,27 @@ 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) { + // 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; + 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; + if (editor.hasPending()) this.showToast?.('Your tab group edit was not saved.', 'error'); + editor.dispose(); + this._tabLayoutEditor = null; + } this.tabLayout = next; const storage = this._getTabCollapseStorage(); const collapsed = storage && next @@ -6384,6 +6441,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 +6508,623 @@ 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.`)) { + // 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); + } + + 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; + // 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 }) }); + 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) }; + // `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(); + // 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); + const focusIdentity = fromBlur ? null : `group:${groupId}`; + if (commit && current && name && name !== current.name) { + if (this.editTabLayout({ type: 'renameGroup', groupId, name }, focusIdentity)) return; + } + if (focusIdentity) { + this._tabFocusIdentity = focusIdentity; + 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, { fromBlur: 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) { + // 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. + 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; + 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. */ + _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; + // 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; + drag.origin.classList.add('tab-layout-dragging'); + container.classList.add('tab-layout-drag-active'); + this.closeTabGroupMenu(); + this.closeTabRailActionMenu?.(); + 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(); + 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; + 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); + 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({ owner: this._tabLayoutOwnerKey(), baseVersion: editor.baseVersion(), savedAt: Date.now(), 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; + } + + /** 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 saved; + try { + const raw = sessionStorage.getItem('codeman:tab-layout-pending'); + if (!raw) return false; + 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); + } + // Set up drag-and-drop handlers on tab elements setupTabDragHandlers() { const container = this.$('sessionTabs'); @@ -6457,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; @@ -6557,7 +7236,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 b908552b..cee646c8 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -68,6 +68,24 @@ '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.': '标签分组在别处持续更改;你的编辑未保存。', + 'Your tab group edit was not saved.': '你的标签分组编辑未保存。', 'Open session manager': '打开会话管理器', Attachments: '附件', 'Open attachment history': '打开附件历史', @@ -934,6 +952,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/session-ui.js b/src/web/public/session-ui.js index 27a74ec8..a5036056 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -2589,6 +2589,62 @@ 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, + * 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 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' }; + 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) { + 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); + const cleanup = () => { + if (writes.get(sessionId) !== task) return; + writes.delete(sessionId); + pending.delete(sessionId); + }; + task.then(cleanup, cleanup); + return task; + }, + async saveSessionName() { if (!this.editingSessionId) return; // Captured: the modal can be closed (or switched to another session) while @@ -2877,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 = () => { @@ -2898,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(); @@ -2954,22 +3017,17 @@ 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. - 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) { - 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); - } + // 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 81b17118..8ba70b26 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -736,6 +736,86 @@ 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; +} + +/* 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)); +} + +/* 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; } @@ -1768,22 +1848,51 @@ 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 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: 0; +} + +/* 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 */ @@ -18815,6 +18924,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/src/web/public/tab-layout-browser.js b/src/web/public/tab-layout-browser.js index de5b529d..6aa48939 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,370 @@ }; } + // ─── 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))); + // 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 }; + 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, + ...(destination.index === undefined ? {} : { 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; + let rereadFor400 = 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 && !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 { + 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 +731,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 17e99ea5..ae906641 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) { @@ -325,6 +327,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/i18n-branding.test.ts b/test/i18n-branding.test.ts index 45f95908..8ffff762 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/inline-rename.test.ts b/test/inline-rename.test.ts index edee0564..e851f310 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,495 @@ 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) => { - const app = ( - window as unknown as { +/** + * 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: { - sessions: Map; - startInlineRename: (id: string) => void; + _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); } - ).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); + 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); + } - 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'); + 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 }); + }); - 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); + 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 }); + }); - const settled = await page.evaluate((sessionId) => { - const app = (window as unknown as { app: { _activeRename: { cancel: () => void } | null } }).app; + 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 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 restoreFetch(); + 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(); - 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, + return value; + }); + await restoreFetch(); + expect(minWidth).toBe('0px'); + }); + + 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: { + tabLayout: unknown; + startTabGroupRename: (groupId: string) => boolean; + }; + } + ).app; + 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; }; - }, id); + 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 }); + }); +}); + +/** + * 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 = ''; + + 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); + + 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 }, + { 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 { + 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' }); + // 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); + + 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, + inlineMinWidth: input.style.minWidth, + }; + }); + 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'); + // 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); + } finally { + await context.close(); + } }); }); diff --git a/test/tab-layout-browser.test.ts b/test/tab-layout-browser.test.ts index a97c6b00..3fd7b85a 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 00000000..3e1f66da --- /dev/null +++ b/test/tab-layout-editing.browser.test.ts @@ -0,0 +1,364 @@ +/** + * @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; + // 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++; + }); + }); + }); + + 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; + w.__termEscapes = 0; + }, 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('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'); + 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 00000000..2e0182db --- /dev/null +++ b/test/tab-layout-editing.test.ts @@ -0,0 +1,1054 @@ +/** + * @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, + }); + // 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', () => { + 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('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); + 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 = ''; + win.__codemanUser = { username: 'admin', role: 'admin', multiUser: false }; +}); + +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('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"'); + // 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('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(); + 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('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(); + 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'); + 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(); + 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({ + owner: '@single', + baseVersion: 8, + savedAt: Date.now(), + operations: [{ type: 'renameGroup', groupId: 'gy', name: 'Later' }], + }) + ); + const none = installFetch(); + makeApp(); + await flush(); + 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); + 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 86d2ffc0..f43cf229 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); });