From 8841913e12c843638aaf0330943e852235b20057 Mon Sep 17 00:00:00 2001 From: Muhammad Ahmed Cheema Date: Thu, 16 Apr 2026 22:10:22 -0700 Subject: [PATCH 1/7] api: starter-prompts loader + endpoint (yaml + defaults) Adds GET /ui/api/starter-prompts (public) for the landing-page "What can do?" section. Defaults ship in-process; operators override via phantom-config/starter-prompts.yaml with a Zod-validated schema (icon/title/description/prompt, max 6 tiles). Cardinal Rule preserved: the loader copies bytes through. No content classification, no intent branching. Tiles are invitations; the agent decides once the prompt lands in the composer. Any YAML or schema failure warns and falls back to defaults so the landing page never renders blank. --- src/ui/api/__tests__/starter-prompts.test.ts | 130 +++++++++++++++++++ src/ui/api/starter-prompts.ts | 37 ++++++ src/ui/starter-prompts.ts | 113 ++++++++++++++++ 3 files changed, 280 insertions(+) create mode 100644 src/ui/api/__tests__/starter-prompts.test.ts create mode 100644 src/ui/api/starter-prompts.ts create mode 100644 src/ui/starter-prompts.ts diff --git a/src/ui/api/__tests__/starter-prompts.test.ts b/src/ui/api/__tests__/starter-prompts.test.ts new file mode 100644 index 0000000..b2c8427 --- /dev/null +++ b/src/ui/api/__tests__/starter-prompts.test.ts @@ -0,0 +1,130 @@ +// Tests for GET /ui/api/starter-prompts. +// +// The endpoint is public (no cookie gate) so most tests call handleUiRequest +// with no Cookie header. Schema and fallback paths exercise the YAML loader +// directly via the config-dir test seam. + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { handleUiRequest, setPublicDir } from "../../serve.ts"; +import { DEFAULT_STARTER_PROMPTS, loadStarterPrompts } from "../../starter-prompts.ts"; +import { setStarterPromptsConfigDirForTests } from "../starter-prompts.ts"; + +setPublicDir(resolve(import.meta.dir, "../../../../public")); + +let tmpDir: string; +let warnings: string[]; +const originalWarn = console.warn; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "phantom-starter-prompts-")); + setStarterPromptsConfigDirForTests(tmpDir); + warnings = []; + console.warn = (...args: unknown[]) => { + warnings.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")); + }; +}); + +afterEach(() => { + setStarterPromptsConfigDirForTests(null); + rmSync(tmpDir, { recursive: true, force: true }); + console.warn = originalWarn; +}); + +function writeYaml(contents: string): void { + writeFileSync(join(tmpDir, "starter-prompts.yaml"), contents, "utf-8"); +} + +function get(): Request { + return new Request("http://localhost/ui/api/starter-prompts", { method: "GET" }); +} + +describe("GET /ui/api/starter-prompts", () => { + test("returns defaults when the YAML is absent", async () => { + const res = await handleUiRequest(get()); + expect(res.status).toBe(200); + expect(res.headers.get("Cache-Control")).toBe("private, max-age=60"); + const body = (await res.json()) as { tiles: { title: string }[] }; + expect(body.tiles.length).toBe(DEFAULT_STARTER_PROMPTS.length); + expect(body.tiles[0].title).toBe("Summarize Hacker News"); + expect(warnings.length).toBe(0); + }); + + test("returns YAML override tiles in order", async () => { + writeYaml( + "tiles:\n - icon: chart\n title: Custom A\n description: First tile.\n prompt: Do A.\n - icon: git\n title: Custom B\n description: Second tile.\n prompt: Do B.\n", + ); + const res = await handleUiRequest(get()); + expect(res.status).toBe(200); + const body = (await res.json()) as { tiles: { title: string }[] }; + expect(body.tiles.length).toBe(2); + expect(body.tiles[0].title).toBe("Custom A"); + expect(body.tiles[1].title).toBe("Custom B"); + }); + + test("malformed YAML logs a warning and returns defaults", async () => { + writeYaml("tiles:\n - icon: chart\n title: [unclosed"); + const res = await handleUiRequest(get()); + expect(res.status).toBe(200); + const body = (await res.json()) as { tiles: unknown[] }; + expect(body.tiles.length).toBe(DEFAULT_STARTER_PROMPTS.length); + expect(warnings.some((w) => w.includes("invalid YAML"))).toBe(true); + }); + + test("schema-invalid YAML returns defaults and logs the field", async () => { + writeYaml("tiles:\n - icon: chart\n title: Missing prompt\n description: no prompt field.\n"); + const res = await handleUiRequest(get()); + expect(res.status).toBe(200); + const body = (await res.json()) as { tiles: unknown[] }; + expect(body.tiles.length).toBe(DEFAULT_STARTER_PROMPTS.length); + expect(warnings.some((w) => w.includes("schema rejected"))).toBe(true); + }); + + test("unknown top-level key fails strict() and returns defaults", async () => { + writeYaml("tiles:\n - icon: chart\n title: OK\n description: fine.\n prompt: fine.\nextra: nope\n"); + const res = await handleUiRequest(get()); + expect(res.status).toBe(200); + const body = (await res.json()) as { tiles: unknown[] }; + expect(body.tiles.length).toBe(DEFAULT_STARTER_PROMPTS.length); + expect(warnings.some((w) => w.includes("schema rejected"))).toBe(true); + }); + + test("over-long title rejects schema and returns defaults", async () => { + const longTitle = "x".repeat(81); + writeYaml(`tiles:\n - icon: chart\n title: "${longTitle}"\n description: short.\n prompt: short.\n`); + const res = await handleUiRequest(get()); + const body = (await res.json()) as { tiles: unknown[] }; + expect(body.tiles.length).toBe(DEFAULT_STARTER_PROMPTS.length); + expect(warnings.some((w) => w.includes("schema rejected"))).toBe(true); + }); + + test("more than six tiles rejects schema and returns defaults", async () => { + const seven = Array.from({ length: 7 }) + .map((_, i) => ` - icon: chart\n title: Tile ${i}\n description: d${i}.\n prompt: p${i}.\n`) + .join(""); + writeYaml(`tiles:\n${seven}`); + const res = await handleUiRequest(get()); + const body = (await res.json()) as { tiles: unknown[] }; + expect(body.tiles.length).toBe(DEFAULT_STARTER_PROMPTS.length); + }); + + test("POST returns 405 with Allow: GET", async () => { + const res = await handleUiRequest(new Request("http://localhost/ui/api/starter-prompts", { method: "POST" })); + expect(res.status).toBe(405); + expect(res.headers.get("Allow")).toBe("GET"); + }); + + test("endpoint is public (no cookie required)", async () => { + const res = await handleUiRequest(get()); + expect(res.status).toBe(200); + }); + + test("loadStarterPrompts returns a fresh copy (callers can mutate safely)", () => { + const a = loadStarterPrompts(tmpDir); + const b = loadStarterPrompts(tmpDir); + expect(a).not.toBe(b); + expect(a[0]).not.toBe(b[0]); + }); +}); diff --git a/src/ui/api/starter-prompts.ts b/src/ui/api/starter-prompts.ts new file mode 100644 index 0000000..ab397a9 --- /dev/null +++ b/src/ui/api/starter-prompts.ts @@ -0,0 +1,37 @@ +// GET /ui/api/starter-prompts - public endpoint that powers the landing page +// "What can do?" section. +// +// Public by design: the tiles render before the operator authenticates so the +// first-visit hero is not empty. Content is operator-controlled copy, not +// sensitive state. See src/ui/starter-prompts.ts for the defaults + YAML +// loader and the Cardinal Rule note. + +import { resolve } from "node:path"; +import { loadStarterPrompts } from "../starter-prompts.ts"; + +let configDirOverride: string | null = null; + +export function setStarterPromptsConfigDirForTests(dir: string | null): void { + configDirOverride = dir; +} + +function getConfigDir(): string { + return configDirOverride ?? resolve(process.cwd(), "phantom-config"); +} + +export function handleStarterPromptsApi(req: Request): Response { + if (req.method !== "GET") { + return new Response("Method not allowed", { + status: 405, + headers: { Allow: "GET" }, + }); + } + + const tiles = loadStarterPrompts(getConfigDir()); + return new Response(JSON.stringify({ tiles }), { + headers: { + "Content-Type": "application/json", + "Cache-Control": "private, max-age=60", + }, + }); +} diff --git a/src/ui/starter-prompts.ts b/src/ui/starter-prompts.ts new file mode 100644 index 0000000..aaad1ae --- /dev/null +++ b/src/ui/starter-prompts.ts @@ -0,0 +1,113 @@ +// Starter-prompt tiles for the landing page "What can do?" section. +// +// Defaults ship in-process; operators override by dropping +// `phantom-config/starter-prompts.yaml` next to the other agent config. The +// YAML is schema-validated with Zod; any parse or validation failure logs a +// warning and falls back to the defaults so the landing page never renders +// blank. +// +// Cardinal Rule preservation: every field flows through as bytes. The loader +// does not inspect titles, descriptions, or prompts. No keyword branching, no +// intent classification. Tiles are static invitations; the agent decides what +// to do once the operator hits Send in the composer. + +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { parse as parseYaml } from "yaml"; +import { z } from "zod"; + +export const STARTER_ICON_KEYS = ["chart", "git", "inbox", "metrics", "alert", "calendar", "search", "globe"] as const; + +export const StarterTileSchema = z + .object({ + icon: z.string().min(1).max(40), + title: z.string().min(1).max(80), + description: z.string().min(1).max(200), + prompt: z.string().min(1).max(2000), + }) + .strict(); + +export const StarterPromptsSchema = z + .object({ + tiles: z.array(StarterTileSchema).min(1).max(6), + }) + .strict(); + +export type StarterTile = z.infer; + +export const DEFAULT_STARTER_PROMPTS: readonly StarterTile[] = [ + { + icon: "chart", + title: "Summarize Hacker News", + description: "Pull today's top stories and group them by theme.", + prompt: "Summarize the top Hacker News stories from the last 24 hours, grouped by theme.", + }, + { + icon: "git", + title: "Monitor my GitHub repos", + description: "Check for new issues, PRs, and commits across my starred repos.", + prompt: "Check for new issues and PRs on my GitHub repos since yesterday.", + }, + { + icon: "metrics", + title: "Build a weekly metrics dashboard", + description: "Create an HTML dashboard I can bookmark and watch.", + prompt: "Build me a weekly metrics dashboard I can check every Monday morning.", + }, + { + icon: "alert", + title: "Watch for production incidents", + description: "Schedule a recurring watcher and alert me on Slack.", + prompt: "Watch for production incidents and alert me on Slack if anything looks off.", + }, + { + icon: "inbox", + title: "Triage my inbox", + description: "Sort new emails by urgency and draft replies for the top few.", + prompt: "Triage my inbox: sort by urgency and draft replies for the top three threads.", + }, + { + icon: "calendar", + title: "Plan a sprint retrospective", + description: "Summarize the last sprint and suggest discussion topics.", + prompt: "Summarize the last sprint and suggest three discussion topics for the retro.", + }, +]; + +function defaultsCopy(): StarterTile[] { + return DEFAULT_STARTER_PROMPTS.map((t) => ({ ...t })); +} + +export function loadStarterPrompts(configDir: string): StarterTile[] { + const filePath = resolve(configDir, "starter-prompts.yaml"); + if (!existsSync(filePath)) return defaultsCopy(); + + let raw: string; + try { + raw = readFileSync(filePath, "utf-8"); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[starter-prompts] read failed (${msg}); using defaults`); + return defaultsCopy(); + } + + let parsed: unknown; + try { + parsed = parseYaml(raw); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[starter-prompts] invalid YAML (${msg}); using defaults`); + return defaultsCopy(); + } + + const check = StarterPromptsSchema.safeParse(parsed); + if (!check.success) { + const issue = check.error.issues[0]; + const where = issue?.path?.length ? issue.path.map((p) => String(p)).join(".") : "body"; + const message = issue?.message ?? "invalid input"; + console.warn(`[starter-prompts] schema rejected at ${where}: ${message}; using defaults`); + return defaultsCopy(); + } + + return check.data.tiles; +} From 0261a56d4a54b31f5aa55104aec569d4a8bffa53 Mon Sep 17 00:00:00 2001 From: Muhammad Ahmed Cheema Date: Thu, 16 Apr 2026 22:10:30 -0700 Subject: [PATCH 2/7] api: GET /ui/api/pages lists agent-created pages Walks public/ up to depth 3, excludes boilerplate (index.html, _base.html, _components.html, _agent-name.js, phantom-logo.svg, favicon.svg, robots.txt) and the dashboard/, _examples/, chat/ directories. Extracts from the first 8 KiB with an entity decoder covering the common named and numeric refs, caps at 120 chars, falls back to the filename when missing. Sorts by mtime desc, returns top 10 with Cache-Control: private, max-age=30. Public endpoint (no cookie gate): content is filenames the agent itself chose to publish. --- src/ui/api/__tests__/pages-api.test.ts | 177 ++++++++++++++++++++++ src/ui/api/pages.ts | 197 +++++++++++++++++++++++++ 2 files changed, 374 insertions(+) create mode 100644 src/ui/api/__tests__/pages-api.test.ts create mode 100644 src/ui/api/pages.ts diff --git a/src/ui/api/__tests__/pages-api.test.ts b/src/ui/api/__tests__/pages-api.test.ts new file mode 100644 index 0000000..dbf4eae --- /dev/null +++ b/src/ui/api/__tests__/pages-api.test.ts @@ -0,0 +1,177 @@ +// Tests for GET /ui/api/pages. +// +// Each test points setPublicDir at a tmp directory so boilerplate exclusions, +// title extraction, and mtime sort exercise real disk IO without touching the +// repo's own public/ tree. + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { handleUiRequest, setPublicDir } from "../../serve.ts"; + +const realPublic = resolve(import.meta.dir, "../../../../public"); + +let tmpDir: string; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "phantom-pages-api-")); + setPublicDir(tmpDir); +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + setPublicDir(realPublic); +}); + +function writePage(rel: string, content: string, mtimeSeconds?: number): string { + const full = join(tmpDir, rel); + mkdirSync(full.substring(0, full.lastIndexOf("/")), { recursive: true }); + writeFileSync(full, content, "utf-8"); + if (mtimeSeconds !== undefined) { + utimesSync(full, mtimeSeconds, mtimeSeconds); + } + return full; +} + +function req(): Request { + return new Request("http://localhost/ui/api/pages", { method: "GET" }); +} + +type PagesResponse = { + pages: Array<{ path: string; title: string; modified_at: string; size: number }>; +}; + +describe("GET /ui/api/pages", () => { + test("empty public dir returns pages: []", async () => { + const res = await handleUiRequest(req()); + expect(res.status).toBe(200); + const body = (await res.json()) as PagesResponse; + expect(body.pages).toEqual([]); + }); + + test("cache-control header present", async () => { + const res = await handleUiRequest(req()); + expect(res.headers.get("Cache-Control")).toBe("private, max-age=30"); + }); + + test("returns a single agent-created page with extracted title", async () => { + writePage("hacker-news.html", "<!DOCTYPE html><html><head><title>HN Digest"); + const res = await handleUiRequest(req()); + const body = (await res.json()) as PagesResponse; + expect(body.pages.length).toBe(1); + expect(body.pages[0].path).toBe("/ui/hacker-news.html"); + expect(body.pages[0].title).toBe("HN Digest"); + expect(body.pages[0].size).toBeGreaterThan(0); + }); + + test("decodes HTML entities in titles and trims whitespace", async () => { + writePage("metrics.html", " Weekly & monthly "); + const res = await handleUiRequest(req()); + const body = (await res.json()) as PagesResponse; + expect(body.pages[0].title).toBe("Weekly & monthly"); + }); + + test("falls back to filename when is missing or empty", async () => { + writePage("no-title.html", "<html><head></head><body>hi</body></html>"); + writePage("empty.html", "<html><head><title> "); + const res = await handleUiRequest(req()); + const body = (await res.json()) as PagesResponse; + const titles = new Map(body.pages.map((p) => [p.path, p.title])); + expect(titles.get("/ui/no-title.html")).toBe("no-title"); + expect(titles.get("/ui/empty.html")).toBe("empty"); + }); + + test("excludes boilerplate filenames", async () => { + for (const name of [ + "index.html", + "_base.html", + "_components.html", + "_agent-name.js", + "phantom-logo.svg", + "favicon.svg", + "robots.txt", + ]) { + writePage(name, "boiler"); + } + writePage("report.html", "Report"); + const res = await handleUiRequest(req()); + const body = (await res.json()) as PagesResponse; + expect(body.pages.length).toBe(1); + expect(body.pages[0].path).toBe("/ui/report.html"); + }); + + test("excludes dashboard, _examples, chat directories wholesale", async () => { + writePage("dashboard/index.html", "Dashboard"); + writePage("dashboard/cost.html", "Cost"); + writePage("_examples/01-landing.html", "Example"); + writePage("chat/index.html", "Chat"); + writePage("keep.html", "Keep"); + const res = await handleUiRequest(req()); + const body = (await res.json()) as PagesResponse; + expect(body.pages.length).toBe(1); + expect(body.pages[0].path).toBe("/ui/keep.html"); + }); + + test("walks up to depth 3", async () => { + writePage("a/b/c/deep.html", "Deep"); + writePage("a/b/c/d/too-deep.html", "Too Deep"); + const res = await handleUiRequest(req()); + const body = (await res.json()) as PagesResponse; + const paths = body.pages.map((p) => p.path); + expect(paths).toContain("/ui/a/b/c/deep.html"); + expect(paths).not.toContain("/ui/a/b/c/d/too-deep.html"); + }); + + test("sorts by mtime desc", async () => { + const now = Date.now() / 1000; + writePage("old.html", "Old", now - 3600); + writePage("newest.html", "Newest", now); + writePage("middle.html", "Middle", now - 1800); + const res = await handleUiRequest(req()); + const body = (await res.json()) as PagesResponse; + expect(body.pages.map((p) => p.path)).toEqual(["/ui/newest.html", "/ui/middle.html", "/ui/old.html"]); + }); + + test("caps at 10 entries", async () => { + const now = Date.now() / 1000; + for (let i = 0; i < 12; i++) { + writePage(`page-${i}.html`, `Page ${i}`, now - i); + } + const res = await handleUiRequest(req()); + const body = (await res.json()) as PagesResponse; + expect(body.pages.length).toBe(10); + }); + + test("POST returns 405", async () => { + const res = await handleUiRequest(new Request("http://localhost/ui/api/pages", { method: "POST" })); + expect(res.status).toBe(405); + expect(res.headers.get("Allow")).toBe("GET"); + }); + + test("skips non-html extensions", async () => { + writePage("note.txt", "hello"); + writePage("data.json", "{}"); + writePage("real.html", "Real"); + const res = await handleUiRequest(req()); + const body = (await res.json()) as PagesResponse; + expect(body.pages.length).toBe(1); + expect(body.pages[0].path).toBe("/ui/real.html"); + }); + + test("endpoint is public (no cookie required)", async () => { + writePage("public-page.html", "Public"); + const res = await handleUiRequest(req()); + expect(res.status).toBe(200); + const body = (await res.json()) as PagesResponse; + expect(body.pages.length).toBe(1); + }); + + test("caps title at 120 chars", async () => { + const longTitle = "t".repeat(300); + writePage("long.html", `${longTitle}`); + const res = await handleUiRequest(req()); + const body = (await res.json()) as PagesResponse; + expect(body.pages[0].title.length).toBe(120); + }); +}); diff --git a/src/ui/api/pages.ts b/src/ui/api/pages.ts new file mode 100644 index 0000000..835b757 --- /dev/null +++ b/src/ui/api/pages.ts @@ -0,0 +1,197 @@ +// GET /ui/api/pages - list agent-created HTML pages under public/. +// +// Public by design: the landing page renders this list pre-auth and the pages +// themselves are cookie-gated by the static handler below. Exposing the list +// of filenames + titles is information the agent already chose to publish. +// +// Walker rules (matches research 04-avatar-and-landing.md Section 2.3): +// - Root = getPublicDir(). Recurse up to depth 3. +// - Include only .html files (case-insensitive extension match). +// - Exclude boilerplate by exact filename: index.html, _base.html, +// _components.html, _agent-name.js, phantom-logo.svg, favicon.svg, +// robots.txt. +// - Skip these root-level directories wholesale: dashboard, _examples, chat, +// and anything starting with '.'. +// - Title: first 8 KiB of the file, regex-extract , decode five basic +// HTML entities, trim, cap 120 chars. Fallback = filename without .html. +// - Sort by mtime desc, return top 10. +// - Returned path is "/ui/<rel>" with forward slashes, never an absolute +// filesystem path. + +import { type Dirent, readdirSync, statSync } from "node:fs"; +import { relative, resolve, sep } from "node:path"; +import { getPublicDir } from "../serve.ts"; + +const EXCLUDED_FILENAMES = new Set([ + "index.html", + "_base.html", + "_components.html", + "_agent-name.js", + "phantom-logo.svg", + "favicon.svg", + "robots.txt", +]); + +const EXCLUDED_ROOT_DIRS = new Set(["dashboard", "_examples", "chat"]); + +const TITLE_REGEX = /<title[^>]*>([^<]*)<\/title>/i; +const MAX_TITLE_LEN = 120; +const PAGE_LIMIT = 10; +const HEAD_BYTES = 8192; +const MAX_DEPTH = 3; + +export type PageEntry = { + path: string; + title: string; + modified_at: string; + size: number; +}; + +type WalkEntry = { + absolutePath: string; + relativePath: string; + mtimeMs: number; + size: number; +}; + +function walkPublicHtml(root: string): WalkEntry[] { + const out: WalkEntry[] = []; + + function recurse(dirAbs: string, depth: number): void { + if (depth > MAX_DEPTH) return; + let entries: Dirent[]; + try { + entries = readdirSync(dirAbs, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const name = entry.name; + if (name.startsWith(".")) continue; + + const childAbs = resolve(dirAbs, name); + + if (entry.isDirectory()) { + if (depth === 0 && EXCLUDED_ROOT_DIRS.has(name)) continue; + recurse(childAbs, depth + 1); + continue; + } + + if (!entry.isFile()) continue; + if (!name.toLowerCase().endsWith(".html")) continue; + if (EXCLUDED_FILENAMES.has(name)) continue; + + const rel = relative(root, childAbs); + if (rel.startsWith("..") || rel.includes("\0")) continue; + const posixRel = rel.split(sep).join("/"); + + let size = 0; + let mtimeMs = 0; + try { + const stat = statSync(childAbs); + size = stat.size; + mtimeMs = stat.mtimeMs; + } catch { + continue; + } + out.push({ absolutePath: childAbs, relativePath: posixRel, mtimeMs, size }); + } + } + + recurse(root, 0); + return out; +} + +// Agent-authored <title> tags can include a handful of common HTML entities. +// We decode the safe, printable ones AND numeric refs so the operator sees the +// glyph, not the raw markup. Anything unrecognized passes through unchanged; +// downstream the string is set via textContent so no rendering bypass occurs. +const NAMED_ENTITIES: Record<string, string> = { + amp: "&", + lt: "<", + gt: ">", + quot: '"', + apos: "'", + nbsp: "\u00A0", + middot: "\u00B7", + hellip: "\u2026", + mdash: "\u2014", + ndash: "\u2013", + rsquo: "\u2019", + lsquo: "\u2018", + rdquo: "\u201D", + ldquo: "\u201C", + copy: "\u00A9", + reg: "\u00AE", + trade: "\u2122", +}; + +function decodeBasicEntities(value: string): string { + return value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, ref: string) => { + if (ref.startsWith("#x") || ref.startsWith("#X")) { + const code = Number.parseInt(ref.slice(2), 16); + return Number.isFinite(code) ? String.fromCodePoint(code) : match; + } + if (ref.startsWith("#")) { + const code = Number.parseInt(ref.slice(1), 10); + return Number.isFinite(code) ? String.fromCodePoint(code) : match; + } + const replacement = NAMED_ENTITIES[ref]; + return replacement ?? match; + }); +} + +function filenameTitle(rel: string): string { + const base = rel.split("/").pop() ?? rel; + return base.replace(/\.html$/i, ""); +} + +async function extractTitle(absolutePath: string, rel: string): Promise<string> { + try { + const head = await Bun.file(absolutePath).slice(0, HEAD_BYTES).text(); + const match = TITLE_REGEX.exec(head); + if (match?.[1]) { + const decoded = decodeBasicEntities(match[1]).trim(); + if (decoded.length > 0) { + return decoded.slice(0, MAX_TITLE_LEN); + } + } + } catch {} + return filenameTitle(rel).slice(0, MAX_TITLE_LEN); +} + +export async function handlePagesApi(req: Request): Promise<Response> { + if (req.method !== "GET") { + return new Response("Method not allowed", { + status: 405, + headers: { Allow: "GET" }, + }); + } + + const root = getPublicDir(); + let walked: WalkEntry[] = []; + try { + walked = walkPublicHtml(root); + } catch { + walked = []; + } + + walked.sort((a, b) => b.mtimeMs - a.mtimeMs); + const top = walked.slice(0, PAGE_LIMIT); + + const pages: PageEntry[] = await Promise.all( + top.map(async (entry) => ({ + path: `/ui/${entry.relativePath}`, + title: await extractTitle(entry.absolutePath, entry.relativePath), + modified_at: new Date(entry.mtimeMs).toISOString(), + size: entry.size, + })), + ); + + return new Response(JSON.stringify({ pages }), { + headers: { + "Content-Type": "application/json", + "Cache-Control": "private, max-age=30", + }, + }); +} From f0455f55038182b2c47ef670aa143d466e418dcd Mon Sep 17 00:00:00 2001 From: Muhammad Ahmed Cheema <cheemawrites@gmail.com> Date: Thu, 16 Apr 2026 22:10:42 -0700 Subject: [PATCH 3/7] api: wire starter-prompts + pages endpoints into the UI dispatcher Both endpoints are public (no cookie gate) so they render on the landing page pre-authentication. Added next to the /ui/avatar GET which has the same access profile. --- src/ui/serve.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/ui/serve.ts b/src/ui/serve.ts index 0973100..7913e25 100644 --- a/src/ui/serve.ts +++ b/src/ui/serve.ts @@ -19,11 +19,13 @@ import { handleHooksApi } from "./api/hooks.ts"; import { handleAvatarDelete, handleAvatarGet, handleAvatarPost } from "./api/identity.ts"; import { handleMemoryFilesApi } from "./api/memory-files.ts"; import { handleMemoryApi } from "./api/memory.ts"; +import { handlePagesApi } from "./api/pages.ts"; import { type PhantomConfigPaths, handlePhantomConfigApi } from "./api/phantom-config.ts"; import { type PluginsApiDeps, handlePluginsApi } from "./api/plugins.ts"; import { handleSchedulerApi } from "./api/scheduler.ts"; import { handleSessionsApi } from "./api/sessions.ts"; import { handleSkillsApi } from "./api/skills.ts"; +import { handleStarterPromptsApi } from "./api/starter-prompts.ts"; import { handleSubagentsApi } from "./api/subagents.ts"; const COOKIE_NAME = "phantom_session"; @@ -231,6 +233,16 @@ export async function handleUiRequest(req: Request): Promise<Response> { return new Response("Method not allowed", { status: 405, headers: { Allow: "GET" } }); } + // Public landing-page data feeds. These render before login so the hero is + // not empty on first visit. Content is operator-public (starter-prompt copy, + // agent-published page filenames) so no cookie gate. + if (url.pathname === "/ui/api/starter-prompts") { + return handleStarterPromptsApi(req); + } + if (url.pathname === "/ui/api/pages") { + return handlePagesApi(req); + } + // Public assets (logo, favicon) - no auth needed if (url.pathname === "/ui/phantom-logo.svg") { const filePath = isPathSafe(url.pathname); From c4cd0efda0401c33522149f715c01092c6748c6f Mon Sep 17 00:00:00 2001 From: Muhammad Ahmed Cheema <cheemawrites@gmail.com> Date: Thu, 16 Apr 2026 22:10:51 -0700 Subject: [PATCH 4/7] chat-ui: ?prefill= composer pre-fill on /chat mount Reads ?prefill= from window.location on the /chat route entry, decodes it, caps at 2000 chars (ellipsis truncation + console warn if longer), seeds the composer via a new ChatInput initialText prop, and clears the query from the URL with history.replaceState so a refresh does not re-prefill. Does NOT auto-submit: the operator reviews the pre-filled prompt and hits Send. This is a consent surface. The seed only runs once (seededRef) so later re-renders never stomp user edits. Wire contract with the landing page: /chat?prefill=<urlencoded>. Cardinal Rule: the prefill string is bytes, not intent. Agent decides what to do on submit. --- chat-ui/src/components/chat-input.tsx | 23 +++++++++++++++-- chat-ui/src/routes/chat-route.tsx | 36 ++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/chat-ui/src/components/chat-input.tsx b/chat-ui/src/components/chat-input.tsx index a71d967..cdb778c 100644 --- a/chat-ui/src/components/chat-input.tsx +++ b/chat-ui/src/components/chat-input.tsx @@ -1,5 +1,5 @@ import { ArrowUp, Square } from "lucide-react"; -import { useCallback, useRef, useState, type KeyboardEvent } from "react"; +import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from "react"; import { Button } from "@/ui/button"; import type { PendingAttachment } from "@/hooks/use-attachments"; import { AttachmentStrip } from "./attachment-strip"; @@ -13,6 +13,7 @@ export function ChatInput({ attachments, onAddFiles, onRemoveFile, + initialText, }: { onSend: (text: string) => void; onStop: () => void; @@ -21,11 +22,29 @@ export function ChatInput({ attachments?: PendingAttachment[]; onAddFiles?: (files: File[]) => void; onRemoveFile?: (id: string) => void; + initialText?: string; }) { - const [text, setText] = useState(""); + const [text, setText] = useState(initialText ?? ""); const textareaRef = useRef<HTMLTextAreaElement>(null); const composingRef = useRef(false); const fileInputRef = useRef<HTMLInputElement>(null); + const seededRef = useRef(false); + + // Seed the composer from the landing-page ?prefill handler exactly once. + // The parent owns whether it fires at all; once the user starts editing we + // never stomp their work, even if the prop re-renders with the same value. + useEffect(() => { + if (seededRef.current) return; + if (!initialText) return; + seededRef.current = true; + setText(initialText); + const el = textareaRef.current; + if (el) { + el.style.height = "auto"; + el.style.height = Math.min(el.scrollHeight, 200) + "px"; + el.focus(); + } + }, [initialText]); const handleSend = useCallback(() => { const trimmed = text.trim(); diff --git a/chat-ui/src/routes/chat-route.tsx b/chat-ui/src/routes/chat-route.tsx index f72622f..5f08ba2 100644 --- a/chat-ui/src/routes/chat-route.tsx +++ b/chat-ui/src/routes/chat-route.tsx @@ -1,12 +1,45 @@ -import { useCallback, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { EmptyState } from "@/components/empty-state"; import { ChatInput } from "@/components/chat-input"; import { createSession } from "@/lib/client"; +const PREFILL_MAX = 2000; + +// The landing page deep-links here as `/chat?prefill=<urlencoded>` to seed the +// composer with a starter prompt. We decode, cap at 2000 chars, strip the +// query param from the URL, and render. The user still has to hit Send; this +// is a consent surface, not an auto-run. +function readPrefill(): string | null { + if (typeof window === "undefined") return null; + const raw = new URLSearchParams(window.location.search).get("prefill"); + if (raw === null) return null; + let decoded: string; + try { + decoded = decodeURIComponent(raw); + } catch { + decoded = raw; + } + if (decoded.length > PREFILL_MAX) { + console.warn( + `[chat] prefill truncated from ${decoded.length} to ${PREFILL_MAX} chars`, + ); + decoded = decoded.slice(0, PREFILL_MAX - 1) + "\u2026"; + } + return decoded; +} + export function ChatRoute() { const navigate = useNavigate(); const creatingRef = useRef(false); + const [initialText, setInitialText] = useState<string | undefined>(undefined); + + useEffect(() => { + const prefill = readPrefill(); + if (prefill === null) return; + setInitialText(prefill); + window.history.replaceState({}, "", "/chat"); + }, []); const handleCreateAndNavigate = useCallback( async (text: string) => { @@ -29,6 +62,7 @@ export function ChatRoute() { onSend={handleCreateAndNavigate} onStop={() => {}} isStreaming={false} + initialText={initialText} /> </> ); From dd76238477cc6bd9cbee0aa4b1b139ccc40d73a9 Mon Sep 17 00:00:00 2001 From: Muhammad Ahmed Cheema <cheemawrites@gmail.com> Date: Thu, 16 Apr 2026 22:11:05 -0700 Subject: [PATCH 5/7] landing: restructure /ui/ with avatar hero, starter tiles, pages list, slimmed quick links Five sections, same design vocabulary: 1. Nav (unchanged) with the existing avatar slot. 2. Hero 2-column: 120x120 avatar tile (letter fallback at 72px Instrument Serif italic in primary color) + eyebrow, display title, lead, primary "Talk to <name>" -> /chat, ghost "Open dashboard" -> /ui/dashboard/. 3. Agent status card (kept) with a small "Details ->" link to /health in the header. 4. "What can <name> do?" - fetches /ui/api/starter-prompts, renders 4 to 6 tiles with inline phosphor-style icons, skeleton placeholder while loading, section hidden on fetch error. Each tile deep-links to /chat?prefill=<urlencoded>. 5. "Pages <name> has created for you" - fetches /ui/api/pages, renders rows with path, title, relative time; empty state deep-links to /chat with a "build me a dashboard" prefill. 6. Quick links slimmed from 3 to 2 (Dashboard + MCP). Health removed in favor of the status-card "Details" link. XSS posture: every operator- or agent-controlled string flows into the DOM via textContent or createElement. Icon SVGs are our own assets keyed by name. Query strings are encodeURIComponent'd. Responsive: hero stacks below 720px, CTAs stack below 500px, tiles grid is auto-fit minmax(260px,1fr), pages rows stack below 640px. Status-badge re-render hardened to textContent too (removes the prior innerHTML template, which is belt-and-suspenders since the values came from our own health payload). --- public/index.html | 347 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 305 insertions(+), 42 deletions(-) diff --git a/public/index.html b/public/index.html index c4d3306..d68abdf 100644 --- a/public/index.html +++ b/public/index.html @@ -22,7 +22,7 @@ <style> :root { - --space-2:8px; --space-3:12px; --space-4:16px; --space-5:20px; --space-6:24px; --space-8:32px; --space-10:40px; --space-12:48px; --space-16:64px; + --space-1:4px; --space-2:8px; --space-3:12px; --space-4:16px; --space-5:20px; --space-6:24px; --space-8:32px; --space-10:40px; --space-12:48px; --space-16:64px; --radius-sm:8px; --radius-md:10px; --radius-lg:14px; --radius-pill:9999px; --motion-fast:100ms; --motion-base:150ms; --ease-out:cubic-bezier(0.25,0.46,0.45,0.94); } @@ -30,6 +30,7 @@ body { font-feature-settings: "ss01","cv11"; font-variant-numeric: tabular-nums; -webkit-font-smoothing:antialiased; } @keyframes phantom-fade-in { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:translateY(0); } } @keyframes phantom-pulse { 0%,100% { opacity:1; } 50% { opacity:0.55; } } +@keyframes phantom-skeleton { 0% { opacity:0.55; } 50% { opacity:0.9; } 100% { opacity:0.55; } } main { animation: phantom-fade-in 300ms var(--ease-out); } .phantom-page { max-width:1100px; margin:0 auto; padding:var(--space-8) var(--space-8); } @@ -43,10 +44,10 @@ .phantom-display { font-family:'Instrument Serif',Georgia,serif; font-size:clamp(44px,5vw,60px); font-weight:400; line-height:1.05; letter-spacing:-0.01em; color:var(--color-base-content); margin:0 0 var(--space-4); } .phantom-display em { font-style:italic; font-weight:400; } .phantom-h1 { font-family:'Instrument Serif',Georgia,serif; font-size:32px; font-weight:500; line-height:1.15; letter-spacing:-0.01em; color:var(--color-base-content); margin:0 0 var(--space-3); } -.phantom-h2 { font-family:'Instrument Serif',Georgia,serif; font-size:22px; font-weight:500; line-height:1.25; color:var(--color-base-content); margin:0 0 var(--space-3); } +.phantom-h2 { font-family:'Instrument Serif',Georgia,serif; font-size:26px; font-weight:500; line-height:1.2; color:var(--color-base-content); margin:0 0 var(--space-2); letter-spacing:-0.01em; } .phantom-h3 { font-family:'Inter',sans-serif; font-size:14px; font-weight:600; line-height:1.3; color:var(--color-base-content); margin:0 0 var(--space-2); } .phantom-eyebrow { font-family:'Inter',sans-serif; font-size:11px; font-weight:600; line-height:1; letter-spacing:0.08em; text-transform:uppercase; color:color-mix(in oklab, var(--color-base-content) 50%, transparent); margin:0 0 var(--space-3); } -.phantom-lead { font-family:'Inter',sans-serif; font-size:17px; font-weight:400; line-height:1.6; color:color-mix(in oklab, var(--color-base-content) 72%, transparent); max-width:560px; } +.phantom-lead { font-family:'Inter',sans-serif; font-size:17px; font-weight:400; line-height:1.6; color:color-mix(in oklab, var(--color-base-content) 72%, transparent); max-width:560px; margin:0 0 var(--space-5); } .phantom-body { font-family:'Inter',sans-serif; font-size:14px; line-height:1.55; color:var(--color-base-content); } .phantom-muted { color:color-mix(in oklab, var(--color-base-content) 55%, transparent); } @@ -69,13 +70,70 @@ .phantom-dot { width:6px; height:6px; border-radius:50%; display:inline-block; } .phantom-dot-live { background:var(--color-success); box-shadow:0 0 0 3px color-mix(in oklab, var(--color-success) 25%, transparent); animation:phantom-pulse 2s cubic-bezier(0.4,0,0.6,1) infinite; } -.phantom-button { display:inline-flex; align-items:center; justify-content:center; gap:var(--space-2); font-family:Inter,sans-serif; font-size:14px; font-weight:500; padding:11px 18px; border-radius:var(--radius-pill); border:1px solid transparent; background:var(--color-base-content); color:var(--color-base-100); cursor:pointer; text-decoration:none; transition:opacity var(--motion-fast), transform var(--motion-fast); } +.phantom-button { display:inline-flex; align-items:center; justify-content:center; gap:var(--space-2); font-family:Inter,sans-serif; font-size:14px; font-weight:500; padding:11px 18px; border-radius:var(--radius-pill); border:1px solid transparent; background:var(--color-base-content); color:var(--color-base-100); cursor:pointer; text-decoration:none; transition:opacity var(--motion-fast), transform var(--motion-fast), box-shadow var(--motion-fast); } .phantom-button:hover { opacity:0.88; } +.phantom-button:focus-visible { outline:none; box-shadow:0 0 0 3px color-mix(in oklab, var(--color-primary) 40%, transparent); } +.phantom-button-primary { background:var(--color-base-content); color:var(--color-base-100); } .phantom-button-ghost { background:transparent; color:var(--color-base-content); border-color:var(--color-base-300); } .phantom-button-ghost:hover { background:color-mix(in oklab, var(--color-base-content) 5%, transparent); } +.phantom-button-sm { padding:7px 12px; font-size:13px; } + +.phantom-hero { display:grid; grid-template-columns:120px 1fr; gap:var(--space-8); align-items:center; margin:var(--space-12) 0 var(--space-16); } +.phantom-hero-avatar-slot { width:120px; height:120px; border-radius:24px; background:color-mix(in oklab, var(--color-primary) 10%, var(--color-base-200)); border:1px solid color-mix(in oklab, var(--color-primary) 20%, var(--color-base-300)); display:flex; align-items:center; justify-content:center; overflow:hidden; } +.phantom-hero-avatar-slot img[data-agent-avatar-img] { width:120px; height:120px; border-radius:24px; object-fit:cover; display:block; } +.phantom-hero-letter { font-family:'Instrument Serif',Georgia,serif; font-size:72px; line-height:1; color:var(--color-primary); font-style:italic; } +.phantom-hero-copy { min-width:0; } +.phantom-hero-copy .phantom-display { margin-bottom:var(--space-3); } +.phantom-hero-actions { display:flex; align-items:center; gap:var(--space-3); flex-wrap:wrap; margin-top:var(--space-6); } +@media (max-width:720px) { + .phantom-hero { grid-template-columns:1fr; gap:var(--space-5); text-align:left; } + .phantom-hero-avatar-slot { margin:0; } +} +@media (max-width:500px) { + .phantom-hero-actions { flex-direction:column; align-items:stretch; } + .phantom-hero-actions .phantom-button { width:100%; } +} + +.phantom-section-header { margin-bottom:var(--space-5); } +.phantom-section-title { font-family:'Instrument Serif',Georgia,serif; font-size:28px; font-weight:500; line-height:1.2; letter-spacing:-0.01em; color:var(--color-base-content); margin:0 0 var(--space-2); } +.phantom-section-sub { font-family:Inter,sans-serif; font-size:14px; line-height:1.55; color:color-mix(in oklab, var(--color-base-content) 62%, transparent); margin:0; max-width:560px; } + +.phantom-details-link { font-family:Inter,sans-serif; font-size:11px; font-weight:500; color:color-mix(in oklab, var(--color-base-content) 55%, transparent); text-decoration:none; letter-spacing:0.02em; transition:color var(--motion-fast); } +.phantom-details-link:hover { color:var(--color-primary); } +.phantom-details-link:focus-visible { outline:none; color:var(--color-primary); text-decoration:underline; } + +.phantom-tiles-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(260px, 1fr)); gap:var(--space-4); } +.phantom-tile { background:var(--color-base-200); border:1px solid var(--color-base-300); border-radius:var(--radius-lg); padding:var(--space-5); display:flex; flex-direction:column; gap:var(--space-3); min-height:170px; transition:border-color var(--motion-base) var(--ease-out), transform var(--motion-base) var(--ease-out); } +.phantom-tile:hover { border-color:color-mix(in oklab, var(--color-primary) 32%, var(--color-base-300)); transform:translateY(-1px); } +.phantom-tile-icon { width:28px; height:28px; border-radius:var(--radius-sm); background:color-mix(in oklab, var(--color-primary) 12%, transparent); color:var(--color-primary); display:inline-flex; align-items:center; justify-content:center; } +.phantom-tile-icon svg { width:16px; height:16px; } +.phantom-tile-title { font-family:Inter,sans-serif; font-size:15px; font-weight:600; line-height:1.25; color:var(--color-base-content); margin:0; } +.phantom-tile-desc { font-family:Inter,sans-serif; font-size:13px; line-height:1.5; color:color-mix(in oklab, var(--color-base-content) 62%, transparent); margin:0; flex:1; } +.phantom-tile-cta { align-self:flex-start; margin-top:auto; } +.phantom-tile-skeleton { background:var(--color-base-200); border:1px solid var(--color-base-300); border-radius:var(--radius-lg); padding:var(--space-5); min-height:170px; display:flex; flex-direction:column; gap:var(--space-3); animation:phantom-skeleton 1.4s ease-in-out infinite; } +.phantom-tile-skeleton-bar { background:color-mix(in oklab, var(--color-base-content) 8%, transparent); border-radius:4px; height:12px; } +.phantom-tile-skeleton-bar.tall { height:18px; width:70%; } +.phantom-tile-skeleton-bar.full { width:100%; } +.phantom-tile-skeleton-bar.short { width:45%; } + +.phantom-pages-list { display:flex; flex-direction:column; gap:var(--space-2); } +.phantom-page-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto; gap:var(--space-4); align-items:center; padding:var(--space-3) var(--space-4); border:1px solid var(--color-base-300); border-radius:var(--radius-md); background:var(--color-base-200); text-decoration:none; color:var(--color-base-content); transition:border-color var(--motion-fast), background-color var(--motion-fast); } +.phantom-page-row:hover { border-color:color-mix(in oklab, var(--color-primary) 32%, var(--color-base-300)); background:color-mix(in oklab, var(--color-primary) 3%, var(--color-base-200)); } +.phantom-page-row:focus-visible { outline:none; border-color:var(--color-primary); box-shadow:0 0 0 3px color-mix(in oklab, var(--color-primary) 25%, transparent); } +.phantom-page-path { font-family:'JetBrains Mono',ui-monospace,monospace; font-size:12px; color:color-mix(in oklab, var(--color-base-content) 58%, transparent); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.phantom-page-title { font-family:Inter,sans-serif; font-size:14px; font-weight:500; color:var(--color-base-content); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.phantom-page-time { font-family:Inter,sans-serif; font-size:12px; color:color-mix(in oklab, var(--color-base-content) 50%, transparent); white-space:nowrap; } +@media (max-width:640px) { + .phantom-page-row { grid-template-columns:1fr; gap:var(--space-1); } + .phantom-page-time { justify-self:start; } +} +.phantom-empty { padding:var(--space-5); border:1px dashed var(--color-base-300); border-radius:var(--radius-md); background:color-mix(in oklab, var(--color-primary) 3%, var(--color-base-200)); font-family:Inter,sans-serif; font-size:14px; line-height:1.55; color:color-mix(in oklab, var(--color-base-content) 70%, transparent); } +.phantom-empty a { color:var(--color-primary); text-decoration:underline; text-underline-offset:2px; } +.phantom-empty a:hover { text-decoration-thickness:2px; } .quick-link { display:flex; align-items:center; gap:var(--space-3); padding:var(--space-3) var(--space-4); border:1px solid var(--color-base-300); border-radius:var(--radius-md); text-decoration:none; color:var(--color-base-content); transition:border-color var(--motion-fast), background-color var(--motion-fast); } .quick-link:hover { border-color:color-mix(in oklab, var(--color-primary) 30%, var(--color-base-300)); background:color-mix(in oklab, var(--color-primary) 3%, transparent); } +.quick-link:focus-visible { outline:none; border-color:var(--color-primary); box-shadow:0 0 0 3px color-mix(in oklab, var(--color-primary) 25%, transparent); } .quick-link-icon { width:32px; height:32px; border-radius:var(--radius-sm); background:color-mix(in oklab, var(--color-primary) 10%, transparent); color:var(--color-primary); display:flex; align-items:center; justify-content:center; flex-shrink:0; } .quick-link-body { flex:1; min-width:0; } .quick-link-title { font-size:14px; font-weight:500; color:var(--color-base-content); margin-bottom:2px; } @@ -105,19 +163,31 @@ <main class="phantom-page"> - <section style="margin:var(--space-12) 0 var(--space-16); max-width:780px;"> - <p class="phantom-eyebrow">The agent is awake</p> - <h1 class="phantom-display"><span data-agent-name> </span> works <em>alongside you</em>, not for you.</h1> - <p class="phantom-lead">Your autonomous AI co-worker. Generates dashboards, schedules jobs, remembers everything, and iterates on itself between messages. This is the surface it creates for the pages it wants to show you.</p> + <section class="phantom-hero"> + <div class="phantom-hero-avatar-slot" data-agent-avatar> + <span class="phantom-hero-letter" data-agent-avatar-fallback data-agent-name-initial> </span> + </div> + <div class="phantom-hero-copy"> + <p class="phantom-eyebrow">the agent is awake</p> + <h1 class="phantom-display"><span data-agent-name> </span> works <em>alongside you</em>, not for you.</h1> + <p class="phantom-lead">An autonomous co-worker that remembers, acts, and evolves in your loop. Chat, schedule jobs, build pages you can bookmark, iterate between messages.</p> + <div class="phantom-hero-actions"> + <a class="phantom-button phantom-button-primary" href="/chat">Talk to <span data-agent-name> </span></a> + <a class="phantom-button phantom-button-ghost" href="/ui/dashboard/">Open dashboard</a> + </div> + </div> </section> <section class="phantom-card" style="margin-bottom:var(--space-10);"> - <div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-5);"> + <div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-5); gap:var(--space-3);"> <p class="phantom-eyebrow" style="margin:0;">Agent status</p> - <span class="phantom-badge phantom-badge-success" id="status-badge"> - <span class="phantom-dot phantom-dot-live"></span> - online - </span> + <div style="display:flex; align-items:center; gap:var(--space-3);"> + <span class="phantom-badge phantom-badge-success" id="status-badge"> + <span class="phantom-dot phantom-dot-live"></span> + online + </span> + <a class="phantom-details-link" href="/health">Details →</a> + </div> </div> <div class="phantom-grid-stats"> <div class="phantom-stat"> @@ -143,15 +213,41 @@ <h1 class="phantom-display"><span data-agent-name> </span> works <em>alongs </div> </section> + <section style="margin-bottom:var(--space-12);"> + <header class="phantom-section-header"> + <h2 class="phantom-section-title">What can <span data-agent-name> </span> do?</h2> + <p class="phantom-section-sub">Starter prompts, not a menu. <span data-agent-name> </span> decides what to actually do once you hit send.</p> + </header> + <div class="phantom-tiles-grid" id="phantom-starter-tiles" aria-busy="true"> + <div class="phantom-tile-skeleton"><div class="phantom-tile-skeleton-bar short" style="width:28px;height:28px;border-radius:var(--radius-sm);"></div><div class="phantom-tile-skeleton-bar tall"></div><div class="phantom-tile-skeleton-bar full"></div><div class="phantom-tile-skeleton-bar short"></div></div> + <div class="phantom-tile-skeleton"><div class="phantom-tile-skeleton-bar short" style="width:28px;height:28px;border-radius:var(--radius-sm);"></div><div class="phantom-tile-skeleton-bar tall"></div><div class="phantom-tile-skeleton-bar full"></div><div class="phantom-tile-skeleton-bar short"></div></div> + <div class="phantom-tile-skeleton"><div class="phantom-tile-skeleton-bar short" style="width:28px;height:28px;border-radius:var(--radius-sm);"></div><div class="phantom-tile-skeleton-bar tall"></div><div class="phantom-tile-skeleton-bar full"></div><div class="phantom-tile-skeleton-bar short"></div></div> + </div> + </section> + + <section style="margin-bottom:var(--space-12);"> + <header class="phantom-section-header"> + <h2 class="phantom-section-title">Pages <span data-agent-name> </span> has created for you</h2> + <p class="phantom-section-sub">HTML your agent has published to this server, newest first.</p> + </header> + <div class="phantom-pages-list" id="phantom-pages-list" aria-busy="true"> + <div class="phantom-page-row" aria-hidden="true" style="pointer-events:none; opacity:0.6;"> + <span class="phantom-page-path">/ui/…</span> + <span class="phantom-page-title">Loading…</span> + <span class="phantom-page-time">…</span> + </div> + </div> + </section> + <section style="margin-bottom:var(--space-10);"> <h2 class="phantom-h2">Quick links</h2> - <div style="display:grid; grid-template-columns:repeat(3,1fr); gap:var(--space-3);"> + <div style="display:grid; grid-template-columns:repeat(2,1fr); gap:var(--space-3);"> - <a href="/health" class="quick-link"> - <span class="quick-link-icon"><svg style="width:16px;height:16px;" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l2 2"/></svg></span> + <a href="/ui/dashboard/" class="quick-link"> + <span class="quick-link-icon"><svg style="width:16px;height:16px;" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z"/></svg></span> <div class="quick-link-body"> - <p class="quick-link-title">Health</p> - <p class="quick-link-desc">/health</p> + <p class="quick-link-title">Dashboard</p> + <p class="quick-link-desc">/ui/dashboard/</p> </div> <svg class="quick-link-arrow" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5"/></svg> </a> @@ -164,23 +260,6 @@ <h2 class="phantom-h2">Quick links</h2> </div> <svg class="quick-link-arrow" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5"/></svg> </a> - - <a href="/ui/dashboard/" class="quick-link"> - <span class="quick-link-icon"><svg style="width:16px;height:16px;" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z"/></svg></span> - <div class="quick-link-body"> - <p class="quick-link-title">Dashboard</p> - <p class="quick-link-desc">/ui/dashboard/</p> - </div> - <svg class="quick-link-arrow" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5"/></svg> - </a> - </div> - </section> - - <section style="margin-bottom:var(--space-12);"> - <div class="phantom-card" style="background:color-mix(in oklab, var(--color-primary) 4%, var(--color-base-200));"> - <p class="phantom-eyebrow" style="color:var(--color-primary);">What is this?</p> - <h2 class="phantom-h2" style="margin-top:4px;">Pages the agent creates for you.</h2> - <p class="phantom-body phantom-muted" style="max-width:560px;">When <span data-agent-name> </span> writes a dashboard, report, chart, or anything richer than Slack can display, it publishes the HTML here. Every page is cookie-auth protected. Ask your agent in Slack for a magic link, or run <code class="phantom-mono" style="padding:2px 6px; background:color-mix(in oklab, var(--color-base-content) 6%, transparent); border-radius:4px;">phantom_generate_login</code> via MCP.</p> </div> </section> @@ -235,22 +314,26 @@ <h2 class="phantom-h2" style="margin-top:4px;">Pages the agent creates for you.< roleEl.textContent = roleName ? roleName + " role" : "-"; } if (badge) { + while (badge.firstChild) badge.removeChild(badge.firstChild); + var dot = document.createElement("span"); + dot.className = "phantom-dot"; + var label; if (data && data.status === "ok") { badge.className = "phantom-badge phantom-badge-success"; - badge.innerHTML = '<span class="phantom-dot phantom-dot-live"></span> online'; + dot.className = "phantom-dot phantom-dot-live"; + label = "online"; } else if (data && data.status === "degraded") { badge.className = "phantom-badge"; - badge.innerHTML = '<span class="phantom-dot"></span> degraded'; + label = "degraded"; } else if (data && data.status) { badge.className = "phantom-badge"; - badge.innerHTML = '<span class="phantom-dot"></span> ' + data.status; + label = String(data.status); } else { - // /health failed or returned no status. Reset to a neutral - // "unknown" state rather than leaving the initial "online" - // badge pinned on a page that cannot reach the agent. badge.className = "phantom-badge"; - badge.innerHTML = '<span class="phantom-dot"></span> unknown'; + label = "unknown"; } + badge.appendChild(dot); + badge.appendChild(document.createTextNode(" " + label)); } } @@ -259,6 +342,186 @@ <h2 class="phantom-h2" style="margin-top:4px;">Pages the agent creates for you.< .then(apply) .catch(function () { apply(null); }); })(); + +// Starter tiles ("What can <name> do?") fetched from /ui/api/starter-prompts. +// Cardinal Rule: tile titles, descriptions, and prompts flow as bytes from the +// server response to textContent and encodeURIComponent. No client-side +// classification, no intent branching. The button just opens /chat with the +// prompt pre-filled; the agent decides what to do once the user hits send. +(function () { + var ICONS = { + chart: '<svg fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125C16.5 3.504 17.004 3 17.625 3h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z"/></svg>', + git: '<svg fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 6a4.5 4.5 0 0 1 9 0v.75a4.5 4.5 0 0 1-9 0V6ZM7.5 15.75a4.5 4.5 0 0 1 9 0V18a4.5 4.5 0 0 1-9 0v-2.25ZM12 10.5v3"/></svg>', + inbox: '<svg fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h3.218a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 0 0-2.15-1.588H6.911a2.25 2.25 0 0 0-2.15 1.588L2.35 13.177a2.25 2.25 0 0 0-.1.661Z"/></svg>', + metrics: '<svg fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3v11.25A2.25 2.25 0 0 0 6 16.5h15M3.75 3v11.25A2.25 2.25 0 0 0 6 16.5h15M3.75 3h15m-12 3-3 3 3 3m6-3h6m-9 7.5 3-3-3-3"/></svg>', + alert: '<svg fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"/></svg>', + calendar: '<svg fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5"/></svg>', + search: '<svg fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>', + globe: '<svg fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418"/></svg>', + generic: '<svg fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><circle cx="12" cy="12" r="9"/></svg>' + }; + + function iconSvg(name) { + return ICONS[name] || ICONS.generic; + } + + function renderArrow() { + var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("fill", "none"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("stroke-width", "1.8"); + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("width", "14"); + svg.setAttribute("height", "14"); + svg.setAttribute("aria-hidden", "true"); + var path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-linejoin", "round"); + path.setAttribute("d", "m8.25 4.5 7.5 7.5-7.5 7.5"); + svg.appendChild(path); + return svg; + } + + function renderTile(tile) { + var article = document.createElement("article"); + article.className = "phantom-tile"; + + var iconWrap = document.createElement("div"); + iconWrap.className = "phantom-tile-icon"; + iconWrap.setAttribute("aria-hidden", "true"); + // Icon SVG is our own asset keyed by name, not operator content, so it is + // safe to assign via innerHTML. Titles, descriptions, and prompts below + // always flow through textContent or encodeURIComponent. + iconWrap.innerHTML = iconSvg(String(tile && tile.icon)); + article.appendChild(iconWrap); + + var h3 = document.createElement("h3"); + h3.className = "phantom-tile-title"; + h3.textContent = String(tile && tile.title != null ? tile.title : ""); + article.appendChild(h3); + + var desc = document.createElement("p"); + desc.className = "phantom-tile-desc"; + desc.textContent = String(tile && tile.description != null ? tile.description : ""); + article.appendChild(desc); + + var cta = document.createElement("a"); + cta.className = "phantom-button phantom-button-ghost phantom-button-sm phantom-tile-cta"; + var rawPrompt = tile && tile.prompt != null ? String(tile.prompt) : ""; + cta.setAttribute("href", "/chat?prefill=" + encodeURIComponent(rawPrompt)); + cta.appendChild(document.createTextNode("Ask now ")); + cta.appendChild(renderArrow()); + article.appendChild(cta); + + return article; + } + + function render(tiles) { + var grid = document.getElementById("phantom-starter-tiles"); + if (!grid) return; + while (grid.firstChild) grid.removeChild(grid.firstChild); + grid.removeAttribute("aria-busy"); + if (!Array.isArray(tiles) || tiles.length === 0) { + var section = grid.parentElement; + if (section) section.style.display = "none"; + return; + } + var frag = document.createDocumentFragment(); + for (var i = 0; i < tiles.length; i++) { + frag.appendChild(renderTile(tiles[i])); + } + grid.appendChild(frag); + } + + fetch("/ui/api/starter-prompts", { credentials: "same-origin" }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { render(d && d.tiles); }) + .catch(function () { + var grid = document.getElementById("phantom-starter-tiles"); + if (grid && grid.parentElement) grid.parentElement.style.display = "none"; + }); +})(); + +// Pages list ("Pages <name> has created for you") fetched from /ui/api/pages. +// Empty state deep-links to /chat with a prefilled "build me a dashboard" +// prompt. All titles flow through textContent so agent-written <title> text +// cannot inject markup. +(function () { + function relativeTime(iso) { + var then = Date.parse(iso); + if (Number.isNaN(then)) return ""; + var diffSec = Math.floor((Date.now() - then) / 1000); + if (diffSec < 60) return "just now"; + if (diffSec < 3600) return Math.floor(diffSec / 60) + "m ago"; + if (diffSec < 86400) return Math.floor(diffSec / 3600) + "h ago"; + if (diffSec < 172800) return "yesterday"; + if (diffSec < 2592000) return Math.floor(diffSec / 86400) + "d ago"; + return new Date(then).toISOString().split("T")[0]; + } + + function renderRow(page) { + var row = document.createElement("a"); + row.className = "phantom-page-row"; + row.setAttribute("href", String(page && page.path ? page.path : "#")); + + var pathSpan = document.createElement("span"); + pathSpan.className = "phantom-page-path"; + pathSpan.textContent = String(page && page.path != null ? page.path : ""); + row.appendChild(pathSpan); + + var titleSpan = document.createElement("span"); + titleSpan.className = "phantom-page-title"; + titleSpan.textContent = String(page && page.title != null ? page.title : ""); + row.appendChild(titleSpan); + + var timeSpan = document.createElement("span"); + timeSpan.className = "phantom-page-time"; + timeSpan.textContent = String(page && page.modified_at ? relativeTime(page.modified_at) : ""); + row.appendChild(timeSpan); + + return row; + } + + function renderEmpty(container) { + container.removeAttribute("aria-busy"); + while (container.firstChild) container.removeChild(container.firstChild); + var empty = document.createElement("div"); + empty.className = "phantom-empty"; + empty.appendChild(document.createTextNode("No pages yet. Ask ")); + var nameSpan = document.createElement("span"); + nameSpan.setAttribute("data-agent-name", ""); + nameSpan.textContent = document.querySelector("[data-agent-name]") && document.querySelector("[data-agent-name]").textContent || "Phantom"; + empty.appendChild(nameSpan); + empty.appendChild(document.createTextNode(" in chat: ")); + var link = document.createElement("a"); + var prompt = "Build me a dashboard that shows the three things I care about this week."; + link.setAttribute("href", "/chat?prefill=" + encodeURIComponent(prompt)); + link.textContent = "\u201Cbuild me a dashboard that shows \u2026\u201D"; + empty.appendChild(link); + container.appendChild(empty); + } + + function render(pages) { + var list = document.getElementById("phantom-pages-list"); + if (!list) return; + list.removeAttribute("aria-busy"); + while (list.firstChild) list.removeChild(list.firstChild); + if (!Array.isArray(pages) || pages.length === 0) { + renderEmpty(list); + return; + } + var frag = document.createDocumentFragment(); + for (var i = 0; i < pages.length; i++) { + frag.appendChild(renderRow(pages[i])); + } + list.appendChild(frag); + } + + fetch("/ui/api/pages", { credentials: "same-origin" }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { render(d && d.pages); }) + .catch(function () { render([]); }); +})(); </script> </body> </html> From 69483694bf921e4797d4f6908fc9be3738ea8038 Mon Sep 17 00:00:00 2001 From: Muhammad Ahmed Cheema <cheemawrites@gmail.com> Date: Thu, 16 Apr 2026 22:11:10 -0700 Subject: [PATCH 6/7] docs: landing starter-prompts.yaml schema and customization Documents the five landing sections, the starter-prompts.yaml schema and its strict-schema fallback behavior, the supported icon keys, the public /ui/api/starter-prompts and /ui/api/pages endpoints, and the Cardinal Rule preservation note (tiles are invitations; the agent decides at submit time). --- docs/landing.md | 90 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 docs/landing.md diff --git a/docs/landing.md b/docs/landing.md new file mode 100644 index 0000000..cfab36f --- /dev/null +++ b/docs/landing.md @@ -0,0 +1,90 @@ +# Landing page (`/ui/`) + +The landing page is the first surface an operator sees when they open their +agent's URL. It has five sections: + +1. **Hero** - 120x120 avatar next to display title and two CTAs: "Talk to + `<name>`" (`/chat`) and "Open dashboard" (`/ui/dashboard/`). The avatar + falls back to an Instrument Serif letter if no avatar has been uploaded. +2. **Agent status card** - live badge and stats (agent, version, uptime, + evolution generation) fed by `/health`. A small "Details" link opens the + HTML health page. +3. **What can `<name>` do?** - 4 to 6 starter-prompt tiles. Each tile has + an icon, title, one-line description, and an "Ask now" button that + deep-links to `/chat?prefill=<urlencoded prompt>`. +4. **Pages `<name>` has created for you** - live list of agent-published + HTML files in `public/`, sorted by mtime descending, top 10. Boilerplate + (`index.html`, `dashboard/*`, `_examples/*`, `chat/*`, internal files) is + filtered out. Empty state deep-links to `/chat` with a prefilled + "build me a dashboard" prompt. +5. **Quick links** - two tiles: Dashboard and MCP endpoint. + +## Customizing the starter prompts + +Starter-prompt tiles are editable by the operator (or by the agent itself, which +has Write access to `phantom-config/`). + +Create `phantom-config/starter-prompts.yaml`: + +```yaml +tiles: + - icon: chart + title: Summarize Hacker News + description: Pull today's top stories and group them by theme. + prompt: Summarize the top Hacker News stories from the last 24 hours, grouped by theme. + - icon: git + title: Monitor my GitHub repos + description: Check for new issues, PRs, and commits across my starred repos. + prompt: Check for new issues and PRs on my GitHub repos since yesterday. +``` + +Rules: + +- Up to 6 tiles. More than 6 -> falls back to defaults. +- Each tile requires `icon`, `title`, `description`, `prompt`. Missing any + field -> falls back to defaults. +- Field caps: `title` 80 chars, `description` 200 chars, `prompt` 2000 chars. +- Unknown top-level keys or unknown tile fields reject the whole file + (strict schema). Falls back to defaults. +- If the YAML is malformed or the schema rejects, the server logs a warning + and serves defaults so the landing page never renders blank. + +### Icon keys + +The frontend maps `icon` to an inline SVG. Supported keys: + +- `chart` +- `git` +- `inbox` +- `metrics` +- `alert` +- `calendar` +- `search` +- `globe` + +Any other value renders a generic circle. + +### Cardinal Rule + +Tile titles, descriptions, and prompts are static strings. The "Ask now" +button opens `/chat?prefill=<urlencoded prompt>` and the agent decides what +to do once the user hits Send. There is no server-side classification, no +client-side intent branching. Tiles are invitations; the agent does the +thinking. + +## Endpoints + +| Endpoint | Method | Auth | Shape | +|----------|--------|------|-------| +| `/ui/api/starter-prompts` | GET | public | `{ tiles: StarterTile[] }` | +| `/ui/api/pages` | GET | public | `{ pages: PageEntry[] }` | + +Both endpoints are public because the landing page renders before the operator +authenticates. The content is operator-public copy (starter prompts) or +filenames the agent chose to publish (pages list). No sensitive state flows +through either endpoint. + +Response caching: + +- Starter prompts: `Cache-Control: private, max-age=60` +- Pages list: `Cache-Control: private, max-age=30` From 115b0a7a67abfce8b4f878fa60f17d3dad4e325c Mon Sep 17 00:00:00 2001 From: Muhammad Ahmed Cheema <cheemawrites@gmail.com> Date: Thu, 16 Apr 2026 22:30:52 -0700 Subject: [PATCH 7/7] chat-ui: drop double URL decode on ?prefill= handler P2 (Codex): URLSearchParams.get() already percent-decodes, so layering decodeURIComponent on top was a double-decode that silently corrupted literal %xx sequences in operator-authored prompts. A tile prompt 'Fetch a %20 file' would surface in the composer as 'Fetch a file'. Use the URLSearchParams value directly. Keep the PREFILL_MAX cap and the console.warn on truncation. --- chat-ui/src/routes/chat-route.tsx | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/chat-ui/src/routes/chat-route.tsx b/chat-ui/src/routes/chat-route.tsx index 5f08ba2..0facf49 100644 --- a/chat-ui/src/routes/chat-route.tsx +++ b/chat-ui/src/routes/chat-route.tsx @@ -12,21 +12,18 @@ const PREFILL_MAX = 2000; // is a consent surface, not an auto-run. function readPrefill(): string | null { if (typeof window === "undefined") return null; - const raw = new URLSearchParams(window.location.search).get("prefill"); - if (raw === null) return null; - let decoded: string; - try { - decoded = decodeURIComponent(raw); - } catch { - decoded = raw; - } - if (decoded.length > PREFILL_MAX) { + // URLSearchParams.get() already percent-decodes. Calling decodeURIComponent + // on top would double-decode and silently corrupt literal %xx substrings in + // operator-authored prompts (e.g. "Fetch a %20 file" would lose the %20). + let value = new URLSearchParams(window.location.search).get("prefill"); + if (value === null) return null; + if (value.length > PREFILL_MAX) { console.warn( - `[chat] prefill truncated from ${decoded.length} to ${PREFILL_MAX} chars`, + `[chat] prefill truncated from ${value.length} to ${PREFILL_MAX} chars`, ); - decoded = decoded.slice(0, PREFILL_MAX - 1) + "\u2026"; + value = value.slice(0, PREFILL_MAX - 1) + "\u2026"; } - return decoded; + return value; } export function ChatRoute() {