diff --git a/CLAUDE.md b/CLAUDE.md index 77b912b1..56663bb6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -193,7 +193,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history and transcript files into one deduped list (pure core `src/services/unified-session-service.ts`), backing the Cmd+K Session Manager, pinning and cross-device tab order (`PUT /api/session-order`, `src/session-order.ts`). ⚠️ Transcript history is THREE stores (`~/.claude/projects`, `~/.omp/agent/sessions`, `~/.codex/sessions`), folded via the `claudeSessionId → Codeman id` alias map (not Claude-only despite the name). ⚠️ `resumeId` is set by a SCANNER row only, never a live session; every surface that re-projects these rows (phone overview included) must carry it through, or a tap silently starts a second conversation. → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager) -**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend reads AND edits it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. Groups are created, renamed, reordered and deleted, and rows moved between them, from the row/group menus (Shift+F10 on a header too) and by pointer drag in the grouped rail. A ref may carry `focus: true` ("Add to Focus" in the row/web-tab menu, the named `setFocus` operation): the rail then draws a SHORTCUT to it in a pinned, collapsible Focus section (`_renderTabFocus()`), its own element in `#tabRail` ABOVE `#sessionTabs`, so it shows on the flat rail too and is never a row, tree item, Alt+N slot, lineage anchor or drag target. ⚠️ Every copy of a ref (`copyRef` in tab-layout-browser.js, `parseRef`/`mapRef` in tab-layout.ts) must carry `focus` like `placement`: the browser PUTs whole layouts, so a dropped field is erased on the next save by whoever saves. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. ⚠️ Every browser write is a named operation through ONE serialized `PUT /api/tab-layout` at a time (`createEditCoordinator`): a 409 replays the operations onto the server's layout and retries (bounded), and an SSE reload is deferred while a write is in flight. Never PUT the layout from anywhere else in the frontend (the `pagehide` keepalive in `_persistPendingTabLayoutEdits` is the one deliberate exception). ⚠️ Placing a NEW session in a group rides its create request (`tabGroupId` on `POST /api/sessions` / `/api/quick-start`) into `TabLayoutService.sessionCreated()`, which registers the session inside the owner lock and places it in the same write; never place a created session with a follow-up PUT. A spawned child lands after its parent; whether it keeps FOLLOWING it until moved by hand is the synced `spawnedTabsFollowParent` setting (default OFF: pinned `manual` in the creating write), read fresh in `sessionCreated()`. ⚠️ A legacy order PUT marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`), and the browser never echoes create/delete through `PUT /api/session-order`: the server already re-projected and broadcast it. Web-tab OPENNESS stays per device. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts) +**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend reads AND edits it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. Groups are created, renamed, reordered and deleted, and rows moved between them, from the row/group menus (Shift+F10 on a header too) and by pointer drag in the grouped rail. A ref may carry `focus: true` ("Add to Focus" in the row/web-tab menu, the named `setFocus` operation): the rail then draws a SHORTCUT to it in a pinned, collapsible Focus section (`_renderTabFocus()`), its own element in `#tabRail` ABOVE `#sessionTabs`, so it shows on the flat rail too and is never a row, tree item, Alt+N slot, lineage anchor or drag target. ⚠️ Every copy of a ref (`copyRef` in tab-layout-browser.js, `parseRef`/`mapRef` in tab-layout.ts) must carry `focus` like `placement`: the browser PUTs whole layouts, so a dropped field is erased on the next save by whoever saves. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. ⚠️ Every browser write is a named operation through ONE serialized `PUT /api/tab-layout` at a time (`createEditCoordinator`): a 409 replays the operations onto the server's layout and retries (bounded), and an SSE reload is deferred while a write is in flight. Never PUT the layout from anywhere else in the frontend (the `pagehide` keepalive in `_persistPendingTabLayoutEdits` is the one deliberate exception). ⚠️ Placing a NEW session in a group rides its create request (`tabGroupId` on `POST /api/sessions` / `/api/quick-start`) into `TabLayoutService.sessionCreated()`, which registers the session inside the owner lock and places it in the same write; never place a created session with a follow-up PUT. A spawned child lands after its parent; whether it keeps FOLLOWING it until moved by hand is the synced `spawnedTabsFollowParent` setting (default OFF: pinned `manual` in the creating write), read fresh in `sessionCreated()`. A case's **default session group** (Case settings; `caseDefaultGroups` in state.json, per owner and case) is the fallback placement in that same write, below `tabGroupId` and the parent, from the create request's `caseName` (quick-start, Ralph start, or inferred lexically from a `POST /api/sessions` `workingDir`); it is read and written ONLY through `TabLayoutService`. ⚠️ A legacy order PUT marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`), and the browser never echoes create/delete through `PUT /api/session-order`: the server already re-projected and broadcast it. Web-tab OPENNESS stays per device. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts) **Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()`, gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation) @@ -307,7 +307,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/` ## State Files -All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users//cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`). +All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users//cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`). **Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored. diff --git a/docs/api-reference.md b/docs/api-reference.md index b3de823c..32774fbf 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -784,6 +784,28 @@ 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` (the read changes nothing; the next session created for the + case forgets it). +- `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, a linked case's exact path, or a Claude worktree of either, +`/.claude/worktrees/`); without a `workingDir` there is no case. 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 fea87671..f446f4a1 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -345,6 +345,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, or a Claude worktree of either, `/.claude/worktrees/`, which is how the Git worktrees modal launches Claude while every other CLI goes through quick-start), only for a named `workingDir` (never the server's own cwd), 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 an adopted layout whose groups changed (`_refreshCaseDefaultGroupOptions` in `_applyTabLayout`; an unchanged group list is skipped, since every create, delete and reorder broadcasts a layout and a rebuild would reset an open dropdown). - **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 6083b847..ae5ac504 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/docs/wiki/HTTP-API.md b/docs/wiki/HTTP-API.md index ec887e90..00d6cd93 100644 --- a/docs/wiki/HTTP-API.md +++ b/docs/wiki/HTTP-API.md @@ -72,7 +72,7 @@ Roughly 235 handlers across 26 route modules. By domain: | ------------------- | -------- | --------------------------------------------------- | | System | 56 | Status, settings, digest, updates, tunnel. | | Sessions | 34 | Create, input, terminal, wait, last response, kill. | -| Cases | 37 | Create, link, clone, worktrees, remote and docker cases. | +| Cases | 39 | Create, link, clone, worktrees, remote and docker cases. | | Files | 17 | Preview, edit, raw, attachments, path picker. | | Orchestrator | 10 | Plans and phases. | | Ralph | 9 | Loop control and configuration. | 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..a809a80a 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,21 +580,79 @@ 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?.(); + 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 { + 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 { + 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); }); } 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 d2644c11..03383854 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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)) diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index fdb5d984..2d0bbda9 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 6da662b1..f3a2b2a2 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -778,6 +778,11 @@ 1M Opus Context Use 1M token context window for new sessions + + + New sessions for this case land in this group @@ -822,6 +827,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 d3eca870..5215d54d 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -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 + //