Files
zeekay 1b367e2b62 chore: rebase the tree onto upstream master
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.
2026-08-01 15:44:40 -07:00

18 KiB

Insights AI agent-run surface — consumer guide

This directory is the conversation-agnostic agent-run UI: a library that renders and drives a streamed agent run — the thread, tool cards, approvals, the composer. If you're embedding a run, showing a status badge, or registering a product's tool cards, you consume it from here.

Building on the surface (adding a thread-item type, a permission rule, stream telemetry)? That's the contributor guide — AGENTS.md — not this file.

1. How to import — the one rule

Import from a domain-scoped api/<module> entry. Never reach into deep internal paths (../components/..., ../logics/...); the api/ modules are the contract and internal files move behind them. The products/* path alias is configured in tsconfig, so the import is absolute:

import { ReadonlyRunSurface } from 'products/insights_ai/frontend/api/readableRun'
import { isTerminalRunStatus } from 'products/insights_ai/frontend/api/logics'

Prefer the narrowest module that does the job. The split exists to preserve code-splitting: the side-effectful tool registry (api/tools) and the markdown/virtualization-heavy thread (api/primitives) must not leak into a bundle that only needs a status helper from api/logics. A status badge that imports a fat path drags presenters and the registry into its chunk; importing api/logics alone does not.

There is deliberately no root index.ts barrel — a barrel that re-exports every tier would re-introduce the exact bundling problem the split solves. Always import an api/<module>.

2. Which surface do I use?

Pick the lowest tier that does the job.

Tier Module What's in it Use when
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) "Just show a run" → ReadonlyRunSurface (inbox embeds). "Drive a run / custom layout" → RunSurface (tasks). "Embed the whole /tasks product" → EmbeddedRunner (Max).
2 — Compound primitives api/primitives Thread + atoms (.Message/.Markdown/.Reasoning/.Failure/.Activity/.ToolCall), ThreadView, Composer.*, AttachedContextBar, QueuedMessageList, RunLogSkeleton, activity primitives + RunActivity, message presenters, permission/question/resource surfaces Custom layout, or a bespoke/compact thread.
3 — Headless logic + types api/logics + api/types runStreamLogic, runInteractionLogic, status helpers (isTerminalRunStatus, INITIAL_PERMISSION_MODE), thinking-message helpers, context injection (attachedContextLogic, useAttachedContext, contextPickerLogic), tool-stream subscriptions (toolStreamEventsLogic, useToolStreamListener); folded-thread + tool types, AttachedContextItem, ToolStreamEvent Status badge, automation, context injection, tool-event listeners — no presenters, no registry.
4 — Extension seam api/tools toolRegistry, registerToolRenderers, lookupToolRenderer, GenericMcpToolRenderer, DataToolRow, ToolActivity, FilePath, diff helpers Your product renders tool cards (insights, dashboards…). Register them from your own scene.

The Tier 1 surfaces are built on api/primitives (Tier 2), which consumes the headless api/logics/api/types (Tier 3). Going down a tier trades convenience for control and a smaller chunk.

ReadonlyRunSurface is lazy by default — code-split behind a RunLogSkeleton Suspense fallback — because the embeds that use it (the inbox detail views) show it as a secondary panel. The RunSurface compound (api/runSurface) is eager: import it only where the surface is the primary content of an already-code-split route (the /tasks runner scene composes its own live-composer layout), so it doesn't pay a second chunk fetch + Suspense flash for the one thing the route exists to show.

3. Recipes

Each recipe shows the granular import to copy.

Read-only embed (inbox-style)

import { ReadonlyRunSurface } from 'products/insights_ai/frontend/api/readableRun'
;<ReadonlyRunSurface taskId={task.id} runId={run.id} interaction="read-only" />

The prepackaged, lazy read-only surface: thread (plus the meta bars for a live run), no composer and no approval prompt. It streams fresh frames while running when interaction='live', and replays the snapshot once when 'read-only'. This is what all three inbox embeds drop in.

Live embed with composer (tasks-style; caller owns the composer + draft/queue)

Compose the RunSurface compound (api/runSurface, eager) and pass your composer UI as the RunSurface.Composer children — the slot owns prompt-vs-composer precedence and the null-bootstrap gate; you own the composer. See scenes/TaskTracker/components/TaskRunChat.tsx for the full wiring.

import { RunSurface } from 'products/insights_ai/frontend/api/runSurface'
import { Composer, QueuedMessageList } from 'products/insights_ai/frontend/api/primitives'
import { runInteractionLogic } from 'products/insights_ai/frontend/api/logics'

// Bind runInteractionLogic (the follow-up/queue facade) keyed by the same runId RunSurface.Root binds.
const { draft, isSubmitting, isBusy, queuedMessages } = useValues(runInteractionLogic(props))
const { cancelRun } = useActions(runInteractionLogic(props))
;<RunSurface.Root taskId={task.id} runId={run.id} interaction="live">
  <div className="@container/thread flex flex-col h-full overflow-hidden">
    <div className="flex-1 min-h-0">
      <RunSurface.Thread />
    </div>
    <RunSurface.Resources />
    <RunSurface.Composer>
      <Composer.Root
        value={draft}
        onChange={setDraft}
        onSubmit={submit}
        loading={isSubmitting}
        isTurnActive={isBusy}
        onStop={() => cancelRun()}
      >
        {/* …Composer.Frame / Field / Textarea / Submit… */}
      </Composer.Root>
    </RunSurface.Composer>
    <RunSurface.ContextUsage />
  </div>
</RunSurface.Root>

Pass isTurnActive + onStop to make the send button a Stop button while the agent is working a turn and the input is empty (clicking cancels the run); with drafted text it stays Send and queues the follow-up. Omit both for a send-only composer.

Custom layout via the RunSurface compound

RunSurface.Root (Tier 1, api/runSurface) binds the stream logic and bootstraps the run; the slots (.Thread/.Composer/.Resources/.ContextUsage) compose into any layout — there is no default. Omit RunSurface.Composer for a no-input surface (that's exactly what ReadonlyRunSurface does); render it with composer children for an interactive one. For something even more bespoke, drop to the Tier 2 primitives (ThreadView, ResourcesBar, Composer.*, ContextUsageBar) and bind runStreamLogic yourself.

Optimistically open a run before it exists

To show the thread the instant a user hits send — their message + a "spinning up" indicator — before the create/run round-trips finish, mount RunSurface.Root in its pending state (a null runId keyed by a client streamKey), seed it via runStreamLogic.startOptimisticRun(message), then supply the real runId once created; the surface attaches it (preserving the seed) and the live SSE echo dedups the message.

import { runStreamLogic } from 'products/insights_ai/frontend/api/logics'
import { RunSurface } from 'products/insights_ai/frontend/api/runSurface'

const streamKey = `draft-${uuid()}`
const stream = runStreamLogic({ streamKey })
stream.mount() // hold it across the render swap; release when done
stream.actions.startOptimisticRun(message) // empty → "spinning up" + the typed message

// render the pending surface (no run yet):
;<RunSurface.Root taskId="" runId={null} streamKey={streamKey} interaction="live">
  <RunSurface.Thread />
</RunSurface.Root>

// …after api create/run resolve, set runId on the same surface to attach + stream it.

The attach is idempotent and seed-preserving via runStreamLogic's bootstrappedRunId / awaitingOptimisticAttach state — so the run can be adopted by a different surface that mounts later, not only by an in-place runId flip. A consumer that navigates (e.g. /tasks/new → /tasks/:id) keeps the seeded instance alive (hold the manual .mount()), then has the destination surface bind the same streamKey plus the real runId: RunSurface.Root sees the instance is already bootstrapped for that run and adopts it without a reset() — no skeleton re-flash, the thread is continuous across the unmount/mount. If that destination also drives runInteractionLogic, pass it the same streamKey (a streamKey ?? runId connect-key) so the composer reads the exact stream the thread renders; runInteractionLogic still keys its own per-run state by runId.

The tasks runner composes exactly this (scenes/TaskTracker/taskTrackerSceneLogic.ts + TaskCreateThread for the pending phase, then TaskDetailPage → TaskRunLog → TaskRunChat adopting the seeded stream after navigation).

Bespoke / compact thread via Thread.* atoms

import { Thread } from 'products/insights_ai/frontend/api/primitives'

// Compose only the atoms you want — e.g. a compact inline preview that drops the activity/tool chrome.
;<Thread.Root items={items}>{(item) => <Thread.Message item={item} />}</Thread.Root>

Register product tool renderers (Tier 4)

import { registerToolRenderers, type ToolRegistryEntry } from 'products/insights_ai/frontend/api/tools'

// Call once from your scene's entrypoint. Tools without an adapter fall through to the generic MCP card.
registerToolRenderers([
  { key: 'my-product-tool', displayName: 'My tool', icon: <IconWrench />, Renderer: MyToolRenderer },
])

This is the generic per-product mechanism. The surface itself is its first consumer: the Insights product data-tool widgets live in components/tool/widgets/ and self-register via widgets/registerDataToolRenderers (side-effect-imported by ToolCallCard), so every surface that renders a tool card gets them.

Headless status badge / automation

import { runStreamLogic, isTerminalRunStatus } from 'products/insights_ai/frontend/api/logics'

const { currentRunStatus } = useValues(runStreamLogic({ streamKey }))
const done = isTerminalRunStatus(currentRunStatus)

No React presenters, no tool registry — this import lane stays out of those chunks.

Inject context the agent should see

Register the resource(s) the user is looking at; while registered, every message sent from the surface is prefixed with invisible context blocks describing them (the user only ever sees their own text): type: 'instructions' items render into <insights_trusted_context> (our own injected guidance — never put user/ingested data there), everything else into <insights_untrusted_context> behind hardening prose. Items are abstract — type is any string ('insight', 'trace', 'text'…), plus key/label/value — and are deduped across providers and across sends within a task (the whole resume chain of runs, matching the backend's per-task semantics; except text, which always resends).

import { useAttachedContext } from 'products/insights_ai/frontend/api/logics'

// In your scene/component — registered while mounted, removed on unmount:
useAttachedContext([{ type: 'insight', key: insight.short_id, label: insight.name }])

JSX-only call sites can render <AttachedContextProvider items={...} /> (api/primitives) instead.

From a kea logic, connect to attachedContextLogic and register through a disposable (the repo-preferred teardown idiom — see /using-kea-disposables); the returned cleanup deregisters on unmount, no beforeUnmount needed. Pass pauseOnPageHidden: false: the registration costs nothing while idle, and the default hide-pause would silently drop context from a queued follow-up that flushes while the tab is hidden.

afterMount(({ actions, cache, values }) => {
  cache.disposables.add(
    () => {
      actions.registerContext('my-scene', [{ type: 'dashboard', key: values.dashboard.id }])
      return () => actions.deregisterContext('my-scene')
    },
    'attachedContext',
    { pauseOnPageHidden: false }
  )
})

Re-dispatching registerContext with the same provider id (e.g. from a subscriptions handler when the resource changes) is an upsert — see logics/contextPickerLogic.ts for the full shape.

Let the user pick context (the composer @-affordance)

AttachedContextBar (api/primitives) is the prepackaged picker + chips row — drop it into Composer.Header, the wrapping row at the top of the frame above the textarea (that's exactly what TaskComposer and TaskRunChat do, so /tasks and the side panel already have it; the footer stays free for the model/effort pickers):

import { AttachedContextBar, Composer } from 'products/insights_ai/frontend/api/primitives'
;<Composer.Header>
  <AttachedContextBar />
</Composer.Header>

The @-button opens a TaxonomicPopover over events/actions/insights/dashboards/notebooks/error-tracking issues; a selection becomes a flat { type, key, label } ref in contextPickerLogic (api/logics) — no entity loading, the agent fetches details. The chips render everything in contextItems, whatever provider contributed it. Closing a chip the picker owns removes the pick; closing any other provider's chip dismisses that key (attachedContextLogic.dismissContext), which sticks even while the provider keeps re-registering the item — re-picking it through the popover restores it.

React to the agent invoking a tool

Subscribe to tool-call lifecycle events (resolved tool names) — e.g. refresh a list when the agent creates a dashboard. Replay events are suppressed by default so a page reload never re-triggers your handler; opt in with includeReplay if you need them.

import { useToolStreamListener } from 'products/insights_ai/frontend/api/logics'

useToolStreamListener({
  tools: ['create_dashboard'],
  onEvent: (event) => {
    if (event.phase === 'completed') {
      loadDashboards()
    }
  },
})

From a kea logic, either connect to toolStreamEventsLogic and listen to its emitToolEvent action (you filter everything yourself, including event.source), or register a subscription through a disposable — same shape as the context recipe above, calling registerToolListener(listenerId, { tools, onEvent }) in setup and deregisterToolListener(listenerId) in cleanup, with pauseOnPageHidden: false (tool events fire while the tab is hidden and missed live events are not redelivered).

Note: for exec-wrapped Insights tools the resolved name can be unknown at phase: 'started' (the command streams in later) — match on completed when you need certainty.

Editor mutations use useMcpToolApplyBack with a stable targetKey for the resource being edited. The send paths snapshot the active targets for that stream before making a request. A response is applied only when exactly one snapshotted target matches the tool, and changing editors creates a new activation that cannot inherit the old response. The default turn_end mode applies only the final matching completion.

4. Coupling boundary

This surface couples to the tasks run API by design (products/tasks/frontend/generated/api) and never to Max or the conversations API — Max is a consumer, not a dependency. The full rule (and the grep gate that enforces it) is in AGENTS.md.

5. Replacement-ready by design

This surface is the intended replacement for Max's legacy LangGraph thread. The new-vs-legacy switch is a consumer concern (Max routes agent_runtime === 'sandbox' conversations through runStreamLogic and renders this surface; its LangGraph path renders its own thread). The surface never branches on Max/LangGraph/conversation. Anything the LangGraph path owns that the surface doesn't express generically (agent modes, billing context, scene attachments) stays in Max and feeds the surface through generic props / the tool registry / streamKey — lifted to a generic seam here, never special-cased. The four-tier contract above is unchanged whichever side of the switch is active. See AGENTS.md for the contributor-side detail.