Skip to content

feat(desktop): add conversation transcript variant for focus-mode agent sessions - #6538

Open
baxen wants to merge 4 commits into
mainfrom
ss-dev-01/conversation-variant
Open

feat(desktop): add conversation transcript variant for focus-mode agent sessions#6538
baxen wants to merge 4 commits into
mainfrom
ss-dev-01/conversation-variant

Conversation

@baxen

@baxen baxen commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Category: feature

User Impact: In the full-cover focus view (any host that pins the conversation variant) the observer transcript now reads like a conversation instead of a dense activity feed — the human's prompt as a right-aligned bubble, the agent's reply as unboxed prose at a comfortable measure, reasoning behind a "Thinking…/Thought for Ns" disclosure, the plan as a live checklist, and session/status noise receded to quiet dividers.

Problem: The transcript had two presentations — the dense default activity feed and compactPreview — both tuned for a narrow panel. Read at full width they are a scan surface, not something you read a turn in.

Solution: A third, purely additive conversation variant, threaded through ManagedAgentSessionPanel and AgentSessionThreadPanel as an optional prop with a width-based default.

Rhythm

Item conversation treatment
Human prompt Right-aligned bubble, filled bg-muted/60, capped at max-w-[85%], author label + avatar, timestamp/context footer revealed on hover or keyboard focus. Unclamped — focus mode shows the whole prompt. Channel context stays behind the existing prompt-context affordance, not inline.
Agent message Full-width unboxed prose in a centered max-w-3xl reading column, leading-relaxed, markdown/code through the existing shared Markdown renderer.
Thought Collapsed <details> disclosure labelled "Thinking…" while streaming and "Thought for Ns" once the turn moves on. Auto-opens while streaming, collapses when the turn produces its next item. A reader who toggles it takes over — stream transitions can't yank it back.
Plan Checklist card that mutates in place (completed / in progress / pending rows plus an "N/M complete" caption). Falls back to markdown for adapters that send free-form plan text.
Session boundary, turn setup, lifecycle status Quiet centered ConversationDivider hairlines.
Error, permission Unchanged loud treatment (destructive / amber), just respaced.
Tool items Deliberately untouched — same ToolActivity presenter as default.

Spacing is gap-8 between turns and gap-3 within one. The timestamp preference is still respected.

Constraints honoured

  • Existing variants render byte-for-byte identical. Enforced by a captured-markup fixture, not by inspection: AgentSessionTranscriptList.conversation.baseline.json was produced by mounting the same transcript on unmodified code, and I re-verified it against main's tip in a clean throwaway worktree before committing. The test fails on any drift in default or compactPreview output.
  • Tool items untouched, so the parallel tool-chain-card slice merges cleanly.
  • Drawer/shell untouched.

Notes for review

  • Variant selection is caller-pinned only. AgentSessionThreadPanel defaults to default and only renders conversation when a host explicitly asks for it (Slice A's cover drawer does). The earlier 640px width heuristic and its module were removed in review: an automatic wide-pane mode is a separate product decision, and swapping the whole transcript presentation as a reader drags a resize handle across a threshold is not a decision to make implicitly.
  • Thought durations are derived from the transcript itself, not from new wire fields. A thought ends at the next leaf item of any kind — tool call or plan update, not just the next assistant message — because reasoning should recede as soon as the agent acts on it; folding only on the next message would pin reasoning open across a long tool run. This is documented in agentSessionConversationMeta.ts and pinned by a thought → tool → message test.
  • AgentSessionTranscriptChrome.tsx is a straight extraction of prompt/divider/setup chrome out of AgentSessionTranscriptList.tsx, which was approaching the 1000-line ratchet. Its one behavioural change is import.meta.env?. (optional chaining) so the module is importable from node render tests — the same pattern already used in features/onboarding/devFreshOnboarding.ts.

Review round (changes requested → addressed)

  1. Programmatic-toggle echo in the thought disclosure — fixed. <details> fires toggle when React changes open, so auto-opening a streaming thought was recorded as a reader choice and pinned the disclosure open forever; reasoning never receded once the agent acted. Only a toggle that disagrees with the last rendered state can have come from the reader. The regression test injects the echo while streaming, then rerenders with a following item and asserts the disclosure collapsed — it is the only test that fails when the guard is reverted (verified). A second test proves a genuine reader toggle still survives later items.

  2. Byte-for-byte fixture now covers the contract it claims. The baseline transcript contains every renderable item kind — prompt with prompt-context and setup lifecycle, assistant message, thought, plan, tool, lifecycle status, error, permission — across two sessions so a session-boundary divider is forced. Both legacy variants were recaptured from pre-change main (074561233) in a clean throwaway worktree. compactPreview's intentional omissions are captured in the fixture and therefore also protected. A companion test asserts the baseline really renders each kind, so a fixture that silently stopped covering one cannot keep passing while protecting nothing.

  3. Thought completion documented as intended (ruling: next leaf item of any kind) with a thought → tool (4s) → message (88s) test asserting the 4s duration.

  4. Width heuristic removed; agentSessionTranscriptVariantChoice.ts and its tests are deleted.

  5. Plan-in-place rerender assertion added: same plan id with advanced entries retains the same card DOM node while progress text changes 1/3 → 2/3.

  6. Fixture pinned to UTC — this was a real bug in the previous head's CI. The captured markup embeds formatted timestamps (formatTranscriptTimestampTitle renders in the ambient zone), and the fixture had been captured at UTC-7, so Desktop Core failed in CI (UTC) on a 12KB diff whose only real content was 12:00:01 PM vs 7:00:01 PM — on 7927fa37a too, not just this round. The test now pins process.env.TZ = "UTC" before the transcript modules are imported (their Intl.DateTimeFormat instances are module-level constants that resolve the zone once at construction), the baseline was recaptured from pre-change main under that zone, and a guard assertion fails with a specific message instead of a markup dump if the ambient zone is not UTC. Verified passing under TZ=UTC, TZ=America/New_York, and TZ=Asia/Tokyo.

  7. Fixture locale pinned too — same bug class as the timezone one, one variable over. AgentSessionTranscriptChrome.tsx formats the session-boundary date with a bare new Date(...).toLocaleString(), which is locale-sensitive as well as zone-sensitive: the fixture holds 6/14/2026, 7:05:00 PM, and under LANG=de-DE the same render produces 14.6.2026, 19:05:00. The TZ guard passed silently while this failed. Three things make the fix non-obvious: assigning process.env.LANG at runtime is a no-op (node resolves its default locale once at startup — TZ works only because Date reads it lazily); patching Intl.DateTimeFormat alone is insufficient because Date.prototype.toLocale* does not route through it; and the Intl.DateTimeFormat replacement must be a plain function, not an arrow, since the render path calls it with new. The guard now asserts resolved locale and a known toLocaleString() output alongside the zone. Non-vacuous: stripping the pin fails under de-DE. Verified green under UTC/en-US, UTC/de-DE, America/New_York/ja-JP, Asia/Tokyo/de-DE, and UTC with LANG unset.

Known follow-up (Slice C sequencing)

Slice C (#6536) is not merged yet, so the shared controlled-details hook it promises does not exist on origin. The echo guard here is a local implementation with the same semantics as C's useToolRunDisclosure (rendered-state ref + useLayoutEffect + ignore-agreeing-toggle), flagged in a code comment so the swap is mechanical on rebase. Still outstanding for the rebase: agentSessionConversationMeta.ts hardcodes segment.kind === "summary", which C replaces with "tool-run" — it must flatten segment.run.items, plus a thought → tool-run meta test.

Validation

At e6c994a69 (based on main tip 074561233):

  • pnpm test5379 passing, 0 failing (full desktop suite, not scoped), and re-run green across five TZ/locale combinations
  • pnpm typecheck — clean
  • pnpm check (biome + px-text + pubkey-truncation) — findings identical to main's baseline (pre-existing terminal.css !important warnings and infos)
  • pnpm check:file-sizes — clean
  • All pre-push hooks green: desktop-check, desktop-typecheck, desktop-test, file-size-check
  • GitHub CI green on e6c994a69 — every check SUCCESS or SKIPPED, including Desktop Core, which was the job the ambient-formatting-dependent fixture had been failing (polled to completion, not inferred from local green)

New coverage: 13 JSDOM rendering tests for the variant (including the recaptured baseline byte-identity assertion, its coverage guard, the echo-regression pair, and the plan-in-place rerender) plus the meta/checklist unit tests.

…nt sessions

**Category:** feature
**User Impact:** In the wide/standalone agent session panel the observer
transcript now reads like a conversation — the human's prompt as a
right-aligned bubble, the agent's reply as unboxed prose at a comfortable
measure, reasoning behind a "Thinking…/Thought for Ns" disclosure, the plan
as a live checklist, and session/status noise receded to quiet dividers.

**Problem:** The transcript had two presentations — the dense `default`
activity feed and `compactPreview` — both tuned for a narrow panel. Read at
full width they are a scan surface, not something you read a turn in.

**Solution:** A third, purely additive `conversation` variant, threaded
through `ManagedAgentSessionPanel` and `AgentSessionThreadPanel` as an
optional prop with a width-based default. Tool items deliberately route
through the same `ToolActivity` presenter as `default` so the parallel
tool-chain-card work merges cleanly, and the drawer/shell is untouched.

The byte-for-byte guarantee for the existing variants is enforced by a
captured-markup fixture, not by inspection: the baseline was produced by
mounting the same transcript on unmodified code, and the test fails on any
drift in `default` or `compactPreview` output.

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
@baxen
baxen requested a review from a team as a code owner August 22, 2026 01:59

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7927fa37a3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +84 to +87
onToggle={(event) => {
setUserOpen(event.currentTarget.open);
}}
open={isOpen}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore programmatic thought disclosure toggles

When a live thought mounts or isStreaming changes, React updates the <details> element's open property, which itself emits toggle; this handler then records that automatic state in userOpen. Consequently, the normal streaming transition pins userOpen to true, and the disclosure remains expanded when the next transcript item arrives instead of automatically collapsing as intended. Track actual summary interaction separately, or suppress toggle events caused by synchronizing open.

Useful? React with 👍 / 👎.

Comment on lines +112 to +114
const resolvedTranscriptVariant =
transcriptVariant ??
resolveAgentSessionTranscriptVariant({ isSinglePanelView, widthPx });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Select the variant using the rendered panel width

When a previously widened panel is restored and the channel viewport later narrows, widthPx retains the stored width while RightAuxiliaryPane clamps the rendered pane to calc(100% - 300px). For example, a stored 700px pane in an 800px channel area is actually rendered around 500px, but this check still selects conversation; because the single-panel breakpoint is only below 600px, the cramped pane remains in the layout this threshold was meant to avoid. Measure or clamp the effective panel width before resolving the variant.

Useful? React with 👍 / 👎.

Review follow-ups on the `conversation` transcript variant.

Variant selection is now caller-pinned only. The 640px width heuristic and
its module are gone: an automatic wide-pane mode is a separate product
decision, and swapping the whole transcript presentation as a reader drags
a resize handle across a threshold is not a decision to make implicitly.
Slice A's cover host pins `conversation` explicitly.

The thought disclosure no longer records the browser's programmatic toggle
echo as a reader choice. `<details>` fires `toggle` when React changes
`open`, so auto-opening a streaming thought was pinning it open forever and
reasoning never receded once the agent acted. Only a toggle that disagrees
with the last rendered state can have come from the reader. The regression
test injects the echo and then rerenders with a following item, and it is
the only test that fails when the guard is removed; a second test proves a
genuine reader toggle still wins.

Thought completion at the next leaf item of any kind — tool call or plan
update, not just the next assistant message — is now documented as intended
rather than incidental, with a thought -> tool -> message test pinning it.

The byte-for-byte fixture now covers the contract it claims. The baseline
transcript contains every renderable item kind (prompt with context and
setup, assistant message, thought, plan, tool, lifecycle status, error,
permission) across two sessions so a session boundary is forced, and both
legacy variants were recaptured from pre-change main. A companion test
asserts the baseline really renders each kind, so a fixture that silently
stopped covering one cannot keep passing while protecting nothing.

Also adds the plan-in-place rerender assertion: same plan id with advanced
entries retains the same card node while the progress text changes.

Known follow-up: Slice C is not merged yet, so its shared controlled-details
hook does not exist on origin and the echo guard here is a local copy with
the same semantics, flagged in a comment for replacement on rebase.

Signed-off-by: ss-dev-01 <11939edb7df583f855dbef923f2358f1184538f88ca452e19e7e35e42ad6d796@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
ss-dev-01 and others added 2 commits August 21, 2026 19:30
The captured-markup fixture embeds formatted timestamps, because
`formatTranscriptTimestampTitle` renders in the ambient zone. It was
captured at UTC-7, so the byte-for-byte comparison failed in CI (UTC) for a
reason that had nothing to do with markup: "at 12:00:01 PM" vs
"at 7:00:01 PM". This is why Desktop Core failed on the previous two heads.

Pin `process.env.TZ = "UTC"` before the transcript modules are imported —
their `Intl.DateTimeFormat` instances are module-level constants that
resolve the zone once at construction — and recapture the baseline from
pre-change main under that zone. A guard assertion in the coverage test now
fails with a specific message if the ambient zone is not UTC, rather than
dumping a 12KB markup diff.

Verified passing under TZ=UTC, TZ=America/New_York, and TZ=Asia/Tokyo.

Signed-off-by: ss-dev-01 <11939edb7df583f855dbef923f2358f1184538f88ca452e19e7e35e42ad6d796@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
Pinning TZ fixed one ambient input to the captured-markup fixture and missed
a second one. The session-boundary divider formats its date with a bare
`toLocaleString()` (`AgentSessionTranscriptChrome.tsx`), which is
locale-sensitive as well as zone-sensitive: the fixture holds
"6/14/2026, 7:05:00 PM", and under `LANG=de-DE` the same render produces
"14.6.2026, 19:05:00". Node takes its default locale from LANG/LC_ALL, so
the byte-for-byte comparison failed on any machine with a non-US locale —
the same class of bug as the timezone one, one variable over, and the TZ
guard passed happily while it happened.

Assigning `process.env.LANG` at runtime does not fix this: node resolves its
default locale once at startup, so unlike `TZ` (which `Date` reads lazily)
the assignment is silently ineffective. Pinning the locale means overriding
the two formatting surfaces the render path reaches — `Intl.DateTimeFormat`
constructed without an explicit locale, and `Date.prototype.toLocale*`,
which does not route through `Intl.DateTimeFormat` and needs its own patch.

`Intl.DateTimeFormat` must be replaced with a plain function, not an arrow:
the render path calls it with `new`, and an arrow function is not a constructor.

The guard assertion now covers locale and `toLocaleString` output alongside
the zone, so a future drift fails with a specific message instead of a 12KB
markup diff.

Verified: the de-DE failure reproduces before this change, and the suite
passes after it under TZ/LANG combinations UTC/en-US, UTC/de-DE,
America/New_York/ja-JP, Asia/Tokyo/de-DE, and UTC with LANG unset.

Signed-off-by: ss-dev-01 <11939edb7df583f855dbef923f2358f1184538f88ca452e19e7e35e42ad6d796@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant