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(null); const composingRef = useRef(false); const fileInputRef = useRef(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..0facf49 100644 --- a/chat-ui/src/routes/chat-route.tsx +++ b/chat-ui/src/routes/chat-route.tsx @@ -1,12 +1,42 @@ -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=` 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; + // 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 ${value.length} to ${PREFILL_MAX} chars`, + ); + value = value.slice(0, PREFILL_MAX - 1) + "\u2026"; + } + return value; +} + export function ChatRoute() { const navigate = useNavigate(); const creatingRef = useRef(false); + const [initialText, setInitialText] = useState(undefined); + + useEffect(() => { + const prefill = readPrefill(); + if (prefill === null) return; + setInitialText(prefill); + window.history.replaceState({}, "", "/chat"); + }, []); const handleCreateAndNavigate = useCallback( async (text: string) => { @@ -29,6 +59,7 @@ export function ChatRoute() { onSend={handleCreateAndNavigate} onStop={() => {}} isStreaming={false} + initialText={initialText} /> ); 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 + ``" (`/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 `` 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=`. +4. **Pages `` 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=` 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` 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 @@