From 9f748b65c61aa9229222a7fe0f2759e1ecf1d562 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Fri, 24 Jul 2026 00:20:23 +0300 Subject: [PATCH 1/2] feat(dashboard): manage saved devices from the settings page Adding and removing a device already worked, but only from the composer's "Run on" menu, and the composer only exists on a project launcher: from the Overview or the settings page there was no way to manage the roster at all. Adds a Devices section to the settings page listing each saved device with its origin and online/offline status, an Add device button (the existing dialog), and a remove per row. The "Run on" picker still lists devices, because choosing a run target is a per-run act; which devices exist is configuration. Removing from settings applies the same guard the composer already did: a device that was the selected run target clears the selection, so a run can never point at a device that is no longer saved. The presentation is new but the state is not: it reads the existing useConnectionProfiles / useDeviceStatus hooks and profiles.ts, so there is one device store, not two. The section says devices are saved in this browser because, unlike every other setting on the page, a device carries a token and so never reaches the daemon. --- .changeset/devices-settings-section.md | 11 ++ .../components/DevicesSettings.test.tsx | 73 +++++++++++++ .../components/DevicesSettings.tsx | 101 ++++++++++++++++++ .../components/SettingsPage.tsx | 4 + 4 files changed, 189 insertions(+) create mode 100644 .changeset/devices-settings-section.md create mode 100644 packages/framework-dashboard/components/DevicesSettings.test.tsx create mode 100644 packages/framework-dashboard/components/DevicesSettings.tsx diff --git a/.changeset/devices-settings-section.md b/.changeset/devices-settings-section.md new file mode 100644 index 000000000..050ca594c --- /dev/null +++ b/.changeset/devices-settings-section.md @@ -0,0 +1,11 @@ +--- +"@gemstack/the-framework": minor +--- + +Manage saved devices from the settings page. + +Adding and removing a device already worked, but only from the composer's "Run on" menu, and the composer only exists on a project launcher. From the Overview or the settings page there was no way to manage the roster at all. The settings page now has a **Devices** section listing each saved device with its origin and online/offline status, an Add device button, and a remove per row. The "Run on" picker still lists devices, because choosing a run target is a per-run act; which devices exist is configuration. + +Removing a device from settings clears the run target when that device was the one selected, the same guard the composer already applied, so a run can never point at a device that is no longer saved. + +The section states that devices are saved in the browser rather than on the server: unlike every other setting on the page, a device carries a token, so it stays in this browser's storage and never reaches the daemon. diff --git a/packages/framework-dashboard/components/DevicesSettings.test.tsx b/packages/framework-dashboard/components/DevicesSettings.test.tsx new file mode 100644 index 000000000..6cc58537d --- /dev/null +++ b/packages/framework-dashboard/components/DevicesSettings.test.tsx @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' + +// The device health poll (#1072) reaches the daemon over Telefunc; hoisted so the factory below can +// close over it (a plain const is initialised after vi.mock hoists). +const checkDevices = vi.hoisted(() => vi.fn()) +vi.mock('../server/devices.telefunc.js', () => ({ checkDevices })) + +import { DevicesSettings } from './DevicesSettings.js' +import { addProfile, listProfiles } from '../lib/profiles.js' +import { selectRemoteDevice, getSelectedRemoteDeviceId } from '../lib/remote-target.js' + +const STUDIO = 'http://192.168.1.5:4200' +const BOX = 'http://box.tail.ts:4200' + +afterEach(() => { + cleanup() + localStorage.clear() + selectRemoteDevice(null) // module-level state, so it outlives a test unless reset + checkDevices.mockReset() +}) + +describe('DevicesSettings (#1052/#1072)', () => { + test('says so when there are no devices, rather than showing an empty list', () => { + checkDevices.mockResolvedValue({}) + render() + expect(screen.getByText(/No devices saved/)).toBeTruthy() + }) + + test('lists each saved device with its origin', () => { + checkDevices.mockResolvedValue({}) + addProfile({ url: STUDIO, token: 'aaa', label: 'Studio' }) + render() + expect(screen.getByText('Studio')).toBeTruthy() + expect(screen.getByText(STUDIO)).toBeTruthy() + }) + + test('removing a device drops it from storage', () => { + checkDevices.mockResolvedValue({}) + addProfile({ url: STUDIO, token: 'aaa', label: 'Studio' }) + render() + + fireEvent.click(screen.getByLabelText('Remove Studio')) + + expect(listProfiles()).toEqual([]) + }) + + test('removing the device a run is targeting clears the run target (#1072)', () => { + // The composer applies this guard on its own remove; managing the roster from settings has to + // apply it too, or the next run points at a device that is no longer in the list. + checkDevices.mockResolvedValue({}) + const studio = addProfile({ url: STUDIO, token: 'aaa', label: 'Studio' }) + selectRemoteDevice(studio.id) + render() + + fireEvent.click(screen.getByLabelText('Remove Studio')) + + expect(getSelectedRemoteDeviceId()).toBe(null) + }) + + test('removing some other device leaves the run target alone', () => { + checkDevices.mockResolvedValue({}) + const studio = addProfile({ url: STUDIO, token: 'aaa', label: 'Studio' }) + addProfile({ url: BOX, token: 'bbb', label: 'Box' }) + selectRemoteDevice(studio.id) + render() + + fireEvent.click(screen.getByLabelText('Remove Box')) + + expect(getSelectedRemoteDeviceId()).toBe(studio.id) + expect(listProfiles().map(p => p.label)).toEqual(['Studio']) + }) +}) diff --git a/packages/framework-dashboard/components/DevicesSettings.tsx b/packages/framework-dashboard/components/DevicesSettings.tsx new file mode 100644 index 000000000..937514ec3 --- /dev/null +++ b/packages/framework-dashboard/components/DevicesSettings.tsx @@ -0,0 +1,101 @@ +import { useState } from 'react' +import { Trash2 } from 'lucide-react' +import { useConnectionProfiles, removeProfile, type ConnectionProfile } from '../lib/profiles.js' +import { useDeviceStatus } from '../lib/use-device-status.js' +import { useSelectedRemoteDeviceId, selectRemoteDevice } from '../lib/remote-target.js' +import { AddDeviceDialog } from './AddDeviceDialog.js' +import { Button } from './ui/button.js' +import { Card, CardContent, CardHeader, CardTitle } from './ui/card.js' +import { cn } from '../lib/utils.js' + +// Saved devices, as a settings section (#1052/#1072). +// +// Adding and removing a device already worked, but only from the composer's "Run on" menu, and the +// composer exists on a project launcher and nowhere else: from the Overview or the settings page +// there was no way to manage the roster at all. The picker keeps listing devices, because choosing +// a run target is a per-run act; which devices exist is configuration, so it belongs here. +// +// Unlike everything else on the settings page these are NOT preferences. A device carries a token, +// so it lives in this browser's localStorage and never reaches the daemon (see profiles.ts). The +// section says so, because the reasonable assumption for a settings row is that it follows you to +// the next browser, and this one does not. + +export function DevicesSettings() { + const profiles = useConnectionProfiles() + const status = useDeviceStatus(profiles) + const selectedDeviceId = useSelectedRemoteDeviceId() + const [adding, setAdding] = useState(false) + + // The same guard the composer applies (#1072): a device that is removed must not stay the run + // target, or the next run points at something that is no longer in the list. + const remove = (profile: ConnectionProfile) => { + if (selectedDeviceId === profile.id) selectRemoteDevice(null) + removeProfile(profile.id) + } + + return ( + + +
+ Devices +

+ Other machines running The Framework that you can run a session on. Saved in this browser, not on the + server, because each one is reached with its own token. +

+
+ +
+ + {profiles.length === 0 ? ( +

+ No devices saved. Add one with the URL another machine prints when it starts. +

+ ) : ( +
    + {profiles.map(profile => ( +
  • +
    +

    {profile.label}

    +

    {profile.url}

    +
    +
    + + +
    +
  • + ))} +
+ )} +
+ + {adding && setAdding(false)} onAdded={() => setAdding(false)} />} +
+ ) +} + +/** Online / offline, or neither while the first ping is still out. */ +function DeviceStatusBadge({ state }: { state: 'online' | 'offline' | undefined }) { + const label = state === 'online' ? 'Online' : state === 'offline' ? 'Offline' : 'Checking…' + return ( + + + {label} + + ) +} diff --git a/packages/framework-dashboard/components/SettingsPage.tsx b/packages/framework-dashboard/components/SettingsPage.tsx index 7f694713d..9910f1f9f 100644 --- a/packages/framework-dashboard/components/SettingsPage.tsx +++ b/packages/framework-dashboard/components/SettingsPage.tsx @@ -3,6 +3,7 @@ import { AGENTS, AGENT_LABELS } from '@gemstack/the-framework/client' import { useDetectedEditors } from '../lib/editors.js' import { usePreferences, updatePreferences, themePreference, type ThemePreference } from '../lib/preferences.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' @@ -87,6 +88,9 @@ export function SettingsPage({ onSelectProject }: { onSelectProject?: ((id: stri /> + {/* Beside "Run on", since a saved device is the other thing a session can run on. */} + +
Date: Fri, 24 Jul 2026 00:51:22 +0300 Subject: [PATCH 2/2] fix(dashboard): give the settings page the launcher's run-option rules 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, Browser on Codex, Auto maintenance without Post-merge cleanup, and anything at all under Transparent. Move the option table and its rules out of Composer into lib/run-option-rows.ts, rendered by both surfaces, so a rule cannot hold in one place and not the other. It is pure data, so the launcher still renders dropdown items and the settings page renders page rows; neither owns the rules. `checked` is the effective value, so nothing claims an option is on while the run ignores it. A disabled row is greyed with its reason rather than hidden: the settings page is where you go to look for a setting, so a vanished row would be worse than a greyed one. The launcher is unchanged (it already hid the Eco drops when Eco is off), and its tests pass untouched. Two smaller cases of the same mistake, fixed here too: - the notification rows now show the delivery capability the bell already did (permission blocked, DISCORD_WEBHOOK / DISCORD_BOT_TOKEN unset) - the spend offset is bounded to the same range as its slider and the sanitizer, which used to clamp a typed 9999 to 50 while the box kept 9999 --- .changeset/settings-run-option-rules.md | 11 ++ .../components/Composer.tsx | 38 +--- .../components/OptionsMenu.tsx | 18 +- .../components/SettingsPage.tsx | 183 +++++++++++------- .../lib/run-option-rows.test.ts | 100 ++++++++++ .../lib/run-option-rows.ts | 176 +++++++++++++++++ 6 files changed, 409 insertions(+), 117 deletions(-) create mode 100644 .changeset/settings-run-option-rules.md create mode 100644 packages/framework-dashboard/lib/run-option-rows.test.ts create mode 100644 packages/framework-dashboard/lib/run-option-rows.ts 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' } : {} +}