NAVIGATION
ELOWEN DOCUMENTATION

Last updated: 10 October 2026

Browse documentation · Transcript, events, cards and workflows
Developer reference

Transcript, events, cards and workflows

Interactive transcript history

BrainChatSurface composes the existing action, status and transcript context slices. Its root owns scroll/prepend anchors, visual-viewport and floating-dock geometry, pending-input reveal and ProjectChatDock; it does not fold events. The canonical fold remains src/brain/transcript.ts with its byte-pinned web/lib/transcript.ts mirror.

Chat rendering lives in siblings under web/modules/advisor/:

  • ChatTranscript owns the memoized Message, turn metadata, tool rows, reasoning and authoring hints.
  • ChatEvents owns event wording, icons and event runs. It and the tool rows share ChatLog's column, row, glyph, native disclosure and output block, so neither renderer imports the other.
  • ChatDiff owns syntax-coloured diff rows and expansion; ChatAttachments owns images, the shared lightbox and file downloads.
  • MessageBlocks draws a reply's blocks segment (plugin message blocks, seam row 72 in docs/PLUGIN_DEV.md) as a card grid under the message: shadcn ItemGroup/Item with ItemMedia, ItemTitle and ItemDescription, chips as the app Badge (neutral muted, positive success, warning warning, danger danger, accent accent), one column below a 32rem container and up to three from 48rem. Every string is a React text node. A 56px object-contain thumbnail opens the shared ChatAttachments lightbox; a picture the daemon no longer serves shows the image-gone tile instead, and a block without a picture has no media slot. A block kind the build does not know draws nothing. The live blocks event and the reloaded segment are the same stored entry, so a reload changes nothing on screen.
  • ChatCards owns card selection, checklist state and memoized transcript extras. Empty or completed lists remain absent, and plugin cards use the existing host renderer seam with its generic fallback.
  • ChatComposer alone reads the draft context. ChatConversationBar owns pickers, reasoning controls and rename; ChatFooterDock owns notices, statusline, uploads and queue. Both bar and footer use ChatStatusline's single model-control ownership rule, including absent or collapsed statusline state.
  • agentPresentation supplies the pure steer description to both the agent table and tool rows. Cache-hit display imports web/lib/displayFormat directly.

For example, a streamed text delta reaches the root and its live message while settled message, card, bar and editor memo boundaries stay unchanged. These are local module boundaries, not plugin API additions: they introduce no DOM wrappers, polling, state stores or extra clocks. Public UI runtime contracts and design tokens are unchanged.

Questions and permission approvals use the existing pending-input mount in BrainChatSurface, drawn as transcript content with no card, border or fill, from RadioGroup, Checkbox, Input, Badge, Button and Item. Item variant="selectable" adds token-based hover, selected and focus-within paint through data-selected and data-disabled; its default variants are unchanged. Several questions page one at a time: the header line carries the question's header and a 2 / 3 position, the footer has Back (from the second page) and Next, and Next becomes Send on the last page, which stays disabled until every question has an answer and then names how many are answered. Next never requires an answer, so the reader can skip ahead and return. Answers live in the card, not in the visible page, so paging and a failed request lose none of them; a single question shows no pager. Paging moves focus to the new question so its number keys keep working. A question has one Tab entry, arrows move within a multi-select, numbers select, Alt+Left/Right pages and Ctrl/Cmd+Enter sends a ready batch from any page. A custom row opens its labelled input in place. Single-choice previews follow the selected option, beside the list on desktop and beneath that row on mobile. A preview is monospace art by the event contract, so it is drawn verbatim in a <pre> on the skin's sunken surface, like the CLI dock, never parsed as Markdown, which would reflow the lines a mockup needs; an approval's command block uses the same surface and the skin's text colour. Headers are shown in full. The card shows sending, read-only, expired and failed-request states; retry retains the selected values. Approvals use direct buttons with no selection or default Enter submission, a code block for the command and a separate destructive Deny action.

AskOption.recommended is a boolean independent of its label. The AskUserQuestion schema, CLI and web consume it directly; models never append a recommendation suffix. details.questionAnswer stores question text, header, selected labels, optional custom text and an explicit answer/approval/expiry outcome beside the model-facing text. questionAnswerOf validates it at the existing result projection boundary. events.ts, messageView.ts and the byte-mirrored transcript fold carry it on the same tool item. Web QuestionAnswerSummary renders it as a quiet list, one line per question with the header beside the chosen answer (or the permission outcome beside its command), with no card or disclosure, and the CLI's expandable tool record shows it too, after settlement or reload without local persistence or prose parsing; a tool without these details retains its ordinary row. Permission-gated tool results carry the same record even for diff, shared media or ShowView results. Suppressing a code-mode wrapper's activity, either for nested traces or ownRow: false, preserves its permission record as a standalone settled tool item in both messageView and the live spawn reducer. interactiveViewOf validates the view once at the common result boundary used by direct events, history and nested records. A view and questionAnswer may coexist in one tool result and trace; the canonical fold keeps the approval as the ordinary tool record immediately before the view, so replay or wait settlement replaces both without duplicating them. HistoryMessage.segments aliases the shared BrainSegment contract, and the browser fold mirrors this same body.

PlanCard replaces the ExitPlanMode activity row with one sanitized Markdown document and inline Implement / Refine actions. If the tool itself needed permission, its QuestionAnswerSummary remains immediately before the plan, independently of the later Implement / Refine decision. Pending authority still comes only from daemon control. A pending plan outside the currently loaded history page uses that same card at the transcript tail until its tool anchor arrives; the tail is removed once that anchor is present, so the plan is shown once. Historical and read-only cards have no decision actions. There is no plan modal, overlay or second decision endpoint. The existing plan request fence, error toast and retryable buttons remain in BrainChatProvider.

InteractiveView renders the core view segment, as transcript content with no card of its own, in a sandboxed srcdoc iframe with sandbox="allow-scripts", never allow-same-origin. A trusted outer shell with frame-src about: contains the untrusted inner frame, whose CSP forbids connections, forms, external resources and nested frames. The outer shell is necessary because a single sandboxed document can still navigate itself in current browsers. Both frames have opaque origins and no popup, top-navigation or form capability. The app accepts only messages from its iframe's contentWindow; the shell accepts only its child. Both use the strict importless bridge parser, byte-pinned between src/shared/interactiveViewProtocol.ts and web/lib/interactiveViewProtocol.ts. The only accepted messages are a nonempty action string within 4096 UTF-8 bytes and a finite height from 1 to 10000 pixels, with no extra keys. Authenticated action writes remain in the host, not the frame. The host sizes the frame to the reported height, so the inner document never scrolls vertically (html{overflow-y:hidden} in the boot style); at a fractional device pixel ratio the frame box would otherwise snap a fraction below that height and paint a scrollbar. Content beyond the 10000-pixel bound is clipped. The view has no border, fill or padding. Its documents are transparent and carry the host's color-scheme with the tokens, so the chat background shows through in every skin; a frame whose scheme differs from its embedder would otherwise be painted on an opaque canvas. The summary, the plain-text fallback for other surfaces, is the section's screen-reader description on the web, not visible text.

The frame receives current skin tokens and computed styles sampled from the shared Button's accent, default and outline variants. Surrounding chrome uses shared tokens. A waiting click resolves /brain/view-answer, never /brain/send; concurrent clicks are suppressed and failed writes leave the view usable with localized feedback. The pluginSessionId boundary locks foreign, read-only and sub-agent views, including scripts that send messages manually. Settled waiting views remain locked on reload. Non-waiting local controls send no actions to the daemon. The short summary stays readable while loading and on every text-only surface. Browser coverage exercises the real BFF, stream fold, reload and locking at 320px mobile and 1440px in Studio OLED/Light and cs/sk/en.

Current conversation state comes from daemon BrainStreamControl, required in status and atomic snapshots and updated by the session-targeted control stream event. BrainChatProvider keeps a read-only projection under its existing generation/session/control-revision fences; foreground busy/Stop uses control.streaming, never transcript shape or the last tool. Snapshot control wins over older replay control. Public PI programStatus and independent backgroundWorking flow to ProgramStatusIndicator, shared by owned history and the admin register, and to its tooltip-free ProgramStatusGlyph on the sidebar's recent conversations; the sidebar Chat row counts own conversations with native working or background activity, and the operation dock groups own hand-started conversations needing attention at desktop widths. The indicator uses existing tokens/tooltip primitives and keeps durable unread, scheduled completion and failure detail independent. An undecided plan can be blocked/question without streaming, and background children leave the parent composer available. Before hydration the state is unknown. A terminal error that closes the live stream invalidates its last control and fences older status reads, removing Stop until the reconnect hydrates current daemon control. Native transport errors without a JSON frame retain browser reconnect semantics. No older-daemon control fallback, client reducer, polling or persistence is introduced.

Tool rows carry producer startedAt, explicit pending state and native/durable durationMs. groupToolItems retains latest detail/count while naming an actual pending member; a later sibling or parent idle never completes that member. An error retires pending execution indicators only in assistant turns after the most recent user turn, including calls before a mid-turn chat marker. Pending tools from earlier runs, recorded timings and independent child/workflow progress remain intact. Reconnect replaces this projection from the atomic snapshot without another journal or request. The row shows the pending call's authored reason, not an inferred internal cause; completion retains performed detail. visibleToolDuration in canonical src/shared/toolPresentation.ts and its byte-pinned browser mirror paints live and settled tool clocks only from 5000ms; the original measurements stay in transcript data. CLI and browser use this same rule. ToolDuration subscribes to the existing shared useNow clock only while its pending timestamped row intersects the viewport, observing the row even before its duration label appears. Settled labels render stored durations and survive reload unchanged; old journal rows without timing render no invented clock. Only the small elapsed label updates on a tick, not the transcript fold or settled Markdown. Card and reasoning clocks reuse the same heartbeat. The CLI's ChatViewport reports pending visibility through its optional callback and invalidates only the bounded set of previously visible pending turns; ChatComposition reuses its existing animation owner, so a yielded nested call keeps counting without claiming the parent is busy. Without that callback a standalone viewport simply renders on demand. There is no daemon timing poll or additional journal. Browser acceptance is specified in chat.tool-status.e2e.ts at 320px with real mobile input and at 1440px, including independent calls, silent completion and reload.

src/brain/transcript.ts is the browser-safe pure fold of complete BrainEvent payloads and durable message views. web/lib/transcript.ts is its byte-pinned importless mirror; TranscriptModel is the CLI's indexed revision adapter around the same fold. Steady events fold only their indexed target; terminal stream errors pass the complete transcript so pending indicators before mid-turn markers settle identically on both surfaces. Images, files, tool commands, diff notes, model-step boundaries and durable identities stay in the data. Renderers alone choose HTML or terminal paint: image previews remain semantic image segments while the CLI renderer suppresses their redundant line; explicit image shares render a caption or filename. Cross-surface tests must exercise the actual renderer instead of expecting terminal words in the fold's data. The common session-targeted stream registration serves parent and read-only views without reconstructing partial events. A snapshot replaces history and provisional replay together. Control fields override replay: pending questions, plans and work mode come from the daemon, never from scanning displayed assistant turns. Explicit null clears stale control. Browser tests must publish the same authoritative pending-plan status before idle: emitting an ExitPlanMode result alone changes the transcript, not the fake daemon's canned control response. Idle reads take an issuance revision and capture their target session, so a late response cannot replace a newer read or rebind.

History requests target the synchronously selected session, including an administrator's foreign read-only view, not the hidden bound parent. Child entry clears the parent's transcript, cards, usage and execution identity before loading. Child reconnect stays on that session; the composer stays present as a disabled read-only field with its explanation and return control, and questions and approvals remain visible but their answer controls are locked. Execution identity comes from the authorized child snapshot, including its delegated placement. The tab's active-session id controls highlighting and post-delete navigation; the account-global active pointer is not display selection.

Idle composer resizing does not pin or scroll the transcript. Its ResizeObserver distinguishes editor-dock growth from transcript growth; while a turn is running and the reader is following newest, editor growth also re-pins the transcript so the checklist and agents tail stay above the composer even between stream deltas. Scrolling away still releases following, including during a live turn; explicit streamed-content and visual-viewport edges retain the same following guard. Real-mobile typing tests at 320 and 390 pixels assert both scrollTop and a visible message anchor remain unchanged. The shared paint helpers in src/shared and their byte-pinned web mirrors decode raw terminal controls without changing journal bytes, preserve structured shell kill outcomes, word failures from full output, validate local answer readiness and normalize absolute timestamps before local formatting. The importless plain-content decoder uses standard Unicode grapheme segmentation for logical eight-character tab stops, independently of terminal geometry; tool headlines receive that decoder at the paint boundary. Absolute-time formatting extends the existing mirrored dbTimestampCore seam. Failed conversation-list reads retain any rows and expose retry instead of claiming empty history. The CLI statistics overlay reports independent section failures.

Web and CLI open the same newest page through GET /brain/messages or the atomic SSE snapshot. The response is always { items, hasMore, nextBefore }; before is the exclusive journal entry id from nextBefore, not a shaped-array offset. The default page size and validated query parser are owned by src/shared/chatHistory.ts, which both the real API and the browser harness use. The browser imports the page-size-only mirror in web/lib/chatHistory.ts as a dependency-free page-size entry; webMirrorContracts enforces equality. The server walks only the requested active-parent suffix using the session/entry index and shapes that page, keeping tool result rows with their assistant anchor even across an intervening user steer. A tool group can extend the requested anchor count. Hidden rows do not consume slots; visibility lookahead retires the cursor when only PI metadata remains at the root. Older-page reads first validate cursor ancestry without projecting messages; an abandoned or missing cursor returns 409 instead of reading an old branch. Pending entries never consume a slot while a live replay exists. With a parked restart and no live replay, only the confirmed provisional prefix can display. Cards, goals and control stay independent sidecars.

The web history hook prepends older pages on scroll-up and fences results by connection generation and history epoch. The surface retains its first visible DOM node as the scroll anchor while prepending. The CLI uses the application-owned SnapshotHydrator with independent parent/child older-page lanes for timeout and cancellation. Its transcript model retains live tail objects when prepending; empty or fully deduplicated pages only update the cursor and must not clear the shared array an unchanged pure fold returns; its viewport corrects the logical anchor by the prefix length, and PageUp, wheel or scrollbar dragging requests the next page after the loaded start is materialized. Synthetic tool anchors are retired when their real rows load, retaining newer live progress. Headless terminal repair walks the same pages only back to its last known durable row. No history cache or background full-history render is involved. An empty conversation returns an empty exhausted page; failed reads retain the current transcript. CLI reads surface the existing hydration error; web older-page reads remain retryable on the next scroll gesture. Snapshots replace the page and replay atomically on resync, including boot recovery. Model replay and explicit export still read the complete PI journal.

Conversation mode has one render-only mirror in BrainChatProvider, hydrated from daemon status, snapshot control, switch acknowledgements and durable mode events. Unknown state shows loading rather than a local Build default. The shared composer and telemetry controls request /brain/command with the selected mode and complete session/client/generation binding; no send carries mode. The mode picker is busy only during the bounded HTTP request. Send, keyboard submits and plugin-picker submits remain responsive and carry their captured binding immediately. A queued: true acknowledgement never selects the requested mode locally; the durable mode event completes the switch. Failed switches retain acknowledged control and show the localized error. Session, generation and control issuance fences prevent stale status or acknowledgements from replacing newer navigation or events. A remote mode event updates the indicator immediately and clears pending plans outside Plan. The authoritative workMode on a compacted history-rewrite frame also hydrates that mirror immediately, including rewind and clear. A new session starts unknown until its own control arrives. Status failures show a localized status-read error, not a mode-switch error. The shared scoped status reader rehydrates unknown control on the next successful ordinary read, including a settled child's usage read, under the same navigation and stream-revision fences; it adds no polling or retries. A read-only child cannot switch its bound parent.

Pending-message chips are a render-only queue in BrainChatProvider, shared by the dock and full chat. The daemon's queuedWithPending projection supplies both PI steering/follow-up messages and fresh sends waiting for manual compaction or a mode switch. Live queue frames and the latest queue event in the atomic stream snapshot replace the complete displayed list, including an explicit empty list, clear optimistic removal state, and advance the existing queue hydration revision so an older connect-time /brain/status reply cannot overwrite them. If the bounded replay contains no queue event, connect-time status still hydrates queued; absence alone does not mean empty. The transcript fold never owns these chips, and no send creates an optimistic message bubble. The existing ChatFooterDock renders them until delivery, hiding the bound parent's queue in child and read-only views. Snapshot handling scans the bounded replay once and adds no requests or polling; web/tests/e2e/specs/chat.compaction-queue.e2e.ts covers attach, late status, live send, reload and delivery on mobile and desktop.

Conversation event rows

Historical image-refusal and provider-timeout events migrate to generic error events in database v55. errorEventLabel displays their preserved text through its existing no-notice branch; neither retired code has a rendering case or translation key.

Every row between the person's messages and the replies (a stopped or failed turn, a changed model, mode, reasoning level, title or working directory, a sub-agent or workflow finishing) is a stored chat event, and the dock renders them all through ChatEventRow in web/modules/advisor/ChatEvents.tsx from the ChatEventView the daemon sends: history rows carry it as event on a role: 'event' message, and the stream delivers it as a chat-event frame, which brainChatStream hands to BrainChatProvider. There is no second frame and no client-side label for a raw kind; add a new row by adding its kind to ChatEventDetails, a case to eventRowView and an icon to EVENT_ICONS (the Record over every kind makes a missing icon a type error).

Cwd events keep their complete identity in the data and title while the visible label abbreviates an absolute path to its last two segments; managed project names remain whole.

Every kind renders as the same row: one lucide icon (distinct per kind, taken from the icon the app already uses for that idea), a label in the muted tone and a value in the foreground tone, joined by → for a change or a command outcome and · for a finish or a shell exit, on one line. The rows live in the tool rows' log column and reuse its primitives rather than imitating them: LogColumn (the indented monospace column at the tool rows' size), LOG_ROW (the row's flex line and padding), LogGlyph (the fixed-width, one-line-tall icon slot, which the tool rows' text glyph and spinner also use, so every label starts at the same x) and LogFold (the tool rows' native <details> disclosure with its chevron). On the full page an event run is drawn inside the same turn grid as a reply and stacks flush against the turn above; role labels and the block gap skip event runs (the gap is taken by the run itself only when it follows the person's message), so a reply's tool rows, its event rows and the rows of the reply that continues after them read as one list. The compact dock follows the same contract: neither variant spaces turns with a container gap, each turn takes its own breakBefore margin and each segment its own symmetric margin, and the dock's modal and ambient extras form their own spaced group after the turns. A failed row takes text-destructive on its icon and label only. A row whose text the column truncates becomes a fold (measured by useTruncated, which stays set once it fired) and unfolds in place through the group-open/fold variant, because a phone cannot hover for the title; a !cmd shows $ command · exit N and folds its output into a bounded (40dvh), keyboard-scrollable ToolOutputBlock. web/tests/e2e/specs/chat.events.e2e.ts measures the contract: no gap between rows, shared icon and label edges with the tool rows, one-line height while collapsed, unfolding, and no sideways overflow at 1440, 390 and 320.

The dock words every row from typed facts in the reader's language, never from the daemon's English: a command outcome through commandOutcomeLabel (a no-op /compact by its reason, /goal status from its GoalStatusSnapshot, and a failure by its ChatCommandNotice when the daemon wrote the reason itself: restart-unavailable, or not-running for a command on a conversation without a live runtime, which failedOutcome derives from ConversationNotRunningError), and a failure through errorEventLabel, which uses the dictionary when the event carries a notice (the texts the daemon wrote itself) and shows text verbatim otherwise, because a provider's or a thrown error's own words have no translation. A notice never changes the English error or text the model reads. A frame whose kind changes what the status bar shows (STATUS_CHANGING_EVENT_KINDS in src/shared/chatPresentation.ts, byte-mirrored into web/lib/chatPresentation.ts, read by BrainChatProvider and the CLI's streamCoordinator) also refreshes the settled session state. For example, a model switched from the CLI shows one row in the web transcript and updates the model chip, without a reconnect. A reasoning level is recorded only when the next turn is admitted, so a change on another client arrives first as a payload-less settings frame that refreshes the same state and adds no row.

Usage and rail projections

Global useModelUsage and useUsageByDay read the complete provider/day counter, including recorded voice, sessionless inference and embeddings. UI API 49 withdraws useResetUsage and model-summary speed fields because this counter stores neither timing nor price provenance. Conversation and workflow speed remain measured. Users SpendLimit renders a small shared Button and ConfirmDialog under Limit and spend; useResetUserUsage(userId) calls the administrator-only target reset and invalidates the target user-spend query plus model/day/origin/provider statistics after success. The confirmation retains errors and blocks repeat clicks while pending; monthly limit configuration is unchanged.

Usage pressure is classified by usagePressure(percent) in the canonical src/shared/displayFormat.ts and its byte-identical browser mirror. Below 70% is normal, 70% starts warning and 90% starts critical. CLI telemetry uses that result with its existing theme colours; browser usageProgressClass and usageProgressColour in usageMeter.ts map it to the existing design tokens. Stats context bars and System diagnostics reuse the same colour mapping. Missing context retains its empty fill and primary colour; no tokens, thresholds, readings or polling behavior change. Classification is constant-time with no I/O.

The daemon's settled provider-request measurement remains authoritative. src/shared/displayFormat.ts owns formatSpeed(value, locale?), contextMeter(percent), providerUsageAfterRead(snapshot, failed) and railProcessVisible(process). Its importless browser copy web/lib/displayFormat.ts is byte-identical, enforced by webMirrorContracts. Formatting costs no I/O. Unknown context has a dash and no measured fill at either rail density; an unknown speed has a dash, a positive sub-unit sample stays positive. The formatter never reinterprets a settled rate as an idle activity estimate. Workflow cards, node details and the web chat statusline consume the same localized formatUsageSpeed wrapper, including sub-unit measurements: 0.4 renders as 0.4 tok/s in English and 0,4 tok/s in Czech/Slovak; positive samples below 0.1 render as <0.1 or <0,1 tok/s. The web statusline omits an unknown/non-positive reading and honors showSpeed without changing the other statusline segments. The CLI composer owner applies the statusline plugin's showSpeed toggle and renders the focused session's measured effectiveTps even after it settles, without gating it on current activity.

Quota fetching stays in UsageService; the web uses the existing polling query. On a transport failure, providerUsageAfterRead keeps only the selected account's prior reading, unchanged, until its fetchedAt is 30 minutes old, and then withdraws it; a successful absent reading clears it. There is no "out of date" note or warning tone: a missing Limits section is the failure signal. The telemetry rail, Settings account rows and their provider-statistics drawer all project polling failures through this helper, so the same quota is shown or withdrawn alike everywhere. A failed read with nothing usable draws no Limits section at all. CLI quota polling also applies providerUsageAfterRead to each provider-keyed cached snapshot after transport failure, dropping withdrawn entries; a successful absent read clears the map. No helper caches data or resolves account identity. xAI/Grok uses the same provider-keyed map under PI id xai, already mapped by the connected-account row and the focused session's usageProvider. Its weekly or monthly allowance reaches the account row, provider-statistics drawer, telemetry rail and CLI through the ordinary ProviderUsage window projection; there is no xAI-specific UI fetcher or provider allow-list.

summarizeModelUsage(rows) in src/shared/effectiveSpeed.ts owns model sorting, maximum token count, token/cache/cost totals, usage presence and duration-weighted effective speed. Its existing byte-guarded browser mirror feeds buildUsageSummary, the plugin runtime's existing stats utility, while the CLI stats overlay formats the same numeric projection. It copies the array before sorting, costs O(n log n), keeps unreported costs and timing null, and returns an empty ledger for empty input. Cache writes remain misses in the canonical cacheHitPct.

Goal facts come from chatEvents.goalStatusSnapshot. GoalStatus.tsx has a body-guarded browser projection because backend runtime imports cannot cross the web build root. Both rail renderers consume those facts, including durable evidence, rather than independently tallying subgoal JSON. CLI goal commands and headless text output also format this snapshot; the headless JSON output remains the raw wire goal. The CLI ProcessPanel consumes railProcessVisible directly. Malformed subgoal JSON omits only the tally. src/shared/railSelection.ts owns uncollectedChildResult; its importless web/lib/railSelection.ts mirror is byte-identical and pinned by webMirrorContracts. Running children or pending result delivery own rail space. railProcessVisible selects only running detached handles for display; foreground handles must remain in the session snapshot. API 34 publishes both selectors through window.ElowenUiRuntime.utils: SubagentsRail uses agents.filter(utils.uncollectedChildResult) and TerminalRail uses processes.filter(utils.railProcessVisible) before either density renders. The bundled rails require API 34, so older hosts refuse them before mount. These predicates cost constant time and no I/O; array filtering is linear and never mutates snapshots. Empty selections draw no section, and without a contributing plugin no plugin section exists.

Conversation cards and drill-in

The host's GET /brain/conversation-links response comes from src/api/routes/brainConversationLinks.ts. Its job-editor link targets the actual plugin owning the live cron control, with the job id percent-encoded, while account grants, job visibility and run-transcript access are still enforced by core. Job and sub-agent branches retain independent loading/available/unavailable/error presentation, so a missing scheduler never erases a conversation's sub-agents.

PluginHistoryBranchProps.open(target) takes session:${encodeURIComponent(sessionId)} with an optional :1 continuation request or :0 preview flag. Registry Cronjob sends its run's continuable flag; bundled Subagent sends only the encoded child id. ConversationHistoryPanel decodes the id and delegates to the existing session opener, which checks actual ownership and eligibility. For the Subagent branch the host always opens a read-only delegated transcript, ignoring continuation requests. The target is navigation metadata, not authority. It adds no polling or separate session store; the existing session stream loads the selected transcript. Without a plugin renderer the host retains its generic branch.

Every delegated child view is read-only, including owned children and nested drill-in. BrainChatProvider.openDelegatedChild sets the existing read-only state before opening the fixed-session stream; BrainChatSurface keeps the same mounted ChatComposer and textarea, locking editing, slash commands and submission. Locked views omit the Actions menu and attachment input rather than showing unusable actions. The composer's outer footprint and the parent's field height are preserved; the field gains the removed menu's width. The narrative hint stays on one line with ellipsis at 320px in English, Czech and Slovak. Its full localized sentence remains in the title and accessible name. Normal parent text keeps native multiline wrapping. The importless childReadOnlyText(locale, { agentType, status }) in the shared chat-presentation seam and its byte-pinned browser mirror supplies both clients' label and description. It names the chosen agent type, not the task name, and distinguishes working, finished and error outcomes. Untyped and fork children omit the type. The fixed-session snapshot's session.delegation restores the child's durable type and outcome after reopen; authoritative delegation frames update the outcome on the same stream without another network read. An idle turn does not finish the delegation while nested children or background jobs remain outstanding. Generic history previews retain their history-specific wording. No Esc instruction is included because web child navigation uses the Return button. No separate read-only banner is rendered. Return occupies the attachment button slot and carries the child's name in its title. No message or skill-picker send targets a child. The parent's draft stays intact and its queue is projected only in the parent view, never into child status or transcript controls. Returning reconnects the bound parent and restores its composer. An owned focused busy child has a real Stop button in the composer's usual send/stop slot, using the existing child-targeted abort action. The attachment/return and send/stop slots use shared icon Buttons with the same geometry in the parent and child, including the coarse-pointer touch-target token. Task cards and the agents strip retain their usual place above the composer; child task rows are display-only through the existing null plugin-session contract. A history/foreign preview with no owned focus has neither Stop nor a model picker; the controller also refuses abort/model actions in that state so they cannot target the hidden parent. Model picks queued before drill-in are fenced by the stream generation. The request's delegated display kind selects the child-specific accessible description, separately from control authority. The existing pluginSessionId: null contract keeps plugin actions read-only too; no extra route or network read is introduced. Conversation history branches no longer advertise a child continuable capability; plugin navigation always opens a read-only delegated transcript.

What a person sees of a session's plugin cards (ctx.emitCard, the todo checklist is the canonical one) comes from one daemon path for every client: the cards of /brain/status and of the fixed-session stream snapshot, then card events. BrainService keeps them per conversation, and the snapshot of a drilled-in sub-agent carries that child's own cards, so the CLI child view (rt.childView.cards) and the web drill-in (openDelegatedChild in BrainChatProvider) draw the child's checklist, never the parent's. Card renderers draw the card they are handed and do not read a plugin route to rebuild it: the transcript card (PluginChatCardHost, falling back to CardBlock) and the telemetry rail's Tasks section (cardTasks in web/lib/railTasks.ts) both read the same card; a card whose rows carry no task ids stays in the transcript, drawn read-only.

The statusline numbers (context meter and window, cost, token rate) follow the same rule. A session's BrainUsage is computed once, from its own live PI session (usageOf in src/brain/events.ts, in the daemon or the sub-agent runner), and published on its step/idle events. /brain/status serves it only for the viewer's own conversations and rejects a child id, so a drill-in takes the child's usage from its stream alone: the last usage-bearing step/idle replayed in the snapshot, then the live frames. The CLI child view (rt.childView.usage) and the web drill-in (openDelegatedChild) both do exactly that; the web clears the parent's usage on entry and keeps its /brain/status usage poll off while another session is on screen, because that poll reads the bound parent. Exiting drill-in rehydrates the parent's own usage from its status and snapshot.

Acting on a session is separate from showing it. BrainChatActions.pluginSessionId is the conversation the plugin chat surfaces (cards, rail sections, pickers) may act on through their own routes: the viewer's own conversation, or null while a delegated child or a read-only transcript is on screen, because a plugin route writes only into the caller's own conversations (BrainService.writePluginCard). A surface without one draws read-only rows, and /tasks is refused in a child view with the same reason as the other parent-scoped commands, as the CLI refuses it. web/tests/e2e/specs/chat.subagent-tasks.e2e.ts opens a child with a task list in a real browser.

Plugin UI API 32 exposes that same cardTasks(cards) projection and cardTasksAddressable(rows) guard through window.ElowenUiRuntime.utils, typed in the UI kit as CardTask. The Todo bundle projects its card with utils.cardTasks([card]); its rail receives the host's already-projected TaskRailData.tasks and passes them unchanged to TaskList. Neither surface rebuilds raw session tasks or extracts owner and blockers from glued text. The host normalizes incoming plugin cards at the emit boundary. A rail slot is an internal host projection, not another wire reader. A missing Todo card produces no rows; an empty or partially unaddressable list fails the id guard. The projection is linear in cards and Todo rows and performs no request or poll. Missing or incompatible bundles retain the existing host fallback behavior.

Workflow DAG layout

Delegation runStartedAt is the current call's epoch-ms anchor, separate from the child creation timestamp. AgentsTable ticks from this anchor while running and uses reported seconds when no anchor was recorded; it never uses the child's age as runtime. Boot recovery derives both live and terminal seconds from that same durable call anchor, including downtime. Rows without an anchor retain last-reported seconds plus respawn time. The claim-guarded completion and failed-recovery parking write terminal seconds atomically with the result so the published row cannot fall back to its last progress tick. Workflow snapshots carry one runTiming interval for the current DAG run. Start, manual resume and boot resume each begin a new interval; terminal snapshots freeze it with finishedAt. Both inspectors label the headline as run elapsed, computed by workflowElapsedSeconds, not the maximum or sum of node durations. Missing historical timing or a terminalized interrupted interval without a known finish renders unavailable. workflowNodeSeconds owns live node duration derivation for the CLI and web. These pure helpers have no I/O or token cost.

API 31 publishes ElowenUiRuntime.utils.workflowLabel and workflowCounts from the browser mirror of the shared workflow layout module. The bundled Subagent rail requires API 34 and uses these helpers for trimmed titles, first-task fallback and done/total counts. Empty node lists retain the workflow-id fallback and zero counts; without the plugin there is no workflow rail section. Bundles requiring this API are refused by older hosts before mounting.

src/shared/workflowLayout.ts owns the workflow graph's longest-path waves, stable barycenter order, via slots and orthogonal edge routes. The importless, byte-identical browser mirror is web/lib/workflowLayout.ts, pinned by tests/contract/webMirrorContracts.test.ts. Call layoutWorkflow(nodes, geometry) with a card width, per-node height, gaps, via-slot height and gutter lane dimensions. It returns positioned cards and edge point lists; the caller paints those units as pixels or terminal cells. With no nodes it returns an empty layout; dangling dependencies and cycles still place every node, while backward or unresolved edges are not routed. The CLI canvas in src/cli/chat/workflowCanvas.ts supplies cell dimensions and the fullscreen web inspector in web/modules/advisor/WorkflowModal.tsx supplies 196-by-68 pixel cards. Neither client has its own layering or routing algorithm. Via slots cost one thin row in each crossed wave, keeping long edges out of non-endpoint cards. The same module's workflowCounts, activeNode and stepSelection are the only node tally and selection walk; the CLI telemetry rail, its transcript marker, the workflow modal and the web inspector all read them rather than re-deriving their own.

The web inspector opens from the telemetry workflow row through the existing Modal fullscreen presentation, as the Agents and Conversation diagnostics inspections do. On desktop its graph and detail panel scroll independently; on a phone the same modal switches between a wave list and the selected node detail. Empty, loading, reconnection, live-following and keyboard behavior remain inside that one surface. The Stop button uses elowenClient.brainWorkflowStop(workflowId) through the shared JSON transport to call the subagent plugin's authenticated POST /brain/workflows/stop, returning its actual { workflowId, status: 'cancelled', stopped, running } response. An interrupted, possibly accepted stop is not replayed and its cause reaches the shared toast adapter. The plugin checks both the run's originating principal and the core-owned parent-conversation owner projection; a missing, foreign or terminal run is refused. Without the subagent plugin, the route is absent and the browser reports an error rather than claiming success.

The live snapshot clips each node's result and error to a 500-character preview, which is what the graph cards and wave rows show. The detail panel of a finished node instead loads the complete outcome from the subagent plugin's authenticated GET /brain/workflows/result?workflowId=&nodeId= route (useWorkflowNodeResult in web/lib/queries.ts), behind the same owner check as Stop (one ownedWorkflow helper in plugins/subagent/lib/workflow.mjs). A done node's answer is re-read from its durable child transcript through ctx.readSubagentResult, the path dependents and the final summary already use, and renders through ChatMarkdown, the transcript's own markdown block; a failed node answers the engine's stored reason. An unfinished node answers 409, an unknown node or a foreign, missing or no-longer-retained run 404, on which the panel falls back to the snapshot preview with a muted note that the complete result is no longer in the live view and lives in the node conversation; any other failure is an ErrorState with retry. The route serves only runs the engine still holds in memory (the subagent resultRetentionMs, one hour by default). The query key carries the node's startedAt as well as its status, because WorkflowResume retries a node under the same workflow id and often the same child session: a new attempt is fetched afresh instead of reusing the previous attempt's settled answer.