diff --git a/CLAUDE.md b/CLAUDE.md index 539f9b32..236b2740 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -254,7 +254,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. BACKEND ONLY: no frontend calls these routes yet. ⚠️ `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 only READS 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. ⚠️ 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`. No frontend WRITES the layout yet. ⚠️ `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) @@ -317,7 +317,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ### Frontend -Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. +Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `tab-layout-browser.js`(5.9) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. **Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on ``; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations) diff --git a/config/test-suites.ts b/config/test-suites.ts index 9f3ed295..35693e24 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -20,6 +20,7 @@ */ export const BROWSER_TEST_GLOBS = [ 'test/tab-rail-resize.browser.test.ts', + 'test/tab-activation.browser.test.ts', 'test/session-sidebar-ux.browser.test.ts', 'test/session-options-responsive.browser.test.ts', 'test/inline-rename.test.ts', diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 848ae8f1..9765c6a8 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -296,7 +296,16 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t **Owner tab layouts** (COD-359, `tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat tab strip, scoped per owner (`SINGLE_USER_LAYOUT_OWNER` = `@single` when multi-user is off), persisted under the `tabLayouts` key in state.json. The pure model is `tab-layout.ts`, `tab-layout-service.ts` is the sole mutation boundary, plus `tab-layout-persistence.ts` and `tab-layout-legacy-order.ts`. A layout is `{version, groups[], ungrouped[], updatedAt}` whose refs point at either a session or a saved webview (`TabRefKind`), capped at 32 groups / 512 refs. -⚠️ **BACKEND ONLY as of 1.24.1**: nothing in `src/web/public/` calls these routes yet, so a UI built on top is new frontend work, not a rewiring job. +⚠️ **The frontend READS the layout; nothing writes it yet.** `tab-layout-browser.js` (pure, loaded before app.js) projects it onto what is live in the page, and app.js's grouped-rail block draws the VERTICAL rail as collapsible group sections. The rules that keep it safe: + +- **Grouped iff vertical AND the owner has at least one group.** No layout, a failed `GET` (retried, newest-wins via `createLoadCoordinator`) or zero groups renders the flat rail unchanged; the horizontal strip, phones and the sidebar never group. +- **A render layer, never an order source.** `sessionOrder` (the server-projected global order), Alt+N, Ctrl+Tab and the palette are untouched; a grouped session row is the flat row's markup, so its badge still names its Alt+N slot. Web tabs keep their slot after every session wherever their group puts them (`renderWebviewTab`). +- **Collapse is per-device** (`codeman:tab-groups-collapsed` in localStorage, ids of deleted groups garbage-collected on adoption, any storage failure means all-expanded). A collapsed group still SHOWS the active row, and `_updateActiveTabImmediate` falls through to a full render whenever the structure key changes, since a class toggle cannot reveal a hidden row. +- **Lineage arcs to a collapse-hidden session anchor to its group header** (`lineage-line--proxied`); two endpoints proxied to one header draw nothing. +- **Drag-reorder is off in the grouped rail** until grouped editing lands: a flat-order drop cannot express a group move, and the server re-ranks within the old group. +- **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, 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. + ⚠️ **`TabLayoutService` is the single mutation boundary** and every lifecycle caller (session created/removed, webview created/deleted, a legacy order PUT) describes ONE completed server action and gets AT MOST ONE versioned write; writing layout state from a route or a manager directly is what the service exists to prevent. diff --git a/scripts/build.mjs b/scripts/build.mjs index 2ab8ecd5..edebdedd 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -86,6 +86,7 @@ run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify -- run('minify terminal-keycode229-recovery.js', 'npx esbuild dist/web/public/terminal-keycode229-recovery.js --minify --outfile=dist/web/public/terminal-keycode229-recovery.js --allow-overwrite'); run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite'); run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite'); +run('minify tab-layout-browser.js', 'npx esbuild dist/web/public/tab-layout-browser.js --minify --outfile=dist/web/public/tab-layout-browser.js --allow-overwrite'); run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite'); run('minify tab-rail-resize.js', 'npx esbuild dist/web/public/tab-rail-resize.js --minify --outfile=dist/web/public/tab-rail-resize.js --allow-overwrite'); run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite'); @@ -113,6 +114,7 @@ console.log('\n[build] content-hash cache busting'); 'input-cjk.js', 'terminal-keycode229-recovery.js', 'sanitize-html.js', + 'tab-layout-browser.js', 'app.js', 'tab-rail-resize.js', 'terminal-ui.js', diff --git a/src/web/public/app.js b/src/web/public/app.js index 56997f21..6b295797 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -319,6 +319,8 @@ const _SSE_HANDLER_MAP = [ // Session order (global tab order sync, COD-131) [SSE_EVENTS.SESSION_ORDER_CHANGED, '_onSessionOrderChanged'], + // Owner tab layout (grouped vertical rail) + [SSE_EVENTS.TAB_LAYOUT_CHANGED, '_onTabLayoutChanged'], // Web tabs (dashboard URLs) [SSE_EVENTS.WEBVIEW_CHANGED, '_onWebviewChanged'], @@ -573,6 +575,12 @@ class CodemanApp { this._shortIdCache = new Map(); // Cache session ID .slice(0, 8) results this.sessionOrder = []; // Track tab order for drag-and-drop reordering this.draggedTabId = null; // Currently dragged tab session ID + // Owner tab layout (GET /api/tab-layout), read-only here: it only changes how + // the vertical rail GROUPS rows. sessionOrder above stays the tab order. + this.tabLayout = null; + this.collapsedTabGroupIds = new Set(); // per-device, localStorage-backed + this._hiddenTabGroupByRef = new Map(); // 'session:' -> collapsed group id + this._lastTabGroupStructureKey = null; this.cases = []; this.currentRun = null; this.totalTokens = 0; @@ -4266,6 +4274,10 @@ class CodemanApp { // Sync sessionOrder with current sessions (preserve order, add new, remove stale) this.syncSessionOrder(); + // (Re)read the owner tab layout on every init, including SSE reconnects: a + // tab:layoutChanged sent while this client was disconnected is never replayed. + this._loadTabLayout(); + if (data.respawnStatus) { this.respawnStatus = data.respawnStatus; } else { @@ -5078,6 +5090,12 @@ class CodemanApp { _updateActiveTabImmediate(sessionId) { const container = this.$('sessionTabs'); if (!container) return; + // Grouped rail: selecting a session hidden in a collapsed group must show it + // (and re-hide the previous exception), which a class toggle cannot do. + if (this._isTabGroupStructureStale()) { + this._fullRenderSessionTabs(); + return; + } const tabs = container.querySelectorAll('.session-tab[data-id]'); for (const tab of tabs) { if (tab.dataset.id === sessionId) { @@ -5086,6 +5104,7 @@ class CodemanApp { tab.classList.remove('active'); } } + this._syncTabTreeSelection(container); // #257: selection used to stop at the class toggle. On phones/tablets the // strip scrolls horizontally, so a tab selected from the palette, a swipe, // Alt+N or a push notification could stay parked off-screen. @@ -5237,7 +5256,12 @@ class CodemanApp { const container = this.$('sessionTabs'); const existingTabs = container.querySelectorAll('.session-tab[data-id]'); const existingIds = new Set([...existingTabs].map(t => t.dataset.id)); - const currentIds = new Set(this.sessions.keys()); + // Grouped rail: a collapsed group keeps its rows out of the DOM, so compare + // against the rows the projection SHOWS, not every live session. + const groupProjection = this._projectTabGroups(); + const currentIds = groupProjection + ? new Set(groupProjection.visibleRefs.filter((ref) => ref.kind === 'session').map((ref) => ref.id)) + : new Set(this.sessions.keys()); // Web tabs live in the same strip but are not in this.sessions, so they need // their own change check. Without it, the session-only comparison below is @@ -5246,14 +5270,20 @@ class CodemanApp { const existingWebIds = [...container.querySelectorAll('.session-tab[data-webview-id]')].map( t => t.dataset.webviewId ); - const wantedWebIds = (this.webviewOrder || []).filter(id => this.webviews?.has(id)); + const wantedWebIds = groupProjection + ? groupProjection.visibleRefs.filter((ref) => ref.kind === 'webview').map((ref) => ref.id) + : (this.webviewOrder || []).filter(id => this.webviews?.has(id)); const webTabsUnchanged = existingWebIds.length === wantedWebIds.length && existingWebIds.every((id, i) => id === wantedWebIds[i]); // Check if we can do incremental update (same session IDs and same web tabs) + // The grouped rail's structure (sections, collapse, the shown exception) can + // change while the id sets stay equal; the in-place patch below cannot move + // or hide a row, so any structural change takes the full rebuild. const canIncremental = existingIds.size === currentIds.size && [...existingIds].every(id => currentIds.has(id)) && - webTabsUnchanged; + webTabsUnchanged && + !this._isTabGroupStructureStale(groupProjection); if (canIncremental) { // Read once for the whole pass, like the full-rebuild path: this touches @@ -5571,6 +5601,12 @@ class CodemanApp { const prevScrollTop = container.scrollTop; const prevActiveTabId = this._lastRenderedActiveTabId; const isFirstRender = !container.querySelector('.session-tab'); + // The rebuild below destroys the focused row. In the grouped tree, put focus + // back on the same item (by identity) so a background render or a keyboard + // collapse does not drop a keyboard user to . + const focusWasInside = container.contains(document.activeElement); + const focusIdentity = this._tabFocusIdentity || (focusWasInside ? this._tabTreeIdentity(document.activeElement) : null); + this._tabFocusIdentity = null; // Build tabs HTML using array for better string concatenation performance. // Iterate in sessionOrder to respect the user's custom tab arrangement, on @@ -5591,6 +5627,10 @@ class CodemanApp { // layout, and the tabs then carry no inline order at all — the header // strip's markup is byte-identical to before. const railSortOrder = this._tabRailSortOrder(tabOrder.filter((id) => this.sessions.has(id))); + // One row per session, in tab order. The flat strip emits them as-is; the + // grouped rail places the SAME markup into its sections, so a row never + // differs between the two (badge = Alt+N slot in sessionOrder either way). + const rowHtml = new Map(); let _tabIdx = 0; for (const id of tabOrder) { const session = this.sessions.get(id); @@ -5654,7 +5694,7 @@ class CodemanApp { const inlineSessionActions = this.shouldInlineSessionActions(); const tabActionsHtml = `⚙⧉×`; - parts.push(`