Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions chat-ui/src/components/chat-input.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -13,6 +13,7 @@ export function ChatInput({
attachments,
onAddFiles,
onRemoveFile,
initialText,
}: {
onSend: (text: string) => void;
onStop: () => void;
Expand All @@ -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();
Expand Down
36 changes: 35 additions & 1 deletion chat-ui/src/routes/chat-route.tsx
Original file line number Diff line number Diff line change
@@ -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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove redundant URI decode when reading prefill

URLSearchParams.get("prefill") already returns a decoded value, so applying decodeURIComponent again corrupts valid literal %xx text in prompts. For example, a starter prompt containing "%2F" is transformed to "/" before it reaches the composer, which silently changes user-authored content. This occurs whenever the prefill text contains percent-encoded-looking substrings without an invalid % sequence to trigger the catch path.

Useful? React with 👍 / 👎.

} 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) => {
Expand All @@ -29,6 +62,7 @@ export function ChatRoute() {
onSend={handleCreateAndNavigate}
onStop={() => {}}
isStreaming={false}
initialText={initialText}
/>
</>
);
Expand Down
90 changes: 90 additions & 0 deletions docs/landing.md
Original file line number Diff line number Diff line change
@@ -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`
Loading
Loading