Skip to content

Commit 1802f76

Browse files
committed
The routine card remembers which project Run now targets (#1647)
The card's "Run in" pick was component state, so it was forgotten whenever the card unmounted — open a run, come back — and the picker read the first registered project again: the user's real repo. The next click fired a triage there and pushed an empty triage branch to its origin. The pick is now a preference, `autoPmProject`, beside the opt-outs and the concurrency it sits with on the card: written when the picker changes, read back on every render, so it holds across navigations, reloads and tabs. The card still validates it against the projects it shows, so a remembered project that is no longer registered falls back to the first.
1 parent d874944 commit 1802f76

7 files changed

Lines changed: 67 additions & 8 deletions

File tree

FEATURES-SPEC.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ happens while nobody is at the keyboard.
2323
- Pre-flight warnings before spending (no `gh`, logged out, repo can't auto-merge)
2424
- Start an agent from a ticket row
2525
- Start an agent from a queue entry's play button
26-
- "Run now" on a routine
26+
- "Run now" on a routine, in a picked project the card remembers across navigations and reloads
2727
- "Configure first, then run" on a routine — the launcher opens with its prompt, so the model and location can be set first
2828
- What a routine's "Run now" is about to spend, on hover — what that routine does, how many agents it costs, which model it will use, and where it runs
2929
- The whole CLI is one command: `the-framework` serves the dashboard — four options, no verbs

packages/the-framework/dashboard/components/RoutineWork.SPEC.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ The Overview's Routine work card: the jobs fired by the scheduled sweep — the
33
## Flows
44

55
- The list is read straight from the definition the daemon runs, so screen and schedule cannot drift; Run now starts the work at once rather than asking the sweep to come sooner.
6+
- The project a Run now targets is picked once and kept as one of the user's settings, so leaving the Overview to look at a run and coming back — or reloading — does not quietly put the first project, the user's real repo, back in the picker. A remembered project that is no longer registered falls back to the first.
67
- The two routines that fan out — queue-draining and ticket-planning — have their Run now ask the sweep for that routine's work only, which is the one path that can spin up several agents, up to the concurrency setting. Draining visits every project; planning stays in the project the card has picked. The two triage routines ask the sweep too, for a different reason: each works on one fixed branch, and only the sweep deletes a leftover copy of it before starting — started directly, the agent read the leftover as a triage already under way and gave up. Every other routine's Run now is one agent started directly, because concurrent copies of it would undo each other. Card-fired routines run unattended, like the sweep's own.
78
- Two checkbox tiers: the master switch turns the schedule on or off, a row's box takes that one routine in or out of it — recorded as opt-outs, so a routine added by a later version runs by default.
89
- "Trigger routine now" sweeps once even with auto-run off (the click is the consent), and the sweep answers on the card per project, so "ran and found nothing" never looks like "never ran".

packages/the-framework/dashboard/components/RoutineWork.test.SPEC.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
Covers the Routine work card: routines listed by label with Run now starting the prompt verbatim (unattended, then jumping to the agent), the drain and the planning routine firing a fan-out-capable sweep instead and each triage routine asking the sweep for itself by its branch, per-routine opt-out boxes versus the master auto-agent switch, the on-demand trigger working with auto-run off, the sweep's per-project answers landing on the card, the concurrency setting's clamping, "Configure first, then run" carrying the prompt to the picked project's launcher instead of starting anything (and saying so on the drain), each Run now naming on hover what the routine does and the model and place its start would use (and the drain saying instead that every project decides its own), and the picker, empty, failure, and busy states.
1+
Covers the Routine work card: routines listed by label with Run now starting the prompt verbatim (unattended, then jumping to the agent), the drain and the planning routine firing a fan-out-capable sweep instead and each triage routine asking the sweep for itself by its branch, per-routine opt-out boxes versus the master auto-agent switch, the on-demand trigger working with auto-run off, the sweep's per-project answers landing on the card, the concurrency setting's clamping, "Configure first, then run" carrying the prompt to the picked project's launcher instead of starting anything (and saying so on the drain), each Run now naming on hover what the routine does and the model and place its start would use (and the drain saying instead that every project decides its own), and the picker (kept as a setting, falling back to the first project when the remembered one is gone), empty, failure, and busy states.
22

33
## Before modifying/creating SPEC.md files
44

packages/the-framework/dashboard/components/RoutineWork.test.tsx

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,14 +236,38 @@ describe('RoutineWork (#1159)', () => {
236236

237237
test('the prompt goes to the project the card has picked, not the first one (#1507)', async () => {
238238
onProjects.mockResolvedValue([project('p1', 'gemstack'), project('p2', 'other')])
239+
// The pick is the setting (#1647); the picker writes it, and the card reads it back.
240+
prefs = { autoPmProject: 'p2' }
239241
const selected: string[] = []
240242
renderCard({ onSelectProject: id => selected.push(id) })
241243
await waitFor(() => expect(screen.getAllByText('Run now').length).toBe(AUTO_PM_ROUTINES.length))
242-
fireEvent.change(screen.getByLabelText('Run in'), { target: { value: 'p2' } })
243244
fireEvent.click(await openRunMenu(ROTATION_JOB))
244245
await waitFor(() => expect(selected).toEqual(['p2']))
245246
})
246247

248+
// #1647: the pick is a preference. Held as card state it was forgotten whenever the card
249+
// unmounted — open a run, come back — and the next click landed on the first project, the real
250+
// repo, which pushed an empty triage branch there.
251+
test('the picked project is a setting, so it holds across navigations and reloads (#1647)', async () => {
252+
onProjects.mockResolvedValue([project('p1', 'gemstack'), project('p2', 'other')])
253+
prefs = { autoPmProject: 'p2' }
254+
renderCard()
255+
await waitFor(() => expect(screen.getAllByText('Run now').length).toBe(AUTO_PM_ROUTINES.length))
256+
// Read back from the setting, not defaulted to the first project.
257+
expect((screen.getByLabelText('Run in') as HTMLSelectElement).value).toBe('p2')
258+
// Written as the setting, so a remount reads the same answer.
259+
fireEvent.change(screen.getByLabelText('Run in'), { target: { value: 'p1' } })
260+
expect(updatePreferences).toHaveBeenCalledWith({ autoPmProject: 'p1' })
261+
})
262+
263+
test('a remembered project that is no longer registered falls back to the first (#1647)', async () => {
264+
onProjects.mockResolvedValue([project('p1', 'gemstack'), project('p2', 'other')])
265+
prefs = { autoPmProject: 'gone' }
266+
renderCard()
267+
await waitFor(() => expect(screen.getAllByText('Run now').length).toBe(AUTO_PM_ROUTINES.length))
268+
expect((screen.getByLabelText('Run in') as HTMLSelectElement).value).toBe('p1')
269+
})
270+
247271
test("the drain's menu item says it is one agent, not the fan-out its Run now fires (#1507)", async () => {
248272
renderCard()
249273
await openRunMenu(AUTO_PM_DRAIN_JOB)
@@ -390,9 +414,10 @@ describe('RoutineWork (#1159)', () => {
390414

391415
test('several projects get a picker, and Run now honours it', async () => {
392416
onProjects.mockResolvedValue([project('p1', 'gemstack'), project('p2', 'rudder')])
417+
// The picker's pick is the setting (#1647), so this is what a picked card reads.
418+
prefs = { autoPmProject: 'p2' }
393419
renderCard()
394-
const select = await screen.findByLabelText('Run in')
395-
fireEvent.change(select, { target: { value: 'p2' } })
420+
expect(((await screen.findByLabelText('Run in')) as HTMLSelectElement).value).toBe('p2')
396421
fireEvent.click(screen.getAllByText('Run now')[1]!)
397422
await waitFor(() => expect(start).toHaveBeenCalled())
398423
expect(start.mock.calls[0]![0]).toBe('p2')

packages/the-framework/dashboard/components/RoutineWork.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,16 +103,21 @@ export function RoutineWork({
103103
const preferences = usePreferences()
104104
const report = useAutoPm()
105105
const { busy, error, start } = useStartAgent()
106-
const [picked, setPicked] = useState<string | null>(null)
107106
// Which routine is in flight, so only its own button says "Starting…".
108107
const [starting, setStarting] = useState<string | null>(null)
109108
// The on-demand sweep (#1210): in flight, and anything worth saying about the last attempt.
110109
const [sweeping, setSweeping] = useState(false)
111110
const [sweepNote, setSweepNote] = useState<string | null>(null)
112111

112+
// The pick is a preference, not card state (#1647): the card unmounts on the most common
113+
// navigation there is — open a run, come back — and a pick held here went with it, so the next
114+
// click landed on the first project, which is the user's real repo. As a setting it holds
115+
// across navigations, reloads and tabs, the way the opt-outs beside it do.
116+
//
113117
// The list arrives after the first render, and a project can be removed under a stale pick, so
114118
// the selection is validated against what is actually there rather than trusted.
115-
const projectId = (picked !== null && projects.some(p => p.id === picked) ? picked : projects[0]?.id) ?? null
119+
const picked = preferences.autoPmProject
120+
const projectId = (picked !== undefined && projects.some(p => p.id === picked) ? picked : projects[0]?.id) ?? null
116121

117122
const autoAgent = preferences.autoPm ?? false
118123
// Absent = nothing opted out, which is also what the store saves an empty list back as.
@@ -231,7 +236,7 @@ export function RoutineWork({
231236
id="routine-project"
232237
className="min-w-0 flex-1 rounded-md border border-border bg-transparent px-2 py-1 text-sm"
233238
value={projectId ?? ''}
234-
onChange={e => setPicked(e.target.value)}
239+
onChange={e => updatePreferences({ autoPmProject: e.target.value })}
235240
>
236241
{projects.map(p => (
237242
<option key={p.id} value={p.id}>

packages/the-framework/src/registry.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,18 @@ test('writePreferences round-trips and clamps the spend-limit slider (#960)', as
322322
assert.deepEqual(await readPreferences(fs, ENV), {})
323323
})
324324

325+
test('writePreferences keeps the routine card\'s picked project, trimmed, and drops an empty one (#1647)', async () => {
326+
const fs = memFs({ [FILE]: JSON.stringify({ projects: [APP_A], preferences: {} }) })
327+
await writePreferences({ autoPmProject: ' tf-1334-dogfood-f8nvbw ' }, fs, ENV)
328+
assert.deepEqual(await readPreferences(fs, ENV), { autoPmProject: 'tf-1334-dogfood-f8nvbw' })
329+
// Not checked against the project list here: the card validates the id against the projects it
330+
// shows, so a project removed since falls back there rather than being erased on read.
331+
await writePreferences({ autoPmProject: '' }, fs, ENV)
332+
assert.deepEqual(await readPreferences(fs, ENV), {})
333+
await writePreferences({ autoPmProject: 42 as never }, fs, ENV)
334+
assert.deepEqual(await readPreferences(fs, ENV), {})
335+
})
336+
325337
test('writePreferences round-trips and clamps the concurrent-agents setting (#1204)', async () => {
326338
const fs = memFs({ [FILE]: JSON.stringify({ projects: [APP_A], preferences: {} }) })
327339
await writePreferences({ autoPmConcurrency: 4 }, fs, ENV)

packages/the-framework/src/registry.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,16 @@ export interface Preferences {
137137
* the queue file, so it stays one agent per tick whatever this says.
138138
*/
139139
autoPmConcurrency?: number
140+
/**
141+
* The project the Routine work card's "Run now" targets (#1647), by project id. Absent = the
142+
* first registered project, which is what the card showed before this existed.
143+
*
144+
* A setting rather than card state, because the pick decides which repo spends quota and gets
145+
* branches pushed, and card state forgot it on the most common navigation there is — open a
146+
* run, come back — so the next click landed on the first project, the user's real one. An id
147+
* that no longer names a registered project reads as absent.
148+
*/
149+
autoPmProject?: string
140150
/**
141151
* How far the automatic-consumption limit sits from the quota boundary, in percentage points
142152
* (#960). Absent defaults to {@link DEFAULT_SPEND_OFFSET} — a half-day cushion ahead of the
@@ -383,6 +393,12 @@ function sanitizePreferences(value: unknown): Preferences {
383393
const concurrency = input['autoPmConcurrency']
384394
if (typeof concurrency === 'number' && Number.isFinite(concurrency))
385395
preferences.autoPmConcurrency = Math.min(Math.max(Math.round(concurrency), 1), MAX_AUTO_PM_CONCURRENCY)
396+
// `autoPmProject` (#1647) is a project id, kept as a bounded free-form string rather than checked
397+
// against the project list for the reason the opt-out names are not checked against the
398+
// catalog: the card validates it against the projects it shows, and an id of a project removed
399+
// since simply falls back there. Empty is dropped, which is exactly what absent means.
400+
const routineProject = input['autoPmProject']
401+
if (typeof routineProject === 'string' && routineProject.trim()) preferences.autoPmProject = routineProject.trim().slice(0, 100)
386402
return preferences
387403
}
388404

0 commit comments

Comments
 (0)