Chat runtime
A high-level overview of the chat-v2 runtime — Postbox-style store, SSE-driven live source, paginated history, holes — with pointers to the full local docs under `apps/web/docs/chat/`.
The chat feature in apps/web (route /chat-v2, plus the embedded /agent
shell) runs on a custom runtime that the team built rather than adapting an
off-the-shelf chat library. This page is the architectural overview;
the implementation details live in
apps/web/docs/chat/,
which is the canonical source for anyone touching the code.
Three-layer split
apps/web/src/features/chat-v2/ Business glue: composes store + protocol + UI
apps/web/src/lib/chat/ Data layer: zustand store, dexie persistence, SSE source
design-system/tdx-ui/src/chat/ Presentation: MessageList, MessageBubble, Composer, MediaViewerThe boundary rules:
tdx-ui/src/chatis purely presentational. It receives data, emits intents through callbacks. It must not import fromapps/web, the lib store, or any network code.apps/web/src/lib/chatknows nothing about React rendering. It exports plain functions and a zustand store. Components subscribe via thin selector hooks infeatures/chat-v2.apps/web/src/features/chat-v2is the only place that wires the store, the agent client, the SSE source, and thetdx-uiprimitives together.
This split is enforced by code review; there are no ESLint rules pinning it today.
Data model — Postbox port
The data layer is a TypeScript port of the iOS TDChat Postbox layer.
Where Swift uses an actor, TS uses a single zustand store with a write
queue (sequential Promise chain) to preserve ordering. Where Swift uses
GRDB, TS uses Dexie over IndexedDB.
Core concepts (full definitions in
apps/web/docs/chat/architecture.md):
MessageID— peer-scoped, namespace-tagged identity.localmessages live alongsidecloudones until the server ack arrives, then get rewritten to the real id.MessageIndex— sort key(timestamp, namespace, id).localsorts beforecloudat the same timestamp so optimistic messages render in place until ack.MessageWindow— a sliding window of entries around an anchor. Entries are eithermessageorhole(an unloaded range).HoleSet— sorted set of half-open ranges, merged on overlap.nearest(index, direction)finds the next hole to fill; scrolling into one triggersMessageStore.fillHole(range).
Live + history dual sources
| Source | Role |
|---|---|
apps/web/src/lib/chat/runtime/live-source.ts | SSE consumer — thread.token, message.new, thread.complete, etc. Applies updates into the store. |
apps/web/src/lib/chat/runtime/remote-loader.ts | HTTP history loader — fills holes when the user scrolls past loaded messages. |
Both sources feed the same MessageStore.apply(updates) API. Dedup is
handled by noteOutgoingDispatched(fingerprint) + hasKnownServerId() so
the live source doesn't write the same message twice.
Per-session store lifecycle
The store is created once per session in
features/chat-v2/providers/chat-runtime-provider.tsx and passed through
React context. There's no global singleton — the provider is mounted
under apps/web/src/app/(agent)/(opal)/layout.tsx so the chat session
survives navigation between sub-routes.
Persistence
Dexie schema, one IndexedDB database per userId:
db.version(1).stores({
messages: '[peerId+namespace+id], peerId, [peerId+timestamp]',
message_holes: '++autoId, peerId',
chat_state: 'peerId', // last cursor, lastReadMessageId
known_ids: '[peerId+fingerprint]', // dedup set
})Per-user isolation lets a multi-account session swap stores without cross-contaminating message data. The keyed dedup index is what makes the live + history dual-source story safe.
Token streaming
docs/changes/2026-03-22-token-streaming-frontend.md
documents the implementation that landed for admin chat. The web chat
follows the same pattern: the backend emits thread.token SSE events per
token; the frontend appends them to a streaming buffer in the store; once
thread.complete fires, the streaming message becomes a normal message
with its final fingerprint.
Where to read further
The chat runtime is the most complex single subsystem in apps/web. For
anything beyond the overview above, read:
apps/web/docs/chat/architecture.md— the full data-layer reference (boundary rules, message identity, store API, persistence, scroll anchoring, in-flight reply handling)apps/web/docs/chat/api-contract.md— the HTTP + SSE surface the client expects from the cloudapps/web/docs/chat/migration-plan.md— chat-v1 → chat-v2 migration sequence
These docs live next to the code they describe by design — they change together. This site links out rather than mirroring them to avoid drift.
Related
- API codegen —
chat-runtimeconsumes the generated agent client (apps/web/src/lib/chat/protocol/agent-client.ts) - MSW mocks — chat handlers in
apps/web/src/lib/msw/handlers/agent.tsmock the SSE stream for Storybook + unit tests - Workspace → Apps →
@todayai-labs/web— top-level package overview
API codegen
TypeScript clients are generated from the backend's OpenAPI spec via `@hey-api/openapi-ts`. `pnpm api:generate` writes to `apps/web/src/lib/api/generated/` — never edit those files by hand.
MSW mocks
MSW (Mock Service Worker) is the architectural mock layer — Storybook stories, unit tests, and the runtime `mockMode=true` path all share the same handlers and fixtures.