diff --git a/CLAUDE.md b/CLAUDE.md index a665ae610..3443d8ef2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -258,7 +258,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history and transcript files into one deduped list (pure core `src/services/unified-session-service.ts`), backing the Cmd+K Session Manager, pinning and cross-device tab order (`PUT /api/session-order`, `src/session-order.ts`). ⚠️ Transcript history is THREE stores (`~/.claude/projects`, `~/.omp/agent/sessions`, `~/.codex/sessions`), folded via the `claudeSessionId → Codeman id` alias map (not Claude-only despite the name). ⚠️ `resumeId` is set by a SCANNER row only, never a live session; every surface that re-projects these rows (phone overview included) must carry it through, or a tap silently starts a second conversation. → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager) -**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend reads AND edits it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. Groups are created, renamed, reordered and deleted, and rows moved between them, from the row/group menus (Shift+F10 on a header too) and by pointer drag in the grouped rail. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. ⚠️ Every browser write is a named operation through ONE serialized `PUT /api/tab-layout` at a time (`createEditCoordinator`): a 409 replays the operations onto the server's layout and retries (bounded), and an SSE reload is deferred while a write is in flight. Never PUT the layout from anywhere else in the frontend (the `pagehide` keepalive in `_persistPendingTabLayoutEdits` is the one deliberate exception). ⚠️ `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 (the `pagehide` keepalive in `_persistPendingTabLayoutEdits` is the one deliberate exception). ⚠️ Placing a NEW session in a group rides its create request (`tabGroupId` on `POST /api/sessions` / `/api/quick-start`) into `TabLayoutService.sessionCreated()`, which registers the session inside the owner lock and places it in the same write; never place a created session with a follow-up PUT. A child session follows its parent's group until moved by hand (`placement: 'manual'`); web-tab OPENNESS stays per device. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts) **Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()`, gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation) diff --git a/docs/api-reference.md b/docs/api-reference.md index 89a873b71..e4cda6918 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -470,6 +470,17 @@ also pure decoration: it confers no permission, and a child is unaffected by its parent exiting. It appears on session state as `parentSessionId` (absent when unresolved) and survives a server restart. +## Placing a new session in a tab group (`tabGroupId`) + +`POST /api/v1/sessions` and `POST /api/v1/quick-start` accept an optional +`tabGroupId` (1 to 100 characters): the id of a group in the caller's tab layout +(`GET /api/v1/tab-layout`). The session is created as usual and placed at the end +of that group in the same layout write; the response then also carries the +committed layout as `tabLayout`. An id the caller's layout does not contain (a +deleted group, another user's group) is ignored and the session lands where it +otherwise would, never a `400`. A malformed value (`null`, an empty string, a +non-string) is a `400 INVALID_INPUT`. Requests without the field are unchanged. + ## Approvals Inbox Cross-session queue of prompts waiting on a human (permission dialogs, diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index cd49d869f..8c5e61c64 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -306,6 +306,7 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t - **The upstream HTML5 drag stays off in the grouped rail**: a flat-order drop cannot express a group move, and the server re-ranks within the old group. The grouped rail has its OWN pointer drag instead (`_bindTabLayoutPointerDrag`, mouse/pen only, bound once on the container): rows move before/after a row or into a group, a header drag reorders groups, and the drop maps to ONE operation through the pure `dropOperation()`. Escape cancels a drag, and the click that ends one is swallowed. ⚠️ A press is only captured once it moves 6 px, so its release can land outside the rail: `pointerup`/`pointercancel` are heard on `window` while a press is pending, a move with the primary button up cancels it, and a new press cancels any previous one. Without that a stale press became a phantom drag on the next hover, and a replaced drag left its capture-phase Escape listener behind, swallowing every Escape before the terminal saw it. The flat rail and the header strip keep the HTML5 drag untouched. For the same reason Ctrl+Shift+{ / } only swaps with a neighbour in the active session's own section (`_canSwapActiveTabWith`, reading the projection's `sectionByRef`): a cross-group swap moves nothing on the server, gets no `session:orderChanged` back, and would leave this client's `sessionOrder` and Alt+N targets out of step with every other device. - **Edits are named operations, saved serially.** `createEditCoordinator` applies `createGroup` / `renameGroup` / `deleteGroup` / `reorderGroup` / `moveRef` to the rail at once, then sends ONE `PUT /api/tab-layout {baseVersion, layout}` at a time; edits made meanwhile wait and go out on the version that write returns. A 409 carries the server's layout: the in-flight operations are replayed onto it (an operation that no longer applies is dropped and reported) and re-sent, at most `maxAttempts` times; a 400 re-reads once (a second 400 is reported as a failed save, not as a race); anything else reports and re-reads. A `moveRef` moves the session together with the sessions that still follow it and marks a hand-moved child `placement: 'manual'`, mirroring the server's `moveRef`, and `normalizeLayout` keeps `placement` because whole layouts are written back. `_onTabLayoutChanged` / `_applyTabLayout` defer a read while a write is in flight and rebase unsaved edits onto a read otherwise. ⚠️ A FAILED read (`_applyTabLayout(null)`) while edits are pending keeps the held layout and the editor and re-reads after the write settles: disposing there would orphan the in-flight write, whose 409 then never gets its rebase. Any path that does drop unsaved work says so in a toast. On `pagehide`, unconfirmed operations go out in a `keepalive` PUT AND into sessionStorage as `{ owner, baseVersion, savedAt, operations }`; after reload they replay onto the fresh layout (a no-op when the keepalive landed), but only for the same owner, within `TAB_LAYOUT_PENDING_MAX_AGE_MS` (60 s), and never onto a layout older than the copy's base. A "Move to " with no anchor carries no `index`, so a replay still puts the row last. - **Every way in has a keyboard path.** The session row menu (Shift+F10 in the tree, the rail's overflow button) gains Move up/down, Move to , Move to Ungrouped and Move to new group in the vertical rail (only "new group" before the first group exists; nothing on the strip). A group header opens its menu with Shift+F10 / ContextMenu, right-click or its hover glyph (a non-focusable, `aria-hidden` span: a treeitem holds no interactive children), and F2 renames it inline (Enter commits and refocuses the header; a commit by BLUR leaves focus where it went, since refocusing from inside the blur handler overrides the user's click). The glyph stays visible under `@media (hover: none)`: a touch tablet has no hover and no long-press `contextmenu`. Group names in "Move to" labels are quoted, so a group named "New group" or "ungrouped" cannot read (or translate, case-insensitively) like the fixed entries. The menu closes on Escape (which it consumes before the global Escape handler), a pointer outside, Tab, focus leaving it, a resize, a second open and any full re-render. It borrows the `.tab-rail-action-menu` class for its look only: `closeTabRailActionMenu()` excludes `.tab-layout-group-action-menu`, so closing the row menu (every `session:deleted` does) cannot strand the group menu's listeners. The inline group editor shares `_activeRename` with the session rename, so only the CURRENT editor may release `_inlineRenameActive`. +- **New, child and web tabs land in groups.** A group menu's "New session" (`createSessionInTabGroup`) runs ONE launch with `run({ count: 1, tabGroupId })`; every launch path puts `tabGroupId` on its create request (`POST /api/sessions` or `/api/quick-start`), and `TabLayoutService.sessionCreated(owner, id, { tabGroupId }, register)` places the session at the end of that group in the SAME versioned write that first records it. The session enters the live map inside the owner lock (`register`, rolled back if the write fails), so no queued layout edit can commit it first. The group is looked up in the creating owner's own layout, so an unknown, deleted or foreign id is ignored rather than failing the spawn, and an explicit group is a hand placement (a child placed this way is `manual`). The create response carries the committed `tabLayout`, adopted before the tab is drawn. A session spawned by another one (`parentSessionId`) and placed by nobody FOLLOWS its parent: the server's normalization puts it after the parent's rows in the parent's container, and `project({ parents })` draws a not-yet-stored child there at once. A hand move makes it `manual`; the row menu says where a child sits (following, placed by hand, parent closed, parent loop) and offers "Follow parent again" only to a `manual` child, which clears `placement` and is re-derived by the server on save. A child whose parent is gone stands on its own and is never re-adopted. Saved web tabs are layout refs like sessions, but OPENNESS stays per device: a grouped web tab renders (and takes an Alt+N slot) only while it is open on this device, and reopens where the layout keeps it. - **Only the grouped rail is a tree.** `#sessionTabs` ships as `role=tablist` with `role=tab` rows, and the header strip, sidebar and flat rail keep exactly that. While grouped, `_applyTabListRole` makes it `role=tree` (and restores `tablist` + its label when grouping ends), named-group headers are level-1 `treeitem`s that `aria-owns` their rows' `role=group` (rows sit beside the header, not inside it), and ungrouped rows plus a collapsed group's kept selection are level-1 items. A collapsed header owns nothing, a group with no open rows is a leaf (no `aria-expanded`, no owned group), and the "Ungrouped" heading is `aria-hidden`. Rows are re-roled in the DOM by `_applyTabTreeSemantics` after render, never by rewriting their markup, so a grouped row's content stays the flat row's. - **One tab stop in the tree.** Exactly one treeitem carries `tabindex=0` (the focused or selected item); every control inside a row drops to `-1`, which is why Shift+F10 / ContextMenu open a row's actions from the keyboard. Focus survives a full re-render by identity (`group:`/`session:`/`webview:`; a row a collapse just hid hands focus to its header), but only when focus was already inside the rail. The tree walk (`_tabTreeItems`) follows painted order WITHIN each group when the rail is sorted; the flat list keeps its own whole-list computed-order walk. `aria-posinset`/`aria-setsize` follow painted order too, so the incremental render path re-runs `_applyTabTreePositions` after it re-sorts rows in place. ⚠️ `_handleTabTreeKeydown` acts only when the key lands on the treeitem ITSELF: a key on a focused in-row control (close, overflow, the rename input) is that control's, or Enter on the overflow button re-selects the row instead of reopening its menu. ⚠️ The roving `tabindex=-1` also hides every item but the stop from the keyboard-dismiss selector's `[tabindex]` arm, which is why `MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR` lists `[role="treeitem"]` (see Dismissing the on-screen keyboard). diff --git a/src/tab-layout-service.ts b/src/tab-layout-service.ts index fe2967c36..c7f96729a 100644 --- a/src/tab-layout-service.ts +++ b/src/tab-layout-service.ts @@ -11,6 +11,7 @@ import { applyLegacySessionRank, recomposeGlobalSessionOrder } from './tab-layou import { flattenOwnerSessionOrder, materializeOrphans, + moveRef, normalizeTabLayout, TabLayoutValidationError, validateTabLayout, @@ -67,6 +68,18 @@ export interface RemovedTabLayoutSession { owner?: string; } +/** Where a newly created session asked to be placed. Advisory: an unknown group is ignored. */ +export interface SessionPlacementHint { + tabGroupId?: string; +} + +/** + * Adds the created session to the live map. Runs INSIDE the owner lock, so no + * other layout mutation can observe (and commit) the session first; the returned + * function undoes it when the layout write fails. + */ +export type RegisterCreatedSession = () => void | (() => void); + interface PreparedOwnerLayout { current: TabLayout | null; authoritative: TabLayout; @@ -97,6 +110,25 @@ const sameLayout = (a: TabLayout, b: TabLayout): boolean => JSON.stringify(a) == const sameOrder = (a: readonly string[], b: readonly string[]): boolean => a.length === b.length && a.every((id, index) => id === b[index]); +/** Move a just-created session to the end of `groupId`, or leave the layout alone. */ +function placeCreatedSession( + layout: TabLayout, + sessionId: string, + groupId: string, + metadata: readonly TabRefMetadata[] +): TabLayout { + const group = layout.groups.find((candidate) => candidate.id === groupId); + if (!group) return layout; + const index = group.refs.filter((ref) => ref.kind !== 'session' || ref.id !== sessionId).length; + try { + return moveRef(layout, { kind: 'session', id: sessionId }, { groupId, index }, metadata); + } catch (error) { + // Not an owner-valid ref of this layout: the hint does not apply. + if (error instanceof TabLayoutValidationError) return layout; + throw error; + } +} + export class TabLayoutService { private restorationState: 'pending' | 'complete' | 'failed' | 'skipped' = 'pending'; private readonly ownerQueues = new Map>(); @@ -320,9 +352,13 @@ export class TabLayoutService { }; } - private async getUnlocked(owner: string): Promise { + private async getUnlocked( + owner: string, + adjust?: (layout: TabLayout, metadata: readonly TabRefMetadata[]) => TabLayout + ): Promise { const prepared = await this.prepareUnlocked(owner); - if (!prepared.needsReconciliationCommit) { + const next = adjust ? adjust(prepared.authoritative, prepared.metadata) : prepared.authoritative; + if (!prepared.needsReconciliationCommit && sameLayout(next, prepared.authoritative)) { const publication = { owner, previous: prepared.current, @@ -335,7 +371,7 @@ export class TabLayoutService { return prepared.authoritative; } const base = prepared.current ?? { ...prepared.authoritative, version: -1 }; - return this.commit(owner, base, prepared.authoritative, prepared.metadata); + return this.commit(owner, base, next, prepared.metadata, prepared.current); } async get(owner: string): Promise { @@ -442,9 +478,37 @@ export class TabLayoutService { } } - /** Reconcile one completed session creation into one versioned mutation. */ - async sessionCreated(owner: string): Promise { - return this.get(owner); + /** + * Reconcile one completed session creation into one versioned mutation. + * + * `placement.tabGroupId` puts the new session at the end of that group in the + * same write. The group is looked up in THIS owner's layout only, so a group id + * from another owner (or one deleted meanwhile) is simply not found and the + * session keeps its normal placement: after its parent when it has one, + * otherwise at the end of Ungrouped. An explicit group is a hand placement, so + * a child session placed this way is marked `manual` and stops following. + */ + async sessionCreated( + owner: string, + sessionId?: string, + placement: SessionPlacementHint = {}, + register?: RegisterCreatedSession + ): Promise { + return this.withOwner(owner, async () => { + const rollback = register?.(); + try { + const groupId = placement.tabGroupId; + return await this.getUnlocked( + owner, + sessionId && groupId + ? (layout, metadata) => placeCreatedSession(layout, sessionId, groupId, metadata) + : undefined + ); + } catch (error) { + if (typeof rollback === 'function') rollback(); + throw error; + } + }); } /** Reconcile one completed saved-webview creation into one versioned mutation. */ diff --git a/src/web/ports/session-port.ts b/src/web/ports/session-port.ts index 78e1fbbdd..adb4e85d9 100644 --- a/src/web/ports/session-port.ts +++ b/src/web/ports/session-port.ts @@ -5,10 +5,17 @@ import type { Session } from '../../session.js'; import type { SessionState } from '../../types.js'; +import type { TabLayout } from '../../tab-layout.js'; +import type { SessionPlacementHint } from '../../tab-layout-service.js'; export interface SessionPort { readonly sessions: ReadonlyMap; - addSession(session: Session): Promise; + /** + * Register a created session with the owner's tab layout. `placement.tabGroupId` + * puts it at the end of that group in the same layout write (ignored when the + * group is not in the owner's layout). Resolves to the committed layout. + */ + addSession(session: Session, placement?: SessionPlacementHint): Promise; cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise; setupSessionListeners(session: Session): Promise; persistSessionState(session: Session): void; diff --git a/src/web/public/app.js b/src/web/public/app.js index 6ba0945d7..4d34381b6 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2053,10 +2053,17 @@ class CodemanApp { _onSessionCreated(data) { this.sessions.set(data.id, data); - // Add new session to end of tab order if (!this.sessionOrder.includes(data.id)) { - this.sessionOrder.push(data.id); - this.saveSessionOrder(); + if (this._serverPlacesSessions()) { + // The server already placed it (a child after its parent, a grouped + // creation in its group) and broadcast that order; adopt it. Echoing a + // local append back as a hand order would pin every child in place. + this.syncSessionOrder(); + } else { + // Add new session to end of tab order + this.sessionOrder.push(data.id); + this.saveSessionOrder(); + } } // Idempotent per id: the POST response and the session:created event both // land here, and a batch launched together cascades in creation order. @@ -6476,6 +6483,8 @@ class CodemanApp { collapsedGroupIds: [...this.collapsedTabGroupIds], activeSessionId: this.activeSessionId, activeWebviewId: this.activeWebviewId, + // A session spawned by another one shows up in its parent's group at once. + parents: this._tabLayoutParents(), }); } @@ -6524,6 +6533,18 @@ class CodemanApp { return !!(this.tabLayout && window.CodemanTabLayout && this._tabOrientation() === 'vertical'); } + /** + * True while the owner has tab groups: session creation and deletion are then + * placed by the server (TabLayoutService), which broadcasts the resulting + * order. The browser must not echo its own guess back through + * PUT /api/session-order, which the server reads as a hand arrangement and + * answers by pinning every child session where it stands. Without groups the + * strip keeps its long-standing append-and-save behaviour. + */ + _serverPlacesSessions() { + return !!(this.tabLayout && window.CodemanTabLayout?.hasGroups(this.tabLayout)); + } + /** child session id -> parent session id, so a moved session takes the sessions that follow it. */ _tabLayoutParents() { const parents = {}; @@ -6673,8 +6694,8 @@ class CodemanApp { if (!this._tabLayoutEditable()) return []; const location = this._tabRefLocation(ref); if (!location) return []; - const actions = []; const grouped = this.tabLayout.groups.length > 0; + const actions = grouped ? this._tabRefPlacementActions(ref) : []; // Up/down follow the STORED order, which is what the rail paints unless a // sort is on (then the sort decides and there is nothing to reorder). if (grouped && !this.isTabRailSorted()) { @@ -6699,6 +6720,52 @@ class CodemanApp { return actions; } + /** + * A child session's placement, for its row menu: an informational line (where + * it sits and why, never actionable) and, for a child placed by hand, "Follow + * parent again". A child follows its parent into the parent's group until it + * is moved by hand; a child whose parent is gone stands on its own and is + * never re-adopted automatically. + */ + _tabRefPlacementActions(ref) { + const parents = this._tabLayoutParents(); + const placement = window.CodemanTabLayout.placementState(this.tabLayout, ref, parents); + if (!placement) return []; + const parent = this.sessions.get(placement.parentId); + const parentName = parent ? this.getSessionName(parent) : placement.parentId; + const summary = { + inherited: `Follows ${parentName}`, + manual: `Placed by hand (parent: ${parentName})`, + dangling: 'Parent closed; placed on its own', + cycle: 'Parent loop; placed on its own', + }[placement.state]; + const actions = [{ label: summary, disabled: true }]; + if (placement.canFollow) { + actions.push({ + label: 'Follow parent again', + run: () => this.editTabLayout({ type: 'followParent', ref, parents: this._tabLayoutParents() }, `${ref.kind}:${ref.id}`), + }); + } + return actions; + } + + /** + * The group menu's "New session": launch ONE session with the current case and + * run mode, placed at the end of this group by the server in the same write + * that records it (the group id rides the create request). A collapsed group + * expands first so the new tab is visible when it arrives. + */ + async createSessionInTabGroup(groupId) { + if (!this._tabLayoutEditable() || !this.tabLayout.groups.some((group) => group.id === groupId)) return false; + if (this._runInFlight) { + this.showToast?.('A session is already starting.', 'info'); + return false; + } + this.toggleTabGroupCollapsed(groupId, false); + await this.run({ count: 1, tabGroupId: groupId }); + return true; + } + // ─── Group and web-tab menus (right-click, the header's ⋯, Shift+F10) ── /** @@ -6739,6 +6806,7 @@ class CodemanApp { const index = groups.findIndex((group) => group.id === groupId); if (index < 0) return false; return this._openTabLayoutMenu(event, `group:${groupId}`, 'Group actions', [ + { label: 'New session', run: () => this.createSessionInTabGroup(groupId) }, { label: 'Rename group', run: () => this.startTabGroupRename(groupId) }, ...(this._canCreateTabGroup() ? [{ label: 'New group', run: () => this.createTabGroup({ index: index + 1 }) }] : []), ...(index > 0 ? [{ label: 'Move group up', run: () => this.moveTabGroup(groupId, -1) }] : []), @@ -8659,7 +8727,8 @@ class CodemanApp { const orderIndex = this.sessionOrder.indexOf(sessionId); if (orderIndex !== -1) { this.sessionOrder.splice(orderIndex, 1); - this.saveSessionOrder(); + // With groups, the server's deletion already re-projected the order. + if (!this._serverPlacesSessions()) this.saveSessionOrder(); } this.terminalBuffers.delete(sessionId); this.terminalBufferCache.delete(sessionId); diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 538534b26..2b3a307bc 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -81,6 +81,11 @@ 'Move down': '下移', 'Move to Ungrouped': '移到未分组', 'Move to new group': '移到新分组', + 'New session': '新建会话', + 'A session is already starting.': '已有会话正在启动。', + 'Follow parent again': '重新跟随父会话', + 'Parent closed; placed on its own': '父会话已关闭;单独放置', + 'Parent loop; placed on its own': '父会话形成循环;单独放置', 'Could not save tab groups.': '无法保存标签分组。', 'Tab groups changed elsewhere; part of your edit no longer applies.': '标签分组已在别处更改;你的部分编辑已不再适用。', @@ -971,6 +976,9 @@ [/^Will create: (.+)$/, (_m, path) => `将创建:${path}`], // Group names are user text: they pass through untranslated. [/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`], + // Session names are user text too. + [/^Follows (.+)$/, (_m, parent) => `跟随 ${parent}`], + [/^Placed by hand \(parent: (.+)\)$/, (_m, parent) => `已手动放置(父会话:${parent})`], [ /^Delete group "(.+)"\? Its tabs move to Ungrouped\.$/, (_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`, diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 9b2bf1b41..708d27acc 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -560,9 +560,12 @@ Object.assign(CodemanApp.prototype, { /** Ensure a newly-created session is visible without waiting for the SSE event. * The POST response and session:created can arrive in either order, so the - * normal idempotent SSE handler remains the single state-upsert path. */ - async _ensureCreatedSessionVisible(sessionId, sessionSnapshot) { + * normal idempotent SSE handler remains the single state-upsert path. + * `tabLayout` is the layout a grouped creation returns (see run()'s + * `tabGroupId`): adopting it first draws the new tab inside its group. */ + async _ensureCreatedSessionVisible(sessionId, sessionSnapshot, tabLayout) { if (!sessionId) return; + if (tabLayout) this._applyTabLayout?.(tabLayout); let session = sessionSnapshot; if (!session && !this.sessions?.has(sessionId)) { @@ -578,8 +581,13 @@ Object.assign(CodemanApp.prototype, { this._renderSessionTabsImmediate?.(); }, - /** Run using the selected mode (Claude Code, OpenCode, Codex, Gemini, or Antigravity) */ - async run() { + /** + * Run using the selected mode (Claude Code, OpenCode, Codex, Gemini, or Antigravity). + * `options` comes from the tab group menu's "New session": `count` overrides + * the instance steppers for this launch only, `tabGroupId` rides every create + * request so the server places the session in that group. + */ + async run(options = {}) { if (this._runInFlight) return; const startedAt = Date.now(); @@ -594,12 +602,12 @@ Object.assign(CodemanApp.prototype, { try { const mode = this._runMode || 'claude'; if (mode === 'shell') { - return await this.runShell(); + return await this.runShell(options); } if (mode === 'claude' || !isExternalCliRunMode(mode)) { - return await this.runClaude(); + return await this.runClaude(options); } - return await this._runCliMode(mode); + return await this._runCliMode(mode, options); } finally { const remaining = minLockMs - (Date.now() - startedAt); if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining)); @@ -1858,9 +1866,10 @@ Object.assign(CodemanApp.prototype, { } }, - async runClaude() { + async runClaude(options = {}) { const caseName = document.getElementById('quickStartCase').value || 'testcase'; - const tabCount = this._readTabCount(); + const tabCount = this._readTabCount(options.count); + const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {}; const ownsLaunchTerminal = this._beginSessionLaunchStatus( `Starting ${tabCount} Claude session(s) in ${caseName}...` @@ -1920,6 +1929,7 @@ Object.assign(CodemanApp.prototype, { for (let i = 0; i < tabCount; i++) { const quickStartBody = JSON.stringify({ caseName, mode: 'claude', sessionName: `w${startNumber + i}-${caseName}`, + ...group, ...(dockerModelOverride !== undefined ? { modelOverride: dockerModelOverride } : {}) }); const doQuickStart = async () => { @@ -1948,7 +1958,7 @@ Object.assign(CodemanApp.prototype, { } } if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session'); - await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); + await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session, data.data.tabLayout); remoteIds.push(data.data.sessionId); } this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} remote session(s) ready`); @@ -1994,6 +2004,7 @@ Object.assign(CodemanApp.prototype, { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workingDir, name, + ...group, ...(hasEnvOverrides ? { envOverrides } : {}), ...(effort ? { effort } : {}), ...(advisorModel ? { advisorModel } : {}), @@ -2007,7 +2018,7 @@ Object.assign(CodemanApp.prototype, { const sessionIds = []; for (const result of createResults) { if (!result.success) throw new Error(result.error); - await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session); + await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session, result.data.tabLayout); sessionIds.push(result.data.session.id); } firstSessionId = sessionIds[0]; @@ -2076,9 +2087,13 @@ Object.assign(CodemanApp.prototype, { } }, - async runShell() { + async runShell(options = {}) { const caseName = document.getElementById('quickStartCase').value || 'testcase'; - const shellCount = Math.min(20, Math.max(1, parseInt(document.getElementById('shellCount').value) || 1)); + const shellCount = Math.min( + 20, + Math.max(1, options.count ?? (parseInt(document.getElementById('shellCount').value) || 1)) + ); + const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {}; const ownsLaunchTerminal = this._beginSessionLaunchStatus( `Starting ${shellCount} Shell session(s) in ${caseName}...`, @@ -2124,11 +2139,11 @@ Object.assign(CodemanApp.prototype, { const res = await fetch('/api/quick-start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caseName, mode: 'shell', sessionName: `s${startNumber + i}-${caseName}` }) + body: JSON.stringify({ caseName, mode: 'shell', sessionName: `s${startNumber + i}-${caseName}`, ...group }) }); const data = await res.json(); if (!data.success) throw new Error(data.error || 'Failed to start remote shell session'); - await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); + await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session, data.data.tabLayout); remoteIds.push(data.data.sessionId); } if (remoteIds[0]) { @@ -2154,7 +2169,7 @@ Object.assign(CodemanApp.prototype, { fetch('/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...(isRemoteCase ? { caseName } : { workingDir }), mode: 'shell', name }) + body: JSON.stringify({ ...(isRemoteCase ? { caseName } : { workingDir }), mode: 'shell', name, ...group }) }).then(r => r.json()) ); const createResults = await Promise.all(createPromises); @@ -2162,7 +2177,7 @@ Object.assign(CodemanApp.prototype, { const sessionIds = []; for (const result of createResults) { if (!result.success) throw new Error(result.error); - await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session); + await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session, result.data.tabLayout); sessionIds.push(result.data.session.id); } @@ -2203,8 +2218,8 @@ Object.assign(CodemanApp.prototype, { * their try block, to put the count in the opening banner: `#tabCount` ships * unconditionally today, but a throw here would escape the launch-error path. */ - _readTabCount() { - return Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount')?.value) || 1)); + _readTabCount(override) { + return Math.min(20, Math.max(1, override ?? (parseInt(document.getElementById('tabCount')?.value) || 1))); }, /** @@ -2237,7 +2252,7 @@ Object.assign(CodemanApp.prototype, { ); if (!data.success) throw new Error(data.error || `Failed to start ${label}`); customModelAnswered = true; - await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); + await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session, data.data.tabLayout); if (!firstSessionId) firstSessionId = data.data.sessionId; } if (tabCount > 1) { @@ -2255,7 +2270,7 @@ Object.assign(CodemanApp.prototype, { * menu call them directly by name (`app.runOpenCode()` etc.), and several * tests assert on that name directly too. */ - async _runCliMode(mode) { + async _runCliMode(mode, options = {}) { const catalogEntry = registryCliById(mode); const entry = RUN_MODE_LAUNCH[mode] || (catalogEntry && { @@ -2272,7 +2287,7 @@ Object.assign(CodemanApp.prototype, { const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; - const tabCount = this._readTabCount(); + const tabCount = this._readTabCount(options.count); const ownsLaunchTerminal = this._beginSessionLaunchStatus( `Starting ${tabCount} ${entry.label} session(s) in ${caseName}...` ); @@ -2310,6 +2325,7 @@ Object.assign(CodemanApp.prototype, { caseName, mode, sessionName, + ...(options.tabGroupId ? { tabGroupId: options.tabGroupId } : {}), ...(isRemote ? {} : { ...(entry.buildConfig(globalSettings) || {}), ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), diff --git a/src/web/public/styles.css b/src/web/public/styles.css index f84982358..0e08053f8 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2804,6 +2804,17 @@ html[data-tab-orientation='vertical'].tab-rail-compact .session-tab .tab-actions color: var(--red); } +/* A child session's placement line: information, not an action. */ +.tab-rail-action-menu button.tab-rail-action-menu-note { + color: var(--text-muted); + font-size: 0.85em; + cursor: default; +} + +.tab-rail-action-menu button.tab-rail-action-menu-note:hover { + background: transparent; +} + :is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-range-field { display: grid; grid-template-columns: minmax(112px, 1fr) 44px; diff --git a/src/web/public/tab-layout-browser.js b/src/web/public/tab-layout-browser.js index dacedf59d..8f4dc9ca1 100644 --- a/src/web/public/tab-layout-browser.js +++ b/src/web/public/tab-layout-browser.js @@ -131,6 +131,12 @@ * tab, else the active session), so selecting a hidden session by keyboard, * palette or Alt+N never leaves the user with no visible selection. * + * `options.parents` (child session id -> parent session id) places a live + * session the layout has not stored yet right after its parent's rows when + * the parent IS stored: that is where the server puts a child that follows its + * parent, so a spawned session appears in its parent's group at once instead + * of jumping there from Ungrouped once the layout re-read lands. + * * @returns {null | { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef }} * null when the layout has no groups: the caller renders the flat rail * unchanged. Each section lists the rows it shows (`refs`) and the rows its @@ -152,6 +158,7 @@ ? `session:${options.activeSessionId}` : ''; const renderable = (ref) => (ref.kind === 'session' ? live.has(ref.id) : open.has(ref.id)); + const withFollowers = followingChildren(layout, liveSessionIds, options.parents); const placed = new Set(); const visibleRefs = []; const hiddenTabGroupByRef = {}; @@ -182,14 +189,14 @@ for (const group of layout.groups) { const isCollapsed = collapsed.has(group.id); - const { shown, hidden, count } = place(group.refs, group.id, isCollapsed); + const { shown, hidden, count } = place(withFollowers(group.refs), group.id, isCollapsed); sections.push({ id: group.id, name: group.name, refs: shown, hidden, count, collapsed: isCollapsed }); } const omissions = [ ...liveSessionIds.map((id) => ({ kind: 'session', id })), ...openWebviewIds.map((id) => ({ kind: 'webview', id })), ]; - const ungrouped = place([...layout.ungrouped, ...omissions], null, false); + const ungrouped = place([...withFollowers(layout.ungrouped), ...omissions], null, false); if (ungrouped.count > 0) { sections.push({ id: null, @@ -227,6 +234,47 @@ return result; } + /** + * For project(): returns `expand(refs)`, which inserts each live, not-yet-stored + * child session right after its parent's rows in the parent's container. A + * child of such a child follows it the same way. Without `parents` it is the + * identity, so callers that pass no lineage see no change. + */ + function followingChildren(layout, liveSessionIds, parents) { + if (!parents || typeof parents !== 'object') return (refs) => refs; + const stored = new Set(refLocations(layout).map((item) => refKey(item.ref))); + const accepted = new Set(); + const pending = new Map(); + // Accept parents before their children (bounded: one pass per accepted child). + for (let changed = true; changed; ) { + changed = false; + for (const id of liveSessionIds) { + const key = `session:${id}`; + const parentId = parents[id]; + if (stored.has(key) || accepted.has(key) || typeof parentId !== 'string' || parentId === id) continue; + const parentKey = `session:${parentId}`; + if (!stored.has(parentKey) && !accepted.has(parentKey)) continue; + accepted.add(key); + if (!pending.has(parentKey)) pending.set(parentKey, []); + pending.get(parentKey).push({ kind: 'session', id }); + changed = true; + } + } + if (!pending.size) return (refs) => refs; + return (refs) => { + const out = [...refs]; + for (const [parentKey, children] of pending) { + const block = lineageBlock(layout, { kind: 'session', id: parentKey.slice(8) }, parents); + let at = -1; + out.forEach((ref, index) => { + if (block.has(refKey(ref))) at = index; + }); + if (at >= 0) out.splice(at + 1, 0, ...children); + } + return out; + }; + } + /** * Everything that changes the grouped rail's STRUCTURE (which rows exist and * where, the headers' names, what a collapse hides), as opposed to a row's own @@ -418,6 +466,35 @@ return keys; } + /** + * How a child session is placed, for the row menu. null for a session with no + * parent (or not stored). Otherwise `{ state, parentId, canFollow }`: + * - inherited: it follows its parent (moves with it). + * - manual: placed by hand; "Follow parent again" puts it back. + * - dangling: its parent is not in this layout (closed, or another owner's), + * so it stands on its own; nothing re-adopts it automatically. + * - cycle: the parent chain loops back to it; it stands on its own. + */ + function placementState(layoutInput, ref, parents) { + const layout = normalizeLayout(layoutInput); + if (!validRef(ref) || ref.kind !== 'session') return null; + const parentId = parents?.[ref.id]; + if (typeof parentId !== 'string' || !parentId || parentId === ref.id) return null; + const stored = new Map(refLocations(layout).map((item) => [refKey(item.ref), item.ref])); + const own = stored.get(refKey(ref)); + if (!own) return null; + if (!stored.has(`session:${parentId}`)) return { state: 'dangling', parentId, canFollow: false }; + // Walk up the stored ancestors; meeting this session again is a loop. + const seen = new Set([ref.id]); + for (let id = parentId; id && stored.has(`session:${id}`); id = parents[id]) { + if (seen.has(id)) return { state: 'cycle', parentId, canFollow: false }; + seen.add(id); + } + return own.placement === 'manual' + ? { state: 'manual', parentId, canFollow: true } + : { state: 'inherited', parentId, canFollow: false }; + } + /** * Where a moved row lands, as the server's `index` (counted AFTER the moved * block is taken out): before or after `anchor` in that container, or at its @@ -546,6 +623,36 @@ destination.splice(clampIndex(op.index, destination.length), 0, ...block); return layout; } + case 'followParent': { + // Clear a hand placement: the child (with the sessions that follow it) + // goes back after its parent's rows. The server re-derives the exact + // order on save; this only has to put the row in the right group now. + const state = placementState(layout, op.ref, op.parents); + if (!state || state.state === 'dangling' || state.state === 'cycle') editError('no parent to follow'); + if (state.state !== 'manual') return layout; + const targetKey = refKey(op.ref); + const clear = (ref) => (refKey(ref) === targetKey ? { kind: ref.kind, id: ref.id } : ref); + for (const group of layout.groups) group.refs = group.refs.map(clear); + layout.ungrouped = layout.ungrouped.map(clear); + const childKeys = new Set(); + for (const key of lineageBlock(layout, op.ref, op.parents)) childKeys.add(key); + const block = refLocations(layout) + .filter((item) => childKeys.has(refKey(item.ref))) + .map((item) => copyRef(item.ref)); + block.sort((a, b) => (refKey(a) === targetKey ? -1 : refKey(b) === targetKey ? 1 : 0)); + for (const group of layout.groups) group.refs = group.refs.filter((ref) => !childKeys.has(refKey(ref))); + layout.ungrouped = layout.ungrouped.filter((ref) => !childKeys.has(refKey(ref))); + const parentRef = { kind: 'session', id: state.parentId }; + const home = refLocations(layout).find((item) => refKey(item.ref) === refKey(parentRef)); + const container = containerRefs(layout, home.groupId); + const parentBlock = lineageBlock(layout, parentRef, op.parents); + let at = -1; + container.forEach((ref, index) => { + if (parentBlock.has(refKey(ref))) at = index; + }); + container.splice(at + 1, 0, ...block); + return layout; + } default: return editError(`unknown operation ${op.type}`); } @@ -739,6 +846,7 @@ applyOperation, moveDestination, movingRefKeys: (layout, ref, parents) => [...lineageBlock(normalizeLayout(layout), ref, parents)], + placementState, dropOperation, contentKey, createEditCoordinator, diff --git a/src/web/public/tab-rail-resize.js b/src/web/public/tab-rail-resize.js index ae9066410..bbf54531b 100644 --- a/src/web/public/tab-rail-resize.js +++ b/src/web/public/tab-rail-resize.js @@ -340,7 +340,13 @@ Object.assign(CodemanApp.prototype, { button.setAttribute('role', 'menuitem'); button.textContent = action.label; if (action.className) button.className = action.className; + // An informational line (a child session's placement): readable, never acts. + if (action.disabled) { + button.setAttribute('aria-disabled', 'true'); + button.classList.add('tab-rail-action-menu-note'); + } button.addEventListener('click', () => { + if (action.disabled) return; this.closeTabRailActionMenu(); action.run(); }); diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index a21b19f46..6a648d50a 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -1172,7 +1172,9 @@ export function registerSessionRoutes( parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner), }); - await ctx.addSession(session); + const tabLayout = body.tabGroupId + ? await ctx.addSession(session, { tabGroupId: body.tabGroupId }) + : await ctx.addSession(session); ctx.store.incrementSessionsCreated(); ctx.persistSessionState(session); await ctx.setupSessionListeners(session); @@ -1189,7 +1191,9 @@ export function registerSessionRoutes( // Avoids serializing 2-3MB of terminal+text buffers per session creation. const lightState = ctx.getSessionStateWithRespawn(session); ctx.broadcast(SseEvent.SessionCreated, lightState); - return { session: lightState }; + // A grouped creation also returns the layout that placed it, so the browser + // draws the tab inside its group at once instead of after a re-read. + return { session: lightState, ...(body.tabGroupId ? { tabLayout } : {}) }; }); // ========== Rename Session ========== @@ -3440,6 +3444,7 @@ export function registerSessionRoutes( parentSessionId, agentOrigin, customModel, + tabGroupId, } = parseBody(QuickStartSchema, req.body); // Resolved ONCE here: the same value labels a case directory this request creates @@ -4080,7 +4085,7 @@ export function registerSessionRoutes( } } - await ctx.addSession(session); + const tabLayout = tabGroupId ? await ctx.addSession(session, { tabGroupId }) : await ctx.addSession(session); ctx.store.incrementSessionsCreated(); ctx.persistSessionState(session); await ctx.setupSessionListeners(session); @@ -4165,6 +4170,7 @@ export function registerSessionRoutes( casePath: resolvedCasePath, caseName, ...(customModel ? { modelSwapInProgress: qsCustomModelSwapInProgress } : {}), + ...(tabGroupId ? { tabLayout } : {}), }; } catch (err) { // Clean up session on error to prevent orphaned resources diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 775b889ae..2302651f8 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -533,12 +533,23 @@ export const DeepSeekWebStartSchema = z */ const parentSessionIdSchema = z.string().max(100).optional(); +/** + * Tab group the new session should be placed in (the group's "New session" + * action). Advisory like parentSessionId: the layout service looks the id up in + * the creating owner's own layout and ignores one it cannot find, so a stale or + * foreign id never fails a spawn. A malformed value (null, empty, over-long) is + * still a 400, because only the browser sends it. + */ +const tabGroupIdSchema = z.string().min(1).max(100).optional(); + export const CreateSessionSchema = z.object({ workingDir: safePathSchema.optional(), mode: sessionModeSchema().optional(), name: z.string().max(100).optional(), /** Session that spawned this one — see parentSessionIdSchema. */ parentSessionId: parentSessionIdSchema, + /** Tab group to place the new session in: see tabGroupIdSchema. */ + tabGroupId: tabGroupIdSchema, envOverrides: safeEnvOverridesSchema, /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ effort: effortLevelSchema, @@ -1080,6 +1091,8 @@ export const QuickStartSchema = z.object({ sessionName: z.string().max(128).optional(), /** Session that spawned this one — see parentSessionIdSchema. */ parentSessionId: parentSessionIdSchema, + /** Tab group to place the new session in: see tabGroupIdSchema. */ + tabGroupId: tabGroupIdSchema, /** Model override written to /.claude/settings.local.json (e.g. "opus[1m]"). * Empty string clears. Applied for local AND docker cases (the docker workspace is * a real host dir, so the settings file crosses the bind mount); rejected for diff --git a/src/web/server.ts b/src/web/server.ts index 4442e5442..83481c542 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -59,7 +59,8 @@ import { RespawnController, RespawnConfig } from '../respawn-controller.js'; import type { TerminalMultiplexer } from '../mux-interface.js'; import { createMultiplexer } from '../mux-factory.js'; import { getStore } from '../state-store.js'; -import { TabLayoutService } from '../tab-layout-service.js'; +import { TabLayoutService, type SessionPlacementHint } from '../tab-layout-service.js'; +import type { TabLayout } from '../tab-layout.js'; import { ownerLayoutKey } from '../tab-layout-persistence.js'; import { readWebviews } from '../webview-store.js'; import { extractCompletionPhrase } from '../ralph-config.js'; @@ -700,15 +701,19 @@ export class WebServer extends EventEmitter { } } - /** Add a tentative session only after its owner layout accepts the creation. */ - private async registerSessionWithLayout(session: Session): Promise { - this.sessions.set(session.id, session); - try { - await this.tabLayouts.sessionCreated(ownerLayoutKey(session.owner)); - } catch (error) { - this.sessions.delete(session.id); - throw error; - } + /** + * Add a tentative session only after its owner layout accepts the creation. + * The session enters the live map inside the layout service's owner lock, so + * the placement it asked for (`tabGroupId`) lands in the same versioned write + * that first records it. + */ + private async registerSessionWithLayout(session: Session, placement?: SessionPlacementHint): Promise { + return this.tabLayouts.sessionCreated(ownerLayoutKey(session.owner), session.id, placement, () => { + this.sessions.set(session.id, session); + return () => { + if (this.sessions.get(session.id) === session) this.sessions.delete(session.id); + }; + }); } /** diff --git a/test/routes/session-routes-tab-group.test.ts b/test/routes/session-routes-tab-group.test.ts new file mode 100644 index 000000000..cca196902 --- /dev/null +++ b/test/routes/session-routes-tab-group.test.ts @@ -0,0 +1,131 @@ +/** + * @fileoverview `tabGroupId` on the create routes: a session created from a + * group's "New session" action asks to be placed in that group. + * + * The routes only validate the field and hand it to `ctx.addSession()`; the + * placement itself (and the rule that an unknown or foreign group is ignored) + * lives in TabLayoutService, covered by test/tab-layout-service.test.ts. What is + * pinned here is the wire contract: the field is validated (a null or an empty + * string is a 400, never a silent drop), it reaches addSession with the new + * session, and the committed layout comes back on the response so the browser + * can draw the tab in its group without waiting for a re-read. Requests without + * the field keep their exact response shape. + * + * Uses app.inject(), so no real HTTP port is needed. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; +import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; +import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; +import { Session } from '../../src/session.js'; + +const LAYOUT = { + version: 4, + updatedAt: '2026-10-01T00:00:00.000Z', + groups: [{ id: 'g1', name: 'Core', refs: [] as Array<{ kind: 'session'; id: string }> }], + ungrouped: [] as Array<{ kind: 'session'; id: string }>, +}; + +describe('create routes: tabGroupId', () => { + let app: FastifyInstance; + let ctx: MockRouteContext; + let workingDir: string; + + beforeEach(async () => { + vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined); + vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined); + workingDir = await mkdtemp(join(tmpdir(), 'codeman-tab-group-')); + app = Fastify({ logger: false }); + await app.register(fastifyCookie); + ctx = createMockRouteContext(); + ctx.addSession.mockImplementation(async (session: { id: string }, placement?: { tabGroupId?: string }) => { + ctx.sessions.set(session.id, session as never); + const refs = [{ kind: 'session' as const, id: session.id }]; + return placement?.tabGroupId === 'g1' + ? { ...LAYOUT, version: 5, groups: [{ ...LAYOUT.groups[0], refs }] } + : { ...LAYOUT, version: 5, ungrouped: refs }; + }); + registerSessionRoutes(app, ctx); + installRouteErrorHandler(app); + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + await rm(workingDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + const body = (raw: string) => { + const parsed = JSON.parse(raw); + return parsed.data ?? parsed; + }; + + it('POST /api/sessions hands the group to addSession and returns the committed layout', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/sessions', + payload: { name: 'w1-x', mode: 'shell', workingDir, tabGroupId: 'g1' }, + }); + expect(res.statusCode).toBe(200); + const data = body(res.body); + expect(ctx.addSession).toHaveBeenCalledTimes(1); + expect(ctx.addSession.mock.calls[0][0].id).toBe(data.session.id); + expect(ctx.addSession.mock.calls[0][1]).toEqual({ tabGroupId: 'g1' }); + expect(data.tabLayout.version).toBe(5); + expect(data.tabLayout.groups[0].refs).toEqual([{ kind: 'session', id: data.session.id }]); + }); + + it('POST /api/sessions without a group keeps its response shape', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/sessions', + payload: { name: 'w1-x', mode: 'shell', workingDir }, + }); + expect(res.statusCode).toBe(200); + expect(Object.keys(body(res.body))).toEqual(['session']); + expect(ctx.addSession.mock.calls[0][1]).toBeUndefined(); + }); + + it('POST /api/quick-start carries the group the same way', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/quick-start', + payload: { caseName: 'tabgroupcase', mode: 'shell', tabGroupId: 'g1' }, + }); + expect(res.statusCode).toBe(200); + const data = body(res.body); + expect(ctx.addSession.mock.calls[0][1]).toEqual({ tabGroupId: 'g1' }); + expect(data.tabLayout.groups[0].refs).toEqual([{ kind: 'session', id: data.sessionId }]); + + const plain = await app.inject({ + method: 'POST', + url: '/api/quick-start', + payload: { caseName: 'tabgroupcase', mode: 'shell' }, + }); + expect(body(plain.body).tabLayout).toBeUndefined(); + }); + + it.each([ + ['null', null], + ['an empty string', ''], + ['a number', 7], + ['an over-long id', 'g'.repeat(101)], + ])('rejects %s as a tabGroupId with a 400 and creates nothing', async (_label, tabGroupId) => { + for (const [url, payload] of [ + ['/api/sessions', { name: 'w1-x', mode: 'shell', workingDir, tabGroupId }], + ['/api/quick-start', { caseName: 'tabgroupcase', mode: 'shell', tabGroupId }], + ] as const) { + const res = await app.inject({ method: 'POST', url, payload }); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).errorCode).toBe('INVALID_INPUT'); + } + expect(ctx.addSession).not.toHaveBeenCalled(); + }); +}); diff --git a/test/run-mode-dispatch.test.ts b/test/run-mode-dispatch.test.ts index 0301147ed..73bd21208 100644 --- a/test/run-mode-dispatch.test.ts +++ b/test/run-mode-dispatch.test.ts @@ -102,7 +102,7 @@ describe('run() dispatch (session-ui.js)', () => { app._runMode = id; await expect(app.run()).resolves.toBe(`cli:${id}`); expect(app._runCliMode).toHaveBeenCalledTimes(1); - expect(app._runCliMode).toHaveBeenCalledWith(id); + expect(app._runCliMode).toHaveBeenCalledWith(id, {}); expect(app.runClaude).not.toHaveBeenCalled(); expect(app.runShell).not.toHaveBeenCalled(); }); @@ -113,7 +113,7 @@ describe('run() dispatch (session-ui.js)', () => { app._runMode = 'custom-agent'; await expect(app.run()).resolves.toBe('cli:custom-agent'); expect(app._runCliMode).toHaveBeenCalledTimes(1); - expect(app._runCliMode).toHaveBeenCalledWith('custom-agent'); + expect(app._runCliMode).toHaveBeenCalledWith('custom-agent', {}); expect(app.runClaude).not.toHaveBeenCalled(); }); diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index b290cf8f2..1820ee59a 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -177,7 +177,9 @@ describe('Run launch synchronization', () => { // once. Confirmed live: adding `this.terminal.clear()` to `_runCliMode` // left this test 32/32 green under the old pattern. const bodies = new Map(); - const header = /^ {2}async (_?run[A-Za-z]*)\(\w*\) \{$/gm; + // Any parameter list (`(mode, options = {})` included), never `\(\w*\)` + // alone, or a launcher that grew a second parameter silently drops out. + const header = /^ {2}async (_?run[A-Za-z]*)\([^)\n]*\) \{$/gm; for (let m = header.exec(src); m; m = header.exec(src)) { const start = m.index + m[0].length; const end = src.indexOf('\n },', start); diff --git a/test/tab-group-launch.test.ts b/test/tab-group-launch.test.ts new file mode 100644 index 000000000..c0ffe846c --- /dev/null +++ b/test/tab-group-launch.test.ts @@ -0,0 +1,135 @@ +/** + * @fileoverview Launching a session INTO a tab group (the group menu's "New + * session"): session-ui.js run paths with `{ count, tabGroupId }`. + * + * Pins that the group id rides the create request of every launch path (local + * /api/sessions, remote/docker and external-CLI /api/quick-start), that the + * one-session count overrides the toolbar steppers without touching them, that + * the layout returned by the create response is adopted BEFORE the new tab is + * drawn (so it is drawn inside its group), and that an ordinary Run sends no + * group at all. + * + * Port: none (vm sandbox, stubbed fetch). + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +const LAYOUT = { version: 9, updatedAt: '', groups: [{ id: 'g1', name: 'Core', refs: [] }], ungrouped: [] }; + +function harness(caseData: Record, extra: { mode?: string } = {}) { + const elements: Record = { + quickStartCase: { value: 'proj' }, + tabCount: { value: '4' }, + shellCount: { value: '3' }, + }; + const requests: Array<{ url: string; body?: any }> = []; + const events: string[] = []; + let counter = 0; + const CodemanApp = function CodemanApp(this: any) {}; + const context = vm.createContext({ + CodemanApp, + localStorage: { getItem: () => null, setItem: () => {} }, + document: { getElementById: (id: string) => elements[id] ?? null }, + fetch: async (url: string, init?: { body?: string }) => { + const body = init?.body ? JSON.parse(init.body) : undefined; + requests.push({ url, body }); + const json = (value: unknown) => ({ json: async () => value }); + if (url === '/api/cases/proj') return json({ success: true, data: caseData }); + if (url.endsWith('/status')) return json({ success: true, data: { available: true, runnable: true } }); + if (url === '/api/sessions') { + const id = `sess-${++counter}`; + return json({ + success: true, + data: { session: { id, name: body.name }, ...(body.tabGroupId ? { tabLayout: LAYOUT } : {}) }, + }); + } + if (url === '/api/quick-start') { + const id = `sess-${++counter}`; + return json({ + success: true, + data: { sessionId: id, session: { id }, ...(body.tabGroupId ? { tabLayout: LAYOUT } : {}) }, + }); + } + return json({ success: true, data: {} }); + }, + console, + }); + for (const file of ['settings-ui.js', 'session-ui.js']) { + vm.runInContext(readFileSync(resolve(import.meta.dirname, '../src/web/public', file), 'utf8'), context, { + filename: file, + }); + } + const app = new (CodemanApp as any)(); + app._runMinLockMs = 0; + app._runMode = extra.mode ?? 'claude'; + app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {}, cols: 80, rows: 24 }; + app.sessions = new Map(); + app.cases = [{ name: 'proj', location: caseData.location }]; + app.loadAppSettingsFromStorage = () => ({}); + app.getCaseSettings = () => ({}); + app.buildEnvOverrides = () => ({}); + app.getEffortSetting = () => undefined; + app.isRalphTrackerEnabledByDefault = () => false; + app.getTerminalDimensions = () => null; + app.isCliAvailable = () => true; + app.loadQuickStartCases = () => {}; + app.selectSession = vi.fn(async () => {}); + app._applyTabLayout = vi.fn(() => events.push('layout')); + app._onSessionCreated = (session: any) => { + events.push(`created:${session.id}`); + app.sessions.set(session.id, session); + }; + app._renderSessionTabsImmediate = () => events.push('render'); + return { app, elements, requests, events }; +} + +const creates = (requests: Array<{ url: string; body?: any }>) => + requests.filter((req) => req.url === '/api/sessions' || req.url === '/api/quick-start'); + +describe('launching into a tab group', () => { + it('local Claude: one session with the group id, and the returned layout adopted before the tab renders', async () => { + const { app, elements, requests, events } = harness({ name: 'proj', path: '/tmp/proj' }); + await app.run({ count: 1, tabGroupId: 'g1' }); + const made = creates(requests); + expect(made).toHaveLength(1); + expect(made[0].url).toBe('/api/sessions'); + expect(made[0].body.tabGroupId).toBe('g1'); + expect(elements.tabCount.value).toBe('4'); + expect(app._applyTabLayout).toHaveBeenCalledWith(LAYOUT); + expect(events.slice(0, 3)).toEqual(['layout', 'created:sess-1', 'render']); + }); + + it('local Shell honours the same one-session override', async () => { + const { app, elements, requests } = harness({ name: 'proj', path: '/tmp/proj' }, { mode: 'shell' }); + await app.run({ count: 1, tabGroupId: 'g1' }); + const made = creates(requests); + expect(made).toHaveLength(1); + expect(made[0].body).toMatchObject({ mode: 'shell', tabGroupId: 'g1' }); + expect(elements.shellCount.value).toBe('3'); + }); + + it.each([ + ['remote Claude', { name: 'proj', path: 'u@h:/w', location: 'remote' }, 'claude'], + ['remote Shell', { name: 'proj', path: 'u@h:/w', location: 'remote' }, 'shell'], + ['an external CLI', { name: 'proj', path: '/tmp/proj' }, 'gemini'], + ])('%s carries the group on its quick-start request', async (_label, caseData, mode) => { + const { app, requests } = harness(caseData, { mode }); + await app.run({ count: 1, tabGroupId: 'g1' }); + const made = creates(requests); + expect(made).toHaveLength(1); + expect(made[0].url).toBe('/api/quick-start'); + expect(made[0].body.tabGroupId).toBe('g1'); + expect(app._applyTabLayout).toHaveBeenCalledWith(LAYOUT); + }); + + it('an ordinary Run sends no group and keeps the stepper count', async () => { + const { app, requests } = harness({ name: 'proj', path: '/tmp/proj' }); + await app.run(); + const made = creates(requests); + expect(made).toHaveLength(4); + expect(made.every((req) => !('tabGroupId' in req.body))).toBe(true); + expect(app._applyTabLayout).not.toHaveBeenCalled(); + }); +}); diff --git a/test/tab-layout-browser.test.ts b/test/tab-layout-browser.test.ts index 3fd7b85ae..36cdf1c6e 100644 --- a/test/tab-layout-browser.test.ts +++ b/test/tab-layout-browser.test.ts @@ -121,6 +121,39 @@ describe('projection', () => { }); }); +describe('projection: sessions spawned by another session', () => { + const grouped = () => ({ + version: 3, + updatedAt: '', + groups: [{ id: 'g1', name: 'Core', refs: [s('p'), s('p-kid'), s('x')] }], + ungrouped: [s('u')], + }); + const s = (id: string) => ({ kind: 'session', id }); + + it("draws a child the layout has not stored yet inside its parent's group, after the parent's rows", () => { + const h = loadHelper(); + const result = h.project(grouped(), { + liveSessionIds: ['p', 'p-kid', 'x', 'u', 'new', 'grandkid', 'orphan'], + parents: { 'p-kid': 'p', new: 'p', grandkid: 'new', orphan: 'gone' }, + }); + expect(result.sections.map((section: any) => [section.id, ids(section.refs)])).toEqual([ + ['g1', ['session:p', 'session:p-kid', 'session:new', 'session:grandkid', 'session:x']], + // A child whose parent is not in the layout has nothing to follow. + [null, ['session:u', 'session:orphan']], + ]); + }); + + it('leaves a stored child where the layout put it, and changes nothing without lineage', () => { + const h = loadHelper(); + const layout = { ...grouped(), ungrouped: [s('u'), s('manual-kid')] }; + const live = ['p', 'p-kid', 'x', 'u', 'manual-kid']; + const withParents = h.project(layout, { liveSessionIds: live, parents: { 'manual-kid': 'p' } }); + expect(ids(withParents.sections[1].refs)).toEqual(['session:u', 'session:manual-kid']); + const without = h.project(layout, { liveSessionIds: [...live, 'new'] }); + expect(ids(without.sections[1].refs)).toEqual(['session:u', 'session:manual-kid', 'session:new']); + }); +}); + describe('structure key', () => { it('changes with every structural input and ignores everything else', () => { const h = loadHelper(); diff --git a/test/tab-layout-editing.test.ts b/test/tab-layout-editing.test.ts index d2014364e..13d387ea3 100644 --- a/test/tab-layout-editing.test.ts +++ b/test/tab-layout-editing.test.ts @@ -98,6 +98,52 @@ describe('operations', () => { }); }); +describe('child sessions: follow and hand placement', () => { + const h = loadHelper(); + const lineage = (): Layout => ({ + version: 5, + updatedAt: '', + groups: [ + { id: 'g1', name: 'Core', refs: [s('p'), s('kid'), s('x')] }, + { id: 'g2', name: 'Ops', refs: [{ kind: 'session', id: 'moved', placement: 'manual' }, s('moved-kid')] }, + ], + ungrouped: [s('u'), { kind: 'session', id: 'orphan', placement: 'manual' }], + }); + const parents = { kid: 'p', moved: 'p', 'moved-kid': 'moved', orphan: 'gone' }; + + it('describes each child: following, placed by hand, or with no parent to follow', () => { + const state = (id: string, extra = {}) => plain(h.placementState(lineage(), s(id), { ...parents, ...extra })); + expect(state('kid')).toEqual({ state: 'inherited', parentId: 'p', canFollow: false }); + expect(state('moved')).toEqual({ state: 'manual', parentId: 'p', canFollow: true }); + expect(state('orphan')).toEqual({ state: 'dangling', parentId: 'gone', canFollow: false }); + expect(state('p')).toBeNull(); + expect(state('x', { x: 'u', u: 'x' })).toMatchObject({ state: 'cycle', canFollow: false }); + }); + + it('Follow parent again clears the hand placement and puts the child (and its followers) after its parent', () => { + const next = h.applyOperation(lineage(), { type: 'followParent', ref: s('moved'), parents }); + expect(keys(next.groups[0].refs)).toEqual([ + 'session:p', + 'session:kid', + 'session:moved', + 'session:moved-kid', + 'session:x', + ]); + expect(next.groups[0].refs.find((ref: Ref) => ref.id === 'moved').placement).toBeUndefined(); + expect(next.groups[1].refs).toEqual([]); + }); + + it('refuses to follow a parent that is gone or loops, and leaves a following child as it is', () => { + expect(() => h.applyOperation(lineage(), { type: 'followParent', ref: s('orphan'), parents })).toThrow(); + expect(() => + h.applyOperation(lineage(), { type: 'followParent', ref: s('x'), parents: { x: 'u', u: 'x' } }) + ).toThrow(); + expect(plain(h.applyOperation(lineage(), { type: 'followParent', ref: s('kid'), parents }))).toEqual( + plain(h.normalizeLayout(lineage())) + ); + }); +}); + describe('drop -> operation', () => { const h = loadHelper(); @@ -729,7 +775,8 @@ describe('group cap', () => { app.closeTabRailActionMenu(); header('g0').focus(); key(header('g0'), 'F10', { shiftKey: true }); - expect(menuLabels()).toEqual(['Rename group', 'Move group down', 'Delete group']); + // "New session" adds a tab, not a group, so it stays offered at the cap. + expect(menuLabels()).toEqual(['New session', 'Rename group', 'Move group down', 'Delete group']); app.closeTabGroupMenu(); }); }); @@ -741,15 +788,15 @@ describe('group menu', () => { win.confirm = vi.fn(() => true); header('gx').focus(); key(header('gx'), 'F10', { shiftKey: true }); - expect(menuLabels()).toEqual(['Rename group', 'New group', 'Move group down', 'Delete group']); - expect(document.activeElement?.textContent).toBe('Rename group'); + expect(menuLabels()).toEqual(['New session', 'Rename group', 'New group', 'Move group down', 'Delete group']); + expect(document.activeElement?.textContent).toBe('New session'); clickMenu('Move group down'); await flush(); expect(puts.at(-1).layout.groups.map((g: any) => g.id)).toEqual(['gy', 'gx']); expect(document.activeElement).toBe(header('gx')); key(header('gx'), 'ContextMenu'); - expect(menuLabels()).toEqual(['Rename group', 'New group', 'Move group up', 'Delete group']); + expect(menuLabels()).toEqual(['New session', 'Rename group', 'New group', 'Move group up', 'Delete group']); clickMenu('Delete group'); expect(win.confirm).toHaveBeenCalledWith('Delete group ""? Its tabs move to Ungrouped.'); await flush(); @@ -889,6 +936,217 @@ describe('group menu', () => { }); }); +describe('new session in a group', () => { + it('expands a collapsed group and launches ONE session targeted at it', async () => { + installFetch(); + const app = makeApp(); + app.toggleTabGroupCollapsed('gx', true); + app.run = vi.fn(async () => {}); + header('gx').focus(); + key(header('gx'), 'F10', { shiftKey: true }); + clickMenu('New session'); + await flush(); + expect(app.run).toHaveBeenCalledTimes(1); + expect(app.run).toHaveBeenCalledWith({ count: 1, tabGroupId: 'gx' }); + expect(app.collapsedTabGroupIds.has('gx')).toBe(false); + expect(JSON.parse(win.localStorage.getItem('codeman:tab-groups-collapsed'))).toEqual([]); + expect(document.querySelector('.tab-layout-group-action-menu')).toBeNull(); + }); + + it('says so, and starts nothing, while another launch is in flight', async () => { + installFetch(); + const app = makeApp(); + app.toggleTabGroupCollapsed('gx', true); + app.run = vi.fn(async () => {}); + app._runInFlight = true; + await app.createSessionInTabGroup('gx'); + expect(app.run).not.toHaveBeenCalled(); + expect(app.collapsedTabGroupIds.has('gx')).toBe(true); + expect(app.showToast).toHaveBeenCalledWith('A session is already starting.', 'info'); + // A group that no longer exists starts nothing either. + app._runInFlight = false; + expect(await app.createSessionInTabGroup('deleted')).toBe(false); + expect(app.run).not.toHaveBeenCalled(); + }); +}); + +describe('child session placement in the row menu', () => { + const lineageLayout = (): Layout => ({ + version: 8, + updatedAt: '2026-10-01T00:00:00.000Z', + groups: [ + { id: 'gx', name: 'Core', refs: [s('s1'), s('s2')] }, + { id: 'gy', name: 'Later', refs: [{ kind: 'session', id: 's3', placement: 'manual' }] }, + ], + ungrouped: [], + }); + const openMenu = (app: Record, id: string) => + app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row(id) }, id); + + it('shows where a child sits and offers Follow parent again only to a hand-placed child', async () => { + const puts = installFetch(); + const app = makeApp(lineageLayout()); + app.sessions.get('s2').parentSessionId = 's1'; + app.sessions.get('s3').parentSessionId = 's1'; + + openMenu(app, 's2'); + expect(menuLabels()).toContain('Follows One'); + expect(menuLabels()).not.toContain('Follow parent again'); + const summary = [...document.querySelectorAll('.tab-rail-action-menu button')].find( + (button) => button.textContent === 'Follows One' + )!; + expect(summary.getAttribute('aria-disabled')).toBe('true'); + summary.click(); // informational: does nothing, keeps the menu open + expect(document.querySelector('.tab-rail-action-menu')).not.toBeNull(); + app.closeTabRailActionMenu(); + + openMenu(app, 's3'); + expect(menuLabels()).toContain('Placed by hand (parent: One)'); + clickMenu('Follow parent again'); + // Optimistic: the child is back under its parent at once. + expect(row('s3').closest('.tab-layout-group')!.getAttribute('data-tab-group-id')).toBe('gx'); + await flush(); + expect(puts).toHaveLength(1); + expect(puts[0].layout.groups[0].refs).toEqual([s('s1'), s('s2'), s('s3')]); + expect(puts[0].layout.groups[1].refs).toEqual([]); + }); + + it('a child whose parent is gone says so and cannot follow', () => { + installFetch(); + const app = makeApp({ + ...lineageLayout(), + groups: [{ id: 'gx', name: 'Core', refs: [{ kind: 'session', id: 's2', placement: 'manual' }] }], + ungrouped: [s('s3')], + }); + app.sessions.delete('s1'); + app.sessions.get('s2').parentSessionId = 's1'; + openMenu(app, 's2'); + expect(menuLabels()).toContain('Parent closed; placed on its own'); + expect(menuLabels()).not.toContain('Follow parent again'); + }); + + it('a hand move makes a following child manual, so it stays when its parent moves', async () => { + const puts = installFetch(); + const app = makeApp(lineageLayout()); + app.sessions.get('s2').parentSessionId = 's1'; + openMenu(app, 's2'); + clickMenu('Move to "Later"'); + await flush(); + expect(puts[0].layout.groups[1].refs).toEqual([ + { kind: 'session', id: 's3', placement: 'manual' }, + { kind: 'session', id: 's2', placement: 'manual' }, + ]); + openMenu(app, 's1'); + clickMenu('Move to Ungrouped'); + await flush(); + expect(puts[1].layout.ungrouped).toEqual([s('s1')]); + expect(keys(puts[1].layout.groups[1].refs)).toEqual(['session:s3', 'session:s2']); + }); + + it('adds nothing to the menu for a session without a parent, or outside a grouped rail', () => { + installFetch(); + const app = makeApp(lineageLayout()); + openMenu(app, 's1'); + expect(menuLabels()).toEqual([ + 'Session options', + 'Move down', + 'Move to "Later"', + 'Move to Ungrouped', + 'Move to new group', + 'Close session', + ]); + }); +}); + +describe('the server places created and closed sessions while groups exist', () => { + const orderPuts = () => + (win.fetch as any).mock.calls.filter(([url]: [string]) => url === '/api/session-order').length; + const stub = (app: Record) => { + app._debounceTimers = {}; + app.markSessionTabEntering = () => {}; + app.markTerminalEntering = () => {}; + app.markConnectionLineEntering = () => {}; + app.renderSessionTabs = () => app._fullRenderSessionTabs(); + app.updateCost = () => {}; + app.startSystemStatsPolling = () => {}; + }; + + it('a spawned child is drawn after its parent and NOT echoed back as a hand order (which would pin it)', async () => { + installFetch(); + const app = makeApp(); + stub(app); + // The server already broadcast its order with the child after its parent. + win.localStorage.setItem('codeman-session-order', JSON.stringify(['s1', 's2', 'kid', 's3'])); + app._onSessionCreated({ id: 'kid', name: 'Kid', status: 'idle', parentSessionId: 's2' }); + expect(row('kid').closest('.tab-layout-group')!.getAttribute('data-tab-group-id')).toBe('gx'); + expect(app.sessionOrder).toEqual(['s1', 's2', 'kid', 's3']); + await new Promise((resolve) => setTimeout(resolve, 450)); + expect(orderPuts()).toBe(0); + + // The order bookkeeping runs first in _cleanupSessionData; the buffer and + // timer maps it clears afterwards are not part of this harness. + try { + app._cleanupSessionData('kid'); + } catch {} + expect(app.sessionOrder).toEqual(['s1', 's2', 's3']); + await new Promise((resolve) => setTimeout(resolve, 450)); + expect(orderPuts()).toBe(0); + }); + + it('keeps the flat rail exactly as before: a new session is appended and its order saved', async () => { + installFetch(); + const app = makeApp({ ...serverLayout(), groups: [], ungrouped: [s('s1'), s('s2'), s('s3')] }); + stub(app); + app._onSessionCreated({ id: 'kid', name: 'Kid', status: 'idle', parentSessionId: 's2' }); + expect(app.sessionOrder).toEqual(['s1', 's2', 's3', 'kid']); + await new Promise((resolve) => setTimeout(resolve, 450)); + expect(orderPuts()).toBe(1); + }); +}); + +describe('saved web tabs in groups (openness is per device)', () => { + const webLayout = (): Layout => ({ + version: 8, + updatedAt: '2026-10-01T00:00:00.000Z', + groups: [{ id: 'gx', name: 'Dash', refs: [s('s1'), w('w1'), s('s2')] }], + ungrouped: [s('s3'), w('w2')], + }); + const webRows = () => + [...document.querySelectorAll('.session-tab[data-webview-id]')].map((el) => [ + el.closest('.tab-layout-group')!.getAttribute('data-tab-group-id'), + el.dataset.webviewId, + el.querySelector('.tab-number')?.textContent ?? null, + ]); + + it('shows a grouped web tab only while it is open here, and reopens it where the layout keeps it', () => { + installFetch(); + const app = makeApp(null); + app.webviews = new Map([ + ['w1', { id: 'w1', name: 'One', url: 'https://one.test' }], + ['w2', { id: 'w2', name: 'Two', url: 'https://two.test' }], + ]); + app.webviewOrder = ['w1']; + app._applyTabLayout(webLayout()); + // w2 is saved and placed, but not open on this device: no row, no number. + expect(webRows()).toEqual([['gx', 'w1', '4']]); + expect(header('gx').querySelector('.tab-layout-group-count')!.textContent).toBe('3'); + + app.webviewOrder = []; + app._fullRenderSessionTabs(); + expect(webRows()).toEqual([]); + // The group survives with its sessions; the layout still holds the web tab. + expect(header('gx').querySelector('.tab-layout-group-count')!.textContent).toBe('2'); + expect(keys(app.tabLayout.groups[0].refs)).toEqual(['session:s1', 'webview:w1', 'session:s2']); + + app.webviewOrder = ['w2', 'w1']; + app._fullRenderSessionTabs(); + const order = [...tabs().querySelectorAll('.session-tab')].map( + (el) => el.dataset.webviewId || el.dataset.id + ); + expect(order).toEqual(['s1', 'w1', 's2', 's3', 'w2']); + }); +}); + describe('server echoes and reloads', () => { it('defers an SSE-triggered read while its own write is in flight, then re-reads once', async () => { let releasePut: (() => void) | null = null; diff --git a/test/tab-layout-grouped-create.test.ts b/test/tab-layout-grouped-create.test.ts new file mode 100644 index 000000000..9fcd8ae21 --- /dev/null +++ b/test/tab-layout-grouped-create.test.ts @@ -0,0 +1,148 @@ +/** + * @fileoverview End to end through the real WebServer: a session created with a + * `tabGroupId` lands in that group in ONE layout version, and a session spawned + * by another one (the X-Codeman-Parent-Session header) follows its parent into + * the parent's group until it is moved by hand. + * + * The service and route halves have their own unit tests; this pins the wiring + * in server.ts (the session joins the live map inside the layout owner lock and + * the placement commits with it) against the real routes and StateStore. + */ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { WebServer } from '../src/web/server.js'; + +const TEST_PORT = 3301; + +type Ref = { kind: string; id: string; placement?: string }; +type Layout = { + version: number; + updatedAt: string; + groups: Array<{ id: string; name: string; refs: Ref[] }>; + ungrouped: Ref[]; +}; + +describe('grouped session creation through the server', () => { + let server: WebServer; + const base = `http://localhost:${TEST_PORT}`; + const workingDir = mkdtempSync(join(tmpdir(), 'codeman-grouped-create-')); + + const api = async ( + path: string, + init: { method?: string; body?: unknown; headers?: Record } = {} + ) => { + const res = await fetch(`${base}${path}`, { + method: init.method ?? 'GET', + headers: { 'Content-Type': 'application/json', ...(init.headers ?? {}) }, + ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }), + }); + return { status: res.status, json: (await res.json()) as any }; + }; + const layout = async (): Promise => (await api('/api/tab-layout')).json.data.layout; + const create = async (body: Record, headers?: Record) => { + const res = await api('/api/sessions', { method: 'POST', body: { workingDir, mode: 'shell', ...body }, headers }); + expect(res.status).toBe(200); + return res.json.data as { session: { id: string }; tabLayout?: Layout }; + }; + const ids = (refs: Ref[]) => refs.map((ref) => ref.id); + + beforeAll(async () => { + server = new WebServer(TEST_PORT, false, true); + await server.start(); + }); + + afterAll(async () => { + await server.stop(); + }, 60000); + + it('places the new session at the end of the group in the same write that records it', async () => { + const first = await create({ name: 'first' }); + const current = await layout(); + const put = await api('/api/tab-layout', { + method: 'PUT', + body: { + baseVersion: current.version, + layout: { + ...current, + groups: [{ id: 'g1', name: 'Core', refs: [{ kind: 'session', id: first.session.id }] }], + ungrouped: [], + }, + }, + }); + expect(put.status).toBe(200); + const before = put.json.data.layout as Layout; + + const made = await create({ name: 'grouped', tabGroupId: 'g1' }); + const after = await layout(); + expect(after.version).toBe(before.version + 1); + expect(ids(after.groups[0].refs)).toEqual([first.session.id, made.session.id]); + expect(made.tabLayout).toEqual(after); + + // An unknown group is a hint that does not apply: the session is still created. + const loose = await create({ name: 'loose', tabGroupId: 'no-such-group' }); + expect(ids((await layout()).ungrouped)).toContain(loose.session.id); + }); + + it('a spawned child follows its parent into the group, and stays put once moved by hand', async () => { + const start = await layout(); + const parentId = start.groups[0].refs[0].id; + const child = await create({ name: 'child' }, { 'X-Codeman-Parent-Session': parentId }); + const followed = await layout(); + expect(ids(followed.groups[0].refs).slice(0, 2)).toEqual([parentId, child.session.id]); + + // Hand-move the child to Ungrouped (what the browser sends for a moveRef). + const moved = await api('/api/tab-layout', { + method: 'PUT', + body: { + baseVersion: followed.version, + layout: { + ...followed, + groups: [ + { ...followed.groups[0], refs: followed.groups[0].refs.filter((ref) => ref.id !== child.session.id) }, + ], + ungrouped: [...followed.ungrouped, { kind: 'session', id: child.session.id, placement: 'manual' }], + }, + }, + }); + expect(moved.status).toBe(200); + const after = await layout(); + expect(after.ungrouped.find((ref) => ref.id === child.session.id)).toEqual({ + kind: 'session', + id: child.session.id, + placement: 'manual', + }); + expect(ids(after.groups[0].refs)).not.toContain(child.session.id); + }); +}); + +describe('server.ts addSession wiring', () => { + it("keys the placement on the SESSION's owner and registers it only inside the layout call", async () => { + const sessions = new Map(); + let registered: (() => void | (() => void)) | undefined; + const sessionCreated = vi.fn(async (_owner: string, _id: string, _placement: unknown, register: () => unknown) => { + expect(sessions.has('bob-new')).toBe(false); + registered = register as never; + const rollback = register() as () => void; + expect(sessions.get('bob-new')).toBe(session); + rollback(); + return { version: 1 }; + }); + const server = Object.create(WebServer.prototype) as any; + server.sessions = sessions; + server.tabLayouts = { sessionCreated }; + const session = { id: 'bob-new', owner: 'bob' }; + + await server.registerSessionWithLayout(session, { tabGroupId: 'g1' }); + + expect(sessionCreated).toHaveBeenCalledWith('bob', 'bob-new', { tabGroupId: 'g1' }, expect.any(Function)); + // The rollback removed it; a rollback never removes a DIFFERENT session under that id. + expect(sessions.has('bob-new')).toBe(false); + const rollback = registered!() as () => void; + const replacement = { id: 'bob-new', owner: 'bob' }; + sessions.set('bob-new', replacement); + rollback(); + expect(sessions.get('bob-new')).toBe(replacement); + }); +}); diff --git a/test/tab-layout-service.test.ts b/test/tab-layout-service.test.ts index 2b08d8c0f..aa07fe1e1 100644 --- a/test/tab-layout-service.test.ts +++ b/test/tab-layout-service.test.ts @@ -1273,3 +1273,144 @@ describe('TabLayoutService', () => { expect(Object.hasOwn(h.store.commitTabLayoutProjection.mock.calls.at(-1)![0], 'bob')).toBe(true); }); }); + +describe('TabLayoutService grouped session creation', () => { + const grouped = (version = 7): TabLayout => ({ + version, + groups: [ + { id: 'g1', name: 'Core', refs: [{ kind: 'session', id: 'a' }] }, + { id: 'g2', name: 'Ops', refs: [] }, + ], + ungrouped: [{ kind: 'session', id: 'b' }], + updatedAt: '2026-08-15T00:00:00.000Z', + }); + const facts = (owner = 'alice') => [ + { id: 'a', owner, createdAt: 1 }, + { id: 'b', owner, createdAt: 2 }, + ]; + /** Registers `id` the way server.ts does: inside the owner lock, with a rollback. */ + const register = (h: ReturnType, fact: SessionFact) => + vi.fn(() => { + h.live.set(fact.id, fact as never); + return () => h.live.delete(fact.id); + }); + + it('places a new session at the end of the requested group in ONE versioned write', async () => { + const h = createHarness({ layouts: { alice: grouped() }, live: facts() }); + const added = register(h, { id: 'new', owner: 'alice', createdAt: 3 }); + + const layout = await h.service.sessionCreated('alice', 'new', { tabGroupId: 'g1' }, added); + + expect(added).toHaveBeenCalledOnce(); + expect(layout.version).toBe(8); + expect(layout.groups[0].refs).toEqual([ + { kind: 'session', id: 'a' }, + { kind: 'session', id: 'new' }, + ]); + expect(layout.ungrouped).toEqual([{ kind: 'session', id: 'b' }]); + expect(h.layouts.alice).toEqual(layout); + expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1); + expect(h.broadcast.mock.calls.filter(([event]) => event === SseEvent.TabLayoutChanged)).toEqual([ + [SseEvent.TabLayoutChanged, { owner: 'alice', version: 8 }], + ]); + }); + + it('registers the session INSIDE the owner lock, so a queued edit cannot commit it first', async () => { + const h = createHarness({ layouts: { alice: grouped() }, live: facts(), order: ['a', 'b'] }); + let release!: () => void; + const blocked = new Promise((resolve) => (release = resolve)); + h.readWebviews.mockImplementationOnce(async () => { + await blocked; + return []; + }); + const occupied = h.service.get('alice'); + await vi.waitFor(() => expect(h.readWebviews).toHaveBeenCalledOnce()); + const added = register(h, { id: 'new', owner: 'alice', createdAt: 3 }); + + const created = h.service.sessionCreated('alice', 'new', { tabGroupId: 'g2' }, added); + await Promise.resolve(); + expect(added).not.toHaveBeenCalled(); + release(); + await occupied; + const layout = await created; + + expect(layout.groups[1].refs).toEqual([{ kind: 'session', id: 'new' }]); + // The queued read committed nothing; the creation is the only write. + expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1); + expect(layout.version).toBe(8); + }); + + it('treats an unknown group as a hint, not an error: the session lands where it normally would', async () => { + const h = createHarness({ layouts: { alice: grouped() }, live: facts() }); + const layout = await h.service.sessionCreated( + 'alice', + 'new', + { tabGroupId: 'gone' }, + register(h, { id: 'new', owner: 'alice', createdAt: 3 }) + ); + expect(layout.ungrouped.map((ref) => ref.id)).toEqual(['b', 'new']); + expect(layout.groups.flatMap((group) => group.refs.map((ref) => ref.id))).toEqual(['a']); + }); + + it("never places a session into another owner's group", async () => { + const h = createHarness({ + layouts: { alice: grouped(), bob: existing([{ kind: 'session', id: 'bob-1' }], 3) }, + live: [...facts(), { id: 'bob-1', owner: 'bob', createdAt: 4 }], + }); + const layout = await h.service.sessionCreated( + 'bob', + 'bob-new', + { tabGroupId: 'g1' }, + register(h, { id: 'bob-new', owner: 'bob', createdAt: 5 }) + ); + expect(layout.groups).toEqual([]); + expect(layout.ungrouped.map((ref) => ref.id)).toEqual(['bob-1', 'bob-new']); + expect(h.layouts.alice).toEqual(grouped()); + }); + + it("a child follows its parent into the parent's group; an explicit group pins it manual", async () => { + const h = createHarness({ layouts: { alice: grouped() }, live: facts() }); + const followed = await h.service.sessionCreated( + 'alice', + 'child', + {}, + register(h, { id: 'child', owner: 'alice', createdAt: 3, parentSessionId: 'a' }) + ); + expect(followed.groups[0].refs).toEqual([ + { kind: 'session', id: 'a' }, + { kind: 'session', id: 'child' }, + ]); + + const pinned = await h.service.sessionCreated( + 'alice', + 'child-2', + { tabGroupId: 'g2' }, + register(h, { id: 'child-2', owner: 'alice', createdAt: 4, parentSessionId: 'a' }) + ); + expect(pinned.groups[1].refs).toEqual([{ kind: 'session', id: 'child-2', placement: 'manual' }]); + // Moving the parent later leaves the hand-placed child where it was. + const moved = await h.service.put( + 'alice', + { + ...pinned, + groups: [{ ...pinned.groups[0], refs: [] }, pinned.groups[1]], + ungrouped: [...pinned.ungrouped, ...pinned.groups[0].refs], + }, + pinned.version + ); + expect(moved.layout.groups[1].refs).toEqual([{ kind: 'session', id: 'child-2', placement: 'manual' }]); + expect(moved.layout.ungrouped.map((ref) => ref.id)).toEqual(['b', 'a', 'child']); + }); + + it('rolls the registration back when the layout write fails', async () => { + const h = createHarness({ layouts: { alice: grouped() }, live: facts() }); + h.store.commitTabLayoutProjection.mockImplementationOnce(() => { + throw new Error('disk full'); + }); + const added = register(h, { id: 'new', owner: 'alice', createdAt: 3 }); + await expect(h.service.sessionCreated('alice', 'new', { tabGroupId: 'g1' }, added)).rejects.toThrow('disk full'); + expect(added).toHaveBeenCalledOnce(); + expect(h.live.has('new')).toBe(false); + expect(h.layouts.alice).toEqual(grouped()); + }); +});