diff --git a/.changeset/settings-run-option-rules.md b/.changeset/settings-run-option-rules.md new file mode 100644 index 000000000..644464f4e --- /dev/null +++ b/.changeset/settings-run-option-rules.md @@ -0,0 +1,11 @@ +--- +"@gemstack/the-framework": patch +--- + +Make the settings page obey the same run-option rules as the launcher (#958). + +The settings page rendered the run options as flat, independently checkable boxes, while the launcher has real rules between them. So the page could show an option checked that the launcher shows off, and allowed combinations that mean nothing: Eco under Vanilla (nothing left to trim), Browser on Codex (inert, the browser rides Claude Code's MCP config), Auto maintenance without Post-merge cleanup, and anything under Transparent, which overrides the lot. + +The table and its rules moved out of the composer into one module both surfaces render, so a rule cannot hold in one place and not the other. A row the rules disable is greyed and shows why, rather than disappearing, since the settings page is where you go to look for a setting. `checked` is now the effective value everywhere, so no surface claims an option is on while the run ignores it. + +Two smaller cases of the same thing: the notification rows now show the delivery capability the bell already showed (browser permission blocked, `DISCORD_WEBHOOK` / `DISCORD_BOT_TOKEN` unset), and the spend offset is bounded to the same range as its slider and the sanitizer, instead of accepting a value that was silently clamped on save. diff --git a/packages/framework-dashboard/components/Composer.tsx b/packages/framework-dashboard/components/Composer.tsx index 1ada9e8e5..f64ace6b8 100644 --- a/packages/framework-dashboard/components/Composer.tsx +++ b/packages/framework-dashboard/components/Composer.tsx @@ -5,7 +5,6 @@ import { AGENTS, AGENT_LABELS, LAUNCHER_PRESETS, type AgentName } from '@gemstac import { usePreferences, updatePreferences, - autopilotEnabled, themePreference, usePreferenceSources, useProjectFileConfig, @@ -19,7 +18,8 @@ import { PromptEditor, type PromptEditorHandle } from './PromptEditor.js' import { PresetCreatePanel } from './PresetCreatePanel.js' import { PresetsMenu } from './PresetsMenu.js' import { AgentModelMenu, type AgentOption } from './AgentModelMenu.js' -import { OptionsMenu, type OptionRow, type RunTarget } from './OptionsMenu.js' +import { OptionsMenu, type RunTarget } from './OptionsMenu.js' +import { runOptionRows } from '../lib/run-option-rows.js' import { AddDeviceDialog } from './AddDeviceDialog.js' import { useConnectionProfiles, connectLocal, isLoopbackHost, removeProfile, type ConnectionProfile } from '../lib/profiles.js' import { useSelectedRemoteDeviceId, selectRemoteDevice } from '../lib/remote-target.js' @@ -143,7 +143,6 @@ export const Composer = forwardRef) diff --git a/packages/framework-dashboard/components/SettingsPage.tsx b/packages/framework-dashboard/components/SettingsPage.tsx index 9910f1f9f..d298814f9 100644 --- a/packages/framework-dashboard/components/SettingsPage.tsx +++ b/packages/framework-dashboard/components/SettingsPage.tsx @@ -1,12 +1,18 @@ import type { ReactNode } from 'react' -import { AGENTS, AGENT_LABELS } from '@gemstack/the-framework/client' +import type { Preferences } from '@gemstack/the-framework' +import { AGENTS, AGENT_LABELS, MAX_SPEND_OFFSET } from '@gemstack/the-framework/client' import { useDetectedEditors } from '../lib/editors.js' import { usePreferences, updatePreferences, themePreference, type ThemePreference } from '../lib/preferences.js' +import { runOptionRows, type OptionRow } from '../lib/run-option-rows.js' +import { useNotificationPermission } from '../lib/notification-permission.js' +import { useLoaded } from '../lib/use-async.js' +import { onNotifyChannels, type NotifyChannels } from '../server/preferences.telefunc.js' import { OnboardingChecklist } from './OnboardingChecklist.js' import { DevicesSettings } from './DevicesSettings.js' import { Card, CardContent, CardHeader, CardTitle } from './ui/card.js' import { Checkbox } from './ui/checkbox.js' import { ScrollArea } from './ui/scroll-area.js' +import { cn } from '../lib/utils.js' // The settings page (#958): every setting in one place, and the Onboarding checklist. // @@ -24,6 +30,15 @@ export function SettingsPage({ onSelectProject }: { onSelectProject?: ((id: stri const preferences = usePreferences() const editors = useDetectedEditors() const theme = themePreference(preferences) + // One shared table with the launcher (#958), rules already applied. + const { main: runOptions, eco: ecoRows } = runOptionRows(preferences) + // A notification toggle is a preference; whether it can deliver is a capability (#948). Both are + // shown, the same way the bell does, so a row cannot promise delivery that will not happen. + const permission = useNotificationPermission() + const channels = useLoaded(onNotifyChannels, null, []) + const webhookReady = channels === null || channels.discordWebhook + const botReady = channels === null || channels.discordBot + const browserBlocked = permission === 'denied' return ( @@ -91,82 +106,44 @@ export function SettingsPage({ onSelectProject }: { onSelectProject?: ((id: stri {/* Beside "Run on", since a saved device is the other thing a session can run on. */} -
- updatePreferences({ transparent: next })} - /> - updatePreferences({ autopilot: next })} - /> - updatePreferences({ technical: next })} - /> - updatePreferences({ vanilla: next })} - /> - updatePreferences({ onBeforeMergeableQuality: next })} - /> - updatePreferences({ browser: next })} - /> + {/* The same table the launcher renders (#958), so a rule cannot hold in one place and + not the other: Transparent overrides the rest, Eco is inert once the system prompt is + off, Browser is Claude-only, and the Eco drops need Eco. A row the rules disable is + shown greyed with its reason rather than hidden, since this is where you come to look. */} +
+ {runOptions.map(row => ( + + ))}
- updatePreferences({ eco: next })} - /> - updatePreferences({ ecoPlanning: next })} - /> - updatePreferences({ ecoResearch: next })} - /> - updatePreferences({ ecoMaintenance: next })} - /> + {ecoRows.map(row => ( + + ))}
updatePreferences({ notifyBrowser: next })} /> updatePreferences({ notifyDiscord: next })} /> @@ -184,7 +161,11 @@ export function SettingsPage({ onSelectProject }: { onSelectProject?: ((id: stri /> updatePreferences({ discordBot: next })} /> @@ -197,10 +178,14 @@ export function SettingsPage({ onSelectProject }: { onSelectProject?: ((id: stri checked={preferences.autoPm ?? false} onChange={next => updatePreferences({ autoPm: next })} /> + {/* Bounded to the same ±MAX_SPEND_OFFSET the slider and the sanitizer use (#960). Without + it a typed 9999 was clamped to 50 on save while the box kept showing 9999. */} updatePreferences({ autoSpendOffset: value })} />
@@ -223,11 +208,22 @@ function Section({ title, description, children }: { title: string; description? ) } -function Row({ label, description, control }: { label: string; description: string; control: ReactNode }) { +function Row({ + label, + description, + control, + dimmed = false, +}: { + label: string + description: string + control: ReactNode + /** A row the rules turned off: greyed, but still shown with its reason. */ + dimmed?: boolean +}) { return (
-

{label}

+

{label}

{description}

{control}
@@ -235,22 +231,59 @@ function Row({ label, description, control }: { label: string; description: stri ) } +/** + * One row of the shared run-option table (#958). + * + * A row the rules disable keeps its place and shows *why* instead of vanishing, because the whole + * point of this page is to be where you look for a setting. `row.checked` is the effective value, + * so an option Transparent overrides reads off here exactly as it does in the launcher. + */ +function OptionToggleRow({ row }: { row: OptionRow }) { + const disabled = row.disabled ?? false + return ( + updatePreferences({ [row.key]: next === true } as Partial)} + aria-label={row.label} + /> + } + /> + ) +} + function ToggleRow({ label, description, checked, onChange, + disabled = false, }: { label: string description: string checked: boolean onChange: (next: boolean) => void + /** A capability the daemon or browser withholds, e.g. notifications the browser has blocked. */ + disabled?: boolean }) { return ( onChange(next === true)} aria-label={label} />} + dimmed={disabled} + control={ + onChange(next === true)} + aria-label={label} + /> + } /> ) } @@ -325,11 +358,15 @@ function NumberRow({ label, description, value, + min, + max, onChange, }: { label: string description: string value: number + min: number + max: number onChange: (next: number) => void }) { return ( @@ -340,7 +377,11 @@ function NumberRow({ onChange(Number(e.target.value) || 0)} + min={min} + max={max} + // Clamped here as well as on the input: `min`/`max` only constrain the spinner, so a typed + // value still has to be held to the range the sanitizer will enforce anyway (#960). + onChange={e => onChange(Math.min(Math.max(Math.round(Number(e.target.value) || 0), min), max))} aria-label={label} className="w-24 rounded-md border border-border bg-background px-2 py-1 text-sm" /> diff --git a/packages/framework-dashboard/lib/run-option-rows.test.ts b/packages/framework-dashboard/lib/run-option-rows.test.ts new file mode 100644 index 000000000..ccc192419 --- /dev/null +++ b/packages/framework-dashboard/lib/run-option-rows.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from 'vitest' +import type { Preferences } from '@gemstack/the-framework' +import { runOptionRows, type OptionRow } from './run-option-rows.js' + +// The rules between the run options (#314/#625/#801/#958). They live in one table because two +// surfaces render them (the launcher gear and the settings page); these pin the rules themselves, +// so neither surface can quietly disagree with the other. + +const rows = (preferences: Preferences) => runOptionRows(preferences) +const find = (list: OptionRow[], key: string) => list.find(r => r.key === key)! + +describe('runOptionRows', () => { + test('autopilot is on by default, and off only when explicitly turned off', () => { + expect(find(rows({}).main, 'autopilot').checked).toBe(true) + expect(find(rows({ autopilot: false }).main, 'autopilot').checked).toBe(false) + }) + + test('Transparent overrides the options below it: they read off and cannot be changed (#625)', () => { + const main = rows({ + transparent: true, + autopilot: true, + technical: true, + vanilla: true, + onBeforeMergeableQuality: true, + browser: true, + }).main + + for (const key of ['autopilot', 'technical', 'vanilla', 'onBeforeMergeableQuality', 'browser']) { + const row = find(main, key) + // Effective, not stored: the run ignores it, so the box must not claim it is on. + expect(row.checked, `${key} checked`).toBe(false) + expect(row.disabled, `${key} disabled`).toBe(true) + } + // Transparent itself stays on and available. + expect(find(main, 'transparent').checked).toBe(true) + expect(find(main, 'transparent').disabled).toBeUndefined() + }) + + test('Eco has nothing to trim once the system prompt is off (Vanilla or Transparent)', () => { + expect(find(rows({ eco: true, vanilla: true }).main, 'eco')).toMatchObject({ + checked: false, + disabled: true, + disabledReason: 'nothing to trim while the system prompt is off', + }) + expect(find(rows({ eco: true, transparent: true }).main, 'eco').disabled).toBe(true) + // Plain Eco, nothing else set: available and on. + expect(find(rows({ eco: true }).main, 'eco').checked).toBe(true) + expect(find(rows({ eco: true }).main, 'eco').disabled).toBeUndefined() + }) + + test('Browser is Claude-only, because it rides Claude Code’s MCP config (#801)', () => { + const onCodex = find(rows({ browser: true, agent: 'codex' }).main, 'browser') + expect(onCodex.checked).toBe(false) + expect(onCodex.disabled).toBe(true) + expect(onCodex.disabledReason).toMatch(/only on Claude Code/) + + const onClaude = find(rows({ browser: true, agent: 'claude' }).main, 'browser') + expect(onClaude.checked).toBe(true) + expect(onClaude.disabled).toBeUndefined() + }) + + test('an unknown stored agent is not Claude Code, so Browser stays disabled', () => { + // The *label* falls back to Claude Code, but the Browser rule tests the stored value directly. + // Carried over from the launcher deliberately: the fallback is cosmetic, and treating an + // unrecognised agent as Claude would offer a browser its driver cannot wire up. + const row = find(rows({ browser: true, agent: 'nope' }).main, 'browser') + expect(row.checked).toBe(false) + expect(row.disabled).toBe(true) + }) + + test('the Eco drops apply only while Eco itself is in force', () => { + const off = rows({ ecoPlanning: true, ecoResearch: true }).eco + for (const key of ['ecoPlanning', 'ecoResearch']) { + expect(find(off, key), key).toMatchObject({ + checked: false, + disabled: true, + disabledReason: 'only applies while Eco is on', + }) + } + const on = find(rows({ eco: true, ecoPlanning: true }).eco, 'ecoPlanning') + expect(on.checked).toBe(true) + expect(on.disabled).toBeUndefined() + }) + + test('Auto maintenance trims nothing unless Post-merge cleanup runs (#556/#801)', () => { + expect(find(rows({ eco: true, ecoMaintenance: true }).eco, 'ecoMaintenance')).toMatchObject({ + checked: false, + disabled: true, + disabledReason: 'only applies while Post-merge cleanup is on', + }) + const live = find(rows({ eco: true, ecoMaintenance: true, onBeforeMergeableQuality: true }).eco, 'ecoMaintenance') + expect(live.checked).toBe(true) + expect(live.disabled).toBeUndefined() + }) + + test('Transparent names the agent actually selected, so the label is never a lie (#948)', () => { + expect(find(rows({ agent: 'codex' }).main, 'transparent').description).toMatch(/Codex/) + expect(find(rows({ agent: 'claude' }).main, 'transparent').description).toMatch(/Claude/) + }) +}) diff --git a/packages/framework-dashboard/lib/run-option-rows.ts b/packages/framework-dashboard/lib/run-option-rows.ts new file mode 100644 index 000000000..63c8a309e --- /dev/null +++ b/packages/framework-dashboard/lib/run-option-rows.ts @@ -0,0 +1,176 @@ +import type { Preferences } from '@gemstack/the-framework' +import { AGENTS, AGENT_LABELS, autopilotEnabled, type AgentName } from '@gemstack/the-framework/client' + +// The Global options as one table (#314), and the rules between them. +// +// This used to be built inline in the composer, which was fine while the launcher was the only +// place that showed it. The settings page (#958) shows the same options, and a second hand-rolled +// copy would drift: the rules here are not decoration, they decide whether a box means anything +// (Eco under Vanilla trims nothing; Browser on Codex is inert). One table, rendered by both. +// +// It is pure data — no JSX — so the launcher can render it as dropdown items and the settings page +// as page rows, without either one owning the rules. + +export type OptionRow = { + key: keyof Preferences + label: string + title: string + /** A short one-line summary shown under the label (#654). */ + description?: string + checked: boolean + /** Disabled beyond the form-wide busy flag (e.g. Eco has nothing to trim under Vanilla). */ + disabled?: boolean + /** Why it's disabled, shown in the description so a greyed row isn't a mystery (the `title` + * tooltip is suppressed on disabled dropdown items). Only rendered while {@link disabled}. */ + disabledReason?: string +} + +/** The main run options and the Eco sub-drops, with every rule between them already applied. */ +export interface RunOptionRows { + main: OptionRow[] + eco: OptionRow[] +} + +/** + * The run-option table for a resolved set of preferences. + * + * `checked` is the *effective* value, not the stored one: an option overridden by Transparent reads + * as off, because that is what the run will do. So a surface that renders this can never claim an + * option is on while the run ignores it. + */ +export function runOptionRows(preferences: Preferences): RunOptionRows { + const transparent = preferences.transparent ?? false // #625: the master off-switch + const autopilot = autopilotEnabled(preferences) // default-on lives in autopilotEnabled + const technical = preferences.technical ?? false + const vanilla = preferences.vanilla ?? false + const eco = preferences.eco ?? false + const ecoPlanning = preferences.ecoPlanning ?? false + const ecoResearch = preferences.ecoResearch ?? false + const ecoMaintenance = preferences.ecoMaintenance ?? false + const onBeforeMergeableQuality = preferences.onBeforeMergeableQuality ?? false + const browser = preferences.browser ?? false + const agent = preferences.agent ?? 'claude' // #650: which coding agent drives the run + // The stored agent as a display name; an unknown stored value falls back to Claude Code. + const agentLabel = AGENT_LABELS[AGENTS.includes(agent as AgentName) ? (agent as AgentName) : 'claude'] + + // Vanilla removes the system prompt (nothing left for Eco to trim); Transparent turns off the + // whole framework, so it overrides the rest too. + const ecoDisabled = vanilla || transparent + // The Eco sub-drops trim sections of a prompt Eco itself is what trims, so they are inert unless + // Eco is actually in force. The launcher hides them instead of greying them (it only renders them + // while Eco is on), so this only ever bites on a surface that lists them unconditionally. + const ecoOff = !eco || ecoDisabled + + const main: OptionRow[] = [ + // Named for the agent actually selected (#948): under Codex, "Raw Claude Code" was a lie. + { + key: 'transparent', + label: 'Transparent', + description: `Raw ${agentLabel} — turns the whole framework off.`, + title: `Fully transparent (#625): run the agent exactly like plain ${agentLabel}, with no framework system prompt, controls, dashboard, guard, or TODO loop. Overrides the options below.`, + checked: transparent, + }, + // Says only what it does (#801): the maintenance stance it used to relax left the system prompt + // with that section (#556), so the countdown is the whole feature. + { + key: 'autopilot', + label: 'Autopilot', + description: 'Auto-accepts the recommended choice after a countdown.', + title: 'Auto-accept the recommended choice after a countdown, instead of waiting for you to pick', + checked: autopilot && !transparent, + ...overriddenByTransparent(transparent), + }, + { + key: 'technical', + label: 'Technical control', + description: 'Surfaces technical detail like tech-stack choices.', + title: 'Expose technical detail (e.g. tech-stack choices)', + checked: technical && !transparent, + ...overriddenByTransparent(transparent), + }, + { + key: 'vanilla', + label: 'Disable system prompt', + description: 'Drops the added system prompt; keeps the session controls.', + title: + "Remove the built-in system prompt but keep the framework's session controls. For a fully raw session, use Transparent. Expand 'Enhanced System Prompt' to read what it removes.", + checked: vanilla && !transparent, + ...overriddenByTransparent(transparent), + }, + { + key: 'eco', + label: 'Eco', + description: 'Trims the system prompt to save tokens.', + title: 'Trim the built-in system prompt to save tokens', + checked: eco && !ecoDisabled, + ...(ecoDisabled ? { disabled: true, disabledReason: 'nothing to trim while the system prompt is off' } : {}), + }, + { + key: 'onBeforeMergeableQuality', + label: 'Post-merge cleanup', + description: 'Runs quality passes once it is ready to merge.', + title: "When the session signals it's ready for merge, run maintainability, readability, and security-audit passes", + checked: onBeforeMergeableQuality && !transparent, + ...overriddenByTransparent(transparent), + }, + // Claude-only (#801): the browser is wired through Claude Code's MCP config, so another agent's + // driver takes no MCP servers and the box would be checkable but inert. + { + key: 'browser', + label: 'Browser', + description: 'Gives the agent a real browser to inspect pages.', + title: + 'Give the agent a real browser via chrome-devtools-mcp: navigate pages, read console + network, inspect the DOM, and screenshot', + checked: browser && !transparent && agent === 'claude', + ...(transparent || agent !== 'claude' + ? { + disabled: true, + disabledReason: transparent + ? 'off while Transparent is on' + : 'only on Claude Code — the browser is wired through its MCP config', + } + : {}), + }, + ] + + const ecoRows: OptionRow[] = [ + { + key: 'ecoPlanning', + label: 'Auto planning', + description: 'Drops the planning section; the agent plans itself.', + title: 'Drop the planning section, letting the agent plan on its own', + checked: ecoPlanning && !ecoOff, + ...(ecoOff ? { disabled: true, disabledReason: 'only applies while Eco is on' } : {}), + }, + { + key: 'ecoResearch', + label: 'Auto research', + description: 'Drops the alternatives/variability section.', + title: 'Drop the alternatives/variability section', + checked: ecoResearch && !ecoOff, + ...(ecoOff ? { disabled: true, disabledReason: 'only applies while Eco is on' } : {}), + }, + // Gated on Post-merge cleanup (#801): #556 moved the Maintenance section out of the system + // prompt and into the on-before-mergeable prompt, so this trims nothing unless that pass runs. + { + key: 'ecoMaintenance', + label: 'Auto maintenance', + description: 'Drops the maintenance section from the post-merge prompt.', + title: 'Drop the Maintenance section from the post-merge cleanup prompt', + checked: ecoMaintenance && onBeforeMergeableQuality && !ecoOff, + ...(ecoOff || !onBeforeMergeableQuality + ? { + disabled: true, + disabledReason: ecoOff ? 'only applies while Eco is on' : 'only applies while Post-merge cleanup is on', + } + : {}), + }, + ] + + return { main, eco: ecoRows } +} + +/** The shared "Transparent overrides it" disable, which most of the main rows carry. */ +function overriddenByTransparent(transparent: boolean): Pick { + return transparent ? { disabled: true, disabledReason: 'off while Transparent is on' } : {} +}