diff --git a/.gitattributes b/.gitattributes index a7f2b7f3b..068cf7f02 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ lib/crates/fabro-spa/assets/** linguist-generated=true -diff +lib/packages/fabro-api-client/src/** linguist-generated=true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9ac7fcd02..5a5c64bb4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,6 +8,7 @@ Outside contributions are welcome! Whether it's a bug fix, a new feature, docume - **Bug fixes and small improvements** -- Send a pull request directly. No need to open an issue first. - **Larger features or changes** -- Please open a [GitHub Issue](https://github.com/fabro-sh/fabro/issues) or start a [Discussion](https://github.com/fabro-sh/fabro/discussions) first so we can align on the approach before you invest significant time. +- **Prefer not to write the code yourself?** -- As an alternative to opening a PR, you can file a [GitHub Issue](https://github.com/fabro-sh/fabro/issues) describing the bug or feature. A Fabro maintainer will implement it (supervising AI coding agents and workflows) and include you as a co-author on the commit that lands the change. - **Questions** -- Open a Discussion or email [bryan@qlty.sh](mailto:bryan@qlty.sh). ## Development setup diff --git a/Cargo.lock b/Cargo.lock index de2596ff0..709e4d2f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1644,7 +1644,7 @@ dependencies = [ [[package]] name = "fabro-acp" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "agent-client-protocol", "agent-client-protocol-tokio", @@ -1666,7 +1666,7 @@ dependencies = [ [[package]] name = "fabro-agent" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -1705,7 +1705,7 @@ dependencies = [ [[package]] name = "fabro-api" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "chrono", "fabro-config", @@ -1726,7 +1726,7 @@ dependencies = [ [[package]] name = "fabro-auth" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -1751,11 +1751,11 @@ dependencies = [ [[package]] name = "fabro-build-support" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" [[package]] name = "fabro-checkpoint" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "chrono", "fabro-config", @@ -1771,7 +1771,7 @@ dependencies = [ [[package]] name = "fabro-cli" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -1872,7 +1872,7 @@ dependencies = [ [[package]] name = "fabro-client" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "anyhow", "bytes", @@ -1901,7 +1901,7 @@ dependencies = [ [[package]] name = "fabro-config" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -1929,7 +1929,7 @@ dependencies = [ [[package]] name = "fabro-core" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "async-trait", "fabro-types", @@ -1944,7 +1944,7 @@ dependencies = [ [[package]] name = "fabro-dev" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -1963,7 +1963,7 @@ dependencies = [ [[package]] name = "fabro-devcontainer" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "fabro-http", "fabro-static", @@ -1980,7 +1980,7 @@ dependencies = [ [[package]] name = "fabro-dump" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "anyhow", "bytes", @@ -1994,7 +1994,7 @@ dependencies = [ [[package]] name = "fabro-github" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -2016,7 +2016,7 @@ dependencies = [ [[package]] name = "fabro-graphviz" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "anyhow", "fabro-types", @@ -2030,7 +2030,7 @@ dependencies = [ [[package]] name = "fabro-hooks" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "async-trait", "fabro-agent", @@ -2054,7 +2054,7 @@ dependencies = [ [[package]] name = "fabro-http" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "fabro-static", "http", @@ -2064,7 +2064,7 @@ dependencies = [ [[package]] name = "fabro-install" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -2079,7 +2079,7 @@ dependencies = [ [[package]] name = "fabro-interview" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "async-trait", "dialoguer", @@ -2094,7 +2094,7 @@ dependencies = [ [[package]] name = "fabro-llm" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2128,7 +2128,7 @@ dependencies = [ [[package]] name = "fabro-macros" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "clap", "fabro-options-metadata", @@ -2139,14 +2139,13 @@ dependencies = [ [[package]] name = "fabro-manifest" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "anyhow", "fabro-api", "fabro-config", "fabro-github", "fabro-graphviz", - "fabro-template", "fabro-types", "fabro-workflow", "git2", @@ -2157,7 +2156,7 @@ dependencies = [ [[package]] name = "fabro-mcp" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "anyhow", "fabro-config", @@ -2173,7 +2172,7 @@ dependencies = [ [[package]] name = "fabro-mcp-server" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2196,7 +2195,7 @@ dependencies = [ [[package]] name = "fabro-model" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "fabro-static", "insta", @@ -2210,7 +2209,7 @@ dependencies = [ [[package]] name = "fabro-oauth" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "anyhow", "axum", @@ -2232,7 +2231,7 @@ dependencies = [ [[package]] name = "fabro-options-metadata" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "serde", "serde_json", @@ -2240,7 +2239,7 @@ dependencies = [ [[package]] name = "fabro-proc" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "cc", "libc", @@ -2249,7 +2248,7 @@ dependencies = [ [[package]] name = "fabro-redact" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "aho-corasick", "ref-cast", @@ -2265,7 +2264,7 @@ dependencies = [ [[package]] name = "fabro-sandbox" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2308,7 +2307,7 @@ dependencies = [ [[package]] name = "fabro-server" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2390,7 +2389,7 @@ dependencies = [ [[package]] name = "fabro-slack" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "fabro-http", "fabro-interview", @@ -2411,18 +2410,18 @@ dependencies = [ [[package]] name = "fabro-spa" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "rust-embed", ] [[package]] name = "fabro-static" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" [[package]] name = "fabro-store" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "async-trait", "bytes", @@ -2449,7 +2448,7 @@ dependencies = [ [[package]] name = "fabro-telemetry" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -2475,10 +2474,11 @@ dependencies = [ [[package]] name = "fabro-template" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "anyhow", "fabro-util", + "miette", "minijinja", "serde", "thiserror 2.0.18", @@ -2487,7 +2487,7 @@ dependencies = [ [[package]] name = "fabro-test" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "assert_cmd", "axum", @@ -2510,7 +2510,7 @@ dependencies = [ [[package]] name = "fabro-tracker" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2524,7 +2524,7 @@ dependencies = [ [[package]] name = "fabro-types" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "chrono", "clap", @@ -2545,7 +2545,7 @@ dependencies = [ [[package]] name = "fabro-util" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "anyhow", "console 0.15.11", @@ -2565,7 +2565,7 @@ dependencies = [ [[package]] name = "fabro-validate" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "fabro-graphviz", "fabro-model", @@ -2577,7 +2577,7 @@ dependencies = [ [[package]] name = "fabro-vault" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "chrono", "fabro-types", @@ -2589,7 +2589,7 @@ dependencies = [ [[package]] name = "fabro-workflow" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -2630,6 +2630,7 @@ dependencies = [ "hex", "httpmock", "md5", + "miette", "mime_guess", "object_store", "predicates", @@ -7491,7 +7492,7 @@ dependencies = [ [[package]] name = "twin-github" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "axum", "base64", @@ -7510,7 +7511,7 @@ dependencies = [ [[package]] name = "twin-openai" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index 1ccdb3caa..4cfb73dbe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.234.0-nightly.0" +version = "0.236.0-nightly.0" license = "MIT" [workspace.dependencies] 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/components/pull-request-chip.test.tsx b/apps/fabro-web/app/components/pull-request-chip.test.tsx new file mode 100644 index 000000000..d5716157e --- /dev/null +++ b/apps/fabro-web/app/components/pull-request-chip.test.tsx @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import TestRenderer, { act } from "react-test-renderer"; + +import { PullRequestChip } from "./pull-request-chip"; + +describe("PullRequestChip", () => { + test("renders a linked pull request number", () => { + let renderer: TestRenderer.ReactTestRenderer | undefined; + act(() => { + renderer = TestRenderer.create( + , + ); + }); + + const link = renderer!.root.findByType("a"); + const rendered = JSON.stringify(renderer!.toJSON()); + expect(link.props.href).toBe("https://github.com/acme/widgets/pull/42"); + expect(rendered).toContain("#42"); + }); +}); diff --git a/apps/fabro-web/app/components/pull-request-chip.tsx b/apps/fabro-web/app/components/pull-request-chip.tsx index 8b561f6c7..a43621de9 100644 --- a/apps/fabro-web/app/components/pull-request-chip.tsx +++ b/apps/fabro-web/app/components/pull-request-chip.tsx @@ -18,7 +18,7 @@ export function PullRequestChip({ const content = ( <> - #{number} + {`#${number}`} {children} ); diff --git a/apps/fabro-web/app/data/runs.test.ts b/apps/fabro-web/app/data/runs.test.ts index dfe10d055..391cb8c4d 100644 --- a/apps/fabro-web/app/data/runs.test.ts +++ b/apps/fabro-web/app/data/runs.test.ts @@ -5,7 +5,7 @@ import { columnStatusDisplay, isRunStatus, mapRunListItem, - mapRunSummaryToRunItem, + mapRunToRunItem, runStatusDisplay, } from "./runs"; @@ -68,14 +68,10 @@ describe("mapRunListItem", () => { title: "Server supplied title", ...withStatus({ kind: "paused", prior_block: null }), pull_request: { - number: 123, - html_url: "https://github.com/fabro-sh/fabro/pull/123", - provider: "github", owner: "fabro-sh", repo: "fabro", - base_branch: "main", - head_branch: "fabro/run/demo", - title: "Add run PR chip", + number: 123, + html_url: "https://github.com/fabro-sh/fabro/pull/123", }, }); const item = mapRunListItem(summary); @@ -98,21 +94,17 @@ describe("mapRunListItem", () => { }); }); -describe("mapRunSummaryToRunItem", () => { +describe("mapRunToRunItem", () => { test("maps canonical run summary to RunItem", () => { const summary = makeRun({ pull_request: { - html_url: "https://github.com/fabro-sh/fabro/pull/456", - number: 456, - provider: "github", owner: "fabro-sh", repo: "fabro", - base_branch: "main", - head_branch: "fabro/run/demo", - title: "Add run PR chip", + number: 456, + html_url: "https://github.com/fabro-sh/fabro/pull/456", }, }); - const item = mapRunSummaryToRunItem(summary); + const item = mapRunToRunItem(summary); expect(item.id).toBe("01ABC"); expect(item.title).toBe("Fix the build"); expect(item.workflow).toBe("fix_build"); @@ -143,7 +135,7 @@ describe("mapRunSummaryToRunItem", () => { }, billing: null, }); - const item = mapRunSummaryToRunItem(summary); + const item = mapRunToRunItem(summary); expect(item.id).toBe("01DEF"); expect(item.title).toBe("Untitled run"); expect(item.workflow).toBe("unknown"); diff --git a/apps/fabro-web/app/data/runs.ts b/apps/fabro-web/app/data/runs.ts index df3e2c289..9ff09e868 100644 --- a/apps/fabro-web/app/data/runs.ts +++ b/apps/fabro-web/app/data/runs.ts @@ -1,7 +1,6 @@ import { formatElapsedSecs, formatDurationSecs } from "../lib/format"; import { BoardColumn, - type BoardColumn as ApiBoardColumn, type Run, type RunStatus as ApiRunStatus, } from "@qltysh/fabro-api-client"; @@ -21,7 +20,7 @@ export interface RunItem { repo: string; title: string; workflow: string; - column?: ColumnStatus; + column?: BoardColumn; lifecycleStatus?: RunStatus | null; lifecycleStatusLabel?: string; number?: number; @@ -41,8 +40,6 @@ export interface RunItem { lastEventAt?: string; } -export type ColumnStatus = ApiBoardColumn; - export const columnStatuses = [ BoardColumn.QUEUED, BoardColumn.INITIALIZING, @@ -51,9 +48,9 @@ export const columnStatuses = [ BoardColumn.SUCCEEDED, BoardColumn.FAILED, BoardColumn.ARCHIVED, -] as const satisfies readonly ColumnStatus[]; +] as const satisfies readonly BoardColumn[]; -export const columnStatusDisplay: Record = { +export const columnStatusDisplay: Record = { queued: { label: "Queued", dot: "bg-fg-muted", text: "text-fg-muted" }, initializing: { label: "Initializing", dot: "bg-amber", text: "text-amber" }, running: { label: "Running", dot: "bg-teal-500", text: "text-teal-500" }, @@ -64,7 +61,7 @@ export const columnStatusDisplay: Record { 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 ( void) | null; @@ -47,12 +48,12 @@ describe("subscribeToBoardEvents", () => { test("coordinated mode shares one global source and invalidates the board runs key", async () => { const source = new FakeEventSource(); const created: string[] = []; - const keys: SseKey[] = []; + const keys: Key[] = []; const coordinator = createCoordinator((url) => { created.push(url); return source; }); - const mutate = (key: SseKey) => { + const mutate = (key: Key) => { keys.push(key); return Promise.resolve(); }; @@ -82,9 +83,9 @@ describe("subscribeToBoardEvents", () => { test("fallback mode preserves the existing shared board EventSource", () => { const source = new FakeEventSource(); const created: string[] = []; - const keys: SseKey[] = []; + const keys: Key[] = []; const coordinator = createFallbackCoordinator(); - const mutate = (key: SseKey) => { + const mutate = (key: Key) => { keys.push(key); return Promise.resolve(); }; diff --git a/apps/fabro-web/app/lib/board-events.ts b/apps/fabro-web/app/lib/board-events.ts index 97c12bfaa..77e44f520 100644 --- a/apps/fabro-web/app/lib/board-events.ts +++ b/apps/fabro-web/app/lib/board-events.ts @@ -39,6 +39,9 @@ const BOARD_STATUS_EVENTS = new Set([ "interview.completed", "interview.timeout", "interview.interrupted", + "pull_request.created", + "pull_request.linked", + "pull_request.unlinked", ]); const subscriptions = new Map(); diff --git a/apps/fabro-web/app/lib/chats-runtime.test.ts b/apps/fabro-web/app/lib/chats-runtime.test.ts new file mode 100644 index 000000000..fe22c2be9 --- /dev/null +++ b/apps/fabro-web/app/lib/chats-runtime.test.ts @@ -0,0 +1,131 @@ +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[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/cross-tab-sse.ts b/apps/fabro-web/app/lib/cross-tab-sse.ts index 3b00d3905..83c1b266f 100644 --- a/apps/fabro-web/app/lib/cross-tab-sse.ts +++ b/apps/fabro-web/app/lib/cross-tab-sse.ts @@ -1,3 +1,5 @@ +import type { Key } from "swr"; + import { queryKeys } from "./query-keys"; import { createBrowserEventSource, @@ -5,7 +7,6 @@ import { type EventPayload, type EventSourceLike, type MutateFn, - type SseKey, sseKeyDedupeId, } from "./sse"; import { getNumber, getString, isRecord, type UnknownRecord } from "./unknown"; @@ -113,7 +114,7 @@ interface SubscribeOptions { subscriptionKey: string; mutate: MutateFn; resolveInvalidation: (payload: TPayload) => EventInvalidation; - resyncKeys: () => SseKey[]; + resyncKeys: () => Key[]; fallbackSubscribe: () => () => void; debounceMs?: number; } @@ -133,11 +134,11 @@ interface LocalSubscription { refcount: number; mutators: Map; fallbacks: Map; - pendingKeys: Map; + pendingKeys: Map; debounceTimer: ReturnType | null; debounceMs: number; resolveInvalidation: (payload: EventPayload) => EventInvalidation; - resyncKeys: () => SseKey[]; + resyncKeys: () => Key[]; } interface LeaderState { @@ -689,7 +690,7 @@ export class CrossTabSseCoordinator { private queueInvalidations( subscription: LocalSubscription, - keys: SseKey[], + keys: Key[], { immediate = false }: { immediate?: boolean } = {}, ) { if (keys.length === 0) return; diff --git a/apps/fabro-web/app/lib/live-events.ts b/apps/fabro-web/app/lib/live-events.ts index 1de529bf5..72120e43a 100644 --- a/apps/fabro-web/app/lib/live-events.ts +++ b/apps/fabro-web/app/lib/live-events.ts @@ -1,3 +1,5 @@ +import type { Key } from "swr"; + import { subscribeToCrossTabSse, type CrossTabSseCoordinator, @@ -9,7 +11,6 @@ import { type EventPayload, type EventSourceLike, type SharedEventSubscription, - type SseKey, } from "./sse"; export interface LiveEventPayload extends EventPayload { @@ -29,7 +30,7 @@ interface LiveEventOptions { const subscriptions = new Map(); const SUBSCRIPTION_KEY = "live-events"; -const NO_KEYS: SseKey[] = []; +const NO_KEYS: Key[] = []; const NOOP_MUTATE = () => Promise.resolve(); export function subscribeToLiveEvents( diff --git a/apps/fabro-web/app/lib/mutations.ts b/apps/fabro-web/app/lib/mutations.ts index 8702cced5..5852a7544 100644 --- a/apps/fabro-web/app/lib/mutations.ts +++ b/apps/fabro-web/app/lib/mutations.ts @@ -148,8 +148,6 @@ export function useSubmitInterviewAnswer(runId: string | undefined) { ); } -export type SteerRunArg = SteerRunRequest; - export function useInterruptRun(runId: string | undefined) { const { mutate } = useSWRConfig(); return useSWRMutation( @@ -171,7 +169,7 @@ export function useSteerRun(runId: string | undefined) { const { mutate } = useSWRConfig(); return useSWRMutation( runId ? `steer-run:${runId}` : null, - async (_key: string, { arg }: { arg: SteerRunArg }) => { + async (_key: string, { arg }: { arg: SteerRunRequest }) => { if (!runId) throw new Error("runId is required"); await apiData(() => humanInTheLoopApi.steerRun(runId, arg)); }, diff --git a/apps/fabro-web/app/lib/run-events.test.tsx b/apps/fabro-web/app/lib/run-events.test.tsx index 75a2349d9..e6f7f48d7 100644 --- a/apps/fabro-web/app/lib/run-events.test.tsx +++ b/apps/fabro-web/app/lib/run-events.test.tsx @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import type { Key } from "swr"; import { queryKeysForRunEvent, @@ -9,7 +10,7 @@ import { type BroadcastChannelLike, } from "./cross-tab-sse"; import { queryKeys } from "./query-keys"; -import type { EventSourceLike, SseKey } from "./sse"; +import type { EventSourceLike } from "./sse"; type MessageHandler = ((event: { data: string }) => void) | null; @@ -82,7 +83,7 @@ describe("subscribeToRunEvents", () => { test("coordinated mode uses the global attach stream and filters by run_id", async () => { const source = new FakeEventSource(); const created: string[] = []; - const keys: SseKey[] = []; + const keys: Key[] = []; const coordinator = createCoordinator((url) => { created.push(url); return source; @@ -118,7 +119,7 @@ describe("subscribeToRunEvents", () => { test("coordinated terminal events invalidate without closing the global stream", async () => { const source = new FakeEventSource(); - const keys: SseKey[] = []; + const keys: Key[] = []; const coordinator = createCoordinator(() => source); const cleanup = subscribeToRunEvents( "run-terminal", @@ -150,9 +151,9 @@ describe("subscribeToRunEvents", () => { test("fallback refcounts run-scoped sources and keeps mutators active until final unsubscribe", () => { const source = new FakeEventSource(); const created: string[] = []; - const keys: SseKey[] = []; + const keys: Key[] = []; const coordinator = createFallbackCoordinator(); - const mutate = (key: SseKey) => { + const mutate = (key: Key) => { keys.push(key); return Promise.resolve(); }; @@ -184,9 +185,9 @@ describe("subscribeToRunEvents", () => { test("fallback runs payload callbacks for later subscribers on a shared source", () => { const source = new FakeEventSource(); const seen: string[] = []; - const keys: SseKey[] = []; + const keys: Key[] = []; const coordinator = createFallbackCoordinator(); - const mutate = (key: SseKey) => { + const mutate = (key: Key) => { keys.push(key); return Promise.resolve(); }; @@ -218,7 +219,7 @@ describe("subscribeToRunEvents", () => { test("fallback terminal events close the source after invalidating keys", () => { const source = new FakeEventSource(); - const keys: SseKey[] = []; + const keys: Key[] = []; const coordinator = createFallbackCoordinator(); const cleanup = subscribeToRunEvents( "run-terminal", @@ -242,7 +243,7 @@ describe("subscribeToRunEvents", () => { test("envelope with suffixed stage_id invalidates stageEvents(runId, stageId)", async () => { const source = new FakeEventSource(); - const keys: SseKey[] = []; + const keys: Key[] = []; const coordinator = createCoordinator(() => source); const cleanup = subscribeToRunEvents( "run-stage", @@ -275,7 +276,7 @@ describe("subscribeToRunEvents", () => { test("falls back to node_id when an event has no stage_id", async () => { const source = new FakeEventSource(); - const keys: SseKey[] = []; + const keys: Key[] = []; const coordinator = createCoordinator(() => source); const cleanup = subscribeToRunEvents( "run-stage-node", @@ -301,7 +302,7 @@ describe("subscribeToRunEvents", () => { const firstSource = new FakeEventSource(); const secondSource = new FakeEventSource(); const sources = [firstSource, secondSource]; - const keys: SseKey[] = []; + const keys: Key[] = []; const coordinator = createFallbackCoordinator(); const firstCleanup = subscribeToRunEvents( diff --git a/apps/fabro-web/app/lib/run-events.ts b/apps/fabro-web/app/lib/run-events.ts index a8dda4926..6ac518451 100644 --- a/apps/fabro-web/app/lib/run-events.ts +++ b/apps/fabro-web/app/lib/run-events.ts @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { useSWRConfig } from "swr"; +import { useSWRConfig, type Key } from "swr"; import { subscribeToCrossTabSse, @@ -12,7 +12,6 @@ import { type EventPayload, type EventSourceLike, type MutateFn, - type SseKey, type SharedEventSubscription, } from "./sse"; @@ -47,6 +46,9 @@ const RUN_SUMMARY_EVENTS = new Set([ "run.archived", "run.unarchived", "run.title.updated", + "pull_request.created", + "pull_request.linked", + "pull_request.unlinked", ]); const STAGE_EVENTS = new Set([ "stage.started", @@ -97,7 +99,7 @@ export function queryKeysForRunEvent( runId: string, event: string, stageId?: string, -): SseKey[] { +): Key[] { if (event === "checkpoint.completed") { return [ ...queryKeys.runs.filesAllScopes(runId), @@ -129,7 +131,7 @@ export function queryKeysForRunEvent( } if (STAGE_EVENTS.has(event)) { - const keys: SseKey[] = [ + const keys: Key[] = [ queryKeys.runs.stages(runId), queryKeys.runs.billing(runId), queryKeys.runs.events(runId, 1000), @@ -144,7 +146,7 @@ export function queryKeysForRunEvent( } if (STEERING_EVENTS.has(event)) { - const keys: SseKey[] = [queryKeys.runs.events(runId, 1000)]; + const keys: Key[] = [queryKeys.runs.events(runId, 1000)]; if (stageId) { keys.push(queryKeys.runs.stageEvents(runId, stageId)); } diff --git a/apps/fabro-web/app/lib/run-settings-snapshot.ts b/apps/fabro-web/app/lib/run-settings-snapshot.ts index e1a0490e0..35f9f50a6 100644 --- a/apps/fabro-web/app/lib/run-settings-snapshot.ts +++ b/apps/fabro-web/app/lib/run-settings-snapshot.ts @@ -1,9 +1,5 @@ -import type { WorkflowSettings } from "@qltysh/fabro-api-client"; - import { getObject } from "./unknown"; -export type Snapshot = WorkflowSettings; - export { getArray, getBool, diff --git a/apps/fabro-web/app/lib/sse.ts b/apps/fabro-web/app/lib/sse.ts index 6e98ec67c..caf02e5f5 100644 --- a/apps/fabro-web/app/lib/sse.ts +++ b/apps/fabro-web/app/lib/sse.ts @@ -1,7 +1,6 @@ import type { Key, MutatorCallback } from "swr"; -export type SseKey = Key; -export type MutateFn = (key: SseKey) => ReturnType; +export type MutateFn = (key: Key) => ReturnType; export interface EventPayload { event?: string; @@ -14,7 +13,7 @@ export interface EventSourceLike { } export interface EventInvalidation { - keys: SseKey[]; + keys: Key[]; close?: boolean; immediate?: boolean; } @@ -26,11 +25,11 @@ export interface SharedEventSubscription { refcount: number; mutators: Map; resolvers: Map; - pendingKeys: Map; + pendingKeys: Map; debounceTimer: ReturnType | null; } -export function sseKeyDedupeId(key: SseKey): string { +export function sseKeyDedupeId(key: Key): string { return stringifyKeyValue(key); } @@ -79,7 +78,7 @@ export function subscribeToSharedEventSource({ return; } - const keys = new Map(); + const keys = new Map(); let close = false; let immediate = false; for (const resolver of current.resolvers.values()) { @@ -129,7 +128,7 @@ export function subscribeToSharedEventSource({ function queueInvalidations( subscription: SharedEventSubscription, - keys: SseKey[], + keys: Key[], { debounceMs, immediate, 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/automation-runs.tsx b/apps/fabro-web/app/routes/automation-runs.tsx index 75cb6e3cd..60ffea6bc 100644 --- a/apps/fabro-web/app/routes/automation-runs.tsx +++ b/apps/fabro-web/app/routes/automation-runs.tsx @@ -3,10 +3,10 @@ import { ChevronDownIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outlin import { Link, useParams } from "react-router"; import { InlineMarkdown } from "../components/inline-markdown"; import { PullRequestChip } from "../components/pull-request-chip"; -import { ciConfig, columnForRun, columnStatusDisplay, deriveCiStatus, mapRunSummaryToRunItem } from "../data/runs"; -import type { ColumnStatus, RunWithStatus } from "../data/runs"; +import { ciConfig, columnForRun, columnStatusDisplay, deriveCiStatus, mapRunToRunItem } from "../data/runs"; +import type { RunWithStatus } from "../data/runs"; import { useWorkflowRuns } from "../lib/queries"; -import type { PaginatedRunList } from "@qltysh/fabro-api-client"; +import type { BoardColumn, PaginatedRunList } from "@qltysh/fabro-api-client"; function mapWorkflowRuns(result: PaginatedRunList | null | undefined): RunWithStatus[] { const apiRuns = result?.data ?? []; @@ -15,7 +15,7 @@ function mapWorkflowRuns(result: PaginatedRunList | null | undefined): RunWithSt const column = columnForRun(r); if (column == null) return null; return { - ...mapRunSummaryToRunItem(r), + ...mapRunToRunItem(r), status: column, statusLabel: columnStatusDisplay[column].label, }; @@ -56,7 +56,7 @@ function RunRow({ run }: { run: RunWithStatus }) { - {run.number != null && ( + {run.pullRequestUrl && run.number != null && ( {run.checks != null && } @@ -71,7 +71,7 @@ export default function AutomationRuns() { const runsQuery = useWorkflowRuns(name); const runs = mapWorkflowRuns(runsQuery.data); const [query, setQuery] = useState(""); - const [statusFilter, setStatusFilter] = useState("all"); + const [statusFilter, setStatusFilter] = useState("all"); const filtered = runs.filter( (r) => (statusFilter === "all" || r.status === statusFilter) && @@ -96,11 +96,11 @@ export default function AutomationRuns() {
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 ( +
+
+
+