fabro/apps/fabro-web/app/lib/chats-runtime.test.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

131 lines
4.2 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import type { ChatModelAdapter } from "@assistant-ui/react";
import { createScriptedAdapter, toThreadMessages } from "./chats-runtime";
import { SCRIPTED_REPLIES } from "./chats-script";
import type { Chat, ChatMessage } from "./chats-types";
const emptyChat: Chat = {
id: "c_test",
title: "",
createdAt: 0,
scriptIndex: 0,
pendingResponse: false,
seedMessages: [],
};
type RunArgs = Parameters<ChatModelAdapter["run"]>[0];
// The scripted adapter only reads `abortSignal` from RunArgs; the other fields
// belong to assistant-ui's full ModelContext surface and have no test value.
// One centralized factory keeps the unavoidable casts off the call sites.
function fakeRunArgs(abortSignal: AbortSignal): RunArgs {
return {
messages: [],
abortSignal,
runConfig: {},
context: { tools: [] } as unknown as RunArgs["context"],
unstable_getMessage: () => ({}) as never,
};
}
async function runAll(
adapter: ChatModelAdapter,
abortSignal: AbortSignal,
): Promise<Array<{ content?: readonly { type: string; text?: string }[] }>> {
const result = adapter.run(fakeRunArgs(abortSignal));
if (Symbol.asyncIterator in result) {
return await Array.fromAsync(result);
}
return [await result];
}
describe("createScriptedAdapter", () => {
test("yields chunks ending in the full scripted reply content", async () => {
let onCompleteCalled = false;
let completedReply: ChatMessage | null = null;
const adapter = createScriptedAdapter({
getChat: () => ({ ...emptyChat, scriptIndex: 0 }),
onReplyComplete: (reply) => {
onCompleteCalled = true;
completedReply = reply;
},
});
const controller = new AbortController();
const runResults = await runAll(adapter, controller.signal);
expect(onCompleteCalled).toBe(true);
expect(completedReply).toBe(SCRIPTED_REPLIES[0]);
// Final result must contain at least one text part with the full text from
// the first scripted reply.
const finalContent = runResults[runResults.length - 1]?.content;
expect(finalContent).toBeDefined();
const finalText = finalContent
?.filter((p) => p.type === "text")
.map((p) => p.text ?? "")
.join("");
const expectedText = SCRIPTED_REPLIES[0]!.content
.filter((p) => p.kind === "text")
.map((p) => p.data.text)
.join("");
expect(finalText).toBe(expectedText);
});
test("picks reply based on getChat().scriptIndex (wraps modulo bank length)", async () => {
let completed: ChatMessage | null = null;
const adapter = createScriptedAdapter({
getChat: () => ({ ...emptyChat, scriptIndex: SCRIPTED_REPLIES.length + 2 }),
onReplyComplete: (reply) => {
completed = reply;
},
});
const controller = new AbortController();
await runAll(adapter, controller.signal);
expect(completed).toBe(SCRIPTED_REPLIES[2]);
});
});
describe("toThreadMessages", () => {
test("converts a user text message", () => {
const out = toThreadMessages([
{ role: "user", content: [{ kind: "text", data: { text: "hi" } }] },
]);
expect(out).toEqual([
{ role: "user", content: [{ type: "text", text: "hi" }] },
]);
});
test("converts an assistant message with paired tool_call + tool_result", () => {
const out = toThreadMessages([
{
role: "assistant",
content: [
{
kind: "tool_call",
data: {
tool_call_id: "t1",
name: "search",
arguments: { q: "hello" },
},
},
{
kind: "tool_result",
data: { tool_call_id: "t1", content: { ok: true } },
},
],
},
]);
expect(out).toHaveLength(1);
expect(out[0]?.role).toBe("assistant");
const parts = out[0]?.content;
expect(Array.isArray(parts)).toBe(true);
if (!Array.isArray(parts)) throw new Error("expected array content");
expect(parts).toHaveLength(1);
const first = parts[0];
expect(first?.type).toBe("tool-call");
if (first?.type !== "tool-call") throw new Error("expected tool-call part");
expect(first.toolCallId).toBe("t1");
expect(first.result).toEqual({ ok: true });
});
});