mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
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:
commit
463a083a23
27 files changed
+1437
-32
No files matched your search
@@ -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>;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.":
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
@@ -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, () => {
|
||||
|
||||
Reference in new issue
Block a user