feat(sidebar): multi-select tasks with bulk archive#2203
Conversation
Cmd/Ctrl-click toggles a task in the selection; shift-click extends a range from the last clicked anchor. Right-clicking a task while 2+ are selected opens a bulk context menu offering "Archive N tasks" with a confirm dialog; right-clicking outside the selection clears it and falls through to the single-task menu. Escape (when no input is focused) clears the selection. The selection is held in a small Zustand store mirroring the inbox pattern, with a prune effect to drop ids that are no longer visible (e.g. after filters change). archiveTaskImperative gets a sibling archiveTasksImperative helper used by both the new bulk path and the existing "Archive prior tasks" action. Generated-By: PostHog Code Task-Id: a599e7e3-7c09-44dc-a847-551d283c9eb4
…chor
Plain click clears lastClickedId, so a follow-up shift-click had no
anchor and only selected the target. Pass the active/routed task as a
fallback anchor to selectRange so shift-click after a navigation
selects the range from the current task to the target.
The active task is also now treated as implicit member of any bulk
selection — the count in the bulk context menu, the bulk archive set,
and the hide-hover-actions visual all use the effective set of
{active, ...selected}. This matches user expectation: with task A
routed and B, C cmd-clicked, right-click reports "Archive 3 tasks".
Generated-By: PostHog Code
Task-Id: a599e7e3-7c09-44dc-a847-551d283c9eb4
Range selection was indexed against the chronological flat list, but in by-project mode the user sees tasks grouped per repo. Shift-click from the first to the third visible task in a group would span across all interleaved chronological tasks from other groups — e.g. picking 3 tasks visually but reporting 14 in the bulk menu. Now build an orderedVisibleTaskIds list that matches the rendered order: pinned, then either grouped-by-project (skipping collapsed sections) or chronological flat. Pass that to selectRange so the range matches what the user sees. Generated-By: PostHog Code Task-Id: a599e7e3-7c09-44dc-a847-551d283c9eb4
Prompt To Fix All With AIFix the following 3 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 3
apps/code/src/renderer/features/sidebar/stores/taskSelectionStore.test.ts:90-112
The "forward range" and "backward range" tests are symmetric cases testing the same logical property (range is order-independent) and are a natural fit for a parameterised test. Per the team's simplicity rules, we prefer `it.each` for cases like this.
```suggestion
it.each([
{ direction: "forward", anchor: "t2", target: "t4" },
{ direction: "backward", anchor: "t4", target: "t2" },
])(
"selects a $direction range from anchor to target",
({ anchor, target }) => {
useTaskSelectionStore.setState({ lastClickedId: anchor });
useTaskSelectionStore.getState().selectRange(target, orderedIds);
expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual([
"t2",
"t3",
"t4",
]);
},
);
```
### Issue 2 of 3
apps/code/src/renderer/features/sidebar/stores/taskSelectionStore.test.ts:130-142
These two cases both assert "selects just the target" with the only difference being whether `lastClickedId` is absent vs. points at an id not in the ordered list. They're a clean `it.each` opportunity.
```suggestion
it.each([
{ case: "no anchor", lastClickedId: null },
{ case: "anchor not in ordered list", lastClickedId: "t99" },
])(
"selects just the target when $case",
({ lastClickedId }) => {
if (lastClickedId) {
useTaskSelectionStore.setState({ lastClickedId });
}
useTaskSelectionStore.getState().selectRange("t3", orderedIds);
expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual(["t3"]);
},
);
```
### Issue 3 of 3
apps/code/src/main/services/context-menu/schemas.ts:13-15
The schema uses `positive()` (≥ 1), but the context menu label renders `"Archive ${taskCount} tasks"` — if `taskCount` is 1 that reads "Archive 1 tasks". Changing to `.min(2)` also makes the schema self-document that this endpoint is intentionally bulk-only.
```suggestion
export const bulkTaskContextMenuInput = z.object({
taskCount: z.number().int().min(2),
});
```
Reviews (1): Last reviewed commit: "fix(sidebar): shift-click range follows ..." | Re-trigger Greptile |
| it("selects a forward range from anchor to target", () => { | ||
| useTaskSelectionStore.setState({ lastClickedId: "t2" }); | ||
|
|
||
| useTaskSelectionStore.getState().selectRange("t4", orderedIds); | ||
|
|
||
| expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual([ | ||
| "t2", | ||
| "t3", | ||
| "t4", | ||
| ]); | ||
| }); | ||
|
|
||
| it("selects a backward range from anchor to target", () => { | ||
| useTaskSelectionStore.setState({ lastClickedId: "t4" }); | ||
|
|
||
| useTaskSelectionStore.getState().selectRange("t2", orderedIds); | ||
|
|
||
| expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual([ | ||
| "t2", | ||
| "t3", | ||
| "t4", | ||
| ]); | ||
| }); |
There was a problem hiding this comment.
The "forward range" and "backward range" tests are symmetric cases testing the same logical property (range is order-independent) and are a natural fit for a parameterised test. Per the team's simplicity rules, we prefer
it.each for cases like this.
| it("selects a forward range from anchor to target", () => { | |
| useTaskSelectionStore.setState({ lastClickedId: "t2" }); | |
| useTaskSelectionStore.getState().selectRange("t4", orderedIds); | |
| expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual([ | |
| "t2", | |
| "t3", | |
| "t4", | |
| ]); | |
| }); | |
| it("selects a backward range from anchor to target", () => { | |
| useTaskSelectionStore.setState({ lastClickedId: "t4" }); | |
| useTaskSelectionStore.getState().selectRange("t2", orderedIds); | |
| expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual([ | |
| "t2", | |
| "t3", | |
| "t4", | |
| ]); | |
| }); | |
| it.each([ | |
| { direction: "forward", anchor: "t2", target: "t4" }, | |
| { direction: "backward", anchor: "t4", target: "t2" }, | |
| ])( | |
| "selects a $direction range from anchor to target", | |
| ({ anchor, target }) => { | |
| useTaskSelectionStore.setState({ lastClickedId: anchor }); | |
| useTaskSelectionStore.getState().selectRange(target, orderedIds); | |
| expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual([ | |
| "t2", | |
| "t3", | |
| "t4", | |
| ]); | |
| }, | |
| ); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/features/sidebar/stores/taskSelectionStore.test.ts
Line: 90-112
Comment:
The "forward range" and "backward range" tests are symmetric cases testing the same logical property (range is order-independent) and are a natural fit for a parameterised test. Per the team's simplicity rules, we prefer `it.each` for cases like this.
```suggestion
it.each([
{ direction: "forward", anchor: "t2", target: "t4" },
{ direction: "backward", anchor: "t4", target: "t2" },
])(
"selects a $direction range from anchor to target",
({ anchor, target }) => {
useTaskSelectionStore.setState({ lastClickedId: anchor });
useTaskSelectionStore.getState().selectRange(target, orderedIds);
expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual([
"t2",
"t3",
"t4",
]);
},
);
```
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| it("selects just the target when there is no anchor", () => { | ||
| useTaskSelectionStore.getState().selectRange("t3", orderedIds); | ||
|
|
||
| expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual(["t3"]); | ||
| }); | ||
|
|
||
| it("selects just the target when anchor is not in the ordered list", () => { | ||
| useTaskSelectionStore.setState({ lastClickedId: "t99" }); | ||
|
|
||
| useTaskSelectionStore.getState().selectRange("t3", orderedIds); | ||
|
|
||
| expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual(["t3"]); | ||
| }); |
There was a problem hiding this comment.
These two cases both assert "selects just the target" with the only difference being whether
lastClickedId is absent vs. points at an id not in the ordered list. They're a clean it.each opportunity.
| it("selects just the target when there is no anchor", () => { | |
| useTaskSelectionStore.getState().selectRange("t3", orderedIds); | |
| expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual(["t3"]); | |
| }); | |
| it("selects just the target when anchor is not in the ordered list", () => { | |
| useTaskSelectionStore.setState({ lastClickedId: "t99" }); | |
| useTaskSelectionStore.getState().selectRange("t3", orderedIds); | |
| expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual(["t3"]); | |
| }); | |
| it.each([ | |
| { case: "no anchor", lastClickedId: null }, | |
| { case: "anchor not in ordered list", lastClickedId: "t99" }, | |
| ])( | |
| "selects just the target when $case", | |
| ({ lastClickedId }) => { | |
| if (lastClickedId) { | |
| useTaskSelectionStore.setState({ lastClickedId }); | |
| } | |
| useTaskSelectionStore.getState().selectRange("t3", orderedIds); | |
| expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual(["t3"]); | |
| }, | |
| ); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/features/sidebar/stores/taskSelectionStore.test.ts
Line: 130-142
Comment:
These two cases both assert "selects just the target" with the only difference being whether `lastClickedId` is absent vs. points at an id not in the ordered list. They're a clean `it.each` opportunity.
```suggestion
it.each([
{ case: "no anchor", lastClickedId: null },
{ case: "anchor not in ordered list", lastClickedId: "t99" },
])(
"selects just the target when $case",
({ lastClickedId }) => {
if (lastClickedId) {
useTaskSelectionStore.setState({ lastClickedId });
}
useTaskSelectionStore.getState().selectRange("t3", orderedIds);
expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual(["t3"]);
},
);
```
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| export const bulkTaskContextMenuInput = z.object({ | ||
| taskCount: z.number().int().positive(), | ||
| }); |
There was a problem hiding this comment.
The schema uses
positive() (≥ 1), but the context menu label renders "Archive ${taskCount} tasks" — if taskCount is 1 that reads "Archive 1 tasks". Changing to .min(2) also makes the schema self-document that this endpoint is intentionally bulk-only.
| export const bulkTaskContextMenuInput = z.object({ | |
| taskCount: z.number().int().positive(), | |
| }); | |
| export const bulkTaskContextMenuInput = z.object({ | |
| taskCount: z.number().int().min(2), | |
| }); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/main/services/context-menu/schemas.ts
Line: 13-15
Comment:
The schema uses `positive()` (≥ 1), but the context menu label renders `"Archive ${taskCount} tasks"` — if `taskCount` is 1 that reads "Archive 1 tasks". Changing to `.min(2)` also makes the schema self-document that this endpoint is intentionally bulk-only.
```suggestion
export const bulkTaskContextMenuInput = z.object({
taskCount: z.number().int().min(2),
});
```
How can I resolve this? If you propose a fix, please make it concise.
Summary
taskSelectionStore) modeled after the inbox pattern, with a prune effect that drops ids no longer visible when filters change.archiveTaskImperativegets a siblingarchiveTasksImperativehelper used by both the new bulk path and the existing "Archive prior tasks" action.Test plan
pnpm --filter code typecheck, biome, and vitest (43 tests in sidebar/tasks) all pass