From 52123ee68f7370ecf7a8e5e8b3f854be63b419b4 Mon Sep 17 00:00:00 2001 From: ss-dev-01 <11939edb7df583f855dbef923f2358f1184538f88ca452e19e7e35e42ad6d796@buzz.block.builderlab.xyz> Date: Fri, 21 Aug 2026 17:58:44 -0700 Subject: [PATCH 1/5] Make agent transcript groups honest when collapsed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A collapsed tool-run group used to present only its label, so a group whose child failed — or was still running — read as ordinary finished work, and a reader had to expand it to find out otherwise. Expansion state also lived in the DOM, so it was silently reverted whenever re-grouping remounted the card: a same-kind run that later absorbs one differing tool call is rebuilt as a mixed burst with a different summary id, discarding the reader's deliberate choice to keep it open. Group presentation now carries three properties: - Aggregate status leans toward the worst outcome (failed > executing > pending > completed) and renders beside the label, outside the collapsible body. Only non-clean outcomes show a badge; a badge on every finished group would train readers to ignore the badge that matters. - Expansion is mount-aware and sticky, keyed by a durable identity: history mounts collapsed, live and failed work mounts open, and an untouched group auto-collapses when it finishes cleanly. Once the reader touches a group, automatic policy never moves it again. - Files a group touched render as chips outside the collapsible body, so collapsing hides the steps without hiding the outcome. The durable key is `group::` rather than the summary id. Leaf ids derive from channel plus ACP tool-call id, so they survive both re-derivation over merged live/archive windows and the same-kind to mixed transition, while turnId keeps two turns from colliding. Low-signal suppression is deliberately conservative: only successful, finished, shell-flavored scaffolding folds away, and only when the group has at least four steps, at least two would hide, and at least one primary step remains. Failures, in-flight steps, file edits, relay ops, and anything the classifier could not identify always stay visible. Separately, an empty transcript no longer claims there was no activity when history was merely unreadable. Archived observer history requires an owner_p save subscription and a completed hydration pass; without either, the archive returns nothing, which is shaped exactly like a channel where the agent never ran. `useLoadArchivedObserverEvents` now reports history certainty, and the empty state says earlier activity may not be shown rather than asserting a fact it did not check. Group rendering moved to TranscriptToolRunGroup so AgentSessionTranscriptList stays under the 1000-line ceiling; the shared row timestamp moved alongside it to avoid an import cycle. Timestamp, animation, source-pill, and raw-feed behavior are unchanged. Co-authored-by: Bradley Axen Signed-off-by: Bradley Axen --- .../agents/ui/AgentSessionTranscriptList.tsx | 147 +++++------ .../agents/ui/TranscriptRowTimestamp.tsx | 26 ++ .../agents/ui/TranscriptToolRunGroup.tsx | 236 ++++++++++++++++++ .../ui/activityRenderClasses/ActivityRow.tsx | 17 ++ .../ui/agentSessionHistoryCertainty.test.mjs | 77 ++++++ .../agents/ui/agentSessionHistoryCertainty.ts | 80 ++++++ .../ui/agentSessionToolRunGroupKey.test.mjs | 97 +++++++ .../agents/ui/agentSessionToolRunGroupKey.ts | 30 +++ .../ui/agentSessionToolRunPartition.test.mjs | 169 +++++++++++++ .../agents/ui/agentSessionToolRunPartition.ts | 202 +++++++++++++++ .../ui/agentSessionToolRunStatus.test.mjs | 117 +++++++++ .../agents/ui/agentSessionToolRunStatus.ts | 73 ++++++ .../ui/agentSessionToolRunViewState.test.mjs | 136 ++++++++++ .../agents/ui/agentSessionToolRunViewState.ts | 116 +++++++++ .../agents/ui/agentSessionToolRunViewStore.ts | 33 +++ .../features/agents/ui/useObserverEvents.ts | 12 +- .../agents/ui/useToolRunGroupViewState.ts | 80 ++++++ .../channels/ui/AgentSessionThreadPanel.tsx | 3 +- 18 files changed, 1560 insertions(+), 91 deletions(-) create mode 100644 desktop/src/features/agents/ui/TranscriptRowTimestamp.tsx create mode 100644 desktop/src/features/agents/ui/TranscriptToolRunGroup.tsx create mode 100644 desktop/src/features/agents/ui/agentSessionHistoryCertainty.test.mjs create mode 100644 desktop/src/features/agents/ui/agentSessionHistoryCertainty.ts create mode 100644 desktop/src/features/agents/ui/agentSessionToolRunGroupKey.test.mjs create mode 100644 desktop/src/features/agents/ui/agentSessionToolRunGroupKey.ts create mode 100644 desktop/src/features/agents/ui/agentSessionToolRunPartition.test.mjs create mode 100644 desktop/src/features/agents/ui/agentSessionToolRunPartition.ts create mode 100644 desktop/src/features/agents/ui/agentSessionToolRunStatus.test.mjs create mode 100644 desktop/src/features/agents/ui/agentSessionToolRunStatus.ts create mode 100644 desktop/src/features/agents/ui/agentSessionToolRunViewState.test.mjs create mode 100644 desktop/src/features/agents/ui/agentSessionToolRunViewState.ts create mode 100644 desktop/src/features/agents/ui/agentSessionToolRunViewStore.ts create mode 100644 desktop/src/features/agents/ui/useToolRunGroupViewState.ts diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index d24c8fab79f..576cdbbf45c 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { motion, useReducedMotion } from "motion/react"; -import { CheckCheck, Clock, Radio } from "lucide-react"; +import { CheckCheck, CircleHelp, Clock, Radio } from "lucide-react"; import { useActiveAgentTurns, @@ -36,8 +36,6 @@ import { useTranscriptAnimationEnabled } from "./transcriptAnimationPreference"; import { useTranscriptTimestampsEnabled } from "./transcriptTimestampPreference"; import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem"; import { - ActivityRow, - ActivityRowContent, ActivityRowLabel, type ActivityRowStats, splitActivityRowCountedObject, @@ -53,8 +51,11 @@ import { turnSetupDetail, turnSetupTimestamp, type TranscriptDisplayBlock, + type TranscriptToolRunChildSegment, type TranscriptTurnSegment, } from "./agentSessionTranscriptGrouping"; +import { TranscriptToolRunGroup } from "./TranscriptToolRunGroup"; +import { TranscriptRowTimestamp } from "./TranscriptRowTimestamp"; import { buildCompactToolSummary } from "./agentSessionToolSummary"; import { shouldShowTranscriptRowTimestamp } from "./agentSessionTranscriptPresentation"; import { formatTranscriptTimestampTitle } from "./agentSessionUtils"; @@ -90,7 +91,12 @@ function useHasCompletedInitialRender() { */ const SHOW_TRANSCRIPT_ACP_SOURCE = shouldShowTranscriptAcpSource(); -export type AgentSessionTranscriptEmptyState = "idle" | "loading"; +/** + * `"unknown"` means history may exist but could not be read (no `owner_p` save + * subscription, or archive hydration incomplete). It is deliberately distinct + * from `"idle"`: only `"idle"` may state that there was no activity. + */ +export type AgentSessionTranscriptEmptyState = "idle" | "loading" | "unknown"; function shouldShowTranscriptAcpSource() { const envValue = import.meta.env.VITE_SHOW_TRANSCRIPT_ACP_SOURCE; @@ -203,6 +209,9 @@ export function AgentSessionTranscriptList({ if (!hasRenderableContent) { const isLoading = emptyState === "loading" || isTurnLive; + // Uncertain history must never be presented as "no activity" — see + // AgentSessionTranscriptEmptyState. + const uncertain = !isLoading && emptyState === "unknown"; return (
@@ -214,6 +223,17 @@ export function AgentSessionTranscriptList({ fuzz={false} loop /> + ) : uncertain ? ( + <> + +

+ Earlier activity may not be shown +

+

+ Archived history isn't available for this view, so activity from + before now may be missing. +

+ ) : ( <> @@ -503,84 +523,49 @@ function SameKindSummaryItem({ [summary.items, summary.renderClass, summary.variant], ); const groupedFileEditStats = summarizeFileEditDiffs(groupedFileEditDiffs); - const expandsToToolItems = summary.items.every( - (item) => item.type === "tool", - ); 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; + // expandable to their own child rows). Rendering is passed as a callback so + // the group card never has to import this module back. Not memoized: the + // group card calls it during render and never uses its identity as a + // dependency, and memoizing would need this component as its own dep. + const renderChild = (child: TranscriptToolRunChildSegment) => + child.kind === "summary" ? ( + + ) : ( + + ); return ( - <> - + - - {childSegments - ? childSegments.map((child) => - child.kind === "summary" ? ( - - ) : ( - - ), - ) - : expandsToToolItems - ? summary.items.map((item) => ( - - )) - : summary.items.map((item) => ( -

- {item.type === "tool" - ? item.descriptor.preview || item.descriptor.label - : item.title} -

- ))} -
-
- {showTimestamp ? ( - - ) : null} - + } + renderChild={renderChild} + showTimestamp={showTimestamp} + summary={summary} + /> ); } @@ -886,25 +871,9 @@ function TranscriptItemRow({ } /** - * Opt-in per-row timestamp, anchored bottom-left under the row content and - * styled to match the chat/transcript timestamps. + * Opt-in per-row timestamp lives in `./TranscriptRowTimestamp` so the tool-run + * group card can render it without importing this module. */ -function TranscriptRowTimestamp({ - messageLink = null, - timestamp, -}: { - messageLink?: { channelId: string; messageId: string } | null; - timestamp: string; -}) { - return ( -
- -
- ); -} function TurnSetupStatus({ items, diff --git a/desktop/src/features/agents/ui/TranscriptRowTimestamp.tsx b/desktop/src/features/agents/ui/TranscriptRowTimestamp.tsx new file mode 100644 index 00000000000..e8e63893300 --- /dev/null +++ b/desktop/src/features/agents/ui/TranscriptRowTimestamp.tsx @@ -0,0 +1,26 @@ +import { TranscriptTimestamp } from "./activityRenderClasses/TranscriptTimestamp"; + +/** + * Opt-in per-row timestamp, anchored bottom-left under the row content and + * styled to match the chat/transcript timestamps. + * + * Extracted from `AgentSessionTranscriptList` so tool-run group chrome can + * render the same timestamp without importing the list module (which imports + * the group card, and would form a cycle). + */ +export function TranscriptRowTimestamp({ + messageLink = null, + timestamp, +}: { + messageLink?: { channelId: string; messageId: string } | null; + timestamp: string; +}) { + return ( +
+ +
+ ); +} diff --git a/desktop/src/features/agents/ui/TranscriptToolRunGroup.tsx b/desktop/src/features/agents/ui/TranscriptToolRunGroup.tsx new file mode 100644 index 00000000000..57ebc866777 --- /dev/null +++ b/desktop/src/features/agents/ui/TranscriptToolRunGroup.tsx @@ -0,0 +1,236 @@ +import * as React from "react"; +import { CircleAlert, FileDiff, FilePen, Loader } from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; +import { + ActivityRow, + ActivityRowContent, +} from "./activityRenderClasses/ActivityRow"; +import type { + TranscriptToolRunChildSegment, + TranscriptToolRunSummary, +} from "./agentSessionTranscriptGrouping"; +import { getToolRunGroupKey } from "./agentSessionToolRunGroupKey"; +import { + countToolRunGroupFailures, + getToolRunGroupStatus, + isToolRunGroupActive, +} from "./agentSessionToolRunStatus"; +import { + collectToolRunArtifacts, + partitionToolRunSteps, +} from "./agentSessionToolRunPartition"; +import { + setToolRunGroupExpanded, + setToolRunGroupInternalStepsShown, +} from "./agentSessionToolRunViewState"; +import { useToolRunGroupViewState } from "./useToolRunGroupViewState"; +import { formatTranscriptTimestampTitle } from "./agentSessionUtils"; +import { TranscriptRowTimestamp } from "./TranscriptRowTimestamp"; + +/** + * Status shown beside a collapsed group's label. + * + * Only non-clean outcomes render. A completed group says nothing — "done" is + * the expected case and a badge on every finished group would train the reader + * to ignore the badge that matters. Failures and in-flight work are exactly the + * states worth interrupting for, which is why the aggregate leans that way. + */ +function ToolRunGroupStatusBadge({ + failureCount, + status, +}: { + failureCount: number; + status: ReturnType; +}) { + if (status === "completed") return null; + + if (status === "failed") { + return ( + + + ); + } + + return ( + + + ); +} + +/** + * Files a group touched, rendered OUTSIDE its collapsible body. + * + * A file the agent edited is an outcome of the turn, not a detail of how the + * steps happened to be batched — so it stays visible when the group is + * collapsed. Otherwise the most consequential thing a group did would be the + * thing a reader has to expand to discover. + */ +function ToolRunGroupArtifacts({ + artifacts, +}: { + artifacts: ReturnType; +}) { + if (artifacts.length === 0) return null; + + return ( +
+ {artifacts.map((artifact) => ( + + {artifact.kind === "edit" ? ( + + ))} +
+ ); +} + +export type TranscriptToolRunGroupProps = { + summary: TranscriptToolRunSummary; + /** + * Group label, already split/animated by the list. Passed as a node (rather + * than a string plus stats) so the group card owns none of the label's + * animation policy. + */ + label: React.ReactNode; + showTimestamp: boolean; + /** Renders one child segment. Passed in to avoid a list↔group import cycle. */ + renderChild: (child: TranscriptToolRunChildSegment) => React.ReactNode; +}; + +/** + * A collapsible group of adjacent tool calls. + * + * Owns three behaviors the plain `ActivityRow` cannot express: + * - a failure-leaning aggregate status, so a collapsed group never reads as + * fine while something inside it failed or is still running; + * - mount-aware sticky expansion keyed by a durable identity, so live work is + * open while it runs, history arrives collapsed, and a reader's own choice is + * never reverted by re-grouping or by the group finishing; + * - artifacts and status rendered outside the collapsible body, so collapsing + * hides the steps without hiding the outcome. + */ +export function TranscriptToolRunGroup({ + label, + renderChild, + showTimestamp, + summary, +}: TranscriptToolRunGroupProps) { + const status = getToolRunGroupStatus(summary.items); + const groupKey = getToolRunGroupKey(summary); + const { state, update } = useToolRunGroupViewState(groupKey, status); + + const failureCount = React.useMemo( + () => countToolRunGroupFailures(summary.items), + [summary.items], + ); + const artifacts = React.useMemo( + () => collectToolRunArtifacts(summary.items), + [summary.items], + ); + + // Children are the mixed burst's own segments when present, otherwise the + // group's leaf items. + const childSegments = React.useMemo( + () => + summary.segments ?? + summary.items.map((item) => ({ kind: "item" as const, item })), + [summary.items, summary.segments], + ); + + const { hiddenItems, primaryItems } = React.useMemo( + () => + partitionToolRunSteps( + childSegments.flatMap((child) => + child.kind === "item" ? [child.item] : child.summary.items, + ), + state.expandedItemIds, + ), + [childSegments, state.expandedItemIds], + ); + + const hiddenIds = React.useMemo( + () => new Set(hiddenItems.map((item) => item.id)), + [hiddenItems], + ); + const visibleChildren = state.showInternalSteps + ? childSegments + : childSegments.filter( + (child) => child.kind !== "item" || !hiddenIds.has(child.item.id), + ); + + return ( + <> + + update((previous) => setToolRunGroupExpanded(previous, open)) + } + open={state.expanded} + openToneScope="summary" + testId="transcript-same-kind-summary" + title={formatTranscriptTimestampTitle(summary.timestamp)} + > + {label} + + + {visibleChildren.map((child) => renderChild(child))} + {hiddenItems.length > 0 ? ( + + ) : null} + {primaryItems.length === 0 && visibleChildren.length === 0 ? ( +

No steps to show.

+ ) : null} +
+
+ + {showTimestamp ? ( + + ) : null} + + ); +} + +export { isToolRunGroupActive }; diff --git a/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx b/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx index 8c82bb0d6cd..6da7edf7884 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx @@ -22,6 +22,15 @@ type ActivityRowProps = { openToneScope?: Exclude; testId?: string; title?: string; + /** + * Controlled disclosure. When provided, the row's open state is owned by the + * caller — needed for tool-run groups, whose expansion must survive the + * remount that re-grouping causes and must be reverted by automatic policy + * when work completes. Omit both to keep the default uncontrolled `
` + * behavior every other row uses. + */ + open?: boolean; + onOpenChange?: (open: boolean) => void; }; type ActivityRowContentProps = { @@ -38,6 +47,8 @@ type ActivityRowContentComponent = React.FC & { export function ActivityRow({ children, className, + onOpenChange, + open, openToneScope = "tool", testId, title, @@ -68,6 +79,12 @@ export function ActivityRow({ className, )} data-testid={testId} + onToggle={ + onOpenChange + ? (event) => onOpenChange(event.currentTarget.open) + : undefined + } + open={open} title={title} > { + assert.equal(getTranscriptHistoryCertainty(complete), "known-empty"); +}); + +test("an unresolved subscription check is never a definitive empty", () => { + assert.equal( + getTranscriptHistoryCertainty({ ...complete, hasSubscription: null }), + "unknown", + ); +}); + +test("a missing owner_p subscription is never a definitive empty", () => { + assert.equal( + getTranscriptHistoryCertainty({ ...complete, hasSubscription: false }), + "unknown", + ); +}); + +test("an unhydrated archive is never a definitive empty", () => { + assert.equal( + getTranscriptHistoryCertainty({ ...complete, archiveHydrated: false }), + "unknown", + ); +}); + +test("no channel means no archive was consulted", () => { + assert.equal( + getTranscriptHistoryCertainty({ ...complete, channelId: null }), + "unknown", + ); +}); + +test("uncertain copy never claims there was no activity", () => { + const cases = [ + { ...complete, hasSubscription: null }, + { ...complete, hasSubscription: false }, + { ...complete, archiveHydrated: false }, + ]; + for (const inputs of cases) { + const copy = getUncertainHistoryCopy(inputs); + const text = `${copy.title} ${copy.description}`.toLowerCase(); + assert.ok(!text.includes("no acp activity"), text); + assert.ok(!text.includes("no activity"), text); + assert.ok(copy.description.length > 0); + } +}); + +test("uncertain copy names the specific gap", () => { + assert.match( + getUncertainHistoryCopy({ ...complete, hasSubscription: null }).title, + /Checking/, + ); + assert.match( + getUncertainHistoryCopy({ ...complete, hasSubscription: false }) + .description, + /isn't indexed/, + ); + assert.match( + getUncertainHistoryCopy({ ...complete, archiveHydrated: false }) + .description, + /hasn't finished loading/, + ); +}); diff --git a/desktop/src/features/agents/ui/agentSessionHistoryCertainty.ts b/desktop/src/features/agents/ui/agentSessionHistoryCertainty.ts new file mode 100644 index 00000000000..41e61c9144c --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionHistoryCertainty.ts @@ -0,0 +1,80 @@ +/** + * Whether an empty transcript is a fact or merely an absence of evidence. + * + * Archived observer history is only readable when the viewing identity holds an + * `owner_p` save subscription and the backfill index has been populated for the + * channel. When either is missing, `useLoadArchivedObserverEvents` returns no + * rows and forces `hasOlderArchived` to false — the same shape as a channel + * where the agent genuinely never ran. Rendering "No ACP activity yet" in that + * state asserts something we did not check, and a supervisor who reads it may + * conclude an agent did nothing when its history was simply unavailable. + * + * So the distinction is preserved here rather than collapsed at the call site. + */ +export type TranscriptHistoryCertainty = "known-empty" | "unknown"; + +export type TranscriptHistoryInputs = { + /** + * Result of the `owner_p` save-subscription check: null while unresolved, + * false when the identity has no subscription (or the lookup failed). + */ + hasSubscription: boolean | null; + /** Channel whose archive would be read; null means no archive was consulted. */ + channelId: string | null; + /** True once the archive backfill/hydration pass finished for this channel. */ + archiveHydrated: boolean; +}; + +/** + * Decide whether emptiness can be stated as fact. + * + * Only an identity with a resolved subscription, a real channel, and a + * completed hydration pass has actually looked at the whole history. Every + * other combination is uncertainty and must say so. + */ +export function getTranscriptHistoryCertainty({ + hasSubscription, + channelId, + archiveHydrated, +}: TranscriptHistoryInputs): TranscriptHistoryCertainty { + if (hasSubscription !== true) return "unknown"; + if (!channelId) return "unknown"; + if (!archiveHydrated) return "unknown"; + return "known-empty"; +} + +export type TranscriptEmptyCopy = { + title: string; + description: string; +}; + +/** + * Copy for the uncertain case. + * + * Phrased as a statement about what this view can see rather than about what + * the agent did, and it never uses "no activity" — the whole point is that we + * do not know. `reason` names the specific gap so the message stays actionable + * instead of vaguely ominous. + */ +export function getUncertainHistoryCopy( + inputs: TranscriptHistoryInputs, +): TranscriptEmptyCopy { + if (inputs.hasSubscription === null) { + return { + title: "Checking for earlier activity", + description: "Looking up whether archived history is available here.", + }; + } + if (inputs.hasSubscription === false) { + return { + title: "Earlier activity may not be shown", + description: + "Archived history isn't indexed for this identity, so only activity observed live appears here.", + }; + } + return { + title: "Earlier activity may still be loading", + description: + "Archived history for this channel hasn't finished loading, so older activity may be missing.", + }; +} diff --git a/desktop/src/features/agents/ui/agentSessionToolRunGroupKey.test.mjs b/desktop/src/features/agents/ui/agentSessionToolRunGroupKey.test.mjs new file mode 100644 index 00000000000..823575ae98c --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolRunGroupKey.test.mjs @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getToolRunGroupKey } from "./agentSessionToolRunGroupKey.ts"; + +const timestamp = "2026-06-14T22:20:23.000Z"; + +function tool(id, turnId = "turn-1") { + return { + id, + type: "tool", + renderClass: "shell", + descriptor: { renderClass: "shell", label: "Ran command", preview: null }, + title: "Ran command", + toolName: "shell", + buzzToolName: null, + status: "completed", + args: {}, + result: "", + isError: false, + timestamp, + startedAt: timestamp, + completedAt: timestamp, + turnId, + }; +} + +test("the key is derived from turn and first leaf, not the batch id", () => { + const items = [tool("tool:ch1:call-1"), tool("tool:ch1:call-2")]; + assert.equal( + getToolRunGroupKey({ id: "summary:shell:tool:ch1:call-1", items }), + "group:turn-1:tool:ch1:call-1", + ); +}); + +test("the key survives the same-kind to mixed transition", () => { + const items = [tool("tool:ch1:call-1"), tool("tool:ch1:call-2")]; + const sameKind = getToolRunGroupKey({ + id: "summary:shell:tool:ch1:call-1", + items, + }); + const mixed = getToolRunGroupKey({ + id: "summary:mixed:tool:ch1:call-1", + items: [...items, tool("tool:ch1:call-3")], + }); + assert.equal(sameKind, mixed); +}); + +test("appending live calls to a growing group does not churn the key", () => { + const first = tool("tool:ch1:call-1"); + const growing = getToolRunGroupKey({ id: "summary:mixed:x", items: [first] }); + const grown = getToolRunGroupKey({ + id: "summary:mixed:x", + items: [first, tool("tool:ch1:call-2"), tool("tool:ch1:call-3")], + }); + assert.equal(growing, grown); +}); + +test("two turns doing identical work never collide", () => { + const a = getToolRunGroupKey({ + id: "summary:shell:a", + items: [tool("tool:ch1:call-1", "turn-1")], + }); + const b = getToolRunGroupKey({ + id: "summary:shell:a", + items: [tool("tool:ch1:call-1", "turn-2")], + }); + assert.notEqual(a, b); +}); + +test("a group without turn identity still keys off its first leaf", () => { + assert.equal( + getToolRunGroupKey({ + id: "summary:shell:x", + items: [tool("tool:ch1:call-9", null)], + }), + "group:no-turn:tool:ch1:call-9", + ); +}); + +test("distinct groups in one turn get distinct keys", () => { + const a = getToolRunGroupKey({ + id: "summary:shell:a", + items: [tool("tool:ch1:call-1")], + }); + const b = getToolRunGroupKey({ + id: "summary:shell:b", + items: [tool("tool:ch1:call-7")], + }); + assert.notEqual(a, b); +}); + +test("an empty group falls back to its batch id rather than colliding", () => { + const a = getToolRunGroupKey({ id: "summary:mixed:a", items: [] }); + const b = getToolRunGroupKey({ id: "summary:mixed:b", items: [] }); + assert.notEqual(a, b); +}); diff --git a/desktop/src/features/agents/ui/agentSessionToolRunGroupKey.ts b/desktop/src/features/agents/ui/agentSessionToolRunGroupKey.ts new file mode 100644 index 00000000000..dbd3f9ef7c1 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolRunGroupKey.ts @@ -0,0 +1,30 @@ +import type { TranscriptToolRunSummary } from "./agentSessionTranscriptGrouping"; + +/** + * Durable identity for a tool-run group, stable across re-grouping. + * + * A summary's own `id` encodes how the group was batched at the moment it was + * built: a same-kind run is `summary:read_file:`, and the moment one + * differing tool call lands beside it the same work is rebuilt as + * `summary:mixed:`. Keying reader state on that id would silently + * discard a deliberate choice to open a group every time the agent emitted one + * more call. + * + * The first leaf item's id is the stable part. Item ids derive from the channel + * and the ACP tool-call id, so they survive both re-derivation over merged + * live/archive windows and the same-kind → mixed transition (a group that + * grows by appending keeps its first leaf). `turnId` scopes the key so two + * turns can never collide, and is read from the leaf rather than the summary + * because only leaves carry turn identity. + */ +export function getToolRunGroupKey( + summary: Pick, +): string { + const first = summary.items[0]; + if (!first) { + // A group with no leaves cannot be identified by content; fall back to the + // batch-derived id rather than colliding every empty group onto one key. + return `group:empty:${summary.id}`; + } + return `group:${first.turnId ?? "no-turn"}:${first.id}`; +} diff --git a/desktop/src/features/agents/ui/agentSessionToolRunPartition.test.mjs b/desktop/src/features/agents/ui/agentSessionToolRunPartition.test.mjs new file mode 100644 index 00000000000..c535168f91b --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolRunPartition.test.mjs @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + collectToolRunArtifacts, + isLowSignalToolStep, + partitionToolRunSteps, +} from "./agentSessionToolRunPartition.ts"; + +const timestamp = "2026-06-14T22:20:23.000Z"; + +function tool(id, renderClass, args, overrides = {}) { + return { + id, + type: "tool", + renderClass, + descriptor: { renderClass, label: "Did a thing", preview: null }, + title: "Did a thing", + toolName: renderClass, + buzzToolName: null, + status: "completed", + args, + result: "", + isError: false, + timestamp, + startedAt: timestamp, + completedAt: timestamp, + turnId: "turn-1", + ...overrides, + }; +} + +const shell = (id, command, overrides) => + tool(id, "shell", { command }, overrides); + +test("routine shell scaffolding is low signal", () => { + assert.equal(isLowSignalToolStep(shell("a", "ls -la src")), true); + assert.equal(isLowSignalToolStep(shell("b", "rg -n foo")), true); + assert.equal(isLowSignalToolStep(shell("c", "wc -l file.ts")), true); +}); + +test("a pipeline is plumbing even when its head is meaningful", () => { + assert.equal( + isLowSignalToolStep(shell("a", "pnpm test 2>&1 | tail -5")), + true, + ); +}); + +test("meaningful commands are never hidden", () => { + assert.equal(isLowSignalToolStep(shell("a", "pnpm test")), false); + assert.equal(isLowSignalToolStep(shell("b", "git commit")), false); +}); + +test("a failing or unfinished step is never hidden", () => { + assert.equal(isLowSignalToolStep(shell("a", "ls", { isError: true })), false); + assert.equal( + isLowSignalToolStep(shell("b", "ls", { status: "failed" })), + false, + ); + assert.equal( + isLowSignalToolStep(shell("c", "ls", { status: "executing" })), + false, + ); +}); + +test("non-shell work is never hidden", () => { + assert.equal( + isLowSignalToolStep(tool("a", "file-edit", { path: "a.ts" })), + false, + ); + assert.equal( + isLowSignalToolStep(tool("b", "relay-op", { channel: "c1" })), + false, + ); +}); + +test("a short group hides nothing", () => { + const items = [shell("a", "ls"), shell("b", "pwd"), shell("c", "wc -l x")]; + const { primaryItems, hiddenItems } = partitionToolRunSteps(items); + assert.equal(hiddenItems.length, 0); + assert.equal(primaryItems.length, 3); +}); + +test("a long group folds its scaffolding away", () => { + const items = [ + shell("a", "ls"), + shell("b", "pwd"), + shell("c", "pnpm test"), + tool("d", "file-edit", { path: "a.ts" }), + ]; + const { primaryItems, hiddenItems } = partitionToolRunSteps(items); + assert.deepEqual( + hiddenItems.map((item) => item.id), + ["a", "b"], + ); + assert.deepEqual( + primaryItems.map((item) => item.id), + ["c", "d"], + ); +}); + +test("a group with only one hideable step keeps everything visible", () => { + const items = [ + shell("a", "ls"), + shell("b", "pnpm test"), + tool("c", "file-edit", { path: "a.ts" }), + tool("d", "file-edit", { path: "b.ts" }), + ]; + assert.equal(partitionToolRunSteps(items).hiddenItems.length, 0); +}); + +test("a group that would fold to nothing keeps everything visible", () => { + const items = [ + shell("a", "ls"), + shell("b", "pwd"), + shell("c", "cat x"), + shell("d", "wc -l y"), + ]; + const { primaryItems, hiddenItems } = partitionToolRunSteps(items); + assert.equal(hiddenItems.length, 0); + assert.equal(primaryItems.length, 4); +}); + +test("a step the reader opened is pinned visible", () => { + const items = [ + shell("a", "ls"), + shell("b", "pwd"), + shell("c", "pnpm test"), + tool("d", "file-edit", { path: "a.ts" }), + ]; + const { primaryItems, hiddenItems } = partitionToolRunSteps(items, ["a"]); + assert.equal(hiddenItems.length, 0); + assert.equal(primaryItems.length, 4); +}); + +test("artifacts collect one entry per distinct file", () => { + const artifacts = collectToolRunArtifacts([ + tool("a", "file-read", { path: "src/a.ts" }), + tool("b", "file-edit", { path: "src/b.ts" }), + tool("c", "file-read", { path: "src/a.ts" }), + shell("d", "pnpm test"), + ]); + assert.deepEqual( + artifacts.map((artifact) => [artifact.filename, artifact.kind]), + [ + ["a.ts", "read"], + ["b.ts", "edit"], + ], + ); +}); + +test("an edit outranks a read of the same file", () => { + const artifacts = collectToolRunArtifacts([ + tool("a", "file-read", { path: "src/a.ts" }), + tool("b", "file-edit", { path: "src/a.ts" }), + ]); + assert.equal(artifacts.length, 1); + assert.equal(artifacts[0].kind, "edit"); + assert.equal(artifacts[0].itemId, "b"); +}); + +test("a failed step contributes no artifact", () => { + assert.deepEqual( + collectToolRunArtifacts([ + tool("a", "file-edit", { path: "src/a.ts" }, { isError: true }), + ]), + [], + ); +}); diff --git a/desktop/src/features/agents/ui/agentSessionToolRunPartition.ts b/desktop/src/features/agents/ui/agentSessionToolRunPartition.ts new file mode 100644 index 00000000000..9e34c17be42 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolRunPartition.ts @@ -0,0 +1,202 @@ +import type { TranscriptItem } from "./agentSessionTypes"; +import { getToolString } from "./agentSessionUtils"; + +type ToolItem = Extract; + +/** + * Shell entry points that are almost always scaffolding for the real work + * (inspecting the tree, reading a file, checking a binary exists) rather than + * the work itself. Matched against the FIRST token of the command only, so + * `grep -rn …` is internal but a project script named `grep-report` is not. + */ +const INTERNAL_SHELL_COMMANDS = new Set([ + "awk", + "basename", + "cat", + "cd", + "chmod", + "cp", + "dirname", + "echo", + "env", + "file", + "find", + "grep", + "head", + "ls", + "mkdir", + "mv", + "printf", + "pwd", + "rg", + "sed", + "sort", + "stat", + "tail", + "tr", + "uniq", + "wc", + "which", +]); + +/** Compound shells are pipelines/chains — plumbing, not a narrative step. */ +const COMPOUND_SHELL_MARKERS = ["&&", "||", "2>&1", " | ", ";"]; + +/** + * A group must be at least this long before any child is hidden. Below it, the + * group is already short enough to read, and hiding would trade one row of + * signal for one row of chrome. + */ +export const INTERNAL_STEP_GROUP_MINIMUM = 4; + +/** At least this many children must be hideable for the toggle to earn a row. */ +export const INTERNAL_STEP_MINIMUM_HIDDEN = 2; + +function isCompletedAndClean(item: TranscriptItem): boolean { + if (item.type !== "tool") return false; + if (item.isError || item.status === "failed") return false; + return item.status === "completed"; +} + +/** + * Whether a single completed step is low-signal enough to fold away. + * + * Deliberately conservative: only successful, finished, shell-flavored + * scaffolding qualifies. A failure, an in-flight step, a file edit, a relay op, + * or anything the classifier could not identify always stays visible — the cost + * of hiding something consequential is far higher than the cost of one extra + * routine row. + */ +export function isLowSignalToolStep(item: TranscriptItem): boolean { + if (!isCompletedAndClean(item)) return false; + const tool = item as ToolItem; + const renderClass = tool.renderClass; + if (renderClass !== "shell") return false; + + const command = getToolString(tool.args, ["command"])?.trim(); + if (!command) return false; + + if (COMPOUND_SHELL_MARKERS.some((marker) => command.includes(marker))) { + return true; + } + + const firstToken = command.split(/\s+/)[0]?.toLowerCase() ?? ""; + const bareToken = firstToken.slice(firstToken.lastIndexOf("/") + 1); + return INTERNAL_SHELL_COMMANDS.has(bareToken); +} + +export type ToolRunPartition = { + /** Steps always rendered when the group is open. */ + primaryItems: TranscriptItem[]; + /** Low-signal steps folded behind progressive disclosure. */ + hiddenItems: TranscriptItem[]; +}; + +/** + * Split a group's children into always-visible and foldable steps. + * + * Returns everything as primary (an empty `hiddenItems`) unless every gate + * passes: the group is long enough, at least two children would hide, and at + * least one primary step remains. A group that folds down to nothing but a + * disclosure toggle is worse than the group it replaced. + * + * `expandedItemIds` pins steps the reader opened, so disclosure never yanks + * away something being read. + */ +export function partitionToolRunSteps( + items: readonly TranscriptItem[], + expandedItemIds: readonly string[] = [], +): ToolRunPartition { + const all = [...items]; + if (all.length < INTERNAL_STEP_GROUP_MINIMUM) { + return { primaryItems: all, hiddenItems: [] }; + } + + const pinned = new Set(expandedItemIds); + const primaryItems: TranscriptItem[] = []; + const hiddenItems: TranscriptItem[] = []; + + for (const item of all) { + if (!pinned.has(item.id) && isLowSignalToolStep(item)) { + hiddenItems.push(item); + continue; + } + primaryItems.push(item); + } + + if ( + primaryItems.length === 0 || + hiddenItems.length < INTERNAL_STEP_MINIMUM_HIDDEN + ) { + return { primaryItems: all, hiddenItems: [] }; + } + + return { primaryItems, hiddenItems }; +} + +export type ToolRunArtifact = { + /** Path as the agent referenced it — used for the tooltip. */ + path: string; + /** Trailing filename segment — the chip label. */ + filename: string; + /** Id of the step that produced or touched it, so a chip can reveal it. */ + itemId: string; + kind: "edit" | "read"; +}; + +function artifactPathForItem(item: TranscriptItem): { + path: string; + kind: "edit" | "read"; +} | null { + if (item.type !== "tool" || item.isError) return null; + const renderClass = item.renderClass; + if (renderClass !== "file-edit" && renderClass !== "file-read") return null; + + const path = + getToolString(item.args, ["path", "file_path", "filePath"]) ?? + item.descriptor.object ?? + null; + if (!path) return null; + + return { path, kind: renderClass === "file-edit" ? "edit" : "read" }; +} + +function filenameForPath(path: string): string { + const trimmed = path.replace(/\/+$/, ""); + const segment = trimmed.slice(trimmed.lastIndexOf("/") + 1); + return segment || trimmed; +} + +/** + * Every file a group touched, one entry per distinct path. + * + * These render OUTSIDE the group's collapsible body: a file the agent worked on + * is a durable outcome, not a detail of how the group happened to be batched, + * so it must not disappear when the group collapses and must look the same + * whether the group holds one step or twenty. An edit wins over a read for the + * same path — writing is the more consequential fact about that file. + */ +export function collectToolRunArtifacts( + items: readonly TranscriptItem[], +): ToolRunArtifact[] { + const byPath = new Map(); + + for (const item of items) { + const resolved = artifactPathForItem(item); + if (!resolved) continue; + + const existing = byPath.get(resolved.path); + if (existing && !(existing.kind === "read" && resolved.kind === "edit")) { + continue; + } + + byPath.set(resolved.path, { + path: resolved.path, + filename: filenameForPath(resolved.path), + itemId: item.id, + kind: resolved.kind, + }); + } + + return [...byPath.values()]; +} diff --git a/desktop/src/features/agents/ui/agentSessionToolRunStatus.test.mjs b/desktop/src/features/agents/ui/agentSessionToolRunStatus.test.mjs new file mode 100644 index 00000000000..b07256c1b43 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolRunStatus.test.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + countToolRunGroupFailures, + getToolRunGroupStatus, + isToolRunGroupActive, +} from "./agentSessionToolRunStatus.ts"; + +const timestamp = "2026-06-14T22:20:23.000Z"; + +function tool(id, status, overrides = {}) { + return { + id, + type: "tool", + renderClass: "shell", + descriptor: { renderClass: "shell", label: "Ran command", preview: null }, + title: "Ran command", + toolName: "shell", + buzzToolName: null, + status, + args: { command: "pnpm test" }, + result: "", + isError: false, + timestamp, + startedAt: timestamp, + completedAt: status === "completed" ? timestamp : null, + turnId: "turn-1", + ...overrides, + }; +} + +test("an all-completed group reports completed", () => { + assert.equal( + getToolRunGroupStatus([tool("a", "completed"), tool("b", "completed")]), + "completed", + ); +}); + +test("failure outranks completed, running, and pending", () => { + const items = [ + tool("a", "completed"), + tool("b", "executing"), + tool("c", "pending"), + tool("d", "failed"), + ]; + assert.equal(getToolRunGroupStatus(items), "failed"); +}); + +test("isError alone is enough to fail the group", () => { + const items = [ + tool("a", "completed"), + tool("b", "completed", { isError: true }), + ]; + assert.equal(getToolRunGroupStatus(items), "failed"); +}); + +test("running outranks pending and completed", () => { + const items = [ + tool("a", "completed"), + tool("b", "pending"), + tool("c", "executing"), + ]; + assert.equal(getToolRunGroupStatus(items), "executing"); +}); + +test("pending outranks completed", () => { + assert.equal( + getToolRunGroupStatus([tool("a", "completed"), tool("b", "pending")]), + "pending", + ); +}); + +test("status precedence is order-independent", () => { + const failing = tool("a", "failed"); + const running = tool("b", "executing"); + const done = tool("c", "completed"); + assert.equal(getToolRunGroupStatus([failing, running, done]), "failed"); + assert.equal(getToolRunGroupStatus([done, running, failing]), "failed"); + assert.equal(getToolRunGroupStatus([running, failing, done]), "failed"); +}); + +test("a lifecycle error row fails the group it sits in", () => { + const items = [ + tool("a", "completed"), + { + id: "err", + type: "lifecycle", + renderClass: "error", + title: "Session error", + text: "boom", + timestamp, + }, + ]; + assert.equal(getToolRunGroupStatus(items), "failed"); +}); + +test("an empty group has nothing outstanding", () => { + assert.equal(getToolRunGroupStatus([]), "completed"); +}); + +test("only executing and pending count as active", () => { + assert.equal(isToolRunGroupActive("executing"), true); + assert.equal(isToolRunGroupActive("pending"), true); + assert.equal(isToolRunGroupActive("failed"), false); + assert.equal(isToolRunGroupActive("completed"), false); +}); + +test("failure count covers both failed status and isError", () => { + const items = [ + tool("a", "failed"), + tool("b", "completed", { isError: true }), + tool("c", "completed"), + ]; + assert.equal(countToolRunGroupFailures(items), 2); + assert.equal(countToolRunGroupFailures([tool("a", "completed")]), 0); +}); diff --git a/desktop/src/features/agents/ui/agentSessionToolRunStatus.ts b/desktop/src/features/agents/ui/agentSessionToolRunStatus.ts new file mode 100644 index 00000000000..a736b4ab176 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolRunStatus.ts @@ -0,0 +1,73 @@ +import type { TranscriptItem } from "./agentSessionTypes"; + +/** + * Aggregate status for a collapsed group of transcript activity. + * + * The ordering below is deliberately failure-leaning: a collapsed parent must + * never present a group as finished-and-fine while one of its children failed + * or is still running. Reading the aggregate is the whole point of collapsing, + * so the aggregate is the one place we refuse to round in the optimistic + * direction. + */ +export type ToolRunGroupStatus = + | "failed" + | "executing" + | "pending" + | "completed"; + +/** Precedence order applied by {@link getToolRunGroupStatus} (worst first). */ +const STATUS_PRECEDENCE: ToolRunGroupStatus[] = [ + "failed", + "executing", + "pending", + "completed", +]; + +function statusForItem(item: TranscriptItem): ToolRunGroupStatus { + if (item.type === "tool") { + if (item.isError || item.status === "failed") return "failed"; + if (item.status === "executing") return "executing"; + if (item.status === "pending") return "pending"; + return "completed"; + } + + // Non-tool rows can still carry failure (a lifecycle error reclassified into + // the group's span). Everything else is inert for aggregation purposes. + return item.renderClass === "error" ? "failed" : "completed"; +} + +/** + * Fold a group's children into one status, leaning toward the worst outcome. + * + * `failed` beats `executing` beats `pending` beats `completed`. An empty group + * reports `completed` — there is nothing outstanding to warn about. + */ +export function getToolRunGroupStatus( + items: readonly TranscriptItem[], +): ToolRunGroupStatus { + let worstIndex = STATUS_PRECEDENCE.length - 1; + for (const item of items) { + const index = STATUS_PRECEDENCE.indexOf(statusForItem(item)); + if (index < worstIndex) { + worstIndex = index; + } + if (worstIndex === 0) break; + } + return STATUS_PRECEDENCE[worstIndex]; +} + +/** True while a group still has work outstanding. */ +export function isToolRunGroupActive(status: ToolRunGroupStatus): boolean { + return status === "executing" || status === "pending"; +} + +/** Number of failing children in a group (used for conspicuous failure copy). */ +export function countToolRunGroupFailures( + items: readonly TranscriptItem[], +): number { + let failures = 0; + for (const item of items) { + if (statusForItem(item) === "failed") failures += 1; + } + return failures; +} diff --git a/desktop/src/features/agents/ui/agentSessionToolRunViewState.test.mjs b/desktop/src/features/agents/ui/agentSessionToolRunViewState.test.mjs new file mode 100644 index 00000000000..bfe2427475e --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolRunViewState.test.mjs @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + initialToolRunGroupViewState, + reconcileToolRunGroupViewState, + setToolRunGroupExpanded, + setToolRunGroupInternalStepsShown, + setToolRunGroupItemExpanded, +} from "./agentSessionToolRunViewState.ts"; + +test("a group that mounts already finished starts collapsed", () => { + assert.equal(initialToolRunGroupViewState("completed").expanded, false); +}); + +test("a group that mounts live or failed starts open", () => { + assert.equal(initialToolRunGroupViewState("executing").expanded, true); + assert.equal(initialToolRunGroupViewState("pending").expanded, true); + assert.equal(initialToolRunGroupViewState("failed").expanded, true); +}); + +test("an untouched group auto-collapses when it finishes cleanly", () => { + const running = initialToolRunGroupViewState("executing"); + const settled = reconcileToolRunGroupViewState( + running, + "executing", + "completed", + ); + assert.equal(settled.expanded, false); + assert.equal(settled.userInteracted, false); +}); + +test("a group the reader opened is never auto-collapsed", () => { + const touched = setToolRunGroupExpanded( + initialToolRunGroupViewState("executing"), + true, + ); + const settled = reconcileToolRunGroupViewState( + touched, + "executing", + "completed", + ); + assert.equal(settled.expanded, true); + assert.equal(settled, touched); +}); + +test("a group the reader deliberately closed stays closed when it fails", () => { + const closed = setToolRunGroupExpanded( + initialToolRunGroupViewState("executing"), + false, + ); + const failed = reconcileToolRunGroupViewState(closed, "executing", "failed"); + assert.equal(failed.expanded, false); +}); + +test("an untouched group opens itself when it starts failing", () => { + const running = initialToolRunGroupViewState("pending"); + const collapsed = reconcileToolRunGroupViewState( + running, + "pending", + "completed", + ); + assert.equal(collapsed.expanded, false); + const failed = reconcileToolRunGroupViewState( + collapsed, + "completed", + "failed", + ); + assert.equal(failed.expanded, true); +}); + +test("an unchanged status never moves the group", () => { + const state = initialToolRunGroupViewState("executing"); + assert.equal( + reconcileToolRunGroupViewState(state, "executing", "executing"), + state, + ); +}); + +test("a group that was never active does not collapse on completed", () => { + const state = initialToolRunGroupViewState("failed"); + const next = reconcileToolRunGroupViewState(state, "failed", "completed"); + assert.equal(next.expanded, true); +}); + +test("opening a child step opens the group and latches interaction", () => { + const state = setToolRunGroupItemExpanded( + initialToolRunGroupViewState("completed"), + "tool:b", + true, + ); + assert.equal(state.expanded, true); + assert.equal(state.userInteracted, true); + assert.deepEqual(state.expandedItemIds, ["tool:b"]); +}); + +test("closing a child step leaves the group open but drops the pin", () => { + const opened = setToolRunGroupItemExpanded( + initialToolRunGroupViewState("completed"), + "tool:b", + true, + ); + const closed = setToolRunGroupItemExpanded(opened, "tool:b", false); + assert.equal(closed.expanded, true); + assert.deepEqual(closed.expandedItemIds, []); +}); + +test("expanded child ids are deterministic regardless of click order", () => { + const base = initialToolRunGroupViewState("completed"); + const forward = setToolRunGroupItemExpanded( + setToolRunGroupItemExpanded(base, "tool:a", true), + "tool:b", + true, + ); + const reverse = setToolRunGroupItemExpanded( + setToolRunGroupItemExpanded(base, "tool:b", true), + "tool:a", + true, + ); + assert.deepEqual(forward.expandedItemIds, reverse.expandedItemIds); +}); + +test("revealing internal steps latches interaction", () => { + const state = setToolRunGroupInternalStepsShown( + initialToolRunGroupViewState("completed"), + true, + ); + assert.equal(state.showInternalSteps, true); + assert.equal(state.userInteracted, true); + const settled = reconcileToolRunGroupViewState( + state, + "executing", + "completed", + ); + assert.equal(settled.showInternalSteps, true); +}); diff --git a/desktop/src/features/agents/ui/agentSessionToolRunViewState.ts b/desktop/src/features/agents/ui/agentSessionToolRunViewState.ts new file mode 100644 index 00000000000..02be029975a --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolRunViewState.ts @@ -0,0 +1,116 @@ +import { + isToolRunGroupActive, + type ToolRunGroupStatus, +} from "./agentSessionToolRunStatus"; + +/** + * Per-group presentation state for a collapsible tool-run group. + * + * Kept as a plain value with pure transitions so the expansion policy is + * testable without a DOM, and so the React layer only has to store it. + */ +export type ToolRunGroupViewState = { + /** Whether the group body is currently open. */ + expanded: boolean; + /** + * True once the reader has opened or closed anything in this group. Sticky: + * after this flips, automatic policy never moves the group again. A reader + * who deliberately opened a group to watch it must not have it closed under + * them when the work finishes. + */ + userInteracted: boolean; + /** Whether low-signal internal steps are revealed. */ + showInternalSteps: boolean; + /** Ids of individual child steps the reader expanded. */ + expandedItemIds: string[]; +}; + +/** + * Mount-aware initial state. + * + * A group that is already finished when it first mounts is history — it starts + * collapsed so a replayed transcript reads as a narrative rather than a wall of + * steps. A group that mounts while still running is live work being supervised, + * so it starts open. Failed groups also start open: a failure the reader has to + * click to discover is a failure the transcript hid. + */ +export function initialToolRunGroupViewState( + status: ToolRunGroupStatus, +): ToolRunGroupViewState { + return { + expanded: isToolRunGroupActive(status) || status === "failed", + userInteracted: false, + showInternalSteps: false, + expandedItemIds: [], + }; +} + +/** + * Apply the automatic policy after a status change. + * + * An untouched group that was active and has now completed successfully + * auto-collapses, so the transcript keeps moving forward as the agent works. + * Any of these keeps it open: + * - the reader interacted (sticky, forever) + * - the group ended in failure (conspicuous by default) + * - the group is still active + */ +export function reconcileToolRunGroupViewState( + previous: ToolRunGroupViewState, + previousStatus: ToolRunGroupStatus, + nextStatus: ToolRunGroupStatus, +): ToolRunGroupViewState { + if (previous.userInteracted) return previous; + if (previousStatus === nextStatus) return previous; + + if (nextStatus === "failed") { + return previous.expanded ? previous : { ...previous, expanded: true }; + } + + const settled = + isToolRunGroupActive(previousStatus) && nextStatus === "completed"; + if (!settled) return previous; + + return { + expanded: false, + userInteracted: false, + showInternalSteps: false, + expandedItemIds: [], + }; +} + +/** Reader toggled the whole group open or closed. */ +export function setToolRunGroupExpanded( + previous: ToolRunGroupViewState, + expanded: boolean, +): ToolRunGroupViewState { + return { ...previous, expanded, userInteracted: true }; +} + +/** Reader toggled one child step. Opening a step also opens the group. */ +export function setToolRunGroupItemExpanded( + previous: ToolRunGroupViewState, + itemId: string, + expanded: boolean, +): ToolRunGroupViewState { + const current = new Set(previous.expandedItemIds); + if (expanded) { + current.add(itemId); + } else { + current.delete(itemId); + } + return { + ...previous, + expanded: expanded ? true : previous.expanded, + userInteracted: true, + expandedItemIds: [...current].sort(), + }; +} + +/** Reader toggled the low-signal internal-step disclosure. */ +export function setToolRunGroupInternalStepsShown( + previous: ToolRunGroupViewState, + showInternalSteps: boolean, +): ToolRunGroupViewState { + return { ...previous, showInternalSteps, userInteracted: true }; +} diff --git a/desktop/src/features/agents/ui/agentSessionToolRunViewStore.ts b/desktop/src/features/agents/ui/agentSessionToolRunViewStore.ts new file mode 100644 index 00000000000..f9c9e773498 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolRunViewStore.ts @@ -0,0 +1,33 @@ +import type { ToolRunGroupViewState } from "./agentSessionToolRunViewState"; + +/** + * Per-group presentation state, keyed by a group's stable identity. + * + * This lives outside React because a group's React identity is not stable + * across regrouping: a same-kind run that later absorbs a differing tool call + * becomes a mixed burst with a different summary id, which remounts the card. + * Without this store, a reader's deliberate choice to open a group would be + * silently reverted by the agent emitting one more tool call. + * + * Community-scoped like every other transcript cache — see + * `resetCommunityState()` in `features/communities/useCommunityInit.ts`. + */ +const groupViewStates = new Map(); + +export function readToolRunGroupViewState( + groupKey: string, +): ToolRunGroupViewState | undefined { + return groupViewStates.get(groupKey); +} + +export function writeToolRunGroupViewState( + groupKey: string, + state: ToolRunGroupViewState, +): void { + groupViewStates.set(groupKey, state); +} + +/** Clear all remembered group state (community switch / test isolation). */ +export function resetAgentSessionToolRunViewState(): void { + groupViewStates.clear(); +} diff --git a/desktop/src/features/agents/ui/useObserverEvents.ts b/desktop/src/features/agents/ui/useObserverEvents.ts index 0c44a64d5f2..539187022ba 100644 --- a/desktop/src/features/agents/ui/useObserverEvents.ts +++ b/desktop/src/features/agents/ui/useObserverEvents.ts @@ -17,6 +17,7 @@ import { import { decryptObserverEvent } from "@/shared/api/tauriObserver"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { ObserverEvent, TranscriptItem } from "./agentSessionTypes"; +import { getTranscriptHistoryCertainty } from "./agentSessionHistoryCertainty"; import type { RelayEvent } from "@/shared/api/types"; import { createArchivePagingState, @@ -405,5 +406,14 @@ export function useLoadArchivedObserverEvents( }; }, [enabled, identityPubkey, hasSubscription, channelId]); - return { fetchOlderArchived, hasOlderArchived }; + // Expose whether an empty transcript can be stated as fact. Callers that + // render an empty state must not claim "no activity" when history was simply + // unreadable — see agentSessionHistoryCertainty. + const historyCertainty = getTranscriptHistoryCertainty({ + hasSubscription, + channelId, + archiveHydrated: !hasOlderArchived, + }); + + return { fetchOlderArchived, hasOlderArchived, historyCertainty }; } diff --git a/desktop/src/features/agents/ui/useToolRunGroupViewState.ts b/desktop/src/features/agents/ui/useToolRunGroupViewState.ts new file mode 100644 index 00000000000..7ddc02dae9a --- /dev/null +++ b/desktop/src/features/agents/ui/useToolRunGroupViewState.ts @@ -0,0 +1,80 @@ +import * as React from "react"; + +import type { ToolRunGroupStatus } from "./agentSessionToolRunStatus"; +import { + initialToolRunGroupViewState, + reconcileToolRunGroupViewState, + type ToolRunGroupViewState, +} from "./agentSessionToolRunViewState"; +import { + readToolRunGroupViewState, + writeToolRunGroupViewState, +} from "./agentSessionToolRunViewStore"; + +type ToolRunGroupViewStateEntry = { + groupKey: string; + status: ToolRunGroupStatus; + state: ToolRunGroupViewState; +}; + +function createEntry( + groupKey: string, + status: ToolRunGroupStatus, +): ToolRunGroupViewStateEntry { + return { + groupKey, + status, + state: + readToolRunGroupViewState(groupKey) ?? + initialToolRunGroupViewState(status), + }; +} + +/** + * Own one group's expansion state, restoring any remembered choice and applying + * the automatic policy when the group's aggregate status changes. + * + * State is recomputed during render (rather than in an effect) so the very + * first paint after a group finishes is already correct — an effect-based + * collapse would flash the open group for a frame. + */ +export function useToolRunGroupViewState( + groupKey: string, + status: ToolRunGroupStatus, +): { + state: ToolRunGroupViewState; + update: ( + next: (previous: ToolRunGroupViewState) => ToolRunGroupViewState, + ) => void; +} { + const [entry, setEntry] = React.useState(() => + createEntry(groupKey, status), + ); + + let current = entry; + if (entry.groupKey !== groupKey) { + current = createEntry(groupKey, status); + setEntry(current); + } else if (entry.status !== status) { + current = { + groupKey, + status, + state: reconcileToolRunGroupViewState(entry.state, entry.status, status), + }; + setEntry(current); + } + + const currentState = current.state; + React.useEffect(() => { + writeToolRunGroupViewState(groupKey, currentState); + }, [groupKey, currentState]); + + const update = React.useCallback( + (next: (previous: ToolRunGroupViewState) => ToolRunGroupViewState) => { + setEntry((previous) => ({ ...previous, state: next(previous.state) })); + }, + [], + ); + + return { state: currentState, update }; +} diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index c1933f14bb7..420a8140c7f 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -138,7 +138,7 @@ export function AgentSessionThreadPanel({ ? undefined : `Last updated ${new Date(latestActivityAt).toLocaleString()}`; - const { fetchOlderArchived, hasOlderArchived } = + const { fetchOlderArchived, hasOlderArchived, historyCertainty } = useLoadArchivedObserverEvents( // Archive history must load regardless of live status — an idle agent's // channel should still show its archived observer history. Enable whenever @@ -491,6 +491,7 @@ export function AgentSessionThreadPanel({ ? `Mention ${agent.name} in the channel to see its work here.` : `Mention ${agent.name} in any channel to see its work here.` } + emptyState={historyCertainty === "known-empty" ? "idle" : "unknown"} profiles={profiles} rawLayout="exclusive" showHeader={false} From c176335fef0e0789b83014c3949e5062ebf063cc Mon Sep 17 00:00:00 2001 From: ss-quality-00 <75789fffd704a5265cd65462cfc263f44561872fbebcea3a131a2a5f511465d2@buzz.block.builderlab.xyz> Date: Fri, 21 Aug 2026 18:09:48 -0700 Subject: [PATCH 2/5] Preserve transcript tool-run view choices Co-authored-by: ss-quality-00 <75789fffd704a5265cd65462cfc263f44561872fbebcea3a131a2a5f511465d2@buzz.block.builderlab.xyz> Signed-off-by: ss-quality-00 <75789fffd704a5265cd65462cfc263f44561872fbebcea3a131a2a5f511465d2@buzz.block.builderlab.xyz> --- desktop/playwright.config.ts | 1 + .../ui/AgentSessionToolItem/ToolItem.tsx | 16 +- .../agents/ui/AgentSessionTranscriptList.tsx | 16 +- .../agents/ui/TranscriptToolRunGroup.tsx | 28 +++- .../agents/ui/activityRenderClasses/types.ts | 3 + .../features/communities/useCommunityInit.ts | 2 + .../transcript-tool-run-quality.smoke.spec.ts | 148 ++++++++++++++++++ 7 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 desktop/tests/e2e/transcript-tool-run-quality.smoke.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index ff8a0e7703b..8da7fdcd85a 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -49,6 +49,7 @@ export default defineConfig({ "**/profile-active-turn.spec.ts", "**/config-bridge-screenshots.spec.ts", "**/observer-feed-screenshots.spec.ts", + "**/transcript-tool-run-quality.smoke.spec.ts", "**/core-memory-screenshots.spec.ts", "**/activity-scope-label-screenshots.spec.ts", "**/welcome-agent-modal-screenshots.spec.ts", diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx index e0f2ce9fbc3..4913938cf3c 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx @@ -29,11 +29,16 @@ export function ToolItem({ agentPubkey, item, profiles, + expanded, + onExpansionChange, }: AgentTranscriptIdentityProps & { item: Extract; profiles?: UserProfileLookup; + expanded?: boolean; + onExpansionChange?: (expanded: boolean) => void; }) { - const [isExpanded, setIsExpanded] = React.useState(false); + const [localExpanded, setLocalExpanded] = React.useState(false); + const isExpanded = expanded ?? localExpanded; const hasArgs = Object.keys(item.args).length > 0; const hasResult = item.result.trim().length > 0; const canonicalToolName = item.buzzToolName ?? item.toolName; @@ -52,9 +57,14 @@ export function ToolItem({ const agentResolvedAvatarUrl = agentProfile?.avatarUrl ?? agentAvatarUrl; const handleToggle = React.useCallback( (event: React.SyntheticEvent) => { - setIsExpanded(event.currentTarget.open); + const nextExpanded = event.currentTarget.open; + if (onExpansionChange) { + onExpansionChange(nextExpanded); + } else { + setLocalExpanded(nextExpanded); + } }, - [], + [onExpansionChange], ); if (compactSummary.presentation === "message") { diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 576cdbbf45c..236edabeb79 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -533,7 +533,13 @@ function SameKindSummaryItem({ // the group card never has to import this module back. Not memoized: the // group card calls it during render and never uses its identity as a // dependency, and memoizing would need this component as its own dep. - const renderChild = (child: TranscriptToolRunChildSegment) => + const renderChild = ( + child: TranscriptToolRunChildSegment, + expansion?: { + expanded: boolean; + onExpansionChange: (expanded: boolean) => void; + }, + ) => child.kind === "summary" ? ( ); @@ -948,17 +956,23 @@ const TranscriptItemView = React.memo(function TranscriptItemView({ agentName, agentPubkey, item, + onExpansionChange, profiles, + expanded, }: AgentTranscriptIdentityProps & { item: TranscriptItem; profiles?: UserProfileLookup; + expanded?: boolean; + onExpansionChange?: (expanded: boolean) => void; }) { return ( ); diff --git a/desktop/src/features/agents/ui/TranscriptToolRunGroup.tsx b/desktop/src/features/agents/ui/TranscriptToolRunGroup.tsx index 57ebc866777..7fa0c7265f7 100644 --- a/desktop/src/features/agents/ui/TranscriptToolRunGroup.tsx +++ b/desktop/src/features/agents/ui/TranscriptToolRunGroup.tsx @@ -23,6 +23,7 @@ import { import { setToolRunGroupExpanded, setToolRunGroupInternalStepsShown, + setToolRunGroupItemExpanded, } from "./agentSessionToolRunViewState"; import { useToolRunGroupViewState } from "./useToolRunGroupViewState"; import { formatTranscriptTimestampTitle } from "./agentSessionUtils"; @@ -121,7 +122,13 @@ export type TranscriptToolRunGroupProps = { label: React.ReactNode; showTimestamp: boolean; /** Renders one child segment. Passed in to avoid a list↔group import cycle. */ - renderChild: (child: TranscriptToolRunChildSegment) => React.ReactNode; + renderChild: ( + child: TranscriptToolRunChildSegment, + expansion?: { + expanded: boolean; + onExpansionChange: (expanded: boolean) => void; + }, + ) => React.ReactNode; }; /** @@ -200,7 +207,24 @@ export function TranscriptToolRunGroup({ {label} - {visibleChildren.map((child) => renderChild(child))} + {visibleChildren.map((child) => + renderChild( + child, + child.kind === "item" && child.item.type === "tool" + ? { + expanded: state.expandedItemIds.includes(child.item.id), + onExpansionChange: (expanded) => + update((previous) => + setToolRunGroupItemExpanded( + previous, + child.item.id, + expanded, + ), + ), + } + : undefined, + ), + )} {hiddenItems.length > 0 ? (
diff --git a/desktop/src/features/agents/ui/TranscriptToolRunGroup.tsx b/desktop/src/features/agents/ui/TranscriptToolRunGroup.tsx index 7fa0c7265f7..1f76c957b0f 100644 --- a/desktop/src/features/agents/ui/TranscriptToolRunGroup.tsx +++ b/desktop/src/features/agents/ui/TranscriptToolRunGroup.tsx @@ -12,7 +12,6 @@ import type { } from "./agentSessionTranscriptGrouping"; import { getToolRunGroupKey } from "./agentSessionToolRunGroupKey"; import { - countToolRunGroupFailures, getToolRunGroupStatus, isToolRunGroupActive, } from "./agentSessionToolRunStatus"; @@ -26,6 +25,7 @@ import { setToolRunGroupItemExpanded, } from "./agentSessionToolRunViewState"; import { useToolRunGroupViewState } from "./useToolRunGroupViewState"; +import { useToolRunGroupKey } from "./useTranscriptToolRunGroupKeys"; import { formatTranscriptTimestampTitle } from "./agentSessionUtils"; import { TranscriptRowTimestamp } from "./TranscriptRowTimestamp"; @@ -34,14 +34,19 @@ import { TranscriptRowTimestamp } from "./TranscriptRowTimestamp"; * * Only non-clean outcomes render. A completed group says nothing — "done" is * the expected case and a badge on every finished group would train the reader - * to ignore the badge that matters. Failures and in-flight work are exactly the - * states worth interrupting for, which is why the aggregate leans that way. + * to ignore the badge that matters. In-flight work is exactly the state worth + * interrupting for. + * + * The `failed` branch is a safety net that mirrors the failure-leaning fold in + * `getToolRunGroupStatus`; no observer frame reaches it today because grouping + * ejects failed calls into standalone rows (see that module's note). It is kept + * deliberately plain — one word, no count — because an aggregate that cannot be + * produced should not carry presentation detail nobody can verify. If grouping + * ever admits failures, this says the true thing rather than nothing. */ function ToolRunGroupStatusBadge({ - failureCount, status, }: { - failureCount: number; status: ReturnType; }) { if (status === "completed") return null; @@ -53,7 +58,7 @@ function ToolRunGroupStatusBadge({ data-testid="tool-run-group-status-failed" >