diff --git a/THIRD_PARTY_NOTICES b/THIRD_PARTY_NOTICES index 51b61b61..f625a63a 100644 --- a/THIRD_PARTY_NOTICES +++ b/THIRD_PARTY_NOTICES @@ -103,8 +103,8 @@ COMPONENT INVENTORY - npm: @floating-ui/react 0.27.20 (declared license: MIT) - npm: @floating-ui/react-dom 2.1.9 (declared license: MIT) - npm: @floating-ui/utils 0.2.12 (declared license: MIT) -- npm: @fontsource/ibm-plex-mono 5.3.0 (declared license: OFL-1.1) -- npm: @fontsource/ibm-plex-sans 5.3.0 (declared license: OFL-1.1) +- npm: @fontsource-variable/geist 5.3.0 (declared license: OFL-1.1) +- npm: @fontsource-variable/geist-mono 5.3.0 (declared license: OFL-1.1) - npm: @hono/node-server 1.19.15 (declared license: MIT) - npm: @mantine/core 9.5.2 (declared license: MIT) - npm: @mantine/hooks 9.5.2 (declared license: MIT) @@ -4295,10 +4295,10 @@ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- Applies to ------------------------------------------------------------- -- npm: @fontsource/ibm-plex-mono 5.3.0 / LICENSE +- npm: @fontsource-variable/geist 5.3.0 / LICENSE ---------------------------------------------------------------------------- -Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ThinItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLight.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Light.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-LightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Regular.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Italic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Medium.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-MediumItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Bold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-BoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font) Geist-Italic[wght].ttf: Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font) This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is copied below, and is also available with a FAQ at: @@ -4393,10 +4393,10 @@ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. --- Applies to ------------------------------------------------------------- -- npm: @fontsource/ibm-plex-sans 5.3.0 / LICENSE +- npm: @fontsource-variable/geist-mono 5.3.0 / LICENSE ---------------------------------------------------------------------------- -Copyright 2019 IBM Corp. All rights reserved. IBMPlexSans-Italic[wdth,wght].ttf: Copyright 2019 IBM Corp. All rights reserved. +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git) GeistMono-Italic[wght].ttf: Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git) This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is copied below, and is also available with a FAQ at: diff --git a/docs/superpowers/plans/2026-09-05-logged-in-ui-shell.md b/docs/superpowers/plans/2026-09-05-logged-in-ui-shell.md new file mode 100644 index 00000000..be48e917 --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-logged-in-ui-shell.md @@ -0,0 +1,3669 @@ +# Logged-in UI Shell Rebuild Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the header-toggle shell with a persistent rail, turn the chat page into a pane with a sticky composer and visible agent activity, give dashboard widgets native ECharts bodies with per-widget configuration, and replace the sign-in code field with a six-digit PinInput, fixing the CSP font, "Ms", new-chat 404, header clipping and fake "Live" defects on the way. + +**Architecture:** `ui/host` is a React 19 + Mantine 9 single-page app served from `internal/ui/dist`; chat runs over AG-UI (`HttpAgent`) and renders tool results as sandboxed MCP-app iframes built from `ui/apps` into `internal/mcp/apps`. The work splits `App.tsx` into a session (`App.tsx`), a shell (`shell.tsx`), a rail (`rail.tsx`) and a chat pane (`chat.tsx`), moves the pure chart and format helpers up to `ui/` so both bundles share them, and rebuilds `dashboard.tsx` around a `widgets/` directory with one file per widget type. + +**Tech Stack:** React 19.2.8, Mantine 9.5.2 (`@mantine/core`, `@mantine/hooks`), TanStack Router 1.170 and Query 5.102, `@ag-ui/client` 0.0.59, `@modelcontextprotocol/ext-apps` 1.7.5, `react-grid-layout` 2.2.4 (`/legacy` import), ECharts 6.1.0 (SVG renderer), Phosphor icons, Vitest 4 + happy-dom, Bun for package management, `just` recipes for builds and the gate. + +**Spec:** `docs/superpowers/specs/2026-09-05-logged-in-ui-shell-design.md` + +## Global Constraints + +- Branch `ui/shell-rebuild`. Every commit message ends with the trailer line `Claude-Session: https://claude.ai/code/session_015K38gEgWmGkXJyyC8GiZ8N`. +- The lefthook pre-commit hook runs `cd ui/host && bun run lint` (which is `tsc --noEmit`) whenever staged files match `ui/host/**/*.{ts,tsx}` or `ui/theme.ts`. Every task must leave `bun run lint` green in `ui/host` before committing. `ui/apps` has the same `lint` script; run it when apps files change. +- Naming: the feature is **Chat / Chats**, the button is **New chat**. The strings "Investigations", "Investigation", "Conversation history" and "Guidance" must not appear in `ui/host/src` or `ui/apps/src` when the plan is done. +- `ui/` (the directory holding `theme.ts`, `tokens.ts`) has no `node_modules`. Files placed there may import only sibling files with relative paths, never bare package names. +- Embedded assets (`internal/ui/dist`, `internal/mcp/apps`) are committed. `just check` fails when they are stale, so a build in a task is followed either by committing the rebuilt assets (Tasks 7 and 14) or by discarding them with `git checkout -- internal/mcp/apps internal/ui/dist` (every other task). +- `echarts` is pinned to `6.1.0` in `ui/host`, the same version `ui/apps` uses. +- Widget types stay exactly `overview | topology | activity | assistant | performance | trace | logs` (server allowlist in `internal/dashboard/service.go`). No Go changes. +- Dashboard windows stay `15m | 1h | 6h | 24h | 168h | 720h`. +- Widget data refetch interval 30 000 ms, 15 000 ms while a query is in error. Dashboard list and selected-dashboard polls 30 000 ms. +- Copy in code comments, commit messages and docs is plain English prose. +- Run tests from `ui/host` with `bun run test ` (Vitest accepts a path filter). Run the full suite with `just ui-test`. + +--- + +## File Structure + +**Shared (`ui/`)** +- `ui/format.ts` — moved from `ui/apps/src/format.ts` unchanged: `integer`, `percent`, `duration`, `windowLabel`, `timelineTimestamp`. +- `ui/contracts.ts` — moved from `ui/apps/src/contracts.ts` unchanged: the observability result types. +- `ui/chart.ts` — new: `healthColor`, `chartTheme`, `statusHex`, `seriesColor`, `severityColor`, `severityHex`, pure functions over `./tokens`. + +**Host (`ui/host/src/`)** +- `api.ts` — new: `getJSON(url)` and the dashboard record types, shared by `rail.tsx` and `dashboard.tsx`. +- `app-context.ts` — new: `FanoutAppContext`, `useFanoutApp`, `createDashboardPrompt`. Breaks the circular import between the session, the shell and the pages. +- `App.tsx` — the session only: agent, thread state, drafts, subscriptions, keyboard "/" shortcut; renders `` inside the context provider. +- `shell.tsx` — new: `AppShell` with header (burger, brand, colour-scheme toggle, account menu), navbar ``, mobile `` with the same rail, Cmd-K handling. +- `rail.tsx` — new: New chat button, Chats section (threads, groups, rename/delete modals), Dashboards section, search field. Router-free; callbacks only. +- `chat.tsx` — new: `ChatPage`, `Composer`, `Welcome`, `ChatMessage`, `toolTitle`, `activityLabel`. +- `echart.tsx` — new: the `EChart` component (copy of `ui/apps/src/echart.tsx` with `height: number | string`). +- `dashboard-layout.ts` — gains `WidgetType`, `widgetDefaults`, `nextDashboardSlot`. +- `widgets/data.ts` — new: `Filters`, `widgetParams`, `useObservability`, `useLastUpdated`, `dashboardWindows`, `windowName`. +- `widgets/widget-card.tsx` — new: card chrome, hover actions menu, remove, configure modal wiring. +- `widgets/configure.tsx` — new: `ConfigureWidget` modal (service / severity / search / trace id). +- `widgets/overview.tsx`, `topology.tsx`, `activity.tsx`, `performance.tsx`, `trace.tsx`, `logs.tsx`, `assistant.tsx` — new: one body per widget type. +- `dashboard.tsx` — page rewrite: header, control row, updated line, grid. +- `auth.tsx` — `ViewerContext`/`useViewer`, PinInput code step, resend and change-email, heading sizes. +- `mcp-app-frame.tsx` — CSP `font-src`, 240px floor. +- `index.css` — rail row, chat pane, widget card rules. +- `routes/chat.index.tsx` — renders `ChatPage` (draft) instead of redirecting. +- Deleted: `chat-history.tsx`, `chat-history.test.tsx`. + +**Apps (`ui/apps/src/`)** +- `components.tsx` — `ViewHeader` slimmed; chart helpers removed (imported from `ui/chart.ts`). +- `overview.tsx`, `topology.tsx`, `performance.tsx`, `trace.tsx`, `logs.tsx` — import paths updated, `eyebrow` prop dropped. +- Deleted: `format.ts`, `contracts.ts` (moved up). + +**Tests (`ui/host/src/`)** +- `mcp-app-frame.test.tsx` (extend), `auth.test.tsx` (extend), `dashboard-layout.test.ts` (extend), `rail.test.tsx` (new, replaces `chat-history.test.tsx`), `chat.test.tsx` (new), `app.test.tsx` (new), `widgets/widgets.test.tsx` (new), `dashboard.test.tsx` (new). + +--- + +## Phase A: foundations and defects + +### Task 1: Always allow inlined fonts in the MCP app CSP + +**Files:** +- Modify: `ui/host/src/mcp-app-frame.tsx:583-602` +- Test: `ui/host/src/mcp-app-frame.test.tsx` + +**Interfaces:** +- Produces: `mcpAppCSP(meta: unknown): string` unchanged signature; output now always contains `font-src 'self' data:`. + +- [ ] **Step 1: Write the failing test** + +Append inside the `describe("MCPAppFrame")` block, after the existing "builds CSP directives only from safe declared domains" test: + +```tsx + it("always allows inlined fonts so embedded views render in the product typeface", () => { + expect(mcpAppCSP({})).toContain("font-src 'self' data:"); + expect(mcpAppCSP(undefined)).toContain("font-src 'self' data:"); + const policy = mcpAppCSP({ ui: { csp: { resourceDomains: ["https://cdn.example.com"] } } }); + expect(policy).toContain("font-src 'self' data: https://cdn.example.com"); + }); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd ui/host && bun run test src/mcp-app-frame.test.tsx` +Expected: FAIL, `expected "default-src 'none'; …" to contain "font-src 'self' data:"`. + +- [ ] **Step 3: Change the directive list** + +In `mcpAppCSP`, replace the `directives` array and the conditional `font-src` push with: + +```ts + const directives = [ + "default-src 'none'", + `script-src 'self' 'unsafe-inline'${resourceSuffix}`, + `style-src 'self' 'unsafe-inline'${resourceSuffix}`, + `img-src 'self' data:${resourceSuffix}`, + `media-src 'self' data:${resourceSuffix}`, + // The apps build inlines their woff2 files as data: URIs; without this + // every embedded view falls back to the system font. + `font-src 'self' data:${resourceSuffix}`, + `connect-src ${connect.length ? connect.join(" ") : "'none'"}`, + ]; + if (frames.length) directives.push(`frame-src ${frames.join(" ")}`); + if (bases.length) directives.push(`base-uri ${bases.join(" ")}`); +``` + +Delete the line `if (resources.length) directives.push(`font-src 'self' ${resources.join(" ")}`);`. + +- [ ] **Step 4: Run the test file to verify it passes** + +Run: `cd ui/host && bun run test src/mcp-app-frame.test.tsx` +Expected: PASS, all tests in the file. + +- [ ] **Step 5: Lint and commit** + +```bash +cd ui/host && bun run lint +cd /Users/v/Projects/labstack/fanout +git add ui/host/src/mcp-app-frame.tsx ui/host/src/mcp-app-frame.test.tsx +git commit -m "fix(ui): let embedded views load their inlined fonts + +The MCP app CSP only emitted font-src when a resource domain was declared, so +the data: woff2 files the apps build inlines were blocked and every embedded +view rendered in the system font. + +Claude-Session: https://claude.ai/code/session_015K38gEgWmGkXJyyC8GiZ8N" +``` + +--- + +### Task 2: Move format, contracts and chart helpers to `ui/` + +**Files:** +- Move: `ui/apps/src/format.ts` → `ui/format.ts`; `ui/apps/src/contracts.ts` → `ui/contracts.ts` +- Create: `ui/chart.ts` +- Modify: `ui/apps/src/components.tsx`, `ui/apps/src/overview.tsx`, `ui/apps/src/topology.tsx`, `ui/apps/src/performance.tsx`, `ui/apps/src/trace.tsx`, `ui/apps/src/logs.tsx` + +**Interfaces:** +- Produces (for Tasks 9 to 11): `import { integer, percent, duration, windowLabel, timelineTimestamp } from "../../format"` and `import type { Overview, Topology, Performance, TraceDetail, Logs, Result, ServiceHealth, Endpoint, LogEntry, TraceSpan, Edge } from "../../contracts"` from `ui/host/src`, and `import { healthColor, chartTheme, statusHex, seriesColor, severityColor, severityHex } from "../../chart"`. From `ui/host/src/widgets` the same paths are `../../../format`, `../../../contracts`, `../../../chart`. + +- [ ] **Step 1: Move the two pure modules** + +```bash +cd /Users/v/Projects/labstack/fanout +git mv ui/apps/src/format.ts ui/format.ts +git mv ui/apps/src/contracts.ts ui/contracts.ts +``` + +- [ ] **Step 2: Create `ui/chart.ts`** + +```ts +/* Chart and status helpers shared by the browser host and the embedded views. + * Pure functions over ./tokens only: this directory has no node_modules, so + * nothing here may import a package. */ +import { bad, chart, info, ok, series, warn } from "./tokens"; + +export function healthColor(health: string) { + return health === "healthy" ? "ok" : health === "degraded" ? "warn" : "bad"; +} + +/* A chart is drawn into a canvas or SVG that cannot read CSS custom + properties, so these hand ECharts resolved values from the same ramps + Mantine gets. The shade differs by scheme for the same reason the accent + does: the palette's own hue reads on Ayu, a darker stop is needed on white. */ +export function chartTheme(dark: boolean) { + return chart[dark ? "dark" : "light"]; +} + +export function statusHex(dark: boolean) { + const shade = dark ? 5 : 7; + return { ok: ok[shade], warn: warn[shade], bad: bad[shade], info: info[shade] }; +} + +/** One colour per service or metric, where the colour identifies rather than + * grades. Hashed so a service keeps its colour between renders, and drawn from + * a palette with no health hue in it. */ +export function seriesColor(name: string, dark: boolean) { + const palette = series[dark ? "dark" : "light"]; + let hash = 0; + for (const character of name) hash = (hash * 31 + character.charCodeAt(0)) | 0; + return palette[Math.abs(hash) % palette.length]; +} + +export function severityColor(value: string) { + const severity = String(value).toUpperCase(); + if (severity === "ERROR" || severity === "FATAL") return "bad"; + if (severity === "WARN" || severity === "WARNING") return "warn"; + if (severity === "INFO") return "info"; + return "gray"; +} + +export function severityHex(value: string, dark: boolean) { + const status = statusHex(dark); + const severity = String(value).toUpperCase(); + if (severity === "ERROR" || severity === "FATAL") return status.bad; + if (severity === "WARN" || severity === "WARNING") return status.warn; + if (severity === "INFO") return status.info; + return chartTheme(dark).muted; +} +``` + +- [ ] **Step 3: Strip the helpers from `ui/apps/src/components.tsx`** + +Delete the `healthColor`, `chartTheme`, `statusHex` and `seriesColor` functions and the comment block above `chartTheme` (lines 96 to 122 of the current file). Change the tokens import at the top to: + +```ts +import { fanoutCssVariables, fanoutThemeConfig } from "../../theme"; +``` + +(remove the `import { bad, chart, info, ok, series, warn } from "../../tokens";` line). + +- [ ] **Step 4: Update the five app entry files** + +In each of `overview.tsx`, `topology.tsx`, `performance.tsx`, `trace.tsx`, `logs.tsx`: + +- change `from "./contracts"` to `from "../../contracts"`; +- change `from "./format"` to `from "../../format"`; +- remove `healthColor`, `chartTheme`, `statusHex`, `seriesColor` from the `./components` import and add `import { … } from "../../chart";` with exactly the names that file uses. For reference: `overview.tsx` uses `healthColor`; `topology.tsx` uses `chartTheme`, `statusHex`; `performance.tsx` uses `chartTheme`, `healthColor`, `seriesColor`, `statusHex`; `trace.tsx` uses `seriesColor` and its own `severityColor`; `logs.tsx` uses `chartTheme`, `statusHex` and its own `severityColor` and `severityHex`. +- in `trace.tsx` delete the local `function severityColor(value: string) {…}` and import `severityColor` from `"../../chart"`; +- in `logs.tsx` delete the local `severityColor` and `severityHex` functions and import both from `"../../chart"`. + +- [ ] **Step 5: Type-check and build the apps, then discard the built HTML** + +```bash +cd ui/apps && bun run lint && bun run build +cd /Users/v/Projects/labstack/fanout && git checkout -- internal/mcp/apps +git status --short +``` + +Expected: lint clean, five `✓ built` lines, and `git status` shows only the `ui/` changes. + +- [ ] **Step 6: Commit** + +```bash +git add ui/format.ts ui/contracts.ts ui/chart.ts ui/apps/src +git commit -m "refactor(ui): share format, contracts and chart helpers between host and apps + +Claude-Session: https://claude.ai/code/session_015K38gEgWmGkXJyyC8GiZ8N" +``` + +--- + +### Task 3: Expose the signed-in account to the app + +**Files:** +- Modify: `ui/host/src/auth.tsx` +- Test: `ui/host/src/auth.test.tsx` + +**Interfaces:** +- Produces: `export type Viewer = { id: string; email: string; name: string; role: string }` and `export function useViewer(): Viewer` from `./auth`. Available anywhere under `AuthGate` once authenticated. + +- [ ] **Step 1: Write the failing test** + +Append to `auth.test.tsx`, inside the `describe` block: + +```tsx + it("exposes the signed-in account through useViewer", async () => { + window.happyDOM.setURL("https://fanout.example.com/"); + fetchMock.mockImplementation(async (input) => authResponse(input, { + id: "viewer-123", + email: "v@example.com", + name: "Vee", + role: "viewer", + })); + function Probe() { + const viewer = useViewer(); + return
Signed in as {viewer.email} ({viewer.role})
; + } + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + await act(async () => root.render( + + + , + )); + + await vi.waitFor(() => expect(document.body.textContent).toContain("Signed in as v@example.com (viewer)")); + await act(async () => root.unmount()); + }); +``` + +Change the import line to `import AuthGate, { useViewer } from "./auth";`. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd ui/host && bun run test src/auth.test.tsx` +Expected: FAIL, `useViewer` is not exported (type error surfaces as a runtime `TypeError: useViewer is not a function`). + +- [ ] **Step 3: Add the viewer context** + +In `auth.tsx`, after the `RuntimeStatusContext` declaration: + +```ts +export type Viewer = { id: string; email: string; name: string; role: string }; +const ViewerContext = createContext(null); + +export function useViewer(): Viewer { + const viewer = useContext(ViewerContext); + if (!viewer) throw new Error("Fanout viewer is unavailable"); + return viewer; +} + +function viewerFromMe(user: unknown): Viewer | null { + if (!user || typeof user !== "object") return null; + const record = user as Record; + if (typeof record.id !== "string" || record.id === "") return null; + return { + id: record.id, + email: typeof record.email === "string" ? record.email : "", + name: typeof record.name === "string" ? record.name : "", + role: typeof record.role === "string" ? record.role : "", + }; +} +``` + +Inside `AuthGate`, add state `const [account, setAccount] = useState(null);` and a loader: + +```ts + async function loadAccount() { + const response = await fetch("/api/auth/me", { credentials: "same-origin" }); + if (!response.ok) { setViewer("none"); setAccount(null); return; } + const user = await response.json().catch(() => null); + setViewer(browserViewerFromMe(user)); + setAccount(viewerFromMe(user)); + } +``` + +Replace the inline `/api/auth/me` fetch chain in the first effect with `loadAccount().catch(() => setViewer("none")).finally(() => setSessionReady(true));`. + +Everywhere the code sets `setViewer("user")` after a successful `login-link`, `verify` or `setup` call, follow it with `void loadAccount();` (three places: the login-token effect, `submit` for the setup and verify branches, and the "Continue to Fanout" button). The `browserViewerFromMe` import stays. + +Wrap the authenticated render: + +```tsx + if (authenticated) return + {children} + ; +``` + +The fallback covers the instant between a successful verify and the `/me` reload; `email` and `name` are the form fields already in state. + +- [ ] **Step 4: Run the test file to verify it passes** + +Run: `cd ui/host && bun run test src/auth.test.tsx` +Expected: PASS, seven tests. + +- [ ] **Step 5: Lint and commit** + +```bash +cd ui/host && bun run lint +cd /Users/v/Projects/labstack/fanout +git add ui/host/src/auth.tsx ui/host/src/auth.test.tsx +git commit -m "feat(ui): expose the signed-in account to the app + +Claude-Session: https://claude.ai/code/session_015K38gEgWmGkXJyyC8GiZ8N" +``` + +--- + +## Phase B: shell + +### Task 4: Shared API helper and the rail + +**Files:** +- Create: `ui/host/src/api.ts`, `ui/host/src/rail.tsx` +- Test: `ui/host/src/rail.test.tsx` (new) +- Delete: `ui/host/src/chat-history.tsx`, `ui/host/src/chat-history.test.tsx` +- Modify: `ui/host/src/index.css` (rename the drawer row rules) + +**Interfaces:** +- Produces from `./api`: `getJSON(url: string): Promise`, `type DashboardSummary = { id: string; name: string; description: string; is_default: boolean; widget_count: number; updated_at: string }`, `type DashboardRecord`, `type Envelope = { data: T }`, `dashboardsQueryKey = ["dashboards"] as const`. +- Produces from `./rail`: `export const threadHistoryQueryKey = ["agent-threads"] as const`, `export type RailHandle = { focusSearch(): void }`, `export type RailProps = { agentAvailable: boolean; activeThreadID?: string; activeDashboardID?: string; autoFocusSearch?: boolean; onNewChat: () => void; onSelectThread: (threadID: string) => void; onDeletedThread: (threadID: string) => void; onSelectDashboard: (dashboardID: string) => void; onCreateDashboard: () => void; ref?: Ref }`, default export `Rail`. +- Consumes: `authorizedFetch` from `./auth`. + +- [ ] **Step 1: Create `api.ts`** + +```ts +import { authorizedFetch } from "./auth"; + +export type Envelope = { data: T }; +export type DashboardSummary = { id: string; name: string; description: string; is_default: boolean; widget_count: number; updated_at: string }; +export type DashboardLayoutRecord = { i: string; x: number; y: number; w: number; h: number; minW?: number; minH?: number }; +export type DashboardWidgetRecord = { id: string; type: string; title: string; config?: Record; enabled: boolean }; +export type DashboardState = { layout: DashboardLayoutRecord[]; widgets: DashboardWidgetRecord[]; filters: { window: string; namespace: string } }; +export type DashboardRecord = { id: string; name: string; description: string; is_default: boolean; state: DashboardState; updated_at: string }; + +export const dashboardsQueryKey = ["dashboards"] as const; + +export async function getJSON(url: string): Promise { + const response = await authorizedFetch(url); + if (!response.ok) throw new Error(`Request failed (${response.status})`); + return response.json() as Promise; +} +``` + +- [ ] **Step 2: Write the failing rail test** + +Create `rail.test.tsx`: + +```tsx +import { MantineProvider } from "@mantine/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, createRef } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import Rail, { type RailHandle } from "./rail"; + +const fetchMock = vi.fn(); + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +function threadsPage(query = "") { + return json({ + threads: [{ threadId: "thread-checkout", title: query ? `Result for ${query}` : "Checkout latency", updatedAt: "2026-07-22 03:00:00" }], + nextCursor: "", + }); +} + +function dashboards() { + return json({ dashboards: [ + { id: "dash-main", name: "System overview", description: "", is_default: true, widget_count: 4, updated_at: "2026-07-22 03:00:00" }, + { id: "dash-checkout", name: "Checkout", description: "", is_default: false, widget_count: 2, updated_at: "2026-07-22 03:00:00" }, + ] }); +} + +function respond(input: RequestInfo | URL, init?: RequestInit) { + const url = new URL(String(input), "http://localhost"); + if (url.pathname === "/api/dashboards") return dashboards(); + if (url.pathname === "/api/agent/threads") return threadsPage(url.searchParams.get("q") ?? ""); + if (url.pathname.startsWith("/api/agent/threads/") && init?.method === "PATCH") return json({ title: "Checkout follow-up" }); + if (url.pathname.startsWith("/api/agent/threads/") && init?.method === "DELETE") return new Response(null, { status: 204 }); + throw new Error(`unexpected request: ${url.pathname}`); +} + +function mount(props: Partial[0]> = {}) { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const handlers = { + onNewChat: vi.fn(), onSelectThread: vi.fn(), onDeletedThread: vi.fn(), onSelectDashboard: vi.fn(), onCreateDashboard: vi.fn(), + }; + const ref = createRef(); + const render = () => root.render( + + + + + , + ); + return { root, handlers, ref, render }; +} + +function setValue(input: HTMLInputElement, value: string) { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(input, value); + input.dispatchEvent(new InputEvent("input", { bubbles: true, data: value, inputType: "insertText" })); + input.dispatchEvent(new Event("change", { bubbles: true })); +} + +describe("Rail", () => { + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); + fetchMock.mockImplementation(async (input, init) => respond(input, init)); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + document.body.innerHTML = ""; + }); + + it("lists chats and dashboards, marks the active rows, and selects", async () => { + const { root, handlers, render } = mount(); + await act(async () => render()); + await vi.waitFor(() => expect(document.body.textContent).toContain("Checkout latency")); + await vi.waitFor(() => expect(document.body.textContent).toContain("System overview")); + expect(document.body.textContent).toContain("Chats"); + expect(document.body.textContent).toContain("Dashboards"); + expect(document.body.textContent).not.toContain("Investigation"); + + const activeRows = document.querySelectorAll('[aria-current="page"]'); + expect(activeRows).toHaveLength(2); + + const thread = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.includes("Checkout latency")); + await act(async () => thread?.click()); + expect(handlers.onSelectThread).toHaveBeenCalledWith("thread-checkout"); + + const dashboard = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.includes("Checkout") && !button.textContent.includes("latency")); + await act(async () => dashboard?.click()); + expect(handlers.onSelectDashboard).toHaveBeenCalledWith("dash-checkout"); + + const newChat = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "New chat"); + await act(async () => newChat?.click()); + expect(handlers.onNewChat).toHaveBeenCalled(); + await act(async () => root.unmount()); + }); + + it("hides chat affordances when the agent is unavailable", async () => { + const { root, render } = mount({ agentAvailable: false }); + await act(async () => render()); + await vi.waitFor(() => expect(document.body.textContent).toContain("System overview")); + expect(document.body.textContent).not.toContain("New chat"); + expect(document.body.textContent).not.toContain("Chats"); + expect(document.body.textContent).not.toContain("Create with AI"); + expect(fetchMock.mock.calls.some(([input]) => String(input).includes("/api/agent/threads"))).toBe(false); + await act(async () => root.unmount()); + }); + + it("searches chats on the server and filters dashboards locally", async () => { + const { root, ref, render } = mount(); + await act(async () => render()); + await vi.waitFor(() => expect(document.body.textContent).toContain("Checkout latency")); + act(() => ref.current?.focusSearch()); + const search = document.querySelector('input[aria-label="Search chats and dashboards"]') as HTMLInputElement; + expect(document.activeElement).toBe(search); + await act(async () => setValue(search, "checkout")); + await vi.waitFor(() => expect(fetchMock.mock.calls.some(([input]) => String(input).includes("q=checkout"))).toBe(true), { timeout: 1500 }); + await vi.waitFor(() => expect(document.body.textContent).toContain("Result for checkout")); + expect(document.body.textContent).toContain("Checkout"); + expect(document.body.textContent).not.toContain("System overview"); + await act(async () => root.unmount()); + }); + + it("renames and deletes a chat", async () => { + const { root, handlers, render } = mount(); + await act(async () => render()); + await vi.waitFor(() => expect(document.querySelector('button[aria-label="Actions for Checkout latency"]')).not.toBeNull()); + const openActions = () => document.querySelector('button[aria-label="Actions for Checkout latency"]') as HTMLButtonElement; + + await act(async () => openActions().click()); + const rename = Array.from(document.querySelectorAll('[role="menuitem"]')).find((item) => item.textContent?.includes("Rename")); + await act(async () => rename?.click()); + const name = document.querySelector('input[value="Checkout latency"]') as HTMLInputElement; + await act(async () => setValue(name, "Checkout follow-up")); + const save = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Save"); + await act(async () => save?.click()); + await vi.waitFor(() => expect(fetchMock.mock.calls.some(([, init]) => init?.method === "PATCH")).toBe(true)); + + await act(async () => openActions().click()); + const remove = Array.from(document.querySelectorAll('[role="menuitem"]')).find((item) => item.textContent?.includes("Delete")); + await act(async () => remove?.click()); + const confirm = Array.from(document.querySelectorAll('[role="dialog"] button')).find((button) => button.textContent?.trim() === "Delete"); + await act(async () => confirm?.click()); + await vi.waitFor(() => expect(fetchMock.mock.calls.some(([, init]) => init?.method === "DELETE")).toBe(true)); + expect(handlers.onDeletedThread).toHaveBeenCalledWith("thread-checkout"); + await act(async () => root.unmount()); + }); +}); +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `cd ui/host && bun run test src/rail.test.tsx` +Expected: FAIL, `Cannot find module './rail'`. + +- [ ] **Step 4: Create `rail.tsx`** + +```tsx +import { ActionIcon, Alert, Badge, Box, Button, Center, Group, Kbd, Loader, Menu, Modal, ScrollArea, Stack, Text, TextInput, UnstyledButton } from "@mantine/core"; +import { useDebouncedValue } from "@mantine/hooks"; +import { DotsThree, MagnifyingGlass, PencilSimple, Plus, Sparkle, Trash } from "@phosphor-icons/react"; +import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useImperativeHandle, useMemo, useRef, useState, type Ref } from "react"; +import { dashboardsQueryKey, getJSON, type DashboardSummary } from "./api"; +import { authorizedFetch } from "./auth"; + +export const threadHistoryQueryKey = ["agent-threads"] as const; + +export type RailHandle = { focusSearch(): void }; + +export type RailProps = { + agentAvailable: boolean; + activeThreadID?: string; + activeDashboardID?: string; + /** The mobile drawer mounts a second rail; it focuses search on open. */ + autoFocusSearch?: boolean; + onNewChat: () => void; + onSelectThread: (threadID: string) => void; + onDeletedThread: (threadID: string) => void; + onSelectDashboard: (dashboardID: string) => void; + onCreateDashboard: () => void; + ref?: Ref; +}; + +type ThreadSummary = { threadId: string; title: string; updatedAt: string }; +type ThreadPage = { threads: ThreadSummary[]; nextCursor: string }; + +async function fetchThreads(query: string, cursor: string): Promise { + const params = new URLSearchParams({ limit: "30" }); + if (query) params.set("q", query); + if (cursor) params.set("cursor", cursor); + const response = await authorizedFetch(`/api/agent/threads?${params}`); + if (!response.ok) throw new Error(`Unable to load chats (${response.status})`); + return response.json(); +} + +export default function Rail({ agentAvailable, activeThreadID, activeDashboardID, autoFocusSearch = false, onNewChat, onSelectThread, onDeletedThread, onSelectDashboard, onCreateDashboard, ref }: RailProps) { + const queryClient = useQueryClient(); + const [search, setSearch] = useState(""); + const [query] = useDebouncedValue(search.trim(), 250); + const [renaming, setRenaming] = useState(null); + const [deleting, setDeleting] = useState(null); + const [renameTitle, setRenameTitle] = useState(""); + const [mutationError, setMutationError] = useState(""); + const [busy, setBusy] = useState(false); + const searchRef = useRef(null); + useImperativeHandle(ref, () => ({ focusSearch: () => searchRef.current?.focus() }), []); + useEffect(() => { if (autoFocusSearch) requestAnimationFrame(() => searchRef.current?.focus()); }, [autoFocusSearch]); + + const history = useInfiniteQuery({ + queryKey: [...threadHistoryQueryKey, query], + queryFn: ({ pageParam }) => fetchThreads(query, pageParam), + initialPageParam: "", + getNextPageParam: (last) => last.nextCursor || undefined, + enabled: agentAvailable, + }); + const dashboards = useQuery({ queryKey: dashboardsQueryKey, queryFn: () => getJSON<{ dashboards: DashboardSummary[] }>("/api/dashboards"), refetchInterval: 30_000 }); + const threads = useMemo(() => history.data?.pages.flatMap((page) => page.threads) ?? [], [history.data]); + const groups = useMemo(() => groupThreads(threads), [threads]); + const visibleDashboards = useMemo(() => { + const items = dashboards.data?.dashboards ?? []; + const needle = query.toLowerCase(); + return needle ? items.filter((item) => item.name.toLowerCase().includes(needle)) : items; + }, [dashboards.data, query]); + + function beginRename(thread: ThreadSummary) { setMutationError(""); setRenameTitle(thread.title); setRenaming(thread); } + + async function renameThread() { + if (!renaming || !renameTitle.trim()) return; + setBusy(true); setMutationError(""); + try { + const response = await authorizedFetch(`/api/agent/threads/${encodeURIComponent(renaming.threadId)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: renameTitle.trim() }) }); + if (!response.ok) throw new Error(`Unable to rename chat (${response.status})`); + await queryClient.invalidateQueries({ queryKey: threadHistoryQueryKey }); + setRenaming(null); + } catch (cause) { + setMutationError(cause instanceof Error ? cause.message : "Unable to rename this chat."); + } finally { setBusy(false); } + } + + async function deleteThread() { + if (!deleting) return; + setBusy(true); setMutationError(""); + try { + const response = await authorizedFetch(`/api/agent/threads/${encodeURIComponent(deleting.threadId)}`, { method: "DELETE" }); + if (!response.ok) throw new Error(`Unable to delete chat (${response.status})`); + const deletedID = deleting.threadId; + setDeleting(null); + await queryClient.invalidateQueries({ queryKey: threadHistoryQueryKey }); + onDeletedThread(deletedID); + } catch (cause) { + setMutationError(cause instanceof Error ? cause.message : "Unable to delete this chat."); + } finally { setBusy(false); } + } + + return + {agentAvailable && } + + + {agentAvailable && + Chats + {history.isLoading &&
} + {history.isError && Chats could not be loaded.} + {!history.isLoading && !history.isError && threads.length === 0 && {query ? "No matching chats" : "No chats yet"}} + {groups.map((group) => + {groups.length > 1 && {group.label}} + {group.threads.map((thread) => onSelectThread(thread.threadId)} onRename={() => beginRename(thread)} onDelete={() => { setMutationError(""); setDeleting(thread); }} />)} + )} + {history.hasNextPage && } +
} + + Dashboards + {dashboards.isLoading &&
} + {dashboards.isError && Dashboards could not be loaded.} + {visibleDashboards.map((dashboard) => { + const active = dashboard.id === activeDashboardID; + return onSelectDashboard(dashboard.id)}> + + {dashboard.name} + {dashboard.is_default && Default} + + ; + })} + {agentAvailable && } +
+
+
+ setSearch(event.currentTarget.value)} leftSection={} rightSection={⌘K} rightSectionWidth={44} placeholder="Search" aria-label="Search chats and dashboards" size="sm" /> + !busy && setRenaming(null)} title="Rename chat" centered> +
{ event.preventDefault(); void renameThread(); }}> + + setRenameTitle(event.currentTarget.value)} maxLength={120} autoFocus /> + {mutationError && {mutationError}} + + + + + +
+
+ !busy && setDeleting(null)} title="Delete chat?" centered> + + This permanently removes {deleting?.title} and its saved messages. + {mutationError && {mutationError}} + + + + + + +
; +} + +function SectionLabel({ children }: { children: string }) { + return {children}; +} + +function ThreadRow({ thread, active, onSelect, onRename, onDelete }: { thread: ThreadSummary; active: boolean; onSelect: () => void; onRename: () => void; onDelete: () => void }) { + return + + + + {thread.title} + {threadTime(thread.updatedAt)} + + + + + + + + } onClick={onRename}>Rename + } onClick={onDelete}>Delete + + + + ; +} + +function parseSQLiteTime(value: string): Date { + if (value.includes("T")) return new Date(value); + return new Date(`${value.replace(" ", "T")}Z`); +} + +function dayStart(value: Date): number { + return new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); +} + +export function groupThreads(threads: ThreadSummary[]): Array<{ label: string; threads: ThreadSummary[] }> { + const today = dayStart(new Date()); + const day = 24 * 60 * 60 * 1000; + const groups = new Map(); + for (const thread of threads) { + const age = Math.floor((today - dayStart(parseSQLiteTime(thread.updatedAt))) / day); + const label = age <= 0 ? "Today" : age === 1 ? "Yesterday" : age <= 7 ? "Previous 7 days" : "Older"; + const group = groups.get(label) ?? []; + group.push(thread); + groups.set(label, group); + } + return [...groups].map(([label, items]) => ({ label, threads: items })); +} + +export function threadTime(value: string): string { + const date = parseSQLiteTime(value); + const today = dayStart(new Date()); + const age = Math.floor((today - dayStart(date)) / (24 * 60 * 60 * 1000)); + if (age <= 1) return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(date); + return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(date); +} +``` + +- [ ] **Step 5: Rename the row rules in `index.css`** + +Replace the four `.chat-history-*` rules with: + +```css +.rail-row { + border-radius: var(--mantine-radius-md); + transition: background-color 120ms ease; +} + +.rail-row:hover { + background: var(--mantine-color-default-hover); +} + +.rail-row[data-active] { + background: var(--mantine-primary-color-light); +} + +.rail-row-actions { + opacity: 0; + transition: opacity 120ms ease; +} + +.rail-row:hover .rail-row-actions, +.rail-row:focus-within .rail-row-actions { + opacity: 1; +} + +@media (hover: none) { + .rail-row-actions { + opacity: 1; + } +} +``` + +- [ ] **Step 6: Delete the drawer** + +```bash +cd /Users/v/Projects/labstack/fanout +git rm -q ui/host/src/chat-history.tsx ui/host/src/chat-history.test.tsx +``` + +`App.tsx` still imports `./chat-history`; Task 5 replaces that import. Until then `bun run lint` fails on `App.tsx`, so this task and Task 5 are committed together at the end of Task 5. Run the rail test alone now. + +- [ ] **Step 7: Run the rail test to verify it passes** + +Run: `cd ui/host && bun run test src/rail.test.tsx` +Expected: PASS, four tests. + +--- + +### Task 5: App context, shell, and the session split + +**Files:** +- Create: `ui/host/src/app-context.ts`, `ui/host/src/shell.tsx` +- Modify: `ui/host/src/App.tsx` (rewrite), `ui/host/src/routes/dashboards.index.tsx`, `ui/host/src/routes/dashboards.$dashboardId.tsx`, `ui/host/src/routes/chat.index.tsx`, `ui/host/src/routes/chat.$threadId.tsx`, `ui/host/src/dashboard.tsx` (imports only), `ui/host/src/index.css` +- Test: `ui/host/src/app.test.tsx` (new) + +**Interfaces:** +- Produces from `./app-context`: + +```ts +export type FanoutAppContextValue = { + agentAvailable: boolean; + threadID: string; // the thread the composer sends to (route param or draft id) + threadMissing: boolean; // the route thread returned 404 + messages: Message[]; + messageTimes: Record; // message id -> epoch ms, when known + ready: boolean; + running: boolean; + activity: string; // "Checking system health…" while a tool runs, else "" + input: string; + setInput: (value: string) => void; + error: string; + bottomRef: RefObject; + inputRef: RefObject; + send: (text: string) => Promise; + submit: (event: FormEvent) => void; + stop: () => void; + retry: () => void; + openChat: (prompt?: string) => void; // new thread, optional first prompt + newThread: () => void; // go to /chat draft + selectThread: (threadID: string) => void; +}; +export const FanoutAppContext: Context; +export function useFanoutApp(): FanoutAppContextValue; +export const createDashboardPrompt: string; +``` + +- Consumes: `Rail` and `RailHandle` from `./rail`; `useViewer`, `logout`, `useRuntimeStatus`, `authorizedFetch` from `./auth`; `ChatPage` from `./chat` (Task 6 creates it; until then `routes/chat.*` keep importing `ChatPage` from `../App`, see Step 8). + +- [ ] **Step 1: Create `app-context.ts`** + +```ts +import type { Message } from "@ag-ui/client"; +import { createContext, useContext, type FormEvent, type RefObject } from "react"; + +export type FanoutAppContextValue = { + agentAvailable: boolean; + threadID: string; + threadMissing: boolean; + messages: Message[]; + messageTimes: Record; + ready: boolean; + running: boolean; + activity: string; + input: string; + setInput: (value: string) => void; + error: string; + bottomRef: RefObject; + inputRef: RefObject; + send: (text: string) => Promise; + submit: (event: FormEvent) => void; + stop: () => void; + retry: () => void; + openChat: (prompt?: string) => void; + newThread: () => void; + selectThread: (threadID: string) => void; +}; + +export const FanoutAppContext = createContext(null); + +export function useFanoutApp(): FanoutAppContextValue { + const context = useContext(FanoutAppContext); + if (!context) throw new Error("Fanout app context is unavailable"); + return context; +} + +export const createDashboardPrompt = "Create a new dashboard for me. First ask what I want to monitor, then design it when you have enough context."; +``` + +- [ ] **Step 2: Create `shell.tsx`** + +```tsx +import { ActionIcon, Alert, AppShell, Avatar, Burger, Drawer, Group, Menu, Tooltip, useComputedColorScheme, useMantineColorScheme } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { GithubLogo, GlobeHemisphereWest, Moon, SignOut, Sun } from "@phosphor-icons/react"; +import { useNavigate, useParams, useRouterState } from "@tanstack/react-router"; +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { createDashboardPrompt, useFanoutApp } from "./app-context"; +import { logout, useViewer } from "./auth"; +import { BrandLockup } from "./brand"; +import Rail, { type RailHandle, type RailProps } from "./rail"; + +export default function Shell({ children }: { children: ReactNode }) { + const { agentAvailable, threadID, threadMissing, newThread, selectThread, openChat } = useFanoutApp(); + const navigate = useNavigate(); + const pathname = useRouterState({ select: (state) => state.location.pathname }); + const { dashboardId } = useParams({ strict: false }) as { dashboardId?: string }; + const isChat = pathname === "/chat" || pathname.startsWith("/chat/"); + const [drawerOpened, drawer] = useDisclosure(false); + const [signOutError, setSignOutError] = useState(""); + const railRef = useRef(null); + + // A navigation from inside the drawer should close it. + useEffect(() => { drawer.close(); }, [pathname]); + + useEffect(() => { + const shortcuts = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { + event.preventDefault(); + if (railRef.current) railRef.current.focusSearch(); else drawer.open(); + } + }; + window.addEventListener("keydown", shortcuts); + return () => window.removeEventListener("keydown", shortcuts); + }, []); + + const railProps: Omit = { + agentAvailable, + activeThreadID: isChat && !threadMissing && pathname !== "/chat" ? threadID : undefined, + activeDashboardID: dashboardId, + onNewChat: newThread, + onSelectThread: selectThread, + onDeletedThread: (deletedID) => { if (deletedID === threadID) newThread(); }, + onSelectDashboard: (id) => void navigate({ to: "/dashboards/$dashboardId", params: { dashboardId: id } }), + onCreateDashboard: () => openChat(createDashboardPrompt), + }; + + return + + + + + + + + + + + + + + } overlayProps={{ backgroundOpacity: 0.24, blur: 1 }}> + + + + {signOutError && setSignOutError("")}>{signOutError}} + {children} + + ; +} + +function ColorSchemeToggle() { + const { setColorScheme } = useMantineColorScheme(); + // Reading the computed scheme rather than the stored one means the button + // offers the opposite of what is on screen even while the setting is "auto". + const scheme = useComputedColorScheme("light", { getInitialValueInEffect: true }); + const next = scheme === "dark" ? "light" : "dark"; + return + setColorScheme(next)}> + {scheme === "dark" ? : } + + ; +} + +function AccountMenu({ onError }: { onError: (message: string) => void }) { + const viewer = useViewer(); + const initial = (viewer.name || viewer.email || "?").trim().charAt(0).toUpperCase(); + return + + + {initial} + + + + {viewer.email || "Signed in"} + }>Fanout on GitHub + }>LabStack + + } onClick={() => void logout().catch((cause) => onError(cause instanceof Error ? cause.message : "Sign-out failed — your session is still active."))}>Sign out + + ; +} +``` + +- [ ] **Step 3: Rewrite `App.tsx` as the session** + +Replace the whole file with: + +```tsx +import { HttpAgent, type Message } from "@ag-ui/client"; +import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query"; +import { Outlet, useNavigate, useParams } from "@tanstack/react-router"; +import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; +import { FanoutAppContext } from "./app-context"; +import AuthGate, { authorizedFetch, useRuntimeStatus } from "./auth"; +import { activityLabel } from "./chat"; +import { createID } from "./id"; +import { threadHistoryQueryKey } from "./rail"; +import Shell from "./shell"; + +function Session() { + const { agent_available: agentAvailable } = useRuntimeStatus(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { threadId: routeThreadID } = useParams({ strict: false }) as { threadId?: string }; + // A draft is a thread the browser has named but the server has not seen. + // Its id is remembered so the route change on first send does not trigger + // a fetch for a thread that does not exist yet. + const [draftID, setDraftID] = useState(createID); + const draftsRef = useRef(new Set()); + const threadID = routeThreadID ?? draftID; + const [messages, setMessages] = useState([]); + const [messageTimes, setMessageTimes] = useState>({}); + const [loadedThreadID, setLoadedThreadID] = useState(""); + const [threadMissing, setThreadMissing] = useState(false); + const [running, setRunning] = useState(false); + const [activity, setActivity] = useState(""); + const [input, setInput] = useState(""); + const [error, setError] = useState(""); + const pendingPromptRef = useRef(""); + const bottomRef = useRef(null); + const inputRef = useRef(null); + const agent = useMemo(() => new HttpAgent({ url: "/api/agent", threadId: threadID, fetch: (url, init) => authorizedFetch(url, init) }), [threadID]); + const ready = !agentAvailable || loadedThreadID === threadID; + + useEffect(() => { + let active = true; + setMessages([]); + setMessageTimes({}); + setLoadedThreadID(""); + setThreadMissing(false); + setRunning(false); + setActivity(""); + setError(""); + if (!agentAvailable) { setLoadedThreadID(threadID); return; } + const isDraft = !routeThreadID || draftsRef.current.has(threadID); + if (isDraft) { + agent.setMessages([]); + setLoadedThreadID(threadID); + } else { + authorizedFetch(`/api/agent/threads/${encodeURIComponent(threadID)}`).then(async (response) => { + if (response.status === 404) return null; + if (!response.ok) throw new Error(`Unable to load thread (${response.status})`); + return response.json() as Promise<{ messages?: Message[] }>; + }).then((thread) => { + if (!active) return; + if (thread === null) { setThreadMissing(true); setLoadedThreadID(threadID); return; } + agent.setMessages(thread.messages ?? []); + setMessages([...(thread.messages ?? [])]); + setLoadedThreadID(threadID); + }).catch(() => { + if (!active) return; + pendingPromptRef.current = ""; + setError("This chat could not be restored. Start a new chat or try again."); + }); + } + const subscription = agent.subscribe({ + onEvent: ({ messages: next }) => setMessages([...next] as Message[]), + onRunInitialized: () => { setRunning(true); setError(""); }, + onToolCallStartEvent: ({ event }) => setActivity(activityLabel(event.toolCallName)), + onToolCallEndEvent: () => setActivity(""), + onRunFinalized: ({ messages: next }) => { + const finished = [...next] as Message[]; + setMessages(finished); + setMessageTimes((times) => { + const stamped = { ...times }; + for (const message of finished) if (message.role === "assistant" && !stamped[message.id]) stamped[message.id] = Date.now(); + return stamped; + }); + setRunning(false); + setActivity(""); + draftsRef.current.delete(threadID); + void queryClient.invalidateQueries({ queryKey: threadHistoryQueryKey }); + }, + onRunFailed: (failure) => { + console.error("Agent run failed", failure); + setError("Fanout could not complete this analysis."); + setRunning(false); + setActivity(""); + void queryClient.invalidateQueries({ queryKey: threadHistoryQueryKey }); + }, + }); + return () => { active = false; subscription.unsubscribe(); agent.abortRun(); }; + }, [agent, agentAvailable, queryClient, routeThreadID, threadID]); + + useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); }, [messages, running]); + + useEffect(() => { + if (!agentAvailable) return; + const shortcuts = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null; + const editing = target?.matches("input, textarea, [contenteditable='true']"); + if (event.key === "/" && !editing) { + event.preventDefault(); + if (routeThreadID) requestAnimationFrame(() => inputRef.current?.focus()); + else void navigate({ to: "/chat" }).then(() => requestAnimationFrame(() => inputRef.current?.focus())); + } + if (event.key === "Escape" && target === inputRef.current) { setInput(""); inputRef.current?.blur(); } + }; + window.addEventListener("keydown", shortcuts); + return () => window.removeEventListener("keydown", shortcuts); + }, [agentAvailable, navigate, routeThreadID]); + + async function run() { + setRunning(true); + setError(""); + try { await agent.runAgent(); } catch (cause) { + console.error("Agent run failed", cause); + setError("Fanout could not complete this analysis."); + setRunning(false); + setActivity(""); + } + } + + async function send(text: string) { + const content = text.trim(); + if (!agentAvailable || !content || running || !ready || threadMissing) return; + if (!routeThreadID) { + draftsRef.current.add(threadID); + void navigate({ to: "/chat/$threadId", params: { threadId: threadID }, replace: true }); + } + const message = { id: createID(), role: "user", content } as Message; + agent.addMessage(message); + setMessages([...agent.messages]); + setMessageTimes((times) => ({ ...times, [message.id]: Date.now() })); + setInput(""); + await run(); + } + + useEffect(() => { + const prompt = pendingPromptRef.current; + if (!ready || !routeThreadID || !prompt) return; + pendingPromptRef.current = ""; + void send(prompt); + }, [ready, routeThreadID, threadID]); + + function submit(event: FormEvent) { event.preventDefault(); void send(input); } + function stop() { agent.abortRun(); setRunning(false); setActivity(""); } + function retry() { if (!running && agent.messages.some((message) => message.role === "user")) void run(); } + function openChat(prompt?: string) { + if (!agentAvailable) return; + const nextThreadID = createID(); + draftsRef.current.add(nextThreadID); + pendingPromptRef.current = prompt ?? ""; + void navigate({ to: "/chat/$threadId", params: { threadId: nextThreadID } }); + } + function newThread() { + agent.abortRun(); + pendingPromptRef.current = ""; + setDraftID(createID()); + void navigate({ to: "/chat" }); + } + function selectThread(selectedThreadID: string) { + void navigate({ to: "/chat/$threadId", params: { threadId: selectedThreadID } }); + } + + return + + ; +} + +export default function App() { + const queryClient = useMemo(() => new QueryClient(), []); + return ; +} +``` + +- [ ] **Step 4: Create a minimal `chat.tsx` so the session compiles** + +Task 6 fills this file in. For now create `ui/host/src/chat.tsx` with the three pieces the session and the routes need, moved verbatim from the old `App.tsx`: `ChatPage`, `Composer`, `Welcome`, `ChatMessage`, `toolTitle`, plus: + +```tsx +const activityLabels: Record = { + observability_overview: "Checking system health…", + service_topology: "Mapping service dependencies…", + service_performance: "Reading performance signals…", + trace_detail: "Inspecting a trace…", + search_logs: "Searching logs…", + intelligence_snapshot: "Reviewing detected anomalies…", +}; + +export function activityLabel(toolName: string): string { + return activityLabels[toolName] ?? "Working on it…"; +} +``` + +Change every `useFanoutApp` import in the moved code to `import { useFanoutApp } from "./app-context";` and change `ChatPage` to read `threadMissing` and `retry` from the context so the file type-checks against the new context shape (the visuals are redone in Task 6). Keep the old `Composer` markup for now but drop its `pos="fixed"` wrapper, replacing it with ``, because the footer it was offset against is gone. + +- [ ] **Step 5: Update the routes** + +`routes/chat.index.tsx`: + +```tsx +import { createFileRoute } from "@tanstack/react-router"; +import { ChatPage } from "../chat"; + +export const Route = createFileRoute("/chat/")({ + component: ChatPage, +}); +``` + +`routes/chat.$threadId.tsx`: change the import to `import { ChatPage } from "../chat";`. + +`routes/dashboards.index.tsx` and `routes/dashboards.$dashboardId.tsx`: change `import { useFanoutApp } from "../App";` to `import { useFanoutApp } from "../app-context";`. + +`dashboard.tsx`: replace the local `getJSON`, `Envelope`, `DashboardRecord`, `DashboardSummary` declarations with `import { dashboardsQueryKey, getJSON, type DashboardRecord, type DashboardSummary, type Envelope } from "./api";` and use `dashboardsQueryKey` where `["dashboards"]` appears. (The page is rewritten in Task 11; this keeps it compiling.) + +- [ ] **Step 6: Remove the dead CSS** + +In `index.css` delete the `.chat-composer-field` rules' dependence on a fixed footer: keep the two `.chat-composer-field` rules as they are (they only style border and focus ring). Nothing else changes in this step. + +- [ ] **Step 7: Write the session test** + +Create `app.test.tsx`: + +```tsx +import { MantineProvider } from "@mantine/core"; +import { createRootRoute, createRoute, createRouter, Outlet, RouterProvider } from "@tanstack/react-router"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +declare global { + interface Window { happyDOM: { setURL(url: string): void } } +} + +const agentMocks = vi.hoisted(() => ({ runAgent: vi.fn(async () => undefined), abortRun: vi.fn(), instances: [] as Array<{ threadId: string }> })); + +vi.mock("@ag-ui/client", () => ({ + HttpAgent: class { + threadId: string; + messages: Array<{ id: string; role: string; content?: string }> = []; + constructor(options: { threadId: string }) { this.threadId = options.threadId; agentMocks.instances.push(this); } + subscribe() { return { unsubscribe: () => undefined }; } + setMessages(messages: Array<{ id: string; role: string; content?: string }>) { this.messages = messages; } + addMessage(message: { id: string; role: string; content?: string }) { this.messages = [...this.messages, message]; } + runAgent = agentMocks.runAgent; + abortRun = agentMocks.abortRun; + }, +})); + +vi.mock("./auth", () => ({ + default: ({ children }: { children: React.ReactNode }) => children, + useRuntimeStatus: () => ({ setup_required: false, auth_mode: "local", agent_available: true, smtp_configured: true, self_signup: false }), + useViewer: () => ({ id: "viewer-1", email: "v@example.com", name: "Vee", role: "admin" }), + authorizedFetch: (input: RequestInfo | URL, init?: RequestInit) => fetch(input, init), + logout: vi.fn(async () => undefined), + clearSession: vi.fn(), +})); + +import App from "./App"; +import { ChatPage } from "./chat"; + +const fetchMock = vi.fn(); + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +function setValue(input: HTMLTextAreaElement, value: string) { + Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set?.call(input, value); + input.dispatchEvent(new InputEvent("input", { bubbles: true, data: value, inputType: "insertText" })); +} + +describe("Session", () => { + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); + agentMocks.runAgent.mockClear(); + agentMocks.instances.length = 0; + fetchMock.mockImplementation(async (input) => { + const url = new URL(String(input), "http://localhost"); + if (url.pathname === "/api/dashboards") return json({ dashboards: [] }); + if (url.pathname === "/api/agent/threads") return json({ threads: [], nextCursor: "" }); + if (url.pathname.startsWith("/api/agent/threads/")) return json({ message: "not found" }, 404); + throw new Error(`unexpected request: ${url.pathname}`); + }); + window.happyDOM.setURL("https://fanout.example.com/chat"); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + document.body.innerHTML = ""; + }); + + it("starts a draft without fetching a thread and names the thread on first send", async () => { + const rootRoute = createRootRoute({ component: App }); + const chatIndex = createRoute({ getParentRoute: () => rootRoute, path: "/chat/", component: ChatPage }); + const chatThread = createRoute({ getParentRoute: () => rootRoute, path: "/chat/$threadId", component: ChatPage }); + const router = createRouter({ routeTree: rootRoute.addChildren([chatIndex, chatThread]) }); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + await act(async () => root.render()); + await vi.waitFor(() => expect(document.querySelector('textarea[aria-label="Message Fanout"]')).not.toBeNull()); + expect(fetchMock.mock.calls.some(([input]) => String(input).includes("/api/agent/threads/"))).toBe(false); + + const composer = document.querySelector('textarea[aria-label="Message Fanout"]') as HTMLTextAreaElement; + await act(async () => setValue(composer, "Summarize system health")); + await act(async () => composer.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }))); + + await vi.waitFor(() => expect(agentMocks.runAgent).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(window.location.pathname).toMatch(/^\/chat\/[0-9a-f-]{36}$/)); + expect(fetchMock.mock.calls.some(([input]) => String(input).includes("/api/agent/threads/"))).toBe(false); + expect(document.body.textContent).not.toContain("no longer exists"); + + await act(async () => root.unmount()); + }); + + it("explains a thread that no longer exists", async () => { + window.happyDOM.setURL("https://fanout.example.com/chat/deleted-thread"); + const rootRoute = createRootRoute({ component: App }); + const chatThread = createRoute({ getParentRoute: () => rootRoute, path: "/chat/$threadId", component: ChatPage }); + const router = createRouter({ routeTree: rootRoute.addChildren([chatThread]) }); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + await act(async () => root.render()); + await vi.waitFor(() => expect(document.body.textContent).toContain("This chat no longer exists")); + expect(document.body.textContent).toContain("New chat"); + await act(async () => root.unmount()); + }); +}); +``` + +The `Outlet` import is unused; remove it if `tsc` complains. `ChatPage` in Task 6 renders the "This chat no longer exists" alert; the placeholder from Step 4 must already render that text when `threadMissing` is true, so add to the Step 4 `ChatPage`: `if (threadMissing) return ;` reading `newThread` from the context. + +- [ ] **Step 8: Run tests and lint** + +Run: `cd ui/host && bun run lint && bun run test` +Expected: lint clean; every test file passes (auth, auth-session, dashboard-layout, mcp-app-frame, rail, app). + +- [ ] **Step 9: Build the host and check the shell by hand** + +```bash +cd /Users/v/Projects/labstack/fanout && just ui-host +``` + +Start the local instance (memory note "local UI screenshot loop") and open `http://127.0.0.1:7520/chat`. Confirm: rail on the left with New chat, Chats, Dashboards, Search; header has burger (below 992px), brand, theme toggle, avatar; no footer; avatar menu shows the email and Sign out; Cmd-K focuses search; at 390px the burger opens the drawer and nothing clips. Then discard the built assets: + +```bash +git checkout -- internal/ui/dist +``` + +- [ ] **Step 10: Commit Tasks 4 and 5 together** + +```bash +git add ui/host/src +git commit -m "feat(ui): replace the header toggle and history drawer with a persistent rail + +The rail is the only navigation: New chat, the chat list with rename and +delete, the dashboard list, and search on Cmd-K. The footer, the Live dot and +the Dashboard/Chat toggle are gone; sign out and the external links live in +the account menu. The session no longer fetches a thread it has just named, +which removes the 404 on every new chat. + +Claude-Session: https://claude.ai/code/session_015K38gEgWmGkXJyyC8GiZ8N" +``` + +--- + +## Phase C: chat pane and embedded views + +### Task 6: The chat pane + +**Files:** +- Modify: `ui/host/src/chat.tsx` (full rewrite of the Task 5 placeholder), `ui/host/src/index.css` +- Test: `ui/host/src/chat.test.tsx` (new) + +**Interfaces:** +- Produces from `./chat`: `ChatPage()`, `activityLabel(toolName: string): string`, `toolTitle(name: string): string`. `Composer` and `Welcome` stay module-private. +- Consumes: `useFanoutApp` from `./app-context`; `MCPAppFrame` and `MCPAppContent` from `./mcp-app-frame`; `BrandMark` from `./brand`. + +- [ ] **Step 1: Write the failing test** + +Create `chat.test.tsx`: + +```tsx +import { MantineProvider } from "@mantine/core"; +import type { Message } from "@ag-ui/client"; +import { act, createRef, type FormEvent } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FanoutAppContext, type FanoutAppContextValue } from "./app-context"; +import { ChatPage } from "./chat"; + +function value(overrides: Partial = {}): FanoutAppContextValue { + return { + agentAvailable: true, threadID: "thread-1", threadMissing: false, messages: [], messageTimes: {}, ready: true, running: false, activity: "", + input: "", setInput: vi.fn(), error: "", bottomRef: createRef(), inputRef: createRef(), + send: vi.fn(async () => undefined), submit: vi.fn((event: FormEvent) => event.preventDefault()), stop: vi.fn(), retry: vi.fn(), + openChat: vi.fn(), newThread: vi.fn(), selectThread: vi.fn(), + ...overrides, + }; +} + +async function mount(context: FanoutAppContextValue) { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + await act(async () => root.render()); + return root; +} + +function button(text: string) { + return Array.from(document.querySelectorAll("button")).find((item) => item.textContent?.trim() === text); +} + +describe("ChatPage", () => { + afterEach(() => { document.body.innerHTML = ""; }); + + it("offers suggestions on an empty draft and sends the chosen one", async () => { + const context = value(); + const root = await mount(context); + expect(document.body.textContent).toContain("What do you want to know about your system?"); + expect(document.body.textContent).not.toContain("See what changed"); + const chip = button("Find the source of elevated errors"); + expect(chip).not.toBeUndefined(); + await act(async () => chip?.click()); + expect(context.send).toHaveBeenCalledWith("Find the source of elevated errors"); + await act(async () => root.unmount()); + }); + + it("renders markdown tables and code blocks with product chrome", async () => { + const messages: Message[] = [ + { id: "u1", role: "user", content: "Show the slowest endpoints" } as Message, + { id: "a1", role: "assistant", content: "| Endpoint | P95 |\n|---|---|\n| GET /orders | 650ms |\n\n```sql\nSELECT 1\n```" } as Message, + ]; + const root = await mount(value({ messages, messageTimes: { u1: Date.UTC(2026, 8, 5, 18, 16) } })); + const table = document.querySelector(".chat-markdown table"); + expect(table).not.toBeNull(); + expect(table?.closest(".chat-table")).not.toBeNull(); + expect(document.querySelector(".chat-markdown pre code")?.textContent).toContain("SELECT 1"); + expect(document.querySelector('button[aria-label="Copy code"]')).not.toBeNull(); + expect(document.querySelector('button[aria-label="Copy message"]')).not.toBeNull(); + expect(document.querySelector(".mantine-Avatar-root")).toBeNull(); + await act(async () => root.unmount()); + }); + + it("shows the activity line and a stop button while running", async () => { + const context = value({ running: true, activity: "Checking system health…", messages: [{ id: "u1", role: "user", content: "hi" } as Message] }); + const root = await mount(context); + expect(document.body.textContent).toContain("Checking system health…"); + const stop = document.querySelector('button[aria-label="Stop"]') as HTMLButtonElement; + expect(stop).not.toBeNull(); + await act(async () => stop.click()); + expect(context.stop).toHaveBeenCalled(); + await act(async () => root.unmount()); + }); + + it("offers retry after a failed run and a new chat for a missing thread", async () => { + const failed = value({ error: "Fanout could not complete this analysis.", messages: [{ id: "u1", role: "user", content: "hi" } as Message] }); + let root = await mount(failed); + await act(async () => button("Retry")?.click()); + expect(failed.retry).toHaveBeenCalled(); + await act(async () => root.unmount()); + document.body.innerHTML = ""; + + const missing = value({ threadMissing: true }); + root = await mount(missing); + expect(document.body.textContent).toContain("This chat no longer exists"); + await act(async () => button("New chat")?.click()); + expect(missing.newThread).toHaveBeenCalled(); + await act(async () => root.unmount()); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd ui/host && bun run test src/chat.test.tsx` +Expected: FAIL on the first test (`See what changed` still present) and the markdown test (`.chat-table` missing). + +- [ ] **Step 3: Rewrite `chat.tsx`** + +```tsx +import type { Message } from "@ag-ui/client"; +import { ActionIcon, Alert, Box, Button, Center, Container, Group, Loader, Paper, Stack, Table, Text, Textarea, Title, Tooltip, Typography } from "@mantine/core"; +import { Check, Copy, PaperPlaneTilt, Stop } from "@phosphor-icons/react"; +import { lazy, Suspense, useState, type ComponentProps, type ReactNode } from "react"; +import Markdown, { type Components } from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { useFanoutApp } from "./app-context"; +import { BrandMark } from "./brand"; +import type { MCPAppContent } from "./mcp-app-frame"; + +const MCPAppFrame = lazy(() => import("./mcp-app-frame")); + +const suggestions = [ + "Summarize system health for the last hour", + "Find the source of elevated errors", + "Map the current service dependencies", + "Show the slowest endpoints", +]; + +const activityLabels: Record = { + observability_overview: "Checking system health…", + service_topology: "Mapping service dependencies…", + service_performance: "Reading performance signals…", + trace_detail: "Inspecting a trace…", + search_logs: "Searching logs…", + intelligence_snapshot: "Reviewing detected anomalies…", +}; + +export function activityLabel(toolName: string): string { + return activityLabels[toolName] ?? "Working on it…"; +} + +export function toolTitle(name: string) { + return ({ observability_overview: "System health", service_topology: "Service map", service_performance: "Performance", trace_detail: "Trace analysis", search_logs: "Logs" } as Record)[name] ?? "System analysis"; +} + +export function ChatPage() { + const { agentAvailable, messages, messageTimes, ready, running, activity, error, threadMissing, bottomRef, send, retry, newThread } = useFanoutApp(); + if (!agentAvailable) return Optional capabilityChat is not configuredAdd an AI provider key to enable chat. Telemetry ingest, dashboards, traces, logs, and metrics remain available without it.; + const visibleMessages = messages.filter((message) => message.role !== "tool"); + return + + + {threadMissing && It was deleted, or the link is wrong.} + {!threadMissing && !ready &&
Loading chat
} + {!threadMissing && ready && <> + {visibleMessages.length === 0 && } + + {visibleMessages.map((message) => )} + {running && {activity || "Analyzing your system"}} + {error && {error}} +
+ + } + + + {!threadMissing && } + ; +} + +function Composer() { + const { input, setInput, inputRef, submit, send, stop, ready, running } = useFanoutApp(); + return + + + +