From 9fb9e00bafbb8bf322a3a689619b6fb65fd50292 Mon Sep 17 00:00:00 2001 From: ss-dev-01 <11939edb7df583f855dbef923f2358f1184538f88ca452e19e7e35e42ad6d796@buzz.block.builderlab.xyz> Date: Fri, 21 Aug 2026 19:13:59 -0700 Subject: [PATCH] fix(desktop): address review on the conversation transcript variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. `
` 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 Signed-off-by: Bradley Axen --- ...nTranscriptList.conversation.baseline.json | 4 +- ...essionTranscriptList.conversation.test.mjs | 393 +++++++++++++++++- .../activityRenderClasses/ThoughtActivity.tsx | 27 +- .../ui/agentSessionConversationMeta.test.mjs | 88 ++-- .../agents/ui/agentSessionConversationMeta.ts | 9 +- .../ui/agentSessionTranscriptVariantChoice.ts | 45 -- .../channels/ui/AgentSessionThreadPanel.tsx | 19 +- 7 files changed, 483 insertions(+), 102 deletions(-) delete mode 100644 desktop/src/features/agents/ui/agentSessionTranscriptVariantChoice.ts diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.baseline.json b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.baseline.json index 56c940f158..3e392a44e7 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.baseline.json +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.baseline.json @@ -1,4 +1,4 @@ { - "default": "
*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-3 [&>p+p]:mt-conversation-paragraph [&>ol]:space-y-conversation-list [&>ul]:space-y-conversation-list [&>*+h1]:mt-3.5 [&>*+h2]:mt-3.5 [&>*+h3]:mt-3.5 [&>*+h4]:mt-3.5 [&>*+h5]:mt-3.5 [&>*+h6]:mt-3.5 [&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5 [&>h4+*]:mt-0.5 [&>h5+*]:mt-0.5 [&>h6+*]:mt-0.5 [&>h1+h2]:mt-1.5! [&>h2+h3]:mt-1.5! [&>h3+h4]:mt-1.5! [&>h4+h5]:mt-1.5! [&>h5+h6]:mt-1.5! [&>*+blockquote]:mt-3.5 [&>blockquote+*]:mt-3.5 [&>*+[data-code-block]]:mt-3.5 [&>[data-code-block]+*]:mt-3.5 [&>*+[data-table-block]]:mt-3.5 [&>[data-table-block]+*]:mt-3.5 [&>*+hr]:mt-4 [&>hr+*]:mt-4 [&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5 leading-5\">

please summarize the plan

Open in chat
12:00 PM
Thinking
*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-3 [&>p+p]:mt-conversation-paragraph [&>ol]:space-y-conversation-list [&>ul]:space-y-conversation-list [&>*+h1]:mt-3.5 [&>*+h2]:mt-3.5 [&>*+h3]:mt-3.5 [&>*+h4]:mt-3.5 [&>*+h5]:mt-3.5 [&>*+h6]:mt-3.5 [&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5 [&>h4+*]:mt-0.5 [&>h5+*]:mt-0.5 [&>h6+*]:mt-0.5 [&>h1+h2]:mt-1.5! [&>h2+h3]:mt-1.5! [&>h3+h4]:mt-1.5! [&>h4+h5]:mt-1.5! [&>h5+h6]:mt-1.5! [&>*+blockquote]:mt-3.5 [&>blockquote+*]:mt-3.5 [&>*+[data-code-block]]:mt-3.5 [&>[data-code-block]+*]:mt-3.5 [&>*+[data-table-block]]:mt-3.5 [&>[data-table-block]+*]:mt-3.5 [&>*+hr]:mt-4 [&>hr+*]:mt-4 [&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5 leading-5\">

weighing the options

Updatedplan
*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-3 [&>p+p]:mt-conversation-paragraph [&>ol]:space-y-conversation-list [&>ul]:space-y-conversation-list [&>*+h1]:mt-3.5 [&>*+h2]:mt-3.5 [&>*+h3]:mt-3.5 [&>*+h4]:mt-3.5 [&>*+h5]:mt-3.5 [&>*+h6]:mt-3.5 [&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5 [&>h4+*]:mt-0.5 [&>h5+*]:mt-0.5 [&>h6+*]:mt-0.5 [&>h1+h2]:mt-1.5! [&>h2+h3]:mt-1.5! [&>h3+h4]:mt-1.5! [&>h4+h5]:mt-1.5! [&>h5+h6]:mt-1.5! [&>*+blockquote]:mt-3.5 [&>blockquote+*]:mt-3.5 [&>*+[data-code-block]]:mt-3.5 [&>[data-code-block]+*]:mt-3.5 [&>*+[data-table-block]]:mt-3.5 [&>[data-table-block]+*]:mt-3.5 [&>*+hr]:mt-4 [&>hr+*]:mt-4 [&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5 leading-5\">
    \n
  • read the transcript
  • \n
  • write the summary (in progress)
  • \n
  • ship it
  • \n
*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-3 [&>p+p]:mt-conversation-paragraph [&>ol]:space-y-conversation-list [&>ul]:space-y-conversation-list [&>*+h1]:mt-3.5 [&>*+h2]:mt-3.5 [&>*+h3]:mt-3.5 [&>*+h4]:mt-3.5 [&>*+h5]:mt-3.5 [&>*+h6]:mt-3.5 [&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5 [&>h4+*]:mt-0.5 [&>h5+*]:mt-0.5 [&>h6+*]:mt-0.5 [&>h1+h2]:mt-1.5! [&>h2+h3]:mt-1.5! [&>h3+h4]:mt-1.5! [&>h4+h5]:mt-1.5! [&>h5+h6]:mt-1.5! [&>*+blockquote]:mt-3.5 [&>blockquote+*]:mt-3.5 [&>*+[data-code-block]]:mt-3.5 [&>[data-code-block]+*]:mt-3.5 [&>*+[data-table-block]]:mt-3.5 [&>[data-table-block]+*]:mt-3.5 [&>*+hr]:mt-4 [&>hr+*]:mt-4 [&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5 leading-5\">

Here is the summary with code.

", - "compactPreview": "
*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-3 [&>p+p]:mt-conversation-paragraph [&>ol]:space-y-conversation-list [&>ul]:space-y-conversation-list [&>*+h1]:mt-3.5 [&>*+h2]:mt-3.5 [&>*+h3]:mt-3.5 [&>*+h4]:mt-3.5 [&>*+h5]:mt-3.5 [&>*+h6]:mt-3.5 [&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5 [&>h4+*]:mt-0.5 [&>h5+*]:mt-0.5 [&>h6+*]:mt-0.5 [&>h1+h2]:mt-1.5! [&>h2+h3]:mt-1.5! [&>h3+h4]:mt-1.5! [&>h4+h5]:mt-1.5! [&>h5+h6]:mt-1.5! [&>*+blockquote]:mt-3.5 [&>blockquote+*]:mt-3.5 [&>*+[data-code-block]]:mt-3.5 [&>[data-code-block]+*]:mt-3.5 [&>*+[data-table-block]]:mt-3.5 [&>[data-table-block]+*]:mt-3.5 [&>*+hr]:mt-4 [&>hr+*]:mt-4 [&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5 text-xs leading-4\">

please summarize the plan

12:00 PM
Thinking
*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-3 [&>p+p]:mt-conversation-paragraph [&>ol]:space-y-conversation-list [&>ul]:space-y-conversation-list [&>*+h1]:mt-3.5 [&>*+h2]:mt-3.5 [&>*+h3]:mt-3.5 [&>*+h4]:mt-3.5 [&>*+h5]:mt-3.5 [&>*+h6]:mt-3.5 [&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5 [&>h4+*]:mt-0.5 [&>h5+*]:mt-0.5 [&>h6+*]:mt-0.5 [&>h1+h2]:mt-1.5! [&>h2+h3]:mt-1.5! [&>h3+h4]:mt-1.5! [&>h4+h5]:mt-1.5! [&>h5+h6]:mt-1.5! [&>*+blockquote]:mt-3.5 [&>blockquote+*]:mt-3.5 [&>*+[data-code-block]]:mt-3.5 [&>[data-code-block]+*]:mt-3.5 [&>*+[data-table-block]]:mt-3.5 [&>[data-table-block]+*]:mt-3.5 [&>*+hr]:mt-4 [&>hr+*]:mt-4 [&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5 leading-5\">

weighing the options

Updatedplan
*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-3 [&>p+p]:mt-conversation-paragraph [&>ol]:space-y-conversation-list [&>ul]:space-y-conversation-list [&>*+h1]:mt-3.5 [&>*+h2]:mt-3.5 [&>*+h3]:mt-3.5 [&>*+h4]:mt-3.5 [&>*+h5]:mt-3.5 [&>*+h6]:mt-3.5 [&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5 [&>h4+*]:mt-0.5 [&>h5+*]:mt-0.5 [&>h6+*]:mt-0.5 [&>h1+h2]:mt-1.5! [&>h2+h3]:mt-1.5! [&>h3+h4]:mt-1.5! [&>h4+h5]:mt-1.5! [&>h5+h6]:mt-1.5! [&>*+blockquote]:mt-3.5 [&>blockquote+*]:mt-3.5 [&>*+[data-code-block]]:mt-3.5 [&>[data-code-block]+*]:mt-3.5 [&>*+[data-table-block]]:mt-3.5 [&>[data-table-block]+*]:mt-3.5 [&>*+hr]:mt-4 [&>hr+*]:mt-4 [&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5 leading-5\">
    \n
  • read the transcript
  • \n
  • write the summary (in progress)
  • \n
  • ship it
  • \n
*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-3 [&>p+p]:mt-conversation-paragraph [&>ol]:space-y-conversation-list [&>ul]:space-y-conversation-list [&>*+h1]:mt-3.5 [&>*+h2]:mt-3.5 [&>*+h3]:mt-3.5 [&>*+h4]:mt-3.5 [&>*+h5]:mt-3.5 [&>*+h6]:mt-3.5 [&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5 [&>h4+*]:mt-0.5 [&>h5+*]:mt-0.5 [&>h6+*]:mt-0.5 [&>h1+h2]:mt-1.5! [&>h2+h3]:mt-1.5! [&>h3+h4]:mt-1.5! [&>h4+h5]:mt-1.5! [&>h5+h6]:mt-1.5! [&>*+blockquote]:mt-3.5 [&>blockquote+*]:mt-3.5 [&>*+[data-code-block]]:mt-3.5 [&>[data-code-block]+*]:mt-3.5 [&>*+[data-table-block]]:mt-3.5 [&>[data-table-block]+*]:mt-3.5 [&>*+hr]:mt-4 [&>hr+*]:mt-4 [&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5 text-xs leading-4\">

Here is the summary with code.

" + "default": "
*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-3 [&>p+p]:mt-conversation-paragraph [&>ol]:space-y-conversation-list [&>ul]:space-y-conversation-list [&>*+h1]:mt-3.5 [&>*+h2]:mt-3.5 [&>*+h3]:mt-3.5 [&>*+h4]:mt-3.5 [&>*+h5]:mt-3.5 [&>*+h6]:mt-3.5 [&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5 [&>h4+*]:mt-0.5 [&>h5+*]:mt-0.5 [&>h6+*]:mt-0.5 [&>h1+h2]:mt-1.5! [&>h2+h3]:mt-1.5! [&>h3+h4]:mt-1.5! [&>h4+h5]:mt-1.5! [&>h5+h6]:mt-1.5! [&>*+blockquote]:mt-3.5 [&>blockquote+*]:mt-3.5 [&>*+[data-code-block]]:mt-3.5 [&>[data-code-block]+*]:mt-3.5 [&>*+[data-table-block]]:mt-3.5 [&>[data-table-block]+*]:mt-3.5 [&>*+hr]:mt-4 [&>hr+*]:mt-4 [&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5 leading-5\">

please summarize the plan

Open in chat
Thinking
*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-3 [&>p+p]:mt-conversation-paragraph [&>ol]:space-y-conversation-list [&>ul]:space-y-conversation-list [&>*+h1]:mt-3.5 [&>*+h2]:mt-3.5 [&>*+h3]:mt-3.5 [&>*+h4]:mt-3.5 [&>*+h5]:mt-3.5 [&>*+h6]:mt-3.5 [&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5 [&>h4+*]:mt-0.5 [&>h5+*]:mt-0.5 [&>h6+*]:mt-0.5 [&>h1+h2]:mt-1.5! [&>h2+h3]:mt-1.5! [&>h3+h4]:mt-1.5! [&>h4+h5]:mt-1.5! [&>h5+h6]:mt-1.5! [&>*+blockquote]:mt-3.5 [&>blockquote+*]:mt-3.5 [&>*+[data-code-block]]:mt-3.5 [&>[data-code-block]+*]:mt-3.5 [&>*+[data-table-block]]:mt-3.5 [&>[data-table-block]+*]:mt-3.5 [&>*+hr]:mt-4 [&>hr+*]:mt-4 [&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5 leading-5\">

weighing the options

Updatedplan
*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-3 [&>p+p]:mt-conversation-paragraph [&>ol]:space-y-conversation-list [&>ul]:space-y-conversation-list [&>*+h1]:mt-3.5 [&>*+h2]:mt-3.5 [&>*+h3]:mt-3.5 [&>*+h4]:mt-3.5 [&>*+h5]:mt-3.5 [&>*+h6]:mt-3.5 [&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5 [&>h4+*]:mt-0.5 [&>h5+*]:mt-0.5 [&>h6+*]:mt-0.5 [&>h1+h2]:mt-1.5! [&>h2+h3]:mt-1.5! [&>h3+h4]:mt-1.5! [&>h4+h5]:mt-1.5! [&>h5+h6]:mt-1.5! [&>*+blockquote]:mt-3.5 [&>blockquote+*]:mt-3.5 [&>*+[data-code-block]]:mt-3.5 [&>[data-code-block]+*]:mt-3.5 [&>*+[data-table-block]]:mt-3.5 [&>[data-table-block]+*]:mt-3.5 [&>*+hr]:mt-4 [&>hr+*]:mt-4 [&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5 leading-5\">
    \n
  • read the transcript
  • \n
  • write the summary (in progress)
  • \n
  • ship it
  • \n
Rancargo test1.0s

cargo test

Permission requested · write src/main.rs
Options: Allow, Deny
Approved (once)
Context compacted
*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-3 [&>p+p]:mt-conversation-paragraph [&>ol]:space-y-conversation-list [&>ul]:space-y-conversation-list [&>*+h1]:mt-3.5 [&>*+h2]:mt-3.5 [&>*+h3]:mt-3.5 [&>*+h4]:mt-3.5 [&>*+h5]:mt-3.5 [&>*+h6]:mt-3.5 [&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5 [&>h4+*]:mt-0.5 [&>h5+*]:mt-0.5 [&>h6+*]:mt-0.5 [&>h1+h2]:mt-1.5! [&>h2+h3]:mt-1.5! [&>h3+h4]:mt-1.5! [&>h4+h5]:mt-1.5! [&>h5+h6]:mt-1.5! [&>*+blockquote]:mt-3.5 [&>blockquote+*]:mt-3.5 [&>*+[data-code-block]]:mt-3.5 [&>[data-code-block]+*]:mt-3.5 [&>*+[data-table-block]]:mt-3.5 [&>[data-table-block]+*]:mt-3.5 [&>*+hr]:mt-4 [&>hr+*]:mt-4 [&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5 leading-5\">

Here is the summary with code.

Turn failed · the harness exited
Most recent observed session · 6/14/2026, 12:05:00 PM
*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-3 [&>p+p]:mt-conversation-paragraph [&>ol]:space-y-conversation-list [&>ul]:space-y-conversation-list [&>*+h1]:mt-3.5 [&>*+h2]:mt-3.5 [&>*+h3]:mt-3.5 [&>*+h4]:mt-3.5 [&>*+h5]:mt-3.5 [&>*+h6]:mt-3.5 [&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5 [&>h4+*]:mt-0.5 [&>h5+*]:mt-0.5 [&>h6+*]:mt-0.5 [&>h1+h2]:mt-1.5! [&>h2+h3]:mt-1.5! [&>h3+h4]:mt-1.5! [&>h4+h5]:mt-1.5! [&>h5+h6]:mt-1.5! [&>*+blockquote]:mt-3.5 [&>blockquote+*]:mt-3.5 [&>*+[data-code-block]]:mt-3.5 [&>[data-code-block]+*]:mt-3.5 [&>*+[data-table-block]]:mt-3.5 [&>[data-table-block]+*]:mt-3.5 [&>*+hr]:mt-4 [&>hr+*]:mt-4 [&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5 leading-5\">

next task

Open in chat
12:05 PM
*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-3 [&>p+p]:mt-conversation-paragraph [&>ol]:space-y-conversation-list [&>ul]:space-y-conversation-list [&>*+h1]:mt-3.5 [&>*+h2]:mt-3.5 [&>*+h3]:mt-3.5 [&>*+h4]:mt-3.5 [&>*+h5]:mt-3.5 [&>*+h6]:mt-3.5 [&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5 [&>h4+*]:mt-0.5 [&>h5+*]:mt-0.5 [&>h6+*]:mt-0.5 [&>h1+h2]:mt-1.5! [&>h2+h3]:mt-1.5! [&>h3+h4]:mt-1.5! [&>h4+h5]:mt-1.5! [&>h5+h6]:mt-1.5! [&>*+blockquote]:mt-3.5 [&>blockquote+*]:mt-3.5 [&>*+[data-code-block]]:mt-3.5 [&>[data-code-block]+*]:mt-3.5 [&>*+[data-table-block]]:mt-3.5 [&>[data-table-block]+*]:mt-3.5 [&>*+hr]:mt-4 [&>hr+*]:mt-4 [&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5 leading-5\">

on it

", + "compactPreview": "
*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-3 [&>p+p]:mt-conversation-paragraph [&>ol]:space-y-conversation-list [&>ul]:space-y-conversation-list [&>*+h1]:mt-3.5 [&>*+h2]:mt-3.5 [&>*+h3]:mt-3.5 [&>*+h4]:mt-3.5 [&>*+h5]:mt-3.5 [&>*+h6]:mt-3.5 [&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5 [&>h4+*]:mt-0.5 [&>h5+*]:mt-0.5 [&>h6+*]:mt-0.5 [&>h1+h2]:mt-1.5! [&>h2+h3]:mt-1.5! [&>h3+h4]:mt-1.5! [&>h4+h5]:mt-1.5! [&>h5+h6]:mt-1.5! [&>*+blockquote]:mt-3.5 [&>blockquote+*]:mt-3.5 [&>*+[data-code-block]]:mt-3.5 [&>[data-code-block]+*]:mt-3.5 [&>*+[data-table-block]]:mt-3.5 [&>[data-table-block]+*]:mt-3.5 [&>*+hr]:mt-4 [&>hr+*]:mt-4 [&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5 text-xs leading-4\">

please summarize the plan

Thinking
*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-3 [&>p+p]:mt-conversation-paragraph [&>ol]:space-y-conversation-list [&>ul]:space-y-conversation-list [&>*+h1]:mt-3.5 [&>*+h2]:mt-3.5 [&>*+h3]:mt-3.5 [&>*+h4]:mt-3.5 [&>*+h5]:mt-3.5 [&>*+h6]:mt-3.5 [&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5 [&>h4+*]:mt-0.5 [&>h5+*]:mt-0.5 [&>h6+*]:mt-0.5 [&>h1+h2]:mt-1.5! [&>h2+h3]:mt-1.5! [&>h3+h4]:mt-1.5! [&>h4+h5]:mt-1.5! [&>h5+h6]:mt-1.5! [&>*+blockquote]:mt-3.5 [&>blockquote+*]:mt-3.5 [&>*+[data-code-block]]:mt-3.5 [&>[data-code-block]+*]:mt-3.5 [&>*+[data-table-block]]:mt-3.5 [&>[data-table-block]+*]:mt-3.5 [&>*+hr]:mt-4 [&>hr+*]:mt-4 [&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5 leading-5\">

weighing the options

Updatedplan
*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-3 [&>p+p]:mt-conversation-paragraph [&>ol]:space-y-conversation-list [&>ul]:space-y-conversation-list [&>*+h1]:mt-3.5 [&>*+h2]:mt-3.5 [&>*+h3]:mt-3.5 [&>*+h4]:mt-3.5 [&>*+h5]:mt-3.5 [&>*+h6]:mt-3.5 [&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5 [&>h4+*]:mt-0.5 [&>h5+*]:mt-0.5 [&>h6+*]:mt-0.5 [&>h1+h2]:mt-1.5! [&>h2+h3]:mt-1.5! [&>h3+h4]:mt-1.5! [&>h4+h5]:mt-1.5! [&>h5+h6]:mt-1.5! [&>*+blockquote]:mt-3.5 [&>blockquote+*]:mt-3.5 [&>*+[data-code-block]]:mt-3.5 [&>[data-code-block]+*]:mt-3.5 [&>*+[data-table-block]]:mt-3.5 [&>[data-table-block]+*]:mt-3.5 [&>*+hr]:mt-4 [&>hr+*]:mt-4 [&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5 leading-5\">
    \n
  • read the transcript
  • \n
  • write the summary (in progress)
  • \n
  • ship it
  • \n
Rancargo test1.0s

cargo test

Permission requested · write src/main.rs
Options: Allow, Deny
Approved (once)
Context compacted
*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-3 [&>p+p]:mt-conversation-paragraph [&>ol]:space-y-conversation-list [&>ul]:space-y-conversation-list [&>*+h1]:mt-3.5 [&>*+h2]:mt-3.5 [&>*+h3]:mt-3.5 [&>*+h4]:mt-3.5 [&>*+h5]:mt-3.5 [&>*+h6]:mt-3.5 [&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5 [&>h4+*]:mt-0.5 [&>h5+*]:mt-0.5 [&>h6+*]:mt-0.5 [&>h1+h2]:mt-1.5! [&>h2+h3]:mt-1.5! [&>h3+h4]:mt-1.5! [&>h4+h5]:mt-1.5! [&>h5+h6]:mt-1.5! [&>*+blockquote]:mt-3.5 [&>blockquote+*]:mt-3.5 [&>*+[data-code-block]]:mt-3.5 [&>[data-code-block]+*]:mt-3.5 [&>*+[data-table-block]]:mt-3.5 [&>[data-table-block]+*]:mt-3.5 [&>*+hr]:mt-4 [&>hr+*]:mt-4 [&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5 text-xs leading-4\">

Here is the summary with code.

Turn failed · the harness exited
Most recent observed session · 6/14/2026, 12:05:00 PM
*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-3 [&>p+p]:mt-conversation-paragraph [&>ol]:space-y-conversation-list [&>ul]:space-y-conversation-list [&>*+h1]:mt-3.5 [&>*+h2]:mt-3.5 [&>*+h3]:mt-3.5 [&>*+h4]:mt-3.5 [&>*+h5]:mt-3.5 [&>*+h6]:mt-3.5 [&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5 [&>h4+*]:mt-0.5 [&>h5+*]:mt-0.5 [&>h6+*]:mt-0.5 [&>h1+h2]:mt-1.5! [&>h2+h3]:mt-1.5! [&>h3+h4]:mt-1.5! [&>h4+h5]:mt-1.5! [&>h5+h6]:mt-1.5! [&>*+blockquote]:mt-3.5 [&>blockquote+*]:mt-3.5 [&>*+[data-code-block]]:mt-3.5 [&>[data-code-block]+*]:mt-3.5 [&>*+[data-table-block]]:mt-3.5 [&>[data-table-block]+*]:mt-3.5 [&>*+hr]:mt-4 [&>hr+*]:mt-4 [&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5 text-xs leading-4\">

next task

12:05 PM
*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-3 [&>p+p]:mt-conversation-paragraph [&>ol]:space-y-conversation-list [&>ul]:space-y-conversation-list [&>*+h1]:mt-3.5 [&>*+h2]:mt-3.5 [&>*+h3]:mt-3.5 [&>*+h4]:mt-3.5 [&>*+h5]:mt-3.5 [&>*+h6]:mt-3.5 [&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5 [&>h4+*]:mt-0.5 [&>h5+*]:mt-0.5 [&>h6+*]:mt-0.5 [&>h1+h2]:mt-1.5! [&>h2+h3]:mt-1.5! [&>h3+h4]:mt-1.5! [&>h4+h5]:mt-1.5! [&>h5+h6]:mt-1.5! [&>*+blockquote]:mt-3.5 [&>blockquote+*]:mt-3.5 [&>*+[data-code-block]]:mt-3.5 [&>[data-code-block]+*]:mt-3.5 [&>*+[data-table-block]]:mt-3.5 [&>[data-table-block]+*]:mt-3.5 [&>*+hr]:mt-4 [&>hr+*]:mt-4 [&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5 text-xs leading-4\">

on it

" } diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs index f9c2507606..0dc89acffc 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs @@ -5,12 +5,14 @@ * (variant context + derived turn meta) is exercised end to end rather than * asserting against re-implemented render classes. * - * The last test is the important one: `conversation` is purely additive, so the - * `default` and `compactPreview` markup for the same transcript must be - * byte-identical to the markup captured before the variant existed. That - * snapshot lives in AgentSessionTranscriptList.conversation.baseline.json and - * was produced by mounting this same transcript on the pre-change commit - * (f79421673) — regenerate it only when a deliberate change to the other + * The byte-for-byte tests at the bottom are the important ones: `conversation` + * is purely additive, so the `default` and `compactPreview` markup for the same + * transcript must be byte-identical to the markup captured before the variant + * existed. That snapshot lives in + * AgentSessionTranscriptList.conversation.baseline.json and was produced by + * mounting `baselineItems()` — a transcript containing every renderable item + * kind across two sessions — on pre-change main (074561233) in a clean + * throwaway worktree. Regenerate it only when a deliberate change to the other * variants is being made. */ @@ -41,10 +43,13 @@ class NoopObserver { } Object.assign(globalThis, { + Element: dom.window.Element, + Event: dom.window.Event, HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true, IntersectionObserver: NoopObserver, MutationObserver: dom.window.MutationObserver, + Node: dom.window.Node, ResizeObserver: NoopObserver, document: dom.window.document, getComputedStyle: (...args) => dom.window.getComputedStyle(...args), @@ -67,9 +72,11 @@ dom.window.cancelAnimationFrame = (id) => clearTimeout(id); globalThis.requestAnimationFrame = dom.window.requestAnimationFrame; globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame; +let act; let cleanup; let render; let createElement; +let useState; let createMemoryHistory; let createRootRoute; let createRouter; @@ -131,6 +138,163 @@ function items() { ]; } +/** + * Everything the legacy variants can render, in one transcript. + * + * The byte-for-byte contract covers `default`/`compactPreview` for EVERY item + * kind, so the baseline input has to contain every kind rather than the happy + * path: prompt (with prompt context and setup lifecycle so the ingress chrome + * renders), assistant message, thought, plan, a tool item, ordinary lifecycle + * status, error, permission — across two sessions so a session-boundary divider + * is forced too. Where `compactPreview` deliberately suppresses a kind, that + * absence is captured in the fixture and is therefore also protected. + * + * Single tool item on purpose: a run of three would collapse into a grouped + * summary and the leaf tool row would never be captured. + */ +function baselineItems() { + const first = { channelId: "chan-1", sessionId: "sess-1", turnId: "turn-1" }; + const second = { channelId: "chan-1", sessionId: "sess-2", turnId: "turn-2" }; + return [ + { + ...first, + id: "life:setup", + type: "lifecycle", + renderClass: "status", + title: "Turn started", + text: "1 trigger", + timestamp: "2026-06-14T19:00:00.000Z", + acpSource: "turn_started", + }, + { + ...first, + id: "meta:context", + type: "metadata", + renderClass: "raw-rail", + title: "Prompt context", + sections: [{ title: "Channel", body: "engineering" }], + timestamp: "2026-06-14T19:00:00.500Z", + acpSource: "session/prompt:context", + }, + { + ...first, + id: "msg:user", + type: "message", + renderClass: "message", + role: "user", + title: "Ada", + text: "please summarize the plan", + timestamp: "2026-06-14T19:00:01.000Z", + messageId: "event-1", + authorPubkey: AUTHOR, + acpSource: "session/prompt:user", + }, + { + ...first, + id: "thought:1", + type: "thought", + renderClass: "thought", + title: "Thinking", + text: "weighing the options", + timestamp: "2026-06-14T19:00:02.000Z", + }, + { + ...first, + id: "plan:1", + type: "plan", + renderClass: "plan", + title: "Plan", + text: "- [x] read the transcript\n- [ ] write the summary (in progress)\n- [ ] ship it", + timestamp: "2026-06-14T19:00:03.000Z", + }, + { + ...first, + id: "tool:1", + type: "tool", + renderClass: "shell", + descriptor: { + renderClass: "shell", + label: "Ran a command", + preview: "cargo test", + tone: "neutral", + source: "shell", + }, + title: "Ran a command", + toolName: "shell", + buzzToolName: null, + status: "completed", + args: { command: "cargo test" }, + result: "ok", + isError: false, + timestamp: "2026-06-14T19:00:04.000Z", + startedAt: "2026-06-14T19:00:04.000Z", + completedAt: "2026-06-14T19:00:05.000Z", + }, + { + ...first, + id: "life:permission", + type: "lifecycle", + renderClass: "permission", + title: "Permission requested", + text: "write src/main.rs\nOptions: Allow, Deny", + outcome: "Approved (once)", + timestamp: "2026-06-14T19:00:06.000Z", + }, + { + ...first, + id: "life:status", + type: "lifecycle", + renderClass: "status", + title: "Context compacted", + text: "", + timestamp: "2026-06-14T19:00:07.000Z", + }, + { + ...first, + id: "msg:assistant", + type: "message", + renderClass: "message", + role: "assistant", + title: "Test Agent", + text: "Here is the summary with `code`.", + timestamp: "2026-06-14T19:00:08.000Z", + }, + { + ...first, + id: "life:error", + type: "lifecycle", + renderClass: "error", + title: "Turn failed", + text: "the harness exited", + timestamp: "2026-06-14T19:00:09.000Z", + }, + // Second session run: forces a session-boundary divider between the runs. + { + ...second, + id: "msg:user2", + type: "message", + renderClass: "message", + role: "user", + title: "Ada", + text: "next task", + timestamp: "2026-06-14T19:05:00.000Z", + messageId: "event-2", + authorPubkey: AUTHOR, + acpSource: "session/prompt:user", + }, + { + ...second, + id: "msg:assistant2", + type: "message", + renderClass: "message", + role: "assistant", + title: "Test Agent", + text: "on it", + timestamp: "2026-06-14T19:05:01.000Z", + }, + ]; +} + async function renderTranscript(variant, overrides = {}) { const rootRoute = createRootRoute({ component: () => @@ -150,9 +314,44 @@ async function renderTranscript(variant, overrides = {}) { return render(createElement(RouterProvider, { router })); } +/** + * Same mount, but the caller can swap the list props afterwards. Needed for the + * contracts that are only visible across a rerender: a streaming thought + * folding once the turn moves on, and a plan mutating in place. + */ +async function renderRerenderableTranscript(variant, initialOverrides = {}) { + let applyProps; + const Harness = () => { + const [overrides, setOverrides] = useState(initialOverrides); + applyProps = setOverrides; + return createElement(AgentSessionTranscriptList, { + ...AGENT, + emptyDescription: "nothing yet", + items: items(), + variant, + ...overrides, + }); + }; + const rootRoute = createRootRoute({ component: Harness }); + const router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/"] }), + routeTree: rootRoute, + }); + await router.load(); + const utils = render(createElement(RouterProvider, { router })); + return { + ...utils, + async setOverrides(next) { + await act(async () => { + applyProps(next); + }); + }, + }; +} + before(async () => { - ({ cleanup, render } = await import("@testing-library/react")); - ({ createElement } = await import("react")); + ({ act, cleanup, render } = await import("@testing-library/react")); + ({ createElement, useState } = await import("react")); ({ createMemoryHistory, createRootRoute, createRouter, RouterProvider } = await import("@tanstack/react-router")); ({ AgentSessionTranscriptList } = await import( @@ -258,6 +457,109 @@ test("conversation auto-opens the thought disclosure while it is streaming", asy assert.doesNotMatch(summary.textContent, /Thought for/); }); +test("conversation folds the thought when the turn moves on, even after the browser echoes the auto-open toggle", async () => { + // Regression guard for the programmatic-toggle echo trap: a real browser fires + // `toggle` when React flips `open`, so the auto-open for a streaming thought + // arrives back at the component as if the reader had clicked. JSDOM does not + // fire that event itself, so the test injects it. If the handler records it as + // a reader choice, the disclosure stays pinned open forever and reasoning + // never recedes once the agent acts. + syncAgentTurnsFromEvents(AGENT.agentPubkey, [ + { + seq: 1, + timestamp: "2026-06-14T19:00:02.000Z", + kind: "turn_started", + agentIndex: 0, + channelId: "chan-1", + sessionId: "sess-1", + turnId: "turn-1", + payload: null, + }, + ]); + const streaming = { channelId: "chan-1", items: items().slice(0, 2) }; + const { container, setOverrides } = await renderRerenderableTranscript( + "conversation", + streaming, + ); + + const disclosure = container.querySelector( + '[data-testid="transcript-thought-item"]', + ); + assert.equal(disclosure.open, true, "streaming thought should auto-open"); + + // The browser echo: `open` already agrees with what React rendered, and the + // event follows rather than causes that state. + await act(async () => { + disclosure.open = true; + disclosure.dispatchEvent(new dom.window.Event("toggle")); + }); + assert.equal( + disclosure.open, + true, + "the echo must not disturb the open tail", + ); + + // The turn produces its next item, so the thought is no longer the streaming + // tail and should recede. + await setOverrides({ ...streaming, items: items().slice(0, 3) }); + + const settled = container.querySelector( + '[data-testid="transcript-thought-item"]', + ); + assert.equal( + settled.open, + false, + "a thought the agent has acted on should fold again", + ); + assert.match( + settled + .querySelector('[data-testid="transcript-thought-disclosure"]') + .textContent.trim(), + /^Thought for 5s/, + ); +}); + +test("conversation keeps a reader-opened thought open after the turn moves on", async () => { + // The other half of the guard: a toggle that DISAGREES with the rendered state + // is a genuine reader choice and must win over the stream transition. + syncAgentTurnsFromEvents(AGENT.agentPubkey, [ + { + seq: 1, + timestamp: "2026-06-14T19:00:02.000Z", + kind: "turn_started", + agentIndex: 0, + channelId: "chan-1", + sessionId: "sess-1", + turnId: "turn-1", + payload: null, + }, + ]); + // Three items so the thought is not the streaming tail: it renders collapsed. + const settledItems = { channelId: "chan-1", items: items().slice(0, 3) }; + const { container, setOverrides } = await renderRerenderableTranscript( + "conversation", + settledItems, + ); + + const disclosure = container.querySelector( + '[data-testid="transcript-thought-item"]', + ); + assert.equal(disclosure.open, false, "a settled thought starts collapsed"); + + await act(async () => { + disclosure.open = true; + disclosure.dispatchEvent(new dom.window.Event("toggle")); + }); + + await setOverrides({ ...settledItems, items: items() }); + + assert.equal( + container.querySelector('[data-testid="transcript-thought-item"]').open, + true, + "the reader's choice should survive later transcript items", + ); +}); + test("conversation renders the plan as a checklist card with in-place progress", async () => { const { container } = await renderTranscript("conversation"); const card = container.querySelector('[data-testid="transcript-plan-item"]'); @@ -273,6 +575,46 @@ test("conversation renders the plan as a checklist card with in-place progress", assert.deepEqual(statuses, ["completed", "in_progress", "pending"]); }); +test("conversation updates the same plan card in place as entries advance", async () => { + // The transcript reducer mutates the plan item in place (same item id), so the + // card must be re-rendered rather than replaced: progress copy changes while + // the same DOM node is retained. A new node here would mean the reader's + // scroll position and any card-level state get thrown away on every update. + const { container, setOverrides } = + await renderRerenderableTranscript("conversation"); + const before = container.querySelector( + '[data-testid="transcript-plan-item"]', + ); + assert.equal( + before.querySelector('[data-testid="transcript-plan-progress"]') + .textContent, + "1/3 complete", + ); + + const advanced = items().map((item) => + item.id === "plan:1" + ? { + ...item, + text: "- [x] read the transcript\n- [x] write the summary\n- [ ] ship it (in progress)", + } + : item, + ); + await setOverrides({ items: advanced }); + + const after = container.querySelector('[data-testid="transcript-plan-item"]'); + assert.equal(after, before, "the same plan card node should be reused"); + assert.equal( + after.querySelector('[data-testid="transcript-plan-progress"]').textContent, + "2/3 complete", + ); + assert.deepEqual( + [...after.querySelectorAll("[data-plan-entry-status]")].map((entry) => + entry.getAttribute("data-plan-entry-status"), + ), + ["completed", "completed", "in_progress"], + ); +}); + test("conversation quiets session boundaries and status rows into centered dividers", async () => { const shared = { channelId: "chan-1", sessionId: "sess-2", turnId: "turn-2" }; const { container } = await renderTranscript("conversation", { @@ -376,7 +718,9 @@ test("conversation keeps errors and permission gates loud", async () => { test("default and compactPreview markup is unchanged by the conversation variant", async () => { for (const variant of ["default", "compactPreview"]) { - const { container } = await renderTranscript(variant); + const { container } = await renderTranscript(variant, { + items: baselineItems(), + }); assert.equal( container.innerHTML, BASELINE_MARKUP[variant], @@ -385,3 +729,34 @@ test("default and compactPreview markup is unchanged by the conversation variant cleanup(); } }); + +test("the byte-for-byte baseline actually exercises every renderable item kind", async () => { + // Guards the fixture itself: a baseline that silently stopped covering a kind + // would keep passing the comparison above while protecting nothing. Asserted + // against `default`, which renders every kind. + const { container } = await renderTranscript("default", { + items: baselineItems(), + }); + const present = (selector) => container.querySelector(selector) !== null; + for (const [kind, selector] of [ + ["prompt", '[data-testid="transcript-user-message"]'], + ["prompt setup", '[data-testid="transcript-turn-setup"]'], + ["assistant message", '[data-testid="transcript-assistant-message"]'], + ["thought", '[data-testid="transcript-thought-item"]'], + ["plan", '[data-testid="transcript-plan-item"]'], + ["tool", '[data-testid="transcript-tool-item"]'], + ["permission", '[data-testid="transcript-permission-item"]'], + ["lifecycle status/error", '[data-testid="transcript-lifecycle-item"]'], + ["session boundary", '[data-testid="session-boundary-divider"]'], + ]) { + assert.ok(present(selector), `baseline must cover ${kind}`); + } + // Status and error share the lifecycle testid, so check both are really there. + const lifecycleText = [ + ...container.querySelectorAll('[data-testid="transcript-lifecycle-item"]'), + ] + .map((node) => node.textContent) + .join("\n"); + assert.match(lifecycleText, /Context compacted/); + assert.match(lifecycleText, /Turn failed/); +}); diff --git a/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx index 260ab6b915..b5701b5fe3 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx @@ -77,13 +77,34 @@ function ConversationThought({ isStreaming, }); + // `
` fires `toggle` for programmatic `open` changes as well as + // clicks. Without this guard the auto-open for a streaming thought would be + // recorded as a reader choice and would then pin the disclosure open forever, + // so reasoning would never fold once the agent acted on it. Only a toggle + // that DISAGREES with the state we last rendered can have come from the + // reader. + // + // Same trap, same guard as the tool-run card's disclosure in the tool-chain + // slice; once that lands and extracts a shared controlled-details hook this + // should consume it rather than keeping a second copy. + const renderedOpenRef = React.useRef(isOpen); + React.useLayoutEffect(() => { + renderedOpenRef.current = isOpen; + }, [isOpen]); + const handleToggle = React.useCallback( + (event: React.SyntheticEvent) => { + const open = event.currentTarget.open; + if (open === renderedOpenRef.current) return; + setUserOpen(open); + }, + [], + ); + return (
{ - setUserOpen(event.currentTarget.open); - }} + onToggle={handleToggle} open={isOpen} title={formatTranscriptTimestampTitle(item.timestamp)} > diff --git a/desktop/src/features/agents/ui/agentSessionConversationMeta.test.mjs b/desktop/src/features/agents/ui/agentSessionConversationMeta.test.mjs index c8506c4266..9eb09c8558 100644 --- a/desktop/src/features/agents/ui/agentSessionConversationMeta.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionConversationMeta.test.mjs @@ -10,7 +10,6 @@ import { formatPlanChecklistProgress, parsePlanChecklist, } from "./agentSessionPlanChecklist.ts"; -import { resolveAgentSessionTranscriptVariant } from "./agentSessionTranscriptVariantChoice.ts"; function item(overrides) { return { @@ -161,6 +160,64 @@ test("buildConversationTurnMeta counts a prompt as an item and skips setup segme // ---- formatThoughtDisclosureLabel ---- +/** + * Product ruling (ss-core-02, Slice B review): reasoning folds as soon as the + * agent ACTS on it — the next leaf item of any kind — not only when the next + * assistant message arrives. A thought pinned open across a long tool run is + * exactly the noise the focus view removes. This test exists so the rule is + * explicit rather than an accident of `items[index + 1]`. + */ +test("buildConversationTurnMeta ends a thought at the next tool call, not the next message", () => { + const meta = buildConversationTurnMeta( + [ + turnBlock([ + { + kind: "item", + item: item({ + id: "thought:1", + type: "thought", + renderClass: "thought", + title: "Thinking", + text: "…", + timestamp: "2026-06-14T19:00:02.000Z", + }), + }, + { + kind: "item", + item: item({ + id: "tool:1", + type: "tool", + renderClass: "shell", + title: "Ran a command", + text: "", + descriptor: { label: "Ran a command", preview: "cargo test" }, + timestamp: "2026-06-14T19:00:06.000Z", + }), + }, + { + kind: "item", + item: item({ + id: "msg:1", + type: "message", + renderClass: "message", + role: "assistant", + title: "Agent", + text: "tests pass", + timestamp: "2026-06-14T19:01:30.000Z", + }), + }, + ]), + ], + { isTurnLive: true, variant: "conversation" }, + ); + + // 4s to the tool call, NOT 88s to the assistant message: the thought is done + // the moment the agent acts, so it folds instead of staying open for the run. + assert.deepEqual(meta.thoughtDurationSecondsById, { "thought:1": 4 }); + // The trailing message is the streaming tail, so the thought is not streaming. + assert.equal(meta.streamingItemId, "msg:1"); +}); + test("formatThoughtDisclosureLabel says Thinking while streaming", () => { assert.equal( formatThoughtDisclosureLabel({ durationSeconds: 4, isStreaming: true }), @@ -208,32 +265,3 @@ test("parsePlanChecklist yields nothing for free-form plan text", () => { assert.deepEqual(checklist.entries, []); assert.equal(formatPlanChecklistProgress(checklist), null); }); - -// ---- resolveAgentSessionTranscriptVariant ---- - -test("resolveAgentSessionTranscriptVariant opts a wide panel into conversation", () => { - assert.equal( - resolveAgentSessionTranscriptVariant({ - isSinglePanelView: false, - widthPx: 720, - }), - "conversation", - ); -}); - -test("resolveAgentSessionTranscriptVariant keeps narrow and single-panel layouts on default", () => { - assert.equal( - resolveAgentSessionTranscriptVariant({ - isSinglePanelView: false, - widthPx: 380, - }), - "default", - ); - assert.equal( - resolveAgentSessionTranscriptVariant({ - isSinglePanelView: true, - widthPx: 1200, - }), - "default", - ); -}); diff --git a/desktop/src/features/agents/ui/agentSessionConversationMeta.ts b/desktop/src/features/agents/ui/agentSessionConversationMeta.ts index b5aa0962da..d1e33b5825 100644 --- a/desktop/src/features/agents/ui/agentSessionConversationMeta.ts +++ b/desktop/src/features/agents/ui/agentSessionConversationMeta.ts @@ -40,8 +40,13 @@ function elapsedSeconds(from: string, to: string): number | null { * can be mid-stream, and only when the agent's turn is actually live. The * thought disclosure auto-opens for exactly that item, so the reader watches * reasoning arrive rather than clicking to find it. - * - **How long a thought ran.** A thought is finished once the turn produces a - * later item; the gap between the thought's timestamp and that next item's + * - **How long a thought ran.** A thought is finished once the turn produces + * ANY later leaf item — a tool call or plan update, not just the next + * assistant message. This is deliberate: reasoning recedes as soon as the + * agent *acts* on it, which is the moment the reader's attention should move + * to the action. Folding only on the next message would pin reasoning open + * across a long tool run, which is exactly the noise the focus view exists + * to remove. The gap between the thought's timestamp and that next item's * timestamp is the "Thought for Ns" duration. Thoughts with no following * item are still open, so they get no duration. * diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptVariantChoice.ts b/desktop/src/features/agents/ui/agentSessionTranscriptVariantChoice.ts deleted file mode 100644 index 55fd4621b9..0000000000 --- a/desktop/src/features/agents/ui/agentSessionTranscriptVariantChoice.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { AgentSessionTranscriptVariant } from "./agentSessionTranscriptContext"; - -/** - * Narrowest panel width that still reads as a conversation. - * - * The `conversation` variant lays the transcript out as a document: a centered - * reading column with right-aligned prompt bubbles capped well inside it. Below - * roughly this width the bubble/prose asymmetry collapses and the dense - * `default` activity feed is the more legible presentation, so the wide layout - * is the only one that opts in. - * - * Calibrated against `shared/layout/auxiliaryPanelLayout.ts`: the panel opens at - * `AUXILIARY_PANEL_DEFAULT_WIDTH_PX` (380) and clamps to at least - * `AUXILIARY_PANEL_MAX_WIDTH_PX` (720), so 640 sits deliberately in the top of - * that range — a panel at its default or merely-widened size keeps the feed, and - * only a reader who has pulled it near full width (or a full-cover host that - * pins the variant explicitly) gets the reading layout. - */ -export const CONVERSATION_TRANSCRIPT_MIN_WIDTH_PX = 640; - -/** - * Pick the transcript variant for an agent session panel from its layout. - * - * Callers may always override by passing an explicit `transcriptVariant`; this - * is only the default when they don't. Kept as a pure function so the threshold - * is testable without mounting the panel. - */ -export function resolveAgentSessionTranscriptVariant({ - isSinglePanelView, - widthPx, -}: { - isSinglePanelView: boolean; - widthPx: number; -}): AgentSessionTranscriptVariant { - // Single-panel view stacks the panel over the channel on a narrow viewport - // and renders it at 100% width, so `widthPx` (the stored/resizable width) - // does not describe what the reader sees. Keep the default feed there. - if (isSinglePanelView) { - return "default"; - } - - return widthPx >= CONVERSATION_TRANSCRIPT_MIN_WIDTH_PX - ? "conversation" - : "default"; -} diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index 6779517638..6065cce15e 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -17,7 +17,6 @@ import { } from "@/features/agents/ui/agentSessionPanelLayout"; import { deriveTranscriptBlockIds } from "@/features/agents/ui/agentSessionTranscriptGrouping"; import type { AgentSessionTranscriptVariant } from "@/features/agents/ui/agentSessionTranscriptContext"; -import { resolveAgentSessionTranscriptVariant } from "@/features/agents/ui/agentSessionTranscriptVariantChoice"; import type { ObserverEvent } from "@/features/agents/ui/agentSessionTypes"; import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel"; import { @@ -84,8 +83,12 @@ type AgentSessionThreadPanelProps = { widthPx: number; transparentChrome?: boolean; /** - * Overrides the transcript presentation. Omit to let the panel pick from its - * own layout — see `resolveAgentSessionTranscriptVariant`. + * Transcript presentation. Caller-pinned only: the panel keeps the dense + * activity feed unless a host explicitly asks for `conversation` (the + * full-cover focus view does). There is deliberately no width or layout + * heuristic here — an automatic wide-pane mode would be a separate product + * decision, and swapping the whole presentation as a reader drags a resize + * handle across a threshold is not one we want to make implicitly. */ transcriptVariant?: AgentSessionTranscriptVariant; }; @@ -102,16 +105,10 @@ export function AgentSessionThreadPanel({ onClose, widthPx, transparentChrome = false, - transcriptVariant, + transcriptVariant = "default", }: AgentSessionThreadPanelProps) { const isLive = isManagedAgentActive(agent); const isOverlay = useIsThreadPanelOverlay(); - // Wide/standalone reading layouts get the conversation transcript; narrow and - // single-panel layouts keep the dense activity feed. An explicit prop always - // wins so callers can pin a presentation. - const resolvedTranscriptVariant = - transcriptVariant ?? - resolveAgentSessionTranscriptVariant({ isSinglePanelView, widthPx }); const sessionChannelId = channelId ?? channel?.id ?? null; // Unified working signal, scoped to this panel's channel (or all channels // when the panel is unscoped) — observer turns primary, typing fallback. @@ -509,7 +506,7 @@ export function AgentSessionThreadPanel({ rawLayout="exclusive" showHeader={false} showRaw={showRawFeed} - transcriptVariant={resolvedTranscriptVariant} + transcriptVariant={transcriptVariant} />