From 04aa44b69c32e6883d358c64cadd141f69772501 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 27 Oct 2026 01:30:07 +0100 Subject: [PATCH] fix: apply the review items for #712 at merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - session-ui.js _reloadCasesAfterRun: reload onto the toolbar's own case after every launch, not only one that names its case; comment updated - test/tab-new-session.test.ts: a worktree tab's "+" Claude launch, the toolbar Run and a group's "New session" each keep the toolbar's case after a "+" moved lastUsedCase (fail without the fix); the reload setup is shared with the existing lastUsedCase test - docs/architecture-invariants.md:349: the _reloadCasesAfterRun sentence covers every launch - app.js _openTabLayoutMenu focusout: focus landing on the trigger is not a departure, so pressing the "+" again closes its picker in a real browser - tab-rail-resize.js openTabRailActionMenu: close the group menu too, so the rail's ... pressed again does not leave the picker beside the row menu - tab-rail-resize.js:329-331: the "New session here…" comment no longer says Enter on the "+" does nothing Co-Authored-By: Claude Opus 5.5 --- docs/architecture-invariants.md | 2 +- src/web/public/app.js | 6 ++- src/web/public/session-ui.js | 16 +++---- src/web/public/tab-rail-resize.js | 5 ++- test/tab-new-session.test.ts | 70 ++++++++++++++++++++++++------- 5 files changed, 73 insertions(+), 26 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index d562e9b7..3640d617 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -346,7 +346,7 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t - **The upstream HTML5 drag stays off in the grouped rail**: a flat-order drop cannot express a group move, and the server re-ranks within the old group. The grouped rail has its OWN pointer drag instead (`_bindTabLayoutPointerDrag`, mouse/pen only, bound once on the container): rows move before/after a row or into a group, a header drag reorders groups, and the drop maps to ONE operation through the pure `dropOperation()`. Escape cancels a drag, and the click that ends one is swallowed. ⚠️ A press is only captured once it moves 6 px, so its release can land outside the rail: `pointerup`/`pointercancel` are heard on `window` while a press is pending, a move with the primary button up cancels it, and a new press cancels any previous one. Without that a stale press became a phantom drag on the next hover, and a replaced drag left its capture-phase Escape listener behind, swallowing every Escape before the terminal saw it. The flat rail and the header strip keep the HTML5 drag untouched. For the same reason Ctrl+Shift+{ / } only swaps with a neighbour in the active session's own section (`_canSwapActiveTabWith`, reading the projection's `sectionByRef`): a cross-group swap moves nothing on the server, gets no `session:orderChanged` back, and would leave this client's `sessionOrder` and Alt+N targets out of step with every other device. - **Edits are named operations, saved serially.** `createEditCoordinator` applies `createGroup` / `renameGroup` / `deleteGroup` / `reorderGroup` / `moveRef` to the rail at once, then sends ONE `PUT /api/tab-layout {baseVersion, layout}` at a time; edits made meanwhile wait and go out on the version that write returns. A 409 carries the server's layout: the in-flight operations are replayed onto it (an operation that no longer applies is dropped and reported) and re-sent, at most `maxAttempts` times; a 400 re-reads once (a second 400 is reported as a failed save, not as a race); anything else reports and re-reads. A `moveRef` moves the session together with the sessions that still follow it and marks a hand-moved child `placement: 'manual'`, mirroring the server's `moveRef`, and `normalizeLayout` keeps `placement` because whole layouts are written back. `_onTabLayoutChanged` / `_applyTabLayout` defer a read while a write is in flight and rebase unsaved edits onto a read otherwise. ⚠️ A FAILED read (`_applyTabLayout(null)`) while edits are pending keeps the held layout and the editor and re-reads after the write settles: disposing there would orphan the in-flight write, whose 409 then never gets its rebase. Any path that does drop unsaved work says so in a toast. On `pagehide`, unconfirmed operations go out in a `keepalive` PUT AND into sessionStorage as `{ owner, baseVersion, savedAt, operations }`; after reload they replay onto the fresh layout (a no-op when the keepalive landed), but only for the same owner, within `TAB_LAYOUT_PENDING_MAX_AGE_MS` (60 s), and never onto a layout older than the copy's base. A "Move to " with no anchor carries no `index`, so a replay still puts the row last. - **Every way in has a keyboard path.** The session row menu (Shift+F10 in the tree, the rail's overflow button) gains Move up/down, Move to , Move to Ungrouped and Move to new group in the vertical rail (only "new group" before the first group exists; nothing on the strip). With **New Session Button on Tabs** on (`showTabNewButton`, per device, `html.tabs-show-new`), every session row menu (the rail, compact or full, the expanded sidebar, the tree; never a tile's, which shares the function) also offers "New session here…", which opens the row's "+" picker, and the compact rail hides it. The "+" itself opens its picker on Enter or Space through its own inline `onkeydown` (the strip's key handler acts only on a focused tab), which is the keyboard path on the header strip, where no row menu exists. The menu cancels a REPEATED Enter or Space, so a held key that opened it cannot also pick the entry it focuses. 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 moving to an element outside it, a resize, a second open and any full re-render. ⚠️ A `focusout` with no `relatedTarget` is not a departure: Safari never focuses a clicked button, so pressing an item blurs the focused one with focus going nowhere, and closing on that dropped the item's click (the tile count menu's rule). The tab "+" picker is this same menu (`_openTabLayoutMenu` with `anyLayout`, so it opens on every layout, not only the editable grouped rail). 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 })`; a tab's "+" (`openTabNewSessionMenu`) runs ONE launch in that tab's case or managed worktree through `_launchOnce` (the Git worktrees modal's launcher too), `run({ count: 1, mode, caseName | worktree, tabGroupId? })`, carrying the tab's named group if it has one. ⚠️ The mode rides the call and is never swapped into the Run button's `_runMode`, so a mode picked while the launch is pending is kept, and the case-list reload after a launch that names its case keeps the toolbar's own case (`_reloadCasesAfterRun`; the no-argument reload would re-select the `lastUsedCase` quick-start has just written). ⚠️ The tab's case is found by what the session runs on (`_tabCaseOf`), never by path alone: `GET /api/cases` gives remote and Docker cases a DISPLAY path with the raw values under `remote`/`docker`, so a Docker session belongs to its container's case on its own engine host, a remote one to the case on its own host whose path encloses its own, and a local one to the deepest case enclosing its folder (a display path never encloses one). A Docker tab's picker starts that container's probe (`_probeDockerCaseModes`) when nothing is cached for its case, since the toolbar probes only its own selected case: that open lists what is already known, the next one the container's answer. From an ungrouped tab the "+" sends no `tabGroupId` on purpose (the create schema cannot say "Ungrouped"), so the case default group below applies, as for a toolbar Run. 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. +- **New, child and web tabs land in groups.** A group menu's "New session" (`createSessionInTabGroup`) runs ONE launch with `run({ count: 1, tabGroupId })`; a tab's "+" (`openTabNewSessionMenu`) runs ONE launch in that tab's case or managed worktree through `_launchOnce` (the Git worktrees modal's launcher too), `run({ count: 1, mode, caseName | worktree, tabGroupId? })`, carrying the tab's named group if it has one. ⚠️ The mode rides the call and is never swapped into the Run button's `_runMode`, so a mode picked while the launch is pending is kept, and the case-list reload after every launch keeps the toolbar's own case (`_reloadCasesAfterRun`): the no-argument reload would re-select the server's `lastUsedCase`, which a "+" launching through quick-start moves to the tab's case while the toolbar stays put, so a later worktree launch, plain Run or group "New session" would carry the toolbar there. ⚠️ The tab's case is found by what the session runs on (`_tabCaseOf`), never by path alone: `GET /api/cases` gives remote and Docker cases a DISPLAY path with the raw values under `remote`/`docker`, so a Docker session belongs to its container's case on its own engine host, a remote one to the case on its own host whose path encloses its own, and a local one to the deepest case enclosing its folder (a display path never encloses one). A Docker tab's picker starts that container's probe (`_probeDockerCaseModes`) when nothing is cached for its case, since the toolbar probes only its own selected case: that open lists what is already known, the next one the container's answer. From an ungrouped tab the "+" sends no `tabGroupId` on purpose (the create schema cannot say "Ungrouped"), so the case default group below applies, as for a toolbar Run. 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. - **A case's default session group** (`caseDefaultGroups` in state.json, keyed by the layout owner key then case name; a group id or `null` for Ungrouped) is the fallback placement for a new session of that case. It is read and written ONLY through `TabLayoutService` (`caseDefaultGroup`, `setCaseDefaultGroup`, `forgetCaseDefaultGroup`, and `sessionCreated`'s `caseName` hint), under the owner lock, never by a route or the store directly. Precedence inside `sessionCreated`'s single write: an explicit `tabGroupId` found in the owner's layout, then a parent that places the child (`hasPlacingParent`, the same conditions as `pinCreatedChild`), then the case default, else normal placement. The id is looked up in the creating owner's own layout, so a deleted or foreign id never fails a spawn; a stale id is forgotten only AFTER the creating write lands (a failed write rolls back and changes nothing). `POST /api/quick-start` and `POST /api/ralph-loop/start` pass their `caseName`; `POST /api/sessions` infers it LEXICALLY from `workingDir` (`inferLocalCaseName`: a direct child of the caller's case space, or an exact linked-case path for an admin or single-user, or a Claude worktree of either, `/.claude/worktrees/`, which is how the Git worktrees modal launches Claude while every other CLI goes through quick-start), only for a named `workingDir` (never the server's own cwd), never for a remote attach and never touching the filesystem. Cron jobs, the one-shot run route and reboot restore keep normal placement. The Case settings selector (`onCaseDefaultGroupChanged`, session-ui.js) reads it on popover open and is redrawn from an adopted layout whose groups changed (`_refreshCaseDefaultGroupOptions` in `_applyTabLayout`; an unchanged group list is skipped, since every create, delete and reorder broadcasts a layout and a rebuild would reset an open dropdown). - **Only the grouped rail is a tree.** `#sessionTabs` ships as `role=tablist` with `role=tab` rows, and the header strip, sidebar and flat rail keep exactly that. While grouped, `_applyTabListRole` makes it `role=tree` (and restores `tablist` + its label when grouping ends), named-group headers are level-1 `treeitem`s that `aria-owns` their rows' `role=group` (rows sit beside the header, not inside it), and ungrouped rows plus a collapsed group's kept selection are level-1 items. A collapsed header owns nothing, a group with no open rows is a leaf (no `aria-expanded`, no owned group), and the "Ungrouped" heading is `aria-hidden`. Rows are re-roled in the DOM by `_applyTabTreeSemantics` after render, never by rewriting their markup, so a grouped row's content stays the flat row's. - **One tab stop in the tree.** Exactly one treeitem carries `tabindex=0` (the focused or selected item); every control inside a row drops to `-1`, which is why Shift+F10 / ContextMenu open a row's actions from the keyboard. Focus survives a full re-render by identity (`group:`/`session:`/`webview:`; a row a collapse just hid hands focus to its header), but only when focus was already inside the rail. The tree walk (`_tabTreeItems`) follows painted order WITHIN each group when the rail is sorted; the flat list keeps its own whole-list computed-order walk. `aria-posinset`/`aria-setsize` follow painted order too, so the incremental render path re-runs `_applyTabTreePositions` after it re-sorts rows in place. ⚠️ `_handleTabTreeKeydown` acts only when the key lands on the treeitem ITSELF: a key on a focused in-row control (close, overflow, the rename input) is that control's, or Enter on the overflow button re-selects the row instead of reopening its menu. ⚠️ The roving `tabindex=-1` also hides every item but the stop from the keyboard-dismiss selector's `[tabindex]` arm, which is why `MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR` lists `[role="treeitem"]` (see Dismissing the on-screen keyboard). diff --git a/src/web/public/app.js b/src/web/public/app.js index 5ce7b0f2..92826124 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -8615,10 +8615,12 @@ class CodemanApp { // menu's own items are not a departure, and neither is focus going nowhere: // Safari never focuses a clicked button, so a press on an item blurs the // focused one with no relatedTarget, and closing then dropped its click - // (the tile count menu's rule, tile-grid.js). + // (the tile count menu's rule, tile-grid.js). Nor is focus landing on the + // trigger: a press on a focusable one (a tab's "+") focuses it before its + // click, and closing there let that click open the menu again. menu.addEventListener('focusout', (focusEvent) => { const to = focusEvent.relatedTarget; - if (!to || menu.contains(to)) return; + if (!to || menu.contains(to) || trigger?.contains?.(to)) return; if (this._tabGroupMenu === menu) this.closeTabGroupMenu(); }); (menu.querySelector('button.selected') || menu.querySelector('button'))?.focus(); diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 50d4bfad..fe7d5c0b 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -2025,12 +2025,14 @@ Object.assign(CodemanApp.prototype, { }, /** - * Re-reads the case list after a launch. The no-argument reload re-selects the - * server's `lastUsedCase`, which quick-start has just set to the launched case, - * so a launch in a case it names (a tab's "+") keeps the toolbar on its own. + * Re-reads the case list after a launch, keeping the toolbar on its own case. + * The no-argument reload would re-select the server's `lastUsedCase`, and a + * tab's "+" launching through quick-start sets that to the tab's case while the + * toolbar stays where it was, so every later launch (a worktree's, a plain Run, + * a group's "New session") would otherwise move the toolbar there. */ - _reloadCasesAfterRun(options) { - this.loadQuickStartCases(options.caseName ? document.getElementById('quickStartCase').value : null); + _reloadCasesAfterRun() { + this.loadQuickStartCases(document.getElementById('quickStartCase').value || null); }, async runClaude(options = {}) { @@ -2142,7 +2144,7 @@ Object.assign(CodemanApp.prototype, { this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} remote session(s) ready`); if (remoteIds[0]) { await this.selectSession(remoteIds[0]); - this._reloadCasesAfterRun(options); + this._reloadCasesAfterRun(); } this.terminal.focus(); return; @@ -2221,7 +2223,7 @@ Object.assign(CodemanApp.prototype, { // Auto-switch to the new session using selectSession (does proper refresh) if (firstSessionId) { await this.selectSession(firstSessionId); - this._reloadCasesAfterRun(options); + this._reloadCasesAfterRun(); } this.terminal.focus(); diff --git a/src/web/public/tab-rail-resize.js b/src/web/public/tab-rail-resize.js index 9cdfafc6..4dc434ae 100644 --- a/src/web/public/tab-rail-resize.js +++ b/src/web/public/tab-rail-resize.js @@ -319,6 +319,7 @@ Object.assign(CodemanApp.prototype, { event.preventDefault(); event.stopPropagation(); this.closeTabRailActionMenu(); + this.closeTabGroupMenu?.(); const trigger = event.currentTarget; const menu = document.createElement('div'); menu.className = 'tab-rail-action-menu'; @@ -327,8 +328,8 @@ Object.assign(CodemanApp.prototype, { const settings = this.loadAppSettingsFromStorage(); const actions = [ { label: 'Session options', run: () => this.openSessionOptions(sessionId) }, - // The row's "+" (showTabNewButton on) as a menu item: its keyboard route, since - // Enter on the "+" itself does nothing, and its only route in the compact rail, + // The row's "+" (showTabNewButton on) as a menu item: a keyboard route beside + // Enter or Space on the "+" itself, and its only route in the compact rail, // which hides it. Session rows only: tiles share this menu and carry no "+". ...(document.documentElement.classList.contains('tabs-show-new') && trigger.closest('.session-tab') ? [{ label: 'New session here…', run: () => this.openTabNewSessionMenu({ currentTarget: trigger }, sessionId) }] diff --git a/test/tab-new-session.test.ts b/test/tab-new-session.test.ts index 1cfd426f..bf18389d 100644 --- a/test/tab-new-session.test.ts +++ b/test/tab-new-session.test.ts @@ -21,7 +21,9 @@ * group then applies, pinned end to end in case-default-group-routes.test.ts); * - the Run button's mode and the toolbar case are left as they were, also when * the mode is changed while the launch is pending and when the server has - * moved lastUsedCase to the tab's case; + * moved lastUsedCase to the tab's case, and a later launch that names no case + * (a worktree tab's "+", the toolbar Run, a group's "New session") keeps the + * toolbar's case too; * - a failed launch reports the server's own error; * - Enter and Space on the "+" open the picker (the header strip's only * keyboard path) and stop there; every other key passes on to the strip; a @@ -643,11 +645,11 @@ describe('a pick from the "+"', () => { expect(app._apiPut).toHaveBeenCalledWith('/api/settings', { runMode: 'claude' }); }); - it.each([ - ['a remote Claude launch (quick-start)', 'rem', 'r'], - ['a local Claude launch', 'proj', 'a'], - ])("%s leaves the toolbar on its own case, though the server's lastUsedCase moved", async (_label, tabCase, id) => { - const app = makeApp(); + /** + * Readies a Claude launch with the shipping case-list reload and the server's + * lastUsedCase at `lastUsedCase`; the returned wait resolves once that reload ran. + */ + function useShippingCaseReload(app: Record, lastUsedCase: string) { app._runMode = 'claude'; app.getAdvisorSetting = () => undefined; app._onSessionCreated = () => {}; @@ -656,25 +658,65 @@ describe('a pick from the "+"', () => { for (const name of ['updateDirDisplayForCase', 'updateMobileCaseLabel', 'updateCasePickerInput']) app[name] = () => {}; for (const name of ['renderCasePickerList', 'closeCasePicker', 'setupQuickStartCasePicker']) app[name] = () => {}; - const select = document.getElementById('quickStartCase') as HTMLSelectElement; - const record = CASES.find((c) => c.name === tabCase)!; win.fetch = vi.fn(async (url: string) => { const json = (value: unknown) => ({ ok: true, status: 200, json: async () => value }); // quick-start saved the launched case as lastUsedCase (session-routes.ts). - if (url === '/api/settings') return json({ success: true, data: { lastUsedCase: tabCase } }); + if (url === '/api/settings') return json({ success: true, data: { lastUsedCase } }); if (url === '/api/cases') return json({ success: true, data: CASES }); - if (url === `/api/cases/${tabCase}`) return json({ success: true, data: record }); + if (url.startsWith('/api/cases/')) { + const record = CASES.find((c) => `/api/cases/${c.name}` === url); + return json(record ? { success: true, data: record } : { success: false, errorCode: 'NOT_FOUND' }); + } if (url === '/api/quick-start') return json({ success: true, data: { sessionId: 'new1', session: { id: 'new1' } } }); if (url === '/api/sessions') return json({ success: true, data: { session: { id: 'new1', name: 'w1' } } }); return json({ success: true, data: {} }); }); + return async () => { + await vi.waitFor(() => expect(app.selectSession).toHaveBeenCalledWith('new1')); + // The case list is reloaded after the launch (not awaited by it). + await vi.waitFor(() => expect(win.fetch.mock.calls.map((call: unknown[]) => call[0])).toContain('/api/cases')); + await flush(); + }; + } + + it.each([ + ['a remote Claude launch (quick-start)', 'rem', 'r'], + ['a local Claude launch', 'proj', 'a'], + ])("%s leaves the toolbar on its own case, though the server's lastUsedCase moved", async (_label, tabCase, id) => { + const app = makeApp(); + const reloaded = useShippingCaseReload(app, tabCase); + const select = document.getElementById('quickStartCase') as HTMLSelectElement; openFrom(app, id); pick('Claude Code'); - await vi.waitFor(() => expect(app.selectSession).toHaveBeenCalledWith('new1')); - // The case list is reloaded after the launch (not awaited by it). - await vi.waitFor(() => expect(win.fetch.mock.calls.map((call: unknown[]) => call[0])).toContain('/api/cases')); - await flush(); + await reloaded(); + expect(select.value).toBe('toolbar-case'); + }); + + // An earlier "+" launch through quick-start left lastUsedCase on the tab's case + // (proj); a later Claude launch that names no case must not follow it there. + it.each([ + [ + 'a "+" Claude launch from a managed worktree tab', + 'horizontal', + (app: Record) => { + openFrom(app, 'f'); + pick('Claude Code'); + }, + ], + ['the toolbar Run with Claude', 'horizontal', (app: Record) => app.run()], + [ + 'a group\'s "New session" with Claude', + 'vertical', + (app: Record) => app.createSessionInTabGroup('gx'), + ], + ])('%s keeps the toolbar on its own case after a "+" moved lastUsedCase', async (_label, orientation, launch) => { + const app = makeApp({ orientation }); + const reloaded = useShippingCaseReload(app, 'proj'); + const select = document.getElementById('quickStartCase') as HTMLSelectElement; + (document.getElementById('tabCount') as HTMLInputElement).value = '1'; + void launch(app); + await reloaded(); expect(select.value).toBe('toolbar-case'); }); });