diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
index d24c8fab79..576cdbbf45 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 0000000000..e8e6389330
--- /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 0000000000..57ebc86677
--- /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 (
+
+
+ {failureCount > 1 ? `${failureCount} failed` : "failed"}
+
+ );
+ }
+
+ return (
+
+
+ {status === "pending" ? "queued" : "running"}
+
+ );
+}
+
+/**
+ * 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" ? (
+
+ ) : (
+
+ )}
+ {artifact.filename}
+
+ ))}
+
+ );
+}
+
+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 ? (
+
+ update((previous) =>
+ setToolRunGroupInternalStepsShown(
+ previous,
+ !previous.showInternalSteps,
+ ),
+ )
+ }
+ type="button"
+ >
+ {state.showInternalSteps
+ ? "Hide routine steps"
+ : `Show ${hiddenItems.length} routine steps`}
+
+ ) : 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 8c82bb0d6c..6da7edf788 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 0000000000..41e61c9144
--- /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 0000000000..823575ae98
--- /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 0000000000..dbd3f9ef7c
--- /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 0000000000..c535168f91
--- /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 0000000000..9e34c17be4
--- /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 0000000000..b07256c1b4
--- /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 0000000000..a736b4ab17
--- /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 0000000000..bfe2427475
--- /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 0000000000..02be029975
--- /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 0000000000..f9c9e77349
--- /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 0c44a64d5f..539187022b 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 0000000000..7ddc02dae9
--- /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 c1933f14bb..420a8140c7 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}