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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions FEATURES-SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 23 additions & 3 deletions packages/framework/dashboard/components/TicketsPage.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -101,14 +102,33 @@ 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.

#### Rationale

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
118 changes: 118 additions & 0 deletions packages/framework/dashboard/components/TicketsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<TicketsPage onOpenTicket={() => {}} />)
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(<TicketsPage onOpenTicket={() => {}} />)
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(<TicketsPage onOpenTicket={() => {}} />)
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(<TicketsPage onOpenTicket={() => {}} />)
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(<TicketsPage onOpenTicket={() => {}} />)
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(<TicketsPage onOpenTicket={() => {}} />)
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' })
})
})
Loading
Loading