---
title: Real Conversation Layout — part 2 (how the panel decides what to render)
---

# Real Conversation Layout — part 2 (how the panel decides what to render)

## What it is

The rules underneath the Real Conversation Layout: what the chat panel is allowed to render, and how it decides that a turn was real conversation rather than plumbing. Part 1, [real-conversation-layout.md](real-conversation-layout.md), describes what a reader actually sees on screen; this part is the machinery that decides it.

## Where to find it

There is nothing to open. This is the chat panel's own rendering logic, so it has no menu, button or setting of its own — the one control a reader can actuate, the layout toggle and its kill switch, is described in part 1.

## How it behaves

### How the chat panel decides what to render

When the panel mounts, the cold-mount fetch goes through `getSessionHistoryLite()` — the same lite path lazy-content-load already uses, so each row arrives with `summary_prose` and `has_tool_activity` instead of full `content`. With the layout on, those rows are then passed through a single pure function:

```
buildRealConversationTurns(messages, { excludeAutoResponses: true })
```

which walks the array once and produces an array of "turns" plus a top-level system-row filter:

- **Filter at source.** Plain `source === 'system'` rows with no `compactMeta.kind` are dropped before bucketing — that's the "Session ready" fix. Every kinded system row (rate-limit, snooze, seed-context, subagent-result, auth-retry, compact-divider) keeps flowing.
- **Classifier.** Inside each turn, `classifyAgentRows` walks the agent rows _backwards_ from the end. A trailing agent row joins the real reply when it carries a non-empty prose answer — whether it is pure-prose OR a **mixed** row (tool calls + a trailing answer; R13/rev-4 — a single Anthropic row can stream both text and `tool_use` blocks); the run stops at the first pure-tool row, and everything before it folds into the pill. **On a turn the agent has MOVED PAST** (a non-terminal close), a mixed row whose trailing prose is NON-substantive intermediate narration ("Let me try X:") ALSO folds instead of promoting — so a dangling "let me…" intro, left behind when a park-and-wake `turnBoundary` split cut the turn mid-sentence, never shows as a fake final message (R69/Rev 46, 2026-07-15). Woken-no-op and canonical "Waiting…" replies are left promoted for their own dedicated fold; the live/terminal turn still shows even a short reply.

Each turn record carries:

- the **real user message** that opened it — or, for a turn that opens with **no** user message (a deferred background task finishing and pinging the session back to life, OR a recovery re-running a turn that already answered — auth/token refresh, rate-limit move, account switch, stuck-turn sweep), the backend stamps a `turnBoundary` marker on that resume reply and the renderer opens a fresh turn on it, so the real answer renders as its own turn instead of collapsing into the prior one (R54, 2026-05-25; extended to recovery re-runs 2026-06-19),
- the agent's **real reply** rows (the trailing pure-prose run; may be empty for in-flight or tool-ending turns),
- the **hidden agent rows** that get folded into the pill,
- the **action count** that goes on the pill label,
- the **system rows** that fall between turns (top-level kinded markers),
- per `compact-divider` row, a `windowMessages: ConversationMessage[]` bucket — every agent + kinded-system row between this divider and the previous landmark, NOT also surfaced at top level. The bucketing IS the hide for in-window rows — the rev-9 `CompactionExpander` no longer renders these rows visibly inside the chevron (the body is JUST the summary), but the bucketing still happens so the live turn body doesn't double-show pre-compact rows. The bucketed rows remain in the DB and stay visible via search / export / share / audit.

The renderer then walks the turn array and emits one `<TurnGroup>` per turn. For each turn, `TurnGroup` picks between two shapes (rev-13, 2026-05-22):

1. **Merged shape** — turn has hidden activity OR is in flight: one `<MergedTurnBubble>` whose header carries the activity toggle (chevron + bot icon + a viewport-responsive activity label + "took Xs" + timestamp — the activity label renders in every state including collapsed-settled (R67, 2026-06-21) so the count is a glanceable "worth expanding?" signal, while the "took Xs" duration is still shown only when the turn is expanded or streaming) and whose body is the agent's final-answer prose. The per-turn tool detail renders in a panel between header and body when the user expands.
2. **Plain shape** — settled turn with zero hidden activity: a plain `<MessageBubble suppressActivityPill>` for the final reply, no merged wrapper. Common for turns where the agent just replied with prose and no tools.

Each top-level kinded system row becomes its own `MessageBubble`. Each `compact-divider` row becomes a `<CompactionExpander>` showing a `> Compaction N` chevron whose body lazy-fetches and renders the model-generated summary as markdown (single-level expander, rev-9 — no nested sub-chevron, no in-expander windowed rows). If the divider claimed agent rows in its `metadata.compactWindow.rows` (the inter-compaction case, rev-11), a `<TurnActivityBubble>` mounts directly above the expander to surface that claimed work — this is the only remaining direct caller of `TurnActivityBubble` after rev-13; the per-turn position routes through `MergedTurnBubble`. After rev-23 (2026-05-25), `TurnActivityBubble` wraps `MessageBubble` via the same `headerIdentitySlot` + `aboveBodySlot` slot pattern `MergedTurnBubble` uses, so the inter-compaction bubble paints with identical chrome (border, surface background, left emerald rail) to every other agent row — single source of chrome truth, no drift. Its header label is a viewport-responsive pair (R56, 2026-05-26): desktop renders `C comments, A actions in Z` from the v233 persisted `comment_count` / `tool_count` column sums, while a `md:hidden` mobile span drops the action count to `C comments in Z` (falling through to `A actions in Z` for a comment-less window so it never reads "0 comments"). The split is CSS-only — two spans toggled at the `md:` breakpoint, no `useIsMobile()` hook, so there is no SSR / first-paint mismatch. When any claimed row's persisted count is null (a pre-v233 row, an un-backfilled install) the per-render sum collapses to null and both spans degrade to the legacy `N actions took Z` label off the lite-safe action count. A kinded `compaction-in-progress` system row (emitted while Omniscio is actively compacting) is intercepted by `TurnGroup` and rendered as a pulsing amber `<CompactingIndicator>` below the final answer, not as its own message bubble.

`MergedTurnBubble` delegates the bubble _chrome_ (border, hover, action menu, share, ARIA, autoresponse badge, plain-speak toggle) to `MessageBubble` via two new slot props: `headerIdentitySlot` (replaces the default `Bot · Agent · timestamp` trio with the activity toggle) and `aboveBodySlot` (renders the expansion panel between header and body). The merged bubble owns the per-turn state — open/close, lite-fetch on first expand, thinking timer — while `MessageBubble` owns the visual shell. The synthesized body that paints in the bubble swaps between the final-answer content (settled) and the aggregated live content (in flight) based on the streaming gate.

**Multi-row real replies render the last row.** Most turns produce a single real-reply row, but a long turn can produce 2+ — the most common shape is `[mixed-row-with-narration, pure-prose-final]`, where an earlier agent row mixed connective prose with a tool call (and was still classified as a real reply because its prose tail was pure) and a later pure-prose row capped the turn. As of 2026-05-24, `TurnGroup` renders ONLY the **last** real-reply row's content in the final bubble — earlier rows already ride in the activity bubble above via `turn.hiddenAgentMessages`, so joining them with blank lines into the final bubble was duplication. The earlier `mergeFinalMessageMetadata` synthesis that OR-combined `hasToolActivity` flags for the deleted `AgentMarkdown` narration rescue was removed in the same change — schema v227 re-derives `summary_prose` correctly at the source, so the renderer no longer needs the rescue gate. **One consequence of "render the last row":** if a later resume merges a throwaway reply into a turn that already held your real answer, that reply becomes the last row and displaces the answer into the activity zone. The turn builder guards the known case of this — a rate-limit-recovery / lb-rebalance no-op (the exact "Nothing to do here…" sentinel) — by splitting it into its own turn so it can never become the last row of your answer's turn (2026-06-30, buried-final-answer fix); see [woken-noop-sentinel.md](woken-noop-sentinel.md).

The merged bubble's header label reads **`N comments · N tool calls`** (desktop) / **`N comments`** (mobile) — it describes the turn's WORK and never the word "Thinking" (the former extended-thinking prefix was removed 2026-06-22). The comment + tool counts are the persisted `comment_count` / `tool_count` columns, derived ONCE at finalize and surfaced to the renderer TOP-LEVEL (`ConversationMessage.commentCount` / `toolCount`) on EVERY path — the full-history read (`mapMessage`), the lite read, AND the live finalize push (`streaming-finalizer` → `SESSION_OUTPUT`) — so a turn shows the same count while you watch it live as after you reopen the session. When those persisted counts are absent (a pre-v233 row), the label degrades to a lite-safe `turn.actionCount` tool-call count (derived at finalize from `has_tool_activity`), which survives a compaction reload unchanged. The counts are never recomputed on the renderer. Full invariant: `merged-turn-bubble-contract.md` `header-label-describes-the-work-only`. When the user clicks the header to expand, a settled lite turn fires one `SESSION_GET_TURN_CONTENT` IPC per hidden row keyed on the row's `metadata.liteContent.fullContentMessageId`, then joins the responses into the expansion panel; a live (in-flight) turn aggregates its hidden rows' `content` from props synchronously with no IPC. Collapsing the header releases the fetched body so memory stays flat on long threads.

For streaming, the `useSessionConversationTurns` sub-hook (under `useSessionPanel/`) derives an in-flight flag from existing run-state metadata + the row position. When the flag is set, `MergedTurnBubble` auto-expands the header (`aria-expanded="true"`) and renders the **aggregated live content** in the bubble body (interleaved tools + connective prose, with `isStreaming={true}`) — the streaming prose stays visible mid-stream per R8. The expansion panel between header and body stays empty during streaming so the same prose never shows twice. (A mixed row that carries both tool calls and prose rides in the hidden rows and contributes to the aggregated body, so its prose stays visible mid-stream; a pure-prose final row sits in `realReplyMessages` and is held back until settle.) The instant the turn settles, the header collapses, the body switches to the final-answer prose, and `isStreaming` flips to `false`.

When the layout is off, the panel falls back to the prior render path (`VirtualMessageList` / `MessageList` over the unfiltered row array). The unified "Load older messages" pill that legacy layout used to render above the first message is **no longer re-enabled by default** — it is gated by the `LOAD_OLDER_MESSAGES_PILL_ENABLED` code-level flag in `SessionPanel.tsx`, which is `false` for every user (see `load-older-pill-disabled postmortem`).

### Detection — what counts as "real"

The classifier reuses signals that already exist. It does not invent a new detector and does not re-run prose heuristics at render time.

- **`has_tool_activity`** — the schema v218 column on `conversation_messages`, derived once at finalize-time by `extractProse`. This is the authoritative per-row "did this row contain a tool call" signal, and it is what the layout classifier reads. Cheap, deterministic, zero render-time work.
- **`isRealMessageProse`** (Heuristic D) in `src/renderer/src/components/ui/agent-message-heuristics.ts` — kept **only** as the kill-switch fallback. When the layout is forced off and rows arrive without `has_tool_activity` (legacy / non-lite path), the classifier falls back to running this heuristic over the last agent row.
- **`computeActivityOverview`** in `src/renderer/src/components/ui/agent-markdown-content.ts` — the per-turn action overview that feeds the "N actions" pill label.

The layout reads the already-derived `summary_prose` and `has_tool_activity` columns shipped on each lite row, so the work stays cheap even on a 4,000-row session.

The classification rule, in short — the **unbroken trailing pure-prose run**:

- Walk the turn's agent rows **backwards** from the end. A row is part of the real reply if and only if it is an agent row with `has_tool_activity === false` **and** every row after it in the turn is also an agent row with `has_tool_activity === false`.
- The first row (scanning backwards) that breaks the run — any row with tool activity — ends the real reply. That row and everything before it fold into the "N actions" pill.
- A turn whose last agent row has tool activity has an **empty** real reply: only the pill renders, no agent bubble. This is correct — the agent's final act was a tool call, not a message to you.
- A turn with zero agent rows (a user message still awaiting its reply — streaming or stalled) renders the user bubble only, no pill.

**A question is always part of the final message (R-QW-FINAL).** The rule above is per-row; a second rule governs the split _within_ a single row's content. If the agent asks a question and then runs only _bookkeeping_ tools (`TaskUpdate` / `TaskCreate`) or extended-thinking before its sign-off, the within-row split point pulls back to the last _substantive_ tool — so the question and its sign-off render together as ONE final-answer bubble, with the bookkeeping still shown in the pill above. Without this, a turn that asked a question and then ticked off a to-do rendered as three blocks (question, pill, then a _second_ final-looking message). A real tool after the question (Bash, Edit, …) still splits the turn. Full detail: the `summary_prose` contract § R-QW-FINAL. (One residual: while a turn is still _streaming_, the live in-flight view may briefly show the wedge before it heals to this settled view the instant the turn settles.)

Why "walk backwards" and not "promote the last row"? A single Anthropic assistant row streams text _and_ tool calls together. An earlier implementation promoted the last row unconditionally, so a row that mixed preface narration with tool calls was shown as the final reply — the agent's "I'll start by checking X" leaked out as if it were the answer. Reading `has_tool_activity` per row and requiring an unbroken pure-prose tail fixes that. See `real-conversation-layout-rev1-postmortem.md`.

### Pin the final answer with a marker (`[[OMNISCIO_FINAL]]`, opt-in)

The classifier above is a **heuristic** — it infers where your final answer starts by walking back through the agent's rows and the within-row tool markers. That inference is right the vast majority of the time, but a few shapes can fool it (a stray bookkeeping tool call or a long stretch of narration right before the answer). If you'd rather the agent tell Omniscio _exactly_ where its answer begins, turn on **Settings → Features → "Show only the final message"** (off by default).

When it's on, Omniscio adds one line to the teaching note every session already receives: _print the marker `[[OMNISCIO_FINAL]]` on its own line immediately before your final message._ When the agent does that, Omniscio shows the text after the marker as the final answer and folds the **working notes** before it — tool calls, thinking, and throat-clearing narration — into the collapsed activity pill: reachable when you expand the turn, never dropped. The marker line itself never appears, and the "N actions" count is unchanged. It is a deterministic boundary that **overrides** the heuristic — with one line it will never cross: it never folds a substantive answer (see the last bullet below).

It can only ever make the split more exact, never worse:

- **Marker absent** — the toggle is off, an older session, a non-Claude engine, or the agent simply forgot — falls back to the heuristic above, byte-for-byte.
- **Detection is always-on.** Omniscio honors an emitted marker whenever it is present, even after you turn the toggle back off — so a message that used the marker still reads correctly later. The toggle only controls whether new sessions are _taught_ to emit it.
- **The legacy `[[AMC_FINAL]]` marker still counts.** The marker was renamed from the old `AMC_` prefix to `OMNISCIO_`, but Omniscio detects BOTH — so a historical message or an in-flight session that emitted `[[AMC_FINAL]]` folds exactly the same. Only `[[OMNISCIO_FINAL]]` is taught going forward. (The same dual detection applies to the Plain Speak, narration, and self-archive markers.)
- **Safe by construction.** Only the LAST marker outside a code fence counts (an agent explaining the feature inside a ` ``` ` block can't trip it), and a marker with nothing after it is ignored (never a blank bubble).
- **Tolerant of light formatting.** A marker the agent wrapped to make it stand out still counts — whether in bold or italics (`**[[OMNISCIO_FINAL]]**`, `*…*`, `_…_`) or in a markdown heading (`## [[OMNISCIO_FINAL]]`, any level `#`…`######`). Both used to silently break the split _and_ leak the raw marker onto the screen — the heading form rendered as a big title and hid nothing. A backtick-wrapped marker is the deliberate exception: that reads as a code example, so it is never a boundary.
- **Tolerant of a missing line break.** If the agent forgets the newline and glues the marker onto the start of the next line (`[[OMNISCIO_FINAL]]## Ready to Merge`), Omniscio repairs it — splitting the marker back onto its own line — so the raw marker never leaks and the line after it renders normally. A mid-sentence mention of the marker, or one shown as a code example, is left alone.
- **Never hides your answer** (2026-07-04, extended 2026-07-09). The marker folds working notes — never a substantive answer, on _either_ side of it. If the agent writes its real answer and then drops the marker right before a **question box** or a **one-line sign-off** ("Waiting on a background process…"), Omniscio shows that answer _together with_ the question/sign-off instead of collapsing it — a question always keeps the context you need to answer it. And if the agent writes a **substantive summary before the marker** (a headline, a conclusion, a deliverable link) followed by its detailed answer after it, both are shown — the summary is no longer swept into the activity pill (the "ALL CLEAR summary vanished" fix). The one thing that still folds a substantive wrap-up is a clean dev-pipeline **gate report**, which is meant to stand on its own; trivial throat-clearing chatter before the marker also still folds. (Older messages already affected re-derive correctly the next time Omniscio loads them.)
- **A repeated lead line shows once** (2026-07-06). On a turn that did no tool work (pure thinking, so there's no activity pill to fold notes into), the marker can't collapse the notes above it — showing them is the safe choice, since there's nowhere to tuck them. But if the agent happened to write the very same opening line twice — once as a note right before the marker and again as its answer's first line — Omniscio now shows that line **once** instead of twice. It only ever removes an exact, byte-for-byte duplicate that still appears in the answer, so nothing is lost; the instant anything differs, both are kept.
- **Make the cut absolute — even on a no-tool turn** (opt-in sub-toggle, 2026-07-11). By default the marker only folds the notes above it when the turn did real tool work — there's an activity pill to tuck them into; on a pure-thinking answer it leaves them visible (previous bullet). Turn on the nested **"Always hide everything before the marker (even without tool activity)"** — under "Show only the final message," off by default — to make the marker an absolute cut on _every_ turn: everything before it folds into the **same standard collapsed activity row the rest of the app already uses** — a pure-thinking answer into the normal `Thinking` row, tool work into the `N actions` row (2026-07-13: a thinking-only turn no longer draws a one-off "hidden" band; it's the same collapse you see on every other turn). When the notes it folds also carry prose, the turn keeps that same `N actions` row as its header — its collapse holds the thinking, the tool calls and the notes together (2026-09-19), so the fold always lives in the header you already know rather than anywhere else. It stays reachable, never deleted, so nothing is lost; it just isn't shown until you expand it. (Turn it off to restore the behavior above, byte-for-byte.)
- **Show every final message when an agent posts more than one** (opt-in sub-toggle, 2026-07-13). A long or autonomous session sometimes marks _more than one_ final message in a single turn — a status report, then a follow-up, then a sign-off. By default only the **last** marked message is shown and everything before it folds, so an earlier report can end up hidden even though the agent meant it as a final message (the "see the full report above" that wasn't actually above). Turn on the nested **"Show every final message when an agent posts more than one"** — under "Show only the final message," off by default — and Omniscio opens the final region at the **first** marker instead: every marked block is shown, in order, with only the tool work between them folded into the activity row. It is the explicit-marker version of how dev-pipeline gate reports already behave, and it never hides the agent's writing — only tool activity folds. Single-marker turns are completely unaffected. (Turn it off to restore the last-marker-only behavior above, byte-for-byte.)

Full mechanics + the three lockstep walkers that honor it (write-side `extractProse`, the renderer bridge, and the render-path `computeActivityOverview`): the `summary_prose` contract § "Final-message marker" (`.claude/memory/contracts/summary-prose-contract.md`).

### Dev-pipeline gate reports show a pipeline stepper

When a session runs the **dev-pipeline** skill, each phase ends with a status line — `[DEV-PIPELINE | GATE 5 | AUTO-APPROVE: OK]`. Instead of that raw text, Omniscio draws a compact **pipeline stepper** at the end of the message: the full pipeline row of eight phase discs (🔍 Plan · ⚔️ Red Team · 🚦 Standards Check · 🔨 Build · 💎 Elegance · 📝 Docs · 🔬 Standards Audit · 🚀 Merge) with the completed gates filled and the current one lit at its true position in the verdict colour — green when the phase is done, amber when it needs you, a brighter green when the branch is tagged (`READY-TO-MERGE`), red when it's blocked (`NOT-READY`). The widget is topped by a **"Dev Pipeline"** labeled divider so it's self-identifying in chat, and the caption is minimal — a single amber "_phase_ needs you — _reason_" line only when a gate needs you, and nothing when a gate is flowing through (the labeled, colour-coded track already carries the phase, progress, and status; an off-screen summary keeps it audible to screen readers). The final Ready-to-Merge gate uses that same track — the rocket is the lit disc — capped by a **quiet caption**: a green check beside "Branch tagged · every gate green" — a still, one-line confirmation in the same shape as the other gates' captions, with no box and no motion. A blocked finish shows the blocker reason instead. Because that caption already says the branch is tagged and every gate is green, Omniscio also hides the report's own duplicate bottom line (`Branch … tagged ready to merge`, whose branch/commit pills wrapped awkwardly on a phone) so the finish is just the clean caption. It is **render-only**: the raw marker — and that summary line — stay in the saved message, so copy, search, and the pipeline's own auto-advance are unaffected. The two always-on standards phases (🚦 Standards Check, 🔬 Standards Audit) carry a **named** gate marker (`GATE CUSTOM_…`) instead of a number, but because they live in the pipeline they light their own disc in that SAME eight-disc row — so every gate bubble shows the identical row — and they split into their own bubble in an autonomous run just like every numbered phase. Only a user-defined custom phase the row can't place falls back to a compact **gate badge** — the phase's own icon + name, green when it passed or amber with its reason when it needs you. Off-switch: build with `AMC_DISABLE_GATE_STEPPER=1`. Full mechanics: the `gate-stepper contract`.

### A turn that ended with no answer collapses to a one-line summary

Some turns genuinely finish on tool/thinking work with **no closing message** — for example, when the conversation hit an automatic compaction mid-turn and the actual answer came afterward in a fresh turn, or when a work segment simply ended on a tool call. These reply-less turns used to **auto-expand every step inline** the moment you opened the session — a wall of "thinking… → ran a tool → thought again" with nothing tying it off. Users read that as "why is this showing me all the thinking steps?"

Now such a turn renders as a **clean, collapsed one-line summary** — `🤖 4 comments · 6 tool calls · 2:25 PM` — with the steps one click away, exactly like a normal finished turn. Nothing is lost: the expansion is still there, just closed by default. Compaction itself stays the quiet `Compaction N` divider between turns; no banner, no callout.

The collapse fires when the system is **certain** the turn has no answer to show. Concretely, the turn produced no promoted reply **and** every folded-away row is confirmed answer-less by one of three signals: it carries the producer's confirmed-empty mark (`summary_prose = ''`, written once at finalize time); it's an interrupted attempt whose derived answer is empty; or it was **cut off mid-tool** — its content ends on an unfinished tool call, which is the boundary a final message would sit _after_, so there's no room for a trailing answer. The one case still deliberately left **auto-expanding**: an older backlog row where an answer _might_ be buried just before a final **bookkeeping** tool call — a bookkeeping tail (`▸ TaskUpdate`) and a *completed* tool result are never treated as a cut-off, so a real answer can never be hidden. Nothing is lost either way: a collapsed turn always shows its labeled header with the full content one click away. The render-side behavior (which turns collapse vs auto-expand, and the collapsed summary label) lives in `MergedTurnBubble.tsx`; the certainty flag (`confidentlyReplyless`) is computed once by the turn classifier in `src/shared/real-conversation-turns.ts`. Full invariant: `merged-turn-bubble-contract.md` (`replyless-shape-3a-collapses-cleanly` + the shape #3a/#3b table).

## Related

Part 1, [real-conversation-layout.md](real-conversation-layout.md), is the page to read first — it covers the visible layout, when it applies and the setting that governs it. Part 3, [real-conversation-layout-part-3.md](real-conversation-layout-part-3.md), covers the cold-mount fetch, the compact clamp, and the map of the code the feature lives in.
