Chat¶
The in-app chat surface: a presentational thread component, a wired resource panel, and the hook/cache layer that keeps a chat live over the WebSocket. It is also how users talk to the platform's agents — a bot reply is just a message with no owner, so the UI needs no agent concept beyond rendering it as the assistant.
Backend contract: Chat and Agent Conversation Flow.
Why it is shaped this way¶
Three separations carry the whole subsystem.
ChatThreadowns no data. Pure UI — header, scrollable message list, composer — driven entirely by props; behavior arrives as handlers (onSend,onSelectOption,onRespondOptions,onLoadOlder,onTypingChange). The only state it keeps is the ephemeral reply draft.ResourceChatPanelis the wiring, and it is context-agnostic. The caller resolves the chat (shooting vs. set design vs. anything else) and hands it in; the panel drives messages, realtime, typing and sends offchat.id.- One flat message cache per chat. Realtime events, optimistic sends and the keyset pager all write into the same
chatKeys.messageList(chatId)entry through the chat cache module — no writer needs to know about the others. That is why the messages query is a plainuseQueryand not an infinite query.
File layout¶
src/components/ChatThread/ # presentational (owns no data)
ChatThread.tsx # composition root: header + list + composer
ChatThreadMessageList.tsx # message list, load-older trigger
ChatThreadBubble.tsx # one message (side, attachments, quote)
ChatThreadComposer.tsx # text + attachments + reply chip + private-note send
ChatThreadTypingIndicator.tsx # "… is writing" row
ChatMarkdown.tsx # bot replies render markdown
MessageOptionsBlock.tsx # switches on options.data.type
SubjectOptionsCards.tsx # the option.subject card grid
optionPreselectContext.ts # pre-checked values for unresolved blocks
useComposerAttachments.ts types.ts
src/components/chat/ # wired panels (data + behavior)
ResourceChatPanel.tsx # generic: chat in → live thread out
SetDesignChatPanel.tsx # resolves the SET_DESIGN chat, role-aware view
SetDesignRequirementsPanel.tsx # the agent's gathered requirements, beside the thread
chatMessageToThreadItem.ts # ChatMessagePublic[] → ChatThreadItem[]
src/hooks/chats/
queries/ useSetDesignChat, useShootingChat, useChatMessages, useChatActionNeeded
mutations/ usePostChatMessage, useDeleteChatMessage, useRespondChatMessageOptions
composite/ useChatRealtime, useChatMessagePager, useChatTyping, useChatTypingSender,
useChatSubscriptionStatus
src/lib/api/query-options/chats.ts # chatKeys + query-options factories + raw page fetchers
src/lib/api/cache/chatCache.ts # every write into the flat message list
src/providers/ChatActionNeededProvider.tsx
Data flow¶
flowchart TD
A["useSetDesignChat<br/>(get-or-create)"] --> B["ChatPublic"]
B --> C["useChatMessages(chat.id)"]
C --> D[("flat message cache")]
E["useChatRealtime<br/>(WebSocket events)"] --> D
F["usePostChatMessage<br/>(optimistic temp → server row)"] --> D
G["useChatMessagePager<br/>(older pages, prepended)"] --> D
D --> H["chatMessagesToThreadItems"]
H --> I["ChatThread (presentational)"]
- Realtime —
useChatRealtimesubscribes the shared socket and foldschat.message_created/message_updated/message_deletedinto the cache; the subscribe is re-issued on every reconnect. WithprivateOnlyit drops non-private frames at the door — defense in depth, so a stale or misbehaving server can never land operator-internal text in a reviewer's cache. - Optimistic send —
usePostChatMessageappends a temp message, reconciles it with the server row and rolls back on error; the server's own WebSocket echo dedups by message id. The composer clears synchronously andChatThreadrestores the draft (text, attachments and reply target, atomically) if the send rejects. - Pagination —
useChatMessagePageris imperative and triggers no fetch on render: it reads the stored cursor off the cached page and prepends older pages.loadUntilMessagewalks back until a quoted parent is loaded. - Typing — the sender emits
is_typing: trueon a heartbeat below the receiver's TTL andfalseafter an idle window; it is inert unless the connection is subscribed. The receiver resolves names, filters the self-echo, and handles the anonymous case: the backend strips the typist's identity for reviewers, and a bot signal has no identity at all. - Subscription acks —
useChatSubscriptionStatussurfaceschat.subscribed/chat.subscribe_error, so a rejected subscribe shows a real error instead of a silently dead thread.
Pressable options¶
A bot message can carry an options block. MessageOptionsBlock switches on options.data.type — option.subject is the only kind shipped, and unknown kinds render nothing, so a new backend kind never breaks an older client.
- Answering calls
useRespondChatMessageOptions→POST /chats/{id}/messages/{messageId}/options/respond. - No optimistic lock. Server truth wins: the block is disabled while the request is in flight, then the returned locked message is swapped in. The
chat.message_updatedecho lands as well; both paths funnel through the same idempotent cache writer. - Errors branch on the API status: 409 already-answered refetches server truth; 400 / 403 / 404 surface a toast.
OptionPreselectContextpre-checks values on unresolved blocks only (e.g. subjects already gathered elsewhere on the page). A resolved block always shows its own answer.
Action-needed badges¶
ChatActionNeededProvider wraps the logged-in shell and polls "chats awaiting my reply" every five minutes, exposing the grand total (global notifications badge), a per-context-type count (sidebar) and a per-resource lookup (card markers, pre-indexed to O(1)).
It deliberately does not invalidate on route change: invalidation refetches active queries regardless of staleTime, which would cost one request per navigation. Freshness comes from the poll.
Add a chat to a new resource¶
- Backend first — the resource needs a chat context type and a context validation entry; without it, get-or-create rejects the context.
- Add a query-options factory in
src/lib/api/query-options/chats.ts, keyed withchatKeys.byContext(...). - Add the resolver hook under
src/hooks/chats/queries/(copyuseSetDesignChat): gate on!authLoading && isLoggedIn() && !!resourceId && !!organizationId— the endpoint is organization-gated, so firing before the org resolves guarantees a 4xx and a false error state. - Add a thin panel under
src/components/chat/that resolves the chat and rendersResourceChatPanel, passingprivateOnlyfrom the viewer's role plus the title, subtitle and any timeline marker or header badge.SetDesignChatPanelis the exemplar. - Render it from the route segment. Messages, realtime, typing, pagination, options and optimistic sends all come from the panel.
If the resource also runs an agent, its bot messages arrive through the same channel; surface the agent's structured output with its own query (see the set-design requirements panel).
Gotchas¶
- Never render a message before the viewer resolves. The panel keeps a spinner in-thread until the current user is known, otherwise bubbles paint on the wrong side and then flip.
is_privatereads backwards.truemeans shared / reviewer-visible;falseis an operator-internal note. Reviewer sends must always betrue.- Reply previews self-heal. A live reply whose parent is not loaded yet is written as restricted; once that parent is paged in, the mapper re-derives a real quote from it. A genuine restriction never reaches that branch — the backend never returns a forbidden parent.
- Keep handlers stable. The panel memoizes items with its handlers in the dependency list; an inline closure defeats per-bubble memoization. Wrap with
useCallback. - Do not reach for an infinite query. Realtime and optimistic writers assume the flat list; the pager exists precisely so both models never have to coexist.
Source: src/components/ChatThread/, src/components/chat/, src/hooks/chats/, src/lib/api/cache/chatCache.ts