Skip to content

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.

  1. ChatThread owns 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.
  2. ResourceChatPanel is 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 off chat.id.
  3. 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 plain useQuery and 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)"]
  • RealtimeuseChatRealtime subscribes the shared socket and folds chat.message_created / message_updated / message_deleted into the cache; the subscribe is re-issued on every reconnect. With privateOnly it 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 sendusePostChatMessage appends 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 and ChatThread restores the draft (text, attachments and reply target, atomically) if the send rejects.
  • PaginationuseChatMessagePager is imperative and triggers no fetch on render: it reads the stored cursor off the cached page and prepends older pages. loadUntilMessage walks back until a quoted parent is loaded.
  • Typing — the sender emits is_typing: true on a heartbeat below the receiver's TTL and false after 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 acksuseChatSubscriptionStatus surfaces chat.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.typeoption.subject is the only kind shipped, and unknown kinds render nothing, so a new backend kind never breaks an older client.

  • Answering calls useRespondChatMessageOptionsPOST /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_updated echo 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.
  • OptionPreselectContext pre-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

  1. Backend first — the resource needs a chat context type and a context validation entry; without it, get-or-create rejects the context.
  2. Add a query-options factory in src/lib/api/query-options/chats.ts, keyed with chatKeys.byContext(...).
  3. Add the resolver hook under src/hooks/chats/queries/ (copy useSetDesignChat): 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.
  4. Add a thin panel under src/components/chat/ that resolves the chat and renders ResourceChatPanel, passing privateOnly from the viewer's role plus the title, subtitle and any timeline marker or header badge. SetDesignChatPanel is the exemplar.
  5. 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_private reads backwards. true means shared / reviewer-visible; false is an operator-internal note. Reviewer sends must always be true.
  • 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