ATProto-based AI Assistant Chat interface.
scn-chat specs web-ui.md
67 kB · 215 lines
Markdown
at main

Web UI #

Summary #

The web UI is a React app built on TanStack Start, styled with Tailwind over a theme of design tokens, as the styling spec lays out. It covers signing in, the chat list, a conversation with branch navigation, the composer with model and effort choice and attachments, streaming replies, sharing, settings for preferences, API keys, and plugin settings, and the admin area from the admin specs. 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 #

The project is meant to be forked and changed in code. Its look is the styling spec's, and making the code easy to extend is the extending spec's. This UI exists to prove the features work end to end, and to give forkers working components and data flow to restyle, extend, or override. Its visual choices live in the theme's tokens, so a fork restyles it there rather than in each component.

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 #

  • TanStack Start with React. Routes render on the server unless they opt out, and the chat and shared routes do.
  • React Compiler, through @rolldown/plugin-babel and plugin-react's reactCompilerPreset, memoizes components and hooks in the browser build and in tests. Server rendering runs uncompiled, since it renders once.
  • TanStack Router with file-based routes in src/routes/. Each route validates its search params.
  • Code is grouped by feature in src/features/: auth, chat-list, conversation, sharing, models, settings, and admin. A feature holds whichever of pages/, components/, hooks/, and lib/ it needs, and its queries.ts. pages/ holds what routes render, layouts included, each file named after its component. Routes stay thin, loading data and rendering a feature's page. Code that several features use and none owns lives in src/shared/, and the browser store in src/store/.
  • 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, admin, and sharing forms, including the forms built from schemas.
  • EventSource for reply streams, read through Query's streamedQuery, which Query still exports as experimental_streamedQuery.
  • Tailwind over a theme of design tokens, as the styling spec lays out.

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, /.well-known, and /robots.txt first, then serves files from dist/client, then passes every other request to Start's handler. The build writes a brotli and a gzip copy beside each text or wasm file over a kilobyte, which the server sends to a browser that accepts them, and the files under /assets, named by a hash of their content, are sent with Cache-Control: public, max-age=31536000, immutable. Answers under /api are compressed with gzip or deflate as they go, for a client that accepts it, apart from event streams, which would otherwise hold events back. Pages are not, since Start may stream them.
  • Loading fast.
    • A route's file loads with the app's entry, and only its component is split into a chunk of its own, so whatever a route names directly, such as its pending or error page or its loader's queries, stays light. The shared page's skeleton and error page live apart from the page, which draws messages and so brings the Markdown renderer.
    • The chat routes' head preloads the store's worker and SQLite's wasm, their largest downloads, so they download alongside the app's code rather than once the worker runs, and preconnects to the avatar's image CDN. Their loader starts the models, attachment types, and profile queries without waiting for them, so they don't wait for the page's code.
    • The conversation and the search results, which bring the Markdown renderers, load with React.lazy. The chat route's loader starts the conversation's download, and the page for a new chat starts it once it shows, so it's there when the first message is sent. Opening the search starts the results'.
    • Streamdown imports rehype-raw for its defaults, which markdown.tsx leaves out, so vite.config.ts aliases it to an empty stand-in, keeping its HTML parser out of the build. Shiki's full bundle names its Oniguruma engine, a 600 KB wasm, for a shorthand highlighter the app never uses, since @streamdown/code highlights with Shiki's JavaScript engine, so vite.config.ts aliases shiki/wasm to a stand-in that fails to load, keeping the wasm out of the build.
    • Pointing at or focusing a link preloads its route, with the query cache, not the router's, deciding what is stale.
  • Development. The same server entry creates Vite in middleware mode on the server's HTTP server, so HMR shares its port. Each request goes through Vite's middlewares first, which serve modules and call on for everything else. Pages go to Start's server entry, which the server imports through Vite's SSR runner on each request, so web edits apply without a restart. pnpm dev runs one process on one port, with no proxy.
  • Request context. Hono passes Start a context holding the configured app name and description, PUBLIC_URL, and a fetch that calls the Hono app in process, resolving paths against the page request and sending its cookie. During server rendering, the typed client in api.ts calls the API through that fetch, found with Start's getGlobalStartContext, and in the browser it uses the browser's fetch. The root route reads the app name and description from the context, and in the browser from the rendered page.
  • Page metadata. Every page carries the description, Open Graph tags for link previews, robots: noindex, since each is someone's own, and the app's version as <meta name="version">, so a deployment's release can be checked without signing in. A page's title is its routes' staticData.title, deepest first, such as "API keys · Settings", "Users · Admin", or Shared chat, before the app name, or the app name alone. Each section of settings and admin has its own, and a page opened from a section, such as a role's or a plugin's, takes that section's. An open chat, and a shared chat with a title, put the chat's title there instead, as "Tile quotes · SCN Chat", through useDocumentTitle, since the title lives in the browser store or the shared chat's query rather than in the route. Until the chat's title is known the tab keeps the route's, and leaving the chat puts the route's back. The login page is the one page search engines may list: it overrides robots, and names its canonical URL and og:url under PUBLIC_URL. /robots.txt keeps crawlers out of /api, /oauth, and /xrpc.
  • @tanstack/react-start is pinned to an exact version, since the dev setup imports Start's server entry by its internal virtual module ID, virtual:tanstack-start-server-entry, as Start's own dev middleware does.

Data #

  • Server data. Query wraps every typed-client call, keyed by route, such as ['models'] or ['preferences'], from the factories in each feature's queries.ts. Writes are mutations that invalidate the keys they change. A 401 from any call ends the session as before. Queries do not retry or refetch on window focus, so failures show at once, as they did before Query. Admin data is keyed under ['admin'], such as ['admin', 'roles'], and the users list is an infinite query, ['admin', 'users', q], paged by the server's cursor.
  • Chats. The chat list is the query ['conversations'], a conversation is ['conversation', skey], and a search is ['search', q], all read from the browser store's worker. StoreProvider turns the store's change events into invalidations: an index change invalidates the chat list, a conversation change invalidates that conversation, and any change invalidates searches. Queries wait while the store is held by another tab. Opening a conversation also checks it against the PDS through the query ['conversation', skey, 'refresh'], which fetches what changed, then reconciles its record keys so nothing missed stays missing, and whose changes come back as store events. useConversation gives the conversation with waiting, while its messages are on their way, from the local copy or, when it has none, from the PDS, and empty, once the PDS has confirmed it has none. The store's React module keeps its query keys and its worker to itself, and offers intents: useRefreshConversation checks an open conversation against the PDS again, as opening it does, and is what sending, a conversation action, and the reply follower use, and useSyncIndex syncs the chat index, for the chat list and the sync status. The reply follower refreshes through useRefreshConversation rather than a query of its own, so the store's hooks know nothing of reply streams, and only a conversation that is open is checked; keep it that way rather than merging the follower into the store's hooks. Only the store's own queries use the conversation prefix.
  • URL state. On a conversation, and on a shared conversation, m names a focused message, and the branch on screen is its ancestors, then the newest sibling at each level below it. Choosing a sibling, or starting a turn by sending, editing, or regenerating, replaces m with the chosen or new message, so the URL always names the branch on screen, and reloading or sharing the link keeps it. On every chat route, q holds the sidebar search: the box starts from it, and the search goes back into it once typing pauses. The chat layout validates q and keeps it on every navigation between chat routes with the router's retainSearchParams, so opening a result, another chat, or a new chat keeps the search until it is cleared. On /admin/users, q holds the users search, set when the search is submitted. Changes to m and q replace the history entry rather than adding one, and the routes validate them in src/shared/search-params.ts.

Routes #

Route Screen Server-rendered
/login The app's logo and description over a handle field, which suggests accounts as the auth spec says, and Continue, with the app's name as a heading only screen readers hear, which goes to /oauth/login, which drops a leading @ from the handle. Continue reads "Signing in…" with a spinner in place of its arrow until the page is left, and a viewer's Sign out is a quiet link under the form Yes
/ Chat list sidebar, and a new chat No
/chat/:skey Chat list sidebar and a conversation No
/shared/: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
/admin Redirects to /admin/users Yes
/admin/users, /admin/roles, /admin/access The admin area's people sections, from the admin spec Yes
/admin/plugins, /admin/plugins/new, /admin/plugins/:id, /admin/models Plugins and models, from the admin-plugins spec Yes
/admin/general, /admin/turns, /admin/sync The app's settings, from the admin-settings spec Yes

The root route's beforeLoad asks /api/me who is signed in, through the query cache, so on the server the answer reaches the browser with the page. A 401 means signed out, and any other failure shows the root's error page, which says what went wrong, offers Try again, which loads the page's data again and clears the error once it succeeds, and links to / as a full load. The signed-in routes share the layout _app, whose beforeLoad redirects signed-out requests to /login, so the server answers them with a redirect before rendering anything. It redirects viewers, who may only open shared chats, to /login too, where they see the server's access message above the sign-in form, with a sign-out button. /login redirects other signed-in users to /. Inside _app, the chat routes share a client-only layout, _app/_chats, which opens the browser store, since chats live there, and draws the sidebar with the chat list, so moving between chats or to a new one keeps the list mounted. Its pending component, ChatPageFrame, is the page's sidebar with skeletons of the chats, and its panel with the composer's frame, which the server sends in place of the client-only chats, so a reload never starts on a blank window. The settings routes are server-rendered. Each section's loader waits for the queries the section cannot show without, which the section reads with useSuspenseQuery, so it renders with its data, and a failure shows the router's default error component in the section's place, inside the settings layout, with a Retry that runs the loader again. Data a section can do without, like the model list on preferences or the account on sync, is only prefetched, and a failure to load it shows as an alert in the section. They open the browser store only for an action that needs it, signing out or rebuilding the device's copy. The admin routes share the layout _app/admin, whose beforeLoad redirects users who are not admins to /. They are server-rendered and load in their loaders like the settings routes, waiting for every query a page reads, and never open the browser store. A page for one role, plugin, or model that doesn't exist says so in the section. The users page keeps showing its results while a new search loads, so the search box keeps its focus. A shared route shows a sign-in prompt instead of redirecting.

Components #

  • Sidebar. shared/SidebarLayout.tsx holds the chat page's sidebar, ChatsLayout, and the settings and admin nav, SettingsSidebarLayout. The chat sidebar starts with a row holding the logo, named by the app name from the admin area, and a toggle that hides the sidebar. On a wide screen the toggle collapses the column, sliding it off the window's edge, a choice kept in the browser and applied before the first paint, as the theme is, so a reload never shows it open first, and a toggle at the top of the panel brings it back. The settings and admin nav sits inside the panel beside the section and never collapses. Below 720px either sidebar is a drawer over the page, closed on each load, opened by the panel's toggle, and closed by the backdrop or by following a link in it.

  • ChatList. The chat sidebar's contents, under the sidebar's head row, from top to bottom:

    • New chat, which goes to /, so a chat is only created once its first message is sent, with a search button beside it that shows the search box below, focused, and hides it again. The box shows from the start when the URL holds a search. Hiding it, by the button or by Escape in the box, clears the search, and Escape returns focus to the button.
    • Tags, when any chat has some: a section that folds away under its heading, with a row for every tag across the chats and how many chats carry it, which filters the list to that tag, or shows every chat again when picked while active. A button at the heading's end clears the filter. Tags come from each chat's index entry.
    • The chats, when there are any: a section that folds away under its Chats heading, as the tags do, with the chats newest first under date headings by updatedAt in local time: Today, Yesterday, Previous 7 days, Previous 30 days, then by month, with the year for other years. Each chat is one line. Untitled chats show "New chat". A chat with a pending reply shows a breathing green dot labeled "Replying", and one whose newest complete reply this device hasn't seen shows a still dot labeled "New reply" and a bolder title. A menu on each chat, shown on hover or focus and always on a touch screen, renames it, edits its tags in a tags field, held to the lexicon's 32 tags of 64 characters and 640 bytes, or deletes it after asking, with the focus on Cancel, through the chat routes, then syncs the index. Renaming, tagging, or confirming a delete takes the chat's place in the list as a card, which Escape closes. Deleting the open chat goes to /. The chats load 50 at a time, the next page as the end of the list nears, with a few skeleton rows while it loads. A tag's count and the filter cover every chat, and the filter pages through only its chats. Only the chats, or the search's results, scroll, fading out at both ends.
    • An account card shows who is signed in: their avatar, display name, and @handle from /api/me/profile and /api/me. Without a display name it shows the handle alone, and without an avatar, or when it fails to load, the name's first letter in a rounded square. A dot on the avatar's corner shows how current this device's copy is: green when synced, breathing while syncing or downloading, grey when open in another tab, and red when it failed, with the status in words on hover: "Synced 2 min ago", "Downloading 3 chats…", "Syncing…", or that the copy is open in another tab or failed to open. The card is a button that opens a menu above it. The menu starts with where the chats live, "Stored on your PDS", or "Stored on this server" with "Your PDS doesn't support spaces yet" beneath, then the same dot, the status in words, and a Sync button that syncs the index now, then Settings, Admin for admins, a Theme row with System, Light, and Dark buttons and a Report an issue link to the project's issues on Tangled, which opens in a new tab, set apart by a line above and below, and Sign out. It opens with the focus on Settings, Tab and the arrow keys, Home, and End move between its controls, Escape closes it back on the card, a click outside closes it, and choosing a theme leaves it open.

    What this device has seen is kept in localStorage per account, as the newest seen reply of each chat. The first list on a device seeds it, so older replies don't all show as new, and the open chat's newest reply counts as seen. Icons come from src/shared/icons.tsx: Lucide's, and the app's own puffier ones drawn to sit with the logo's bubble letters, in the text color and hidden from assistive technology, which a fork can swap for another set.

  • Conversation. It shows the messages on the current branch, from the root to the selected leaf. A message with siblings shows "2/3" between chevrons 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. A conversation with no messages, once its refresh from the PDS has finished, says to describe what they need or drop in a file, an instruction rather than a question so it hints at what the app takes, where the chat is saved, and offers a few starters, each of which begins the message in the composer for the user to finish. With no chat open, on /, the page is that empty conversation with its composer, whose first message creates the chat, sends to it, and opens it. A send that fails after creating the chat reuses that chat when tried again, so a retry never makes a second one.

  • Conversation header. The title, then on the right a Jump to icon button, Share, and a More menu, kept to one row so the header stays quiet. Each of them opens a panel under the header, and only one is open at a time, so opening one closes another. Escape closes whichever is open, and the jump list and the More menu also close on a click outside the header, while the rename and share forms stay until closed.

    • Clicking the title renames the chat, as does Rename in the More menu. The rename form will not save an empty title, and Escape or Cancel closes it.
    • Jump to always shows, even when the branch on screen holds only one of the user's messages. Rather than drop down, it lays a layer over the chat from under the header to the panel's foot, which blurs and fades the chat behind it, and stacks the user's messages on it oldest first as their bubbles, each clamped to two lines, under a title, Jump to a message, that holds still while they scroll and names the list. Beside the title, Show replies, a quiet ToggleChip with a chat icon that turns green with a check while pressed, interleaves the replies with them, each on the left as the chat sets it, in a grey bubble that darkens on hover, as the user's are green, with its text on one line without its Markdown marks, link targets, or code fences, through unmarkText in parts/plain.ts, and clamped to two lines too. A reply with no text is left out, such as one still pending, one that only ran tools or failed before writing, one holding only images or files, or an encrypted one. The toggle is remembered in localStorage on this device and starts off. outlineOf in lib/outline.ts lists the messages, replies included, and the stack filters them. It opens on the message being read, the last of those listed whose top is above a third of the window, marked with aria-current and a ring, green on the user's and grey on a reply's, scrolled to, and focused. Toggling the replies moves the mark to the message being read among those now listed and scrolls to it, even when that is the same message, while focus stays on the toggle. Up, Down, Home, and End move between the messages. Picking one scrolls the chat to the message while the layer still covers it, and makes its bubble glow a moment, or a reply wash in grey behind it, so the eye finds it, while the layer fades away. It only scrolls, so the branch and m stay as they are. A click on the layer past the messages closes it, as Escape and the Jump to button do.
    • Share opens the sharing panel from the sharing spec, and shows only for users whose chats live in spaces.
    • The More menu holds Rename, Edit tags, Instructions, "Sync this chat", and Delete. "Sync this chat" calls the conversation's sync route and then refreshes it in the browser store.
    • Edit tags and Delete do what the sidebar's menu does, as panels under the header. Edit tags takes the tags in a tags field, starting from the chat's entry in the chat index in the browser store, and is disabled until the store answers. Saving them unchanged only closes the panel, and saving them syncs the index. Delete asks "Delete “title” for good?" first, with the focus on Cancel, and once deleted goes to / and syncs the index. A failure shows in the conversation's alert and leaves the panel open, and Escape or Cancel closes either.
    • Instructions in the More menu opens a panel with a textarea holding the chat's own instructions, the systemPrompt of its info record, which the model follows in every reply in this chat after the user's custom instructions, as chat-turns says. Saving sends them trimmed through the conversation's PATCH route and closes the panel, and saving them empty clears them, which leaves systemPrompt out of the record, as when the chat was created. Saving them unchanged only closes the panel, so the chat doesn't move up the list. A failed save shows in the conversation's alert and leaves the panel open, and Escape or Cancel closes it. Every chat has an info record from its creation, so the menu item is disabled until the record has arrived from the local copy or the PDS, and the panel never starts empty over stored instructions.
  • Message. A message shows its work as glass, not a black box: what the model did is visible as it happens, collapsed to a line by default, and every step can be opened down to its exact input and output.

    • Its header names its author: "You", or the model that wrote the reply, from the record's model.
    • Assistant text parts and reasoning are rendered as GitHub-flavored Markdown with Streamdown, as ADR 0005 explains. While a part streams, Streamdown completes its unfinished Markdown, so a ** not yet closed already shows bold and a link still being typed shows as its text, and renders it block by block, so the blocks already written don't render again. Its words fade in as they arrive, over the theme's enter duration, a burst of them as a short cascade, and not under reduced motion. Code blocks and math appear without fading, and a finished part renders without the fade's spans. Fenced code is highlighted by Shiki in the theme's colors, as it streams too, and a language Shiki doesn't know shows as plain text. Finished code highlighted earlier in the session is colored from its block's first frame, as when a reply's record replaces its stream, rather than drawn plain and then again in color. highlight.ts keeps the tokens of the last 200 finished blocks for that. Math between $$ renders with KaTeX, loaded the first time a text holds some, and emphasis beside Chinese, Japanese, and Korean punctuation works. Raw HTML is not rendered and shows as the text it is, links open in a new tab, apart from links within the text, such as a footnote and its way back, which scroll to their target in the same reply, since every reply numbers its footnotes from one, a fenced code block has buttons in its corner that copy its code and download it as a file named for its language, as code.py, once its fence closes, a table has menus in its corner that copy it as Markdown, CSV, or TSV and download it as CSV or Markdown, through Streamdown's table helpers with our own buttons and menu, and these show on hover and always on a touch screen, 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, except that runs of lines starting with >, as a quote from a reply sends them, show as quotes without their markers, in the same bubble as the text, through splitQuotes in parts/quotes.ts. Full Markdown for user text is planned.
    • Parts show in the order the model produced them. Text, sources, images, and files show through partViews in src/parts/index.ts, and each run of reasoning and tool parts is one block of steps, drawn by StepsView, a <details> whose summary names the steps, such as "Thought · web_search ×2". A block of one step shows that step alone. A block shows only while the model is working in it, so once the reply's text starts streaming the work gives way to it. The block stays mounted but hidden, so its steps' times are still recorded. A finished reply keeps its steps behind a button at the end of its footer, named by their summary across every block, as "Thought for 12s · web_search", which opens them as one list under the footer and keeps the footer shown while open. That list has no cards or numbers: each step sits on a quiet rail with a light dot, green when its tool finished, red when it failed, and grey for a thought, joined to the next by a short soft segment. A reasoning step is a row like a tool step's, naming it, "Thought for 12s", or "Thinking" while it runs, with a muted glimpse of its first line of text, its heading, quote, and list marks and inline Markdown left out. It opens onto the whole reasoning as Markdown, and the glimpse goes while it is open. A tool call and its result, matched by callId, are one step whose summary shows the tool and a glimpse of its input, which goes while the step is open, and whose body shows the input and output pretty-printed when they are JSON, with a link to outputBlob when there is one. A failed tool or a call with no result is marked as such. A step renders its body only once it is first opened, and keeps it once closed, so a closed thought doesn't parse every token streaming into it and a closed tool step doesn't hold all the tool returned.
    • The footer's steps and each step start closed, and the reader opens what they want to see.
    • Sources are shown after the parts behind their count, such as "3 sources", a <details> whose summary is a grey chip shaped like the source rows, with a link icon before the count and a chevron after it, and which starts closed, so the sources stay out of the reply's way until asked for. Opening it shows them as a numbered list, one source to a line. Each is a link named by its title, followed by its site, or named by its site alone when it has no title, with the full URL on hover, and a long title is cut short to keep its line. A streaming reply's sources join the list as they arrive. The list is not folded, since opening the count already asks for every source. The search results in a web search step fold past seven through useFold in src/parts/fold.ts.
    • Images are shown inline, loading and decoding only as they near the screen. A file shows as the chip it was in the composer, FileChip in src/shared/ui/, with a file icon, its name, cut short to the chip's width and shown in full on hover, or "Attached file" without one, over its kind, from its extension or else its MIME type, and its size, as "PDF · 1.2 MB", and an icon at its end for what opening it does. The whole chip is a link: a PDF opens in a new tab in the browser's own viewer, with an arrow out, and any other file downloads under its name, with a download arrow. On hover it takes the sources chip's tint. Files side by side share a row that wraps toward the message's edge, and an image between them keeps its place.
    • A pending reply shows its stream step by step, as the final record would show it. While the last block of steps is still going, its summary says what the model is doing now: "Thinking…", "Running web_search…", or "Reading the results…". A pending reply's header is its status, rather than a line repeating the model's name below it. Before any part arrives, and for a pending reply with no stream, as on the shared page, the model's name breathes. A reply just sent shows nothing for its first 400ms, so one that starts quickly never flashes a waiting state. Once a part streams in, the header shows the name plainly. It reads only whether the reply has started, so a delta rerenders only the stream. While the turn waits for the rate limit, a line under the header says so.
    • Steps are timed where time is spent, as chat apps time thinking. While reasoning streams, its step and the block's summary count up, as "Thinking… 8s", and when it ends the step reads "Thought for 12s" and the finished reply's footer button totals it, as "Thought for 12s · web_search". A running tool counts up the same way, and its time goes once it finishes. The record holds no times, so they are measured in the browser while the reply streams and kept in memory by reply and step, so they carry over from the stream to the finished record. A reply not watched while it streamed, as after a reload, reads "Thought". An errored reply says it failed and why, with a "Try again" button beside it that regenerates it with the same model, as Regenerate does, and is disabled while a regenerate is on its way. A cancelled one carries a small amber Stopped badge, with the Stop button's square, under where it broke off.
    • The message element carries data-status, and live steps data-live, data-current, and data-state, so a theme can style each state.
  • Message actions. On user messages, edit, which opens the composer prefilled and sends a sibling, copy, and details. Editing another message starts the composer over from that message. On assistant replies, copy, regenerate, which regenerates with the reply's own model, sent with the request so a later default or a sibling's model never takes its place, and More, a menu holding the rest: quote, which adds the text selected inside the reply when the menu opened, or the whole reply when nothing in it was selected, to the composer as a quote chip and focuses it, and says which it will quote; regenerate with, which shows the models in the menu's place and regenerates with the one picked at once; and details. Details, as a button or from More, opens a card of what the message's record says: the model, effort, tokens, status, when it was written in full, its record key, and where it is stored. Regenerate with and details each take the menu's place under a button back to it, and each view starts with focus on its first control. Escape or a click outside closes the menu or card and returns focus to its button. While generating, stop. The actions are icon buttons named by their labels, grouped as the message's actions, and sit at the message's edge, so a reply's start its footer and the user's end theirs, with the most used nearest the edge. A reply's steps button comes after them. The actions show on hover and on keyboard focus, while a menu or card is open, always on the latest reply, since its actions are the ones most often wanted next, and always on a touch screen, which has no hover. The shared view shows details as each message's only action.

  • Composer. A textarea that grows with its text up to a cap, then scrolls, whose placeholder instructs rather than asks: "Ask anything" in a new chat, and "Reply" under a message, and nothing while an open chat's messages are on their way, so a chat opened on a reload never says one and then the other, and a chat just started goes from "Ask anything" to nothing to "Reply" as it opens, and a row under it with an attach button and Send, and under the card a model select from /api/models. Attach and Send are icon buttons at either end of the row, a paperclip that opens the hidden file input and an up arrow, so the main action always sits in the corner. The model select is quiet caption text in a line under the card, starting in line with the textarea's text. The line never wraps, so a long model name is cut short with an ellipsis, with the whole name in the button's tooltip and in its list where the list has room. Once its list holds eight entries or more, about as many as it shows before scrolling, it opens with a Search models box, which finds a model by its name, its ID, or its provider, as the model select in Preferences does. For a model with the reasoning capability, the chosen one or, with none chosen, the model the server falls back to, a quiet effort select, the same one as in Preferences, sits after it on that line, keeping its width, and shows the chosen level's pie and name. Each message names the model that answers it, the one chosen or, with none chosen, the one the select names, so a chat started on the default keeps that model whatever later becomes of the default. In a chat with a model of its own, the model last used on the branch, the select shows that model by name and offers no "Default", following a switch of model from the moment the message that made it arrives, through a reply that is running, stopped, or failed, and on reopening the chat. A chosen model the catalog no longer offers shows as unavailable, and is never swapped for another. Before the chat has a model, "Default" is the default from settings, the user's or the admin's, and names it, as "Default · Claude Sonnet", once the catalog offers it, and says "Default model" otherwise. While an open chat's messages are on their way, the select shows a loading line and no effort, since nothing yet says which model the chat uses. The theme grows the textarea with field-sizing: content, and where a browser lacks it, useAutoGrow sets its height from its text. The effort is sent only while its select is shown. It starts on the effort last chosen in the composer on this device, kept in localStorage as a convenience, or on Auto. The attach button accepts images only for models with vision, plus any type an ingester supports. With no model chosen it follows the fallback model's capabilities when the catalog lists it, and otherwise accepts images and leaves the check to the server. Files also attach when pasted into the textarea or dropped anywhere on the conversation, which marks itself with data-dropping while files are dragged over it. A paste that carries text as well as files pastes as text, since office apps copy a picture of the copied text alongside it. Pasted and dropped files skip the attach button's filter, so the server refuses a type no ingester reads, and an image for a model without vision is refused before upload. Each file uploads as soon as it is added and shows as a chip above the textarea, with a local preview for an image or a file icon, its name over its kind and size, as "PDF · 1.2 MB", with "Uploading…" in their place until its upload finishes, and a button that removes it. Removing a file while it uploads cancels the upload. A failed upload drops its chip and says which file failed and why. An image attached before switching to a model without vision stays, and Send is disabled under a note that the model can't read images, until the image is removed or a model that can is chosen. Send waits until every upload has finished, and sending lets go of the attachments it sent, keeping any added while it was on its way. Quotes from a reply show above the attachments, each as a block quote across the card cut to two lines, the whole text on hover, and a button that removes it, so the user sees what the model will be shown apart from what they wrote. Sending puts each quote at the start of the message as a Markdown block quote, in the order they were added, above the text, and lets go of the quotes it sent, keeping any added while it was on its way. Send is disabled while there is nothing to send, no text but spaces, no quotes, and no attachments. Once the composer holds any text, quote, or attachment, a Clear button, an × in the top corner of the box, as a field's own clear button rather than among the controls under it, empties the textarea, removes every quote and attachment, cancelling uploads still running, clears the composer's error, and focuses the textarea. It is disabled while a message is on its way, since sending clears the composer itself. Each paragraph in the textarea runs in the direction of its own text, as Persian runs right to left, while the box keeps the page's direction, so text either way stops short of Clear. Enter sends, and Shift+Enter adds a newline. On a touch screen, which has no hover, Enter adds a newline too and Send sends, as phone keyboards lead people to expect, and an Enter that an input method uses to pick its words, as for Japanese, never sends. Send is disabled while a message is on its way, and Enter does nothing then, so a message is sent once. Until an open chat's messages arrive, Send is disabled and Enter does nothing too, since the message would not yet know which one it answers. While the reply at the end of the branch runs, Stop, a square icon, takes Send's place and Enter does nothing, and that reply's own actions leave Stop to the composer. A composer editing an earlier message says so above the box, that sending adds the message as a new version, with a Cancel button, and Escape in the textarea cancels too. Its Send's tooltip says "Send as a new version", and it sends as usual. The file input clears after each pick, so choosing the same file again attaches it again. The model select offers "Default model" only when the server can choose a model without one, following chat-turns: the user's preferences or the admin set a default. Otherwise the composer asks for a model, and sending waits until one is chosen. When /api/models lists no models at all, Send is disabled and a note points to Settings to add an API key.

    • While the page's code loads, the composer's frame stands in for it, in the same card at the same place: a plain textarea with the composer's placeholder, which is "Ask anything" for a new chat and nothing for an open one, whose messages haven't said yet whether it replies, the attach button and Send, and the model select's loading line under the card, all disabled but the textarea. The server sends it with the page's frame, so the box takes typing before any script runs, and Enter only adds a line, since there is nothing yet to send it. The conversation's column shows it again while its own code downloads. Each frame that gives way leaves what its box holds, with the cursor and whether it had the focus, in composer-handoff.ts, and the frame or composer that takes its place shows the text, puts the cursor back, and takes the focus, so the swap can't be seen. A frame with no text that never had the focus leaves nothing. Only a composer that starts empty takes a draft, never one editing a message, and a draft nothing took, as when the page fails to load, waits for the next one. The frame and the composer are built from the same parts, in composer-parts.tsx. The composer's own parts, in Composer.tsx, read its draft from a Composer.Provider, and ReplyComposer and EditComposer each compose the parts they show.
  • Settings. A sidebar links back to the chats and to each section, with an icon, and marks the current one. Under the way back, it names the area: "Settings" as a title, or, for admins, two tabs, Settings and Admin, with icons, so the way into the other area is always at the top. The open one is raised on a grey tray and is not a link, since it would only lead back to its first section, and it marks the open area while the section's link marks the page. Under the links, a quiet line names the app's version, from the root package.json, so anyone can tell which release a deployment runs. Signing out is in the chat sidebar's account menu. Only the current section is shown, under its title and a line saying what it is for, as cards of related settings in one narrow column. A setting is a row with its label and a description on the left and its control on the right, which drops under them on a narrow screen. On/off settings are checkboxes drawn as switches, except several on one row, such as what a key's model can do, which are toggle chips. A form's Save sits in a bar that sticks to the foot of the panel, shows "Saved" after a save until the next change, and shows a failed save's message. Save is disabled until the form differs from what was saved, and the bar says "Unsaved changes" beside it until then, though not beside a button that adds, such as Add plugin. Leaving the page doesn't ask, so unsaved changes are lost. Below 720px the sidebar is a drawer, as on the chat page, opened by a toggle at the top of the page and closed by the backdrop or by following a link in it. The admin area shares this layout and these pieces, which live in src/shared/ui/SettingsPage.tsx.

    • Preferences: a theme of System, Light, or Dark under Appearance, which applies at once and is kept on this device in localStorage rather than in the preferences record, as the styling spec says. The account menu offers the same choice. Then default model and thinking effort under Models, custom instructions, and generate titles under Chats. Saving also stores the browser's time zone, which the app saves on sign-in as well when it differs from the stored one. A stored default model the model list no longer offers, as after its key is deleted, shows as unavailable and is kept on save until the user picks another. When the preferences can't load, the section shows why and no form.
    • API keys: a list of the user's keys, each with its name, provider, how many models it covers, and last four characters, or a note when there are none. Each row opens the key's page. Deleting a key asks to confirm in its row first. An Add key button beside the title opens a form below the list to add a key, and Cancel, Escape, or adding the key closes it again, with focus back on the button. Cancel waits while a key is being added. The form has its labels over its fields, with focus on the first: provider, key, an optional name, and model IDs, with a button to load the provider's models where it lists them. Loaded models appear below the field as a list to tick, none ticked, and the key is added with the ticked ones and any typed IDs. For providers with user endpoints, base URL and slug fields. The slug fills in from the name as it is typed, until the user edits the slug, and clearing it hands it back to the name. A key's page (/settings/api-keys/<id>), under a link back to the list, is titled with the key's name over its provider and base URL. Its Models card has a row for each model: its name, its ID when that differs, Vision, Thinking, and Tools as chips that stay pressed while on, and a button that removes it. In a narrow column the chips go under the name. Under it, Add models has a field to type a model ID, where Enter adds rather than saves, and, for providers that list their models, Load models, which lists them with the saved key to tick the ones to add. A ticked or typed model waits, ticked, in that list and joins the Models card only on Save, so unticking it before then leaves it out. A typed model the list doesn't have goes at the top of the list, and stays there once unticked, to tick again. A model the key already has shows ticked, can't be unticked, and says Added, since its remove button takes it out. Typing an ID that is already on the key or already ticked says so under the field rather than adding it again. A model removed and ticked again is saved as the key last had it, and one typed before the list loaded takes the list's name for it. A key that can't be read anymore shows a red note asking for it to be deleted and added again, and a page for a key that isn't saved anymore says so in place of the form. The changes are one draft under one Save, which is disabled while the key would have no models. Below the draft, a red-edged card deletes the key, asking to confirm in place first, then returns to the list. The API keys section stays marked in the sidebar there.
    • Plugins: one form with a card per plugin and a single Save, which saves every plugin's settings and any tool switches that changed. A card has a switch for each tool the user may switch, labeled "Enabled" when it is the plugin's only one and by tool name otherwise, with the tool's description, then the fields generated from the plugin's JSON schema. When no plugin has settings or switches for the user, the section says so rather than showing nothing.
    • Sync: a note saying where the user's chats are stored, their PDS or this server, then the background sync switch, shown only when the admin allows users to opt out, and a button that deletes and rebuilds the browser store, which asks to confirm in place first. The switch moves as soon as it is clicked.
  • Loading. Content on its way shows a skeleton in its own shape, which fades in only after a quiet wait, rather than the words "Loading…" or an empty space:

    • The page's frame, which the server sends for the chat routes, has the sidebar's logo and toggle over skeletons of New chat, the chats, and the account card, the composer's frame at the foot of the panel, which takes typing at once, and an open chat's header, with a line for its title, over its paragraphs, which keeps them clear of the sidebar's toggle on a narrow screen, and the chat list keeps the rows' skeleton until the local copy answers, or, when it has no chats yet, until the chat index first syncs. While another tab holds the copy, a card over the blurred panel says why instead.
    • A search shows result rows until it first answers, so it never says that nothing matched before it has looked.
    • The account card shows its avatar's tile and a line for the name until the profile answers.
    • A conversation shows a line for its title and a few paragraphs of text lines, with no bubble or name, until the local copy answers, or, when it has no messages, until the PDS has, so a titled chat never flashes "New chat". On a reload, the paragraphs in the page's frame show at once, and the conversation's column and the conversation carry it on in turn, so it never vanishes to fade in again.
    • The composer's and Preferences' model selects hold a line in place of the name until the models load.
    • The sharing panel, the shared page, which the server also sends as its skeleton, settings and admin pages, the users list, and the Sync page's background switch each show their own shapes.

    A button busy with what it was asked, such as Save or Load models, keeps its label with a spinner before it. Sign in's Continue is the exception: it reads "Signing in…" and drops its arrow, since it is leaving the page.

  • Admin. The admin specs describe each section. features/admin/pages/ mirrors features/settings/pages/: a layout for the sidebar, which groups the sections under People, Features, and App, each with an icon, links back to the chats, holds under the way back the same Settings and Admin tabs as the settings sidebar, marks the current section, a plugin's page counting as Plugins, and one file per section. Sections are built like the settings ones, with tables and lists in cards, in the same column width as every other section. A page opened from another, such as a role's or a plugin's, has a link back above its title. Removing a role, a role's member, a plugin, or a model, and revoking a key, asks to confirm in place first. Suspending a user first asks for a reason, seen only by admins. Status, such as a plugin's or whether an account is suspended, shows as a badge. A form that edits stored values starts from them, and starts over when they change after a save. A failed save shows its message, and the field issues the server returns show beside their fields.

Schema forms #

SchemaFields renders one plugin's settings, a JSON schema object, as setting rows of the card it sits in, reporting each change to its parent. A boolean is a switch, and an enum, number, or short text sits beside its label. A secret, a list, or long text sits under it, and a nested object is a titled group of rows. The plugin form binds each plugin's settings object as one TanStack Form field, and each switchable tool as another. It supports strings, as text inputs or password inputs for secret fields, plus numbers, booleans as switches, 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 under rule, whose effect is SHOW, HIDE, ENABLE, or DISABLE. Its condition names another top-level field as #/properties/<name> 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.

The chat #

useChat(skey), in features/conversation/chat.ts, is the open conversation as a chat, in the shape of the AI SDK's useChat without its message list: the branch on screen and the messages behind it from the browser store, whether they are on their way or known empty, the reply running at the end of the branch, and send, regenerate, and stop. It sits on useConversation, the reply follower, and useBranch. Every turn, from a send, an edit, or a regeneration, follows its reply, puts its new message on the branch, replacing m, and calls the view's onTurnStarted, with which the conversation scrolls to the bottom. A send also checks the conversation against the PDS, so the sent message shows without waiting for the stream. useStartChat sends a new chat's first message, creating the chat, and a send that fails after creating it reuses it on the next try. The composer only shows the Draft, useDraft, which holds the text, quotes, attachments, and chosen model and effort, decides whether they can be sent, builds the message from them, and hands it to send, letting go of what it sent once that resolves. Renaming, the chat's instructions, and syncing stay in useConversationActions, and the conversation's one alert shows how whichever action or turn ran last went. useBranch scrolls to a message the URL focuses, from a link or a search result, once it is on screen, and a message picked on the page, by switching siblings or starting a turn, keeps the scroll where it is.

Streaming #

The reply follower, in features/conversation/reply-follower.ts, follows each pending reply in the open conversation until its final record arrives. The conversation follows its replies with useReplyFollower(skey, messages), which follows every message whose record is pending, and returns follow for a reply just sent or regenerated, which it follows at once, before its pending record reaches the browser store.

Each followed reply has a stream query over the reply's stream endpoint, private to the follower. It reduces the stream's events into the reply's parts in the record's own shape, by index: part-start starts a text or reasoning part, delta appends to it, and part sets a finished tool call, tool result, or source. A queued event marks the reply as waiting until its first part arrives. The query ends after the status event. Since streamed parts have the record's shape, a pending reply and a finished one render through the same view. A finished status (complete, error, or cancelled) refreshes the conversation from the PDS, which brings the final record into the browser store. The events that arrive within one animation frame are reduced as one batch, so a reply streaming a word at a time renders at most once a frame, and the status event is passed on at once with whatever came before it. A stream runs once while the conversation is open. Leaving the conversation drops it, and coming back follows the reply again.

A reply's views read it from the follower without ever starting a stream, so each delta re-renders only the views that show it, never the conversation. A pending reply is drawn by PendingReply.tsx, which MessageView uses for its header and body while the record is pending, and which finds the conversation to follow through the FollowedConversation context that ConversationView provides. Its body reads useFollowedReply(skey, rkey): what has streamed so far, and why the reply is no longer live, if it isn't. Its header reads useReplyStarted(skey, rkey), which re-renders the header only once the reply starts. The stream's part views get the pending record itself. Without that context, as on the shared page, a pending reply shows only its model's name breathing. A reply followed since it was sent stops being followed once the local copy holds its final record, so what streamed of it isn't kept beside the record. A change to the conversation, such as a message arriving or a reply finishing, renders again only the messages it changed: BranchView gives each message's row only its record, which the local copy keeps the same object while unchanged, its place among its versions, and functions that stay the same, and the reply running at the end of the branch reaches the Stop actions through the RunningReply context.

If a reply is still pending after its stream ends, because the stream dropped or the status says another server runs it, the follower polls, checking the conversation against the PDS as opening it does, with a backoff from two seconds to thirty until the reply leaves pending. The first poll comes one interval after the stream ends, and polling continues while the tab is in the background. Meanwhile the pending reply keeps what streamed and says why the reply is no longer live: "Lost the live stream. Checking for the reply…" when the stream dropped or sent an event it couldn't read, and "Not streaming on this server. Checking for the reply…" when its status was not final. That one ends with a Why? toggle that expands, in place, to: "This server isn't writing the reply. It may have restarted, or another chat service you've signed into may have picked up your message. If replies keep failing, revoke that service's access in your PDS account settings."

While the reader is at the bottom of the conversation, it stays scrolled to the bottom as replies stream and messages arrive, gliding down rather than jumping. A screen or more of content arriving at once, as when a chat's messages load after it opens, lands at its end at once, and under prefers-reduced-motion every move is a jump. Reaching to scroll, by wheel, touch, or the scrollbar, ends a glide at once. Once they scroll up it stops following, until they scroll back down or start a turn, by sending, editing, or regenerating, which always scrolls to the bottom, where the new message is. While they are away from the bottom, a button above the composer jumps back down and follows again. A conversation opened on a linked message, or a shared one, scrolls to that message instead. Switching siblings does not scroll.

Scope Boundaries #

  • No visual design or theming of its own. The styling spec has the look.
  • No table of part views or forker guide. Those belong to the extending spec.
  • No server rendering of chat or shared routes.
  • No PWA, offline support, or notifications.
  • No mobile-specific layout beyond the sidebar drawer below 720px.

Edge Cases and Decisions #

  • 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.
  • In development the server imports Start's server entry itself rather than letting Start install its dev middleware, because that middleware calls the entry without a request context.
  • Vite loads its config through its module runner. The default loader imports a temporary bundle of the config, which node --watch sees and restarts on in a loop.
  • With no index.html, Vite's dependency scan starts from the route files. Otherwise a dependency first seen when a split route loads makes Vite re-optimize mid-load, and that route fails to import.
  • Unknown paths get a 404 from the server, and the client then redirects them to /.
  • Chats are never server-rendered. Doing so would read the conversation from the PDS on every navigation and duplicate the browser store.
  • The settings routes do not open the browser store just by rendering, so a settings tab does not take the store from a chat tab. useOpenStore gives them the provided store, as in tests, or opens it when the action runs.
  • api.ts checks for a window before asking Start for the request context. Outside Start's compiler, as in tests, that lookup always takes its server branch.
  • In-process calls only read, and the session cookie is only set at login, so responses from them have no cookies to pass on to the browser.
  • The login page reads error and next from validated search params, since there is no location on the server.
  • 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. So choosing a sibling high in the conversation resets the levels below it to their newest, rather than remembering earlier choices there.
  • Opening a conversation with m, from a link or a reload, scrolls to that message. Choosing a message on screen does not, so switching siblings keeps the reader's place.
  • The router parses search values as JSON, so the validators turn a number back into text, as for a search of 2024.
  • The store's search hook is useChatSearch, apart from the router's useSearch.
  • 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.
  • Where a screen shows one error for several actions, it shows the error of the action that ran last, so a later success clears an earlier failure.
  • Each form starts from the stored values once they load. Submitting runs a mutation, which holds any error for display, so submitting never rejects. The preferences form saves the stored record with its own fields changed, so fields it does not show, like the time zone, are kept.
  • The plugin form addresses fields by position, as plugins[i].values and plugins[i].tools[j], since tool names and setting keys are the plugins' own and may contain the dots TanStack Form uses in field paths. For the same reason a plugin's settings are one field rather than one per setting.
  • The forms check nothing beyond required inputs. The server validates every save against the plugin's or the record's schema and returns the error to show.
  • Invalidating a conversation on a store change is exact, so its own refresh query, which causes those invalidations, does not rerun in a loop.
  • Adding or deleting an API key also invalidates the model list, since a user's keys add their own models.
  • A pending reply keeps showing its streamed text after its stream drops, until the final record arrives, instead of going back to "Thinking...".
  • Each wait on the same replies is its own poll query, keyed by the replies waited on, so the backoff starts over for each new wait.
  • Shared links show a sign-in prompt when signed out, and every other route sends signed-out users to /login.
  • Viewers go to /login rather than seeing the access message on every route, so the server answers with a redirect before rendering, as it does for signed-out users, and the chat routes never open a store for an account without access. Signing a viewer out deletes nothing, since they have no chats on the device.
  • The admin forms send what the form holds and leave validation to the server, like the settings forms. The server's 400 carries issues with field paths, which ApiError keeps, so SchemaFields can show each one beside its field.
  • The General, Turns, and Sync sections save their groups one at a time, so a failure names the group whose fields its issues belong to.
  • A change to a plugin rebuilds the server's plugins, so it invalidates the admin's plugins and models, and the admin's own models, providers, plugin settings, and attachment types. A change to an admin model invalidates the admin models and the model list.

Acceptance Criteria #