mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
fix(tabs): review-driven hardening for the tab-layout foundation and vertical rail
Post-merge follow-ups from the deep review of #334 and #335, so they ship in the same release as the features. Tab-layout foundation (#335): - PUT /api/session-order drops unknown/foreign ids again instead of 400ing the whole write, in both the owner and the admin path (single-user requests are the synthetic admin, so that path is the one the browser hits). The frontend debounces its reorder push and swallows errors, so a session deleted inside the debounce window silently cost the user the entire reorder - and the endpoint sits on the stable /api/v1 surface, where the pre-layout server merged leniently. - A failed mux restore no longer locks explicit deletions into 500s for the process lifetime: runSessionDeletion and webviewDeleted degrade to best-effort without layout coordination, while the automated stale sweep (runStaleSessionCleanup) stays fail-closed. - sse-events doc comment: no 'suppressed' hook event exists; hooks stay 8. - registerSessionWithLayout resolves its owner through ownerLayoutKey() instead of a hardcoded '@single'. Vertical rail (#334) - all rail-awareness gaps in sidebar-only predicates, unified behind the new _isVerticalTabList() (sidebar OR rail): - Drag-reorder read the insertion side from clientX in the rail, so before/after was effectively arbitrary on vertical rows; the drag-over indicators now draw as top/bottom edges there like the sidebar's. - The active tab is scrolled into view in the rail (Alt+N/palette selection used to leave the row below the fold). - Floating subagent/ultracode windows anchor to the RIGHT of rail tabs, and the connector redraw gates (render tail + strip scroll) cover the rail. - Server-seeded tabOrientation is applied when the async settings load resolves, not only at boot, so a fresh device shows the rail immediately. - The pre-paint script stamps data-tab-orientation and --tab-rail-width (sidebar-wins and solo carve-outs included), removing the flash of the header strip on every vertical-mode load. - The session name font defaults to 12px, the sidebar's historical 0.75rem size, so installs that never touch the new slider are not restyled. Also documents the rail in CLAUDE.md (second #sessionTabs host, mover ordering, the axis-predicate rule) and gives tab-rail-resize.js its @dependency/@loadorder header. Full gate green (6093 tests); the excluded browser suite was run by hand - only the known environmental failures (opencode/codex binaries) remain, identical to pristine master. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -264,13 +264,13 @@ 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) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `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) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `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).
|
||||
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) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `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) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `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).
|
||||
|
||||
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. ⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart. ⚠️ The terminal-pane styles may animate **transform / opacity / clip-path only**, xterm's FitAddon derives rows+cols from `getComputedStyle(parent).width/height`, so animating width/height/padding there would resize the PTY. ⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings → Appearance, full per-surface lab at `?animlab=1`.
|
||||
|
||||
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip is a horizontal scroller (desktop wraps to a second row instead), so the active tab can sit off-screen. Three rules keep it reachable and they only work together: `_updateActiveTabImmediate()` scrolls the selected tab into view via `computeTabScrollLeft()` (pure, in constants.js) using **rect math on the strip's own `scrollLeft`**, never `scrollIntoView()`, which would also scroll the document under a fixed header; `_fullRenderSessionTabs()` **restores `scrollLeft`** across the `innerHTML` rebuild, since ambient rebuilds (a task badge appearing, a session created elsewhere) otherwise snap a mid-swipe strip back to 0; and it re-reveals the active tab **only when it changed** (`_lastRenderedActiveTabId`), so browsing the far end of the strip is not undone by background renders. ⚠️ **The ACTIVE tab is the only one with action icons, and on a phone they can eat it**: `.session-tab.active .tab-name` reserves `min-width: 44px` in the ≤430px block, because a short session name rendered a 13px label against a 50px gear+close cluster, putting the tab's geometric CENTRE on the gear, so a thumb aiming at the tab opened Session Options instead of switching (measured at 360/393/430px; only long names cleared it). ⚠️ **The floor is set by the 10th tab onward, not by the tabs you can see**: `.tab-number` renders only for `_tabIdx < 9`, so tab 10 loses 16px + a gap off its left and its centre sits 10px further right. The centre clears the icons when `reserved > icons + rightEdge - leftRunUp - gap` (= 50 + 9 - 17 - 4 = **38px**), hit-testing snaps to whole pixels so 39px still lands on the gear, and the practical floor is 40px — a NUMBERED tab clears it at 20px, which is exactly why reasoning from the tabs on screen would put the centre back on the gear. `test/mobile-tab-tap-zones.test.ts` recomputes that inequality from the stylesheet, so widening the gear or the padding fails there rather than on a phone. The guarantee is centre-off-the-ICONS, not centre-inside-the-label (on a numberless tab it lands in the gap between them, which still switches). Non-active tabs keep their icons hidden and stay tappable end to end. ⚠️ Mobile no longer hoists the active session to the front of the strip: that reordering ran on full renders only, so tab order flipped depending on which render path fired, and it renumbered the Alt+N badges. Scroll-into-view replaces it; do not reintroduce it.
|
||||
|
||||
**Session list layout: header strip or left sidebar** (`sessionListLayout`, App Settings → Appearance → Tabs, default `header`; per-device policy — it IS in `SettingsUpdateSchema` and persists server-side, but `displayKeys` makes a device keep its own value): with many sessions the horizontal strip stops being scannable, so the list can move into a vertical `<aside>` with a filter box and a live count, collapsible to a 44px rail (`--sidebar-width` 260 / `--sidebar-width-collapsed` 44) via **Alt+B** (`toggleSessionSidebar`; Alt, not Ctrl+B, which must reach tmux/readline in the terminal). ⚠️ **There is ONE `#sessionTabs` element and it is MOVED between two hosts** (`#sessionTabsHost` in the header, `#sessionSidebarList` in the aside), never a second list — so every render path, drag-reorder handler and Alt+N index keeps working unchanged, and `applySessionListLayout()` is the only thing that reparents it. ⚠️ It sets `data-session-list` / `data-sidebar` on `<html>` and must run BEFORE `applyTabWrapSettings()`, which is the one owner of `tabs-two-rows`/`tabs-show-folder` and reads those attributes. ⚠️ Leaving sidebar mode **clears `_sidebarFilter`**: the filter box only exists in the aside, so a stale filter would hide sessions from the header strip with no reachable control to clear it. ⚠️ On handhelds the aside is an off-canvas overlay rather than a docked rail, and a closed drawer keeps `display: flex`, so it is marked `inert` + `aria-hidden` (`_isSessionSidebarOverlay()`) or its filter box and ~4 tab stops per session stay in the tab order; the DOCKED desktop rail must never be inerted, its rows are still clickable. The desktop home rail (`home-sessions.js`) defers to it, since both dock the session list flush left.
|
||||
**Session list layout: header strip or left sidebar** (`sessionListLayout`, App Settings → Appearance → Tabs, default `header`; per-device policy — it IS in `SettingsUpdateSchema` and persists server-side, but `displayKeys` makes a device keep its own value): with many sessions the horizontal strip stops being scannable, so the list can move into a vertical `<aside>` with a filter box and a live count, collapsible to a 44px rail (`--sidebar-width` 260 / `--sidebar-width-collapsed` 44) via **Alt+B** (`toggleSessionSidebar`; Alt, not Ctrl+B, which must reach tmux/readline in the terminal). ⚠️ **There is ONE `#sessionTabs` element and it is MOVED between hosts** (`#sessionTabsHost` in the header, `#sessionSidebarList` in the aside, `#tabRail` for the vertical rail below), never a second list — so every render path, drag-reorder handler and Alt+N index keeps working unchanged. Exactly TWO functions reparent it and they must run in this order: `applySessionListLayout()` first (sidebar wins), then `applyTabOrientation()` (settings-ui.js), which moves the tabs into `#tabRail` only when the sidebar does not own them. ⚠️ It sets `data-session-list` / `data-sidebar` on `<html>` and must run BEFORE `applyTabWrapSettings()`, which is the one owner of `tabs-two-rows`/`tabs-show-folder` and reads those attributes. ⚠️ **The vertical tab rail** (`tabOrientation`/`tabRailWidth`/`sessionSidebarFontSize`, all per-device display keys that ARE in the schema, like `sessionListLayout`) is a SECOND vertical list next to the sidebar: the orientation setting is silently ignored while the sidebar layout is chosen, desktop/tablet only (`resolveTabOrientation` forces horizontal on mobile), resizable via `tab-rail-resize.js` (which owns terminal refits during the drag). ⚠️ **Axis decisions must use `_isVerticalTabList()`** (sidebar OR rail), never `isSessionSidebarActive()` alone: the rail leaves `data-session-list` at `header`, and the sidebar-only predicate shipped four rail bugs at once (drag insertion side read from clientX, active tab never scrolled into view, floating windows anchored below tabs instead of beside them, connector redraws skipped on rail scroll). The pre-paint script stamps `data-tab-orientation` (+ `--tab-rail-width`) like it stamps the sidebar keys, or vertical mode flashes through the header strip; the name font size defaults to 12px, the sidebar's historical size, so untouched installs are never restyled. ⚠️ Leaving sidebar mode **clears `_sidebarFilter`**: the filter box only exists in the aside, so a stale filter would hide sessions from the header strip with no reachable control to clear it. ⚠️ On handhelds the aside is an off-canvas overlay rather than a docked rail, and a closed drawer keeps `display: flex`, so it is marked `inert` + `aria-hidden` (`_isSessionSidebarOverlay()`) or its filter box and ~4 tab stops per session stay in the tab order; the DOCKED desktop rail must never be inerted, its rows are still clickable. The desktop home rail (`home-sessions.js`) defers to it, since both dock the session list flush left.
|
||||
|
||||
**Phone overview home screen** (`mobile-overview.js`, phones only, per-device `mobileOverviewEnabled`, default ON): under 430px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in `showWelcome()`/`hideWelcome()` (terminal-ui.js) behind `shouldUseMobileOverview()`, which is **width-driven** (`getDeviceType() === 'mobile'`) because this is a layout decision, unlike the settings namespace which stays handheld-based. ⚠️ The container ships with the `hidden` attribute and only this module removes it: never give `.mobile-overview` a bare `display` rule, since desktop does not load `mobile.css` (`media="(max-width: 1023px)"`) and would then render it unstyled. Live re-renders ride on the tail of `_renderSessionTabsImmediate()` (every state change it needs already funnels there); PAST rows come from one `_fetchUnifiedSessions(60)` per home-screen visit and resume through the shared `resumeHistorySession()`, so they behave exactly like the welcome screen's Resume list. ⚠️ Two things must stay in lockstep with surfaces outside this module, because divergence reads as a bug rather than a style: the split Run button carries the **toolbar's own classes** (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) so the per-backend gradient and the light-skin overrides apply unchanged (mobile.css must therefore set no `background`/`color` on it), and row status uses the **session-tab language** (green dot when fine, `pulse` while working, yellow blinking row when waiting for input, red blinking row when a question is pending, mirroring `tab-alert-idle`/`tab-alert-action`). The picker mirrors the toolbar run-mode menu (`setRunMode()` + `run()`, `openWebviewFromMenu()` for saved dashboards) and deliberately omits its Recent-Sessions block, since PAST SESSIONS is that. Status pills carry `data-i18n-skip` (generic words like "idle" collide with state strings elsewhere).
|
||||
|
||||
|
||||
@@ -376,10 +376,14 @@ export class TabLayoutService {
|
||||
.filter((item) => item.kind === 'session' && item.ownerValid && item.visible)
|
||||
.map((item) => item.id)
|
||||
);
|
||||
const invalid = normalized.find((id) => !visible.has(id));
|
||||
if (invalid) throw new TabLayoutValidationError(`session is not owned by layout owner: ${invalid}`);
|
||||
// Unknown or foreign ids are DROPPED, never a 400: the browser debounces
|
||||
// its reorder push (and swallows errors), so a session deleted inside
|
||||
// that window would otherwise cost the user the whole reorder — and the
|
||||
// endpoint sits on the stable /api/v1 surface, where the pre-layout
|
||||
// server merged leniently. Same philosophy as resolveParentSessionId.
|
||||
const requestedVisible = normalized.filter((id) => visible.has(id));
|
||||
const currentKnown = flattenOwnerSessionOrder(prepared.authoritative).filter((id) => visible.has(id));
|
||||
const effective = mergeSessionOrder(normalized, currentKnown);
|
||||
const effective = mergeSessionOrder(requestedVisible, currentKnown);
|
||||
const ranked = applyLegacySessionRank(prepared.authoritative, effective, prepared.metadata);
|
||||
const needsLayout = prepared.needsReconciliationCommit || !sameLayout(prepared.authoritative, ranked);
|
||||
const base = prepared.current ?? { ...prepared.authoritative, version: -1 };
|
||||
@@ -407,8 +411,10 @@ export class TabLayoutService {
|
||||
const { persisted, live } = this.sessionRecords();
|
||||
for (const record of persisted) knownOwners.set(record.id, ownerOf(record));
|
||||
for (const record of live) knownOwners.set(record.id, ownerOf(record));
|
||||
const invalid = normalized.find((id) => !knownOwners.has(id));
|
||||
if (invalid) throw new TabLayoutValidationError(`session is not visible machine-wide: ${invalid}`);
|
||||
// Unknown ids are DROPPED, never a 400 — see putOwnerLegacyOrder. In
|
||||
// single-user mode every request is the synthetic admin, so this path
|
||||
// IS the one the browser's debounced (error-swallowing) push hits.
|
||||
const known = normalized.filter((id) => knownOwners.has(id));
|
||||
|
||||
const publications: OwnerProjectionPublication[] = [];
|
||||
const updates: Record<string, TabLayout> = Object.create(null) as Record<string, TabLayout>;
|
||||
@@ -419,7 +425,7 @@ export class TabLayoutService {
|
||||
.filter((item) => item.kind === 'session' && item.ownerValid && item.visible)
|
||||
.map((item) => item.id)
|
||||
);
|
||||
const requestedOwner = normalized.filter((id) => visible.has(id));
|
||||
const requestedOwner = known.filter((id) => visible.has(id));
|
||||
const currentKnown = flattenOwnerSessionOrder(prepared.authoritative).filter((id) => visible.has(id));
|
||||
const effective = mergeSessionOrder(requestedOwner, currentKnown);
|
||||
const ranked = applyLegacySessionRank(prepared.authoritative, effective, prepared.metadata);
|
||||
@@ -429,7 +435,7 @@ export class TabLayoutService {
|
||||
if (needsLayout) updates[owner] = next;
|
||||
publications.push({ owner, previous: prepared.current, next, metadata: prepared.metadata });
|
||||
}
|
||||
const change = this.publish(updates, publications, normalized);
|
||||
const change = this.publish(updates, publications, known);
|
||||
return { order: [...change.globalOrder], ...change };
|
||||
});
|
||||
if (result) return result;
|
||||
@@ -489,6 +495,12 @@ export class TabLayoutService {
|
||||
* commits only after the resource cleanup finishes.
|
||||
*/
|
||||
async runSessionDeletion<T>(removed: readonly RemovedTabLayoutSession[], action: () => Promise<T>): Promise<T> {
|
||||
// A failed restoration must not lock the user out of explicitly closing a
|
||||
// tab for the rest of the process lifetime: degrade to best-effort deletion
|
||||
// without layout coordination. Only the AUTOMATED stale sweep stays
|
||||
// fail-closed on 'failed' (runStaleSessionCleanup), because that one picks
|
||||
// its victims itself from state a failed restore may have left incomplete.
|
||||
if (this.restorationState === 'failed') return action();
|
||||
this.assertDeletionReady();
|
||||
if (this.restorationState === 'skipped' || removed.length === 0) return action();
|
||||
const owners = new Set(removed.map(ownerOf));
|
||||
@@ -643,6 +655,9 @@ export class TabLayoutService {
|
||||
}
|
||||
|
||||
async webviewDeleted(owner: string, id: string): Promise<void> {
|
||||
// Same explicit-user-action escape hatch as runSessionDeletion: a failed
|
||||
// restore skips layout coordination instead of failing the delete.
|
||||
if (this.restorationState === 'failed') return;
|
||||
this.assertDeletionReady();
|
||||
if (this.restorationState === 'skipped') return;
|
||||
await this.withOwner(owner, async () => {
|
||||
|
||||
+41
-19
@@ -988,6 +988,11 @@ class CodemanApp {
|
||||
this.applySkin();
|
||||
this.applyLocalization();
|
||||
this.applySessionListLayout();
|
||||
// A fresh device seeding tabOrientation from the server would otherwise
|
||||
// show no rail until a resize or a settings save: the boot-time call ran
|
||||
// before this async load resolved. Must stay AFTER applySessionListLayout
|
||||
// (same ordering rule as the settings-save path).
|
||||
this.applyTabOrientation?.();
|
||||
this.applyMonitorVisibility();
|
||||
this.applyLineageLineSettings?.();
|
||||
// ultracodeFloatingWindows syncs from the server (non-display key), but on a
|
||||
@@ -3762,7 +3767,10 @@ class CodemanApp {
|
||||
|
||||
resolveSessionSidebarFontSize(value) {
|
||||
const size = Number(value);
|
||||
return Number.isInteger(size) && size >= 11 && size <= 18 ? size : 14;
|
||||
// Default 12, matching the sidebar's historical 0.75rem name size: a user
|
||||
// who never touches the slider must not get silently restyled (14 here
|
||||
// bumped every existing sidebar install on the rail feature's release).
|
||||
return Number.isInteger(size) && size >= 11 && size <= 18 ? size : 12;
|
||||
}
|
||||
|
||||
applySessionSidebarFontSize(settings = null) {
|
||||
@@ -3787,6 +3795,17 @@ class CodemanApp {
|
||||
return document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the session list renders as a vertical column: the sidebar layout
|
||||
* OR the vertical tab rail. Axis decisions (drag insertion side, active-tab
|
||||
* scroll-into-view, floating-window anchors) must use THIS, not
|
||||
* isSessionSidebarActive() alone — the rail leaves data-session-list at
|
||||
* 'header', so the sidebar predicate reads a vertical rail as horizontal.
|
||||
*/
|
||||
_isVerticalTabList() {
|
||||
return this.isSessionSidebarActive() || this._tabOrientation() === 'vertical';
|
||||
}
|
||||
|
||||
shouldInlineSessionActions() {
|
||||
if (this.isSessionSidebarActive()) return !this.isSessionSidebarCollapsed();
|
||||
return this._tabOrientation() === 'vertical' && !document.documentElement.classList.contains('tab-rail-compact');
|
||||
@@ -4266,12 +4285,13 @@ class CodemanApp {
|
||||
container.querySelector('.session-tab.active');
|
||||
if (!tab) return;
|
||||
|
||||
// Sidebar layout: the list scrolls VERTICALLY in its own scroller, so the
|
||||
// horizontal computeTabScrollLeft math below would always no-op (scrollLeft
|
||||
// pinned at 0). With 25+ sessions the active row is routinely below the
|
||||
// fold; 'nearest' never scrolls when it is already visible, and only the
|
||||
// list's own scroller moves — the drawer and document stay put.
|
||||
if (this.isSessionSidebarActive()) {
|
||||
// Sidebar layout AND the vertical rail: the list scrolls VERTICALLY in its
|
||||
// own scroller, so the horizontal computeTabScrollLeft math below would
|
||||
// always no-op (scrollLeft pinned at 0). With 25+ sessions the active row
|
||||
// is routinely below the fold; 'nearest' never scrolls when it is already
|
||||
// visible, and only the list's own scroller moves — drawer/rail and
|
||||
// document stay put.
|
||||
if (this._isVerticalTabList()) {
|
||||
tab.scrollIntoView({ block: 'nearest' });
|
||||
return;
|
||||
}
|
||||
@@ -4302,12 +4322,13 @@ class CodemanApp {
|
||||
|
||||
/**
|
||||
* Where a floating window (subagent / ultracode) attaches to its parent tab.
|
||||
* Header strip: below the tab, connector runs vertically. Sidebar: to the
|
||||
* RIGHT of the tab, connector runs horizontally — otherwise the window spawns
|
||||
* on top of the sidebar and its bezier loops backwards underneath it.
|
||||
* Header strip: below the tab, connector runs vertically. Sidebar AND the
|
||||
* vertical rail: to the RIGHT of the tab, connector runs horizontally —
|
||||
* otherwise the window spawns on top of the list and its bezier loops
|
||||
* backwards underneath it.
|
||||
*/
|
||||
_tabAnchor(rect) {
|
||||
if (this.isSessionSidebarActive()) {
|
||||
if (this._isVerticalTabList()) {
|
||||
return {
|
||||
x: rect.right,
|
||||
y: rect.top + rect.height / 2,
|
||||
@@ -4600,11 +4621,12 @@ class CodemanApp {
|
||||
// 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 arcs off their anchors. Only pay for it when there
|
||||
// is something anchored to tab rects: lineage arcs, or — in sidebar layout,
|
||||
// where lineage is skipped and the edge count stays 0 — the subagent/
|
||||
// ultracode connectors, whose rows a badge changes the HEIGHT of. Same
|
||||
// widening as the strip-scroll listener in session-lineage.js.
|
||||
if (this._lineageEdgeCount > 0 || this.isSessionSidebarActive()) this.updateConnectionLines();
|
||||
// is something anchored to tab rects: lineage arcs, or — in a VERTICAL list
|
||||
// (sidebar, where lineage is skipped and the edge count stays 0, or the
|
||||
// rail, which can show connectors with zero lineage edges too) — the
|
||||
// subagent/ultracode connectors, whose rows a badge changes the HEIGHT of.
|
||||
// Same widening as the strip-scroll listener in session-lineage.js.
|
||||
if (this._lineageEdgeCount > 0 || this._isVerticalTabList()) this.updateConnectionLines();
|
||||
|
||||
this.applySidebarFilter(this._sidebarFilter);
|
||||
}
|
||||
@@ -5017,9 +5039,9 @@ class CodemanApp {
|
||||
// inside the handler — these listeners survive a layout flip between
|
||||
// renders, so capturing the axis at bind time would go stale.
|
||||
// drag-over-left/-right keep their names and now read as before/after;
|
||||
// the sidebar CSS just draws them as top/bottom edges.
|
||||
// the sidebar/rail CSS just draws them as top/bottom edges.
|
||||
const rect = tab.getBoundingClientRect();
|
||||
const insertBefore = this.isSessionSidebarActive()
|
||||
const insertBefore = this._isVerticalTabList()
|
||||
? e.clientY < rect.top + rect.height / 2
|
||||
: e.clientX < rect.left + rect.width / 2;
|
||||
|
||||
@@ -5043,7 +5065,7 @@ class CodemanApp {
|
||||
|
||||
// Determine insertion position (same axis rule as the dragover handler)
|
||||
const rect = tab.getBoundingClientRect();
|
||||
const insertBefore = this.isSessionSidebarActive()
|
||||
const insertBefore = this._isVerticalTabList()
|
||||
? e.clientY < rect.top + rect.height / 2
|
||||
: e.clientX < rect.left + rect.width / 2;
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
app.js, NOT the handheld storage-key test `m`. Use a different predicate
|
||||
here and boot will contradict this value, animating the drawer open by
|
||||
itself on every load between 768 and 1023px. -->
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';}</script>
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';var W=Number(A.tabRailWidth);if(V&&Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';}</script>
|
||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||
<style>
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
|
||||
@@ -1933,10 +1933,10 @@
|
||||
<span class="set-row-desc">Adjust only session names in the vertical sidebar.</span>
|
||||
</div>
|
||||
<label class="set-range-field" for="appSettingsSessionSidebarFontSize">
|
||||
<input type="range" id="appSettingsSessionSidebarFontSize" min="11" max="18" step="1" value="14"
|
||||
<input type="range" id="appSettingsSessionSidebarFontSize" min="11" max="18" step="1" value="12"
|
||||
aria-labelledby="appSettingsSessionSidebarFontSizeLabel"
|
||||
oninput="document.getElementById('appSettingsSessionSidebarFontSizeValue').textContent=this.value+' px'">
|
||||
<output id="appSettingsSessionSidebarFontSizeValue" for="appSettingsSessionSidebarFontSize">14 px</output>
|
||||
<output id="appSettingsSessionSidebarFontSizeValue" for="appSettingsSessionSidebarFontSize">12 px</output>
|
||||
</label>
|
||||
</div>
|
||||
<div class="set-row" data-search="tall tabs folder name two rows">
|
||||
|
||||
@@ -230,10 +230,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
const strip = document.getElementById('sessionTabs');
|
||||
if (!strip) return;
|
||||
this._lineageScrollHandler = () => {
|
||||
// Sidebar layout scrolls the SAME element vertically, and there the
|
||||
// subagent/ultracode connectors anchor to tab rects too (lineage arcs are
|
||||
// skipped, so _lineageEdgeCount alone would never redraw them).
|
||||
if (this._lineageEdgeCount > 0 || this.isSessionSidebarActive?.()) this.updateConnectionLines();
|
||||
// Sidebar layout and the vertical rail scroll the SAME element
|
||||
// vertically, and there the subagent/ultracode connectors anchor to tab
|
||||
// rects too (the sidebar skips lineage arcs entirely, and the rail can
|
||||
// show connectors with zero lineage edges, so _lineageEdgeCount alone
|
||||
// would never redraw them).
|
||||
if (this._lineageEdgeCount > 0 || this._isVerticalTabList?.()) this.updateConnectionLines();
|
||||
};
|
||||
strip.addEventListener('scroll', this._lineageScrollHandler, { passive: true });
|
||||
},
|
||||
|
||||
@@ -2444,7 +2444,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
tabOrientation: 'horizontal',
|
||||
tabRailWidth: 256,
|
||||
sessionListLayout: 'header',
|
||||
sessionSidebarFontSize: 14,
|
||||
sessionSidebarFontSize: 12,
|
||||
cjkInputEnabled: false,
|
||||
terminalWheelLocalScrollback: false, // mobile scrolls via touch, not wheel
|
||||
webglRendererEnabled: false, // mobile always uses the DOM renderer
|
||||
|
||||
@@ -17173,11 +17173,11 @@ html[data-session-list="sidebar"] .session-sidebar .tab-name {
|
||||
html[data-session-list='sidebar'] .session-sidebar .tab-name {
|
||||
flex: 0 1 auto;
|
||||
white-space: nowrap;
|
||||
font-size: var(--session-sidebar-name-font-size, 14px);
|
||||
font-size: var(--session-sidebar-name-font-size, 12px);
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name {
|
||||
font-size: var(--session-sidebar-name-font-size, 14px);
|
||||
font-size: var(--session-sidebar-name-font-size, 12px);
|
||||
}
|
||||
|
||||
html[data-session-list='sidebar'] .session-sidebar .tab-actions,
|
||||
@@ -17198,11 +17198,13 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-clos
|
||||
/* Drag-reorder indicators become horizontal edges. The class names stay
|
||||
drag-over-left / drag-over-right (they read as before/after now) so app.js,
|
||||
the base rules above and the generated gesture bundle need no renaming. */
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab.drag-over-left {
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab.drag-over-left,
|
||||
html[data-tab-orientation='vertical'] .tab-rail .session-tab.drag-over-left {
|
||||
box-shadow: 0 -2px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab.drag-over-right {
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab.drag-over-right,
|
||||
html[data-tab-orientation='vertical'] .tab-rail .session-tab.drag-over-right {
|
||||
box-shadow: 0 2px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
/** @fileoverview Accessible, device-local vertical session-rail sizing. */
|
||||
/**
|
||||
* @fileoverview Accessible, device-local vertical session-rail sizing.
|
||||
*
|
||||
* Pointer + keyboard resizing for the vertical tab rail (`tabOrientation:
|
||||
* 'vertical'`), with a preferred width persisted per device (`tabRailWidth`)
|
||||
* and re-clamped against the viewport on resize. During a drag it takes
|
||||
* ownership of terminal refits (`_tabRailResizeOwnsObserver` suppresses
|
||||
* terminal-ui's throttled ResizeObserver) and performs ONE settle-time resize,
|
||||
* reverting per-frame below 40 columns so the PTY is never thrashed.
|
||||
*
|
||||
* @dependency CodemanApp (app.js) - methods attach to its prototype
|
||||
* @dependency CodemanTabRail (constants.js) - width policy (resolveWidth, bounds)
|
||||
* @loadorder 6.5 (after app.js, before terminal-ui.js)
|
||||
*/
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
_getTabRailMinimumTerminalWidth() {
|
||||
|
||||
+2
-1
@@ -51,6 +51,7 @@ import type { TerminalMultiplexer } from '../mux-interface.js';
|
||||
import { createMultiplexer } from '../mux-factory.js';
|
||||
import { getStore } from '../state-store.js';
|
||||
import { TabLayoutService } from '../tab-layout-service.js';
|
||||
import { ownerLayoutKey } from '../tab-layout-persistence.js';
|
||||
import { readWebviews } from '../webview-store.js';
|
||||
import { extractCompletionPhrase } from '../ralph-config.js';
|
||||
import { fileStreamManager } from '../file-stream-manager.js';
|
||||
@@ -615,7 +616,7 @@ export class WebServer extends EventEmitter {
|
||||
private async registerSessionWithLayout(session: Session): Promise<void> {
|
||||
this.sessions.set(session.id, session);
|
||||
try {
|
||||
await this.tabLayouts.sessionCreated(session.owner ?? '@single');
|
||||
await this.tabLayouts.sessionCreated(ownerLayoutKey(session.owner));
|
||||
} catch (error) {
|
||||
this.sessions.delete(session.id);
|
||||
throw error;
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
* - **Plan orchestration** (5): started, progress, subagent, completed, cancelled
|
||||
* - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed
|
||||
* - **Image / attachments** (2): image:detected, attachment:detected
|
||||
* - **Hooks** (9): idle_prompt, permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, stop, teammate_idle, task_completed, suppressed
|
||||
* - **Hooks** (8): idle_prompt, permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, stop, teammate_idle, task_completed
|
||||
* - **Approvals** (3): pending, updated, resolved (cross-session Approvals Inbox)
|
||||
* - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error
|
||||
* - **Clipboard** (1): write
|
||||
|
||||
@@ -52,15 +52,17 @@ describe('vertical session navigation UX contract', () => {
|
||||
expect(html).toContain('aria-labelledby="appSettingsSessionSidebarFontSizeLabel"');
|
||||
expect(html).toContain('--session-sidebar-name-font-size');
|
||||
expect(settingsUi).toContain('sessionSidebarFontSize: this.resolveSessionSidebarFontSize(');
|
||||
expect(settingsUi).toContain('sessionSidebarFontSize: 14,');
|
||||
// 12 = the sidebar's historical 0.75rem name size: the default must
|
||||
// never restyle an install whose user never touched the slider.
|
||||
expect(settingsUi).toContain('sessionSidebarFontSize: 12,');
|
||||
expect(settingsUi).toContain("'sessionSidebarFontSize'");
|
||||
expect(app).toContain('resolveSessionSidebarFontSize(value)');
|
||||
expect(app).toContain('applySessionSidebarFontSize(settings = null)');
|
||||
expect(styles).toMatch(
|
||||
/\.session-sidebar \.tab-name[^}]*font-size: var\(--session-sidebar-name-font-size, 14px\)/s
|
||||
/\.session-sidebar \.tab-name[^}]*font-size: var\(--session-sidebar-name-font-size, 12px\)/s
|
||||
);
|
||||
expect(styles).toMatch(
|
||||
/\.tab-rail \.session-tab \.tab-name[^}]*font-size: var\(--session-sidebar-name-font-size, 14px\)/s
|
||||
/\.tab-rail \.session-tab \.tab-name[^}]*font-size: var\(--session-sidebar-name-font-size, 12px\)/s
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -65,10 +65,6 @@ describe('TabLayoutService', () => {
|
||||
await expect(h.service.runSessionDeletion([{ id: 'mine' }], action)).rejects.toThrow(/restoration.*pending/i);
|
||||
expect(action).not.toHaveBeenCalled();
|
||||
|
||||
h.service.markRestorationFailed();
|
||||
await expect(h.service.runSessionDeletion([{ id: 'mine' }], action)).rejects.toThrow(/restoration.*failed/i);
|
||||
expect(action).not.toHaveBeenCalled();
|
||||
|
||||
h.service.markRestorationSkipped();
|
||||
await expect(h.service.runSessionDeletion([{ id: 'mine' }], action)).resolves.toBe('removed');
|
||||
expect(action).toHaveBeenCalledTimes(1);
|
||||
@@ -76,6 +72,28 @@ describe('TabLayoutService', () => {
|
||||
expect(h.broadcastSessionOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('degrades an explicit deletion to best-effort after a failed restore, while the stale sweep stays blocked', async () => {
|
||||
// A tmux hiccup at boot used to lock DELETE /api/sessions/:id (and webview
|
||||
// deletes) into opaque 500s for the whole process lifetime. A user's
|
||||
// explicit close now runs without layout coordination; only the AUTOMATED
|
||||
// stale sweep stays fail-closed, because it picks its own victims from
|
||||
// state a failed restore may have left incomplete.
|
||||
const h = createHarness({ layouts: { '@single': existing([{ kind: 'session', id: 'mine' }]) } });
|
||||
const action = vi.fn(async () => 'removed');
|
||||
h.service.markRestorationFailed();
|
||||
|
||||
await expect(h.service.runSessionDeletion([{ id: 'mine' }], action)).resolves.toBe('removed');
|
||||
expect(action).toHaveBeenCalledTimes(1);
|
||||
expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled();
|
||||
expect(h.broadcastSessionOrder).not.toHaveBeenCalled();
|
||||
|
||||
await expect(h.service.webviewDeleted('@single', 'w1')).resolves.toBeUndefined();
|
||||
|
||||
const cleanup = vi.fn();
|
||||
await expect(h.service.runStaleSessionCleanup(new Set(), cleanup)).rejects.toThrow(/restoration.*failed/i);
|
||||
expect(cleanup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prepares and commits a complete-state session deletion around the resource action', async () => {
|
||||
const h = createHarness({
|
||||
layouts: { alice: existing([{ kind: 'session', id: 'mine' }]) },
|
||||
@@ -877,7 +895,7 @@ describe('TabLayoutService', () => {
|
||||
it.each([
|
||||
['foreign owner', ['b', 'a']],
|
||||
['unknown stored ref', ['unknown', 'a']],
|
||||
])('rejects a regular legacy request containing a %s without mutation or events', async (_label, requested) => {
|
||||
])('drops a %s from a regular legacy request instead of rejecting the write', async (_label, requested) => {
|
||||
const original = existing([
|
||||
{ kind: 'session', id: 'a' },
|
||||
{ kind: 'session', id: 'unknown' },
|
||||
@@ -891,16 +909,60 @@ describe('TabLayoutService', () => {
|
||||
],
|
||||
});
|
||||
|
||||
await expect(h.service.putLegacyOrder({ owner: 'alice', isAdmin: false }, requested)).rejects.toThrow(
|
||||
/not owned by layout owner/i
|
||||
);
|
||||
// Unknown/foreign ids are dropped, never a 400: the browser's reorder push
|
||||
// is debounced and swallows errors, so a rejection would silently lose the
|
||||
// user's whole reorder when a session dies inside the debounce window.
|
||||
const result = await h.service.putLegacyOrder({ owner: 'alice', isAdmin: false }, requested);
|
||||
|
||||
expect(result.order).toEqual(['a']);
|
||||
expect(h.layouts.alice).toEqual(original);
|
||||
expect(h.order).toEqual(['a', 'b', 'unknown']);
|
||||
expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled();
|
||||
expect(h.broadcast).not.toHaveBeenCalled();
|
||||
expect(h.broadcastSessionOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies the surviving reorder when the request contains a deleted id (owner path)', async () => {
|
||||
const h = createHarness({
|
||||
layouts: {
|
||||
alice: existing([
|
||||
{ kind: 'session', id: 'a1' },
|
||||
{ kind: 'session', id: 'a2' },
|
||||
]),
|
||||
},
|
||||
order: ['a1', 'a2'],
|
||||
live: [
|
||||
{ id: 'a1', owner: 'alice', createdAt: 1 },
|
||||
{ id: 'a2', owner: 'alice', createdAt: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await h.service.putLegacyOrder({ owner: 'alice', isAdmin: false }, ['a2', 'ghost', 'a1']);
|
||||
|
||||
expect(result.order).toEqual(['a2', 'a1']);
|
||||
expect(h.order).toEqual(['a2', 'a1']);
|
||||
});
|
||||
|
||||
it('applies the surviving reorder when the request contains a deleted id (admin path)', async () => {
|
||||
const h = createHarness({
|
||||
layouts: {
|
||||
alice: existing([
|
||||
{ kind: 'session', id: 'a1' },
|
||||
{ kind: 'session', id: 'a2' },
|
||||
]),
|
||||
},
|
||||
order: ['a1', 'a2'],
|
||||
live: [
|
||||
{ id: 'a1', owner: 'alice', createdAt: 1 },
|
||||
{ id: 'a2', owner: 'alice', createdAt: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await h.service.putLegacyOrder({ owner: 'root', isAdmin: true }, ['a2', 'ghost', 'a1']);
|
||||
|
||||
expect(result.order).toEqual(['a2', 'a1']);
|
||||
expect(h.order).toEqual(['a2', 'a1']);
|
||||
});
|
||||
|
||||
it('keeps containers and anchored slots fixed, materializes ranked children, and merges missing known sessions', async () => {
|
||||
const h = createHarness({
|
||||
layouts: {
|
||||
|
||||
Reference in New Issue
Block a user