Skip to content

Commit 72af1f6

Browse files
authored
Queue plans for the whole shown set, beside the queue-add (#1685)
1 parent 98cada4 commit 72af1f6

18 files changed

Lines changed: 330 additions & 94 deletions

FEATURES-SPEC.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ happens while nobody is at the keyboard.
9999
- A plan page when a plan exists; a button to start an agent writing one when it doesn't
100100
- Queue a ticket into the AI queue
101101
- Queue every ticket the filters show into the AI queue, in one click from the page heading
102+
- Queue a plan for every unplanned ticket the filters show, from the same heading
102103
- Tickets carry a GitHub issue link, so merging closes the issue
103104

104105
## Handoff and what lands in git

packages/framework/dashboard/components/TicketsPage.SPEC.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ The user wants to see the whole backlog — not one project's slice of it — de
1212
- **Click-to-filter** - clicking a row's topic adds that topic to the filter, clicking its claim marker narrows to claimed tickets; both add to what is already filtered rather than replacing it.
1313
- **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.
1414
- **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.
15+
- **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.
1516
- **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.
1617

1718
## Business logic
@@ -92,6 +93,22 @@ Once a click has queued the shown set, the button reads "Queued" and rests; any
9293

9394
Queueing rather than starting agents keeps the one click cheap and durable: entries are what the framework already picks up on its own, survive anything that interrupts the work, and spend nothing until an agent actually starts. Skipping already-queued tickets matters because a duplicate entry would outlive its agent's check-off as an open entry naming a closed ticket, costing the sweep an agent.
9495

96+
### Queue plans for the whole shown set
97+
98+
#### User story
99+
100+
The user has filtered the backlog to a slice they intend to work soon and wants plans written for all of it first — to read before committing agents — without asking ticket by ticket.
101+
102+
#### Business logic
103+
104+
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.
105+
106+
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.
107+
108+
#### Rationale
109+
110+
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".
111+
95112
### Filtered-away tickets are accounted for
96113

97114
#### User story

packages/framework/dashboard/components/TicketsPage.test.SPEC.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ Grouping: the flat mode renders one cross-project list ordered across projects,
88

99
The page-wide queue-add button: every shown ticket is queued the way the ticket detail page queues one (title as the entry, ticket linked, priority picking the section) and no agent is started; after the click the button reads "Queued" and stays disabled until the shown set changes, when it arms again counting the new set; a ticket an open queue entry already links to is not queued twice, and a checked-off entry does not count as queued; claimed tickets are skipped, with the button's label counting only the unclaimed tickets it will add; and the button is absent when nothing is shown or every shown ticket is claimed.
1010

11+
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.
12+
1113
## Before modifying/creating SPEC.md files
1214

1315
You must always read and respect https://raw.githubusercontent.com/brillout/sdd/refs/heads/main/sdd.md

packages/framework/dashboard/components/TicketsPage.test.tsx

Lines changed: 105 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const onAllTickets = vi.hoisted(() => vi.fn())
88
const onTicketsMeta = vi.hoisted(() => vi.fn())
99
const onQueue = vi.hoisted(() => vi.fn())
1010
vi.mock('../rpc/reads.js', () => ({ onAllTickets, onTicketsMeta, onQueue }))
11-
vi.mock('../rpc/control.js', () => ({ sendQueueTicket: vi.fn(), sendStart: vi.fn() }))
11+
vi.mock('../rpc/control.js', () => ({ sendQueueTicket: vi.fn(), sendQueueTicketPlan: vi.fn(), sendStart: vi.fn() }))
1212

1313
const { TicketsPage } = await import('./TicketsPage.js')
1414

@@ -29,6 +29,15 @@ beforeEach(() => {
2929
window.history.replaceState(null, '', '/tickets')
3030
})
3131

32+
/** Fresh control mocks for the tests that click the header's queue-adds. */
33+
const controls = async () => {
34+
const { sendStart, sendQueueTicket, sendQueueTicketPlan } = await import('../rpc/control.js')
35+
vi.mocked(sendStart).mockClear().mockResolvedValue({ ok: true, agentId: 'a1' })
36+
vi.mocked(sendQueueTicket).mockClear().mockResolvedValue({ ok: true, file: 'TODO_AGENTS.md' })
37+
vi.mocked(sendQueueTicketPlan).mockClear().mockResolvedValue({ ok: true, file: 'TODO_AGENTS.md' })
38+
return { sendStart, sendQueueTicket, sendQueueTicketPlan }
39+
}
40+
3241
afterEach(cleanup)
3342

3443
// The Tickets view (#1144): every registered project's backlog, one section each — reading
@@ -260,13 +269,6 @@ describe('TicketsPage grouping (#1144)', () => {
260269
// every unclaimed shown ticket joins the AI queue (unless an open entry already links to it),
261270
// and no agent starts: the queue's own consumers do that.
262271
describe('TicketsPage add the shown set to the AI queue', () => {
263-
const controls = async () => {
264-
const { sendStart, sendQueueTicket } = await import('../rpc/control.js')
265-
vi.mocked(sendStart).mockClear().mockResolvedValue({ ok: true, agentId: 'a1' })
266-
vi.mocked(sendQueueTicket).mockClear().mockResolvedValue({ ok: true, file: 'TODO_AGENTS.md' })
267-
return { sendStart, sendQueueTicket }
268-
}
269-
270272
test('every shown ticket joins the queue, and the button rests as Queued until the set changes', async () => {
271273
onAllTickets.mockResolvedValue([
272274
{
@@ -341,22 +343,115 @@ describe('TicketsPage add the shown set to the AI queue', () => {
341343
expect(sendQueueTicket).toHaveBeenCalledWith('p1', 'First', { file: 'a.md' })
342344
})
343345

344-
test('nothing shown, no button: an empty shown set is not an offer', async () => {
346+
test('nothing shown, no buttons: an empty shown set is not an offer', async () => {
345347
onAllTickets.mockResolvedValue([
346348
{ projectId: 'p1', projectName: 'Alpha', tickets: [ticket({ file: 'a.md', title: 'First' })] },
347349
])
348350
window.history.replaceState(null, '', '/tickets?q=zzz-no-match')
349351
render(<TicketsPage onOpenTicket={() => {}} />)
350352
await screen.findByText(/1 ticket hidden by the current filters/i)
351353
expect(screen.queryByRole('button', { name: /to the ai queue/i })).toBeNull()
354+
expect(screen.queryByRole('button', { name: /queue plans/i })).toBeNull()
352355
})
353356

354-
test('every shown ticket claimed, no button: the whole set is already being worked', async () => {
357+
test('every shown ticket claimed, no buttons: the whole set is already being worked', async () => {
355358
onAllTickets.mockResolvedValue([
356359
{ projectId: 'p1', projectName: 'Alpha', tickets: [ticket({ file: 'a.md', title: 'First', locked: true })] },
357360
])
358361
render(<TicketsPage onOpenTicket={() => {}} />)
359362
await screen.findByText('First')
360363
expect(screen.queryByRole('button', { name: /to the ai queue/i })).toBeNull()
364+
expect(screen.queryByRole('button', { name: /queue a plan/i })).toBeNull()
365+
})
366+
})
367+
368+
// The plan sibling of the queue-add: one `Create tickets/<stem>.plan.md` entry per shown ticket
369+
// still to plan — the Plan tickets preset's own ask, placed by the ticket's priority — and no
370+
// agent starts here either.
371+
describe('TicketsPage queue plans for the shown set', () => {
372+
test('every shown ticket still to plan gets its plan queued, and the button rests as Plans queued', async () => {
373+
onAllTickets.mockResolvedValue([
374+
{
375+
projectId: 'p1',
376+
projectName: 'Alpha',
377+
tickets: [ticket({ file: 'a.md', title: 'First', priority: '7' }), ticket({ file: 'b.md', title: 'Second' })],
378+
},
379+
])
380+
const { sendStart, sendQueueTicket, sendQueueTicketPlan } = await controls()
381+
render(<TicketsPage onOpenTicket={() => {}} />)
382+
fireEvent.click(await screen.findByRole('button', { name: 'Queue plans for all 2 tickets shown below' }))
383+
// One plan ask per ticket, the ticket named with its priority so the entry lands in its
384+
// section — and neither an implementation entry nor an agent comes out of this button.
385+
await waitFor(() => expect(sendQueueTicketPlan).toHaveBeenCalledTimes(2))
386+
expect(sendQueueTicketPlan).toHaveBeenCalledWith('p1', { file: 'a.md', priority: '7' })
387+
expect(sendQueueTicketPlan).toHaveBeenCalledWith('p1', { file: 'b.md' })
388+
expect(sendQueueTicket).not.toHaveBeenCalled()
389+
expect(sendStart).not.toHaveBeenCalled()
390+
const rested = await screen.findByRole('button', { name: 'Plans queued' })
391+
expect((rested as HTMLButtonElement).disabled).toBe(true)
392+
})
393+
394+
test('planned and claimed tickets are skipped, and the label counts only what is left to plan', async () => {
395+
onAllTickets.mockResolvedValue([
396+
{
397+
projectId: 'p1',
398+
projectName: 'Alpha',
399+
tickets: [
400+
ticket({ file: 'a.md', title: 'First' }),
401+
ticket({ file: 'b.md', title: 'Second', planned: true }),
402+
ticket({ file: 'c.md', title: 'Third', locked: true, lockedBy: 'agent-1' }),
403+
],
404+
},
405+
])
406+
const { sendQueueTicketPlan } = await controls()
407+
render(<TicketsPage onOpenTicket={() => {}} />)
408+
fireEvent.click(await screen.findByRole('button', { name: 'Queue a plan for the one unplanned ticket shown below' }))
409+
await screen.findByRole('button', { name: 'Plans queued' })
410+
expect(sendQueueTicketPlan).toHaveBeenCalledTimes(1)
411+
expect(sendQueueTicketPlan).toHaveBeenCalledWith('p1', { file: 'a.md' })
412+
})
413+
414+
test('a plan already asked for — or a ticket queued for implementation — is not asked again', async () => {
415+
onAllTickets.mockResolvedValue([
416+
{
417+
projectId: 'p1',
418+
projectName: 'Alpha',
419+
tickets: [
420+
ticket({ file: 'a.md', title: 'First' }),
421+
ticket({ file: 'b.md', title: 'Second' }),
422+
ticket({ file: 'c.md', title: 'Third' }),
423+
],
424+
},
425+
])
426+
// a.md's plan ask is already an open entry (recognized by its exact text); b.md is queued
427+
// for implementation, whose work would land before a trailing plan could matter.
428+
onQueue.mockResolvedValue([
429+
{
430+
projectId: 'p1',
431+
projectName: 'Alpha',
432+
open: 2,
433+
total: 2,
434+
items: [
435+
{ text: 'Create tickets/a.plan.md', done: false },
436+
{ text: '[Second](tickets/b.md)', done: false },
437+
],
438+
},
439+
])
440+
const { sendQueueTicketPlan } = await controls()
441+
render(<TicketsPage onOpenTicket={() => {}} />)
442+
fireEvent.click(await screen.findByRole('button', { name: 'Queue plans for all 3 tickets shown below' }))
443+
await screen.findByRole('button', { name: 'Plans queued' })
444+
expect(sendQueueTicketPlan).toHaveBeenCalledTimes(1)
445+
expect(sendQueueTicketPlan).toHaveBeenCalledWith('p1', { file: 'c.md' })
446+
})
447+
448+
test('every shown ticket planned already, no plan button — the queue-add still offers', async () => {
449+
onAllTickets.mockResolvedValue([
450+
{ projectId: 'p1', projectName: 'Alpha', tickets: [ticket({ file: 'a.md', title: 'First', planned: true })] },
451+
])
452+
render(<TicketsPage onOpenTicket={() => {}} />)
453+
await screen.findByText('First')
454+
expect(screen.queryByRole('button', { name: /queue a plan|queue plans/i })).toBeNull()
455+
expect(screen.getByRole('button', { name: 'Add the ticket shown below to the AI queue' })).toBeTruthy()
361456
})
362457
})

0 commit comments

Comments
 (0)