diff --git a/FEATURES-SPEC.md b/FEATURES-SPEC.md index 20a9857f7..269d13dad 100644 --- a/FEATURES-SPEC.md +++ b/FEATURES-SPEC.md @@ -25,6 +25,7 @@ happens while nobody is at the keyboard. - 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 +- What a routine's "Run now" is about to spend, on hover — what that routine does, which model it will use, and where it runs - 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/Composer.tsx b/packages/the-framework/dashboard/components/Composer.tsx index f9e28a6f4..ccaadc21f 100644 --- a/packages/the-framework/dashboard/components/Composer.tsx +++ b/packages/the-framework/dashboard/components/Composer.tsx @@ -24,6 +24,7 @@ import { useConnectionProfiles, connectLocal, removeProfile, type ConnectionProf import { useSelectedRemoteDeviceId, selectRemoteDevice } from '../lib/remote-target.js' import { useDeviceStatus } from '../lib/use-device-status.js' import { stashDraftFromUrl, takePendingDraft } from '../lib/draft-handoff.js' +import { DRIVER_MODELS } from '../lib/agent-settings.js' import { ResolvedOptions } from './ResolvedOptions.js' import { ClaudeLogo, CodexLogo } from './driver-logos.js' import { Button } from './ui/button.js' @@ -40,29 +41,22 @@ import { cn } from '../lib/utils.js' // each list, and picking it stored nothing, so the menu's own answer to "which model" was "we do // not know" (#1143). Not choosing is still a state — it is just no longer something to pick, and // the trigger says so rather than naming the first model as if it had been chosen. -// The names and labels are the framework's own vocabulary (browser-safe via /client); only the -// icons and model lists are UI data, and the Record shape means a new agent -// framework-side is a compile error here rather than a silently missing menu entry. -const DRIVER_UI: Record = { - claude: { - icon: , - models: [ - { value: 'fable', label: 'Fable' }, - { value: 'opus', label: 'Opus' }, - { value: 'sonnet', label: 'Sonnet' }, - { value: 'haiku', label: 'Haiku' }, - ], - }, - codex: { - icon: , - models: [ - { value: 'gpt-5-codex', label: 'GPT-5 Codex' }, - { value: 'gpt-5', label: 'GPT-5' }, - { value: 'o3', label: 'o3' }, - ], - }, +// The names and labels are the framework's own vocabulary (browser-safe via /client), and the model +// lists are shared UI data (`lib/agent-settings.ts`), since the Routine work card names a model too +// (#1506) and the two must not drift. Only the icons are this component's own, and the +// Record shape means a new agent framework-side is a compile error here rather +// than a silently missing menu entry. +const DRIVER_UI: Record = { + claude: { icon: }, + codex: { icon: }, } -const DRIVER_OPTIONS: DriverOption[] = DRIVERS.map(name => ({ value: name, label: DRIVER_LABELS[name], ...DRIVER_UI[name] })) + +const DRIVER_OPTIONS: DriverOption[] = DRIVERS.map(name => ({ + value: name, + label: DRIVER_LABELS[name], + models: DRIVER_MODELS[name], + ...DRIVER_UI[name], +})) export interface ComposerHandle { clear: () => void diff --git a/packages/the-framework/dashboard/components/DriverModelMenu.tsx b/packages/the-framework/dashboard/components/DriverModelMenu.tsx index ed9b9158c..3b2453415 100644 --- a/packages/the-framework/dashboard/components/DriverModelMenu.tsx +++ b/packages/the-framework/dashboard/components/DriverModelMenu.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react' import { ChevronDown, Check } from 'lucide-react' import { cn } from '../lib/utils.js' +import { NO_MODEL_PINNED } from '../lib/agent-settings.js' import { buttonVariants } from './ui/button.js' import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip.js' import { @@ -32,9 +33,6 @@ export interface DriverOption { models: ModelOption[] } -/** What the trigger and tooltip say when no model is pinned and the CLI picks for itself. */ -const NO_MODEL_PINNED = "the CLI's own default" - function driverOf(drivers: DriverOption[], value: string): DriverOption | undefined { return drivers.find(a => a.value === value) ?? drivers[0] } diff --git a/packages/the-framework/dashboard/components/OptionsMenu.tsx b/packages/the-framework/dashboard/components/OptionsMenu.tsx index c390ac8aa..079d3249c 100644 --- a/packages/the-framework/dashboard/components/OptionsMenu.tsx +++ b/packages/the-framework/dashboard/components/OptionsMenu.tsx @@ -31,6 +31,7 @@ export type { OptionRow } from '../lib/agent-option-rows.js' // Moved to ui/option-label.tsx (#948) so menus without preference wiring can share it; // re-exported to keep this module the import site the other menus already use. import { OptionLabel } from './ui/option-label.js' +import { RUN_TARGET_LABELS } from '../lib/agent-settings.js' export { OptionLabel } /** @@ -91,9 +92,9 @@ function StatusDot({ status }: { status: DeviceStatus | undefined }) { // (Check-marked rows), not the boolean OptionRow. "Claude web" describes the hand-off it is // rather than promising a streamed agent: the session runs on claude.ai and opens its own PR. const RUN_TARGET_ROWS: { value: AgentTarget; label: string; description: string }[] = [ - { value: 'local', label: 'This machine', description: 'Run on this machine, as today.' }, - { value: 'actions', label: 'GitHub Actions', description: 'Run on a fresh GitHub Actions runner.' }, - { value: 'web', label: 'Claude web', description: 'Hand off to a Claude Code cloud session, which opens its own PR.' }, + { value: 'local', label: RUN_TARGET_LABELS.local, description: 'Run on this machine, as today.' }, + { value: 'actions', label: RUN_TARGET_LABELS.actions, description: 'Run on a fresh GitHub Actions runner.' }, + { value: 'web', label: RUN_TARGET_LABELS.web, description: 'Hand off to a Claude Code cloud session, which opens its own PR.' }, ] // One flat "Run on" list (#1066/#1067): the driver rows, then the saved devices and "Add a device", diff --git a/packages/the-framework/dashboard/components/RoutineWork.SPEC.md b/packages/the-framework/dashboard/components/RoutineWork.SPEC.md index 38a580710..2b295bbd8 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". +- Hovering a Run now says what it is about to spend before it is spent: what that routine does, which model it will use, and where it runs — none of which the card can otherwise show, because all three come from the Global options on another page. The queue-draining routine answers differently, since its Run now is the sweep rather than one start: it visits every project the daemon watches, and each of those decides its own model and place. - 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 14b2d08a6..510823f0f 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, "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. +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), 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. ## 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 769160df2..41aaaf675 100644 --- a/packages/the-framework/dashboard/components/RoutineWork.test.tsx +++ b/packages/the-framework/dashboard/components/RoutineWork.test.tsx @@ -104,6 +104,55 @@ describe('RoutineWork (#1159)', () => { expect(started[0]).toEqual(['p1', ROTATION_JOB.prompt, 'run-1']) }) + /** One row's Run now, by its place in the list the card renders. */ + const runNowOf = async (job: AutoPmJob) => { + await waitFor(() => expect(screen.getAllByText('Run now').length).toBe(AUTO_PM_ROUTINES.length)) + return screen.getAllByText('Run now')[AUTO_PM_ROUTINES.indexOf(job)]!.closest('button')! + } + + test('Run now says what the routine does and the settings its start would use (#1506)', async () => { + prefs = { model: 'opus' } + renderCard() + const hint = await hoverTooltip(await runNowOf(ROTATION_JOB)) + // The preset's own sentence, not a second one written for this card: the launcher describes + // the same routine with the same words. + expect(hint.textContent).toContain(ROTATION_JOB.tooltip) + // The three facts the card cannot otherwise show, because all three live in the Global + // options on another page — and the model is the one this start would really pass. + expect(hint.textContent).toContain('Claude Code · Opus · This machine') + expect(hint.textContent).toMatch(/Starts one agent in gemstack, unattended/) + }) + + test('no model pinned is said as such, never as the first one in the list (#1143/#1506)', async () => { + renderCard() + expect((await hoverTooltip(await runNowOf(ROTATION_JOB))).textContent).toContain("the CLI's own default") + cleanup() + // A model pinned on the *other* driver is not this driver's model either, so naming it would + // name something the agent is never passed. + prefs = { driver: 'claude', model: 'gpt-5' } + renderCard() + const hint = await hoverTooltip(await runNowOf(ROTATION_JOB)) + expect(hint.textContent).toContain("the CLI's own default") + expect(hint.textContent).not.toContain('GPT-5') + }) + + test('where it runs is read off the preference, not assumed to be this machine (#1506)', async () => { + prefs = { target: 'web' } + renderCard() + expect((await hoverTooltip(await runNowOf(ROTATION_JOB))).textContent).toContain('Claude web') + }) + + test("the drain's Run now reports the sweep it fires, not these settings (#1506)", async () => { + prefs = { model: 'opus', autoPmConcurrency: 3 } + renderCard() + const hint = await hoverTooltip(await runNowOf(AUTO_PM_DRAIN_JOB)) + // The sweep visits every project and resolves each one's own committed settings on top of + // these, so the model and place the other rows promise would both be a guess here. + expect(hint.textContent).toContain("Each project's own settings decide the model and where it runs.") + expect(hint.textContent).toMatch(/Sweeps every project the daemon watches, up to 3 agents each, unattended/) + expect(hint.textContent).not.toContain('Opus') + }) + /** 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)) diff --git a/packages/the-framework/dashboard/components/RoutineWork.tsx b/packages/the-framework/dashboard/components/RoutineWork.tsx index 48391db00..6a5a2c5f6 100644 --- a/packages/the-framework/dashboard/components/RoutineWork.tsx +++ b/packages/the-framework/dashboard/components/RoutineWork.tsx @@ -15,6 +15,7 @@ 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 { describeAgentSettings } from '../lib/agent-settings.js' import { cn } from '../lib/utils.js' import { Card, CardContent, CardHeader, CardTitle } from './ui/card.js' import { Button, buttonVariants } from './ui/button.js' @@ -141,6 +142,18 @@ export function RoutineWork({ onSelectProject(projectId) } + /** + * What a Run now is about to spend, said before it is spent (#1506). The card fires prompts on + * settings that are nowhere on it: the model and where it runs come from the Global options, a + * page away, so the button's own cost was invisible right up until the agent existed. + * + * The first line is the preset's own sentence rather than one written again here (#1506), so the + * launcher and this card describe a routine the same way, and the second is rendered from the + * very preferences the start reads — not a copy that can go stale. + */ + const settings = describeAgentSettings(preferences) + const projectName = projects.find(p => p.id === projectId)?.name + 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 @@ -229,17 +242,38 @@ export function RoutineWork({ 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. */}
- + + void runNow(job)} + className="rounded-r-none border-r-0" + /> + } + > + + {starting === job.name ? 'Starting…' : 'Run now'} + + + {job.tooltip && {job.tooltip}} + {/* The drain row's Run now is the sweep, so neither half of the settings + line would be true of it: the sweep resolves each project's own + `the-framework.yml` on top of these preferences, and it visits every + project rather than the one picked above. It says that instead. */} + + {job.drains ? "Each project's own settings decide the model and where it runs." : settings} + + + {job.drains + ? `Sweeps every project the daemon watches, up to ${concurrency} ${concurrency === 1 ? 'agent' : 'agents'} each, unattended.` + : `Starts one agent${projectName ? ` in ${projectName}` : ''}, unattended — nothing is asked mid-run.`} + + + updatePreferences({ target: value as 'local' | 'actions' | 'web' })} /> diff --git a/packages/the-framework/dashboard/components/ui/tooltip.SPEC.md b/packages/the-framework/dashboard/components/ui/tooltip.SPEC.md index 2ba5ec533..f05705cbe 100644 --- a/packages/the-framework/dashboard/components/ui/tooltip.SPEC.md +++ b/packages/the-framework/dashboard/components/ui/tooltip.SPEC.md @@ -1,4 +1,4 @@ -The dashboard's tooltip, opening instantly rather than after a hover delay. +The dashboard's tooltip, opening instantly rather than after a hover delay, and drawn above whatever it opens over — a hint triggered from inside a menu is read, not covered by it. ## Before modifying/creating SPEC.md files diff --git a/packages/the-framework/dashboard/components/ui/tooltip.tsx b/packages/the-framework/dashboard/components/ui/tooltip.tsx index 79554efe8..3bc9fe5db 100644 --- a/packages/the-framework/dashboard/components/ui/tooltip.tsx +++ b/packages/the-framework/dashboard/components/ui/tooltip.tsx @@ -30,7 +30,13 @@ function TooltipContent({ }) { return ( + {/* The stacking layer belongs here, on the positioned element (#1506). It used to sit on the + popup below, which Base UI leaves `position: static` — where a z-index does nothing at + all. A tooltip opened over a menu or a popover, both of which carry theirs on their own + positioner, was painted *behind* it: the preset hints in the launcher's menu rendered + with the right words and were covered by the very menu that triggered them. */} = { + claude: [ + { value: 'fable', label: 'Fable' }, + { value: 'opus', label: 'Opus' }, + { value: 'sonnet', label: 'Sonnet' }, + { value: 'haiku', label: 'Haiku' }, + ], + codex: [ + { value: 'gpt-5-codex', label: 'GPT-5 Codex' }, + { value: 'gpt-5', label: 'GPT-5' }, + { value: 'o3', label: 'o3' }, + ], +} + +/** What a surface says when no model is pinned and the CLI picks for itself (#1143). */ +export const NO_MODEL_PINNED = "the CLI's own default" + +/** Where an agent runs, as the gear's list and the Settings page name it. */ +export const RUN_TARGET_LABELS: Record = { + local: 'This machine', + actions: 'GitHub Actions', + web: 'Claude web', +} + +/** + * The settings a start made from these preferences would use, as one line: which CLI, which model, + * and where it runs. + * + * A model is named only within its own driver's list, and nothing is invented when it is missing: + * a model pinned on the other driver, or never pinned at all, says {@link NO_MODEL_PINNED} rather + * than borrowing the first entry — the same rule the launcher's trigger follows (#1143), for the + * same reason. Naming a model the agent will not actually be passed is worse than saying nothing. + */ +export function describeAgentSettings(preferences: Preferences): string { + const driver: DriverName = isDriverName(preferences.driver) ? preferences.driver : 'claude' + const model = DRIVER_MODELS[driver].find(m => m.value === preferences.model)?.label ?? NO_MODEL_PINNED + return [DRIVER_LABELS[driver], model, RUN_TARGET_LABELS[preferences.target ?? 'local']].join(' · ') +} diff --git a/packages/the-framework/src/auto-pm.ts b/packages/the-framework/src/auto-pm.ts index 6881077cf..f100e4733 100644 --- a/packages/the-framework/src/auto-pm.ts +++ b/packages/the-framework/src/auto-pm.ts @@ -163,6 +163,13 @@ export interface AutoPmJob { * job fires rather than written again here, so a relabelled preset relabels its routine. */ label?: string + /** + * The preset's own one-line "what this does" (#1506), for a surface that has to say what a click + * is about to spend an agent on before it spends it. Read off the preset like + * {@link AutoPmJob.label}, so the sentence the launcher shows for a preset and the sentence the + * routines list shows for its routine are the same sentence. Absent for a preset without one. + */ + tooltip?: string | undefined /** * This job works an entry already on the queue, rather than putting entries on it (#1117). * @@ -338,23 +345,27 @@ export const AUTO_PM_JOBS: readonly AutoPmJob[] = [ name: presets.updateTickets.name, prompt: presets.updateTickets.render(), label: presets.updateTickets.label, + tooltip: presets.updateTickets.tooltip, }, { name: presets.triageQuick.name, prompt: presets.triageQuick.render(), label: presets.triageQuick.label, + tooltip: presets.triageQuick.tooltip, pinnedBranch: `${AGENT_BRANCH_PREFIX}${presets.triageQuick.name}`, }, { name: presets.triageConsensual.name, prompt: presets.triageConsensual.render(), label: presets.triageConsensual.label, + tooltip: presets.triageConsensual.tooltip, pinnedBranch: `${AGENT_BRANCH_PREFIX}${presets.triageConsensual.name}`, }, { name: presets.planTickets.name, prompt: presets.planTickets.render(), label: presets.planTickets.label, + tooltip: presets.planTickets.tooltip, fansOut: true, }, ] @@ -368,6 +379,7 @@ export const AUTO_PM_DRAIN_JOB: AutoPmJob = { name: presets.drainQueue.name, prompt: presets.drainQueue.render(), label: presets.drainQueue.label, + tooltip: presets.drainQueue.tooltip, drains: true, autoMerge: true, } @@ -390,6 +402,7 @@ export const AUTO_PM_MAINTENANCE_JOB: AutoPmJob = { prompt: presets.maintenance.render(), describe: 'sweeping the codebase for maintenance work', label: presets.maintenance.label, + tooltip: presets.maintenance.tooltip, } /** diff --git a/packages/the-framework/src/index.ts b/packages/the-framework/src/index.ts index 4f64096c8..9e6dae5f2 100644 --- a/packages/the-framework/src/index.ts +++ b/packages/the-framework/src/index.ts @@ -18,6 +18,7 @@ export type { AutoPmJob, AutoPmOutcome, AutoPmReport } from './auto-pm.js' export type { FrameworkFileConfig } from './config.js' export type { ChoiceRequest, FrameworkEvent } from './events.js' export type { QuotaBoundaryStatus } from './quota-boundary.js' +export type { AgentLocation } from './agent-location.js' export type { CustomPreset, Preferences } from './registry.js' export type { DriverQuotaWindow } from './driver/types.js' export type { AgentMeta, AgentStatus } from './store/agent-store.js' diff --git a/packages/the-framework/src/preset-catalog.SPEC.md b/packages/the-framework/src/preset-catalog.SPEC.md index c8d733279..8f34752ff 100644 --- a/packages/the-framework/src/preset-catalog.SPEC.md +++ b/packages/the-framework/src/preset-catalog.SPEC.md @@ -11,6 +11,7 @@ The one table of every built-in preset — the prompts the product offers as one - Presets that pause for a human are kept off unattended schedules; the scheduled triage pair pins its own session name so a firing aborts instead of triaging twice. - The GitHub-sync preset always opens an agent of its own — its work is about the repo, not the conversation it was clicked from. - The launcher's menu is one ordered list; the queue-drain preset is daemon-only and absent from it. +- A preset can carry a one-line description of what it queues, said wherever the preset is offered: under its name in the launcher's menu, and on the routine that fires it, so the same work is described the same way in both places. - Recognising "the prompt that drains the queue" compares against the rendered preset itself, so rewording the preset cannot silently break the detection. ## Before modifying/creating SPEC.md files diff --git a/packages/the-framework/src/preset-catalog.ts b/packages/the-framework/src/preset-catalog.ts index 1f19b8c8f..0f4308016 100644 --- a/packages/the-framework/src/preset-catalog.ts +++ b/packages/the-framework/src/preset-catalog.ts @@ -91,7 +91,7 @@ export const presets = { updateTickets: definePreset({ name: 'update-tickets', template: PRESETS_UPDATE_TICKETS, label: 'Update from GitHub', newAgent: true, tooltip: 'Bring `tickets/` up to date with the GitHub issues. An empty `tickets/` gets a full first import.' }), /** [Plan tickets] (#685): turn tickets into costed plans. */ - planTickets: definePreset({ name: 'plan-tickets', template: PRESETS_PLAN_TICKETS, label: 'Plan tickets (aka spike)' }), + planTickets: definePreset({ name: 'plan-tickets', template: PRESETS_PLAN_TICKETS, label: 'Plan tickets (aka spike)', tooltip: 'Turn `tickets/*.md` into costed plans (`tickets/*.plan.md`)' }), /** [Suggest new tickets] (#462/#683): the dashboard prefills this one line and the user edits it freely. */ suggestNewTickets: definePreset({ name: 'suggest-new-tickets', template: PRESETS_SUGGEST_NEW_TICKETS, label: 'Suggest new tickets' }), @@ -114,7 +114,7 @@ export const presets = { suggestTicketsToWorkOn: definePreset({ name: 'suggest-tickets-to-work-on', template: PRESETS_SUGGEST_TICKETS_TO_WORK_ON, label: 'Suggest tickets to work on', tooltip: 'Add tickets to queue (TODO_AGENTS.md)' }), /** [Drain queue] (#855): work the entries already on `TODO_AGENTS.md`. */ - drainQueue: definePreset({ name: 'drain-queue', template: PRESETS_DRAIN_QUEUE, label: 'Spin up agents working on the AI queue' }), + drainQueue: definePreset({ name: 'drain-queue', template: PRESETS_DRAIN_QUEUE, label: 'Spin up agents working on the AI queue', tooltip: 'Work the entries already on the queue (TODO_AGENTS.md)' }), /** * [Do quick-win work] (#891) and [Do consensual work] (#892): read `tickets/*.md`, pick the ones