Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ pattern (`assistant/docs/`, `docs/` at the repo root).
- **Route protection**: uses React Router v7 [middleware](https://reactrouter.com/how-to/middleware) (`v8_middleware` future flag), not layout gate components or `useEffect` redirects. Auth is always required — the middleware redirects unauthenticated users to `/account/login`. See [`docs/CONVENTIONS.md` — Route protection via middleware](./docs/CONVENTIONS.md#route-protection-via-middleware).
- **Assistant lifecycle**: owned by `RootLayout` and passed down via [outlet context](https://reactrouter.com/start/framework/outlet). `ChatLayout` reads it via `useRootOutletContext()` and re-publishes the chat-scoped slice as `AssistantContextValue` for its own children. Routes under `ChatLayout` keep consuming the resolved `assistantId` via `useAssistantContext()` — never hardcode or independently resolve it.
- **Active-assistant gating**: routes that require a working assistant (queries against `/v1/assistants/{id}/...`, anything that reads or writes per-assistant state) are mounted under `<ActiveAssistantGate>` in `src/routes.tsx`. The gate defers child rendering until `assistantId` is non-null AND `assistantState.kind === "active"`, then re-provides a narrowed outlet context. Inside the gate, call `useActiveAssistantContext()` instead of `useAssistantContext()` — the returned `assistantId` is typed `string` (non-null). **Do not add `if (!assistantId) return null;` guards in gated routes** — the gate makes them unreachable. Routes that intentionally render across non-active states (today: `ChatPage`, `DocumentViewerPage`) live outside the gate and keep using `useAssistantContext()`.
- **Code splitting**: routes use `Component` (not `element`) with the object-based [`lazy` property](https://reactrouter.com/start/data/route-object#lazy) for route-level code splitting. New routes should default to `lazy` unless they're on the primary landing path (chat). See [`docs/CONVENTIONS.md` — Route-level code splitting](./docs/CONVENTIONS.md#route-level-code-splitting).

## Commands

Expand Down
33 changes: 33 additions & 0 deletions apps/web/docs/CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,39 @@ References:
- [React — Thinking in React](https://react.dev/learn/thinking-in-react)
- [React Router — Layout Routes](https://reactrouter.com/start/framework/routing#layout-routes)

### Route-level code splitting

Routes use `Component` (not `element`) and the object-based `lazy`
property for code splitting. Vite creates a separate chunk per dynamic
`import()`, so each lazy route loads only when navigated to.

**Eager routes** (critical path — always in the initial bundle):
`RootLayout`, `ChatLayout`, `ChatPage`, `DocumentViewerPage`,
`ConversationRedirect`, `ActiveAssistantGate`, `NotFound`.

**Lazy routes** (everything else): settings, logs, account/auth,
onboarding, intelligence pages, library, inspector, home, connect.

```ts
// Lazy route — object syntax (preferred)
{ path: "settings", lazy: { Component: () => import("./settings-layout.js").then((m) => m.SettingsLayout) } }

// Eager route — direct Component reference
{ path: "conversations/:conversationId", Component: ChatPage }
```

When adding a new route, default to `lazy` unless it's on the primary
landing path. Use `Component`, not `element` — they are mutually
exclusive and `lazy` returns `Component`.

The `RouterProvider.onError` handler in `main.tsx` catches chunk load
failures (stale deploys, network errors) and triggers a page reload.

References:
- [React Router — Route Object (`Component`)](https://reactrouter.com/start/data/route-object#component)
- [React Router — Lazy Loading (Data Mode)](https://reactrouter.com/start/data/custom#3-lazy-loading)
- [React Router — `lazy` property](https://reactrouter.com/start/data/route-object#lazy)

---

## Code organization
Expand Down
41 changes: 41 additions & 0 deletions apps/web/src/components/root-error-boundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useRouteError, isRouteErrorResponse } from "react-router";

import { Button } from "@vellum/design-library/components/button";

/**
* Root error boundary rendered by React Router when any unhandled error
* occurs during route resolution (including lazy chunk load failures),
* loader execution, or component rendering.
*
* Uses `useRouteError()` — the React Router v7 data-mode API for
* accessing the caught error inside an `ErrorBoundary` route property.
*
* References:
* - https://reactrouter.com/how-to/error-boundary
* - https://reactrouter.com/start/data/route-object
*/
export function RootErrorBoundary() {
const error = useRouteError();

const status = isRouteErrorResponse(error) ? error.status : undefined;
const heading = status === 404 ? "Page not found" : "Something went wrong";
const message =
status === 404
? "The page you requested doesn't exist."
: "An unexpected error occurred. Try reloading the page.";

return (
<div
data-slot="root-error-boundary"
className="flex min-h-svh flex-col items-center justify-center gap-4 p-6 text-center"
>
<h1 className="text-2xl font-semibold text-[var(--content-primary)]">
{heading}
</h1>
<p className="max-w-md text-[var(--content-secondary)]">{message}</p>
<Button variant="primary" onClick={() => window.location.reload()}>
Reload
</Button>
</div>
);
}
10 changes: 9 additions & 1 deletion apps/web/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider } from "react-router";
import * as Sentry from "@sentry/react";

import { useAuthStore, setupAuthListeners } from "@/stores/auth-store.js";
import { setupOrganizationStore } from "@/stores/organization-store.js";
Expand All @@ -26,7 +27,14 @@ async function boot() {
createRoot(rootEl).render(
<StrictMode>
<AppProviders>
<RouterProvider router={router} />
<RouterProvider
router={router}
onError={(error) => {
Sentry.captureException(error, {
tags: { context: "RouterProvider" },
});
}}
Comment thread
ashleeradka marked this conversation as resolved.
/>
</AppProviders>
</StrictMode>,
);
Expand Down
Loading