Skip to content

feat(tabs): edit groups in the vertical rail - #525

Draft
aakhter wants to merge 3 commits into
Ark0N:masterfrom
aakhter:pr/grouped-rail-edit
Draft

aakhter wants to merge 3 commits into
Ark0N:masterfrom
aakhter:pr/grouped-rail-edit

Conversation

@aakhter

@aakhter aakhter commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

Stacked on #519, which is itself on #517. Only the last commit (2fcbf2fc) is new.

This makes the grouped vertical rail editable from the browser. You can create, rename, reorder and delete groups, and move tabs between groups or out to Ungrouped. Edits can be made three ways:

  • the row menu;
  • a group menu (Shift+F10, ContextMenu, right-click or the header's hover glyph; F2 renames inline);
  • dragging with mouse or pen in the grouped rail.

A flat rail gets "Move to new group" in its row menu, which is how the first group is made.

Saving. Every edit is a named operation, applied to the rail at once and saved through the existing PUT /api/tab-layout:

  • One write is in flight at a time, on the version the server last returned.
  • A version conflict replays the pending operations onto the layout the server sent back and retries, so a concurrent edit from another device survives. An operation that no longer applies is dropped with a toast.
  • A reload triggered by SSE waits for an in-flight write.
  • Unsaved edits survive a page reload through a keepalive PUT plus a sessionStorage copy that replays harmlessly.

There are no server changes.

Unchanged: the flat rail's markup with no groups, the tree semantics and single roving tab stop from #519, sessionOrder and Alt+N, and the HTML5 drag in the flat rail and the header strip. Group menus close on Escape, a click outside, Tab, focus loss, a resize or any re-render. New strings have zh-CN entries, and group names only ever reach the DOM as text.

Tests

  • test/tab-layout-editing.test.ts: operations, write coordination, conflict rebase, drop mapping, menus, rename, dismissal, reload recovery.
  • test/tab-layout-editing.browser.test.ts: real pointer drags, editor paint and menu Escape. Run with npm run test:browser.
  • Checked in Chromium against an isolated instance: after every create, rename, move, drag, reorder and delete, the UI matched GET /api/tab-layout. A second page followed over SSE. A conflicting PUT from curl mid-session was rebased, and both edits survived.

For review

  • In the grouped tree, Shift+F10 on a web tab now opens a small menu ("Web tab settings" plus the moves) instead of the settings modal directly.
  • If a layout re-read fails while edits are still unsaved, those edits are discarded without a toast. This is rare, and I'm happy to surface it instead.
  • Touch drag is not included; tablets use the menus.
  • test/inline-rename.test.ts has one browser failure ("Vertical rail paints typing in an unclamped editor restores clamp on cancel"). It fails the same way without this commit.

The vertical tab rail now reads the owner's tab layout (GET /api/tab-layout)
and draws its groups as collapsible sections. This is the first frontend
consumer of the tab-layout backend and it is read-only: nothing in the
browser writes the layout yet.

- tab-layout-browser.js (new, pure, loaded before app.js): projects the
  layout onto the live sessions and open web tabs, renders the grouped
  markup, stores collapse per device, and sequences loads newest-wins with
  a bounded retry on failure.
- app.js: loads the layout on init and on tab:layoutChanged, renders the
  grouped rail from the same per-row markup the flat rail uses, falls
  through to a full render whenever the grouping structure changes, and
  withholds drag-reorder in the grouped rail.
- Grouping is opt-in by construction. With no layout, a failed read, a
  layout without groups, or a horizontal strip, the rail renders exactly
  as before (byte-identical markup).
- Grouping is a render layer only: sessionOrder, Alt+N, Ctrl+Tab and the
  palette keep reading the server-projected order, and row badges keep
  their Alt+N slot.
- A collapsed group still shows the active row; lineage arcs to a hidden
  session anchor to its group header.
- webview-tabs.js: renderWebviewTab() extracted so a single web tab can be
  placed into its group with unchanged markup.
The grouped vertical rail is now an ARIA tree with a tree keyboard model,
and tab rows are pinned as full-row activation targets whose controls keep
their own actions and stable hit targets.

Tree semantics (grouped vertical rail only):
- #sessionTabs becomes role=tree while grouped and returns to its shipped
  role=tablist and label when grouping ends. The header strip, sidebar and
  flat rail keep role=tablist / role=tab exactly as before (the flat rail's
  markup is unchanged byte for byte).
- A named group's header is a level-1 treeitem with aria-expanded that
  aria-owns its rows' role=group (rows are level 2). Ungrouped rows and the
  row a collapsed group keeps showing are level-1 items; a collapsed header
  owns nothing, and the "Ungrouped" heading is a visual divider hidden from
  assistive tech. aria-level, aria-setsize and aria-posinset are set on every
  item, and aria-selected follows the selection without a rebuild.
- Exactly one treeitem carries tabindex=0 (roving). Controls inside rows
  leave the tab order, so Shift+F10 / ContextMenu open a row's actions
  (session action menu, web tab settings).
- Up/Down walk visible items, Home/End jump, Right expands a header or enters
  it, Left collapses a header or climbs from a row to its header, Enter/Space
  select a row or toggle a header. With the activity sort on, the walk follows
  painted order within each group; the flat list keeps its whole-list walk.
- Focus survives a full re-render by identity (a row a collapse just hid hands
  focus to its header), but a render never pulls focus into the rail.
- The group header is the treeitem itself (no nested button), still toggled by
  click through the same onclick and still the lineage proxy anchor.

Full-row activation:
- Clicking a row's status dot, mode chip, name or padding already selected it
  upstream; that is now pinned in real Chromium for the strip, the flat rail
  and the grouped rail, together with every control (gear, detach, close,
  overflow, web tab gear and close) running only its own action.
- The close control now shows a pointer like its siblings instead of the
  default arrow.
- Enter/Space on a focused web tab in the flat list opens it; it used to call
  selectSession(undefined).
- The action controls are pinned to stay under the pointer when a row is
  hovered (no reflow-on-hover moving the gear out from under a click).

New Chromium suite test/tab-activation.browser.test.ts is listed in
BROWSER_TEST_GLOBS (run with npm run test:browser).
The grouped vertical rail can now be edited from the browser: groups are
created, renamed, reordered and deleted, and tabs are moved between them, by
menu, keyboard or pointer drag. Every edit is saved through the existing
PUT /api/tab-layout; there are no server changes.

Saving (tab-layout-browser.js, pure):
- Edits are named operations (createGroup, renameGroup, deleteGroup,
  reorderGroup, moveRef) applied to the rail at once, mirroring the server
  model: a moved session takes the sessions that still follow it, and a
  hand-moved child is marked placement 'manual'. normalizeLayout now keeps
  placement and updatedAt, since whole layouts are written back.
- createEditCoordinator keeps ONE PUT {baseVersion, layout} in flight. Edits
  made in the same turn share a write; edits made while one is in flight go
  out on the version it returns. A 409 replays the operations onto the
  layout the server returned and retries (bounded); an operation that no
  longer applies is dropped and reported. A 400 re-reads first; any other
  failure reports and re-reads.
- dropOperation maps a finished drag to one operation, or null for a drop
  that changes nothing.

Wiring (app.js, tab-rail-resize.js):
- The session row menu gains Move up/down, Move to <group>, Move to
  Ungrouped and Move to new group in the vertical rail. Before the first
  group exists it offers only "Move to new group", which is how a flat rail
  becomes grouped; the header strip's menu is unchanged.
- A group header opens its menu with Shift+F10 / ContextMenu, right-click or
  a hover glyph (a non-focusable aria-hidden span, so the treeitem still
  holds no interactive child): Rename, New group, Move group up/down,
  Delete. F2 renames inline. A web tab row's Shift+F10 opens its settings
  plus the same moves.
- The menu closes on Escape (consumed before the global Escape handler, focus
  back to its row or header), a pointer outside, Tab, focus leaving it, a
  resize, a second open and any full re-render.
- Inline group rename shares the session rename's ownership handle, so only
  the current editor releases the render guard. Enter or blur commits,
  Escape cancels, IME composition keys are left to the IME, and the label
  becomes a flex slot so the editor gets the full width while typing.
- Pointer drag (mouse and pen) in the grouped rail only: rows before/after a
  row or into a group, a header drag reorders groups. Escape cancels; the
  click that ends a drag neither selects nor toggles. The flat rail and the
  header strip keep their HTML5 drag untouched.
- A tab:layoutChanged read is deferred while a write is in flight and run
  once it settles; a read otherwise rebases unsaved edits. On pagehide,
  unconfirmed edits go out in a keepalive PUT and into sessionStorage, and
  replay after reload (a no-op when the keepalive landed).
- New strings have zh-CN entries; group names reach the DOM only as text.

Unchanged: the flat rail's markup when no group exists, the tree semantics
and single roving tab stop, sessionOrder and Alt+N.

Tests: test/tab-layout-editing.test.ts (operations, coordinator, drop
mapping, menus, rename, dismissal, SSE deferral, reload recovery, flat-rail
identity) and test/tab-layout-editing.browser.test.ts (real pointer drags,
editor paint, menu Escape), listed in BROWSER_TEST_GLOBS.
@aakhter

aakhter commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

Hi @Ark0N, this is the third PR of the grouped-rail stack (on #519, which is on #517; only the last commit is new). GitHub wouldn't let me open it as a regular PR ("does not have the correct permissions to execute CreatePullRequest"), and as a draft it also refuses to let me mark it ready for review. So it's sitting as a draft only because of that, not because it's unfinished. It's ready for review whenever you get to it, and please mark it ready on your side if that's easier.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant