From ff9b8f539f6401308bf8dabe5e4149475eeb1fb4 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Sun, 5 Jul 2026 21:29:56 +0300 Subject: [PATCH] feat(framework): dashboard mode checkboxes for the active Open Loop modes (#272) A run under a domain preset now emits a 'modes' framework event carrying the known modes and the active subset; the dashboard renders them as read-only checkboxes in a Modes panel. OPEN_LOOP_MODES is the single source of truth for the ordering, shared with the meta-select router. No preset = no event, no panel. The event persists, so --resume rehydrates the panel. --- .changeset/dashboard-mode-checkboxes.md | 7 +++++++ packages/framework/src/dashboard/page.ts | 21 +++++++++++++++++++ .../framework/src/dashboard/server.test.ts | 3 +++ packages/framework/src/events.test.ts | 12 +++++++++++ packages/framework/src/events.ts | 18 ++++++++++++++++ packages/framework/src/index.ts | 1 + packages/framework/src/meta-select.ts | 3 ++- packages/framework/src/preset-run.test.ts | 20 ++++++++++++++++++ packages/framework/src/run.ts | 4 +++- 9 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 .changeset/dashboard-mode-checkboxes.md diff --git a/.changeset/dashboard-mode-checkboxes.md b/.changeset/dashboard-mode-checkboxes.md new file mode 100644 index 0000000..5e464b1 --- /dev/null +++ b/.changeset/dashboard-mode-checkboxes.md @@ -0,0 +1,7 @@ +--- +'@gemstack/framework': minor +--- + +feat(framework): show the active Open Loop modes as read-only checkboxes on the dashboard (#272) + +When a run builds under a domain preset, the dashboard now renders a Modes panel with the run's modes as checkboxes ([x] technical / [ ] autopilot), so the policy driving the build is visible beside the stack and loop panels. Backed by a new `modes` framework event (`OPEN_LOOP_MODES` is the canonical mode ordering, shared with the meta-select router); a run with no preset emits nothing and shows no panel. The event persists with the run, so `--resume` rehydrates the panel too. diff --git a/packages/framework/src/dashboard/page.ts b/packages/framework/src/dashboard/page.ts index bfadb8a..d7cbf10 100644 --- a/packages/framework/src/dashboard/page.ts +++ b/packages/framework/src/dashboard/page.ts @@ -64,6 +64,10 @@ export function dashboardHtml(title: string, stoppable = false): string { font-weight: 600; } .badge.grade { background: #14371f; color: #67d98f; } .badge.proto { background: #2a2320; color: #f0a35e; } + #modes li { display: flex; align-items: center; gap: 8px; color: #b7c0d0; } + #modes .box { color: #6ea8fe; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } + #modes li.off { color: #5c657a; } + #modes li.off .box { color: #3a4256; } #activity { grid-column: 1 / -1; } #log { font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; color: #96a0b3; max-height: 320px; overflow-y: auto; white-space: pre-wrap; } @@ -95,6 +99,10 @@ export function dashboardHtml(title: string, stoppable = false): string {
building…
+

Deploy

not decided yet
@@ -198,6 +206,18 @@ function renderRationale(e) { row.className = 'alt'; row.innerHTML = '' + esc(a.option) + ' \\u2014 ' + esc(a.whyNot); }); } +function renderModes(all, active) { + const on = new Set(active || []); + const ul = $('modes'); ul.innerHTML = ''; + for (const m of (all || [])) { + const checked = on.has(m); + const li = document.createElement('li'); + if (!checked) li.className = 'off'; + li.innerHTML = '' + (checked ? '[x]' : '[ ]') + ' ' + esc(m); + ul.appendChild(li); + } + $('modes-panel').hidden = false; +} function setSessionLink(sessionId, sessionLink) { const el = $('session-link'); if (sessionLink) { @@ -229,6 +249,7 @@ function onEvent(fe) { } else if (fe.kind === 'bootstrap') bootstrap(fe.event); else if (fe.kind === 'driver') driver(fe.event); + else if (fe.kind === 'modes') renderModes(fe.all, fe.active); else if (fe.kind === 'log') log(fe.message); else if (fe.kind === 'end') { ended = true; diff --git a/packages/framework/src/dashboard/server.test.ts b/packages/framework/src/dashboard/server.test.ts index e74a065..97c4d44 100644 --- a/packages/framework/src/dashboard/server.test.ts +++ b/packages/framework/src/dashboard/server.test.ts @@ -60,6 +60,9 @@ test('dashboard serves the HTML page with the title', async () => { assert.equal(status, 200) assert.match(body, /My Framework/) assert.match(body, /new EventSource\('\/events'\)/) + // The Modes panel + its renderer ship in the page (#272), hidden until a modes event. + assert.match(body, /id="modes-panel" hidden/) + assert.match(body, /function renderModes/) } finally { await dash.close() } diff --git a/packages/framework/src/events.test.ts b/packages/framework/src/events.test.ts index 7491052..5eca05e 100644 --- a/packages/framework/src/events.test.ts +++ b/packages/framework/src/events.test.ts @@ -1,6 +1,7 @@ import { strict as assert } from 'node:assert' import { test } from 'node:test' import { + OPEN_LOOP_MODES, SESSION_ID_PLACEHOLDER, formatFrameworkEvent, hasSessionIdPlaceholder, @@ -35,6 +36,17 @@ test('formatFrameworkEvent renders a preview line', () => { ) }) +test('formatFrameworkEvent renders modes as checkboxes (#272)', () => { + assert.equal( + formatFrameworkEvent({ kind: 'modes', all: ['autopilot', 'technical'], active: ['technical'] }), + ' modes: [ ] autopilot [x] technical', + ) +}) + +test('OPEN_LOOP_MODES is the canonical mode ordering', () => { + assert.deepEqual([...OPEN_LOOP_MODES], ['autopilot', 'technical']) +}) + test('formatFrameworkEvent distinguishes finished / stopped / failed (#218)', () => { assert.equal(formatFrameworkEvent({ kind: 'end', ok: true }), '✓ finished') assert.equal(formatFrameworkEvent({ kind: 'end', ok: false, stopped: true }), '■ stopped') diff --git a/packages/framework/src/events.ts b/packages/framework/src/events.ts index da558bc..d206def 100644 --- a/packages/framework/src/events.ts +++ b/packages/framework/src/events.ts @@ -30,6 +30,13 @@ export type FrameworkEvent = | { kind: 'preview'; url: string; command: string } /** A framework-level log line. */ | { kind: 'log'; message: string } + /** + * The run's active Open Loop modes (#272), emitted once when a domain preset is + * in effect. `all` is every mode the run knows about (stable order); `active` is + * the subset switched on for this run. The dashboard renders them as read-only + * checkboxes so the policy driving the build is visible. + */ + | { kind: 'modes'; all: readonly string[]; active: readonly string[] } /** * The run finished. `ok` is false when it threw. `stopped` marks the common, * non-error case where the user interrupted it (the dashboard Stop button / @@ -50,6 +57,10 @@ export function formatFrameworkEvent(event: FrameworkEvent): string { return `▶ your app is running at ${event.url}` case 'log': return ` ${event.message}` + case 'modes': { + const shown = event.all.map(m => `${event.active.includes(m) ? '[x]' : '[ ]'} ${m}`).join(' ') + return ` modes: ${shown}` + } case 'driver': return formatDriverEvent(event.event) case 'bootstrap': @@ -102,6 +113,13 @@ function truncate(text: string, max = 100): string { return flat.length > max ? flat.slice(0, max - 1) + '…' : flat } +/** + * The Open Loop modes a run can activate, in the order the dashboard shows them. + * The single source of truth for both the mode checkboxes (#272) and the + * meta-select router's validation ({@link import('./meta-select.js').META_SELECT_MODES}). + */ +export const OPEN_LOOP_MODES = ['autopilot', 'technical'] as const + /** * The placeholder a `--session-link` template uses for the real session id. * Remote Control does not expose a URL you can build from a session id, so we diff --git a/packages/framework/src/index.ts b/packages/framework/src/index.ts index 78de5db..796d0ba 100644 --- a/packages/framework/src/index.ts +++ b/packages/framework/src/index.ts @@ -69,6 +69,7 @@ export { resolveSessionLink, hasSessionIdPlaceholder, SESSION_ID_PLACEHOLDER, + OPEN_LOOP_MODES, } from './events.js' export { startDashboard, dashboardHtml, type Dashboard, type DashboardOptions } from './dashboard/index.js' export { diff --git a/packages/framework/src/meta-select.ts b/packages/framework/src/meta-select.ts index 1effeee..b85ee98 100644 --- a/packages/framework/src/meta-select.ts +++ b/packages/framework/src/meta-select.ts @@ -1,5 +1,6 @@ import type { DomainPreset } from '@gemstack/ai-autopilot' import type { DriverSession } from './driver/index.js' +import { OPEN_LOOP_MODES } from './events.js' /** * AI meta-select (#204, Rom's "meta-meta prompt"): before a run, infer which Open @@ -16,7 +17,7 @@ import type { DriverSession } from './driver/index.js' */ /** The Open Loop modes a run can activate. Mirrors the CLI's `--autopilot` / `--technical`. */ -export const META_SELECT_MODES = ['autopilot', 'technical'] as const +export const META_SELECT_MODES = OPEN_LOOP_MODES /** The system framing for the meta-select turn: a tiny, fast classifier, not a builder. */ export const META_SELECT_SYSTEM = diff --git a/packages/framework/src/preset-run.test.ts b/packages/framework/src/preset-run.test.ts index 98b6dba..1ff07ca 100644 --- a/packages/framework/src/preset-run.test.ts +++ b/packages/framework/src/preset-run.test.ts @@ -51,10 +51,30 @@ test('a domain preset frames the run and is narrated', async () => { assert.ok(log, 'domain preset is logged') assert.match((log as { message: string }).message, /modes: technical/) + // The active modes are emitted for the dashboard checkboxes (#272): both known + // modes, with only the active one on. + const modes = events.find(e => e.kind === 'modes') + assert.deepEqual(modes, { kind: 'modes', all: ['autopilot', 'technical'], active: ['technical'] }) + // The run still completes normally. assert.equal(result.productionGrade, true) }) +test('no modes event is emitted without a domain preset (#272)', async () => { + const events: FrameworkEvent[] = [] + await runFramework({ + intent: FAKE_INTENT, + driver: fakeDriver(), + cwd: '/tmp/ws', + signals: FAKE_SIGNALS, + onEvent: e => events.push(e), + }) + assert.equal( + events.some(e => e.kind === 'modes'), + false, + ) +}) + test('the preset loop is materialized against the driver and runs its chain', async () => { const { driver } = recordingDriver() const { loop } = await runFramework({ diff --git a/packages/framework/src/run.ts b/packages/framework/src/run.ts index 8857a46..c7529b6 100644 --- a/packages/framework/src/run.ts +++ b/packages/framework/src/run.ts @@ -28,7 +28,7 @@ import { import type { Driver, DriverEvent, DriverSession } from './driver/index.js' import { memoryFraming, type LoadedMemory } from './memory.js' import { decideDeploy, deployWith, domainLoopChecklist, driverArchitect, driverBuild, driverChecklist, driverImprove, driverLoopPrompts } from './steps.js' -import { hasSessionIdPlaceholder, resolveSessionLink, type FrameworkEvent } from './events.js' +import { hasSessionIdPlaceholder, OPEN_LOOP_MODES, resolveSessionLink, type FrameworkEvent } from './events.js' /** * The framework's default full-fledged pass budget. Higher than ai-autopilot's @@ -289,6 +289,8 @@ export async function runFramework(opts: RunFrameworkOptions): Promise