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

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>
This commit is contained in:
Codeman maintainer committed 2026-10-22 19:54:54 +02:00
commit 463a083a23
27 files changed
+1437 -32

No files matched your search

+3 -1
View File
@@ -13,7 +13,9 @@ export interface SessionPort {
/**
* Register a created session with the owner's tab layout. `placement.tabGroupId`
* puts it at the end of that group in the same layout write (ignored when the
* group is not in the owner's layout). Resolves to the committed layout.
* group is not in the owner's layout); without one (and without a parent that
* places it), `placement.caseName`'s default session group does the same.
* Resolves to the committed layout.
*/
addSession(session: Session, placement?: SessionPlacementHint): Promise<TabLayout>;
cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise<void>;
+2
View File
@@ -7758,6 +7758,8 @@ class CodemanApp {
this._tabLayoutEditor = null;
}
this.tabLayout = next;
// An open Case settings popover lists the groups as its Default session group options.
this._refreshCaseDefaultGroupOptions?.(next);
const storage = this._getTabCollapseStorage();
const collapsed = storage && next
? window.CodemanTabLayout.loadCollapsedGroupIds(storage, next.groups.map((group) => group.id))
+3
View File
@@ -678,6 +678,9 @@
'Agent Teams': '智能体团队',
'Claude Model': 'Claude 模型',
'1M Opus Context': 'Opus 100 万上下文',
'Default session group': '默认会话分组',
'New sessions for this case land in this group': '此案例的新会话将放入此分组',
'Could not save the default session group.': '无法保存默认会话分组。',
'Remote auto-reconnect': '远程自动重连',
'Pin tab names on the CLI': '在 CLI 上固定标签名',
"Launch every Claude session with its tab name, so the CLI's prompt label and /resume list match your tabs. Claude then stops generating its own titles for those sessions, and a rename in Codeman holds only until the next turn. Applies from the next session start or respawn, no server restart needed.":
+10
View File
@@ -778,6 +778,11 @@
<span>1M Opus Context</span>
</label>
<span class="form-hint">Use 1M token context window for new sessions</span>
<label class="case-default-group-label" for="caseDefaultGroup">Default session group</label>
<select id="caseDefaultGroup" class="case-default-group-select" onchange="app.onCaseDefaultGroupChanged(this)">
<option value="">Ungrouped</option>
</select>
<span class="form-hint">New sessions for this case land in this group</span>
</div>
</div>
<!-- Mobile-only case button + gear -->
@@ -822,6 +827,11 @@
<span>1M Opus Context</span>
</label>
<span class="form-hint">Use 1M token context window</span>
<label class="case-default-group-label" for="caseDefaultGroupMobile">Default session group</label>
<select id="caseDefaultGroupMobile" class="case-default-group-select" onchange="app.onCaseDefaultGroupChanged(this)">
<option value="">Ungrouped</option>
</select>
<span class="form-hint">New sessions for this case land in this group</span>
</div>
</div>
+99
View File
@@ -3361,6 +3361,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('caseAgentTeams').checked = settings.agentTeams;
document.getElementById('caseOpusContext1m').checked = settings.opusContext1m;
popover.classList.remove('hidden');
this._loadCaseDefaultGroup();
// Close on outside click (one-shot listener)
const closeHandler = (e) => {
@@ -3409,6 +3410,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('caseAgentTeamsMobile').checked = settings.agentTeams;
document.getElementById('caseOpusContext1mMobile').checked = settings.opusContext1m;
popover.classList.remove('hidden');
this._loadCaseDefaultGroup();
const closeHandler = (e) => {
if (!popover.contains(e.target) && !e.target.classList.contains('btn-case-settings-mobile')) {
@@ -3435,6 +3437,103 @@ Object.assign(CodemanApp.prototype, {
if (desktopOpusCheckbox) desktopOpusCheckbox.checked = settings.opusContext1m;
},
// ── Default session group (server-side, per owner + case) ──
//
// Unlike the two checkboxes above (this browser's localStorage), the default
// group lives on the server, because the server places a new session in it in
// the same layout write that registers it. The value is a group ID; '' in the
// <select> is Ungrouped (null on the wire). Desktop and phone popovers mirror.
_caseDefaultGroupSelects() {
return ['caseDefaultGroup', 'caseDefaultGroupMobile']
.map((id) => document.getElementById(id))
.filter(Boolean);
},
/** Rebuild both selectors from `groups`, keeping `groupId` if it still exists. */
_renderCaseDefaultGroupSelects(groups, groupId) {
const known = groups.some((group) => group.id === groupId);
const value = known ? groupId : '';
for (const select of this._caseDefaultGroupSelects()) {
const ungrouped = document.createElement('option');
ungrouped.value = '';
ungrouped.textContent = 'Ungrouped';
const options = groups.map((group) => {
const option = document.createElement('option');
option.value = group.id;
option.textContent = group.name;
option.setAttribute('data-i18n-skip', '');
return option;
});
select.replaceChildren(ungrouped, ...options);
select.value = value;
}
return value;
},
/** Read the selected case's default group from the server (popover open). */
async _loadCaseDefaultGroup() {
const caseName = document.getElementById('quickStartCase')?.value;
const selects = this._caseDefaultGroupSelects();
if (!caseName || selects.length === 0) return;
for (const select of selects) select.disabled = true;
const data = await this._apiJson(`/api/cases/${encodeURIComponent(caseName)}/default-group`);
if (document.getElementById('quickStartCase')?.value !== caseName) return; // case switched meanwhile
const groups = Array.isArray(data?.groups) ? data.groups : [];
const groupId = typeof data?.groupId === 'string' ? data.groupId : null;
this._caseDefaultGroupState = { caseName, groupId, groups };
this._renderCaseDefaultGroupSelects(groups, groupId);
for (const select of selects) select.disabled = !data;
},
/** Save a selector change; on refusal restore the last confirmed value. */
async onCaseDefaultGroupChanged(select) {
const state = this._caseDefaultGroupState;
if (!state) return;
const groupId = select.value || null;
const selects = this._caseDefaultGroupSelects();
for (const other of selects) {
other.value = select.value;
other.disabled = true;
}
const res = await this._apiPut(`/api/cases/${encodeURIComponent(state.caseName)}/default-group`, { groupId });
let saved = null;
if (res?.ok) {
try {
saved = (await res.json())?.data ?? null;
} catch {
saved = null;
}
}
if (saved) {
state.groupId = typeof saved.groupId === 'string' ? saved.groupId : null;
if (Array.isArray(saved.groups)) state.groups = saved.groups;
} else {
this.showToast?.('Could not save the default session group.', 'error');
}
this._renderCaseDefaultGroupSelects(state.groups, state.groupId);
for (const other of selects) other.disabled = false;
},
/**
* The tab layout changed (a group was created, renamed or deleted): redraw the
* options from it. A deleted selection shows as Ungrouped, which is where the
* server now places new sessions (it forgets the stale ID at the next creation).
* Every create, delete and reorder broadcasts a layout, so an unchanged group
* list is left alone: rebuilding it would reset a dropdown the user has open.
*/
_refreshCaseDefaultGroupOptions(layout) {
const state = this._caseDefaultGroupState;
if (!state || !layout || !Array.isArray(layout.groups)) return;
const groups = layout.groups.map((group) => ({ id: group.id, name: group.name }));
const unchanged = groups.length === state.groups.length
&& groups.every((group, i) => group.id === state.groups[i].id && group.name === state.groups[i].name);
if (unchanged) return;
state.groups = groups;
const value = this._renderCaseDefaultGroupSelects(state.groups, state.groupId);
if (!value) state.groupId = null;
},
// ═══════════════════════════════════════════════════════════════
// Create Case Modal
// ═══════════════════════════════════════════════════════════════
+21
View File
@@ -6669,6 +6669,27 @@ html[data-skin="og"] .welcome-primary:hover {
display: none;
}
/* Default session group selector (Case settings, desktop and phone popovers) */
.case-default-group-label {
display: block;
margin-top: 8px;
font-size: 0.75rem;
color: var(--text);
}
.case-default-group-select {
display: block;
width: 100%;
margin-top: 4px;
padding: 3px 6px;
font: inherit;
font-size: 0.75rem;
color: var(--text);
background: var(--bg-input, var(--bg-card));
border: 1px solid var(--control-border);
border-radius: 6px;
}
.case-settings-popover .form-hint {
margin-top: 0.2rem;
font-size: 0.6rem;
+8 -1
View File
@@ -42,6 +42,8 @@ import { SseEvent } from '../sse-events.js';
import type { AuthPort } from '../ports/auth-port.js';
import type { SessionPort } from '../ports/session-port.js';
import type { EventPort } from '../ports/event-port.js';
import type { TabLayoutPort } from '../ports/tab-layout-port.js';
import { ownerLayoutKey } from '../../tab-layout-persistence.js';
const CreateUserSchema = z.object({
username: z.string().min(1).max(64),
@@ -73,7 +75,10 @@ function storeError(reply: FastifyReply, err: unknown): ReturnType<typeof create
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, err instanceof Error ? err.message : 'error');
}
export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & AuthPort & EventPort): void {
export function registerAdminRoutes(
app: FastifyInstance,
ctx: SessionPort & AuthPort & EventPort & TabLayoutPort
): void {
// Gate: admin routes exist only in multi-user mode, and only for admins.
const gate = (req: FastifyRequest, reply: FastifyReply): boolean => {
if (!isMultiUserMode()) {
@@ -299,6 +304,8 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
);
}
await fsp.rm(realTarget, { recursive: true, force: true });
// The user's default session group for that case goes with it.
await ctx.tabLayouts.forgetCaseDefaultGroup(ownerLayoutKey(username), params.caseName).catch(() => undefined);
audit(req, 'user.case-delete', username, { caseName: params.caseName });
ctx.broadcast(SseEvent.AdminUsersChanged, {});
return { success: true, data: { username, caseName: params.caseName } };
+87 -4
View File
@@ -29,6 +29,7 @@ import {
ClonePreflightSchema,
LinkCaseSchema,
CaseOrderSchema,
CaseDefaultGroupSchema,
RemoteCaseLinkSchema,
RemoteHostSchema,
DockerCaseLinkSchema,
@@ -51,7 +52,7 @@ import {
import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { prepareNewCasePath } from '../case-path.js';
import { boundedPathExists, describeUnknownPath, probePath } from '../../utils/index.js';
import { boundedPathExists, describeUnknownPath, probePath, probePathKind } from '../../utils/index.js';
import { readAgentCaseMarker, type AgentCaseMarker } from '../../agent-case-marker.js';
import { settingsWriteBlocker, writeHooksConfig } from '../../hooks-config.js';
import {
@@ -70,7 +71,9 @@ import {
import { isMultiUserMode } from '../../config/multiuser.js';
import type { AuthUser } from '../../types.js';
import { SseEvent } from '../sse-events.js';
import type { EventPort, ConfigPort, SessionPort } from '../ports/index.js';
import type { EventPort, ConfigPort, SessionPort, TabLayoutPort } from '../ports/index.js';
import { ownerLayoutKey } from '../../tab-layout-persistence.js';
import { TabLayoutValidationError } from '../../tab-layout.js';
import type { FastifyRequest } from 'fastify';
import { dataPath, getDataDir } from '../../config/instance.js';
import {
@@ -246,7 +249,33 @@ async function ensureCaseImage(
return { ok: true, imageBuilding: true };
}
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort & SessionPort): void {
/**
* Is `name` a case the caller can see (the same rules as GET /api/cases and
* resolveCasePath): an owned remote or Docker case, a linked case (admin or
* single-user only), or a directory in the caller's own case space. Bounded:
* a case folder on a mount that stopped answering still counts as there, like
* the case list keeps listing it.
*/
async function caseVisibleToCaller(name: string, req: FastifyRequest): Promise<boolean> {
if (!SAFE_CASE_NAME.test(name)) return false;
const user = getAuthUser(req);
if ((await readRemoteCases(CODEMAN_CONFIG_DIR)).some((c) => c.name === name && canAccessOwned(user, c.owner))) {
return true;
}
if ((await readDockerCases(CODEMAN_CONFIG_DIR)).some((c) => c.name === name && canAccessOwned(user, c.owner))) {
return true;
}
const linkedCases = await readLinkedCases();
if (linkedCases[name] && (!isMultiUserMode() || isAdmin(req)))
return (await probePath(linkedCases[name])) !== 'absent';
const kind = await probePathKind(join(resolveCasesDir(user), name));
return kind === 'directory' || kind === 'unknown';
}
export function registerCaseRoutes(
app: FastifyInstance,
ctx: EventPort & ConfigPort & SessionPort & TabLayoutPort
): void {
// ═══════════════════════════════════════════════════════════════
// Case CRUD (list, create, link, detail, fix-plan)
// ═══════════════════════════════════════════════════════════════
@@ -1584,6 +1613,19 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
app.delete('/api/cases/:name', async (req): Promise<ApiResponse<{ name: string }>> => {
const { name } = req.params as { name: string };
const user = getAuthUser(req);
// The caller's default session group for this case goes with it (best-effort:
// a stale value is ignored at creation anyway).
// An admin deleting another user's remote/Docker case clears that owner's too.
const forgetCaseDefault = async (caseOwner?: string): Promise<void> => {
const owners = new Set([ownerLayoutKey(ownerFor(req)), ...(caseOwner ? [ownerLayoutKey(caseOwner)] : [])]);
for (const owner of owners) {
try {
await ctx.tabLayouts.forgetCaseDefaultGroup(owner, name);
} catch {
/* never blocks the delete */
}
}
};
if (!validatePathWithinBase(name, resolveCasesDir(user))) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
@@ -1593,12 +1635,14 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
// docker case is skipped so control falls through to the caller's own local delete.
// canAccessOwned is all-true for admins/single-user, so flag-OFF stays byte-identical.
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
if (remoteCases.some((item) => item.name === name && canAccessOwned(user, item.owner))) {
const remoteCase = remoteCases.find((item) => item.name === name && canAccessOwned(user, item.owner));
if (remoteCase) {
await writeRemoteCases(
CODEMAN_CONFIG_DIR,
remoteCases.filter((item) => !(item.name === name && canAccessOwned(user, item.owner)))
);
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'remote-unlinked' });
await forgetCaseDefault(remoteCase.owner);
return { success: true, data: { name } };
}
@@ -1631,6 +1675,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
.catch(() => {});
}
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'docker-unlinked' });
await forgetCaseDefault(dockerCase.owner);
return { success: true, data: { name } };
}
@@ -1643,6 +1688,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
try {
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'unlinked' });
await forgetCaseDefault();
return { success: true, data: { name } };
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
@@ -1658,12 +1704,49 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
try {
await fs.rm(casePath, { recursive: true, force: true });
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'deleted' });
await forgetCaseDefault();
return { success: true, data: { name } };
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
});
// ========== Default session group ==========
/**
* The tab group new sessions of this case land in when the create request names
* no group and no parent places them. Scoped to the caller's own tab layout: the
* options are their groups, and a stored id that is no longer one of them reads
* as Ungrouped (null); the read writes nothing (the next creation for the case
* forgets it). Not admin-gated in multi-user mode: the
* value is per owner (like the layout it points into), never server-wide.
*/
app.get('/api/cases/:name/default-group', async (req, reply) => {
const { name } = req.params as { name: string };
if (!(await caseVisibleToCaller(name, req))) {
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${name}" not found`));
}
const view = await ctx.tabLayouts.caseDefaultGroup(ownerLayoutKey(ownerFor(req)), name);
return { success: true, data: { caseName: name, ...view } };
});
app.put('/api/cases/:name/default-group', async (req, reply) => {
const { name } = req.params as { name: string };
const { groupId } = parseBody(CaseDefaultGroupSchema, req.body, 'Invalid default group');
if (!(await caseVisibleToCaller(name, req))) {
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${name}" not found`));
}
try {
const view = await ctx.tabLayouts.setCaseDefaultGroup(ownerLayoutKey(ownerFor(req)), name, groupId);
return { success: true, data: { caseName: name, ...view } };
} catch (err) {
if (err instanceof TabLayoutValidationError) {
return reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'That tab group does not exist'));
}
throw err;
}
});
// ========== Reorder Cases ==========
app.put('/api/cases/order', async (req, reply): Promise<ApiResponse<{ order: string[] }> | undefined> => {
+2 -2
View File
@@ -413,8 +413,8 @@ export function registerRalphRoutes(
const promptPath = join(casePath, '@ralph_prompt.md');
writeFileSync(promptPath, fullPrompt, 'utf-8');
// Register session
await ctx.addSession(session);
// Register session (in its case's default session group, if one is set)
await ctx.addSession(session, { caseName });
ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session);
await ctx.setupSessionListeners(session);
+57 -14
View File
@@ -4,9 +4,9 @@
* auto-clear, auto-compact, image watcher, flicker filter, and logout.
*/
import { FastifyInstance, type FastifyReply } from 'fastify';
import { FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify';
import { z } from 'zod';
import { join, dirname, extname, basename } from 'node:path';
import { join, dirname, extname, basename, resolve as resolvePath } from 'node:path';
import { homedir } from 'node:os';
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
import { execFile } from 'node:child_process';
@@ -77,7 +77,7 @@ import {
} from './custom-model-routes.js';
import { matchesPattern } from '../../config/cli-registry/patterns.js';
import { ownerLayoutKey } from '../../tab-layout-persistence.js';
import { TabLayoutValidationError } from '../../tab-layout.js';
import { TabLayoutValidationError, type TabLayout } from '../../tab-layout.js';
import {
sessionWaits,
resolveWaitSignals,
@@ -108,6 +108,7 @@ import {
ownerFor,
parseBody,
persistAndBroadcastSession,
readJsonConfig,
resolveAgentCaseOrigin,
resolveCasesDir,
resolveParentSessionId,
@@ -213,6 +214,42 @@ import { UPLOADS_DIR } from '../paste-image-gc.js';
const LINKED_CASES_FILE = dataPath('linked-cases.json');
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));
}
/**
* 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, 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> {
// 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);
if (SAFE_CASE_NAME.test(name)) return name;
}
if (isMultiUserMode() && !isAdmin(req)) return undefined;
const linked = await readJsonConfig<Record<string, string>>(LINKED_CASES_FILE, 'linked cases', {});
for (const [name, path] of Object.entries(linked)) {
if (SAFE_CASE_NAME.test(name) && typeof path === 'string' && resolvePath(path) === target) return name;
}
return undefined;
}
// Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation)
// eslint-disable-next-line no-control-regex
const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/;
@@ -1421,9 +1458,14 @@ export function registerSessionRoutes(
parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner),
});
const tabLayout = body.tabGroupId
? await ctx.addSession(session, { tabGroupId: body.tabGroupId })
: await ctx.addSession(session);
// 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 } : {}),
};
const tabLayout =
Object.keys(placement).length > 0 ? await ctx.addSession(session, placement) : await ctx.addSession(session);
ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session);
await ctx.setupSessionListeners(session);
@@ -1441,9 +1483,13 @@ export function registerSessionRoutes(
// Avoids serializing 2-3MB of terminal+text buffers per session creation.
const lightState = ctx.getSessionStateWithRespawn(session);
ctx.broadcast(SseEvent.SessionCreated, lightState);
// A grouped creation also returns the layout that placed it, so the browser
// draws the tab inside its group at once instead of after a re-read.
return { session: lightState, ...(body.tabGroupId ? { tabLayout } : {}) };
// A grouped creation (asked for, or its case's default group) also returns the
// layout that placed it, so the browser draws the tab inside its group at once
// instead of after a re-read.
return {
session: lightState,
...(body.tabGroupId || landedInGroup(tabLayout, session.id) ? { tabLayout } : {}),
};
});
// ========== Rename Session ==========
@@ -4507,7 +4553,7 @@ export function registerSessionRoutes(
}
}
const tabLayout = tabGroupId ? await ctx.addSession(session, { tabGroupId }) : await ctx.addSession(session);
const tabLayout = await ctx.addSession(session, { caseName, ...(tabGroupId ? { tabGroupId } : {}) });
ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session);
await ctx.setupSessionListeners(session);
@@ -4599,7 +4645,7 @@ export function registerSessionRoutes(
caseName,
...(worktree ? { worktree, workingDir: workspacePath } : {}),
...(customModel ? { modelSwapInProgress: qsCustomModelSwapInProgress } : {}),
...(tabGroupId ? { tabLayout } : {}),
...(tabGroupId || landedInGroup(tabLayout, session.id) ? { tabLayout } : {}),
};
} catch (err) {
// Clean up session on error to prevent orphaned resources
@@ -4730,9 +4776,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.
*
+9
View File
@@ -1997,6 +1997,15 @@ export const CaseOrderSchema = z.object({
order: z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format')),
});
/**
* PUT /api/cases/:name/default-group — the tab group new sessions of this case
* land in: one of the caller's own group ids, or null for Ungrouped. Same bounds
* as tabGroupIdSchema; the route checks the id against the caller's layout.
*/
export const CaseDefaultGroupSchema = z.object({
groupId: z.string().min(1).max(100).nullable(),
});
/** PUT /api/session-order — global tab order (ordered sessionIds), COD-131 */
export const SessionOrderUpdateSchema = z.object({
// Bounded defensively: ids are uuid-ish (<=100 chars) and the client pushes only
+3 -2
View File
@@ -539,6 +539,7 @@ export class WebServer extends EventEmitter {
// Fresh, not the 2s cache: the settings PUT does not invalidate it, and a
// flip must apply to the very next spawn.
childrenFollowParent: async () => (await this.readSettings(true)).spawnedTabsFollowParent === true,
caseDefaults: this.store,
});
if (this.testMode) this.tabLayouts.markRestorationSkipped();
@@ -794,8 +795,8 @@ export class WebServer extends EventEmitter {
/**
* Add a tentative session only after its owner layout accepts the creation.
* The session enters the live map inside the layout service's owner lock, so
* the placement it asked for (`tabGroupId`) lands in the same versioned write
* that first records it.
* the placement it asked for (`tabGroupId`, else its case's default group)
* lands in the same versioned write that first records it.
*/
private async registerSessionWithLayout(session: Session, placement?: SessionPlacementHint): Promise<TabLayout> {
return this.tabLayouts.sessionCreated(ownerLayoutKey(session.owner), session.id, placement, () => {