fix(tabs): a Claude worktree session gets its case's default group too

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>
This commit is contained in:
Codeman maintainer committed 2026-10-22 19:29:25 +02:00
1 parent 74c980413a
commit 5a677bf03c
4 files changed
+49 -10

No files matched your search

+13 -8
View File
@@ -207,6 +207,9 @@ const CODEMAN_CONFIG_DIR = getDataDir();
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
/** `<repo>/.claude/worktrees/<name>`: the layout Claude Code's own worktree feature creates. */
const CLAUDE_WORKTREE_PATH = /^(.*)\/\.claude\/worktrees\/([^/]+)\/?$/;
/** Did the committed layout put this session in a named group (not Ungrouped)? */
function landedInGroup(layout: TabLayout | undefined, sessionId: string): boolean {
return !!layout?.groups.some((group) => group.refs.some((ref) => ref.kind === 'session' && ref.id === sessionId));
@@ -215,12 +218,16 @@ function landedInGroup(layout: TabLayout | undefined, sessionId: string): boolea
/**
* The case a POST /api/sessions `workingDir` belongs to, for its default session
* group: a direct child of the caller's case space, or (admin / single-user, as
* GET /api/cases lists them) a linked case's exact path. Lexical only: the
* browser sends the path the case list gave it, and this must never touch a
* possibly-dead mount. Anything else is no case, which keeps today's placement.
* GET /api/cases lists them) a linked case's exact path, or a Claude worktree
* (`<case>/.claude/worktrees/<name>`) of either. Lexical only: the browser sends
* the path the case list gave it, and this must never touch a possibly-dead
* mount. Anything else is no case, which keeps today's placement.
*/
async function inferLocalCaseName(req: FastifyRequest, workingDir: string): Promise<string | undefined> {
const target = resolvePath(workingDir);
// The Git worktrees modal launches Claude through this route in the worktree, and every other CLI
// through quick-start with the case name: both are sessions of that case.
const resolved = resolvePath(workingDir);
const target = CLAUDE_WORKTREE_PATH.exec(resolved)?.[1] || resolved;
const user = getAuthUser(req);
if (dirname(target) === resolvePath(resolveCasesDir(user))) {
const name = basename(target);
@@ -1442,7 +1449,8 @@ export function registerSessionRoutes(
parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner),
});
const placementCase = remote ? undefined : await inferLocalCaseName(req, workingDir);
// Only a named workingDir: without one the session starts in the server's own cwd, which is no case.
const placementCase = remote || !body.workingDir ? undefined : await inferLocalCaseName(req, workingDir);
const placement = {
...(body.tabGroupId ? { tabGroupId: body.tabGroupId } : {}),
...(placementCase ? { caseName: placementCase } : {}),
@@ -4722,9 +4730,6 @@ export function registerSessionRoutes(
worktreeRepo?: string;
};
/** `<repo>/.claude/worktrees/<name>` — the layout Claude Code's own worktree feature creates. */
const CLAUDE_WORKTREE_PATH = /^(.*)\/\.claude\/worktrees\/([^/]+)\/?$/;
/**
* Recover cwd / branch / worktree from a transcript chunk.
*