Skip to content

feat(tabs): start a session beside any tab from an opt-in + in its actions - #712

Open
JDProfresh wants to merge 2 commits into
Ark0N:masterfrom
JDProfresh:feat/tab-plus-new-session
Open

JDProfresh wants to merge 2 commits into
Ark0N:masterfrom
JDProfresh:feat/tab-plus-new-session

Conversation

@JDProfresh

@JDProfresh JDProfresh commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Opening another session in the case you're already working in meant re-picking the case and the mode in the toolbar. An opt-in + in each session tab's action row now starts one session beside that tab, in its case and its group, and leaves the toolbar alone. Built to your answers in #700.

What it does

  • Where and when. Per-device setting "New Session Button on Tabs" (App Settings, Tabs), next to showTabDetachButton, off by default on every device. When it's on, a + sits with the tab's gear and close icons and uses the same reveal. On a phone it shows on the active tab only, and the label reserve grows from 44 to 68px while the setting is on. That keeps your arithmetic (gear 32 + + 28 + close 20, less close's 2px margin) under the 80px max-width, and test/mobile-tab-tap-zones.test.ts pins it.

  • Labels. The picker lists each CLI as its logo and name, in the Run menu's markup and order: agents in catalog order, the shell last.

  • Docker. The list is gated by _runModeAvailableForCase, the helper the toolbar Run menu already uses. So a Docker case offers what its container has (the live probe, then docker.availableModes, and no gating for an owned container).

  • Which case. A tab's case is resolved by identity, not by matching paths across locations:

    • a Docker tab matches the case with its engine host and container;
    • a remote tab matches the case on its host whose path encloses its folder;
    • a local tab matches the deepest local case that encloses its folder.

    GET /api/cases gives remote and Docker cases display paths (user@host:/p, box:/p), so a raw path comparison would miss them, or worse, match a local case that sits at the Docker workspace's path. A tab in a managed worktree launches in that worktree.

  • Group. A pick starts one session in the tab's named group. An ungrouped tab sends no tabGroupId on purpose, so TabLayoutService.sessionCreated() puts the new session in the case's default session group (feat(tabs): per-case default session group #676) when the case has one, the same as a toolbar Run. test/routes/case-default-group-routes.test.ts pins both cases end to end (the real frontend in JSDOM against the real routes): an ungrouped tab lands in the default group, and a grouped tab lands in its own group.

  • Keyboard route. While the setting is on, the session row menu offers "New session here…" wherever it shows (vertical rail, sidebar; session tabs only, never tiles), and it opens the same picker.

  • The toolbar is not touched. run() takes the mode as an option (options.mode), so a + launch never swaps _runMode. The worktree launcher had the same swap, and both now go through one _launchOnce(). A side effect: picking another mode in the Run menu while a worktree or + launch is still pending used to be overwritten when the launch finished; now it stays. After a launch that names its case, the case list reloads onto the toolbar's own case. Before, runClaude() re-selected lastUsedCase, so a Claude + in a remote or Docker case moved the toolbar to the tab's case.

  • lastUsedCase. As you noted, POST /api/quick-start still writes lastUsedCase (src/web/routes/session-routes.ts:4588, for an admin or in single-user mode). So a + launch through quick-start (any CLI other than Claude or Shell, or Claude or Shell in a remote or Docker case) leaves the toolbar on the tab's case after a page reload. Worktree launches already do the same. It no longer moves within the page.

  • Safari. _openTabLayoutMenu no longer closes on a focusout with no relatedTarget, which is the tile count menu's rule. Safari never focuses a clicked button, so the picker closed before the click landed. My iPhone 17 Pro Max (iOS 26.6.1) confirmed both the failure and the fix. The same rule now applies to the named-group menu. The "Every way in has a keyboard path" bullet in docs/architecture-invariants.md is updated to match.

Also in the PR: zh-CN for the label, description and "New session here", a Settings-Reference.md row, a paragraph in The-Dashboard.md, and the phone-reserve and group-placement sentences in docs/architecture-invariants.md and src/web/CLAUDE.md.

The phone overview's Run picker still gates on the host's CLIs; that's the separate fix you mentioned, not touched here.

Screenshots

Screenshots to follow: the desktop strip with the + revealed, the desktop picker, and the phone tab and picker at 440px.

Tests

  • New test/tab-new-session.test.ts, plus end-to-end placement tests in case-default-group-routes.test.ts and additions to tab-group-launch, tab-layout-editing, mobile-tab-tap-zones and worktree-ui (the last now pins run({ count: 1, worktree, mode }) with _runMode untouched). The case fixtures use real GET /api/cases records. They include a local case at a Docker workspace's path, the same container on another engine host, the same path on another remote host, and a nested local case.
  • I broke every guard one at a time (32 mutations across the two rounds) and each made its test fail.
  • npm test: 12,594 passed. Two failures also fail on unmodified master here, and neither file is touched:
  • typecheck, lint, format:check, check:frontend-syntax and check:browser-excludes all pass.
  • npm run test:mobile -- test/mobile/tabs.test.ts: 30 passed and 1 failed on both the branch and master. It's the same failure: "switching tabs with the keyboard closed does not leave the terminal textarea focused".

…tions

Opening another session in the case you are already working in meant re-picking the case and the mode in the toolbar. An opt-in + now sits with each tab's gear and close icons (App Settings, Tabs, New Session Button on Tabs: per device, off by default everywhere). It opens a menu of the CLIs that tab's case can start, each shown with its logo and name as in the Run menus, and a pick starts one session in that tab's case, or the managed worktree it runs in, and in its named group, without changing the Run button's mode or case.

The CLIs offered follow the Run menu's own rule for the case, so a Docker case lists what its container has rather than what the host has. A tab outside any named group sends no group, so the case's default session group places the new session the way it places a toolbar Run. Wherever a tab has a row menu (the vertical rail, the sidebar) that menu gains "New session here…", the keyboard route to the same picker, since Enter on the + itself does nothing. On a phone the + joins gear and close on the active tab only, and the label reserve grows to 68px while the setting is on, so the tab's centre stays off the icons.

The shared tab layout menu no longer closes when focus leaves an item for nowhere: Safari never focuses a clicked button, so pressing an item blurred the focused one with no related target and the click was lost. This is the tile count menu's rule, and it fixes the named-group menu as well.
@JDProfresh

Copy link
Copy Markdown
Contributor Author

CI's one red here is test/quick-start.test.ts > "does not inject by default, accepts the setting via PUT, then injects on quick-start" (ENOENT on the injected SKILL.md). It isn't from this PR: nothing in the diff reaches the quick-start route or the agent skill, the file passes 5 of 5 runs locally on this branch, and the same test has failed in 15 earlier CI runs on unrelated branches since 2026-09-26 (among them feature/host-gpu-clocks, pr/auth-ip-lockout, feat/shared-workspace-access, feat/whats-new, feat/cli-copilot and pr/grouped-rail-placement).

The test outwaits the server's settings cache with a fixed sleep: readSettings() caches for 2,000 ms (src/web/server.ts), the settings PUT doesn't invalidate it, and the test sleeps 2,100 ms (line 379), so it relies on a 100 ms margin. If you'd like, I can send a small separate PR that clears that cache when the PUT writes settings.json, so the sleep can go.

@Ark0N

Ark0N commented Oct 25, 2026

Copy link
Copy Markdown
Owner

Thank you @JDProfresh for this. It adds an opt-in + to each session tab that starts one session in that tab's case (or worktree) and named group, in any CLI the case can run, without touching the Run button.

It follows the answers from #700 closely, and the tests are unusually careful: real GET /api/cases record shapes with deliberate path collisions, the end-to-end placement checks through the real create routes, and the mutation pass. Replacing the _runMode swap with run({ mode }) and sharing _launchOnce with the worktree modal is a nice cleanup too.

Three small things, none of them a blocker:

  1. The header-strip + has no keyboard route (src/web/public/app.js:7144). The span is tabindex="0" with aria-haspopup="menu", but Enter and Space do nothing on it, and on the header strip (the default layout) there is no row menu to fall back on. Please add a keydown for Enter/Space on .tab-new that calls app.openTabNewSessionMenu(event, id) (with preventDefault and stopPropagation), so the control does what it announces. That keeps the "Every way in has a keyboard path" bullet in docs/architecture-invariants.md true for the strip as well.
  2. Docker tabs read whatever probe result is already cached (src/web/public/app.js:8397). _runModeAvailableForCase never starts the container probe, and the toolbar only probes the case it has selected, so a + on another Docker tab falls back to the link-time availableModes (or no gating). Please kick the probe when the picker opens, for example if (caseEntry?.location === 'docker' && !this._dockerCaseModes?.[caseName]) void this._probeDockerCaseModes(caseEntry, null);, or reword the description and docs so they do not promise a live probe.
  3. Two sentences start lowercase in the "New, child and web tabs land in groups" bullet (docs/architecture-invariants.md:349): "from an ungrouped tab it sends no..." and "every launch path puts...".

The one red CI job is test/quick-start.test.ts (agent skill injection after a fixed 2.1 s cache wait), which this PR does not touch and which passes locally. It needs a re-run, nothing from you.

If you push these, I will merge once CI is green. If not, I will apply them on top at merge time.

…container

The + announces a menu but Enter and Space did nothing on it, and the header strip has no row menu to fall back on. A + on a Docker tab the toolbar has not selected read only the link-time mode list, since nothing probed its container.

Opening the picker from the keyboard focuses an entry, so a held Enter or Space repeated onto it and launched that CLI; the shared tab-layout menu now ignores a repeated Enter or Space.
@JDProfresh

Copy link
Copy Markdown
Contributor Author

Thanks for the careful read. All three are in 4f6481b, a new commit on top (nothing rewritten), plus one thing I found along the way.

  1. Keyboard route on the strip's +. The .tab-new span now has its own onkeydown (src/web/public/app.js:7146): Enter or Space calls app.openTabNewSessionMenu(event, id) with preventDefault() and stopPropagation(). It is inline like the span's onclick, so currentTarget stays the + and the picker anchors on it. Every other key reaches the strip as before. The "Every way in has a keyboard path" bullet (docs/architecture-invariants.md:348) now says this instead of "Enter on the + does nothing".
  2. Docker tabs. openTabNewSessionMenu now starts _probeDockerCaseModes(caseEntry, null) when the tab's case is a Docker case with nothing in _dockerCaseModes (src/web/public/app.js:8397). I took your suggestion as written. The open picker does not re-render when the probe lands, because the probe's repaint only refreshes the Run menu it is handed. So the first open lists what is already known, and the next open lists what the container answered. The comment on _runModeAvailableForCase (src/web/public/session-ui.js:825) and bullet 349 now say that the picker starts the probe.
  3. Capitals. Both sentences in bullet 349 are fixed ("From an ungrouped tab the + sends no...", "Every launch path puts...").
  4. A held key. Opening the picker from the keyboard focuses an entry. Holding Enter or Space on the + then launched that CLI, because the key's auto-repeat landed on the focused entry and the shared tab-layout menu acted on it. The menu now cancels a repeated Enter or Space (src/web/public/app.js:8595), and bullet 348 says so.

Tests, in test/tab-new-session.test.ts. I ran them on c7835a1 first and watched them fail, and they pass now:

  • Enter and Space on the header strip's + go through the span's real inline handler. Each calls openTabNewSessionMenu with exactly that event and the tab's id, opens the picker with the expected entries, and never reaches the strip's key handler. Space on a tab whose folder is not a case still has its default prevented. a, ArrowRight, ArrowDown, Escape, Tab and F2 open nothing and do reach the strip.
  • Opening on an adopted Docker tab that has not been probed calls the probe with that case entry and null, and POSTs { hostId, container } to /api/docker-cases/adopt-preflight. The next open lists the container's answer and does not probe again. An owned container gets probed too. Local, remote and worktree tabs never probe.
  • A repeated Enter or Space on the entry the picker focused is cancelled, the picker stays open and nothing launches. A fresh Enter or Space keeps its native activation, and a repeated ArrowDown still moves.

I removed each guard in turn: either key arm, the key check, the inline preventDefault, the cache check, the Docker check, the probe call, and the repeat check or either key in it. Each removal turned a test red. Removing the inline stopPropagation() did not, because both paths of openTabNewSessionMenu already stop propagation. I kept it because you asked for it and it matches the onclick.

Gate: typecheck, lint, format, frontend syntax and browser-excludes are clean. npm test gave 12599 passed, 12 skipped and 2 failed across 633 files. Both failures are local to this machine and fail the same way on unmodified master here: test/tui/tui-switch-guard.test.ts (tmux 3.2a) and the one-shot zstd case in test/deepseek-transcript.test.ts (Node 26, fixed by #710). I also checked in Chromium, on a test page with the same handler and menu listener, that holding Enter or Space on the + now opens the picker without launching anything (before the guard it launched once), and that a fresh Enter or Space on the entry still activates it.

@Ark0N

Ark0N commented Oct 26, 2026

Copy link
Copy Markdown
Owner

Thank you @JDProfresh for the follow-up commit. The PR adds an opt-in + to each session tab (and a "New session here…" row-menu entry) that starts one session of any CLI the tab's case can run, in that case or worktree and its named group, without touching the Run button. All three points from the first review are in, and the held-key guard is a good catch.

Testing the toolbar side more closely turned up two more things:

  1. The toolbar case can still move after a launch (src/web/public/session-ui.js:2033). _reloadCasesAfterRun keeps the toolbar's case only when the launch carries caseName. Otherwise loadQuickStartCases(null) re-selects the server's lastUsedCase, and every + launch through quick-start now sets that to the tab's case while the toolbar stays where it was. With lastUsedCase moved that way, two sequences move the toolbar in the page:

    • a + Claude launch from a tab in a managed worktree (its options carry worktree, not caseName) moves the toolbar right away;
    • the next plain toolbar Run with Claude (and the group menu's "New session" with Claude) moves the toolbar to the tab's case once it finishes, so the Run after that starts somewhere the user did not pick.

    Please make the reload keep the toolbar's own case for every launch: this.loadQuickStartCases(document.getElementById('quickStartCase').value || null);. With that change the PR's own tests still pass, and so does a JSDOM test of both sequences modelled on your lastUsedCase test. Please add that test, and update the method's comment and the sentence about _reloadCasesAfterRun in docs/architecture-invariants.md:349.

  2. Pressing the + again does not close its picker in a real browser (src/web/public/app.js:8621). The + is focusable, so in Chromium the press moves focus onto it, the menu's focusout closes the picker, and the click opens it again. The test at test/tab-new-session.test.ts:754 passes because a programmatic call moves no focus. Two lines fix it (both checked in headless Chromium): add || trigger?.contains?.(to) to the early return in that focusout handler, and call this.closeTabGroupMenu?.() right after this.closeTabRailActionMenu() in openTabRailActionMenu (src/web/public/tab-rail-resize.js:321). Without the second line, opening the picker from the rail's ⋯ menu and pressing ⋯ again leaves both menus on screen. If you would rather not keep the toggle, removing the claim and that test is fine too.

  3. Small one: the comment at src/web/public/tab-rail-resize.js:331 still says Enter on the + does nothing.

  4. The screenshots from A + on each tab to start a session beside it, in any installed CLI (opt-in) #700 (desktop strip, desktop picker, phone tab and picker) are still to come, and the phone one matters most.

If you push these, I will merge once CI is green. If not, I will apply them on top at merge time.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants