Merge pull request #525 from aakhter/pr/grouped-rail-edit

feat(tabs): edit groups in the vertical rail
This commit is contained in:
Codeman maintainer
2026-10-05 19:51:40 +02:00
13 changed files with 2657 additions and 28 deletions
+1 -1
View File
@@ -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)
+1
View File
@@ -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',
+4 -2
View File
@@ -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 <group>" 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 <group>, 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).
+699 -16
View File
@@ -577,14 +577,23 @@ 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();
this._shortIdCache = new Map(); // Cache session ID .slice(0, 8) results
this.sessionOrder = []; // Track tab order for drag-and-drop reordering
this.draggedTabId = null; // Currently dragged tab session ID
// Owner tab layout (GET /api/tab-layout), read-only here: it only changes how
// the vertical rail GROUPS rows. sessionOrder above stays the tab order.
// Owner tab layout: read via GET /api/tab-layout and edited from the vertical
// rail via PUT /api/tab-layout (editTabLayout). It decides how the rail GROUPS
// rows; the server projects it onto the session order, so sessionOrder above
// stays the tab order.
this.tabLayout = null;
this.collapsedTabGroupIds = new Set(); // per-device, localStorage-backed
this._hiddenTabGroupByRef = new Map(); // 'session:<id>' -> collapsed group id
@@ -866,6 +875,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 +1278,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 +5716,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 +5747,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 <body>.
const focusWasInside = container.contains(document.activeElement);
// An edit made from a menu (focus now on <body>) 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 +5912,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 +6239,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 +6344,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 +6352,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 +6388,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 +6410,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 +6443,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 +6510,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 <body>.
*/
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 +7138,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 +7238,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();
+24
View File
@@ -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);
+80
View File
@@ -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;
}
+396 -6
View File
@@ -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 (
`<section class="tab-layout-group${section.collapsed ? ' tab-layout-group--collapsed' : ''}" role="presentation" data-tab-group-id="${id}">` +
`<div class="tab-layout-group-header tab-layout-group-toggle" role="treeitem" tabindex="-1" data-tab-group-header="${id}"${expandedAttr}${expanded ? ` aria-owns="${refsId}"` : ''} onclick="app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)">` +
`<div class="tab-layout-group-header tab-layout-group-toggle" role="treeitem" tabindex="-1" data-tab-group-header="${id}"${expandedAttr}${expanded ? ` aria-owns="${refsId}"` : ''} onclick="app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)" oncontextmenu="event.preventDefault(); app.openTabGroupMenu(event, this.dataset.tabGroupHeader)">` +
'<span class="tab-layout-group-chevron" aria-hidden="true"></span>' +
`<span class="tab-layout-group-name" id="${nameId}" data-i18n-skip>${escapeHtml(section.name)}</span>` +
`<span class="tab-layout-group-count">${section.count}</span></div>` +
`<span class="tab-layout-group-count">${section.count}</span>` +
// Pointer path to the group menu (right-click on the header works too).
// Deliberately NOT a button and not focusable: a treeitem holds no
// interactive children, and the keyboard path is Shift+F10 /
// ContextMenu on the header itself. aria-hidden keeps the glyph out of
// the header's accessible name.
'<span class="tab-layout-group-menu" aria-hidden="true" title="Group actions" ' +
'onclick="event.stopPropagation(); app.openTabGroupMenu(event, this.closest(\'[data-tab-group-header]\').dataset.tabGroupHeader)">&#x22EF;</span></div>' +
`<div class="tab-layout-group-refs" id="${refsId}" ${expanded ? `role="group" aria-labelledby="${nameId}"` : 'role="presentation"'}>${rows}</div></section>`
);
})
@@ -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,
+5 -1
View File
@@ -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) }]
: []),
+12
View File
@@ -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('<span class="logo">Codeman</span>');
const api = dom.window.CodemanI18n;
+10 -2
View File
@@ -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(');
});
});
+364
View File
@@ -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: '<!doctype html><html><body></body></html>' })
);
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(`<!doctype html>
<html data-tab-orientation="vertical" data-tab-rail-sort="manual">
<head><style>${read('styles.css')}</style></head>
<body>
<main class="main" style="width:100%;height:760px">
<aside class="tab-rail" id="tabRail" aria-label="Session navigation" style="width:280px"></aside>
<button id="elsewhere">elsewhere</button>
<textarea id="term" aria-label="stand-in for the terminal" style="position:absolute;left:700px;top:300px"></textarea>
</main>
</body>
</html>`);
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',
'<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs"></div>'
);
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<string, unknown> = {};
// 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);
});
});
File diff suppressed because it is too large Load Diff
+7
View File
@@ -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<HTMLElement>('.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);
});