feat(desktop): select several tasks in the Session rail - #4365
Conversation
de8ce6c to
d8af780
Compare
hqhq1025
left a comment
There was a problem hiding this comment.
I found two user-visible correctness issues in the current head; details are inline. The branch is also currently CONFLICTING/DIRTY against main (8b0e43f7), and the conflict overlaps the newly merged Session-removal preview/count contract, so that contract needs to be preserved during the rebase.
The PR description is also stale: it still advertises Command/Ctrl-click and Shift-click selection, while commit d8af780 intentionally removed modifier-click gestures in favor of the explicit row-menu/checkbox mode. Please update the behavior and verification sections before final human review.
Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.
| // The MODE stays on. The person was in the middle of tidying up, and | ||
| // taking the checkboxes away after each sweep would make them re-enter | ||
| // for the next one. | ||
| setSelection((current) => ({ active: current.active, selectedIds: new Set() })); |
There was a problem hiding this comment.
[P2] Do not let an old sweep clear a newer selection
Done intentionally remains enabled while a bulk operation is running, but this finally clears whichever selection exists when the old request settles. A reachable sequence is: select A, start archive/delete, click Done after confirming, re-enter selection mode from B’s row menu, and select B before the Host operation finishes. When A’s request resolves, this updater replaces B with an empty set. I reproduced that sequence with a deferred archiveSelected command; the final selected set was [] instead of [B]. Track a selection/request generation (or clear only the submitted snapshot when it is still current) and add an async regression so completion from an earlier mode instance cannot mutate a later one.
| if (sessionIds.length === 0) return; | ||
| const ok = await toastApi.confirm({ | ||
| title: copy.bulkDeleteTitle(sessionIds.length), | ||
| description: copy.bulkDeleteDescription, |
There was a problem hiding this comment.
[P2] Preserve the linked-subtask warning in bulk delete
This confirmation only says that the selected tasks and their revisions are permanently deleted, and the success path only reports removed. However, the existing Host removal contract archives ordinary linked subagent tasks when their parent is deleted; the exact-head retirement test archives direct subagent Sessions when their parent family is removed passes and demonstrates that behavior. Current main now exposes previewRemoval plus archivedSubtaskCount specifically so destructive confirmations warn about these survivors and completion reports how many moved to Archived. Because this new bulk path bypasses both, deleting a selected parent can make its subtasks unexpectedly reappear under Archived with no warning or explanation. During the required rebase, aggregate the Host previews (including the uncertain fallback) before confirming and carry the executed archived-subtask counts through the sweep/result toast.
d8af780 to
90625c3
Compare
|
Both findings were real; both are fixed in [P2] Old sweep clearing a newer selection. Reproduced exactly as described. The [P2] Linked-subtask warning in bulk delete. Also real. One deliberate cost worth naming: this is N previews before a destructive confirm, one round trip per selected task. I kept it because the number the confirm shows has to be one the user can act on, and a set is exactly where a wrong number is least recoverable. The conflict resolution kept both halves of the line it landed on — Description rewritten. It advertised the ⌘/Shift-click gestures that |
The rail could act on one task at a time. Clearing out a run of finished ones meant opening the ⋯ menu, confirming, and repeating — once per task. ⌘/Ctrl-click marks a row instead of opening it, Shift-click marks a run, and a bar in the rail's sticky chrome offers archive and delete over the marked set. Escape clears it; Delete asks for the marked set rather than the focused row, because deleting one of several rows a user marked is the shape of an unrecoverable surprise. Nothing renders until something is marked, so the rail at rest is unchanged. A range never leaves its group. A project group's collapsed state lives inside Astryx's `SideNavItem` and is not readable from the rail, so a range across groups could quietly include rows nobody can see. Within one group the question does not arise: both endpoints had to be clicked, and a row that can be clicked is on screen. The selection is a THIRD rail context, for the reason the chrome is a second one. It changes on every modified click while the list does not, and folding it into `SessionRailData` would give that value a new identity per click — the ~1,000-fiber render the split exists to prevent (apache#4109). Rows read it directly rather than receiving it through the memoized row renderer, so a selection change re-renders the rows on screen and nothing above them. Both sweeps reuse the machinery the archived-task purge already had rather than copying its accounting. `purgeSessions` becomes `sweepSessions`, parameterized by how each id decides the archived premise: Settings asserts it for every target, and the rail reads it off the task exactly as single-row delete does, because the rail lists unarchived tasks and asserting it there would refuse them all. Bulk archive accounts by count and first failure instead, since archiving has no third disposition to report. Neither raises a toast per task: one sweep is one message. Generated-by: Claude Opus 5 via Claude Code
Review of the first design found it undiscoverable and, at the rail's real width, broken. ⌘/Shift-click is invisible to anyone who has not been told about it, and the bar put a count and three text buttons on one line: at 244px the buttons measured 228px and the count was squeezed to 12px, where it wrapped one character per line. Selection is now a mode. It is entered from the row's ⋯ menu — where a person already looks for what a row can do — and while it is on, every row carries a checkbox and a master box sits above them showing "已选 1 / 3". The master box ticks and unticks every listed row and reads `indeterminate` in between, which is the usual state during a selection and the one a checked/unchecked pair cannot express. "All" means the rows the rail is listing, not every task in the catalog. A box that silently reached past what sits under it would name a number the user never agreed to. Unticking every row is select-none, not leave: a mode that ended itself on the last untick would take the checkboxes away mid-gesture, and one mis-click would cost the user the way back in. Pruning to nothing keeps the mode for the same reason — the rows went away because the catalog changed, not because the user was finished. Escape and the 取消 button leave. The bar heads the LIST rather than the chrome, and moved out of SideNav's `topContent` to say so. Up there it landed above the one hairline SideNav draws under the whole sticky region — grouped with 按时间 / 按项目 and cut off from the rows it governs, with its own rule making a second line 9px from the first. It is `position: sticky` inside the scroller instead, so it still does not scroll away, over the rail's own ground colour. Two rows, not one, because one does not fit. The commands are `secondary` rather than `ghost`: a bare label beside a checkbox and a count does not read as something to press. Generated-by: Claude Opus 5 via Claude Code
CI's renderer architecture check refused the selection hook:
features/session-navigation/controller/use-session-selection.ts:
feature imports unbudgeted renderer legacy code: ../../../locales/shell-copy.js
A feature may not reach into renderer legacy copy without a line in
`renderer-architecture.json`, and `session-row-actions.ts` has one only
because it predates the rule. Adding a second entry would grow the ledger
the check exists to shrink, so the wording moved instead of the budget.
`archiveSelected` and `deleteSelected` now live in `session-row-actions`,
which already holds this feature's copy: each confirms, runs its sweep, and
reports the outcome. The bare sweeps below them stay silent, because
Settings' purge still phrases its own confirm — the caller that genuinely
owns different wording. The rail's phrasing was never that caller; it is
the feature's own, and it belongs where the feature keeps its strings.
The hook keeps what is actually its business: the marked set, freezing it
at the press, the busy flag, and clearing it afterwards while the mode
stays on. It no longer needs a locale or a toast API at all.
`npm run check:architecture` now passes locally, along with
check:app-shell-hooks and check:asf-headers — gates this branch had never
run before CI ran them for it.
Generated-by: Claude Opus 5 via Claude Code
CI's Astryx surface gate failed on the new file:
astryx surface inventory is stale
- on disk but not in .paths (1):
packages/ui/src/session-selection-bar.tsx
The inventory tracks every renderer file that renders Astryx components and
what each one reaches for, so a new component file has to be recorded. It
is generated, not hand-written: this is the output of
`npm run astryx:surface-inventory:write`, unedited.
The new file lands as `aligned` (Button, CheckboxInput), and
`session-history-list.tsx` gains `CheckboxInput` in its own row. No
blocker or reimplementation entries appear.
I had run the architecture gate this time but not this one. Rather than
find the next gate the same way, I enumerated every `npm run` CI invokes
and ran the ones this branch can affect: astryx:surface-inventory and its
tests, astryx:theme, check:renderer-architecture (base mode),
check:app-shell-hooks, check:asf-headers, format:check, lint, typecheck,
and the @maka/ui and @maka/desktop suites. All pass, and astryx:theme
leaves the tree clean.
Generated-by: Claude Opus 5 via Claude Code
CI's Knip gate found dead surface:
Unused exported types (1)
SessionSelectionGesture features/session-navigation/testing.ts
I exported it beside the selection model for tests to use, and then the
tests never did — they pass object literals, which TypeScript checks
structurally against `applySessionSelectionGesture`'s own parameter. The
type still exists where it is used; only the testing re-export is gone.
Third gate this branch has failed one at a time, so this time I read the
CI workflow and ran every step it invokes that this branch can affect:
knip for both workspaces, script-entrypoints, the app-shell hook gate and
its own test, astryx:theme --check, the app-icon drift tests, the Astryx
surface inventory and its tests, check:renderer-architecture in base mode,
check:asf-headers, lint, format:check, the full build, repo-wide typecheck,
and the @maka/ui and @maka/desktop suites. All pass, and `npm run build`
leaves the tree clean.
Generated-by: Claude Opus 5 via Claude Code
CI's e2e render contract caught a real regression, and it is the very
contract this branch claimed to respect:
switching sessions does not rewrite the whole Session rail
rail rows touched by one session switch, of 12
Expected: <= 2
Received: 12
Two independent causes, which is why fixing either alone left the count at
12 and why I measured them one at a time before believing either.
**A per-render array prop.** Rows were handed their group's session ids so
a Shift-click range could know where it may reach. Computed in the render —
even once per group rather than once per row — that array has a new
identity every time, and `SessionNavRow`'s `memo` compares props, not the
identity of the factory that produced them. Memoizing it does not help:
every candidate key is derived from `rail.sessions`, whose identity moves
on a session switch, so the memo rebuilds exactly when it must not.
The prop is gone, and with it the modifier-click gestures it existed for.
The rail's selection is checkboxes and a master box now; ⌘-click was the
earlier design's affordance and nothing in the current one needs a range.
`SessionSelectionGesture`, the range and anchor in the model, and
`sessionSelectionGestureMode` go with it.
**A context every row subscribes to.** A context consumer re-renders when
its value changes and `memo` cannot stop it, so a row reading the whole
selection re-rendered whenever `listedSessionIds` moved — again, on every
session switch. `SessionRailRowSelection` is now a separate, narrower
context holding only what a row needs, memoized on the selection alone; the
bar keeps the wide one.
Verified by running the spec locally, which this branch had never done:
failing at 12 before, `1 passed` after, and passing on `main` throughout.
Also rebased onto current main, where `@maka/ui`'s test script is now
`test:dist`.
Two `app-update-attestation` cases fail on this machine with and without
these changes — the packaged Electron runtime's TUF/Sigstore checks — so
they are environmental, not this branch's.
Generated-by: Claude Opus 5 via Claude Code
Two P2s from review, both reproduced before fixing and both covered by a test that fails when the fix is reverted. **A settled sweep cleared whatever was marked, not what it asked about.** `Done` stays enabled during a sweep because leaving asks nothing of the Host — so a person can leave the selection, re-enter from another row's menu and mark B while A's request is still out. The `finally` replaced the whole set with an empty one, discarding B to answer A's completion. It now removes exactly the submitted ids and leaves anything else alone; the mode still stays on. A deferred-command regression drives that sequence, and reverting the fix fails it. **Bulk delete bypassed the linked-subtask contract.** The Host archives a deleted parent's ordinary subagent tasks rather than deleting them, and `main` now exposes `previewRemoval` plus `archivedSubtaskCount` so a destructive confirm can warn about the survivors and the toast can report how many moved. Single-row delete uses both; this new path used neither, so deleting a selected parent made its subtasks reappear under Archived with no warning and no explanation. `deleteSelected` now asks the Host for a preview per selected task before confirming — the renderer's projection cannot answer it — and a single preview failure makes the warning uncertain rather than silently under-reporting a destructive set. The sweep already accumulated `archivedSubtasks` once rebased; the toast reports that executed total, not the estimate. Four cases cover the warning, the uncertain fallback, the silent case, and a declined confirm. Rebased onto `9249bf3f`, which is where those Host affordances arrived. The conflicts were the two halves of one line — main's destructured `archivedSubtaskCount` and this branch's per-id archived premise — and two additive copy blocks. Generated-by: Claude Opus 5 via Claude Code
CI failed on the surface gate with only the `.md` stale:
- docs/astryx-surface-file-inventory.md does not match generator output
The diff is one line — `232 files … aligned 231` becomes `233 … 232`. A
file that renders Astryx arrived on `main` while this branch was out, and
`.paths` came with it; the totals line in the table did not.
Worth naming because it is a shape this branch has now hit twice: the gate
passed locally and failed in CI, because CI checks the branch MERGED with
current `main` and I was four commits behind. Running the gates on an
un-rebased branch proves less than it looks like it does. Rebased onto
`8fa1e894` and regenerated with `astryx:surface-inventory:write`.
Generated-by: Claude Opus 5 via Claude Code
90625c3 to
bbdaeca
Compare
|
@Joob1n — I've opened #4455 on top of this, and I'd rather you heard it here than found it in the commit log. Requesting your review there; nothing in it is settled. The short version: #4455 is a continuation of this PR, not a reversal of it. Its first revision was the interaction #4455 now ships, and the description above records why it was given up:
The gestures weren't judged worse than a mode; they cost the #4109 budget 12 of 12, and no memoisation fixes that, because every candidate key derives from What #4455 keeps from your work, because it was right and is not specific to the mode:
What it deliberately narrows, so it is on the record rather than buried in a diff: the rail no longer offers bulk delete, select-all, or Doing this a day after your PR landed is the cheapest moment, not a comment on the work: nothing depends on the mode's API yet and no user has built a habit on it. But you know this code better than anyone right now, and the six interaction decisions in #4455 were each settled separately — any of them can be reopened, including the one that matters most to me, that picked rows and the open task share a single ground. |
* feat(desktop): select several tasks in the Session rail The rail could act on one task at a time. Clearing out a run of finished ones meant opening the ⋯ menu, confirming, and repeating — once per task. ⌘/Ctrl-click marks a row instead of opening it, Shift-click marks a run, and a bar in the rail's sticky chrome offers archive and delete over the marked set. Escape clears it; Delete asks for the marked set rather than the focused row, because deleting one of several rows a user marked is the shape of an unrecoverable surprise. Nothing renders until something is marked, so the rail at rest is unchanged. A range never leaves its group. A project group's collapsed state lives inside Astryx's `SideNavItem` and is not readable from the rail, so a range across groups could quietly include rows nobody can see. Within one group the question does not arise: both endpoints had to be clicked, and a row that can be clicked is on screen. The selection is a THIRD rail context, for the reason the chrome is a second one. It changes on every modified click while the list does not, and folding it into `SessionRailData` would give that value a new identity per click — the ~1,000-fiber render the split exists to prevent (apache#4109). Rows read it directly rather than receiving it through the memoized row renderer, so a selection change re-renders the rows on screen and nothing above them. Both sweeps reuse the machinery the archived-task purge already had rather than copying its accounting. `purgeSessions` becomes `sweepSessions`, parameterized by how each id decides the archived premise: Settings asserts it for every target, and the rail reads it off the task exactly as single-row delete does, because the rail lists unarchived tasks and asserting it there would refuse them all. Bulk archive accounts by count and first failure instead, since archiving has no third disposition to report. Neither raises a toast per task: one sweep is one message. Generated-by: Claude Opus 5 via Claude Code * feat(desktop): make the rail's multi-select an explicit mode Review of the first design found it undiscoverable and, at the rail's real width, broken. ⌘/Shift-click is invisible to anyone who has not been told about it, and the bar put a count and three text buttons on one line: at 244px the buttons measured 228px and the count was squeezed to 12px, where it wrapped one character per line. Selection is now a mode. It is entered from the row's ⋯ menu — where a person already looks for what a row can do — and while it is on, every row carries a checkbox and a master box sits above them showing "已选 1 / 3". The master box ticks and unticks every listed row and reads `indeterminate` in between, which is the usual state during a selection and the one a checked/unchecked pair cannot express. "All" means the rows the rail is listing, not every task in the catalog. A box that silently reached past what sits under it would name a number the user never agreed to. Unticking every row is select-none, not leave: a mode that ended itself on the last untick would take the checkboxes away mid-gesture, and one mis-click would cost the user the way back in. Pruning to nothing keeps the mode for the same reason — the rows went away because the catalog changed, not because the user was finished. Escape and the 取消 button leave. The bar heads the LIST rather than the chrome, and moved out of SideNav's `topContent` to say so. Up there it landed above the one hairline SideNav draws under the whole sticky region — grouped with 按时间 / 按项目 and cut off from the rows it governs, with its own rule making a second line 9px from the first. It is `position: sticky` inside the scroller instead, so it still does not scroll away, over the rail's own ground colour. Two rows, not one, because one does not fit. The commands are `secondary` rather than `ghost`: a bare label beside a checkbox and a count does not read as something to press. Generated-by: Claude Opus 5 via Claude Code * fix(desktop): keep the rail's bulk wording inside its budgeted module CI's renderer architecture check refused the selection hook: features/session-navigation/controller/use-session-selection.ts: feature imports unbudgeted renderer legacy code: ../../../locales/shell-copy.js A feature may not reach into renderer legacy copy without a line in `renderer-architecture.json`, and `session-row-actions.ts` has one only because it predates the rule. Adding a second entry would grow the ledger the check exists to shrink, so the wording moved instead of the budget. `archiveSelected` and `deleteSelected` now live in `session-row-actions`, which already holds this feature's copy: each confirms, runs its sweep, and reports the outcome. The bare sweeps below them stay silent, because Settings' purge still phrases its own confirm — the caller that genuinely owns different wording. The rail's phrasing was never that caller; it is the feature's own, and it belongs where the feature keeps its strings. The hook keeps what is actually its business: the marked set, freezing it at the press, the busy flag, and clearing it afterwards while the mode stays on. It no longer needs a locale or a toast API at all. `npm run check:architecture` now passes locally, along with check:app-shell-hooks and check:asf-headers — gates this branch had never run before CI ran them for it. Generated-by: Claude Opus 5 via Claude Code * docs(desktop): record the selection bar in the Astryx surface inventory CI's Astryx surface gate failed on the new file: astryx surface inventory is stale - on disk but not in .paths (1): packages/ui/src/session-selection-bar.tsx The inventory tracks every renderer file that renders Astryx components and what each one reaches for, so a new component file has to be recorded. It is generated, not hand-written: this is the output of `npm run astryx:surface-inventory:write`, unedited. The new file lands as `aligned` (Button, CheckboxInput), and `session-history-list.tsx` gains `CheckboxInput` in its own row. No blocker or reimplementation entries appear. I had run the architecture gate this time but not this one. Rather than find the next gate the same way, I enumerated every `npm run` CI invokes and ran the ones this branch can affect: astryx:surface-inventory and its tests, astryx:theme, check:renderer-architecture (base mode), check:app-shell-hooks, check:asf-headers, format:check, lint, typecheck, and the @maka/ui and @maka/desktop suites. All pass, and astryx:theme leaves the tree clean. Generated-by: Claude Opus 5 via Claude Code * chore(desktop): stop exporting an unused selection type CI's Knip gate found dead surface: Unused exported types (1) SessionSelectionGesture features/session-navigation/testing.ts I exported it beside the selection model for tests to use, and then the tests never did — they pass object literals, which TypeScript checks structurally against `applySessionSelectionGesture`'s own parameter. The type still exists where it is used; only the testing re-export is gone. Third gate this branch has failed one at a time, so this time I read the CI workflow and ran every step it invokes that this branch can affect: knip for both workspaces, script-entrypoints, the app-shell hook gate and its own test, astryx:theme --check, the app-icon drift tests, the Astryx surface inventory and its tests, check:renderer-architecture in base mode, check:asf-headers, lint, format:check, the full build, repo-wide typecheck, and the @maka/ui and @maka/desktop suites. All pass, and `npm run build` leaves the tree clean. Generated-by: Claude Opus 5 via Claude Code * fix(desktop): keep one session switch off every rail row CI's e2e render contract caught a real regression, and it is the very contract this branch claimed to respect: switching sessions does not rewrite the whole Session rail rail rows touched by one session switch, of 12 Expected: <= 2 Received: 12 Two independent causes, which is why fixing either alone left the count at 12 and why I measured them one at a time before believing either. **A per-render array prop.** Rows were handed their group's session ids so a Shift-click range could know where it may reach. Computed in the render — even once per group rather than once per row — that array has a new identity every time, and `SessionNavRow`'s `memo` compares props, not the identity of the factory that produced them. Memoizing it does not help: every candidate key is derived from `rail.sessions`, whose identity moves on a session switch, so the memo rebuilds exactly when it must not. The prop is gone, and with it the modifier-click gestures it existed for. The rail's selection is checkboxes and a master box now; ⌘-click was the earlier design's affordance and nothing in the current one needs a range. `SessionSelectionGesture`, the range and anchor in the model, and `sessionSelectionGestureMode` go with it. **A context every row subscribes to.** A context consumer re-renders when its value changes and `memo` cannot stop it, so a row reading the whole selection re-rendered whenever `listedSessionIds` moved — again, on every session switch. `SessionRailRowSelection` is now a separate, narrower context holding only what a row needs, memoized on the selection alone; the bar keeps the wide one. Verified by running the spec locally, which this branch had never done: failing at 12 before, `1 passed` after, and passing on `main` throughout. Also rebased onto current main, where `@maka/ui`'s test script is now `test:dist`. Two `app-update-attestation` cases fail on this machine with and without these changes — the packaged Electron runtime's TUF/Sigstore checks — so they are environmental, not this branch's. Generated-by: Claude Opus 5 via Claude Code * fix(desktop): answer both review findings on the rail's bulk sweeps Two P2s from review, both reproduced before fixing and both covered by a test that fails when the fix is reverted. **A settled sweep cleared whatever was marked, not what it asked about.** `Done` stays enabled during a sweep because leaving asks nothing of the Host — so a person can leave the selection, re-enter from another row's menu and mark B while A's request is still out. The `finally` replaced the whole set with an empty one, discarding B to answer A's completion. It now removes exactly the submitted ids and leaves anything else alone; the mode still stays on. A deferred-command regression drives that sequence, and reverting the fix fails it. **Bulk delete bypassed the linked-subtask contract.** The Host archives a deleted parent's ordinary subagent tasks rather than deleting them, and `main` now exposes `previewRemoval` plus `archivedSubtaskCount` so a destructive confirm can warn about the survivors and the toast can report how many moved. Single-row delete uses both; this new path used neither, so deleting a selected parent made its subtasks reappear under Archived with no warning and no explanation. `deleteSelected` now asks the Host for a preview per selected task before confirming — the renderer's projection cannot answer it — and a single preview failure makes the warning uncertain rather than silently under-reporting a destructive set. The sweep already accumulated `archivedSubtasks` once rebased; the toast reports that executed total, not the estimate. Four cases cover the warning, the uncertain fallback, the silent case, and a declined confirm. Rebased onto `9249bf3f`, which is where those Host affordances arrived. The conflicts were the two halves of one line — main's destructured `archivedSubtaskCount` and this branch's per-id archived premise — and two additive copy blocks. Generated-by: Claude Opus 5 via Claude Code * chore(docs): refresh the Astryx surface inventory total CI failed on the surface gate with only the `.md` stale: - docs/astryx-surface-file-inventory.md does not match generator output The diff is one line — `232 files … aligned 231` becomes `233 … 232`. A file that renders Astryx arrived on `main` while this branch was out, and `.paths` came with it; the totals line in the table did not. Worth naming because it is a shape this branch has now hit twice: the gate passed locally and failed in CI, because CI checks the branch MERGED with current `main` and I was four commits behind. Running the gates on an un-rebased branch proves less than it looks like it does. Rebased onto `8fa1e894` and regenerated with `astryx:surface-inventory:write`. Generated-by: Claude Opus 5 via Claude Code
Summary
The Session rail could act on one task at a time. Clearing out a run of finished tasks meant opening the ⋯ menu, confirming, and repeating — once per task.
The rail now has a selection mode. It is entered from a row's ⋯ menu, where a person already looks for what a row can do, and entering marks that row: they picked a row, not an abstract mode. While it is on, every row carries a checkbox and a master row sits above them:
The master box ticks and unticks every listed row and reads
indeterminatein between — the usual state during a selection, and the one a checked/unchecked pair cannot express. Escape and 取消 leave. Nothing renders until the mode is on, so the rail at rest is unchanged, and a surface that never wires it up gains no chrome.Four decisions are worth stating, because each is a constraint rather than a preference.
"All" means the rows the rail is listing. Not every task in the catalog: a box that silently reached past what sits under it would name a number the user never agreed to.
Unticking every row is select-none, not leave. A mode that ended itself on the last untick would take the checkboxes away mid-gesture, and one mis-click would cost the user the way back in. Pruning to nothing keeps the mode for the same reason — the rows went away because the catalog changed, not because the user was finished.
A row click still opens the task while the mode is on. The box is the selection affordance; making the whole row a toggle would cost the rail the one thing it is for.
Both sweeps reuse the purge machinery instead of copying its accounting.
purgeSessionsbecomessweepSessions, parameterized by how each id decides the archived premise: Settings asserts it for every target; the rail reads it off the task exactly as single-row delete does, because the rail lists unarchived tasks and asserting it there would refuse every deletion the rail can ask for. Bulk archive accounts by count and first failure instead of reusingSessionPurgeOutcome, which would carry arestoredfield that can never be anything but empty. Neither raises a toast per task: one sweep is one message.Bulk delete carries the linked-subtask contract that
mainadded: it asks the Host for apreviewRemovalper selected task before confirming, warns that ordinary subtasks will be kept and moved to Archived, and reports the Host's executedarchivedSubtaskCountafterwards. A single preview failure makes the warning uncertain rather than silently under-reporting a destructive set.On the existing bulk pattern
The project's other multi-item surface, Settings › archived tasks, is filter-then-sweep: narrow with the search box, then purge everything visible. That is deliberate and its reasoning is sound ("Frozen at the click"), but it does not transfer here. The rail has no search box, its rows are grouped rather than listed, and the set a user wants to clear out is usually a handful of scattered rows rather than a prefix of a filtered list. Marking is the only way to name that set. The two patterns now coexist and share one sweep underneath.
Render contract
The rail's #4109 budget — one session switch must touch at most 2 rows — is enforced by
e2e/session-rail-render-contract.spec.ts, and an earlier revision of this branch broke it at 12 of 12. Two independent causes, which is why fixing either alone left the count unchanged:SessionNavRow'smemocompares props, not the identity of the factory that produced them, so a fresh array is a changed prop for every row. Memoizing it does not help: every candidate key derives fromrail.sessions, whose identity moves on a session switch. The prop is gone, and with it the modifier-click gestures it existed for — the mode is checkboxes now and nothing in it needs a range.memocannot stop it, so a row reading the whole selection re-rendered wheneverlistedSessionIdsmoved.SessionRailRowSelectionis a separate, narrower context holding only what a row needs, memoized on the selection alone; the bar keeps the wide one.The spec passes on this branch and on
main; it failed at 12 in between.Behavior change
An unmodified click on a rail row still opens the task. With rows marked,
Delete/Backspacein the rail deletes the marked set behind a confirm instead of the focused row — the only change to an existing keyboard behaviour, and only while a selection exists.Verification
Two
app-update-attestationcases (TUF/Sigstore in the packaged Electron runtime) fail on my machine with and without this branch's changes, so they are environmental. The one e2e failure iscomposer-plus-menu-stability's "deleting the session while a toggle is pending" — I ran it three times onmain, where it failed twice, so it is a pre-existing flake rather than this branch's.Each change fails a test when reverted individually:
finallydata-selectedon the rowdeleteSessionsfailsarchiveSessionsfailsSelf-review
Reviewing the diff before opening this turned up four defects, all fixed: a per-row array allocation that defeated the row memo; a comment left describing code it no longer sat above; a
preventDefault()justified by the wrong event phase (text selection starts onmousedown, so the real fix isuser-select: nonein CSS); and Escape/Delete jumping theinput, textarea, [contenteditable]guard, which would have cleared the selection behind an open rename dialog.Review then found two more, both reproduced before fixing — they are the subject of the most recent commit and are described under Summary.
AI use
Select exactly one:
Tool(s) and scope: Claude Opus 5 via Claude Code — design, implementation, tests, and this description. Reviewed and verified locally by me; the commits carry
Generated-bytrailers.Checklist
Does this PR entail a change in behavior?