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
+5 -3
View File
@@ -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
);
});
+71 -9
View File
@@ -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: {