diff --git a/CLAUDE.md b/CLAUDE.md index 4781d479..d0b3601c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -262,7 +262,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. ⚠️ 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 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) diff --git a/config/test-suites.ts b/config/test-suites.ts index a46f3002..0a36d96f 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -23,6 +23,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/tab-activation.browser.test.ts', 'test/tab-layout-editing.browser.test.ts', 'test/tab-rail-search.browser.test.ts', + 'test/tab-focus.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/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index 3c47784d..e621e14b 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -29,7 +29,7 @@ Session List Layout** can move it into a vertical sidebar on the left instead, a | -------------------- | --------------------------------------------------------------------------------- | | **Header tab strip** | The default. One list in tab order unless you pick another [Tab layout](#tab-layouts); it scrolls sideways on a phone. | | **Left sidebar** | A vertical list with a filter box and a live session count. `Alt+B` collapses it to a narrow rail that keeps the status dots and task badges visible. On a phone it is an off-canvas drawer rather than a docked rail. A detailed variant adds the home screen's per-session line (`created 3d ago · working 12m`) and a status pill. | -| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. **Tab groups:** pick *Move to new group* from a row's ⋯ menu (or Shift+F10 on it) to make the first one; a group header's menu (right-click, Shift+F10 or its ⋯ glyph) renames it (also F2), reorders or deletes it, rows move between groups from their own menu or by dragging with a mouse or pen, and a collapsed group stays collapsed on that device. **Search sessions** at the top of the rail narrows it to the tabs whose name matches (a web tab by its title), across every group, collapsed ones included, without changing the groups or the order; a tab with an alert stays visible even when its name does not match; Escape or × clears it, and it is never saved. Desktop and tablet only. | +| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. **Tab groups:** pick *Move to new group* from a row's ⋯ menu (or Shift+F10 on it) to make the first one; a group header's menu (right-click, Shift+F10 or its ⋯ glyph) renames it (also F2), reorders or deletes it, rows move between groups from their own menu or by dragging with a mouse or pen, and a collapsed group stays collapsed on that device. **Search sessions** at the top of the rail narrows it to the tabs whose name matches (a web tab by its title), across every group, collapsed ones included, without changing the groups or the order; a tab with an alert stays visible even when its name does not match; Escape or × clears it, and it is never saved. **Focus:** *Add to Focus* in a row's ⋯ menu (right-click on a web tab) pins a shortcut to that tab in a **Focus** section at the top of the rail, with or without groups; the tab itself stays where it is. Click a shortcut to switch to its tab, right-click it (or press the menu key) for *Remove from Focus*. Focus follows you across devices; whether it is collapsed is kept per device. Desktop and tablet only. | It is the same list either way, just re-hosted: tab order, drag-to-reorder, the `Alt+1` to `Alt+9` numbers and every status colour below behave identically in both. The setting is diff --git a/src/tab-layout.ts b/src/tab-layout.ts index 34cda478..cb09495e 100644 --- a/src/tab-layout.ts +++ b/src/tab-layout.ts @@ -15,6 +15,13 @@ export interface TabRef { kind: TabRefKind; id: string; placement?: 'manual'; + /** + * Pinned to the vertical rail's Focus section. The browser draws a SHORTCUT to + * the ref there; the ref itself stays in its group, so the flag never moves + * anything and nothing that moves a ref may drop it. Only `true` is stored: + * an unfocused ref carries no key at all. + */ + focus?: true; } export interface TabGroup { @@ -104,7 +111,13 @@ function parseRef(value: unknown, label: string): TabRef { if (value.placement !== undefined && value.placement !== 'manual') { throw new TabLayoutValidationError(`${label}.placement must be manual when present`); } - return value.placement === 'manual' ? { kind: value.kind, id, placement: 'manual' } : { kind: value.kind, id }; + if (value.focus !== undefined && value.focus !== true) { + throw new TabLayoutValidationError(`${label}.focus must be true when present`); + } + const ref: TabRef = { kind: value.kind, id }; + if (value.placement === 'manual') ref.placement = 'manual'; + if (value.focus === true) ref.focus = true; + return ref; } function parseTabLayout(input: unknown, repairDuplicates: boolean): TabLayout { @@ -395,6 +408,11 @@ export function setManualPlacement(input: TabLayout, target: TabRef, manual: boo return mapRef(input, target, (ref) => ({ ...ref, placement: 'manual' })); } +/** Pin `target` to the Focus section, or unpin it. Moves nothing; idempotent both ways. */ +export function setTabFocus(input: TabLayout, target: TabRef, focused: boolean): TabLayout { + return mapRef(input, target, ({ focus: _focus, ...ref }) => (focused ? { ...ref, focus: true } : ref)); +} + export function followParent(input: TabLayout, target: TabRef, metadata: readonly TabRefMetadata[]): TabLayout { const normalized = normalizeTabLayout(input, metadata); if (target.kind !== 'session') { @@ -420,7 +438,8 @@ export function followParent(input: TabLayout, target: TabRef, metadata: readonl throw new TabLayoutValidationError(`session parent is not represented: ${targetMetadata.parentSessionId}`); } - const cleared = mapRef(normalized, target, (ref) => ({ kind: ref.kind, id: ref.id })); + // Clear the manual placement only: a focused ref stays focused. + const cleared = mapRef(normalized, target, ({ placement: _placement, ...ref }) => ref); return normalizeTabLayout(cleared, metadata); } diff --git a/src/web/public/app.js b/src/web/public/app.js index 0021ec99..7ce3230d 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -6183,6 +6183,8 @@ class CodemanApp { } } this._syncTabTreeSelection(container); + // The Focus shortcut of the selected tab is marked aria-current. + this._renderTabFocus(); // #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. @@ -6674,6 +6676,10 @@ class CodemanApp { this._refreshMobileOverviewIfVisible?.(); // Same deal for the desktop home screen's tab column. this._refreshHomeSessionsIfVisible?.(); + // Focus shortcuts carry the status dot and alert of the tab they stand for. + // A no-op unless what the section would draw changed (and after a full + // rebuild above it already has). + this._renderTabFocus(); // The full-render path already redraws the connection SVG; this incremental // one does not, and a badge appearing widens a tab and shifts every tab after // it, sliding the lineage lines off their anchors. Only pay for it when there @@ -7027,6 +7033,10 @@ class CodemanApp { // Set up keyboard navigation for tabs this.setupTabKeyboardNavigation(container); + // The Focus section sits above this list in the rail, so it is drawn before + // the connection lines measure the rows. + this._renderTabFocus(); + // Update connection lines after tabs change (positions may have shifted) this.updateConnectionLines(); @@ -7593,6 +7603,9 @@ class CodemanApp { // rail (always so on the flat rail, which is every owner without groups). // Rebuild only when what the rail would draw actually changed. if (this._isTabGroupStructureStale()) this._fullRenderSessionTabs(); + // A Focus flag set on another device is not a structural change of the + // list, so it would not rebuild it: draw the section on its own. + else if (this._renderTabFocus()) this.updateConnectionLines?.(); // Edits left unsaved by the previous page (see _persistPendingTabLayoutEdits). if (next && !this._tabLayoutRestoreChecked) { this._tabLayoutRestoreChecked = true; @@ -7668,6 +7681,226 @@ class CodemanApp { return this.collapsedTabGroupIds.has(groupId) === shouldCollapse; } + // ═══════════════════════════════════════════════════════════════ + // Owner tab layout: the Focus section (pinned shortcuts) + // ═══════════════════════════════════════════════════════════════ + // + // A ref flagged `focus: true` in the layout gets a SHORTCUT in a small + // "Focus" section pinned to the top of the vertical rail; the row itself stays + // in its group. The section is its own element in #tabRail, OUTSIDE + // #sessionTabs, on purpose: it shows above the flat rail as well as the + // grouped one, and nothing that walks #sessionTabs (Alt+N badges, the tree's + // roving stop and posinset, lineage lines, drag, the row filters, the rail + // sort) ever sees a shortcut. Its own keyboard model is a list with one tab + // stop and Up/Down/Home/End between shortcuts; each shortcut is a button, so + // Enter/Space select its tab, and the context-menu key opens "Remove from + // Focus". Collapse is per-device localStorage, like a group's. + + /** True when `ref` carries `focus: true` in the held layout. */ + _tabRefFocused(ref) { + const location = this._tabRefLocation(ref); + return location?.refs[location.index]?.focus === true; + } + + /** Pin or unpin a row. One named operation through the edit coordinator. */ + setTabRefFocus(ref, focused) { + if (!this._tabRefLocation(ref)) return false; + return this.editTabLayout({ type: 'setFocus', ref: { kind: ref.kind, id: ref.id }, focused: focused === true }, `${ref.kind}:${ref.id}`); + } + + /** The row menu's Focus entry ([] where the rail cannot edit the layout). */ + _tabRefFocusActions(ref) { + if (!this._tabLayoutEditable() || !this._tabRefLocation(ref)) return []; + const focused = this._tabRefFocused(ref); + return [{ label: focused ? 'Remove from Focus' : 'Add to Focus', run: () => this.setTabRefFocus(ref, !focused) }]; + } + + _tabFocusCollapsedNow() { + if (this._tabFocusCollapsed === undefined) { + const storage = this._getTabCollapseStorage(); + const loaded = storage ? window.CodemanTabLayout.loadFocusCollapsed(storage) : { collapsed: false, ok: false }; + if (!loaded.ok) this._tabCollapseStorageFailed = true; + this._tabFocusCollapsed = loaded.collapsed; + } + return this._tabFocusCollapsed; + } + + /** Collapse/expand the Focus section. Per-device; a store failure leaves it expanded. */ + toggleTabFocusCollapsed(forceCollapsed) { + const shouldCollapse = forceCollapsed === undefined ? !this._tabFocusCollapsedNow() : forceCollapsed === true; + const storage = this._getTabCollapseStorage(); + const saved = storage ? window.CodemanTabLayout.saveFocusCollapsed(storage, shouldCollapse) : { collapsed: false, ok: false }; + if (!saved.ok) this._tabCollapseStorageFailed = true; + this._tabFocusCollapsed = saved.collapsed; + if (this._renderTabFocus()) this.updateConnectionLines?.(); + document.getElementById('tabFocusToggle')?.focus(); + return this._tabFocusCollapsed === shouldCollapse; + } + + /** The focused refs this rail draws now ([] off the vertical rail or without a layout). */ + _tabFocusRefs() { + if (!this.tabLayout || this._tabOrientation() !== 'vertical' || !window.CodemanTabLayout) return []; + return window.CodemanTabLayout.focusRefs(this.tabLayout, { + liveSessionIds: this.sessionOrder.filter((id) => this.sessions.has(id)), + openWebviewIds: (this.webviewOrder || []).filter((id) => this.webviews?.has(id)), + }); + } + + /** The Focus section element, created once inside #tabRail just above the list. */ + _tabFocusHost() { + let host = document.getElementById('tabFocus'); + if (host) return host; + const rail = document.getElementById('tabRail'); + if (!rail) return null; + host = document.createElement('section'); + host.id = 'tabFocus'; + host.className = 'tab-focus'; + host.hidden = true; + host.setAttribute('aria-labelledby', 'tabFocusTitle'); + // Delegated, so the listeners survive every repaint of the section. + host.addEventListener('keydown', (event) => this._handleTabFocusKeydown(event)); + host.addEventListener('click', (event) => { + if (event.target?.closest?.('#tabFocusToggle')) { + this.toggleTabFocusCollapsed(); + return; + } + const item = event.target?.closest?.('.tab-focus-item'); + if (item) this.openTabFocusShortcut(event, item.dataset.focusKey); + }); + host.addEventListener('contextmenu', (event) => { + const item = event.target?.closest?.('.tab-focus-item'); + if (item) this.openTabFocusMenu(event, item.dataset.focusKey, item); + }); + const list = this.$('sessionTabs'); + if (list && list.parentElement === rail) rail.insertBefore(host, list); + else rail.appendChild(host); + return host; + } + + /** + * Draw the Focus section from the held layout. Cheap enough for every render + * pass: the markup is compared with what was drawn last, and the DOM is only + * touched when it changed. Returns true when it did (the rail's list moved, + * so connector lines anchored to its rows need a redraw). + */ + _renderTabFocus() { + const refs = this._tabFocusRefs(); + const host = refs.length ? this._tabFocusHost() : document.getElementById('tabFocus'); + if (!host) return false; + if (!refs.length) { + if (host.hidden && !host.firstChild) return false; + host.hidden = true; + host.replaceChildren(); + this._lastTabFocusHtml = ''; + return true; + } + // Keep the rail's list just below the section: applyTabOrientation() + // re-appends #sessionTabs to the rail when the orientation flips. + const list = this.$('sessionTabs'); + const rail = host.parentElement; + if (list && rail && list.parentElement === rail && host.nextElementSibling !== list) rail.insertBefore(host, list); + const collapsed = this._tabFocusCollapsedNow(); + const activeKey = this.activeWebviewId + ? `webview:${this.activeWebviewId}` + : this.activeSessionId + ? `session:${this.activeSessionId}` + : ''; + const focusedEl = host.contains(document.activeElement) ? document.activeElement : null; + const focusedKey = focusedEl?.dataset?.focusKey || (focusedEl?.id === 'tabFocusToggle' ? 'toggle' : null); + const keys = refs.map((ref) => `${ref.kind}:${ref.id}`); + // One tab stop in the list: the shortcut that had it, else the selected + // tab's, else the first. + const stopKey = [focusedKey, this._tabFocusStopKey, activeKey].find((key) => key && keys.includes(key)) || keys[0]; + const items = refs + .map((ref) => { + const key = `${ref.kind}:${ref.id}`; + let name; + let statusHtml; + let alertClass = ''; + if (ref.kind === 'session') { + const session = this.sessions.get(ref.id); + name = this.getSessionName(session); + statusHtml = ``; + const alert = this.tabAlerts?.get(ref.id); + alertClass = alert === 'action' ? ' tab-alert-action' : alert === 'idle' ? ' tab-alert-idle' : ''; + } else { + const webview = this.webviews.get(ref.id); + name = webview?.name || ref.id; + statusHtml = ``; + } + // The name is user text: the whole button is kept away from the + // translator (a session named "3 sessions" must not come out in Chinese). + return ( + `
  • ` + ); + }) + .join(''); + const html = + `` + + ``; + if (!host.hidden && this._lastTabFocusHtml === html) return false; + host.classList.toggle('tab-focus--collapsed', collapsed); + host.innerHTML = html; + host.hidden = false; + this._lastTabFocusHtml = html; + if (focusedKey === 'toggle') document.getElementById('tabFocusToggle')?.focus(); + else if (focusedKey) host.querySelector(`[data-focus-key="${CSS.escape(focusedKey)}"]`)?.focus(); + return true; + } + + _tabFocusRefFromKey(key) { + const at = typeof key === 'string' ? key.indexOf(':') : -1; + if (at < 0) return null; + const kind = key.slice(0, at); + return kind === 'session' || kind === 'webview' ? { kind, id: key.slice(at + 1) } : null; + } + + /** A shortcut was clicked: select its tab, exactly as the row would. */ + openTabFocusShortcut(event, key) { + const ref = this._tabFocusRefFromKey(key); + if (!ref) return undefined; + this._tabFocusStopKey = key; + if (ref.kind === 'webview') return this.handleWebviewTabClick?.(event, ref.id); + return this.handleSessionTabClick(event, ref.id); + } + + /** Right-click, Shift+F10 or the menu key on a shortcut (`trigger` anchors the menu). */ + openTabFocusMenu(event, key, trigger) { + const ref = this._tabFocusRefFromKey(key); + if (!ref) return false; + this._tabFocusStopKey = key; + // A delegated listener's currentTarget is the section; anchor on the shortcut. + const anchored = trigger + ? { preventDefault: () => event?.preventDefault?.(), stopPropagation: () => event?.stopPropagation?.(), currentTarget: trigger, type: event?.type, clientX: event?.clientX } + : event; + return this._openTabLayoutMenu(anchored, `focus:${key}`, 'Focus actions', [ + { label: 'Remove from Focus', run: () => this.setTabRefFocus(ref, false) }, + ]); + } + + /** Up/Down/Home/End move between shortcuts; the list keeps one tab stop. */ + _handleTabFocusKeydown(event) { + const current = event.target?.closest?.('.tab-focus-item'); + if (!current || !['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return; + const items = [...event.currentTarget.querySelectorAll('.tab-focus-item')]; + const index = items.indexOf(current); + const next = + event.key === 'Home' + ? items[0] + : event.key === 'End' + ? items[items.length - 1] + : items[(index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length]; + if (!next) return; + event.preventDefault(); + for (const item of items) item.tabIndex = item === next ? 0 : -1; + this._tabFocusStopKey = next.dataset.focusKey; + next.focus(); + } + // ═══════════════════════════════════════════════════════════════ // Owner tab layout: editing groups from the vertical rail // ═══════════════════════════════════════════════════════════════ @@ -7931,7 +8164,7 @@ class CodemanApp { // live tree item by identity. const rail = this.$('sessionTabs'); const item = - (trigger?.isConnected && trigger.closest('[role="treeitem"]')) || + (trigger?.isConnected && (trigger.closest('[role="treeitem"]') || trigger.closest('.tab-focus-item'))) || [...(rail?.querySelectorAll('[role="treeitem"]') || [])].find((el) => this._tabTreeIdentity(el) === identity); item?.focus(); } @@ -7958,7 +8191,8 @@ class CodemanApp { /** Keyboard actions for a web-tab row in the vertical rail: its settings plus group moves. */ openTabWebviewMenu(event, webviewId) { - const moves = this._tabRefMoveActions({ kind: 'webview', id: webviewId }); + const ref = { kind: 'webview', id: webviewId }; + const moves = [...this._tabRefFocusActions(ref), ...this._tabRefMoveActions(ref)]; if (!moves.length) { this.showWebviewModal?.(webviewId); return false; diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 36421444..07a55d5f 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -98,6 +98,11 @@ 'Follow parent again': '重新跟随父会话', 'Parent closed; placed on its own': '父会话已关闭;单独放置', 'Parent loop; placed on its own': '父会话形成循环;单独放置', + // The Focus section pinned at the top of the vertical rail (shortcuts to tabs). + Focus: '焦点', + 'Add to Focus': '添加到焦点', + 'Remove from Focus': '从焦点移除', + 'Focus actions': '焦点操作', 'Could not save tab groups.': '无法保存标签分组。', 'Tab groups changed elsewhere; part of your edit no longer applies.': '标签分组已在别处更改;你的部分编辑已不再适用。', diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 5e7d5477..e6283e0d 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -828,6 +828,141 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tabs--grouped .tab-layo user-select: none; } +/* --- Focus section (pinned shortcuts, app.js _renderTabFocus) ---------- */ +/* Its own element above #sessionTabs in the rail, so it shows above the flat + rail and the grouped one alike and never takes part in the list's order, + tree or Alt+N numbering. Capped so a long Focus never starves the list. */ +.tab-focus { + display: none; +} + +html[data-tab-orientation='vertical'] .tab-rail .tab-focus:not([hidden]) { + display: flex; + flex: 0 0 auto; + flex-direction: column; + gap: 2px; + max-height: 40%; + min-height: 0; + padding: 0.35rem 0.35rem 0.3rem calc(0.35rem + 24px); + border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent); +} + +html[data-tab-orientation='vertical'] .tab-rail .tab-focus-toggle { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + min-width: 0; + padding: 4px 6px 2px; + border: 0; + border-radius: 4px; + color: var(--text); + background: transparent; + font: inherit; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-align: left; + cursor: pointer; + user-select: none; +} + +html[data-tab-orientation='vertical'] .tab-rail .tab-focus-toggle:hover { + background: var(--bg-hover); +} + +html[data-tab-orientation='vertical'] .tab-rail :is(.tab-focus-toggle, .tab-focus-item):focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.tab-focus-title { + flex: 1 1 auto; + min-width: 0; +} + +.tab-focus-count { + flex: 0 0 auto; + font-variant-numeric: tabular-nums; + opacity: 0.75; +} + +.tab-focus--collapsed .tab-layout-group-chevron::before { + content: '\25B8'; +} + +html[data-tab-orientation='vertical'] .tab-rail .tab-focus-list { + display: flex; + flex-direction: column; + gap: 2px; + min-height: 0; + margin: 0; + padding: 0; + overflow-y: auto; + list-style: none; +} + +html[data-tab-orientation='vertical'] .tab-rail .tab-focus-list[hidden] { + display: none; +} + +html[data-tab-orientation='vertical'] .tab-rail .tab-focus-item { + position: relative; + display: flex; + align-items: center; + gap: 6px; + width: 100%; + min-width: 0; + padding: 3px 8px; + border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent); + border-radius: 4px; + color: var(--text-dim, var(--text-muted)); + background: transparent; + font: inherit; + font-size: 12px; + text-align: left; + cursor: pointer; +} + +html[data-tab-orientation='vertical'] .tab-rail .tab-focus-item:hover { + color: var(--text); + background: var(--bg-hover); +} + +html[data-tab-orientation='vertical'] .tab-rail .tab-focus-item[aria-current='true'] { + color: var(--text); + background: color-mix(in srgb, var(--accent) 12%, transparent); + border-color: color-mix(in srgb, var(--accent) 55%, transparent); +} + +.tab-focus-name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* The tab's own status dot and alert colour, so a shortcut says what its tab + says (the row keeps its full alert ring; a shortcut only recolours the dot). */ +.tab-focus-item .tab-status { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; + background: var(--green); +} +.tab-focus-item .tab-status.error { background: var(--red); } +.tab-focus-item .tab-status.ended { background: var(--text-muted); opacity: 0.5; } +.tab-focus-item.tab-alert-action .tab-status { background: var(--red); box-shadow: 0 0 6px rgba(239, 68, 68, 0.7); } +.tab-focus-item.tab-alert-idle .tab-status { background: var(--yellow); box-shadow: 0 0 6px rgba(234, 179, 8, 0.6); } +.tab-focus-item .tab-web-icon { + display: inline-flex; + align-items: center; + flex-shrink: 0; + color: var(--text-dim, var(--text-muted)); +} + html[data-tab-orientation='vertical'] .tab-rail .tab-layout-drag-active { cursor: grabbing; } diff --git a/src/web/public/tab-layout-browser.js b/src/web/public/tab-layout-browser.js index 8f4dc9ca..39be5686 100644 --- a/src/web/public/tab-layout-browser.js +++ b/src/web/public/tab-layout-browser.js @@ -13,7 +13,7 @@ * 3. Load sequencing: concurrent layout reads settle newest-wins, and a failed * read degrades to the flat rail with a capped, backed-off retry. * 4. Editing: named operations (create/rename/delete/reorder a group, move a - * row) applied optimistically and saved through ONE serialized + * row, pin a row to Focus) applied optimistically and saved through ONE serialized * `PUT /api/tab-layout` at a time, rebased onto the server's layout on a * version conflict. * @@ -32,17 +32,26 @@ 'use strict'; const COLLAPSED_STORAGE_KEY = 'codeman:tab-groups-collapsed'; + // The Focus section's collapse, per device. Its own key, not an entry in the + // group list above: that list is garbage-collected against the layout's group + // ids, and Focus is not a group. + const FOCUS_COLLAPSED_STORAGE_KEY = 'codeman:tab-focus-collapsed'; const refKey = (ref) => `${ref.kind}:${ref.id}`; const validRef = (ref) => !!ref && (ref.kind === 'session' || ref.kind === 'webview') && typeof ref.id === 'string' && ref.id.length > 0; const asIds = (value) => (Array.isArray(value) ? value.filter((id) => typeof id === 'string' && id) : []); const stableIds = (value) => [...new Set(asIds(value))]; - // `placement: 'manual'` must survive the round trip: the browser writes whole - // layouts back, and dropping it would re-attach a hand-placed child session to - // its parent's subtree on the next save. - const copyRef = (r) => - r.placement === 'manual' ? { kind: r.kind, id: r.id, placement: 'manual' } : { kind: r.kind, id: r.id }; + // `placement: 'manual'` and `focus: true` must survive the round trip: the + // browser writes whole layouts back, so a field dropped here is erased on the + // next save, by whoever saves. Losing placement re-attaches a hand-placed child + // session to its parent's subtree; losing focus unpins every Focus shortcut. + const copyRef = (r) => { + const copy = { kind: r.kind, id: r.id }; + if (r.placement === 'manual') copy.placement = 'manual'; + if (r.focus === true) copy.focus = true; + return copy; + }; const copyRefs = (value) => (Array.isArray(value) ? value.filter(validRef).map(copyRef) : []); /** Server limits (src/tab-layout.ts), mirrored so a bad edit fails before the PUT. */ @@ -108,6 +117,52 @@ } } + /** + * The Focus section's per-device collapse. `ok: false` means the store threw; + * any value other than 'true' reads as expanded. + */ + function loadFocusCollapsed(storage) { + try { + return { collapsed: storage.getItem(FOCUS_COLLAPSED_STORAGE_KEY) === 'true', ok: true }; + } catch (_error) { + return { collapsed: false, ok: false }; + } + } + + function saveFocusCollapsed(storage, collapsed) { + try { + storage.setItem(FOCUS_COLLAPSED_STORAGE_KEY, collapsed === true ? 'true' : 'false'); + return { collapsed: collapsed === true, ok: true }; + } catch (_error) { + return { collapsed: false, ok: false }; + } + } + + /** + * The refs pinned to the Focus section that this browser can draw: focused, + * a live session or an open web tab, each once, in stored order (groups in + * order, then Ungrouped). Works with or without named groups, so Focus also + * shows above the flat rail. A ref whose session closed or whose web tab is + * not open here simply drops out; the server prunes deleted refs on its own. + */ + function focusRefs(layoutInput, options = {}) { + if (!layoutInput) return []; + const layout = normalizeLayout(layoutInput); + const live = new Set(asIds(options.liveSessionIds)); + const open = new Set(asIds(options.openWebviewIds)); + const seen = new Set(); + const result = []; + for (const ref of [...layout.groups.flatMap((group) => group.refs), ...layout.ungrouped]) { + if (ref.focus !== true) continue; + if (!(ref.kind === 'session' ? live.has(ref.id) : open.has(ref.id))) continue; + const key = refKey(ref); + if (seen.has(key)) continue; + seen.add(key); + result.push({ kind: ref.kind, id: ref.id }); + } + return result; + } + function saveCollapsedGroupIds(storage, groupIds) { const ids = stableIds(groupIds); try { @@ -137,11 +192,13 @@ * parent, so a spawned session appears in its parent's group at once instead * of jumping there from Ungrouped once the layout re-read lands. * - * @returns {null | { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef }} + * @returns {null | { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef, focusRefs }} * null when the layout has no groups: the caller renders the flat rail * unchanged. Each section lists the rows it shows (`refs`) and the rows its * collapse hides (`hidden`); `sectionByRef` maps every placed row * (`:`) to its section id (null = Ungrouped), shown or hidden. + * `focusRefs` is focusRefs() for the same options: shortcuts, never rows, + * so they are in none of the other fields. */ function project(layoutInput, options = {}) { if (!layoutInput) return null; @@ -207,7 +264,7 @@ collapsed: false, }); } - return { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef }; + return { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef, focusRefs: focusRefs(layout, options) }; } const ALERT_RANK = { action: 2, idle: 1 }; @@ -603,6 +660,17 @@ layout.groups.splice(clampIndex(op.index, layout.groups.length), 0, moved); return layout; } + case 'setFocus': { + // Pin or unpin one row. It never moves: Focus draws a shortcut, and the + // row stays in its group. Idempotent, so a replay after a 409 is safe. + if (!validRef(op.ref)) editError('invalid row'); + if (typeof op.focused !== 'boolean') editError('invalid Focus state'); + const location = refLocations(layout).find((item) => refKey(item.ref) === refKey(op.ref)); + if (!location) editError('unknown row'); + if (op.focused) location.ref.focus = true; + else delete location.ref.focus; + return layout; + } case 'moveRef': { if (!validRef(op.ref)) editError('invalid row'); const targetKey = refKey(op.ref); @@ -853,6 +921,9 @@ createLoadCoordinator, loadCollapsedGroupIds, saveCollapsedGroupIds, + focusRefs, + loadFocusCollapsed, + saveFocusCollapsed, MAX_GROUPS, }; })(typeof window !== 'undefined' ? window : globalThis); diff --git a/src/web/public/tab-rail-resize.js b/src/web/public/tab-rail-resize.js index 871d5850..4f305a59 100644 --- a/src/web/public/tab-rail-resize.js +++ b/src/web/public/tab-rail-resize.js @@ -327,7 +327,8 @@ Object.assign(CodemanApp.prototype, { const settings = this.loadAppSettingsFromStorage(); const actions = [ { label: 'Session options', run: () => this.openSessionOptions(sessionId) }, - // Group placement (vertical rail with a tab layout only; [] elsewhere). + // Focus and group placement (vertical rail with a tab layout only; [] elsewhere). + ...(this._tabRefFocusActions?.({ kind: 'session', id: sessionId }) || []), ...(this._tabRefMoveActions?.({ kind: 'session', id: sessionId }) || []), ...((this.tabDetachButtonEnabled?.(settings) ?? settings.showTabDetachButton) || this.detachedSessions?.has(sessionId) diff --git a/src/web/public/webview-tabs.js b/src/web/public/webview-tabs.js index 7c3876f6..173f76f8 100644 --- a/src/web/public/webview-tabs.js +++ b/src/web/public/webview-tabs.js @@ -325,6 +325,7 @@ Object.assign(CodemanApp.prototype, { return `