Ctrl-clicking a second message selected only the second. The first stayed highlighted because it was the one open — a different state wearing a similar colour — and was never actually in the selection. So both looked picked, one was, and every action that followed applied to half of what the screen showed, without saying so.
Why
The cause is obvious once the two rules sit next to each other:
shift: select(ids.slice(start, end + 1), true) // the whole run, anchor included
ctrl: select([rowId], !selected[rowId]) // only the row clicked
Two branches of one handler that had drifted apart, with nothing asserting they agreed. mbunkus spotted exactly this — that shift-click follows conventional list semantics and ctrl-click doesn't.
The fix
Both rules are now one tested function (lib/listSelection.ts) rather than two branches and a hope:
Ctrl-click brings the current row with it while nothing is selected yet. Once there is a selection it toggles exactly one row, which is the whole point of it.
Shift-click is unchanged, including keeping its anchor put so extending a range twice grows it from the same place rather than from wherever it last reached.
The mobile rule (a plain tap goes on selecting once a selection exists) moved across unchanged.
Guarded against the cases that would misbehave: an anchor that has scrolled out of the list, an anchor equal to the row clicked, and no anchor at all.
Verification
npm run typecheck, npm test (579 pass, 15 new) and npm run build green.
By hand against dev:mock, reproducing the report first:
plain-click a message → opens, nothing checked
ctrl-click another → 2 checked, both the one that was open and the one clicked
a further ctrl-click → 3; ctrl-click it again → back to 2
shift-click over a run of four → 4 checked, contiguous, anchor included
Screenshot of the shift case attached in the conversation.
Note on the last test
It asserts the property that actually failed rather than the branches: whichever modifier begins a selection, the anchor is in it. The individual branches were each defensible on their own — what was missing was anything checking they agreed.
Fixes #186.
## What was wrong
Ctrl-clicking a second message selected **only the second**. The first stayed highlighted because it was the one *open* — a different state wearing a similar colour — and was never actually in the selection. So both looked picked, one was, and every action that followed applied to half of what the screen showed, without saying so.
## Why
The cause is obvious once the two rules sit next to each other:
```
shift: select(ids.slice(start, end + 1), true) // the whole run, anchor included
ctrl: select([rowId], !selected[rowId]) // only the row clicked
```
Two branches of one handler that had drifted apart, with nothing asserting they agreed. mbunkus spotted exactly this — that shift-click follows conventional list semantics and ctrl-click doesn't.
## The fix
Both rules are now one tested function (`lib/listSelection.ts`) rather than two branches and a hope:
- **Ctrl-click brings the current row with it** while nothing is selected yet. Once there *is* a selection it toggles exactly one row, which is the whole point of it.
- **Shift-click is unchanged**, including keeping its anchor put so extending a range twice grows it from the same place rather than from wherever it last reached.
- The mobile rule (a plain tap goes on selecting once a selection exists) moved across unchanged.
Guarded against the cases that would misbehave: an anchor that has scrolled out of the list, an anchor equal to the row clicked, and no anchor at all.
## Verification
`npm run typecheck`, `npm test` (579 pass, **15 new**) and `npm run build` green.
By hand against `dev:mock`, reproducing the report first:
- plain-click a message → opens, nothing checked
- ctrl-click another → **2 checked**, both the one that was open and the one clicked
- a further ctrl-click → 3; ctrl-click it again → back to 2
- shift-click over a run of four → 4 checked, contiguous, anchor included
Screenshot of the shift case attached in the conversation.
## Note on the last test
It asserts the property that actually failed rather than the branches: *whichever modifier begins a selection, the anchor is in it*. The individual branches were each defensible on their own — what was missing was anything checking they agreed.
**Merged** 2026-09-01 as coffey-labs/ihasmail@4912ea8ef4c1
<sub>Rebuilt from: git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Fixes #186.
What was wrong
Ctrl-clicking a second message selected only the second. The first stayed highlighted because it was the one open — a different state wearing a similar colour — and was never actually in the selection. So both looked picked, one was, and every action that followed applied to half of what the screen showed, without saying so.
Why
The cause is obvious once the two rules sit next to each other:
Two branches of one handler that had drifted apart, with nothing asserting they agreed. mbunkus spotted exactly this — that shift-click follows conventional list semantics and ctrl-click doesn't.
The fix
Both rules are now one tested function (
lib/listSelection.ts) rather than two branches and a hope:Guarded against the cases that would misbehave: an anchor that has scrolled out of the list, an anchor equal to the row clicked, and no anchor at all.
Verification
npm run typecheck,npm test(579 pass, 15 new) andnpm run buildgreen.By hand against
dev:mock, reproducing the report first:Screenshot of the shift case attached in the conversation.
Note on the last test
It asserts the property that actually failed rather than the branches: whichever modifier begins a selection, the anchor is in it. The individual branches were each defensible on their own — what was missing was anything checking they agreed.
Merged 2026-09-01 as coffey-labs/ihasmail@4912ea8ef4
Rebuilt from: git history, session transcript.