* feat(settings): persist account preferences across browsers * docs(settings): scope preference guidance to user persistence * fix(settings): preserve SSR and fence custom-agent defaults * test: include user persistence in scoped guidance inventory * fix(settings): sync explicit edits and preserve local tab updates
36 KiB
Data Flow
- 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 - 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. The detail view exposes explicit editing only for an already-opened formal UTF-8 text artifact under/mnt/user-data/outputs. Drafts stay in provider memory until Save so switching right-side panels cannot discard them, render in Markdown/HTML preview, and are protected from remote refreshes by the loaded SHA-256 revision. Saving is disabled during an active run; a changed revision preserves the draft and surfaces a conflict instead of overwriting agent output. 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. The Gateway retains range ownership and returns 206/416 throughFileResponse. 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 Settings > Tools MCP switch calls the targetedPATCH /api/mcp/configmutation, disables switches until that mutation's success refetch completes, displays the backend errordetailthrough a toast, and invalidates["mcpConfig"]only after success. Server management uses targetedPOST /api/mcp/config/servers,PUT /api/mcp/config/server, and bodylessDELETE /api/mcp/config/servers/{server_name}mutations. 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. Settings > Integrations 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. - Components subscribe to thread state and render updates
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 anchored after 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; reasoning disclosure and run activity/duration are rendered separately.
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 only text plus the selected slash-skill name 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. InputBox waits for enabled skills before restoring a skill chip, degrades a missing/disabled skill back to editable slash text, and clears the stored draft through SendMessageOptions.onSent only after the send passes the in-flight guard. Attachments, sidecar quotes, voice state, and polish undo state are not persisted.
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.
/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. The chat pages render GoalStatus above the composer from AgentThreadState.goal, with local optimistic state until an incremental goal update or final state reload 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.
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.
Generic tool details receive the explicit Debug flag from MessageGroup, independent of token statistics. 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. Serialization budgets include escaped strings, punctuation, indentation, closing delimiters, and truncation markers so structured previews remain valid JSON without a final mid-token slice. 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. Objects/arrays are formatted only on expansion; specialized renderers retain their existing 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
- 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 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 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. - SSE replay gaps are handled in
core/api/api-client.ts, which wraps both initial and joined run streams because the upstream SDK ignores unknown event names. An id-less backendgapcontrol frame clears stale reconnect metadata, emits an internalstream_replay_gapcustom event, reloads durable thread values, and resumes after the server-provided retained tail when one exists (or rejoins without a cursor if the buffer is empty), with up to five recovery rejoins after the original stream (six total stream calls on an all-gap exhaustion path). The wrapper remains a lazy async iterable because the SDK consumes it withfor await.core/threads/hooks.tsclears optimistic/transient/subtask state, invalidates durable history caches, and shows the localized recovery warning; never let a gap fall through as a normal stream finish or cancel the still-running backend run. - 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. - 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/) — the subtask card shows a subagent's full step timeline (#3779): its assistant reasoning turns interleaved with the tools it ran. The task tool's model-visibledescriptionis an optional progress label;MessageListuses the requiredprompt(then the localized generic subtask label) when a provider omits it, so a valid task call never renders a blank card title.Subtask.steps[]is accumulated live fromtask_runningevents (appended viamergeSteps, not overwritten) and backfilled on expand for historical runs byfetchSubtaskSteps, which pages the events endpoint scoped to one task (GET/runs/{runId}/events?event_types=subagent.step&task_id=…&after_seq=…) until a short page, so the run-wide limit can't 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.
Interaction Ownership
src/app/workspace/chats/[thread_id]/page.tsxowns composer busy-state wiring.src/app/workspace/chats/[thread_id]/page.tsxowns branch-from-turn submission and navigation; sidecarMessageListinstances do not receive the branch action.core/threads/thread-branch-tree.tsprojects only loaded, same-pin branch lineage into Recent chats. Missing, malformed, cross-pin, self, or cyclic parents stay top-level; unpinned groups follow their freshest descendant while pinned root order stays stable.recent-chat-list.tsxcaps visual indentation without changing the recursive order.src/app/workspace/chats/[thread_id]/page.tsxandsrc/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsxown edit-and-rerun submission wiring because the page must preserve normal/custom-agent run context;MessageListonly detects the latest editable user turn and renders the inline editor.src/app/workspace/chats/[thread_id]/page.tsxgates the Workspace Browser trigger and browser right panel on/api/features -> browser_control.enabled;src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsxapplies the same capability gate and additionally requires the Custom Agent's tool groups to be unrestricted or includebrowser. Default/failed feature discovery hides the browser control so optional backend installs do not show a dead Live socket.src/app/workspace/chats/[thread_id]/page.tsxandsrc/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsxown active-goal display state for their composer overlays.src/components/workspace/messages/message-list.tsxowns human-input card answered/latest/pending gating; entry pages only translate a submitted card response intosendMessagecalls.src/components/workspace/browser-view/browser-view-panel.tsxforwards each physical pointer click as oneclickinput; do not also emitdown/upfor the same gesture because the remote Playwright click would run twice.src/components/workspace/browser-view/use-browser-stream.tsrequests binary JPEG frames withframe_format=binary; status, URL, tabs, and navigation rejection messages remain JSON.LatestBrowserFrameBufferkeeps only the newest pending frame, publishes throughuseSyncExternalStoreat most once per animation frame, and owns object-URL revocation. Keep the Gateway's legacy JSON/base64 frame path for older clients.src/core/threads/hooks.tsowns pre-submit upload state and thread submission.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.