Skip to content

feat(tabs): per-case default session group - #676

Merged
Ark0N merged 1 commit into
Ark0N:masterfrom
aakhter:pr/case-default-group
Oct 22, 2026
Merged

Ark0N merged 1 commit into
Ark0N:masterfrom
aakhter:pr/case-default-group

Conversation

@aakhter

@aakhter aakhter commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

Problem

Tab groups (#528) can place a session explicitly, through a group's own "New session" or a tabGroupId on the create request. A normal Run has nothing to go on, though. If every session for case app belongs in an app group, each new tab lands in Ungrouped and has to be moved by hand.

Approach

Case settings gets a Default session group selector. It lists Ungrouped plus every named group the user has. A new session for the case is placed in that group in the same versioned layout write that registers it, so the tab never shows in Ungrouped first.

  • Storage: new optional caseDefaultGroups key in state.json, keyed by the tab-layout owner key (@single or the username) and then the case name. The value is a group id, or null for an intentional Ungrouped. It stores the id, so renaming the group doesn't break it. I didn't reuse the existing case stores: the Case settings checkboxes live in browser localStorage, and the linked, remote and Docker case files cover only some case kinds and aren't owner-scoped for local cases. This value has to be read on the server when the session is created, in the same owner scope as the layout it points into.
  • Mutation boundary: every read and write goes through TabLayoutService under the owner lock: caseDefaultGroup, setCaseDefaultGroup, forgetCaseDefaultGroup, and a new caseName hint on sessionCreated.
  • Precedence (inside sessionCreated): an explicit tabGroupId found in the owner's layout, then the parent placement (feat(tabs): place new, child and web tabs into groups #528 child placement, under spawnedTabsFollowParent), then the case default, else Ungrouped.
  • Stale or foreign ids: the id is looked up only in the creating owner's own layout. An id that isn't there never fails a spawn: the session lands in Ungrouped. The stale value is forgotten only after the creating write has landed. If that write fails, the existing rollback runs and nothing changes.
  • Which create paths use it: POST /api/quick-start passes its caseName, which covers local, linked, remote and Docker cases. POST /api/ralph-loop/start passes its case too. POST /api/sessions works out the case from workingDir, by path comparison only and without touching the filesystem: a direct child of the caller's case space, or the exact path of a linked case (admin or single-user only, same as GET /api/cases). It never does this for a remote attach. When a case default places the session, the create response carries tabLayout, the same as it does for tabGroupId, and the browser adopts that layout before drawing the tab.
  • API: GET and PUT /api/cases/:name/default-group ({ groupId: string | null }), using the standard envelope. Both return 404 for a case the caller can't see. A PUT with an unknown group id returns 400 and leaves the stored value alone. No admin role is required in multi-user mode, because the value is per owner and only touches the caller's own layout. Deleting a case (and an admin deleting a user's case folder) forgets the value.
  • UI: a selector in both the desktop and phone Case settings popovers. They mirror each other. Opening the popover reads the value from the server, and a change saves it right away. A refused save reverts with a toast. The options are redrawn from every adopted tab layout (_applyTabLayout), so creating, renaming or deleting a group shows up straight away. If the selected group is deleted, the selector shows Ungrouped. Group names are excluded from translation; the new strings have zh-CN entries.
  • Not changed: existing sessions never move. Cron jobs, the one-shot run route and reboot restore keep their current placement.

Tests

  • test/case-default-group.test.ts: store round-trip, owner isolation, malformed on-disk values, and the service precedence: explicit beats parent (follow on and off), parent beats default, default beats none. Also: deleted group falls back and is cleared, foreign owner's id is ignored, rename keeps working, existing sessions never move, a failed write rolls back and changes nothing.
  • test/routes/case-default-group-routes.test.ts: the create routes against the real TabLayoutService. Covers POST /api/sessions in a case folder and outside one, explicit-group precedence, POST /api/quick-start for local, remote and Docker cases, deleted-group fallback, and the creation error path. Also covers GET/PUT: options, null, rename, stale value, invalid ids, 404, remote and Docker cases, cleanup on delete, and multi-user owner scoping.
  • test/case-default-group-ui.test.ts: the selector lists Ungrouped plus all groups, saves, mirrors desktop and phone, reverts on failure, and refreshes when groups are created, renamed or deleted. Also checks the _applyTabLayout hook and the zh-CN strings.
  • test/admin-routes.test.ts: an admin case delete forgets the user's value.
  • test/routes/session-routes-tab-group.test.ts: quick-start now also passes caseName.
  • Mutation check: turning off the case default in sessionCreated fails 9 tests; letting it beat the parent fails 2.
  • Gates: typecheck, lint, format:check, check:frontend-syntax, check:public-assets and check:browser-excludes all pass. npm run test:ci passes: 592 files, 11847 tests.
  • Real browser (Chromium, isolated instance on loopback): created a group, picked it in Case settings, renamed it while the popover was open (the selector updated), and ran a session. The first create response, GET /api/tab-layout and the rendered rail all showed the session in the group. Then I deleted the group: the selector fell back to Ungrouped, the next session was created in Ungrouped, and the stale value was cleared.

Case settings gains a "Default session group" selector (Ungrouped plus every
named tab group of the owner). A new session of the case lands in that group
in the same versioned layout write that registers it, so it is drawn in place
with no Ungrouped-to-group jump.

- Stored per owner and case in state.json (caseDefaultGroups), as the group's
  stable id or null for Ungrouped; read and written only through
  TabLayoutService under the owner lock.
- Precedence in sessionCreated: explicit tabGroupId, then the parent
  placement, then the case default, else Ungrouped. A deleted or foreign id
  never fails a spawn and is forgotten only after the creating write lands.
- POST /api/quick-start (local, linked, remote, Docker) and
  /api/ralph-loop/start pass their case; POST /api/sessions infers it
  lexically from workingDir. The create response carries tabLayout when a
  case default placed the session.
- GET/PUT /api/cases/:name/default-group; deleting a case (or an admin
  deleting a user's case folder) forgets the value.
- The selector mirrors desktop and phone, reverts on a refused save, and is
  redrawn from every adopted tab layout; zh-CN strings added.
@Ark0N

Ark0N commented Oct 22, 2026

Copy link
Copy Markdown
Owner

Thank you @aakhter, this is a well-built feature: a per-case Default session group in Case settings that the server applies inside the same creating layout write, below an explicit group and the parent placement. The precedence, the owner scoping, forgetting a stale id only after the creating write lands, and the rollback path all fit the tab-layout invariants, and the tests cover the service, the routes against the real TabLayoutService and the selector.

A few things to settle before merge:

  1. Worktree sessions are inconsistent (src/web/routes/session-routes.ts:222). The Git worktrees modal launches Claude through POST /api/sessions with workingDir = <case>/.claude/worktrees/<name>. inferLocalCaseName only accepts a direct child of the case space or an exact linked path, so that session lands in Ungrouped. Every other CLI launches the same worktree through quick-start with caseName, which applies the default (session-routes.ts:4502). Please have inferLocalCaseName strip a trailing /.claude/worktrees/<name> lexically before its two checks (CLAUDE_WORKTREE_PATH near line 4726 can be hoisted to module scope and reused), and add a route test for a worktree workingDir.
  2. Ralph pass-through has no test (src/web/routes/ralph-routes.ts:417). One toHaveBeenCalledWith(expect.anything(), { caseName: ... }) assertion in test/routes/ralph-routes.test.ts pins it.
  3. Root CLAUDE.md: add caseDefaultGroups to the state.json list under State Files (line 305), and one sentence to the Owner tab layouts paragraph (line 191) saying a case's default group is the fallback placement in sessionCreated() below tabGroupId and the parent, read and written only through TabLayoutService.

Optional polish, take or leave:

  • src/tab-layout-service.ts:606: move the stale-default cleanup out of the try whose catch rolls back the creation.
  • src/tab-layout-service.ts:621: the GET could return null for a stale id without deleting it, so GET stays read-only.
  • src/web/public/session-ui.js:3434: skip rebuilding the options when the group ids and names have not changed, so a session created elsewhere does not reset an open dropdown.
  • src/web/routes/session-routes.ts:1445: only infer a case when the body named a workingDir.
  • docs/wiki/HTTP-API.md:75: Cases is now 39 handlers.

If you push the three items above I will merge once CI is green; otherwise I will apply them on top at merge time. Thanks again for the careful work and the thorough description.

@Ark0N
Ark0N merged commit 74c9804 into Ark0N:master Oct 22, 2026
2 checks passed
Ark0N pushed a commit that referenced this pull request Oct 22, 2026
Merge-time fix for #676 (review finding 1 and nit 7). The Git worktrees modal
launches Claude through POST /api/sessions with workingDir
<case>/.claude/worktrees/<name>, which inferLocalCaseName did not recognise,
so the session landed in Ungrouped while the same gesture for any other CLI
(quick-start with the case name) landed in the group. The inference now
strips a trailing .claude/worktrees/<name> lexically before its two checks,
reusing CLAUDE_WORKTREE_PATH (hoisted to module scope). It also runs only for
a named workingDir, never on the server's own cwd, as the hook and skill
injection above already do. Route tests for both, failing without the fix;
api-reference and architecture-invariants say so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ark0N pushed a commit that referenced this pull request Oct 22, 2026
Merge-time fix for #676 (review finding 2). POST /api/ralph-loop/start now
passes { caseName } to addSession so the session lands in its case's default
session group, and nothing tested it. The registration-order test asserts the
placement argument; it fails against the route without the change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ark0N pushed a commit that referenced this pull request Oct 22, 2026
…side the rollback

Merge-time fix for #676 (review nits 4 and 5). sessionCreated forgot a stale
case default inside the try whose catch rolls the registration back; it now
does so after the try, once the session is committed, so the cleanup can
never drop a committed session from the live map. caseDefaultGroup (behind
GET /api/cases/:name/default-group) no longer deletes a stale id as a side
effect: it reports Ungrouped and leaves the value for the next creation to
forget. The two tests that expected the read to clear it now expect it to
write nothing; the creation test still pins the cleanup.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ark0N pushed a commit that referenced this pull request Oct 22, 2026
…not change

Merge-time fix for #676 (review nit 6). Once a Case settings popover had been
opened, every adopted tab layout (each create, delete and reorder) replaced
both selects' options, even with the popover hidden and the group list
unchanged, so a session created elsewhere could reset a native dropdown the
user had open. _refreshCaseDefaultGroupOptions now returns early when the ids
and names are the same. A vm case pins it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ark0N pushed a commit that referenced this pull request Oct 22, 2026
Merge-time fix for #676 (review finding 3 and nit 9). The State Files entry
for state.json names the new caseDefaultGroups key, and the Owner tab layouts
paragraph says where a case's default session group sits in the placement
order of sessionCreated() and that only TabLayoutService reads or writes it.
The wiki endpoint map counts the two new case routes (37 to 39).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ark0N pushed a commit that referenced this pull request Oct 22, 2026
…workingDir case

Merge-time fix for #676. The test for nit 7 changed the worker's real cwd,
which also throws under a threads pool; a process.cwd spy gives the route the
same case-folder cwd with nothing to restore by hand. It still fails without
the workingDir gate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ark0N pushed a commit that referenced this pull request Oct 22, 2026
Conflict in CLAUDE.md's State Files line resolved by keeping #661's new
config-backup entries and this PR's "case default groups" in state.json.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Ark0N

Ark0N commented Oct 22, 2026

Copy link
Copy Markdown
Owner

Merged and shipped in 1.44.0. Thank you @aakhter! Placing the session inside the same creating write, and keeping every read and write of the new key behind TabLayoutService, made this a pleasure to review. Same day, too.

What I applied at merge, as separate commits:

  • Worktree sessions: a Claude session started from the Git worktrees modal now lands in the case's default group like every other CLI's. inferLocalCaseName strips a trailing .claude/worktrees/<name> before its checks, and runs only for a named workingDir, never the server's own cwd. Route tests cover both.
  • Ralph: a test pins the { caseName } a Ralph loop start registers with.
  • Docs: CLAUDE.md names caseDefaultGroups in State Files and the placement order in Owner tab layouts. The wiki's Cases count is 39.
  • Nits: the stale-id cleanup sits after the rollback try. The GET is read-only and reports Ungrouped for a stale id. The selects are left alone when the group list did not change.

Left for later: a hint on the disabled selector for a case that does not exist yet, and sharing one SAFE_CASE_NAME.

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