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
11 changes: 11 additions & 0 deletions .changeset/onboarding-checklist-and-settings-page.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions packages/framework-dashboard/components/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -25,6 +27,9 @@ export function DashboardPage({
interventions: Intervention[]
}) {
const { value: data } = usePolled<DashboardData | null>(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 (
<ScrollArea className="min-h-0 flex-1">
Expand All @@ -34,6 +39,8 @@ export function DashboardPage({
<p className="text-sm text-muted-foreground">Everything the agent is doing, across every project.</p>
</div>

{!onboardingDismissed && <OnboardingChecklist dismissible onSelectProject={onSelectProject} />}

<NeedsYou items={interventions} onSelectProject={onSelectProject} />

{data === null ? (
Expand Down
69 changes: 69 additions & 0 deletions packages/framework-dashboard/components/DiscordBotDialog.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange} title="Discord bot">
<div className="flex flex-col gap-3 text-sm">
<p className="text-muted-foreground">{DISCORD_BOT_DESCRIPTION}</p>

{configured ? (
<p className="text-muted-foreground">
The daemon has a bot token, so the bot can run. Use the toggle below to turn it on or off.
</p>
) : (
<div className="flex flex-col gap-2">
<p className="font-medium">Not configured yet</p>
<ol className="list-decimal space-y-1 pl-5 text-muted-foreground">
<li>Create a Discord application with a bot, and invite it to your server.</li>
<li>
Set <code className="rounded bg-muted px-1 py-0.5 text-xs">DISCORD_BOT_TOKEN</code> in the
environment the daemon runs in.
</li>
<li>Restart the daemon so it reads the token.</li>
</ol>
</div>
)}

<div className="flex items-center justify-between gap-3 rounded-md border border-border p-3">
<span className="flex flex-col gap-0.5">
<span>{enabled ? 'Bot enabled' : 'Bot disabled'}</span>
<span className="text-xs text-muted-foreground">
{configured
? 'Whether Discord messages may start and steer sessions.'
: 'Can be turned on now; it starts working once the token is set.'}
</span>
</span>
<Button variant={enabled ? 'outline' : 'default'} size="sm" onClick={() => updatePreferences({ discordBot: !enabled })}>
{enabled ? 'Disable' : 'Enable'}
</Button>
</div>
</div>
</Dialog>
)
}
20 changes: 2 additions & 18 deletions packages/framework-dashboard/components/NotificationsMenu.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -23,30 +23,14 @@ 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)
const discord = discordEnabled(preferences)
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,
Expand Down
235 changes: 235 additions & 0 deletions packages/framework-dashboard/components/OnboardingChecklist.tsx
Original file line number Diff line number Diff line change
@@ -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<DashboardData | null>(onDashboard, null, 10_000, [])
const { value: suggestion, reload: reloadSuggestion } = usePolled<OnboardingSuggestion | null>(onOnboarding, null, 30_000, [])
const channels = useLoaded<NotifyChannels | null>(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<string | null>(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: (
<div className="flex flex-col items-end gap-1">
<div className="flex flex-wrap justify-end gap-2">
{suggestion?.cwd && !suggestion.cwdProjectId && (
<Button size="sm" onClick={addCwd} disabled={addingCwd} title={suggestion.cwd}>
{addingCwd ? 'Adding…' : `Add ${suggestion.cwd} as project`}
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setAddingProject(true)}>
Select &amp; add project directory
</Button>
</div>
{addError && <span className="text-xs text-destructive">{addError}</span>}
</div>
),
},
{
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: (
<div className="flex flex-col items-end gap-1">
<Button size="sm" onClick={importTickets} disabled={!targetProjectId || starting}>
{starting ? 'Starting…' : 'Import tickets from GitHub'}
</Button>
{!targetProjectId && <span className="text-xs text-muted-foreground">Add a project first</span>}
{startError && <span className="text-xs text-destructive">{startError}</span>}
</div>
),
},
{
key: 'discord-bot',
label: 'Add the Discord bot',
description: DISCORD_BOT_DESCRIPTION,
done: channels?.discordBot ?? false,
action: (
<Button size="sm" variant="outline" onClick={() => setDiscordOpen(true)}>
{channels?.discordBot ? 'Bot settings' : 'Set up the bot'}
</Button>
),
},
{
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' ? (
<span className="text-xs text-muted-foreground">Blocked in your browser settings</span>
) : permission === 'unsupported' ? (
<span className="text-xs text-muted-foreground">Not supported by this browser</span>
) : (
<Button size="sm" variant="outline" onClick={enableBrowserNotifications}>
Enable
</Button>
),
},
{
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: (
<span className="text-right text-xs text-muted-foreground">
Set <code className="rounded bg-muted px-1 py-0.5">DISCORD_WEBHOOK</code> on the daemon, then restart it
</span>
),
},
]

const doneCount = steps.filter(s => s.done).length

return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-3">
<div>
<CardTitle>Onboarding</CardTitle>
<p className="text-sm text-muted-foreground">
{doneCount} of {steps.length} set up.
</p>
</div>
{dismissible && (
<Button
variant="ghost"
size="sm"
onClick={() => updatePreferences({ onboardingDismissed: true })}
title="Remove, you can resume the onboarding on the settings page"
aria-label="Remove, you can resume the onboarding on the settings page"
>
<X className="h-4 w-4" aria-hidden />
</Button>
)}
</CardHeader>
<CardContent>
<ul className="divide-y divide-border">
{steps.map(step => (
<li key={step.key} className="flex items-start justify-between gap-4 py-3 first:pt-0 last:pb-0">
<div className="flex min-w-0 items-start gap-3">
{step.done ? (
<Check className="mt-0.5 h-4 w-4 shrink-0 text-[var(--color-primary)]" aria-label="Done" />
) : (
<Circle className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-label="Not done" />
)}
<div className="min-w-0">
<p className={step.done ? 'text-sm text-muted-foreground line-through' : 'text-sm'}>{step.label}</p>
<p className="text-xs text-muted-foreground">{step.description}</p>
</div>
</div>
{!step.done && step.action && <div className="shrink-0">{step.action}</div>}
</li>
))}
</ul>
</CardContent>

{addingProject && (
<AddProjectPanel
onAdded={() => {
reload()
reloadSuggestion()
}}
onClose={() => setAddingProject(false)}
/>
)}
<DiscordBotDialog open={discordOpen} onOpenChange={setDiscordOpen} configured={channels?.discordBot ?? false} />
</Card>
)
}
Loading
Loading