Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions .ai/runs/2026-08-23-task-list-filters-bulk-edit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# Execution plan β€” project Tasks list: status filter, reference search, multi-select bulk edit

**Date:** 2026-08-23
**Slug:** `task-list-filters-bulk-edit`
**Branch:** `feat/task-list-filters-bulk-edit`
**Engine:** `om-auto-create-pr (steps: 12, --loop: no)`

## 🎯 Goal

The per-project Tasks list (`/p/:projectId/`, `packages/web/src/routes/tasks-overview.tsx`) can only
be narrowed by the Active/Archived tabs and a free-text box that searches title, branch and
workflow. Three things are missing and all three were asked for: filtering by **status**, finding a
task by its **PR or issue number**, and **selecting rows to edit them together** (archive being the
motivating case). The global cross-project page (`/tasks`) already has facet filters; the project
page β€” the one people actually live in β€” does not.

This run brings the project Tasks list up to that bar: a status facet filter with live counts,
reference-number search, and a selection column plus a bulk action bar that can archive, restore,
and mark read/unread any number of selected tasks at once.

## Scope

- `packages/web/src/lib/tasks-table.ts` β€” the pure filter half: reference-aware search, the
status-facet model (options, counts, active-filter count).
- `packages/web/src/lib/task-selection.ts` (new) β€” the pure selection half: toggling, select-all,
pruning a selection to what is still visible, and which bulk actions a selection supports.
- `packages/web/src/routes/tasks-overview.tsx` β€” the filter bar, the checkbox column, the bulk
action bar, and the route-level mutations that fan the bulk action out per run.
- Unit tests beside each of the above (`*.test.ts` / `*.test.tsx`), the repo's convention.

### Non-goals

- **No server or contract change.** Every action already has an endpoint
(`POST /runs/:id/archive`, `/read`, `/unread`); a bulk action fans out client-side over the
selected ids. A dedicated batch route would be a contract change for a local server answering
a handful of requests β€” not worth it, and reversible if it ever is.
- **No URL-persisted filter state** on this page. The global page keeps its filters in the URL
because a cross-project view is a thing people share; the project list's existing search is
local component state and stays that way, so the route's test surface and props do not change
shape. Revisit if the filters grow.
- **No workflow / branch / tag facets.** Status is what was asked for; more facets are one entry
each in the same bar once they are wanted.
- **No bulk delete or bulk cancel.** Both are destructive in a way archive is not, and neither
was asked for.
- The sidebar quick-list and the global `/tasks` page are untouched.

## Implementation Plan

### Phase 1 β€” the pure filter model

Extend `lib/tasks-table.ts`, which is already "the pure half of the Tasks table", rather than
inventing a second module the component would have to consult separately.

1.1 Teach the search box PR and issue numbers: `#909`, `909`, `pr 909`, `issue 42` all match the
references `taskReferences()` resolves for a run. Numeric-only needles must not start matching
random digits inside a title β€” the reference match is an *additional* haystack, not a
replacement.
1.2 Add the status-facet model: a `TaskListFilters` shape (`{ query, statuses }`), a
`filterTaskList()` that ANDs the facet with the search, `statusFacetOptions()` producing
options with counts computed against the list as the *other* narrowings leave it (the same
rule the global page's counts follow), and `activeFilterCount()` for the Clear affordance.

### Phase 2 β€” the pure selection model

2.1 New `lib/task-selection.ts`: `toggleSelected`, `selectAll`/`clearSelection`,
`pruneSelection` (a selection must never keep ids that scrolled out of the view or got
archived under it), `selectionState` for the header checkbox's three states, and
`bulkActionsFor(selectedRuns)` deciding which of archive / restore / mark-read / mark-unread
apply and to how many rows.

### Phase 3 β€” the filter bar on the Tasks table

3.1 Render a Status `FacetFilter` (the existing shared component) in the Tasks header, wire it to
local state beside the existing `query`, and add a Clear control that resets both.
3.2 Update the empty state so "no tasks match" is reported when a *facet* narrowed the list to
nothing, not only when a search string did, and update the search box's placeholder/aria to
say numbers are searchable.
3.3 Tests for the bar: filtering by status, combined status + text, counts, clear, empty state.

### Phase 4 β€” selection and the bulk action bar

4.1 A selection column: a header checkbox (all / none / indeterminate) and a per-row checkbox on
the desktop table, plus the same affordance on the `<md` cards so mobile is not left behind.
Row-click navigation must not fire when the click lands on the checkbox.
4.2 The bulk action bar: appears only with a selection, names the count, offers Archive, Restore,
Mark read, Mark unread β€” each disabled when no selected row can take it β€” and a Clear.
4.3 Route wiring: one mutation that fans the chosen action out over the selected ids with
`Promise.allSettled`, invalidates the runs query once, and reports partial failure honestly in
a toast rather than silently dropping it.
4.4 Tests: selection mechanics, select-all over the filtered view, action gating, the fan-out
(including a partial failure), and that navigation is not hijacked.

### Phase 5 β€” validation and reporting

5.1 Full `validation.commands` gate: `npm run typecheck`, `npm test`, `npm run test:unit`,
`npm run build`, `npm run test:package`.
5.2 `om-auto-review-pr --autofix`, then the summary comment.

## Risks

- **Row-click hijacking.** The table row navigates on click and already excludes
`a, button, input`; a checkbox is an `input`, so it is covered β€” but the card list's guard only
excludes `a`, and that one must be widened or the first tap on a card checkbox would navigate
away. Covered by a test.
- **A stale selection.** Ids selected before a filter change, an archive, or an SSE patch can
reference rows that are no longer on screen; acting on them would archive something the user
cannot see. `pruneSelection` against the visible list is the guard, and it is tested.
- **Partial bulk failure.** N independent requests can fail independently. The fan-out reports
"3 of 5 archived" rather than a blanket success or a blanket error toast.
- **Search widening.** Matching numbers must not make unrelated rows appear; the reference
haystack is limited to the references the row's own chip resolves.

## Progress

PR: #3

> Convention: `- [ ]` pending, `- [x]` done. Append ` β€” <commit sha>` when a step lands. Do not rename step titles.

### Phase 1: The pure filter model

- [x] 1.1 Reference-number search in `filterRuns` β€” 97a5ecb6
- [x] 1.2 Status-facet model (`TaskListFilters`, `filterTaskList`, `statusFacetOptions`, `activeFilterCount`) β€” 97a5ecb6

### Phase 2: The pure selection model

- [x] 2.1 `lib/task-selection.ts` with toggling, pruning, header state and action gating β€” 557126a1

### Phase 3: The filter bar on the Tasks table

- [x] 3.1 Status facet + Clear in the Tasks header β€” 099c7fff
- [x] 3.2 Filter-aware empty state and reference-aware search affordance β€” 099c7fff
- [x] 3.3 Filter-bar tests β€” 099c7fff

### Phase 4: Selection and the bulk action bar

- [x] 4.1 Selection column on the table and the mobile cards β€” 099c7fff
- [x] 4.2 Bulk action bar (archive / restore / mark read / mark unread) β€” 099c7fff
- [x] 4.3 Route-level bulk mutation fan-out with honest partial-failure reporting β€” 099c7fff
- [x] 4.4 Selection and bulk-action tests β€” 099c7fff

### Phase 5: Validation

- [x] 5.1 Full validation gate green β€” 45037789 (typecheck / test 6221 / test:unit 36 / build + check:pack / test:package 15, all green)
- [x] 5.2 `om-auto-review-pr --autofix` clean β€” a4ab6fec (verdict APPROVED; one minor finding β€” the bulk receipt's wording β€” fixed in that commit, no blockers or majors)
154 changes: 154 additions & 0 deletions packages/web/src/lib/task-selection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { describe, expect, it } from 'vitest'

import type { RunRecord } from '@open-mercato/cezar-api-client'
import {
bulkActionTargets,
bulkResultMessage,
selectionSummary,
toggleAllVisible,
toggleSelected,
} from '@/lib/task-selection'

let seq = 0

function run(over: Partial<RunRecord> = {}): RunRecord {
seq += 1
return {
id: `r${seq}`,
title: `Task ${seq}`,
workflow: 'default',
task: `task ${seq}`,
status: 'done',
createdAt: '2026-07-14T10:00:00.000Z',
tokensUsed: 0,
archived: false,
steps: [],
...over,
}
}

const ids = (runs: readonly RunRecord[]) => runs.map((r) => r.id)

describe('toggleSelected', () => {
it('ticks an unticked row and unticks a ticked one', () => {
expect([...toggleSelected(new Set(), 'a')]).toEqual(['a'])
expect([...toggleSelected(new Set(['a', 'b']), 'a')]).toEqual(['b'])
})

it('never mutates the set it was given', () => {
const before = new Set(['a'])
toggleSelected(before, 'b')
expect([...before]).toEqual(['a'])
})
})

describe('selectionSummary', () => {
const visible = [run({ id: 'a' }), run({ id: 'b' }), run({ id: 'c' })]

it('reports none, some and all for the header checkbox', () => {
expect(selectionSummary(visible, new Set()).state).toBe('none')
expect(selectionSummary(visible, new Set(['a'])).state).toBe('some')
expect(selectionSummary(visible, new Set(['a', 'b', 'c'])).state).toBe('all')
})

it('ignores an id whose row is no longer on screen β€” a stale pick can never act', () => {
// The row was filtered away, archived, or patched out from under the selection.
const summary = selectionSummary(visible, new Set(['a', 'gone']))
expect(ids(summary.runs)).toEqual(['a'])
expect(summary.count).toBe(1)
// …and it does not count towards "all", or the header box would claim a full list it has not got.
expect(selectionSummary(visible, new Set(['a', 'b', 'c', 'gone'])).state).toBe('all')
})

it('keeps the visible order rather than the order rows were clicked in', () => {
expect(ids(selectionSummary(visible, new Set(['c', 'a'])).runs)).toEqual(['a', 'c'])
})

it('is empty, not "all", when nothing is on screen at all', () => {
const summary = selectionSummary([], new Set(['a']))
expect(summary.state).toBe('none')
expect(summary.count).toBe(0)
})
})

describe('bulkActionTargets', () => {
const finished = run({ id: 'done', status: 'done', finishedAt: '2026-07-14T10:30:00.000Z', seenAt: '2026-07-14T10:31:00.000Z' })
const unreadRun = run({ id: 'unread', status: 'failed', finishedAt: '2026-07-14T10:30:00.000Z' })
const review = run({ id: 'review', status: 'review' })
const running = run({ id: 'running', status: 'running' })
const archived = run({ id: 'archived', status: 'done', archived: true, finishedAt: '2026-07-14T09:00:00.000Z' })
const cancelled = run({ id: 'cancelled', status: 'cancelled', finishedAt: '2026-07-14T10:30:00.000Z' })

const targets = bulkActionTargets([finished, unreadRun, review, running, archived, cancelled])

it('archives only unarchived, finished rows β€” a review gate is not swept by a checkbox', () => {
expect(ids(targets.archive)).toEqual(['done', 'unread', 'cancelled'])
})

it('restores exactly the archived rows, with no status gate', () => {
expect(ids(targets.restore)).toEqual(['archived'])
})

it('marks read only what is currently unread', () => {
expect(ids(targets.read)).toEqual(['unread'])
})

it('marks unread only what is read AND could be unread β€” never a cancelled or archived row', () => {
expect(ids(targets.unread)).toEqual(['done'])
})

it('answers with empty lists for an empty selection rather than throwing', () => {
const none = bulkActionTargets([])
expect([none.archive, none.restore, none.read, none.unread]).toEqual([[], [], [], []])
})
})

describe('bulkResultMessage', () => {
it('names the action in the past tense, counts in tasks, and reads as a sentence', () => {
expect(bulkResultMessage('archive', 3, [])).toBe('Archived 3 tasks.')
expect(bulkResultMessage('restore', 1, [])).toBe('Restored 1 task.')
// The object goes in the MIDDLE of "marked … read" β€” this string is shown to a person.
expect(bulkResultMessage('read', 2, [])).toBe('Marked 2 tasks read.')
expect(bulkResultMessage('unread', 1, [])).toBe('Marked 1 task unread.')
})

it('reports the half that landed AND the half that did not, with the first reason', () => {
// The failure mode this exists for: a flat "Archived 5 tasks." over two refused writes is a
// claim the list will contradict a second later.
expect(bulkResultMessage('archive', 5, ['run is locked', 'run is locked'])).toBe(
'Archived 3 of 5 tasks β€” 2 failed: run is locked',
)
})

it('does not pretend a total failure was a success', () => {
expect(bulkResultMessage('archive', 2, ['409 conflict', '409 conflict'])).toBe(
'Archived 0 of 2 tasks β€” 2 failed: 409 conflict',
)
})

it('still reads as a sentence when the reason is empty', () => {
expect(bulkResultMessage('read', 1, [''])).toBe('Marked 0 of 1 task read β€” 1 failed')
})
})

describe('toggleAllVisible', () => {
const visible = [run({ id: 'a' }), run({ id: 'b' })]

it('selects every visible row from empty', () => {
expect([...toggleAllVisible(visible, new Set())].sort()).toEqual(['a', 'b'])
})

it('clears from a partial selection β€” the escape from an indeterminate box is emptying it', () => {
expect([...toggleAllVisible(visible, new Set(['a']))]).toEqual([])
})

it('clears from a full selection', () => {
expect([...toggleAllVisible(visible, new Set(['a', 'b']))]).toEqual([])
})

it('only ever touches what is on screen β€” a filtered-away pick is neither swept in nor dropped', () => {
// Selecting all under a filter must mean "all of these", not "every task in the project".
expect([...toggleAllVisible(visible, new Set(['hidden']))].sort()).toEqual(['a', 'b', 'hidden'])
expect([...toggleAllVisible(visible, new Set(['a', 'b', 'hidden']))]).toEqual(['hidden'])
})
})
Loading