Skip to content

feat(tabs): pin tabs to a Focus section on the vertical rail - #582

Draft
aakhter wants to merge 1 commit into
Ark0N:masterfrom
aakhter:pr/rail-focus-section
Draft

aakhter wants to merge 1 commit into
Ark0N:masterfrom
aakhter:pr/rail-focus-section

Conversation

@aakhter

@aakhter aakhter commented Oct 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a Focus section to the vertical tab rail. Pick "Add to Focus" from a row's ⋯ menu (or right-click a web tab) and a shortcut to that tab shows up in a small, collapsible section pinned at the top of the rail. The tab itself doesn't move: it stays in its group (or in the flat list), and the shortcut just gets you to it without scrolling. Click a shortcut to switch to that tab, right-click it (or hit the menu key) for "Remove from Focus".

Focus is part of the owner tab layout, so it follows you across devices. Whether the section is collapsed is per device, same as group collapse.

(Screenshot, DSF 1: a grouped rail with three Focus shortcuts above the groups, one carrying an action alert, the selected tab's shortcut highlighted. I couldn't attach images from the CLI; happy to post it if it helps.)

Model change

  • TabRef (session and web tab refs) gets an optional focus?: true in src/tab-layout.ts. Parsing is strict: only true or absent, anything else is a 400. An unfocused ref carries no key at all.
  • followParent used to rebuild the ref from {kind, id}, which would have dropped the flag. It now strips only placement.
  • New pure helper setTabFocus(layout, ref, focused). It moves nothing and is idempotent.
  • TabLayoutService, persistence and the legacy-order projection needed no code change. Everything there goes through parseRef and spreads refs, so once the parser keeps the flag, PUT, legacy order PUTs, group moves, deletes and reconciliation keep it too. That's pinned by tests rather than assumed. No new route: the flag rides the existing PUT /api/tab-layout.

Browser

  • copyRef / normalizeLayout in tab-layout-browser.js carry focus the same way they already carry placement. This is the important bit: the browser PUTs whole layouts, so before this change any client save would have quietly erased every Focus flag.
  • New named operation setFocus in applyOperation, sent through the existing createEditCoordinator (no new PUT path). It replays on a 409 like the others, and gets dropped and reported if its row vanished in the conflict.
  • focusRefs(layout, options) lists the focused refs the page can actually draw (live session or open web tab, stored order, deduped). project() exposes the same list as projection.focusRefs.
  • Collapse state lives under its own localStorage key (codeman:tab-focus-collapsed), not in the group-collapse list, because that list is garbage-collected against group ids and Focus isn't a group.

Behaviour

The section is its own <section id="tabFocus"> inside #tabRail, just above #sessionTabs, and not inside the list. That keeps it out of everything that walks the list:

  • no Alt+N badge, and the rows' badges are byte-identical with and without Focus
  • not a tree item, so the grouped rail's ARIA tree, its single roving tabindex=0 and posinset/setsize are untouched
  • not a lineage anchor (session-lineage.js only measures #sessionTabs .session-tab)
  • not a drag source or drop target (pointer drag and HTML5 drag are bound on #sessionTabs)
  • not counted by the sidebar count, not hidden by the row filters, not reordered by the rail sort

Shortcuts are <button>s in a <ul> labelled "Focus", and the toggle is a button with aria-expanded/aria-controls. The selected tab's shortcut gets aria-current, and each shortcut shows its tab's status dot and alert colour.

Keyboard / a11y decision: the list has exactly one tab stop (the last used shortcut, else the selected tab's, else the first), and Up/Down/Home/End move between shortcuts. Enter/Space are the native button click. So Tab goes: Focus toggle, one stop in the Focus list, one stop in the tree. I didn't fold the shortcuts into the tree's walk. They're duplicates of rows already in the tree, so walking them twice would be noise, and making them tree items would mean two treeitems for the same tab. A separate list with its own single stop seemed like the smallest correct model.

Shortcut names are user text, so the shortcut button carries data-i18n-skip. A session named "3 sessions" shouldn't come out in Chinese.

A focused session that closes, or a web tab that isn't open on this device, just drops out of the section. The server already prunes refs for deleted sessions and web tabs during reconciliation, and the tests cover both. When nothing focused is left the section hides.

A flag changed on another device isn't a structural change of the list, so _applyTabLayout repaints the section on its own instead of waiting for a rebuild. The section also repaints on both render paths and on selection changes, but only touches the DOM when its markup actually changed.

The no-groups decision

Focus works on the flat rail too (a layout with no named groups). Since the section lives outside #sessionTabs, that came for free: project() still returns null for a layout without groups, the flat rail stays a plain tablist exactly as before, and the Focus section just sits above it. The other way to get there would have been to make project() return a projection whenever something is focused. That turns the flat rail into the grouped tree, which switches off the state/case arrangements on the rail ("named groups win") and changes the drag model, all because you pinned one tab. That seemed like the wrong trade.

To make Focus reachable for web tabs on the flat rail, a right-click on a web tab in the vertical rail now opens its action menu (Web tab settings, Add to Focus, group moves). Off the vertical rail the browser keeps its native menu. Session rows already had their ⋯ menu.

Tests and gates

New tests (TDD, written before the code):

  • test/tab-layout-focus.test.ts (22): strict parse, normalize (including lineage reorder and pruning a gone ref), moveRef/followParent/deleteGroup/materializeOrphans keep the flag, setTabFocus, the legacy-order projection and migration, TabLayoutService through PUT, a PUT that moves a focused ref to another group, a legacy order PUT, session and web-tab deletion, multi-user owner scoping (per owner, a foreign ref is still refused), and a real PUT /api/tab-layout round trip through Fastify and a StateStore reload.
  • test/tab-focus-browser.test.ts (15): copyRef/normalize keep focus, every existing op keeps it, setFocus add/remove/idempotent/refusals, the coordinator PUT, replay on a 409 keeping a flag set elsewhere, a dropped setFocus after a conflict, adoptExternal, focusRefs with and without groups, the collapse storage.
  • test/tab-focus-rail.test.ts (17, JSDOM through the shipping CodemanApp): section placement on grouped and flat rails, rows/badges/tree HTML byte-identical with Focus on, one tab stop per list, no duplicate ids, click selects, aria-current follows selection, status and alert carried, gone session drops out, repaint on a remote flag change, row and web-tab menus, the strip has no entry, collapse persistence, keyboard walk, focus kept across repaint, zh-CN.
  • test/tab-focus.browser.test.ts (5, Playwright, added to BROWSER_TEST_GLOBS): the section paints above the list, real right-clicks on a web tab and on a shortcut, the browser's real Tab order (toggle, one list stop, one tree stop), collapse, and a drag from a shortcut doing nothing.
  • test/tab-layout-editing.test.ts: four menu expectations now include "Add to Focus".

Gates, all exit 0: npm run typecheck, npm run lint, npm run format:check, npm run check:frontend-syntax, npm run check:public-assets, npm run check:browser-excludes, npm run build.

Targeted runs:

  • npm test -- every non-browser test file that loads a touched file (app.js, tab-layout-browser.js, webview-tabs.js, tab-rail-resize.js, i18n.js, styles.css or tab-layout*): 126 files, 2215 tests, all passing.
  • npm run test:browser -- tab-focus, tab-layout-editing, tab-rail-resize, tab-activation, session-sidebar-ux, session-options-responsive, spreadsheet-preview, mobile-ime-preview, inline-rename: 9 files, 76 tests, all passing.

Mutation checks:

  • dropping focus in the browser's copyRef fails 26 tests across the browser-module and rail suites
  • making the service drop focus from a ref a PUT moved to another group fails the "keeps focus on a ref a PUT moves into another group" test

Judgement calls for the maintainer

  • Separate section vs. inside the tree. Putting Focus outside #sessionTabs is what makes it cheap on the flat rail and keeps it out of Alt+N, lineage, drag, sort and filters without special cases. The cost is that it's a second keyboard region (its own single tab stop) rather than part of the tree walk.
  • No reorder within Focus. Shortcuts follow stored order (groups in order, then ungrouped). If you want manual Focus ordering, that would need a separate order list in the layout. I left it out on purpose.
  • Shortcuts don't follow the rail search (if/when the rail search box lands). They're pins, so I'd rather they stay put while you search, but that's easy to change.
  • Web-tab right-click. It's new behaviour on web-tab rows in the vertical rail (the web-tab menu used to be reachable only with Shift+F10 in the grouped tree). The strip and sidebar are unchanged.
  • Sidebar layout and phones don't show Focus. They never have a vertical rail, and the row menus there don't offer it.

This touches some of the same rail code as #580 (search). The two merge cleanly except for one line each in config/test-suites.ts and the Dashboard wiki's vertical rail row; I'll rebase whichever lands second.

@aakhter

aakhter commented Oct 10, 2026

Copy link
Copy Markdown
Contributor Author

Opened as a draft only because GitHub won't let my account open a regular PR on this repo; it's ready for review. Second of the vertical-rail ports (after #580). It doesn't depend on #580 or #528 and merges onto current master on its own.

A session or web-tab ref in the owner tab layout may now carry
`focus: true`. "Add to Focus" in a row's menu (right-click on a web tab)
sets it; the vertical rail then draws a shortcut to that tab in a pinned,
collapsible "Focus" section at its top, while the row itself stays where
it is. A click on a shortcut selects its tab, a right-click or the menu
key offers "Remove from Focus".

Model: `TabRef.focus?: true`, parsed strictly (only `true` or absent) and
carried through normalization, every pure edit and the legacy-order
projection; `setTabFocus()` is the pure helper. TabLayoutService needed
no change and no route was added: the flag rides the existing
`PUT /api/tab-layout`. Browser: `copyRef`/`normalizeLayout` keep `focus`
the way they keep `placement` (the browser writes whole layouts, so a
dropped field would be erased on every save), `setFocus` is a named
operation through `createEditCoordinator` and replays on a 409, and
`focusRefs()` (also on `project()`) lists what the rail can draw.

The section is its own element in #tabRail above #sessionTabs, so it
works on the flat rail (no named groups) as well as the grouped one, and
shortcuts are never rows: no Alt+N badge, no tree item, no lineage
anchor, no drag target. It is a list with one tab stop and
Up/Down/Home/End between shortcuts. Collapse is per-device localStorage
under its own key. A shortcut whose session closed or whose web tab is
not open drops out; the server already prunes deleted refs. zh-CN
strings added.
@aakhter

aakhter commented Oct 10, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto 1.41.0 now that #580 is in (cde90a4). The only conflicts were the expected one-liners: both browser tests are kept in config/test-suites.ts, and the Dashboard wiki's vertical rail row now carries both the search and the Focus sentences. The two features merge cleanly in app.js otherwise. With both in, the gates pass, 10 rail/tab-layout/i18n files pass (288 tests), and the Focus and rail-search browser tests pass together (13). Focus shortcuts aren't filtered by the rail search (they sit outside the list); that's listed as a judgement call in the description if you'd rather they follow it.

@aakhter
aakhter force-pushed the pr/rail-focus-section branch from ff03d85 to cde90a4 Compare October 10, 2026 17:44
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