Transcript, events, cards and workflows
Conversation list contract
The conversation switcher uses one list renderer for mine and administrator-only all.
Its feature contract lives in web/modules/advisor/conversationListModel.ts; rows and
toolbar receive data and callbacks rather than reading stores or deciding ownership.
The controller adapts the existing personal and managed queries. Interaction state belongs
to the mounted panel, keyed by view, and ends on close or a change of authorized scope.
Both listing DTOs include projectRef: ProjectExecutionRef | null, parsed from stored
authority in core. The browser joins stable project ids only to the authorized Project
catalog. Host/no selected project, an unavailable historical Project and a failed catalog
read remain distinct. Managed activity omits unread; caller-owned pin/unread metadata
comes from the personal listing. Foreign unread is not applicable. The personal DTO
also carries required platform: 'api' | 'subagent' | 'web' | 'cli', projected by core
(ownerSurface in statusService.ts): api from stored API-token creation attribution,
subagent for a row with a parent session, otherwise web once the web participated and
cli before that. Mine's Type facet distinguishes API from the combined Web / CLI
category using that field, never a session-id parser. This origin mark does not change
ownership, continuation or action capabilities.
brainSearch(query, scope = 'mine') keeps caller-only search by default. Only
scope=instance broadens the existing route, through resolveUserReadScope.
Instance results cover the managed listing universe, channels included; transcript
exclusions and bounded newest-first hits remain unchanged. Search state is scoped to
the mounted view and late completions cannot update a different view.
The controller grants rename, fork, pin and icon editing only to caller-owned ordinary conversations. Foreign and channel conversations open read-only; authorized export and administrator deletion keep their existing endpoints. Caller-owned deletion uses the chat controller so deleting the active conversation preserves retargeting. Bulk operations snapshot selected ids, run sequentially with the active conversation last for deletion, freeze conflicting controls and retain failed selections.
Opening an ordinary row is navigation through the provider's existing selection seam. A newer
selection or an atomic stream resync may supersede its connection generation without making the
open fail; the newest connection owns the displayed transcript. Actual connection and permission
errors still reject. Callers that continue with a mutation or a voice callback use switchSession
or startNewConversation and must still validate the returned binding generation before continuing.
Filters use OR within a facet, AND across facets and search. Model identity includes
provider, owner means the hosting account, and status uses native program control.
Each option carries the mark its rows draw: the model's brand mark, the conversation status dot
(the same ConversationStateDot the glyph paints) and the platform mark, cron for scheduled
types. The marks are decorative, so the option text stays the accessible name.
Context-only ancestors remain reachable but are not selected as direct matches.
The existing conversation tree owns ancestry; branch enrichment never changes root paging.
Newest uses local-calendar date sections; every other sort is global. Mine keeps pinned
roots first, once only. Section totals count filtered roots before paging.
Rows use list/listitem semantics and one native open control; Space selects without opening.
ConversationSwitcherModal owns only the current view while open. It mounts
ConversationListPanel keyed by that view inside Modal size="page" and
ModalBody fill, without width or height overrides. Desktop uses the same centered
frame as Settings; phones use fullscreen, place the administrator Segmented on its
own line and retain the dashboard link. Controls stay local through
ControlSurfaceToolbar promote={false}; New opens NewConversationMenu, including
its Project chooser, rather than creating a conversation immediately.
The panel owns search, facets, sorting, root page, selection, manual expansion and
transient confirmations/drill-in. useConversationListController(view, { search, pageIds, onNavigate }) owns authorized reads and asynchronous per-id operations,
not a second session store. Mutations resolve true on success and false when their
scope or lifetime has changed; actual failures reject for localized panel feedback.
A successful write still invalidates the personal, managed, branch and search caches
when its original panel has unmounted or changed scope; lifetime fences suppress only
late UI completion, not cache freshness for another mounted consumer.
Owned deletion passes quiet: true through the chat controller so batches announce
once. Export accepts an optional AbortSignal and aborts outstanding downloads when
the controller unmounts. The branch hook's explicit enabled flag suppresses disabled
scope reads; placeholder data never crosses scopes, and a new page reports its
sub-agent metadata as loading rather than an exact zero. A zero-root search keeps
observing the last page request in the same authorization scope, including its first
pending response. Only request identity and bounded ids are retained by the hook;
response data remains in QueryClient. This lets schedule-name search recover its
parent without clearing search after the initial links arrive. Empty visible ids
disable new reads after a page has been requested, while an explicit retry remains
bounded to that last page; a scope change discards the retained identity. Before any
page exists, the hook requests GET /brain/conversation-links?scope=mine&ids= (or
admin-only scope=all) to discover authorized schedules without any sub-agent roots.
The client preserves an explicit empty array as ids=; an absent ids parameter
still requests the whole authorized tree. This keeps cold schedule-only searches
independent of filtered root paging, including a query entered while sessions load.
Sub-agent metadata remains unavailable until roots are visible again.
Authorized user-directory metadata retains owner-uploaded avatars through the existing Avatar renderer.
Both views render ConversationList once. Search waits
300 ms at two trimmed characters; pending/error/retry and bounded transcript results
are distinct from a successful empty list. The secondary line prefers the first
highlighted transcript hit, then native state; it never fetches a latest-message preview.
Newest groups roots as Pinned (Mine only), Today, Yesterday, This week and Older,
using the browser's local calendar and Monday week boundary. Name, Tokens, Model,
State and Owner order globally in Results, after Mine's pinned roots; direction
and stable recency/id tie-breaking remain available. Root section totals are computed
before paging, while descendants stay with their parent and never consume a page slot.
After a successful pin or unpin, the panel follows the operated root to its page in
the current filtered, grouped and sorted root order, once the committed pin projection
arrives. This lets the list restore the same focused row control after a section or
page remount; unpin can move forward several pages rather than always returning to
the first. Focus lost with the remounted control is taken back from the body or from an
enclosing element such as the dialog, whose focus trap catches it while the row is off
the shown page, never from another control. Failed pin writes leave the page and control focus unchanged, and pending
writes retain the shared busy fence.
One useNow subscription serves relative times and calendar rollover.
A page holds a fixed 30 roots that scroll inside the panel; data-conversation-root marks
only direct roots, and expanding a branch changes neither the page capacity nor the page-id
request. The shared paging
hook consumes a serialized facet key in its reset inputs. Its Pager total counts every
paged root, including contextual ancestors; section match totals exclude those ancestors.
Only an active facet passes the tree filter's fourth matchFacets predicate, so clearing
all facets restores manual expansion instead of forcing every family open.
Page selection includes only direct matching roots on the current page, excluding
context-only ancestors and an active rename editor. Search, facets, sort and page
changes clear it; refetches prune missing ids. Bulk delete and HTML/JSONL export
snapshot ids and freeze conflicting controls while processing sequentially. Export
explains separate downloads and reports initiated requests, not files saved by the
browser. Deletion keeps the active conversation last and retains failed ids for retry;
one batch summary replaces per-id error announcements. Administrator Delete all is a
separate full-scope confirmation naming all accounts and all managed conversations,
regardless of the current search, facets or page. The panel passes an optional native
DOM ref to the existing SelectionActionBar and observes its border-box height.
Its scroll area reserves that measured height plus the bar's computed CSS bottom
inset, so wrapped actions cannot cover the final root on a narrow phone. The reserve
ignores entrance transforms, follows bar resizing, and is removed with the observer
when selection clears or the panel unmounts; other registers keep their existing layout.
Scheduled branches mount PluginHistoryBranchHost in named regions with standalone
list/disclosure fallback content. Every session target uses parseBrainSessionTarget
and preserves its continuation flag. The separate Subagent drill-in still owns its
own table and forces delegated navigation read-only. The public DataTable runtime
exports remain unchanged; this register's list cutover does not convert other registers.
Fixtures use the existing wire DTOs plus the declared feature row fields: a nullable Project ref, authorized Project or null, explicit unavailable flag, native status, nullable caller-relative unread, nullable pin timestamp and explicit capabilities. Plugin branches mount inside named list-compatible regions, never host table rows. No latest-message preview, database migration, persistent preference or compatibility renderer is introduced.
Local computer destinations
GET /brain/execution keeps authorized server Projects first, in the account's configured order, and
appends local devices in their connection registry order, including offline devices. The dashboard
composer therefore defaults to the first server Project while its persisted explicit local choice still
resolves to that device. Offline state stays visible to every consumer of the shared list.
ProjectMenuContent renders the same account-scoped execution-target list for chat, the dashboard composer and new conversations. A local row uses its opaque targetId and renders on one line: the device label, then the muted Computer · Connected or Computer · Offline status. It shows neither the startup path nor the conversation's working directory; the path still feeds the search and the ProjectPicker trigger's fallback. executionRefKey preserves that identity across selection and dashboard persistence without pretending it is a project ID. ProjectPicker names the selected device and reports its current conversation cwd, while read-only and child views retain their existing switch restrictions. The shared ProjectIcon renders local targets as a laptop without an editor blob request; project icons keep their existing cache. PluginChatProject also carries {kind:'local',targetId}, allowing existing chat-dock consumers to distinguish it before asking for project-owned data. No local targets means no additional rows or background work.
startNewConversation passes the picker's target in the existing brainStart request. The daemon
authorizes it through the Project selection resolver before the first spawn and startup instruction read;
the first send follows that confirmed binding. No follow-up execution mutation is needed for creation.
Resuming an existing conversation keeps its selected target. Later picker changes continue through
brainSetExecution and the existing Project switch boundary.
Connection lanes in the composer footer
ChatFooterDock is the single nonblocking presentation placement for degraded
transport, model retry and waiting_for_computer execution state. It consumes
connectionState, typed connectionFailure, modelRetry, executionState and
the provider's retryConnection action. Canonical observations replace retained
ready/reconnecting booleans; no admitted observation/failure means no invented
offline row. Fresh connected/ready transport disappears without clearing either
independent model or execution row. All lanes can coexist before the first text.
A typed start failure uses localized connectionFailure.startFailed with unknown
connection state and no retry button: reconnect cannot replay /brain/start.
Policy/stream failures use their typed labels and the owner's actual admitted
GET target through actions.retryConnection, never raw error/status prose.
A setup or reader failure cannot attest to current transport. Computer waiting
uses its typed phase without invented device names, timers or attempt counts.
Ordinary server notices remain separate; no string matching identifies retries.
The ProjectPicker trigger renders canonical observation from
telemetry.project?.connectionState ?? null, preserving unknown/stale semantics
rather than deriving Offline from the execution catalog
SwitchableProject.computer.connected. The catalog wire shape is unchanged.
Local cwd and its full title, durable selected execution identity, menu keyboard
behavior and authorized mutation handlers retain their existing ownership.
The footer adds no fetch, controller or policy owner and does not block shell navigation. The provider removes its former fullscreen overlay and duplicate reconnect notice, retaining one footer transport lane; component tests do not prove the complete browser flow. Shared component rules, including unknown counts, freshness and accessible countdowns, live in Connection status.
Finite chat stream and background lifetime
useBrainChatStream (web/modules/advisor/brainChatStream.ts) binds actual
readDaemonStream to one finite shared connection controller, with strict
current-auth loadConnectionPolicy/getConnectionPolicy. It neither reads admin
config nor supplies a default/previous-account policy. Chat omits optional
maxBufferLength, preserving its existing unbounded SSE reassembly rather than
inventing a chat frame cap. Bytes, comments, headers and heartbeats refresh only
liveness. The 15-second snapshot timeout and existing configurable silence limits
remain separate watchdog/revive boundaries, not new retry maxima.
BrainChatStatus exposes connectionState: ConnectionState | null,
connectionFailure: { source: 'start' | 'policy' | 'stream'; status: number | null; code: string | null } | null, modelRetry: RetryProgress | null and canonical
executionState. BrainChatActions.retryConnection(): void owns current-bound
GET/bootstrap recovery, never start/send mutation replay. Initial attachment and
explicit selection alone invoke brainStart; failed start stays typed/unready
without an admitted stream target or inert Retry button. Navigation, New
conversation and explicit open remain available. The committed ChatFooterDock
is the actual independent transport/model/waiting consumer, not another owner.
Application readiness follows successful SYNCHRONOUS snapshot application plus current binding/generation/auth checks, never connection-open or a partial frame. Transport loss preserves transcript, model retry, computer waiting and daemon-owned workPending until authoritative snapshot/control explicitly clears them. Live retry advances the existing control hydration stamp so older status cannot erase it, including before the first token. Existing ready/reconnecting state still supports activity lifecycle and workflow consumers; presentation uses canonical observations, not those booleans as a second retry authority.
Close, auth transition and explicit target switch retire callbacks synchronously; a replacement waits for old policy/controller/reader cleanup. Focus, resync and silence watchdog can reconnect the current attempt but cannot renew exhausted work. Explicit Retry or an observed offline-to-online edge can restart that same target's finite episode. A foreground return also renews an exhausted or stale episode once, even when navigator.onLine stayed true throughout phone sleep. A healthy brief hide does not reconnect. Blocked/refused targets and failed policy bootstrap are not renewed by a foreground return.
subscribeRevive owns that edge: visibilitychange to visible, pageshow (including
persisted bfcache restores), and document resume share one foregroundReturned
signal, rearmed by hidden visibility, pagehide or freeze. The first pageshow/resume
may be the only observed restore signal. Hidden signals cannot spend a foreground
renewal; repeated visible signals never refill it, even outside burst coalescing.
Each real return starts at most one finite episode; a visible page with a dead
server still exhausts and offers Retry. No wake replays brainStart/send, refetches
history over a running turn or clears its text: only the same GET's atomic snapshot
reconciles it. Read-only child taps omit parent client/generation authority, stay
on their exact child GET and remain unready when unavailable; there is no parent
brainStart fallback.
Phone lock can emit pagehide with persisted:false even while a terminal WebSocket
keeps the page eligible to return. The provider therefore removes only its browser
pagehide Stop beacon. Visibility reporting and server generation tombstones stay;
transport detach retains the binding. The EXISTING 30-minute idle sweep
(IDLE_LIVE_SESSION_TTL_MS in src/brain/service/liveSessionReaper.ts, run by
reapIdleLiveSessions) eventually collects an abandoned runtime, not immediate detach
cleanup. Explicit Stop and selection retain their existing behavior. The now-unused
brainSessionStop beacon and isStreamDataFrame helper, with only their dedicated tests,
are removed; visibility reporting and the active silence watchdog remain.
No mounted/attached binding means no stream/policy work. Active chat keeps its existing one stream, snapshot hydration and watchdog resources, with no second socket, timer or retry loop. Focused hook/provider/footer tests use actual shared owners but are not a real-browser or physical-phone acceptance claim.
Interactive transcript history
The slash menu reads GET /brain/commands and refetches when a slash draft opens. Live input aliases are contributed through registerCommandProvider, with the invoking account's grants and skill ownership. The Skills plugin publishes the same user-invocable catalog used by /skills, including skills hidden from automatic discovery. Choosing an alias sends the raw slash command; the resource-loader input extension replaces its token with the declared invocation and forwards its argument tail to the existing plugin transform. Core and static plugin names win collisions. CLI suggestions use the same endpoint and refresh on opening too.
/help opens HelpModal, a filterable list of the same catalog with each command's description. Choosing a row runs that command through the runSlash path the slash menu uses, and /help itself is left out. The modal's first row is the rewind action. F9 in a focused chat surface (data-chat-rewind-surface) calls the same rewind() in BrainChatProvider, which refuses in read-only and delegated child views and while a rewind is already in flight. While a reply is streaming, the rewind request carries stopOnly. A typed rewind refusal (code rewind_*) is shown through typedRefusalMessage in the page's language; any other failure shows brainChat.rewindFailed.
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. The floating-dock observer publishes React state only when tile presence changes; its Effect Event reads current state without reinstalling the geometry observer. Repeated token mutations still invalidate layout, but unchanged tile presence must not enqueue no-op React commits. BrainChatDockReserve.test.tsx covers tile appearance/removal and BrainChatLongChatPerformance.test.tsx keeps the existing per-token render budget.
A non-triggering owner-call tail can be durably accepted while its preceding native tool is still running.
The atomic stream snapshot supplies these rows as optional conversationAppends, separately from
settled history and live events. fromSnapshot folds history and replay first, then appends these
inert display turns and records their suffix length as ChatView.conversationAppendCount. reduce
excludes that suffix from each live fold and restores it afterward, so tool progress, text and idle
continue in the original run without another user admission or thinking state. A session reset drops it;
a new snapshot replaces it atomically when the safe journal flush promotes the same rows into history.
keepOlderWindow excludes this suffix from journal-anchor matching, while ordinary older-page prepends
retain it. BrainChatProvider passes the count through its transcript slice and excludes pending rows
from live narration. BrainChatSurface and transcriptBlocks use it to keep the active work shell before
the cards rather than treating them as new work. With no pending input the count is absent and the
existing fold/grouping is unchanged. The server owns identities and promotion; no client queue, storage
writer, polling or timer is added. Rendering remains linear in the bounded snapshot/replay and pending
suffix. See voice.md for the owner-call producer and the durable append contract.
Chat rendering lives in siblings under web/modules/advisor/:
ChatTranscriptowns the memoizedMessage(the person's messages, event runs and dividers), the memoizedAssistantRun, turn metadata, tool rows, reasoning and authoring hints.workRunsis the pure display grouping of assistant work, the only place that decides where a run starts, ends and folds.transcriptBlocks(turns, working, awaitingEcho, workPending, conversationAppendCount)groups native assistant steps and joins across a terminaldurationMsonly when the next native turn's first step hastrigger: 'delivery'. The shared transcript fold derives that trigger from typedturn-boundaryframes or the same history projection. A visible user turn, divider or untriggered assistant turn starts a separate group; scheduled wake-ups are untriggered. Event rows between joined steps belong to their work; trailing events stay outside.workPendingkeeps the last group live during outstanding conversation work without appending an empty shell; a fresh human send still reserves its shell before the first output.splitNativeRunsappliessplitRunseparately at every native terminal step. Only the last native turn's answer stays visible; an earlier turn's interim answer stays in that turn's work at its recorded place, before any tool calls that followed it (splitRun'sinterimflag skips the settled answer lift), so the folded log reads in time order and the settled group shows one final answer. While the joined section is live, delivered files, explicit images, submitted plans and completed views stay in their recorded positions inside each native turn's work timeline. Every native split receives the whole section's settled state, so a terminaldurationMs, delivery continuation or streaming pause cannot pin them while sub-agent, workflow, background-process or question work remains outstanding. A waiting plan/view and actionable questions remain visible outside the disclosure. Only once the joined section settles and automatically collapses are deliveries lifted out, including stopped and failed sections; reopening a settled section keeps them pinned.splitRundivides a native turn into the work timeline, the items still waiting for the reader (the plan whose decision is pending, an interactive view waiting for an answer) and the visible tail: the trailing text and message blocks plus a closing plan orAskUserQuestionrecord after the last ordinary work step. A streaming step'scomposingstate also counts as following work:splitRunkeeps its preceding text in the timeline, before the authoring hint thatAssistantRunappends, even while tool arguments are still streaming. Snapshot replay uses the same split; once thetoolevent arrives its row takes the hint's place in event order. When a run finished successfully (settled, neither stopped nor failed) and that tail holds no text, the run's last text segment is its answer and joins the tail ahead of it, so a closing call after the answer, such as saving a memory, folds without hiding the answer; a live, stopped or failed run keeps the strict rule. For a settled section, before finding that tail,splitRuncollects every delivered file, explicit image, submittedExitPlanModeplan and completedShowViewfrom all steps in recorded order through the sameisDeliveryclassification. Mixed tool segments split around submitted plans; each plan keeps its original tool item, including its approvalQuestionAnswerSummary, and the existingToolPillsrenderer draws the samePlanCardwith daemon-owned decision controls. Pending, approved and dismissed plans use this delivery path after settlement, including a plan-only ending and an earlier native turn in a joined section. Plans have no delivery timestamp, soorderDeliverieskeeps their recorded position while ordering timestamped attachments. Files, images and plans are extracted immediately, while completed views stay in the work items as boundaries during the tail walk so narration before a view is not promoted into the closing text. When building the result, completed views are lifted out of work and all deliveries join the visible tail in delivery order, above the closing text. Live deliveries remain part of the work timeline even when the reader manually folds it; after settlement they stay visible when later tools follow them, after reload and when no final text is produced. Image segments markedpreviewstay beside theirReadtool rows in the work timeline; waiting views remain in the waiting section until answered. Segments keep their identity unless a split cuts through a tools segment.runOutcomereadsinterruptedorerrorfrom the event row right after the run. It is web-only; the CLI draws steps as before.ChatEventsowns event wording, icons and event runs. It and the tool rows shareChatLog's column, row, glyph, native disclosure and output block, so neither renderer imports the other.ChatDiffowns syntax-coloured diff rows and expansion;ChatAttachmentsowns images, the shared lightbox and file downloads.MessageBlocksdraws a reply'sblockssegment (plugin message blocks, seam row 70 indocs/plugin-author/seam-catalog.md) as a card grid under the message: shadcnItemGroup/ItemwithItemMedia,ItemTitleandItemDescription, chips as the appBadge(neutralmuted, positivesuccess, warningwarning, dangerdanger, accentaccent), one column below a 32rem container and up to three from 48rem. Every string is a React text node. A 56pxobject-containthumbnail opens the sharedChatAttachmentslightbox; 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 liveblocksevent and the reloaded segment are the same stored entry, so a reload changes nothing on screen.ChatCardsowns the generic read-only card, folding, preview and memoized transcript extras. Empty or completed lists remain absent. PluginChatCardHost selects registered plugin renderers by opaque card id; interactive task controls belong only to that plugin renderer. Loading, absent and refused renderers use StaticCard. A renderer crash stays inside PluginErrorBoundary and displays its notice. Session identity, live state and the command-opening callback still reach the plugin; task rail lookup, addressability and ownership are unchanged.ChatComposeralone reads the draft context. Empty editable fields usebrainChat.placeholderShortbelow thesmviewport breakpoint and in the compact dock. The full variant renders an empty placeholder during server render and until the viewport is measured, then uses the slash-command hint fromsmupward. The same component serves delegated read-only views with their existing lock label and full accessible description. Fields stay at 16px belowmdand on coarse pointers, including landscape phones, to avoid iOS focus zoom, and autosizing still measures only typed content. File selection, clipboard files of any MIME type and drops on the composer call the sameBrainChatProvider.addFilesaction and resumable binary upload seam. Each upload queue captures its original session id.bindSessionowns all three authoritative parent binding changes: explicit start, stream snapshot and live session event. A changed id cancels pending transfers and clears staged references; reconnecting the same id and previewing its child keep them. Cancelled completions cannot attach a late result to the new conversation, and a rejected send restores its draft only while its original parent session remains bound, merging submitted and current attachments by stored path. The provider owns one shared text draft, not per-conversation drafts: a rejection after switching showsbrainChat.sendErrorPreviousConversation, leaves the current draft untouched and does not restore the previous message or attachments when returning. A locked composer suppresses file-drop navigation without starting an upload; ordinary text drops and pastes retain their native editing behavior.ChatConversationBarowns pickers, reasoning controls and rename;ChatFooterDockowns notices, statusline, uploads and queue. Both bar and footer useChatStatusline's single model-control ownership rule, including absent or collapsed statusline state.agentPresentationsupplies the pure steer description to both the agent table and tool rows. Cache-hit display importsweb/lib/displayFormatdirectly.
BrainChatProvider.sendMessage records the latest fresh send's submittedRun display identity before posting the request. Its awaitingEcho state reserves an empty run through transcriptBlocks; the authoritative user event binds that identity to the user echo id. The same AssistantRun key survives the pending shell, the echo, the first assistant output and settle, preserving its clock and the reader's disclosure choice. A queued steer reuses the existing live run without another shell; a fresh-conversation send ignores the previous conversation's working state. HTTP rejection, discard_user, a terminal frame before echo and conversation rebinding remove the pending shell. No optimistic user bubble or additional timer is introduced; reload restores the live run from snapshot control and the transcript, including a user turn still awaiting its first assistant step.
Shared general files download through GET /brain/chat-files/:file using the existing owned journal reference and administrator oversight rules. The route uses the standard range-parser and the existing byte-counted nodeResponseStream/requestScopedStream adapters to stream disk bytes with Content-Length, Accept-Ranges: bytes and single HTTP byte ranges for resumed downloads, including suffix and open-ended ranges; unsatisfiable byte ranges return 416. Malformed, unknown-unit and disjoint multi-range requests receive the complete response. ETags identify the immutable content address. Every response remains an opaque authenticated attachment with the original name, private caching and nosniff. The BFF already forwards Range and streams the response body with browser disconnect cancellation. nginx's 25m limit applies to request bodies, so these GET downloads do not cross it; conversation uploads retain their separate streaming location with client_max_body_size 0.
shapeBrainMessages and segmentsForTraces retain the ordinary ShareFile/ShareImage tool row and its separate payload, matching the live transcript on the web and CLI. Repeated explicit image deliveries retain each caption, even when a Read preview or another share uses the same stored bytes. Only repeated model-facing Read previews deduplicate by content reference. Successful shares and completed views carry deliveredAt, a host-recorded completion time in Unix microseconds: direct calls are stamped by the universal composed-tool wrapper after execution and any awaited plugin observer, nested calls immediately before their delivery event and durable trace settlement. For file/image shares, the trace budget reserves this metadata with the payload. Waiting views have no completion time; completed view events and history segments preserve it through the shared InteractiveViewDisplay wire type and the live fold. The importless canonical orderDeliveries in src/shared/chatPresentation.ts and its byte-pinned web/lib/chatPresentation.ts mirror order stamped payloads without moving tool rows or changing unrecorded historical positions. segmentsForTraces applies it within one reporting entry, and splitRun applies it to pinned deliveries after the joined web section settles so late results rewriting an earlier exec entry cannot move ahead of an earlier delivery reported by wait or a completed direct ShowView between that yield and the late settlement. The same wire field reaches live and historical CLI turns. The web's workRuns grouping is shared by the full chat, compact dock and child timelines; the CLI keeps its step rendering without the web disclosure.
Native checkpoint route refusals use the existing HTTP and typed chat-event feedback. typedRefusalMessage (used by apiErrorMessage) maps native_checkpoint_conversion_failed to the current document locale's core dictionary; SessionEvents maps the native-checkpoint-conversion notice and the same reason on model-switch-failed. All three cs/sk/en dictionaries explain that the original ChatGPT account and model must be available for readable conversion. The queued notice retains model/provider reconciliation through the existing picker event, and refusal preserves the transcript and prior selection.
The same mapping turns the 409 codes LOCAL_TARGET_OFFLINE and LOCAL_WORKDIR_MISSING into brainChat.localTargetOffline and brainChat.localWorkDirMissing. They replace the generic send failure in the chat composer's toast, plugin-picker and slash-command sends, and the dashboard composer's inline error, and they reach the conversation opening toasts and the new-conversation menu through apiErrorMessage. The draft stays in the composer. The Playwright fake daemon answers /brain/start and /brain/send with LOCAL_TARGET_OFFLINE for a local target that the seeded brain/execution listing marks connected: false (web/tests/e2e/specs/dashboard.offline-computer.e2e.ts).
shapeBrainMessages passes the journal row's ISO 8601 UTC created_at through as BrainMessageView.createdAt. Fresh and queued user echoes carry their announcement time as ISO-8601 UTC, so the live replay snapshot retains the run's clock before PI appends its user entry. Settled history uses the stored display time. This is display metadata only: PI entry timestamps and stored display/accounting time stay untouched. The web's work clock parses the same ISO timestamps as MessageMeta, which renders them through localDateTime; the group's final duration is the last joined terminal step's createdAt minus its opener's timestamp, including waiting gaps rather than summing native durationMs. The daemon stamps terminal display createdAt at native turn completion without rewriting provider-visible journal entries. Native durationMs still marks each turn boundary. The CLI receives the same normalized message view and keeps its existing shared time formatting.
AssistantRun draws one run in one turn shell. While the run is live (it is the last run after the person's newest message and the conversation is busy, its newest step is still streaming or daemon control reports workPending) a WorkSummary button shows the shared ShimmerText "Working…" with a useNow clock counted from the fresh send's recorded start, otherwise from the person's UTC message timestamp, or from the opener assistant step's UTC timestamp when no visible user message opened it. Delivery continuations retain the opener's React key, clock and disclosure choice, and the timeline is open: one chat-work__node per segment or event run, with no rule and no markers. Tool, reasoning and event steps sit one log step (1rem) in under the header; interim prose and message blocks (data-step text or blocks) start on the answer's edge, because streaming text is drawn as the visible tail and moves into the timeline only when a later work step follows it, so an indent would shift it sideways mid-stream; a running tool row shows its own icon. Once the run settles the button reads "Worked for 24s", "Stopped after …", "Failed after …" or, for history without a stored duration, "Work log", and the timeline folds closed in about a quarter of a second (MotionCollapse: height and opacity over the shared motionTransition; reduced effects fade only, off stops at once) while the label cross-fades (MotionFade); the reader's own toggle wins from then on and folds the same way, as on the reasoning block. The button is native and carries aria-expanded and aria-controls. While the timeline folds it keeps the current steps, so the closing answer that moves into the tail is drawn once; once the fold has finished the timeline is not in the DOM. A run loaded from history mounts already closed and does not animate. The waiting items and the visible tail follow it, and the meta line under a folded run omits the run time it already shows. A live run shows its disclosure even before the first work step; a settled run with no work steps draws no disclosure. Inside the timeline the log column drops its own indent so tool rows and event rows share the steps' left edge. web/tests/e2e/specs/chat.work-timeline.e2e.ts measures live, settled, reloaded and keyboard states, the step indent, the absence of rule and marker styling, and overflow at 320 and 1440 in both studio skins. Per animation frame it also checks that the closing answer is drawn once, that a fold passes through intermediate heights, and that effects off folds in a single frame.
For example, a streamed text delta reaches the root and its live run while settled message, run, segment, card, bar and editor memo boundaries stay unchanged: AssistantRun compares its turns one by one, and each SegmentView is memoized on its segment, so a settled step inside the live run is not drawn again. BrainChatProvider reads workPending from live control and atomic snapshot control into its event-rate status context. An idle frame switches off streaming only, preserving outstanding work until newer control arrives; neither path refetches history, and resync still reconnects for an atomic snapshot. Each native turn renders under a stable fragment so joining a delivery preserves settled segment DOM identities. sameRun retains its existing real prop comparisons. These local module boundaries add no plugin API, polling, persistence or extra clocks. chat.work-run-continuations.e2e.ts checks pending sends, delivery gaps, preserved tails, collapse, reload, independent turns, Stop and errors in cs/sk/en at desktop and mobile widths. 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 inner body's display: flow-root keeps child margins inside its measured scrollHeight, so a plain paragraph's margins cannot collapse outside the reported height and clip its text when the host resizes the frame. 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 the shared ConversationIcon renderer and its editable ConversationIconPicker, used by owned history, the admin register, chat headers, sidebar recent conversations and the assistant menu; 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.
settleTail(turns) replaces only a streaming assistant tail, clears its composing metadata and returns whether it changed. The marker, user and step folds use it before another turn displaces the tail; TranscriptModel uses the same function before appendLocalTurn and publishes exactly one tail mutation when changed. Empty, non-assistant and settled tails retain identity and return false. The operation visits only the tail and mutates the supplied turn array; it does not end overall thinking activity or persist journal data. transcriptFoldParity checks the browser fold and indexed TranscriptModel against the immutable canonical projection. ChatTurn.id is an optional source journal identity for stable browser keys and prepend deduplication. Durable user/event ids may arrive live; live assistant turns receive no stored id during settlement, and only assistant turns constructed from stored history carry one. Plans are read from ToolItem through submittedPlanOf, not inferred from assistant text. TranscriptModel exposes actual turns through turnAt and no longer maintains a parallel last-assistant-text cache.
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.
ChatComposer owns textarea sizing for both /chat and the advisor dock: an empty field uses one row, and nonempty text determines its border-box height. Deletions and replacements reset the textarea for measurement while holding its form's used height; the form is released synchronously after the final textarea height is written, so intermediate layout cannot expand the transcript and clamp a live scroll offset. The dashboard HomeComposer is a fixed two-row field and has no autosizing or transcript scrolling. BrainChatSurface.pinToNewest is the sole follow-newest writer. Both transcript scrollports disable native scroll anchoring (overflow-anchor: none), which otherwise moves the agents tail when editor growth reduces the scrollport, even when the surface deliberately preserves an idle reading anchor. Its transcript/content ResizeObserver owns layout-driven pinning: idle editor-dock growth preserves the reading anchor; during a live turn, editor growth follows newest so the checklist and agents tail stay above the composer between stream deltas. Keyboard geometry only publishes the surface inset, and floating-tile geometry only publishes its exclusion layout; neither writes scroll position. The resize observer gives changed visual viewport dimensions, viewport inset or keyboard-open state precedence over editor-dock resizing, so keyboard safe-area removal in the same delivery still follows newest. Explicit conversation entry and transcript changes use the same writer while the reader follows the newest turn, a prepended page included. Scrolling away releases following, including during a live turn, and only a reader-driven scroll moving down into the bottom zone resumes it, because a finger leaving the bottom crosses that zone first; from then on holdReadingAnchor is the sole position writer (see the history paragraph below). chat.composer-growth.e2e.ts types and deletes character by character at 320 and 390 pixels in real mobile contexts, with two visible agents, and measures scrollTop, a message anchor, the agents strip and composer bounds. Idle anchors stay fixed; live tails stay on the dock.
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 uses the mirrored localDateTime seam (src/shared/localDateTime.ts and web/lib/localDateTime.ts). 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.
BrainStatusService.streamSnapshot partitions the durable prefix and bounded live replay before history windowing. Replayed user.durableId values exclude their stored echo rows; replayed chat-event.event.id values exclude those exact journal marker rows. A delegated result is already settled when committed, even while its parent's assistant/tool rows remain provisional, so retaining its history copy would move the badge ahead of the user's active work and cause the renderer to discard its correctly ordered replay twin. Both web fromSnapshot and CLI TranscriptModel therefore receive it once, in replay order after the tools. Only marker ids actually present in the captured replay are excluded: older settled markers and markers evicted by replay truncation remain history. Exclusion happens before slot counting and visibility lookahead, preserving the normal nextBefore cursor and older-page reads. This is display partitioning only; the settled journal and commit-time delivery acknowledgement remain unchanged, with no extra query, timer or client queue.
The web history hook prepends older pages and fences results by connection generation and history epoch. Every newest-window replacement (a live or read-only stream snapshot, including a shared-controller GET reconnect, and the newest-page read behind terminal truncation repair and rewind) goes through its one replaceNewestWindow, which keeps the lazy-loaded older pages and their cursor when the pure keepOlderWindow in the shared transcript fold proves them valid: the replacement's first stored turn id is already on screen at index k > 0, so the turns in front of it are still its ancestors on the active branch, and no retained row still shows a running sub-agent or workflow that the replacement no longer anchors. Otherwise the window resets to the replacement's cursor. Every replacement bumps the history epoch, so an older or newest page read before it is discarded, also when the window was kept: such a page may still show work the replacement has settled, and the kept cursor reads it again. The decision reads the last rendered view outside the state updater; loadOlder renders each applied page synchronously (flushSync), so that view already holds every page the cursor has moved past. BrainChatSurface owns the one paging and anchoring mechanism, the fill-and-anchor pattern of mature chat transcripts (Element Web's ScrollPanel fills until a pixel threshold above the viewport is met and restores a tracked node's offset after every fill; the anchor selection mirrors the CSS Scroll Anchoring algorithm, which Safari does not implement). One rule, fillOlderBand: while older history remains and the history sentinel's bottom edge is within OLDER_BAND_SCREENS (three) measured scrollport heights above the scrollport, request the next page. The rule is asked on every scroll event, on every transcript commit, on scrollport and content resizes, when the sentinel mounts and when the reader lifts a halt. Exactly one page is in flight: olderRef.inFlight answers scroll events synchronously, and loadOlder hands a second surface the request already running. After the commit holds the reading anchor the rule is asked again, so pages whose steps fold into a collapsed run without adding height keep coming until the band holds content or the history is exhausted, while a stationary reader whose band is filled requests nothing. There are no input listeners for paging, no band-entry state and no per-gesture budgets. loadOlder(ready) fetches the page, awaits the surface's gate, re-checks the generation and epoch fences and only then prepends with flushSync. The gate is scrollSettle.ts: iOS decelerates toward an offset UIKit fixed when the finger lifted, so a scrollTop written while the finger is down or the momentum runs is overridden by the next animation tick, and a prepend needs exactly such a write. A touchstart on the transcript opens a touch session; the gate resolves once the finger is up and no reader-driven scroll event has arrived for SCROLL_SETTLE_MS (250 ms), including after a bounce at the top edge. Outside a touch session (wheel, keyboard, scrollbar, the desktop) it resolves at once, so a page lands as soon as it arrives. A fling that outruns the three-screen band therefore stops at the loaded top with the spinner, and the page already fetched appears above the reader the moment the scroller is still; it never moves the text the reader is on. A failed page shows brainChat.olderLoadError and halts paging; the sentinel slot shows a common.retry ghost button, the only way to ask again, so nothing retries on its own. Following the newest turn is read from position alone: a scroll event that is not the surface's own write releases following when it leaves the bottom zone and resumes it only on the way down into it; an offset landing exactly on the bottom is the browser clamping it after the scroller grew (the composer shrank while idle), which moves up and still follows. While the reader follows the newest turn every layout change pins to it. Otherwise the surface keeps the first visible innermost readable block inside the source segment as its reading anchor. A paragraph inside a list item takes precedence over its ancestor, whose top would not move when an image inside that item loads above the paragraph. BrainChatSurface reuses each run's React key through a stored step present in the preceding render, so prepending its opening message preserves the reader's disclosure choice. Its step-to-key map is replaced by the rendered window on each render, with no history retained outside that window. AssistantRun keys segment renderers and timeline rows by source step and segment, and exposes that identity as data-chat-anchor; split tool groups use their first source tool. The surface stores the surrounding block's data-tk, the segment identity, the descendant path and its top in scroll-content coordinates, cancelling the current scroll offset and UI zoom. Every transcript commit and content resize moves scrollTop by that target's layout movement before paint. This holds text when a prepend extends the same run, and when late media above the paragraph changes that segment's height; anchoring only the run shell would miss both. Growth below the reader does not move the anchor. A single turn's data-tk is its React key; a run's is its last stored step, preserving its reading identity when a page adds its opener. Native scroll anchoring stays off. 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; a failed web older-page read is reported and asked again only from the retry control in the sentinel slot. Live compacted and resync frames reconnect through the existing serialized stream controller for an atomic page/replay/control snapshot, on owned and read-only surfaces. The read-only tap retires stale callbacks before reopening. Compaction is announced outside an open agent run, but cold-start and native pre-prompt compaction can be followed by user admission and provider output before an independent history read returns; such a read must not replace the transcript. Snapshot replay never invokes the live reconnect handler, so idle manual compaction does not loop. Boot recovery uses the same snapshot path. 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.
The conversation list keeps its model mark icon-only and shows a separate, right-aligned two-line usage column at lg and above. Between lg and xl, supplemental running-agent and scheduled-job badges yield space to the title and usage; the existing branch/menu still exposes that work. A fixed desktop action track keeps model and usage columns aligned across owned and read-only rows. Both list routes carry cost alongside the existing tokens: transcript.sessionListing sums both in the same current-epoch brain_usage_session_totals query, with no journal decoding or per-row reads. These are the conversation's own totals, not descendant totals. Empty totals are zero; this tracked ledger does not distinguish unpriced usage from zero cost. Token sorting still uses the original tokens field.
useConversationListController reads the live measurement from the daemon's list payload, never from the open chat. Every row whose conversation is WORKING carries liveUsage — the measured effectiveTps of the current turn and the session's prompt-cache split — wherever that conversation runs: the web chat in another tab, the CLI, or a paired computer. The daemon projects it from the same sessionUsageSnapshot the open chat's status reports, so there is ONE measurement and ONE speed definition; a settled conversation carries no liveUsage and its row shows nothing. A working row shows its measured effectiveTps and cacheHitPct (cacheRead / (cacheRead + cacheWrite + input)); other rows have no fabricated live metrics. A delegated child whose turn runs in the sub-agent runner carries the measured speed alone — the daemon reads it from the run row and the prompt-cache split never crosses that boundary, so its row shows no cache share.
The list's own live channel carries the refresh: GET /brain/conversations invalidates the personal and the administrator's list when a conversation's durable activity changes, when a provider request settled and when a delegated child running in the sub-agent runner measured a new speed, so a working row measures itself as the turn runs. An ordinary account receives the invalidations of its own conversations only; an administrator also receives those of the conversations its All list shows. The controller opens no additional stream, timer or request, and the open chat in this tab is not a special case any more.
A turn settles once per provider response, so one working conversation produces a burst of invalidations. Each list therefore keeps ONE read in flight and reads once more when a change landed while that read was running: twenty settles cost two reads, not twenty. The read is never cancelled when it is superseded, so the list never waits for a request it no longer wants. formatTokens, formatCost(usd, 2) and the mirrored formatSpeed(tps, locale, 1) own display policy; the optional single decimal preserves other speed consumers' existing precision.
ConversationListPanel owns the typed sort key/direction; ConversationListToolbar presents the choices and conversationListModel.ts owns the comparator and section ordering. No table-column sort path remains. This state is ephemeral and adds no persistence or requests.
API-token creation mark in the register
POST /v1/agent/runs passes whether authentication supplied apiTokenId to BrainService.openRunConversation. Only the fresh-conversation branch carries startedViaApiToken through lifecycle, spawner and session factory to store.journal.createSession. The factory inserts brain_sessions.started_via_api_token in the same transaction as the session row. The shipped schema (packages/db/migrations/0001_baseline.sql) declares it bigint NOT NULL DEFAULT 0 with a 0/1 check, so old and ordinary conversations read as unattributed. Continuation, respawn, token revocation and token regeneration do not rewrite it; a new web fork starts with its own default attribution.
The store's session listing includes that column in its existing bounded row read. BrainStatusService.listManagedSessions projects 1 as ManagedSessionView.platform = "api"; a channel session keeps platformOfSession(id), and every other row takes the same ownerSurface as Mine, so both views mark every row. ConversationListRow draws that mark in a fixed 16px box directly before the title, in both views, using the existing PlatformIcon renderer, whose non-plugin glyph set maps api to KeyRound with the shared cs/sk/en dashboard.surfaces.api label and native tooltip. It never requests a plugin icon. List glyphs retain their existing native tooltip independently of the named open control. Marked rows retain kind: "conversation", so hosted-room attribution does not apply even with direct = false. Ownership, session ids, routing, CLI and usage origin remain governed by their existing seams. There is no per-row query, new subscription or stored token identity. The harness's apiRunConversation fixture exercises this register projection in nav.conversation-icons.e2e.ts.
Persisted conversation icons
The existing metadata seam PATCH /brain/sessions/:id accepts optional title and icon, requires at least one, and rejects unknown fields. icon is an empty string or a member of the single src/shared/categoryIconNames.ts allowlist, byte-mirrored to the web. The shipped schema declares brain_sessions.icon as text NOT NULL DEFAULT '', so an unset icon is an empty string rather than null. BrainService.renameSession retains the owned-user-conversation boundary and returns { id, title, icon }; store updates touch only supplied metadata. An icon-only edit leaves recency and the rename journal unchanged. Resetting to the default bubble stores an empty name.
BrainConversationIcon carries this identity in stored/listed sessions, managed rows, dashboard continue references and diagnostics. Daemon status identities always publish it; the general wire identity permits omission for the CLI's real unattached identity, and the CLI provides no icon editor. No second metadata endpoint, activity state, subscription or browser persistence is introduced.
All host conversation rows use the renderer documented in Shared UI. Editable controls appear in the sidebar/drawer, owned history and switcher, owned rows of the admin register, desktop assistant menu and full/compact chat headers. The host root conversation of a scheduled-task history branch uses that same control; plugin-owned task rows keep their task presentation. Foreign/channel register rows, dashboard continue pills and diagnostics render the same saved glyph without an editor. Read-only chat headers take their icon from the viewed stream identity through BrainChatProvider.conversationIcon, so a conversation absent from the owned listing retains its saved glyph. Partial provider/model events preserve this identity; the next session snapshot/status supplies its own icon. The launcher menu lists the conversations that are working, waiting for an answer or approval, or have an unread result or failure, except the one on screen. Choosing one opens it in the assistant dock. Each busy row has a Stop action that calls brainAbort and keeps the menu open. With no such conversations, the mascot opens the assistant directly. The existing phone layout keeps the mascot hidden and offers navigation/history/header controls instead.
Conversation event rows
Historical image-refusal and provider-timeout events were rewritten to generic error events when those notices were retired. 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 event rows read as one list. Event rows between two steps of one unfinished agent run are drawn inside that run's work timeline; event rows after a run sit between its folded summary and the next block, and the tool rows inside an opened timeline share their icon and label edges. 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 event rows, shared icon and label edges with the tool rows of the opened runs around them, 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 web/lib/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?, fractionDigits?, mode = 'standard'), contextMeter(percent, mode = 'clamped'), 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; the default context mode validates finite readings and clamps them to 0–100. ChatDeckHero and ChatFooterDock select contextMeter(percent, 'raw').label behind their existing null guards, preserving unbounded rounded labels (including NaN/Infinity), surrounding token text and DOM. An unknown standard speed has a dash, a positive sub-unit sample stays positive. CLI sub-agent/workflow metadata selects the formatter's compact fourth argument for the existing whole-number t/s presentation; browser consumers and the public three-argument plugin utility remain standard.
The formatter never reinterprets a settled rate as an idle activity estimate. Workflow cards, node
details, the web chat statusline and the stats modal call the same formatSpeed(value, locale) with the UI locale, 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.
While a conversation is busy, the web chat refreshes its usage with a /brain/status read every 3 seconds (useBrainChatUsage in web/modules/advisor/brainChatUsage.ts). The timer runs only in a visible tab with no other session on screen, and streamed tokens do not restart it.
Quota fetching stays in UsageService on the daemon's schedule; the web's useBrainRateLimitsAll query does not poll but is invalidated by the daemon's provider-usage event and whenever the core event stream opens (useElowenEvents). 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 reads also apply 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/modelUsageSummary.ts owns descending model sorting,
maximum token count, token/cache totals, known-cost totals and usage presence. Its importless,
byte-pinned web/lib/modelUsageSummary.ts mirror feeds buildUsageSummary in web/lib/usageBars.ts,
used by the chat StatsModal and dashboard MetricsTile; the CLI stats overlay imports the core source.
It copies the array before sorting, retains row objects, and costs O(n log n) time.
Empty input returns an empty model list, zero token/cache totals, a maximum of one, null cost and
false usage presence. Known costs are summed; unknown costs do not contribute, and an entirely
unpriced ledger stays null. A cost-only row still counts as usage.
This projection neither measures speed nor reads or persists data. src/brain/session/effectiveTiming.ts
owns measured request and turn timing, recorded through the canonical ProviderRequestRecorder.
Its pure speedOf and addSpeedSample arithmetic lives in src/shared/effectiveSpeed.ts;
brain events consume the effective timing rather than estimating speed from the model ledger.
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.
Both bundled rails consume them and refuse to mount on a host below their declared API level (Terminal 63,
Subagent 66). 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. ConversationListPanel 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 Options 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.
Agent presentation
web/modules/advisor/agentPresentation.tsowns pure agent presentation:steerDescription(agent, t.agents)supplies the steer description to the agent cards and tool rows, anddeliveryLabels(agent, t.agents)suppliesdeliveryMethodanddeliveryStateto the agent cards inAgentsModal. Call it while rendering with the current locale's labels. Automatic delivery takes precedence; otherwise background work is manual and foreground work is inline. Only pending and acknowledged results add a delivery-state label; an absent or other result state returns undefined, so callers omit that line. These synchronous helpers perform no I/O or state changes and leave layout, tooltips and time formatting to their consumers. Cache-hit display importsweb/lib/displayFormatdirectly.
The live sub-agent row
The running sub-agents of a conversation are one rail section in both densities: the open rail draws the
rows, and the collapsed strip opens the same rows in a popover (RailMenu). The rows come from the
SubagentState the daemon already publishes for the conversation's own delegated children, so nothing
here reads a second source. Both densities draw the same row: the same status mark (the rail's own
RailDot, breathing while the run goes on), the same model's mark, the same name, the same figures and
the same stop control. The popover is the rail at its widest (RAIL_MAX_WIDTH), because a live row
carries a name, a model's mark, the run's note, its elapsed time, its speed and a stop control, and at
the rail's default width the name itself would be cut.
A row with a status note takes two lines (RailRow's note). The first line carries the status mark,
the model's mark, the name and the row's trailing meta; the second carries the note itself, muted, one
line deep, and inset so that it sits under the NAME and not under the model's mark. A row without a
note stays one line, like every other row of the rail. The name is the row's identity and is never cut
to a stub: the rail's default width is 340px, of which a row's own content is 308px, and the name, the
model's mark, the elapsed time, the speed and the stop button do not all fit on one line of it. The
trailing meta column therefore keeps the elapsed time — the clock the reader watches — and the speed
and the token count ride the end of the note's line, where they still read as the row's own figures.
A one-line row keeps every figure in the trailing meta column, the rail's house contract.
A row's name and a model's mark are hover targets, and the row's button stretches its own click area
over the whole row so that a click anywhere opens the child. That stretched area is painted above the
button's content, so the two targets are lifted above it (relative z-10) — otherwise Chromium
hit-tests the stretched area and the pointer never reaches the mark or the name. The lift changes
nothing else: the row still opens the child on a click, and the targets keep their own keyboard focus
and accessible names.
A row carries four facts, and each one has exactly one authority:
- the elapsed time is counted from the run's own
runStartedAt, the delegated call's stamp, on the shared visible-tab clock (useNow). A settled row shows its ownsecondsinstead; the clock ticks only while something runs. - the generation speed is the daemon's measured
effectiveTps— the samesrc/shared/effectiveSpeed.tsaggregate every other surface renders — formatted by the host's oneformatSpeed. A run with no measured request shows no speed, never an estimate. - the last status note is the run's
detail, the child's current tool or its authored status note. - the model mark is the child's own model, drawn with the shared
ModelIconand named on hover by the sharedMarkStrip; the name itself opens aHoverCardwith the delegation brief, which opens only after the pointer rests and stays bounded and scrollable. The row carries no nativetitle, so the card is the only tip and the browser's own tooltip never paints over it.
The stop control on a running row is the host's own child stop: POST /brain/abort with the child's
session id, the route the drilled-in child's Stop button and the CLI use. The daemon resolves the child
through the durable ancestry of an owner conversation, so another account's child is refused there, and
the run, its turns and its own sub-agents come down together. The row offers no stop for a settled
child, and the web adds no stop mechanism of its own.
A running workflow's row is drawn the same way, from the WorkflowState the rail already receives. Its
model marks are the distinct models of the nodes running now (a node's effectiveModel over its
declared model), stacked by the same MarkStrip; the name is workflowLabel, with the full label on
the HoverCard instead of a native title. The note names the running nodes by id, or reads as waiting
when none runs, and its line ends with the progress (done/total from workflowCounts), the failed
node count when there is one, the summed effectiveTps of the running nodes and the workflow's token
total. The trailing meta is the run's elapsed from workflowElapsedSeconds, the same headline both
workflow inspectors show. The stop is the plugin's own POST /brain/workflows/stop, the route the web
inspector's Stop button uses. The note's inset follows the width of the mark stack, so it stays under
the name whichever number of marks precedes it.
Stream watchdog defaults
DEFAULT_STREAM_SILENCE derives limitMs and reviveLimitMs from RUNTIME_LIMIT_DEFAULTS.streamSilenceLimitMs and streamReviveSilenceLimitMs. resolveStreamSilence uses these values before configured values arrive and retains the heartbeat floor from RUNTIME_LIMIT_BOUNDS. startStreamWatchdog measures wall-clock silence, skips hidden pages and returns the stop callback. BrainChatProvider resolves this pair from config.runtime.limits for its live conversation stream, without a separate setting or timer owner.
Advisor statistics
Advisor statistics rendering is owned by web/modules/advisor/StatsModal.tsx. Its private StatTile renders the existing label and value spans for conversation metrics, model totals and context-window figures. Pass a translated label, an optional Lucide icon and the already formatted value as children. Tiles preserve the existing muted background, borders and typography; omitting the icon preserves the plain context-label style. Missing usage values retain the caller's unavailable marker, and an absent context breakdown retains the modal's empty state. This component performs no fetching or accounting and is not exported as a shared UI primitive. StatsModal is its only consumer, with the model row remaining separate.
Workflow DAG layout
Delegation runStartedAt is the current call's epoch-ms anchor, separate from the child creation timestamp. AgentsModal 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 uses these helpers for trimmed titles, first-task fallback and done/total counts, and API 67 adds workflowElapsedSeconds so the rail's workflow row shows the same run elapsed as the inspectors; its manifest requires API 67. 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.
The advisor workflow presentation keeps node-status display metadata in NODE_STATUS in web/modules/advisor/workflow/WorkflowNodeCard.tsx. Each supported status supplies its icon, tone, classes and translated label key. WorkflowNodeCard resolves t[labelKey] for its accessible name, and WorkflowNodePanel resolves the same key for its status badge. The metadata is internal to the advisor surface; callers use the existing workflowModal dictionary and the typed pending, running, done and error statuses. WorkflowGraph derives each routed edge's SVG path and live state once per render and reuses them for the edge and its energy animation. Energy is emitted only between a done source and a running target; without such edges there are no energy circles. Path layers remain ahead of energy layers. These render-time calculations add no I/O and leave layout and routing with the existing workflowLayout owner. WorkflowModal is the real consumer on desktop, while the phone wave list uses the same node-card status metadata.
The pagehide handler separately checks persisted and releases bindings with detachOnly. A bfcache-ineligible page may report persisted false during a phone lock; work-in-flight refusal and the daemon idle reaper remain authoritative.
Conversation names and pins
ChatStart offers an optional name above the greeting for a new, editable conversation absent from
the confirmed owned-session listing. BrainChatProvider owns this draft by session id, so dock and page
surfaces share it and switching conversations cannot apply it to another conversation. The first
brainSend(text, display, binding, title) carries a nonempty name as POST /brain/send.title; no name
is written on field edits or merely opening a conversation. Failed sends retain the name and message.
The daemon shares normalization (normalizeConversationTitle in src/brain/conversationTitler.ts,
whose manual mode rejects an empty title for both paths) and storage with the existing rename path
and commits the name on the first-message admission transaction. A manual-title flag prevents later
automatic replacement; an omitted name keeps automatic titling.
ConversationListPanel offers Pin conversation and Unpin conversation in the existing row action
menu. brainSetSessionPinned(id, pinned) sends PATCH /brain/sessions/:id/pin with { pinned: boolean }
and returns { id, pinnedAt: string | null }. Core accepts only the caller's own ordinary conversations;
foreign, channel and absent ids return 404. Pending rows disable their pin action, failed writes show
a toast, and success invalidates the shared brain-sessions query. Core also publishes the existing
owner-scoped conversation-list invalidation so other devices refresh. There is no browser pin store
or pin limit. The sidebar filters that same listing by pinnedAt and orders newest pins first with id
as a stable tie-break. With no pins the Chat row has no submenu.
New conversation start
A conversation with no transcript blocks opens on ChatStart (web/modules/advisor/ChatStart.tsx) once its history is confirmed empty; before that the surface shows the loading state. It repeats the dashboard hero: the greeting and ask over prompt cards. web/lib/heroCopy.ts is the one source both surfaces read. recapVariants(recap) expands the day's digest batch, with missing per-variant hero fields inherited from the top level; heroGreeting returns the agent-written greeting or the reader's time-of-day line with the first name, and null until the browser clock is known; staticHeroPrompts holds the static prompt seeds. The dashboard rotates through the variants (useRecapRotation) and adds its own costs and setup pills. The chat start reads the first variant through the same useDashRecap query, seeded on /chat by the route's server prefetch (fetchDashRecap, as on /dash) so the greeting is right on the first frame, and draws at most four shadcn Cards (chat icon and label as the CardTitle, the prompt as the CardDescription; the title's button stretches over the card as its one control). The greeting takes the dashboard's text-display role on the full page and text-page-title in the dock. Below the md breakpoint the cards are hidden and the greeting and ask are centred. The cards hold today's agent-written pills, else the summary, plan, find and capabilities seeds. A card only seeds the composer through openBrainComposer, so the text can be edited before sending. The recap query is the dashboard's POST /dash/recap, so opening an empty conversation may start the day's digest exactly as opening the dashboard does; a disabled recap answers { enabled: false } and the static copy stays.
Advisor session-open request
openBrainSession(sessionId, continuable, delegated = false) stores one BrainOpenRequest across the dock mount gap and dispatches the live event. It contains sessionId, continuable and delegated only when true; openBrainSessionPage adds page true. No request name exists. consumePendingBrainSession returns null when absent and clears a request once consumed. BrainChatProvider opens delegated requests read-only without a pre-supplied name, even when continuable. focusSubagentSession(sessionId, name) retains actual rail child naming. The register's separate child tree consumes delegated opens; its inline rows follow conversation ancestry and schedule groups.
Conversation history selection
ConversationListPanel keeps selection by conversation id, limited to direct matching roots on the current page and excluding rename editors and context-only ancestors. Select explicitly reveals checkboxes on touch as well as desktop. Search, facets, sort, direction or page changes clear it; refetches prune missing ids. The shared SelectionActionBar offers Clear, sequential HTML/JSONL export and confirmed Delete. Export first explains separate browser downloads and reports requests initiated, never files saved. No host DataTable selection path remains.
Bulk delete has no endpoint. It calls the controller's per-id delete one conversation at a time, with the open conversation last, so the per-id ownership check stays the only authority. A failed batch shows one error toast: a failure message when nothing was deleted, or a done and failed count on a partial run. Only successful deletions leave the selection, so failed rows stay picked for a retry.
Replay and tool-list projections
LiveEventReplay and appendReplayBrainEvent share one private pure fold-target computation for adjacent text/reasoning deltas and keyed snapshot-state replacements. User, step and tool events remain ordering boundaries. LiveEventReplay keeps sequence stamping, replacement character deltas and the appendedChars optimization, trimming at 512 events or 524288 serialized characters. The array appender keeps its caller's event-count limit and returns true only when an oldest entry was dropped. HeadlessSnapshotReconciler is a real array consumer; the live session journal is the other consumer. With no events the buffers stay empty; neither path reads durable history or changes the public event JSON contract.
GET /users/:id/tools returns name, label, plugin, group, state and toggleable. Its admin authorization, account/plugin grants and family-pattern rules are unchanged. The Users editor derives its monochrome glyph from the tool name and receives no icon field. An empty result uses the existing empty state. CLI/web ToolItem projections likewise retain tool execution data without icon; their renderers derive name-based markers. BrainEvent.icon remains the live channel icon contract: spawnEventReducer resolves it, and shared liveMessage/liveTrace retain and render it for chat adapters, with their existing generic fallback when no icon matches. The plugin tools endpoint and plugin manifest icon resolver remain unchanged.