From 6fa6c31703e15e28ff813d4c2fac1da75681a947 Mon Sep 17 00:00:00 2001 From: UltimaPhoenix <2079222+UltimaPhoenix@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:53:12 +0100 Subject: [PATCH] feat(dashboard): first-run and empty states where the user lands /knowledge shows a set-up block while the knowledge table is empty, and one line above the map once it is set up but no lesson exists yet. /lessons with no lesson and no filter shows an example lesson, the diagnostic command for this install and the pacing read from Settings. A filtered-empty list names the filters that hid everything and offers Search all time / Clear every filter. The command comes from core/runtime.ts doctorHint(channel); copy is host-neutral outside the /devcoach:setup hint. ui_home=auto is unchanged. --- CLAUDE.md | 3 +- docs/usage/web-ui.md | 22 +++- src/core/runtime.ts | 19 +++ src/web/app.ts | 20 +++- src/web/views.ts | 229 ++++++++++++++++++++++++++++++------ tests/runtime.test.ts | 29 +++++ tests/web-first-run.test.ts | 202 +++++++++++++++++++++++++++++++ tests/web.test.ts | 36 +++++- 8 files changed, 512 insertions(+), 48 deletions(-) create mode 100644 tests/web-first-run.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 20054bd..97931ef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,8 @@ dev-coach/ │ │ ├── share.ts share-fetch.ts # lesson sharing: payload, code/link/.devcoach.md codecs, parseSharedInput; URL fetch │ │ ├── claude-history.ts # cross-project stack scan of ~/.claude (projects map, manifests, activity, memories) │ │ ├── courses.ts # courses: directory + document validation over the db.ts rows (see Courses below) -│ │ ├── runtime.ts # where devcoach runs from: classifyPath (channel × Node manager × OS), describeRuntime, the stderr startup line +│ │ ├── runtime.ts # where devcoach runs from: classifyPath (channel × Node manager × OS), describeRuntime, the stderr startup line, +│ │ │ # doctorHint (channel → the diagnostic the dashboard's first-run / empty states show) │ ├── mcp/server.ts # McpServer: 25 tools + 11 resources + devcoach_instructions prompt │ ├── cli/commands.ts # Commander dispatcher (35 subcommands: 27 visible + 8 hidden hooks) + term.ts (colours, tables, OSC 8 link()) + open.ts (browser) │ │ # install.ts (install/uninstall/doctor) + mcp-command.ts (resolveCommand, chooseServerCommand, checkServerCommand) diff --git a/docs/usage/web-ui.md b/docs/usage/web-ui.md index 137c638..fa2b5c8 100644 --- a/docs/usage/web-ui.md +++ b/docs/usage/web-ui.md @@ -76,7 +76,17 @@ Changes save immediately as you edit. **View mode**: topic names are clickable links that filter the lessons page to show only lessons for that topic. -**Stats bar** (top of page): `N lessons total · N / max today · N this week`, then whether a lesson is available now — or why not. +**Stats bar** (top of page, once there is a lesson): `N lessons total · N / max today · N this week`, then whether a lesson is available now — or why not. + +**Before the first lesson** the page says what to do next instead of showing a blank map: + +- **Not set up yet** (no topic at all): a *Set up devcoach* block — say *"set up devcoach"* to your + agent (with the Claude Code plugin, `/devcoach:setup`), then keep working. It also gives the one + command that checks the wiring for the way you installed devcoach: `devcoach doctor` for Homebrew + and global npm installs, `/devcoach:setup` under the plugin, `npx -y devcoach doctor` otherwise. +- **Set up, no lesson yet**: the map, with one line above it — the first lesson arrives after real + work in your agent, at most `max_per_day` a day, `min_gap_minutes` apart (both read from + Settings) — and a link to what a lesson looks like. ` (one entry per sender) - Free-text search (also matches the sender of a shared lesson) -Active filters show as chips above the table; when nothing matches you get "No lessons match the -current filters." +Active filters show as chips above the table. When they hide every lesson, the page names them +(*No lessons match 🔍 "kafka" in 🕐 Last 7 days.*) and offers **Search all time** (drops the period +or date range, keeps the rest — shown only when one is set) and **Clear every filter**. + +With no lesson at all and no filter, the page explains how lessons arrive instead: an example of a +lesson (greyed out, so you recognise the shape when the first one comes), the diagnostic command +for your install with a **Copy** button, and the pacing from Settings. A lesson someone shared with +you can be added right away with **+ Import** — it never counts against the daily limit. **Sort:** click the Date, Topic, Title, Level or Feedback column header. Ascending or descending. diff --git a/src/core/runtime.ts b/src/core/runtime.ts index 3f3be1c..83601a2 100644 --- a/src/core/runtime.ts +++ b/src/core/runtime.ts @@ -213,6 +213,25 @@ export function describeRuntime(inputs: RuntimeInputs = {}): RuntimeInfo { }; } +/** How a user checks this install's wiring, and where they type it. */ +export interface DoctorHint { + command: string; + /** A shell, or the Claude Code prompt (the plugin's own command). */ + where: "terminal" | "claude-code"; +} + +/** + * The one diagnostic a page can point at for this install: the plugin has `/devcoach:setup` + * (runtime → server → profile), Homebrew and npm-global put `devcoach` on the PATH, and every + * other channel (npx, the Desktop extension, Gemini, a checkout) has no `devcoach` to call. + */ +export function doctorHint(channel: Channel): DoctorHint { + if (channel === "claude-plugin") return { command: "/devcoach:setup", where: "claude-code" }; + if (channel === "homebrew" || channel === "npm-global") + return { command: "devcoach doctor", where: "terminal" }; + return { command: "npx -y devcoach doctor", where: "terminal" }; +} + /** `npm-global, bound to Node v26.5.0 (nvm)` — the install, and what it depends on. */ export function describeWhere(where: PathClass): string { const parts: string[] = [where.channel]; diff --git a/src/web/app.ts b/src/web/app.ts index 28d60be..f426022 100644 --- a/src/web/app.ts +++ b/src/web/app.ts @@ -12,6 +12,7 @@ import * as db from "../core/db"; import { detectGitUserName } from "../core/git"; import { hasCode, hasHighlighter, highlighterBlock } from "../core/highlighter"; import type { KnowledgeEntry, Lesson, UiHome } from "../core/models"; +import { type Channel, describeRuntime, doctorHint } from "../core/runtime"; import { buildSharePayload, decodeShareCode, @@ -157,10 +158,14 @@ function uiTheme(): string { export interface AppOptions { /** Invoked by POST /shutdown after the response is sent (startUi wires the graceful stop). */ onShutdown?: () => void; + /** How this devcoach was installed (default: the running process) — picks the diagnostic + * command the first-run pages show. */ + channel?: Channel; } export function createApp(opts: AppOptions = {}): Hono { const app = new Hono(); + const doctor = doctorHint(opts.channel ?? describeRuntime().where.channel); // A write refused because a newer devcoach upgraded the database: say so, with both versions, // instead of a bare 500. Everything else keeps Hono's default behaviour. @@ -248,7 +253,8 @@ export function createApp(opts: AppOptions = {}): Hono { allGroups: profile.groups.map((g) => g.name), stats, rateLimit, - maxPerDay: settings.max_per_day, + pacing: { maxPerDay: settings.max_per_day, minGapMinutes: settings.min_gap_minutes }, + doctor, uiTheme: settings.ui_theme, }), ); @@ -410,7 +416,9 @@ export function createApp(opts: AppOptions = {}): Hono { allBranches: db.getDistinctColumn(conn, "branch"), allCommits: db.getDistinctColumn(conn, "commit_hash"), allSharedBy: db.listSharedBy(conn), - theme: db.getSettings(conn).ui_theme, + settings: db.getSettings(conn), + // Only the no-lesson state asks; an empty knowledge table means onboarding never ran. + setUp: total > 0 || Object.keys(db.getAllKnowledge(conn)).length > 0, }; }); @@ -450,7 +458,13 @@ export function createApp(opts: AppOptions = {}): Hono { perPage: PER_PAGE, total: data.total, totalPages: data.totalPages, - uiTheme: data.theme, + uiTheme: data.settings.ui_theme, + setUp: data.setUp, + pacing: { + maxPerDay: data.settings.max_per_day, + minGapMinutes: data.settings.min_gap_minutes, + }, + doctor, }), ); }); diff --git a/src/web/views.ts b/src/web/views.ts index 545a9c2..72b5342 100644 --- a/src/web/views.ts +++ b/src/web/views.ts @@ -4,6 +4,7 @@ import { html, raw } from "hono/html"; import type { HtmlEscapedString } from "hono/utils/html"; import type { CourseWithSteps, KnowledgeEntry, Lesson, Settings } from "../core/models"; +import type { DoctorHint } from "../core/runtime"; import type { SharedLesson } from "../core/share"; type Html = HtmlEscapedString | Promise; @@ -191,6 +192,111 @@ export function layout(o: { `; } +// ── First run and empty states ─────────────────────────────────────────────── +// What a new user sees before the first lesson: pulled by the pages only, never echoed by a hook. +// The copy is host-neutral ("your agent"); Claude Code is named only next to the plugin's command. + +/** The pacing settings, as the empty states describe them. */ +export interface Pacing { + maxPerDay: number; + minGapMinutes: number; +} + +const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`; + +/** "4 hours", "1 hour 30 minutes", "15 minutes"; null when there is no gap. */ +function gapPhrase(minutes: number): string | null { + if (minutes <= 0) return null; + const h = Math.floor(minutes / 60); + const m = minutes % 60; + if (!h) return plural(m, "minute"); + return m ? `${plural(h, "hour")} ${plural(m, "minute")}` : plural(h, "hour"); +} + +/** "at most 2 a day, 4 hours apart" — or "at most 2 lessons a day, …" with a unit. */ +function pacingPhrase(p: Pacing, unit?: string): string { + const perDay = unit ? plural(p.maxPerDay, unit) : String(p.maxPerDay); + const gap = gapPhrase(p.minGapMinutes); + return `at most ${perDay} a day${gap ? `, ${gap} apart` : ""}`; +} + +const inlineCode = (text: string) => + html`${text}`; + +/** Copies `text` from a data attribute, so nothing user-shaped ever sits in a JS literal. */ +function copyButton(text: string): Html { + return html``; +} + +/** Setting up happens in the agent: the words every host understands, then the plugin's command. */ +function setupHint(): Html { + return html`Say “set up devcoach” to your agent. With the Claude Code plugin, type ${inlineCode("/devcoach:setup")}.`; +} + +/** The one diagnostic for this install (`doctorHint`): a terminal command, or the plugin's own. */ +function doctorLine(hint: DoctorHint, lead: string): Html { + const command = html`${inlineCode(hint.command)}${copyButton(hint.command)}`; + return html`

${lead} + ${ + hint.where === "claude-code" + ? html`In Claude Code, run ${command}: it checks the runtime, the server and your profile.` + : html`Check the wiring from a terminal: ${command} — it says what is missing, and why the next lesson will or won't come.` + }

`; +} + +/** A lesson as the chat shows it, greyed out: the shape to recognise when the first one arrives. */ +function lessonGhost(): Html { + return html`
+
What a lesson looks like
+
+

busy_timeout: SQLite has no lock queue · SQLite · Mid

+

The SQLITE_BUSY you just fixed: two processes wrote to the same database at the same instant. The code assumed writers wait in line; SQLite has no queue, so the second one fails at once…

+

💡 Senior tip: busy_timeout is a ceiling, not a target. Keep write transactions short and open them with BEGIN IMMEDIATE.

+
+
`; +} + +/** /knowledge before onboarding: an empty knowledge table means the profile was never set up. */ +function firstRunBlock(pacing: Pacing, doctor: DoctorHint): Html { + return html`
+

Set up devcoach

+

devcoach teaches after real work: when your agent finishes a task, the coach decides whether one thing is worth telling you, pitched at what you already know. This map is what it knows about you, and it stays empty until you set up your profile.

+
    +
  1. Set up your profile. ${setupHint()} It fills this map from the projects it finds, or from a few questions.
  2. +
  3. Keep working. The first lesson arrives after real work in your agent, ${pacingPhrase(pacing)}.
  4. +
+ ${doctorLine(doctor, "Your agent does not know devcoach?")} +
`; +} + +/** /knowledge once set up, before the first lesson: one line above the map. */ +function noLessonStrip(pacing: Pacing): Html { + return html`

No lesson yet — the first one arrives after real work in your agent, ${pacingPhrase(pacing)}. What a lesson looks like →

`; +} + +/** /lessons with no lesson and no filter: what devcoach does, the ghost card, the pacing. */ +function lessonsEmpty(d: { setUp: boolean; pacing: Pacing; doctor: DoctorHint }): Html { + return html`
+

No lessons yet

+

devcoach teaches after real work. Finish a task in your agent — a fix, a review, a migration — and when it ends, the coach decides whether one thing is worth telling you. The lesson arrives at the end of the reply and lands here.

+ ${ + d.setUp + ? "" + : html`

First, set up your profile. ${setupHint()}

` + } + ${lessonGhost()} + ${doctorLine(d.doctor, "Nothing arrived after a few tasks?")} +

Pacing: ${pacingPhrase(d.pacing, "lesson")}, and only after real work. Change it in Settings. Someone shared a lesson with you? Paste it with + Import: it never counts against the limit.

+
`; +} + +/** "a", "a and b", "a, b and c" — each item already marked up. */ +function andList(items: Html[]): Html[] { + return items.flatMap((item, i) => + i === 0 ? [item] : [html`${i === items.length - 1 ? " and " : ", "}`, item], + ); +} + // ── Profile (profile.html) ─────────────────────────────────────────────────── export interface ProfileData { @@ -198,11 +304,17 @@ export interface ProfileData { allGroups: string[]; stats: Record; rateLimit: { allowed: boolean; reason?: string | null }; - maxPerDay: number; + pacing: Pacing; + /** The diagnostic command for this install, shown in the first-run block. */ + doctor: DoctorHint; uiTheme: string; } export function profilePage(d: ProfileData): Html { + // An empty knowledge table reliably means onboarding has not run (topics come only from it, + // or from the Edit mode below). The stats line says nothing before the first lesson. + const setUp = Object.values(d.categorised).some((topics) => topics.length > 0); + const hasLessons = Number(d.stats.total_lessons ?? 0) > 0; const groupOptions = (current: string) => d.allGroups .filter((g) => g !== "Other") @@ -257,10 +369,12 @@ export function profilePage(d: ProfileData): Html { -
+${ + hasLessons + ? html`
${Number(d.stats.total_lessons ?? 0)} lessons total · - ${Number(d.stats.lessons_today ?? 0)} / ${d.maxPerDay} today + ${Number(d.stats.lessons_today ?? 0)} / ${d.pacing.maxPerDay} today · ${Number(d.stats.lessons_this_week ?? 0)} this week · @@ -269,7 +383,10 @@ export function profilePage(d: ProfileData): Html { ? html`Available now` : html`${d.rateLimit.reason}` } -
+
` + : "" +} +${setUp ? (hasLessons ? "" : noLessonStrip(d.pacing)) : firstRunBlock(d.pacing, d.doctor)} ${Object.entries(d.categorised).map(([category, topics]) => { return html` @@ -400,6 +517,11 @@ export interface LessonsData { total: number; totalPages: number; uiTheme: string; + /** Whether onboarding has run (the knowledge table is not empty). */ + setUp: boolean; + pacing: Pacing; + /** The diagnostic command for this install, shown when there is no lesson yet. */ + doctor: DoctorHint; } const PERIOD_LABELS: Record = { @@ -442,25 +564,32 @@ function lessonsQs(s: LessonsSelected, overrides: Record = {}): return `?${q.toString()}`; } +interface ActiveFilter { + label: string; + /** The same list without this filter. */ + clear: string; + /** A period or a date range — what "Search all time" drops. */ + time?: true; + star?: true; +} + +/** Lessons exist, the filters hide them all: name the filters, offer the two ways out. */ +function filteredEmpty(s: LessonsSelected, filters: ActiveFilter[]): Html { + const name = (f: ActiveFilter) => + html`${f.label}`; + const time = filters.find((f) => f.time); + const others = andList(filters.filter((f) => !f.time).map(name)); + const linkClass = + "text-accent-700 dark:text-accent-300 underline underline-offset-2 hover:text-accent-600 dark:hover:text-accent-200"; + return html`
+

${others.length ? html`No lessons match ${others}${time ? html` in ${name(time)}` : ""}.` : html`No lessons in ${time ? name(time) : ""}.`}

+

${time ? html`Search all time · ` : ""}Clear every filter

+
`; +} + export function lessonsPage(d: LessonsData): Html { const s = d.s; const customDate = Boolean(s.date_from || s.date_to); - const anyFilter = - (s.period !== "all" && !customDate) || - customDate || - Boolean( - s.category || - s.level || - s.project || - s.repository || - s.branch || - s.commit || - s.starred || - s.imported || - s.shared_by || - s.search || - s.feedback, - ); const periodLabel = customDate ? s.date_from && s.date_to ? `${s.date_from} → ${s.date_to}` @@ -476,6 +605,41 @@ export function lessonsPage(d: LessonsData): Html { : s.imported === "0" ? "👤 My own" : "🤝 Shared"; + // Every active filter once: the chips above the list and the sentence of the filtered-empty state. + const candidates: (ActiveFilter | false | "")[] = [ + customDate && { + label: `📅 ${periodLabel}`, + clear: lessonsQs(s, { date_from: "", date_to: "" }), + time: true, + }, + s.period !== "all" && + !customDate && { + label: `🕐 ${periodLabel}`, + clear: lessonsQs(s, { period: "all" }), + time: true, + }, + s.feedback && { + label: FEEDBACK_LABELS[s.feedback] ?? s.feedback, + clear: lessonsQs(s, { feedback: "" }), + }, + s.starred && { label: "★ Starred", clear: lessonsQs({ ...s, starred: false }), star: true }, + (s.imported || s.shared_by) && { + label: sharedLabel, + clear: lessonsQs(s, { imported: "", shared_by: "" }), + }, + s.search && { label: `🔍 "${s.search}"`, clear: lessonsQs(s, { search: "" }) }, + s.category && { label: s.category, clear: lessonsQs(s, { category: "" }) }, + s.level && { + label: `${LEVEL_EMOJI[s.level] ?? ""} ${s.level}`, + clear: lessonsQs(s, { level: "" }), + }, + s.project && { label: `📁 ${s.project}`, clear: lessonsQs(s, { project: "" }) }, + s.repository && { label: `⎇ ${s.repository}`, clear: lessonsQs(s, { repository: "" }) }, + s.branch && { label: `⎇ ${s.branch}`, clear: lessonsQs(s, { branch: "" }) }, + s.commit && { label: s.commit.slice(0, 7), clear: lessonsQs(s, { commit: "" }) }, + ]; + const activeFilters = candidates.filter((f): f is ActiveFilter => Boolean(f)); + const anyFilter = activeFilters.length > 0; // [imported value, sender, label] — the sender travels in data attributes, never in a JS literal. const sharedOptions: [string, string, string][] = [ ["", "", "All lessons"], @@ -672,18 +836,11 @@ export function lessonsPage(d: LessonsData): Html { ${ anyFilter ? html`
- ${customDate ? chip(`📅 ${periodLabel}`, lessonsQs(s, { date_from: "", date_to: "" })) : ""} - ${s.period !== "all" && !customDate ? chip(`🕐 ${periodLabel}`, lessonsQs(s, { period: "all" })) : ""} - ${s.feedback ? chip(FEEDBACK_LABELS[s.feedback] ?? s.feedback, lessonsQs(s, { feedback: "" })) : ""} - ${s.starred ? html`★ Starred×` : ""} - ${s.imported || s.shared_by ? chip(sharedLabel, lessonsQs(s, { imported: "", shared_by: "" })) : ""} - ${s.search ? chip(`🔍 "${s.search}"`, lessonsQs(s, { search: "" })) : ""} - ${s.category ? chip(s.category, lessonsQs(s, { category: "" })) : ""} - ${s.level ? chip(`${LEVEL_EMOJI[s.level] ?? ""} ${s.level}`, lessonsQs(s, { level: "" })) : ""} - ${s.project ? chip(`📁 ${s.project}`, lessonsQs(s, { project: "" })) : ""} - ${s.repository ? chip(`⎇ ${s.repository}`, lessonsQs(s, { repository: "" })) : ""} - ${s.branch ? chip(`⎇ ${s.branch}`, lessonsQs(s, { branch: "" })) : ""} - ${s.commit ? chip(s.commit.slice(0, 7), lessonsQs(s, { commit: "" })) : ""} + ${activeFilters.map((f) => + f.star + ? html`${f.label}×` + : chip(f.label, f.clear), + )}
` : "" } @@ -772,11 +929,9 @@ ${ ` : "" }` - : html`
-

📭

-

No lessons match the current filters.

- ${anyFilter ? html`Clear all filters` : ""} -
` + : anyFilter + ? filteredEmpty(s, activeFilters) + : lessonsEmpty(d) }