Repository navigation
Conversation
Contributor
Author
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.
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 |
aakhter
force-pushed
the
pr/rail-focus-section
branch
from
October 10, 2026 17:44
ff03d85 to
cde90a4
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 optionalfocus?: trueinsrc/tab-layout.ts. Parsing is strict: onlytrueor absent, anything else is a 400. An unfocused ref carries no key at all.followParentused to rebuild the ref from{kind, id}, which would have dropped the flag. It now strips onlyplacement.setTabFocus(layout, ref, focused). It moves nothing and is idempotent.TabLayoutService, persistence and the legacy-order projection needed no code change. Everything there goes throughparseRefand 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 existingPUT /api/tab-layout.Browser
copyRef/normalizeLayoutintab-layout-browser.jscarryfocusthe same way they already carryplacement. This is the important bit: the browser PUTs whole layouts, so before this change any client save would have quietly erased every Focus flag.setFocusinapplyOperation, sent through the existingcreateEditCoordinator(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 asprojection.focusRefs.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:tabindex=0and posinset/setsize are untouched#sessionTabs .session-tab)#sessionTabs)Shortcuts are
<button>s in a<ul>labelled "Focus", and the toggle is a button witharia-expanded/aria-controls. The selected tab's shortcut getsaria-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
_applyTabLayoutrepaints 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 returnsnullfor a layout without groups, the flat rail stays a plaintablistexactly as before, and the Focus section just sits above it. The other way to get there would have been to makeproject()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 realPUT /api/tab-layoutround trip through Fastify and a StateStore reload.test/tab-focus-browser.test.ts(15): copyRef/normalize keep focus, every existing op keeps it,setFocusadd/remove/idempotent/refusals, the coordinator PUT, replay on a 409 keeping a flag set elsewhere, a droppedsetFocusafter a conflict, adoptExternal,focusRefswith 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 toBROWSER_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:
focusin the browser'scopyReffails 26 tests across the browser-module and rail suitesfocusfrom a ref a PUT moved to another group fails the "keeps focus on a ref a PUT moves into another group" testJudgement calls for the maintainer
#sessionTabsis 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.This touches some of the same rail code as #580 (search). The two merge cleanly except for one line each in
config/test-suites.tsand the Dashboard wiki's vertical rail row; I'll rebase whichever lands second.