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 @@ -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
Expand Down
38 changes: 16 additions & 22 deletions packages/the-framework/dashboard/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<DriverName, ...> shape means a new agent
// framework-side is a compile error here rather than a silently missing menu entry.
const DRIVER_UI: Record<DriverName, { icon: DriverOption['icon']; models: DriverOption['models'] }> = {
claude: {
icon: <ClaudeLogo className="h-4 w-4" />,
models: [
{ value: 'fable', label: 'Fable' },
{ value: 'opus', label: 'Opus' },
{ value: 'sonnet', label: 'Sonnet' },
{ value: 'haiku', label: 'Haiku' },
],
},
codex: {
icon: <CodexLogo className="h-4 w-4" />,
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<DriverName, ...> shape means a new agent framework-side is a compile error here rather
// than a silently missing menu entry.
const DRIVER_UI: Record<DriverName, { icon: DriverOption['icon'] }> = {
claude: { icon: <ClaudeLogo className="h-4 w-4" /> },
codex: { icon: <CodexLogo className="h-4 w-4" /> },
}
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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]
}
Expand Down
7 changes: 4 additions & 3 deletions packages/the-framework/dashboard/components/OptionsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

/**
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand Down
49 changes: 49 additions & 0 deletions packages/the-framework/dashboard/components/RoutineWork.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
56 changes: 45 additions & 11 deletions packages/the-framework/dashboard/components/RoutineWork.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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. */}
<div className="flex shrink-0 items-center">
<Button
type="button"
variant="outline"
size="xs"
disabled={busy || !projectId}
onClick={() => void runNow(job)}
className="rounded-r-none border-r-0"
>
<Play className="h-3 w-3" aria-hidden />
{starting === job.name ? 'Starting…' : 'Run now'}
</Button>
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="outline"
size="xs"
disabled={busy || !projectId}
onClick={() => void runNow(job)}
className="rounded-r-none border-r-0"
/>
}
>
<Play className="h-3 w-3" aria-hidden />
{starting === job.name ? 'Starting…' : 'Run now'}
</TooltipTrigger>
<TooltipContent className="max-w-[22rem] space-y-1">
{job.tooltip && <span className="block">{job.tooltip}</span>}
{/* 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. */}
<span className="block text-muted-foreground">
{job.drains ? "Each project's own settings decide the model and where it runs." : settings}
</span>
<span className="block text-muted-foreground">
{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.`}
</span>
</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger
type="button"
Expand Down
7 changes: 4 additions & 3 deletions packages/the-framework/dashboard/components/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Button } from './ui/button.js'
import { Checkbox } from './ui/checkbox.js'
import { ScrollArea } from './ui/scroll-area.js'
import { cn } from '../lib/utils.js'
import { RUN_TARGET_LABELS } from '../lib/agent-settings.js'

// The settings page (#958): every setting in one place, and the Onboarding checklist.
//
Expand Down Expand Up @@ -105,9 +106,9 @@ export function SettingsPage({
description="Where an agent executes: this machine, a fresh GitHub Actions runner, or a Claude Code cloud session."
value={preferences.target ?? 'local'}
options={[
{ value: 'local', label: 'This device' },
{ value: 'actions', label: 'GitHub Actions' },
{ value: 'web', label: 'Claude web' },
{ value: 'local', label: RUN_TARGET_LABELS.local },
{ value: 'actions', label: RUN_TARGET_LABELS.actions },
{ value: 'web', label: RUN_TARGET_LABELS.web },
]}
onChange={value => updatePreferences({ target: value as 'local' | 'actions' | 'web' })}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand Down
8 changes: 7 additions & 1 deletion packages/the-framework/dashboard/components/ui/tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,13 @@ function TooltipContent({
}) {
return (
<TooltipPrimitive.Portal>
{/* 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. */}
<TooltipPrimitive.Positioner
className="z-50"
sideOffset={sideOffset}
{...(side ? { side } : {})}
{...(align ? { align } : {})}
Expand All @@ -42,7 +48,7 @@ function TooltipContent({
// that no element carries a `title`.
role="tooltip"
className={cn(
'z-50 rounded-md border border-border bg-card px-2 py-1 text-xs text-card-foreground shadow-md',
'rounded-md border border-border bg-card px-2 py-1 text-xs text-card-foreground shadow-md',
className,
)}
{...props}
Expand Down
10 changes: 10 additions & 0 deletions packages/the-framework/dashboard/lib/agent-settings.SPEC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
The words the dashboard uses for the three settings every start is made of — which coding agent, which model, and where it runs — in one place, so each surface that names them says the same thing.

## Flows

- A surface that has to state what a start would use gets it as one line: the coding agent, the model, and the place, read from the user's own settings rather than from a copy that could fall behind them.
- A model is only ever named within its own coding agent's list. With nothing pinned, or with a model pinned on the other agent, the line says the CLI picks for itself instead of naming a model that would never be passed.

## Before modifying/creating SPEC.md files

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