mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 07:29: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:
@@ -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