feat(desktop): pick session rows with shift and cmd - #4455
Open
Astro-Han wants to merge 3 commits into
Open
Conversation
Astro-Han
force-pushed
the
feat/session-rail-modifier-select
branch
from
September 1, 2026 09:51
2de662b to
ee3b522
Compare
Astro-Han
force-pushed
the
feat/session-rail-modifier-select
branch
2 times, most recently
from
September 1, 2026 11:08
a625a91 to
8e72307
Compare
6 tasks
Astro-Han
force-pushed
the
feat/session-rail-modifier-select
branch
3 times, most recently
from
September 1, 2026 13:30
a4808c2 to
78fc628
Compare
Deleting a task is the one row action that cannot be undone, and the rail is where a mis-click is likeliest: rows are dense, the ⋯ menu is one hover away, and the row under the cursor moves as the catalog refreshes. The rail now stops at 归档 — the row menu, the selection bar's second button, and the Delete/Backspace binding are all gone. Deleting still exists, in Settings › 已归档任务, which can only reach a task that was archived first. That archive step is what makes the intent deliberate, and it is the same route the bulk purge already took. Two behaviours change for the user. Deleting a task that was never archived is now two steps instead of one: archive it in the rail, then delete it in Settings. Delete and Backspace no longer act on the focused row. With the rail's bulk delete gone, `sweepSessions` had one caller left and one live value for its `requireArchivedFor` parameter, so it collapses back into `purgeSessions`. Generated-by: Claude Opus 5 via Claude Code
The Session rail's multi-select was a MODE. A row's ⋯ offered 选择任务, which entered it; entering swapped a checkbox column in beside every row, put a bar with a master checkbox above the list, and changed what an ordinary click meant. Leaving it was a 取消 button or Escape. The mode lived in one state, the marks in another, and the bar and the rows read two different halves of it — while the ⋯ menu went on offering to act on the single row under the cursor with twelve rows visibly marked. Replace it with the gesture every file list already uses. A plain click opens a task and picks exactly it; ⌘/Ctrl-click adds or removes one row without moving the main pane; Shift-click picks the run from the anchor to the row clicked. There is nothing to enter and nothing to leave, so Escape simply drops the picks. Picked rows and the open row share one ground — the same `var(--color-neutral)` Astryx's own `isSelected` compiles to, so the two are one paint rather than a near-match kept by hand. `isSelected` itself stays on the open row alone: it renders `aria-current="page"`, and a set of picked rows is not a set of current pages. What a screen reader hears instead rides in the row's existing description. ⋯ and right-click are one menu, not two lists to keep in agreement: right-click opens the row's own ⋯. Its verbs count the set — 置顶 3 项, 归档 3 项 — and a set already pinned throughout offers 取消置顶 instead, which is what a set-wide toggle has to do to be unambiguous. A menu opened on a row outside the set replaces the set with that row, the way a file list answers a right-click on an unselected file; a row already in the set leaves it alone. 重命名 is absent from a set menu: renaming names one task. The picked set reaches rows as PROPS. A plain click now changes the selection, so a context the rows subscribed to would redraw all ~1,000 fibers for a switch that moved two of them (apache#4109); the rendered order a Shift range needs is read off the DOM at the moment of the click, which costs nothing per render and is the true order rather than a reconstruction of it. Archiving a set does not confirm, matching the single row — it is reversible, and Settings › 已归档任务 is where it can be undone or made permanent. Generated-by: Claude Opus 5 via Claude Code
A Shift range read the rail's rendered order straight off the DOM, and rendered is not the same as reachable. `SideNavItem` collapses a project by grid track rather than by unmounting, so a range dragged past a collapsed group swept up rows that were nowhere on screen — twenty tasks archived from a gesture that highlighted three. A session shared from someone else's Host is handed no row actions for the same reason it has no ⋯, but nothing stopped it joining a set and being archived with it; and right-clicking one claimed the press, discarded the set, and opened nothing, because the ⋯ it went looking for was never rendered. One question was being answered in two selectors that only knew how to find a row, so `isPickableRow` answers it once and the list reads it everywhere: a row is pickable when it is ours to act on and not inside an inert subtree. A row with no actions is now a plain navigation item all the way down — a modifier on it navigates rather than dying, and a right-click on it goes to the native menu. Escape had the same shape of bug one layer up: the guard asked whether focus was in a text field, when the question is whether anything above the rail owns the press. Astryx's layer stack listens on `document`, below this handler in the bubble, and stands down on a press that is already defaultPrevented — so Escape in a rename dialog cleared the set AND left the dialog with no way to close. Also drops the sweep's post-completion unmark. "The selection follows the catalog" already has an owner in the prune effect, and an archive refreshes the catalog before it resolves; saying it twice was redundant for archive and wrong for pin, which leaves its rows listed and had the set empty itself out from under the next action. Generated-by: Claude Opus 5 via Claude Code
Astro-Han
force-pushed
the
feat/session-rail-modifier-select
branch
from
September 1, 2026 14:41
78fc628 to
25b0c78
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
The requirement comes from #4365, merged this morning, and this is a continuation of it rather than a reversal of it.
#4365's first revision was this interaction. Its own description records why it was dropped:
The modifier gestures were not judged worse than a mode. They were given up because handing every row its group's id array broke the rail's #4109 render budget at 12 of 12, and no memoisation fixes it: every candidate key derives from
rail.sessions, whose identity moves on a session switch. The checkbox mode is what was reachable once that door closed.This PR removes the constraint instead of living with it. The rendered order is never a prop. It is read off the DOM at the moment of the click — one query per click, nothing per render, and it is the true order, groups and collapsed projects included, rather than a reconstruction. With that, a range costs the budget nothing, and the mode is no longer the only shape available.
Which matters, because a mode carries two costs that are permanent rather than adjustable:
So: plain click opens and picks; ⌘/Ctrl-click adds or removes one row without moving the main pane; Shift-click picks the run from the anchor. Nothing to enter or leave, so Escape just drops the picks. Finder, VS Code, Claude Desktop and Codex all spell it this way, and nothing is added to the rail.
var(--color-neutral)that Astryx'sisSelectedcompiles to.isSelectedstays on the open row alone — it rendersaria-current="page", and picked rows are not current pages; "picked" rides in the row's existing visually-hidden description.What #4365 established, and what this replaces
Kept, because they were right and are not specific to the mode:
session-selection-sweep-race.test.tsdrives the catalog to prove it, since a fixture whose session list never changes cannot tell the two cases apart.Reshaped — same capability, different gesture: multi-select itself, and bulk archive. Set-wide 置顶 / 取消置顶 is new; #4365's bar offered 归档 and 删除 only.
Three capabilities the rail no longer offers. Each is a decision with a reason, not an oversight, and each is stated here rather than left inside an implementation name:
Delete/Backspaceon the marked set#4365's linked-subtask contract is not lost with the rail's bulk delete:
previewRemovalstill runs for the Settings purge (session-row-actions.ts:212), which is the surface that now owns every deletion. What this PR removes is the rail's entrance to it, not the accounting behind it.Also replaced, as implementation:
SessionSelection.activeand the enter/exit/select-all paths,SessionRailRowSelection,session-selection-bar.tsx, the checkbox column,data-selecting/data-selected,deleteSelected/sweepSessions, thebulkDelete*copy set.Doing this now rather than later is the cheaper moment: nothing yet depends on the mode's shape or its API, and one day in, no user has built a habit on it. Happy to be argued out of any of it — the six interaction decisions above were settled one at a time and each can be revisited.
Third commit: only pickable rows
An adversarial review of this branch found that reading the rendered order off the DOM had traded one problem for another: rendered is not the same as reachable.
Escapeasked whether focus was in a text field, when the question is whether anything above the rail owns the press. Astryx's layer stack listens ondocument, below this handler in the bubble, and stands down on an already-defaultPrevented press — soEscapein a rename dialog cleared the set and left the dialog with no way to close.--color-neutralaway, which is why Astryx'sisSelectednames a system color; only its default branch had been copied, leaving picked rows unmarked in high contrast.One question was being answered in two selectors that only knew how to find a row.
isPickableRowanswers it once — ours to act on, and not inside aninertsubtree — and the list reads it everywhere. A row with no actions is now a plain navigation item all the way down: a modifier on it navigates rather than dying, and a right-click goes to the native menu.Known and left as-is, stated rather than hidden: the right-click menu is anchored on the ⋯ button rather than at the cursor, since
MoreMenutakes a placement but not a virtual anchor — the price of one menu behind two entrances.Refs #4365
Verification
Picking a set — same fixture, rendered on
mainand on this branch:The row menu:
Passing locally:
npm test(9731 tests),npm run typecheck,npm run build,npm run lint,npm run format:check,astryx:surface-inventory+:test,astryx:theme --check,knip(both workspaces),check:renderer-architecture,check:app-shell-hooks,check:asf-headers.Tests:
session-history-multi-select.test.tsxdrives 19 real clicks (which gesture picks what, that a modifier never opens a task, that a rail with no selection wired up is unchanged, menu adoption, counted verbs, and the third commit's cases below);session-navigation-selection.test.tscovers the reducer (anchors, ranges both directions, re-dragging from the same origin, ranges from the open task, pruning);session-selection-sweep-race.test.tsdrives the catalog around a sweep;parent-session-deletion.spec.tsnow archives in the rail, then deletes in Settings.Playwright: 99 passed, 4 skipped, including
session-rail-render-contract.spec.ts(2 rows per session switch), the check most relevant to the props-not-context decision.New story
Product/Sidebar Multi Select › PickedRun, inapps/desktop/storiesbecause the selection is the app's — it mounts the realSessionListPanelon the realuseSessionSelection, and its initial set comes from twocommands.pickcalls rather than a seeded fixture.A gap worth naming
Not a failure of #4365's review, and not something a reviewer could reasonably have been expected to catch — a structural blind spot in a gate we all rely on.
docs/astryx-surface-file-inventory.mdscores a file by what it imports:blockeris a rawbutton/input/select,reimplementationis a@maka/uiexport shadowing an Astryx component by name. A selection bar built fromButtonandCheckboxInputtherefore lands inaligned, correctly by those rules. A surface assembled from compliant parts into a shape the design system does not have cannot be seen by an import-level check.Storybook does not close it either:
git grepacross**/stories/**onmainfinds nothing touching the selection bar or any multi-select state.apps/desktop/stories/FIDELITY.mdgoverns whether an existing story is reachable; it does not ask a new surface to have one.Two closures, both decidable from a
.pathsdiff:docs/astryx-surface-file-inventory.pathsis a new product surface — ask the PR to name the Astryx pattern it instantiates, or say why the design system has none.FIDELITY.mdalready asks a story to name its real path.Out of scope here; raising it for whoever owns that gate.
Review focus
Whether picked rows and the open task sharing one ground is right. Taken from Claude Desktop and Codex, it means the rail distinguishes "the task you are reading" from "the tasks you picked" only by weight and by
aria-current.AI use
Select exactly one:
Tool(s) and scope: Claude Opus 5 via Claude Code — interaction design, implementation, tests and the story, under human review at each decision. All three commits carry
Generated-by: Claude Opus 5 via Claude Code.Checklist
Does this PR entail a change in behavior?