Imported from AliSK81/ckw (
app/web/AGENTS.md). Install upstream withnpx skills add AliSK81/ckw --skill web. Copyright stays with the author.
Working on the interface
app/web/ is generated. The source is web/ at the repository root — a React application on
assistant-ui, built by make web into
app/web/index.html and app/web/static/, both committed and guarded by git diff --exit-code in CI. Edit web/src, run make web, commit both. Never hand-edit the output.
The one file here that is not generated is i18n/*.json. The interface fetches a dictionary at
runtime from /static/i18n/<code>.json, so a translation change needs no rebuild. GET /languages enumerates that folder and reports each language's own name and its direction, so
adding a language is a JSON file and nothing else — the interface carries no list of its own.
web/src/api/ fetch wrappers and the SSE frame mapping
web/src/i18n/ the dictionary, the formatters, script detection
web/src/components/ the views, and the assistant-ui thread vendored into this repository
web/src/controls/ the form primitives the platform does not give us
web/src/registry/ the extension points, and the catalogue that fills them
web/src/fonts/ Vazirmatn, vendored as .woff2 beside the CSS that names it
Extending without editing
The same rule the rest of the repository keeps: every extension point is a registry plus a
catalogue entry, and adding one never means editing a dispatcher. web/src/registry/points.ts
declares the points, web/src/registry/catalogue.ts is the whole list.
| To add | Do this |
|---|---|
| A view in the rail | views.register("name", { label, Mark, Panel, order }), and a nav.name key |
| A column in the library table | columns.register("name", { header, Cell, numeric?, order }) |
| An action on a library row | actions.register("name", { label, Mark, run or href, danger?, order }) |
| A tone for a document status | tones.register("Status", { tone }) |
| A command in the palette | commands.register("name", { label, Mark, run }), and a command.name key |
| A part in an assistant's answer | parts.register(NAME, { Render }) in thread-parts.ts, so it lands in the chat chunk |
| A shortcut on the palette's key sheet | shortcuts.register("name", { keys, label }) |
A registry refuses a duplicate name, refuses a name nothing registered rather than answering
undefined, and is sealed at import, so a late registration fails there rather than on the
first render that needed it. Entries come back in declared order.
registry.test.ts is the conformance suite, and it covers a new entry automatically: every
registered entry must name a key the dictionaries actually carry, so an entry cannot ship
half-translated, and every action must have either somewhere to go or something to run.
A view's Panel is loaded when a reader first opens it and stays mounted after, so a new view
costs nothing on first paint. Register it with lazy() as the others are. check_bundle holds
first paint — the entry script and its modulepreloads — to a budget, so a static import of a
heavy view fails the build rather than quietly undoing the split.
thread-parts.ts is a second catalogue rather than part of the first, and deliberately: the
message parts are typed against assistant-ui, so registering them from the main catalogue drags
that surface into the entry chunk and costs a reader who never opens chat about 30 kB.
check_bundle catches it if that moves.
A command's run is handed a Conducting, the same way a row action is handed a Doing: the
capability arrives as an argument rather than being reached for, so nothing in the catalogue
needs a hook or a context. Every view is already offered in the palette, and so is every
language the server declares — those are dynamic, so they are derived rather than registered.
Every asset is served by an allowlist route in app/main.py, never a directory mount. A file
that is not a .js, .css, .woff2, .svg or an i18n/*.json is a 404, so a path that
escapes the folder cannot resolve. The build's hashed filenames fall out of that walk unchanged.
There is no CDN and no runtime network dependency. A dependency is either an npm package that the build inlines into the bundle, or a file vendored beside the source that imports it. A runtime network dependency breaks air-gapped deployment and leaks who is using the system.
Persian and English are equal. Most of the rules below exist because bidirectional text fails in ways that look like styling bugs and are not.
Why assistant-ui and not a hand-written thread
Its primitives carry the thread's behaviour — viewport, autoscroll, message and part
identity, the composer, branch and edit state — and none of its layout is written in physical
properties, so dir="rtl" mirrors it without a stylesheet fork. The components are vendored
into web/src/components/, not imported as a black box, so every visible string is a file we
own and translate through app/web/i18n/*.json, and tools/check_i18n.py keeps meaning what
it means.
Direction
Three directions exist and conflating them is the cause of most bidi bugs.
| Direction | Comes from | Set on |
|---|---|---|
| Interface chrome | The user's chosen locale | <html dir>, server-rendered |
| A message or document | That content's own language | The message element |
| An embedded run | The run itself | <bdi> around it |
- Set
direxplicitly on every message from its known language. Do not rely ondir="auto"for content. First-strong detection reads a leading citation marker, digit, or Latin model name as the base direction and flips the whole paragraph. - Use
dir="auto"only on the composer input, where the user sees the result as they type. - Wrap every interpolated value in
<bdi>: filenames, document titles, citation markers, facet values, model names, URLs, numbers with units. Do not emulate it withunicode-bidi: isolateon a span — user agents may ignore the styling. - Never build a sentence by concatenating a translated string with a runtime value. Use a
placeholder and interpolate through
<bdi>—segments()returns the runs to do it with. In plain-text contexts —title,aria-label,<option>— wrap the value in U+2068 and U+2069 instead, which is whatisolated()is for. - Give
<pre>,<code>and numeric columnsdir="ltr". Useunicode-bidi: plaintexton<pre>so each line resolves its own direction. - Select on
:dir(rtl), never[dir="rtl"]. An attribute selector cannot see a direction thatdir="auto"resolved. - Use logical properties throughout:
margin-inline,padding-inline-start,inset-inline-start,border-inline-end,text-align: start. Neverleft/right. - Never
text-align: justify. Kashida justification is inconsistent across engines.
Digits
Persian readers expect Persian digits in prose and ASCII digits in identifiers.
- Interface chrome — counts, sizes, dates:
number(). Thefa-IRdefault numbering system already yields Persian digits. Force ASCII withascii(), which adds the-u-nu-latnextension, and reserve it for what a reader would copy or quote: character offsets, scores, similarities, identifiers. A step count is chrome; a character offset is not. - Dates for Persian users:
date()selectsfa-IR-u-ca-persian. Do not ship a date library. - Model output and document text: leave exactly as produced. Rewriting digits inside an answer corrupts identifiers, code and quoted source.
Type
Vazirmatn covers Arabic-script ranges, the platform's own face covers the rest, split by
unicode-range under one family name so an English session never downloads the Persian face.
- The vendored subset is
@fontsource'sarabiccut, which is built with all layout features. Stripping layout features breaks Arabic joining and produces disconnected letterforms. This is the most common Persian webfont bug. - Keep U+200C in the
unicode-range. Without the zero-width non-joiner, Persian compounds break. font-synthesis: none. Synthetic bold destroys letter joins.- Persian needs more leading than Latin at the same size. Set it under
:lang(fa). - Match fallback metrics with
size-adjustso the font swap does not reflow a streaming answer.
Streaming
- The
ChatModelAdapteryields cumulative content, never deltas. Each yield replaces the previous content entirely; assistant-ui diffs it and appends to the text node itself. - Only the last part streams. A text part reports
runningonly while it is last, so source and data parts are appended after the text on the finaldoneframe, never reserved empty up front. - Fix
diron the message before the first token arrives. A direction flip mid-stream throws the whole bubble across the screen. - Render plain text while streaming. Parse markdown for completed blocks only — a half-open
fence or an unpaired
**restructures everything after it on every keystroke. aria-live="off"while streaming, announce once on completion. A live region that fires per token is unusable with a screen reader.- Auto-scroll only when the reader is already near the bottom, and never smoothly during a
stream.
ThreadPrimitive.Viewportalready does this. - Wire the stop control to the run's
AbortSignal, and make the server honour the disconnect. A cancelled stream that leaves the work running turns a retry into a load amplifier.
Editing a question that was already answered
The interface lets a reader edit a question and ask it again, and the server forks rather than
appends, so the transcript it holds and the transcript on screen never diverge. Every assistant
turn reports the checkpoint it settled at; re-running sends that checkpoint as fromCheckpoint
and LangGraph branches the thread there. The branch point is the last answered turn that
survived the truncation, so editing the second question of five branches after the first
answer and not after the fifth.
A question with no answered turn before it — the one that opened the conversation — has no checkpoint to branch from, so re-asking it appends. That case is the one thing this does not fork, and it is worth knowing before reading the transcript.
Controls
The platform's own form controls cannot be styled in both themes, cannot show a check against
the chosen row, and render their own calendar in their own calendar system. web/src/controls/
replaces the ones that showed:
Selectis a listbox:aria-activedescendant, arrow/Home/End navigation, Escape restoring focus to the trigger. It takeslabel, orlabelledBywhen a visible label is beside it.Checkboxdraws its own mark over a visually hidden input, so the label association and the focus ring are real rather than painted.DateFielddraws the month itself, because<input type="date">renders the browser's calendar and a Persian reader needsfa-IR-u-ca-persian.calendar.tsderives the month fromIntlalone — this repository has decided against a date library — so the Persian and Gregorian grids are one code path rather than a special case.SkeletonandEmptyStateexist so a view never renders a void while it waits.
Every control takes its strings as props. None writes a sentence of its own, which is what
keeps check_i18n able to see them.
Counts go through plural(), never a (s) suffix. It selects with Intl.PluralRules and
reads <key>.one / <key>.other. Where one sentence carries two counts, pluralise each part
separately and join them with a key that holds only the punctuation — chat.trace does this.
Never
- Never
dangerouslySetInnerHTML, and never assign toinnerHTML. Not model output, not a filename, not a search snippet, not an error message. Render children and let React escape. - Never
prompt(),confirm()oralert(). They cannot be styled, translated, or given a direction. - Never a colour that only resolves in one theme. Define tokens for both.
Checks
make web # rebuild, then commit app/web
npm --prefix web run test # rendered-DOM assertions, including bidi
uv run pytest tests/features/web -q # what the server itself renders
uv run python -m tools.check_i18n # key and placeholder parity, and no hardcoded strings
Model prose renders through @assistant-ui/react-markdown, and only once a text part settles — while
a part is still streaming the raw text is rendered instead, because a half-open fence reflows every
block after it on each token. Icons come from lucide-react; the interface draws none of its own.
check_i18n reads every .tsx under web/src and fails on a sentence written between JSX
tags or into an alt, aria-label, label, placeholder or title attribute. A vendored
component that keeps its English string passes every other check in this repository, so this
is the only thing standing between an upstream copy and a half-translated interface. A value
the server owns is translated too: library.status.<value> covers every member of
DocumentStatus, and a test fails if the enum gains one the dictionaries cannot say.
Bidi behaviour is asserted against the rendered DOM — the presence of <bdi>, the computed
dir — never against pixels. Screenshot tests here are flaky and prove less.
The vitest suite is where those assertions live now, because the page the server sends is a
shell and everything a reader sees is drawn by React. App.test.tsx renders the whole
interface against a stubbed fetch and holds the guarantees the server-rendered markup used
to carry: unique ids, a name on every control, a tablist wired to its panels, dir="auto"
nowhere but the fields a reader types into, and every interpolated name inside a <bdi>.
tests/features/web/test_page.py keeps only what the server itself still decides.
Running the whole thing locally
make check needs Docker, because conftest.py starts ParadeDB at session scope — pgvector
distances and BM25 analysis are the thing under test and faking them tests nothing. The image
is named in tests/data/harness.json.
dockerd & # if the daemon is not already up
docker pull paradedb/paradedb:0.25.1-pg17 # ~1.4 GB, what the harness starts
make check
To drive the real application rather than the suite, give it a database of the same image and point the inference URLs somewhere — they are only reached when a turn is walked, so the library and search surfaces come up without a model:
docker run -d --name appdb -e POSTGRES_USER=app -e POSTGRES_PASSWORD=app -e POSTGRES_DB=app \
-p 55432:5432 paradedb/paradedb:0.25.1-pg17
APP_DATABASE_URL=postgresql://app:app@127.0.0.1:55432/app APP_BLOB_URL=file:///tmp/appblobs \
uv run uvicorn app.main:api --port 7300
Migrations run on startup, so the first boot against an empty database is the schema check.
