48 KiB
Data Flow
Scope local-turn ordering to displayThreadId ?? threadId, the displayed
conversation identity. Confirming a new chat changes the SDK threadId from
undefined to its existing display ID; preserve the human anchor across that
transition and clear it only on a displayed-conversation switch or the existing
failure/replay-gap paths. Draft and confirmation regressions live in
tests/unit/core/threads/local-turn-order.dom.test.tsx.
Answer details use workspace/message-details descriptors; skill run scoping
is documented in docs/skill-usage-ui.md.
Encode raw paths preserving literal percent escapes. Decode Markdown once;
match image names to raw paths.
Sanitize URLs; key Streamdown by file/thread/mock context and scroll key.
Detect file types by basename, including Dockerfile/Makefile.
Language maps use own properties; constructor/__proto__ keep fallback.
HTML bases skip DOM without base text; inert templates ignore comments/scripts
and templates. preview-base.dom.test.ts (happy-dom); RCDATA needs
browser checks; other helpers use node.
HTML assets: credential GET, no 401 navigation; current-thread only, no redirects,
bound bytes/count/CSS depth/time/expansion; keep doctype, rewrite SVG style.
Keep opaque sandbox/data URLs; external refs stay browser-loaded.
Abort stale work; revoke URLs; gate content/URL/scroll.
Tests: html-assets.dom.test.ts, artifact-file-preview.dom.test.tsx,
tests/e2e/artifact-preview.spec.ts.
- Optional composer helpers such as
core/input-polishcan rewrite the local draft before submission, andcore/voice-inputcan transcribe browser microphone input into that same local draft; confirmed user input then flows to thread hooks (core/threads/hooks.ts) → LangGraph SDK streaming. Voice transcript joining separates every adjacent pair of trimmed word segments, preserves CJK joins and provider whitespace, and attaches leading punctuation/percent signs while retaining spaces after sentence punctuation. Regression cases live intests/unit/core/voice-input/speech-recognition.test.ts. - Stream events update thread state (messages, artifacts, todos, goal). The main thread stream uses the LangGraph SDK's
throttle: truemode so updates received in the same macrotask coalesce before React is notified; do not replace it with a numeric delay without validating the SDK's trailing-debounce behavior on a continuous stream. File-tool artifact auto-open work must run in an effect with timer cleanup; never schedule timers while rendering streamedwrite_fileorstr_replaceupdates.ThreadState.artifactsremains the authoritative artifact list. The artifacts provider persists only thread-scoped panel UI state (open, selected path, and a refresh bootstrap cache) in session storage; an initial empty stream value must not overwrite that restored state before history finishes loading. Formal artifact content is refreshed once when the run finishes; transientwrite-file:previews remain message-driven. UTF-8 outputs under/mnt/user-data/outputsare editable while idle. Root drafts survive SPA/home/showcase routes and unknown auth; confirmed account change/logout clears them. Owner/generation fences reject stale updates. Auth binds through a stable setter context, outside the typing state subscription. Drafts preview in Markdown/HTML and warn on unload off-route. SHA-256 conflicts preserve edits. Layout, draft-context and editor DOM tests cover this. Regular artifact text loads request at most the first 1 MiB through an HTTP byte range. A truncated preview must stay lightweight and expose an explicit full-file action; do not mount CodeMirror for that artifact until the user requests and receives the complete content. Truncated responses must never establish or replace an editing baseline, including when discarding a draft: their ETag can match the full response. Editing and saving require complete content; retain existing drafts across partial reloads. The Gateway retains range ownership and returns 206/416 throughFileResponse. Keep Exit editing available while editing even if a reload is loading, truncated, or fails; exiting retains the draft and is disabled only during a pending save. useThreadHistoryloads persisted conversation pages fromGET /api/threads/{id}/messages/page, preserving the backend's thread-global eventseq; rendering overlays checkpoint/live copies at their matching canonical identities (a summarized checkpoint may contain a protected early input plus a recent tail). Context-compaction rescue diffs every retained visible identity rather than slicing at the first anchor, and keeps a run-scoped ledger of committed visible messages so replacement updates and repeated rolling checkpoint windows cannot erase an already displayed step. A checkpoint/transient prefix whose canonical position is still behind an unloaded cursor page is woven in before the first shared anchor, not discarded: both the checkpoint and seq-sorted history place it earlier, so that position is known even when the pages between are not. Position authority is seq-first and lives incore/threads/message-order.ts(re-exported throughhooks.ts): every normalized identity tracks latest visible content and trusted position separately; a validdeerflow_seq(positive safe integer, earliest value wins per identity, hidden control copies contribute a position only when no visible copy carries one) joins an ascending skeleton that outranks identity-anchor weaving, which remains the fallback for no-seq segments whose internal order is preserved. Live-only and rescued messages with trusted seq also serve as anchors for adjacent no-seq segments. A trailing segment follows its last live-only positioned anchor within the loaded window; after a shared anchor or a rescued prefix before the window, it stays at the tail. Optimistic messages remain last; the transient bridge inserts positioned rows before weaving so the insertion cannot reverse previously displayed steps. Rescued sequence anchors preserve preceding captured steps even before React has rendered them; only a leading prefix anchored to loaded history requires previously rendered ordering to cross an unloaded cursor gap. Content replacement never drops the known seq,run_id, orturn_duration, and the compaction transient bridge plus rendered ledger share the same position priority.deerflow_seqis server-owned display metadata and is never written back into a checkpoint. It must never be appended to the tail (#4065) — the tail is provably wrong — but suppressing it entirely is how a user's own question vanished from a long thread once the first 50-row history page no longer reached back to it (#4666). A collapsed unloaded gap is recoverable by paging; a dropped message is not. Weaving alone restores the message but not its exact position — after compaction the live window carries too few anchors — so both sides now carry the backend's thread-globaladditional_kwargs.deerflow_seq:buildVisibleHistoryMessagescopies each row'sseq, and the Gateway stamps it ontovaluesframe messages it has already persisted. A live message whose seq is below the loaded window's lower bound is placed ahead of everything on screen instead of before the nearest anchor, which is what puts a compaction-rescued first user turn back at the head rather than mid-transcript. That split happens before the anchor walk, not inside it: a compacted checkpoint can share no identity at all with the loaded page — it keeps only the current run's recent tail, while the page on screen was fetched turns earlier — and the anchor walk then never runs at all, which is precisely when a rescued turn most needs its seq. Doing the split inside the walk left that case appending the message after the whole window (#4666), the one arrangement #4065 proved wrong. A message without a seq (still streaming, so not in the feed yet) keeps the weaving path — the tail is already its correct position. Optimistic messages are then added without timestamp re-sorting. History invalidation preserves already-loaded pages so their established ordering positions are not discarded. Dynamic context re-keys each submitted user message from the client-generatedlocal-human-*identityXto the visible server echoX__user; UI identity matching normalizes that reserved suffix only for human messages so the optimistic input and checkpoint replacement remain one visible turn. At dispatch, a local-turn anchor snapshots the checkpoint identity baseline, canonical history identities and maximum trusted seq, and any pre-existing transient-bridge identities. Render repair usesconfirmedHistoryIdentitiespluspreSubmitMaxSeqto restore baseline or history-confirmed messages above that exact human anchor, while moving only speculative non-baseline AI/tool steps behind it;currentTurnRunIdskeeps already-persisted steps of the active turn below their human. Keep the anchor scoped to its originating thread through finish, stop, and stream error because the SDK's settled frame can retain transient event order; replace it on the next local submit and clear it on thread switch or replay-gap recovery.- Stop actions call the LangGraph SDK stream stop path;
core/threads/hooks.tsinvalidates current-thread, thread-history, token-usage, and sidebar/search caches immediately and schedules one follow-up refetch because SDK stop may finish via abort + fire-and-forget cancel before backend title finalization commits - TanStack Query manages server state.
UserPreferencesBoundarypreserves workspace SSR and initializes the account cache before browser paint; only subsequent account switches gate consumers until the new cache is active. Four allowlisted settings sync through/api/v1/auth/preferences; confirmed caches are account-scoped in localStorage and pending patches are account-scoped in tab-local sessionStorage. A stopped account cannot apply late responses, and each request carries its expected user ID to fence shared-cookie switches. Storage events refresh from the server without echo-writing unchanged data; failed reads and writes retry with capped backoff and focus/online wakeups. Legacy unscoped settings are never automatically uploaded. Display settings and thread model overrides remain local. Shared local-only fields still merge across tabs on storage changes/removal/clear, preserving account preferences. Explicit InputBox selections pass only fields changed by the action, so a mode/effort edit cannot upload an unrelated thread model override. InputBox marks automatic model/mode resolution separately from user choices on both normal and Custom Agent chat pages;resolveThreadContextmust neither enqueue account writes nor create a fallback thread override that masks a later server preference. The Capability Center > My plugins MCP switch calls the targetedPATCH /api/mcp/personal/configmutation, disables switches until that mutation's success refetch completes, displays the backend errordetailthrough a toast, and invalidates["mcpConfig"]only after success. Personal server management uses targetedPOST /api/mcp/personal/config/servers,PUT /api/mcp/personal/config/server, and bodylessDELETE /api/mcp/personal/config/servers/{server_name}mutations. The deployment/api/mcp/configendpoints remain admin-only and are used by the Platform provided editor. Delete names are percent-encoded, including legacy empty and slash-containing names; every successful mutation invalidates["mcpConfig"]only after the response. Current-chat MCP background tasks usecore/background-tasks: the header trigger is hidden for new/mock/static-demo threads and unless/api/featuresreports the startup-scopedmcp_taskscapability; the list query is disabled while that capability is unavailable, so default-disabled and memory-backend deployments never poll an endpoint that cannot serve tasks. It lists at most 20 local task records, refreshes every 3 seconds while any task is active (15 seconds otherwise), fetches bounded task details only while a user expands a card, and cancels through the thread-scoped local-ID endpoint. The expanded view shows result/preview, artifact metadata, input requests, and the latest poll, notification-delivery, or cancellation error without exposing the persisted remote handle. A persisted cancel request remains "Cancelling…" only while the task status is still active; if remote cancellation keeps failing, the active card remains expandable and shows the attempt count plus the latest bounded error while the backend continues retrying. Notification delivery failures expose their bounded error and attempt count; retryable failures use backend backoff, while a permanent rejection or exhausted five-attempt budget is shown as stopped rather than implying that retries will continue. Explicit durable native-subagent batches usecore/subagent-batchesandThreadSubagentBatches./api/featuresreports SQL-repository availability separately from the startup worker. A running worker exposes the trigger on both default and Custom Agent chat pages; a stopped worker keeps threads with durable history visible in read-only mode for inspection and JSONL export, while deployments with neither a worker nor history keep the trigger hidden. The panel renders bounded progress and incrementally paged item previews, controls pause/resume/cancel, retries failed items, and exports JSONL. Worker-dependent mutations stay disabled in read-only history mode, and persisted progress is normalized to a bounded percentage before reaching the UI primitive. Item pagination uses a fixed page size and an explicit load-more control; full results remain available only through JSONL export. The panel must not infer batch mode from prompt text or inject the complete result set into chat state. Capability Center > Plugins > Lark uses a local generation only to suppress stale React callbacks; server-issued Lark flow generations must be passed through every config/auth completion and across switch-or-register to authorization chains so backend cross-tab ordering remains authoritative. Settings > Subagents reads one catalog for built-in, config, and managed definitions. Only administrators see managed-definition mutation controls; Custom Agent settings consume the same query and preserve stale selected names as removable "missing" entries instead of silently widening the allowlist. The Agents gallery imports and exports versioned JSON packages throughcore/agents; it may prefill an import name from untrusted JSON, but only the Gateway validates or persists the package. A 409 is an explicit rename prompt/error, never permission to overwrite the existing Agent. Import errors join FastAPI 422 validation messages into readable text while preserving string conflict details; unusable error bodies use a status fallback. New-Agent saves require the latestsetup_agentsuccess and read-back. ToolMessage/deerflow_tool_meta.statuserrors or exhausted retries restore Save. - Components subscribe to thread state and render updates
AI message grouping uses extractContentFromMessage() to identify visible answer content. A non-empty content array may contain only Anthropic thinking blocks; keep it in assistant:processing until answer content arrives. Cover both streamed snapshots in tests/unit/core/messages/utils.test.ts.
Inline <think> extraction scans code and reasoning openers in source order. Code tags stay in answer/copy data, including unfinished streaming spans. Blank lines, headings, thematic breaks, interrupting lists and fences end inline spans; ATX spans also end at the heading's newline. Fenced/indented code stays protected; indentation cannot interrupt a paragraph. Fences opened after list markers scan to a matching closer or the end of the list item, with tab-aware container indentation; their closers must not open a new top-level fence. Outside inline code, a backslash escapes one backtick, not the whole run. Real reasoning closes independently of Markdown inside it. Preserve the content-keyed cache and no-tag fast path.
Project moves in core/threads/hooks.ts cancel all per-thread metadata query
variants after the write succeeds, merge only deerflow_project_id, then
invalidate/refetch that metadata prefix. This fences delayed pre-move reads and
restarts initial reads that have no cached snapshot. Search and project-thread
lists are invalidated on settlement. Keep the delayed-read regression in
tests/unit/core/threads/move-thread.dom.test.tsx for moves and removal.
The chat header's context-window control is intentionally persistent: while context_usage is unavailable, ContextUsageBadge renders a gauge placeholder rather than unmounting; once data arrives, the same position shows the percentage. useThreadTokenUsage retains placeholder data only when the response thread_id still matches the active route, so same-thread refetches do not flicker and cross-thread navigation never displays the previous chat's usage.
Settings skill uploads reject archives larger than 100 MiB before starting the
request and disable the hidden file input while an install is pending. The API
client preserves structured SkillScan findings on SkillRequestError, and the
settings page renders a compact file/rule/line summary so security rejections
remain actionable; a proxy-generated 413 is mapped to the localized size error.
Run duration is run-scoped UI metadata even though the compatibility field additional_kwargs.turn_duration is repeated on historical AI messages. core/messages/run-duration.ts folds those copies into one display owned by the run's last visible message group. MessageList owns the temporary client-side duration for a just-completed live turn until authoritative history arrives. The duration is total run wall-clock time, not per-message reasoning time. At that anchor, a completed reasoning disclosure uses the duration as its visible label with a divider underneath and a visually hidden localized reasoning cue for its accessible name. This applies to assistant bubbles, processing groups, and subagent groups. getMessageGroupReasoningMessage shares the processing renderer’s trailing-reasoning selection so reasoning before the final tool cannot consume the duration of a disclosure that is not rendered. Groups without a matching rendered reasoning disclosure use a standalone duration header above their content. Historical reasoning without duration retains its generic label. Never repeat the duration on every AI message.
MessageReasoning controls its disclosure state: completed mounts start collapsed, live streams open and close once after completion, and history expansion stays under user control. Keep this lifecycle in the workspace wrapper rather than editing generated AI elements. Duration-target selection is lazy for mounted, completed groups with a duration and cached by stable group identity; streaming updates must not re-convert unchanged historical processing/subagent groups. Changed groups must invalidate that cache.
The workspace-change card follows the same rule: it is resolved from (threadId, runId) alone, so every AI message of a run would render an identical copy. A run ends in more than one terminal assistant bubble whenever the model emits answer text that never gains a tool call, so core/messages/workspace-change-anchor.ts picks the run's last assistant bubble and MessageListItem renders the badge only for that anchor (#4555). Any future run-scoped display belongs in the same place — do not hang one off every message. The two anchor helpers deliberately differ in which group types they accept as a run's last position, because an anchor is only useful where the display is actually rendered: run duration is emitted by MessageList around every group, so it accepts any type, while the workspace-change card comes from MessageListItem and so restricts to assistant. Keep a new helper's candidate set matched to its own render site rather than unifying them.
Composer drafts are tab-scoped browser state. core/threads/composer-draft.ts stores text with inline reference tokens and conversation metadata in sessionStorage, keyed by user, agent, and logical conversation scope. New-chat pages pass the stable scope "new" because their runtime threadId is a fresh UUID on every reload; established conversations use their real thread ID. New drafts write skillName: null; InputBox waits for the enabled agent skill scope before converting an older draft selection into an inline @ reference, and degrades a missing/disabled skill back to editable slash text. The stored draft clears through SendMessageOptions.onSent, which fires only once the send is dispatched: after the in-flight guard and any attachment upload, so a failed upload keeps the draft, quotes, conversation references and staged project files for a retry. If the composer moved to another conversation or unmounted meanwhile, onSent leaves live state alone and retires only the stored draft and staged files that send carried, keeping a later draft or attachment. Attachments, sidecar quotes, voice state, and polish undo state are not persisted.
Conversation references (read_conversation, opt-in on the backend) are attached from the composer. ReferenceConversationsButton (components/workspace/conversation-references/) renders only while /api/features reports conversation_references.enabled, opens a picker over the same useThreads() list the sidebar uses (the current thread excluded, capped at max_references), and shows removable chips in the composer header. On submit the thread IDs ride InputBoxSubmitOptions.conversationReferences → SendMessageOptions.conversationReferences → run context.conversation_references, which the Gateway consumes at admission; the LangGraph SDK drops unknown top-level body fields, so the top-level request field is not reachable from the web UI. core/conversation-references also writes display-only additional_kwargs.conversation_references ({thread_id, title, agent_name?}) on the visible human message so message-list-item.tsx can render read-only chips linking to the source — through pathOfThread, so custom-agent sources route to /workspace/agents/{agent}/chats/{id}; that metadata grants nothing. References are per message: they are not persisted with the draft, clear on send or thread switch, and regenerate/edit of a turn runs without them unless attached again.
Auth UI note: the login page's "keep me signed in" option submits only remember_me to the Gateway and may persist only the email address through core/auth/remember-login.ts. Passwords and tokens must never be stored in frontend storage; the HttpOnly access_token and readable csrf_token cookies remain Gateway-owned.
The web / picker lists only /goal and /compact; skills are selected through the inline @ picker. /goal and /compact are built-in composer commands, not skill activations. src/components/workspace/input-box.tsx intercepts /goal, /goal clear, and /goal <condition> before normal chat submission, calling Gateway GET/PUT/DELETE /api/threads/{thread_id}/goal. Setting /goal <condition> also submits the condition text as the next user task so the agent starts running immediately; status and clear do not start a run. On a project-scoped new chat (/workspace/chats/new?project=…), the chat page's project pre-create runs before the goal PUT via the composer's onPrepareThread callback: the goal endpoint materializes a missing thread row itself, and an unassigned row would make the later idempotent thread create return it without assigning the project. Goal and compact requests are tied to the current threadId with an AbortController, so switching threads or unmounting the composer aborts in-flight requests and stale responses cannot update the new thread's composer state. Both chat pages render GoalStatus above the composer from goal, goal_outcome and messages via useActiveGoal (e2e: goal-status.spec.ts); a /goal result stays local until a goal value or that goal's met record arrives. /compact calls POST /api/threads/{thread_id}/compact to summarize older active context while leaving the full visible chat history intact; it is skipped on new/empty threads and blocked server-side while a run is in flight. Thread rename uses the same serialized state-write route; the rename dialog stays open and surfaces the server error when an active run returns 409.
Composer runs:create gating belongs in InputBox.handleSubmit after command classification and before dispatch. Reject message and goal-set actions before onPrepareThread, goal persistence, success feedback, or draft clearing. Goal status/clear and compact do not start runs; preserve their endpoint-specific gates. Regression coverage lives in input-box-send-gating.dom.test.tsx.
The / skill list stays reachable after a skill is selected: typing / in the editable text beside the chip reopens it, and picking an entry swaps the chip rather than adding a second one, because the wire format carries exactly one leading /skill. That list offers skills only while a chip is selected — a builtin command owns the whole composer line, so /goal behind a selected skill would submit as chat text instead of running the command. The trigger itself is unchanged: a slash only opens the list at the start of the input (getLeadingSlashSkillQuery), pinned by tests/e2e/chat.spec.ts.
Human input requests are a structured message protocol layered on normal chat history. The backend writes request payloads to ToolMessage.artifact.human_input, src/core/messages/human-input.ts owns the runtime validators/types, and src/components/workspace/messages/human-input-card.tsx renders the reusable card. The protocol is versioned on the request side only: v1 covers free_text / choice_with_other, and v2 adds form (typed fields — text/textarea/number/select/multi_select/checkbox/date — with required-field validation in the card). Replies deliberately stay on the v1 response protocol: the form card submits a response_kind: "text" reply whose value is the human-readable summary plus one JSON block keyed by stable field names (buildHumanInputFormSubmissionValue — the readable part alone is ambiguous because labels/values may contain the separators), so the model can reconstruct the submitted mapping without a structured response kind. The validators reject unknown versions/modes (and field names colliding with JS Object.prototype members) so future protocol bumps degrade to the plain-text ToolMessage fallback rather than rendering a broken card. Form values are read through own-property access only (readHumanInputFormValue); select fields stay controlled from their empty-string placeholder state through selection; checkbox fields are native <input type="checkbox"> controls seeded to an explicit false (buildInitialHumanInputFormValues) so an untouched checkbox submits as "no" while a required checkbox keeps must-agree semantics (no HTML required attribute — native constraint validation would intercept the custom submit path), and form controls carry label/htmlFor, aria-required plus a visually-hidden localized "required" marker, and aria-invalid/error associations whose error node stays mounted while any field is still invalid. Composer-bypass closure: deriveHumanInputThreadState treats a visible plain human message as answering the latest unanswered request opened before it (only the latest — nothing guarantees a single outstanding request across runs, and closing all would silently swallow older decisions; an older request left open simply becomes the active card again). This lets current users bypass a structured form through the normal composer and preserves compatibility with old v1-only frontends that degrade a v2 request to plain text. MessageList owns answered/latest/pending state for visible cards, but derives answered responses from raw thread.messages because replies are hidden; pending cards clear when the hidden reply appears, when dispatch is dropped, or when a new thread.error reports an async stream failure. Page-level card submit callbacks must send a normal human message and put hide_from_ui: true plus the response payload in the fourth sendMessage(..., options) argument as options.additionalKwargs; the third argument remains run context such as { agent_name }. Composer entry points remain enabled while a human-input request is open; a normal visible message intentionally bypasses the card and starts the next run without structured response metadata.
Tool-calling AI messages can contain user-visible text as well as tool_calls. core/messages/utils.ts keeps these turns in an assistant:processing group, and components/workspace/messages/message-group.tsx must render the visible text as a processing step instead of treating the message as only tool metadata. This preserves provider text such as error explanations or "trying another approach" notes during tool-heavy runs.
While the current turn is still loading, a content-only AI message after the latest visible human input also stays in that processing group until the turn settles: a provider may append tool-call chunks to the same message later, and classifying it as a final assistant bubble too early makes the text jump into the steps panel. MessageGroup therefore renders processing text even before the first tool call arrives.
The same rule applies after an earlier tool call: a later content-only AI message remains visible after the current last tool-call step while streaming, because that message may itself gain another tool call before the turn settles.
Because the same message is rendered by two different components over its lifetime, reasoning must sit above the answer text in both. MessageListItem paints the settled bubble's <Reasoning> disclosure above its content, so MessageGroup puts the trailing reasoning disclosure above the assistant text that follows it and convertToSteps emits a message's reasoning step before its content step — otherwise the two swap places the instant the turn settles (#4576). Assistant text emitted before that reasoning keeps its earlier position; only the answer the reasoning produced moves below it.
Edit-and-rerun is deliberately latest-turn-only. core/messages/utils.ts::getLatestEditableTurn() exposes a human turn only when the transcript is idle and the most recent visible turn ends in a terminal assistant message. core/threads/hooks.ts::editAndRegenerateMessage() calls POST /api/threads/{id}/runs/edit-regenerate/prepare, submits the returned replacement message/checkpoint/metadata through the same LangGraph stream path as regenerate, optimistically hides the superseded message ids, and clears the optimistic replacement once the persisted replacement arrives.
MessageGroup builds its tool-result and browser-preview lookups once per processing group before converting messages to steps. The lookup preserves the first non-empty result and first screenshot-bearing browser view for each tool-call ID, matching the streamed-message display semantics without repeatedly scanning the full group for every tool call.
Use core/messages/tool-error.ts for tool/Debug failures; never classify text or partial results. Scheduler completion needs an object response. Keep details Debug-only via MessageGroup. tool-call-details.tsx mounts payload previews only while expanded; core/messages/tool-detail-preview.ts caps text output at 12,000 characters, visits at most 12,000 values, and limits nesting to six levels. Count escapes, punctuation, indentation, closing delimiters and truncation markers within the budget; never slice structured JSON mid-token. Generic calls keep the original ToolMessage content/status. Text is displayed verbatim (or as a bounded prefix), never passed through JSON.parse: reparsing can round numeric IDs, discard duplicate keys, and alter quoted strings. Format objects/arrays on expansion; retain specialized result conversion. An empty received payload is distinct from a missing message, and neither implies a running/completed state. Copy actions use the shared clipboard fallback, copy the displayed preview, and reset feedback after two seconds. Truncated containers include an inline ellipsis unless an object has already emitted a real ellipsis key; in that case, only the external truncation notice is guaranteed. Arrays stop after a child collapses because the text budget is exhausted, avoiding repeated trailing markers while preserving literal ellipsis elements. Cycle, accessor, and depth-limit markers do not suppress later siblings. Truncated string prefixes preserve complete UTF-16 surrogate pairs. Object traversal stops before a key exceeds the remaining text budget; never shorten property names, since shortened keys can collide with real data. Renderer routing and result conversion share getToolCallKind. Disclosure accessible names include the tool name and call ID.
Array previews coalesce consecutive generated markers only at the end into one omitted-suffix notice. The serializer tracks marker provenance and complete entry boundaries; it must not deduplicate literal ellipsis values or shift the indices of later real elements. A cycle/accessor/depth-limit tail can therefore share one notice while the same markers in the middle retain their positions.
Key Patterns
REST core/api/fetcher.ts: preserve Request; CSRF uses effective method/headers.
Conversation exports read every persisted message page through
fetchThreadExportMessages (200 rows/page). Run mergeMessages after history
deduplication to restore earliest trusted positions for identities repeated across runs.
Never export only the loaded UI window or compacted checkpoint. Fail the whole
export on a page error or non-advancing cursor; demos keep their fixture path.
- Server Components by default,
"use client"only for interactive components - Static root boundary —
src/app/layout.tsxmust not read cookies or import chat-only KaTeX/Streamdown styles. Auth and workspace layouts own the cookie-derived locale provider; docs derive locale from their route, and blog owns its preference cookie. Public server routes load one dictionary at a time throughcore/i18n/translations.ts; the interactive auth/workspace client provider owns both formatter-bearing dictionaries because functions cannot cross the RSC boundary. Keep public/static and keep rich-content CSS on the routes that render it. - Thread hooks (
useThreadStream,useSubmitThread,useThreads) are the primary API interface - Thread routes — construct Web UI chat paths through
core/threads/utils.ts::pathOfThread(), which percent-encodes both custom agent names and thread IDs before inserting them into route segments - LangGraph client is a singleton obtained via
getAPIClient()incore/api/ - Run creation retries are disabled only for the initial
POST /runs/streamincore/api/api-client.ts, because an ambiguous failure can otherwise create duplicate runs. Automatic SSE recovery keeps the SDK's normal HTTP retry budget forGETrequests to the server-providedLocation, including transient 5xx responses, and forwardsLast-Event-ID. Reads and explicit SSE joins keep their normal retry behavior. Regression tests cover both a failed creation POST and POST success → stream interruption → GET 504 → GET success. - Run stream options are sanitized by
core/api/stream-mode.ts: the Gateway-supported set isvalues,messages-tuple,updates,debug,tasks,checkpoints, andcustom; any request containing an unsupported mode throws before HTTP instead of being partially forwarded or silently defaulting tovalues.streamResumableis retained by thread hooks only for SDK-side reconnect bookkeeping but stripped before the HTTP request because the Gateway does not accept that request option; actual replay uses the SSELast-Event-IDcursor. The main chat client's initial and rejoined streams are forced to incrementalmessages-tuple,updates, andcustommodes so SDK lazy tracking cannot add repeated full-statevaluessnapshots; explicitly requested non-snapshot modes such asdebug,tasks, andcheckpointsare preserved.valuesremains supported outside this chat wrapper for explicit state inspection and by the replay-gap durable-state reload below. The backend file-tool chunk batcher is keyed tomessages-tuple, notvalues, so omitting snapshots must not regresswrite_file/str_replacestreaming into one SSE frame per model token.core/threads/stream-state.tsfolds the user-visible non-message fields fromupdateswith the matching DeerFlow reducer semantics, includingtool_artifactscapture/consumption updates (same-handle replacement preserves order, retention directives trim the oldest entries, and the absolute ceiling is 1000) and rejects irrelevant frames before calling the SDK mutator, while the SDK'smessages-tuplemanager remains the sole owner of live message chunk assembly and deduplication. Keep this boundary aligned with the backend request schema;messagesandeventsare not supported and must not be forwarded. - Active-run recovery in
core/threads/hooks.tsdiscovers pending/running runs when a reopened chat has no tab-local reconnect pointer. Remember SDK-completed run IDs for the mounted hook, including submitted and natively reconnected runs: stale or delayed runs reads must not rejoin a completed run and interrupt follow-up suggestions. Keep recovery retries bounded and continue allowing a different active run to reconnect. The SDK's same-tab reconnect keeps its pointer on error and never retries, so a failed reconnect (not a failed submit) releases the pointer and recovery takes over with its own bounded retries. - SSE replay gaps (
core/api/api-client.ts) wrap initial/joined streams as a lazy async iterable: the SDK ignores unknown events. An id-lessgapclears stale reconnect metadata, emits astream_replay_gapcustom event, reloads durable values, then rejoins after the server-provided retained tail (without a cursor for an empty buffer). Allow five recovery rejoins after the original stream (six calls total).core/threads/hooks.tsclears optimistic/transient/subtask state, invalidates durable history caches and shows the localized warning; never finish normally on a gap or cancel the running backend run. Merge submitted initial-stream input or saved joined-stream input into each recovered values frame: the first checkpoint may not yet contain the human message. Restore the tab-local run pointer only if empty or still owned by this run, never over another run's pointer after a durable-state read. A foreign pointer can cost an extra lookup/rejoin on reload; add no server probe. The hooks' same-namedrememberReconnectRunremains unconditional: active-run rejoin claims ownership. - Streaming Markdown rendering is owned by
core/streamdown: Streamdown'sanimated/isAnimatingAPI handles incremental word animation, while the sharedstreamdownRenderingPluginsconfig registers the named code-highlighting and Mermaid plugins required by Streamdown 2.5. Keep wrappers and derived configs wired to that shared object; do not reintroduce a rehype plugin that wraps every word, because reparsing a growing block remounts old words and replays their animation. - Citation links in message and artifact Markdown must derive their
citation:label from the fullReactNodechildren tree, since Streamdown may provide element or array children during streaming rather than a plain string. - Web-search steps accept either a provider result array or a Webz-style
{ query, returned_results, results }envelope. Unwrap only in the web-search renderer; skip entries without string titles/URLs, preserve other tool payloads and apply the same link allowlist to both formats. UI regressions pin source titles/links and browser reloads, including malformed neighboring entries. - Tool-step links in
message-group.tsx(web_fetchargs,web_search/image_searchresult URLs) are model- or provider-controlled, so they pass the markdownisSafeHrefallowlist; every surface renders a rejected href through the sharedUnsafeLinkmarker. - Environment validation uses
@t3-oss/env-nextjswith Zod schemas (src/env.js). Skip withSKIP_ENV_VALIDATION=1 - Subtask step history and runtime metadata (
core/tasks/) — cards interleave subagent reasoning and tool steps (#3779). Taskdescriptionis optional progress text;MessageListtitles use the requiredprompt, then a localized generic label, so valid calls never show a blank title.Subtask.steps[]appends livetask_runningevents viamergeSteps. On historical-card expansion,fetchSubtaskStepsbackfills task-scoped events (GET/runs/{runId}/events?event_types=subagent.step&task_id=…&after_seq=…) until a short page or a non-advancing full page; run-wide limits must not truncate the timeline.task_startedcarries the effectivemodel_name;task_runningcarries a cumulative usage snapshot after each completed LLM call.core/tasks/lifecycle.tsnormalizes these additive events, andcomputeNextSubtaskkeeps the largest cumulative total so replayed or late SSE frames cannot double-count or roll the folded card backward. Terminal ToolMessage metadata (subagent_model_name/subagent_token_usage) restores the same values from normal history after reload; no per-card event fetch is needed.core/tasks/steps.tsis the pure step model:messageToStep(live),eventsToSteps(reload),mergeSteps(dedup bymessage_index), andstepsForDisplay(what the card renders — keeps tool steps + AI steps with text, drops the trailing final-answer AI step when completed since it's shown asresult).core/tasks/context.tsx'suseUpdateSubtaskapplies updates against atasksRefmirroring the latest state (not a closure snapshot), so a late-resolvingfetchSubtaskStepsbackfill merges into current state instead of clobbering SSE steps or sibling subtasks that arrived meanwhile. The owningrun_idis carried onto history content messages inbuildVisibleHistoryMessagesso the card can resolve the events endpoint. - Structured subtask status parsing in
core/tasks/subtask-result.tsaccepts only own keys of the status mapping. Prototype names such asconstructor,toString, and__proto__use the samein_progressfallback as other unknown statuses; structured metadata prevents falling back to legacy result text. - Subtask render synchronization (
core/tasks/subtask-render.ts) —MessageListderives message-backed task snapshots inuseMemoand publishes them to task context from an effect, never during render. Current message arguments own the card title and prompt; live task state retains lifecycle and runtime metadata until a terminal message snapshot supplies status/result/error. Cards receive the same snapshot as a render fallback so effect timing cannot leave streamed arguments stale.
Interaction Ownership
Model picker, chat entry pages, branch tree, and browser-control ownership are
detailed in frontend/docs/conversation-ui-ownership.md.
core/threads/hooks.tsowns uploads/sends. Both optimistic and submitted humans usebuildHumanMessageAdditionalKwargsto retain quote/reference metadata and staged project files.core/uploads/api.tsrejectssuccess: falsebefore send or composer clear. Keep the existing retry path; accepted files are not rolled back.src/components/workspace/chats/chat-box.tsxowns the desktop right-panel layout, and all three right panels (artifacts, sidecar, browser) share oneResizablePanelGroup— do not fork a non-resizable branch per panel kind, which is how the artifacts divider silently lost its drag handle (#4465). Open/close iscollapse()/resize()on the side panel's imperative handle, not conditional rendering, so the width can animate. Three constraints hold that together: the size transition is applied from the group as[&>[data-panel]]:transition-[flex-grow]because the sized flex item is the library's own[data-panel]element rather than the childclassNamelands on; it is applied only while an open/close is in flight, so a drag is not interpolated frame by frame; and during the animation the panel content is held at its final width incqwand clipped, because a reflowing message list re-runs its scroll-to-bottom (pinned bytests/e2e/sidecar-chat.spec.ts's no-animated-scroll test) and a re-wrapping composer changes which responsive labels it shows. Because the panel iscollapsible, the library can also collapse it to0%on its own when a drag crossesminSize, without going through the state that owns it.onResizerecords the last positive size while the pointer moves, but the owningsidecar/browserView/artifactsOpenstate must only mirror a final0%layout fromonLayoutChanged, after pointer release; closing on the first0%resize frame breaks a continuous drag that reaches the edge and then reverses before release.
ask_clarification ToolMessages delimit runs despite hidden human replies; skill
usage splits after the clarification group. Keep the tool call in its processing
group and render accompanying text outside the execution panel, including mixed
tool calls. findCurrentTurnStartIndex owns full and incremental grouping:
split incremental prefix/tail only at human boundaries, then stabilize
clarification references from full grouping.
Knowledge source citations
KnowledgeSourcesProvider scopes source records to the current message list.
Only versioned native knowledge_search/task tool artifacts supply evidence;
AI/human text and metadata cannot create a source. CitationLink resolves
#knowledge-… citations through that context and renders unavailable text when
there is no matching record. KnowledgeSourcesPanel lists only sources cited
outside code/images. Dialog excerpts render as plain text, never HTML or nested
Markdown. Source records retain retrieval-time evidence, not live documents.
Resolve knowledge destinations before testing the label in message and artifact
link renderers: Sources lists use ordinary titles without a citation: prefix.
Custom Agents save edited knowledge_scope defaults; removing knowledge access
clears them atomically. knowledgeScopeToSelection keeps IDs, using IDs for
missing labels. Catalog needs saved/draft access and exact-Agent capabilities.
catalog + dataset_filter enable bases; documents + document_filter files.
Pending/failed capabilities block queries and unsupported Apply; keep old file
filters. Chats seed defaults; local overrides survive new-thread replacement and
reset per conversation. Gateway supplies defaults and enforces scope.
MCP hooks and the editor default to personal scope. Mutation-option builders require an explicit scope. MCP control accessible names include the localized ownership section, so same-named shared and personal connections remain distinct without region context. Query keys include scope and authenticated user ID; remount each editor when that ID changes. Platform provided uses the deployment editor only for administrators outside static mode, restoring add, edit, toggle and delete through the existing admin-only MCP API. Ordinary users see shared entries read-only. My plugins contains personal configuration templates, Lark account authorization and the personal MCP editor; a deployment installation must not mark a personal template configured.
Plugin mention providers extend the existing composer picker via
core/extensions/use-mentions.ts. Keep provider queries bounded and fenced by
viewer, thread, query, and installed snapshot.
Provider and page-surface IDs must be strings before slug validation; JavaScript
coercion is not validation. A settled snapshot with no enabled, viewer-visible
mention providers returns an empty result immediately without a search timer.
Settled results may remain visible during query/retry refreshes only within the
same viewer, thread, locale and installed snapshot; stale responses stay fenced.
Inline plugin references serialize into human-message
additional_kwargs.extension_mentions; these IDs and labels
are untrusted input, never a routing or permission grant. Built-in mentions must
remain usable when a provider fails or times out.