diff --git a/FEATURES-SPEC.md b/FEATURES-SPEC.md index 9d89f5e90..5c904d10b 100644 --- a/FEATURES-SPEC.md +++ b/FEATURES-SPEC.md @@ -101,6 +101,7 @@ happens while nobody is at the keyboard. - Queue a ticket into the AI queue - Queue every ticket the filters show into the AI queue, in one click from the page heading - Queue a plan for every unplanned ticket the filters show, from the same heading +- Select tickets row by row (a checkbox per row) — while any are selected, the heading's queue buttons act on just the selected tickets - Tickets carry a GitHub issue link, so merging closes the issue ## Handoff and what lands in git diff --git a/packages/framework/dashboard/components/TicketsPage.SPEC.md b/packages/framework/dashboard/components/TicketsPage.SPEC.md index 878fffac1..ba90d8e67 100644 --- a/packages/framework/dashboard/components/TicketsPage.SPEC.md +++ b/packages/framework/dashboard/components/TicketsPage.SPEC.md @@ -2,7 +2,7 @@ The dashboard's Tickets page: every registered project's `tickets/` backlog on o ## User story -The user wants to see the whole backlog — not one project's slice of it — decide what to work next, and act on a ticket right there: read it, read its plan, have an agent plan it, or have an agent work it. And when the filters have carved out a coherent slice, they want to queue that whole slice for the AI in one click. +The user wants to see the whole backlog — not one project's slice of it — decide what to work next, and act on a ticket right there: read it, read its plan, have an agent plan it, or have an agent work it. And when the filters have carved out a coherent slice, they want to queue that whole slice for the AI in one click — or hand-pick some of the shown tickets and have those same clicks act on just the picked ones. ## Business logic — TL;DR @@ -13,6 +13,7 @@ The user wants to see the whole backlog — not one project's slice of it — de - **Plan it or work it from the row** - a ticket can be handed to a planning agent or to an unattended work agent without leaving the page. - **Queue the whole shown set** - a button beside the page's heading adds every unclaimed shown ticket to the AI queue (a ticket already queued stays as it is), counting on its label what one click adds; no agent starts — the queue's own consumers do that. - **Queue plans for the whole shown set** - a sibling button queues one plan ask per shown ticket still to plan — the plan-tickets ask, placed by the ticket's priority — skipping tickets already planned, already queued, or claimed. +- **Selecting rows narrows the queue buttons** - every row carries a checkbox; while any shown row is ticked, both queue buttons say "selected" and act on just the ticked tickets, with a readout of how many are selected and a way to clear the selection. - **Filtered-away tickets are accounted for** - the page says how many tickets the filters hide and offers to clear them; a project the user deselected disappears silently instead. ## Business logic @@ -83,7 +84,7 @@ The user narrows the backlog to a coherent slice — a topic, a priority band, o #### Business logic -Whenever the filters leave at least one unclaimed ticket showing, the page's heading row offers a button that adds the shown tickets to the AI queue — "Add all X tickets shown below to the AI queue". Each ticket is queued exactly as the ticket detail page's Queue action queues it (the entry links back to the ticket and lands in its priority's section), walked in the shown order so entries within a priority section keep the order the reader saw, and each on its own project's queue, so a shown set spanning projects needs no special case. A ticket an open queue entry already links to is left as it stands — "add" means the set ends up queued, never queued twice. No agent starts: the queue is what the framework's own routine drain fans out over and what the AI Queue card's play buttons start one entry at a time. +Whenever the filters leave at least one unclaimed ticket showing, the page's heading row offers a button that adds the shown tickets — or, while any row is selected, just the selected ones (see "Selecting rows narrows the queue buttons") — to the AI queue: "Add all X tickets shown below to the AI queue". Each ticket is queued exactly as the ticket detail page's Queue action queues it (the entry links back to the ticket and lands in its priority's section), walked in the shown order so entries within a priority section keep the order the reader saw, and each on its own project's queue, so a shown set spanning projects needs no special case. A ticket an open queue entry already links to is left as it stands — "add" means the set ends up queued, never queued twice. No agent starts: the queue is what the framework's own routine drain fans out over and what the AI Queue card's play buttons start one entry at a time. Claimed tickets — those an agent already holds — are skipped: they are being worked, and the label then counts only the unclaimed tickets so it never promises a ticket it will skip; hovering explains the mechanics and says how many claimed tickets are being left alone. With nothing to add — nothing shown, or everything shown claimed — the button is not offered. @@ -101,7 +102,7 @@ The user has filtered the backlog to a slice they intend to work soon and wants #### Business logic -Beside the queue-the-shown-set button sits its plan sibling: one click queues, for every shown ticket that has no plan yet and no claim on it, the ask for that ticket's plan — the same wording the plan-tickets preset queues — placed in the AI queue by the ticket's own priority, walked in the shown order. A drain agent reaching such an entry writes the plan. No agent starts from the click. +Beside the queue-the-shown-set button sits its plan sibling: one click queues, for every shown ticket that has no plan yet and no claim on it — or, while any row is selected, only every such *selected* ticket (see "Selecting rows narrows the queue buttons") — the ask for that ticket's plan — the same wording the plan-tickets preset queues — placed in the AI queue by the ticket's own priority, walked in the shown order. A drain agent reaching such an entry writes the plan. No agent starts from the click. The click leaves alone what queueing again would waste: a ticket whose plan ask is already an open entry (recognized by its exact wording), and a ticket already queued for implementation — its work would land before a trailing plan could matter. The button's label counts only what it will ask for, saying "unplanned" the moment its count differs from the shown tally; hovering explains the mechanics, the worked order, and what is left alone. Once a click has queued the shown set's plans the button reads "Plans queued" and rests until the shown set changes. With nothing left to plan the button is not offered — the queue-the-tickets button stands on its own. @@ -109,6 +110,25 @@ The click leaves alone what queueing again would waste: a ticket whose plan ask Plans are for a human to read before spending agents, so asking for them in bulk is the natural prelude to queueing the same slice for implementation. The asks ride the same queue as everything else so the framework's own consumers pick them up with no new machinery — and the entry deliberately is not a ticket link, since a leading ticket link is what every reader takes as "queued for implementation". +### Selecting rows narrows the queue buttons + +#### User story + +The filters cannot always carve out exactly the tickets the user means — a hand-picked few from across the shown list — and they want the page's queue buttons to act on just those, without queueing them one by one from their detail pages. + +#### Business logic + +Every ticket row carries a checkbox, in grouped and flat mode alike. While at least one shown row is ticked, both queue buttons stop speaking for the whole shown set and speak for the selection instead: their labels say "selected" and count only the selected tickets each click would add, their skip rules unchanged — the queue-add still skips claimed selected tickets, the plan button still skips planned and claimed ones and everything already queued. The heading row says how many tickets are selected and offers to clear the selection in one click. Both buttons' rested "Queued"/"Plans queued" states are per acted-on set, so changing the selection arms them again, exactly as changing the filters does. + +Only selected rows the filters still show count: a selected ticket the filters have hidden is neither counted nor acted on — what a button acts on is always visible below it — but the tick itself survives and comes back with the row when the filters release it. With every selected row hidden, the buttons speak for the whole shown set again and the selection readout disappears. + +Selecting is page state, never an action: checkboxes are always enabled, and ticking one starts nothing. + +#### Rationale + +- Narrowing the existing buttons rather than adding selection-only ones: one pair of buttons whose label always names its set keeps the heading readable, and the "Queued" rest state carries over unchanged. +- Counting only shown selected rows keeps the buttons honest — a click never touches a ticket the user cannot currently see — while preserved ticks spare the user re-picking after a detour through the filters. + ### Filtered-away tickets are accounted for #### User story diff --git a/packages/framework/dashboard/components/TicketsPage.test.SPEC.md b/packages/framework/dashboard/components/TicketsPage.test.SPEC.md index 1898c474f..8f29bdf8b 100644 --- a/packages/framework/dashboard/components/TicketsPage.test.SPEC.md +++ b/packages/framework/dashboard/components/TicketsPage.test.SPEC.md @@ -10,6 +10,8 @@ The page-wide queue-add button: every shown ticket is queued the way the ticket Its plan sibling: every shown ticket still to plan gets one plan ask queued, the ticket named with its priority, with no implementation entry and no agent started; after the click the button reads "Plans queued" and rests; planned and claimed tickets are skipped with the label counting only what is left to plan; a plan ask already queued (recognized by its exact wording) and a ticket already queued for implementation are not asked again; and with every shown ticket planned the plan button is absent while the queue-add still offers. +Row selection: ticking rows makes both queue buttons say "selected", count only the ticked tickets, and act on just them — the rest of the shown set stays put — with the heading saying how many are selected; changing the selection re-arms a rested button for the new set; clearing the selection hands the buttons back to the whole shown set; a claimed ticket in the selection is still skipped, the label counting without it; a selected ticket the filters hide is neither counted nor acted on but stays ticked and comes back with the row; and the flat cross-project list selects the same way, each row queued on its own project. + ## Before modifying/creating SPEC.md files You must always read and respect https://raw.githubusercontent.com/brillout/sdd/refs/heads/main/sdd.md diff --git a/packages/framework/dashboard/components/TicketsPage.test.tsx b/packages/framework/dashboard/components/TicketsPage.test.tsx index 38c8d2e75..4bdaa4a1f 100644 --- a/packages/framework/dashboard/components/TicketsPage.test.tsx +++ b/packages/framework/dashboard/components/TicketsPage.test.tsx @@ -455,3 +455,121 @@ describe('TicketsPage queue plans for the shown set', () => { expect(screen.getByRole('button', { name: 'Add the ticket shown below to the AI queue' })).toBeTruthy() }) }) + +// Row selection (GitHub's list idiom): every row carries a checkbox, and while any is ticked the +// heading's queue buttons speak for — and act on — just the selected tickets. +describe('TicketsPage selection scopes the queue buttons', () => { + const threeTickets = () => + onAllTickets.mockResolvedValue([ + { + projectId: 'p1', + projectName: 'Alpha', + tickets: [ + ticket({ file: 'a.md', title: 'First', priority: '7' }), + ticket({ file: 'b.md', title: 'Second' }), + ticket({ file: 'c.md', title: 'Third' }), + ], + }, + ]) + + test('selected tickets are what the queue-add adds — the rest of the shown set stays put', async () => { + threeTickets() + const { sendQueueTicket } = await controls() + render( {}} />) + await screen.findByText('First') + fireEvent.click(screen.getByRole('checkbox', { name: 'Select First' })) + fireEvent.click(screen.getByRole('checkbox', { name: 'Select Third' })) + expect(screen.getByText('2 selected')).toBeTruthy() + // The label stops speaking for the shown set and counts the selection instead. + fireEvent.click(screen.getByRole('button', { name: 'Add the 2 selected tickets to the AI queue' })) + await screen.findByRole('button', { name: 'Queued' }) + expect(sendQueueTicket).toHaveBeenCalledTimes(2) + expect(sendQueueTicket).toHaveBeenCalledWith('p1', 'First', { file: 'a.md', priority: '7' }) + expect(sendQueueTicket).toHaveBeenCalledWith('p1', 'Third', { file: 'c.md' }) + // Changing the selection is changing the set: the rested button arms again for the new one. + fireEvent.click(screen.getByRole('checkbox', { name: 'Select Second' })) + expect(await screen.findByRole('button', { name: 'Add the 3 selected tickets to the AI queue' })).toBeTruthy() + }) + + test('the plan button narrows to the selection the same way', async () => { + threeTickets() + const { sendQueueTicketPlan } = await controls() + render( {}} />) + await screen.findByText('First') + fireEvent.click(screen.getByRole('checkbox', { name: 'Select Second' })) + fireEvent.click(screen.getByRole('button', { name: 'Queue a plan for the selected ticket' })) + await screen.findByRole('button', { name: 'Plans queued' }) + expect(sendQueueTicketPlan).toHaveBeenCalledTimes(1) + expect(sendQueueTicketPlan).toHaveBeenCalledWith('p1', { file: 'b.md' }) + }) + + test('clearing the selection hands the buttons back to the whole shown set', async () => { + threeTickets() + render( {}} />) + await screen.findByText('First') + fireEvent.click(screen.getByRole('checkbox', { name: 'Select First' })) + expect(screen.getByRole('button', { name: 'Add the selected ticket to the AI queue' })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Clear selection' })) + expect(screen.queryByText(/selected/)).toBeNull() + expect(screen.getByRole('button', { name: 'Add all 3 tickets shown below to the AI queue' })).toBeTruthy() + }) + + test('a claimed ticket in the selection is still skipped, and the label counts without it', async () => { + onAllTickets.mockResolvedValue([ + { + projectId: 'p1', + projectName: 'Alpha', + tickets: [ + ticket({ file: 'a.md', title: 'First' }), + ticket({ file: 'b.md', title: 'Second', locked: true, lockedBy: 'agent-1' }), + ], + }, + ]) + const { sendQueueTicket } = await controls() + render( {}} />) + await screen.findByText('First') + fireEvent.click(screen.getByRole('checkbox', { name: 'Select First' })) + fireEvent.click(screen.getByRole('checkbox', { name: 'Select Second' })) + fireEvent.click(screen.getByRole('button', { name: 'Add the one unclaimed selected ticket to the AI queue' })) + await screen.findByRole('button', { name: 'Queued' }) + expect(sendQueueTicket).toHaveBeenCalledTimes(1) + expect(sendQueueTicket).toHaveBeenCalledWith('p1', 'First', { file: 'a.md' }) + }) + + test('a selected ticket the filters hide is neither counted nor acted on, and stays selected', async () => { + threeTickets() + const { sendQueueTicket } = await controls() + render( {}} />) + await screen.findByText('Second') + fireEvent.click(screen.getByRole('checkbox', { name: 'Select Second' })) + // Hide the selected row: the selection has nothing shown, so the buttons speak for the + // shown set again and the selection readout goes quiet. + fireEvent.change(screen.getByRole('textbox', { name: /search tickets/i }), { target: { value: 'First' } }) + await waitFor(() => expect(screen.queryByText('Second')).toBeNull()) + expect(screen.queryByText('1 selected')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Add the ticket shown below to the AI queue' })) + await screen.findByRole('button', { name: 'Queued' }) + expect(sendQueueTicket).toHaveBeenCalledTimes(1) + expect(sendQueueTicket).toHaveBeenCalledWith('p1', 'First', { file: 'a.md', priority: '7' }) + // The tick itself survives the filter and comes back with the row. + fireEvent.change(screen.getByRole('textbox', { name: /search tickets/i }), { target: { value: '' } }) + const box = await screen.findByRole('checkbox', { name: 'Select Second' }) + expect(box.getAttribute('data-checked')).not.toBeNull() + }) + + test('the flat cross-project list selects the same way, each row on its own project', async () => { + onAllTickets.mockResolvedValue([ + { projectId: 'p1', projectName: 'Alpha', tickets: [ticket({ file: 'a.md', title: 'Alpha ticket' })] }, + { projectId: 'p2', projectName: 'Beta', tickets: [ticket({ file: 'b.md', title: 'Beta ticket' })] }, + ]) + window.history.replaceState(null, '', '/tickets?group=none') + const { sendQueueTicket } = await controls() + render( {}} />) + await screen.findByText('Beta ticket') + fireEvent.click(screen.getByRole('checkbox', { name: 'Select Beta ticket' })) + fireEvent.click(screen.getByRole('button', { name: 'Add the selected ticket to the AI queue' })) + await screen.findByRole('button', { name: 'Queued' }) + expect(sendQueueTicket).toHaveBeenCalledTimes(1) + expect(sendQueueTicket).toHaveBeenCalledWith('p2', 'Beta ticket', { file: 'b.md' }) + }) +}) diff --git a/packages/framework/dashboard/components/TicketsPage.tsx b/packages/framework/dashboard/components/TicketsPage.tsx index 6a5ad5b65..93948fc59 100644 --- a/packages/framework/dashboard/components/TicketsPage.tsx +++ b/packages/framework/dashboard/components/TicketsPage.tsx @@ -39,7 +39,8 @@ function initialView(): TicketsView { // faceted filters (priority/topics/stage/effort/uncertainty/project/unlinked), sort with // direction, and Group by project (each section its own poll-independent TicketsPanel) vs a flat // cross-project list — the one view that can answer "what is the single highest-priority ticket -// anywhere". +// anywhere". Rows are selectable (GitHub's list idiom): while any shown row is ticked, the +// heading's queue buttons narrow from the whole shown set to just the selected tickets. export function TicketsPage({ onOpenTicket, onOpenTicketPlan, @@ -170,31 +171,61 @@ export function TicketsPage({ const shownGroups = view.filters.projects.length > 0 ? groups.filter(g => view.filters.projects.includes(g.projectId)) : groups const flatRows = sortRows(visible, view.sort) - // What the queue-add buttons act on — the shown rows minus what each add would waste. Both - // skip claimed tickets: a ticket some agent already holds (#1420) is being worked, and its - // entry would outlive that work as queue noise. The plan add also skips planned tickets, - // whose plan already exists. In the shown order, each row carrying its own project, so a - // cross-project view needs no special case: every entry lands on its own project's queue. - const targets = flatRows.filter(r => !r.ticket.locked) - const claimedShown = flatRows.length - targets.length + // The row selection (GitHub's list idiom): tick some rows and the queue buttons narrow to just + // them. Keyed project + file, since the page spans projects and two projects can share a + // filename. Only shown selected rows count — a selected ticket the filters hide is neither + // acted on nor counted, and comes back with its row — so what the buttons act on is always + // visible below. A key whose ticket is gone lingers in the set harmlessly: it intersects + // nothing shown. + const [selected, setSelected] = useState>(new Set()) + const toggleSelected = (key: string) => + setSelected(prev => { + const next = new Set(prev) + if (!next.delete(key)) next.add(key) + return next + }) + const selectedShown = flatRows.filter(r => selected.has(`${r.projectId}/${r.ticket.file}`)) + const hasSelection = selectedShown.length > 0 + + // What the queue-add buttons act on — the shown rows, narrowed to the selected ones the moment + // any row is selected, minus what each add would waste. Both skip claimed tickets: a ticket + // some agent already holds (#1420) is being worked, and its entry would outlive that work as + // queue noise. The plan add also skips planned tickets, whose plan already exists. In the + // shown order, each row carrying its own project, so a cross-project view needs no special + // case: every entry lands on its own project's queue. + const scope = hasSelection ? selectedShown : flatRows + const targets = scope.filter(r => !r.ticket.locked) + const claimedShown = scope.length - targets.length const planTargets = targets.filter(r => !r.ticket.planned) - const planSkipped = flatRows.length - planTargets.length - // One flip per shown set and per button: once this exact set is queued the button says so and - // rests, and any change to the set — a filter, a poll bringing new tickets — arms it again. + const planSkipped = scope.length - planTargets.length + // One flip per acted-on set and per button: once this exact set is queued the button says so and + // rests, and any change to the set — a filter, a poll bringing new tickets, a selection — arms + // it again. const queueKey = targets.map(r => `${r.projectId}/${r.ticket.file}`).join('\n') const queuedShown = queuedKey === queueKey const planKey = planTargets.map(r => `${r.projectId}/${r.ticket.file}`).join('\n') const plansQueuedShown = plansQueuedKey === planKey - // The labels count what the click adds — and stop saying "all" the moment their count differs - // from the shown tally, never promising a ticket they will skip. - const queueLabel = - targets.length === 1 + // The labels count what the click adds — saying "selected" while a selection narrows the + // buttons, and stopping saying "all" the moment their count differs from the set's tally, + // never promising a ticket they will skip. + const queueLabel = hasSelection + ? targets.length === 1 + ? `Add the ${claimedShown > 0 ? 'one unclaimed ' : ''}selected ticket to the AI queue` + : claimedShown > 0 + ? `Add the ${targets.length} unclaimed selected tickets to the AI queue` + : `Add the ${targets.length} selected tickets to the AI queue` + : targets.length === 1 ? `Add the ${claimedShown > 0 ? 'one unclaimed ' : ''}ticket shown below to the AI queue` : claimedShown > 0 ? `Add the ${targets.length} unclaimed tickets shown below to the AI queue` : `Add all ${targets.length} tickets shown below to the AI queue` - const planLabel = - planTargets.length === 1 + const planLabel = hasSelection + ? planTargets.length === 1 + ? `Queue a plan for the ${planSkipped > 0 ? 'one unplanned ' : ''}selected ticket` + : planSkipped > 0 + ? `Queue plans for the ${planTargets.length} unplanned selected tickets` + : `Queue plans for the ${planTargets.length} selected tickets` + : planTargets.length === 1 ? `Queue a plan for the ${planSkipped > 0 ? 'one unplanned ' : ''}ticket shown below` : planSkipped > 0 ? `Queue plans for the ${planTargets.length} unplanned tickets shown below` @@ -220,12 +251,26 @@ export function TicketsPage({ Every project's tickets/ backlog — what the agent plans from.

- {/* The whole shown set onto the queue: the detail page's Queue action lifted to the - page, and its plan sibling — the [Plan tickets] preset's entries for the same set. - Each renders only when it has something to add — "all 0 tickets" is not an offer, - the list below already explains an empty set, and an all-claimed (or, for plans, - all-planned) one has nothing left to ask for. */} + {/* The whole shown set onto the queue — or, with rows ticked, just the selected ones: + the detail page's Queue action lifted to the page, and its plan sibling — the [Plan + tickets] preset's entries for the same set. Each renders only when it has something + to add — "all 0 tickets" is not an offer, the list below already explains an empty + set, and an all-claimed (or, for plans, all-planned) one has nothing left to ask + for. */}
+ {/* The selection's own readout and exit: while any row is ticked the buttons stop + speaking for the shown set, so the count says what took over and the clear hands + the page back without hunting down every ticked box. */} + {hasSelection && ( + + + {selectedShown.length} selected + + + + )} {loaded && planTargets.length > 0 && ( - Each ticket gets its plan asked for on the AI queue — the same "Create tickets/….plan.md" entry the + Each {hasSelection ? 'selected ' : ''}ticket gets its plan asked for on the AI queue — the same "Create tickets/….plan.md" entry the Plan tickets preset queues — worked highest priority first and, within a priority, in the order shown below. Tickets already planned, already queued, or held by an agent stay as they are. + {hasSelection && ' The rest of the shown set stays put.'} )} @@ -282,9 +328,10 @@ export function TicketsPage({ )} - {'Every ticket joins the AI queue — the work the framework picks up on its own, worked highest priority first and, within a priority, in the order shown below. A ticket already queued stays as it is.'} - {claimedShown === 1 && ' The claimed ticket shown is left to the agent holding it.'} - {claimedShown > 1 && ` The ${claimedShown} claimed tickets shown are left to the agents holding them.`} + {`Every ${hasSelection ? 'selected ' : ''}ticket joins the AI queue — the work the framework picks up on its own, worked highest priority first and, within a priority, in the order shown below. A ticket already queued stays as it is.`} + {hasSelection && ' The rest of the shown set stays put.'} + {claimedShown === 1 && ` The claimed ticket ${hasSelection ? 'selected' : 'shown'} is left to the agent holding it.`} + {claimedShown > 1 && ` The ${claimedShown} claimed tickets ${hasSelection ? 'selected' : 'shown'} are left to the agents holding them.`} )} @@ -337,6 +384,8 @@ export function TicketsPage({ ticket={r.ticket} projectName={r.projectName} busy={busy} + selected={selected.has(`${r.projectId}/${r.ticket.file}`)} + onToggleSelect={() => toggleSelected(`${r.projectId}/${r.ticket.file}`)} onOpen={() => onOpenTicket(r.projectId, r.ticket.file)} onStartWork={() => void startWork(r.projectId, r.ticket.file)} onOpenPlan={onOpenTicketPlan ? () => onOpenTicketPlan(r.projectId, r.ticket.file) : undefined} @@ -362,6 +411,8 @@ export function TicketsPage({ tickets={sorted.map(r => r.ticket)} loaded hiddenByFilter={g.tickets.length - groupRows.length} + isSelected={file => selected.has(`${g.projectId}/${file}`)} + onToggleSelect={file => toggleSelected(`${g.projectId}/${file}`)} onOpen={file => onOpenTicket(g.projectId, file)} onOpenPlan={onOpenTicketPlan ? file => onOpenTicketPlan(g.projectId, file) : undefined} onAgentStarted={(intent, agentId) => onAgentStarted?.(g.projectId, intent, agentId)} diff --git a/packages/framework/dashboard/components/TicketsPanel.SPEC.md b/packages/framework/dashboard/components/TicketsPanel.SPEC.md index e74a64276..cd7645ec7 100644 --- a/packages/framework/dashboard/components/TicketsPanel.SPEC.md +++ b/packages/framework/dashboard/components/TicketsPanel.SPEC.md @@ -6,7 +6,7 @@ The user wants to read a project's backlog at a glance — what each ticket is, ## Business logic — TL;DR -- **A ticket is one row** - title, project (in the cross-project list), topics, claim, effort, uncertainty, priority, age, plan, and the GitHub item behind it, all on one line; the row opens the ticket's detail page. +- **A ticket is one row** - title, project (in the cross-project list), topics, claim, effort, uncertainty, priority, age, plan, and the GitHub item behind it, all on one line; the row opens the ticket's detail page. Where the surrounding page selects tickets for its bulk actions, the row also leads with that selection's checkbox. - **Start work from the row** - a play control on the row's left edge starts an unattended agent on that one ticket and nothing else, with the ticket named on the agent. - **The plan column is either a plan or an offer to write one** - a planned ticket links to its plan; an unplanned one offers to start an agent that writes it. - **Claimed rows say who holds them** - a ticket an agent has claimed shows a hammer and the holder's name, meaning an agent is planning or implementing it. @@ -32,6 +32,8 @@ Every ticket occupies a single line. The title takes whatever width the row has Where the surrounding page supports filtering, a row's topics and its claim marker are clickable: a topic filters the page to that topic, the claim marker filters to claimed tickets. Where the page has no filters, they are plain labels. +Where the surrounding page selects tickets for its bulk actions — the Tickets page's queue buttons — the row's left edge leads with a checkbox showing and toggling that selection. The selection itself belongs to the page (it can span projects); the row only reports the toggle, navigates nowhere on it, and never disables the box — selecting is state, not an action. Where the page selects nothing, no checkbox renders. + ### Start work from the row #### User story diff --git a/packages/framework/dashboard/components/TicketsPanel.test.SPEC.md b/packages/framework/dashboard/components/TicketsPanel.test.SPEC.md index 439c234f3..35c4b040b 100644 --- a/packages/framework/dashboard/components/TicketsPanel.test.SPEC.md +++ b/packages/framework/dashboard/components/TicketsPanel.test.SPEC.md @@ -4,6 +4,8 @@ The plan column links a planned ticket to its plan and starts an agent asked to Row controls do not double as navigation: starting work, filtering by a topic, filtering to claimed tickets, and following the GitHub link all leave the row unopened, while clicking the title opens it. A claimed ticket names its holder inline rather than only on hover. +Rows carry a selection checkbox only when the surrounding page wires one: it shows the page's selection state, reports a toggle by the ticket's filename without opening the row, and is absent entirely on a page that selects nothing. + "Update from GitHub" sends the update preset's own text — the same instruction the onboarding checklist sends under that label — unattended, and hands the user to the agent doing the update; a refused start says why and moves the user nowhere. A filled backlog offers the update beside a stamp saying when `tickets/` last caught up, admitting "No record of an import yet" when it does not know; the empty backlog offers exactly one update button and no stamp. A list emptied by filters says how many tickets are hidden and withholds the update, and a panel with no project renders nothing at all. ## Before modifying/creating SPEC.md files diff --git a/packages/framework/dashboard/components/TicketsPanel.test.tsx b/packages/framework/dashboard/components/TicketsPanel.test.tsx index 58735f537..d06655d66 100644 --- a/packages/framework/dashboard/components/TicketsPanel.test.tsx +++ b/packages/framework/dashboard/components/TicketsPanel.test.tsx @@ -94,6 +94,33 @@ describe('TicketsPanel (#697/#1144)', () => { expect(await screen.findByText('plan-1-0')).toBeTruthy() }) + test('rows carry a selection checkbox only when the page wires one, toggling by file without opening the row', async () => { + const onToggleSelect = vi.fn() + const onOpen = vi.fn() + render( + true} + onToggleSelect={onToggleSelect} + onOpen={onOpen} + />, + ) + const box = await screen.findByRole('checkbox', { name: /select do the thing/i }) + // The page said this row is picked, so the box shows it. + expect(box.getAttribute('data-checked')).not.toBeNull() + fireEvent.click(box) + expect(onToggleSelect).toHaveBeenCalledWith('2026-07-20_do-the-thing.md') + // A sibling of the row's open button, like every control here: selecting must not navigate. + expect(onOpen).not.toHaveBeenCalled() + cleanup() + // Without the page's wiring — a surface with nothing to scope to a selection — no checkbox. + render( {}} />) + await screen.findByText('Do the thing') + expect(screen.queryByRole('checkbox')).toBeNull() + }) + test('topic badges filter on click when the page passes a handler, without opening the row (#1144)', async () => { const onOpen = vi.fn() const onTopicClick = vi.fn() diff --git a/packages/framework/dashboard/components/TicketsPanel.tsx b/packages/framework/dashboard/components/TicketsPanel.tsx index 800a751da..737978d09 100644 --- a/packages/framework/dashboard/components/TicketsPanel.tsx +++ b/packages/framework/dashboard/components/TicketsPanel.tsx @@ -8,6 +8,7 @@ import { sendStart } from '../rpc/control.js' import { onTicketsMeta } from '../rpc/reads.js' import { Button } from './ui/button.js' import { Badge } from './ui/badge.js' +import { Checkbox } from './ui/checkbox.js' import { useAction } from '../lib/use-action.js' import { useLoaded } from '../lib/use-async.js' import { formatRelative, formatAge, formatDateTime } from '../lib/format-date.js' @@ -53,6 +54,8 @@ export function TicketRow({ ticket, projectName, busy, + selected, + onToggleSelect, onOpen, onStartWork, onOpenPlan, @@ -65,6 +68,11 @@ export function TicketRow({ projectName?: string | undefined /** Disables the session-starting buttons while a session start is in flight. */ busy: boolean + /** Whether the row is picked for the surrounding page's bulk actions — the page owns the + * selection, the row only shows and toggles it. Without a toggle handler no checkbox + * renders, like the click-to-filter handlers: selection is a page feature, not the row's. */ + selected?: boolean | undefined + onToggleSelect?: (() => void) | undefined onOpen: () => void /** The start column: spin up an agent implementing this one ticket. */ onStartWork: () => void @@ -78,7 +86,16 @@ export function TicketRow({ }) { return (
  • - {/* The start column, the row's left edge: one click spins up an agent implementing this + {/* The selection checkbox, the row's left edge — GitHub's list idiom: pick some rows and the + page's bulk actions narrow to them. Never disabled: selecting is page state, not an + action, so it costs nothing and can be changed any time. A sibling of the open button + like every control on the row. */} + {onToggleSelect && ( +
    + onToggleSelect()} aria-label={`Select ${ticket.title}`} /> +
    + )} + {/* The start column: one click spins up an agent implementing this ticket — the AI Queue card's play button (#855), offered where the backlog is read instead of only after queueing. A sibling of the open button like every control on the row (an interactive control nested in a button is invalid HTML): starting is not opening. */} @@ -256,6 +273,8 @@ export function TicketsPanel({ tickets, loaded, hiddenByFilter = 0, + isSelected, + onToggleSelect, onOpen, onOpenPlan, onAgentStarted, @@ -266,6 +285,12 @@ export function TicketsPanel({ projectId: string | null tickets: WorkspaceTicket[] loaded: boolean + /** Whether a row's ticket is picked for the surrounding page's bulk actions — the selection + * lives on the page (it spans projects), the panel only threads it to its rows by file. + * Absent on a page with nothing to scope to a selection, and the rows then carry no + * checkbox, like the click-to-filter handlers. */ + isSelected?: ((file: string) => boolean) | undefined + onToggleSelect?: ((file: string) => void) | undefined /** How many of this project's tickets the caller's filters hid (#1144/#1230). An empty * `tickets` with some hidden reads as "filtered", not as "nothing here" — the import prompt * offers work that has already been done. */ @@ -387,6 +412,8 @@ export function TicketsPanel({ key={ticket.file} ticket={ticket} busy={busy} + selected={isSelected?.(ticket.file) ?? false} + onToggleSelect={onToggleSelect ? () => onToggleSelect(ticket.file) : undefined} onOpen={() => onOpen(ticket.file)} onStartWork={() => void startWork(ticket.file)} onOpenPlan={onOpenPlan ? () => onOpenPlan(ticket.file) : undefined}