diff --git a/apps/fabro-web/app/app.css b/apps/fabro-web/app/app.css index da290d54d..d269e21b7 100644 --- a/apps/fabro-web/app/app.css +++ b/apps/fabro-web/app/app.css @@ -1,7 +1,27 @@ @import "@xterm/xterm/css/xterm.css"; + +/* + * Do NOT `import "./app.css"` from JS — Bun's CSS bundler rejects the + * `@layer name, name;` ordering rule between `@import` rules below. This file + * is built standalone by the Tailwind CLI step in `scripts/build.ts`; the + * generated `assets/app.css` is linked from `index.template.html`. + */ + +/* + * Cascade layer order. @assistant-ui/react-ui ships unlayered CSS authored + * against Tailwind v3; in Tailwind v4 utilities live in @layer utilities, and + * any unlayered CSS wins over layered CSS regardless of selector specificity. + * Putting assistant-ui in a named layer that we declare BEFORE utilities makes + * Tailwind v4 utility classes cascade above assistant-ui's scoped preflight. + */ +@layer theme, base, assistant-ui, components, utilities; + @import "tailwindcss"; @plugin "@tailwindcss/typography"; +@import "@assistant-ui/react-ui/styles/index.css" layer(assistant-ui); +@import "@assistant-ui/react-ui/styles/markdown.css" layer(assistant-ui); + @layer base { a[href], button, @@ -127,3 +147,41 @@ .graph-svg ellipse[stroke="#357f9e"], .graph-svg polygon[stroke="#357f9e"] { stroke: #67B2D7; } .graph-svg [fill="#1a1a1a"] { fill: #c6d4e0; } + +/* --------------------------------------------------------------------------- + * assistant-ui theme overrides (--aui-* variables) — mapped to Fabro tokens. + * Values are HSL component triples so assistant-ui's hsl(var(...)) wrapper + * works. Scoped to .fabro-chat so the shadcn theme cannot leak out. + * + * Source colors come from the @theme block above: + * navy-950 #0F1729 = 220 47% 11% + * panel #252C3D = 222 24% 19% + * panel-alt #1a2133 = 223 33% 15% + * teal-500 #67B2D7 = 200 60% 62% (Fabro's "teal" is a sky blue) + * mint #5AC8A8 = 163 49% 57% + * ice-100 #E8EDF3 = 213 27% 93% + * ice-300 #A8B5C5 = 213 22% 72% + * ------------------------------------------------------------------------- */ +.fabro-chat { + --aui-background: 220 47% 11%; + --aui-foreground: 0 0% 100%; + --aui-card: 222 24% 19%; + --aui-card-foreground: 0 0% 100%; + --aui-popover: 222 24% 19%; + --aui-popover-foreground: 0 0% 100%; + --aui-primary: 200 60% 62%; + --aui-primary-foreground: 220 47% 11%; + --aui-secondary: 223 33% 15%; + --aui-secondary-foreground: 0 0% 100%; + --aui-muted: 223 33% 15%; + --aui-muted-foreground: 213 22% 72%; + --aui-accent: 163 49% 57%; + --aui-accent-foreground: 220 47% 11%; + --aui-destructive: 0 76% 66%; + --aui-destructive-foreground: 0 0% 100%; + --aui-border: 218 28% 17%; + --aui-input: 218 28% 17%; + --aui-ring: 200 60% 62%; + --aui-radius: 0.5rem; + --aui-thread-max-width: 44rem; +} diff --git a/apps/fabro-web/app/components/chats/composer-chips.tsx b/apps/fabro-web/app/components/chats/composer-chips.tsx new file mode 100644 index 000000000..fc07d7838 --- /dev/null +++ b/apps/fabro-web/app/components/chats/composer-chips.tsx @@ -0,0 +1,98 @@ +import { useState } from "react"; +import { + Listbox, + ListboxButton, + ListboxOption, + ListboxOptions, +} from "@headlessui/react"; +import { + ChevronUpDownIcon, + CheckIcon, + FolderIcon, + CpuChipIcon, +} from "@heroicons/react/16/solid"; + +type Choice = { id: string; label: string }; + +const DEFAULT_PROJECT: Choice = { id: "fabro-web", label: "fabro-web" }; +const PROJECTS: Choice[] = [ + DEFAULT_PROJECT, + { id: "fabro-workflows", label: "fabro-workflows" }, + { id: "fabro-cli", label: "fabro-cli" }, +]; + +const DEFAULT_BRANCH: Choice = { id: "main", label: "main" }; +const BRANCHES: Choice[] = [ + DEFAULT_BRANCH, + { id: "develop", label: "develop" }, + { id: "feature/start-page", label: "feature/start-page" }, +]; + +const DEFAULT_MODEL: Choice = { id: "claude-opus-4-7", label: "Claude Opus 4.7" }; +const MODELS: Choice[] = [ + DEFAULT_MODEL, + { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" }, + { id: "gpt-5", label: "GPT-5" }, +]; + +function BranchIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +type IconComponent = React.ComponentType<{ className?: string }>; + +function Chip({ + options, + value, + onChange, + Icon, +}: { + options: Choice[]; + value: Choice; + onChange: (c: Choice) => void; + Icon: IconComponent; +}) { + return ( + +
+ + + {value.label} + + + + {options.map((opt) => ( + + + {opt.label} + + ))} + +
+
+ ); +} + +export default function ComposerChips() { + const [project, setProject] = useState(DEFAULT_PROJECT); + const [branch, setBranch] = useState(DEFAULT_BRANCH); + const [model, setModel] = useState(DEFAULT_MODEL); + return ( +
+ + + +
+ ); +} diff --git a/apps/fabro-web/app/components/chats/custom-composer.tsx b/apps/fabro-web/app/components/chats/custom-composer.tsx new file mode 100644 index 000000000..adbb56d97 --- /dev/null +++ b/apps/fabro-web/app/components/chats/custom-composer.tsx @@ -0,0 +1,43 @@ +import { ComposerPrimitive, ThreadPrimitive } from "@assistant-ui/react"; +import { ArrowUpIcon } from "@heroicons/react/24/solid"; +import { StopIcon } from "@heroicons/react/24/outline"; + +import ComposerChips from "./composer-chips"; + +export default function CustomComposer() { + return ( + + +
+ + + + + + + + + + + +
+
+ ); +} diff --git a/apps/fabro-web/app/components/chats/tool-fallback.tsx b/apps/fabro-web/app/components/chats/tool-fallback.tsx new file mode 100644 index 000000000..32fa82062 --- /dev/null +++ b/apps/fabro-web/app/components/chats/tool-fallback.tsx @@ -0,0 +1,48 @@ +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; +import { WrenchScrewdriverIcon } from "@heroicons/react/24/outline"; + +export default function ToolFallback(props: ToolCallMessagePartProps) { + const { toolName, args, result } = props; + return ( +
+
+ + tool + + {toolName} + +
+
+
+          {formatJson(args)}
+        
+
+ {result !== undefined && ( +
+
+            {formatJson(result)}
+          
+
+ )} +
+ ); +} + +function Section({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
+ {label} +
+
{children}
+
+ ); +} + +function formatJson(value: unknown): string { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} diff --git a/apps/fabro-web/app/layouts/app-shell.test.tsx b/apps/fabro-web/app/layouts/app-shell.test.tsx index ee7832aca..41f0e3e4f 100644 --- a/apps/fabro-web/app/layouts/app-shell.test.tsx +++ b/apps/fabro-web/app/layouts/app-shell.test.tsx @@ -6,15 +6,17 @@ describe("getVisibleNavigation", () => { const items = getVisibleNavigation(true); const names = items.map((i) => i.name); expect(names[0]).toBe("Automations"); + expect(names).toContain("Chats"); expect(names).toContain("Runs"); expect(names).toContain("Insights"); expect(names).toContain("Settings"); }); - test("hides Automations and Insights in production mode", () => { + test("hides Automations, Chats, and Insights in production mode", () => { const items = getVisibleNavigation(false); const names = items.map((i) => i.name); expect(names).not.toContain("Automations"); + expect(names).not.toContain("Chats"); expect(names).not.toContain("Insights"); expect(names).toContain("Runs"); expect(names).toContain("Settings"); diff --git a/apps/fabro-web/app/layouts/app-shell.tsx b/apps/fabro-web/app/layouts/app-shell.tsx index 15e058817..7c2a86a7f 100644 --- a/apps/fabro-web/app/layouts/app-shell.tsx +++ b/apps/fabro-web/app/layouts/app-shell.tsx @@ -11,6 +11,7 @@ import { Bars3Icon, BeakerIcon, ChartBarIcon, + ChatBubbleLeftRightIcon, Cog6ToothIcon, PlayIcon, SparklesIcon, @@ -25,11 +26,24 @@ import { useAuthMe } from "../lib/queries"; const allNavigation = [ { name: "Automations", href: "/automations", icon: SparklesIcon, demoOnly: true }, + { + name: "Chats", + href: "/chats/new", + icon: ChatBubbleLeftRightIcon, + demoOnly: true, + // /chats/new is the click target, but the entry stays active on any + // /chats/:id child route as well. + activePathPrefix: "/chats", + }, { name: "Runs", href: "/runs", icon: PlayIcon, demoOnly: false }, { name: "Insights", href: "/insights", icon: ChartBarIcon, demoOnly: true }, { name: "Settings", href: "/settings", icon: Cog6ToothIcon, demoOnly: false }, ]; +function activeFor(item: (typeof allNavigation)[number], pathname: string): boolean { + return pathname.startsWith(item.activePathPrefix ?? item.href); +} + export function getVisibleNavigation(demoMode: boolean) { return allNavigation.filter((item) => !item.demoOnly || demoMode); } @@ -61,7 +75,7 @@ export default function AppShell() { const { user, provider, demoMode } = auth; const navigation = getVisibleNavigation(demoMode); - const currentNav = navigation.find((item) => pathname.startsWith(item.href)); + const currentNav = navigation.find((item) => activeFor(item, pathname)); const title = currentNav?.name ?? ""; const lastMatch = matches[matches.length - 1]; const handle = lastMatch?.handle as { headerExtra?: React.ReactNode } | undefined; @@ -101,7 +115,7 @@ export default function AppShell() {
{navigation.map((item) => { - const current = pathname.startsWith(item.href); + const current = activeFor(item, pathname); return (
{navigation.map((item) => { - const current = pathname.startsWith(item.href); + const current = activeFor(item, pathname); return ( [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> { + 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 }); + }); +}); diff --git a/apps/fabro-web/app/lib/chats-runtime.ts b/apps/fabro-web/app/lib/chats-runtime.ts new file mode 100644 index 000000000..cc43f6f1a --- /dev/null +++ b/apps/fabro-web/app/lib/chats-runtime.ts @@ -0,0 +1,126 @@ +import type { + ChatModelAdapter, + ChatModelRunResult, + ThreadAssistantMessagePart, + ThreadMessageLike, +} from "@assistant-ui/react"; + +import type { Chat, ChatContentPart, ChatMessage } from "./chats-types"; +import { pickReply } from "./chats-script"; + +const STREAM_CHUNK_CHARS = 28; +const STREAM_CHUNK_INTERVAL_MS = 55; + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new DOMException("Aborted", "AbortError")); + return; + } + const handle = setTimeout(resolve, ms); + signal.addEventListener( + "abort", + () => { + clearTimeout(handle); + reject(new DOMException("Aborted", "AbortError")); + }, + { once: true }, + ); + }); +} + +function toAssistantParts( + content: readonly ChatContentPart[], +): ThreadAssistantMessagePart[] { + const out: ThreadAssistantMessagePart[] = []; + for (const part of content) { + if (part.kind === "text") { + out.push({ type: "text", text: part.data.text }); + } else if (part.kind === "tool_call") { + out.push({ + type: "tool-call", + toolCallId: part.data.tool_call_id, + toolName: part.data.name, + args: part.data.arguments, + argsText: JSON.stringify(part.data.arguments), + }); + } else if (part.kind === "tool_result") { + for (let i = out.length - 1; i >= 0; i--) { + const candidate = out[i]; + if ( + candidate?.type === "tool-call" && + candidate.toolCallId === part.data.tool_call_id + ) { + out[i] = { ...candidate, result: part.data.content }; + break; + } + } + } + } + return out; +} + +export function createScriptedAdapter(args: { + getChat: () => Chat | undefined; + onReplyComplete: (reply: ChatMessage) => void; +}): ChatModelAdapter { + return { + async *run({ abortSignal }) { + const chat = args.getChat(); + const reply = pickReply(chat?.scriptIndex ?? 0); + const accumulated: ChatContentPart[] = []; + + for (const part of reply.content) { + if (part.kind === "text") { + const text = part.data.text; + let cursor = 0; + accumulated.push({ kind: "text", data: { text: "" } }); + const accIndex = accumulated.length - 1; + while (cursor < text.length) { + cursor = Math.min(cursor + STREAM_CHUNK_CHARS, text.length); + accumulated[accIndex] = { + kind: "text", + data: { text: text.slice(0, cursor) }, + }; + yield buildUpdate(accumulated); + if (cursor < text.length) { + await sleep(STREAM_CHUNK_INTERVAL_MS, abortSignal); + } + } + } else { + accumulated.push(part); + yield buildUpdate(accumulated); + await sleep(STREAM_CHUNK_INTERVAL_MS * 3, abortSignal); + } + } + + args.onReplyComplete(reply); + }, + }; +} + +function buildUpdate(parts: ChatContentPart[]): ChatModelRunResult { + return { content: toAssistantParts(parts) }; +} + +export function toThreadMessages( + messages: readonly ChatMessage[], +): ThreadMessageLike[] { + return messages.map((msg) => { + if (msg.role === "user") { + return { + role: "user", + content: msg.content + .filter((p) => p.kind === "text") + .map((p) => ({ type: "text", text: p.data.text }) as const), + }; + } + if (msg.role === "assistant") { + return { + role: "assistant", + content: toAssistantParts(msg.content), + }; + } + return { role: "system", content: [] }; + }); +} diff --git a/apps/fabro-web/app/lib/chats-script.ts b/apps/fabro-web/app/lib/chats-script.ts new file mode 100644 index 000000000..9eb39661e --- /dev/null +++ b/apps/fabro-web/app/lib/chats-script.ts @@ -0,0 +1,189 @@ +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 void>(\n" + + " fn: T,\n" + + " ms: number,\n" + + "): (...args: Parameters) => void {\n" + + " let handle: ReturnType | 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 + ); +} diff --git a/apps/fabro-web/app/lib/chats-store.test.tsx b/apps/fabro-web/app/lib/chats-store.test.tsx new file mode 100644 index 000000000..1b7082b3a --- /dev/null +++ b/apps/fabro-web/app/lib/chats-store.test.tsx @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { act } from "react-test-renderer"; + +import { renderHook, setupReactTestEnv } from "./test-utils"; +import { + ChatsProvider, + useChatsActions, + useChatsState, +} from "./chats-store"; + +function useStore() { + return { ...useChatsActions(), state: useChatsState() }; +} + +function wrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +describe("chats-store reducer", () => { + let teardown: () => void = () => {}; + beforeEach(() => { + teardown = setupReactTestEnv(); + }); + afterEach(() => { + teardown(); + }); + + + test("createChatWithFirstMessage seeds title and user message", () => { + const { result } = renderHook(() => useStore(), { wrapper }); + let id = ""; + act(() => { + id = result.current.createChatWithFirstMessage("Help me with React"); + }); + const chat = result.current.state.chats[id]; + expect(chat?.title).toBe("Help me with React"); + expect(chat?.pendingResponse).toBe(true); + expect(chat?.seedMessages).toHaveLength(1); + expect(chat?.seedMessages[0]?.role).toBe("user"); + expect(chat?.seedMessages[0]?.content[0]).toEqual({ + kind: "text", + data: { text: "Help me with React" }, + }); + }); + + test("title is truncated to 40 chars at word boundary", () => { + const { result } = renderHook(() => useStore(), { wrapper }); + let id = ""; + act(() => { + id = result.current.createChatWithFirstMessage( + "Help me draft a launch announcement email for our new analytics dashboard", + ); + }); + expect(result.current.state.chats[id]?.title).toBe( + "Help me draft a launch announcement…", + ); + }); + + test("consumePendingResponse clears the flag", () => { + const { result } = renderHook(() => useStore(), { wrapper }); + let id = ""; + act(() => { + id = result.current.createChatWithFirstMessage("hi"); + }); + expect(result.current.state.chats[id]?.pendingResponse).toBe(true); + act(() => { + result.current.consumePendingResponse(id); + }); + expect(result.current.state.chats[id]?.pendingResponse).toBe(false); + }); + + test("advanceScriptIndex increments by one", () => { + const { result } = renderHook(() => useStore(), { wrapper }); + let id = ""; + act(() => { + id = result.current.createChatWithFirstMessage("hi"); + }); + expect(result.current.state.chats[id]?.scriptIndex).toBe(0); + act(() => { + result.current.advanceScriptIndex(id); + }); + expect(result.current.state.chats[id]?.scriptIndex).toBe(1); + }); + + test("seed chats appear in order on mount", () => { + const { result } = renderHook(() => useStore(), { wrapper }); + expect(result.current.state.order.length).toBeGreaterThanOrEqual(3); + const titles = result.current.state.order.map( + (id) => result.current.state.chats[id]?.title, + ); + expect(titles).toContain("Draft a launch email"); + expect(titles).toContain("Refactor a React hook"); + expect(titles).toContain("Compare Postgres vs SQLite"); + }); +}); diff --git a/apps/fabro-web/app/lib/chats-store.tsx b/apps/fabro-web/app/lib/chats-store.tsx new file mode 100644 index 000000000..94d251f01 --- /dev/null +++ b/apps/fabro-web/app/lib/chats-store.tsx @@ -0,0 +1,201 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useReducer, + type ReactNode, +} from "react"; + +import type { Chat, ChatMessage } from "./chats-types"; +import { pickReply } from "./chats-script"; + +type State = { + chats: Record; + order: string[]; // newest first +}; + +type Action = + | { + type: "create"; + id: string; + title: string; + createdAt: number; + userMessage: ChatMessage; + } + | { type: "consume_pending"; chatId: string } + | { type: "advance_script"; chatId: string }; + +function deriveTitle(text: string): string { + const trimmed = text.trim().replace(/\s+/g, " "); + if (trimmed.length <= 40) return trimmed || "New chat"; + const cut = trimmed.slice(0, 40); + const lastSpace = cut.lastIndexOf(" "); + const base = lastSpace > 20 ? cut.slice(0, lastSpace) : cut; + return `${base}…`; +} + +function userMessage(text: string): ChatMessage { + return { + role: "user", + content: [{ kind: "text", data: { text } }], + }; +} + +function seedChat(args: { + id: string; + title: string; + ageDays: number; + scriptIndex: number; + userText: string; +}): Chat { + return { + id: args.id, + title: args.title, + createdAt: Date.now() - args.ageDays * 86_400_000, + scriptIndex: args.scriptIndex + 1, // seeded reply already "consumed" + pendingResponse: false, + seedMessages: [userMessage(args.userText), pickReply(args.scriptIndex)], + }; +} + +const initialState: State = (() => { + const seeds: Chat[] = [ + seedChat({ + id: "seed_email", + title: "Draft a launch email", + ageDays: 0.5, + scriptIndex: 0, + userText: + "Help me draft a launch announcement email for our new analytics dashboard.", + }), + seedChat({ + id: "seed_hook", + title: "Refactor a React hook", + ageDays: 2, + scriptIndex: 3, + userText: + "My useChat hook has grown to 200 lines and I keep tangling concerns. How should I think about refactoring it?", + }), + seedChat({ + id: "seed_db", + title: "Compare Postgres vs SQLite", + ageDays: 6, + scriptIndex: 5, + userText: + "For a side project with ~50 daily users, should I reach for Postgres or stick with SQLite?", + }), + ]; + const chats: Record = {}; + for (const s of seeds) chats[s.id] = s; + return { chats, order: seeds.map((s) => s.id) }; +})(); + +function reducer(state: State, action: Action): State { + switch (action.type) { + case "create": { + const chat: Chat = { + id: action.id, + title: action.title, + createdAt: action.createdAt, + scriptIndex: 0, + pendingResponse: true, + seedMessages: [action.userMessage], + }; + return { + chats: { ...state.chats, [action.id]: chat }, + order: [action.id, ...state.order], + }; + } + case "consume_pending": { + const existing = state.chats[action.chatId]; + if (!existing || !existing.pendingResponse) return state; + return { + ...state, + chats: { + ...state.chats, + [action.chatId]: { ...existing, pendingResponse: false }, + }, + }; + } + case "advance_script": { + const existing = state.chats[action.chatId]; + if (!existing) return state; + return { + ...state, + chats: { + ...state.chats, + [action.chatId]: { + ...existing, + scriptIndex: existing.scriptIndex + 1, + }, + }, + }; + } + } +} + +export type ChatsActions = { + createChatWithFirstMessage: (text: string) => string; + consumePendingResponse: (chatId: string) => void; + advanceScriptIndex: (chatId: string) => void; +}; + +const ChatsStateContext = createContext(null); +const ChatsActionsContext = createContext(null); + +function shortId(): string { + return `c_${Math.random().toString(36).slice(2, 8)}`; +} + +export function ChatsProvider({ children }: { children: ReactNode }) { + const [state, dispatch] = useReducer(reducer, initialState); + + const actions = useMemo( + () => ({ + createChatWithFirstMessage(text: string) { + const id = shortId(); + dispatch({ + type: "create", + id, + title: deriveTitle(text), + createdAt: Date.now(), + userMessage: userMessage(text), + }); + return id; + }, + consumePendingResponse(chatId: string) { + dispatch({ type: "consume_pending", chatId }); + }, + advanceScriptIndex(chatId: string) { + dispatch({ type: "advance_script", chatId }); + }, + }), + [], + ); + + return ( + + + {children} + + + ); +} + +export function useChatsState(): State { + const value = useContext(ChatsStateContext); + if (!value) throw new Error("useChatsState must be used inside "); + return value; +} + +export function useChatsActions(): ChatsActions { + const value = useContext(ChatsActionsContext); + if (!value) throw new Error("useChatsActions must be used inside "); + return value; +} + +export function useChat(chatId: string | undefined): Chat | undefined { + const state = useChatsState(); + return chatId ? state.chats[chatId] : undefined; +} diff --git a/apps/fabro-web/app/lib/chats-types.ts b/apps/fabro-web/app/lib/chats-types.ts new file mode 100644 index 000000000..0272ead32 --- /dev/null +++ b/apps/fabro-web/app/lib/chats-types.ts @@ -0,0 +1,59 @@ +/** + * Stricter discriminated-union view over @qltysh/fabro-api-client's + * `CompletionContentPart` ({ kind: string; data: any }). Each variant in our + * union is assignable to the API client type at the boundary, but inside the + * chat code we get exhaustive switch checking. + */ +export type ChatContentPart = + | { kind: "text"; data: { text: string } } + | { + kind: "tool_call"; + data: { + tool_call_id: string; + name: string; + arguments: { [key: string]: JsonValue }; + }; + } + | { + kind: "tool_result"; + data: { + tool_call_id: string; + content: JsonValue; + is_error?: boolean; + }; + }; + +export type JsonValue = + | null + | string + | number + | boolean + | JsonValue[] + | { [key: string]: JsonValue }; + +export type ChatRole = "user" | "assistant" | "system"; + +/** Strict in-app message shape; widens to the API's CompletionMessage at the + * wire boundary. Keeping this strict inside the chat code lets every + * `switch (part.kind)` be exhaustive. */ +export type ChatMessage = { + role: ChatRole; + content: ChatContentPart[]; +}; + +/** + * Sidebar/store wrapper around a single chat. Messages and the in-flight + * stream live inside assistant-ui's runtime; the store holds the metadata + * needed to render the sidebar, derive titles, and drive the scripted + * reply bank. `seedMessages` is the initial history fed to the runtime via + * `initialMessages` on mount. `pendingResponse` flags a chat where the user + * sent the first message but the assistant has not yet replied. + */ +export type Chat = { + id: string; + title: string; + createdAt: number; + scriptIndex: number; + seedMessages: ChatMessage[]; + pendingResponse: boolean; +}; diff --git a/apps/fabro-web/app/lib/test-utils.tsx b/apps/fabro-web/app/lib/test-utils.tsx new file mode 100644 index 000000000..f881952de --- /dev/null +++ b/apps/fabro-web/app/lib/test-utils.tsx @@ -0,0 +1,57 @@ +import { createElement, type ReactNode } from "react"; +import TestRenderer, { act } from "react-test-renderer"; + +const IS_REACT_ACT_ENV = "IS_REACT_ACT_ENVIRONMENT" as const; + +/** + * Per-test setup for code that uses react-test-renderer: + * - Sets IS_REACT_ACT_ENVIRONMENT (required by act()). + * - Silences react-test-renderer's deprecation warning. + * + * Returns a teardown function; pair with beforeEach/afterEach so the global + * state is scoped to the test rather than leaking process-wide. + */ +export function setupReactTestEnv(): () => void { + type Globals = { [IS_REACT_ACT_ENV]?: boolean }; + const globals = globalThis as Globals; + const hadEnv = IS_REACT_ACT_ENV in globals; + const previousEnv = globals[IS_REACT_ACT_ENV]; + globals[IS_REACT_ACT_ENV] = true; + + const originalConsoleError = console.error; + console.error = ((...args: unknown[]) => { + if ( + typeof args[0] === "string" && + args[0].startsWith("react-test-renderer is deprecated") + ) { + return; + } + originalConsoleError(...args); + }) as typeof console.error; + + return () => { + console.error = originalConsoleError; + if (hadEnv) { + globals[IS_REACT_ACT_ENV] = previousEnv; + } else { + delete globals[IS_REACT_ACT_ENV]; + } + }; +} + +export function renderHook( + hook: () => T, + options: { wrapper: React.ComponentType<{ children: ReactNode }> }, +): { result: { current: T } } { + const result = { current: undefined as unknown as T }; + function HookHost() { + result.current = hook(); + return null; + } + act(() => { + TestRenderer.create( + createElement(options.wrapper, null, createElement(HookHost)), + ); + }); + return { result }; +} diff --git a/apps/fabro-web/app/root.tsx b/apps/fabro-web/app/root.tsx index a1cfa3506..063d3579d 100644 --- a/apps/fabro-web/app/root.tsx +++ b/apps/fabro-web/app/root.tsx @@ -1,5 +1,4 @@ import { isRouteErrorResponse, Outlet } from "react-router"; -import "./app.css"; export default function Root() { return ; diff --git a/apps/fabro-web/app/router.tsx b/apps/fabro-web/app/router.tsx index e29d7b432..6e1d085bc 100644 --- a/apps/fabro-web/app/router.tsx +++ b/apps/fabro-web/app/router.tsx @@ -6,6 +6,9 @@ import * as RedirectHome from "./routes/redirect-home"; import * as Setup from "./routes/setup"; import * as AuthLogin from "./routes/auth-login"; import * as Start from "./routes/start"; +import * as ChatsLayout from "./routes/chats-layout"; +import * as ChatsNew from "./routes/chats-new"; +import * as ChatsDetail from "./routes/chats-detail"; import * as Automations from "./routes/automations"; import * as AutomationDetail from "./routes/automation-detail"; import * as AutomationDefinition from "./routes/automation-definition"; @@ -87,6 +90,12 @@ export const routes: RouteObject[] = [ }), children: [ route("start", Start), + route("chats", ChatsLayout, { + children: [ + route("new", ChatsNew), + route(":chatId", ChatsDetail), + ], + }), route("automations", Automations), route("automations/:name", AutomationDetail, { children: [ diff --git a/apps/fabro-web/app/routes/chats-detail.tsx b/apps/fabro-web/app/routes/chats-detail.tsx new file mode 100644 index 000000000..def71da9c --- /dev/null +++ b/apps/fabro-web/app/routes/chats-detail.tsx @@ -0,0 +1,103 @@ +import { useEffect, useMemo, useRef } from "react"; +import { useNavigate, useParams } from "react-router"; +import { + AssistantRuntimeProvider, + useLocalRuntime, +} from "@assistant-ui/react"; +import { Thread, makeMarkdownText } from "@assistant-ui/react-ui"; + +import { useChat, useChatsActions } from "../lib/chats-store"; +import { + createScriptedAdapter, + toThreadMessages, +} from "../lib/chats-runtime"; +import CustomComposer from "../components/chats/custom-composer"; +import ToolFallback from "../components/chats/tool-fallback"; +import { EmptyState } from "../components/state"; +import type { Chat, ChatMessage } from "../lib/chats-types"; + +// AppShell handle lives on the parent chats-layout route; do not redeclare it +// here. + +const MarkdownText = makeMarkdownText(); + +export default function ChatsDetail() { + const { chatId } = useParams<{ chatId: string }>(); + const navigate = useNavigate(); + const chat = useChat(chatId); + + if (!chatId || !chat) { + return ( +
+ navigate("/chats/new")} + className="text-sm font-medium text-teal-300 hover:text-teal-500" + > + Start a new chat + + } + /> +
+ ); + } + + return ; +} + +function ChatRuntime({ chatId, chat }: { chatId: string; chat: Chat }) { + const { advanceScriptIndex, consumePendingResponse } = useChatsActions(); + + // Keep latest `chat` accessible to the stable adapter closure below without + // recreating the adapter (and the assistant-ui runtime) on every store dispatch. + const chatRef = useRef(chat); + useEffect(() => { + chatRef.current = chat; + }); + + const initialMessages = useMemo( + () => toThreadMessages(chat.seedMessages), + [chat.seedMessages], + ); + + const adapter = useMemo( + () => + createScriptedAdapter({ + getChat: () => chatRef.current, + onReplyComplete: (_reply: ChatMessage) => advanceScriptIndex(chatId), + }), + [chatId, advanceScriptIndex], + ); + + const runtime = useLocalRuntime(adapter, { initialMessages }); + + // Autorespond: chats arriving here from /chats/new carry the user's first + // message in seedMessages with pendingResponse=true. Trigger one startRun + // once per mount; the ref dedupes within a StrictMode mount cycle (state + // updates from consumePendingResponse aren't visible to the re-fired effect + // closure), and the store flag dedupes across mounts (e.g. navigating away + // and back to the same chat). + const didStartRef = useRef(false); + useEffect(() => { + if (!chat.pendingResponse || didStartRef.current) return; + didStartRef.current = true; + consumePendingResponse(chatId); + runtime.thread.startRun({ parentId: null }); + }, [chat.pendingResponse, chatId, consumePendingResponse, runtime]); + + return ( + +
+ +
+
+ ); +} diff --git a/apps/fabro-web/app/routes/chats-layout.tsx b/apps/fabro-web/app/routes/chats-layout.tsx new file mode 100644 index 000000000..e4d17df47 --- /dev/null +++ b/apps/fabro-web/app/routes/chats-layout.tsx @@ -0,0 +1,64 @@ +import { NavLink, Outlet, useNavigate } from "react-router"; +import { PencilSquareIcon } from "@heroicons/react/24/outline"; + +import { ChatsProvider, useChatsState } from "../lib/chats-store"; + +export const handle = { hideHeader: true, fullHeight: true, wide: true }; + +export default function ChatsLayout() { + return ( + + {/* Cancel AppShell's content padding (px-4 py-6 / sm:px-6 / lg:px-8) so the + * chat surface bleeds edge-to-edge below the top nav. The height grows + * by the vertical padding amount to recover the full viewport area. */} +
+ +
+ +
+
+
+ ); +} + +function Sidebar() { + const state = useChatsState(); + const navigate = useNavigate(); + return ( + + ); +} diff --git a/apps/fabro-web/app/routes/chats-new.tsx b/apps/fabro-web/app/routes/chats-new.tsx new file mode 100644 index 000000000..f696c243d --- /dev/null +++ b/apps/fabro-web/app/routes/chats-new.tsx @@ -0,0 +1,69 @@ +import { useRef, useState, type FormEvent } from "react"; +import { useNavigate } from "react-router"; +import { ArrowUpIcon } from "@heroicons/react/24/solid"; + +import { useChatsActions } from "../lib/chats-store"; +import ComposerChips from "../components/chats/composer-chips"; + +export function meta() { + return [{ title: "New chat — Fabro" }]; +} + +export default function ChatsNew() { + const navigate = useNavigate(); + const { createChatWithFirstMessage } = useChatsActions(); + const [text, setText] = useState(""); + const textareaRef = useRef(null); + + function submit(value: string) { + const trimmed = value.trim(); + if (!trimmed) return; + const id = createChatWithFirstMessage(trimmed); + navigate(`/chats/${id}`); + } + + function onSubmit(e: FormEvent) { + e.preventDefault(); + submit(text); + } + + return ( +
+
+
+