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;
+3 -3
View File
@@ -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">
+6 -4
View File
@@ -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 });
},
+1 -1
View File
@@ -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
+6 -4
View File
@@ -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);
}
+14 -1
View File
@@ -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
View File
@@ -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;
+1 -1
View File
@@ -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