fabro/apps/fabro-web/app/lib/chats-script.ts
Bryan Helmkamp 7ac15b28f0
feat(fabro-web): port /chats/new + /chats/:id from prototype (#289)
## Summary

Ports the validated `/chats/new` and `/chats/:id` chat surface from
`docs/superpowers/prototypes/2026-05-16-chats-new/` into
`apps/fabro-web`. Client-side scripted prototype mounted inside the
existing `AppShell`; replaces `/start` as the planned new "kick off
agent work" entry point (but does not delete `/start` in this phase).

- New routes: `/chats/new` (empty-state composer) and `/chats/:chatId`
(active conversation with assistant-ui's `<Thread>`, scripted streaming
replies, markdown + tool-call rendering).
- Drives `@assistant-ui/react` + `@assistant-ui/react-ui` via
`useLocalRuntime` and a custom `ChatModelAdapter` that cycles a 6-entry
scripted reply bank.
- Tailwind v4 cascade fix: assistant-ui CSS is now imported via `@layer
assistant-ui` so v4 utilities cascade above the package's unlayered
scoped preflight. Includes a discovered Bun-specific tweak — see Notable
Deviations below.
- StrictMode-safe first-message handoff: store seeds the user message
into `seedMessages` with a `pendingResponse: true` flag, and
`chats-detail` triggers a single `runtime.thread.startRun({ parentId:
null })` then consumes the flag. Avoids the prototype's
autorespond-lost-stream race under React 19 StrictMode.

The Ask-Fabro right sidebar (also in the prototype) is **out of scope**
for this PR.

Companion spec:
[`docs/superpowers/specs/2026-05-16-chats-new-prototype-design.md`](../tree/chats-new-port/docs/superpowers/specs/2026-05-16-chats-new-prototype-design.md)
Implementation plan:
[`docs/superpowers/plans/2026-05-16-chats-new-fabro-web-port.md`](../tree/chats-new-port/docs/superpowers/plans/2026-05-16-chats-new-fabro-web-port.md)

## Screenshots

Captured from a local debug `fabro server` running this branch's binary,
signed in via GitHub.

### `/chats/new` (empty state)

![chats-new empty
state](https://github.com/fabro-sh/fabro/raw/chats-new-port/docs/superpowers/prototypes/2026-05-16-chats-new/screenshots/chats-new-v4.png)

### `/chats/:chatId` (active conversation)

![chats-detail active
chat](https://github.com/fabro-sh/fabro/raw/chats-new-port/docs/superpowers/prototypes/2026-05-16-chats-new/screenshots/chats-detail-v4.png)

## Files

**New** (under `apps/fabro-web/`):
- `app/lib/chats-types.ts` — `Chat` wrapper + `ChatContentPart`
discriminated union over the API client's `CompletionContentPart`
- `app/lib/chats-script.ts` — 6-entry scripted reply bank
(`CompletionMessage[]`)
- `app/lib/chats-store.tsx` — Context + `useReducer` for chat metadata,
`pendingResponse` flag, scriptIndex
- `app/lib/chats-runtime.ts` — `createScriptedAdapter` +
`toThreadMessages` boundary converter
- `app/lib/test-utils.tsx` — minimal `renderHook` shim (lifts the
duplicated `IS_REACT_ACT_ENVIRONMENT` + dep-warning silencing pattern
out of `install-app.test.tsx`)
-
`app/components/chats/{tool-fallback,composer-chips,custom-composer}.tsx`
- `app/routes/{chats-layout,chats-new,chats-detail}.tsx`
- Tests: `chats-store.test.tsx` (5), `chats-runtime.test.ts` (4),
`chats-router.test.tsx` (3)

**Modified:**
- `package.json` — adds `@assistant-ui/{react,react-ui,react-markdown}`
(pinned exactly to versions verified in the prototype)
- `app/app.css` — `@layer` declaration + assistant-ui CSS imports into
`layer(assistant-ui)` + `.fabro-chat` `--aui-*` variable overrides
mapping to the Fabro palette
- `app/root.tsx` — removed `import "./app.css"` (see Notable Deviations)
- `app/router.tsx` — wires the chats routes under the AppShell tree

## Notable deviations from the plan

Two intentional deviations, both explained in their commit bodies:

1. **`apps/fabro-web/app/root.tsx` no longer imports `./app.css`.**
Bun's CSS bundler (used by `Bun.build` on `entry.tsx`) rejects
spec-valid `@layer name, name;` ordering between `@import` rules, even
though Tailwind's CLI accepts it. The CSS is built standalone by the
Tailwind CLI step in `scripts/build.ts` and linked from
`index.template.html`, so dropping the JS-side import bypasses Bun's
parser without any runtime change. A safety-net comment at the top of
`app.css` warns future engineers against re-adding the import. Commit:
`c37690be9`.
2. **`!` non-null assertions removed** in two places where the verbatim
prototype copy violated the global CLAUDE.md rule banning `!` in
production code: `chats-script.ts` now uses a typed `FALLBACK_REPLY` and
`??` coalescing; `composer-chips.tsx` lifts the first option of each
chip into a `DEFAULT_*` constant. `chats-runtime.test.ts`'s `for await`
drain loops were also replaced with `Array.fromAsync(...)` per the
no-loops-in-tests rule. Commits: `ace6ac6d4`, `652ad97af`.

## Test plan

- [x] `cd apps/fabro-web && bun run typecheck` — clean
- [x] `bun test` — 383 pass / 0 fail (12 new tests for chats)
- [x] `cd apps/fabro-web && bun run build` — succeeds; assistant-ui CSS
bundled into `dist/assets/app.css`
- [x] **Manual browser smoke test** — debug `fabro` binary running this
branch served `/chats/new` and `/chats/seed_email` correctly inside the
real AppShell with GitHub-OAuth auth (screenshots above).

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:10:16 -04:00

189 lines
6.1 KiB
TypeScript

import type { ChatMessage } from "./chats-types";
/**
* Scripted assistant replies cycled through per chat. Generic content,
* intentionally not Fabro-specific. Each entry is a single assistant
* ChatMessage; tool calls and their results are siblings in the content
* array so the renderer can pair them.
*/
export const SCRIPTED_REPLIES: ChatMessage[] = [
{
role: "assistant",
content: [
{
kind: "text",
data: {
text:
"Hi! I'm a scripted prototype reply. A few things I can show off:\n\n" +
"- Markdown rendering (lists, **bold**, *italics*, `code`)\n" +
"- Streaming text appearing incrementally\n" +
"- Tool calls with arguments and results\n" +
"- Multi-paragraph responses with code blocks\n\n" +
"Send another message to see the next response in the bank.",
},
},
],
},
{
role: "assistant",
content: [
{
kind: "text",
data: {
text:
"Here's a TypeScript snippet that debounces a function:\n\n" +
"```ts\n" +
"export function debounce<T extends (...args: any[]) => void>(\n" +
" fn: T,\n" +
" ms: number,\n" +
"): (...args: Parameters<T>) => void {\n" +
" let handle: ReturnType<typeof setTimeout> | undefined;\n" +
" return (...args) => {\n" +
" if (handle) clearTimeout(handle);\n" +
" handle = setTimeout(() => fn(...args), ms);\n" +
" };\n" +
"}\n" +
"```\n\n" +
"The trailing-edge variant is the most common; a leading-edge variant fires immediately then suppresses subsequent calls.",
},
},
],
},
{
role: "assistant",
content: [
{
kind: "text",
data: {
text: "Let me search for that real quick.",
},
},
{
kind: "tool_call",
data: {
tool_call_id: "call_search_1",
name: "search_web",
arguments: {
query: "current best practices for rate limiting an HTTP API",
max_results: 5,
},
},
},
{
kind: "tool_result",
data: {
tool_call_id: "call_search_1",
content: {
results: [
{
title: "Token bucket vs leaky bucket",
url: "https://example.com/rate-limit-algorithms",
snippet:
"Token bucket allows bursts, leaky bucket smooths traffic.",
},
{
title: "Distributed rate limiting with Redis",
url: "https://example.com/redis-rate-limit",
snippet:
"INCR + EXPIRE is the simplest fixed-window approach.",
},
],
},
},
},
{
kind: "text",
data: {
text:
"\n\nTwo solid starting points. For most APIs, a Redis-backed sliding window keyed by API key gives you per-tenant fairness without a lot of moving parts. For burst tolerance, a token-bucket per route is a nice layer on top.",
},
},
],
},
{
role: "assistant",
content: [
{
kind: "text",
data: {
text:
"## The 4-fold path of refactoring a hook\n\n" +
"When a React hook starts feeling tangled, work the corners in order:\n\n" +
"### 1. Extract pure computation\n" +
"Anything that is a function of inputs (no side effects, no state) leaves the hook entirely.\n\n" +
"### 2. Collapse derived state into `useMemo`\n" +
"State that is computable from other state shouldn't be its own state.\n\n" +
"### 3. Split orthogonal concerns into sibling hooks\n" +
"If two effects don't share dependencies, they don't belong in the same hook.\n\n" +
"### 4. Promote to a reducer\n" +
"Once there are 3+ related `useState` calls coordinating updates, `useReducer` makes the state machine explicit.\n\n" +
"> The honest test: can you write a one-sentence description of what the hook is responsible for? If not, it's doing too much.",
},
},
],
},
{
role: "assistant",
content: [
{
kind: "text",
data: {
text: "I'll compute that for you.",
},
},
{
kind: "tool_call",
data: {
tool_call_id: "call_calc_1",
name: "run_calculation",
arguments: {
expression: "compound_interest(principal=10000, rate=0.05, years=10)",
},
},
},
{
kind: "tool_result",
data: {
tool_call_id: "call_calc_1",
content: {
value: 16288.95,
currency: "USD",
note: "Annual compounding; rounded to cents.",
},
},
},
{
kind: "text",
data: {
text:
"\n\n**$16,288.95** after 10 years. Bumping the rate to 7% would put you at roughly $19,672, and continuous compounding at 5% lands at $16,487 — so the extra two points of rate matters more than the compounding cadence.",
},
},
],
},
{
role: "assistant",
content: [
{
kind: "text",
data: {
text:
"Good question. The short answer: it depends on whether you need transactions across multiple writes.\n\n" +
"If you do — Postgres. If everything you do is single-row, SQLite is faster, simpler to operate, and easier to back up. A surprising amount of production traffic can live happily on SQLite if you accept its one-writer-at-a-time constraint.\n\n" +
"Next step: tell me about your read/write ratio and I can be more specific.",
},
},
],
},
];
const FALLBACK_REPLY: ChatMessage = {
role: "assistant",
content: [{ kind: "text", data: { text: "(No reply available.)" } }],
};
export function pickReply(scriptIndex: number): ChatMessage {
return (
SCRIPTED_REPLIES[scriptIndex % SCRIPTED_REPLIES.length] ?? FALLBACK_REPLY
);
}