From 600ccd1722c777c480d8a04834d540cfac0763e2 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Sat, 22 Aug 2026 03:26:13 +0300 Subject: [PATCH] A routine can be configured before it is run (#1507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run now spends an agent on settings that are nowhere on the Routine work card: the model and where it runs come from the Global options, so the only way to change them was to leave, edit the preferences, and come back. Each Run now becomes a split button. Its chevron opens one item, "Configure first, then run", which hands the routine's prompt to the picked project's launcher — where the model, the location, and the prompt itself can all be changed before anything is spent. The carry is the draft stash a hot ticket already uses (#1066/#1139) rather than a second mechanism: the launcher takes it once as it mounts, so a reload does not re-seed it. A split button rather than the hover-revealed control the issue floats: a control that only exists under a mouse is reachable by neither keyboard nor touch, and the chevron keeps the common click at one click. The drain row's two halves really do different jobs — its Run now fires a fan-out sweep and the launcher can only ever send one agent — so its menu item says so instead of looking like the same action. --- FEATURES-SPEC.md | 1 + .../dashboard/components/DashboardPage.tsx | 2 +- .../dashboard/components/RoutineWork.SPEC.md | 1 + .../components/RoutineWork.test.SPEC.md | 2 +- .../dashboard/components/RoutineWork.test.tsx | 121 +++++++++++++----- .../dashboard/components/RoutineWork.tsx | 91 +++++++++++-- 6 files changed, 175 insertions(+), 43 deletions(-) diff --git a/FEATURES-SPEC.md b/FEATURES-SPEC.md index 1833364f0..20a9857f7 100644 --- a/FEATURES-SPEC.md +++ b/FEATURES-SPEC.md @@ -24,6 +24,7 @@ happens while nobody is at the keyboard. - Start an agent from a ticket row - Start an agent from a queue entry's play button - "Run now" on a routine +- "Configure first, then run" on a routine — the launcher opens with its prompt, so the model and location can be set first - The whole CLI is one command: `the-framework` serves the dashboard — four options, no verbs - `--host` / `--port`, the two things a browser cannot be asked; `--help` / `--version` - Reach the dashboard from another machine — non-loopback bind behind a generated shared token diff --git a/packages/the-framework/dashboard/components/DashboardPage.tsx b/packages/the-framework/dashboard/components/DashboardPage.tsx index d23131944..6d873380b 100644 --- a/packages/the-framework/dashboard/components/DashboardPage.tsx +++ b/packages/the-framework/dashboard/components/DashboardPage.tsx @@ -69,7 +69,7 @@ export function DashboardPage({ {/* Routine work sits below the AI Queue (#1139/#1159): the scheduled jobs and the button that fires one now. */} - + diff --git a/packages/the-framework/dashboard/components/RoutineWork.SPEC.md b/packages/the-framework/dashboard/components/RoutineWork.SPEC.md index f758e22c7..38a580710 100644 --- a/packages/the-framework/dashboard/components/RoutineWork.SPEC.md +++ b/packages/the-framework/dashboard/components/RoutineWork.SPEC.md @@ -6,6 +6,7 @@ The Overview's Routine work card: the jobs fired by the scheduled sweep — the - The queue-draining routine's Run now fires a drain-only sweep — the only path that can fan out several agents, up to the concurrency setting; card-fired routines run unattended, like the sweep's own. - 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. - "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". +- Beside each Run now sits "Configure first, then run": it opens the picked project's launcher with that routine's prompt already in the box, so the model and where it runs can be set before an agent is spent. For the queue-draining routine it says what it costs — the launcher sends one agent, not the fan-out. ## Before modifying/creating SPEC.md files diff --git a/packages/the-framework/dashboard/components/RoutineWork.test.SPEC.md b/packages/the-framework/dashboard/components/RoutineWork.test.SPEC.md index 48ef60423..14b2d08a6 100644 --- a/packages/the-framework/dashboard/components/RoutineWork.test.SPEC.md +++ b/packages/the-framework/dashboard/components/RoutineWork.test.SPEC.md @@ -1,4 +1,4 @@ -Covers the Routine work card: routines listed by label with Run now starting the prompt verbatim (unattended, then jumping to the agent), the drain firing a fan-out-capable sweep instead, 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, and the picker, empty, failure, and busy states. +Covers the Routine work card: routines listed by label with Run now starting the prompt verbatim (unattended, then jumping to the agent), the drain firing a fan-out-capable sweep instead, 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), and the picker, empty, failure, and busy states. ## Before modifying/creating SPEC.md files diff --git a/packages/the-framework/dashboard/components/RoutineWork.test.tsx b/packages/the-framework/dashboard/components/RoutineWork.test.tsx index dbad13928..769160df2 100644 --- a/packages/the-framework/dashboard/components/RoutineWork.test.tsx +++ b/packages/the-framework/dashboard/components/RoutineWork.test.tsx @@ -1,3 +1,4 @@ +import type { ComponentProps } from 'react' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import type { AutoPmJob, AutoPmReport, Preferences, ProjectSummary } from '../../src/index.js' @@ -33,6 +34,11 @@ vi.mock('../lib/use-start-agent.js', () => ({ })) const { RoutineWork } = await import('./RoutineWork.js') +const { takePendingDraft } = await import('../lib/draft-handoff.js') + +/** The card with both navigation props filled in; a test overrides only the one it asserts on. */ +const renderCard = (props: Partial> = {}) => + render( {}} onSelectProject={() => {}} {...props} />) const project = (id: string, name: string): ProjectSummary => ({ id, path: `/repos/${name}`, name, activated: true }) @@ -58,13 +64,13 @@ afterEach(cleanup) describe('RoutineWork (#1159)', () => { test('lists every routine the sweep can fire, by its preset label', async () => { - render( {}} />) + renderCard() await waitFor(() => expect(screen.getAllByText('Run now').length).toBe(AUTO_PM_ROUTINES.length)) for (const job of AUTO_PM_ROUTINES) expect(screen.getByText(job.label ?? job.name)).toBeTruthy() }) test('a routine that describes itself gets a line under its name; the rest are just their label', async () => { - render( {}} />) + renderCard() await waitFor(() => expect(screen.getAllByText('Run now').length).toBe(AUTO_PM_ROUTINES.length)) // Today that is the maintenance sweep alone, whose label does not say what it does. expect(screen.getByText(AUTO_PM_MAINTENANCE_JOB.describe!)).toBeTruthy() @@ -81,7 +87,7 @@ describe('RoutineWork (#1159)', () => { test('Run now starts the routine prompt verbatim and selects the run it started (#1191)', async () => { const started: unknown[][] = [] - render( started.push(args)} />) + renderCard({ onAgentStarted: (...args) => started.push(args) }) await waitFor(() => expect(screen.getAllByText('Run now').length).toBeGreaterThan(0)) fireEvent.click(screen.getAllByText('Run now')[1]!) await waitFor(() => expect(start).toHaveBeenCalled()) @@ -98,12 +104,69 @@ describe('RoutineWork (#1159)', () => { expect(started[0]).toEqual(['p1', ROTATION_JOB.prompt, 'run-1']) }) + /** Open one row's secondary half — the chevron beside its Run now. */ + const openRunMenu = async (job: AutoPmJob) => { + await waitFor(() => expect(screen.getAllByText('Run now').length).toBe(AUTO_PM_ROUTINES.length)) + fireEvent.click(screen.getByRole('button', { name: `Other ways to run ${routineName(job)}` })) + return await screen.findByText('Configure first, then run') + } + + test('Configure first, then run hands the prompt to the launcher instead of starting it (#1507)', async () => { + const selected: string[] = [] + renderCard({ onSelectProject: id => selected.push(id) }) + fireEvent.click(await openRunMenu(ROTATION_JOB)) + // The launcher, not an agent: this button exists because Run now spends the agent on a model + // and a location that are nowhere on this card. + await waitFor(() => expect(selected).toEqual(['p1'])) + expect(start).not.toHaveBeenCalled() + expect(sendAutoPmSweep).not.toHaveBeenCalled() + // Carried through the same stash a hot ticket uses (#1066/#1139), so the launcher rehydrates + // with this routine's prompt verbatim rather than an empty composer. + expect(takePendingDraft()).toBe(ROTATION_JOB.prompt) + }) + + test('the prompt goes to the project the card has picked, not the first one (#1507)', async () => { + onProjects.mockResolvedValue([project('p1', 'gemstack'), project('p2', 'other')]) + const selected: string[] = [] + renderCard({ onSelectProject: id => selected.push(id) }) + await waitFor(() => expect(screen.getAllByText('Run now').length).toBe(AUTO_PM_ROUTINES.length)) + fireEvent.change(screen.getByLabelText('Run in'), { target: { value: 'p2' } }) + fireEvent.click(await openRunMenu(ROTATION_JOB)) + await waitFor(() => expect(selected).toEqual(['p2'])) + }) + + test("the drain's menu item says it is one agent, not the fan-out its Run now fires (#1507)", async () => { + renderCard() + await openRunMenu(AUTO_PM_DRAIN_JOB) + // The launcher can only ever send one agent, so this row's two halves really do different + // jobs — and the row says so rather than letting them look alike. + expect(await screen.findByText(/one agent, not the fan-out/)).toBeTruthy() + }) + + test('a routine whose Run now is a plain start promises the settings, not a caveat (#1507)', async () => { + renderCard() + await openRunMenu(ROTATION_JOB) + expect(await screen.findByText(/set the model and where it runs/)).toBeTruthy() + expect(screen.queryByText(/one agent, not the fan-out/)).toBeNull() + }) + + test('Configure first stays live while a start is in flight, since it starts nothing (#1507)', async () => { + busy = true + const selected: string[] = [] + renderCard({ onSelectProject: id => selected.push(id) }) + await waitFor(() => expect(screen.getAllByText('Run now').length).toBe(AUTO_PM_ROUTINES.length)) + // Run now is out — one start at a time — but going to look at the settings is not a start. + expect(screen.getAllByText('Run now')[1]!.closest('button')!.disabled).toBe(true) + fireEvent.click(await openRunMenu(ROTATION_JOB)) + await waitFor(() => expect(selected).toEqual(['p1'])) + }) + test("the drain's Run now fires a drain-only sweep, the only thing that can fan out (#1204)", async () => { // Rom's demo click: "Spin up agents working on the AI queue" must spin up to the concurrency, // one agent per entry. A plain start could only ever be one agent on the first entry. sendAutoPmSweep.mockResolvedValue({ ok: true }) const started: unknown[][] = [] - render( started.push(args)} />) + renderCard({ onAgentStarted: (...args) => started.push(args) }) await waitFor(() => expect(screen.getAllByText('Run now').length).toBeGreaterThan(0)) fireEvent.click(screen.getAllByText('Run now')[0]!) await waitFor(() => expect(sendAutoPmSweep).toHaveBeenCalledWith({ drainOnly: true })) @@ -114,7 +177,7 @@ describe('RoutineWork (#1159)', () => { test("the drain's Run now on a host with no sweep says so instead of failing silently (#1204)", async () => { sendAutoPmSweep.mockResolvedValue({ ok: false }) - render( {}} />) + renderCard() await waitFor(() => expect(screen.getAllByText('Run now').length).toBeGreaterThan(0)) fireEvent.click(screen.getAllByText('Run now')[0]!) await waitFor(() => expect(screen.getByText(/not running the sweep/).textContent).toMatch(/nothing to trigger/)) @@ -123,7 +186,7 @@ describe('RoutineWork (#1159)', () => { test('a start that reports no run id still hands the project over, for the adopt fallback (#1191)', async () => { start.mockResolvedValue({ ok: true }) const started: unknown[][] = [] - render( started.push(args)} />) + renderCard({ onAgentStarted: (...args) => started.push(args) }) await waitFor(() => expect(screen.getAllByText('Run now').length).toBeGreaterThan(0)) fireEvent.click(screen.getAllByText('Run now')[1]!) await waitFor(() => expect(started).toHaveLength(1)) @@ -134,7 +197,7 @@ describe('RoutineWork (#1159)', () => { start.mockResolvedValue(undefined) startError = 'A session is already active for this project.' const started: unknown[][] = [] - render( started.push(args)} />) + renderCard({ onAgentStarted: (...args) => started.push(args) }) await waitFor(() => expect(screen.getAllByText('Run now').length).toBeGreaterThan(0)) fireEvent.click(screen.getAllByText('Run now')[1]!) await waitFor(() => expect(start).toHaveBeenCalled()) @@ -146,23 +209,23 @@ describe('RoutineWork (#1159)', () => { test('with auto-run on and a reported sweep, the box says when it next runs', async () => { prefs = { autoPm: true } autoPm = { enabled: true, sweptAt: Date.now(), nextSweepAt: Date.now() + 2 * 60 * 60_000, outcomes: [] } - render( {}} />) + renderCard() await waitFor(() => expect(screen.getByText('Auto-runs in 2 hr')).toBeTruthy()) }) test('with auto-run off, or before the daemon has reported, it says only what it does', async () => { prefs = { autoPm: true } autoPm = undefined - const { rerender } = render( {}} />) + const { rerender } = renderCard() // On, but no sweep has reported yet: a countdown here would be invented. await waitFor(() => expect(screen.getByText('Auto-run')).toBeTruthy()) prefs = {} - rerender( {}} />) + rerender( {}} onSelectProject={() => {}} />) expect(screen.getByText('Auto-run')).toBeTruthy() }) test('the checkbox is the one global preference, and carries the tooltip', async () => { - render( {}} />) + renderCard() await waitFor(() => expect(screen.getByText('Auto-run')).toBeTruthy()) // One box per routine (#1209), plus the master at the foot. expect(screen.getAllByRole('checkbox')).toHaveLength(AUTO_PM_ROUTINES.length + 1) @@ -173,7 +236,7 @@ describe('RoutineWork (#1159)', () => { }) test('every routine starts ticked, and unticking one records only that one (#1209)', async () => { - render( {}} />) + renderCard() await waitFor(() => expect(screen.getAllByText('Run now').length).toBe(AUTO_PM_ROUTINES.length)) // Nothing saved means nothing opted out: the schedule is whole until it is edited. for (const job of AUTO_PM_ROUTINES) expect(routineBox(job).getAttribute('aria-checked')).toBe('true') @@ -184,7 +247,7 @@ describe('RoutineWork (#1159)', () => { test('an opted-out routine shows unticked, and re-ticking it drops only it (#1209)', async () => { const other = AUTO_PM_ROUTINES[1]!.name prefs = { autoPmOptOut: [AUTO_PM_DRAIN_JOB.name, other] } - render( {}} />) + renderCard() await waitFor(() => expect(screen.getAllByText('Run now').length).toBe(AUTO_PM_ROUTINES.length)) expect(routineBox(AUTO_PM_DRAIN_JOB).getAttribute('aria-checked')).toBe('false') fireEvent.click(screen.getByText(routineName(AUTO_PM_DRAIN_JOB))) @@ -194,7 +257,7 @@ describe('RoutineWork (#1159)', () => { test('Run now ignores the checkbox: it fires the routine once, on demand (#1209)', async () => { prefs = { autoPmOptOut: AUTO_PM_ROUTINES.map(job => job.name) } - render( {}} />) + renderCard() await waitFor(() => expect(screen.getAllByText('Run now').length).toBe(AUTO_PM_ROUTINES.length)) for (const button of screen.getAllByText('Run now')) expect((button as HTMLButtonElement).disabled).toBe(false) fireEvent.click(screen.getAllByText('Run now')[1]!) @@ -204,19 +267,19 @@ describe('RoutineWork (#1159)', () => { test('auto-run on with nothing ticked says so, rather than counting down to nothing (#1209)', async () => { prefs = { autoPm: true, autoPmOptOut: AUTO_PM_ROUTINES.map(job => job.name) } - render( {}} />) + renderCard() await waitFor(() => expect(screen.getByText(/Every routine is unticked/)).toBeTruthy()) cleanup() // One routine back on and the warning goes: the schedule has something to do again. prefs = { autoPm: true, autoPmOptOut: AUTO_PM_ROUTINES.slice(1).map(job => job.name) } - render( {}} />) + renderCard() await waitFor(() => expect(screen.getByText('Auto-run')).toBeTruthy()) expect(screen.queryByText(/Every routine is unticked/)).toBeNull() }) test('several projects get a picker, and Run now honours it', async () => { onProjects.mockResolvedValue([project('p1', 'gemstack'), project('p2', 'rudder')]) - render( {}} />) + renderCard() const select = await screen.findByLabelText('Run in') fireEvent.change(select, { target: { value: 'p2' } }) fireEvent.click(screen.getAllByText('Run now')[1]!) @@ -226,7 +289,7 @@ describe('RoutineWork (#1159)', () => { test('Trigger routine now fires the sweep instead of waiting out the countdown (#1210)', async () => { prefs = { autoPm: true } - render( {}} />) + renderCard() const button = await screen.findByText('Trigger routine now') fireEvent.click(button) await waitFor(() => expect(sendAutoPmSweep).toHaveBeenCalledTimes(1)) @@ -234,7 +297,7 @@ describe('RoutineWork (#1159)', () => { test('with auto-run off the trigger still sweeps once: the click is the ask (#1210)', async () => { prefs = { autoPm: false } - render( {}} />) + renderCard() const button = (await screen.findByText('Trigger routine now')).closest('button')! expect(button.disabled).toBe(false) // The tooltip carries the one thing worth saying here: this is a one-shot, not an enrolment. @@ -246,7 +309,7 @@ describe('RoutineWork (#1159)', () => { test('a host with no sweep says so rather than looking like it worked (#1210)', async () => { prefs = { autoPm: true } sendAutoPmSweep.mockResolvedValue({ ok: false }) - render( {}} />) + renderCard() fireEvent.click(await screen.findByText('Trigger routine now')) await waitFor(() => expect(screen.getByRole('status').textContent).toMatch(/not running the sweep/i)) }) @@ -259,7 +322,7 @@ describe('RoutineWork (#1159)', () => { ok: true, outcomes: [{ projectId: 'p1', path: '/home/me/repo', started: false, message: 'the queue has work waiting and its routine is switched off' }], }) - render( {}} />) + renderCard() fireEvent.click(await screen.findByText('Trigger routine now')) await waitFor(() => expect(screen.getByRole('status').textContent).toBe('the queue has work waiting and its routine is switched off'), @@ -275,7 +338,7 @@ describe('RoutineWork (#1159)', () => { { projectId: 'p2', path: '/home/me/beta', started: false, message: 'a run is already active' }, ], }) - render( {}} />) + renderCard() fireEvent.click(await screen.findByText('Trigger routine now')) await waitFor(() => expect(screen.getByRole('status').textContent).toMatch(/alpha: started a drain/)) expect(screen.getByRole('status').textContent).toMatch(/beta: a run is already active/) @@ -286,7 +349,7 @@ describe('RoutineWork (#1159)', () => { ok: true, outcomes: [{ projectId: 'p1', path: '/home/me/repo', started: false, message: 'the queue is empty, so there is nothing to drain' }], }) - render( {}} />) + renderCard() await waitFor(() => expect(screen.getAllByText('Run now').length).toBeGreaterThan(0)) fireEvent.click(screen.getAllByText('Run now')[0]!) await waitFor(() => @@ -295,14 +358,14 @@ describe('RoutineWork (#1159)', () => { }) test('one project needs no picker', async () => { - render( {}} />) + renderCard() await waitFor(() => expect(screen.getAllByText('Run now').length).toBeGreaterThan(0)) expect(screen.queryByLabelText('Run in')).toBeNull() }) test('with no project there is nothing to run a routine in, and it says so', async () => { onProjects.mockResolvedValue([]) - render( {}} />) + renderCard() await waitFor(() => expect(screen.getByText('Add a project to run a routine.')).toBeTruthy()) expect(screen.queryByText('Run now')).toBeNull() }) @@ -310,7 +373,7 @@ describe('RoutineWork (#1159)', () => { // #1204: how many agents the routine keeps going at once. test('the concurrent-agents box shows the daemon default until it is set', async () => { - render( {}} />) + renderCard() const box = (await screen.findByLabelText('Concurrent agents')) as HTMLInputElement // The default rather than 1, so the number on screen is the number the sweep would use. expect(box.value).toBe(String(DEFAULT_AUTO_PM_CONCURRENCY)) @@ -318,7 +381,7 @@ describe('RoutineWork (#1159)', () => { }) test('typing a concurrency writes it, clamped to the cap', async () => { - render( {}} />) + renderCard() const box = await screen.findByLabelText('Concurrent agents') fireEvent.change(box, { target: { value: '5' } }) expect(updatePreferences).toHaveBeenCalledWith({ autoPmConcurrency: 5 }) @@ -335,13 +398,13 @@ describe('RoutineWork (#1159)', () => { test('the fine print follows the setting rather than promising an idle machine', async () => { prefs = { autoPmConcurrency: 4 } - render( {}} />) + renderCard() await waitFor(() => expect(screen.getByText(/Keeps up to 4 agents going at once/)).toBeTruthy()) }) test('a start already in flight disables every Run now', async () => { busy = true - render( {}} />) + renderCard() await waitFor(() => expect(screen.getAllByText('Run now').length).toBeGreaterThan(0)) for (const button of screen.getAllByText('Run now')) expect((button as HTMLButtonElement).disabled).toBe(true) }) diff --git a/packages/the-framework/dashboard/components/RoutineWork.tsx b/packages/the-framework/dashboard/components/RoutineWork.tsx index d66cd7d36..48391db00 100644 --- a/packages/the-framework/dashboard/components/RoutineWork.tsx +++ b/packages/the-framework/dashboard/components/RoutineWork.tsx @@ -6,7 +6,7 @@ import { MAX_AUTO_PM_CONCURRENCY, agentOptionsFromPreferences, } from '../../src/client.js' -import { CalendarClock, Play } from 'lucide-react' +import { CalendarClock, ChevronDown, Play } from 'lucide-react' import { onProjects } from '../rpc/projects.js' import { sendAutoPmSweep } from '../rpc/quota.js' import { useAutoPm } from '../lib/quota.js' @@ -14,10 +14,19 @@ import { usePreferences, updatePreferences } from '../lib/preferences.js' import { useStartAgent } from '../lib/use-start-agent.js' import { useLoaded } from '../lib/use-async.js' import { formatUntil } from '../lib/format-date.js' +import { stashPendingDraft } from '../lib/draft-handoff.js' +import { cn } from '../lib/utils.js' import { Card, CardContent, CardHeader, CardTitle } from './ui/card.js' -import { Button } from './ui/button.js' +import { Button, buttonVariants } from './ui/button.js' import { Checkbox } from './ui/checkbox.js' +import { OptionLabel } from './ui/option-label.js' import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip.js' +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from './ui/dropdown-menu.js' // The Overview's "Routine work" card (#1159): the jobs the idle sweep fires on a schedule, each with // a Run now button that starts it against a project immediately. @@ -55,12 +64,15 @@ function describeOutcomes(outcomes: AutoPmOutcome[] | undefined): string { export function RoutineWork({ onAgentStarted, + onSelectProject, }: { /** * Told which run the button just started (#1191). The project-carrying form, because the * Overview has no project selected — each row picks one — so the shell cannot supply it. */ onAgentStarted: (projectId: string, intent: string, agentId?: string) => void + /** Where "Configure first, then run" lands (#1507): the picked project's launcher. */ + onSelectProject: (id: string) => void }) { const projects = useLoaded(onProjects, NO_PROJECTS, []) const preferences = usePreferences() @@ -110,6 +122,25 @@ export function RoutineWork({ else setSweepNote(describeOutcomes('outcomes' in result ? result.outcomes : undefined)) } + /** + * "Configure first, then run" (#1507). Run now spends an agent on settings that are nowhere on + * this card: the model and where it runs come from the Global options, so the only way to change + * them was to leave, edit the preferences, and come back. This hands the same prompt to the + * launcher instead — where those controls are, and where the prompt itself can be edited before + * it is sent. + * + * The carry is the draft stash a hot ticket already uses (#1066/#1139) rather than a second + * mechanism: the launcher takes it once as it mounts, so nothing re-seeds on a reload. + * + * Not gated on `busy`: this starts nothing, and being unable to go and look at the settings + * because a start is in flight would be the opposite of the point. + */ + const configureFirst = (job: AutoPmJob) => { + if (!projectId) return + stashPendingDraft(job.prompt) + onSelectProject(projectId) + } + const runNow = async (job: AutoPmJob) => { if (!projectId || busy) return // The drain's Run now means "spin agents up on the queue" (#1204), and only the sweep can fan @@ -193,16 +224,52 @@ export function RoutineWork({ )} - + {/* A split button, not a hover-revealed second control (#1507): the routine + it belongs to is not always the row the pointer is on, and a control that + only exists under a mouse is reachable by neither keyboard nor touch. The + chevron is the secondary half, so the common click stays one click. */} +
+ + + + + + + configureFirst(job)} + className="items-start" + > + + + + +
))}