Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 74 additions & 26 deletions desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
DialogTitle,
} from "@/shared/ui/dialog";
import { Toggle } from "@/shared/ui/toggle";
import { AnimatedCount } from "@/shared/ui/AnimatedCount";
import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo";
import type { PromptSection, TranscriptItem } from "./agentSessionTypes";
import { TurnLivenessIndicator } from "./TurnLivenessIndicator";
Expand All @@ -34,6 +35,7 @@ import {
ActivityRowContent,
ActivityRowLabel,
type ActivityRowStats,
splitActivityRowCountedObject,
splitActivityRowLabel,
} from "./activityRenderClasses/ActivityRow";
import { TranscriptTimestamp } from "./activityRenderClasses/TranscriptTimestamp";
Expand Down Expand Up @@ -386,7 +388,9 @@ function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) {
return `turn:${turnId}:setup`;
}
if (segment.kind === "prompt") {
return `turn:${turnId}:prompt`;
// A turn can hold multiple prompt segments (initial prompt + mid-turn
// steers), so key on the user message id rather than the bare turn id.
return `turn:${turnId}:prompt:${segment.user.id}`;
}
if (segment.kind === "summary") {
return segment.summary.id;
Expand Down Expand Up @@ -455,10 +459,10 @@ function SameKindSummaryItem({
}) {
const groupedFileEditDiffs = React.useMemo(
() =>
summary.renderClass === "file-edit"
summary.renderClass === "file-edit" || summary.variant === "mixed"
? getGroupedFileEditDiffs(summary.items)
: [],
[summary.items, summary.renderClass],
[summary.items, summary.renderClass, summary.variant],
);
const groupedFileEditStats = summarizeFileEditDiffs(groupedFileEditDiffs);
const expandsToToolItems = summary.items.every(
Expand All @@ -467,6 +471,10 @@ function SameKindSummaryItem({
const variant = useAgentSessionTranscriptVariant();
const timestampsEnabled = useTranscriptTimestampsEnabled();
const showTimestamp = timestampsEnabled && variant !== "compactPreview";
// Mixed bursts expand to their child segments in original order: raw tool
// rows plus nested same-kind summaries that joined the burst (which stay
// expandable to their own child rows).
const childSegments = summary.segments ?? null;

return (
<>
Expand All @@ -483,30 +491,52 @@ function SameKindSummaryItem({
<ActivityRowContent
className={cn(
"flex flex-col",
expandsToToolItems ? "gap-0.5" : "gap-1 pl-5",
expandsToToolItems || childSegments ? "gap-0.5" : "gap-1 pl-5",
)}
>
{expandsToToolItems
? summary.items.map((item) => (
<TranscriptItemView
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
item={item}
key={item.id}
profiles={profiles}
/>
))
: summary.items.map((item) => (
<p
className="truncate text-xs text-muted-foreground"
key={item.id}
>
{item.type === "tool"
? item.descriptor.preview || item.descriptor.label
: item.title}
</p>
))}
{childSegments
? childSegments.map((child) =>
child.kind === "summary" ? (
<SameKindSummaryItem
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
key={child.summary.id}
profiles={profiles}
summary={child.summary}
/>
) : (
<TranscriptItemView
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
item={child.item}
key={child.item.id}
profiles={profiles}
/>
),
)
: expandsToToolItems
? summary.items.map((item) => (
<TranscriptItemView
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
item={item}
key={item.id}
profiles={profiles}
/>
))
: summary.items.map((item) => (
<p
className="truncate text-xs text-muted-foreground"
key={item.id}
>
{item.type === "tool"
? item.descriptor.preview || item.descriptor.label
: item.title}
</p>
))}
</ActivityRowContent>
</ActivityRow>
{showTimestamp ? (
Expand Down Expand Up @@ -550,15 +580,33 @@ function ToolRunSummaryLabel({
label: string;
stats?: ActivityRowStats | null;
}) {
const animationPreferenceEnabled = useTranscriptAnimationEnabled();
const parts = splitActivityRowLabel(label);

if (!parts) {
return <span className="truncate text-sm font-medium">{label}</span>;
}

// Streaming bursts grow their count in place ("Ran 16 tool calls" →
// "Ran 17 tool calls"); rolling the digits odometer-style makes the
// increment legible. AnimatedCount keeps an sr-only static value and
// falls back to static text under prefers-reduced-motion.
const countedObject =
animationPreferenceEnabled && typeof parts.object === "string"
? splitActivityRowCountedObject(parts.object)
: null;
const object = countedObject ? (
<>
<AnimatedCount value={countedObject.count} />
{countedObject.rest}
</>
) : (
parts.object
);

return (
<ActivityRowLabel
object={parts.object}
object={object}
openToneScope="summary"
stats={stats}
verb={parts.verb}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,24 @@ export function splitActivityRowLabel(
);
return match ? { verb: match[1], object: match[2] } : null;
}

export type ActivityRowCountedObject = {
count: number;
rest: string;
};

/**
* Split a summary label object like "16 tool calls" into its leading count
* and the trailing text (" tool calls"), so the number can animate through
* AnimatedCount while streaming bursts grow. Returns null when the object
* does not lead with a count.
*/
export function splitActivityRowCountedObject(
object: string,
): ActivityRowCountedObject | null {
const match = object.match(/^(\d+)(\s.+)$/);
if (!match) return null;
const count = Number(match[1]);
if (!Number.isFinite(count)) return null;
return { count, rest: match[2] };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
splitActivityRowCountedObject,
splitActivityRowLabel,
} from "./ActivityRow.tsx";

test("splitActivityRowLabel splits verb-led summary labels", () => {
assert.deepEqual(splitActivityRowLabel("Ran 16 tool calls"), {
verb: "Ran",
object: "16 tool calls",
});
assert.equal(splitActivityRowLabel("Thinking"), null);
});

test("splitActivityRowCountedObject splits a leading count", () => {
assert.deepEqual(splitActivityRowCountedObject("16 tool calls"), {
count: 16,
rest: " tool calls",
});
assert.deepEqual(splitActivityRowCountedObject("3 files"), {
count: 3,
rest: " files",
});
assert.deepEqual(splitActivityRowCountedObject("12 Buzz relay ops"), {
count: 12,
rest: " Buzz relay ops",
});
});

test("splitActivityRowCountedObject leaves non-counted objects alone", () => {
assert.equal(splitActivityRowCountedObject("npm install"), null);
assert.equal(splitActivityRowCountedObject("2 "), null);
assert.equal(splitActivityRowCountedObject("16"), null);
assert.equal(splitActivityRowCountedObject("file 16"), null);
});
53 changes: 53 additions & 0 deletions desktop/src/features/agents/ui/agentSessionTranscript.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1179,3 +1179,56 @@ test("observer feed renders system-prompt before prompt-context in display order
"system-prompt item must have turnId=null",
);
});

test("steer ingress bundles its prompt context into the steer prompt segment, not a standalone row", () => {
// The stray "Prompt context · 1 section" row was leaking from the goose
// session/steer path: it upserted metadata without an acpSource, so display
// grouping never consumed it into a prompt bundle. It must now ride behind
// the steer message bubble's checks-icon dialog like session/prompt context.
const events = [
{
seq: 1,
timestamp: "2026-07-01T10:00:00.000Z",
kind: "acp_write",
agentIndex: 0,
channelId: "ch-1",
sessionId: "sess-1",
turnId: "turn-1",
payload: {
jsonrpc: "2.0",
id: 5,
method: "_goose/unstable/session/steer",
params: {
sessionId: "sess-1",
prompt: [
{
type: "text",
text: `[Buzz event: @mention]\nEvent ID: ${"e".repeat(64)}\nFrom: x (hex: ${"f".repeat(64)})\nContent: steer me`,
},
{ type: "text", text: "[Thread context]\nPrior messages here." },
],
},
},
},
];

const rawItems = buildTranscript(events);
const steerMessage = rawItems.find((item) => item.type === "message");
const steerContext = rawItems.find((item) => item.type === "metadata");
assert.equal(steerMessage?.acpSource, "session/steer:user");
assert.equal(steerContext?.acpSource, "session/steer:context");

const [block] = buildTranscriptDisplayBlocks(rawItems);
assert.equal(block.kind, "turn");
const promptSegment = block.segments.find(
(segment) => segment.kind === "prompt",
);
assert.ok(promptSegment, "expected steer message to render a prompt bundle");
assert.equal(promptSegment.context?.id, steerContext.id);
assert.ok(
!block.segments.some(
(segment) => segment.kind === "item" && segment.item.type === "metadata",
),
"steer context must not leak as a standalone metadata row",
);
});
3 changes: 2 additions & 1 deletion desktop/src/features/agents/ui/agentSessionTranscript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -906,7 +906,7 @@ export function processTranscriptEvent(
event.timestamp,
ctx,
parsedPrompt.userPubkey,
undefined,
"session/steer:user",
parsedPrompt.userEventId,
);
}
Expand All @@ -918,6 +918,7 @@ export function processTranscriptEvent(
parsedPrompt.sections,
event.timestamp,
ctx,
"session/steer:context",
);
}
}
Expand Down
Loading