diff --git a/.changeset/onboarding-checklist-and-settings-page.md b/.changeset/onboarding-checklist-and-settings-page.md new file mode 100644 index 000000000..4f0c9a282 --- /dev/null +++ b/.changeset/onboarding-checklist-and-settings-page.md @@ -0,0 +1,11 @@ +--- +"@gemstack/the-framework": minor +--- + +Onboarding checklist and a settings page (#958). + +The Overview gains an **Onboarding** section: add a project (one click for the directory the server runs in), fill the AI task queue, fill `tickets/` (with an "Import tickets from GitHub" button), add the Discord bot, turn on browser notifications, and add Discord notifications. Every step's done-state is derived from a real fact (a registered project, a non-empty queue, a ticket on disk, a granted browser permission, credentials the daemon holds), so a step cannot be ticked by clicking it, and one done outside the dashboard shows up done. It can be dismissed, which hides it only on the Overview. + +Settings now have a page of their own at `/settings`, reachable from the header, collecting what was spread across the header menus: appearance and editor, agent / model / run-on, run options, eco, notifications, and automation. The Onboarding checklist lives there too and is not dismissible, which is what dismissing it on the Overview points you to. + +Supporting changes: `onDashboard`'s per-project rollup carries `hasTickets`, a new `onOnboarding` read offers the server's working directory as a first project (gated on the same wiring as adding projects, so a public host discloses nothing), and `onboardingDismissed` joins the preferences. diff --git a/packages/framework-dashboard/components/DashboardPage.tsx b/packages/framework-dashboard/components/DashboardPage.tsx index 3f9b0eaaa..6fc5f7753 100644 --- a/packages/framework-dashboard/components/DashboardPage.tsx +++ b/packages/framework-dashboard/components/DashboardPage.tsx @@ -8,6 +8,8 @@ import { RunOutcomes } from './RunOutcomes.js' import { Quota } from './Quota.js' import { Card, CardContent, CardHeader, CardTitle } from './ui/card.js' import { usePolled } from '../lib/use-async.js' +import { usePreferences } from '../lib/preferences.js' +import { OnboardingChecklist } from './OnboardingChecklist.js' import { cn } from '../lib/utils.js' import { formatDateTime, formatRelative } from '../lib/format-date.js' import { ScrollArea } from './ui/scroll-area.js' @@ -25,6 +27,9 @@ export function DashboardPage({ interventions: Intervention[] }) { const { value: data } = usePolled(onDashboard, null, 5000, []) + // Dismissing only hides it here (#958); the settings page keeps it, which is what the + // dismiss control says. + const onboardingDismissed = usePreferences().onboardingDismissed ?? false return ( @@ -34,6 +39,8 @@ export function DashboardPage({

Everything the agent is doing, across every project.

+ {!onboardingDismissed && } + {data === null ? ( diff --git a/packages/framework-dashboard/components/DiscordBotDialog.tsx b/packages/framework-dashboard/components/DiscordBotDialog.tsx new file mode 100644 index 000000000..9179f4ed9 --- /dev/null +++ b/packages/framework-dashboard/components/DiscordBotDialog.tsx @@ -0,0 +1,69 @@ +import { Dialog } from './ui/dialog.js' +import { Button } from './ui/button.js' +import { usePreferences, updatePreferences, discordBotEnabled } from '../lib/preferences.js' + +// The Discord bot's explainer and setup dialog (#958). +// +// The description is exported because it is shown twice on purpose: once on the Onboarding +// checklist row, and again here — the dialog is also reachable without the checklist, and a +// modal that only says "enable this" explains nothing to whoever opens it that way. +// +// The token itself stays a daemon environment variable. This dialog explains and toggles; it +// deliberately does not take a token, which would mean the dashboard storing a secret. + +/** What the Discord bot is, in one line. Shown on the checklist row and again inside the dialog. */ +export const DISCORD_BOT_DESCRIPTION = + 'Brings sessions into Discord: it posts what each session is doing, and lets you start and steer sessions by replying — so you can follow work with no dashboard open.' + +export function DiscordBotDialog({ + open, + onOpenChange, + configured, +}: { + open: boolean + onOpenChange: (open: boolean) => void + /** Whether the daemon actually has a bot token. The toggle is a preference; this is the capability. */ + configured: boolean +}) { + const enabled = discordBotEnabled(usePreferences()) + + return ( + +
+

{DISCORD_BOT_DESCRIPTION}

+ + {configured ? ( +

+ The daemon has a bot token, so the bot can run. Use the toggle below to turn it on or off. +

+ ) : ( +
+

Not configured yet

+
    +
  1. Create a Discord application with a bot, and invite it to your server.
  2. +
  3. + Set DISCORD_BOT_TOKEN in the + environment the daemon runs in. +
  4. +
  5. Restart the daemon so it reads the token.
  6. +
+
+ )} + +
+ + {enabled ? 'Bot enabled' : 'Bot disabled'} + + {configured + ? 'Whether Discord messages may start and steer sessions.' + : 'Can be turned on now; it starts working once the token is set.'} + + + +
+
+
+ ) +} diff --git a/packages/framework-dashboard/components/NotificationsMenu.tsx b/packages/framework-dashboard/components/NotificationsMenu.tsx index a4f382658..9a2e3a5b6 100644 --- a/packages/framework-dashboard/components/NotificationsMenu.tsx +++ b/packages/framework-dashboard/components/NotificationsMenu.tsx @@ -1,5 +1,5 @@ -import { useSyncExternalStore } from 'react' import { Bell, BellOff } from 'lucide-react' +import { useNotificationPermission } from '../lib/notification-permission.js' import { usePreferences, updatePreferences, notificationsEnabled, discordEnabled, discordBotEnabled, newActivityEnabled, humanInterventionEnabled } from '../lib/preferences.js' import { onNotifyChannels, type NotifyChannels } from '../server/preferences.telefunc.js' import { useLoaded } from '../lib/use-async.js' @@ -23,22 +23,6 @@ import { // the header control that writes them. The Discord *bot* (#680) sits in its own "Chat" group // rather than under a delivery method: it is the one control here that takes messages in. -/** Subscribe to `Notification.permission` changes where the browser supports it, else 'unsupported'. */ -function usePermission(): NotificationPermission | 'unsupported' { - return useSyncExternalStore( - subscribePermission, - () => (typeof Notification === 'undefined' ? 'unsupported' : Notification.permission), - () => 'unsupported', - ) -} - -function subscribePermission(onChange: () => void): () => void { - // No permission-change event fires on every browser; the value also changes right after our own - // requestPermission() resolves (which re-renders anyway). Poll lightly as a backstop. - const timer = setInterval(onChange, 3000) - return () => clearInterval(timer) -} - export function NotificationsMenu() { const preferences = usePreferences() const browser = notificationsEnabled(preferences) @@ -46,7 +30,7 @@ export function NotificationsMenu() { const activity = newActivityEnabled(preferences) const needsYou = humanInterventionEnabled(preferences) const discordBot = discordBotEnabled(preferences) - const permission = usePermission() + const permission = useNotificationPermission() const browserSupported = permission !== 'unsupported' const blocked = permission === 'denied' // Whether the daemon can actually deliver on Discord (#948): the toggle is a preference, diff --git a/packages/framework-dashboard/components/OnboardingChecklist.tsx b/packages/framework-dashboard/components/OnboardingChecklist.tsx new file mode 100644 index 000000000..a9ed6792c --- /dev/null +++ b/packages/framework-dashboard/components/OnboardingChecklist.tsx @@ -0,0 +1,235 @@ +import { useState } from 'react' +import type { DashboardData, OnboardingSuggestion } from '@gemstack/the-framework' +import { presets } from '@gemstack/the-framework/client' +import { Check, Circle, X } from 'lucide-react' +import { onDashboard } from '../server/reads.telefunc.js' +import { onOnboarding, sendAddProject } from '../server/projects.telefunc.js' +import { onNotifyChannels, type NotifyChannels } from '../server/preferences.telefunc.js' +import { usePolled, useLoaded } from '../lib/use-async.js' +import { usePreferences, updatePreferences, notificationsEnabled } from '../lib/preferences.js' +import { useNotificationPermission } from '../lib/notification-permission.js' +import { useStartRun } from '../lib/use-start-run.js' +import { AddProjectPanel } from './AddProjectPanel.js' +import { DiscordBotDialog, DISCORD_BOT_DESCRIPTION } from './DiscordBotDialog.js' +import { Button } from './ui/button.js' +import { Card, CardContent, CardHeader, CardTitle } from './ui/card.js' + +// The Onboarding checklist (#958): the integrations a new install needs, each with the state it +// is actually in rather than a static list to read past. +// +// Every "done" is derived from a real fact — a registered project, a non-empty queue, a ticket on +// disk, a granted browser permission, a daemon that holds the Discord credentials — so a step +// cannot be ticked by clicking it, and a step done outside the dashboard shows up ticked anyway. +// +// It renders in two places: the Overview, where it can be dismissed, and the settings page, where +// it cannot — that is what dismissing it promises you can come back to. + +/** One checklist row: what it is, whether it is done, and what to do about it. */ +interface Step { + key: string + label: string + description: string + done: boolean + /** The action(s) offered while it is not done. */ + action?: React.ReactNode +} + +export function OnboardingChecklist({ + dismissible = false, + onSelectProject, +}: { + /** The Overview offers to hide it; the settings page always shows it. */ + dismissible?: boolean + onSelectProject?: ((id: string) => void) | undefined +}) { + // Slower than the Overview's own 5s poll: onboarding state changes at human speed, and this + // read fans out over every project to answer the tickets question. + const { value: data, reload } = usePolled(onDashboard, null, 10_000, []) + const { value: suggestion, reload: reloadSuggestion } = usePolled(onOnboarding, null, 30_000, []) + const channels = useLoaded(onNotifyChannels, null, []) + const preferences = usePreferences() + const permission = useNotificationPermission() + const { start, busy: starting, error: startError } = useStartRun() + + const [addingProject, setAddingProject] = useState(false) + const [addingCwd, setAddingCwd] = useState(false) + const [addError, setAddError] = useState(null) + const [discordOpen, setDiscordOpen] = useState(false) + + // The project onboarding acts on: the one this server runs in when it is registered, else the + // only/most recent one. Onboarding is a first-run flow, so there is rarely a second candidate. + const targetProjectId = suggestion?.cwdProjectId ?? data?.projects[0]?.projectId ?? null + + const projectCount = data?.totals.projects ?? 0 + const hasTickets = data?.projects.some(p => p.hasTickets) ?? false + const browserGranted = permission === 'granted' && notificationsEnabled(preferences) + + const addCwd = async () => { + if (!suggestion?.cwd) return + setAddingCwd(true) + setAddError(null) + const result = await sendAddProject(suggestion.cwd, false).catch(() => ({ ok: false as const, error: 'Could not reach the daemon.' })) + setAddingCwd(false) + if (!result.ok) { + setAddError(result.error) + return + } + reload() + reloadSuggestion() + } + + const enableBrowserNotifications = () => { + updatePreferences({ notifyBrowser: true }) + // Asking for permission must ride this user gesture. + if (permission === 'default') void Notification.requestPermission() + } + + const importTickets = async () => { + if (!targetProjectId) return + const started = await start(targetProjectId, presets.importTickets.render(), 'prompt', {}) + // The import runs as a session; follow it, since that is where its output appears. + if (started) onSelectProject?.(targetProjectId) + } + + const steps: Step[] = [ + { + key: 'project', + label: 'Add a project', + description: 'A project is a git repo The Framework may work in.', + done: projectCount > 0, + action: ( +
+
+ {suggestion?.cwd && !suggestion.cwdProjectId && ( + + )} + +
+ {addError && {addError}} +
+ ), + }, + { + key: 'todos', + label: 'Populate the queue of AI tasks', + description: + 'TODO_AGENTS.md is the queue: each unchecked item is work the agent picks up on its own, so a filled queue is what lets it keep going without you.', + done: (data?.totals.openTodos ?? 0) > 0, + }, + { + key: 'tickets', + label: 'Populate tickets/', + description: + 'tickets/ holds the bigger things to work on, in the repo. The agent plans and spikes from them, and they are the input the queue is filled from.', + done: hasTickets, + action: ( +
+ + {!targetProjectId && Add a project first} + {startError && {startError}} +
+ ), + }, + { + key: 'discord-bot', + label: 'Add the Discord bot', + description: DISCORD_BOT_DESCRIPTION, + done: channels?.discordBot ?? false, + action: ( + + ), + }, + { + key: 'browser-notification', + label: 'Add browser notifications', + description: 'Desktop pings while the dashboard is open, so a session waiting on you does not sit unnoticed.', + done: browserGranted, + action: + permission === 'denied' ? ( + Blocked in your browser settings + ) : permission === 'unsupported' ? ( + Not supported by this browser + ) : ( + + ), + }, + { + key: 'discord-notification', + label: 'Add Discord notifications', + description: 'Delivers the same notifications to Discord, so they reach you with no dashboard open.', + done: channels?.discordWebhook ?? false, + action: ( + + Set DISCORD_WEBHOOK on the daemon, then restart it + + ), + }, + ] + + const doneCount = steps.filter(s => s.done).length + + return ( + + +
+ Onboarding +

+ {doneCount} of {steps.length} set up. +

+
+ {dismissible && ( + + )} +
+ +
    + {steps.map(step => ( +
  • +
    + {step.done ? ( + + ) : ( + + )} +
    +

    {step.label}

    +

    {step.description}

    +
    +
    + {!step.done && step.action &&
    {step.action}
    } +
  • + ))} +
+
+ + {addingProject && ( + { + reload() + reloadSuggestion() + }} + onClose={() => setAddingProject(false)} + /> + )} + +
+ ) +} diff --git a/packages/framework-dashboard/components/SettingsPage.tsx b/packages/framework-dashboard/components/SettingsPage.tsx new file mode 100644 index 000000000..7f694713d --- /dev/null +++ b/packages/framework-dashboard/components/SettingsPage.tsx @@ -0,0 +1,346 @@ +import type { ReactNode } from 'react' +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 { Card, CardContent, CardHeader, CardTitle } from './ui/card.js' +import { Checkbox } from './ui/checkbox.js' +import { ScrollArea } from './ui/scroll-area.js' + +// The settings page (#958): every setting in one place, and the Onboarding checklist. +// +// Until now settings were spread across the header's menus — the composer's gear, the bell, the +// theme toggle — which is fine while you are running something and useless when you are looking +// for one. This is the page the Overview's "you can resume the onboarding on the settings page" +// points at, so the checklist lives here too and is not dismissible. +// +// Everything here writes the GLOBAL tier. `usePreferences`/`updatePreferences` scope themselves to +// the project in the URL, and this route has none (#958 reserves `/settings`), so a value set here +// is the default rather than one repo's override — which is what a settings page should mean. The +// per-project overrides stay where the run is configured, in the launcher's gear. + +export function SettingsPage({ onSelectProject }: { onSelectProject?: ((id: string) => void) | undefined; onDone?: () => void }) { + const preferences = usePreferences() + const editors = useDetectedEditors() + const theme = themePreference(preferences) + + return ( + +
+
+

Settings

+

+ Your defaults, everywhere. A project can still override its run options from the launcher. +

+
+ + + +
+ updatePreferences({ theme: value as ThemePreference })} + /> + ({ value: e.bin, label: e.label })), + ]} + onChange={value => updatePreferences({ editor: value })} + /> +
+ +
+ ({ value: a, label: AGENT_LABELS[a] }))} + onChange={value => updatePreferences({ agent: value })} + /> + updatePreferences({ model: value })} + /> + updatePreferences({ target: value as 'local' | 'actions' })} + /> +
+ +
+ updatePreferences({ transparent: next })} + /> + updatePreferences({ autopilot: next })} + /> + updatePreferences({ technical: next })} + /> + updatePreferences({ vanilla: next })} + /> + updatePreferences({ onBeforeMergeableQuality: next })} + /> + updatePreferences({ browser: next })} + /> +
+ +
+ updatePreferences({ eco: next })} + /> + updatePreferences({ ecoPlanning: next })} + /> + updatePreferences({ ecoResearch: next })} + /> + updatePreferences({ ecoMaintenance: next })} + /> +
+ +
+ updatePreferences({ notifyBrowser: next })} + /> + updatePreferences({ notifyDiscord: next })} + /> + updatePreferences({ notifyHumanIntervention: next })} + /> + updatePreferences({ notifyNewActivity: next })} + /> + updatePreferences({ discordBot: next })} + /> +
+ +
+ updatePreferences({ autoPm: next })} + /> + updatePreferences({ autoSpendOffset: value })} + /> +
+
+
+ ) +} + +function Section({ title, description, children }: { title: string; description?: string; children: ReactNode }) { + return ( + + + {title} + {description &&

{description}

} +
+ +
{children}
+
+
+ ) +} + +function Row({ label, description, control }: { label: string; description: string; control: ReactNode }) { + return ( +
+
+

{label}

+

{description}

+
+
{control}
+
+ ) +} + +function ToggleRow({ + label, + description, + checked, + onChange, +}: { + label: string + description: string + checked: boolean + onChange: (next: boolean) => void +}) { + return ( + onChange(next === true)} aria-label={label} />} + /> + ) +} + +function SelectRow({ + label, + description, + value, + options, + onChange, +}: { + label: string + description: string + value: string + options: { value: string; label: string }[] + onChange: (next: string) => void +}) { + return ( + onChange(e.target.value)} + aria-label={label} + className="rounded-md border border-border bg-background px-2 py-1 text-sm" + > + {options.map(o => ( + + ))} + + } + /> + ) +} + +function TextRow({ + label, + description, + value, + placeholder, + onChange, +}: { + label: string + description: string + value: string + placeholder?: string + onChange: (next: string) => void +}) { + return ( + onChange(e.target.value)} + aria-label={label} + className="w-48 rounded-md border border-border bg-background px-2 py-1 text-sm" + /> + } + /> + ) +} + +function NumberRow({ + label, + description, + value, + onChange, +}: { + label: string + description: string + value: number + onChange: (next: number) => void +}) { + return ( + onChange(Number(e.target.value) || 0)} + 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/notification-permission.ts b/packages/framework-dashboard/lib/notification-permission.ts new file mode 100644 index 000000000..9a7626878 --- /dev/null +++ b/packages/framework-dashboard/lib/notification-permission.ts @@ -0,0 +1,21 @@ +import { useSyncExternalStore } from 'react' + +// The browser's notification permission as a subscribable value (#627). It lives here rather than +// in the notifications menu because the Onboarding checklist (#958) asks the same question, and a +// second copy of the polling backstop would be a second thing to keep right. + +/** Subscribe to `Notification.permission` where the browser supports it, else 'unsupported'. */ +export function useNotificationPermission(): NotificationPermission | 'unsupported' { + return useSyncExternalStore( + subscribePermission, + () => (typeof Notification === 'undefined' ? 'unsupported' : Notification.permission), + () => 'unsupported', + ) +} + +function subscribePermission(onChange: () => void): () => void { + // No permission-change event fires on every browser; the value also changes right after our own + // requestPermission() resolves (which re-renders anyway). Poll lightly as a backstop. + const timer = setInterval(onChange, 3000) + return () => clearInterval(timer) +} diff --git a/packages/framework-dashboard/lib/route.test.ts b/packages/framework-dashboard/lib/route.test.ts index 36b7ebb59..db74803d2 100644 --- a/packages/framework-dashboard/lib/route.test.ts +++ b/packages/framework-dashboard/lib/route.test.ts @@ -27,6 +27,20 @@ describe('parseRoute', () => { expect(parseRoute('/a%20b/c%2Fd')).toEqual({ projectId: 'a b', runId: 'c/d' }) expect(parseRoute('/%E0%A4%A')).toEqual({ projectId: '%E0%A4%A', runId: null }) }) + + it('reads the settings page, which belongs to no project (#958)', () => { + expect(parseRoute('/settings')).toEqual({ view: 'settings', projectId: null, runId: null }) + // Trailing slash and stray segments are the same page, like every other route. + expect(parseRoute('/settings/')).toEqual({ view: 'settings', projectId: null, runId: null }) + expect(parseRoute('/settings/anything')).toEqual({ view: 'settings', projectId: null, runId: null }) + }) + + it('leaves every other first segment a project, so only the reserved word is taken (#958)', () => { + // A generated project id is `-`, so it can never be the bare reserved word — + // but anything merely starting with it still has to route to a project. + expect(parseRoute('/settings-a1b2')).toEqual({ projectId: 'settings-a1b2', runId: null }) + expect(parseRoute('/my-settings')).toEqual({ projectId: 'my-settings', runId: null }) + }) }) describe('formatRoute', () => { @@ -44,12 +58,18 @@ describe('formatRoute', () => { expect(formatRoute({ projectId: 'a b', runId: 'c/d' })).toBe('/a%20b/c%2Fd') }) + it('writes the settings page, and it outranks a stale project id (#958)', () => { + expect(formatRoute({ view: 'settings', projectId: null, runId: null })).toBe('/settings') + expect(formatRoute({ view: 'settings', projectId: 'my-repo', runId: 'run-1' })).toBe('/settings') + }) + it('round-trips', () => { for (const route of [ { projectId: null, runId: null }, { projectId: 'my-repo', runId: null }, { projectId: 'my-repo', runId: 'run-1' }, { projectId: 'a b', runId: 'c/d' }, + { view: 'settings' as const, projectId: null, runId: null }, ]) { expect(parseRoute(formatRoute(route))).toEqual(route) } diff --git a/packages/framework-dashboard/lib/route.ts b/packages/framework-dashboard/lib/route.ts index 852f68cbd..01728fc1b 100644 --- a/packages/framework-dashboard/lib/route.ts +++ b/packages/framework-dashboard/lib/route.ts @@ -9,8 +9,18 @@ // Both ids are URL-safe by construction (the registry derives a project id from its path, a run id // from its start time), so the segments are still encoded/decoded — a URL typed by hand is input. +/** + * The one first segment that names a view rather than a project (#958). + * + * Safe to reserve because a project id is never this word: the registry builds one as + * `-`, so every real id carries a `-` suffix. + */ +export const SETTINGS_SEGMENT = 'settings' + /** What the dashboard is looking at, as carried by the URL. */ export interface Route { + /** A top-level view belonging to no project (#958). Absent on the Overview/project/session axis. */ + view?: 'settings' /** The selected project, or null for the Overview. */ projectId: string | null /** The selected session (run id), or null for the project's home/launcher. */ @@ -20,12 +30,14 @@ export interface Route { /** Read the route out of a path. Anything unparseable is the Overview, and extra segments are ignored. */ export function parseRoute(pathname: string): Route { const [projectId, runId] = pathname.split('/').filter(Boolean).map(decodeSegment) + if (projectId === SETTINGS_SEGMENT) return { view: 'settings', projectId: null, runId: null } if (!projectId) return { projectId: null, runId: null } return { projectId, runId: runId ?? null } } /** The path for a route — the inverse of {@link parseRoute}. */ -export function formatRoute({ projectId, runId }: Route): string { +export function formatRoute({ view, projectId, runId }: Route): string { + if (view === 'settings') return `/${SETTINGS_SEGMENT}` if (!projectId) return '/' const project = encodeURIComponent(projectId) return runId ? `/${project}/${encodeURIComponent(runId)}` : `/${project}` diff --git a/packages/framework-dashboard/pages/index/+Page.tsx b/packages/framework-dashboard/pages/index/+Page.tsx index b6df5ee12..5aee78ce2 100644 --- a/packages/framework-dashboard/pages/index/+Page.tsx +++ b/packages/framework-dashboard/pages/index/+Page.tsx @@ -11,6 +11,7 @@ import { Button } from '../../components/ui/button.js' import { RunHistory } from '../../components/RunHistory.js' import { ProjectHome } from '../../components/ProjectHome.js' import { DashboardPage } from '../../components/DashboardPage.js' +import { SettingsPage } from '../../components/SettingsPage.js' import { RunView } from '../../components/RunView.js' import { runLabel } from '../../lib/run-label.js' import { RightRail } from '../../components/RightRail.js' @@ -28,7 +29,7 @@ import { useDocumentTitle } from '../../lib/document-title.js' import { useWorking } from '../../lib/use-working.js' import { useFavicon } from '../../lib/favicon.js' import { useDaemonHealth } from '../../lib/use-daemon-health.js' -import { TriangleAlert } from 'lucide-react' +import { TriangleAlert, Settings } from 'lucide-react' /** Stable, so `files` keeps one identity while no project is selected. */ const EMPTY_FILES: string[] = [] @@ -58,7 +59,7 @@ const EMPTY_ACTIVITY: Activity[] = [] // what the remembered-project state (#475) was for. export default function Page() { const { route, go } = useRoute() - const { projectId, runId } = route + const { view, projectId, runId } = route // A just-started run: bump the tick so the Sessions rail shows an optimistic "starting…" row // with the typed prompt at once, before the spawned process writes its run.json. `id` is the @@ -180,6 +181,13 @@ export default function Page() { go({ projectId: null, runId: null }) } + // The settings page (#958): every setting in one place, plus the Onboarding checklist, which is + // where dismissing it from the Overview says you can pick it back up. + const showSettings = () => { + setAdopting(false) + go({ view: 'settings', projectId: null, runId: null }) + } + // The live run feed is owned here so both the main view and the right rail's choice gates // (#440) read one shared Telefunc Channel. Hooks run before the relay early return below. // The run whose feed and controls are in play is simply the one in the URL; in the no-id @@ -213,6 +221,7 @@ export default function Page() { // run streams its own feed and is steered by its own id (#749). const selectedRun = runId ? runs.find(run => run.id === runId) : undefined const renderMain = () => { + if (view === 'settings') return if (!projectId) return if (unknownProject) return ( @@ -308,6 +317,9 @@ export default function Page() { + {!healthy && ( diff --git a/packages/framework-dashboard/server/projects.telefunc.ts b/packages/framework-dashboard/server/projects.telefunc.ts index e5aac9747..64e02c2c3 100644 --- a/packages/framework-dashboard/server/projects.telefunc.ts +++ b/packages/framework-dashboard/server/projects.telefunc.ts @@ -4,6 +4,6 @@ // Imported then exported, not re-exported (#1014): telefunc's dev transform appends // `__decorateTelefunction(, ...)` per export, which needs a local binding. An // `export ... from` creates none, so `pnpm dev` died with ` is not defined`. -import { onProjects, sendAddProject } from '@gemstack/the-framework/dashboard-rpc' +import { onProjects, sendAddProject, onOnboarding } from '@gemstack/the-framework/dashboard-rpc' -export { onProjects, sendAddProject } +export { onProjects, sendAddProject, onOnboarding } diff --git a/packages/the-framework/src/dashboard-rpc/index.ts b/packages/the-framework/src/dashboard-rpc/index.ts index 87afb28d3..d155fc37c 100644 --- a/packages/the-framework/src/dashboard-rpc/index.ts +++ b/packages/the-framework/src/dashboard-rpc/index.ts @@ -5,7 +5,7 @@ export { onRuns, onRun, onDocs, onProjectLog, onQueue, onOverview, onInterventions, onActivity, onDashboard, onGithubUrl, onGitStatus, onProjectFiles, onProjectFileStatus, onFileDiff, onRunChanges, onFileContent, onTickets, onRetainedWorktrees, onRunWorktree, onRunHandoff, onSystemPromptUser } from './reads.telefunc.js' export { sendStop, sendChoice, sendMessage, sendStart, sendPreview, onServeTargets, sendStopPreview, onPreviewStatus, sendOpenInApp, sendRemoveWorktree, sendDeleteSession, sendPushBranch, sendOpenPullRequest, sendQueueTicket, type QueueTicketResult } from './control.telefunc.js' export { onEvents } from './events.telefunc.js' -export { onProjects, sendAddProject } from './projects.telefunc.js' +export { onProjects, sendAddProject, onOnboarding } from './projects.telefunc.js' export { onPreferences, savePreferences, diff --git a/packages/the-framework/src/dashboard-rpc/projects.telefunc.ts b/packages/the-framework/src/dashboard-rpc/projects.telefunc.ts index 8f34bc9db..0863811f8 100644 --- a/packages/the-framework/src/dashboard-rpc/projects.telefunc.ts +++ b/packages/the-framework/src/dashboard-rpc/projects.telefunc.ts @@ -1,7 +1,7 @@ import { getContext } from 'telefunc' import { contextProjects } from './context.js' import type { ProjectSummary } from '../dashboard/projects.js' -import type { AddProjectResult } from '../dashboard/types.js' +import type { AddProjectResult, OnboardingSuggestion } from '../dashboard/types.js' import type { DashboardContext } from '../dashboard/telefunc-serve.js' // The Projects sidebar behind the new dashboard (#405): the global registry (#390) the @@ -26,3 +26,18 @@ export async function sendAddProject(path: string, directory: boolean): Promise< if (!trimmed) return { ok: false, error: 'a project path is required' } return addProject(trimmed, directory) } + +/** + * The Onboarding checklist's one server-side fact (#958): the directory this server runs in, + * so the first step can offer "Add {cwd} as project" without the user typing a path. + * + * Gated on the same `addProject` wiring as {@link sendAddProject}. A public host (the relay) + * cannot act on the suggestion anyway, and must not disclose where it runs, so it offers none. + */ +export async function onOnboarding(): Promise { + const { addProject } = getContext() + if (!addProject) return { cwd: null, cwdProjectId: null } + const cwd = process.cwd() + const registered = await contextProjects().list() + return { cwd, cwdProjectId: registered.find(p => p.path === cwd)?.id ?? null } +} diff --git a/packages/the-framework/src/dashboard/dashboard.test.ts b/packages/the-framework/src/dashboard/dashboard.test.ts index 0510bb83d..4ebd69a16 100644 --- a/packages/the-framework/src/dashboard/dashboard.test.ts +++ b/packages/the-framework/src/dashboard/dashboard.test.ts @@ -68,3 +68,15 @@ test('buildDashboard buckets run activity across a 14-day window, oldest-first', // The June run falls outside the window and is not counted anywhere. assert.equal(data.activity.reduce((n, d) => n + d.count, 0), 3) }) + +test('buildDashboard reports per-project ticket presence, which onboarding reads (#958)', async () => { + const data = await buildDashboard([project('a', '/a'), project('b', '/b')], { + runs: async () => [], + queue: async () => [], + tickets: async cwd => cwd === '/a', + now: NOW, + }) + + assert.equal(data.projects.find(p => p.projectId === 'a')!.hasTickets, true) + assert.equal(data.projects.find(p => p.projectId === 'b')!.hasTickets, false) +}) diff --git a/packages/the-framework/src/dashboard/dashboard.ts b/packages/the-framework/src/dashboard/dashboard.ts index 71c0dee13..3cb497de8 100644 --- a/packages/the-framework/src/dashboard/dashboard.ts +++ b/packages/the-framework/src/dashboard/dashboard.ts @@ -1,6 +1,7 @@ import { listRuns, type RunMeta, type RunStatus } from '../store/index.js' import type { ProjectSummary } from './projects.js' import { collectQueue, type ProjectQueue } from './queue.js' +import { hasTickets } from './tickets.js' import { buildOverview, type ActiveRun, type RecentProject, type OverviewDeps } from './overview.js' // The Overview dashboard page (#471): the cross-project rollup that used to live cramped in @@ -21,6 +22,8 @@ export interface ProjectStat { runs: number /** Open TODO items in this project's queue. */ openTodos: number + /** Whether the repo has any ticket in `tickets/` (#958) — presence only, not a count. */ + hasTickets: boolean lastActivityAt?: string } @@ -61,6 +64,8 @@ const ACTIVITY_DAYS = 14 export interface DashboardDeps extends OverviewDeps { /** Archived runs for a project path. Defaults to {@link listRuns} (forgiving of a missing dir). */ runs?: (cwd: string) => Promise + /** Whether a project has tickets (#958). Defaults to {@link hasTickets} (false on any error). */ + tickets?: (cwd: string) => Promise /** The clock, for the activity window. Defaults to `new Date()`. */ now?: () => Date } @@ -80,6 +85,7 @@ function localDateKey(d: Date): string { */ export async function buildDashboard(projects: ProjectSummary[], deps: DashboardDeps = {}): Promise { const listRunsFor = deps.runs ?? (cwd => listRuns(cwd).catch(() => [])) + const hasTicketsFor = deps.tickets ?? (cwd => hasTickets(cwd).catch(() => false)) const now = deps.now ? deps.now() : new Date() // Compute the queue once and hand it to buildOverview so the backlog is read a single time. @@ -117,6 +123,7 @@ export async function buildDashboard(projects: ProjectSummary[], deps: Dashboard running: overview.active.some(a => a.projectId === project.id), runs: runs.length, openTodos: openByProject.get(project.id) ?? 0, + hasTickets: await hasTicketsFor(project.path), ...(project.lastActivityAt ? { lastActivityAt: project.lastActivityAt } : {}), }) } diff --git a/packages/the-framework/src/dashboard/index.ts b/packages/the-framework/src/dashboard/index.ts index 92129538a..e281a3565 100644 --- a/packages/the-framework/src/dashboard/index.ts +++ b/packages/the-framework/src/dashboard/index.ts @@ -1,5 +1,5 @@ export { startDashboard, type Dashboard, type DashboardOptions } from './server.js' -export type { StartRunKind, StartRunOptions, StartRunResult, AddProjectResult, PreviewResult, PreviewStatus, RunWorktree } from './types.js' +export type { StartRunKind, StartRunOptions, StartRunResult, AddProjectResult, OnboardingSuggestion, PreviewResult, PreviewStatus, RunWorktree } from './types.js' export { summarizeProject, defaultProjectsProvider, diff --git a/packages/the-framework/src/dashboard/tickets.test.ts b/packages/the-framework/src/dashboard/tickets.test.ts index bc28b3ebd..a5966f011 100644 --- a/packages/the-framework/src/dashboard/tickets.test.ts +++ b/packages/the-framework/src/dashboard/tickets.test.ts @@ -3,7 +3,7 @@ import { test } from 'node:test' import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { readTickets } from './tickets.js' +import { readTickets, hasTickets } from './tickets.js' async function repo(files: Record = {}): Promise { const cwd = await mkdtemp(join(tmpdir(), 'tf-tickets-')) @@ -94,3 +94,20 @@ test('readTickets ignores non-markdown files (#697)', async () => { const cwd = await repo({ '2026-07-20_thing.md': '# Thing\n', 'notes.txt': 'nope' }) assert.deepEqual((await readTickets(cwd)).map(t => t.file), ['2026-07-20_thing.md']) }) + +test('hasTickets is false with no tickets directory, true with a ticket (#958)', async () => { + assert.equal(await hasTickets(await repo()), false) + assert.equal(await hasTickets(await repo({ '2026-07-20_thing.md': '# Thing\n' })), true) +}) + +test('hasTickets agrees with readTickets: a lone spike or plan is not a ticket (#958)', async () => { + // The onboarding step asks whether `tickets/` is populated; a `.plan.md` with no ticket beside + // it is written *about* a ticket, so answering yes there would tick the step off nothing. + const cwd = await repo({ '2026-07-20_thing.plan.md': '# Plan\n', '2026-07-20_thing.spike.md': '# Spike\n' }) + assert.equal(await hasTickets(cwd), false) + assert.deepEqual(await readTickets(cwd), []) +}) + +test('hasTickets ignores non-markdown files, like readTickets (#958)', async () => { + assert.equal(await hasTickets(await repo({ 'notes.txt': 'nope' })), false) +}) diff --git a/packages/the-framework/src/dashboard/tickets.ts b/packages/the-framework/src/dashboard/tickets.ts index f829f61c6..8d38274be 100644 --- a/packages/the-framework/src/dashboard/tickets.ts +++ b/packages/the-framework/src/dashboard/tickets.ts @@ -27,6 +27,18 @@ const MAX_TICKET_BYTES = 4_000 /** A ticket's siblings, which are not tickets of their own. */ const SIBLING = /\.(plan|spike)\.md$/ +/** + * Whether the project has any ticket at all (#958). + * + * A `readdir` rather than a {@link readTickets} parse: the Onboarding checklist only needs + * presence, and it asks for every project on each dashboard poll, so reading and describing + * every ticket to answer a yes/no would be paid over and over. + */ +export async function hasTickets(cwd: string): Promise { + const names = await readdir(join(cwd, TICKETS_DIR)).catch(() => [] as string[]) + return names.some(name => name.endsWith('.md') && !SIBLING.test(name)) +} + /** * A filename made readable, for a ticket with no heading. The format is * `_.md`, but the tickets imported from GitHub are `-.md`, diff --git a/packages/the-framework/src/dashboard/types.ts b/packages/the-framework/src/dashboard/types.ts index 0b34dae55..056c32547 100644 --- a/packages/the-framework/src/dashboard/types.ts +++ b/packages/the-framework/src/dashboard/types.ts @@ -16,6 +16,20 @@ export type AddProjectResult = | { ok: true; added: number; alreadyActivated: number } | { ok: false; error: string } +/** + * What the Onboarding checklist (#958) needs and no other read carries: the server's own + * working directory, offered as the one-click first project. + * + * Both fields are null where adding projects is not wired (the relay), so a public host + * never discloses its filesystem layout. + */ +export interface OnboardingSuggestion { + /** The server's working directory, or null when it cannot be offered. */ + cwd: string | null + /** The project id for {@link cwd} when it is already registered, else null. */ + cwdProjectId: string | null +} + /** * The dashboard's Global options (#314), posted alongside a Start. Each maps to a * run flag: Autopilot + Technical to modes, Vanilla to removing the built-in diff --git a/packages/the-framework/src/index.ts b/packages/the-framework/src/index.ts index e8ab7a4c7..3ee34e681 100644 --- a/packages/the-framework/src/index.ts +++ b/packages/the-framework/src/index.ts @@ -128,7 +128,7 @@ export { type SweepDeps, type MaintenanceFs, } from './maintenance.js' -export { startDashboard, summarizeProject, defaultProjectsProvider, readDocs, type Dashboard, type DashboardOptions, type StartRunKind, type StartRunResult, type AddProjectResult, type PreviewResult, type PreviewStatus, type RunWorktree, type ProjectSummary, type ProjectsProvider, type SummarizeDeps, type WorkspaceDoc, readTickets, type WorkspaceTicket, type ProjectQueue, type QueueItem, type Overview, type ActiveRun, type RecentProject, type DashboardData, type ProjectStat, type ActivityDay, type GitStatus, type LinkedPr, type FileDiff, type FileChange, type FileContent, type RunHandoff, type HandoffCommit, type HandoffFile, type HandoffResult, buildInterventions, type Intervention, type OpenPr, type PrLister, type InterventionsDeps, buildActivity, activityKey, pickNewActivity, type Activity, type ActivityDeps } from './dashboard/index.js' +export { startDashboard, summarizeProject, defaultProjectsProvider, readDocs, type Dashboard, type DashboardOptions, type StartRunKind, type StartRunResult, type AddProjectResult, type OnboardingSuggestion, type PreviewResult, type PreviewStatus, type RunWorktree, type ProjectSummary, type ProjectsProvider, type SummarizeDeps, type WorkspaceDoc, readTickets, type WorkspaceTicket, type ProjectQueue, type QueueItem, type Overview, type ActiveRun, type RecentProject, type DashboardData, type ProjectStat, type ActivityDay, type GitStatus, type LinkedPr, type FileDiff, type FileChange, type FileContent, type RunHandoff, type HandoffCommit, type HandoffFile, type HandoffResult, buildInterventions, type Intervention, type OpenPr, type PrLister, type InterventionsDeps, buildActivity, activityKey, pickNewActivity, type Activity, type ActivityDeps } from './dashboard/index.js' export { startPreview, detectDevScript, detectServeTargets, parsePreviewUrl, PREVIEW_SCRIPTS, type PreviewHandle, type StartPreviewOptions, type ServeTarget } from './preview.js' export { RunStore, diff --git a/packages/the-framework/src/registry.test.ts b/packages/the-framework/src/registry.test.ts index 714c72ff4..37b91e413 100644 --- a/packages/the-framework/src/registry.test.ts +++ b/packages/the-framework/src/registry.test.ts @@ -221,6 +221,7 @@ test('every boolean preference survives a save; the sanitizer cannot silently dr notifyNewActivity: true, notifyHumanIntervention: true, autoPm: true, + onboardingDismissed: true, } const fs = memFs() await writePreferences(allOn, fs, ENV) diff --git a/packages/the-framework/src/registry.ts b/packages/the-framework/src/registry.ts index e8853f7da..ced76c5af 100644 --- a/packages/the-framework/src/registry.ts +++ b/packages/the-framework/src/registry.ts @@ -124,6 +124,12 @@ export interface Preferences { autoSpendOffset?: number /** User-defined presets (#626): the user's own saved prompts, shown beside the built-in presets. */ customPresets?: CustomPreset[] + /** + * Whether the Overview's Onboarding checklist has been dismissed (#958). Absent = show it, + * so a fresh install is walked through setup; dismissing only hides it on the Overview, and + * the same checklist stays available on the settings page. + */ + onboardingDismissed?: boolean } /** @@ -269,6 +275,7 @@ const BOOLEAN_PREFERENCES: Record = { notifyNewActivity: true, notifyHumanIntervention: true, autoPm: true, + onboardingDismissed: true, } const PREFERENCE_KEYS = Object.keys(BOOLEAN_PREFERENCES) as BooleanPreferenceKey[]