diff --git a/.ai/runs/2026-08-23-task-list-filters-bulk-edit.md b/.ai/runs/2026-08-23-task-list-filters-bulk-edit.md new file mode 100644 index 000000000..d204f7fc7 --- /dev/null +++ b/.ai/runs/2026-08-23-task-list-filters-bulk-edit.md @@ -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 ` Convention: `- [ ]` pending, `- [x]` done. Append ` β€” ` 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) diff --git a/packages/web/src/lib/task-selection.test.ts b/packages/web/src/lib/task-selection.test.ts new file mode 100644 index 000000000..b34bda37f --- /dev/null +++ b/packages/web/src/lib/task-selection.test.ts @@ -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 { + 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']) + }) +}) diff --git a/packages/web/src/lib/task-selection.ts b/packages/web/src/lib/task-selection.ts new file mode 100644 index 000000000..0f53058f2 --- /dev/null +++ b/packages/web/src/lib/task-selection.ts @@ -0,0 +1,165 @@ +import type { RunRecord } from '@open-mercato/cezar-api-client' +import { canBeUnread, isUnread } from '@/lib/read-state' +import { FINISHED_STATUSES } from '@/lib/tasks-table' + +/** + * The pure half of selecting rows in the Tasks table and editing them together: what a click on a + * checkbox does, what the header checkbox says, and which bulk actions a given selection can + * actually carry out. + * + * Pure on the same terms as `lib/tasks-table.ts` and `lib/task-groups.ts` β€” no React, no router, + * no clock β€” because this is the behavior worth testing as a table, and because getting it wrong + * is not cosmetic: a bulk action is N irreversible-ish writes at once. + * + * Two decisions the whole module rests on: + * + * 1. **A selection is a set of ids, and it is always read against the rows CURRENTLY ON SCREEN.** + * Nothing here trusts the set on its own. Rows leave the view constantly β€” a filter is typed, + * a status changes under an SSE patch, a run is archived β€” and an id that outlived its row + * must not be able to act. `selectionSummary` intersects the set with the visible list, and + * every action is built from that intersection, so a stale id is inert rather than dangerous. + * 2. **An action offers itself only for the rows it would actually change.** Archiving a + * selection of five where two are already archived is a three-row action, and it says three. + * The alternative β€” sending five requests and calling two of them successes β€” reports work + * that never happened. + */ + +/** The bulk edits the Tasks table offers. Deliberately the set that is undoable in one click: + * archive/restore are each other's inverse, and so are read/unread. Cancelling or deleting a + * batch of runs is not here β€” neither is reversible, and neither was asked for. */ +export type BulkActionId = 'archive' | 'restore' | 'read' | 'unread' + +/** Every action id, in the order the bar offers them. Exported so the bar renders from one list + * rather than a hand-written row that can drift from this module. */ +export const BULK_ACTION_IDS: readonly BulkActionId[] = ['archive', 'restore', 'read', 'unread'] + +/** + * Which runs, of those selected, each action would actually change. + * + * The gates are the SAME ones the single-row actions use elsewhere in the cockpit, deliberately: + * + * - `archive` β€” not archived yet, and finished (`FINISHED_STATUSES`). A `review` run still wants + * a human, and the "Archive finished" broom has always refused to sweep one; a checkbox must + * not become the way around that rule. + * - `restore` β€” currently archived. No status gate: bringing a row back is undoing your own + * filing, and it is always safe. + * - `read` β€” currently unread (`isUnread`). + * - `unread` β€” currently read AND eligible to be unread at all (`canBeUnread`), which excludes + * cancelled, archived and still-running rows. + */ +export type BulkActionTargets = Record + +export function bulkActionTargets(runs: readonly RunRecord[]): BulkActionTargets { + return { + archive: runs.filter((run) => !run.archived && FINISHED_STATUSES.has(run.status)), + restore: runs.filter((run) => run.archived), + read: runs.filter((run) => isUnread(run)), + unread: runs.filter((run) => canBeUnread(run) && !isUnread(run)), + } +} + +/** What the header checkbox is: every visible row picked, some of them, or none. `some` is the + * indeterminate state β€” a real third value, not "not all", which is why it is spelled out. */ +export type HeaderSelectionState = 'all' | 'some' | 'none' + +export interface SelectionSummary { + /** Selected AND still on screen β€” the only rows any action ever touches (see the module note). */ + runs: RunRecord[] + /** How many of those there are β€” the number the bar prints. */ + count: number + state: HeaderSelectionState + /** What each action would change, already narrowed to `runs`. */ + targets: BulkActionTargets +} + +/** + * The whole selection, resolved against what is on screen. + * + * Computed in one pass and handed to the component as one object rather than four hooks, so the + * bar's count, its per-action counts and the header checkbox can never be computed from three + * different snapshots of the same list. + */ +export function selectionSummary( + visible: readonly RunRecord[], + selected: ReadonlySet, +): SelectionSummary { + const runs = visible.filter((run) => selected.has(run.id)) + const state: HeaderSelectionState = + runs.length === 0 ? 'none' : runs.length === visible.length ? 'all' : 'some' + return { runs, count: runs.length, state, targets: bulkActionTargets(runs) } +} + +/** Tick or untick one row. A new Set, so React sees a change; the caller's is untouched. */ +export function toggleSelected(selected: ReadonlySet, id: string): Set { + const next = new Set(selected) + if (!next.delete(id)) next.add(id) + return next +} + +/** + * What the header checkbox does, given what it currently says. + * + * Only ever over the VISIBLE rows: select-all under a filter means "all of these", never "all + * the tasks in the project, including the ones the filter is hiding" β€” the second reading is how + * a bulk archive swallows rows nobody looked at. + * + * `some` clears rather than completes, matching every list that has ever had a tri-state header + * box: the indeterminate box is a partial selection, and the escape from one is emptying it. + */ +export function toggleAllVisible( + visible: readonly RunRecord[], + selected: ReadonlySet, +): Set { + const { state } = selectionSummary(visible, selected) + if (state === 'none') { + const next = new Set(selected) + for (const run of visible) next.add(run.id) + return next + } + // Drop exactly the visible ids. A selection made before a filter narrowed the list survives β€” + // it is not on screen, so it cannot act, and re-widening the filter gets it back. + const next = new Set(selected) + for (const run of visible) next.delete(run.id) + return next +} + +/** An empty selection β€” the state the bar hides in, and what a completed bulk action returns to. */ +export const NO_SELECTION: ReadonlySet = new Set() + +/** + * Past tense, per action: what the receipt says a bulk edit DID. + * + * Split into a verb and a trailing complement rather than one phrase because English puts the + * object in the middle of "marked … read": `Marked 3 tasks read.` is a sentence, `Marked read 3 + * tasks.` is a log line, and this string is shown to a person. + */ +const BULK_DONE_VERB: Record = { + archive: { verb: 'Archived', complement: '' }, + restore: { verb: 'Restored', complement: '' }, + read: { verb: 'Marked', complement: ' read' }, + unread: { verb: 'Marked', complement: ' unread' }, +} + +/** + * The receipt a bulk edit leaves in a toast. + * + * A bulk action is N independent requests, and N requests can fail independently β€” so the message + * has to be able to say "some". Reporting a flat success over a batch where two writes were + * refused claims work that never happened, and reporting a flat failure hides the writes that + * landed; both leave the reader with a list they cannot trust. The first failure's reason rides + * along because with a local server it is nearly always the same reason for all of them, and it + * is the only thing that makes the failure actionable. + */ +export function bulkResultMessage( + action: BulkActionId, + total: number, + failures: readonly string[], +): string { + const { verb, complement } = BULK_DONE_VERB[action] + const noun = (count: number) => `${count} ${count === 1 ? 'task' : 'tasks'}` + if (failures.length === 0) return `${verb} ${noun(total)}${complement}.` + const reason = failures[0] + return `${verb} ${total - failures.length} of ${noun(total)}${complement} β€” ${ + failures.length + } failed${reason ? `: ${reason}` : ''}` +} diff --git a/packages/web/src/lib/tasks-table.test.ts b/packages/web/src/lib/tasks-table.test.ts index 831f7164d..b2e241b98 100644 --- a/packages/web/src/lib/tasks-table.test.ts +++ b/packages/web/src/lib/tasks-table.test.ts @@ -2,18 +2,26 @@ import { describe, expect, it } from 'vitest' import type { ProcessUsage, RunRecord } from '@open-mercato/cezar-api-client' import { + NO_TASK_FILTERS, + activeFilterCount, compareGroups, filterRuns, + filterTaskList, finishedRunCount, formatCost, formatMem, githubRepoBase, + hasActiveTaskFilters, prNumber, + referenceNeedle, scheduledResume, + statusFacetOptions, taskReference, taskPrUrl, taskIssueUrl, taskReferences, + taskStatusValue, + toggleStatusFilter, usageCells, workflowLabel, } from '@/lib/tasks-table' @@ -177,6 +185,175 @@ describe('filterRuns', () => { }) }) +describe('referenceNeedle', () => { + const cases: Array<[query: string, expected: ReturnType]> = [ + ['909', { number: 909 }], + ['#909', { number: 909 }], + [' #909 ', { number: 909 }], + ['pr 909', { kind: 'PR', number: 909 }], + ['PR#909', { kind: 'PR', number: 909 }], + ['pull 909', { kind: 'PR', number: 909 }], + ['issue 42', { kind: 'Issue', number: 42 }], + ['issue#42', { kind: 'Issue', number: 42 }], + // Not a reference lookup β€” an ordinary search, and it must stay one. + ['909 tokens', undefined], + ['zod', undefined], + ['v4', undefined], + ['#', undefined], + ['', undefined], + ] + it.each(cases)('reads %j as %j', (query, expected) => { + expect(referenceNeedle(query)).toEqual(expected) + }) +}) + +describe('filterRuns β€” by tracker reference', () => { + const created = run({ id: 'created', title: 'Ship the thing', pullRequestUrl: 'https://github.com/o/r/pull/909' }) + const about = run({ id: 'about', title: 'Review it', referencedPullRequestUrl: 'https://github.com/o/r/pull/904' }) + const issue = run({ id: 'issue', title: 'File it', issueNumber: 909 }) + const runs = [created, about, issue] + const ids = (query: string) => filterRuns(runs, query).map((r) => r.id) + + it('finds a task by its PR number, written the way the tracker writes it', () => { + expect(ids('#909')).toEqual(['created', 'issue']) + expect(ids('904')).toEqual(['about']) + }) + + it('narrows to one kind when the reader names the kind', () => { + expect(ids('pr 909')).toEqual(['created']) + expect(ids('issue 909')).toEqual(['issue']) + }) + + it('still searches the text for a bare number β€” the reference is an extra haystack, not a swap', () => { + const numbered = [run({ id: 'named', title: '788: rename the thing' })] + expect(filterRuns(numbered, '788').map((r) => r.id)).toEqual(['named']) + }) + + it('does not invent a reference a row does not have', () => { + expect(ids('#1234')).toEqual([]) + }) +}) + +describe('taskStatusValue', () => { + it('answers with the word the status pill prints, sub-states included', () => { + expect(taskStatusValue(run({ status: 'waiting' }))).toBe('needs you') + expect(taskStatusValue(run({ status: 'review' }))).toBe('needs review') + expect(taskStatusValue(run({ status: 'running' }))).toBe('running') + expect(taskStatusValue(run({ status: 'running', activity: 'monitoring' }))).toBe('monitoring') + // The two the record cannot express on its own β€” the whole reason the facet reads the pill. + expect(taskStatusValue(run({ status: 'failed', autoResumeAt: '2026-07-14T11:00:00.000Z' }))).toBe('scheduled') + expect(taskStatusValue(run({ status: 'failed' }))).toBe('failed') + }) +}) + +describe('filterTaskList', () => { + const runs = [ + run({ id: 'w', title: 'Waiting one', status: 'waiting' }), + run({ id: 'd1', title: 'Done one', status: 'done' }), + run({ id: 'd2', title: 'Done two', status: 'done' }), + run({ id: 'f', title: 'Failed one', status: 'failed' }), + ] + const ids = (statuses: string[], query = '') => + filterTaskList(runs, { query, statuses }).map((r) => r.id) + + it('keeps everything when no status is picked β€” an empty facet is no opinion', () => { + expect(ids([])).toEqual(['w', 'd1', 'd2', 'f']) + }) + + it('ORs the picked statuses', () => { + expect(ids(['done'])).toEqual(['d1', 'd2']) + expect(ids(['done', 'failed'])).toEqual(['d1', 'd2', 'f']) + }) + + it('ANDs the facet with the search box', () => { + expect(ids(['done'], 'two')).toEqual(['d2']) + expect(ids(['failed'], 'two')).toEqual([]) + }) +}) + +describe('statusFacetOptions', () => { + const runs = [ + run({ id: 'w', title: 'Alpha', status: 'waiting' }), + run({ id: 'r', title: 'Beta', status: 'running' }), + run({ id: 'd1', title: 'Gamma', status: 'done' }), + run({ id: 'd2', title: 'Delta', status: 'done' }), + ] + + it('offers exactly the statuses present, in the list’s own priority order', () => { + expect(statusFacetOptions(runs, NO_TASK_FILTERS).map((option) => option.value)).toEqual([ + 'needs you', + 'running', + 'done', + ]) + }) + + it('counts against the search box but NOT against its own ticks', () => { + // Ticking `done` must not make every other option read 0 β€” unticking it has to promise the + // rows it will actually bring back. + const ticked = statusFacetOptions(runs, { query: '', statuses: ['done'] }) + expect(ticked.map((option) => [option.value, option.count])).toEqual([ + ['needs you', 1], + ['running', 1], + ['done', 2], + ]) + // The query does narrow the counts β€” it is the other narrowing in force. + const searched = statusFacetOptions(runs, { query: 'gamma', statuses: [] }) + expect(searched.map((option) => [option.value, option.count])).toEqual([ + ['needs you', 0], + ['running', 0], + ['done', 1], + ]) + }) + + it('gives every label the attention ladder can produce a place, in reading order', () => { + // The drift guard for `STATUS_FILTER_ORDER`: a label `deriveAttention` learns to emit that + // nobody adds to the ladder would sort to the bottom, and this table would say so. + const everything = [ + run({ status: 'waiting' }), + run({ status: 'review' }), + run({ status: 'running' }), + run({ status: 'running', activity: 'monitoring' }), + run({ status: 'failed', autoResumeAt: '2026-07-14T11:00:00.000Z' }), + run({ status: 'queued' }), + run({ status: 'done' }), + run({ status: 'failed' }), + run({ status: 'cancelled' }), + ] + expect(statusFacetOptions(everything, NO_TASK_FILTERS).map((option) => option.value)).toEqual([ + 'needs you', + 'needs review', + 'running', + 'monitoring', + 'scheduled', + 'queued', + 'done', + 'failed', + 'cancelled', + ]) + }) +}) + +describe('toggleStatusFilter / activeFilterCount / hasActiveTaskFilters', () => { + it('adds and removes without mutating the array it was given', () => { + const values: readonly string[] = ['done'] + expect(toggleStatusFilter(values, 'failed')).toEqual(['done', 'failed']) + expect(toggleStatusFilter(values, 'done')).toEqual([]) + expect(values).toEqual(['done']) + }) + + it('counts every narrowing the reader turned on, whitespace excluded', () => { + expect(activeFilterCount(NO_TASK_FILTERS)).toBe(0) + expect(activeFilterCount({ query: ' ', statuses: [] })).toBe(0) + expect(activeFilterCount({ query: 'zod', statuses: [] })).toBe(1) + expect(activeFilterCount({ query: 'zod', statuses: ['done', 'failed'] })).toBe(3) + }) + + it('says whether anything is narrowing the list at all', () => { + expect(hasActiveTaskFilters(NO_TASK_FILTERS)).toBe(false) + expect(hasActiveTaskFilters({ query: '', statuses: ['done'] })).toBe(true) + }) +}) + describe('finishedRunCount', () => { it('counts unarchived done/failed/cancelled only', () => { expect( diff --git a/packages/web/src/lib/tasks-table.ts b/packages/web/src/lib/tasks-table.ts index 4983ba71d..b2afc4eb0 100644 --- a/packages/web/src/lib/tasks-table.ts +++ b/packages/web/src/lib/tasks-table.ts @@ -1,4 +1,5 @@ import type { ProcessUsage, RunRecord, RunStatus } from '@open-mercato/cezar-api-client' +import { deriveAttention, type AttentionInput } from '@/lib/attention' import { groupTitle, runTitle, type ListView } from '@/lib/task-groups' /** @@ -19,8 +20,10 @@ const USAGE_LIVE_STATUSES: ReadonlySet = new Set(['running', 'waiting export const TERMINAL_STATUSES: ReadonlySet = new Set(['done', 'failed', 'review', 'cancelled']) /** What "Archive finished" archives (`POST /api/runs/archive-finished` server-side): outcomes, - * not gates β€” a `review` run still wants a human and must not be swept away. */ -const FINISHED_STATUSES: ReadonlySet = new Set(['done', 'failed', 'cancelled']) + * not gates β€” a `review` run still wants a human and must not be swept away. Exported because + * the same rule decides whether a SELECTED row can be archived by hand (`lib/task-selection.ts`); + * one definition means the broom and the bulk bar can never disagree about what "finished" is. */ +export const FINISHED_STATUSES: ReadonlySet = new Set(['done', 'failed', 'cancelled']) /** * Humanized RSS β€” `612 MB`, `1.2 GB`. Ported from the legacy `fmtBytes` (ps gives KB, the store @@ -83,23 +86,190 @@ export function workflowLabel(run: RunRecord): string { return run.workflow } +/** + * What a search box entry is asking for when it is asking for a TRACKER REFERENCE: `#909`, a bare + * `909`, `pr 909`, `pr#909`, `issue 42`. Undefined for anything else, which is every ordinary + * word β€” this is a recognizer, not a parser that has to succeed. + * + * The keyword is optional and, when present, NARROWS the kind: `pr 42` must not surface the task + * that merely opened issue #42, because a reader who typed the kind meant it. Without one, both + * kinds match β€” `#42` is how the number is written everywhere in the cockpit and in the tracker, + * and demanding a keyword to use it would be a worse search box, not a stricter one. + */ +export function referenceNeedle(query: string): { kind?: TaskReference['kind']; number: number } | undefined { + const match = /^(?:(pr|pull|issue)\s*)?#?\s*(\d{1,9})$/.exec(query.trim().toLowerCase()) + const number = match?.[2] + if (number === undefined) return undefined + const keyword = match?.[1] + const kind = keyword === undefined ? undefined : keyword === 'issue' ? 'Issue' : 'PR' + return { number: Number(number), ...(kind ? { kind } : {}) } +} + +/** Does this run carry the reference the needle named? Asked of `taskReferences` β€” the same list + * the row's own chip is built from β€” so search can only ever find a number the table itself + * would be willing to show, never a scraped candidate pointing at another repository (#526). */ +function matchesReference( + run: TaskReferenceInput, + needle: ReturnType, +): boolean { + if (needle === undefined) return false + return taskReferences(run).some( + (reference) => + reference.number === needle.number && (needle.kind === undefined || reference.kind === needle.kind), + ) +} + /** * The header search: case-insensitive substring over what the table actually shows β€” the * displayed title (`runTitle`: the auto-summary when one exists, per R2 #389), branch and * workflow (both the raw name and the label the column prints). Not the task prompt, and not a * raw `title` hidden behind a summary: matching on text the table never displays makes rows * appear for no visible reason. + * + * Plus one thing the table shows that is not text: the REFERENCE chip. `#909` and `issue 42` are + * how a task is named in a PR body, in a standup and in the tracker, and the number is routinely + * the only thing the reader has. It is an ADDITIONAL haystack rather than a replacement, so a + * bare `909` still finds the title that contains it β€” the auto-name prefix (`909: …`) means the + * two answers usually agree anyway, and where they do not, both are what the reader asked for. */ export function filterRuns(runs: readonly RunRecord[], query: string): RunRecord[] { const needle = query.trim().toLowerCase() if (!needle) return [...runs] - return runs.filter((run) => - [runTitle(run), run.branch ?? '', run.workflow, workflowLabel(run)].some((text) => - text.toLowerCase().includes(needle), - ), + const reference = referenceNeedle(needle) + return runs.filter( + (run) => + [runTitle(run), run.branch ?? '', run.workflow, workflowLabel(run)].some((text) => + text.toLowerCase().includes(needle), + ) || matchesReference(run, reference), ) } +/** + * The Tasks table's narrowings, beyond the Active/Archived tabs: the search box and the status + * facet. One shape rather than two loose props, so "is anything narrowed?" and "clear it" are one + * question each (`activeFilterCount` / `NO_TASK_FILTERS`) instead of a check per control. + * + * The per-project page keeps this in component state rather than in the URL, unlike the global + * `/tasks` page: a cross-project view is something people paste to each other, a project's own + * list is where they already are. + */ +export interface TaskListFilters { + /** Free text, matched by `filterRuns` above. */ + query: string + /** Selected status values (`taskStatusValue`), ORed. Empty = every status, which is the + * no-opinion default rather than a state anyone has to set. */ + statuses: readonly string[] +} + +export const NO_TASK_FILTERS: TaskListFilters = { query: '', statuses: [] } + +/** + * The status a row SHOWS β€” `deriveAttention().label`, the exact word in its Status pill. + * + * Filtering on the pill rather than on `RunRecord.status` is what makes the facet able to say + * `scheduled` and `monitoring` at all: both are sub-states the record spells as `failed` and + * `running`, and a filter whose vocabulary disagreed with the column beside it would be a second + * status grammar β€” the one thing `lib/attention.ts` exists to prevent. + */ +export function taskStatusValue(run: AttentionInput): string { + return deriveAttention(run).label +} + +/** + * The order the status facet lists its options in: the list's own priority ladder (needs-you + * first, then work in flight, then outcomes), NOT the alphabet β€” the same reading order + * `sortRuns` gives the rows themselves. A label this array has never heard of sorts last + * alphabetically rather than being dropped; `tasks-table.test.ts` pins every label + * `deriveAttention` can currently produce to a place in here, so a new one is a failing test + * rather than a silent trip to the bottom. + */ +const STATUS_FILTER_ORDER: readonly string[] = [ + 'needs permission', + 'needs you', + 'needs review', + 'running', + 'monitoring', + 'scheduled', + 'queued', + 'unseen', + 'done', + 'failed', + 'cancelled', +] + +const statusFilterRank = (value: string): number => { + const index = STATUS_FILTER_ORDER.indexOf(value) + return index === -1 ? STATUS_FILTER_ORDER.length : index +} + +/** One option of the status facet. Structurally the `FacetOption` the shared filter pill takes β€” + * spelled here so this module stays free of anything that imports React. */ +export interface StatusFacetOption { + value: string + label: string + count: number +} + +/** + * The status facet's options for a list. + * + * Options come from every status PRESENT in the list, so a facet can never offer a value that + * could only ever empty the table. Counts, though, are computed against the list as the OTHER + * narrowing (the search box) leaves it and with the status facet's own ticks ignored β€” the same + * rule the global page follows, and the reason unticking a value promises exactly the number of + * rows it delivers. A count of `0` is shown rather than hidden: "this would empty the table" is + * what a filter should say before it is clicked, not after. + */ +export function statusFacetOptions( + runs: readonly RunRecord[], + filters: TaskListFilters, +): StatusFacetOption[] { + const counts = new Map() + for (const run of filterTaskList(runs, { ...filters, statuses: [] })) { + const value = taskStatusValue(run) + counts.set(value, (counts.get(value) ?? 0) + 1) + } + const present = new Set(runs.map((run) => taskStatusValue(run))) + return [...present] + .sort((a, b) => statusFilterRank(a) - statusFilterRank(b) || a.localeCompare(b)) + .map((value) => ({ value, label: value, count: counts.get(value) ?? 0 })) +} + +/** The list a set of filters leaves. Facet AND search β€” a status tick and a typed word narrow + * together, which is the only reading of two controls that are both switched on. The + * Active/Archived split is not here: that chooses WHICH list you are reading (`sortRuns`), not + * how it is narrowed. */ +export function filterTaskList( + runs: readonly RunRecord[], + filters: TaskListFilters, +): RunRecord[] { + const byStatus = + filters.statuses.length === 0 + ? runs + : runs.filter((run) => filters.statuses.includes(taskStatusValue(run))) + return filterRuns(byStatus, filters.query) +} + +/** Add or remove one status from the facet. A new array, so it composes with `setState` without + * a mutation nobody can see. (The global page's `toggleFacetValue` is the same one-liner; it is + * not imported because that module is the cross-project page's own model and this one must not + * depend on it.) */ +export function toggleStatusFilter(values: readonly string[], value: string): string[] { + return values.includes(value) ? values.filter((current) => current !== value) : [...values, value] +} + +/** How many narrowings are in force β€” the number "Clear" prints, and therefore a promise it has + * to keep: every status tick, plus the search text if there is any. */ +export function activeFilterCount(filters: TaskListFilters): number { + return filters.statuses.length + (filters.query.trim() === '' ? 0 : 1) +} + +/** Is the list narrowed by anything the reader turned on? Drives both the Clear affordance and + * the empty state's wording β€” "nothing here" and "nothing matches" are different facts. */ +export function hasActiveTaskFilters(filters: TaskListFilters): boolean { + return activeFilterCount(filters) > 0 +} + /** How many active runs "Archive finished" would sweep. The button only exists when this is * nonzero β€” a broom over an empty floor is noise (legacy showed the same count-gated button). */ export function finishedRunCount(runs: readonly RunRecord[]): number { diff --git a/packages/web/src/routes/tasks-overview.test.tsx b/packages/web/src/routes/tasks-overview.test.tsx index 9423878d2..455e562a6 100644 --- a/packages/web/src/routes/tasks-overview.test.tsx +++ b/packages/web/src/routes/tasks-overview.test.tsx @@ -2,7 +2,7 @@ import { QueryClientProvider } from '@tanstack/react-query' import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import type { ComponentProps } from 'react' import { MemoryRouter, Route, Routes, useLocation } from 'react-router' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { GlobalEventsProvider } from '@/api/global-events' import { queryKeys } from '@/api/queries' @@ -10,6 +10,7 @@ import { createQueryClient } from '@/api/query-client' import type { ProcessUsage, RunRecord } from '@open-mercato/cezar-api-client' import { ListViewProvider } from '@/components/list-view' import { TaskQuickListContainer } from '@/components/task-quick-list' +import { Toaster, resetToasts } from '@/components/ui/toaster' import { TasksOverview, TasksOverviewRoute } from '@/routes/tasks-overview' const NOW = Date.parse('2026-07-14T12:00:00.000Z') @@ -44,7 +45,7 @@ function renderOverview(props: Partial> = { const onArchiveFinished = props.onArchiveFinished ?? vi.fn() const onMarkAllRead = props.onMarkAllRead ?? vi.fn() const onRename = props.onRename ?? vi.fn() - const utils = render( + const tree = (extra: Partial>) => ( @@ -57,6 +58,7 @@ function renderOverview(props: Partial> = { now={NOW} expandedColumns={{ branch: true }} {...props} + {...extra} onViewChange={onViewChange} onArchiveFinished={onArchiveFinished} onMarkAllRead={onMarkAllRead} @@ -69,16 +71,82 @@ function renderOverview(props: Partial> = { ) - return { ...utils, onViewChange, onArchiveFinished, onMarkAllRead, onRename } + const utils = render(tree({})) + return { + ...utils, + onViewChange, + onArchiveFinished, + onMarkAllRead, + onRename, + /** New props, same mounted component β€” how a test plays the data changing UNDER the reader + * (a run finishing over the SSE stream) without losing the filters and ticks they set. */ + setProps: (extra: Partial>) => utils.rerender(tree(extra)), + } } const location = () => screen.getByTestId('location').textContent const tableRow = (id: string) => document.querySelector(`[data-slot="task-table-row"][data-run-id="${id}"]`) const card = (id: string) => document.querySelector(`[data-slot="task-card"][data-run-id="${id}"]`) -const cellsOf = (id: string): string[] => [...(tableRow(id)?.querySelectorAll('td') ?? [])].map((td) => td.textContent ?? '') +/** The row's DATA cells. The leading selection box is not one of them β€” it is the handle for + * editing the row, not a fact about the run β€” so it stays out of every column assertion. */ +const cellsOf = (id: string): string[] => + [...(tableRow(id)?.querySelectorAll('td:not([data-column-id="select"])') ?? [])].map( + (td) => td.textContent ?? '', + ) afterEach(cleanup) +beforeAll(() => { + // The Status facet is a Popover + cmdk; cmdk scrolls the active item into view and jsdom has + // no scrollIntoView. + Element.prototype.scrollIntoView = vi.fn() +}) + +beforeEach(() => { + // …and Radix positions the popover with floating-ui, which needs a ResizeObserver jsdom + // does not have either. + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ) +}) + +/** The Status facet's trigger, its options, and the row/header tick boxes β€” the handles every + * filter-and-select assertion below reaches for. */ +const statusTrigger = () => document.querySelector('[data-slot="facet-status"]') as HTMLElement +const statusOption = (value: string) => + document.querySelector(`[data-slot="facet-option"][data-value="${value}"]`) +const rowBox = (id: string) => + tableRow(id)?.querySelector('[data-slot="select-task"]') as HTMLInputElement +const cardBox = (id: string) => + card(id)?.querySelector('[data-slot="select-task"]') as HTMLInputElement +const selectAllBox = () => document.querySelector('[data-slot="select-all"]') as HTMLInputElement +const bulkBar = () => document.querySelector('[data-slot="bulk-action-bar"]') +const bulkButton = (action: string) => + document.querySelector(`[data-action="bulk-${action}"]`) as HTMLButtonElement + +/** + * Open the Status facet and tick one option. + * + * The popover deliberately stays OPEN after a pick (ticking three statuses in a row is the + * common case), so this cannot key completion on the list unmounting. It keys on the option's + * own `aria-checked` instead, and re-queries the node on every poll: cmdk re-renders the list as + * the popover settles, and clicking a node from a previous poll is a silent no-op (#413). + */ +async function pickStatus(value: string): Promise { + if (!statusOption(value)) fireEvent.click(statusTrigger()) + await waitFor(() => { + const node = statusOption(value) + if (node?.getAttribute('aria-checked') === 'true') return + if (node) fireEvent.click(node) + throw new Error(`status "${value}" is not picked yet`) + }) +} + describe('TasksOverview β€” the table', () => { it('starts with Branch folded while keeping fixed columns and an in-place restore control', () => { const onToggleColumn = vi.fn() @@ -273,9 +341,9 @@ describe('TasksOverview β€” the table', () => { ], }) - const headers = [...document.querySelectorAll('[data-slot="tasks-table"] th')].map( - (cell) => cell.textContent, - ) + const headers = [ + ...document.querySelectorAll('[data-slot="tasks-table"] th:not([data-column-id="select"])'), + ].map((cell) => cell.textContent) expect(headers).toEqual(['Status', 'Task', 'Workflow', 'Branch', 'Β±', 'Ref', 'CPU', 'Mem', 'Started']) expect(cellsOf('hidden')).toEqual([ 'done', @@ -290,7 +358,7 @@ describe('TasksOverview β€” the table', () => { ]) const queued = tableRow('queued-hidden') as HTMLElement - expect(queued.querySelectorAll('td')).toHaveLength(8) + expect(queued.querySelectorAll('td:not([data-column-id="select"])')).toHaveLength(8) expect(queued.querySelector('[data-slot="queue-note"]')?.getAttribute('colspan')).toBe('2') expect(queued.textContent).not.toContain('12.0k') expect(queued.textContent).not.toContain('$0.02') @@ -920,6 +988,235 @@ describe('TasksOverview β€” compare-variants strip', () => { }) }) +describe('TasksOverview β€” the status filter', () => { + const mixed = () => [ + run({ id: 'w', title: 'Waiting one', status: 'waiting' }), + run({ id: 'r', title: 'Running one', status: 'running' }), + run({ id: 'd', title: 'Done one', status: 'done' }), + ] + + it('narrows the table to the picked status, and back when it is unpicked', async () => { + renderOverview({ runs: mixed() }) + await pickStatus('done') + + expect(tableRow('d')).not.toBeNull() + expect(tableRow('w')).toBeNull() + expect(tableRow('r')).toBeNull() + // The cards are the same list below `md`, so they narrow with it. + expect(card('w')).toBeNull() + + // Un-tick: everything is back. `pickStatus` refuses to double-click, so this is the raw one. + fireEvent.click(statusOption('done') as HTMLElement) + await waitFor(() => expect(tableRow('w')).not.toBeNull()) + }) + + it('ORs several statuses β€” "what needs me OR failed?" is one question, not two', async () => { + renderOverview({ runs: [...mixed(), run({ id: 'f', title: 'Failed one', status: 'failed' })] }) + await pickStatus('failed') + await pickStatus('needs you') + + expect(tableRow('f')).not.toBeNull() + expect(tableRow('w')).not.toBeNull() + expect(tableRow('d')).toBeNull() + }) + + it('offers the statuses of the list on screen, in reading order, each with its row count', async () => { + renderOverview({ + runs: [...mixed(), run({ id: 'd2', title: 'Done two', status: 'done' }), run({ id: 'arc', status: 'done', archived: true })], + }) + fireEvent.click(statusTrigger()) + await waitFor(() => expect(statusOption('done')).not.toBeNull()) + + const options = [...document.querySelectorAll('[data-slot="facet-option"]')].map((node) => node.textContent) + // Reading order, not the alphabet β€” and no `archived` row's status leaks into the Active tab. + expect(options).toEqual(['needs you1', 'running1', 'done2']) + }) + + it('ANDs the facet with the search box', async () => { + renderOverview({ runs: [...mixed(), run({ id: 'd2', title: 'Done two', status: 'done' })] }) + await pickStatus('done') + fireEvent.change(screen.getByRole('textbox', { name: 'Search tasks' }), { target: { value: 'two' } }) + + expect(tableRow('d2')).not.toBeNull() + expect(tableRow('d')).toBeNull() + }) + + it('counts every narrowing in Clear, and one click undoes all of them', async () => { + renderOverview({ runs: mixed() }) + // Nothing narrowed yet β€” no Clear to press. + expect(document.querySelector('[data-action="clear-filters"]')).toBeNull() + + await pickStatus('done') + fireEvent.change(screen.getByRole('textbox', { name: 'Search tasks' }), { target: { value: 'one' } }) + const clear = document.querySelector('[data-action="clear-filters"]') as HTMLElement + expect(clear.textContent).toContain('(2)') + + fireEvent.click(clear) + await waitFor(() => expect(tableRow('w')).not.toBeNull()) + expect((screen.getByRole('textbox', { name: 'Search tasks' }) as HTMLInputElement).value).toBe('') + expect(document.querySelector('[data-action="clear-filters"]')).toBeNull() + }) + + it('blames the filter, not the list, when a facet alone empties the table', async () => { + // The reachable shape of this: a filter is on, and the data moves under it β€” the `done` run + // is archived from another surface and the stream takes it out of the Active list. + const { setProps } = renderOverview({ + runs: [run({ id: 'd', title: 'Done one', status: 'done' }), run({ id: 'w', status: 'waiting' })], + }) + await pickStatus('done') + expect(tableRow('d')).not.toBeNull() + + setProps({ runs: [run({ id: 'w2', status: 'waiting' })] }) + + const empty = document.querySelector('[data-slot="tasks-empty"]') + if (!empty) throw new Error('no empty state rendered') + // Not "No tasks yet" β€” the tasks are there, the filter is hiding them, and the state has to + // say which of those two it is. + expect(empty.getAttribute('data-empty-kind')).toBe('search-miss') + expect(screen.getByText('No tasks match the filters you picked.')).not.toBeNull() + }) + + it('finds a task by the PR or issue number in its chip', () => { + renderOverview({ + runs: [ + run({ id: 'pr', title: 'Ship it', pullRequestUrl: 'https://github.com/o/r/pull/909' }), + run({ id: 'iss', title: 'File it', issueNumber: 42 }), + ], + }) + const box = screen.getByRole('textbox', { name: 'Search tasks' }) + + fireEvent.change(box, { target: { value: '#909' } }) + expect(tableRow('pr')).not.toBeNull() + expect(tableRow('iss')).toBeNull() + + fireEvent.change(box, { target: { value: 'issue 42' } }) + expect(tableRow('iss')).not.toBeNull() + expect(tableRow('pr')).toBeNull() + }) +}) + +describe('TasksOverview β€” selecting rows and editing them together', () => { + const batch = () => [ + run({ id: 'd1', title: 'Done one', status: 'done', finishedAt: ago(60_000) }), + run({ id: 'd2', title: 'Done two', status: 'done', finishedAt: ago(60_000), seenAt: ago(30_000) }), + run({ id: 'rev', title: 'Wants a human', status: 'review' }), + ] + + it('shows no bar until something is ticked, then names the count', () => { + renderOverview({ runs: batch() }) + expect(bulkBar()).toBeNull() + + fireEvent.click(rowBox('d1')) + expect(bulkBar()?.querySelector('[data-slot="bulk-selection-count"]')?.textContent).toBe('1 selected') + fireEvent.click(rowBox('rev')) + expect(bulkBar()?.querySelector('[data-slot="bulk-selection-count"]')?.textContent).toBe('2 selected') + + // Un-ticking the last one puts the bar away again. + fireEvent.click(rowBox('d1')) + fireEvent.click(rowBox('rev')) + expect(bulkBar()).toBeNull() + }) + + it('drives the header box through none β†’ all β†’ none, indeterminate in between', () => { + renderOverview({ runs: batch() }) + expect(selectAllBox().getAttribute('data-state')).toBe('none') + + fireEvent.click(rowBox('d1')) + expect(selectAllBox().getAttribute('data-state')).toBe('some') + expect(selectAllBox().indeterminate).toBe(true) + + fireEvent.click(selectAllBox()) + // A partial selection clears rather than completes β€” the escape from an indeterminate box. + expect(bulkBar()).toBeNull() + + fireEvent.click(selectAllBox()) + expect(bulkBar()?.querySelector('[data-slot="bulk-selection-count"]')?.textContent).toBe('3 selected') + expect(selectAllBox().checked).toBe(true) + }) + + it('selects all of the FILTERED list, never the rows a filter is hiding', async () => { + const onBulkAction = vi.fn() + renderOverview({ runs: batch(), onBulkAction }) + await pickStatus('done') + + fireEvent.click(selectAllBox()) + expect(bulkBar()?.querySelector('[data-slot="bulk-selection-count"]')?.textContent).toBe('2 selected') + + fireEvent.click(bulkButton('archive')) + expect(onBulkAction.mock.calls[0]?.[1].map((r: RunRecord) => r.id)).toEqual(['d1', 'd2']) + }) + + it('counts each action by the rows it would really change, and says why it cannot', () => { + renderOverview({ runs: batch() }) + fireEvent.click(selectAllBox()) + + // The review row is selected but is not archivable β€” the broom refuses it too. + expect(bulkButton('archive').textContent).toContain('2') + expect(bulkButton('archive').disabled).toBe(false) + // One unread (d1 finished, never seen), one read (d2), and nothing archived to restore. + expect(bulkButton('read').textContent).toContain('1') + expect(bulkButton('unread').textContent).toContain('1') + expect(bulkButton('restore').disabled).toBe(true) + expect(bulkButton('restore').getAttribute('title')).toBe('Nothing selected is archived.') + }) + + it('hands the action exactly the rows it applies to, then empties the selection', () => { + const onBulkAction = vi.fn() + renderOverview({ runs: batch(), onBulkAction }) + fireEvent.click(selectAllBox()) + fireEvent.click(bulkButton('archive')) + + expect(onBulkAction).toHaveBeenCalledTimes(1) + expect(onBulkAction.mock.calls[0]?.[0]).toBe('archive') + // The `review` row was ticked but is not archivable, so it is not sent. + expect(onBulkAction.mock.calls[0]?.[1].map((r: RunRecord) => r.id)).toEqual(['d1', 'd2']) + expect(bulkBar()).toBeNull() + }) + + it('waits rather than letting a second batch race the first', () => { + const onBulkAction = vi.fn() + renderOverview({ runs: batch(), onBulkAction, bulkPending: true }) + fireEvent.click(rowBox('d1')) + + expect(bulkButton('archive').disabled).toBe(true) + fireEvent.click(bulkButton('archive')) + expect(onBulkAction).not.toHaveBeenCalled() + }) + + it('ticks a row without opening it β€” the box is not a click on the row', () => { + renderOverview({ runs: batch() }) + fireEvent.click(rowBox('d1')) + expect(location()).toBe('/') + expect(tableRow('d1')?.getAttribute('data-selected')).toBe('true') + + // …and the row still navigates when the click lands anywhere else. + fireEvent.click(tableRow('d1') as HTMLElement) + expect(location()).toBe('/tasks/d1') + }) + + it('ticks a card without opening it either', () => { + renderOverview({ runs: batch() }) + fireEvent.click(cardBox('d1')) + expect(location()).toBe('/') + expect(card('d1')?.getAttribute('data-selected')).toBe('true') + // One selection, two renderings of it: the desktop row agrees with the mobile card. + expect(rowBox('d1').checked).toBe(true) + }) + + it('cannot act on a row that has left the view', async () => { + const onBulkAction = vi.fn() + renderOverview({ runs: batch(), onBulkAction }) + fireEvent.click(rowBox('d1')) + fireEvent.click(rowBox('rev')) + + // A filter hides the review row while it is still ticked. + await pickStatus('done') + expect(bulkBar()?.querySelector('[data-slot="bulk-selection-count"]')?.textContent).toBe('1 selected') + fireEvent.click(bulkButton('archive')) + expect(onBulkAction.mock.calls[0]?.[1].map((r: RunRecord) => r.id)).toEqual(['d1']) + }) +}) + describe('TasksOverviewRoute β€” wired to the app', () => { const fetchMock = vi.fn() @@ -928,6 +1225,7 @@ describe('TasksOverviewRoute β€” wired to the app', () => { }) afterEach(() => { + act(() => resetToasts()) cleanup() fetchMock.mockReset() vi.unstubAllGlobals() @@ -1063,6 +1361,56 @@ describe('TasksOverviewRoute β€” wired to the app', () => { }) }) + it('fans a bulk archive out per run, finishes the batch when one is refused, and says so', async () => { + const rows = [ + run({ id: 'b1', title: 'Batch one', status: 'done' }), + run({ id: 'b2', title: 'Batch two', status: 'done' }), + ] + fetchMock.mockImplementation(async (input) => { + const url = String(input) + if (url === '/api/v1/runs') return json(rows) + // One row refuses. `allSettled` is what makes the other one still land. + if (url === '/api/v1/runs/b1/archive') return json({ error: 'run is locked by another agent' }, 409) + if (url === '/api/v1/runs/b2/archive') return json({ ...rows[1], archived: true }) + return json({}) + }) + render( + + + + + + + + , + ) + await waitFor(() => expect(tableRow('b1')).not.toBeNull()) + + fireEvent.click(selectAllBox()) + fireEvent.click(bulkButton('archive')) + + await waitFor(() => { + const archives = fetchMock.mock.calls + .filter(([path]) => String(path).endsWith('/archive')) + .map(([path, options]) => [String(path), options?.method]) + // Both were attempted β€” a refusal must not cancel the rest of the batch. + expect(archives.sort()).toEqual([ + ['/api/v1/runs/b1/archive', 'POST'], + ['/api/v1/runs/b2/archive', 'POST'], + ]) + }) + // The receipt is honest about the half that failed, and carries the server's reason. + expect( + await screen.findByText('Archived 1 of 2 tasks β€” 1 failed: run is locked by another agent'), + ).not.toBeNull() + // Same doctrine as every other mutation here: ask the endpoint again rather than trusting a + // cache that believes both rows moved. + await waitFor(() => { + const listFetches = fetchMock.mock.calls.filter(([path]) => String(path) === '/api/v1/runs') + expect(listFetches.length).toBeGreaterThan(1) + }) + }) + it('PATCHes a table rename to /api/v1/runs/:id and refetches the authoritative list', async () => { renderApp([run({ id: 'rn1', title: 'Old name', status: 'done' })]) await waitFor(() => expect(tableRow('rn1')).not.toBeNull()) diff --git a/packages/web/src/routes/tasks-overview.tsx b/packages/web/src/routes/tasks-overview.tsx index d8b073cfd..f29adf4bc 100644 --- a/packages/web/src/routes/tasks-overview.tsx +++ b/packages/web/src/routes/tasks-overview.tsx @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { ArchiveIcon, + ArchiveRestoreIcon, CheckCheckIcon, ChevronsLeftIcon, ChevronsRightIcon, @@ -8,6 +9,8 @@ import { CoinsIcon, CpuIcon, DollarSignIcon, + EyeIcon, + EyeOffIcon, FileDiffIcon, GitBranchIcon, ListChecksIcon, @@ -19,11 +22,12 @@ import { SearchIcon, SearchXIcon, WorkflowIcon, + XIcon, } from 'lucide-react' import * as React from 'react' import { Link, useNavigate } from '@/lib/project-router' -import { archiveFinished, markAllRunsSeen, patchRun } from '@/api/client' +import { archiveFinished, archiveRun, markAllRunsSeen, markRunSeen, markRunUnseen, patchRun } from '@/api/client' import { useRunUsage } from '@/api/global-events' import { queryKeys, useHealth, useReferenceProjectId, useRuns } from '@/api/queries' import type { RunRecord } from '@open-mercato/cezar-api-client' @@ -31,6 +35,7 @@ import { CenteredState } from '@/components/centered-state' import { DiffStatLabel } from '@/components/diff-stat' import { DirectionalUsage } from '@/components/directional-usage' import { TitleEditInput, useTitleEditor } from '@/components/editable-title' +import { FacetFilter } from '@/components/facet-filter' import { useListView } from '@/components/list-view' import { Pill } from '@/components/pill' import { TaskReferenceChip } from '@/components/reference-conflict-action' @@ -53,14 +58,31 @@ import { } from '@/lib/task-columns' import { listCounts, queuePositions, runTitle, sortRuns, type ListView } from '@/lib/task-groups' import { + BULK_ACTION_IDS, + NO_SELECTION, + bulkResultMessage, + selectionSummary, + toggleAllVisible, + toggleSelected, + type BulkActionId, + type HeaderSelectionState, + type SelectionSummary, +} from '@/lib/task-selection' +import { + NO_TASK_FILTERS, + activeFilterCount, compareGroups, - filterRuns, + filterTaskList, finishedRunCount, formatCost, + hasActiveTaskFilters, scheduledResume, + statusFacetOptions, taskReference, + toggleStatusFilter, usageCells, workflowLabel, + type TaskListFilters, type UsageCell, } from '@/lib/tasks-table' import { usageMetricVisibility } from '@/lib/token-metrics' @@ -87,6 +109,8 @@ export function TasksOverview({ onArchiveFinished, onMarkAllRead, onRename, + onBulkAction = () => undefined, + bulkPending = false, now = Date.now(), showTokens = true, showCost = true, @@ -105,6 +129,13 @@ export function TasksOverview({ /** Inline rename from the table's Task cell (spec step 15) β€” the route wires this to * `PATCH /api/runs/:id`, the same flow as the run header's pencil. */ onRename: (id: string, title: string) => void + /** One bulk edit over the selected rows. Already narrowed to the rows the action would really + * change (`SelectionSummary.targets`), so the route fans out exactly N requests and reports + * exactly N outcomes. Defaulted, like `onToggleColumn`, so a direct render needs no stub. */ + onBulkAction?: (action: BulkActionId, runs: readonly RunRecord[]) => void + /** A bulk edit is in flight β€” the bar's buttons wait rather than letting a second batch race + * the first over the same rows. */ + bulkPending?: boolean /** Injected so the ages are not racing the clock in tests. */ now?: number /** Presentation capability; defaults visible for older health responses and direct renders. */ @@ -116,17 +147,39 @@ export function TasksOverview({ /** Prevent a shallow write before the authoritative workspace state can preserve siblings. */ columnsPending?: boolean }) { - const [query, setQuery] = React.useState('') + const [filters, setFilters] = React.useState(NO_TASK_FILTERS) + // Selected row ids. Kept raw β€” never pruned by an effect β€” because every reader intersects it + // with what is on screen (`selectionSummary`), so an id whose row left the view is already + // inert. An effect that pruned it would be a second source of truth racing the first. + const [selected, setSelected] = React.useState>(NO_SELECTION) const all = runs ?? [] const counts = listCounts(all) - const visible = sortRuns(filterRuns(all, query), view) + // In-view (Active or Archived) and sorted, before the filter bar narrows it: the status facet's + // options come from here, so it offers the statuses of the list you are LOOKING at rather than + // the ones your own ticks have left. + const inView = sortRuns(all, view) + const visible = filterTaskList(inView, filters) // Positions come from the full list, never the filtered one: a search must not renumber the // queue the engine is actually going to drain. const positions = queuePositions(all) - const strips = compareGroups(filterRuns(all, query), view) + const strips = compareGroups(filterTaskList(all, filters), view) const finished = finishedRunCount(all) const columns = taskColumnsForCapabilities({ tokens: showTokens, cost: showCost }) const unread = unreadDoneCount(all) + const statusOptions = statusFacetOptions(inView, filters) + const selection = selectionSummary(visible, selected) + + const setQuery = (query: string) => setFilters((current) => ({ ...current, query })) + const clearFilters = () => setFilters(NO_TASK_FILTERS) + // The bar acts, then empties itself: the rows it changed are usually leaving the view (an + // archive is the motivating case), and a selection left pointing at them would invite a second + // click that does nothing. The receipt is the route's toast, not a lingering tick. + const runBulkAction = (action: BulkActionId) => { + const targets = selection.targets[action] + if (targets.length === 0) return + onBulkAction(action, targets) + setSelected(NO_SELECTION) + } return (
@@ -171,6 +224,32 @@ export function TasksOverview({ Archive finished ) : null} + {/* Status, as the same searchable multi-select pill the global Tasks page uses β€” one + filter grammar across both surfaces. Multi-select rather than a ` setQuery(event.target.value)} - placeholder="Search tasks…" + // The number is advertised because it is not guessable: a box that says only + // "Search tasks" is not a box anyone pastes a PR number into. (Spelled in words + // rather than as a `#nnn` example β€” a three-digit one reads as a hex colour to the + // design guardian, and it is not worth an exception.) + placeholder="Search tasks, or a PR/issue number…" aria-label="Search tasks" className="h-9 w-full rounded-md border border-input bg-card pr-3 pl-8 text-[13px] text-foreground outline-none placeholder:text-soft-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50" /> @@ -188,8 +271,17 @@ export function TasksOverview({
+ {selection.count > 0 ? ( + setSelected(NO_SELECTION)} + /> + ) : null} + {runs === undefined ? null : visible.length === 0 ? ( - + ) : ( <> {/* β‰₯md: the table. */} @@ -200,6 +292,10 @@ export function TasksOverview({ + {/* The selection column is outside the foldable registry on purpose: it is + not a column of run DATA, it is the handle for editing the rows, and it + must never be folded away with the metric columns. */} + {columns.map((column) => { const expanded = isColumnExpanded(column.id, expandedColumns) return ( @@ -214,6 +310,20 @@ export function TasksOverview({ + {columns.map((column) => ( setSelected((current) => toggleSelected(current, run.id))} /> ))} @@ -252,6 +364,8 @@ export function TasksOverview({ now={now} showTokens={showTokens} showCost={showCost} + selected={selected.has(run.id)} + onToggleSelected={() => setSelected((current) => toggleSelected(current, run.id))} /> ))} @@ -297,9 +411,12 @@ export function TasksOverview({ * (spec: textures on hero/empty surfaces only); a missed search or an unswept archive is just * a fact, so those stay flat. `heading="h2"` because the page's h1 is the header's "Tasks". */ -function TasksEmptyState({ view, query }: { view: ListView; query: string }) { - const needle = query.trim() - const kind = needle ? 'search-miss' : view === 'archived' ? 'archive' : 'no-tasks' +function TasksEmptyState({ view, filters }: { view: ListView; filters: TaskListFilters }) { + const needle = filters.query.trim() + // A status tick empties the list exactly as a missed search does, and for the same reason β€” + // something the reader turned on. Reporting "Nothing archived yet" over a filtered-away archive + // would blame the list for the filter. + const kind = hasActiveTaskFilters(filters) ? 'search-miss' : view === 'archived' ? 'archive' : 'no-tasks' return (
{kind === 'search-miss' ? ( @@ -308,7 +425,7 @@ function TasksEmptyState({ view, query }: { view: ListView; query: string }) { icon={} tone="neutral" title="No matching tasks" - subtitle={`No tasks match β€œ${needle}”.`} + subtitle={needle ? `No tasks match β€œ${needle}”.` : 'No tasks match the filters you picked.'} /> ) : kind === 'archive' ? ( ` rather than a styled `
`, because the + * row it sits in already exempts real controls from its click-to-navigate handler (`closest('a, + * button, input')`), and because a native box is the one that keyboards, screen readers and + * shift-click already understand. `indeterminate` is a DOM property with no attribute, so it is + * set through the ref β€” the one thing React cannot express declaratively here. + */ +function SelectionCheckbox({ + state, + label, + onToggle, + slot = 'select-task', + className, +}: { + state: HeaderSelectionState + label: string + onToggle: () => void + slot?: string + className?: string +}) { + return ( + { + if (element) element.indeterminate = state === 'some' + }} + onChange={onToggle} + aria-label={label} + title={label} + className={cn('size-3.5 shrink-0 cursor-pointer accent-violet', className)} + /> + ) +} + +/** What each bulk action is called, and what it looks like. One table so the bar renders from + * `BULK_ACTION_IDS` and a fifth action is an entry here plus a case in the route's fan-out. */ +const BULK_ACTIONS: Record = { + archive: { + label: 'Archive', + icon:
{ if ((event.target as Element).closest('a, button, input')) return navigate(to) }} - className="group/row cursor-pointer hover:bg-muted" + className={cn('group/row cursor-pointer hover:bg-muted', selected && 'bg-violet/5')} > + {columns.map((column) => { if (column.id === 'memory') return null if (column.id === 'cpu') { @@ -789,12 +1049,16 @@ function TaskCard({ now, showTokens, showCost, + selected, + onToggleSelected, }: { run: RunRecord queuePosition: number | null now: number showTokens: boolean showCost: boolean + selected: boolean + onToggleSelected: () => void }) { const navigate = useNavigate() const attention = deriveAttention(run) @@ -811,13 +1075,25 @@ function TaskCard({
{ - if ((event.target as Element).closest('a')) return + // `input` joins the exemption list now that a card carries a tick box: without it the + // first tap on the box would select the row AND navigate away from the list it belongs to. + if ((event.target as Element).closest('a, button, input')) return navigate(to) }} - className="cursor-pointer rounded-lg border border-border bg-card px-3.5 py-3 shadow-xs" + className={cn( + 'cursor-pointer rounded-lg border border-border bg-card px-3.5 py-3 shadow-xs', + selected && 'border-violet/40 bg-violet/5', + )} >
+ {attention.label} {scheduled ? {scheduled.label} : null} @@ -910,6 +1186,27 @@ function BranchChip({ branch }: { branch: string }) { ) } +/** One row's half of a bulk edit. The switch is exhaustive over `BulkActionId`, so a fifth action + * is a compile error here rather than a button that quietly does nothing. */ +function bulkRequest(action: BulkActionId, id: string): Promise { + switch (action) { + case 'archive': + return archiveRun(id, true) + case 'restore': + return archiveRun(id, false) + case 'read': + return markRunSeen(id) + case 'unread': + return markRunUnseen(id) + } +} + +/** A rejected fan-out request, as a sentence. `allSettled` hands back `unknown`, and the client's + * rejections are `Error`s carrying the server's one-line reason. */ +function errorMessage(reason: unknown): string { + return reason instanceof Error ? reason.message : String(reason) +} + /** * The overview wired to live data: `useRuns()` (kept fresh by the global SSE stream), the shared * Active/Archived context (the sidebar's tabs and these are one state), and the archive-finished @@ -940,6 +1237,29 @@ export function TasksOverviewRoute() { onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.runs.all }), onError: (error: Error) => toast(error.message, { tone: 'danger' }), }) + // The multi-edit fan-out. There is no batch endpoint and this run does not add one: every + // action already has a per-run route, the server is on loopback, and a selection is tens of + // rows rather than thousands. `allSettled`, not `all`, is the whole point β€” one refused write + // must not cancel the rest, and the receipt has to be able to say "3 of 5". + const bulk = useMutation({ + mutationFn: async ({ action, runs }: { action: BulkActionId; runs: readonly RunRecord[] }) => { + const results = await Promise.allSettled(runs.map((run) => bulkRequest(action, run.id))) + const failures = results.flatMap((result) => + result.status === 'rejected' ? [errorMessage(result.reason)] : [], + ) + return { total: runs.length, failures } + }, + onSuccess: ({ total, failures }, { action }) => { + toast(bulkResultMessage(action, total, failures), failures.length > 0 ? { tone: 'danger' } : undefined) + }, + // Reached only if the fan-out itself threw, which `allSettled` makes unlikely β€” but a toast + // beats a silent no-op if it ever does. + onError: (error: Error) => toast(error.message, { tone: 'danger' }), + // Always, both halves: the stream has probably patched each run already, but the endpoint's + // answer is the truth β€” and after a partial failure the cache is the only place that still + // believes every row changed. + onSettled: () => queryClient.invalidateQueries({ queryKey: queryKeys.runs.all }), + }) const now = useNow(30_000) const taskTableColumns = useTaskTableColumns() // Chip statuses are hydrated HERE rather than inside `TasksOverview`, which is a pure @@ -969,6 +1289,8 @@ export function TasksOverviewRoute() { onArchiveFinished={() => archive.mutate()} onMarkAllRead={() => markAllRead.mutate()} onRename={(id, title) => rename.mutate({ id, title })} + onBulkAction={(action, selected) => bulk.mutate({ action, runs: selected })} + bulkPending={bulk.isPending} now={now} showTokens={metricVisibility.tokens} showCost={metricVisibility.cost}
+ setSelected((current) => toggleAllVisible(visible, current))} + /> +
+ +