Skip to content

feat(desktop): select several tasks in the Session rail - #4365

Merged
likun666661 merged 8 commits into
apache:mainfrom
Joob1n:feat/session-rail-multi-select
Sep 1, 2026
Merged

feat(desktop): select several tasks in the Session rail#4365
likun666661 merged 8 commits into
apache:mainfrom
Joob1n:feat/session-rail-multi-select

Conversation

@Joob1n

@Joob1n Joob1n commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

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:

[按时间 | 按项目]
──────────────────────────
[▬]  已选 1 / 3        取消
[  归档  ]  [  删除  ]
最近
[✓]  cyberpet
[ ]  查询本机代理端口号
[ ]  yes, means one time…

The master box ticks and unticks every listed row and reads indeterminate in 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. purgeSessions becomes sweepSessions, 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 reusing SessionPurgeOutcome, which would carry a restored field 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 main added: it asks the Host for a previewRemoval per selected task before confirming, warns that ordinary subtasks will be kept and moved to Archived, and reports the Host's executed archivedSubtaskCount afterwards. 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:

  • A per-render array prop. Rows were handed their group's session ids so a Shift-click range could know where it may reach. SessionNavRow's memo compares 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 from rail.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.
  • 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. SessionRailRowSelection is 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/Backspace in 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

packages/ui         290 tests, 290 pass, 0 fail
apps/desktop       1824 tests, 1822 pass, 2 fail  (see below)
Desktop e2e         96 passed, 1 failed, 4 skipped  (see below)
check:renderer-architecture --base   pass
astryx surface inventory + tests     pass
knip (apps/desktop, packages/ui)     pass
lint, format:check, typecheck, build pass
check:app-shell-hooks, check:asf-headers, astryx:theme --check   pass

Two app-update-attestation cases (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 is composer-plus-menu-stability's "deleting the session while a toggle is pending" — I ran it three times on main, where it failed twice, so it is a pre-existing flake rather than this branch's.

Each change fails a test when reverted individually:

reverted result
unmark-the-submitted-set in the sweep's finally sweep-race 1 pass / 1 fail
subtask preview before the bulk delete confirm purge suite 20 pass / 2 fail
the checkbox / data-selected on the row ui 289 pass / 1 fail
per-task archived premise in the sweep deleteSessions fails
bulk archive's failure accounting archiveSessions fails
the selection model's toggle / master state / prune 10 cases across 3 suites

Self-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 on mousedown, so the real fix is user-select: none in CSS); and Escape/Delete jumping the input, 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:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

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-by trailers.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

@github-actions github-actions Bot added the effort/XL Over 1000 readable lines label Aug 31, 2026
@Joob1n
Joob1n force-pushed the feat/session-rail-multi-select branch 2 times, most recently from de8ce6c to d8af780 Compare August 31, 2026 17:01

@hqhq1025 hqhq1025 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() }));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

@Joob1n
Joob1n force-pushed the feat/session-rail-multi-select branch from d8af780 to 90625c3 Compare September 1, 2026 02:35
@Joob1n

Joob1n commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Both findings were real; both are fixed in 90625c38, and each has a test that fails when the fix is reverted. Rebased onto 9249bf3f first, which is where previewRemoval and archivedSubtaskCount arrived.

[P2] Old sweep clearing a newer selection. Reproduced exactly as described. The finally replaced the whole set rather than the one it submitted, so A's completion discarded B. It now removes only the submitted ids and leaves the rest alone; the mode still stays on afterwards. session-selection-sweep-race.test.ts drives leave → re-enter → mark B → settle A with a deferred command; reverting the fix fails it (1 pass / 1 fail).

[P2] Linked-subtask warning in bulk delete. Also real. deleteSelected now asks the Host for a previewRemoval per selected task before confirming, and a single preview failure makes the warning uncertain rather than silently under-reporting a destructive set — the renderer's projection cannot answer this, so estimating it here would over-promise. The sweep already accumulates archivedSubtasks; the toast reports that executed total, not the estimate. Four cases cover the warning, the uncertain fallback, the silent case, and a declined confirm; reverting fails two of them.

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 — main's destructured archivedSubtaskCount and this branch's per-id archived premise — so sweepSessions carries the count for the Settings purge and the rail alike.

Description rewritten. It advertised the ⌘/Shift-click gestures that d8af780 removed. It now describes the checkbox mode, states why the modifier gestures went (they were one of two causes of a #4109 render-contract regression this branch introduced and then fixed), and reports verification against the current head — including that the one remaining e2e failure, composer-plus-menu-stability, fails twice in three runs on main and is not this branch's.

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
@Joob1n
Joob1n force-pushed the feat/session-rail-multi-select branch from 90625c3 to bbdaeca Compare September 1, 2026 03:12
@Joob1n
Joob1n requested a review from hqhq1025 September 1, 2026 04:10
@likun666661
likun666661 merged commit 54015e4 into apache:main Sep 1, 2026
1 check passed
@Astro-Han

Copy link
Copy Markdown
Contributor

@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:

Rows were handed their group's session ids so a Shift-click range could know where it may reach. […] 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.

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 rail.sessions. #4455 removes the constraint instead of living with it: the rendered order is never a prop at all. 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. With that, a range costs the budget nothing and the mode is no longer the only shape available.

What #4455 keeps from your work, because it was right and is not specific to the mode:

  • A sweep unmarks the set it submitted, never whatever is picked when it lands — the P2 from this review. Carried over with its regression test.
  • Pruning the selection against the catalog, extended to the range anchor.
  • The two-cause diagnosis of the render budget. Both causes are why the picked set reaches rows as props in feat(desktop): pick session rows with shift and cmd #4455: a plain click now changes the selection, so a context the rows subscribed to would redraw ~1,000 fibers for a switch that moved two. Your analysis is what made that obvious.

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 Delete/Backspace on the marked set. Delete moves to Settings › 已归档任务, which can only reach a task archived first; select-all is not rebuilt because a range is one Shift-click. Your linked-subtask contract survives that move — previewRemoval still runs for the Settings purge, which now owns every deletion. What went away is the rail's entrance to it, not the accounting behind it.

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.

abhinav-phi pushed a commit to abhinav-phi/maka that referenced this pull request Sep 1, 2026
* 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XL Over 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants