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
5 changes: 5 additions & 0 deletions .changeset/dashboard-preferences.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@gemstack/framework': minor
---

Persist the dashboard's Global options (Autopilot, Technical, Vanilla, Eco) in the same `the-framework.json` as the project list, read and written daemon-side over Telefunc (`onPreferences` / `savePreferences`), so they survive restarts without localStorage (#410). The registry file becomes an object `{ projects, preferences }`; older bare-array files still read and are migrated on the next write.
9 changes: 3 additions & 6 deletions packages/framework-dashboard/components/ChoicePanel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import type { ChoiceRequest } from '@gemstack/framework'
import { sendChoice } from '../server/control.telefunc.js'
import { autopilotOn, setAutopilot } from '../lib/autopilot.js'
import { usePreferences, updatePreferences, autopilotEnabled } from '../lib/preferences.js'
import { Button } from './ui/button.js'
import { cn } from '../lib/utils.js'

Expand All @@ -26,7 +26,7 @@ export function ChoicePanel({
const [checked, setChecked] = useState<Set<string>>(
() => new Set(choice.multi ? choice.options.filter(o => o.default).map(o => o.id) : []),
)
const [autopilot, setAutopilotState] = useState(autopilotOn)
const autopilot = autopilotEnabled(usePreferences())
const [secondsLeft, setSecondsLeft] = useState<number | null>(null)
const [cancelled, setCancelled] = useState(false)

Expand Down Expand Up @@ -95,10 +95,7 @@ export function ChoicePanel({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autopilot, cancelled, busy])

const toggleAutopilot = (on: boolean) => {
setAutopilotState(on)
setAutopilot(on) // keep the Start form's Global option in lockstep (#433)
}
const toggleAutopilot = (on: boolean) => updatePreferences({ autopilot: on }) // shared with the Start form (#410)

return (
<section className="border-b border-border bg-accent/40 p-4">
Expand Down
37 changes: 17 additions & 20 deletions packages/framework-dashboard/components/StartRunForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from '@gemstack/framework/client'
import { sendStart } from '../server/control.telefunc.js'
import { onProjects } from '../server/projects.telefunc.js'
import { autopilotOn, setAutopilot } from '../lib/autopilot.js'
import { usePreferences, updatePreferences, autopilotEnabled } from '../lib/preferences.js'
import { Button } from './ui/button.js'
import { cn } from '../lib/utils.js'

Expand All @@ -33,13 +33,15 @@ export function StartRunForm({ projectId }: { projectId: string }) {
const [error, setError] = useState<string | null>(null)
const [note, setNote] = useState<string | null>(null)

const [autopilot, setAutopilotState] = useState(autopilotOn)
const [technical, setTechnical] = useState(false)
const [vanilla, setVanilla] = useState(false)
const [eco, setEco] = useState(false)
const [ecoPlanning, setEcoPlanning] = useState(false)
const [ecoResearch, setEcoResearch] = useState(false)
const [ecoMaintenance, setEcoMaintenance] = useState(false)
// The Global options persist daemon-side (#410), shared with the choice-gate countdown.
const preferences = usePreferences()
const autopilot = autopilotEnabled(preferences)
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

// Context selector (#439/#314): the agent can reach every registered repo, so ticking a
// subset narrows its focus — the picked paths become one `Context:` line in the system
Expand Down Expand Up @@ -127,11 +129,6 @@ export function StartRunForm({ projectId }: { projectId: string }) {
}
}

const toggleAutopilot = (on: boolean) => {
setAutopilotState(on)
setAutopilot(on) // keep the choice-gate countdown in lockstep (#433)
}

return (
<form onSubmit={submit} className="border-b border-border p-4">
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Start a run</div>
Expand All @@ -155,29 +152,29 @@ export function StartRunForm({ projectId }: { projectId: string }) {

<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
<label className="flex cursor-pointer items-center gap-1.5" title="Auto-accept the recommended choice after a countdown; also relaxes the maintenance stance">
<input type="checkbox" checked={autopilot} onChange={e => toggleAutopilot(e.target.checked)} disabled={busy} /> Autopilot
<input type="checkbox" checked={autopilot} onChange={e => updatePreferences({ autopilot: e.target.checked })} disabled={busy} /> Autopilot
</label>
<label className="flex cursor-pointer items-center gap-1.5" title="Expose technical detail (e.g. tech-stack choices)">
<input type="checkbox" checked={technical} onChange={e => setTechnical(e.target.checked)} disabled={busy} /> Technical control
<input type="checkbox" checked={technical} onChange={e => updatePreferences({ technical: e.target.checked })} disabled={busy} /> Technical control
</label>
<label className="flex cursor-pointer items-center gap-1.5" title="Remove all system prompts: the same as raw Claude Code">
<input type="checkbox" checked={vanilla} onChange={e => setVanilla(e.target.checked)} disabled={busy} /> Vanilla
<input type="checkbox" checked={vanilla} onChange={e => updatePreferences({ vanilla: e.target.checked })} disabled={busy} /> Vanilla
</label>
<label className={cn('flex items-center gap-1.5', ecoDisabled ? 'opacity-40' : 'cursor-pointer')} title="Trim the built-in system prompt to save tokens">
<input type="checkbox" checked={eco && !ecoDisabled} onChange={e => setEco(e.target.checked)} disabled={busy || ecoDisabled} /> Eco
<input type="checkbox" checked={eco && !ecoDisabled} onChange={e => updatePreferences({ eco: e.target.checked })} disabled={busy || ecoDisabled} /> Eco
</label>
</div>

{eco && !ecoDisabled && (
<div className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1.5 pl-4 text-xs text-muted-foreground">
<label className="flex cursor-pointer items-center gap-1.5" title="Drop the planning section, letting the agent plan on its own">
<input type="checkbox" checked={ecoPlanning} onChange={e => setEcoPlanning(e.target.checked)} disabled={busy} /> Auto planning
<input type="checkbox" checked={ecoPlanning} onChange={e => updatePreferences({ ecoPlanning: e.target.checked })} disabled={busy} /> Auto planning
</label>
<label className="flex cursor-pointer items-center gap-1.5" title="Drop the alternatives/variability section">
<input type="checkbox" checked={ecoResearch} onChange={e => setEcoResearch(e.target.checked)} disabled={busy} /> Auto research
<input type="checkbox" checked={ecoResearch} onChange={e => updatePreferences({ ecoResearch: e.target.checked })} disabled={busy} /> Auto research
</label>
<label className="flex cursor-pointer items-center gap-1.5" title="Drop the maintenance section">
<input type="checkbox" checked={ecoMaintenance} onChange={e => setEcoMaintenance(e.target.checked)} disabled={busy} /> Auto maintenance
<input type="checkbox" checked={ecoMaintenance} onChange={e => updatePreferences({ ecoMaintenance: e.target.checked })} disabled={busy} /> Auto maintenance
</label>
</div>
)}
Expand Down
15 changes: 0 additions & 15 deletions packages/framework-dashboard/lib/autopilot.ts

This file was deleted.

67 changes: 67 additions & 0 deletions packages/framework-dashboard/lib/preferences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { useEffect, useSyncExternalStore } from 'react'
import type { Preferences } from '@gemstack/framework'
import { onPreferences, savePreferences } from '../server/preferences.telefunc.js'

// The dashboard's Global options (#410), owned by the daemon and persisted in the same
// `the-framework.json` as the project list — no more localStorage. Loaded once over Telefunc
// and cached in this module so every component (the Start form's toggles + the choice-gate
// countdown) reads one shared value and stays in lockstep: an update writes through to the
// cache, notifies subscribers, and persists daemon-side. Prerender has no daemon, so the
// server snapshot is the empty default and the real values load on the client.

const EMPTY: Preferences = {}
let cache: Preferences | null = null
let loading: Promise<void> | null = null
const listeners = new Set<() => void>()

function notify(): void {
for (const listener of listeners) listener()
}

function subscribe(listener: () => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}

function snapshot(): Preferences {
return cache ?? EMPTY
}

function ensureLoaded(): void {
if (cache || loading) return
loading = onPreferences()
.then(preferences => {
cache = preferences
})
.catch(() => {
cache = {}
})
.finally(() => {
loading = null
notify()
})
}

/**
* Merge a patch into the shared preferences, persist it daemon-side, and notify every
* subscriber so the Start form and the choice gate stay in lockstep. The write-through keeps
* the UI responsive; the `savePreferences` round-trip is best-effort (a failed save is not
* worth surfacing over a checkbox toggle).
*/
export function updatePreferences(patch: Partial<Preferences>): void {
cache = { ...(cache ?? {}), ...patch }
notify()
void savePreferences(cache).catch(() => {})
}

/** The shared user preferences, loaded once from the daemon and kept in sync across components. */
export function usePreferences(): Preferences {
const preferences = useSyncExternalStore(subscribe, snapshot, () => EMPTY)
useEffect(ensureLoaded, [])
return preferences
}

/** Autopilot defaults on (the demo default), matching the old localStorage semantics. */
export function autopilotEnabled(preferences: Preferences): boolean {
return preferences.autopilot ?? true
}
7 changes: 7 additions & 0 deletions packages/framework-dashboard/server/preferences.telefunc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Re-export shim (#410): the user-preferences telefunctions live in @gemstack/framework so the
// daemon serves them in-process, reading/writing the same `the-framework.json` as the registry.
// Keeping this file at `server/preferences.telefunc.ts` means the client bakes the RPC key
// `/server/preferences.telefunc.ts` — the exact key the daemon registers the impls under (see
// framework's dashboard-rpc/register.ts). The telefunc Vite transform turns these named
// re-exports into client RPC stubs.
export { onPreferences, savePreferences } from '@gemstack/framework/dashboard-rpc'
14 changes: 14 additions & 0 deletions packages/framework/src/dashboard-rpc/context.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getContext } from 'telefunc'
import { defaultProjectsProvider, type ProjectsProvider } from '../dashboard/projects.js'
import type { EventsSource } from '../dashboard/telefunc-serve.js'
import type { PreferencesStore } from '../registry.js'

/**
* The {@link ProjectsProvider} a telefunction should read a project id against (#427).
Expand Down Expand Up @@ -31,3 +32,16 @@ export function contextEventsSource(): EventsSource | undefined {
return undefined
}
}

/**
* The user-preferences store on the context, or undefined (#410). The daemon/foreground wire
* the real registry file; a public host (the relay) leaves it unset, so the preferences RPCs
* degrade to a read-only default / a no-op write on a shared host.
*/
export function contextPreferences(): PreferencesStore | undefined {
try {
return getContext<{ preferences?: PreferencesStore }>()?.preferences
} catch {
return undefined
}
}
1 change: 1 addition & 0 deletions packages/framework/src/dashboard-rpc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export { onRuns, onRun, onDocs, onProjectLog, onQueue, onOverview } from './read
export { sendStop, sendChoice, sendStart } from './control.telefunc.js'
export { onEvents } from './events.telefunc.js'
export { onProjects, sendAddProject } from './projects.telefunc.js'
export { onPreferences, savePreferences, type SavePreferencesResult } from './preferences.telefunc.js'
export { registerDashboardTelefunctions, DASHBOARD_TELEFUNC_KEYS } from './register.js'
17 changes: 17 additions & 0 deletions packages/framework/src/dashboard-rpc/preferences.telefunc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { onPreferences, savePreferences } from './preferences.telefunc.js'

// Outside a Telefunc `serve({ context })` there is no preferences store on the context — the
// same situation as the public relay, which never wires one. The RPCs must degrade safely: a
// read falls back to the empty default, and a write reports it is not enabled rather than
// touching the host's home file.

test('onPreferences with no store returns the empty default', async () => {
assert.deepEqual(await onPreferences(), {})
})

test('savePreferences with no store is a not-enabled no-op', async () => {
const result = await savePreferences({ autopilot: false })
assert.deepEqual(result, { ok: false, error: 'preferences are not enabled on this server' })
})
27 changes: 27 additions & 0 deletions packages/framework/src/dashboard-rpc/preferences.telefunc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { contextPreferences } from './context.js'
import type { Preferences } from '../registry.js'

// The user-preferences surface behind the new dashboard (#410): the Global options (Autopilot,
// Technical, Vanilla, Eco + its section drops) the Start form and choice gate share. Persisted
// daemon-side in the same `the-framework.json` as the project list, so they survive restarts
// with no localStorage. The store is threaded through the Telefunc request context by the
// daemon (the real registry file); a public host (the relay) leaves it unwired, so reads fall
// back to defaults and writes report they are not enabled.

/** The outcome of a {@link savePreferences} write. */
export type SavePreferencesResult = { ok: true } | { ok: false; error: string }

/** The user's stored dashboard preferences, or `{}` on a host that has none / does not store them. */
export async function onPreferences(): Promise<Preferences> {
const store = contextPreferences()
if (!store) return {}
return store.read().catch(() => ({}))
}

/** Persist the dashboard preferences (sanitized in the store). No-op-safe on a public host. */
export async function savePreferences(preferences: Preferences): Promise<SavePreferencesResult> {
const store = contextPreferences()
if (!store) return { ok: false, error: 'preferences are not enabled on this server' }
await store.save(preferences)
return { ok: true }
}
6 changes: 5 additions & 1 deletion packages/framework/src/dashboard-rpc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { onRuns, onRun, onDocs, onProjectLog, onQueue, onOverview } from './read
import { sendStop, sendChoice, sendStart } from './control.telefunc.js'
import { onEvents } from './events.telefunc.js'
import { onProjects, sendAddProject } from './projects.telefunc.js'
import { onPreferences, savePreferences } from './preferences.telefunc.js'

// The client bakes each RPC key from the dashboard's source path (relative to its Vite
// root, keeping the `.ts` extension) as `"<telefuncFilePath>:<exportName>"`. Since the
Expand All @@ -14,6 +15,7 @@ export const DASHBOARD_TELEFUNC_KEYS = {
control: '/server/control.telefunc.ts',
events: '/server/events.telefunc.ts',
projects: '/server/projects.telefunc.ts',
preferences: '/server/preferences.telefunc.ts',
} as const

let registered = false
Expand All @@ -28,7 +30,7 @@ export function registerDashboardTelefunctions(appRootDir: string = process.cwd(
registered = true
const reg = (fn: (...args: never[]) => unknown, name: string, key: string): void =>
__decorateTelefunction(fn as never, name, key, appRootDir)
const { reads, control, events, projects } = DASHBOARD_TELEFUNC_KEYS
const { reads, control, events, projects, preferences } = DASHBOARD_TELEFUNC_KEYS
reg(onRuns, 'onRuns', reads)
reg(onRun, 'onRun', reads)
reg(onDocs, 'onDocs', reads)
Expand All @@ -41,4 +43,6 @@ export function registerDashboardTelefunctions(appRootDir: string = process.cwd(
reg(onEvents, 'onEvents', events)
reg(onProjects, 'onProjects', projects)
reg(sendAddProject, 'sendAddProject', projects)
reg(onPreferences, 'onPreferences', preferences)
reg(savePreferences, 'savePreferences', preferences)
}
10 changes: 9 additions & 1 deletion packages/framework/src/dashboard/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
import type { AddressInfo } from 'node:net'
import type { EcoOptions } from '../system-prompt.js'
import type { ProjectsProvider } from './projects.js'
import { registryPreferencesStore, type PreferencesStore } from '../registry.js'
import { serveClientBundle } from './static.js'
import { makeTelefuncMount } from './telefunc-serve.js'

Expand Down Expand Up @@ -36,6 +37,13 @@ export interface DashboardOptions {
* repo under a directory) and register it (the daemon does). Omit to disable adding.
*/
onAddProject?: (path: string, directory: boolean) => Promise<AddProjectResult> | AddProjectResult
/**
* The user-preferences store (#410): the `onPreferences` / `savePreferences` telefunctions
* read/write it through the request context. Defaults to the real registry file (the daemon
* and per-run foreground dashboard both want it); the public relay serves its own mount and
* never wires one, so preferences stay inert there.
*/
preferences?: PreferencesStore
/**
* Serve the new dashboard bundle (#405) from this directory — the prerendered Vike SPA
* (`index.html` + `assets/**`). The daemon also mounts the dashboard's Telefunc surface
Expand Down Expand Up @@ -108,7 +116,7 @@ export function startDashboard(opts: DashboardOptions = {}): Promise<Dashboard>
// registry, byte-identical to the daemon default; the per-run dashboard passes a
// single-project provider, the relay an empty one.
const telefuncMount = clientBundleDir
? makeTelefuncMount(opts.onStart, opts.projects, undefined, opts.onAddProject)
? makeTelefuncMount(opts.onStart, opts.projects, undefined, opts.onAddProject, opts.preferences ?? registryPreferencesStore())
: undefined

const server = createServer((req, res) => {
Expand Down
6 changes: 6 additions & 0 deletions packages/framework/src/dashboard/telefunc-serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Telefunc } from 'telefunc/node'
import { registerDashboardTelefunctions } from '../dashboard-rpc/register.js'
import type { ProjectsProvider } from './projects.js'
import type { FrameworkEvent } from '../events.js'
import type { PreferencesStore } from '../registry.js'
import { isSameOriginRequest, type AddProjectResult, type StartRunKind, type StartRunOptions, type StartRunResult } from './server.js'

/** Wired by the daemon so `sendStart` can reach the daemon's own `startRun` closure. */
Expand Down Expand Up @@ -33,6 +34,9 @@ export interface DashboardContext {
addProject?: AddProjectHandler
projects?: ProjectsProvider
eventsSource?: EventsSource
/** The user-preferences store (#410). The daemon/foreground wire the real registry file;
* a public host (the relay) leaves it unset so `onPreferences`/`savePreferences` are inert. */
preferences?: PreferencesStore
}

let instance: Telefunc | undefined
Expand Down Expand Up @@ -62,6 +66,7 @@ export function makeTelefuncMount(
projects?: ProjectsProvider,
eventsSource?: EventsSource,
addProject?: AddProjectHandler,
preferences?: PreferencesStore,
): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
return async (req, res) => {
if (!isSameOriginRequest(req)) {
Expand All @@ -75,6 +80,7 @@ export function makeTelefuncMount(
...(addProject ? { addProject } : {}),
...(projects ? { projects } : {}),
...(eventsSource ? { eventsSource } : {}),
...(preferences ? { preferences } : {}),
}
// Never let a telefunc failure become an unhandled rejection that kills the daemon:
// telefunc 0.2.22 throws on a bare `GET /_telefunc` (it passes the request as a body,
Expand Down
Loading
Loading