feat(header): session tabs — a horizontal working set of open sessions

Web/desktop header replaces the single session title with a strip of
soft pill tabs, one per session the user has opened (sidebar, palette
or deep link — opening anywhere adds a tab once). The active tab is the
familiar title block — rename, meta row and the full session menu —
inside a gently selected pill; a brand-new draft shows as a transient
pill until its session exists. Inactive tabs show the title with a
hover-revealed "..." menu (close tab, close other tabs, copy id) that
nudges the text like sidebar rows, and close by middle-click too.

Tabs drag to reorder, scroll behind the right-side header buttons with
soft fade edges, respect the reserved window-controls inset, and
persist across reloads. Closing the active tab activates its neighbour
(or opens a new draft when it was the last). Tab ids whose session is
not in the loaded list stay stored but hidden, so a partial session
list never destroys the working set. VS Code keeps the plain title;
mobile is untouched.
This commit is contained in:
Bohdan Triapitsyn
2026-08-24 17:07:09 +03:00
parent 7bd27d44d6
commit a3813f57e9
15 changed files with 645 additions and 1 deletions
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import { useSessionTabsStore } from './useSessionTabsStore';
describe('useSessionTabsStore', () => {
beforeEach(() => {
useSessionTabsStore.setState({ tabIds: [] });
});
test('ensureTab appends once and preserves order', () => {
const store = useSessionTabsStore.getState();
store.ensureTab('a');
store.ensureTab('b');
store.ensureTab('a');
expect(useSessionTabsStore.getState().tabIds).toEqual(['a', 'b']);
});
test('closeTab removes only the given id; closeOtherTabs keeps only it', () => {
useSessionTabsStore.setState({ tabIds: ['a', 'b', 'c'] });
useSessionTabsStore.getState().closeTab('b');
expect(useSessionTabsStore.getState().tabIds).toEqual(['a', 'c']);
useSessionTabsStore.getState().closeOtherTabs('c');
expect(useSessionTabsStore.getState().tabIds).toEqual(['c']);
});
test('reorderTabs moves by id and ignores unknown ids', () => {
useSessionTabsStore.setState({ tabIds: ['a', 'b', 'c'] });
useSessionTabsStore.getState().reorderTabs('c', 'a');
expect(useSessionTabsStore.getState().tabIds).toEqual(['c', 'a', 'b']);
const before = useSessionTabsStore.getState().tabIds;
useSessionTabsStore.getState().reorderTabs('x', 'a');
expect(useSessionTabsStore.getState().tabIds).toBe(before);
});
test('removeTabs drops only confirmed-gone ids and no-ops otherwise', () => {
useSessionTabsStore.setState({ tabIds: ['a', 'b'] });
const before = useSessionTabsStore.getState().tabIds;
useSessionTabsStore.getState().removeTabs(['x']);
expect(useSessionTabsStore.getState().tabIds).toBe(before);
useSessionTabsStore.getState().removeTabs(['a']);
expect(useSessionTabsStore.getState().tabIds).toEqual(['b']);
});
});