Repository navigation
feat(tabs): per-case default session group - #676
Conversation
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.
|
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:
Optional polish, take or leave:
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. |
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>
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>
…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>
…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>
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>
…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>
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>
|
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 What I applied at merge, as separate commits:
Left for later: a hint on the disabled selector for a case that does not exist yet, and sharing one |
Problem
Tab groups (#528) can place a session explicitly, through a group's own "New session" or a
tabGroupIdon the create request. A normal Run has nothing to go on, though. If every session for caseappbelongs in anappgroup, 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.
caseDefaultGroupskey instate.json, keyed by the tab-layout owner key (@singleor the username) and then the case name. The value is a group id, ornullfor 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.TabLayoutServiceunder the owner lock:caseDefaultGroup,setCaseDefaultGroup,forgetCaseDefaultGroup, and a newcaseNamehint onsessionCreated.sessionCreated): an explicittabGroupIdfound in the owner's layout, then the parent placement (feat(tabs): place new, child and web tabs into groups #528 child placement, underspawnedTabsFollowParent), then the case default, else Ungrouped.POST /api/quick-startpasses itscaseName, which covers local, linked, remote and Docker cases.POST /api/ralph-loop/startpasses its case too.POST /api/sessionsworks out the case fromworkingDir, 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 asGET /api/cases). It never does this for a remote attach. When a case default places the session, the create response carriestabLayout, the same as it does fortabGroupId, and the browser adopts that layout before drawing the tab.GETandPUT /api/cases/:name/default-group({ groupId: string | null }), using the standard envelope. Both return 404 for a case the caller can't see. APUTwith 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._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.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 realTabLayoutService. CoversPOST /api/sessionsin a case folder and outside one, explicit-group precedence,POST /api/quick-startfor 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_applyTabLayouthook 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 passescaseName.sessionCreatedfails 9 tests; letting it beat the parent fails 2.npm run test:cipasses: 592 files, 11847 tests.GET /api/tab-layoutand 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.