From 4cfea5761d730aeaaaebef20abad8221f9f29911 Mon Sep 17 00:00:00 2001 From: Zero Liu Date: Fri, 3 Jul 2026 05:02:54 +0800 Subject: [PATCH] fix(agent-mode): stop folding the completed trail into "Worked for X" (#2652) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agent-mode): always render the completed trail inline Remove the "Worked for X" collapse that folded everything before the final text block once a turn cleanly ended. It hid content the user was mid-read on and broke reading flow; a finished turn now renders exactly like the live streaming view — nothing is hidden. Also drops the now-unused splitTrailingText helper and adds a regression test. Co-Authored-By: Claude Opus 4.8 * refactor(agent-mode): drop dead turnDurationMs plumbing With the "Worked for X" collapse removed, the turn's wall-clock duration is never read. Remove turnDurationMs end-to-end (session, store, and the persisted message type) and the turnStartedAt bookkeeping that existed solely to compute it. turnStopReason is fully preserved. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../session/AgentMessageStore.test.ts | 2 +- src/agentMode/session/AgentMessageStore.ts | 13 +-- src/agentMode/session/AgentSession.ts | 19 ++-- src/agentMode/session/types.ts | 6 -- src/agentMode/ui/AgentChatMessages.tsx | 1 - src/agentMode/ui/AgentTrailView.test.tsx | 25 +++++ src/agentMode/ui/AgentTrailView.tsx | 95 +------------------ src/agentMode/ui/agentTrail.test.ts | 50 +--------- src/agentMode/ui/agentTrail.ts | 29 ------ 9 files changed, 42 insertions(+), 198 deletions(-) diff --git a/src/agentMode/session/AgentMessageStore.test.ts b/src/agentMode/session/AgentMessageStore.test.ts index b09147d4..b0dfa14e 100644 --- a/src/agentMode/session/AgentMessageStore.test.ts +++ b/src/agentMode/session/AgentMessageStore.test.ts @@ -267,7 +267,7 @@ describe("AgentMessageStore", () => { const v2 = store.getDisplayMessages()[0]; expect(v2).not.toBe(v1); - store.markTurnComplete(id, "end_turn", 10); + store.markTurnComplete(id, "end_turn"); const v3 = store.getDisplayMessages()[0]; expect(v3).not.toBe(v2); expect(v3.turnStopReason).toBe("end_turn"); diff --git a/src/agentMode/session/AgentMessageStore.ts b/src/agentMode/session/AgentMessageStore.ts index 505e9933..7e4fdd60 100644 --- a/src/agentMode/session/AgentMessageStore.ts +++ b/src/agentMode/session/AgentMessageStore.ts @@ -31,7 +31,6 @@ interface StoredAgentMessage { context?: MessageContext; content?: unknown[]; turnStopReason?: StopReason; - turnDurationMs?: number; // Live per-agent fan-out state. In-memory only; the persisted body carries the // composite that reconstructs it on load. fanout?: FanoutTurn; @@ -234,7 +233,6 @@ export class AgentMessageStore { content: message.content, parts: message.parts, turnStopReason: message.turnStopReason, - turnDurationMs: message.turnDurationMs, version: 0, }); this.lastDisplay = null; @@ -242,16 +240,15 @@ export class AgentMessageStore { } /** - * Stamp a finished turn's `stopReason` and frozen `durationMs` onto its - * placeholder assistant message. Returns false if the message is missing or - * already marked complete — the latter lets callers skip notifying. + * Stamp a finished turn's `stopReason` onto its placeholder assistant message. + * Returns false if the message is missing or already marked complete — the + * latter lets callers skip notifying. */ - markTurnComplete(id: string, stopReason: StopReason, durationMs: number): boolean { + markTurnComplete(id: string, stopReason: StopReason): boolean { const msg = this.messages.find((m) => m.id === id); if (!msg) return false; if (msg.turnStopReason !== undefined) return false; msg.turnStopReason = stopReason; - msg.turnDurationMs = durationMs; this.touch(msg); return true; } @@ -461,7 +458,6 @@ export class AgentMessageStore { content: msg.content, parts: msg.parts, turnStopReason: msg.turnStopReason, - turnDurationMs: msg.turnDurationMs, fanout, version: 0, }); @@ -488,7 +484,6 @@ export class AgentMessageStore { content: m.content, parts: m.parts, turnStopReason: m.turnStopReason, - turnDurationMs: m.turnDurationMs, // Snapshot so each adapted view carries a fresh reference; the orchestrator // mutates one turn in place, so the same reference would freeze the dropdown. ...(m.fanout ? { fanout: snapshotFanoutTurn(m.fanout) } : {}), diff --git a/src/agentMode/session/AgentSession.ts b/src/agentMode/session/AgentSession.ts index 1f6cc8aa..fab55303 100644 --- a/src/agentMode/session/AgentSession.ts +++ b/src/agentMode/session/AgentSession.ts @@ -959,7 +959,6 @@ export class AgentSession { ): Promise { const placeholderId = this.placeholderId; const sessionId = this.backendSessionId!; - const turnStartedAt = Date.now(); try { // Extract live Web Viewer content (reader-mode markdown, YouTube // transcripts) just before the prompt is built so it reflects the page @@ -988,7 +987,7 @@ export class AgentSession { // gate can be bypassed (pasting a pill), so re-check entitlement here at // the session boundary. Paying users short-circuit; everyone else is hard-blocked. if (!(await this.ensureMultiAgentEntitlement())) { - return this.blockFanoutForEntitlement(placeholderId, turnStartedAt); + return this.blockFanoutForEntitlement(placeholderId); } // Give every fan-out agent the PRIOR visible transcript as a read-only @@ -1012,7 +1011,7 @@ export class AgentSession { // the next normal turn skip the block, permanently stripping the project // manifest from the main chat. Leaving it unset lets that turn deliver it // (and each ephemeral fan-out, being memoryless, re-receives it meanwhile). - return await this.runFanoutPath(placeholderId, displayText, promptBlocks, turnStartedAt); + return await this.runFanoutPath(placeholderId, displayText, promptBlocks); } // Single-agent path: prepend any buffered fan-out turns as one labeled @@ -1057,10 +1056,7 @@ export class AgentSession { ); this.store.markMessageError(placeholderId, message); } - if ( - placeholderId && - this.store.markTurnComplete(placeholderId, resp.stopReason, Date.now() - turnStartedAt) - ) { + if (placeholderId && this.store.markTurnComplete(placeholderId, resp.stopReason)) { this.notifyMessages(); } // Some backends flush the prompt result before the turn's last content @@ -1110,13 +1106,13 @@ export class AgentSession { * Clean up a paywall-blocked fan-out turn: surface the upgrade prompt and * finalize the placeholder as an error so no dangling bubble remains. */ - private blockFanoutForEntitlement(placeholderId: string, turnStartedAt: number): StopReason { + private blockFanoutForEntitlement(placeholderId: string): StopReason { showMultiAgentUpgradePrompt(); this.store.markMessageError( placeholderId, "Multi-agent QA is a Copilot Plus feature. Upgrade to mention more than one agent in a turn." ); - this.store.markTurnComplete(placeholderId, "refusal", Date.now() - turnStartedAt); + this.store.markTurnComplete(placeholderId, "refusal"); this.currentMessageIds = new Set(); if (this.placeholderId === placeholderId) this.placeholderId = null; this.notifyMessages(); @@ -1133,8 +1129,7 @@ export class AgentSession { private async runFanoutPath( placeholderId: string, originalPromptText: string, - promptBlocks: PromptContent[], - turnStartedAt: number + promptBlocks: PromptContent[] ): Promise { const signal = this.abortController?.signal ?? new AbortController().signal; const input: FanoutRunInput = { @@ -1181,7 +1176,7 @@ export class AgentSession { this.pendingFanoutContext.push({ question: originalPromptText, summary: replay }); } } - if (this.store.markTurnComplete(placeholderId, stopReason, Date.now() - turnStartedAt)) { + if (this.store.markTurnComplete(placeholderId, stopReason)) { this.notifyMessages(); } if (this.placeholderId === placeholderId) this.placeholderId = null; diff --git a/src/agentMode/session/types.ts b/src/agentMode/session/types.ts index 0b96213e..3feec1b7 100644 --- a/src/agentMode/session/types.ts +++ b/src/agentMode/session/types.ts @@ -888,12 +888,6 @@ export interface AgentChatMessage { * leave the trail uncollapsed so the user sees what happened. */ turnStopReason?: StopReason; - /** - * Wall-clock ms the turn took, frozen at `prompt()` resolution. Stored - * so re-renders don't shift the "Worked for X" label. Absent until the - * turn ends. - */ - turnDurationMs?: number; /** * Per-agent fan-out state when this assistant message is a multi-agent QA turn. * LIVE in-memory only — persistence rides in the body as a composite diff --git a/src/agentMode/ui/AgentChatMessages.tsx b/src/agentMode/ui/AgentChatMessages.tsx index f79561e5..62450d53 100644 --- a/src/agentMode/ui/AgentChatMessages.tsx +++ b/src/agentMode/ui/AgentChatMessages.tsx @@ -181,7 +181,6 @@ const AgentChatMessages = memo( showThinkingTail={message.id === streamingMessageId && showBottomLoader} app={app} turnStopReason={message.turnStopReason} - turnDurationMs={message.turnDurationMs} /> ) : ( diff --git a/src/agentMode/ui/AgentTrailView.test.tsx b/src/agentMode/ui/AgentTrailView.test.tsx index 18ab74d2..985a01a6 100644 --- a/src/agentMode/ui/AgentTrailView.test.tsx +++ b/src/agentMode/ui/AgentTrailView.test.tsx @@ -107,3 +107,28 @@ describe("AgentTrail copy / insert actions", () => { expect(screen.queryByTitle("Insert / Replace at cursor")).toBeNull(); }); }); + +describe("AgentTrail inline trail (no collapse)", () => { + beforeEach(() => { + (window as unknown as { activeDocument: Document }).activeDocument = window.document; + }); + + it("renders research inline with no 'Worked for' toggle on a completed research+answer turn", () => { + renderTrail({ + parts: [ + // Multi-word title with no vendorToolName renders verbatim as the + // ActionCard's collapsed line (GENERIC_SUMMARY → genericToolLabel). + { kind: "tool_call", id: "t1", title: "Search vault", status: "completed" }, + text("The final answer."), + ], + turnStopReason: "end_turn", + }); + + // The "Worked for X" collapse is gone: the whole trail renders inline. + expect(screen.queryByText(/Worked for/i)).toBeNull(); + // The trailing prose renders as the final answer. + expect(screen.getByText("The final answer.")).toBeTruthy(); + // The research tool card renders inline (not folded behind a toggle). + expect(screen.getByText("Search vault")).toBeTruthy(); + }); +}); diff --git a/src/agentMode/ui/AgentTrailView.tsx b/src/agentMode/ui/AgentTrailView.tsx index 04c46de7..f83af4d5 100644 --- a/src/agentMode/ui/AgentTrailView.tsx +++ b/src/agentMode/ui/AgentTrailView.tsx @@ -1,10 +1,5 @@ -import React, { useState } from "react"; -import { - agentResponseText, - buildAgentTrail, - splitTrailingText, - type RenderNode, -} from "@/agentMode/ui/agentTrail"; +import React from "react"; +import { agentResponseText, buildAgentTrail, type RenderNode } from "@/agentMode/ui/agentTrail"; import type { AgentMessagePart, StopReason } from "@/agentMode/session/types"; import { ActionCard } from "@/agentMode/ui/ActionCard"; import { AgentMessageActions } from "@/agentMode/ui/AgentMessageActions"; @@ -14,10 +9,6 @@ import { ReasoningBlock } from "@/agentMode/ui/ReasoningBlock"; import { AgentMarkdownText } from "@/agentMode/ui/AgentMarkdownText"; import { planEntryClass, planEntryIcon } from "@/agentMode/ui/planEntryStyles"; import { BottomLoadingIndicator } from "@/components/chat-components/BottomLoadingIndicator"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; -import { formatDuration } from "@/lib/duration"; -import { cn } from "@/lib/utils"; -import { Sparkles, ChevronRight } from "lucide-react"; import { App } from "obsidian"; interface AgentTrailProps { @@ -32,13 +23,9 @@ interface AgentTrailProps { showThinkingTail?: boolean; /** Obsidian `App` for the markdown renderer used by `text` parts. */ app: App; - /** Backend stopReason once the turn has ended. Only `end_turn` triggers - * collapsing — cancelled / refusal / max_tokens leave the trail expanded - * so the user can see exactly where things stopped. */ + /** Backend stopReason once the turn has ended. Only `cancelled` suppresses + * the Copy / Insert affordances (treated as having no user-visible answer). */ turnStopReason?: StopReason; - /** Frozen wall-clock duration of the turn, in ms. Drives the - * "Worked for X" label on a collapsed turn. */ - turnDurationMs?: number; } export const AgentTrail: React.FC = ({ @@ -47,17 +34,7 @@ export const AgentTrail: React.FC = ({ showThinkingTail, app, turnStopReason, - turnDurationMs, }) => { - // Decide whether the turn qualifies for the "Worked for X" collapse. The - // collapse is post-stream only — while events are still arriving the user - // sees every tool call land in real time. - const canCollapse = - !isStreaming && - turnStopReason === "end_turn" && - typeof turnDurationMs === "number" && - parts.length > 0; - // Copy / Insert act on the agent's full textual response. Gate them off while // the message is still streaming and on cancelled turns (treated as having no // user-visible answer), plus whenever there is no prose to act on. @@ -67,27 +44,6 @@ export const AgentTrail: React.FC = ({ ) : null; - if (canCollapse) { - const { research, final } = splitTrailingText(parts); - // Both halves must be non-empty for a collapse to be meaningful: research - // gives the user something to hide, and final gives them something to read - // inline. If either is missing, fall through to the linear render. - const researchHasContent = research.some((p) => p.kind !== "text" || p.text.trim().length > 0); - const finalHasContent = final.some((p) => p.text.trim().length > 0); - if (researchHasContent && finalHasContent) { - return ( -
- - {final.map((p, i) => ( - // eslint-disable-next-line @eslint-react/no-array-index-key -- text parts are append-only and may contain duplicate text - - ))} - {actions} -
- ); - } - } - return (
= ({ research, durationMs, app }) => { - const [isExpanded, setIsExpanded] = useState(false); - return ( - - -
- - - - Worked for - {formatDuration(durationMs)} - -
-
- -
- -
-
-
- ); -}; - function renderNode( node: RenderNode, key: string | number, diff --git a/src/agentMode/ui/agentTrail.test.ts b/src/agentMode/ui/agentTrail.test.ts index abdd410d..068d6957 100644 --- a/src/agentMode/ui/agentTrail.test.ts +++ b/src/agentMode/ui/agentTrail.test.ts @@ -1,9 +1,4 @@ -import { - agentResponseText, - buildAgentTrail, - splitTrailingText, - type RenderNode, -} from "@/agentMode/ui/agentTrail"; +import { agentResponseText, buildAgentTrail, type RenderNode } from "@/agentMode/ui/agentTrail"; import type { AgentMessagePart } from "@/agentMode/session/types"; function tool( @@ -303,49 +298,6 @@ describe("buildAgentTrail", () => { }); }); -describe("splitTrailingText", () => { - it("returns empty final when there are no trailing text parts", () => { - const parts: AgentMessagePart[] = [tool("a"), thought("...")]; - const { research, final } = splitTrailingText(parts); - expect(research).toEqual(parts); - expect(final).toEqual([]); - }); - - it("groups all trailing text parts as the final answer", () => { - const parts: AgentMessagePart[] = [ - tool("a"), - text("intermediate"), - tool("b"), - text("final part 1"), - text("final part 2"), - ]; - const { research, final } = splitTrailingText(parts); - expect(research.map((p) => p.kind)).toEqual(["tool_call", "text", "tool_call"]); - expect(final.map((p) => p.text)).toEqual(["final part 1", "final part 2"]); - }); - - it("treats a tool_call after text as research, not final", () => { - const parts: AgentMessagePart[] = [tool("a"), text("midway answer"), tool("b")]; - const { research, final } = splitTrailingText(parts); - expect(research).toHaveLength(3); - expect(final).toEqual([]); - }); - - it("returns empty research when the whole turn is text", () => { - const parts: AgentMessagePart[] = [text("just"), text(" text")]; - const { research, final } = splitTrailingText(parts); - expect(research).toEqual([]); - expect(final.map((p) => p.text)).toEqual(["just", " text"]); - }); - - it("trailing thought breaks the run (thought is research)", () => { - const parts: AgentMessagePart[] = [text("answer"), thought("post-hoc reasoning")]; - const { research, final } = splitTrailingText(parts); - expect(research).toHaveLength(2); - expect(final).toEqual([]); - }); -}); - describe("agentResponseText", () => { it("collects all text parts in stream order, even across interleaved research", () => { const parts: AgentMessagePart[] = [ diff --git a/src/agentMode/ui/agentTrail.ts b/src/agentMode/ui/agentTrail.ts index 8f7cbe06..a7f51111 100644 --- a/src/agentMode/ui/agentTrail.ts +++ b/src/agentMode/ui/agentTrail.ts @@ -25,35 +25,6 @@ export interface BuildAgentTrailOptions { maxDepth?: number; } -/** - * Split a turn's parts into the trailing user-visible answer and everything - * that came before (the "research"). The boundary is the last contiguous run - * of `text` parts: any `tool_call`, `thought`, or `plan` part after a text - * run reclassifies that run as research. - * - * Used by the trail UI to fold the research portion into a single - * "Worked for X" block once a turn has cleanly ended (`stopReason: end_turn`). - * Streaming turns and cancelled / refused / errored turns keep the trail - * uncollapsed — that's the caller's responsibility, not this helper's. - */ -export function splitTrailingText(parts: AgentMessagePart[]): { - research: AgentMessagePart[]; - final: TextPart[]; -} { - const final: TextPart[] = []; - let boundary = parts.length; - for (let i = parts.length - 1; i >= 0; i--) { - const p = parts[i]; - if (p.kind === "text") { - final.unshift(p); - boundary = i; - continue; - } - break; - } - return { research: parts.slice(0, boundary), final }; -} - /** * The agent's full textual response across the turn, ready for the clipboard * or the editor: every `text` part in stream order (not just the trailing run),