diff --git a/CLAUDE.md b/CLAUDE.md index da3048d..3102f8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,10 +7,11 @@ An AI assistant chat app where each conversation is an atproto permissioned spac - `lexicons/` holds our lexicon JSON, and `lexicons/upstream/` the vendored `com.atproto` lexicons the server calls. - `packages/lexicons` is the generated lexicon code and the `nsid` module. `packages/plugin-api` is what plugins build against. - `apps/server` is the Hono server. `src/server.ts` wires every service together. -- `apps/web` is the React app. `src/store/` is the browser's local SQLite copy. +- `apps/web` is the TanStack Start app, which the server runs inside Hono. `src/store/` is the browser's local SQLite copy. - `plugins/` holds the shipped plugins. - `docs/architecture.md` is the human-facing architecture overview, and `docs/plugins.md` the plugin author guide. Update them when a change affects what they describe. -- `pnpm dev` runs the server and Vite together. `pnpm check` runs lint, type checking, and every test. `pnpm codegen` regenerates the lexicon code after a lexicon change, and `pnpm publish-lexicons ` publishes the lexicons from the lexicon authority account. `pnpm keys` prints fresh secrets. +- `GLOSSARY.md` holds the project's terms, and `docs/adr/` the decisions behind its less obvious choices. Read the relevant ADR before changing what it decided. +- `pnpm dev` runs the server, which serves the web app through Vite, as one process. `pnpm check` runs lint, type checking, and every test. `pnpm codegen` regenerates the lexicon code after a lexicon change, and `pnpm publish-lexicons ` publishes the lexicons from the lexicon authority account. `pnpm keys` prints fresh secrets. - Local setup: copy `config.example.yml` to `config.yml` for public settings and plugins, and `.env.example` to `.env` for secrets, filled in from `pnpm keys`. Secrets never go in `config.yml`; it references them as `${NAME}`. - Server tests run on both SQLite and PGlite through `tests/helpers/db.ts`. `tests/helpers/fake-pds.ts` is a fake spaces PDS with real error codes, policies, and signed commits. @@ -30,17 +31,19 @@ These are settled. Raise a concern before working against any of them. - The browser keeps its own local copy of the user's chats in SQLite compiled to WebAssembly, for the chat list, the open conversation, and search. That copy lives on the user's device, not on the server. - BYO API keys are stored encrypted in the app database, never in records. - Providers, tools, file ingesters, and turn hooks are plugins. Web search, fetch, and image generation are plugins, not core. +- The browser runs only code built into the fork. A plugin's web half is registered in the fork's source, and what end users can add is a theme, which is design tokens only. +- Messages in the browser come from the local copy. No chat client library holds them, and the stream is an overlay until the record arrives. - The project is meant to be forked. Code refers to lexicon NSIDs only through one generated module, and app name, URLs, OAuth metadata, and admin models come from `config.yml`. ## Planned stack -TypeScript throughout, as a pnpm workspace on Node 24 LTS, run natively with Node's type stripping and no compile step. Hono server with server-sent events for streaming. Kysely over SQLite and Postgres. Vite and React for the frontend, with no framework on top. Vitest for tests. Provider plugins use the Vercel AI SDK internally, behind our own plugin interface. +TypeScript throughout, as a pnpm workspace on Node 24 LTS, run natively with Node's type stripping and no compile step. Hono server with server-sent events for streaming. Kysely over SQLite and Postgres. TanStack Start with React for the frontend, run inside the Hono server, with TanStack Router, Query, and Form. Hono is the only API, so there are no Start server functions. Vitest for tests. Provider plugins use the Vercel AI SDK internally, behind our own plugin interface. Prefer existing libraries over writing our own, especially the official `@atproto/*` packages for anything protocol related. Dependencies must have licenses compatible with MIT. Admins configure plugins, global models, roles, and every other public setting in `config.yml`, with secrets in `.env`. Roles gate which admin models a user may use. There is no admin UI yet. -The web UI must stay barebones with minimal code and styling. A separate designer will build the real interface on top of it. +The web UI stays plain, with minimal code and styling. Its look will come from a theme, which a designer will build, so core components carry as few visual decisions as possible. ## Lexicons diff --git a/GLOSSARY.md b/GLOSSARY.md new file mode 100644 index 0000000..502f6f4 --- /dev/null +++ b/GLOSSARY.md @@ -0,0 +1,31 @@ +# SCN Chat + +An AI assistant chat app where each conversation lives in a permissioned space on the user's own PDS, built to be forked and extended. + +## Extension + +**Plugin**: +A package that extends the server, the web app, or both, with one ID across both halves. Admins enable server halves in the config; forkers build web halves into their fork. +_Avoid_: Extension, add-on, UI plugin, web extension + +**Tool**: +A capability a plugin offers that the model can call during a turn. +_Avoid_: Function, action + +**Renderer**: +A component from a plugin's web half that shows one kind of message part, such as a particular tool's call and result, in place of the default display. +_Avoid_: Tool UI, widget + +**Slot**: +A fixed place in the web app, such as the composer or a message's actions, where plugins' web halves add controls. +_Avoid_: Extension point, hook (hooks are server-side) + +**Override**: +A forker's replacement for one of the web app's core components. Overrides are code in a fork, never part of a theme. +_Avoid_: Custom component, theme component + +## Look + +**Theme**: +A set of design tokens that sets the look of the web app. A fork ships themes, and users can import their own. A theme is data, never code. +_Avoid_: Skin, style, appearance diff --git a/docs/adr/0001-tanstack-start-inside-hono.md b/docs/adr/0001-tanstack-start-inside-hono.md new file mode 100644 index 0000000..79dc8a5 --- /dev/null +++ b/docs/adr/0001-tanstack-start-inside-hono.md @@ -0,0 +1,9 @@ +# TanStack Start runs inside the Hono server + +The web app is built on TanStack Start with SSR, but Hono stays the process owner and treats Start as a fetch handler: Hono answers `/api`, `/oauth`, `/xrpc`, and `/.well-known`, serves Start's built client assets, and hands every other request to Start's built `fetch`. In development the same Hono entry runs Vite in middleware mode instead. We chose this over the documented Start-outside layout because importing the server into Start's build bundles our workspace packages, puts runtime plugin loading through `import()` inside a bundle, and reloads the sync timers, database handles, and notify renewers on every HMR. Hono and the plugins keep running unbundled on Node's type stripping. + +## Consequences + +- Hono stays the only API. There are no Start server functions, so auth, origin checks, and error mapping live in one place. During SSR, loaders reach Hono in process through the request context, never by importing server code. +- Only the shell, login, settings, and the session redirect are server-rendered. Chat and shared routes are `ssr: false`, since their data lives in the browser's local copy, and rendering them on the server would mean reading chats from the PDS on every navigation. +- The dev setup depends on Start's undocumented `installDevServerMiddleware` option, so Start is pinned to an exact version. If it disappears, the fallback is importing Start's server entry through Vite's SSR runner, as the plugin does internally. diff --git a/docs/adr/0002-no-third-party-code-in-the-browser.md b/docs/adr/0002-no-third-party-code-in-the-browser.md new file mode 100644 index 0000000..d6be73c --- /dev/null +++ b/docs/adr/0002-no-third-party-code-in-the-browser.md @@ -0,0 +1,3 @@ +# No third-party code runs in the browser at runtime + +The browser holds the user's whole chat history and a signed-in session, so any script it runs can read every chat and act as the user. Only code a forker built into their fork runs there. Plugins' web halves are registered in the fork's source and switched on by which plugins the server loaded, never fetched at runtime. What end users can plug in is a theme, and a theme is design tokens checked against their types, with no CSS or code and nothing that loads a URL. Custom CSS was rejected because attribute selectors with `url()` can leak data, and the CSP that would block that also blocks the click-to-load images in Markdown replies. diff --git a/docs/adr/0003-no-chat-library-owns-messages.md b/docs/adr/0003-no-chat-library-owns-messages.md new file mode 100644 index 0000000..acda67f --- /dev/null +++ b/docs/adr/0003-no-chat-library-owns-messages.md @@ -0,0 +1,3 @@ +# No AI chat library owns message state in the browser + +Messages are records: written to the PDS, synced into the browser's local copy, and branched by parent. The reply stream is only an overlay until the record arrives. Chat client libraries such as the AI SDK's `useChat`, TanStack AI's client, and assistant-ui's runtimes all keep their own message list, which would need adapters in both directions and invites a second source of truth. So the browser's chat logic is our own, on TanStack primitives, reading the local copy. The AI SDK stays on the server, where `@ai-sdk/provider` is already part of the plugin contract. TanStack AI was also rejected there, since it was still a 0.x release candidate and moving to it would break every provider plugin. Renderers receive our lexicon types, so no UI library's types enter the plugin contract either. diff --git a/docs/architecture.md b/docs/architecture.md index 44b4173..b4df35b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,6 +6,12 @@ This document describes the SCN Chat architecture at a high level. Each feature This in an AI assistant chat app that uses the users' spaces-enabled PDS as a source of truth for their chat history. Due to the alpha nature of spaces the app will also store the chat history in its own database. This feature will be removed when spaces are in widespread production use. +## The web app + +The web app is built on [TanStack Start](https://tanstack.com/start), but the Hono server owns the process. Hono answers `/api`, `/oauth`, `/xrpc`, and `/.well-known` itself, serves the web app's built assets, and passes every other request to Start. In development the same server runs Vite in middleware mode, so `pnpm dev` is one process. Only the shell, login, and settings are server-rendered. Chat routes render in the browser, since their data lives in the browser's local copy. + +Hono is the only API. The web app calls it through Hono's typed client, in process during server rendering and over HTTP in the browser, with TanStack Query on top. See [ADR 0001](adr/0001-tanstack-start-inside-hono.md) and [specs/web-ui.md](../specs/web-ui.md). + ## Chat history storage The goal is to keep users in control of their own chat data. The server should not keep a copy of the chat records. The records are stored in the browser in a wasm sqlite db to allow easy listing and search without having to perform intensive operations on the PDS. @@ -50,6 +56,14 @@ The storage type is chosen on first login and currently cannot be changed. So if See [specs/auth.md](../specs/auth.md). +## Decisions + +The [ADRs](adr/) record the choices a reader might otherwise undo: + +- [0001](adr/0001-tanstack-start-inside-hono.md): TanStack Start runs inside the Hono server. +- [0002](adr/0002-no-third-party-code-in-the-browser.md): No third-party code runs in the browser at runtime. Themes are design tokens only. +- [0003](adr/0003-no-chat-library-owns-messages.md): No AI chat library owns message state in the browser. + ## Plugins The system is built to support custom model providers, tools, file ingesters, and support a hook system. These are configured in `config.yml` and loaded startup. See [plugins.md](plugins.md). diff --git a/specs/README.md b/specs/README.md index 3bd471a..99af076 100644 --- a/specs/README.md +++ b/specs/README.md @@ -22,8 +22,15 @@ The overview at the end of this file lists every spec and whether it is active, ### Active +- [[web-ui]]: rebuilding the web app on TanStack Start inside Hono, at parity with its acceptance criteria. Phase 1 of the web rebuild. + ### Planned +The later phases of the web rebuild, each spec written before its phase starts: + +- web-plugins: Renderers, Slots, and settings panels from plugins' web halves, registered in a fork. Lifts the plugins spec's "no UI" boundary. +- themes: the styling stack, the design token contract, a default theme, and Overrides, then themes users pick or import, saved in their settings. + ### Completed Phase 1, initial build-out, implemented and tested, awaiting a check against a real spaces PDS: @@ -38,7 +45,6 @@ Phase 1, initial build-out, implemented and tested, awaiting a check against a r - [[titles]] - [[sharing]] - [[browser-store]] -- [[web-ui]] After the initial build-out: diff --git a/specs/auth.md b/specs/auth.md index 16e62fd..dedcd23 100644 --- a/specs/auth.md +++ b/specs/auth.md @@ -130,7 +130,7 @@ A `pnpm keys` script prints a fresh `OAUTH_PRIVATE_KEYS` value and a `SECRET_KEY - Login calls an `onLogin` hook, which creates the settings space and, for spaces users, registers for notifications, runs discovery, and syncs the index in the background. A failure is logged and does not block the login. - A handle is stored only when it resolves back to the same DID. - `JoseKey` comes from `@atproto/oauth-client-node`'s re-export, so key types cannot come from two package versions. -- In development `PUBLIC_URL` is the Vite dev server, which proxies the server routes, so the OAuth callback returns to the web app. +- In development `PUBLIC_URL` is the server, which also serves the web app through Vite, so the OAuth callback returns to the web app. ## Acceptance Criteria diff --git a/specs/foundation.md b/specs/foundation.md index 355c3ff..a22661f 100644 --- a/specs/foundation.md +++ b/specs/foundation.md @@ -93,7 +93,7 @@ Migrations and queries stay within what both databases support. Timestamps are I - `GET /api/health` returns `{ "status": "ok", "appName": "..." }` after a trivial database query succeeds. It returns 503 with `{ "status": "error" }` when the query fails, and logs the error. - Every route under `/api` is private to our web app. None of it is a public API. -- In production the server also serves the built web app from `apps/web/dist`, with an `index.html` fallback for client-side routes. In development Vite serves the web app and proxies `/api` to the server. `index.html` carries an `__APP_NAME__` placeholder in its title and `application-name` meta tag, filled with the configured app name by the server in production and by a Vite plugin, which asks the server, in development. The web app reads the name from the meta tag. +- The server also serves the web app, which the web-ui spec describes: Start's built handler in production, and Vite in middleware mode in development. - Logging uses pino, with request logging through a small Hono middleware. ### Web app @@ -112,7 +112,7 @@ Vitest runs every package from the root config. Tests live in a `tests/` directo | Root script | Does | |---|---| -| `pnpm dev` | Runs the server with `node --watch` and the Vite dev server together | +| `pnpm dev` | Runs the server with `node --watch`, serving the web app through Vite in the same process | | `pnpm build` | Builds the web app | | `pnpm start` | Runs the server in production mode, serving the built web app | | `pnpm test` | Runs all tests once | diff --git a/specs/web-ui.md b/specs/web-ui.md index cd72672..c1f7aea 100644 --- a/specs/web-ui.md +++ b/specs/web-ui.md @@ -2,40 +2,58 @@ ## Summary -The web UI is a deliberately bare React app. It exposes every phase 1 feature with plain HTML elements and almost no styling, so a designer can build the real interface on top without fighting existing design decisions. It covers signing in, the chat list, a conversation with branch navigation, the composer with model and effort choice and attachments, streaming replies, sharing, and settings for preferences, API keys, and plugin settings. It talks to the server through Hono's typed client, so API changes surface as type errors. +The web UI is a deliberately plain React app built on TanStack Start. It exposes every feature with plain HTML elements and almost no styling, so its look can come entirely from a theme later without fighting existing design decisions. It covers signing in, the chat list, a conversation with branch navigation, the composer with model and effort choice and attachments, streaming replies, sharing, and settings for preferences, API keys, and plugin settings. The Hono server owns the process and runs Start inside it, and the app talks to the server only through Hono's typed client, so API changes surface as type errors. ## Motivation -A separate designer will build the production interface. The phase 1 UI exists to prove the features work end to end, and to give the designer working components and data flow to restyle or replace. Every visual choice made here is one the designer might have to undo, so this UI makes as few as possible. +The project is meant to be forked and customized, and a designer will build the production look. Both go through themes and the extension seams in later phases, not through this spec. This UI exists to prove the features work end to end, and to give forkers and the designer working components and data flow to restyle, extend, or override. Every visual choice made here is one a theme might have to undo, so this UI makes as few as possible. + +The first build used Vite and React with `wouter` and hand-written data hooks. It was rebuilt on TanStack Start, Router, Query, and Form to give forkers a conventional, type-safe base, with the same behavior and acceptance criteria. See ADR 0001 for how Start sits inside Hono, and ADR 0003 for why no chat client library holds messages. ## Design ### Stack -- React with Vite, from the foundation spec. -- `wouter` for routing, a router of about 2 KB. -- `hono/client`'s `hc` with the server's exported route types, for a typed API client with no hand-written API types. -- Chats are read from the browser store, through its worker API. Writes and other data go through the typed client with small hooks. No state management or data-fetching library. -- `EventSource` for reply streams. -- One small stylesheet for layout only: a sidebar and main column with flexbox, and `white-space: pre-wrap` on message text. No colors, fonts, or component library. +- TanStack Start with React, server-rendering only the routes marked below. +- TanStack Router with file-based routes in `src/routes/`. Each route validates its search params. +- `hono/client`'s `hc` with the server's exported route types, for a typed API client with no hand-written API types. There are no Start server functions: Hono is the only API. +- TanStack Query for all data, both server calls and chats read from the browser store through its worker API. +- TanStack Form for the settings forms and schema forms. +- `EventSource` for reply streams, read through Query's `streamedQuery`. +- One small stylesheet carried over from the first build, for layout and a stand-in look. No component library or CSS framework yet. The styling stack is the themes spec's decision. + +### Serving + +The server process runs Hono, and Hono runs Start, so the server and plugins stay unbundled on Node's type stripping. + +- **Production.** `vite build` writes `apps/web/dist/client` and `apps/web/dist/server/server.js`, whose default export is a fetch handler. Hono answers `/api`, `/oauth`, `/oauth-client-metadata.json`, `/xrpc`, and `/.well-known` first, then serves files from `dist/client`, then passes every other request to Start's handler. +- **Development.** The same server entry creates Vite in middleware mode with Start's `installDevServerMiddleware` option, and passes every request Hono does not answer to Vite's middlewares. `pnpm dev` runs one process on one port, with no proxy. +- **Request context.** Hono passes Start a context holding the Hono app and the configured app name. During server rendering, the typed client calls the Hono app in process through it, forwarding the request's cookie, and the root route reads the app name from it for the page title and `application-name` meta tag. +- `@tanstack/react-start` is pinned to an exact version, since the dev setup relies on an undocumented option. + +### Data + +- **Server data.** Query wraps every typed-client call, keyed by route, such as `['models']` or `['preferences']`. Writes are mutations that invalidate the keys they change. A 401 from any call ends the session as before. +- **Chats.** The chat list is the query `['conversations']` and a conversation is `['conversation', skey]`, both read from the browser store's worker. The store's change events invalidate them: an `index` change invalidates the chat list, and a `conversation` change invalidates that conversation. Queries wait while the store is held by another tab. +- **URL state.** On chat routes, `m` names a focused message and `q` holds the search term. Choosing a sibling replaces `m` with that sibling, so the URL always names the branch on screen, and reloading, going back, or sharing the link keeps it. ### Routes -| Route | Screen | -|---|---| -| `/login` | A handle field and a sign-in button that goes to `/oauth/login` | -| `/` | Chat list sidebar, and a new chat | -| `/c/:skey` | Chat list sidebar and a conversation | -| `/s/:ownerDid/:skey` | A shared conversation, read-only | -| `/settings` | Preferences, with a sidebar linking to each settings section | -| `/settings/api-keys`, `/settings/plugins`, `/settings/sync` | The other settings sections | +| Route | Screen | Server-rendered | +|---|---|---| +| `/login` | A handle field and a sign-in button that goes to `/oauth/login` | Yes | +| `/` | Chat list sidebar, and a new chat | No | +| `/c/:skey` | Chat list sidebar and a conversation | No | +| `/s/:ownerDid/:skey` | A shared conversation, read-only | No | +| `/settings` | Preferences, with a sidebar linking to each settings section | Yes | +| `/settings/api-keys`, `/settings/plugins`, `/settings/sync` | The other settings sections | Yes | -Any route except `/login` and a shared route redirects to `/login` when `/api/me` returns 401. A shared route shows a sign-in prompt instead. +The signed-in routes share a layout route that is server-rendered and checks `/api/me` in its `beforeLoad`, redirecting to `/login` on a 401 before any page is sent. Its child chat routes render on the client, since chats live in the browser store. A shared route shows a sign-in prompt instead of redirecting. ### Components - **ChatList.** Conversation titles from the browser store, newest first. Untitled conversations show "New chat". It has a button to start a new chat. -- **Conversation.** It shows the messages on the current branch, from the root to the selected leaf. A message with siblings shows "‹ 2 / 3 ›" controls that switch branches. By default the branch follows the newest sibling at each level. It also has the title, with rename, the share control from the sharing spec, and a sync button that calls the conversation's sync route and then refreshes it in the browser store. +- **Conversation.** It shows the messages on the current branch, from the root to the selected leaf. A message with siblings shows "‹ 2 / 3 ›" controls that switch branches. By default the branch follows the newest sibling at each level, and below a focused message it follows the newest sibling at each level after it. It also has the title, with rename, the share control from the sharing spec, and a sync button that calls the conversation's sync route and then refreshes it in the browser store. - **Message.** - Assistant text parts, including streamed text, are rendered as GitHub-flavored Markdown with `react-markdown` and `remark-gfm`. Raw HTML is not rendered, links open in a new tab, and images show their alt text, full URL, and a load button instead of loading, so injected content cannot leak the chat through an image URL without the user choosing to load it. User text is shown as plain text. - Reasoning parts go in a closed `
`. @@ -53,28 +71,32 @@ Any route except `/login` and a shared route redirects to `/login` when `/api/me ### Schema forms -`SchemaFields` renders a JSON schema object as labeled inputs, reporting each change to its parent form. It supports strings, as text inputs or password inputs for secret fields, plus numbers, booleans as checkboxes, and string enums as selects. Any other type is shown as unsupported, not guessed at. A field's schema may carry a [JSON Forms rule](https://jsonforms.io/docs/uischema/rules) under `rule`, whose effect is `SHOW`, `HIDE`, `ENABLE`, or `DISABLE`. Its condition names another top-level field as `#/properties/` and tests that field's value with a JSON Schema limited to `const`, `enum`, `not`, and `minLength`, honoring `failWhenUndefined`. A rule with any other scope, keyword, or effect is ignored with a console warning, so the field stays shown and enabled. +`SchemaFields` renders a JSON schema object as labeled inputs bound to a TanStack Form field each, so the parent form owns values, validation, and which fields changed. It supports strings, as text inputs or password inputs for secret fields, plus numbers, booleans as checkboxes, and string enums as selects. Any other type is shown as unsupported, not guessed at. A field's schema may carry a [JSON Forms rule](https://jsonforms.io/docs/uischema/rules) under `rule`, whose effect is `SHOW`, `HIDE`, `ENABLE`, or `DISABLE`. Its condition names another top-level field as `#/properties/` and tests that field's value with a JSON Schema limited to `const`, `enum`, `not`, and `minLength`, honoring `failWhenUndefined`. A rule with any other scope, keyword, or effect is ignored with a console warning, so the field stays shown and enabled. ### Streaming -After sending, the conversation subscribes to the reply's stream endpoint and appends deltas to the pending reply. On the `status` event, it asks the browser store to refresh the conversation, which fetches the final record. If the stream fails, it refreshes the conversation every two seconds until the reply leaves `pending`. +After sending, the conversation opens a stream query, `['reply-stream', skey, rkey]`, over the reply's stream endpoint. It appends deltas to the pending reply's streamed text and reasoning. On the `status` event the stream ends and the conversation query is invalidated, which fetches the final record through the browser store. If the stream fails, or the status says another server runs the reply, the conversation query refetches with a backoff from two seconds to thirty until the reply leaves `pending`. -While the reader is at the bottom of the conversation, it stays scrolled to the bottom as replies stream and messages arrive. Once they scroll up it stops following, until they scroll back down or send a message, which always scrolls to the bottom. A conversation opened on a linked message scrolls to that message instead. +While the reader is at the bottom of the conversation, it stays scrolled to the bottom as replies stream and messages arrive. Once they scroll up it stops following, until they scroll back down or send a message, which always scrolls to the bottom. A conversation opened on a linked message scrolls to that message instead. Switching siblings does not scroll. ## Scope Boundaries -- No visual design, theming, dark mode, icons, or animations. +- No visual design, theming, dark mode beyond the stand-in stylesheet, icons, or animations. Those belong to the themes spec. +- No Renderers, Slots, or plugin settings panels. Those belong to the web plugins spec. +- No server rendering of chat or shared routes. - No code highlighting. - No PWA, offline support, or notifications. - No mobile-specific layout beyond what the browser does. -- No search box. That is the search spec. - No admin screens. ## Edge Cases and Decisions -- The Markdown renderer builds React elements rather than injecting HTML, so it needs no separate sanitizer. The designer may replace it. -- The typed Hono client comes from the server's route types, so the web app and server share one definition of the API. +- The Markdown renderer builds React elements rather than injecting HTML, so it needs no separate sanitizer. A theme may restyle it. +- The typed Hono client comes from the server's route types, so the web app and server share one definition of the API. On the server it calls the Hono app in process, and in the browser it uses `fetch`, so there is one client for both. +- The web app never imports server code. Start reaches Hono only through the request context, so the server never enters Vite's module graph or reloads on HMR. +- Chats are never server-rendered. Doing so would read the conversation from the PDS on every navigation and duplicate the browser store. - The default branch is the newest sibling at each level, matching how ChatGPT shows the latest regeneration. +- `m` keeps the name search links already used. One focused message is enough to name a branch, since its ancestors are fixed and everything below it follows the newest sibling. - Write routes read JSON without validators, so the typed client sends bodies through its request options. Response types still come from the server's routes. - The `read` helper returns the body of the successful responses and throws an `ApiError` with the server's message otherwise. - Shared links show a sign-in prompt when signed out, and every other route sends signed-out users to `/login`. @@ -82,10 +104,11 @@ While the reader is at the bottom of the conversation, it stays scrolled to the ## Acceptance Criteria - [ ] Signing in with a handle starts OAuth, and the app returns to the chat list afterwards. -- [ ] Signed-out users are sent to `/login`. +- [ ] Signed-out users requesting a signed-in route get a redirect to `/login` from the server, before any page is rendered. +- [ ] `pnpm dev` serves the API and the web app from one process and port, and a production build serves both from the server entry. - [ ] Creating a chat, sending a message, and watching the reply stream works end to end. - [ ] The chat list shows new titles once they are generated. -- [ ] Sibling controls switch branches and show the matching replies. +- [ ] Sibling controls switch branches and show the matching replies, and reloading the page keeps the chosen branch. - [ ] Regenerating adds a sibling reply and selects it. - [ ] Editing a message adds a sibling user message with its own reply. - [ ] Stop cancels a generating reply, which then shows as stopped. @@ -100,3 +123,4 @@ While the reader is at the bottom of the conversation, it stays scrolled to the - [ ] The conversation follows a streaming reply while the reader is at the bottom, not after they scroll up, and scrolls to the bottom on send. - [ ] The sync button syncs the conversation and shows changes written from another client. - [ ] The background sync switch appears only when the admin allows opting out, and saves the account setting. +- [ ] The page title and `application-name` meta tag show the configured app name. -- knotmirror 0.0.1 From f1faa946a91b55da3d1e83474a40c7b75ae60ba1 Mon Sep 17 00:00:00 2001 From: pdelfan Date: Thu, 1 Oct 2026 22:28:10 -0700 Subject: [PATCH 02/68] Rebuild the web app on TanStack Start and Router, with routes at /chat and /shared. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/index.html | 13 - apps/web/package.json | 6 +- apps/web/src/App.tsx | 95 -- apps/web/src/app-name.ts | 8 +- apps/web/src/components/BusyBanner.tsx | 4 +- apps/web/src/components/ChatList.tsx | 12 +- apps/web/src/components/ConversationView.tsx | 4 +- apps/web/src/components/SearchResults.tsx | 8 +- apps/web/src/components/ShareControl.tsx | 2 +- apps/web/src/main.tsx | 13 - apps/web/src/pages/SettingsPage.tsx | 64 +- apps/web/src/routeTree.gen.ts | 273 ++++++ apps/web/src/router.tsx | 12 + apps/web/src/routes/__root.tsx | 54 ++ apps/web/src/routes/_app.tsx | 42 + apps/web/src/routes/_app/chat/$skey.tsx | 14 + apps/web/src/routes/_app/index.tsx | 4 + apps/web/src/routes/_app/settings.tsx | 10 + .../web/src/routes/_app/settings/api-keys.tsx | 4 + apps/web/src/routes/_app/settings/index.tsx | 4 + apps/web/src/routes/_app/settings/plugins.tsx | 4 + apps/web/src/routes/_app/settings/sync.tsx | 4 + apps/web/src/routes/login.tsx | 10 + .../web/src/routes/shared/$ownerDid/$skey.tsx | 11 + apps/web/src/session.tsx | 34 +- apps/web/src/start.ts | 13 + apps/web/tsconfig.json | 2 +- apps/web/vite.config.ts | 61 +- apps/web/vitest.config.ts | 12 + biome.json | 9 +- pnpm-lock.yaml | 915 +++++++++++++++++- 31 files changed, 1460 insertions(+), 261 deletions(-) delete mode 100644 apps/web/index.html delete mode 100644 apps/web/src/App.tsx delete mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/routeTree.gen.ts create mode 100644 apps/web/src/router.tsx create mode 100644 apps/web/src/routes/__root.tsx create mode 100644 apps/web/src/routes/_app.tsx create mode 100644 apps/web/src/routes/_app/chat/$skey.tsx create mode 100644 apps/web/src/routes/_app/index.tsx create mode 100644 apps/web/src/routes/_app/settings.tsx create mode 100644 apps/web/src/routes/_app/settings/api-keys.tsx create mode 100644 apps/web/src/routes/_app/settings/index.tsx create mode 100644 apps/web/src/routes/_app/settings/plugins.tsx create mode 100644 apps/web/src/routes/_app/settings/sync.tsx create mode 100644 apps/web/src/routes/login.tsx create mode 100644 apps/web/src/routes/shared/$ownerDid/$skey.tsx create mode 100644 apps/web/src/start.ts create mode 100644 apps/web/vitest.config.ts diff --git a/apps/web/index.html b/apps/web/index.html deleted file mode 100644 index a0e85df..0000000 --- a/apps/web/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - __APP_NAME__ - - -
- - - diff --git a/apps/web/package.json b/apps/web/package.json index 5245e05..b256db8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,20 +4,20 @@ "private": true, "type": "module", "scripts": { - "dev": "vite", "build": "vite build", "typecheck": "tsc --noEmit -p ." }, "dependencies": { "@scn-chat/lexicons": "workspace:*", "@sqlite.org/sqlite-wasm": "3.53.4-build1", + "@tanstack/react-router": "1.170.41", + "@tanstack/react-start": "1.168.60", "comlink": "4.4.2", "hono": "4.13.9", "react": "19.3.0", "react-dom": "19.3.0", "react-markdown": "10.1.0", - "remark-gfm": "4.0.1", - "wouter": "3.11.0" + "remark-gfm": "4.0.1" }, "devDependencies": { "@scn-chat/server": "workspace:*", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx deleted file mode 100644 index c5bc445..0000000 --- a/apps/web/src/App.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { useEffect, useState } from 'react' -import { Redirect, Route, Switch, useLocation } from 'wouter' -import { ApiError, api, onUnauthorized, read } from './api.ts' -import { messageOf } from './components/useAction.ts' -import { syncTimeZone } from './lib/time-zone.ts' -import { ChatPage } from './pages/ChatPage.tsx' -import { LoginPage } from './pages/LoginPage.tsx' -import { SettingsPage } from './pages/SettingsPage.tsx' -import { SharedPage } from './pages/SharedPage.tsx' -import { type Me, MeContext } from './session.tsx' -import { openStore } from './store/client.ts' -import { StoreProvider } from './store/react.tsx' - -type Session = - | { state: 'loading' } - | { state: 'signed-out' } - | { state: 'signed-in'; me: Me } - | { state: 'error'; message: string } - -export function useSession(): Session { - const [session, setSession] = useState({ state: 'loading' }) - useEffect(() => { - read(api.auth.me.$get()) - .then((me) => setSession({ state: 'signed-in', me: me as Me })) - .catch((err: unknown) => - setSession( - err instanceof ApiError && err.status === 401 - ? { state: 'signed-out' } - : { state: 'error', message: messageOf(err) }, - ), - ) - }, []) - return session -} - -function SignedIn({ me }: { me: Me }) { - const store = openStore(me.did) - useEffect(() => { - const ended = () => - // Use a full page load, since the signed-in routes redirect /login to /. - store - .deleteLocalCopy() - .catch((err: unknown) => console.error('Could not delete the local copy', err)) - .finally(() => location.assign('/login')) - syncTimeZone().catch((err: unknown) => console.warn('Could not save the time zone', err)) - const stopApi = onUnauthorized(ended) - const stopStore = store.onChange((change) => change.type === 'unauthorized' && ended()) - return () => { - stopApi() - stopStore() - } - }, [store]) - return ( - - - - {(params) => } - - - - - - - - - - - - - ) -} - -export function App() { - const session = useSession() - if (session.state === 'loading') return

Loading...

- if (session.state === 'error') - return

Could not reach the server: {session.message}

- const signedIn = session.state === 'signed-in' - return ( - - - {(params) => ( - - )} - - {signedIn ? : } - - ) -} - -function SignedOut() { - const [location] = useLocation() - if (location !== '/login') return - return -} diff --git a/apps/web/src/app-name.ts b/apps/web/src/app-name.ts index 015c3e0..d28df1c 100644 --- a/apps/web/src/app-name.ts +++ b/apps/web/src/app-name.ts @@ -1,4 +1,4 @@ -/** The configured app name, used in index.html. */ -const meta = document.querySelector('meta[name="application-name"]') -if (!meta) throw new Error('index.html has no application-name meta tag') -export const appName = meta.content +/** The configured app name, from the meta tag the root route renders on the server. */ +export function appName(): string { + return document.querySelector('meta[name="application-name"]')?.content ?? '' +} diff --git a/apps/web/src/components/BusyBanner.tsx b/apps/web/src/components/BusyBanner.tsx index f44efae..0d9c327 100644 --- a/apps/web/src/components/BusyBanner.tsx +++ b/apps/web/src/components/BusyBanner.tsx @@ -9,8 +9,8 @@ export function BusyBanner() { return (
{state === 'busy' - ? `${appName} is busy in another tab.` - : `${appName} couldn't open this device's copy of your chats.`}{' '} + ? `${appName()} is busy in another tab.` + : `${appName()} couldn't open this device's copy of your chats.`}{' '} diff --git a/apps/web/src/components/ChatList.tsx b/apps/web/src/components/ChatList.tsx index db33520..17538da 100644 --- a/apps/web/src/components/ChatList.tsx +++ b/apps/web/src/components/ChatList.tsx @@ -1,5 +1,5 @@ +import { Link, useNavigate } from '@tanstack/react-router' import { useState } from 'react' -import { Link, useLocation } from 'wouter' import { api, read } from '../api.ts' import { useConversations, useSearch } from '../store/react.tsx' import { SearchResults } from './SearchResults.tsx' @@ -16,10 +16,10 @@ export function ChatList() { const found = useSearch(searching ? typed : '') const { error, run } = useAction() const signOut = useSignOut() - const [, navigate] = useLocation() + const navigate = useNavigate() const create = async () => { const created = await read(api.chats.conversations.$post()) - navigate(`/c/${created.skey}`) + await navigate({ to: '/chat/$skey', params: { skey: created.skey } }) } return (