feat: per-case default session group

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.
This commit is contained in:
Aamer Akhter committed 2026-10-22 03:42:42 -04:00
1 parent 39077281d1
commit 74c980413a
24 files changed
+1362 -26

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
@@ -7692,6 +7692,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
@@ -774,6 +774,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 -->
@@ -818,6 +823,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>
+93
View File
@@ -3272,6 +3272,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) => {
@@ -3320,6 +3321,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')) {
@@ -3346,6 +3348,97 @@ 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 on its next read).
*/
_refreshCaseDefaultGroupOptions(layout) {
const state = this._caseDefaultGroupState;
if (!state || !layout || !Array.isArray(layout.groups)) return;
state.groups = layout.groups.map((group) => ({ id: group.id, name: group.name }));
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 } };
+86 -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,48 @@ 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) and is forgotten. 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);
+49 -11
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';
@@ -76,7 +76,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,
@@ -107,6 +107,7 @@ import {
ownerFor,
parseBody,
persistAndBroadcastSession,
readJsonConfig,
resolveAgentCaseOrigin,
resolveCasesDir,
resolveParentSessionId,
@@ -204,6 +205,35 @@ 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_-]+$/;
/** 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. 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);
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/;
@@ -1412,9 +1442,13 @@ 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);
const placementCase = remote ? 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);
@@ -1432,9 +1466,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 ==========
@@ -4461,7 +4499,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);
@@ -4553,7 +4591,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
+9
View File
@@ -1978,6 +1978,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
@@ -470,6 +470,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();
@@ -725,8 +726,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, () => {