Chat Transcript and Turns
August 30, 2026 · View on GitHub
The transcript is a JSON-lines stream of AgentChatEventEnvelope records.
Everything the renderer draws (messages, tool calls, commands, file
changes, plans, pending inputs, turn dividers) is derived from this one
stream. Sessions persist the stream to disk so they survive restarts.
Event envelope
type AgentChatEventEnvelope = {
sessionId: string;
timestamp: string;
event: AgentChatEvent;
sequence?: number;
provenance?: {
messageId?: string;
providerMessageId?: string;
providerParentAgentId?: string | null;
providerOrigin?: string | null;
providerSupersedes?: string[];
providerRetractedMessageIds?: string[];
threadId?: string | null;
role?: "user" | "orchestrator" | "worker" | "agent" | null;
targetKind?: string | null;
sourceSessionId?: string | null;
attemptId?: string | null;
stepKey?: string | null;
laneId?: string | null;
runId?: string | null;
};
};
Type definitions live in apps/desktop/src/shared/types/chat.ts. The
envelope carries transport metadata; the actual payload is the
discriminated AgentChatEvent union.
provenance is populated for delegated worker chat, where messages can
originate from orchestrator, worker, or user threads and must be routed
back to the correct activity feed.
Canonical assistant text (fragile — read before editing)
chat.getTranscript — plus the cursor-paged chat-history reader and the
internal readTranscriptEntries used by auto-title and handoff — flattens the
envelope stream into role-tagged entries via transcriptEntriesFromEnvelopes in
apps/desktop/src/main/services/chat/chatTranscriptEntries.ts. Clients that
hold both the live fragment stream and this canonical text (iOS, the web
client) reconcile the two, so the module owes them one invariant:
Canonical text is byte-identical to what a renderer draws from the same envelopes. ADE never invents a character.
That is stronger than "don't corrupt text", and it is the property that matters: a client holding both renditions can only reconcile them if they agree. The moment canonical groups or joins differently from a renderer, the two are no longer deltas of each other and the client concatenates them — rendering the message twice.
So canonical mirrors the renderer exactly:
- Entries group on
messageId(else the turn), because that is what every renderer keys on. Desktop'sshouldMergeTextRowscomparesmessageIdand ignoresitemId; iOS collapses a text event onto itsmessageIdinworkAssistantMessageStableId. Grouping more finely — for example splitting on theitemIdCodex advances per provider message — makes a client silently concatenate two entries it keyed the same. - Identified fragments concatenate verbatim, whatever interleaves, because
the renderer joins merged rows with a bare
${previous}${next}. ADE has no block-level identity it can trust, so guessing a boundary from interleaved events is what spliced"\n\n"into the middle of a word ("no new mod"+"ifier chain needed:").
Only when ADE cannot tie a fragment to a provider message does it fall back to
inferring boundaries from interleaved events. Ephemeral chrome — activity
hints, live context_usage, token counters — is invisible to every renderer, so
letting it break a run made the canonical text disagree with what desktop, the
TUI, and iOS actually draw. isTranscriptContentEvent is an allowlist of
content types on purpose: a new event type defaults to "does not break the
run", which at worst drops a paragraph break, whereas the inverse default
corrupts words. Keep genuinely rendered rows (todo_update, the subagent_*
cards, ade_card, done) in that list — they are not chrome.
A user message clears every open entry, so a stream key reused in a later turn cannot merge backwards into text that preceded the user.
A whitespace-only fragment (a word gap, or a markdown hard break " \n") is a
real delta and is dropped only when there is no run for it to continue.
Because canonical and rendered text agree, clients need no reconciliation
heuristic of their own. iOS merges every assistant fragment through
mergeWorkStreamingText regardless of where the envelope came from. An earlier
attempt to add a client-side guard for whole-message rows had to be removed: it
could not tell a complete message from one chunk of a paged canonical fetch
(getChatTranscriptPage can split a message at an envelope boundary), so it
dropped the other chunk. Keep the agreement on the host side; do not reintroduce
a client rule that has to guess what a sequence-less envelope contains.
Parsing
parseAgentChatTranscript(raw) in
apps/desktop/src/shared/chatTranscript.ts is the canonical parser. It
tolerates malformed lines (silently skips), normalises missing
timestamps to Date.now(), and only passes through envelopes with a
non-empty sessionId and a non-null event object.
The parser is used both in the main process (for persisted state replay, recovery, and auto-title generation) and the renderer (for transcript-derived summaries in session cards).
Two helpers summarise a parsed stream:
hasMaterialWorkerChatEvent(events)-- returns true when any event type in{ text, reasoning, tool_call, tool_result, command, file_change }is present. Used to gate worker-chat activity badges.hasWorkerChatLifecycleEvent(events)-- returns true when any event other thanuser_messageis present.deriveAgentChatTranscriptSummary(events, maxChars = 280)-- returns the last text/reasoning/error/status message, compacted to a single line.
The event union
AgentChatEvent is a discriminated union defined at
apps/desktop/src/shared/types/chat.ts. Major members:
| Type | Purpose |
|---|---|
user_message | A user turn; carries text, attachments, turnId, optional steerId and deliveryState. deliveryState is "queued" while a steer waits for turn-end delivery, "delivered" once flushed at turn boundary, "inline" when the user inline-dispatched a queued steer into the active Claude turn (SDK shouldQuery:false send), and "failed" if dispatch errored. |
text | Streaming assistant text; identified by messageId (preferred) or turn/item identity. Fragments merge when shouldMergeTextRows() returns true. |
transcript_retraction | Provider-level retraction signal. Claude emits this for refusal fallback retracted_message_uuids and assistant supersedes; renderers remove prior assistant text rows whose messageId matches messageIds, optionally retaining replacementMessageId as the new provider message id. The persisted JSONL remains append-only. |
reasoning | Chain-of-thought or assistant-internal reasoning; surfaces as a distinct transcript row with a collapsible header. |
tool_call / tool_result | Paired per tool invocation; rendered inside work-log groups. tool_result.status can be running, completed, failed, or interrupted. Claude SDK tool_result_meta is retained as optional toolResultMeta, and the provider's raw payload as optional structured; both are local-only debug material — they are bounded on disk and stripped from the sync wire, because no renderer, TUI, web, or iOS client decodes either. Provider-native MCP calls retain mcp: AgentChatMcpToolSource (server, tool, optional plugin/resource/app context) so transcript labels, the TUI/iOS, and Sources use the connector identity instead of a generic tool name. |
file_change | Emitted when the agent writes or deletes a file; carries path, diff, and kind. |
command | A shell command invocation; carries cwd, output, exitCode, durationMs. |
plan | Final plan payload (steps + explanation); replaces any earlier plan_text rows for that turn. |
plan_text | Streaming plan fragments; merged via shouldMergePlanTextRows(). |
approval_request | Legacy approval; newer code emits an embedded PendingInputRequest via detail. |
structured_question | Claude SDK AskUserQuestion tool surface. |
pending_input_resolved | Hidden row; consumed by pending-input derivation to clear UI state. |
status | Turn-level lifecycle: started, completed, interrupted, failed. |
done | Final turn marker with model, model id, usage, cost, and optional open-string terminalReason. Claude may also carry canonicalModel, modelProvider, apiErrorStatus, fastModeDisabledReason, userMessageUuid, and requestSentWallMs; these preserve billing/provider provenance, terminal HTTP class, Fast fallback cause, and request correlation/latency metadata without exposing raw provider envelopes. Failed/interrupted dividers translate known reasons into a short explanation; completed turns omit the reason. Also clears non-question pending inputs when status is not completed. |
error | Provider/runtime failure with message, detail, and semantic errorInfo. Codex can report the same terminal failure first as an app-server error notification and again on failed turn/completed; ADE keeps one visible row for the same turn/error identity while preserving distinct failures. |
activity | Ephemeral UI hint (thinking, searching, running_command). Hidden from the transcript. |
todo_update | Task-list snapshot; consumed by ChatTasksPanel. |
subagent_started / subagent_progress / subagent_result | Legacy Claude background subagent lifecycle. Each envelope carries taskId, parentToolUseId, description, and optional agentId, parentAgentId, agentType, and providerSessionId; Claude start rows bind the native child to the owning Claude session so transcript drill-in never mistakes the ADE chat id for a provider session id. For Claude / ade-code agentType is the Task tool's subagent_type (stashed at the tool_use boundary and joined on parentToolUseId); for Codex parallel agents it is a per-turn Agent #N label assigned at first announcement and the raw threadId is mirrored as agentId; for OpenCode subagents agentType is omitted so the row falls back to the description (taken from session.title). Codex app-server subAgentActivity items also flow into these rows and may carry label, model, and reasoningEffort for richer roster labels. Claude SDK runs also stash taskType (subagent / background / local_workflow / cron / other) and workflowName at spawn so the renderer can label rows by workflow without re-deriving them per event; ambient/housekeeping tasks (the SDK's skip_transcript=true flag — e.g. session-title generation) and plain Claude Code task runs (task_type other with no agent metadata, e.g. "Re-run affected test files") are both tracked only for cleanup and filtered out symmetrically across spawn, progress, and completion notifications so the subagent panel never flashes them, while a backgrounded Bash shell (task_type local_bash/background) is routed to the background pane rather than the roster. Every subagent_result is gated on a recorded subagent_started (emittedSubagentStartIds), so an interrupt cannot emit a phantom stopped card for a subagent that never announced; terminal events clear both the taskId and agentId aliases. The service also emits canonical subagent.started / subagent.progress / subagent.completed rows from runtimeEvents.ts so all runtimes can converge on the same envelope. Two additional producers fan into the same three event types: Claude Workflow runs — the SDK's undocumented workflow_progress snapshot on system:task_progress is normalized by claudeWorkflowProgress.ts (defensive: malformed entries dropped, previews clipped, counts capped, unknown states degrade to queued/running; an unparseable snapshot leaves the generic task rendering untouched) and diffed per tick into started/progress/result transitions under a stable <taskId>::a<index> / latched-agentId identity, so each workflow agent renders as its own row with phase, tokens, and duration, reconnects upsert instead of duplicating, and agents left running when the workflow ends are closed out as stopped; and child chat spawns — a session created with orchestrationParentSessionId outside an orchestration run (e.g. ade chat create from a tracked agent shell) emits synthetic subagent_started/subagent_result events keyed chat:<childSessionId> into the parent so the child lists in the parent's subagents panel, its first finished turn reporting completed/failed/stopped. |
scheduled_work_update | Scheduled/background-work lifecycle snapshot. ADE emits it for provider-neutral action schedules and Claude ScheduleWakeup, CronCreate, CronDelete, /loop/hook snapshots, remote triggers, cron/background task lifecycle messages, and durable scheduler transitions. It carries kind (wakeup, cron, loop, remote_trigger, background_task), status (scheduled, paused, running, fired, missed, completed, cancelled, failed, stopped), provenance ids, optional cron/prompt/reason/timestamps, firedAt, late, and durable; shared/chatScheduledWork.ts folds it into active/history Chat Info rows on desktop, ADE Code, and iOS. background_task is additionally how the live Claude runtime reports a backgrounded shell command — it emits no subagent lifecycle events for one — so on desktop those snapshots also drive the in-thread background_job_line; other kinds bundle as activity. Parent turn completion does not imply background completion, and background_task snapshots whose sourceTaskId belongs to a real subagent are omitted from the Background roster (and from the job line) to avoid duplicate Agent rows. One-shots progress through scheduled -> fired -> completed; crons record the fire and return to scheduled with lastRunAt plus their next occurrence. |
tool_use_start / tool_use_complete / tool_use_summary | Claude SDK tool lifecycle tracking (see Claude tool-use tracking). |
step_boundary | Workflow step boundary marker. |
system_notice | Non-transcript chrome: auth errors, rate limits, and file persistence hints. Special-cased renders: the "Promoted to Cursor Cloud" pill, and the status:"subagent_spawned" chip (emitted into the parent when a child chat session is created with a parent lineage; detail.spawnedSession carries the child sessionId/laneId/title and the chip deep-links via ade:work:select-session; the TUI shows the message line; iOS renders it through its existing system_notice mapping). |
conversation_reset | Marks a fresh Claude conversation inside the same ADE session. ADE adopts newConversationId as the next SDK resume pointer, clears conversation-scoped auto-title/continuity caches while preserving a manual title, and renderers show a New conversation divider. |
interrupt_receipt | Records SDK UUIDs that remain queued or were cancelled after an interrupt, plus the selected stopMode. Clients show the full remaining count; ADE-attributed messages include their steerId and offer cancellation through the SDK control channel. The long-lived query remains attached while messages are still queued so the receipt stays actionable. |
queue_recovery | Bounded recovery lifecycle for Claude Stop & clear queue: available renders one Undo card for the actually cancelled ADE-attributed messages, restored rehydrates the original steer payloads/ids, and expired closes the eight-second window. Terminal recovery events suppress the earlier available card during replay. |
command_lifecycle | Ground-truth lifecycle for ADE-owned Claude messages (queued, started, completed, cancelled, discarded). ADE ignores internal UUIDs it cannot attribute, deduplicates repeated states, clears staged composer rows once execution begins, and renders only cancelled/discarded terminal anomalies to keep transcripts quiet. |
claude_goal_updated / claude_goal_cleared | Read-only Claude /goal lifecycle. The update carries the condition, iteration count, token baseline, timestamps, and optional last reason; the session summary mirrors the latest value as claudeGoal. |
session_meta_updated | Runtime-native session metadata update. Carries title / manual-name state, and — when a client changes the session's mode via updateSession — the permission/interaction mode fields (permissionMode, interactionMode, claudePermissionMode, codexApprovalPolicy/codexSandbox/codexConfigSource, opencodePermissionMode, droidPermissionMode, cursorModeId, cursorModeSnapshot). The renderer treats it as a local-touch event so Work lists and grid tiles refresh when a provider renames a session, patches the session summary with any mode fields present, and re-seeds the selected chat's composer mode controls so a mode change on another client (desktop ↔ iOS) shows up live without a refetch. All mode fields are optional; a title-only emit carries none of them. |
completion_report | Structured closeout produced by the reportCompletion workflow tool. |
turn_diff_summary | Git-level before/after SHA + per-file stats for a completed turn. |
delegation_state | Delegated worker state updates. |
context_usage | Provider-neutral context occupancy. Automatic Claude samples use origin: "live" | "snapshot" | "compact" and are filtered out of the transcript; live is the responsive stream estimate, while snapshot/compact come from the SDK control channel after initialization, settled turns, and compact completion. state is measured, compacting, recalculating, or unknown; non-measured states deliberately hide the old percentage. Monotonic sampleId plus capturedAt support stale-response rejection and diagnostics. The user-invoked /context command carries origin: "command" (historical undefined-origin snapshots are treated the same) and still renders its inline breakdown card. Optional typed fields (inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens) carry the breakdown the meter's hover shows without reparsing the display categories. |
context_compact | Provider-neutral manual/automatic compaction lifecycle. state: "started" begins the boundary and state: "completed" may carry preTokens, postTokens, tokensRemoved, durationMs, provider, and per-session count. trigger: "ade_fallback" identifies ADE's guarded fallback. A completed boundary invalidates older context-meter usage on desktop, ADE Code, and iOS; exact post-compaction snapshots may refill the meter immediately, while stale same-turn aggregate counters are ignored. |
web_search | Provider-neutral web-search/fetch lifecycle; renderers group these with other tool calls instead of showing them as standalone event cards. Actions can carry query, queries, title, url, and snippet; desktop and iOS render URL actions as in-app-browser result chips, while the TUI keeps a concise one-line action summary. Codex 0.145 additionally emits structured results (an array of { url, title, snippet } capped at 8 by the adapter) plus resultsTotal (the pre-cap hit count). Renderers thread these onto the same grouped row — desktop/iOS surface them as Sources chips (deduped against the action URLs) and the Sources tab, and the TUI shows up to three title — domain preview lines with a +N more tail. Codex emits native web-search items; claudeStructuredActivity.ts maps Claude server-tool blocks into the same event. |
codex_image_generation / codex_image_view | Compact generated/viewed-image lifecycle used across providers despite the legacy type prefix. Codex emits native image items, Cursor maps generateImage, OpenCode maps image file parts, and Droid maps assistant image blocks. Large stored data URIs are removed with original/omitted byte metadata. |
codex_safety_buffering / codex_moderation_metadata / codex_sleep / codex_thread_deleted / codex_turn_stalled | Codex app-server runtime state. Safety buffering, moderation metadata, and sleep are compact status rows; codex_thread_deleted clears the stored upstream thread; codex_turn_stalled is the structured recovery event shown when a turn produced no useful output after app-server reconciliation. Its actions are wait, steer, interrupt_retry_same_thread, and restart_resume_thread. |
auto_approval_review | When auto-approval policy kicks in, this event carries the review text. |
prompt_suggestion | Suggested follow-up prompts for the user. |
Subagent model attribution
Subagent lifecycle rows may carry the child model and reasoningEffort. For
Claude, the service resolves the model from the task lifecycle frame or the
native Agent/Task tool input, including tool input that arrives after the
lifecycle frame. If a start row was already emitted with inherited parent
metadata, the late input updates the live snapshot and emits a corrected start
row so the renderer's model chip reflects the child that actually ran.
Claude getSubagentTranscript reads also attach subagentMetadata to the
provider message shape with the child thread id, parent thread id, and the
first model found across the historical/current SDK message shapes. The
renderer treats a reported model as authoritative and shows the parent model
as inherited only when no child model was reported.
Claude context guardrails
Live Claude occupancy emits at most once every five seconds and only after a
one-point percentage change. ADE never preempts the SDK's natural compaction.
The streamed reading is refreshed from authoritative SDK getContextUsage()
snapshots after runtime initialization, every settled turn, and compact
completion. A compact start emits compacting; completion emits
recalculating until exact postTokens or the control snapshot establishes the
new measured value. A failed snapshot emits unknown, so an old 100% is never
presented as fresh.
It sends a fallback /compact only at a turn boundary when all three gates
hold: occupancy is at least 97%, no natural context_compact has appeared
since occupancy crossed 90%, and no fallback was already issued in the same
high-water episode. The episode resets below 80%.
If a turn ends with terminalReason: "prompt_too_long" (or the equivalent
overflow signature), ADE compacts once and asks the user to re-send the last
message. It does not automatically replay that message.
Canonical runtime events
apps/desktop/src/main/services/chat/runtimeEvents.ts defines the
provider-neutral event vocabulary that runtime adapters should emit
internally: turn.started, content.delta, tool.started,
tool.completed, tool.failed, subagent.started,
subagent.progress, subagent.completed, teammate.idle,
task.completed, turn.completed, and compact.boundary.
The current migration is additive. Claude still emits the legacy
underscore subagent rows used by older renderer paths, then
buildCanonicalAgentChatRuntimeEvent() writes the canonical dotted
subagent row beside it. AgentChatPane filters the dotted rows from
the transcript display because they are coordination data, while
subagent-specific panels can consume either shape during the transition.
Structured activity normalization
Adapters preserve provider richness while converging on compact event shapes:
- Claude server
web_search/web_fetchblocks becomeweb_searchstart and terminal events. Claude MCP blocks become paired tool events; unfinished server activities are closed when the turn ends. - Codex 0.144.5
mcpToolCallitems retain plugin/app/resource metadata, while native web/image/subagent items keep their specialized compact rows. - Cursor MCP calls and generated images, OpenCode image file parts, and Droid assistant image blocks reuse those same tool/image events.
Every event uses the provider item id (plus turn id) as its lifecycle key.
Desktop chatTranscriptRows, ADE Code aggregateChatBlocks, and iOS
WorkEventMapping/WorkTranscriptParser therefore update one row instead of
printing repeated start/progress/result records. The Codex Sources tab derives
files, web results, connector apps/actions, and external URLs from this same
stream; it is a view, not a second persistence channel.
Render pipeline
apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts
implements a two-layer transform:
-
Render events. Raw envelopes become
ChatTranscriptRenderEventvalues:-
Tool, command, file-change, and web-search events collapse into
ChatWorkLogEntryobjects (status, label, tone, diff stats, and web-search action metadata for result chips). -
Text, reasoning, plan, status, pending input, and user-message events pass through as visible rows. Before a synthetic scheduled
user_messagecarryingmetadata.scheduledWake, the transform inserts ascheduled_wake_dividerkeyedscheduled-wake:<scheduleId>:<turnId>with fire time, reason, and late state; the compact while-you-were-away card scrolls to these stable keys. -
subagent_started/subagent_progress/subagent_resultevents collapse per agent (keyed byagentId ?? taskId) into two stable render rows — asubagent_spawn_anchorat the start position (mutated in place as progress arrives) and asubagent_result_cardat the settle position — while backgrounded shell commands collapse to a singlebackground_job_line, pushed on the job's first sighting (so a running job is visible in the thread) and mutated in place through to its terminal state, which is never reopened by a late progress tick. Two producers feed that one row and share its key space: the LIVE runtime, which reports background shells only asscheduled_work_update {kind:"background_task"}(emitClaudeBackgroundTaskUpdate— it emits no subagent lifecycle events for them at all), and legacysubagent_*events carryingtaskType: background, which is all older persisted transcripts hold. Other scheduled kinds are unaffected. Classification can legitimately flip mid-task, so both directions are guarded: once a task has opened a job line it stays a background job (a lateagentTypecannot strand the running line and push a card pair for the same task), and a task that proves itself a real subagent has its job line spliced out before the spawn anchor lands — the level-set path gates only on task type, so an agent that reports none falls through to the background emitter and would otherwise render as a line and a card pair. The anchor keys (subagent-spawn:/subagent-result:/background-chip:<agentKey>, the last kept from the finish-chip era on purpose) never change on rebind so the virtualizer's measured heights survive; atranscript_retractionsplice repairs each stored row index, including thebackgroundJobRowIndexByKeymap that resolves job lines. Raw lifecycle events are then hidden.BackgroundJobLinerenders that row as a quiet centered rule-line in the same idiom as the scheduled-wake and spawn-return dividers — deliberately not a card, because background jobs are frequent and rarely the point of the turn. Running reads⚙ Background · <command> · <elapsed>with a once-per-second ticker anchored to the real start timestamp; a terminal update rewrites the same row to✓/✗ · exit <code> · <duration>. The scheduled-work wire format carries no exit code, so a job reported only through the live producer shows a duration measured from its own first sighting and no exit. Anopenaffordance dispatchesade:chat:open-infoto reveal the chat actions pane, where the job's full state and output already live, and is omitted where no host registered a listener for that event. -
ade_cardcollapses percardIdinto ONE permanent chronological row keyedade-card:<cardId>. A repeat emit mutates that row in place — a new object under the same key, merged over the previous payload, so an update that omitsrows/metricspatches rather than blanks them — which keeps the row at its original position and preserves the virtualizer's measured height as a long-running card (CI, a build, an artifact pull) progresses. When a detail refresh fails,degradedReasondoes not destructively replace an earlier rich payload: rows/progress/metrics survive withstale: true, and a first-time empty failure rendersdetail unavailableplus its retry action instead of a false-green zero-count card. Emitters may providedurationMsonly for a real measured run; the renderer labels the card update span astrackedso it is never presented as work time.rowsTruncatedbecomes a compact+N moresummary. It is deliberately NOT activity: it is never classified byclassifyActivityPhaseRowand never bundled, so an interleaved reasoning/work phase cannot swallow it. The payload contract and its helpers live inapps/desktop/src/shared/adeCard.ts, shared with the TUI; iOS mirrors it. Every surface rendersfallbackText+ thenavTargetdeeplink for avariantit does not recognize, which is what makes one wire contract safe across three independent release trains. There is no red tone in the vocabulary — failures are amber, per the house policy inSubagentActivityCards.tsx. -
pending_input_resolved,activity,step_boundary, raw tool/ command/file-change events, standalone reasoning events, andscheduled_work_updateare hidden (consumed by other derivations). -
transcript_retractionis also hidden, but mutates the accumulated rows by removing prior assistanttextrows whose providermessageIdwas retracted or superseded. -
Exact duplicate
errorrows are collapsed by turn id, message, detail, and semanticerrorInfo. This replay guard handles historical transcripts written before provider-side dedupe without hiding distinct failures from the same turn.
-
-
Grouped envelopes. Adjacent work-log render events in the same turn merge into
work_log_groupblocks. When atool_use_summaryevent immediately follows a group from the same turn, its summary and tool-use IDs are absorbed into the group instead of rendering as a separate row. This keeps the transcript compact when the agent runs many tools in a single turn. -
Activity phase collapse. After work-log grouping, contiguous runs of
reasoning+work_log_grouprows within the same turn (unbroken by assistant text, user messages, plans, pending inputs, or other hard boundaries) can merge again when the phase is noisy: at least three rows, or at least two reasoning rows, or at least two work groups. The pass emits one mergedThoughtrow and one merged work-log group in chronological first-occurrence order. Simple one-thought + one-tool turns stay as two grouped envelopes. Shared logic lives inapps/desktop/src/shared/chatActivityPhase.ts; desktop wires it throughgroupChatTranscriptRows(), the TUI throughaggregateChatBlocks(), and iOS throughcollapseActivityPhaseTimelineEntries(). -
Client presentation. Grouping remains lossless, but normalized tool, command, hook, and web-search groups no longer occupy permanent transcript rows. During a live turn they are available from the expandable working status; after
donethey move to the existing turn-finished /Ran forstatus. On desktop thework_log_groupenvelopes are filtered out of the rendered timeline entirely rather than rendered empty, so they do not consume row gaps. File changes are reported once per turn, at that turn's done divider, instead of once per uninterrupted burst of tool entries — a turn whose bursts were broken up by prose used to stack six near-identical panels through one reply. Assistant narration is unchanged. Desktop and hosted web share this presentation inAgentChatMessageList; iOS opens the activity in a sheet so the working row stays readable at narrow widths; ADE Code expands the same activity from its working or turn-finished row. This is capability preserving: clients show only events and file data the selected provider actually emitted, without synthesizing Claude-style file histories for other runtimes.
Each work-log entry carries a collapseKey built from turnId,
logicalItemId (preferred) or itemId, and tool/command identity.
Streaming updates for the same tool call merge into the existing entry
instead of appending a new row.
withLocalhostUrls(entry) runs at every emit/merge step and stamps
entry.localUrls?: ChatLocalhostUrl[] whenever the entry's
command/output/args/result/label/detail mention a localhost,
127.0.0.1, 0.0.0.0, or [::1] URL. The extractor (also exported as
extractLocalhostUrlsFromText) trims trailing punctuation, normalises
the host to localhost for the canonical href, and dedupes by
href. Downstream ChatWorkLogBlock (specifically the
ChatToolActivityDetails view reachable from the working indicator and the
done divider) consumes entry.localUrls to render the localhost-strip chips
that route into the in-app browser.
Text merging
Adjacent text events merge via shouldMergeTextRows():
- Events with matching
messageIdalways merge. - Events without
messageIdfall back to matchingturnIdanditemId.
This prevents duplicate rows when the provider streams fragmented text.
For Claude SDK rows, messageId is the provider message UUID/id so a
later transcript_retraction can remove the exact assistant text that a
model refusal fallback or supersedes message invalidated.
plan_text merging uses shouldMergePlanTextRows() with the same
heuristic. When a final plan event arrives for a turn, any preceding
plan_text rows for that turn are discarded and replaced with the
single plan row.
Turn diff summaries
When a turn completes on a lane and the service can compute a diff
between the before and after SHAs, the service emits
turn_diff_summary with per-file add/delete counts. The
ChatTurnDiffPanel component renders the summary inline; individual
file diffs are fetched lazily via ade.agentChat.getTurnFileDiff. This is
the only summary that can offer real git diffs and a SHA-scoped revert.
A turn that changed files without moving HEAD emits no such event — no lane,
a runtime with no git integration, or edits that never reached a checkpoint.
Those turns fall back to ChatTurnFilesChangedSummary, derived purely from
work-log entries and therefore available for every runtime (see
composer-and-ui.md). Exactly one of
the two renders per turn: the desktop message list suppresses the fallback for
any done row whose turn id appears in the session's turn_diff_summary set.
Turn recap
chatTranscriptRows also emits a synthetic turn_recap row when a
turn completes. The recap aggregates completed, failed, and interrupted
tool invocations into a single summary line with task-progress counts.
Claude tool-use tracking
The Claude SDK runtime tracks individual tool invocations via the SDK's
toolUseID:
- On
tool_use_startthe service records the invocation as in-progress. - When the SDK returns a
tool_use_summarywithpreceding_tool_use_ids, each ID is matched back to its pending invocation and marked complete, emittingtool_use_completewith the summary text. AskUserQuestionis special: when the SDK invokes it, the service builds aPendingInputRequest, attaches thetoolUseID, and emits the request inline. When the user responds, atool_resultgoes back to the SDK with the answer text, andpending_input_resolvedclears the UI. There is no idle timer to suspend for human deliberation: Claude's time-based idle watchdog was removed because long tool calls emit no stream events, andpauseIdleWatchdog/resumeIdleWatchdogsurvive only as no-op stubs so approval and elicitation callers did not have to change.resolvedToolUseIdstracks already-resolved tool uses so double resolutions (UI double-click, interrupted turn, stale state) are swallowed rather than throwing.
Text batching
apps/desktop/src/main/services/chat/chatTextBatching.ts accumulates
streaming text fragments for up to 100 ms before flushing as a single
assistant-text event. This reduces renderer re-render frequency during
fast streams.
Critical invariant: the buffer must be flushed immediately on every
non-text event (tool call, turn boundary, error) to preserve ordering.
shouldFlushBufferedAssistantTextForEvent() is the gate. Any new event
type added to the union must be considered for this check.
getRecentEntries (used by auto-title and compaction flush) calls the
flush helper first so reads always reflect the latest streamed content.
Every flush is labelled with why it happened — timer (the 100 ms
window elapsed), identityBreak (the incoming fragment could not be
appended to the buffered one), interleave (a non-text event forced the
ordering flush) — and, while ADE_PERF_RUN_ID is set, recorded as a
chatTextFlush perf event by services/perf/chatTextProbe.ts. That probe
is what measures the real cadence the renderer has to absorb (flush count,
characters per flush, deltas coalesced, gap since the previous flush). It
is a no-op with no allocations when no run id is set. Note that
agentChatService is not Electron-only: in a normal dev session the ade
runtime daemon hosts the chat sessions, so it — not Electron main — is the
process that emits these events, and both may append to the same log at
once. See ARCHITECTURE.md.
The lumpiness this batching produces is what the renderer's paced text reveal smooths out; see composer-and-ui.md. The store still receives each flush whole and immediately — only the painted slice is paced.
Virtual scrolling and message-list layout
AgentChatMessageList.tsx keeps render cost proportional to the visible
viewport rather than total message count using its own virtualizer —
not @tanstack/react-virtual. The pieces:
measuredHeights: aMapfrom stable row key to last measured DOM height, withESTIMATED_ROW_HEIGHTas the fallback for rows that have never been rendered.- Spacer divs: a top spacer offsets the rendered window to its correct scroll position and a bottom spacer fills the remaining scroll area, both sized from that map.
MeasuredEventRow: wraps each rendered row and reports its real height through aResizeObserver;handleMeasurewrites the map and callsreconcileMeasuredScrollTop, which adjustsscrollTopwhen a row above the viewport changed height so the visible content stays still.
Below VIRTUALIZATION_THRESHOLD rows the list skips all of this and
renders every row directly. The transcript has one responsive content-width
contract: chatAppearance.ts publishes --chat-content-width as
min(100%, clamp(720px, 62vw, 1180px)) and aliases the older
--chat-column variable to it. Prose, composer, cards, plans, file changes,
activity details, and pills all use that token, while the JS
resolveChatContentWidthPx() mirror supplies floating-pane layout math.
Card rows compose chatCardPrimitives.tsx: a fixed 16 px glyph column,
flexible title/content column, and auto-sized meta/action column. Passing
one-line facts use a hairline row, live/detail-bearing work uses an inset, and
failures use an amber rail. The same primitives back AdeCard,
CodexPlanCard, files changed, tool/work rows, and settled subagent rows.
Notable rendering rules:
- Assistant messages and transcript cards share the responsive content width.
- Completed-turn dividers show local time and measured duration. Tool activity
and proof each have independent collapsed controls. Chat-owned proof is
bucketed into turns by
artifact.createdAt; expandingN proofrenders the horizontalChatProofFilmstripimmediately below that divider. The filmstrip is chronological, starts collapsed, and never moves to a pinned thread footer. Local project-relative URIs render through the artifact protocol; remote items fall back to their kind label and open the runtime-backed drawer. - Code blocks in assistant messages render through
HighlightedCode. - User messages animate in with a
motion/reactspring transition, and over-long ones (>600 chars or >8 lines) collapse behind a CSS gradient mask with a Show full message toggle. - Tables use rounded borders and a subtle inset-shadow treatment.
- System notices render compact inline rather than as pill badges.
- Plan approval cards cap at
max-h-72with pre-wrapped text so long multi-step plans scroll.
History snapshots, scroll-back, and misses
A chat pane hydrates from ade.agentChat.getEventHistory
(AgentChatEventHistorySnapshot) and pages backwards with
ade.agentChat.getEventHistoryPage (AgentChatEventHistoryPage). Both DTOs
live in apps/desktop/src/shared/types/chat.ts. Three fields carry the whole
contract, and each exists because the obvious substitute is wrong.
Runtime callers send both actions as one object envelope
({ sessionId, beforeOffset?, maxBytes? }). The desktop preload and ADE Code
TUI use that canonical shape; the runtime registry still accepts the legacy
positional form while older packaged clients age out. This matters because a
one-argument runtime wrapper silently discards a second positional options
argument before validation, making every older-page request fail even though
the initial snapshot succeeds.
hasOlderHistory — is there anything to scroll back to?
agentChatService derives it from the tail read (transcriptTruncated || windowTruncated), never from envelope object identity, so it stays correct when
a snapshot was served entirely from the in-memory ring buffer or the envelopes
were re-created by the coalescing/subagent pipeline (which loses identity).
Clients must gate the "load earlier messages" head slot on this field, not
on tailStartOffset > 0. resolveSnapshotHistoryCursor in AgentChatPane
enforces that: a hasOlderHistory === false returns cursor 0 even when a
non-zero tailStartOffset is reported, because otherwise the list offers a
scroll-back affordance that can only ever fail — which is exactly what produced
the false couldn't load earlier messages banner. The field is optional for
compatibility; older runtimes that omit it fall back to the legacy offset-only
rule.
tailStartOffset — the paging cursor, in three tiers
tailStartOffset is the beforeOffset a client passes to
getChatEventHistoryPage. It is resolved in strict precedence:
- Exact. The oldest returned event maps to a physical transcript line — use that line's byte offset.
- Identity lost, window complete. Nothing was dropped from the head of the
merged window (
!windowTruncated), so everything at or after the tail read'sstartOffsetis already in this response and paging fromstartOffsetis still exact. A tail that started at byte 0 yields anullcursor: there is nothing older. - Degraded. Identity was lost and the merged window dropped events, so
ADE cannot name the byte offset of the oldest returned event. It pages from
endOffset(the end of the transcript), gated onhasOlderHistory. Pages then re-deliver events the client already shows — which the client dedupes — but scroll-back still reaches the head. This fallback must survive: removing it strands truncated transcripts whose snapshot came entirely from the ring buffer with no way to scroll back at all.
advanceOlderHistoryCursor requires hasMore and a strictly decreasing
startOffset before it advances, which mirrors the service guarantee and makes
client paging loops provably terminating. A page that claims hasMore without
decreasing the cursor is a retryable protocol failure, not evidence that the
head was reached.
Desktop, personal chat, and ADE Code request 256 KiB pages. Desktop and personal-chat selected views keep at most 60,000 events / 32 MiB resident; a background personal-chat view keeps 1,000 events / 2 MiB. Page responses are committed only while the selected session, bound runtime, request generation, and requested cursor still match.
One user-visible "load earlier" is one batch, not one page. readOlderHistoryBatch
keeps pulling pages until the accumulated span contains a user_message,
because pages are cut by bytes: a single page of a long streamed reply can be
hundreds of superseded text delta rows that fold to one rendered line, so a
page-per-trigger design reads as "load earlier did nothing". The same loop also
continues through empty-but-progressing pages, so sparse transcript regions do
not look exhausted. Two bounds keep it terminating: maxPages (default 8,
shared by both behaviours) and maxAnchorEvents (default 400, for a turn that
legitimately spans many pages). Hitting either is not an error — the reader has
real content and the next scroll continues. Pages already collected in a batch
are returned even if a later read reports sessionFound === false, which would
otherwise latch "no older history" and drop them until a reload. An overlapping snapshot
may preserve an exhausted cursor only when its oldest retained event survived
the merge; a replacement snapshot or cap eviction re-arms paging.
History hydration and live delivery have separate authority. The history API
owns the ordered range it returns; the live stream owns only events outside
that range. shared/chatHistoryMerge.ts applies that contract across desktop
and ADE Code: exact/semantic duplicates are removed, delayed live rows are
inserted by timestamp before later terminal rows, and a replayed old turn can
never be appended after the authoritative tail. Event identity is cached by
envelope object so a 60,000-event resident window does not re-serialize every
payload on each streaming flush. Desktop installs the live listener before its
passive history read, while the local runtime pump replays the narrow handoff
window and filters older buffered events by subscription start time. The hosted
web adapter consumes chat_subscribe snapshots only as stream watermarks and
hydrates visible history through chat.getChatEventHistory; it does not
re-emit snapshot rows as new live messages. ADE Code uses its semantic
provider-run identity for overlap, then normalizes delayed events
chronologically. iOS continues to sort and dedupe its materialized event set by
the same lifecycle contract.
The renderer keeps a bounded per-session view cache so switching back can paint immediately. A hidden chat's retention subscription captures the concrete outgoing project binding, even when that binding was the active unpinned path, so a later project switch cannot silently retarget the retained stream. Returning adopts that subscription synchronously, renders the cached tail, and reconciles against authoritative history without blanking the list. Composer controls are withheld for the one-frame interval where the incoming transcript id and internal selected-session id differ; an outgoing Stop button or pending input can therefore never appear over a settled incoming transcript.
unavailable — "could not reach the runtime", not "no such session"
sessionFound: false is an authoritative answer: this project runtime has no
such session. unavailable: true is not — it means the bound runtime could not
be reached (remote hop down, machine asleep, project switch in flight), so no
history could be read at all. Clients must never clear, tombstone, or blank a
chat on it.
It is produced at every boundary that can fail to reach a runtime:
- Preload (
apps/desktop/src/preload/preload.ts) returns it forgetEventHistory/getEventHistoryPagewhen the call was left unhandled during a project transition and the window's runtime context is remote. Falling through to the local main-process chat service there was the bug: the local service has never heard of a remote session id, so it answers a falsesessionFound: falsethat the renderer treats as authoritative and uses to wipe the transcript and its cache. Local bindings still fall through to IPC, because there the local service is the right answer. The page variant also echoes the caller's cursor back asstartOffsetso it does not additionally claim the head of the transcript was reached. See Remote runtime internals. - The web-client adapter (
renderer/webclient/adapter/agentChat.ts,personalChats.ts) sets it on the fallback value used when the host command could not be reached or dispatched.
resolveChatHistoryMissAction in AgentChatPane turns a miss into one of three
actions:
| Result | Action | Meaning |
|---|---|---|
unavailable: true | sync-pending | Keep everything, retry later, raise the catch-up hairline. |
sessionFound: false, events rendered | keep-missing | Authoritative miss, but blanking a transcript the user is reading is strictly worse than leaving a stale-but-real one on screen. Marked for a later "chat no longer exists" pass. |
sessionFound: false, nothing rendered | clear | Safe to drop the empty view. |
Persisted transcript
Sessions persist the transcript to disk under the .ade layout.
Chat replay prefers the dedicated per-session JSONL at
.ade/transcripts/chat/<sessionId>.jsonl; the legacy managed transcript path
can still exist for compatibility and may be byte-capped by the terminal/session
storage budget. When multiple transcript candidates are present, recovery first
prefers files that contain real chat event envelopes, then uncapped files, then
newer readable candidates with file size only as a tie-breaker, so header-only
or capped files do not hide compacted chat history.
Persisted chat events keep the same public AgentChatEvent shape, but bulky
payloads are compacted before storage for rows users rarely need in full after
the turn is over. Large command output, tool results (both result and the
provider's raw structured payload), file diffs, reasoning text, and inline
image data URIs are replaced with a short preview (or no inline media) plus
original/omitted-byte metadata on the event (outputOriginalBytes,
resultOmittedBytes, diffOmittedBytes, textOmittedBytes,
urlOmittedBytes, etc.). Desktop/runtime live subscribers still receive the
original event while a turn is active. Persisted-history consumers see the
stored preview on replay.
The policy — every cap, every wrapper shape — lives in one module,
apps/desktop/src/shared/chatEventCompaction.ts, because it has two consumers
that must never disagree: the stored transcript
(compactChatEventForStorage) and the mobile/web sync wire
(compactChatEventForWire). The wire variant runs storage compaction first, so
a live push and the same event re-read after reconnect hydration are
byte-identical, then drops tool_result.structured and
tool_result.toolResultMeta outright — no client decodes either field, so
phones and web clients paid a download and a JSON parse for something they
immediately discarded. Removing a field no client reads is backward-compatible
by construction and needs no capability gate; adding or reshaping one still
does.
Compaction must stay idempotent. The wire applies it to events that already
came off disk compacted (hydration and the replay ring), and re-wrapping is not
a harmless no-op: the wrapper's newline-dense preview re-serializes with JSON
escaping and comes out bigger than the cap, so each pass grew the payload
while overwriting originalBytes with the previous pass's size. The module
recognizes its own wrapper by shape (summary starting with [ADE] Large ,
plus preview / originalBytes / omittedBytes) rather than by a marker key,
because every surface that renders an object-shaped tool result dumps it as
JSON, so an added key would become the first line the user reads — and shape
detection also recognizes wrappers written by builds that predate the check.
The recognition is size-bounded at 2× the cap so a provider payload cannot
coincidentally buy itself an exemption.
The shortened-payload notices are user-facing copy now (the same compaction
feeds phones), so they no longer mention "stored chat history". Transcripts
already on disk carry the old wording; summarizeDiffStats in
chatTranscriptRows.ts matches both so an old shortened diff is still counted
as shortened rather than parsed as real diff lines.
sessionRecovery.ts implements version-2 reconstruction:
- Recent entries (bounded) are parsed back into envelopes.
- A continuity summary is injected into the new runtime context on resume.
- Provider-native runtime state (Claude session id, Codex app-server socket path, OpenCode runtime ids) is rehydrated so the next turn can use the same session instead of creating a new one.
Claude restart and Stop recovery
Every parent turn must finish with both a terminal status and a matching
done event. A process crash can occur after the user message or
status: "started" has been persisted but before that pair is written. When a
Claude runtime is created, agentChatService therefore checks the latest
non-steer parent turn even when the previous process never persisted an SDK
session id. It fills in only the missing member of the terminal pair, preserves
an already-written terminal status, marks the session idle, and persists the
repair. A newer complete parent turn makes an older incomplete turn irrelevant;
restart recovery never rewrites historical turns.
Restart reconciliation first closes orphaned background and subagent rows, then
appends the parent terminal pair last. This ordering is deliberate: renderer
turn state is derived in event order, so a cleanup row must not make a repaired
turn look active again. Pressing Stop on an already-idle Claude runtime runs the
same parent-turn repair, which lets a stale red Stop state settle without
requiring a live Claude process. Repeated reconciliation and repeated Stop calls
are idempotent because an already-complete status + done pair is no longer an
unsettled turn.
Live Claude control calls are bounded independently of the desktop action
timeout. Provider interrupt() gets 2.5 seconds; active stopTask() calls get
2 seconds each and run concurrently. During ordinary Stop, a hung SDK control
channel is logged and local interruption cleanup continues rather than holding
the action bridge until its 30-second request timeout; an interrupt-and-replace
request that requires provider acknowledgement fails within the control-call
bound instead of sending the replacement ambiguously. Likewise, a steer sent to
an idle or stale Claude session waits only for input-dispatch acceptance; the
provider turn keeps streaming asynchronously instead of making the steer action
wait for the full answer.
Queue handling is explicit at this boundary. stop_and_clear is the
backward-compatible default and uses cancel_queued: true when the Claude
session advertises the capability; otherwise ADE interrupts first and cancels
each attributed provider-queued message through cancelAsyncMessage. Local
pending steers are cleared in the same operation. stop_only interrupts the
turn but preserves the local/provider queues. A successful clear snapshots only
the queued steers the runtime actually cancelled, emits queue_recovery, and
accepts one restoreCancelledQueue call for eight seconds; expiry and restore
are persisted as terminal recovery events so replay cannot resurrect Undo.
A Codex turn ends through several paths — Stop, the local interrupt finish, the
app-server's turn/aborted, runtime teardown, thread/deleted, an app-server
crash — and every one of them runs settleCodexPendingInputs so the turn cannot
leave a plan, exec, or permission card behind. That matters more for Codex than
for the other providers because a Codex plan approval is raised after the turn
completes, when activeTurnId is already null: Stop's "nothing to interrupt"
arms are the common case for the one card that blocks every later send. The
helper empties the approvals map as it settles, so the app-server's own
turn/aborted arriving after a local interrupt is a no-op rather than a second
receipt. Settle teardown's stop_only interrupt is the deliberate exception and
leaves the cards for the user.
Codex adapters deduplicate repeated lifecycle notifications before converting them to envelope events. Terminal app-server failures use a bounded semantic key (turn id + message + detail + error identity) shared by the early notification and failed completion path; retrying notifications stay non-terminal provider-health notices. The renderer applies the same exact identity rule while replaying persisted history, so older transcripts do not regain duplicate visible failures after restart.
Gotchas
messageIdis preferred over turn/item identity for merging. If a provider adapter stops emittingmessageId, the fallback path is correct but noisier. Track regressions inshouldMergeTextRowswhen swapping SDKs.- Hidden event types drop silently. Adding a new event type that
should still be grouped into the work log requires plumbing through
chatTranscriptRows.tsandHiddenTranscriptEvent. logicalItemIdvsitemId. Collapse keys preferlogicalItemIdso streaming updates of the same logical tool merge even when the provider re-emits with a new physicalitemId. Missing this breaks into duplicate rows.- Turn diff emission depends on lane context. If a session is
disassociated from a lane,
turn_diff_summarywill not emit. Do not rely on it for non-lane surfaces. - Claude parent terminal events are an ordered pair. Restart and idle-Stop
repair must leave the parent
status+donepair after any orphan cleanup. - A terminal turn does not clear a Codex card; a receipt does. A Codex plan
approval is raised after
turn/completed, so the renderer intentionally keeps plan-approval and question inputs across adone: completed. The only thing that retires such a card is an explicitpending_input_resolved— which is why every Codex turn-ending path has to emit one. On runtime death the receipt is deliberately withheld for a plan approval (preserveRecoverablePlanApprovals) sorespondToInputcan rebuild the card from the transcript; withholding it for anything else strands the composer. See README › Fragile and tricky wiring. - Claude idle turns close on an SDK event, never on a timer. An idle turn is
opened by background/subagent output that has no result envelope of its own,
so its only authoritative end is the
system/session_state_changedmessage withstate: "idle", which the SDK sends afterheldBackResultflushes and the background-agent loop exits.finishClaudeIdleTurnno-ops when no idle turn is open, so handling every idle transition is safe. Do not reintroduce a time-based idle watchdog to cover this — the previous one fired false positives during long tool calls. New Claudesystemsubtypes are caught by a compile-time exhaustiveness guard rather than by review; see README › Fragile and tricky wiring. Emitting later lifecycle rows can resurrect a stopped renderer state. - A failed history read is not evidence that a chat is gone. Never infer
sessionFound: falsefrom a connection or dispatch failure — setunavailable: trueinstead. Every new code path that can answer a chat history read without reaching the bound runtime has to make that distinction, or it will blank a healthy transcript. See History snapshots, scroll-back, and misses. tailStartOffset > 0does not mean older history exists. In the degraded tier it is a conservative end-of-file cursor. Gate scroll-back UI onhasOlderHistory.- Subscribed mobile history pages do not activate project runtimes.
Modern sync hosts advertise
chatHistoryPaging; thechat_subscribeack carriescursorKind: "byte",tailStartOffset, andhasOlderHistory, and the phone requestschat_historypages against the already-authorized subscription. The host reads the same local, personal, or foreign quick-look transcript path already bound to that subscription. A scope mismatch or transient read failure returnsunavailable: truewith the caller's cursor intact; only an authoritative missing session or a strictly decreasing page that reaches zero exhausts history.