fix(agent-mode): stop folding the completed trail into "Worked for X" (#2652)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Zero Liu 2026-07-03 05:02:54 +08:00 committed by GitHub
parent 703529ac67
commit 4cfea5761d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 42 additions and 198 deletions

View file

@ -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");

View file

@ -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) } : {}),

View file

@ -959,7 +959,6 @@ export class AgentSession {
): Promise<StopReason> {
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<StopReason> {
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;

View file

@ -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

View file

@ -181,7 +181,6 @@ const AgentChatMessages = memo(
showThinkingTail={message.id === streamingMessageId && showBottomLoader}
app={app}
turnStopReason={message.turnStopReason}
turnDurationMs={message.turnDurationMs}
/>
</div>
) : (

View file

@ -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();
});
});

View file

@ -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<AgentTrailProps> = ({
@ -47,17 +34,7 @@ export const AgentTrail: React.FC<AgentTrailProps> = ({
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<AgentTrailProps> = ({
<AgentMessageActions text={answer} app={app} />
) : 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 (
<div className="tw-group tw-flex tw-flex-col tw-gap-1">
<WorkedForBlock research={research} durationMs={turnDurationMs} app={app} />
{final.map((p, i) => (
// eslint-disable-next-line @eslint-react/no-array-index-key -- text parts are append-only and may contain duplicate text
<AgentMarkdownText key={`final-${i}`} text={p.text} app={app} />
))}
{actions}
</div>
);
}
}
return (
<div className="tw-group tw-flex tw-flex-col tw-gap-1">
<LinearTrail
@ -126,49 +82,6 @@ const LinearTrail: React.FC<{
);
};
interface WorkedForBlockProps {
research: AgentMessagePart[];
durationMs: number;
app: App;
}
/**
* Collapsed-by-default "Worked for X" header that wraps the research portion
* of a completed turn. Clicking expands the original trail inline same
* components as the linear path, no streaming spinners (the turn has ended).
*/
const WorkedForBlock: React.FC<WorkedForBlockProps> = ({ research, durationMs, app }) => {
const [isExpanded, setIsExpanded] = useState(false);
return (
<Collapsible
open={isExpanded}
onOpenChange={setIsExpanded}
className="tw-mb-2 tw-mt-1 tw-w-full max-md:tw-mb-1.5 max-md:tw-mt-0.5"
>
<CollapsibleTrigger asChild>
<div className="copilot-divider-b tw-flex tw-w-full tw-cursor-pointer tw-items-center tw-gap-1.5 tw-pb-2 tw-text-left tw-text-sm tw-text-muted hover:tw-text-normal">
<span className="tw-flex tw-size-icon-xs tw-shrink-0 tw-items-center tw-justify-center">
<Sparkles className="tw-size-3 tw-text-muted" />
</span>
<span className="tw-font-medium">Worked for</span>
<span className="tw-text-muted">{formatDuration(durationMs)}</span>
<ChevronRight
className={cn(
"tw-ml-auto tw-size-3 tw-text-muted tw-transition-transform",
isExpanded && "tw-rotate-90"
)}
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="tw-mt-2">
<LinearTrail parts={research} isStreaming={false} app={app} />
</div>
</CollapsibleContent>
</Collapsible>
);
};
function renderNode(
node: RenderNode,
key: string | number,

View file

@ -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[] = [

View file

@ -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),