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:
Codeman maintainer
2026-08-24 00:59:48 +02:00
parent f3c615b669
commit 7a340fe7bc
12 changed files with 174 additions and 55 deletions
+41 -19
View File
@@ -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;