chat live card: child metadata row stays, summary outranks the timeline, heading separator

Owner follow-up on the nested subagent cards (all five owner decisions, option A):

- A collapsed nested child keeps its metadata row — the harness chip with the
  run count ("Cursor · 1 ok"), cost and last update, with the docked Reviews
  count — and hides only its narration line. The previous change had hidden the
  whole row while collapsed, which removed the one fact the owner reads first
  on a subagent: where it ran and how many runs settled. An empty child meta
  row reserves no height.
- A card's summary outranks its details: the latest-activity line is body
  size (--type-body / --line-body); collapsed timeline rows are a meta-size
  log in the quieter ink; an expanded row returns to body size in primary ink.
  Inline headings and bold spans inside timeline rows inherit the row's size
  at weight 600. Before, the 12px summary sat above 14px bold details.
- Nested children drop the "Show details" label (the root keeps it); the notes
  count and the chevron remain.
- A markdown heading followed by text keeps " — " as its separator in the
  one-line preview ("summary — Cursor Grok…", not "summary Cursor Grok…").
  One shared rule, utils.joinMarkdownHeadings, follows the renderer's own
  heading classifier (MARKDOWN_HEADING_MAX_CHARS by visible length, now
  isMarkdownHeading): markers come off, a real heading followed by text gets
  " —", a marker-led prose line only loses its markers; CRLF, indented
  continuation and whitespace-only blank lines are normalized. It runs in
  log_events.describeText before the preview collapses newlines (afterwards
  no line-anchored rule can tell a heading from prose, and a non-leading
  heading used to leak its markers into the collapsed preview), and
  plainActivityText uses the same rule for raw multi-line input. Effect: a
  progress-row headline whose narration began with "## " no longer renders
  its whole preview as one bold label; it reads as plain "heading — …".
- DESIGN.md §5 and ARCHITECTURE.md §3 state the rules; the nested-cards smoke
  asserts the metadata row with the rendered harness chip and run count, the
  docked Reviews count on a collapsed child, the hidden label, the 14px
  summary and the 12px → 14px timeline row; node tests cover the separator
  edge cases and the live reducer path, and pin the CSS contracts with an
  unambiguous block scan (the previous `(\s+[^\n]+\n)*` shape backtracked
  exponentially on the indented @container copy of a rule).

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
This commit is contained in:
Anton Razzhigaev 2026-09-02 16:17:07 +00:00
parent 07b533654e
commit 2d4cd3e072
11 changed files with 327 additions and 58 deletions

View file

@ -746,7 +746,7 @@ Shared frontend primitives prevent pages from acquiring competing contracts. `pa
`web/modules/chat.js` owns the canonical message timeline, input recall and draft, attachment staging, runtime controls, budget projection, routing annotations, task cards, child cards, WebSocket subscriptions, thread routing, dedupe/insertion order, unread state, and reconnect reconciliation. Its per-instance `chat_media.js` controller owns delivered-media/link builders, task-keyed photo/file grouping, player registries, object URLs, dialogs, and their listeners/timers; `reset()` runs before a full history rebuild and `destroy()` joins the chat-instance teardown. Every ordinary message has one canonical durable chat row. Project views are lenses over those rows and task bindings; Project conversion does not create a second message, unread event, or cost record. Routing acknowledgements live in a compact sidecar keyed by `client_message_id` and update the existing owner message without adding a synthetic assistant bubble.
Top-level messages, media bubbles, and task-card roots are ordered by their raw numeric timestamps rather than formatted display text. An insertion precedes only siblings with a strictly later timestamp, so equal timestamps retain arrival order and timestamp-free transient nodes retain append order; the typing indicator remains last. Ordinary application-controlled Chat height mutations use a stable-viewport seam: when the pre-mutation distance to the live edge is at most 48 CSS pixels, the transcript follows the bottom; otherwise it restores the visible message or keyed nested-card/Reviews anchor. Awaited Load-older, reconnect reconciliation, browser-visibility return, and cross-instance restoration retain explicit lifecycle handling rather than extending that synchronous seam across an await or hidden layout. Native scroll anchoring may assist, but is not the application authority. While the reader is away, remote in-thread delivery coalesces into one instance-local activity marker; history/reconnect replay, local interactions, and layout-only changes do not set it, and landing at the bottom or using jump-to-latest clears it. Collapsed live cards reserve a one-line title band (a coined two-line name still clamps to two; the stable-viewport seam absorbs that growth), a two-line activity band (kept while the card runs and on a finished card that has narration; a finished card whose activity is empty folds the band) and a compact metadata band that the quiet `Reviews N` count shares, without an empty Reviews row or a fixed outer-card height; a collapsed nested child card reserves none of this — it is one identity row (status chip · `role · model` · notes/toggle) in quieter ink, and its activity and metadata appear only when it is expanded; the card's title, activity and metadata text is selectable and copyable, and a drag that selects text never toggles the card; the neutral 32-pixel jump control is centered above the composer. Scroll position is remembered per chat instance and restored after a Project panel is recreated.
Top-level messages, media bubbles, and task-card roots are ordered by their raw numeric timestamps rather than formatted display text. An insertion precedes only siblings with a strictly later timestamp, so equal timestamps retain arrival order and timestamp-free transient nodes retain append order; the typing indicator remains last. Ordinary application-controlled Chat height mutations use a stable-viewport seam: when the pre-mutation distance to the live edge is at most 48 CSS pixels, the transcript follows the bottom; otherwise it restores the visible message or keyed nested-card/Reviews anchor. Awaited Load-older, reconnect reconciliation, browser-visibility return, and cross-instance restoration retain explicit lifecycle handling rather than extending that synchronous seam across an await or hidden layout. Native scroll anchoring may assist, but is not the application authority. While the reader is away, remote in-thread delivery coalesces into one instance-local activity marker; history/reconnect replay, local interactions, and layout-only changes do not set it, and landing at the bottom or using jump-to-latest clears it. Collapsed live cards reserve a one-line title band (a coined two-line name still clamps to two; the stable-viewport seam absorbs that growth), a two-line activity band (kept while the card runs and on a finished card that has narration; a finished card whose activity is empty folds the band) and a compact metadata band that the quiet `Reviews N` count shares, without an empty Reviews row or a fixed outer-card height; a collapsed nested child card reserves no title or activity band — it is an identity row (status chip · `role · model` · notes and chevron) in quieter ink over its metadata band (harness chip with the run count, cost, last update, the docked `Reviews N`), an empty metadata band reserves nothing, and only its narration appears when it is expanded; the card's title, activity and metadata text is selectable and copyable, and a drag that selects text never toggles the card; the neutral 32-pixel jump control is centered above the composer. Scroll position is remembered per chat instance and restored after a Project panel is recreated.
History reconciliation is two-pass. Progress and system records first rebuild timestamped task-card state; cards and regular user/assistant messages are then inserted chronologically; only after that may terminal state seal a card. This prevents terminal replay from discarding earlier progress, preserves progress-only and nested-child cards, and recovers cards whose final summary was missed while disconnected. Live echoes and history rows are deduplicated with stable message identities, while a reconnect may still rebuild ordinary bubbles from durable history when the prior socket missed them.
@ -762,9 +762,9 @@ Direct in-process chat turns and ephemeral decision turns do not create supervis
Owner-message continuity is journal-backed: each locally sent owner row is kept (bounded, with its routing annotation) until a fetched history response returns the same `client_message_id`. A full feed rebuild replays exactly what the server returned and then re-renders the unconfirmed journal rows, so a stale history snapshot — fetched before the send was logged — cannot erase a message the owner just sent; the durable history row remains the retirement authority, and a dropped offline submission is evicted from the journal (its bubble is marked undelivered, a presentation that does not survive a later full rebuild). Task finalization is presented honestly: a root's early final answer carries the typed `task_phase="finalizing"` marker (the same fact history replay derives from the open post-task checkpoint, which also withholds `task_terminal_status` while open), the card holds a sticky `Finalizing…` phase through post-task frames, and `task_cost_finalized` is bookkeeping that never resolves a card. Settled `task_done` remains the live fast path; if a previously observed managed root disappears from a queue-authoritative snapshot whose request began after the root was observed, the existing state-refresh fan-out immediately removes activity/cancel authority and starts one single-flight durable task-detail read. Page-wide request generations prevent an older state response from undoing a newer projection, while the request-start timestamp separately protects live frames that arrived after the request began. Truthful terminal detail reuses Main's terminal-card reducer and existing history synchronization, with the same snapshot fanned to open Project panels; missing or nonterminal detail remains retryable on a later existing refresh. Two liveness invariants close the stuck-"Working..." class at its root. First, the history window is lineage-closed: a subagent FINAL chat row older than the window's progress recency floor (and not actively running) is emitted without its lineage fields — the same floor rule the progress stream already enforces — so replay can no longer mint an unfinishable parent card for a task the window cannot describe (`ouroboros/gateway/history.py`, single strip point inside the terminal-truth annotation pass). Second, liveness is reconciled over the CARD SET, not only the activity registry: after every snapshot hydration, each connected, unfinished, non-subagent root card whose id the snapshot does not vouch for is fed through the existing missing-managed-task seam and finished ONLY by proven durable terminal detail; a card with no durable result honestly keeps its state (owner decision). The header badge has exactly one writer — the status reducer — with the panel-boot "Online" seed as the sole documented exception; the old replay-time bypass that painted "Working..." straight from DOM state is deleted.
Task activity is collapsed into a live task card per root instead of flooding the transcript with individual tool and progress bubbles. `log_events.js` keeps the live task card and grouped task cards on one reducer across Chat and Dashboard Logs. Non-terminal LLM/tool/checkpoint failures remain inspectable timeline/detail facts but do not promote the whole card, even when they are the first retained line; only authoritative terminal task truth changes the terminal status. Unknown Chat event names do not acquire severity from keyword substrings, while Logs retains its diagnostic categorization. A failed child keeps a local `Failed` chip and the existing neutral nested container while its root continues independently. A card keeps a concise latest activity line (a plain-text projection of the headline the timeline renders as markdown: the renderer's marker inventory — including double-backtick code spans and headings of any depth — is stripped line by line, so a stray pipe row or list marker the timeline would show literally is dropped here too; inside a timeline line a markdown heading of any level renders as an inline subsection label at body size, never as a page title) and an expandable chronological timeline; server-truncated result or trace rows fetch their complete typed task record on demand into a bounded viewer. The compact card is therefore a navigation/presentation projection, not the full result authority. Owner actions remain with their domain controls, and urgent toast/unread behavior remains confined to explicit incident facts such as `task_incident`; neither is inferred from task severity.
Task activity is collapsed into a live task card per root instead of flooding the transcript with individual tool and progress bubbles. `log_events.js` keeps the live task card and grouped task cards on one reducer across Chat and Dashboard Logs. Non-terminal LLM/tool/checkpoint failures remain inspectable timeline/detail facts but do not promote the whole card, even when they are the first retained line; only authoritative terminal task truth changes the terminal status. Unknown Chat event names do not acquire severity from keyword substrings, while Logs retains its diagnostic categorization. A failed child keeps a local `Failed` chip and the existing neutral nested container while its root continues independently. A card keeps a concise latest activity line (a plain-text projection of the headline the timeline renders as markdown: the renderer's marker inventory — including double-backtick code spans and headings of any depth — is stripped line by line, so a stray pipe row or list marker the timeline would show literally is dropped here too; a heading followed by text keeps ` — ` as its separator, projected before the preview collapses newlines; inside a timeline line a markdown heading of any level renders as an inline subsection label at the row's size, never as a page title) and an expandable chronological timeline; server-truncated result or trace rows fetch their complete typed task record on demand into a bounded viewer. The card's summary outranks its details: collapsed timeline rows are meta-size in the quieter ink under the body-size activity line, and an expanded row returns to body size. The compact card is therefore a navigation/presentation projection, not the full result authority. Owner actions remain with their domain controls, and urgent toast/unread behavior remains confined to explicit incident facts such as `task_incident`; neither is inferred from task severity.
Subagents render as distinct child cards keyed by their actual child task ids. Parent cards retain lineage references without duplicating the child's final answer into the parent timeline. Nested children are collapsed by default to keep deep trees scannable; a child's headline is its identity, `role · model`, with the short task id added only when a sibling of the same parent shares that role and model (Logs keep the full `role · model (id) — status` form), and its status is carried by the chip alone. Reviews are not task lineage: a reviewer run's explicit execution receipt belongs inside the real owning task card, while its harness or neutral API mark identifies the delivery channel and never creates a synthetic child card or proves execution by itself. The selected `subagent_id`/configured snapshot, requested route, effective engine route/model/account and terminal execution evidence remain separate facts: saved or dispatch intent must not be redrawn as proof of where the run actually settled. Legacy lane/executor fields remain readable on historical cards only.
Subagents render as distinct child cards keyed by their actual child task ids. Parent cards retain lineage references without duplicating the child's final answer into the parent timeline. Nested children are collapsed by default to keep deep trees scannable; a child's headline is its identity, `role · model`, with the short task id added only when a sibling of the same parent shares that role and model (Logs keep the full `role · model (id) — status` form), and its status is carried by the chip alone; collapsed, a child shows that identity row over its metadata row (harness chip with the run count, cost, last update) and hides only its narration line. Reviews are not task lineage: a reviewer run's explicit execution receipt belongs inside the real owning task card, while its harness or neutral API mark identifies the delivery channel and never creates a synthetic child card or proves execution by itself. The selected `subagent_id`/configured snapshot, requested route, effective engine route/model/account and terminal execution evidence remain separate facts: saved or dispatch intent must not be redrawn as proof of where the run actually settled. Legacy lane/executor fields remain readable on historical cards only.
The task card's `Reviews` section is a read-only presentation projection over independent domain authorities. Skill history, typed plan-review state, task-acceptance evidence, and repository-review records retain their own lifecycle, verdict, enforcement, and raw evidence. A row is admitted only with stable review identity, an exact real presentation-owner task, typed domain state, and exact subject/candidate binding where that domain requires it; incomplete or unbound review remains on its domain surface rather than being attached by chat, repository, timestamp, model, or current activity. This delivery admits task-bound Skill Review, plan review, and task acceptance; advisory and commit review remain on their existing domain surfaces until a bounded exact task/candidate projection exists. When review history is the only retained fact for an exact owner, Chat may render an inert owner anchor with `Reviews`, but that anchor has no task phase, typing indicator, or chat-wide liveness until canonical task activity/status arrives. The projection never synthesizes a task-wide review verdict and changes no review routing, status, attention, or enforcement policy.

View file

@ -267,14 +267,22 @@ not move them into the migrated set in section 8.
(a task card's summary), the surface ignores a pointer click whose drag
produced a non-empty selection; keyboard activation is unaffected.
- **A markdown heading inside chat is a subsection label**, never a page
title: in chat bubbles and in a task card's timeline every heading level
renders at `--type-body` semibold (inline, without block margins, in the
timeline). The page-size `md-h1` belongs to non-chat surfaces only.
- **A nested child card is subordinate to its root.** Collapsed, it is one
identity row — status chip · `role · model` · notes/toggle — in
`--text-secondary` ink at weight 400, with no reserved title or activity
lines; its activity and metadata appear only when it is expanded. The root
keeps `--text-primary` at weight 500 for its title.
title: in chat bubbles every heading level renders at `--type-body`
semibold; in a task card's timeline it renders inline, without block
margins, at its row's own size. The page-size `md-h1` belongs to non-chat
surfaces only.
- **A task card's summary outranks its details.** The latest-activity line is
`--type-body`; collapsed timeline rows are a dense log at `--type-meta` in
`--text-secondary`; an expanded row returns to `--type-body` in
`--text-primary`. Details never render larger than the summary above them;
an inline label inside a row is semibold at the row's own size.
- **A nested child card is subordinate to its root.** Collapsed, it is an
identity row — status chip · `role · model` · `N notes` and the chevron (the
`Show details` label belongs to the root) — in `--text-secondary` ink at
weight 400, with no reserved title or activity lines, over its metadata row
(harness chip with the run count, cost, last update). Only its narration
line waits for expansion. The root keeps `--text-primary` at weight 500 for
its title.
### List editors
@ -306,8 +314,8 @@ not child-task cards and never prove execution by themselves.
- A collapsed task card shows only a quiet `Reviews N` count, docked on the
metadata row (it wraps under the metadata on a narrow card), optionally with an
active count; a collapsed nested child card shows it only once expanded, with
the rest of its metadata. It has no aggregate pass/fail alert, no synthesized verdict, and
active count; a collapsed nested child card docks it on its metadata row the
same way. It has no aggregate pass/fail alert, no synthesized verdict, and
no review dollars.
- Expanding `Reviews` reveals one row per currently admitted review group
(`Skill review`, `Plan review`, or `Task acceptance`). Expanding a group

File diff suppressed because one or more lines are too long

View file

@ -1,8 +1,11 @@
"""Rendered contracts of NESTED subagent cards and of card text selection.
A nested child is subordinate to its root: collapsed, it is one identity row
(status chip · `role · model` · notes/toggle) in quieter ink, with its activity
and metadata shown only when expanded; twins (same parent, role and model)
A nested child is subordinate to its root: collapsed, it is an identity row
(status chip · `role · model` · notes and chevron, no `Show details` label) in
quieter ink over its metadata row (harness chip with the run count, cost, last
update, docked `Reviews N`); only its narration waits for expansion. The card's
summary outranks its details: a body-size activity line over meta-size timeline
rows, an expanded row back at body size. Twins (same parent, role and model)
keep the short task id, a lone child does not, and no headline carries the
status word the chip already shows. Card text is selectable, and a drag that
selects text never toggles the card while a plain click still does.
@ -38,7 +41,14 @@ FACTS = """sel => {
titleSelect: style(title).userSelect || style(title).webkitUserSelect,
titleTop: rect(title).top, chipTop: rect(q('[data-live-phase]')).top,
activityDisplay: style(q('[data-live-activity]')).display,
metaDisplay: style(q('[data-live-meta]')).display,
activityFont: style(q('[data-live-activity]')).fontSize,
metaDisplay: style(q('[data-live-meta]')).display, metaText: q('[data-live-meta]').textContent,
metaTop: rect(q('[data-live-meta]')).top,
chipText: q('.chat-live-executor-chip')?.textContent || '', chipHeight: q('.chat-live-executor-chip') ? rect(q('.chat-live-executor-chip')).height : 0,
reviewsText: q('[data-live-review-summary]')?.textContent || '', reviewsHidden: q('[data-live-review-summary]')?.hidden ?? true,
reviewsDisplay: q('[data-live-review-summary]') ? style(q('[data-live-review-summary]')).display : 'none',
reviewsTop: q('[data-live-review-summary]') ? rect(q('[data-live-review-summary]')).top : -1,
toggleDisplay: style(q('[data-live-toggle]')).display,
ariaExpanded: button.getAttribute('aria-expanded'),
cardRight: rect(card).right, cardHScroll: card.scrollWidth > card.clientWidth + 1,
sideRight: rect(q('.chat-live-summary-side')).right,
@ -66,6 +76,9 @@ def _seed(data_dir):
"subagent_task_id": child_id, "parent_task_id": "nest-root", "root_task_id": "nest-root",
"subagent_role": role, "model": "google/gemini-3.6-flash", "status": "completed",
"result": f"{role} result",
**({"executor_route": "cursor=grok", "execution_evidence": {
"delegated_runs_started": 1, "delegated_runs_settled": 1, "delegated_runs_failed": 0,
"delegated_runs_succeeded": 1, "subscription_cost_usd": 0.4}} if role == "scout" else {}),
})
(logs_dir / "progress.jsonl").write_text("".join(json.dumps(r) + "\n" for r in rows), encoding="utf-8")
@ -103,15 +116,27 @@ def test_ui_smoke_nested_cards_are_one_identity_row_and_text_selects(direct_serv
assert lone["text"] == "reviewer · gemini-3.6-flash", lone
for facts in (twin_a, twin_b, lone):
assert "Done" not in facts["text"] and "—" not in facts["text"], facts
# One row: the title shares the chip row and takes one line.
# Identity row: the title shares the chip row and takes one line; the
# metadata row stays (harness/cost/updated), only the narration waits.
assert abs(facts["titleTop"] - facts["chipTop"]) <= 4, facts
assert facts["titleLines"] <= 1.2, facts
assert facts["activityDisplay"] == "none" and facts["metaDisplay"] == "none", facts
assert facts["activityDisplay"] == "none" and facts["metaDisplay"] != "none", facts
assert "updated" in facts["metaText"], facts
# The `Show details` label is the root's; a child keeps notes + chevron.
assert facts["toggleDisplay"] == "none", facts
# Quieter than the root: weight 400 and the secondary ink.
assert facts["titleWeight"] in ("400", "normal"), facts
assert facts["titleColor"] != root["titleColor"], (facts, root)
# The owner's first fact on a subagent — where it ran and how many runs settled —
# is rendered in the collapsed row: the harness chip with the run count.
for facts in (twin_a, twin_b):
assert facts["chipText"] == "Cursor · 1 ok" and facts["chipHeight"] > 0, facts
assert lone["chipText"] == "", lone
assert root["titleWeight"] == "500", root
assert root["activityDisplay"] != "none" and root["metaDisplay"] != "none", root
assert root["toggleDisplay"] != "none", root
# The summary outranks the details: body-size activity line.
assert root["activityFont"] == "14px", root
# A long identity ellipsizes inside the row; the side controls stay inside the card.
page.set_viewport_size({"width": 700, "height": 800})
page.wait_for_timeout(300)
@ -188,12 +213,39 @@ def test_ui_smoke_nested_cards_are_one_identity_row_and_text_selects(direct_serv
page.wait_for_timeout(300)
assert page.evaluate(FACTS, CHILD % "11112222aaaa-pln")["text"] == "planner · gpt-5.6-sol"
assert page.evaluate(FACTS, CHILD % "33334444bbbb-pln")["text"] == "planner · gemini-3.6-flash"
# Expanding a child reveals its activity and metadata.
# A child's review count docks on its metadata row while collapsed, like the root's.
_emit_ws_frame(page, {**child_frame, "task_id": "nest-review", "subagent_task_id": "nest-review",
"subagent_role": "reviewer", "subagent_event": "completed",
"content": "reviewer re-read the candidate and filed a long note " + "word " * 40,
"review_projection": {"panels": [{"panel_id": "nested-review", "surface": "task_acceptance",
"aggregate_signal": "PASS", "reason": "smoke", "actors": []}]}})
page.wait_for_function(
"() => document.querySelector('%s [data-live-review-summary]')?.textContent === 'Reviews 1'" % (CHILD % "nest-review"),
timeout=10_000,
)
reviewed = page.evaluate(FACTS, CHILD % "nest-review")
assert reviewed["expanded"] == "0" and not reviewed["reviewsHidden"] and reviewed["reviewsDisplay"] != "none", reviewed
assert abs(reviewed["reviewsTop"] - reviewed["metaTop"]) <= 2, reviewed
# Expanding a child reveals its narration line (metadata was already there).
page.locator(CHILD % "nest-review").locator(":scope > [data-live-summary-button]").click()
page.wait_for_timeout(200)
opened = page.evaluate(FACTS, CHILD % "nest-review")
assert opened["expanded"] == "1" and opened["activityDisplay"] != "none", opened
assert opened["metaDisplay"] != "none", opened
assert opened["metaDisplay"] != "none" and opened["activityFont"] == "14px", opened
# Collapsed timeline rows are a meta-size log under the summary; an expanded
# row returns to body size.
rows = page.evaluate(
"""sel => Array.from(document.querySelector(sel).querySelectorAll(':scope > [data-live-timeline] .chat-live-line'))
.map(line => ({size: getComputedStyle(line.querySelector('.chat-live-line-title')).fontSize,
expandable: line.classList.contains('expandable')}))""",
CHILD % "nest-review",
)
assert rows and all(r["size"] == "12px" for r in rows), rows
line_toggle = page.locator(f'{CHILD % "nest-review"} > [data-live-timeline] .chat-live-line-toggle').first
assert line_toggle.count() >= 1, rows
line_toggle.click()
page.wait_for_timeout(150)
assert line_toggle.locator(".chat-live-line-title").evaluate("el => getComputedStyle(el).fontSize") == "14px"
# Text is selectable, and a drag that selects text does not toggle the card.
assert root["titleSelect"] == "text", root
title = page.locator(f"{ROOT} > .chat-live-summary-button [data-live-title]")

View file

@ -2,6 +2,7 @@
// live-card presentation projections (moved verbatim from chat.js) plus the
// in-flight direct/ephemeral turn status reducer and snapshot hydration.
import { compactModel } from './log_events.js';
import { joinMarkdownHeadings } from './utils.js';
import { REUSABLE_TASK_IDS } from './task_control_menu.js';
import {
accountedUpperBound,
@ -467,19 +468,20 @@ export const COLLAPSED_ACTIVITY_MAX = 240;
* strikethrough, headings, bullets, links, table pipes. It strips line by line
* without the renderer's block context, so a stray pipe row or list marker the
* timeline would show literally is dropped here too: over-stripping is the
* accepted side of that trade, a leaked marker is not. A headline that is
* nothing but markers keeps its source text: an empty projection would flip
* the reserved activity band's `:empty` rules.
* accepted side of that trade, a leaked marker is not. Headings follow the
* renderer's own rule (`joinMarkdownHeadings`: markers off, ` — ` before the
* text under a real heading). A headline that is nothing but markers keeps its
* source text: an empty projection would flip the reserved activity band's
* `:empty` rules.
*/
export function plainActivityText(text = '') {
const source = String(text || '');
const plain = source
const plain = joinMarkdownHeadings(source)
.replace(/```\w*\n([\s\S]*?)```/g, '$1')
.replace(/(``|`)(.+?)\1/g, '$2')
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/\*(.+?)\*/g, '$1')
.replace(/~~(.+?)~~/g, '$1')
.replace(/^#{1,6} (.+)$/gm, '$1')
.replace(/^- (.+)$/gm, '$1')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
.replace(/^\|(.+)\|$/gm, (_, row) => row.split('|').map((cell) => cell.trim()).join(' '))

View file

@ -1,4 +1,4 @@
import { accountedUpperBound, accountedUpperBoundWithChildren, formatUsd4 } from './utils.js';
import { accountedUpperBound, accountedUpperBoundWithChildren, formatUsd4, joinMarkdownHeadings } from './utils.js';
import { harnessPresentation } from './harness_presentation.js';
import {
classifyReviewLifecycle,
@ -54,10 +54,15 @@ function shortText(text, maxLen = 180) {
return s.length > maxLen ? s.slice(0, maxLen - 3) + '...' : s;
}
function describeText(text, maxLen = 180) {
// For markdown narration, headings are projected (markers off, ` — ` before the
// text under them) BEFORE the newlines collapse into the one-line preview:
// afterwards no line-anchored rule could tell a heading from prose. Typed text
// (shell commands, errors, traces) is never markdown: a `# comment` stays one.
// `full` stays the source text either way.
function describeText(text, maxLen = 180, { markdown = false } = {}) {
const full = String(text || '').trim();
if (!full) return { preview: '', full: '' };
const previewSource = full.replace(/\s+/g, ' ');
const previewSource = (markdown ? joinMarkdownHeadings(full) : full).replace(/\s+/g, ' ');
return {
preview: previewSource.length > maxLen ? previewSource.slice(0, maxLen - 3) + '...' : previewSource,
full,
@ -820,7 +825,7 @@ function chatView({
export function summarizeChatLiveEvent(evt) {
const t = evt.type || evt.event || 'unknown';
const groupId = getLogTaskGroupId(evt);
const progressText = describeText(String(evt.content || evt.text || '').replace(/^💬\s*/, ''), 240);
const progressText = describeText(String(evt.content || evt.text || '').replace(/^💬\s*/, ''), 240, { markdown: true });
const key = (...parts) => [t, groupId, ...parts].join(':');
if (t === 'owner_hurry') {
@ -867,7 +872,7 @@ export function summarizeChatLiveEvent(evt) {
const rawEvent = String(evt.subagent_event || '').toLowerCase();
const role = String(evt.subagent_role || '').trim();
const status = String(evt.status || '').trim();
const resultText = describeText(evt.result || '', 320);
const resultText = describeText(evt.result || '', 320, { markdown: true });
const traceText = describeText(evt.trace_summary || '', 320);
const errorText = describeText(evt.error || '', 220);
const reasonDetail = evt.reason_code ? `Reason: ${String(evt.reason_code)}` : '';

View file

@ -347,11 +347,66 @@ export function accountedUpperBoundWithChildren(payload) {
// count as what the reader sees, not as their markup.
export const MARKDOWN_HEADING_MAX_CHARS = 80;
// What the reader sees of a heading, at either stage of the pipeline. Rendered
// text has already turned matched spans into tags (not visible) and `<`/`&` into
// entities (one character each). Raw text is projected the way the renderer
// would: only MATCHED span pairs and link destinations are invisible; an unmatched
// `*`, a literal `<okay>` or a literal `&amp;` stay visible characters.
function visibleHeadingText(text, rendered) {
const linkless = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
if (rendered) return linkless.replace(/<[^>]*>/g, '').replace(/&[#\w]+;/g, 'x');
return linkless.replace(/(``|`)(.+?)\1/g, '$2').replace(/\*\*(.+?)\*\*/g, '$1').replace(/\*(.+?)\*/g, '$1').replace(/~~(.+?)~~/g, '$1');
}
function isMarkdownHeading(text, { rendered = false } = {}) {
return visibleHeadingText(text, rendered).length <= MARKDOWN_HEADING_MAX_CHARS;
}
function headingOrProse(cls, text) {
// Links render later, so their destination is not visible text either.
const visible = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/<[^>]*>/g, '').replace(/&[#\w]+;/g, 'x');
if (visible.length > MARKDOWN_HEADING_MAX_CHARS) return text;
return `<strong class="${cls}">${text}</strong>`;
return isMarkdownHeading(text, { rendered: true }) ? `<strong class="${cls}">${text}</strong>` : text;
}
// The renderer's fence grammar (`/```(\w*)\n([\s\S]*?)```/`), line by line: a line
// ENDING in ``` plus a word-only info string opens a fence, whatever precedes it on
// that line, PROVIDED a later line closes it (the renderer's regex needs the closer;
// an unclosed opener is ordinary text); the next line containing ``` closes it.
// `md-js` opens nothing.
const FENCE_OPEN = /```\w*$/;
const FENCE_CLOSE = /```/;
/**
* Plain-text projection of ATX headings for one-line previews: the markers go,
* and a heading (by the renderer's own visible-length rule) followed by text keeps
* ` — ` as its separator, so a collapsed preview reads `summary — …` rather than
* gluing the label to the sentence. A marker-led line the renderer treats as prose
* loses only its markers; a heading already ending in a dash or colon gets no
* second one; lines inside a code fence are code and stay untouched. CRLF and
* trailing blanks are normalized. Line-by-line on purpose: a regex over the whole
* text with a lazy or trailing-blank tail is quadratic on long whitespace runs.
*/
export function joinMarkdownHeadings(text) {
const lines = String(text || '').replace(/\r\n?/g, '\n').split('\n').map((line) => line.trimEnd());
// Next non-blank line and "a closer exists below" for every line: one backward
// pass each, no per-heading rescans.
const nextText = new Array(lines.length);
const closerBelow = new Array(lines.length);
for (let i = lines.length - 1, carry = '', closer = false; i >= 0; i -= 1) {
nextText[i] = carry; closerBelow[i] = closer;
if (lines[i].trim()) carry = lines[i];
if (FENCE_CLOSE.test(lines[i])) closer = true;
}
let fenced = false;
return lines.map((line, index) => {
if (fenced) { if (FENCE_CLOSE.test(line)) fenced = false; return line; }
if (FENCE_OPEN.test(line) && closerBelow[index]) { fenced = true; return line; }
const heading = /^#{1,6} (.*\S)$/.exec(line);
if (!heading) return line;
const next = nextText[index];
// A visibly dash- or colon-terminated heading (`**Steps:**` included) needs no second separator.
const visible = visibleHeadingText(heading[1], false);
const separate = next && !FENCE_OPEN.test(next) && visible.length <= MARKDOWN_HEADING_MAX_CHARS && !/[—–\-:]$/.test(visible);
return heading[1] + (separate ? ' —' : '');
}).join('\n');
}
export function renderMarkdown(text) {

View file

@ -2025,16 +2025,18 @@ body.resizing-panels { user-select: none; }
}
.chat-live-card:not([data-expanded="1"]) > .chat-live-summary-button .chat-live-activity {
min-height: 2.8em;
min-height: 3em;
}
/* Root activity remains visually quiet until naming; collapsed cards still
reserve its two-line band while running (a finished card folds an empty
band, below). Anywhere-wrap protects narrow cards and URLs. */
/* The latest-activity line is the card's own reading text, so it is body
size: a card's summary outranks its timeline details (DESIGN.md §5). Root
activity remains visually quiet until naming; collapsed cards still reserve
its two-line band while running (a finished card folds an empty band,
below). Anywhere-wrap protects narrow cards and URLs. */
.chat-live-activity {
color: var(--text-meta);
font-size: var(--type-meta);
line-height: 1.4;
font-size: var(--type-body);
line-height: var(--line-body);
flex: 1 1 100%;
min-width: 0;
overflow-wrap: anywhere;
@ -2394,9 +2396,11 @@ body.resizing-panels { user-select: none; }
}
/* A nested child is subordinate to its root: quieter ink, and collapsed it is
one identity row (chip · role · model · notes/toggle) with its activity and
metadata shown only when expanded. The title stays on one line; the narrow
regime still moves it under the chip row so it keeps a readable width. */
an identity row (chip · role · model · notes + chevron; the `Show details`
label is the root's) over its metadata row (harness chip with the run count,
cost, last update, docked Reviews count). Only its narration line waits for
expansion. The title stays on one line; the narrow regime still moves it
under the chip row so it keeps a readable width. */
.chat-live-card.subagent .chat-live-title {
font-weight: 400;
color: var(--text-secondary);
@ -2415,11 +2419,15 @@ body.resizing-panels { user-select: none; }
}
.chat-live-card.subagent:not([data-expanded="1"]) > .chat-live-summary-button .chat-live-activity,
.chat-live-card.subagent:not([data-expanded="1"]) > .chat-live-summary-button .chat-live-meta,
.chat-live-card.subagent:not([data-expanded="1"]) > .chat-live-summary-button .chat-live-review-summary {
.chat-live-card.subagent .chat-live-toggle,
.chat-live-card.subagent .chat-live-meta:empty {
display: none;
}
.chat-live-card.subagent:not([data-expanded="1"]) > .chat-live-summary-button .chat-live-meta {
min-height: 0;
}
.chat-live-line {
border-top: 1px solid rgba(255,255,255,0.05);
padding-top: 10px;
@ -2476,11 +2484,13 @@ body.resizing-panels { user-select: none; }
min-width: 0;
}
/* Collapsed timeline rows are a dense log under the card's summary: meta size
in the quieter ink. An expanded row is reading text again (below). */
.chat-live-line-title {
color: var(--text-meta);
font-size: var(--type-body);
color: var(--text-secondary);
font-size: var(--type-meta);
font-weight: 400;
line-height: 1.45;
line-height: var(--line-meta);
white-space: pre-wrap;
}
@ -2505,16 +2515,17 @@ body.resizing-panels { user-select: none; }
.chat-live-line-body {
color: var(--text-meta);
color: var(--text-secondary);
/* white-space is set with the other rendered-markdown surfaces, next to the
chat bubble's pre-wrap default this body has to opt out of. */
word-break: break-word;
font-size: var(--type-body);
line-height: var(--line-body);
font-size: var(--type-meta);
line-height: var(--line-meta);
}
/* A markdown heading inside a timeline line is a subsection label (DESIGN.md
§2), never a page title: body size, semibold, inline, no block margins. */
§2), never a page title: the row's own size, semibold, inline, no block
margins. Bold spans take the same semibold weight. */
.chat-live-line-title .md-h1,
.chat-live-line-title .md-h2,
.chat-live-line-title .md-h3,
@ -2522,12 +2533,17 @@ body.resizing-panels { user-select: none; }
.chat-live-line-body .md-h2,
.chat-live-line-body .md-h3 {
display: inline;
font-size: var(--type-body);
font-size: inherit;
font-weight: 600;
line-height: inherit;
margin: 0;
}
.chat-live-line-title strong,
.chat-live-line-body strong {
font-weight: 600;
}
/* P3: the genuinely-full fetched output is shown in a bounded, scrollable box so a
huge subagent/research result expands in place without growing the chat endlessly. */
.chat-live-line-body.chat-live-line-body-full {
@ -2544,10 +2560,14 @@ body.resizing-panels { user-select: none; }
.chat-live-line[data-expanded="1"] .chat-live-line-title {
color: var(--text-primary);
font-size: var(--type-body);
line-height: var(--line-body);
}
.chat-live-line[data-expanded="1"] .chat-live-line-body {
color: var(--text-primary);
font-size: var(--type-body);
line-height: var(--line-body);
}
.chat-live-line.error .chat-live-line-title,

View file

@ -1,3 +1,4 @@
import { joinMarkdownHeadings } from '../modules/utils.js';
import assert from 'node:assert/strict';
import test from 'node:test';
@ -116,3 +117,71 @@ test('renderMarkdownSafe exposes escaped pre/code fallback without DOMPurify', (
globalThis.DOMPurify = priorPurify;
}
});
test('joinMarkdownHeadings follows the renderer heading rule and leaves other markup alone', () => {
assert.equal(joinMarkdownHeadings('## summary\n\nbody'), 'summary —\n\nbody');
assert.equal(joinMarkdownHeadings('## Only'), 'Only');
assert.equal(joinMarkdownHeadings(`## ${'y'.repeat(81)}\nnext`), `${'y'.repeat(81)}\nnext`);
assert.equal(joinMarkdownHeadings('## [Short](http://x/very/long/url/that/is/not/visible/text)\nnext'), '[Short](http://x/very/long/url/that/is/not/visible/text) —\nnext');
assert.equal(joinMarkdownHeadings('**bold**\n- item'), '**bold**\n- item');
assert.equal(joinMarkdownHeadings('```sh\n# comment\nls\n```\nafter'), '```sh\n# comment\nls\n```\nafter');
assert.equal(joinMarkdownHeadings('## Done —\nnext'), 'Done —\nnext');
assert.equal(joinMarkdownHeadings('## Title\n```\ncode\n```'), 'Title\n```\ncode\n```');
// One fence grammar with the renderer: an indented opener is a fence; a non-word
// info string (`md-js`) is not a fence for either, so its `##` line is a heading.
assert.equal(joinMarkdownHeadings(' ```md\n## code\n```\nafter'), ' ```md\n## code\n```\nafter');
// The renderer opens a fence on ANY line ending in ```<info>: a prefixed opener too,
// and the fence closes on the next line that contains ```.
assert.equal(joinMarkdownHeadings('prefix ```md\n## code\n```\n## after\ntext'), 'prefix ```md\n## code\n```\nafter —\ntext');
assert.equal(joinMarkdownHeadings('```md\n## code\nend ```\n## after\ntext'), '```md\n## code\nend ```\nafter —\ntext');
// An unclosed opener is ordinary text for the renderer, so its `##` line is a heading,
// and a heading right before an unclosed opener is followed by text.
assert.equal(joinMarkdownHeadings('```md\n## code\nmore'), '```md\ncode —\nmore');
assert.match(renderMarkdown('```md\n## code\nmore'), /md-h2/);
assert.equal(joinMarkdownHeadings('## Title\n```md\nmore'), 'Title —\n```md\nmore');
assert.equal(joinMarkdownHeadings('## Title\n```md\nmore\n```'), 'Title\n```md\nmore\n```');
// Fence delimiters follow the renderer byte for byte: trailing blanks or a CR after
// the info string make it ordinary text (no fence) for both.
// (…so `## code` is a heading and the stray ``` under it is text it is followed by)
assert.equal(joinMarkdownHeadings('```md \n## code\n```'), '```md\ncode —\n```');
assert.doesNotMatch(renderMarkdown('```md \n## code\n```'), /<pre>/);
assert.equal(joinMarkdownHeadings('```md\r\n## code\r\n```'), '```md\ncode —\n```');
assert.doesNotMatch(renderMarkdown('```md\r\n## code\r\n```'), /<pre>/);
// Linear on hostile brackets: 40k unmatched `[` inside one heading line.
const brackets = Date.now(); joinMarkdownHeadings(`## ${'['.repeat(40000)}\nnext`);
assert.ok(Date.now() - brackets < 500, 'link projection must stay linear');
assert.equal(joinMarkdownHeadings('## [a](x) [b\nnext'), '[a](x) [b —\nnext');
// `&lt;` typed raw is four visible characters (the renderer shows it literally).
assert.equal(joinMarkdownHeadings(`## ${'x'.repeat(79)}&lt;\nnext`), `${'x'.repeat(79)}&lt;\nnext`);
// An entity-like literal is visible as typed on the raw path (9 characters).
assert.equal(joinMarkdownHeadings(`## ${'q'.repeat(72)}&abcdefg;\nnext`), `${'q'.repeat(72)}&abcdefg;\nnext`);
assert.equal(joinMarkdownHeadings(`## ${'q'.repeat(71)}&abcdefg;\nnext`), `${'q'.repeat(71)}&abcdefg; —\nnext`);
// (its `##` line is a heading; the stray closing ``` right after it is a fence line, so no separator)
assert.equal(joinMarkdownHeadings('```md-js\n## code\n```'), '```md-js\ncode —\n```'); // the trailing ``` has no closer: text
assert.equal(joinMarkdownHeadings('```md-js\n## code\ntext'), '```md-js\ncode —\ntext');
assert.match(renderMarkdown('```md-js\n## code\n```'), /md-h2/);
assert.doesNotMatch(renderMarkdown(' ```md\n## code\n```\nafter'), /md-h2/);
// Stage-correct visibility: on the rendered path an unmatched `*` is a visible
// character (80 x + `*` = 81 → prose); on the raw path a literal `<…>` or `&amp;`
// is visible too, and a styled `**Steps:**` still ends in a colon.
assert.equal(renderMarkdown(`## ${'x'.repeat(80)}*`), `${'x'.repeat(80)}*`); // prose keeps its text, not the markers
assert.match(renderMarkdown(`## ${'x'.repeat(79)}*`), /^<strong class="md-h2">/);
assert.equal(joinMarkdownHeadings(`## <${'x'.repeat(79)}>\nnext`), `<${'x'.repeat(79)}>\nnext`);
assert.equal(joinMarkdownHeadings(`## ${'x'.repeat(76)}&amp;\nnext`), `${'x'.repeat(76)}&amp;\nnext`); // 76 + 5 literal chars = 81 → prose
assert.equal(joinMarkdownHeadings(`## ${'x'.repeat(74)}&amp;\nnext`), `${'x'.repeat(74)}&amp; —\nnext`);
assert.equal(joinMarkdownHeadings('## **Steps:**\nnext'), '**Steps:**\nnext');
assert.equal(joinMarkdownHeadings('## *Done —*\nnext'), '*Done —*\nnext');
// Raw `<okay>` is visible text (the renderer escapes it): a heading of 78 chars
// including such a literal is still a heading on the raw path.
const withAngle = `${'q'.repeat(72)}<okay>`;
assert.equal(joinMarkdownHeadings(`## ${withAngle}\nnext`), `${withAngle} —\nnext`);
assert.equal(joinMarkdownHeadings(`## ${'q'.repeat(75)}<okay>\nnext`), `${'q'.repeat(75)}<okay>\nnext`);
// Linear on many headings, not only on one long line.
const many = Array.from({ length: 20000 }, (_, i) => `## h${i}\ntext ${i}`).join('\n');
const started = Date.now(); joinMarkdownHeadings(many);
assert.ok(Date.now() - started < 1000, 'many headings must stay linear');
// Raw span markers are not visible text: a bold 78-char heading is still a heading.
const bold78 = `**${'z'.repeat(78)}**`;
assert.equal(joinMarkdownHeadings(`## ${bold78}\nnext`), `${bold78} —\nnext`);
assert.equal(renderMarkdown(`## ${bold78}`), `<strong class="md-h2"><strong>${'z'.repeat(78)}</strong></strong>`);
});

View file

@ -407,7 +407,17 @@ test('chat bubble heading clamp is scoped in style.css', () => {
// size (DESIGN.md §2); the global md-h1 page-size rule stays for non-chat
// surfaces, and the live-card timeline carries its own inline clamp.
assert.match(styleSource, /\.chat-bubble \.message \.md-h1,\n\.chat-bubble \.message \.md-h2,\n\.chat-bubble \.message \.md-h3 \{\n\s+font-size: var\(--type-body\);\n\s+font-weight: 600;\n\}/);
assert.match(styleSource, /\.chat-live-line-body \.md-h3 \{\n(\s+[^\n]+\n)*\s+display: inline;/);
// The timeline label follows its row's size: collapsed rows are meta size,
// an expanded row is body size (DESIGN.md §5, "summary outranks details").
// Unambiguous block scan (indent, then a non-space start): the `(\s+[^\n]+\n)*`
// shape backtracks exponentially when an earlier same-named indented rule
// (the @container copy of .chat-live-line-title) has no such declaration.
const decl = (selector, declaration) => new RegExp(`\\n${selector} \\{\\n(?:[ \\t]+\\S[^\\n]*\\n)*?[ \\t]+${declaration}`);
assert.match(styleSource, decl('\\.chat-live-line-body \\.md-h3', 'display: inline;'));
assert.match(styleSource, decl('\\.chat-live-line-body \\.md-h3', 'font-size: inherit;'));
assert.match(styleSource, decl('\\.chat-live-line-title', 'font-size: var\\(--type-meta\\);'));
assert.match(styleSource, decl('\\.chat-live-line\\[data-expanded="1"\\] \\.chat-live-line-body', 'font-size: var\\(--type-body\\);'));
assert.match(styleSource, decl('\\.chat-live-activity', 'font-size: var\\(--type-body\\);'));
// The rich bubble renderer demotes h4-h6 to the smallest label so the clamp reaches them.
const richSource = readFileSync(new URL('../modules/chat_markdown.js', import.meta.url), 'utf8');
assert.match(richSource, /querySelectorAll\('h1, h2, h3, h4, h5, h6'\)[\s\S]{0,160}Math\.min\(Number\(heading\.tagName\.slice\(1\)\), 3\)/);

View file

@ -163,9 +163,57 @@ test('the collapsed activity line is plain text: the renderer\'s markdown invent
// preferred to a leaked marker).
assert.equal(plainActivityText('**Planning a network update** I need `git fetch`'),
'Planning a network update I need git fetch');
assert.equal(plainActivityText('### Title\n- one\n- two [link](http://x)'), 'Title\none\ntwo link');
// A heading keeps ' — ' before the text under it (any number of blank lines);
// a heading with nothing under it is just its text.
assert.equal(plainActivityText('### Title\n- one\n- two [link](http://x)'), 'Title —\none\ntwo link');
assert.equal(plainActivityText('## summary\n\nCursor Grok confirmed'), 'summary —\n\nCursor Grok confirmed');
assert.equal(plainActivityText('## Only'), 'Only');
assert.equal(plainActivityText('## Trailing \nnext'), 'Trailing —\nnext');
// Continuation may be indented, come after whitespace-only lines, or use CRLF.
assert.equal(plainActivityText('## summary\r\ncontinued'), 'summary —\ncontinued');
assert.equal(plainActivityText('## summary\n continued'), 'summary —\n continued');
assert.equal(plainActivityText('## A\n \nB'), 'A —\n\nB');
// A marker-led line the renderer treats as prose (over MARKDOWN_HEADING_MAX_CHARS)
// loses its markers but gets no separator.
const prose = 'x'.repeat(85);
assert.equal(plainActivityText(`## ${prose}\nnext`), `${prose}\nnext`);
// A `#` line inside a fence is code: no separator, and its markers stay.
assert.equal(plainActivityText('```md\n## not a heading\n```'), '## not a heading');
assert.equal(plainActivityText('```sh\n# comment\nls -la\n```\nafter'), '# comment\nls -la\n\nafter');
// A heading that already ends in a dash or colon gets no second separator.
assert.equal(plainActivityText('## summary —\nnext'), 'summary —\nnext');
assert.equal(plainActivityText('## Steps:\nnext'), 'Steps:\nnext');
assert.equal(plainActivityText('## **Steps:**\nnext'), 'Steps:\nnext');
const styled = summarizeChatLiveEvent({ is_progress: true, content: '## **Steps:**\nnext', task_id: 'p3', chat_id: 1 });
assert.equal(boundActivityPreview(styled.headline), 'Steps: next');
// Typed shell text is not markdown: a `# comment` in a failed command's compact
// diagnostic keeps its `#` and gets no separator.
const failed = summarizeChatLiveEvent({ type: 'tool_call_finished', is_error: true, status: 'non_zero_exit', exit_code: 1,
args: { cmd: '# explain\nfalse' }, task_id: 'p4', chat_id: 1 });
assert.ok(failed.body.includes('Command: # explain false'), failed.body);
assert.ok(!failed.body.includes(' —'), failed.body);
// Linear on hostile whitespace: a 40k-space run inside a marker line.
const started = Date.now();
plainActivityText(`# a${' '.repeat(40000)}b\nnext`);
assert.ok(Date.now() - started < 500, 'heading projection must stay linear');
assert.equal(boundActivityPreview('## summary\n\nCursor Grok confirmed the skew'), 'summary — Cursor Grok confirmed the skew');
// The live preview path: the reducer collapses newlines in describeText, so the
// heading rule must have run before that — a non-leading heading keeps no marker.
const live = summarizeChatLiveEvent({ is_progress: true, content: 'text\n## Second\nbeta', task_id: 'p1', chat_id: 1 });
assert.equal(live.headline, 'text Second — beta');
assert.equal(boundActivityPreview(live.headline), 'text Second — beta');
// A fenced `##` line — with text inside and after the fence — stays code on the
// live path too: markers kept, no separator (the fence itself collapses to the
// renderer's code-span remnant, which is not this rule's contract).
for (const content of ['```md\n## code\n```\nafter', '```md\n## not a heading\nnext\n```', ' ```md\n## code\n```\nafter', ' ```md\n## code\n ```\nafter', 'prefix ```md\n## code\n```\nafter']) {
const fenced = summarizeChatLiveEvent({ is_progress: true, content, task_id: 'p2', chat_id: 1 });
for (const text of [fenced.headline, boundActivityPreview(fenced.headline), plainActivityText(content)]) {
assert.ok(text.includes('## code') || text.includes('## not a heading'), text);
assert.ok(!text.includes(' —'), text);
}
}
assert.equal(plainActivityText('~~old~~ *new*'), 'old new');
assert.equal(plainActivityText('#### Deep\n``x → y`` tail'), 'Deep\nx → y tail');
assert.equal(plainActivityText('#### Deep\n``x → y`` tail'), 'Deep —\nx → y tail');
assert.equal(plainActivityText('```js\nlet a = 1;\n```'), 'let a = 1;');
assert.equal(boundActivityPreview('| a | b |\n|---|---|\n| 1 | 2 |'), 'a b 1 2');
// Markers-only text keeps its source: an empty projection would flip the