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