The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
22 KiB
Insights AI agent-run surface — frontend guide
This directory is the composable Insights AI agent-run UI: a conversation-agnostic library of surfaces
that render and drive an agent run (the streamed thread, tool cards, approvals, the composer). It is
consumed by the Max scene (frontend/src/scenes/max) and the signals inbox — and is meant to be
embeddable anywhere an agent run needs to be shown or interacted with. The standalone runner scene
(TaskTracker, the /tasks route) lives here too, under scenes/.
This used to live under a
sandbox/subfolder behind aSandbox*codename prefix; it's now laid out conventionally directly underfrontend/with the prefix dropped. The word "sandbox" that remains is the agent runtime (agent_runtime === 'sandbox'), a real domain term — not the old codename.
1. The public API is the api/<module> facade
Every external consumer imports from a domain-scoped api/<module> entry — never from deep paths
(components/..., logics/...). The api/ modules are the contract; internal files move freely behind
them. There are four tiers, split along dependency/side-effect boundaries (not cosmetics — that's what
preserves code-splitting). Consumers pick the lowest tier that does the job. The full decision table,
import rule, and copy-paste recipes live in the consumer-facing README.md; the summary:
| Tier | Module | What's in it |
|---|---|---|
| 1 — Prepackaged surfaces | api/readableRun + api/runSurface + api/runner |
ReadonlyRunSurface (lazy, code-split read-only embed); the RunSurface compound (Root + slots, eager) for custom layouts; EmbeddedRunner (lazy TaskTracker product for inline hosts) |
| 2 — Compound primitives | api/primitives |
Thread + atoms, ThreadView, Composer.*, QueuedMessageList, RunLogSkeleton, activity primitives + RunActivity, message presenters, permission/question/resource surfaces |
| 3 — Headless logic + types | api/logics + api/types |
runStreamLogic, runInteractionLogic, status + thinking helpers; folded-thread + tool types |
| 4 — Extension seam | api/tools |
toolRegistry, registerToolRenderers, lookupToolRenderer, GenericMcpToolRenderer, DataToolRow, ToolActivity, FilePath, diff helpers |
Why the split, not one flat barrel: the tool registry registers built-ins at module load — a top-level
side effect that is not tree-shaken. A single barrel statically re-exports it alongside the
markdown/virtualization-heavy thread and the headless logics, so a consumer wanting only
isTerminalRunStatus for a status badge would drag the registry + presenters into its chunk. Isolating the
side-effectful registry in api/tools and keeping the headless lane (api/logics + api/types) free of
React/registry imports is what keeps each consumer's bundle to its subtree.
There is deliberately no root index.ts barrel: a single aggregate that re-exports every tier would
re-fatten any consumer's chunk and undo the split. It also wouldn't match the repo norm — no other
products/*/frontend ships a barrel; consumers import the specific path they need. Every public symbol is
reached through an api/<module> entry; add new exports to the relevant tier module, not to a new barrel.
The headline exports per module:
api/readableRun—ReadonlyRunSurface, the lazy, code-split read-only embed: calling<ReadonlyRunSurface .../>renders the run thread (and, for a live run, the meta bars) behind aRunLogSkeletonSuspense fallback — no composer, no approval prompt. The heavy chunk (theRunSurfacecompound — stream logic, virtualized thread, tool/diff renderers) is reached only through its dynamicimport(), so importing this light module never statically pulls the impl. This is the form every embed uses (the inbox detail views), where the surface is a secondary panel worth splitting out. It streams fresh frames while running wheninteraction='live', and replays the snapshot once when'read-only'.api/runSurface— theRunSurfacecompound (Root+ the.Thread/.Composer/.Resources/.ContextUsageslots), eager, for consumers that build a custom layout.RunSurface.Rootbinds the stream logic and bootstraps the run; the slots compose into whatever layout the surface needs.RunSurface.Composeris the input-region slot — it owns prompt-vs-composer precedence (a pending approval/question replaces the composer) and the null-bootstrap gate, and takes the composer UI aschildren; omit it for no input region. The meta slots (.Resources/.ContextUsage) self-bind and self-hide when empty. There is no default layout — the prepackaged read-only embed (ReadonlyRunSurface) is one concrete composition; the runner scene (scenes/TaskTracker/TaskRunChat) composes its own live-composer layout. Because the compound is eager, import it only from an already route-split scene (the/tasksrunner) or another lazily-loaded layout module — a light bundle that should stay split usesapi/readableRuninstead.api/runner—EmbeddedRunner(<EmbeddedRunner taskId? />), the lazy, code-split TaskTracker product (tasks list + composer + agent-run detail) for hosts that render the whole/tasksexperience inline (the Max scene surfaces it behind the sandbox view toggle). The heavy scene chunk loads only viaEmbeddedRunner's dynamicimport(), so importing this module never statically pulls the scene. Task selection/creation still routes through the scene's own/tasks/:idURLs — it's the standalone product embedded, not a route-decoupled widget.api/primitives—Thread(Radix-style compound:Thread.Rootis the virtualized presenter, the atomsThread.Message/.Markdown/.Reasoning/.Failure/.Activity/.ToolCallare the building blocks for bespoke threads),Composer(logic-free compound input — the caller ownsvalue/onChange/onSubmit),RunLogSkeleton(the shared "run log is loading" loader — theReadonlyRunSurfaceSuspense fallback and theRunSurfacebootstrap fallback, also used by the runner scene), activity primitives, message presenters, and the permission/question/resource surfaces.api/logics—runStreamLogic(SSE stream + thread projection, see §3),runInteractionLogic(Max-agnostic follow-up/queue facade), status helpers (isTerminalRunStatus,INITIAL_PERMISSION_MODE), thinking-message helpers,attachedContextLogic+useAttachedContext(context injection, see §3), andtoolStreamEventsLogic+useToolStreamListener(tool-stream subscriptions, see §3). Imports onlylogics/*+hooks/*+utils/*— never a component or the registry. The two hooks are a deliberate, mild deviation from the "no React" reading of this lane: they importreact+keabut no components, so the lane stays registry- and presenter-free.api/types— folded-thread + tool domain types,AttachedContextItem,ToolStreamEvent(pure types).api/tools—toolRegistry,registerToolRenderers(the generic per-product seam, see §2),lookupToolRenderer,GenericMcpToolRenderer,DataToolRow,ToolActivity,FilePath, and the diff/exec helpers. Isolated here because importing it pulls the side-effectful registry chunk.
2. Coupling boundary — couples to tasks runs, never to Max
This surface couples to the tasks run API by design (products/tasks/frontend/generated/api): a task +
its run + the SSE stream together are the agent-run surface. Importing the tasks run/stream/command API
from the stream and interaction logics is expected.
It must stay free of the Max scene and conversation orchestration. Do not import scenes/max/*,
maxThreadLogic, maxContextLogic, MaxUIContext, or the conversations API from anywhere under
products/insights_ai/frontend. Max is a consumer of this surface, not a dependency of it.
runStreamLogickeys on a genericstreamKey(conversation id for Max, run/task id for a task viewer). Keep it generic — no Max-specific branching.- The Insights product data-tool renderers live in this surface and self-register — via the generic seam.
api/toolsexposestoolRegistryand the convenience wrapperregisterToolRenderers(entries): the generic per-product mechanism for plugging in cards that display Insights entities. The data-tool widgets (insights, dashboards, recordings, error-tracking issues, notebooks, query results) live incomponents/tool/widgets/and register themselves viawidgets/registerDataToolRenderers, whichcomponents/tool/ToolCallCardside-effect-imports. Because that import sits at the render chokepoint, every consumer that renders a tool card — the/tasksrunner, the signals inbox, and Max's sandbox path — gets the widgets, without the consumer having to opt in. The basetoolRegistrymodule itself stays product-free (built-ins, exec verbs, question card, generic MCP card, genericEditDiffRenderer); the widgets carry noscenes/maximport, so the grep gate below stays green. Max's frozen LangGraph path is a consumer: it composesVisualizationWidget/RecordingsWidget/ErrorTrackingFiltersWidgetthroughapi/primitives. - If Max needs something the surface doesn't express generically, lift it to a generic prop/selector here
and have Max adapt — never special-case Max in this directory. (The recordings "accept these filters"
bar is one such lift:
RecordingsWidgettakes an optionalonAcceptFiltersprop; Max's LangGraph path passes itsonAcceptSessionFiltersselector, the sandbox path passes nothing.) Enforced by a grep gate:grep -rE "scenes/max|maxThreadLogic|MaxUIContext" products/insights_ai/frontendmust be empty of imports.
3. Streaming architecture (logics/runStreamLogic.ts)
The heart of the surface:
- SSE connection — a
fetchbody reader pumped througheventsource-parser; a reconnect resumes after the last Redis stream id viaLast-Event-ID(capped exponential backoff + cumulative cap). - Ordered, append-only
logis the single source of truth — every wire frame (plus a few synthetic client entries) is appended, never keyed or per-entry deduped — with one exception: supersededtool_call_updateframes are field-wise merged pertoolCallId(appendToRunLog). Each update carries the full accumulatedrawOutput/contentsnapshot, so retaining every one balloons memory by orders of magnitude while the fold only ever renders the merged latest. - Pure projection
foldLogToThread(entries) → { threadItems, toolInvocations }, memoized onlogidentity, derives the rendered thread. - Keyed by
streamKeyso concurrent streams stay independent.
Permissions: parse (parsePermissionRequestFrame) → route (policy/toolPolicy auto-approves
built-ins + read-only Insights exec, else surfaces a card) → answer (respondToPermission) → resolve (pins
the id so a reconnect replay can't re-surface it).
Supporting modules live in policy/ (tool policy + permission/question utils) and types/
(streamTypes = folded thread shapes; wireTypes = ACP wire shapes + guards). Wire types are
loosely typed — guard at the parse boundary with runtime checks; never assume a field is present.
Context injection (logics/attachedContextLogic.ts): a global registry of on-screen context. A provider
(a mounted useAttachedContext hook / AttachedContextProvider component, or a kea logic registering via
cache.disposables — setup dispatches registerContext(providerId, items), cleanup deregisterContext,
with pauseOnPageHidden: false since a hide-paused registration would drop context from sends that happen
while the tab is hidden; contextPickerLogic is the exemplar) contributes
abstract AttachedContextItems — type is an arbitrary string ('insight', 'dashboard', 'trace',
'text'…; never an enumerated union), plus key/label/value. contextItems flattens and dedupes by
${type}:${key ?? value}. At send time the send paths (runInteractionLogic.sendNow/startNewRun,
taskTrackerSceneLogic.submitNewTask) wrap the outgoing message with
wrapWithInsightsContext (utils/insightsContextBlock.ts) — a prefix of <insights_trusted_context>
(type: 'instructions' items, our own injected guidance) and/or <insights_untrusted_context> (everything
else — data that can embed user/ingested text, rendered behind hardening prose) that is invisible to the
user: the live echo (pushHumanMessage) carries the raw text and unwrapUserMessageContent strips every
leading block on history replay (including the legacy <insights_context> wrapper still emitted by the
deprecated backend context_wrapper.py path and present in old history) — stripping works on the tags, not
the body.
The send paths prune context already sent anywhere in the task's resume chain, via two task-keyed layers in
attachedContextLogic (task-scoped so the dedupe survives a terminal-run send re-pointing to a fresh run):
sentContextKeysByTask, in-memory keys marked right after each send (covers the send→echo window), and
seenContextLinesByTask, the durable layer — runStreamLogic.ingestAcpFrame records the rendered lines
(contextItemLine/extractContextBlockLines) of every context block found in a persisted or echoed user
message, and since the logs/ snapshot replays the full resume chain this survives reloads, other tabs, and
other users' sessions (matching the backend's _collect_seen_entity_refs/prune_repeated_entity_refs);
text items are never deduped, repeated text is intentional.
User-picked context (logics/contextPickerLogic.ts + components/composer/AttachedContextBar.tsx): the
composer's @-affordance. AttachedContextBar (Tier 2, drop into Composer.Header, the top-of-frame row above the textarea; already wired into
TaskComposer and TaskRunChat) renders a TaxonomicPopover whose selections are projected to flat refs by
taxonomicItemToAttachedContext — no entity loading, the agent fetches details — and stored in
contextPickerLogic, which is just another provider (user-picker) on attachedContextLogic. The bar's chips
render all of contextItems; closing a picked chip removes it from the picker, closing any other
provider's chip dispatches attachedContextLogic.dismissContext(key). Dismissal filters the key out of
contextItems and survives provider re-registration (the scene bridge upserts on every scene read — a
closed chip must not resurrect); re-picking the same item undismissContexts it.
Tool-stream events (logics/toolStreamEventsLogic.ts): a global bus runStreamLogic publishes
tool-call lifecycle events to — phase: started/updated/completed/failed, with toolName resolved via
toolResolver (inner Insights MCP tool, e.g. create_dashboard). Subscribe with useToolStreamListener({ tools, onEvent }), or kea-natively by connecting to the bus and listening to
toolStreamEventsLogic.actionTypes.emitToolEvent. Replay-sourced events are suppressed unless the
subscription sets includeReplay (a reload must not re-trigger UI reactions). Caveat: for exec-wrapped
Insights tools the resolved name may be __insights_exec_unknown__ at started (the command streams in via
updates) and is reliable by completed. useMcpToolApplyBack supports tool_call_completed and turn_end
timing.
The bus also publishes live turn-complete and run-terminal events so apply-back consumers can flush after a
persistent run's response. Subscriber callbacks are isolated (a throwing listener is captured, never breaks
ingestion).
4. Conventions
- Logic in the logic, never in a component. Wire parsing, log folding, SSE handling, telemetry, and
permission routing belong in
runStreamLogic(or apolicy/sibling). Components consume selectors and dispatch actions. - Keep UI runtime-agnostic. Render components take plain props (
ThreadItem,ToolInvocation,PermissionRequestRecord); they know nothing about langgraph vs sandbox or the conversation. - Atomic components. One responsibility per component — small memoized leaf presenters keyed by the
stable item
id, not a giant inline switch. - Memoize —
threadItemsre-projects on every appended frame. Wrap leaves inReact.memo, derive withuseMemo, wrap child callbacks inuseCallback, and subscribe narrowly (select only what you render). - Keep the projection pure.
foldLogToThreadis pure and deterministic; item ids stay stable across re-folds. Listeners fire only side effects, each with a fire-once guard, suppressed onsource: 'replay'. - A tool card is two header lines plus an accordion — overflow goes in the accordion. Every tool
renderer wraps its content in
ToolActivity, which exposes exactly two always-visible header lines: thetitleand thesubtitle(the one salient input — a command, path, repo, branch). Any other presentable information a tool produces — parsed output, commit/repo lists, file contents, diffs, raw text — must go in the collapsiblebody, never the always-visiblechildren. The body is theActivityaccordion: it auto-expands while the tool runs and collapses once it completes, so the thread stays scannable (one or two lines per tool) and a reader expands only the cards they care about. Reservechildren(always-visible) for genuinely interactive payloads that would be useless collapsed (e.g. theAskUserQuestionrecap the user must act on) — not for output. When in doubt, it goes in the accordion.
5. Layout
api/ # public API facade — the contract (import api/<module>, never deep paths)
readableRun.ts # Tier 1: ReadonlyRunSurface (lazy read-only embed)
runSurface.ts # Tier 1: RunSurface compound (Root + slots, eager) for custom layouts
runner.ts # Tier 1: EmbeddedRunner (lazy TaskTracker product) for inline hosts
primitives.ts # Tier 2: Composer, Thread + atoms, ThreadView, QueuedMessageList, presenters, perm/question/resource
logics.ts # Tier 3: runStreamLogic, runInteractionLogic, context store + hooks, tool-event bus (headless)
types.ts # Tier 3: folded-thread + tool domain types, AttachedContextItem, ToolStreamEvent (pure types)
tools.ts # Tier 4: toolRegistry + registerToolRenderers seam (side-effectful — isolated)
components/ # RunSurfaceImpl (the RunSurface compound, heavy chunk); ReadonlyRunSurfaceImpl (prepackaged
# read-only layout) + ReadonlyRunSurface (its lazy wrapper, replaces the old RunViewer.tsx);
# RunLogSkeleton (shared loader), Thread, Composer, perm/question/resource surfaces, activity, tool/;
# AttachedContextProvider (render-null context injection wrapper)
composer/ # the Composer compound; AttachedContextBar (@-picker + context chips)
tool/ # tool registry + renderers (built-ins, generic MCP, EditDiffRenderer, diff/exec utils)
widgets/ # Insights product data-tool widgets (insight/dashboard/recordings/error-tracking/notebook/query) + registerDataToolRenderers
hooks/ # useAttachedContext, useToolStream — mount-scoped registration wrappers over the logics
logics/ # runStreamLogic, runInteractionLogic, attachedContextLogic, contextPickerLogic, toolStreamEventsLogic;
# tasksLogic/taskLogic data logics (+ *LogicType.ts)
policy/ # tool policy + permission/question utils
types/ # streamTypes (folded thread + ToolStreamEvent), wireTypes (ACP), contextTypes
# (AttachedContextItem), toolTypes, taskTypes (task/run domain)
messages/ # MessageTemplate, MarkdownMessage, ReasoningAnswer, AssistantFailureMessage
utils/ # thinkingMessages, insightsContextBlock (trusted/untrusted context-block builder)
lib/ # task/run helpers (parse-logs, task-status, repository, ph-debug, util-functions)
scenes/ # standalone scenes registered via ../manifest.tsx
TaskTracker/ # the runner scene (component, stories, scene logics, scene-specific components/)
generated/ # auto-generated API types (mcp_tools / docs_search) — do not edit by hand
6. Replacement-readiness
This surface is the intended replacement for Max's legacy LangGraph thread. The design keeps the eventual switch clean, so build with it in mind:
- The new-vs-legacy switch is a consumer concern, not this surface's. Max already routes
conversation.agent_runtime === 'sandbox'throughrunStreamLogic(keyed byconversationId) and rendersthreadItemsvia this surface, while its LangGraphEventSourcepath renders Max's own thread. This surface must never branch on Max/LangGraph/conversation — enforced by the §2 grep gate. - For full replacement, what the LangGraph path owns that the surface doesn't express generically —
agent modes, billing context, scene attachments/context — stays in Max as a consumer and feeds the
surface through generic props / the tool registry /
streamKey. If the surface needs it, lift it to a generic seam here; never special-case Max. The setting flips the default implementation in Max; this surface's contract (the four tiers in §1) is unchanged whichever side of the switch is active.