From 74c980413a49ac997ac3c97f4ce6e965e2cbd31c Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Thu, 22 Oct 2026 03:42:42 -0400 Subject: [PATCH] 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. --- docs/api-reference.md | 20 + docs/architecture-invariants.md | 1 + docs/wiki/Core-Concepts.md | 4 + src/state-store.ts | 35 ++ src/tab-layout-service.ts | 94 +++- src/types/app-state.ts | 6 + src/web/ports/session-port.ts | 4 +- src/web/public/app.js | 2 + src/web/public/i18n.js | 3 + src/web/public/index.html | 10 + src/web/public/session-ui.js | 93 ++++ src/web/public/styles.css | 21 + src/web/routes/admin-routes.ts | 9 +- src/web/routes/case-routes.ts | 90 +++- src/web/routes/ralph-routes.ts | 4 +- src/web/routes/session-routes.ts | 60 ++- src/web/schemas.ts | 9 + src/web/server.ts | 5 +- test/admin-routes.test.ts | 5 +- test/case-default-group-ui.test.ts | 160 +++++++ test/case-default-group.test.ts | 332 ++++++++++++++ test/mocks/mock-route-context.ts | 6 + test/routes/case-default-group-routes.test.ts | 411 ++++++++++++++++++ test/routes/session-routes-tab-group.test.ts | 4 +- 24 files changed, 1362 insertions(+), 26 deletions(-) create mode 100644 test/case-default-group-ui.test.ts create mode 100644 test/case-default-group.test.ts create mode 100644 test/routes/case-default-group-routes.test.ts diff --git a/docs/api-reference.md b/docs/api-reference.md index c0350a66..068b5d0a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -778,6 +778,26 @@ deleted group, another user's group) is ignored and the session lands where it otherwise would, never a `400`. A malformed value (`null`, an empty string, a non-string) is a `400 INVALID_INPUT`. Requests without the field are unchanged. +### A case's default session group + +- `GET /api/v1/cases/:name/default-group` → `{ caseName, groupId, groups }`: `groupId` is a + group id from the caller's own tab layout or `null` (Ungrouped), `groups` the caller's + current named groups (`{ id, name }`, in layout order). A stored id whose group was + deleted reads back as `null` and is forgotten. +- `PUT /api/v1/cases/:name/default-group` with `{ "groupId": string | null }` saves it. An + id not in the caller's layout is a `400 INVALID_INPUT` that leaves the old value in place. + +Both answer `404 NOT_FOUND` for a case the caller cannot see (the same rules as +`GET /api/v1/cases`). The value is stored per owner and case, so in multi-user mode it is +never shared and needs no admin role. A new session of the case is placed in that group in +its creating write when nothing stronger applies; the order is: an explicit `tabGroupId`, +then the session it was spawned from (`parentSessionId`), then the case default, else +Ungrouped. `POST /api/v1/quick-start` uses its `caseName` (local, linked, remote and Docker +cases); `POST /api/v1/sessions` uses the case whose folder `workingDir` is (a direct child of +the caller's case space, or a linked case's exact path). When a case default places the +session, the create response carries `tabLayout` as it does for `tabGroupId`. Deleting the +case forgets the value; existing sessions never move. + ## Approvals Inbox Cross-session queue of prompts waiting on a human (permission dialogs, diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index a50539c8..096344e0 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -331,6 +331,7 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t - **Edits are named operations, saved serially.** `createEditCoordinator` applies `createGroup` / `renameGroup` / `deleteGroup` / `reorderGroup` / `moveRef` to the rail at once, then sends ONE `PUT /api/tab-layout {baseVersion, layout}` at a time; edits made meanwhile wait and go out on the version that write returns. A 409 carries the server's layout: the in-flight operations are replayed onto it (an operation that no longer applies is dropped and reported) and re-sent, at most `maxAttempts` times; a 400 re-reads once (a second 400 is reported as a failed save, not as a race); anything else reports and re-reads. A `moveRef` moves the session together with the sessions that still follow it and marks a hand-moved child `placement: 'manual'`, mirroring the server's `moveRef`, and `normalizeLayout` keeps `placement` because whole layouts are written back. `_onTabLayoutChanged` / `_applyTabLayout` defer a read while a write is in flight and rebase unsaved edits onto a read otherwise. ⚠️ A FAILED read (`_applyTabLayout(null)`) while edits are pending keeps the held layout and the editor and re-reads after the write settles: disposing there would orphan the in-flight write, whose 409 then never gets its rebase. Any path that does drop unsaved work says so in a toast. On `pagehide`, unconfirmed operations go out in a `keepalive` PUT AND into sessionStorage as `{ owner, baseVersion, savedAt, operations }`; after reload they replay onto the fresh layout (a no-op when the keepalive landed), but only for the same owner, within `TAB_LAYOUT_PENDING_MAX_AGE_MS` (60 s), and never onto a layout older than the copy's base. A "Move to " with no anchor carries no `index`, so a replay still puts the row last. - **Every way in has a keyboard path.** The session row menu (Shift+F10 in the tree, the rail's overflow button) gains Move up/down, Move to , Move to Ungrouped and Move to new group in the vertical rail (only "new group" before the first group exists; nothing on the strip). A group header opens its menu with Shift+F10 / ContextMenu, right-click or its hover glyph (a non-focusable, `aria-hidden` span: a treeitem holds no interactive children), and F2 renames it inline (Enter commits and refocuses the header; a commit by BLUR leaves focus where it went, since refocusing from inside the blur handler overrides the user's click). The glyph stays visible under `@media (hover: none)`: a touch tablet has no hover and no long-press `contextmenu`. Group names in "Move to" labels are quoted, so a group named "New group" or "ungrouped" cannot read (or translate, case-insensitively) like the fixed entries. The menu closes on Escape (which it consumes before the global Escape handler), a pointer outside, Tab, focus leaving it, a resize, a second open and any full re-render. It borrows the `.tab-rail-action-menu` class for its look only: `closeTabRailActionMenu()` excludes `.tab-layout-group-action-menu`, so closing the row menu (every `session:deleted` does) cannot strand the group menu's listeners. The inline group editor shares `_activeRename` with the session rename, so only the CURRENT editor may release `_inlineRenameActive`. - **New, child and web tabs land in groups.** A group menu's "New session" (`createSessionInTabGroup`) runs ONE launch with `run({ count: 1, tabGroupId })`; every launch path puts `tabGroupId` on its create request (`POST /api/sessions` or `/api/quick-start`), and `TabLayoutService.sessionCreated(owner, id, { tabGroupId }, register)` places the session at the end of that group in the SAME versioned write that first records it. The session enters the live map inside the owner lock (`register`, rolled back if the write fails), so no queued layout edit can commit it first. The group is looked up in the creating owner's own layout, so an unknown, deleted or foreign id is ignored rather than failing the spawn, and an explicit group is a hand placement (a child placed this way is `manual`). The create response carries the committed `tabLayout`, adopted before the tab is drawn. A session spawned by another one (`parentSessionId`) and placed by nobody lands after its parent: the server's normalization puts it after the parent's rows in the parent's container, and `project({ parents })` draws a not-yet-stored child there at once. Whether it then FOLLOWS its parent is the synced `spawnedTabsFollowParent` setting (default OFF), which `sessionCreated()` reads fresh through the `childrenFollowParent` dep: off, the child is marked `manual` in that same creating write; on, it follows until moved by hand. Deciding at creation is enough, so flipping the setting leaves existing tabs alone. A hand move makes a following child `manual`; with the setting on, the row menu notes where a child sits (following, placed by hand, parent closed, parent loop; plain text, never a focusable menu item) and offers "Follow parent again" only to a `manual` child, which clears `placement` and is re-derived by the server on save. With the setting off the row menu says nothing about placement. A legacy `PUT /api/session-order` (strip or phone drag, a same-section Ctrl+Shift+{/}, a device without a layout, an admin's cross-owner order) marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`, `childrenSplitFromParent`); a PUT of the current order, or one that moves a family together, leaves every stored placement alone. The browser never echoes a create or delete through that PUT, with or without groups: the service already re-projects the order and broadcasts it (`session:orderChanged`), and `syncSessionOrder` adopts it. A child whose parent is gone stands on its own and is never re-adopted. Saved web tabs are layout refs like sessions, but OPENNESS stays per device: a grouped web tab renders (and takes an Alt+N slot) only while it is open on this device, and reopens where the layout keeps it. +- **A case's default session group** (`caseDefaultGroups` in state.json, keyed by the layout owner key then case name; a group id or `null` for Ungrouped) is the fallback placement for a new session of that case. It is read and written ONLY through `TabLayoutService` (`caseDefaultGroup`, `setCaseDefaultGroup`, `forgetCaseDefaultGroup`, and `sessionCreated`'s `caseName` hint), under the owner lock, never by a route or the store directly. Precedence inside `sessionCreated`'s single write: an explicit `tabGroupId` found in the owner's layout, then a parent that places the child (`hasPlacingParent`, the same conditions as `pinCreatedChild`), then the case default, else normal placement. The id is looked up in the creating owner's own layout, so a deleted or foreign id never fails a spawn; a stale id is forgotten only AFTER the creating write lands (a failed write rolls back and changes nothing). `POST /api/quick-start` and `POST /api/ralph-loop/start` pass their `caseName`; `POST /api/sessions` infers it LEXICALLY from `workingDir` (`inferLocalCaseName`: a direct child of the caller's case space, or an exact linked-case path for an admin or single-user), never for a remote attach and never touching the filesystem. Cron jobs, the one-shot run route and reboot restore keep normal placement. The Case settings selector (`onCaseDefaultGroupChanged`, session-ui.js) reads it on popover open and is redrawn from every adopted layout (`_refreshCaseDefaultGroupOptions` in `_applyTabLayout`). - **Only the grouped rail is a tree.** `#sessionTabs` ships as `role=tablist` with `role=tab` rows, and the header strip, sidebar and flat rail keep exactly that. While grouped, `_applyTabListRole` makes it `role=tree` (and restores `tablist` + its label when grouping ends), named-group headers are level-1 `treeitem`s that `aria-owns` their rows' `role=group` (rows sit beside the header, not inside it), and ungrouped rows plus a collapsed group's kept selection are level-1 items. A collapsed header owns nothing, a group with no open rows is a leaf (no `aria-expanded`, no owned group), and the "Ungrouped" heading is `aria-hidden`. Rows are re-roled in the DOM by `_applyTabTreeSemantics` after render, never by rewriting their markup, so a grouped row's content stays the flat row's. - **One tab stop in the tree.** Exactly one treeitem carries `tabindex=0` (the focused or selected item); every control inside a row drops to `-1`, which is why Shift+F10 / ContextMenu open a row's actions from the keyboard. Focus survives a full re-render by identity (`group:`/`session:`/`webview:`; a row a collapse just hid hands focus to its header), but only when focus was already inside the rail. The tree walk (`_tabTreeItems`) follows painted order WITHIN each group when the rail is sorted; the flat list keeps its own whole-list computed-order walk. `aria-posinset`/`aria-setsize` follow painted order too, so the incremental render path re-runs `_applyTabTreePositions` after it re-sorts rows in place. ⚠️ `_handleTabTreeKeydown` acts only when the key lands on the treeitem ITSELF: a key on a focused in-row control (close, overflow, the rename input) is that control's, or Enter on the overflow button re-selects the row instead of reopening its menu. ⚠️ The roving `tabindex=-1` also hides every item but the stop from the keyboard-dismiss selector's `[tabindex]` arm, which is why `MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR` lists `[role="treeitem"]` (see Dismissing the on-screen keyboard). diff --git a/docs/wiki/Core-Concepts.md b/docs/wiki/Core-Concepts.md index 60da25e6..6efe48a5 100644 --- a/docs/wiki/Core-Concepts.md +++ b/docs/wiki/Core-Concepts.md @@ -13,6 +13,10 @@ Codeman-side configuration: - Which CLI the Run button should default to. - Per-case toggles (Agent Teams, 1M Opus context). +- A **Default session group** (Case settings…): new sessions of the case land in that tab group of the + vertical rail unless you start them from a group's own *New session* or they follow the session that + spawned them. *Ungrouped* turns it off. It follows you across devices, never moves existing tabs, and a + deleted group simply falls back to Ungrouped. - Where it runs, if it is not the local filesystem: see [Location overlays](#location-overlays). Three ways to get one, all under **New or link a case…** at the bottom of the case picker diff --git a/src/state-store.ts b/src/state-store.ts index 6afaddbf..88510c93 100644 --- a/src/state-store.ts +++ b/src/state-store.ts @@ -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(); diff --git a/src/tab-layout-service.ts b/src/tab-layout-service.ts index 0b0e5802..a6c781d6 100644 --- a/src/tab-layout-service.ts +++ b/src/tab-layout-service.ts @@ -50,6 +50,11 @@ interface TabLayoutServiceDeps { * from then on; on, it keeps following its parent until moved by hand. */ childrenFollowParent?(): boolean | Promise; + /** + * Where per-case default session groups live (StateStore). Optional so a + * harness without it simply never applies a case default. + */ + caseDefaults?: Pick; 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,17 +580,31 @@ export class TabLayoutService { ): Promise { 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?.(); 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); + const layout = await this.getUnlocked(owner, adjust); + // A default whose group was deleted is ignored above and forgotten here, + // only once the creation's own write has landed. + if (staleCaseDefault && caseName) this.deps.caseDefaults?.deleteCaseDefaultGroup(owner, caseName); + return layout; } catch (error) { if (typeof rollback === 'function') rollback(); throw error; @@ -568,6 +612,50 @@ export class TabLayoutService { }); } + /** + * 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 forgotten and + * reported as `null` (Ungrouped), which is also where creation would put it. + */ + async caseDefaultGroup(owner: string, caseName: string): Promise { + 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); + if (typeof stored === 'string' && !groups.some((group) => group.id === stored)) { + this.deps.caseDefaults?.deleteCaseDefaultGroup(owner, caseName); + return { groupId: null, groups }; + } + return { groupId: typeof stored === 'string' ? 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 { + 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 { + await this.withOwner(owner, async () => { + this.deps.caseDefaults?.deleteCaseDefaultGroup(owner, caseName); + }); + } + /** Reconcile one completed saved-webview creation into one versioned mutation. */ async webviewCreated(owner: string): Promise { return this.get(owner); diff --git a/src/types/app-state.ts b/src/types/app-state.ts index 4382573a..ccd96542 100644 --- a/src/types/app-state.ts +++ b/src/types/app-state.ts @@ -121,6 +121,12 @@ export interface AppState { sessionOrder?: string[]; /** Owner-scoped authoritative grouped tab layouts. */ tabLayouts?: Record; + /** + * 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>; } // ========== Default Configuration ========== diff --git a/src/web/ports/session-port.ts b/src/web/ports/session-port.ts index adb4e85d..6a735f5b 100644 --- a/src/web/ports/session-port.ts +++ b/src/web/ports/session-port.ts @@ -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; cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise; diff --git a/src/web/public/app.js b/src/web/public/app.js index 62716497..bfe0ae30 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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)) diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 14db1489..893ce13d 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -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.": diff --git a/src/web/public/index.html b/src/web/public/index.html index abbd2cd9..aac45b8d 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -774,6 +774,11 @@ 1M Opus Context Use 1M token context window for new sessions + + + New sessions for this case land in this group @@ -818,6 +823,11 @@ 1M Opus Context Use 1M token context window + + + New sessions for this case land in this group diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 6067dc0b..73bf6303 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -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 + //