mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +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
@@ -286,6 +286,9 @@ export class StateStore {
|
||||
if (this.state.tabLayouts !== undefined) {
|
||||
parts.push(`"tabLayouts":${JSON.stringify(this.state.tabLayouts)}`);
|
||||
}
|
||||
if (this.state.caseDefaultGroups !== undefined) {
|
||||
parts.push(`"caseDefaultGroups":${JSON.stringify(this.state.caseDefaultGroups)}`);
|
||||
}
|
||||
|
||||
return `{${parts.join(',')}}`;
|
||||
}
|
||||
@@ -710,6 +713,38 @@ export class StateStore {
|
||||
return { layouts: structuredClone(validated), sessionOrder: [...sessionOrder] };
|
||||
}
|
||||
|
||||
/**
|
||||
* An owner's default session group for a case: a group id, `null` for an
|
||||
* intentional Ungrouped choice, or `undefined` when unset. state.json is read
|
||||
* unvalidated, so anything but a string or null reads as unset.
|
||||
*/
|
||||
getCaseDefaultGroup(owner: string, caseName: string): string | null | undefined {
|
||||
const all = this.state.caseDefaultGroups;
|
||||
if (!all || typeof all !== 'object' || !Object.hasOwn(all, owner)) return undefined;
|
||||
const forOwner = all[owner];
|
||||
if (!forOwner || typeof forOwner !== 'object' || !Object.hasOwn(forOwner, caseName)) return undefined;
|
||||
const value = forOwner[caseName];
|
||||
return typeof value === 'string' || value === null ? value : undefined;
|
||||
}
|
||||
|
||||
/** Persist an owner's default session group for a case (`null` = Ungrouped). */
|
||||
setCaseDefaultGroup(owner: string, caseName: string, groupId: string | null): void {
|
||||
const all = this.state.caseDefaultGroups ?? {};
|
||||
const forOwner = Object.hasOwn(all, owner) ? all[owner] : {};
|
||||
this.state.caseDefaultGroups = { ...all, [owner]: { ...forOwner, [caseName]: groupId } };
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Forget an owner's default session group for a case (unset, not Ungrouped). */
|
||||
deleteCaseDefaultGroup(owner: string, caseName: string): void {
|
||||
if (this.getCaseDefaultGroup(owner, caseName) === undefined) return;
|
||||
const all = this.state.caseDefaultGroups ?? {};
|
||||
const forOwner = { ...all[owner] };
|
||||
delete forOwner[caseName];
|
||||
this.state.caseDefaultGroups = { ...all, [owner]: forOwner };
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Resets all state to initial values and saves immediately. */
|
||||
reset(): void {
|
||||
this.state = createInitialState();
|
||||
|
||||
@@ -50,6 +50,11 @@ interface TabLayoutServiceDeps {
|
||||
* from then on; on, it keeps following its parent until moved by hand.
|
||||
*/
|
||||
childrenFollowParent?(): boolean | Promise<boolean>;
|
||||
/**
|
||||
* Where per-case default session groups live (StateStore). Optional so a
|
||||
* harness without it simply never applies a case default.
|
||||
*/
|
||||
caseDefaults?: Pick<StateStore, 'getCaseDefaultGroup' | 'setCaseDefaultGroup' | 'deleteCaseDefaultGroup'>;
|
||||
now?: () => string;
|
||||
}
|
||||
|
||||
@@ -78,6 +83,17 @@ export interface RemovedTabLayoutSession {
|
||||
/** Where a newly created session asked to be placed. Advisory: an unknown group is ignored. */
|
||||
export interface SessionPlacementHint {
|
||||
tabGroupId?: string;
|
||||
/**
|
||||
* The case the session was started for. Its default session group (if any)
|
||||
* is the fallback when neither `tabGroupId` nor a parent places the session.
|
||||
*/
|
||||
caseName?: string;
|
||||
}
|
||||
|
||||
/** A case's default session group plus the owner's current named groups to choose from. */
|
||||
export interface CaseDefaultGroupView {
|
||||
groupId: string | null;
|
||||
groups: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,6 +174,20 @@ function pinCreatedChild(layout: TabLayout, sessionId: string, metadata: readonl
|
||||
return moveRef(pinned, { kind: 'session', id: sessionId }, { groupId: group?.id ?? null, index: to }, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the just-created session have a parent that places it (right after that
|
||||
* parent)? Mirrors the conditions under which normalization and
|
||||
* {@link pinCreatedChild} treat it as a child.
|
||||
*/
|
||||
function hasPlacingParent(layout: TabLayout, sessionId: string, metadata: readonly TabRefMetadata[]): boolean {
|
||||
const item = metadata.find((fact) => fact.kind === 'session' && fact.id === sessionId);
|
||||
const parentId = item?.parentSessionId;
|
||||
if (!item?.ownerValid || !parentId) return false;
|
||||
const parentValid = metadata.some((fact) => fact.kind === 'session' && fact.id === parentId && fact.ownerValid);
|
||||
const refs = [...layout.groups.flatMap((group) => group.refs), ...layout.ungrouped];
|
||||
return parentValid && refs.some((ref) => ref.kind === 'session' && ref.id === parentId);
|
||||
}
|
||||
|
||||
/** Move a just-created session to the end of `groupId`, or leave the layout alone. */
|
||||
function placeCreatedSession(
|
||||
layout: TabLayout,
|
||||
@@ -550,21 +580,79 @@ export class TabLayoutService {
|
||||
): Promise<TabLayout> {
|
||||
return this.withOwner(owner, async () => {
|
||||
const follow = sessionId ? (await this.deps.childrenFollowParent?.()) === true : true;
|
||||
const caseName = sessionId ? placement.caseName : undefined;
|
||||
const caseDefault = caseName ? this.deps.caseDefaults?.getCaseDefaultGroup(owner, caseName) : undefined;
|
||||
let staleCaseDefault = false;
|
||||
const rollback = register?.();
|
||||
let layout: TabLayout;
|
||||
try {
|
||||
const groupId = placement.tabGroupId;
|
||||
const adjust =
|
||||
sessionId && (groupId || !follow)
|
||||
sessionId && (groupId || !follow || typeof caseDefault === 'string')
|
||||
? (layout: TabLayout, metadata: readonly TabRefMetadata[]) => {
|
||||
const placed = groupId ? placeCreatedSession(layout, sessionId, groupId, metadata) : layout;
|
||||
// Precedence: explicit group > parent placement > case default > none.
|
||||
const explicit = groupId && layout.groups.some((group) => group.id === groupId) ? groupId : undefined;
|
||||
let placed = explicit ? placeCreatedSession(layout, sessionId, explicit, metadata) : layout;
|
||||
if (!explicit && typeof caseDefault === 'string') {
|
||||
if (!layout.groups.some((group) => group.id === caseDefault)) staleCaseDefault = true;
|
||||
else if (!hasPlacingParent(layout, sessionId, metadata))
|
||||
placed = placeCreatedSession(layout, sessionId, caseDefault, metadata);
|
||||
}
|
||||
return follow ? placed : pinCreatedChild(placed, sessionId, metadata);
|
||||
}
|
||||
: undefined;
|
||||
return await this.getUnlocked(owner, adjust);
|
||||
layout = await this.getUnlocked(owner, adjust);
|
||||
} catch (error) {
|
||||
if (typeof rollback === 'function') rollback();
|
||||
throw error;
|
||||
}
|
||||
// A default whose group was deleted is ignored above and forgotten here, only once the
|
||||
// creation's own write has landed, and outside the rollback: the session is committed by now,
|
||||
// and nothing this cleanup does may drop it from the live map.
|
||||
if (staleCaseDefault && caseName) this.deps.caseDefaults?.deleteCaseDefaultGroup(owner, caseName);
|
||||
return layout;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A case's default session group and the owner's named groups to choose from.
|
||||
* A stored id that is no longer one of this owner's groups is reported as
|
||||
* `null` (Ungrouped), which is also where creation would put it. Read-only:
|
||||
* the next creation for the case forgets the stale id.
|
||||
*/
|
||||
async caseDefaultGroup(owner: string, caseName: string): Promise<CaseDefaultGroupView> {
|
||||
return this.withOwner(owner, async () => {
|
||||
const { authoritative } = await this.prepareUnlocked(owner);
|
||||
const groups = authoritative.groups.map((group) => ({ id: group.id, name: group.name }));
|
||||
const stored = this.deps.caseDefaults?.getCaseDefaultGroup(owner, caseName);
|
||||
const known = typeof stored === 'string' && groups.some((group) => group.id === stored);
|
||||
return { groupId: known ? stored : null, groups };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a case's default session group: one of this owner's current groups, or
|
||||
* `null` for Ungrouped. Only future creations read it; nothing is moved.
|
||||
* Throws {@link TabLayoutValidationError} for an id not in the owner's layout.
|
||||
*/
|
||||
async setCaseDefaultGroup(owner: string, caseName: string, groupId: string | null): Promise<CaseDefaultGroupView> {
|
||||
return this.withOwner(owner, async () => {
|
||||
const caseDefaults = this.deps.caseDefaults;
|
||||
if (!caseDefaults) throw new Error('Case default groups are not available');
|
||||
const { authoritative } = await this.prepareUnlocked(owner);
|
||||
const groups = authoritative.groups.map((group) => ({ id: group.id, name: group.name }));
|
||||
if (groupId !== null && !groups.some((group) => group.id === groupId)) {
|
||||
throw new TabLayoutValidationError('Unknown tab group');
|
||||
}
|
||||
caseDefaults.setCaseDefaultGroup(owner, caseName, groupId);
|
||||
return { groupId, groups };
|
||||
});
|
||||
}
|
||||
|
||||
/** Forget a case's default session group (the case was deleted or unlinked). */
|
||||
async forgetCaseDefaultGroup(owner: string, caseName: string): Promise<void> {
|
||||
await this.withOwner(owner, async () => {
|
||||
this.deps.caseDefaults?.deleteCaseDefaultGroup(owner, caseName);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +121,12 @@ export interface AppState {
|
||||
sessionOrder?: string[];
|
||||
/** Owner-scoped authoritative grouped tab layouts. */
|
||||
tabLayouts?: Record<string, TabLayout>;
|
||||
/**
|
||||
* Per-case default session group, keyed by the tab-layout owner key, then case
|
||||
* name. A group id from that owner's layout, or null for an intentional
|
||||
* Ungrouped choice. Read and written only through TabLayoutService.
|
||||
*/
|
||||
caseDefaultGroups?: Record<string, Record<string, string | null>>;
|
||||
}
|
||||
|
||||
// ========== Default Configuration ==========
|
||||
|
||||
@@ -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