mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
fix(tabs): put child follow behind a setting, stop unpinning children on order PUTs
Review fixes for "place new, child and web tabs into groups". - Spawned tabs follow their parent only with the new synced setting spawnedTabsFollowParent (App Settings > Appearance > Tabs, off by default). TabLayoutService.sessionCreated() reads it fresh through a childrenFollowParent dep: off, a spawned session lands after its parent and is marked manual in the same write; on, it follows until moved by hand. The row menu shows the placement note and "Follow parent again" only when on. "New session" in a group menu is unaffected. zh-CN label and a Settings-Reference row. - applyLegacySessionRank marks a child manual only when the requested order splits it from its parent's block. A PUT of the current order, or one that moves a family together, leaves every stored placement alone. - The browser never echoes a create or delete through PUT /api/session-order, with or without groups: the layout service already re-projects the order and broadcasts it, and syncSessionOrder adopts it. - The placement note in the row menu is plain text described by the menu, not a focusable menu item. - A tab that first appears inside a collapsed group briefly marks that group's header. - i18n skips .tab-name, so tab names are never translated. - Tests: legacy-order cases for an unchanged order, a family reorder and a split; service cases for the setting and for order PUTs; the scheduled registration test now exercises register/undo; the grouped-create test binds port 0; one route case runs through the real layout service.
This commit is contained in:
1 parent
b8ccfc33d1
commit
5fb56b0889
21 files changed
+803
-118
No files matched your search
@@ -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)
|
**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). ⚠️ 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)
|
**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 spawned child lands after its parent; whether it keeps FOLLOWING it until moved by hand is the synced `spawnedTabsFollowParent` setting (default OFF: pinned `manual` in the creating write), read fresh in `sessionCreated()`. ⚠️ A legacy order PUT marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`), and the browser never echoes create/delete through `PUT /api/session-order`: the server already re-projected and broadcast it. 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)
|
**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)
|
||||||
|
|
||||||
|
|||||||
@@ -312,7 +312,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.
|
- **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.
|
- **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`.
|
- **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`.
|
||||||
- **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.
|
- **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 lands after 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. Whether it then FOLLOWS its parent is the synced `spawnedTabsFollowParent` setting (default OFF), which `sessionCreated()` reads fresh through the `childrenFollowParent` dep: off, the child is marked `manual` in that same creating write; on, it follows until moved by hand. Deciding at creation is enough, so flipping the setting leaves existing tabs alone. A hand move makes a following child `manual`; with the setting on, the row menu notes where a child sits (following, placed by hand, parent closed, parent loop; plain text, never a focusable menu item) and offers "Follow parent again" only to a `manual` child, which clears `placement` and is re-derived by the server on save. With the setting off the row menu says nothing about placement. A legacy `PUT /api/session-order` (strip or phone drag, a same-section Ctrl+Shift+{/}, a device without a layout, an admin's cross-owner order) marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`, `childrenSplitFromParent`); a PUT of the current order, or one that moves a family together, leaves every stored placement alone. The browser never echoes a create or delete through that PUT, with or without groups: the service already re-projects the order and broadcasts it (`session:orderChanged`), and `syncSessionOrder` adopts it. 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.
|
- **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).
|
- **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).
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ every session or only the active tab.
|
|||||||
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
|
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
|
||||||
| Spawn Lineage Lines | Lines from each tab to the sessions it spawned; the selected tab's family is drawn thicker. Desktop only, on by default. |
|
| Spawn Lineage Lines | Lines from each tab to the sessions it spawned; the selected tab's family is drawn thicker. Desktop only, on by default. |
|
||||||
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
|
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
|
||||||
|
| Spawned Tabs Follow Their Parent | A session started by another one (an agent's worker, a `parentSessionId` spawn) follows its parent's tab into its group and moves with it until you move it by hand; the row menu then says where it sits and offers *Follow parent again*. Off, it starts right after its parent and stays put. Decided when the tab is created, so flipping it leaves existing tabs alone. Synced, off by default. |
|
||||||
| Overview Home Screen | The phone home screen. On by default. |
|
| Overview Home Screen | The phone home screen. On by default. |
|
||||||
|
|
||||||
### Models
|
### Models
|
||||||
|
|||||||
@@ -32,29 +32,85 @@ export function applyLegacySessionRank(
|
|||||||
|
|
||||||
const isRanked = (ref: TabRef): boolean =>
|
const isRanked = (ref: TabRef): boolean =>
|
||||||
ref.kind === 'session' && sessionMetadata.has(ref.id) && requestedRank.has(ref.id);
|
ref.kind === 'session' && sessionMetadata.has(ref.id) && requestedRank.has(ref.id);
|
||||||
const prepare = (ref: TabRef): TabRef => {
|
|
||||||
if (ref.kind !== 'session') return { ...ref };
|
|
||||||
const item = sessionMetadata.get(ref.id);
|
|
||||||
const ownerValidParent = item?.parentSessionId && sessionMetadata.has(item.parentSessionId);
|
|
||||||
return ownerValidParent ? { ...ref, placement: 'manual' } : { ...ref };
|
|
||||||
};
|
|
||||||
const rankContainer = (refs: readonly TabRef[]): TabRef[] => {
|
const rankContainer = (refs: readonly TabRef[]): TabRef[] => {
|
||||||
const ranked = refs
|
const ranked = refs.filter(isRanked).sort((a, b) => requestedRank.get(a.id)! - requestedRank.get(b.id)!);
|
||||||
.filter(isRanked)
|
|
||||||
.map(prepare)
|
|
||||||
.sort((a, b) => requestedRank.get(a.id)! - requestedRank.get(b.id)!);
|
|
||||||
let rankedIndex = 0;
|
let rankedIndex = 0;
|
||||||
return refs.map((ref) => (isRanked(ref) ? ranked[rankedIndex++] : { ...ref }));
|
return refs.map((ref) => ({ ...(isRanked(ref) ? ranked[rankedIndex++] : ref) }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const transformed: TabLayout = {
|
const ranked: TabLayout = {
|
||||||
...layout,
|
...layout,
|
||||||
groups: layout.groups.map((group) => ({ ...group, refs: rankContainer(group.refs) })),
|
groups: layout.groups.map((group) => ({ ...group, refs: rankContainer(group.refs) })),
|
||||||
ungrouped: rankContainer(layout.ungrouped),
|
ungrouped: rankContainer(layout.ungrouped),
|
||||||
};
|
};
|
||||||
|
const manual = childrenSplitFromParent(ranked, sessionMetadata);
|
||||||
|
const pin = (ref: TabRef): TabRef =>
|
||||||
|
ref.kind === 'session' && manual.has(ref.id) ? { ...ref, placement: 'manual' } : ref;
|
||||||
|
const transformed: TabLayout = {
|
||||||
|
...ranked,
|
||||||
|
groups: ranked.groups.map((group) => ({ ...group, refs: group.refs.map(pin) })),
|
||||||
|
ungrouped: ranked.ungrouped.map(pin),
|
||||||
|
};
|
||||||
return normalizeTabLayout(transformed, metadata);
|
return normalizeTabLayout(transformed, metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The following children (no `manual` placement, an owner-valid parent) that a
|
||||||
|
* ranked layout separates from their parent's block. A parent's block is the
|
||||||
|
* parent and, right after it in the same container, its following descendants;
|
||||||
|
* sibling order inside the block is free, and a web tab the ranking left in a
|
||||||
|
* slot between them does not split it (normalization closes the block again). Only these children become `manual`:
|
||||||
|
* a PUT of the current order, or one that moves a family together, leaves every
|
||||||
|
* stored placement alone. Pinning one child shrinks its parent's block, so the
|
||||||
|
* check repeats until nothing more is pinned.
|
||||||
|
*/
|
||||||
|
function childrenSplitFromParent(ranked: TabLayout, sessionMetadata: ReadonlyMap<string, TabRefMetadata>): Set<string> {
|
||||||
|
const containers = [...ranked.groups.map((group) => group.refs), ranked.ungrouped];
|
||||||
|
const following = new Map<string, string>();
|
||||||
|
for (const refs of containers) {
|
||||||
|
for (const ref of refs) {
|
||||||
|
if (ref.kind !== 'session' || ref.placement === 'manual') continue;
|
||||||
|
const parentId = sessionMetadata.get(ref.id)?.parentSessionId;
|
||||||
|
if (parentId && sessionMetadata.has(parentId)) following.set(ref.id, parentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const descendsFrom = (id: string, ancestorId: string): boolean => {
|
||||||
|
let current = following.get(id);
|
||||||
|
for (let hops = 0; current !== undefined && hops <= following.size; hops++) {
|
||||||
|
if (current === ancestorId) return true;
|
||||||
|
current = following.get(current);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const manual = new Set<string>();
|
||||||
|
for (let changed = true; changed; ) {
|
||||||
|
changed = false;
|
||||||
|
for (const refs of containers) {
|
||||||
|
const index = new Map<string, number>();
|
||||||
|
refs.forEach((ref, at) => {
|
||||||
|
if (ref.kind === 'session') index.set(ref.id, at);
|
||||||
|
});
|
||||||
|
refs.forEach((ref, at) => {
|
||||||
|
if (ref.kind !== 'session') return;
|
||||||
|
const parentId = following.get(ref.id);
|
||||||
|
if (parentId === undefined) return;
|
||||||
|
const parentAt = index.get(parentId);
|
||||||
|
const inBlock =
|
||||||
|
parentAt !== undefined &&
|
||||||
|
parentAt < at &&
|
||||||
|
refs
|
||||||
|
.slice(parentAt + 1, at)
|
||||||
|
.every((between) => between.kind !== 'session' || descendsFrom(between.id, parentId));
|
||||||
|
if (inBlock) return;
|
||||||
|
following.delete(ref.id);
|
||||||
|
manual.add(ref.id);
|
||||||
|
changed = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return manual;
|
||||||
|
}
|
||||||
|
|
||||||
export function recomposeGlobalSessionOrder(
|
export function recomposeGlobalSessionOrder(
|
||||||
current: readonly string[],
|
current: readonly string[],
|
||||||
projections: readonly OwnerOrderProjection[],
|
projections: readonly OwnerOrderProjection[],
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
materializeOrphans,
|
materializeOrphans,
|
||||||
moveRef,
|
moveRef,
|
||||||
normalizeTabLayout,
|
normalizeTabLayout,
|
||||||
|
setManualPlacement,
|
||||||
TabLayoutValidationError,
|
TabLayoutValidationError,
|
||||||
validateTabLayout,
|
validateTabLayout,
|
||||||
type TabLayout,
|
type TabLayout,
|
||||||
@@ -43,6 +44,12 @@ interface TabLayoutServiceDeps {
|
|||||||
readWebviews(): Promise<readonly TabLayoutWebviewRecord[]>;
|
readWebviews(): Promise<readonly TabLayoutWebviewRecord[]>;
|
||||||
broadcast(event: string, data: unknown): void;
|
broadcast(event: string, data: unknown): void;
|
||||||
broadcastSessionOrder(change: SessionOrderProjectionChange): void;
|
broadcastSessionOrder(change: SessionOrderProjectionChange): void;
|
||||||
|
/**
|
||||||
|
* The synced `spawnedTabsFollowParent` setting, read at every creation. Off (or
|
||||||
|
* absent), a spawned session lands after its parent once and is hand-placed
|
||||||
|
* from then on; on, it keeps following its parent until moved by hand.
|
||||||
|
*/
|
||||||
|
childrenFollowParent?(): boolean | Promise<boolean>;
|
||||||
now?: () => string;
|
now?: () => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +117,23 @@ const sameLayout = (a: TabLayout, b: TabLayout): boolean => JSON.stringify(a) ==
|
|||||||
const sameOrder = (a: readonly string[], b: readonly string[]): boolean =>
|
const sameOrder = (a: readonly string[], b: readonly string[]): boolean =>
|
||||||
a.length === b.length && a.every((id, index) => id === b[index]);
|
a.length === b.length && a.every((id, index) => id === b[index]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand-place a just-created child where normalization put it (right after its
|
||||||
|
* parent), so it stops following. A session that does not follow anything is
|
||||||
|
* left alone.
|
||||||
|
*/
|
||||||
|
function pinCreatedChild(layout: TabLayout, sessionId: string, metadata: readonly TabRefMetadata[]): TabLayout {
|
||||||
|
const item = metadata.find((fact) => fact.kind === 'session' && fact.id === sessionId);
|
||||||
|
const parentId = item?.parentSessionId;
|
||||||
|
if (!item?.ownerValid || !parentId) return layout;
|
||||||
|
const refs = [...layout.groups.flatMap((group) => group.refs), ...layout.ungrouped];
|
||||||
|
const ref = refs.find((candidate) => candidate.kind === 'session' && candidate.id === sessionId);
|
||||||
|
const parentStored = refs.some((candidate) => candidate.kind === 'session' && candidate.id === parentId);
|
||||||
|
const parentValid = metadata.some((fact) => fact.kind === 'session' && fact.id === parentId && fact.ownerValid);
|
||||||
|
if (!ref || ref.placement === 'manual' || !parentStored || !parentValid) return layout;
|
||||||
|
return setManualPlacement(layout, ref, true);
|
||||||
|
}
|
||||||
|
|
||||||
/** Move a just-created session to the end of `groupId`, or leave the layout alone. */
|
/** Move a just-created session to the end of `groupId`, or leave the layout alone. */
|
||||||
function placeCreatedSession(
|
function placeCreatedSession(
|
||||||
layout: TabLayout,
|
layout: TabLayout,
|
||||||
@@ -487,6 +511,12 @@ export class TabLayoutService {
|
|||||||
* session keeps its normal placement: after its parent when it has one,
|
* 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
|
* 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.
|
* a child session placed this way is marked `manual` and stops following.
|
||||||
|
*
|
||||||
|
* Whether a child placed by its lineage KEEPS following is the synced
|
||||||
|
* `spawnedTabsFollowParent` setting, read here at creation: off (the default),
|
||||||
|
* the child lands after its parent once and is marked `manual` in the same
|
||||||
|
* write; on, it follows its parent until moved by hand. Flipping the setting
|
||||||
|
* later leaves existing tabs as they are.
|
||||||
*/
|
*/
|
||||||
async sessionCreated(
|
async sessionCreated(
|
||||||
owner: string,
|
owner: string,
|
||||||
@@ -495,15 +525,18 @@ export class TabLayoutService {
|
|||||||
register?: RegisterCreatedSession
|
register?: RegisterCreatedSession
|
||||||
): Promise<TabLayout> {
|
): Promise<TabLayout> {
|
||||||
return this.withOwner(owner, async () => {
|
return this.withOwner(owner, async () => {
|
||||||
|
const follow = sessionId ? (await this.deps.childrenFollowParent?.()) === true : true;
|
||||||
const rollback = register?.();
|
const rollback = register?.();
|
||||||
try {
|
try {
|
||||||
const groupId = placement.tabGroupId;
|
const groupId = placement.tabGroupId;
|
||||||
return await this.getUnlocked(
|
const adjust =
|
||||||
owner,
|
sessionId && (groupId || !follow)
|
||||||
sessionId && groupId
|
? (layout: TabLayout, metadata: readonly TabRefMetadata[]) => {
|
||||||
? (layout, metadata) => placeCreatedSession(layout, sessionId, groupId, metadata)
|
const placed = groupId ? placeCreatedSession(layout, sessionId, groupId, metadata) : layout;
|
||||||
: undefined
|
return follow ? placed : pinCreatedChild(placed, sessionId, metadata);
|
||||||
);
|
}
|
||||||
|
: undefined;
|
||||||
|
return await this.getUnlocked(owner, adjust);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (typeof rollback === 'function') rollback();
|
if (typeof rollback === 'function') rollback();
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
+49
-27
@@ -703,6 +703,9 @@ const URL_SESSION_WAIT_MS = 30000;
|
|||||||
*/
|
*/
|
||||||
const TAB_LAYOUT_PENDING_MAX_AGE_MS = 60000;
|
const TAB_LAYOUT_PENDING_MAX_AGE_MS = 60000;
|
||||||
|
|
||||||
|
/** How long a collapsed group's header stays marked after a tab arrives in it. */
|
||||||
|
const TAB_GROUP_ARRIVAL_MS = 2000;
|
||||||
|
|
||||||
class CodemanApp {
|
class CodemanApp {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.sessions = new Map();
|
this.sessions = new Map();
|
||||||
@@ -2288,16 +2291,12 @@ class CodemanApp {
|
|||||||
_onSessionCreated(data) {
|
_onSessionCreated(data) {
|
||||||
this.sessions.set(data.id, data);
|
this.sessions.set(data.id, data);
|
||||||
if (!this.sessionOrder.includes(data.id)) {
|
if (!this.sessionOrder.includes(data.id)) {
|
||||||
if (this._serverPlacesSessions()) {
|
// The server placed it when it recorded the creation (a child after its
|
||||||
// The server already placed it (a child after its parent, a grouped
|
// parent, a grouped creation in its group) and broadcast that order
|
||||||
// creation in its group) and broadcast that order; adopt it. Echoing a
|
// (session:orderChanged); adopt it, appending only while that broadcast is
|
||||||
// local append back as a hand order would pin every child in place.
|
// still on its way. Never echo it back through PUT /api/session-order:
|
||||||
this.syncSessionOrder();
|
// the server reads that as a hand arrangement of every tab.
|
||||||
} else {
|
this.syncSessionOrder();
|
||||||
// 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
|
// Idempotent per id: the POST response and the session:created event both
|
||||||
// land here, and a batch launched together cascades in creation order.
|
// land here, and a batch launched together cascades in creation order.
|
||||||
@@ -6787,6 +6786,7 @@ class CodemanApp {
|
|||||||
this._applyTabTreeSemantics(container, { identity: focusIdentity, refocus: focusWasInside });
|
this._applyTabTreeSemantics(container, { identity: focusIdentity, refocus: focusWasInside });
|
||||||
this._syncTabGroupHeaderAlerts(container, groupProjection);
|
this._syncTabGroupHeaderAlerts(container, groupProjection);
|
||||||
}
|
}
|
||||||
|
this._noteTabGroupArrivals(container, groupProjection);
|
||||||
this._syncTabTriageChrome(container, triage);
|
this._syncTabTriageChrome(container, triage);
|
||||||
this._syncTabArrangementClasses(container, { clusters: !!clusterLayout, groupProjection });
|
this._syncTabArrangementClasses(container, { clusters: !!clusterLayout, groupProjection });
|
||||||
const activeBandMoved = this._noteActiveTabBand(container) && this._isScrollingTabRow(container);
|
const activeBandMoved = this._noteActiveTabBand(container) && this._isScrollingTabRow(container);
|
||||||
@@ -7059,6 +7059,38 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tab that first appears inside a COLLAPSED group (a spawned child, a session
|
||||||
|
* another device placed there) would otherwise arrive silently, the header
|
||||||
|
* count being its only trace. Its group header is marked for a moment instead
|
||||||
|
* (`.tab-layout-group-arrived`). Only a ref absent from the previous grouped
|
||||||
|
* render counts, so collapsing a group, or the first paint, marks nothing.
|
||||||
|
*/
|
||||||
|
_noteTabGroupArrivals(container, projection) {
|
||||||
|
const previous = this._renderedTabGroupRefs;
|
||||||
|
this._renderedTabGroupRefs = projection ? new Set(Object.keys(projection.sectionByRef || {})) : null;
|
||||||
|
if (!projection) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const arrivals = (this._tabGroupArrivals ||= new Map());
|
||||||
|
if (previous) {
|
||||||
|
for (const [key, groupId] of Object.entries(projection.hiddenTabGroupByRef || {})) {
|
||||||
|
if (previous.has(key)) continue;
|
||||||
|
arrivals.set(groupId, now + TAB_GROUP_ARRIVAL_MS);
|
||||||
|
setTimeout(() => {
|
||||||
|
if ((arrivals.get(groupId) ?? 0) > Date.now()) return;
|
||||||
|
arrivals.delete(groupId);
|
||||||
|
for (const header of this.$('sessionTabs')?.querySelectorAll('[data-tab-group-header]') || []) {
|
||||||
|
if (header.dataset.tabGroupHeader === groupId) header.classList.remove('tab-layout-group-arrived');
|
||||||
|
}
|
||||||
|
}, TAB_GROUP_ARRIVAL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const header of container.querySelectorAll('[data-tab-group-header]')) {
|
||||||
|
const until = arrivals.get(header.dataset.tabGroupHeader) ?? 0;
|
||||||
|
header.classList.toggle('tab-layout-group-arrived', until > now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Keep aria-selected on the grouped tree in step with the .active class. */
|
/** Keep aria-selected on the grouped tree in step with the .active class. */
|
||||||
_syncTabTreeSelection(container) {
|
_syncTabTreeSelection(container) {
|
||||||
if (container?.getAttribute('role') !== 'tree') return;
|
if (container?.getAttribute('role') !== 'tree') return;
|
||||||
@@ -7428,18 +7460,6 @@ class CodemanApp {
|
|||||||
return !!(this.tabLayout && window.CodemanTabLayout && this._tabOrientation() === 'vertical');
|
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. */
|
/** child session id -> parent session id, so a moved session takes the sessions that follow it. */
|
||||||
_tabLayoutParents() {
|
_tabLayoutParents() {
|
||||||
const parents = {};
|
const parents = {};
|
||||||
@@ -7616,13 +7636,16 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A child session's placement, for its row menu: an informational line (where
|
* A child session's placement, for its row menu: an informational note (where
|
||||||
* it sits and why, never actionable) and, for a child placed by hand, "Follow
|
* it sits and why: plain text, never focusable) and, for a child placed by hand, "Follow
|
||||||
* parent again". A child follows its parent into the parent's group until it
|
* 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
|
* is moved by hand; a child whose parent is gone stands on its own and is
|
||||||
* never re-adopted automatically.
|
* never re-adopted automatically.
|
||||||
*/
|
*/
|
||||||
_tabRefPlacementActions(ref) {
|
_tabRefPlacementActions(ref) {
|
||||||
|
// Following is the synced "Spawned tabs follow their parent" setting (off by
|
||||||
|
// default): off, every spawned tab is hand-placed, so there is nothing to say.
|
||||||
|
if (this.loadAppSettingsFromStorage?.()?.spawnedTabsFollowParent !== true) return [];
|
||||||
const parents = this._tabLayoutParents();
|
const parents = this._tabLayoutParents();
|
||||||
const placement = window.CodemanTabLayout.placementState(this.tabLayout, ref, parents);
|
const placement = window.CodemanTabLayout.placementState(this.tabLayout, ref, parents);
|
||||||
if (!placement) return [];
|
if (!placement) return [];
|
||||||
@@ -7634,7 +7657,7 @@ class CodemanApp {
|
|||||||
dangling: 'Parent closed; placed on its own',
|
dangling: 'Parent closed; placed on its own',
|
||||||
cycle: 'Parent loop; placed on its own',
|
cycle: 'Parent loop; placed on its own',
|
||||||
}[placement.state];
|
}[placement.state];
|
||||||
const actions = [{ label: summary, disabled: true }];
|
const actions = [{ label: summary, note: true }];
|
||||||
if (placement.canFollow) {
|
if (placement.canFollow) {
|
||||||
actions.push({
|
actions.push({
|
||||||
label: 'Follow parent again',
|
label: 'Follow parent again',
|
||||||
@@ -9658,9 +9681,8 @@ class CodemanApp {
|
|||||||
// Remove from tab order
|
// Remove from tab order
|
||||||
const orderIndex = this.sessionOrder.indexOf(sessionId);
|
const orderIndex = this.sessionOrder.indexOf(sessionId);
|
||||||
if (orderIndex !== -1) {
|
if (orderIndex !== -1) {
|
||||||
|
// Not saved: the server's deletion already re-projected and broadcast the order.
|
||||||
this.sessionOrder.splice(orderIndex, 1);
|
this.sessionOrder.splice(orderIndex, 1);
|
||||||
// With groups, the server's deletion already re-projected the order.
|
|
||||||
if (!this._serverPlacesSessions()) this.saveSessionOrder();
|
|
||||||
}
|
}
|
||||||
this.terminalBuffers.delete(sessionId);
|
this.terminalBuffers.delete(sessionId);
|
||||||
this.terminalBufferCache.delete(sessionId);
|
this.terminalBufferCache.delete(sessionId);
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
'.response-viewer-content',
|
'.response-viewer-content',
|
||||||
'.file-preview-content',
|
'.file-preview-content',
|
||||||
'.session-tab-name',
|
'.session-tab-name',
|
||||||
|
// Tab names are user text: the strip, rail and sidebar draw them in .tab-name.
|
||||||
|
'.tab-name',
|
||||||
'.session-name',
|
'.session-name',
|
||||||
'.case-name',
|
'.case-name',
|
||||||
'.notif-item-message',
|
'.notif-item-message',
|
||||||
@@ -430,6 +432,9 @@
|
|||||||
'Ultracode Floating Windows': 'Ultracode 浮动窗口',
|
'Ultracode Floating Windows': 'Ultracode 浮动窗口',
|
||||||
'Approvals Inbox': '审批收件箱',
|
'Approvals Inbox': '审批收件箱',
|
||||||
'Auto-name Sessions': '自动命名会话',
|
'Auto-name Sessions': '自动命名会话',
|
||||||
|
'Spawned Tabs Follow Their Parent': '派生的标签跟随父会话',
|
||||||
|
"A session started by another one keeps following its parent's tab into its group and moves with it, until you move it by hand. Off: it starts next to its parent and stays where it is.":
|
||||||
|
'由另一个会话启动的会话会跟随父会话的标签进入其分组并随之移动,直到你手动移动它。关闭时:它出现在父会话旁边并停留在原处。',
|
||||||
Approvals: '审批',
|
Approvals: '审批',
|
||||||
'Prompts waiting on you, across all sessions': '所有会话中等待您处理的提示',
|
'Prompts waiting on you, across all sessions': '所有会话中等待您处理的提示',
|
||||||
'No pending approvals': '没有待处理的审批',
|
'No pending approvals': '没有待处理的审批',
|
||||||
|
|||||||
@@ -2237,6 +2237,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAutoNameSessions"><span class="slider"></span></label>
|
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAutoNameSessions"><span class="slider"></span></label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="set-row" id="appSettingsSpawnedTabsFollowParentItem" data-search="spawned child session follow parent tab group lineage move">
|
||||||
|
<div class="set-row-text">
|
||||||
|
<span class="set-row-label">Spawned Tabs Follow Their Parent <span class="set-tag">synced</span></span>
|
||||||
|
<span class="set-row-desc">A session started by another one keeps following its parent's tab into its group and moves with it, until you move it by hand. Off: it starts next to its parent and stays where it is.</span>
|
||||||
|
</div>
|
||||||
|
<label class="switch switch-sm"><input type="checkbox" id="appSettingsSpawnedTabsFollowParent"><span class="slider"></span></label>
|
||||||
|
</div>
|
||||||
<div class="set-row" id="appSettingsMobileOverviewItem" data-search="overview home screen phone logo">
|
<div class="set-row" id="appSettingsMobileOverviewItem" data-search="overview home screen phone logo">
|
||||||
<div class="set-row-text">
|
<div class="set-row-text">
|
||||||
<span class="set-row-label">Overview Home Screen <span class="set-tag">phone</span></span>
|
<span class="set-row-label">Overview Home Screen <span class="set-tag">phone</span></span>
|
||||||
|
|||||||
@@ -442,6 +442,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
document.getElementById('appSettingsLineageLines').checked = settings.sessionLineageLines ?? defaults.sessionLineageLines ?? true;
|
document.getElementById('appSettingsLineageLines').checked = settings.sessionLineageLines ?? defaults.sessionLineageLines ?? true;
|
||||||
// Auto-name sessions: synced, default OFF (opt-in; only an explicit true enables).
|
// Auto-name sessions: synced, default OFF (opt-in; only an explicit true enables).
|
||||||
document.getElementById('appSettingsAutoNameSessions').checked = settings.autoNameSessions === true;
|
document.getElementById('appSettingsAutoNameSessions').checked = settings.autoNameSessions === true;
|
||||||
|
// Spawned tabs follow their parent: synced (the server places the tab), default OFF.
|
||||||
|
document.getElementById('appSettingsSpawnedTabsFollowParent').checked = settings.spawnedTabsFollowParent === true;
|
||||||
const lineageItem = document.getElementById('appSettingsLineageLinesItem');
|
const lineageItem = document.getElementById('appSettingsLineageLinesItem');
|
||||||
if (lineageItem) lineageItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none';
|
if (lineageItem) lineageItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none';
|
||||||
document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false;
|
document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false;
|
||||||
@@ -2551,6 +2553,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
|
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
|
||||||
sessionLineageLines: document.getElementById('appSettingsLineageLines').checked,
|
sessionLineageLines: document.getElementById('appSettingsLineageLines').checked,
|
||||||
autoNameSessions: document.getElementById('appSettingsAutoNameSessions').checked,
|
autoNameSessions: document.getElementById('appSettingsAutoNameSessions').checked,
|
||||||
|
spawnedTabsFollowParent: document.getElementById('appSettingsSpawnedTabsFollowParent').checked,
|
||||||
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
|
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
|
||||||
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
|
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
|
||||||
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
||||||
|
|||||||
@@ -716,6 +716,19 @@ html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A tab just arrived inside this collapsed group: a short highlight on its header. */
|
||||||
|
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header.tab-layout-group-arrived {
|
||||||
|
color: var(--text);
|
||||||
|
background: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||||
|
transition: background-color 0.6s ease-out, color 0.6s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header.tab-layout-group-arrived {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle {
|
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
@@ -2909,17 +2922,14 @@ html[data-tab-orientation='vertical'].tab-rail-compact .session-tab .tab-actions
|
|||||||
color: var(--red);
|
color: var(--red);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A child session's placement line: information, not an action. */
|
/* A child session's placement note: information, not an action (no focus, no hover). */
|
||||||
.tab-rail-action-menu button.tab-rail-action-menu-note {
|
.tab-rail-action-menu .tab-rail-action-menu-note {
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-rail-action-menu button.tab-rail-action-menu-note:hover {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-range-field {
|
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-range-field {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(112px, 1fr) 44px;
|
grid-template-columns: minmax(112px, 1fr) 44px;
|
||||||
|
|||||||
@@ -336,18 +336,24 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
{ label: 'Close session', className: 'danger', run: () => this.requestCloseSession(sessionId) },
|
{ label: 'Close session', className: 'danger', run: () => this.requestCloseSession(sessionId) },
|
||||||
];
|
];
|
||||||
for (const action of actions) {
|
for (const action of actions) {
|
||||||
|
// A note (a child session's placement) is plain text, not a menu item: it
|
||||||
|
// takes no focus and no arrow stop, and describes the menu instead.
|
||||||
|
if (action.note) {
|
||||||
|
const note = document.createElement('div');
|
||||||
|
note.className = 'tab-rail-action-menu-note';
|
||||||
|
note.setAttribute('role', 'none');
|
||||||
|
note.id = 'tabRailActionMenuNote';
|
||||||
|
note.textContent = action.label;
|
||||||
|
menu.setAttribute('aria-describedby', note.id);
|
||||||
|
menu.appendChild(note);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const button = document.createElement('button');
|
const button = document.createElement('button');
|
||||||
button.type = 'button';
|
button.type = 'button';
|
||||||
button.setAttribute('role', 'menuitem');
|
button.setAttribute('role', 'menuitem');
|
||||||
button.textContent = action.label;
|
button.textContent = action.label;
|
||||||
if (action.className) button.className = action.className;
|
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', () => {
|
button.addEventListener('click', () => {
|
||||||
if (action.disabled) return;
|
|
||||||
this.closeTabRailActionMenu();
|
this.closeTabRailActionMenu();
|
||||||
action.run();
|
action.run();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1361,6 +1361,14 @@ export const SettingsUpdateSchema = z
|
|||||||
* broadcast and /api/search, which is the user's choice to make.
|
* broadcast and /api/search, which is the user's choice to make.
|
||||||
*/
|
*/
|
||||||
autoNameSessions: z.boolean().optional(),
|
autoNameSessions: z.boolean().optional(),
|
||||||
|
/**
|
||||||
|
* Spawned tabs follow their parent: a session created by another one keeps
|
||||||
|
* following its parent's tab (into its group, moving with it) until moved by
|
||||||
|
* hand. SYNCED, default OFF, because the server does the placement:
|
||||||
|
* TabLayoutService.sessionCreated() reads it at every creation, and with it
|
||||||
|
* off the child lands after its parent once and is hand-placed from then on.
|
||||||
|
*/
|
||||||
|
spawnedTabsFollowParent: z.boolean().optional(),
|
||||||
/**
|
/**
|
||||||
* Read My Mind (docs/readmymind-plan.md): capture the user's submitted
|
* Read My Mind (docs/readmymind-plan.md): capture the user's submitted
|
||||||
* prompts into per-case intent profiles. SYNCED, default OFF (opt-in:
|
* prompts into per-case intent profiles. SYNCED, default OFF (opt-in:
|
||||||
|
|||||||
@@ -457,6 +457,9 @@ export class WebServer extends EventEmitter {
|
|||||||
this.cachedLightState = null;
|
this.cachedLightState = null;
|
||||||
this.sse.broadcastSessionOrder(change);
|
this.sse.broadcastSessionOrder(change);
|
||||||
},
|
},
|
||||||
|
// Fresh, not the 2s cache: the settings PUT does not invalidate it, and a
|
||||||
|
// flip must apply to the very next spawn.
|
||||||
|
childrenFollowParent: async () => (await this.readSettings(true)).spawnedTabsFollowParent === true,
|
||||||
});
|
});
|
||||||
if (this.testMode) this.tabLayouts.markRestorationSkipped();
|
if (this.testMode) this.tabLayouts.markRestorationSkipped();
|
||||||
|
|
||||||
|
|||||||
@@ -189,14 +189,39 @@ describe('custom display name and browser localization', () => {
|
|||||||
const dom = makeDom('');
|
const dom = makeDom('');
|
||||||
const api = dom.window.CodemanI18n;
|
const api = dom.window.CodemanI18n;
|
||||||
api.configure({ language: 'zh-CN' });
|
api.configure({ language: 'zh-CN' });
|
||||||
const labels = ['Move to "New group"', 'Move to new group', 'Move to "ungrouped"', 'Move to Ungrouped'].map((label) =>
|
const labels = ['Move to "New group"', 'Move to new group', 'Move to "ungrouped"', 'Move to Ungrouped'].map(
|
||||||
api.t(label)
|
(label) => api.t(label)
|
||||||
);
|
);
|
||||||
expect(labels).toEqual(['移到“New group”', '移到新分组', '移到“ungrouped”', '移到未分组']);
|
expect(labels).toEqual(['移到“New group”', '移到新分组', '移到“ungrouped”', '移到未分组']);
|
||||||
expect(new Set(labels).size).toBe(4);
|
expect(new Set(labels).size).toBe(4);
|
||||||
dom.window.close();
|
dom.window.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('never translates a tab name, even one that reads like a UI string', async () => {
|
||||||
|
const tab = (name: string) =>
|
||||||
|
`<div class="session-tab"><span class="tab-info"><span class="tab-name-row">` +
|
||||||
|
`<span class="tab-name" data-full-name="${name}">${name}</span></span></span></div>`;
|
||||||
|
const dom = makeDom(
|
||||||
|
`<div id="sessionTabs">${tab('New session')}${tab('Follows up on bug')}</div><div id="menu"></div>`
|
||||||
|
);
|
||||||
|
const { window } = dom;
|
||||||
|
const api = window.CodemanI18n;
|
||||||
|
api.start();
|
||||||
|
api.configure({ language: 'zh-CN' });
|
||||||
|
const names = () => [...window.document.querySelectorAll('.tab-name')].map((el) => el.textContent);
|
||||||
|
expect(names()).toEqual(['New session', 'Follows up on bug']);
|
||||||
|
// A tab drawn after the switch stays untranslated too, while the same
|
||||||
|
// strings as real UI text still translate.
|
||||||
|
window.document.getElementById('sessionTabs')!.insertAdjacentHTML('beforeend', tab('New session'));
|
||||||
|
const menu = window.document.getElementById('menu')!;
|
||||||
|
menu.innerHTML = '<button>New session</button><div class="tab-rail-action-menu-note">Follows One</div>';
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(names()).toEqual(['New session', 'Follows up on bug', 'New session']);
|
||||||
|
expect(menu.querySelector('button')?.textContent).toBe('新建会话');
|
||||||
|
expect(menu.querySelector('div')?.textContent).toBe('跟随 One');
|
||||||
|
dom.window.close();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders hostile-looking names as text rather than HTML', () => {
|
it('renders hostile-looking names as text rather than HTML', () => {
|
||||||
const dom = makeDom('<span class="logo">Codeman</span>');
|
const dom = makeDom('<span class="logo">Codeman</span>');
|
||||||
const api = dom.window.CodemanI18n;
|
const api = dom.window.CodemanI18n;
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js
|
|||||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||||
import { Session } from '../../src/session.js';
|
import { Session } from '../../src/session.js';
|
||||||
|
import { TabLayoutService } from '../../src/tab-layout-service.js';
|
||||||
|
import { ownerLayoutKey } from '../../src/tab-layout-persistence.js';
|
||||||
|
import type { TabLayout } from '../../src/tab-layout.js';
|
||||||
|
|
||||||
const LAYOUT = {
|
const LAYOUT = {
|
||||||
version: 4,
|
version: 4,
|
||||||
@@ -129,3 +132,95 @@ describe('create routes: tabGroupId', () => {
|
|||||||
expect(ctx.addSession).not.toHaveBeenCalled();
|
expect(ctx.addSession).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('create routes: tabGroupId through the real layout service', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let ctx: MockRouteContext;
|
||||||
|
let workingDir: string;
|
||||||
|
const layouts: Record<string, TabLayout> = {};
|
||||||
|
let order: string[] = [];
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
|
||||||
|
workingDir = await mkdtemp(join(tmpdir(), 'codeman-tab-group-real-'));
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(fastifyCookie);
|
||||||
|
ctx = createMockRouteContext();
|
||||||
|
ctx.sessions.clear(); // only the sessions these requests create
|
||||||
|
for (const key of Object.keys(layouts)) delete layouts[key];
|
||||||
|
order = [];
|
||||||
|
// The real service over an in-memory store, wired the way server.ts wires it.
|
||||||
|
const service = new TabLayoutService({
|
||||||
|
store: {
|
||||||
|
getTabLayout: (owner: string) => layouts[owner] ?? null,
|
||||||
|
getTabLayouts: () => ({ ...layouts }),
|
||||||
|
getSessions: () => ({}),
|
||||||
|
getSessionOrder: () => [...order],
|
||||||
|
commitTabLayoutProjection: (
|
||||||
|
updates: Readonly<Record<string, TabLayout>>,
|
||||||
|
project: (latest: readonly string[]) => readonly string[]
|
||||||
|
) => {
|
||||||
|
order = [...project(order)];
|
||||||
|
Object.assign(layouts, structuredClone(updates));
|
||||||
|
return { layouts: structuredClone(updates), sessionOrder: [...order] };
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
sessions: ctx.sessions as never,
|
||||||
|
readWebviews: async () => [],
|
||||||
|
broadcast: () => {},
|
||||||
|
broadcastSessionOrder: () => {},
|
||||||
|
});
|
||||||
|
service.markRestorationSkipped();
|
||||||
|
ctx.addSession.mockImplementation((session: Session, placement?: { tabGroupId?: string }) =>
|
||||||
|
service.sessionCreated(ownerLayoutKey(session.owner), session.id, placement, () => {
|
||||||
|
ctx.sessions.set(session.id, session as never);
|
||||||
|
return () => ctx.sessions.delete(session.id);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
registerSessionRoutes(app, ctx);
|
||||||
|
installRouteErrorHandler(app);
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
await rm(workingDir, { recursive: true, force: true });
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const unwrap = (raw: string) => {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return parsed.data ?? parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('places the created session at the end of an existing group and returns the stored layout', async () => {
|
||||||
|
const first = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sessions',
|
||||||
|
payload: { name: 'w1-x', mode: 'shell', workingDir },
|
||||||
|
});
|
||||||
|
const firstId = unwrap(first.body).session.id as string;
|
||||||
|
const owner = Object.keys(layouts)[0];
|
||||||
|
layouts[owner] = {
|
||||||
|
...layouts[owner],
|
||||||
|
groups: [{ id: 'g1', name: 'Core', refs: [{ kind: 'session', id: firstId }] }],
|
||||||
|
ungrouped: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sessions',
|
||||||
|
payload: { name: 'w2-x', mode: 'shell', workingDir, tabGroupId: 'g1' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const data = unwrap(res.body);
|
||||||
|
expect(data.tabLayout).toEqual(layouts[owner]);
|
||||||
|
expect(data.tabLayout.version).toBe(layouts[owner].version);
|
||||||
|
expect(data.tabLayout.groups[0].refs).toEqual([
|
||||||
|
{ kind: 'session', id: firstId },
|
||||||
|
{ kind: 'session', id: data.session.id },
|
||||||
|
]);
|
||||||
|
expect(data.tabLayout.ungrouped).toEqual([]);
|
||||||
|
expect(order).toEqual([firstId, data.session.id]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,9 +6,17 @@ describe('scheduled session layout registration', () => {
|
|||||||
it('registers the layout before lifecycle work and rolls back a rejected tentative session', async () => {
|
it('registers the layout before lifecycle work and rolls back a rejected tentative session', async () => {
|
||||||
const sessions = new Map<string, { id: string; owner?: string }>();
|
const sessions = new Map<string, { id: string; owner?: string }>();
|
||||||
const session = { id: 'scheduled-session', owner: 'alice' };
|
const session = { id: 'scheduled-session', owner: 'alice' };
|
||||||
const sessionCreated = vi.fn(async () => {
|
let registeredBeforeFailure = false;
|
||||||
throw new Error('layout unavailable');
|
// The layout service registers the session inside its owner lock, then the
|
||||||
});
|
// write fails and it calls the undo it was handed.
|
||||||
|
const sessionCreated = vi.fn(
|
||||||
|
async (_owner: string, _id: string, _placement: unknown, register: () => void | (() => void)) => {
|
||||||
|
const undo = register();
|
||||||
|
registeredBeforeFailure = sessions.get(session.id) === session;
|
||||||
|
if (typeof undo === 'function') undo();
|
||||||
|
throw new Error('layout unavailable');
|
||||||
|
}
|
||||||
|
);
|
||||||
const server = Object.create(WebServer.prototype) as {
|
const server = Object.create(WebServer.prototype) as {
|
||||||
sessions: typeof sessions;
|
sessions: typeof sessions;
|
||||||
tabLayouts: { sessionCreated: typeof sessionCreated };
|
tabLayouts: { sessionCreated: typeof sessionCreated };
|
||||||
@@ -19,7 +27,8 @@ describe('scheduled session layout registration', () => {
|
|||||||
|
|
||||||
await expect(server.registerSessionWithLayout(session)).rejects.toThrow('layout unavailable');
|
await expect(server.registerSessionWithLayout(session)).rejects.toThrow('layout unavailable');
|
||||||
|
|
||||||
expect(sessionCreated).toHaveBeenCalledWith('alice');
|
expect(sessionCreated).toHaveBeenCalledWith('alice', 'scheduled-session', undefined, expect.any(Function));
|
||||||
|
expect(registeredBeforeFailure).toBe(true);
|
||||||
expect(sessions.has(session.id)).toBe(false);
|
expect(sessions.has(session.id)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* The "Spawned tabs follow their parent" setting (`spawnedTabsFollowParent`).
|
||||||
|
*
|
||||||
|
* SYNCED and OFF by default: the server does the placement, so the value must
|
||||||
|
* reach it (in SettingsUpdateSchema, never a per-device display key), and the
|
||||||
|
* layout service reads it at every session creation. These tests read the real
|
||||||
|
* schema, settings-ui.js, index.html, i18n.js, server.ts and the wiki.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
||||||
|
|
||||||
|
const root = resolve(import.meta.dirname, '..');
|
||||||
|
const read = (path: string) => readFileSync(resolve(root, path), 'utf8');
|
||||||
|
const html = read('src/web/public/index.html');
|
||||||
|
const settingsUi = read('src/web/public/settings-ui.js');
|
||||||
|
const i18n = read('src/web/public/i18n.js');
|
||||||
|
const server = read('src/web/server.ts');
|
||||||
|
const wiki = read('docs/wiki/Settings-Reference.md');
|
||||||
|
|
||||||
|
const LABEL = 'Spawned Tabs Follow Their Parent';
|
||||||
|
|
||||||
|
describe('spawnedTabsFollowParent setting', () => {
|
||||||
|
it('is a synced boolean in the settings schema', () => {
|
||||||
|
expect(SettingsUpdateSchema.safeParse({ spawnedTabsFollowParent: true }).success).toBe(true);
|
||||||
|
expect(SettingsUpdateSchema.safeParse({ spawnedTabsFollowParent: 'yes' }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not a per-device display key', () => {
|
||||||
|
const start = settingsUi.indexOf('const displayKeys = new Set([');
|
||||||
|
const block = settingsUi.slice(start, settingsUi.indexOf(']);', start));
|
||||||
|
expect(start).toBeGreaterThan(-1);
|
||||||
|
expect(block).not.toContain('spawnedTabsFollowParent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has an unchecked switch in App Settings > Appearance > Tabs, marked synced', () => {
|
||||||
|
const tabs = html.slice(html.indexOf('<h4>Tabs</h4><span class="set-scope">device</span>'));
|
||||||
|
const group = tabs.slice(0, tabs.indexOf('</section>'));
|
||||||
|
const row = group.match(
|
||||||
|
/<div class="set-row"[^>]*id="appSettingsSpawnedTabsFollowParentItem"[\s\S]*?<\/label>/
|
||||||
|
)?.[0];
|
||||||
|
expect(row).toBeTruthy();
|
||||||
|
expect(row).toContain(LABEL);
|
||||||
|
expect(row).toContain('<span class="set-tag">synced</span>');
|
||||||
|
expect(row).toMatch(/<input type="checkbox" id="appSettingsSpawnedTabsFollowParent">/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads as off unless explicitly true, and saves from the switch', () => {
|
||||||
|
expect(settingsUi).toContain(
|
||||||
|
"document.getElementById('appSettingsSpawnedTabsFollowParent').checked = settings.spawnedTabsFollowParent === true;"
|
||||||
|
);
|
||||||
|
expect(settingsUi).toContain(
|
||||||
|
"spawnedTabsFollowParent: document.getElementById('appSettingsSpawnedTabsFollowParent').checked,"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is read fresh by the layout service at creation time', () => {
|
||||||
|
expect(server).toMatch(
|
||||||
|
/childrenFollowParent: async \(\) => \(await this\.readSettings\(true\)\)\.spawnedTabsFollowParent === true/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a zh-CN label and a wiki row', () => {
|
||||||
|
expect(i18n).toMatch(new RegExp(`'${LABEL}': '[^']+'`));
|
||||||
|
expect(wiki).toMatch(new RegExp(`\\| ${LABEL}\\s+\\|[^\\n]*Synced, off by default`));
|
||||||
|
});
|
||||||
|
});
|
||||||
+114
-33
@@ -190,7 +190,10 @@ describe('drop -> operation', () => {
|
|||||||
const onUngrouped = h.dropOperation(base(), { type: 'ref', ref: s('a') }, { type: 'ungrouped' }, {});
|
const onUngrouped = h.dropOperation(base(), { type: 'ref', ref: s('a') }, { type: 'ungrouped' }, {});
|
||||||
expect(onUngrouped).toMatchObject({ groupId: null });
|
expect(onUngrouped).toMatchObject({ groupId: null });
|
||||||
expect(onUngrouped).not.toHaveProperty('index');
|
expect(onUngrouped).not.toHaveProperty('index');
|
||||||
const crowded = { ...base(), groups: [{ ...base().groups[0], refs: [...base().groups[0].refs, s('late')] }, base().groups[1]] };
|
const crowded = {
|
||||||
|
...base(),
|
||||||
|
groups: [{ ...base().groups[0], refs: [...base().groups[0].refs, s('late')] }, base().groups[1]],
|
||||||
|
};
|
||||||
const replayed = h.applyOperation(crowded, onHeader);
|
const replayed = h.applyOperation(crowded, onHeader);
|
||||||
expect(replayed.groups[0].refs.at(-1)).toEqual(w('web'));
|
expect(replayed.groups[0].refs.at(-1)).toEqual(w('web'));
|
||||||
});
|
});
|
||||||
@@ -819,7 +822,7 @@ describe('group menu', () => {
|
|||||||
expect(app.tabLayout.groups).toHaveLength(2);
|
expect(app.tabLayout.groups).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("closing the row menu (as session:deleted does) leaves an open group menu and its listeners alone", () => {
|
it('closing the row menu (as session:deleted does) leaves an open group menu and its listeners alone', () => {
|
||||||
installFetch();
|
installFetch();
|
||||||
const app = makeApp();
|
const app = makeApp();
|
||||||
header('gx').focus();
|
header('gx').focus();
|
||||||
@@ -982,26 +985,36 @@ describe('child session placement in the row menu', () => {
|
|||||||
});
|
});
|
||||||
const openMenu = (app: Record<string, any>, id: string) =>
|
const openMenu = (app: Record<string, any>, id: string) =>
|
||||||
app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row(id) }, id);
|
app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row(id) }, id);
|
||||||
|
/** The synced "Spawned tabs follow their parent" setting, ON. */
|
||||||
|
const following = (app: Record<string, any>) => {
|
||||||
|
app.loadAppSettingsFromStorage = () => ({ spawnedTabsFollowParent: true });
|
||||||
|
return app;
|
||||||
|
};
|
||||||
|
const note = () => document.querySelector<HTMLElement>('.tab-rail-action-menu .tab-rail-action-menu-note');
|
||||||
|
|
||||||
it('shows where a child sits and offers Follow parent again only to a hand-placed child', async () => {
|
it('shows where a child sits and offers Follow parent again only to a hand-placed child', async () => {
|
||||||
const puts = installFetch();
|
const puts = installFetch();
|
||||||
const app = makeApp(lineageLayout());
|
const app = following(makeApp(lineageLayout()));
|
||||||
app.sessions.get('s2').parentSessionId = 's1';
|
app.sessions.get('s2').parentSessionId = 's1';
|
||||||
app.sessions.get('s3').parentSessionId = 's1';
|
app.sessions.get('s3').parentSessionId = 's1';
|
||||||
|
|
||||||
openMenu(app, 's2');
|
openMenu(app, 's2');
|
||||||
expect(menuLabels()).toContain('Follows One');
|
expect(note()!.textContent).toBe('Follows One');
|
||||||
|
expect(menuLabels()).not.toContain('Follows One');
|
||||||
expect(menuLabels()).not.toContain('Follow parent again');
|
expect(menuLabels()).not.toContain('Follow parent again');
|
||||||
const summary = [...document.querySelectorAll<HTMLElement>('.tab-rail-action-menu button')].find(
|
// Information, not an action: never focusable, never a menu item, and the
|
||||||
(button) => button.textContent === 'Follows One'
|
// menu opens on its first real action.
|
||||||
)!;
|
expect(note()!.tagName).not.toBe('BUTTON');
|
||||||
expect(summary.getAttribute('aria-disabled')).toBe('true');
|
expect(note()!.getAttribute('role')).toBe('none');
|
||||||
summary.click(); // informational: does nothing, keeps the menu open
|
expect(note()!.hasAttribute('tabindex')).toBe(false);
|
||||||
|
expect(document.activeElement?.textContent).toBe('Session options');
|
||||||
|
expect(document.querySelector('.tab-rail-action-menu')!.getAttribute('aria-describedby')).toBe(note()!.id);
|
||||||
|
note()!.click();
|
||||||
expect(document.querySelector('.tab-rail-action-menu')).not.toBeNull();
|
expect(document.querySelector('.tab-rail-action-menu')).not.toBeNull();
|
||||||
app.closeTabRailActionMenu();
|
app.closeTabRailActionMenu();
|
||||||
|
|
||||||
openMenu(app, 's3');
|
openMenu(app, 's3');
|
||||||
expect(menuLabels()).toContain('Placed by hand (parent: One)');
|
expect(note()!.textContent).toBe('Placed by hand (parent: One)');
|
||||||
clickMenu('Follow parent again');
|
clickMenu('Follow parent again');
|
||||||
// Optimistic: the child is back under its parent at once.
|
// Optimistic: the child is back under its parent at once.
|
||||||
expect(row('s3').closest('.tab-layout-group')!.getAttribute('data-tab-group-id')).toBe('gx');
|
expect(row('s3').closest('.tab-layout-group')!.getAttribute('data-tab-group-id')).toBe('gx');
|
||||||
@@ -1018,16 +1031,31 @@ describe('child session placement in the row menu', () => {
|
|||||||
groups: [{ id: 'gx', name: 'Core', refs: [{ kind: 'session', id: 's2', placement: 'manual' }] }],
|
groups: [{ id: 'gx', name: 'Core', refs: [{ kind: 'session', id: 's2', placement: 'manual' }] }],
|
||||||
ungrouped: [s('s3')],
|
ungrouped: [s('s3')],
|
||||||
});
|
});
|
||||||
|
following(app);
|
||||||
app.sessions.delete('s1');
|
app.sessions.delete('s1');
|
||||||
app.sessions.get('s2').parentSessionId = 's1';
|
app.sessions.get('s2').parentSessionId = 's1';
|
||||||
openMenu(app, 's2');
|
openMenu(app, 's2');
|
||||||
expect(menuLabels()).toContain('Parent closed; placed on its own');
|
expect(note()!.textContent).toBe('Parent closed; placed on its own');
|
||||||
expect(menuLabels()).not.toContain('Follow parent again');
|
expect(menuLabels()).not.toContain('Follow parent again');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('with the follow setting off (the default) shows no follow note and no Follow parent again', () => {
|
||||||
|
installFetch();
|
||||||
|
const app = makeApp(lineageLayout());
|
||||||
|
app.sessions.get('s2').parentSessionId = 's1';
|
||||||
|
app.sessions.get('s3').parentSessionId = 's1';
|
||||||
|
for (const id of ['s2', 's3']) {
|
||||||
|
openMenu(app, id);
|
||||||
|
expect(note()).toBeNull();
|
||||||
|
expect(menuLabels()).not.toContain('Follow parent again');
|
||||||
|
expect(menuLabels()[0]).toBe('Session options');
|
||||||
|
app.closeTabRailActionMenu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('a hand move makes a following child manual, so it stays when its parent moves', async () => {
|
it('a hand move makes a following child manual, so it stays when its parent moves', async () => {
|
||||||
const puts = installFetch();
|
const puts = installFetch();
|
||||||
const app = makeApp(lineageLayout());
|
const app = following(makeApp(lineageLayout()));
|
||||||
app.sessions.get('s2').parentSessionId = 's1';
|
app.sessions.get('s2').parentSessionId = 's1';
|
||||||
openMenu(app, 's2');
|
openMenu(app, 's2');
|
||||||
clickMenu('Move to "Later"');
|
clickMenu('Move to "Later"');
|
||||||
@@ -1045,7 +1073,7 @@ describe('child session placement in the row menu', () => {
|
|||||||
|
|
||||||
it('adds nothing to the menu for a session without a parent, or outside a grouped rail', () => {
|
it('adds nothing to the menu for a session without a parent, or outside a grouped rail', () => {
|
||||||
installFetch();
|
installFetch();
|
||||||
const app = makeApp(lineageLayout());
|
const app = following(makeApp(lineageLayout()));
|
||||||
openMenu(app, 's1');
|
openMenu(app, 's1');
|
||||||
expect(menuLabels()).toEqual([
|
expect(menuLabels()).toEqual([
|
||||||
'Session options',
|
'Session options',
|
||||||
@@ -1058,7 +1086,7 @@ describe('child session placement in the row menu', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('the server places created and closed sessions while groups exist', () => {
|
describe('the server places created and closed sessions; the browser never echoes them', () => {
|
||||||
const orderPuts = () =>
|
const orderPuts = () =>
|
||||||
(win.fetch as any).mock.calls.filter(([url]: [string]) => url === '/api/session-order').length;
|
(win.fetch as any).mock.calls.filter(([url]: [string]) => url === '/api/session-order').length;
|
||||||
const stub = (app: Record<string, any>) => {
|
const stub = (app: Record<string, any>) => {
|
||||||
@@ -1070,37 +1098,88 @@ describe('the server places created and closed sessions while groups exist', ()
|
|||||||
app.updateCost = () => {};
|
app.updateCost = () => {};
|
||||||
app.startSystemStatsPolling = () => {};
|
app.startSystemStatsPolling = () => {};
|
||||||
};
|
};
|
||||||
|
/** Create then delete `kid`, with the server's order broadcast landing first. */
|
||||||
it('a spawned child is drawn after its parent and NOT echoed back as a hand order (which would pin it)', async () => {
|
const createAndClose = async (app: Record<string, any>, serverOrder: string[]) => {
|
||||||
installFetch();
|
app._onSessionOrderChanged({ order: serverOrder });
|
||||||
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' });
|
app._onSessionCreated({ id: 'kid', name: 'Kid', status: 'idle', parentSessionId: 's2' });
|
||||||
expect(row('kid').closest('.tab-layout-group')!.getAttribute('data-tab-group-id')).toBe('gx');
|
const created = [...app.sessionOrder];
|
||||||
expect(app.sessionOrder).toEqual(['s1', 's2', 'kid', 's3']);
|
const drawn = [...tabs().querySelectorAll<HTMLElement>('.session-tab[data-id]')].map((el) => el.dataset.id);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 450));
|
await new Promise((resolve) => setTimeout(resolve, 450));
|
||||||
expect(orderPuts()).toBe(0);
|
const putsAfterCreate = orderPuts();
|
||||||
|
|
||||||
// The order bookkeeping runs first in _cleanupSessionData; the buffer and
|
// The order bookkeeping runs first in _cleanupSessionData; the buffer and
|
||||||
// timer maps it clears afterwards are not part of this harness.
|
// timer maps it clears afterwards are not part of this harness.
|
||||||
try {
|
try {
|
||||||
app._cleanupSessionData('kid');
|
app._cleanupSessionData('kid');
|
||||||
} catch {}
|
} catch {}
|
||||||
expect(app.sessionOrder).toEqual(['s1', 's2', 's3']);
|
const closed = [...app.sessionOrder];
|
||||||
await new Promise((resolve) => setTimeout(resolve, 450));
|
await new Promise((resolve) => setTimeout(resolve, 450));
|
||||||
expect(orderPuts()).toBe(0);
|
return { created, drawn, closed, putsAfterCreate, putsAfterClose: orderPuts() };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('with groups: a spawned child is drawn after its parent and NOT echoed back as a hand order', async () => {
|
||||||
|
installFetch();
|
||||||
|
const app = makeApp();
|
||||||
|
stub(app);
|
||||||
|
const result = await createAndClose(app, ['s1', 's2', 'kid', 's3']);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
created: ['s1', 's2', 'kid', 's3'],
|
||||||
|
// Drawn inside the parent's group, right after it (the rail draws groups first).
|
||||||
|
drawn: ['s2', 'kid', 's1', 's3'],
|
||||||
|
closed: ['s1', 's2', 's3'],
|
||||||
|
putsAfterCreate: 0,
|
||||||
|
putsAfterClose: 0,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the flat rail exactly as before: a new session is appended and its order saved', async () => {
|
it('with no groups (the flat rail): the server broadcast decides the order, and nothing is echoed', async () => {
|
||||||
installFetch();
|
installFetch();
|
||||||
const app = makeApp({ ...serverLayout(), groups: [], ungrouped: [s('s1'), s('s2'), s('s3')] });
|
const app = makeApp({ ...serverLayout(), groups: [], ungrouped: [s('s1'), s('s2'), s('s3')] });
|
||||||
stub(app);
|
stub(app);
|
||||||
app._onSessionCreated({ id: 'kid', name: 'Kid', status: 'idle', parentSessionId: 's2' });
|
const result = await createAndClose(app, ['s1', 's2', 'kid', 's3']);
|
||||||
expect(app.sessionOrder).toEqual(['s1', 's2', 's3', 'kid']);
|
expect(result).toMatchObject({
|
||||||
|
created: ['s1', 's2', 'kid', 's3'],
|
||||||
|
closed: ['s1', 's2', 's3'],
|
||||||
|
putsAfterCreate: 0,
|
||||||
|
putsAfterClose: 0,
|
||||||
|
});
|
||||||
|
// The header strip and phones read the same sessionOrder the flat rail drew.
|
||||||
|
expect(result.drawn).toEqual(['s1', 's2', 'kid', 's3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with no layout read at all (the header strip): a session arriving before its order is appended, not saved', async () => {
|
||||||
|
installFetch();
|
||||||
|
const app = makeApp(null);
|
||||||
|
stub(app);
|
||||||
|
app._onSessionCreated({ id: 'late', name: 'Late', status: 'idle' });
|
||||||
|
expect(app.sessionOrder).toEqual(['s1', 's2', 's3', 'late']);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 450));
|
await new Promise((resolve) => setTimeout(resolve, 450));
|
||||||
expect(orderPuts()).toBe(1);
|
expect(orderPuts()).toBe(0);
|
||||||
|
// The server's broadcast then wins.
|
||||||
|
app._onSessionOrderChanged({ order: ['late', 's1', 's2', 's3'] });
|
||||||
|
expect(app.sessionOrder).toEqual(['late', 's1', 's2', 's3']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a session arriving inside a collapsed group', () => {
|
||||||
|
it('briefly marks the group header instead of arriving silently', async () => {
|
||||||
|
installFetch();
|
||||||
|
const app = makeApp();
|
||||||
|
app.toggleTabGroupCollapsed('gx', true);
|
||||||
|
// Collapsing alone hides rows that were already there: no mark.
|
||||||
|
expect(header('gx').classList.contains('tab-layout-group-arrived')).toBe(false);
|
||||||
|
app.sessions.set('kid', { id: 'kid', name: 'Kid', status: 'idle', parentSessionId: 's2' });
|
||||||
|
app.sessionOrder.push('kid');
|
||||||
|
app._fullRenderSessionTabs();
|
||||||
|
expect(row('kid')).toBeNull();
|
||||||
|
expect(header('gx').classList.contains('tab-layout-group-arrived')).toBe(true);
|
||||||
|
expect(header('gy').classList.contains('tab-layout-group-arrived')).toBe(false);
|
||||||
|
// It survives a re-render while it lasts, then goes.
|
||||||
|
app._fullRenderSessionTabs();
|
||||||
|
expect(header('gx').classList.contains('tab-layout-group-arrived')).toBe(true);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2100));
|
||||||
|
expect(header('gx').classList.contains('tab-layout-group-arrived')).toBe(false);
|
||||||
|
app._fullRenderSessionTabs();
|
||||||
|
expect(header('gx').classList.contains('tab-layout-group-arrived')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1227,7 +1306,7 @@ describe('server echoes and reloads', () => {
|
|||||||
expect(gets).toHaveLength(1);
|
expect(gets).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('replays only its own owner\'s recent copy of unsaved edits', async () => {
|
it("replays only its own owner's recent copy of unsaved edits", async () => {
|
||||||
const copy = (extra: Record<string, unknown>) =>
|
const copy = (extra: Record<string, unknown>) =>
|
||||||
win.sessionStorage.setItem(
|
win.sessionStorage.setItem(
|
||||||
'codeman:tab-layout-pending',
|
'codeman:tab-layout-pending',
|
||||||
@@ -1334,7 +1413,9 @@ describe('server echoes and reloads', () => {
|
|||||||
|
|
||||||
it('keeps the group menu glyph visible where there is no hover (touch tablets)', () => {
|
it('keeps the group menu glyph visible where there is no hover (touch tablets)', () => {
|
||||||
const css = read('styles.css');
|
const css = read('styles.css');
|
||||||
const block = css.match(/@media \(hover: none\) \{\s*html\[data-tab-orientation='vertical'\] \.tab-rail \.tab-layout-group-menu \{([^}]*)\}/);
|
const block = css.match(
|
||||||
|
/@media \(hover: none\) \{\s*html\[data-tab-orientation='vertical'\] \.tab-rail \.tab-layout-group-menu \{([^}]*)\}/
|
||||||
|
);
|
||||||
expect(block?.[1]).toMatch(/opacity:\s*1/);
|
expect(block?.[1]).toMatch(/opacity:\s*1/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* @fileoverview End to end through the real WebServer: a session created with a
|
* @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
|
* `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
|
* by another one (the X-Codeman-Parent-Session header) lands after its parent in
|
||||||
* the parent's group until it is moved by hand.
|
* the parent's group: hand-placed from then on by default, or following its
|
||||||
|
* parent until moved by hand with the synced `spawnedTabsFollowParent` setting.
|
||||||
*
|
*
|
||||||
* The service and route halves have their own unit tests; this pins the wiring
|
* 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
|
* in server.ts (the session joins the live map inside the layout owner lock and
|
||||||
@@ -14,8 +15,6 @@ import { tmpdir } from 'node:os';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { WebServer } from '../src/web/server.js';
|
import { WebServer } from '../src/web/server.js';
|
||||||
|
|
||||||
const TEST_PORT = 3301;
|
|
||||||
|
|
||||||
type Ref = { kind: string; id: string; placement?: string };
|
type Ref = { kind: string; id: string; placement?: string };
|
||||||
type Layout = {
|
type Layout = {
|
||||||
version: number;
|
version: number;
|
||||||
@@ -26,7 +25,7 @@ type Layout = {
|
|||||||
|
|
||||||
describe('grouped session creation through the server', () => {
|
describe('grouped session creation through the server', () => {
|
||||||
let server: WebServer;
|
let server: WebServer;
|
||||||
const base = `http://localhost:${TEST_PORT}`;
|
let base = '';
|
||||||
const workingDir = mkdtempSync(join(tmpdir(), 'codeman-grouped-create-'));
|
const workingDir = mkdtempSync(join(tmpdir(), 'codeman-grouped-create-'));
|
||||||
|
|
||||||
const api = async (
|
const api = async (
|
||||||
@@ -49,8 +48,9 @@ describe('grouped session creation through the server', () => {
|
|||||||
const ids = (refs: Ref[]) => refs.map((ref) => ref.id);
|
const ids = (refs: Ref[]) => refs.map((ref) => ref.id);
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
server = new WebServer(TEST_PORT, false, true);
|
server = new WebServer(0, false, true);
|
||||||
await server.start();
|
await server.start();
|
||||||
|
base = `http://localhost:${server.boundPort}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -85,24 +85,51 @@ describe('grouped session creation through the server', () => {
|
|||||||
expect(ids((await layout()).ungrouped)).toContain(loose.session.id);
|
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 () => {
|
it('by default a spawned child lands after its parent once and is hand-placed', async () => {
|
||||||
|
const start = await layout();
|
||||||
|
const parentId = start.groups[0].refs[0].id;
|
||||||
|
const child = await create({ name: 'pinned-child' }, { 'X-Codeman-Parent-Session': parentId });
|
||||||
|
const after = await layout();
|
||||||
|
expect(after.groups[0].refs.slice(0, 2)).toEqual([
|
||||||
|
{ kind: 'session', id: parentId },
|
||||||
|
{ kind: 'session', id: child.session.id, placement: 'manual' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with the follow setting on, a spawned child follows its parent until moved by hand', async () => {
|
||||||
|
const saved = await api('/api/settings', { method: 'PUT', body: { spawnedTabsFollowParent: true } });
|
||||||
|
expect(saved.status).toBe(200);
|
||||||
const start = await layout();
|
const start = await layout();
|
||||||
const parentId = start.groups[0].refs[0].id;
|
const parentId = start.groups[0].refs[0].id;
|
||||||
const child = await create({ name: 'child' }, { 'X-Codeman-Parent-Session': parentId });
|
const child = await create({ name: 'child' }, { 'X-Codeman-Parent-Session': parentId });
|
||||||
const followed = await layout();
|
const followed = await layout();
|
||||||
expect(ids(followed.groups[0].refs).slice(0, 2)).toEqual([parentId, child.session.id]);
|
expect(followed.groups[0].refs.slice(0, 2)).toEqual([
|
||||||
|
{ kind: 'session', id: parentId },
|
||||||
|
{ kind: 'session', id: child.session.id },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// A legacy order PUT of the current order (what any device may send) keeps it following.
|
||||||
|
const order = [...followed.groups.flatMap((group) => group.refs), ...followed.ungrouped]
|
||||||
|
.filter((ref) => ref.kind === 'session')
|
||||||
|
.map((ref) => ref.id);
|
||||||
|
const echoed = await api('/api/session-order', { method: 'PUT', body: { order } });
|
||||||
|
expect(echoed.status).toBe(200);
|
||||||
|
expect(echoed.json.data.order).toEqual(order);
|
||||||
|
expect((await layout()).groups[0].refs.find((ref) => ref.id === child.session.id)).toEqual({
|
||||||
|
kind: 'session',
|
||||||
|
id: child.session.id,
|
||||||
|
});
|
||||||
|
|
||||||
// Hand-move the child to Ungrouped (what the browser sends for a moveRef).
|
// Hand-move the child to Ungrouped (what the browser sends for a moveRef).
|
||||||
|
const before = await layout();
|
||||||
const moved = await api('/api/tab-layout', {
|
const moved = await api('/api/tab-layout', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: {
|
body: {
|
||||||
baseVersion: followed.version,
|
baseVersion: before.version,
|
||||||
layout: {
|
layout: {
|
||||||
...followed,
|
...before,
|
||||||
groups: [
|
groups: [{ ...before.groups[0], refs: before.groups[0].refs.filter((ref) => ref.id !== child.session.id) }],
|
||||||
{ ...followed.groups[0], refs: followed.groups[0].refs.filter((ref) => ref.id !== child.session.id) },
|
ungrouped: [...before.ungrouped, { kind: 'session', id: child.session.id, placement: 'manual' }],
|
||||||
],
|
|
||||||
ungrouped: [...followed.ungrouped, { kind: 'session', id: child.session.id, placement: 'manual' }],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -83,6 +83,86 @@ describe('applyLegacySessionRank', () => {
|
|||||||
expect(normalizeTabLayout(result, facts)).toEqual(result);
|
expect(normalizeTabLayout(result, facts)).toEqual(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('leaves every placement alone when the requested order is the current order', () => {
|
||||||
|
const input = layout({
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 'family',
|
||||||
|
name: 'Family',
|
||||||
|
refs: [session('parent'), session('child'), session('grandchild'), session('other')],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
ungrouped: [session('loose', 'manual')],
|
||||||
|
});
|
||||||
|
const facts = [
|
||||||
|
metadata('parent', { order: 0 }),
|
||||||
|
metadata('child', { order: 1, parentSessionId: 'parent' }),
|
||||||
|
metadata('grandchild', { order: 2, parentSessionId: 'child' }),
|
||||||
|
metadata('other', { order: 3 }),
|
||||||
|
metadata('loose', { order: 4, parentSessionId: 'parent' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = applyLegacySessionRank(input, ['parent', 'child', 'grandchild', 'other', 'loose'], facts);
|
||||||
|
|
||||||
|
expect(result.groups[0].refs).toEqual(input.groups[0].refs);
|
||||||
|
expect(result.ungrouped).toEqual([session('loose', 'manual')]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a child following when the reorder keeps it in its parent block', () => {
|
||||||
|
const input = layout({
|
||||||
|
ungrouped: [session('x'), session('parent'), session('c1'), session('c2'), webview('w'), session('y')],
|
||||||
|
});
|
||||||
|
const facts = [
|
||||||
|
metadata('x', { order: 0 }),
|
||||||
|
metadata('parent', { order: 1 }),
|
||||||
|
metadata('c1', { order: 2, parentSessionId: 'parent' }),
|
||||||
|
metadata('c2', { order: 3, parentSessionId: 'parent' }),
|
||||||
|
metadata('y', { order: 4 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
// The whole family moves ahead of x, and its two children swap places.
|
||||||
|
const result = applyLegacySessionRank(input, ['parent', 'c2', 'c1', 'x', 'y'], facts);
|
||||||
|
|
||||||
|
expect(result.ungrouped).toEqual([
|
||||||
|
session('parent'),
|
||||||
|
session('c2'),
|
||||||
|
session('c1'),
|
||||||
|
session('x'),
|
||||||
|
webview('w'),
|
||||||
|
session('y'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not read a web tab left between a parent and its child as a split', () => {
|
||||||
|
const input = layout({ ungrouped: [session('x'), webview('w'), session('parent'), session('child')] });
|
||||||
|
const facts = [
|
||||||
|
metadata('x', { order: 0 }),
|
||||||
|
metadata('parent', { order: 1 }),
|
||||||
|
metadata('child', { order: 2, parentSessionId: 'parent' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = applyLegacySessionRank(input, ['parent', 'child', 'x'], facts);
|
||||||
|
|
||||||
|
expect(result.ungrouped).toEqual([session('parent'), session('child'), webview('w'), session('x')]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pins only the child the requested order splits from its parent block', () => {
|
||||||
|
const input = layout({
|
||||||
|
ungrouped: [session('parent'), session('c1'), session('c2'), session('x')],
|
||||||
|
});
|
||||||
|
const facts = [
|
||||||
|
metadata('parent', { order: 0 }),
|
||||||
|
metadata('c1', { order: 1, parentSessionId: 'parent' }),
|
||||||
|
metadata('c2', { order: 2, parentSessionId: 'parent' }),
|
||||||
|
metadata('x', { order: 3 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = applyLegacySessionRank(input, ['parent', 'c1', 'x', 'c2'], facts);
|
||||||
|
|
||||||
|
expect(result.ungrouped).toEqual([session('parent'), session('c1'), session('x'), session('c2', 'manual')]);
|
||||||
|
expect(normalizeTabLayout(result, facts)).toEqual(result);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps a ranked child manual in its group when normalization materializes its missing parent', () => {
|
it('keeps a ranked child manual in its group when normalization materializes its missing parent', () => {
|
||||||
const input = layout({
|
const input = layout({
|
||||||
groups: [{ id: 'child-group', name: 'Child', refs: [session('child')] }],
|
groups: [{ id: 'child-group', name: 'Child', refs: [session('child')] }],
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ function createHarness(
|
|||||||
live?: SessionFact[];
|
live?: SessionFact[];
|
||||||
webviews?: Array<{ id: string; owner?: string }>;
|
webviews?: Array<{ id: string; owner?: string }>;
|
||||||
now?: () => string;
|
now?: () => string;
|
||||||
|
/** The synced spawnedTabsFollowParent setting (absent = off, the default). */
|
||||||
|
follow?: boolean;
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
const layouts = { ...(options.layouts ?? {}) };
|
const layouts = { ...(options.layouts ?? {}) };
|
||||||
@@ -45,6 +47,7 @@ function createHarness(
|
|||||||
readWebviews,
|
readWebviews,
|
||||||
broadcast,
|
broadcast,
|
||||||
broadcastSessionOrder,
|
broadcastSessionOrder,
|
||||||
|
childrenFollowParent: vi.fn(() => options.follow === true),
|
||||||
now: options.now ?? (() => '2026-08-16T12:00:00.000Z'),
|
now: options.now ?? (() => '2026-08-16T12:00:00.000Z'),
|
||||||
});
|
});
|
||||||
return { service, store, broadcast, broadcastSessionOrder, layouts, order, live, persisted, readWebviews };
|
return { service, store, broadcast, broadcastSessionOrder, layouts, order, live, persisted, readWebviews };
|
||||||
@@ -1368,8 +1371,8 @@ describe('TabLayoutService grouped session creation', () => {
|
|||||||
expect(h.layouts.alice).toEqual(grouped());
|
expect(h.layouts.alice).toEqual(grouped());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a child follows its parent into the parent's group; an explicit group pins it manual", async () => {
|
it('with the follow setting on, a child follows its parent; an explicit group pins it manual', async () => {
|
||||||
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
|
const h = createHarness({ layouts: { alice: grouped() }, live: facts(), follow: true });
|
||||||
const followed = await h.service.sessionCreated(
|
const followed = await h.service.sessionCreated(
|
||||||
'alice',
|
'alice',
|
||||||
'child',
|
'child',
|
||||||
@@ -1388,18 +1391,89 @@ describe('TabLayoutService grouped session creation', () => {
|
|||||||
register(h, { id: 'child-2', owner: 'alice', createdAt: 4, parentSessionId: 'a' })
|
register(h, { id: 'child-2', owner: 'alice', createdAt: 4, parentSessionId: 'a' })
|
||||||
);
|
);
|
||||||
expect(pinned.groups[1].refs).toEqual([{ kind: 'session', id: 'child-2', placement: 'manual' }]);
|
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.
|
// Move ONLY the parent: the following child goes with it, the hand-placed one stays.
|
||||||
const moved = await h.service.put(
|
const moved = await h.service.put(
|
||||||
'alice',
|
'alice',
|
||||||
{
|
{
|
||||||
...pinned,
|
...pinned,
|
||||||
groups: [{ ...pinned.groups[0], refs: [] }, pinned.groups[1]],
|
groups: [{ ...pinned.groups[0], refs: [{ kind: 'session', id: 'child' }] }, pinned.groups[1]],
|
||||||
ungrouped: [...pinned.ungrouped, ...pinned.groups[0].refs],
|
ungrouped: [...pinned.ungrouped, { kind: 'session', id: 'a' }],
|
||||||
},
|
},
|
||||||
pinned.version
|
pinned.version
|
||||||
);
|
);
|
||||||
|
expect(moved.layout.groups[0].refs).toEqual([]);
|
||||||
expect(moved.layout.groups[1].refs).toEqual([{ kind: 'session', id: 'child-2', placement: 'manual' }]);
|
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']);
|
expect(moved.layout.ungrouped).toEqual([
|
||||||
|
{ kind: 'session', id: 'b' },
|
||||||
|
{ kind: 'session', id: 'a' },
|
||||||
|
{ kind: 'session', id: 'child' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with the follow setting off (the default), a child lands after its parent once and is hand-placed', async () => {
|
||||||
|
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
|
||||||
|
const created = await h.service.sessionCreated(
|
||||||
|
'alice',
|
||||||
|
'child',
|
||||||
|
{},
|
||||||
|
register(h, { id: 'child', owner: 'alice', createdAt: 3, parentSessionId: 'a' })
|
||||||
|
);
|
||||||
|
// Same place as a following child, in ONE write, but pinned there.
|
||||||
|
expect(created.version).toBe(8);
|
||||||
|
expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1);
|
||||||
|
expect(created.groups[0].refs).toEqual([
|
||||||
|
{ kind: 'session', id: 'a' },
|
||||||
|
{ kind: 'session', id: 'child', placement: 'manual' },
|
||||||
|
]);
|
||||||
|
const moved = await h.service.put(
|
||||||
|
'alice',
|
||||||
|
{
|
||||||
|
...created,
|
||||||
|
groups: [{ ...created.groups[0], refs: [created.groups[0].refs[1]] }, created.groups[1]],
|
||||||
|
ungrouped: [...created.ungrouped, { kind: 'session', id: 'a' }],
|
||||||
|
},
|
||||||
|
created.version
|
||||||
|
);
|
||||||
|
expect(moved.layout.groups[0].refs).toEqual([{ kind: 'session', id: 'child', placement: 'manual' }]);
|
||||||
|
expect(moved.layout.ungrouped.map((ref) => ref.id)).toEqual(['b', 'a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the follow setting fresh at every creation', async () => {
|
||||||
|
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
|
||||||
|
const setting = vi.fn<() => boolean>().mockReturnValueOnce(true).mockReturnValueOnce(false);
|
||||||
|
(h.service as unknown as { deps: { childrenFollowParent: typeof setting } }).deps.childrenFollowParent = setting;
|
||||||
|
await h.service.sessionCreated(
|
||||||
|
'alice',
|
||||||
|
'c1',
|
||||||
|
{},
|
||||||
|
register(h, { id: 'c1', owner: 'alice', createdAt: 3, parentSessionId: 'a' })
|
||||||
|
);
|
||||||
|
const layout = await h.service.sessionCreated(
|
||||||
|
'alice',
|
||||||
|
'c2',
|
||||||
|
{},
|
||||||
|
register(h, { id: 'c2', owner: 'alice', createdAt: 4, parentSessionId: 'a' })
|
||||||
|
);
|
||||||
|
expect(setting).toHaveBeenCalledTimes(2);
|
||||||
|
expect(layout.groups[0].refs).toEqual([
|
||||||
|
{ kind: 'session', id: 'a' },
|
||||||
|
{ kind: 'session', id: 'c1' },
|
||||||
|
{ kind: 'session', id: 'c2', placement: 'manual' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a session without a parent is never pinned, whatever the setting', async () => {
|
||||||
|
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
|
||||||
|
const layout = await h.service.sessionCreated(
|
||||||
|
'alice',
|
||||||
|
'plain',
|
||||||
|
{},
|
||||||
|
register(h, { id: 'plain', owner: 'alice', createdAt: 3 })
|
||||||
|
);
|
||||||
|
expect(layout.ungrouped).toEqual([
|
||||||
|
{ kind: 'session', id: 'b' },
|
||||||
|
{ kind: 'session', id: 'plain' },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rolls the registration back when the layout write fails', async () => {
|
it('rolls the registration back when the layout write fails', async () => {
|
||||||
@@ -1414,3 +1488,75 @@ describe('TabLayoutService grouped session creation', () => {
|
|||||||
expect(h.layouts.alice).toEqual(grouped());
|
expect(h.layouts.alice).toEqual(grouped());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('TabLayoutService legacy order PUTs keep a following child following', () => {
|
||||||
|
const family = (): TabLayout => ({
|
||||||
|
version: 4,
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 'g1',
|
||||||
|
name: 'Core',
|
||||||
|
refs: [
|
||||||
|
{ kind: 'session', id: 'x' },
|
||||||
|
{ kind: 'session', id: 'p' },
|
||||||
|
{ kind: 'session', id: 'kid' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
ungrouped: [{ kind: 'session', id: 'y' }],
|
||||||
|
updatedAt: '2026-08-15T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
const live = [
|
||||||
|
{ id: 'x', owner: 'alice', createdAt: 1 },
|
||||||
|
{ id: 'p', owner: 'alice', createdAt: 2 },
|
||||||
|
{ id: 'kid', owner: 'alice', createdAt: 3, parentSessionId: 'p' },
|
||||||
|
{ id: 'y', owner: 'alice', createdAt: 4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const actor of [
|
||||||
|
{ owner: 'alice', isAdmin: false },
|
||||||
|
{ owner: 'admin', isAdmin: true },
|
||||||
|
]) {
|
||||||
|
it(`a PUT of the current order leaves placement alone (${actor.isAdmin ? 'admin' : 'owner'})`, async () => {
|
||||||
|
const h = createHarness({ layouts: { alice: family() }, live, order: ['x', 'p', 'kid', 'y'] });
|
||||||
|
await h.service.putLegacyOrder(actor, ['x', 'p', 'kid', 'y']);
|
||||||
|
expect(h.layouts.alice).toEqual(family());
|
||||||
|
expect(h.store.commitTabLayoutProjection.mock.calls.every(([updates]) => !Object.hasOwn(updates, 'alice'))).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('a reorder that keeps the child after its parent keeps it following, so it moves with its parent', async () => {
|
||||||
|
const h = createHarness({ layouts: { alice: family() }, live, order: ['x', 'p', 'kid', 'y'] });
|
||||||
|
await h.service.putLegacyOrder({ owner: 'alice', isAdmin: false }, ['p', 'kid', 'x', 'y']);
|
||||||
|
expect(h.layouts.alice.groups[0].refs).toEqual([
|
||||||
|
{ kind: 'session', id: 'p' },
|
||||||
|
{ kind: 'session', id: 'kid' },
|
||||||
|
{ kind: 'session', id: 'x' },
|
||||||
|
]);
|
||||||
|
// Still following: moving only the parent takes the child along.
|
||||||
|
const current = h.layouts.alice;
|
||||||
|
const moved = await h.service.put(
|
||||||
|
'alice',
|
||||||
|
{
|
||||||
|
...current,
|
||||||
|
groups: [{ ...current.groups[0], refs: [current.groups[0].refs[1], current.groups[0].refs[2]] }],
|
||||||
|
ungrouped: [...current.ungrouped, { kind: 'session', id: 'p' }],
|
||||||
|
},
|
||||||
|
current.version
|
||||||
|
);
|
||||||
|
expect(moved.layout.ungrouped.map((ref) => ref.id)).toEqual(['y', 'p', 'kid']);
|
||||||
|
expect(moved.layout.groups[0].refs).toEqual([{ kind: 'session', id: 'x' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a reorder that splits the child from its parent pins it', async () => {
|
||||||
|
const h = createHarness({ layouts: { alice: family() }, live, order: ['x', 'p', 'kid', 'y'] });
|
||||||
|
await h.service.putLegacyOrder({ owner: 'alice', isAdmin: false }, ['kid', 'x', 'p', 'y']);
|
||||||
|
expect(h.layouts.alice.groups[0].refs).toEqual([
|
||||||
|
{ kind: 'session', id: 'kid', placement: 'manual' },
|
||||||
|
{ kind: 'session', id: 'x' },
|
||||||
|
{ kind: 'session', id: 'p' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user