From 090787726c2455df4efd15fbdc058619935725ec Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Wed, 24 Jun 2026 22:59:45 -0700 Subject: [PATCH 01/49] feat(desktop): polish agent-session transcript UI atop declared-ownership model Replay of PR #1061 (tho/activity-ui-polish) onto fresh main: lands the 20 UI-polish commits as one net diff, reconciled with #1229's merged declared-ownership model (viewerIsOwner = isCurrentUserOwner || isOwner) and #1089's content-visibility-auto virtualization. Notable reconciles: - markdown.tsx: ported the compact/tight variant system into main's newer lightbox/spoiler markdown component (rather than overwriting it), layering variant density/leading overrides after the base owl-spacing so tailwind-merge wins. Dropped the branch's hardcoded text-[15px] in favor of main's rem-token text-sm base (post-#1052 zoom-safe scale). - agentSessionTranscript.ts: pass TranscriptItemContext (not channelId). - managed_agents: thread avatar_url through ManagedAgentSummary so the transcript renders the assistant-bubble avatar from the pinned record snapshot; bumped runtime.rs size override 2001 -> 2002 for the +1 line. Observer-seed screenshots intentionally excluded (separate follow-up). Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/scripts/check-file-sizes.mjs | 4 +- .../src-tauri/src/managed_agents/runtime.rs | 1 + desktop/src-tauri/src/managed_agents/types.rs | 1 + .../agents/ui/AgentSessionToolItem.tsx | 569 ++++++++---------- .../agents/ui/AgentSessionTranscriptList.tsx | 567 +++++++++++++++-- .../agents/ui/ManagedAgentSessionPanel.tsx | 69 ++- .../src/features/agents/ui/RawEventRail.tsx | 44 +- .../ui/agentSessionPanelLayout.test.mjs | 85 +++ .../agents/ui/agentSessionPanelLayout.ts | 48 ++ .../ui/agentSessionToolItemHelpers.test.mjs | 128 ++++ .../ui/agentSessionToolSummary.test.mjs | 157 +++++ .../agents/ui/agentSessionToolSummary.ts | 550 +++++++++++++++++ .../agents/ui/agentSessionTranscript.ts | 125 +++- .../agentSessionTranscriptGrouping.test.mjs | 213 +++++++ .../ui/agentSessionTranscriptGrouping.ts | 208 +++++++ .../ui/agentSessionTranscriptHelpers.test.mjs | 31 + .../ui/agentSessionTranscriptHelpers.ts | 34 +- ...gentSessionTranscriptPresentation.test.mjs | 148 +++++ .../ui/agentSessionTranscriptPresentation.ts | 280 +++++++++ .../features/agents/ui/agentSessionTypes.ts | 40 +- .../features/agents/ui/agentSessionUtils.ts | 85 +++ .../features/agents/ui/rawEventRail.test.mjs | 42 ++ .../channels/ui/AgentSessionThreadPanel.tsx | 53 +- .../features/channels/ui/BotActivityBar.tsx | 21 +- .../features/channels/ui/ChannelScreen.tsx | 44 +- .../channels/ui/useChannelActivityTyping.ts | 12 +- desktop/src/shared/api/tauri.ts | 7 +- desktop/src/shared/api/types.ts | 1 + desktop/src/shared/ui/markdown.tsx | 49 +- desktop/src/shared/ui/markdown/types.ts | 5 + desktop/src/shared/ui/toggle.tsx | 6 +- desktop/src/testing/e2eBridge.ts | 5 + desktop/tests/e2e/channels.spec.ts | 6 + 33 files changed, 3145 insertions(+), 493 deletions(-) create mode 100644 desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs create mode 100644 desktop/src/features/agents/ui/agentSessionPanelLayout.ts create mode 100644 desktop/src/features/agents/ui/agentSessionToolItemHelpers.test.mjs create mode 100644 desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs create mode 100644 desktop/src/features/agents/ui/agentSessionToolSummary.ts create mode 100644 desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs create mode 100644 desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts create mode 100644 desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs create mode 100644 desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts create mode 100644 desktop/src/features/agents/ui/rawEventRail.test.mjs diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 60768de9b..c21ff060c 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -73,6 +73,8 @@ const overrides = new Map([ // unify refactor followup. +26 for resolve_effective_prompt_model_provider // re-introduced after 826d735fe removal (config-bridge caller still needs it). // PGID resolution helper + PID-recycling safety guard added for orphan sweep. + // activity-feed threads avatar_url into build_managed_agent_summary for the + // assistant-bubble pinned snapshot. ["src-tauri/src/managed_agents/runtime.rs", 2150], // applyWorkspace reposDir parameter plus the validateReposDir binding, // threaded through Tauri invokes for configurable repos_dir, plus the @@ -121,7 +123,7 @@ const overrides = new Map([ ["src/features/channels/readState/readStateManager.ts", 1030], // Shared UI was added to this guard after splitting globals/markdown so // large shared renderers cannot grow further while follow-up splits land. - ["src/shared/ui/markdown.tsx", 2082], + ["src/shared/ui/markdown.tsx", 2119], ["src/shared/ui/VideoPlayer.tsx", 2199], ["src/shared/ui/sidebar.tsx", 1042], // Option C databricks-model-discovery: parse/HTTP logic moved to buzz-agent diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 585ffea26..de38c3b1b 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1539,6 +1539,7 @@ pub fn build_managed_agent_summary( max_turn_duration_seconds: record.max_turn_duration_seconds, parallelism: record.parallelism, system_prompt: record.system_prompt.clone(), + avatar_url: record.avatar_url.clone(), model: record.model.clone(), provider: record.provider.clone(), persona_out_of_date, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 3ff56fcc4..c3fb09709 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -268,6 +268,7 @@ pub struct ManagedAgentSummary { pub max_turn_duration_seconds: Option, pub parallelism: u32, pub system_prompt: Option, + pub avatar_url: Option, pub model: Option, /// LLM inference provider, from the agent's pinned record snapshot. pub provider: Option, diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx index f2aa0247c..773c9dd80 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx @@ -1,29 +1,16 @@ import * as React from "react"; -import { ArrowUpRight, ChevronDown, Wrench } from "lucide-react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { ChevronDown, Send } from "lucide-react"; -import { useAppNavigation } from "@/app/navigation/useAppNavigation"; -import { useUsersBatchQuery } from "@/features/profile/hooks"; -import { resolveUserLabel } from "@/features/profile/lib/identity"; -import type { Channel, UserProfileSummary } from "@/shared/api/types"; -import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; import { cn } from "@/shared/lib/cn"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; -import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import type { TranscriptItem } from "./agentSessionTypes"; +import { getBuzzToolInfo } from "./agentSessionToolCatalog"; +import { buildCompactToolSummary } from "./agentSessionToolSummary"; import { - formatToolTitle, - getBuzzToolInfo, - getToolStatusDisplay, -} from "./agentSessionToolCatalog"; -import { - asRecord, formatCodeValue, - formatDuration, - formatTranscriptTime, - getResultArray, - getToolString, - getToolStringList, - shortenMiddle, + getToolDurationDisplay, + isInlineImageData, } from "./agentSessionUtils"; export function ToolItem({ @@ -32,14 +19,12 @@ export function ToolItem({ item: Extract; }) { const [isExpanded, setIsExpanded] = React.useState(false); - const status = getToolStatusDisplay(item.status, item.isError); const hasArgs = Object.keys(item.args).length > 0; const hasResult = item.result.trim().length > 0; const canonicalToolName = item.buzzToolName ?? item.toolName; const buzzTool = getBuzzToolInfo(canonicalToolName); - const ToolIcon = buzzTool?.icon ?? Wrench; - const showStatus = status.state !== "output-available"; - const toolTitle = formatToolTitle(canonicalToolName, item.title); + const compactSummary = buildCompactToolSummary(item); + const duration = getToolDurationDisplay(item); const handleToggle = React.useCallback( (event: React.SyntheticEvent) => { setIsExpanded(event.currentTarget.open); @@ -48,40 +33,38 @@ export function ToolItem({ ); return ( -
+
- - {ToolIcon ? ( - + {compactSummary.presentation === "message" ? ( + - ) : null} - - {toolTitle} - - {buzzTool ? ( - - ) : null} - {showStatus ? ( - - - {status.label} - - ) : null} - - + ) : ( + + )} @@ -97,11 +88,219 @@ export function ToolItem({ ); } +function compactSummaryTone() { + return "text-muted-foreground/60 group-open:text-muted-foreground"; +} + +function resolveImageSrc(source: string): string { + return isInlineImageData(source) ? source : rewriteRelayUrl(source); +} + +function CompactToolSummaryRow({ + duration, + label, + preview, + thumbnailSrc, +}: { + duration: string | null; + label: string; + preview: string | null; + thumbnailSrc: string | null; +}) { + const [thumbnailFailed, setThumbnailFailed] = React.useState(false); + const mutedTone = compactSummaryTone(); + const resolvedThumbnail = React.useMemo(() => { + if (!thumbnailSrc || thumbnailFailed) return null; + return resolveImageSrc(thumbnailSrc); + }, [thumbnailFailed, thumbnailSrc]); + + return ( + <> + + {label} + + {resolvedThumbnail ? ( + setThumbnailFailed(true)} + src={resolvedThumbnail} + title={preview ?? undefined} + /> + ) : preview ? ( + + {preview} + + ) : null} + {duration ? ( + {duration} + ) : null} + + + ); +} + +function CompactMessageSummary({ + duration, + isError, + label, + preview, +}: { + duration: string | null; + isError: boolean; + label: string; + preview: string | null; +}) { + const mutedTone = compactSummaryTone(); + return ( +
+
+

+ {preview || "Message content unavailable."} +

+
+
+ + + {label} + + {duration ? ( + {duration} + ) : null} + +
+
+ ); +} + +function ViewImageToolPreview({ + src, + title, +}: { + src: string; + title: string | null; +}) { + const [lightboxOpen, setLightboxOpen] = React.useState(false); + const [imageFailed, setImageFailed] = React.useState(false); + const resolvedSrc = React.useMemo(() => resolveImageSrc(src), [src]); + const alt = title ?? "Viewed image"; + + if (imageFailed) { + return null; + } + + return ( + <> + {/* biome-ignore lint/a11y/useKeyWithClickEvents: opens lightbox on click */} + {alt} setLightboxOpen(true)} + onError={() => setImageFailed(true)} + src={resolvedSrc} + title={title ?? undefined} + /> + + + ); +} + +function ImageLightbox({ + alt, + onOpenChange, + open, + src, +}: { + alt: string; + onOpenChange: (open: boolean) => void; + open: boolean; + src: string; +}) { + return ( + + + + event.preventDefault()} + onPointerDownOutside={(event) => event.preventDefault()} + > + + {alt} + + + Full-size image preview. Press Escape or click outside the image to + close. + + + {alt} + + + + + + + ); +} + function ToolDetailBlocks({ args, description, hasArgs, hasResult, + imagePreview, isError, result, }: { @@ -109,6 +308,7 @@ function ToolDetailBlocks({ description?: string; hasArgs: boolean; hasResult: boolean; + imagePreview: { src: string | null; title: string | null } | null; isError: boolean; result: string; }) { @@ -119,6 +319,12 @@ function ToolDetailBlocks({ {description}

) : null} + {imagePreview?.src ? ( + + ) : null} {hasArgs ? (
   );
 }
-
-const toolFullDateTimeFormat = new Intl.DateTimeFormat(undefined, {
-  weekday: "long",
-  year: "numeric",
-  month: "long",
-  day: "numeric",
-  hour: "numeric",
-  minute: "2-digit",
-  second: "2-digit",
-});
-
-function ToolTimestamp({
-  item,
-}: {
-  item: Extract;
-}) {
-  const time = formatTranscriptTime(item.timestamp);
-  if (!time) return null;
-  const duration =
-    item.startedAt && item.completedAt
-      ? formatDuration(item.startedAt, item.completedAt)
-      : null;
-  const date = new Date(item.timestamp);
-  const fullDateTime = Number.isNaN(date.getTime())
-    ? item.timestamp
-    : toolFullDateTimeFormat.format(date);
-  return (
-    
-      
-        
-          {time}
-          {duration ? ` · ${duration}` : null}
-        
-      
-      {fullDateTime}
-    
-  );
-}
-
-function BuzzToolInlineAction({
-  args,
-  result,
-}: {
-  args: Record;
-  result: string;
-}) {
-  const { channels } = useChannelNavigation();
-  const { goChannel } = useAppNavigation();
-  const resultValue = React.useMemo(
-    () => parseToolResultValue(result),
-    [result],
-  );
-  const resultRecord = asRecord(resultValue);
-  const channelId =
-    getToolString(args, ["channel_id", "channelId"]) ??
-    getToolString(resultRecord, ["channel_id", "channelId"]);
-  const pubkeys = React.useMemo(
-    () => getToolStringList(args, ["pubkeys", "pubkey"]),
-    [args],
-  );
-  const profilesQuery = useUsersBatchQuery(pubkeys, {
-    enabled: pubkeys.length > 0,
-  });
-  const profiles = profilesQuery.data?.profiles;
-  const openChannel = React.useCallback(
-    (messageId?: string) => {
-      if (!channelId) return;
-      void goChannel(channelId, messageId ? { messageId } : undefined);
-    },
-    [channelId, goChannel],
-  );
-  const action = React.useMemo(
-    () =>
-      getBuzzToolInlineAction({
-        args,
-        channelId,
-        channels,
-        openChannel,
-        profiles,
-        resultValue,
-      }),
-    [args, channelId, channels, openChannel, profiles, resultValue],
-  );
-
-  if (!action) {
-    return null;
-  }
-
-  if (action.onClick) {
-    return (
-      
-    );
-  }
-
-  return (
-    
-      {action.avatar}
-      {action.label}
-      {action.value}
-    
-  );
-}
-
-type BuzzToolInlineActionModel = {
-  avatar?: React.ReactNode;
-  label: string;
-  value: string;
-  title: string;
-  onClick?: () => void;
-};
-
-function getBuzzToolInlineAction({
-  args,
-  channelId,
-  channels,
-  openChannel,
-  profiles,
-  resultValue,
-}: {
-  args: Record;
-  channelId: string | null;
-  channels: Channel[];
-  openChannel: (messageId?: string) => void;
-  profiles: Record | undefined;
-  resultValue: unknown;
-}): BuzzToolInlineActionModel | null {
-  const resultRecord = asRecord(resultValue);
-  const eventId =
-    getToolString(args, ["event_id", "eventId"]) ??
-    getToolString(resultRecord, ["event_id", "eventId", "id"]);
-
-  if (eventId && channelId) {
-    return {
-      label: resultRecord.accepted === true ? "posted" : "event",
-      onClick: () => openChannel(eventId),
-      title: eventId,
-      value: getChannelChipLabel(channels, channelId),
-    };
-  }
-
-  const messages = getResultArray(resultValue, resultRecord, "messages");
-  if (messages) {
-    return {
-      label: "read",
-      onClick: channelId ? () => openChannel() : undefined,
-      title: `${messages.length} messages`,
-      value: `${messages.length} message${messages.length === 1 ? "" : "s"}`,
-    };
-  }
-
-  if (channelId) {
-    return {
-      label: "channel",
-      onClick: () => openChannel(),
-      title: channelId,
-      value: getChannelChipLabel(channels, channelId),
-    };
-  }
-
-  const workflowId =
-    getToolString(args, ["workflow_id", "workflowId"]) ??
-    getToolString(resultRecord, ["workflow_id", "workflowId"]);
-  if (workflowId) {
-    return {
-      label: "workflow",
-      title: workflowId,
-      value: shortenMiddle(workflowId, 26),
-    };
-  }
-
-  const pubkeys = getToolStringList(args, ["pubkeys", "pubkey"]);
-  if (pubkeys.length > 0) {
-    if (pubkeys.length === 1) {
-      const pk = pubkeys[0];
-      const displayName = resolveUserLabel({ pubkey: pk, profiles });
-      const profile = profiles?.[pk.toLowerCase()];
-      return {
-        avatar: (
-          
-        ),
-        label: "user",
-        title: pk,
-        value: displayName,
-      };
-    }
-    return {
-      label: "users",
-      title: pubkeys
-        .map((pk) => resolveUserLabel({ pubkey: pk, profiles }))
-        .join(", "),
-      value: `${pubkeys.length} users`,
-    };
-  }
-
-  const query = getToolString(args, ["query"]);
-  if (query) {
-    return {
-      label: "query",
-      title: query,
-      value: shortenMiddle(query, 30),
-    };
-  }
-
-  if (typeof resultRecord.accepted === "boolean") {
-    return {
-      label: "relay",
-      title: resultRecord.accepted ? "accepted" : "rejected",
-      value: resultRecord.accepted ? "accepted" : "rejected",
-    };
-  }
-
-  return null;
-}
-
-function parseToolResultValue(result: string): unknown {
-  const trimmed = result.trim();
-  if (!trimmed) return null;
-
-  try {
-    const parsed = JSON.parse(trimmed);
-    if (typeof parsed !== "string") return parsed;
-    try {
-      return JSON.parse(parsed);
-    } catch {
-      return parsed;
-    }
-  } catch {
-    return null;
-  }
-}
-
-function getChannelChipLabel(channels: Channel[], channelId: string) {
-  const channel = channels.find((candidate) => candidate.id === channelId);
-  return channel ? `#${channel.name}` : `#${shortenMiddle(channelId, 22)}`;
-}
diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
index 3ae4051f0..45c25a23b 100644
--- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
+++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
@@ -1,32 +1,90 @@
 import * as React from "react";
-import { Bot, Brain, ChevronDown, Radio, TerminalSquare } from "lucide-react";
+import {
+  AlertCircle,
+  Brain,
+  CheckCheck,
+  ChevronDown,
+  CircleDot,
+  Radio,
+  TerminalSquare,
+} from "lucide-react";
 
 import {
   resolveUserLabel,
   type UserProfileLookup,
 } from "@/features/profile/lib/identity";
 import { cn } from "@/shared/lib/cn";
+import { normalizePubkey } from "@/shared/lib/pubkey";
 import { Markdown } from "@/shared/ui/markdown";
+import { Toggle } from "@/shared/ui/toggle";
 import { UserAvatar } from "@/shared/ui/UserAvatar";
-import type { TranscriptItem } from "./agentSessionTypes";
+import type { PromptSection, TranscriptItem } from "./agentSessionTypes";
 import { ToolItem } from "./AgentSessionToolItem";
+import {
+  buildTranscriptDisplayBlocks,
+  formatTurnSetupLabel,
+  turnSetupDetail,
+  turnSetupTimestamp,
+  type TranscriptDisplayBlock,
+  type TranscriptTurnSegment,
+} from "./agentSessionTranscriptGrouping";
 import { formatTranscriptTime } from "./agentSessionUtils";
 import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
 
+const TRANSCRIPT_ACP_SOURCE_STORAGE_KEY = "buzz:show-transcript-acp-source";
+
+/**
+ * Opt-in only: source pills are useful while iterating on observer parsing, but
+ * they should not appear for every local dev session.
+ */
+const SHOW_TRANSCRIPT_ACP_SOURCE = shouldShowTranscriptAcpSource();
+
+function shouldShowTranscriptAcpSource() {
+  const envValue = import.meta.env.VITE_SHOW_TRANSCRIPT_ACP_SOURCE;
+  if (envValue === "1" || envValue === "true") {
+    return true;
+  }
+
+  if (typeof window === "undefined") {
+    return false;
+  }
+
+  try {
+    return (
+      window.localStorage.getItem(TRANSCRIPT_ACP_SOURCE_STORAGE_KEY) === "1"
+    );
+  } catch {
+    return false;
+  }
+}
+
+type AgentTranscriptIdentityProps = {
+  agentAvatarUrl: string | null;
+  agentName: string;
+  agentPubkey: string;
+};
+
 export function AgentSessionTranscriptList({
+  agentAvatarUrl,
   agentName,
+  agentPubkey,
   emptyDescription,
   items,
   profiles,
-}: {
-  agentName: string;
+}: AgentTranscriptIdentityProps & {
   emptyDescription: string;
+  isWorking?: boolean;
   items: TranscriptItem[];
   profiles?: UserProfileLookup;
 }) {
+  const displayBlocks = React.useMemo(
+    () => buildTranscriptDisplayBlocks(items),
+    [items],
+  );
+
   if (items.length === 0) {
     return (
-      
+

No ACP activity yet

{emptyDescription}

@@ -34,38 +92,428 @@ export function AgentSessionTranscriptList({ ); } + return ( +
+
+ {displayBlocks.map((block) => ( +
+ +
+ ))} +
+
+ ); +} + +function TranscriptAcpSourceBadge({ source }: { source: string }) { + return ( + + {source} + + ); +} + +function getDisplayBlockKey(block: TranscriptDisplayBlock) { + if (block.kind === "single") { + return block.item.id; + } + return `turn:${block.turnId}`; +} + +function TranscriptDisplayBlockView({ + agentAvatarUrl, + agentName, + agentPubkey, + block, + profiles, +}: AgentTranscriptIdentityProps & { + block: TranscriptDisplayBlock; + profiles?: UserProfileLookup; +}) { + if (block.kind === "single") { + return ( + + ); + } + + return ( +
+ {block.segments.map((segment) => ( + + ))} +
+ ); +} + +function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) { + if (segment.kind === "setup") { + return `turn:${turnId}:setup`; + } + if (segment.kind === "prompt") { + return `turn:${turnId}:prompt`; + } + return segment.item.id; +} + +function TranscriptTurnSegmentView({ + agentAvatarUrl, + agentName, + agentPubkey, + profiles, + segment, +}: AgentTranscriptIdentityProps & { + profiles?: UserProfileLookup; + segment: TranscriptTurnSegment; +}) { + if (segment.kind === "prompt") { + return ( + + ); + } + + if (segment.kind === "setup") { + return ; + } + + return ( + + ); +} + +function TurnPromptBlock({ + context, + profiles, + setup, + user, +}: { + context: Extract | null; + profiles?: UserProfileLookup; + setup: Extract[]; + user: Extract; +}) { + return ( +
+ {SHOW_TRANSCRIPT_ACP_SOURCE ? ( +
+ + {context ? ( + + ) : null} +
+ ) : null} + +
+ ); +} + +function PromptUserMessage({ + context = null, + item, + profiles, + setup = [], +}: { + context?: Extract | null; + item: Extract; + profiles?: UserProfileLookup; + setup?: Extract[]; +}) { + const [contextOpen, setContextOpen] = React.useState(false); + const text = item.text.trim(); + const authorProfile = item.authorPubkey + ? profiles?.[item.authorPubkey.toLowerCase()] + : null; + const authorLabel = item.authorPubkey + ? resolveUserLabel({ + pubkey: item.authorPubkey, + fallbackName: item.title, + profiles, + }) + : item.title || "User"; + return (
- {items.map((item) => ( -
- + +
+
+ + {contextOpen && context ? ( + + ) : null}
+ +
+
+ ); +} + +function PromptContextSections({ + sections, + setup, +}: { + sections: PromptSection[]; + setup: Extract[]; +}) { + return ( +
+ + {sections.map((section) => ( +
+ + {section.title} + + +
+            {section.body.trim() || "No metadata."}
+          
+
))}
); } -const TranscriptItemView = React.memo(function TranscriptItemView({ +function PromptSetupSummary({ + items, +}: { + items: Extract[]; +}) { + const label = formatTurnSetupLabel(items); + const detail = turnSetupDetail(items); + const setupText = [label, detail].filter(Boolean).join(" · "); + + if (!setupText) { + return null; + } + + return ( +

+ {setupText} +

+ ); +} + +function TurnSetupFooter({ + context = null, + contextOpen = false, + items, + onContextOpenChange, + timestamp, +}: { + context?: Extract | null; + contextOpen?: boolean; + items: Extract[]; + onContextOpenChange?: (open: boolean) => void; + timestamp: string; +}) { + const label = formatTurnSetupLabel(items); + const detail = turnSetupDetail(items); + const tooltipText = [label, detail].filter(Boolean).join(" · "); + const showSetup = items.length > 0; + const showContext = context != null && context.sections.length > 0; + + if (!showSetup && !showContext) { + return ; + } + + const contextToggle = showContext ? ( + + {showSetup ? + ) : null; + + return ( +
+ {showContext && showSetup ? ( + + {contextToggle} + +

{tooltipText}

+
+
+ ) : null} + {!showContext && showSetup ? ( + + + + + +

{tooltipText}

+
+
+ ) : null} + {showContext && !showSetup ? contextToggle : null} + +
+ ); +} + +function TranscriptItemRow({ + agentAvatarUrl, agentName, + agentPubkey, item, profiles, +}: AgentTranscriptIdentityProps & { + item: TranscriptItem; + profiles?: UserProfileLookup; +}) { + return ( +
+ {SHOW_TRANSCRIPT_ACP_SOURCE && item.acpSource ? ( + + ) : null} + +
+ ); +} + +function TurnSetupStatus({ + items, }: { - agentName: string; + items: Extract[]; +}) { + const timestamp = turnSetupTimestamp(items); + if (items.length === 0 || !timestamp) { + return null; + } + + return ( +
+ +
+ ); +} + +function getTranscriptItemRowSpacing(item: TranscriptItem): string { + if (item.type === "message") { + return "my-2.5"; + } + if (item.type === "tool") { + return "my-1"; + } + return "my-2"; +} + +const TranscriptItemView = React.memo(function TranscriptItemView({ + agentAvatarUrl, + agentName, + agentPubkey, + item, + profiles, +}: AgentTranscriptIdentityProps & { item: TranscriptItem; profiles?: UserProfileLookup; }) { if (item.type === "message") { return ( - + ); } if (item.type === "tool") { @@ -81,11 +529,12 @@ const TranscriptItemView = React.memo(function TranscriptItemView({ }); function MessageItem({ + agentAvatarUrl, agentName, + agentPubkey, item, profiles, -}: { - agentName: string; +}: AgentTranscriptIdentityProps & { item: Extract; profiles?: UserProfileLookup; }) { @@ -101,34 +550,54 @@ function MessageItem({ profiles, }) : item.title || "User"; + const agentProfile = profiles?.[normalizePubkey(agentPubkey)] ?? null; + const assistantLabel = resolveUserLabel({ + pubkey: agentPubkey, + fallbackName: agentName, + profiles, + preferResolvedSelfLabel: true, + }); + const assistantAvatarUrl = agentProfile?.avatarUrl ?? agentAvatarUrl; return (
{!isAssistant ? ( ) : null}
{isAssistant ? ( -
- - +
+ + + {assistantLabel} - {agentName}
) : null} @@ -139,10 +608,10 @@ function MessageItem({ )} > {isAssistant ? ( - + ) : ( <> -

{text}

+ )} @@ -158,7 +627,10 @@ function ThoughtItem({ item: Extract; }) { return ( -
+
{item.title} @@ -166,7 +638,7 @@ function ThoughtItem({
- +
); @@ -178,11 +650,14 @@ function MetadataItem({ item: Extract; }) { return ( -
+
- - {item.title} - + + {item.title} + {item.sections.length} section{item.sections.length === 1 ? "" : "s"} @@ -198,7 +673,7 @@ function MetadataItem({ {section.title} -
+            
               {section.body.trim() || "No metadata."}
             
@@ -217,12 +692,20 @@ function LifecycleItem({ return (
+ {isError ? ( + + ) : ( + + )} {item.title} - {item.text ? - {item.text} : null} + {item.text ? · {item.text} : null}
); @@ -248,7 +731,7 @@ function TranscriptTimestamp({ timestamp }: { timestamp: string }) { return ( - + {formatted} diff --git a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx index df4d7f474..b2b848243 100644 --- a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx @@ -21,14 +21,23 @@ import type { ObserverEvent, TranscriptItem, } from "./agentSessionTypes"; +import { + deriveLatestSessionId, + resolveRawRailLayout, + scopeByChannel, +} from "./agentSessionPanelLayout"; import { shorten } from "./agentSessionUtils"; import { useObserverEvents, useAgentTranscript } from "./useObserverEvents"; type ManagedAgentSessionPanelProps = { - agent: Pick; + agent: Pick & { + avatarUrl?: string | null; + }; channelId?: string | null; className?: string; emptyDescription?: string; + isWorking?: boolean; + rawLayout?: "responsive" | "exclusive"; showHeader?: boolean; showRaw?: boolean; profiles?: UserProfileLookup; @@ -39,6 +48,8 @@ export function ManagedAgentSessionPanel({ channelId = null, className, emptyDescription = "Mention this agent in a channel to watch the next turn.", + isWorking = false, + rawLayout = "responsive", showHeader = true, showRaw = true, profiles, @@ -51,27 +62,19 @@ export function ManagedAgentSessionPanel({ const transcript = useAgentTranscript(hasObserver, agent.pubkey); const scopedTranscript = React.useMemo( - () => - channelId - ? transcript.filter((item) => item.channelId === channelId) - : transcript, + () => scopeByChannel(transcript, channelId), [channelId, transcript], ); const scopedEvents = React.useMemo( - () => - channelId - ? events.filter((event) => event.channelId === channelId) - : events, + () => scopeByChannel(events, channelId), [channelId, events], ); - const latestSessionId = React.useMemo(() => { - for (let i = scopedEvents.length - 1; i >= 0; i--) { - if (scopedEvents[i].sessionId) return scopedEvents[i].sessionId; - } - return null; - }, [scopedEvents]); + const latestSessionId = React.useMemo( + () => deriveLatestSessionId(scopedEvents), + [scopedEvents], + ); return (
@@ -140,26 +147,51 @@ function SessionHeader({ } function SessionBody({ + agentAvatarUrl, agentName, + agentPubkey, connectionState, emptyDescription, errorMessage, events, hasObserver, + isWorking, profiles, + rawLayout, showRaw, transcript, }: { + agentAvatarUrl: string | null; agentName: string; + agentPubkey: string; connectionState: ConnectionState; emptyDescription: string; errorMessage: string | null; events: ObserverEvent[]; hasObserver: boolean; + isWorking: boolean; profiles?: UserProfileLookup; + rawLayout: "responsive" | "exclusive"; showRaw: boolean; transcript: TranscriptItem[]; }) { + const rawRail = resolveRawRailLayout(showRaw, rawLayout); + + if (rawRail.mode === "exclusive") { + return ( + <> + + + {errorMessage ? ( +

+ + {errorMessage} +

+ ) : null} + + ); + } + return ( <> {!hasObserver ? ( @@ -169,18 +201,21 @@ function SessionBody({ ) : (
- {showRaw ? : null} + {rawRail.mode === "side" ? : null}
)} diff --git a/desktop/src/features/agents/ui/RawEventRail.tsx b/desktop/src/features/agents/ui/RawEventRail.tsx index 5aca8963b..153b77e77 100644 --- a/desktop/src/features/agents/ui/RawEventRail.tsx +++ b/desktop/src/features/agents/ui/RawEventRail.tsx @@ -1,46 +1,28 @@ -import * as React from "react"; - -import { Button } from "@/shared/ui/button"; import type { ObserverEvent } from "./agentSessionTypes"; import { describeRawEvent } from "./agentSessionTranscript"; export function RawEventRail({ events }: { events: ObserverEvent[] }) { - const [expanded, setExpanded] = React.useState(false); - const visible = expanded ? events : events.slice(-18); - return ( - +
); } diff --git a/desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs b/desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs new file mode 100644 index 000000000..96af79d16 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + deriveLatestSessionId, + resolveRawRailLayout, + scopeByChannel, +} from "./agentSessionPanelLayout.ts"; + +// ---- scopeByChannel ---- + +const items = [ + { id: "a", channelId: "channel-1" }, + { id: "b", channelId: "channel-2" }, + { id: "c", channelId: "channel-1" }, +]; + +test("scopeByChannel returns the input unchanged when channelId is null", () => { + assert.equal(scopeByChannel(items, null), items); +}); + +test("scopeByChannel returns the input unchanged when channelId is undefined", () => { + assert.equal(scopeByChannel(items, undefined), items); +}); + +test("scopeByChannel filters items down to the requested channel", () => { + const scoped = scopeByChannel(items, "channel-1"); + assert.deepEqual( + scoped.map((item) => item.id), + ["a", "c"], + ); +}); + +test("scopeByChannel returns an empty array when no item matches", () => { + assert.deepEqual(scopeByChannel(items, "channel-99"), []); +}); + +// ---- deriveLatestSessionId ---- + +test("deriveLatestSessionId returns null for an empty list", () => { + assert.equal(deriveLatestSessionId([]), null); +}); + +test("deriveLatestSessionId returns the last event's sessionId", () => { + const events = [ + { seq: 1, sessionId: "sess-1" }, + { seq: 2, sessionId: "sess-2" }, + ]; + assert.equal(deriveLatestSessionId(events), "sess-2"); +}); + +test("deriveLatestSessionId skips trailing events without a sessionId", () => { + const events = [ + { seq: 1, sessionId: "sess-1" }, + { seq: 2, sessionId: null }, + { seq: 3, sessionId: undefined }, + ]; + assert.equal(deriveLatestSessionId(events), "sess-1"); +}); + +test("deriveLatestSessionId returns null when no event carries a sessionId", () => { + const events = [{ seq: 1, sessionId: null }, { seq: 2 }]; + assert.equal(deriveLatestSessionId(events), null); +}); + +// ---- resolveRawRailLayout (raw-ACP view toggle) ---- + +test("resolveRawRailLayout hides the rail when showRaw is off", () => { + assert.deepEqual(resolveRawRailLayout(false, "responsive"), { + mode: "hidden", + }); + assert.deepEqual(resolveRawRailLayout(false, "exclusive"), { + mode: "hidden", + }); +}); + +test("resolveRawRailLayout renders the rail exclusively when toggled on in exclusive layout", () => { + assert.deepEqual(resolveRawRailLayout(true, "exclusive"), { + mode: "exclusive", + }); +}); + +test("resolveRawRailLayout renders the rail beside the transcript in responsive layout", () => { + assert.deepEqual(resolveRawRailLayout(true, "responsive"), { mode: "side" }); +}); diff --git a/desktop/src/features/agents/ui/agentSessionPanelLayout.ts b/desktop/src/features/agents/ui/agentSessionPanelLayout.ts new file mode 100644 index 000000000..e0aaeb203 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionPanelLayout.ts @@ -0,0 +1,48 @@ +import type { ObserverEvent } from "./agentSessionTypes"; + +/** + * Filter transcript items or raw observer events down to a single channel. + * A null `channelId` means "no scoping" — the input is returned as-is. + */ +export function scopeByChannel( + items: readonly T[], + channelId: string | null | undefined, +): T[] { + if (!channelId) return items as T[]; + return items.filter((item) => item.channelId === channelId); +} + +/** + * Derive the most recent session id from a list of observer events by + * scanning from the end. Returns null when no event carries a sessionId. + */ +export function deriveLatestSessionId( + events: readonly ObserverEvent[], +): string | null { + for (let i = events.length - 1; i >= 0; i--) { + const sessionId = events[i]?.sessionId; + if (sessionId) return sessionId; + } + return null; +} + +export type RawRailLayout = + | { mode: "hidden" } + | { mode: "exclusive" } + | { mode: "side" }; + +/** + * Decide how the raw-ACP event rail should be rendered relative to the + * transcript: + * - `hidden` — raw view is off + * - `exclusive` — raw rail replaces the transcript entirely + * - `side` — raw rail renders alongside the transcript (responsive) + */ +export function resolveRawRailLayout( + showRaw: boolean, + rawLayout: "responsive" | "exclusive", +): RawRailLayout { + if (!showRaw) return { mode: "hidden" }; + if (rawLayout === "exclusive") return { mode: "exclusive" }; + return { mode: "side" }; +} diff --git a/desktop/src/features/agents/ui/agentSessionToolItemHelpers.test.mjs b/desktop/src/features/agents/ui/agentSessionToolItemHelpers.test.mjs new file mode 100644 index 000000000..52b548ec6 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolItemHelpers.test.mjs @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + formatDurationMs, + getToolDurationDisplay, + isInlineImageData, + parseToolResultValue, +} from "./agentSessionUtils.ts"; + +// ---- isInlineImageData (dual-layer image-scheme security guard) ---- + +test("isInlineImageData accepts data:image/ URIs", () => { + const dataUri = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC"; + assert.equal(isInlineImageData(dataUri), true); +}); + +test("isInlineImageData rejects non-image data: schemes (no passthrough widening)", () => { + // A non-image data: URI must NOT be treated as a safe inline image — + // it has to fall through to the relay rewrite path. + assert.equal( + isInlineImageData( + "data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==", + ), + false, + ); + assert.equal(isInlineImageData("data:application/json;base64,e30="), false); +}); + +test("isInlineImageData rejects relay-relative and absolute media URLs", () => { + assert.equal(isInlineImageData("/media/abc123.png"), false); + assert.equal(isInlineImageData("https://relay.example/media/abc.png"), false); +}); + +// ---- formatDurationMs ---- + +test("formatDurationMs returns null for negative input", () => { + assert.equal(formatDurationMs(-1), null); +}); + +test("formatDurationMs renders sub-10s with one decimal", () => { + assert.equal(formatDurationMs(400), "0.4s"); + assert.equal(formatDurationMs(9900), "9.9s"); +}); + +test("formatDurationMs rounds 10s..60s to whole seconds", () => { + assert.equal(formatDurationMs(12300), "12s"); + assert.equal(formatDurationMs(59400), "59s"); +}); + +test("formatDurationMs renders minutes and seconds", () => { + assert.equal(formatDurationMs(90000), "1m 30s"); + assert.equal(formatDurationMs(120000), "2m"); +}); + +test("formatDurationMs carries a rounded 60s into the next minute", () => { + // 89.7s rounds the seconds component to 60, which must carry to 1m 30s + assert.equal(formatDurationMs(89700), "1m 30s"); +}); + +// ---- parseToolResultValue (JSON double-parse) ---- + +test("parseToolResultValue returns null for empty/whitespace", () => { + assert.equal(parseToolResultValue(""), null); + assert.equal(parseToolResultValue(" "), null); +}); + +test("parseToolResultValue parses a JSON object", () => { + assert.deepEqual(parseToolResultValue('{"duration_ms":123}'), { + duration_ms: 123, + }); +}); + +test("parseToolResultValue unwraps a double-encoded JSON string", () => { + // The result is a JSON string that itself contains JSON. + const doubleEncoded = JSON.stringify(JSON.stringify({ ok: true })); + assert.deepEqual(parseToolResultValue(doubleEncoded), { ok: true }); +}); + +test("parseToolResultValue returns the inner string when it is not JSON", () => { + const wrapped = JSON.stringify("plain text"); + assert.equal(parseToolResultValue(wrapped), "plain text"); +}); + +test("parseToolResultValue returns null for invalid JSON", () => { + assert.equal(parseToolResultValue("not json {"), null); +}); + +// ---- getToolDurationDisplay (fallback chain) ---- + +const startedAt = "2026-06-14T19:00:00.000Z"; +const completedAt = "2026-06-14T19:00:02.000Z"; + +test("getToolDurationDisplay prefers start/complete timestamps", () => { + assert.equal( + getToolDurationDisplay({ startedAt, completedAt, result: "" }), + "2.0s", + ); +}); + +test("getToolDurationDisplay falls back to duration_ms in the result payload", () => { + assert.equal( + getToolDurationDisplay({ + startedAt: null, + completedAt: null, + result: JSON.stringify({ duration_ms: 3500 }), + }), + "3.5s", + ); +}); + +test("getToolDurationDisplay falls back to elapsed_ms when duration_ms absent", () => { + assert.equal( + getToolDurationDisplay({ + result: JSON.stringify({ elapsed_ms: 65000 }), + }), + "1m 5s", + ); +}); + +test("getToolDurationDisplay returns null when no duration is available", () => { + assert.equal(getToolDurationDisplay({ result: "" }), null); + assert.equal( + getToolDurationDisplay({ result: JSON.stringify({ other: 1 }) }), + null, + ); +}); diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs new file mode 100644 index 000000000..fe25dc2ca --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildCompactToolSummary } from "./agentSessionToolSummary.ts"; + +const baseTimestamp = "2026-06-14T19:00:00.000Z"; + +function makeTool(overrides = {}) { + return { + id: "tool:1", + type: "tool", + title: "Tool call", + toolName: "shell", + buzzToolName: null, + status: "completed", + args: {}, + result: "", + isError: false, + timestamp: baseTimestamp, + startedAt: baseTimestamp, + completedAt: "2026-06-14T19:00:01.000Z", + ...overrides, + }; +} + +test("buildCompactToolSummary formats Buzz send_message preview", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "send_message", + buzzToolName: "send_message", + title: "Send Message", + args: { content: "Hello team" }, + }), + ); + + assert.equal(summary.kind, "buzz"); + assert.equal(summary.label, "Send Message"); + assert.equal(summary.preview, "Hello team"); + assert.equal(summary.presentation, "message"); +}); + +test("buildCompactToolSummary treats buzz messages send commands as messages", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "buzz-dev-mcp__shell", + args: { + command: + 'buzz --format compact messages send --channel channel-1 --content "@Ned are you working"', + }, + }), + ); + + assert.equal(summary.kind, "shell"); + assert.equal(summary.label, "Send Message"); + assert.equal(summary.preview, "@Ned are you working"); + assert.equal(summary.presentation, "message"); +}); + +test("buildCompactToolSummary extracts simple piped buzz message content", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "shell", + args: { + command: + 'echo "hello from stdin" | ./target/release/buzz messages send --channel channel-1 --content -', + }, + }), + ); + + assert.equal(summary.label, "Send Message"); + assert.equal(summary.preview, "hello from stdin"); + assert.equal(summary.presentation, "message"); +}); + +test("buildCompactToolSummary formats shell command preview", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "buzz-dev-mcp__shell", + args: { command: "git status" }, + }), + ); + + assert.equal(summary.label, "Ran command"); + assert.equal(summary.preview, "git status"); + assert.equal(summary.presentation, "inline"); +}); + +test("buildCompactToolSummary formats view_image thumbnail source", () => { + const source = + "https://sprout-oss.stage.blox.sqprod.co/media/ffd1b2721f2d52e19f0ca2be9aa7842cdec5b4e0215aaab2a67c26a2a76a6a83.png"; + const summary = buildCompactToolSummary( + makeTool({ + toolName: "buzz-dev-mcp__view_image", + args: { source }, + }), + ); + + assert.equal(summary.label, "Viewed image"); + assert.equal(summary.thumbnailSrc, source); + assert.equal(summary.preview, source); +}); + +test("buildCompactToolSummary uses basename for local view_image paths", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "view_image", + args: { source: "desktop/assets/screenshot.png" }, + }), + ); + + assert.equal(summary.thumbnailSrc, null); + assert.equal(summary.preview, "screenshot.png"); +}); + +test("buildCompactToolSummary formats read_file path preview", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "read_file", + args: { path: "desktop/src/app/App.tsx" }, + }), + ); + + assert.equal(summary.label, "Read file"); + assert.equal(summary.preview, "desktop/src/app/App.tsx"); +}); + +test("buildCompactToolSummary formats todo list preview", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "todo", + args: { + todos: [ + { text: "Ship compact summaries", done: false }, + { text: "Verify UI", done: false }, + ], + }, + }), + ); + + assert.equal(summary.label, "Updated todos"); + assert.equal(summary.preview, "Ship compact summaries (+1)"); +}); + +test("buildCompactToolSummary uses running and failed labels", () => { + assert.equal( + buildCompactToolSummary( + makeTool({ toolName: "str_replace", status: "executing" }), + ).label, + "Editing file", + ); + assert.equal( + buildCompactToolSummary( + makeTool({ toolName: "str_replace", status: "failed", isError: true }), + ).label, + "Edit failed", + ); +}); diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.ts b/desktop/src/features/agents/ui/agentSessionToolSummary.ts new file mode 100644 index 000000000..1513e1017 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.ts @@ -0,0 +1,550 @@ +import type { ToolStatus, TranscriptItem } from "./agentSessionTypes"; +import { + formatToolTitle, + getBuzzToolInfo, + isGenericToolTitle, + normalizeToolNameText, +} from "./agentSessionToolCatalog"; +import { + asRecord, + getToolString, + getToolStringList, +} from "./agentSessionUtils"; + +export type CompactToolKind = + | "shell" + | "read_file" + | "view_image" + | "str_replace" + | "todo" + | "stop_hook" + | "post_compact_hook" + | "dev_mcp" + | "buzz" + | "generic"; + +export type CompactToolSummary = { + kind: CompactToolKind; + label: string; + preview: string | null; + /** When set, the compact row renders a tiny image instead of text preview. */ + thumbnailSrc: string | null; + presentation: "inline" | "message"; +}; + +const DEVELOPER_TOOL_BASES = new Set([ + "shell", + "read_file", + "view_image", + "str_replace", + "todo", + "stop", + "postcompact", +]); + +type ToolItem = Extract; + +/** Build the muted compact summary label and preview for any tool row. */ +export function buildCompactToolSummary(item: ToolItem): CompactToolSummary { + const kind = resolveCompactToolKind(item); + const messageSendPreview = extractMessageSendPreview(item, kind); + if (messageSendPreview !== undefined) { + return { + kind, + label: compactMessageSendLabel(item.status, item.isError), + preview: messageSendPreview, + thumbnailSrc: null, + presentation: "message", + }; + } + + const { preview, thumbnailSrc } = extractCompactToolPreview(item, kind); + return { + kind, + label: compactToolLabel(kind, item, item.status, item.isError), + preview, + thumbnailSrc, + presentation: "inline", + }; +} + +function resolveCompactToolKind(item: ToolItem): CompactToolKind { + const developerKind = resolveDeveloperToolKind(item); + if (developerKind) { + return developerKind; + } + + for (const value of [item.buzzToolName, item.toolName, item.title]) { + if (value && getBuzzToolInfo(value)) { + return "buzz"; + } + } + + return "generic"; +} + +function resolveDeveloperToolKind(item: ToolItem): CompactToolKind | null { + for (const value of [item.toolName, item.title, item.buzzToolName]) { + const kind = classifyDeveloperToolName(value); + if (kind) return kind; + } + return null; +} + +function classifyDeveloperToolName( + value: string | null | undefined, +): CompactToolKind | null { + if (!value) return null; + + const normalized = normalizeToolNameText(value); + const base = stripMcpServerPrefix(normalized); + + if (base === "shell" || normalized.endsWith("_shell")) { + return "shell"; + } + if (base === "read_file") return "read_file"; + if (base === "view_image") return "view_image"; + if (base === "str_replace") return "str_replace"; + if (base === "todo") return "todo"; + if (base === "stop") return "stop_hook"; + if (base === "postcompact") return "post_compact_hook"; + + if (DEVELOPER_TOOL_BASES.has(base)) { + return "dev_mcp"; + } + + if (normalized.includes("buzz_dev_mcp")) { + return "dev_mcp"; + } + + return null; +} + +function stripMcpServerPrefix(normalized: string): string { + return normalized.replace(/^buzz_dev_mcp_/, ""); +} + +function compactToolLabel( + kind: CompactToolKind, + item: ToolItem, + status: ToolStatus, + isError: boolean, +): string { + const failed = isError || status === "failed"; + const running = status === "executing" || status === "pending"; + + if (kind === "buzz") { + const title = formatToolTitle( + item.buzzToolName ?? item.toolName, + item.title, + ); + if (failed) return `${title} failed`; + if (running) return title; + return title; + } + + const labels: Record< + Exclude, + { completed: string; running: string; failed: string } + > = { + generic: { + completed: "Ran tool", + running: "Running tool", + failed: "Tool failed", + }, + ...developerToolLabels(), + }; + + const set = labels[kind]; + if (failed) return set.failed; + if (running) return set.running; + return set.completed; +} + +function developerToolLabels(): Record< + Exclude, + { completed: string; running: string; failed: string } +> { + return { + shell: { + completed: "Ran command", + running: "Running command", + failed: "Command failed", + }, + read_file: { + completed: "Read file", + running: "Reading file", + failed: "Read failed", + }, + view_image: { + completed: "Viewed image", + running: "Viewing image", + failed: "View failed", + }, + str_replace: { + completed: "Edited file", + running: "Editing file", + failed: "Edit failed", + }, + todo: { + completed: "Updated todos", + running: "Updating todos", + failed: "Todo update failed", + }, + stop_hook: { + completed: "Checked todos", + running: "Checking todos", + failed: "Todo check failed", + }, + post_compact_hook: { + completed: "Synced todos", + running: "Syncing todos", + failed: "Todo sync failed", + }, + dev_mcp: { + completed: "Ran tool", + running: "Running tool", + failed: "Tool failed", + }, + }; +} + +function compactMessageSendLabel(status: ToolStatus, isError: boolean) { + if (isError || status === "failed") { + return "Send Message failed"; + } + return "Send Message"; +} + +type CompactToolPreview = { + preview: string | null; + thumbnailSrc: string | null; +}; + +function extractCompactToolPreview( + item: ToolItem, + kind: CompactToolKind, +): CompactToolPreview { + const args = item.args; + + switch (kind) { + case "shell": + return textPreview(getToolString(args, ["command"])); + case "read_file": + case "str_replace": + return textPreview(getToolString(args, ["path"])); + case "view_image": + return getViewImagePreview(getToolString(args, ["source"])); + case "todo": + return textPreview(getTodoPreview(args)); + case "stop_hook": + case "post_compact_hook": + return emptyPreview(); + case "dev_mcp": + case "generic": + return textPreview( + getToolString(args, [ + "command", + "path", + "source", + "query", + "name", + "content", + "message", + ]) ?? + (item.title && !isGenericToolTitle(item.title) ? item.title : null), + ); + case "buzz": + return textPreview(extractBuzzToolPreview(args)); + } +} + +function extractBuzzToolPreview(args: Record): string | null { + const content = getToolString(args, ["content", "message", "text", "body"]); + if (content) { + return content; + } + + const query = getToolString(args, ["query", "search"]); + if (query) { + return query; + } + + const channelId = getToolString(args, ["channel_id", "channelId"]); + if (channelId) { + return channelId; + } + + const workflowId = getToolString(args, ["workflow_id", "workflowId"]); + if (workflowId) { + return workflowId; + } + + const pubkeys = getToolStringList(args, ["pubkeys", "pubkey"]); + if (pubkeys.length === 1) { + return pubkeys[0]; + } + if (pubkeys.length > 1) { + return `${pubkeys.length} users`; + } + + return getToolString(args, ["event_id", "eventId", "name"]); +} + +function extractMessageSendPreview( + item: ToolItem, + kind: CompactToolKind, +): string | null | undefined { + if (isBuzzSendMessageTool(item)) { + return extractBuzzToolMessageContent(item.args); + } + + if (kind !== "shell") { + return undefined; + } + + const command = getToolString(item.args, ["command"]); + return command ? extractBuzzCliSendMessageContent(command) : undefined; +} + +function isBuzzSendMessageTool(item: ToolItem) { + return [item.buzzToolName, item.toolName, item.title].some((value) => { + if (!value) return false; + return normalizeToolNameText(value) === "send_message"; + }); +} + +function extractBuzzToolMessageContent( + args: Record, +): string | null { + return getToolString(args, ["content", "message", "text", "body"]); +} + +function extractBuzzCliSendMessageContent( + command: string, +): string | null | undefined { + const tokens = tokenizeShellCommand(command); + const commandRange = findBuzzMessagesSendCommand(tokens); + if (!commandRange) { + return undefined; + } + + const content = getFlagValue(tokens, commandRange.sendIndex + 1, "--content"); + if (!content) { + return null; + } + if (content !== "-") { + return content; + } + + const pipedContent = extractSimpleEchoPipeContent( + tokens, + commandRange.buzzIndex, + ); + return pipedContent ?? null; +} + +function tokenizeShellCommand(command: string): string[] { + const tokens: string[] = []; + let current = ""; + let quote: "'" | '"' | null = null; + let escaping = false; + + const pushCurrent = () => { + if (current.length > 0) { + tokens.push(current); + current = ""; + } + }; + + for (const char of command) { + if (escaping) { + current += char; + escaping = false; + continue; + } + + if (char === "\\" && quote !== "'") { + escaping = true; + continue; + } + + if (quote) { + if (char === quote) { + quote = null; + } else { + current += char; + } + continue; + } + + if (char === "'" || char === '"') { + quote = char; + continue; + } + + if (/\s/.test(char)) { + pushCurrent(); + continue; + } + + if (char === "|" || char === ";" || char === "&") { + pushCurrent(); + tokens.push(char); + continue; + } + + current += char; + } + + if (escaping) { + current += "\\"; + } + pushCurrent(); + return tokens; +} + +function findBuzzMessagesSendCommand( + tokens: string[], +): { buzzIndex: number; sendIndex: number } | null { + for (let i = 0; i < tokens.length; i++) { + if (!isBuzzExecutable(tokens[i])) { + continue; + } + + const messagesIndex = tokens.indexOf("messages", i + 1); + if (messagesIndex === -1) { + continue; + } + if ( + messagesIndex > i && + hasCommandSeparator(tokens, i + 1, messagesIndex) + ) { + continue; + } + if (tokens[messagesIndex + 1] === "send") { + return { buzzIndex: i, sendIndex: messagesIndex + 1 }; + } + } + + return null; +} + +function isBuzzExecutable(token: string) { + return token === "buzz" || token.split(/[\\/]/).pop() === "buzz"; +} + +function hasCommandSeparator(tokens: string[], start: number, end: number) { + for (let i = start; i < end; i++) { + if (isCommandSeparator(tokens[i])) { + return true; + } + } + return false; +} + +function isCommandSeparator(token: string) { + return token === "|" || token === ";" || token === "&"; +} + +function getFlagValue(tokens: string[], start: number, flag: string) { + for (let i = start; i < tokens.length; i++) { + const token = tokens[i]; + if (isCommandSeparator(token)) { + return null; + } + if (token === flag) { + return tokens[i + 1] && !isCommandSeparator(tokens[i + 1]) + ? tokens[i + 1] + : null; + } + if (token.startsWith(`${flag}=`)) { + return token.slice(flag.length + 1); + } + } + return null; +} + +function extractSimpleEchoPipeContent( + tokens: string[], + buzzIndex: number, +): string | null { + const pipeIndex = tokens.lastIndexOf("|", buzzIndex); + if (pipeIndex <= 0) { + return null; + } + + const echoStart = findSegmentStart(tokens, pipeIndex - 1); + const leftSegment = tokens.slice(echoStart, pipeIndex); + if (leftSegment[0] !== "echo") { + return null; + } + + const contentTokens = leftSegment + .slice(1) + .filter((token) => !token.startsWith("-")); + return contentTokens.length > 0 ? contentTokens.join(" ") : null; +} + +function findSegmentStart(tokens: string[], beforeIndex: number) { + for (let i = beforeIndex; i >= 0; i--) { + if (isCommandSeparator(tokens[i])) { + return i + 1; + } + } + return 0; +} + +function textPreview(preview: string | null): CompactToolPreview { + return { preview, thumbnailSrc: null }; +} + +function emptyPreview(): CompactToolPreview { + return { preview: null, thumbnailSrc: null }; +} + +function getViewImagePreview(source: string | null): CompactToolPreview { + if (!source) { + return emptyPreview(); + } + + const trimmed = source.trim(); + if ( + trimmed.startsWith("data:image/") || + trimmed.startsWith("http://") || + trimmed.startsWith("https://") + ) { + return { + preview: trimmed, + thumbnailSrc: trimmed, + }; + } + + const basename = trimmed.split(/[/\\]/).pop() ?? trimmed; + return { + preview: basename, + thumbnailSrc: null, + }; +} + +function getTodoPreview(args: Record): string | null { + const todos = args.todos; + if (!Array.isArray(todos)) { + return "todo list"; + } + if (todos.length === 0) { + return "empty list"; + } + + const first = todos[0]; + const firstText = + first && typeof first === "object" + ? getToolString(asRecord(first), ["text"]) + : null; + + if (firstText) { + return todos.length > 1 ? `${firstText} (+${todos.length - 1})` : firstText; + } + + return `${todos.length} item${todos.length === 1 ? "" : "s"}`; +} diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index 8e1cee499..bc169d3af 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -109,6 +109,12 @@ function sealOpenMessages(d: TranscriptDraft) { } } +type TranscriptItemContext = { + channelId: string | null; + turnId: string | null; + sessionId: string | null; +}; + function upsertMessage( d: TranscriptDraft, id: string, @@ -116,8 +122,9 @@ function upsertMessage( title: string, text: string, timestamp: string, - channelId: string | null, + ctx: TranscriptItemContext, authorPubkey: string | null = null, + acpSource?: string, ) { const currentKey = d.activeMessageKey.get(id); @@ -127,8 +134,11 @@ function upsertMessage( replaceItem(d, currentKey, { ...existing, text: existing.text + text, - channelId, + channelId: ctx.channelId, + turnId: ctx.turnId ?? existing.turnId, + sessionId: ctx.sessionId ?? existing.sessionId, authorPubkey: authorPubkey ?? existing.authorPubkey, + acpSource: acpSource ?? existing.acpSource, }); return; } @@ -143,8 +153,11 @@ function upsertMessage( title, text, timestamp, - channelId, + channelId: ctx.channelId, + turnId: ctx.turnId, + sessionId: ctx.sessionId, authorPubkey, + acpSource, }); d.activeMessageKey = new Map(d.activeMessageKey); d.activeMessageKey.set(id, newKey); @@ -157,15 +170,33 @@ function upsertTextItem( title: string, text: string, timestamp: string, - channelId: string | null, + ctx: TranscriptItemContext, + acpSource?: string, ) { const existing = d.itemsById.get(id); if (existing && existing.type === type) { - replaceItem(d, id, { ...existing, text: existing.text + text, channelId }); + replaceItem(d, id, { + ...existing, + text: existing.text + text, + channelId: ctx.channelId, + turnId: ctx.turnId ?? existing.turnId, + sessionId: ctx.sessionId ?? existing.sessionId, + acpSource: acpSource ?? existing.acpSource, + }); return; } sealOpenMessages(d); - pushItem(d, { id, type, title, text, timestamp, channelId }); + pushItem(d, { + id, + type, + title, + text, + timestamp, + channelId: ctx.channelId, + turnId: ctx.turnId, + sessionId: ctx.sessionId, + acpSource, + }); } function upsertMetadata( @@ -174,15 +205,33 @@ function upsertMetadata( title: string, sections: PromptSection[], timestamp: string, - channelId: string | null, + ctx: TranscriptItemContext, + acpSource?: string, ) { const existing = d.itemsById.get(id); if (existing?.type === "metadata") { - replaceItem(d, id, { ...existing, sections, channelId }); + replaceItem(d, id, { + ...existing, + sections, + channelId: ctx.channelId, + turnId: ctx.turnId ?? existing.turnId, + sessionId: ctx.sessionId ?? existing.sessionId, + acpSource: acpSource ?? existing.acpSource, + }); return; } sealOpenMessages(d); - pushItem(d, { id, type: "metadata", title, sections, timestamp, channelId }); + pushItem(d, { + id, + type: "metadata", + title, + sections, + timestamp, + channelId: ctx.channelId, + turnId: ctx.turnId, + sessionId: ctx.sessionId, + acpSource, + }); } function upsertTool( @@ -196,7 +245,8 @@ function upsertTool( result: string, isError: boolean, timestamp: string, - channelId: string | null, + ctx: TranscriptItemContext, + acpSource?: string, ) { const existing = d.itemsById.get(id); const canonicalBuzzToolName = @@ -225,7 +275,10 @@ function upsertTool( existing.completedAt == null ? timestamp : existing.completedAt, - channelId, + channelId: ctx.channelId, + turnId: ctx.turnId ?? existing.turnId, + sessionId: ctx.sessionId ?? existing.sessionId, + acpSource: acpSource ?? existing.acpSource, }); return; } @@ -243,7 +296,10 @@ function upsertTool( timestamp, startedAt: timestamp, completedAt: null, - channelId, + channelId: ctx.channelId, + turnId: ctx.turnId, + sessionId: ctx.sessionId, + acpSource, }); } @@ -259,6 +315,11 @@ export function processTranscriptEvent( const channelId = event.channelId ?? null; const ch = channelId ?? "global"; + const ctx: TranscriptItemContext = { + channelId, + turnId: event.turnId, + sessionId: event.sessionId ?? d.latestSessionId, + }; if (event.kind === "turn_started") { upsertTextItem( @@ -268,7 +329,8 @@ export function processTranscriptEvent( "Turn started", describeTurnStarted(event.payload), event.timestamp, - channelId, + ctx, + event.kind, ); } else if (event.kind === "session_resolved") { upsertTextItem( @@ -278,7 +340,8 @@ export function processTranscriptEvent( "Session ready", describeSessionResolved(event.payload), event.timestamp, - channelId, + ctx, + event.kind, ); } else if (event.kind === "acp_parse_error") { upsertTextItem( @@ -288,7 +351,8 @@ export function processTranscriptEvent( "Wire parse error", extractBlockText(event.payload), event.timestamp, - channelId, + ctx, + event.kind, ); } else if (event.kind === "turn_error" || event.kind === "agent_panic") { const payload = asRecord(event.payload); @@ -303,7 +367,8 @@ export function processTranscriptEvent( title, `${outcome}: ${error}`, event.timestamp, - channelId, + ctx, + event.kind, ); } else if (event.kind === "acp_read" || event.kind === "acp_write") { const payload = asRecord(event.payload); @@ -321,8 +386,9 @@ export function processTranscriptEvent( parsedPrompt.userTitle, parsedPrompt.userText, event.timestamp, - channelId, + ctx, parsedPrompt.userPubkey, + "session/prompt:user", ); } if (parsedPrompt.sections.length > 0) { @@ -332,7 +398,8 @@ export function processTranscriptEvent( "Prompt context", parsedPrompt.sections, event.timestamp, - channelId, + ctx, + "session/prompt:context", ); } } @@ -353,7 +420,7 @@ export function processTranscriptEvent( "System prompt", sections, event.timestamp, - channelId, + ctx, ); } } @@ -402,7 +469,9 @@ export function processTranscriptEvent( "Assistant", extractContentText(update.content), event.timestamp, - channelId, + ctx, + null, + updateType, ); } else if (updateType === "user_message_chunk") { // Suppress user_message_chunk echo when a steer already rendered @@ -416,7 +485,9 @@ export function processTranscriptEvent( "User", extractContentText(update.content), event.timestamp, - channelId, + ctx, + null, + updateType, ); } } else if (updateType === "agent_thought_chunk") { @@ -427,7 +498,8 @@ export function processTranscriptEvent( "Thinking", extractContentText(update.content), event.timestamp, - channelId, + ctx, + updateType, ); } else if (updateType === "tool_call") { const toolId = asString(update.toolCallId) ?? `tool:${event.seq}`; @@ -443,7 +515,8 @@ export function processTranscriptEvent( extractToolResult(update), false, event.timestamp, - channelId, + ctx, + updateType, ); } else if (updateType === "tool_call_update") { const toolId = asString(update.toolCallId) ?? `tool:${event.seq}`; @@ -462,7 +535,8 @@ export function processTranscriptEvent( extractToolResult(update), status === "failed", event.timestamp, - channelId, + ctx, + updateType, ); } else if (updateType === "plan") { upsertTextItem( @@ -472,7 +546,8 @@ export function processTranscriptEvent( "Plan", extractContentText(update.content) || JSON.stringify(update, null, 2), event.timestamp, - channelId, + ctx, + updateType, ); } } diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs new file mode 100644 index 000000000..7639a97c6 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs @@ -0,0 +1,213 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildTranscriptDisplayBlocks, + flattenDisplayBlocks, + formatTurnSetupLabel, +} from "./agentSessionTranscriptGrouping.ts"; + +const baseTimestamp = "2026-06-14T22:20:23.000Z"; + +function lifecycle(id, title, acpSource, turnId, text = "") { + return { + id, + type: "lifecycle", + title, + text, + timestamp: baseTimestamp, + acpSource, + turnId, + sessionId: "sess-1", + channelId: "channel-1", + }; +} + +function userPrompt(id, text, turnId) { + return { + id, + type: "message", + role: "user", + title: "Buzz event", + text, + timestamp: baseTimestamp, + acpSource: "session/prompt:user", + turnId, + sessionId: "sess-1", + channelId: "channel-1", + }; +} + +function promptContext(id, turnId) { + return { + id, + type: "metadata", + title: "Prompt context", + sections: [{ title: "Channel", body: "general" }], + timestamp: baseTimestamp, + acpSource: "session/prompt:context", + turnId, + sessionId: "sess-1", + channelId: "channel-1", + }; +} + +function assistantMessage(id, text, turnId) { + return { + id, + type: "message", + role: "assistant", + title: "Assistant", + text, + timestamp: "2026-06-14T22:20:47.000Z", + acpSource: "agent_message_chunk", + turnId, + sessionId: "sess-1", + channelId: "channel-1", + }; +} + +function toolCall(id, turnId) { + return { + id, + type: "tool", + title: "Shell", + toolName: "buzz-dev-mcp__shell", + buzzToolName: null, + status: "completed", + args: {}, + result: "ok", + isError: false, + timestamp: "2026-06-14T22:20:47.000Z", + startedAt: "2026-06-14T22:20:47.000Z", + completedAt: "2026-06-14T22:20:47.400Z", + acpSource: "tool_call_update", + turnId, + sessionId: "sess-1", + channelId: "channel-1", + }; +} + +test("buildTranscriptDisplayBlocks bundles user prompt, setup, and context together", () => { + const rawItems = [ + lifecycle( + "turn", + "Turn started", + "turn_started", + "turn-1", + "Triggered by 1 event.", + ), + lifecycle("session", "Session ready", "session_resolved", "turn-1"), + userPrompt("prompt", "@Ned deliberate, wider pass", "turn-1"), + promptContext("context", "turn-1"), + assistantMessage("assistant", "Thinking out loud.", "turn-1"), + toolCall("tool", "turn-1"), + ]; + + const blocks = buildTranscriptDisplayBlocks(rawItems); + const displayOrder = flattenDisplayBlocks(blocks).map((item) => item.id); + + assert.deepEqual(displayOrder, [ + "prompt", + "turn", + "session", + "context", + "assistant", + "tool", + ]); + + const turnBlock = blocks[0]; + assert.equal(turnBlock?.kind, "turn"); + assert.equal(turnBlock.segments[0]?.kind, "prompt"); + const promptSegment = turnBlock.segments[0]; + assert.equal(promptSegment.user.id, "prompt"); + assert.equal(promptSegment.context?.id, "context"); + assert.equal(promptSegment.setup.length, 2); + assert.equal(turnBlock.segments[1]?.kind, "item"); + assert.equal(turnBlock.segments[2]?.kind, "item"); +}); + +test("buildTranscriptDisplayBlocks collapses setup lifecycle inside prompt bundle", () => { + const rawItems = [ + lifecycle("turn", "Turn started", "turn_started", "turn-1"), + lifecycle("session", "Session ready", "session_resolved", "turn-1"), + userPrompt("prompt", "hello", "turn-1"), + ]; + + const blocks = buildTranscriptDisplayBlocks(rawItems); + assert.equal(blocks.length, 1); + assert.equal(blocks[0]?.kind, "turn"); + + const turnBlock = blocks[0]; + assert.equal(turnBlock.segments.length, 1); + assert.equal(turnBlock.segments[0]?.kind, "prompt"); + assert.equal( + formatTurnSetupLabel(turnBlock.segments[0].setup), + "Turn started · Session ready", + ); +}); + +test("buildTranscriptDisplayBlocks hides setup and context when prompt is missing", () => { + const rawItems = [ + lifecycle("turn", "Turn started", "turn_started", "turn-1"), + lifecycle("session", "Session ready", "session_resolved", "turn-1"), + promptContext("context", "turn-1"), + toolCall("tool", "turn-1"), + ]; + + const blocks = buildTranscriptDisplayBlocks(rawItems); + const displayOrder = flattenDisplayBlocks(blocks).map((item) => item.id); + + assert.deepEqual(displayOrder, ["tool"]); +}); + +test("buildTranscriptDisplayBlocks drops setup-and-context-only turns", () => { + const rawItems = [ + lifecycle("turn", "Turn started", "turn_started", "turn-1"), + lifecycle("session", "Session ready", "session_resolved", "turn-1"), + promptContext("context", "turn-1"), + ]; + + const blocks = buildTranscriptDisplayBlocks(rawItems); + + assert.deepEqual(blocks, []); +}); + +test("buildTranscriptDisplayBlocks leaves error lifecycle prominent outside prompt bundle", () => { + const rawItems = [ + lifecycle("turn", "Turn started", "turn_started", "turn-1"), + userPrompt("prompt", "hello", "turn-1"), + lifecycle( + "error", + "Turn error", + "turn_error", + "turn-1", + "timeout: agent hung", + ), + ]; + + const blocks = buildTranscriptDisplayBlocks(rawItems); + const displayOrder = flattenDisplayBlocks(blocks).map((item) => item.id); + + assert.deepEqual(displayOrder, ["prompt", "turn", "error"]); + assert.equal(blocks[0]?.segments[0]?.kind, "prompt"); + assert.equal(blocks[0]?.segments[1]?.kind, "item"); + assert.equal(blocks[0]?.segments[1]?.item.id, "error"); +}); + +test("buildTranscriptDisplayBlocks passes through items without turnId", () => { + const orphan = { + id: "orphan", + type: "lifecycle", + title: "Wire parse error", + text: "bad json", + timestamp: baseTimestamp, + acpSource: "acp_parse_error", + channelId: "channel-1", + }; + + const blocks = buildTranscriptDisplayBlocks([orphan]); + assert.equal(blocks.length, 1); + assert.equal(blocks[0]?.kind, "single"); + assert.equal(blocks[0]?.item.id, "orphan"); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts new file mode 100644 index 000000000..fd2877f27 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts @@ -0,0 +1,208 @@ +import type { TranscriptItem } from "./agentSessionTypes"; + +export type TranscriptTurnSegment = + | { kind: "item"; item: TranscriptItem } + | { kind: "setup"; items: Extract[] } + | { + kind: "prompt"; + user: Extract; + context: Extract | null; + setup: Extract[]; + }; + +export type TranscriptDisplayBlock = + | { kind: "single"; item: TranscriptItem } + | { kind: "turn"; turnId: string; segments: TranscriptTurnSegment[] }; + +function isUserPrompt( + item: TranscriptItem, +): item is Extract { + return ( + item.type === "message" && + item.role === "user" && + item.acpSource === "session/prompt:user" + ); +} + +function isPromptContext( + item: TranscriptItem, +): item is Extract { + return ( + item.type === "metadata" && item.acpSource === "session/prompt:context" + ); +} + +function isSetupLifecycle( + item: TranscriptItem, +): item is Extract { + return ( + item.type === "lifecycle" && + (item.acpSource === "turn_started" || item.acpSource === "session_resolved") + ); +} + +function isErrorLifecycle( + item: TranscriptItem, +): item is Extract { + return ( + item.type === "lifecycle" && item.title.toLowerCase().includes("error") + ); +} + +type TurnBucket = { + turnId: string; + items: TranscriptItem[]; +}; + +function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] { + const userPrompt = items.find(isUserPrompt) ?? null; + const setupLifecycle = items.filter(isSetupLifecycle); + const promptContext = items.find(isPromptContext) ?? null; + const consumed = new Set(); + + if (userPrompt) consumed.add(userPrompt); + for (const item of setupLifecycle) consumed.add(item); + if (promptContext) consumed.add(promptContext); + + const activity = items.filter((item) => !consumed.has(item)); + + if (!userPrompt) { + return activity.map((item) => ({ kind: "item", item })); + } + + const segments: TranscriptTurnSegment[] = [ + { + kind: "prompt", + user: userPrompt, + context: promptContext, + setup: setupLifecycle, + }, + ]; + + for (const item of activity) { + if (isErrorLifecycle(item)) { + segments.push({ kind: "item", item }); + continue; + } + segments.push({ kind: "item", item }); + } + + return segments; +} + +/** + * Build presentation-only display blocks from normalized transcript items. + * Raw observer order is preserved in the source items; this only reorders + * within a turn for user-facing narrative flow. + */ +export function buildTranscriptDisplayBlocks( + items: TranscriptItem[], +): TranscriptDisplayBlock[] { + const blocks: TranscriptDisplayBlock[] = []; + const turnBuckets = new Map(); + const displayOrder: Array< + { kind: "single"; item: TranscriptItem } | { kind: "turn"; turnId: string } + > = []; + + for (const item of items) { + const turnId = item.turnId; + if (!turnId) { + displayOrder.push({ kind: "single", item }); + continue; + } + + let bucket = turnBuckets.get(turnId); + if (!bucket) { + bucket = { turnId, items: [] }; + turnBuckets.set(turnId, bucket); + displayOrder.push({ kind: "turn", turnId }); + } + bucket.items.push(item); + } + + for (const entry of displayOrder) { + if (entry.kind === "single") { + blocks.push({ kind: "single", item: entry.item }); + continue; + } + + const bucket = turnBuckets.get(entry.turnId); + if (!bucket || bucket.items.length === 0) { + continue; + } + + const segments = classifyTurnItems(bucket.items); + if (segments.length > 0) { + blocks.push({ + kind: "turn", + turnId: entry.turnId, + segments, + }); + } + } + + return blocks; +} + +/** Flatten display blocks back to items for testing display order. */ +export function flattenDisplayBlocks( + blocks: TranscriptDisplayBlock[], +): TranscriptItem[] { + const result: TranscriptItem[] = []; + + for (const block of blocks) { + if (block.kind === "single") { + result.push(block.item); + continue; + } + + for (const segment of block.segments) { + if (segment.kind === "item") { + result.push(segment.item); + } else if (segment.kind === "prompt") { + result.push(segment.user); + result.push(...segment.setup); + if (segment.context) { + result.push(segment.context); + } + } else { + result.push(...segment.items); + } + } + } + + return result; +} + +/** Human-readable labels for a collapsed turn setup row. */ +export function formatTurnSetupLabel( + items: Extract[], +): string { + const labels = items.map((item) => item.title); + return labels.join(" · "); +} + +/** Earliest timestamp among setup lifecycle items. */ +export function turnSetupTimestamp( + items: Extract[], +): string | null { + if (items.length === 0) return null; + return items.reduce( + (earliest, item) => + Date.parse(item.timestamp) < Date.parse(earliest) + ? item.timestamp + : earliest, + items[0].timestamp, + ); +} + +/** Optional detail text from setup lifecycle items (e.g. trigger count). */ +export function turnSetupDetail( + items: Extract[], +): string | null { + const details = items + .map((item) => item.text.trim()) + .filter((text) => text.length > 0); + if (details.length === 0) return null; + return details.join(" "); +} diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs index 2d9ed410b..76210f2cd 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs @@ -63,6 +63,37 @@ test("parsePromptText extracts content, hex pubkey, and a title-cased kind", () ); }); +test("parsePromptText preserves multiline event content in the user bubble text", () => { + const text = [ + "[Buzz event: @mention]", + "Event ID: event-1", + "Channel: agents", + `From: tho (hex: ${HEX})`, + "Time: 2026-06-15T17:15:00Z", + "Content: @Ned", + "", + "- remove that stray cherry pick if it's not adding value here", + "- help me understand what that e2eBridge change does", + "- we'd want the e2e seed path as a separate pull request", + 'Tags: [["h","agents"]]', + "Parsed: mentions=[Ned]", + ].join("\n"); + + const result = parsePromptText(text); + + assert.equal( + result.userText, + [ + "@Ned", + "", + "- remove that stray cherry pick if it's not adding value here", + "- help me understand what that e2eBridge change does", + "- we'd want the e2e seed path as a separate pull request", + ].join("\n"), + ); + assert.equal(result.userPubkey, HEX); +}); + test("parsePromptText lowercases the extracted hex pubkey", () => { const text = [ "[Buzz event: dm]", diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts index 857c24c63..ec2d05bda 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts @@ -127,9 +127,39 @@ function parsePromptSections(text: string): PromptSection[] { return sections; } +const EVENT_CONTENT_BOUNDARY_RE = + /^(?:Event ID|Channel|Kind|From|Time|Tags|Parsed):\s*/; +const EVENT_BLOCK_BOUNDARY_RE = /^--- Event \d+\b/; + function extractEventContent(body: string): string { - const contentMatch = body.match(/^Content:\s*(.*)$/m); - return contentMatch?.[1]?.trim() ?? ""; + const lines = body.split(/\r?\n/); + const chunks: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const match = lines[i].match(/^Content:\s?(.*)$/); + if (!match) { + continue; + } + + const contentLines = [match[1] ?? ""]; + for (let j = i + 1; j < lines.length; j++) { + const line = lines[j]; + if ( + EVENT_CONTENT_BOUNDARY_RE.test(line) || + EVENT_BLOCK_BOUNDARY_RE.test(line) + ) { + break; + } + contentLines.push(line); + } + + const content = contentLines.join("\n").trim(); + if (content) { + chunks.push(content); + } + } + + return chunks.join("\n\n"); } function extractEventAuthorPubkey(body: string): string | null { diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs new file mode 100644 index 000000000..a13736189 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs @@ -0,0 +1,148 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildTranscriptPresentation, + getActivityHeadline, + isMeaningfulItem, +} from "./agentSessionTranscriptPresentation.ts"; + +const baseTimestamp = "2026-06-14T19:00:00.000Z"; + +function makeTool(overrides = {}) { + return { + id: "tool:1", + type: "tool", + title: "Send Message", + toolName: "send_message", + buzzToolName: "send_message", + status: "executing", + args: { channel_id: "abc" }, + result: "", + isError: false, + timestamp: baseTimestamp, + startedAt: baseTimestamp, + completedAt: null, + ...overrides, + }; +} + +function makeMessage(overrides = {}) { + return { + id: "msg:1", + type: "message", + role: "assistant", + title: "Assistant", + text: "Looking into that now.", + timestamp: baseTimestamp, + ...overrides, + }; +} + +test("getActivityHeadline formats tool titles and assistant text", () => { + assert.equal(getActivityHeadline(makeTool()), "Send Message"); + assert.equal( + getActivityHeadline(makeMessage({ text: "First line\nSecond line" })), + "First line", + ); + assert.equal(getActivityHeadline(makeMessage({ text: " " })), "Responding"); +}); + +test("isMeaningfulItem ignores lifecycle noise and metadata", () => { + assert.equal( + isMeaningfulItem({ + id: "life:1", + type: "lifecycle", + title: "Turn started", + text: "", + timestamp: baseTimestamp, + }), + false, + ); + assert.equal( + isMeaningfulItem({ + id: "meta:1", + type: "metadata", + title: "Prompt context", + sections: [], + timestamp: baseTimestamp, + }), + false, + ); + assert.equal( + isMeaningfulItem({ + id: "life:2", + type: "lifecycle", + title: "Turn error", + text: "boom", + timestamp: baseTimestamp, + }), + true, + ); +}); + +test("buildTranscriptPresentation marks running tools as active while working", () => { + const items = [ + makeMessage({ id: "msg:user", role: "user", text: "Please help" }), + makeTool({ id: "tool:running", status: "executing" }), + ]; + + const presentation = buildTranscriptPresentation(items, true); + + assert.equal(presentation.state, "tool_running"); + assert.equal(presentation.headline, "Send Message"); + assert.equal(presentation.counts.tools, 1); + assert.equal(presentation.counts.messages, 1); + assert.ok(presentation.activeItemIds.has("tool:running")); +}); + +test("buildTranscriptPresentation highlights assistant streaming while working", () => { + const items = [ + makeMessage({ id: "msg:assistant", role: "assistant", text: "Drafting" }), + ]; + + const presentation = buildTranscriptPresentation(items, true); + + assert.equal(presentation.state, "responding"); + assert.equal(presentation.headline, "Drafting"); + assert.ok(presentation.activeItemIds.has("msg:assistant")); +}); + +test("buildTranscriptPresentation surfaces lifecycle errors", () => { + const items = [ + makeTool({ + id: "tool:done", + status: "completed", + completedAt: "2026-06-14T19:00:05.000Z", + }), + { + id: "life:error", + type: "lifecycle", + title: "Turn error", + text: "timeout", + timestamp: "2026-06-14T19:00:06.000Z", + }, + ]; + + const presentation = buildTranscriptPresentation(items, false); + + assert.equal(presentation.state, "error"); + assert.equal(presentation.hasError, true); + assert.equal(presentation.headline, "Turn error"); +}); + +test("buildTranscriptPresentation returns idle state when not working", () => { + const items = [ + makeTool({ + id: "tool:done", + status: "completed", + completedAt: "2026-06-14T19:00:05.000Z", + }), + ]; + + const presentation = buildTranscriptPresentation(items, false); + + assert.equal(presentation.state, "idle"); + assert.equal(presentation.activeItemIds.size, 0); + assert.equal(presentation.headline, "Send Message"); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts new file mode 100644 index 000000000..bb6fd4c9f --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts @@ -0,0 +1,280 @@ +import { formatToolTitle } from "./agentSessionToolCatalog"; +import type { TranscriptItem } from "./agentSessionTypes"; + +export type TranscriptActivityCounts = { + tools: number; + toolErrors: number; + thoughts: number; + messages: number; + lifecycle: number; + metadata: number; +}; + +export type TranscriptActivityState = + | "idle" + | "responding" + | "thinking" + | "tool_running" + | "error"; + +export type TranscriptPresentation = { + headline: string; + state: TranscriptActivityState; + counts: TranscriptActivityCounts; + latestMeaningfulItem: TranscriptItem | null; + latestMeaningfulItemId: string | null; + activeItemIds: ReadonlySet; + lastUpdatedAt: string | null; + hasError: boolean; +}; + +const LIFECYCLE_NOISE = new Set([ + "turn started", + "session ready", + "wire parse error", +]); + +/** Human-readable headline for a single transcript item. */ +export function getActivityHeadline(item: TranscriptItem): string | null { + if (item.type === "tool") { + return formatToolTitle(item.buzzToolName ?? item.toolName, item.title); + } + + if (item.type === "message") { + if (item.role === "assistant") { + const trimmed = item.text.trim(); + if (trimmed.length > 0) { + const firstLine = trimmed.split("\n")[0]?.trim() ?? ""; + if (firstLine.length > 0) { + return firstLine.length > 72 + ? `${firstLine.slice(0, 69)}…` + : firstLine; + } + } + return "Responding"; + } + return item.title || "User prompt"; + } + + if (item.type === "thought") { + return item.title === "Plan" ? "Planning" : item.title; + } + + if (item.type === "metadata") { + return item.title; + } + + return item.title; +} + +function isLifecycleNoise( + item: Extract, +) { + return LIFECYCLE_NOISE.has(item.title.toLowerCase()); +} + +/** Whether an item should contribute to the "Now" summary and headline scan. */ +export function isMeaningfulItem(item: TranscriptItem): boolean { + if (item.type === "lifecycle") { + return !isLifecycleNoise(item); + } + if (item.type === "metadata") { + return false; + } + return true; +} + +function isToolRunning(item: Extract) { + return item.status === "executing" || item.status === "pending"; +} + +function isLifecycleError( + item: Extract, +) { + return item.title.toLowerCase().includes("error"); +} + +function countItems(items: TranscriptItem[]): TranscriptActivityCounts { + const counts: TranscriptActivityCounts = { + tools: 0, + toolErrors: 0, + thoughts: 0, + messages: 0, + lifecycle: 0, + metadata: 0, + }; + + for (const item of items) { + switch (item.type) { + case "tool": + counts.tools += 1; + if (item.isError || item.status === "failed") { + counts.toolErrors += 1; + } + break; + case "thought": + counts.thoughts += 1; + break; + case "message": + counts.messages += 1; + break; + case "lifecycle": + counts.lifecycle += 1; + break; + case "metadata": + counts.metadata += 1; + break; + } + } + + return counts; +} + +function findLatestMeaningfulItem( + items: TranscriptItem[], +): TranscriptItem | null { + for (let i = items.length - 1; i >= 0; i--) { + const item = items[i]; + if (isMeaningfulItem(item)) { + return item; + } + } + return null; +} + +function resolveActivityState( + latest: TranscriptItem | null, + hasError: boolean, + isWorking: boolean, +): TranscriptActivityState { + if (!isWorking) { + return hasError ? "error" : "idle"; + } + + if (hasError && latest?.type === "lifecycle" && isLifecycleError(latest)) { + return "error"; + } + + if (latest?.type === "tool" && isToolRunning(latest)) { + return "tool_running"; + } + + if (latest?.type === "thought") { + return "thinking"; + } + + if (latest?.type === "message" && latest.role === "assistant") { + return "responding"; + } + + if (latest?.type === "tool") { + return "tool_running"; + } + + return "idle"; +} + +function resolveHeadline( + latest: TranscriptItem | null, + state: TranscriptActivityState, + isWorking: boolean, +): string { + if (latest) { + const headline = getActivityHeadline(latest); + if (headline) { + return headline; + } + } + + if (isWorking) { + switch (state) { + case "tool_running": + return "Running a tool"; + case "thinking": + return "Thinking"; + case "responding": + return "Responding"; + case "error": + return "Encountered an error"; + default: + return "Working"; + } + } + + if (state === "error") { + return "Last turn ended with an error"; + } + + return "Waiting for activity"; +} + +function collectActiveItemIds( + items: TranscriptItem[], + isWorking: boolean, +): ReadonlySet { + if (!isWorking || items.length === 0) { + return new Set(); + } + + const active = new Set(); + + for (let i = items.length - 1; i >= 0; i--) { + const item = items[i]; + + if (item.type === "tool" && isToolRunning(item)) { + active.add(item.id); + break; + } + + if (item.type === "thought") { + active.add(item.id); + break; + } + + if (item.type === "message" && item.role === "assistant") { + active.add(item.id); + break; + } + } + + return active; +} + +function detectError(items: TranscriptItem[]): boolean { + for (let i = items.length - 1; i >= 0; i--) { + const item = items[i]; + if (!isMeaningfulItem(item)) { + continue; + } + if (item.type === "lifecycle" && isLifecycleError(item)) { + return true; + } + if (item.type === "tool" && (item.isError || item.status === "failed")) { + return true; + } + break; + } + return false; +} + +/** Derive presentation metadata for a transcript list. */ +export function buildTranscriptPresentation( + items: TranscriptItem[], + isWorking = false, +): TranscriptPresentation { + const latestMeaningfulItem = findLatestMeaningfulItem(items); + const hasError = detectError(items); + const state = resolveActivityState(latestMeaningfulItem, hasError, isWorking); + + return { + headline: resolveHeadline(latestMeaningfulItem, state, isWorking), + state, + counts: countItems(items), + latestMeaningfulItem, + latestMeaningfulItemId: latestMeaningfulItem?.id ?? null, + activeItemIds: collectActiveItemIds(items, isWorking), + lastUpdatedAt: + items.length > 0 ? (items[items.length - 1]?.timestamp ?? null) : null, + hasError, + }; +} diff --git a/desktop/src/features/agents/ui/agentSessionTypes.ts b/desktop/src/features/agents/ui/agentSessionTypes.ts index 2ff4ea305..c4061bb1d 100644 --- a/desktop/src/features/agents/ui/agentSessionTypes.ts +++ b/desktop/src/features/agents/ui/agentSessionTypes.ts @@ -20,42 +20,52 @@ export type ConnectionState = export type ToolStatus = "executing" | "completed" | "failed" | "pending"; +/** Observer/ACP wire label for dev-only transcript debugging. */ +export type TranscriptAcpSource = string; + +/** Shared optional identity fields attached during transcript construction. */ +export type TranscriptItemIdentity = { + turnId?: string | null; + sessionId?: string | null; + channelId?: string | null; +}; + export type TranscriptItem = - | { + | ({ id: string; type: "message"; role: "assistant" | "user"; title: string; text: string; timestamp: string; + acpSource?: TranscriptAcpSource; authorPubkey?: string | null; - channelId?: string | null; - } - | { + } & TranscriptItemIdentity) + | ({ id: string; type: "thought"; title: string; text: string; timestamp: string; - channelId?: string | null; - } - | { + acpSource?: TranscriptAcpSource; + } & TranscriptItemIdentity) + | ({ id: string; type: "lifecycle"; title: string; text: string; timestamp: string; - channelId?: string | null; - } - | { + acpSource?: TranscriptAcpSource; + } & TranscriptItemIdentity) + | ({ id: string; type: "metadata"; title: string; sections: PromptSection[]; timestamp: string; - channelId?: string | null; - } - | { + acpSource?: TranscriptAcpSource; + } & TranscriptItemIdentity) + | ({ id: string; type: "tool"; title: string; @@ -68,8 +78,8 @@ export type TranscriptItem = timestamp: string; startedAt: string; completedAt: string | null; - channelId?: string | null; - }; + acpSource?: TranscriptAcpSource; + } & TranscriptItemIdentity); export type PromptSection = { title: string; diff --git a/desktop/src/features/agents/ui/agentSessionUtils.ts b/desktop/src/features/agents/ui/agentSessionUtils.ts index 19d915ec8..39cec560e 100644 --- a/desktop/src/features/agents/ui/agentSessionUtils.ts +++ b/desktop/src/features/agents/ui/agentSessionUtils.ts @@ -64,6 +64,91 @@ export function asRecord(value: unknown): Record { : {}; } +/** + * True when a tool image source is an inline `data:image/` URI that should be + * rendered as-is. This is the dual-layer image-scheme guard: only the + * `data:image/` prefix is treated as a safe passthrough — every other scheme + * (including other `data:` subtypes) must be routed through the relay rewriter. + * Never widen this beyond `data:image/`. + */ +export function isInlineImageData(source: string): boolean { + return source.startsWith("data:image/"); +} + +function getToolNumber( + record: Record, + keys: string[], +): number | null { + for (const key of keys) { + const value = record[key]; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + } + return null; +} + +/** Format a millisecond duration; negative input yields null. */ +export function formatDurationMs(ms: number): string | null { + if (ms < 0) return null; + const totalSeconds = ms / 1000; + if (totalSeconds < 60) { + return totalSeconds < 10 + ? `${totalSeconds.toFixed(1)}s` + : `${Math.round(totalSeconds)}s`; + } + let minutes = Math.floor(totalSeconds / 60); + let seconds = Math.round(totalSeconds % 60); + if (seconds === 60) { + minutes += 1; + seconds = 0; + } + return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; +} + +/** + * Parse a tool result string into a value. Handles the double-encoding case + * where a JSON string itself contains JSON. Returns null on empty or invalid + * input. + */ +export function parseToolResultValue(result: string): unknown { + const trimmed = result.trim(); + if (!trimmed) return null; + + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed !== "string") return parsed; + try { + return JSON.parse(parsed); + } catch { + return parsed; + } + } catch { + return null; + } +} + +/** + * Resolve a tool's display duration. Prefers the start/complete timestamps, + * then falls back to `duration_ms`/`elapsed_ms` fields inside the parsed + * result payload. + */ +export function getToolDurationDisplay(item: { + startedAt?: string | null; + completedAt?: string | null; + result: string; +}): string | null { + if (item.startedAt && item.completedAt) { + return formatDuration(item.startedAt, item.completedAt); + } + + const resultRecord = asRecord(parseToolResultValue(item.result)); + const durationMs = + getToolNumber(resultRecord, ["duration_ms", "durationMs"]) ?? + getToolNumber(resultRecord, ["elapsed_ms", "elapsedMs"]); + return durationMs == null ? null : formatDurationMs(durationMs); +} + export function asString(value: unknown): string | null { return typeof value === "string" ? value : null; } diff --git a/desktop/src/features/agents/ui/rawEventRail.test.mjs b/desktop/src/features/agents/ui/rawEventRail.test.mjs new file mode 100644 index 000000000..7750fdac2 --- /dev/null +++ b/desktop/src/features/agents/ui/rawEventRail.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { describeRawEvent } from "./agentSessionTranscriptHelpers.ts"; + +function rawEvent(overrides = {}) { + return { + seq: 1, + kind: "acp", + sessionId: "sess-1", + channelId: "channel-1", + payload: {}, + ...overrides, + }; +} + +test("describeRawEvent surfaces the session/update sessionUpdate label", () => { + const event = rawEvent({ + payload: { + method: "session/update", + params: { update: { sessionUpdate: "agent_message_chunk" } }, + }, + }); + assert.equal(describeRawEvent(event), "agent_message_chunk"); +}); + +test("describeRawEvent falls back to the method when session/update lacks an update label", () => { + const event = rawEvent({ + payload: { method: "session/update", params: {} }, + }); + assert.equal(describeRawEvent(event), "session/update"); +}); + +test("describeRawEvent uses the method for non-session/update payloads", () => { + const event = rawEvent({ payload: { method: "session/prompt" } }); + assert.equal(describeRawEvent(event), "session/prompt"); +}); + +test("describeRawEvent falls back to the event kind when no method is present", () => { + const event = rawEvent({ kind: "acp_parse_error", payload: {} }); + assert.equal(describeRawEvent(event), "acp_parse_error"); +}); diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index 301ef12b6..b9fa2a294 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -1,4 +1,5 @@ -import { Octagon, Settings } from "lucide-react"; +import * as React from "react"; +import { Octagon, Settings, TerminalSquare } from "lucide-react"; import { toast } from "sonner"; import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel"; @@ -24,6 +25,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; +import { Switch } from "@/shared/ui/switch"; import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions"; type AgentSessionThreadPanelProps = { @@ -59,6 +61,19 @@ export function AgentSessionThreadPanel({ useEscapeKey(onClose, isOverlay || isSinglePanelView); const { ref: scrollRef, onScroll } = useStickToBottom(); + const rawFeedScopeKey = `${agent.pubkey}:${channel?.id ?? "all"}`; + const [rawFeedState, setRawFeedState] = React.useState(() => ({ + scopeKey: rawFeedScopeKey, + show: false, + })); + const showRawFeed = + rawFeedState.scopeKey === rawFeedScopeKey && rawFeedState.show; + const handleRawFeedChange = React.useCallback( + (checked: boolean) => { + setRawFeedState({ scopeKey: rawFeedScopeKey, show: checked }); + }, + [rawFeedScopeKey], + ); async function handleInterruptTurn() { if (!channel) { @@ -81,6 +96,34 @@ export function AgentSessionThreadPanel({ const agentHeaderActions = ( + {isLive ? ( +
+ + +
+ ) : null} {isLive && isWorking ? ( @@ -146,7 +189,9 @@ export function AgentSessionThreadPanel({ backButtonTestId="agent-session-back" onBack={onBackToProfile} > - Activity + + {showRawFeed ? "Raw ACP Activity" : "Activity"} + {agentHeaderActions} @@ -185,9 +230,11 @@ 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.` } + isWorking={isWorking} profiles={profiles} + rawLayout="exclusive" showHeader={false} - showRaw={false} + showRaw={showRawFeed} /> diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index 4d6613d92..5f7b12a24 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -2,8 +2,7 @@ import * as React from "react"; import { Loader2 } from "lucide-react"; import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents"; -import type { TranscriptItem } from "@/features/agents/ui/agentSessionTypes"; -import { formatToolTitle } from "@/features/agents/ui/agentSessionToolCatalog"; +import { getActivityHeadline } from "@/features/agents/ui/agentSessionTranscriptPresentation"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ManagedAgent } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -27,18 +26,6 @@ const HOVER_OPEN_DELAY_MS = 150; const HOVER_CLOSE_DELAY_MS = 180; const HEADLINE_ROTATION_MS = 2200; -function getActivityHeadline(item: TranscriptItem): string | null { - if (item.type === "tool") { - return formatToolTitle(item.buzzToolName ?? item.toolName, item.title); - } - - if (item.type === "message") { - return item.role === "assistant" ? "Responding" : item.title; - } - - return item.title; -} - export function BotActivityComposerAction({ agents, channelId = null, @@ -185,10 +172,11 @@ export function BotActivityComposerAction({ "border border-background", isInline ? "!h-[18px] !w-[18px] shadow-xs ring-1 ring-primary/25 text-3xs" - : "!h-5 !w-5 text-3xs", + : "shrink-0", )} displayName={agent.name} key={agent.pubkey} + size="xs" /> ))} @@ -248,8 +236,9 @@ export function BotActivityComposerAction({ > {agent.name} diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index bfa0b9cc3..5197c6717 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -285,17 +285,40 @@ export function ChannelScreen({ : [], [activeChannel], ); + const channelMembersQuery = useChannelMembersQuery(activeChannel?.id ?? null); + const channelMembers = channelMembersQuery.data; + const managedAgentsQuery = useManagedAgentsQuery(); + const managedAgents = managedAgentsQuery.data ?? []; + const relayAgentsQuery = useRelayAgentsQuery(); + const relayAgents = relayAgentsQuery.data ?? []; + const knownAgentPubkeys = React.useMemo(() => { + const pubkeys = new Set(); + for (const member of channelMembers ?? []) { + if (member.role === "bot" || member.isAgent) { + pubkeys.add(normalizePubkey(member.pubkey)); + } + } + for (const agent of managedAgents) { + pubkeys.add(normalizePubkey(agent.pubkey)); + } + for (const agent of relayAgents) { + pubkeys.add(normalizePubkey(agent.pubkey)); + } + return pubkeys; + }, [channelMembers, managedAgents, relayAgents]); const messageProfilePubkeys = React.useMemo( () => [ ...new Set([ ...messageAuthorPubkeys, ...messageMentionPubkeys, ...activeDmParticipantPubkeys, + ...knownAgentPubkeys, ...typingEntries.map((entry) => entry.pubkey), ]), ], [ activeDmParticipantPubkeys, + knownAgentPubkeys, messageAuthorPubkeys, messageMentionPubkeys, typingEntries, @@ -304,25 +327,8 @@ export function ChannelScreen({ const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, { enabled: messageProfilePubkeys.length > 0, }); - const channelMembersQuery = useChannelMembersQuery(activeChannel?.id ?? null); - const channelMembers = channelMembersQuery.data; - const managedAgentsQuery = useManagedAgentsQuery(); - const managedAgents = managedAgentsQuery.data ?? []; - const relayAgentsQuery = useRelayAgentsQuery(); - const relayAgents = relayAgentsQuery.data ?? []; const agentPubkeys = React.useMemo(() => { - const pubkeys = new Set(); - for (const member of channelMembers ?? []) { - if (member.role === "bot" || member.isAgent) { - pubkeys.add(normalizePubkey(member.pubkey)); - } - } - for (const agent of managedAgents) { - pubkeys.add(normalizePubkey(agent.pubkey)); - } - for (const agent of relayAgents) { - pubkeys.add(normalizePubkey(agent.pubkey)); - } + const pubkeys = new Set(knownAgentPubkeys); for (const [pubkey, profile] of Object.entries( messageProfilesQuery.data?.profiles ?? {}, )) { @@ -331,7 +337,7 @@ export function ChannelScreen({ } } return pubkeys; - }, [channelMembers, managedAgents, messageProfilesQuery.data, relayAgents]); + }, [knownAgentPubkeys, messageProfilesQuery.data]); const agentPubkeysPending = activeChannel?.channelType === "dm" && (channelMembersQuery.isPending || diff --git a/desktop/src/features/channels/ui/useChannelActivityTyping.ts b/desktop/src/features/channels/ui/useChannelActivityTyping.ts index c7b95d206..d616da690 100644 --- a/desktop/src/features/channels/ui/useChannelActivityTyping.ts +++ b/desktop/src/features/channels/ui/useChannelActivityTyping.ts @@ -99,7 +99,7 @@ export function mergeAgentNamesIntoProfiles( relayAgents: RelayAgent[], ): UserProfileLookup { const merged = { ...profiles }; - for (const agent of [...relayAgents, ...managedAgents]) { + for (const agent of relayAgents) { const key = normalizePubkey(agent.pubkey); merged[key] = { ...merged[key], @@ -109,5 +109,15 @@ export function mergeAgentNamesIntoProfiles( isAgent: true, }; } + for (const agent of managedAgents) { + const key = normalizePubkey(agent.pubkey); + merged[key] = { + ...merged[key], + displayName: merged[key]?.displayName || agent.name, + avatarUrl: merged[key]?.avatarUrl ?? agent.avatarUrl, + nip05Handle: merged[key]?.nip05Handle ?? null, + isAgent: true, + }; + } return merged; } diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 805151e04..3151491a3 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -45,10 +45,7 @@ import type { RuntimeConfigSurface, } from "@/shared/api/types"; -type RawIdentity = { - pubkey: string; - display_name: string; -}; +type RawIdentity = { pubkey: string; display_name: string }; type RawProfile = { pubkey: string; @@ -206,6 +203,7 @@ export type RawManagedAgent = { max_turn_duration_seconds: number | null; parallelism: number; system_prompt: string | null; + avatar_url?: string | null; model: string | null; provider: string | null; persona_out_of_date: boolean; @@ -869,6 +867,7 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent { maxTurnDurationSeconds: agent.max_turn_duration_seconds, parallelism: agent.parallelism, systemPrompt: agent.system_prompt, + avatarUrl: agent.avatar_url ?? null, model: agent.model, // Fallbacks for pre-feature mocks/fixtures. Real records always carry them. provider: agent.provider ?? null, diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index fabcee20f..8a8bc6b41 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -293,6 +293,7 @@ export type ManagedAgent = { maxTurnDurationSeconds: number | null; parallelism: number; systemPrompt: string | null; + avatarUrl: string | null; model: string | null; /** LLM inference provider, from the agent's pinned record snapshot. */ provider: string | null; diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 1218ecbb8..19aeb4762 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -71,6 +71,7 @@ import type { ImetaEntry, MarkdownProps, MarkdownRuntime, + MarkdownVariant, } from "./markdown/types"; import { SpoilerInline } from "./markdown/SpoilerInline"; import { @@ -1501,12 +1502,23 @@ function ImageBlock({ alt, dim, resolvedSrc, src }: ImageBlockProps) { } function createMarkdownComponents( + variant: MarkdownVariant, runtimeRef: React.RefObject, interactive = true, + mediaInset = false, ): Components { - const paragraphClassName = "leading-[inherit]"; - const listItemClassName = "my-1 [&_p]:inline"; - const listClassName = "space-y-1 pl-6 marker:text-muted-foreground"; + const paragraphClassName = + variant === "tight" + ? "leading-5" + : variant === "compact" + ? "leading-6" + : "leading-[inherit]"; + const listItemClassName = + variant === "tight" ? "my-0.5 [&_p]:inline" : "my-1 [&_p]:inline"; + const listClassName = + variant === "tight" + ? "space-y-0.5 pl-6 marker:text-muted-foreground" + : "space-y-1 pl-6 marker:text-muted-foreground"; return { spoiler: ({ @@ -1699,7 +1711,12 @@ function createMarkdownComponents( if (resolvedSrc?.endsWith(".mp4")) { const entry = src ? imetaByUrl?.get(src) : undefined; return ( - + createMarkdownComponents(runtimeRef, interactive), - [runtimeRef, interactive], + () => + createMarkdownComponents(variant, runtimeRef, interactive, mediaInset), + [variant, runtimeRef, interactive, mediaInset], ); // biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable @@ -2028,6 +2055,13 @@ function MarkdownInner({ "[&>*+hr]:mt-4 [&>hr+*]:mt-4", "[&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5", ].join(" "), + // Variant overrides: density tweaks for agent-session transcript surfaces. + // Layered after the base owl-spacing set so tailwind-merge lets the + // narrower leading + tighter inter-block gaps win for compact/tight. + variant === "compact" && + "leading-6 [&>*+*]:mt-2 [&>*+h1]:mt-3 [&>*+h2]:mt-3 [&>*+h3]:mt-3 [&>*+blockquote]:mt-3 [&>blockquote+*]:mt-3 [&>*+[data-code-block]]:mt-3 [&>[data-code-block]+*]:mt-3 [&>*+[data-table-block]]:mt-3 [&>[data-table-block]+*]:mt-3 [&>*+hr]:mt-3.5 [&>hr+*]:mt-3.5 [&>p+ul]:mt-1 [&>p+ol]:mt-1 [&>div+ul]:mt-1 [&>div+ol]:mt-1", + variant === "tight" && + "leading-5 [&>*+*]:mt-2 [&>*+h1]:mt-2.5 [&>*+h2]:mt-2.5 [&>*+h3]:mt-2.5 [&>*+blockquote]:mt-3 [&>blockquote+*]:mt-3 [&>*+[data-code-block]]:mt-3 [&>[data-code-block]+*]:mt-3 [&>*+[data-table-block]]:mt-3 [&>[data-table-block]+*]:mt-3 [&>*+hr]:mt-3.5 [&>hr+*]:mt-3.5 [&>p+ul]:mt-1 [&>p+ol]:mt-1 [&>div+ul]:mt-1 [&>div+ol]:mt-1", className, )} > @@ -2053,8 +2087,11 @@ export const Markdown = React.memo( (prev, next) => prev.content === next.content && prev.className === next.className && + prev.compact === next.compact && prev.customEmoji === next.customEmoji && prev.interactive === next.interactive && + prev.mediaInset === next.mediaInset && + prev.tight === next.tight && prev.agentMentionPubkeysByName === next.agentMentionPubkeysByName && prev.mentionPubkeysByName === next.mentionPubkeysByName && shallowArrayEqual(prev.mentionNames, next.mentionNames) && diff --git a/desktop/src/shared/ui/markdown/types.ts b/desktop/src/shared/ui/markdown/types.ts index d9924331a..144814cd7 100644 --- a/desktop/src/shared/ui/markdown/types.ts +++ b/desktop/src/shared/ui/markdown/types.ts @@ -35,6 +35,7 @@ export type MarkdownRuntime = { export type MarkdownProps = { channelNames?: string[]; className?: string; + compact?: boolean; content: string; customEmoji?: CustomEmoji[]; imetaByUrl?: ImetaLookup; @@ -42,6 +43,10 @@ export type MarkdownProps = { agentMentionPubkeysByName?: Record; mentionNames?: string[]; mentionPubkeysByName?: Record; + mediaInset?: boolean; searchQuery?: string; + tight?: boolean; videoReviewContext?: VideoReviewContext; }; + +export type MarkdownVariant = "default" | "compact" | "tight"; diff --git a/desktop/src/shared/ui/toggle.tsx b/desktop/src/shared/ui/toggle.tsx index 5d3671d8b..e6e9eac4f 100644 --- a/desktop/src/shared/ui/toggle.tsx +++ b/desktop/src/shared/ui/toggle.tsx @@ -10,10 +10,14 @@ const toggleVariants = cva( variants: { variant: { default: "bg-transparent", - outline: "border border-input/40 bg-background hover:bg-muted/70", + ghost: + "bg-transparent hover:bg-muted/70 hover:text-foreground data-[state=on]:bg-muted data-[state=on]:text-foreground", + outline: + "border border-input/40 bg-background hover:bg-muted/70 data-[state=on]:bg-muted data-[state=on]:text-foreground", }, size: { default: "h-9 px-3 min-w-9", + xs: "h-5 min-h-0 min-w-0 gap-1 rounded-md px-1.5 text-xs font-medium [&_svg]:size-3.5", sm: "h-8 px-2 min-w-8", lg: "h-10 px-3 min-w-10", }, diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index eaaf6eaa4..bd590becb 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -39,6 +39,7 @@ type MockCommandAvailability = { type MockManagedAgentSeed = { pubkey: string; name: string; + avatarUrl?: string | null; personaId?: string | null; status?: RawManagedAgent["status"]; channelNames?: string[]; @@ -389,6 +390,7 @@ type RawManagedAgent = { max_turn_duration_seconds: number | null; parallelism: number; system_prompt: string | null; + avatar_url: string | null; model: string | null; env_vars?: Record; status: "running" | "stopped" | "deployed" | "not_deployed"; @@ -937,6 +939,7 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent { max_turn_duration_seconds: agent.max_turn_duration_seconds ?? null, parallelism: agent.parallelism, system_prompt: agent.system_prompt, + avatar_url: agent.avatar_url ?? null, model: agent.model, env_vars: { ...(agent.env_vars ?? {}) }, status: agent.status, @@ -1450,6 +1453,7 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent { max_turn_duration_seconds: null, parallelism: 1, system_prompt: null, + avatar_url: seed.avatarUrl ?? null, model: null, env_vars: {}, status, @@ -5670,6 +5674,7 @@ async function handleCreateManagedAgent( max_turn_duration_seconds: args.input.maxTurnDurationSeconds ?? null, parallelism: args.input.parallelism ?? 1, system_prompt: args.input.systemPrompt?.trim() || null, + avatar_url: avatarUrl, model: args.input.model?.trim() || null, env_vars: { ...(args.input.envVars ?? {}) }, status: args.input.spawnAfterCreate ? "running" : "stopped", diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 919ce7e49..03f69b519 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -961,6 +961,12 @@ test("shows and clears activity indicators for active channel agents", async ({ await expect(page.getByTestId("agent-session-thread-panel")).toContainText( "alice", ); + await expect(page.getByTestId("agent-transcript-now-summary")).toHaveCount(0); + await expect(page.getByTestId("agent-session-stop-turn")).toBeVisible(); + await expect(page.getByTestId("agent-session-stop-turn")).toBeDisabled(); + await expect(page.getByTestId("agent-session-thread-panel")).toContainText( + "No ACP activity yet", + ); await expect(page.getByTestId("message-typing-indicator")).toHaveCount(0); await page.evaluate((pubkey) => { From 3cf91029db42bf41fdc0079c87662fdf9e8700d8 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Mon, 29 Jun 2026 14:54:33 -0700 Subject: [PATCH 02/49] fix(desktop): harden agent transcript reducer Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../agents/ui/AgentSessionTranscriptList.tsx | 1 - .../agents/ui/ManagedAgentSessionPanel.tsx | 6 - .../agents/ui/agentSessionTranscript.test.mjs | 187 +++++++++++++++ .../agents/ui/agentSessionTranscript.ts | 18 +- ...gentSessionTranscriptPresentation.test.mjs | 67 ------ .../ui/agentSessionTranscriptPresentation.ts | 222 ------------------ 6 files changed, 202 insertions(+), 299 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 45c25a23b..3afea9ca7 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -73,7 +73,6 @@ export function AgentSessionTranscriptList({ profiles, }: AgentTranscriptIdentityProps & { emptyDescription: string; - isWorking?: boolean; items: TranscriptItem[]; profiles?: UserProfileLookup; }) { diff --git a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx index b2b848243..8051c4798 100644 --- a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx @@ -36,7 +36,6 @@ type ManagedAgentSessionPanelProps = { channelId?: string | null; className?: string; emptyDescription?: string; - isWorking?: boolean; rawLayout?: "responsive" | "exclusive"; showHeader?: boolean; showRaw?: boolean; @@ -48,7 +47,6 @@ export function ManagedAgentSessionPanel({ channelId = null, className, emptyDescription = "Mention this agent in a channel to watch the next turn.", - isWorking = false, rawLayout = "responsive", showHeader = true, showRaw = true, @@ -101,7 +99,6 @@ export function ManagedAgentSessionPanel({ errorMessage={errorMessage} events={scopedEvents} hasObserver={hasObserver} - isWorking={isWorking} profiles={profiles} rawLayout={rawLayout} showRaw={showRaw} @@ -155,7 +152,6 @@ function SessionBody({ errorMessage, events, hasObserver, - isWorking, profiles, rawLayout, showRaw, @@ -169,7 +165,6 @@ function SessionBody({ errorMessage: string | null; events: ObserverEvent[]; hasObserver: boolean; - isWorking: boolean; profiles?: UserProfileLookup; rawLayout: "responsive" | "exclusive"; showRaw: boolean; @@ -211,7 +206,6 @@ function SessionBody({ agentName={agentName} agentPubkey={agentPubkey} emptyDescription={emptyDescription} - isWorking={isWorking} items={transcript} profiles={profiles} /> diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index d984b0c19..d61529305 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -194,3 +194,190 @@ test("buildTranscript categorizes explicit Buzz tool calls for the activity bar" assert.deepEqual(item.args, { limit: 20 }); assert.equal(item.status, "completed"); }); + +function sessionUpdate(seq, update, overrides = {}) { + return { + ...baseEvent, + ...overrides, + seq, + kind: "acp_read", + payload: { + method: "session/update", + params: { + sessionId: overrides.sessionId ?? baseEvent.sessionId, + update, + }, + }, + }; +} + +function assistantChunk(seq, messageId, text, overrides = {}) { + return sessionUpdate( + seq, + { + sessionUpdate: "agent_message_chunk", + messageId, + content: { type: "text", text }, + }, + overrides, + ); +} + +test("buildTranscript de-duplicates repeated tool updates into one canonical row", () => { + const items = toolItems([ + acpToolUpdate(40, { + sessionUpdate: "tool_call", + toolCallId: "call-dupe", + status: "executing", + title: "shell", + kind: "shell", + rawInput: { command: "echo hi" }, + }), + acpToolUpdate(41, { + sessionUpdate: "tool_call_update", + toolCallId: "call-dupe", + status: "completed", + title: "shell", + kind: "shell", + rawOutput: "hi", + }), + acpToolUpdate(42, { + sessionUpdate: "tool_call_update", + toolCallId: "call-dupe", + status: "completed", + title: "shell", + kind: "shell", + rawOutput: "hi", + }), + ]); + + assert.equal(items.length, 1); + assert.equal(items[0].id, `tool:${baseEvent.channelId}:call-dupe`); + assert.equal(items[0].status, "completed"); + assert.equal(items[0].result, "hi"); +}); + +test("buildTranscript keeps a completed tool terminal when a late executing call arrives", () => { + const [item] = toolItems([ + acpToolUpdate(50, { + sessionUpdate: "tool_call_update", + toolCallId: "call-regression", + status: "completed", + title: "shell", + kind: "shell", + rawOutput: "done", + }), + acpToolUpdate(51, { + sessionUpdate: "tool_call", + toolCallId: "call-regression", + status: "executing", + title: "shell", + kind: "shell", + rawInput: { command: "echo done" }, + }), + ]); + + assert.equal(item.status, "completed"); + assert.equal(item.completedAt, baseEvent.timestamp); + assert.deepEqual(item.args, { command: "echo done" }); + assert.equal(item.result, "done"); +}); + +test("buildTranscript rebuilds out-of-order tool frames as one canonical row with retained ids", () => { + const [item] = toolItems([ + sessionUpdate( + 60, + { + sessionUpdate: "tool_call_update", + toolCallId: "call-out-of-order", + status: "completed", + title: "read_file", + kind: "read_file", + rawOutput: "file contents", + }, + { + channelId: "22222222-2222-2222-2222-222222222222", + sessionId: "sess-2", + turnId: "turn-2", + timestamp: "2026-06-18T00:00:05Z", + }, + ), + sessionUpdate( + 61, + { + sessionUpdate: "tool_call", + toolCallId: "call-out-of-order", + status: "executing", + title: "read_file", + kind: "read_file", + rawInput: { path: "AGENTS.md" }, + }, + { + channelId: "22222222-2222-2222-2222-222222222222", + sessionId: "sess-2", + turnId: "turn-2", + timestamp: "2026-06-18T00:00:04Z", + }, + ), + ]); + + assert.equal( + item.id, + "tool:22222222-2222-2222-2222-222222222222:call-out-of-order", + ); + assert.equal(item.status, "completed"); + assert.deepEqual(item.args, { path: "AGENTS.md" }); + assert.equal(item.channelId, "22222222-2222-2222-2222-222222222222"); + assert.equal(item.turnId, "turn-2"); + assert.equal(item.sessionId, "sess-2"); +}); + +test("buildTranscript coalesces assistant chunks until the message is sealed", () => { + const messages = buildTranscript([ + assistantChunk(70, "msg-1", "Hello "), + assistantChunk(71, "msg-1", "world"), + ]).filter((item) => item.type === "message" && item.role === "assistant"); + + assert.equal(messages.length, 1); + assert.equal(messages[0].text, "Hello world"); + assert.equal(messages[0].id, `assistant:${baseEvent.channelId}:msg-1`); +}); + +test("buildTranscript starts a continuation for same-message chunks after sealing", () => { + const messages = buildTranscript([ + assistantChunk(80, "msg-2", "First"), + acpToolUpdate(81, { + sessionUpdate: "tool_call", + toolCallId: "call-seal", + status: "executing", + title: "shell", + kind: "shell", + }), + assistantChunk(82, "msg-2", "Second"), + ]).filter((item) => item.type === "message" && item.role === "assistant"); + + assert.equal(messages.length, 2); + assert.equal(messages[0].text, "First"); + assert.equal(messages[1].text, "Second"); + assert.match(messages[1].id, /:c\d+$/); +}); + +test("buildTranscript preserves channel, turn, and session ids through message updates", () => { + const [message] = buildTranscript([ + assistantChunk(90, "msg-identity", "One ", { + channelId: "33333333-3333-3333-3333-333333333333", + sessionId: "sess-identity", + turnId: "turn-identity", + }), + assistantChunk(91, "msg-identity", "Two", { + channelId: "33333333-3333-3333-3333-333333333333", + sessionId: null, + turnId: null, + }), + ]).filter((item) => item.type === "message" && item.role === "assistant"); + + assert.equal(message.text, "One Two"); + assert.equal(message.channelId, "33333333-3333-3333-3333-333333333333"); + assert.equal(message.turnId, "turn-identity"); + assert.equal(message.sessionId, "sess-identity"); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index bc169d3af..22f0b9461 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -234,6 +234,18 @@ function upsertMetadata( }); } +function isTerminalToolStatus(status: ToolStatus) { + return status === "completed" || status === "failed"; +} + +function mergeToolStatus(existing: ToolStatus, next: ToolStatus): ToolStatus { + if (isTerminalToolStatus(existing) && !isTerminalToolStatus(next)) { + return existing; + } + + return next; +} + function upsertTool( d: TranscriptDraft, id: string, @@ -261,18 +273,18 @@ function upsertTool( } else if (!existing.buzzToolName && !isGenericToolTitle(toolName)) { updatedToolName = toolName; } + const mergedStatus = mergeToolStatus(existing.status, status); replaceItem(d, id, { ...existing, title: updatedTitle, toolName: updatedToolName, buzzToolName: updatedBuzzToolName, - status, + status: mergedStatus, args: Object.keys(args).length > 0 ? args : existing.args, result: result || existing.result, isError: isError || existing.isError, completedAt: - (status === "completed" || status === "failed") && - existing.completedAt == null + isTerminalToolStatus(mergedStatus) && existing.completedAt == null ? timestamp : existing.completedAt, channelId: ctx.channelId, diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs index a13736189..790951a9f 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs @@ -2,7 +2,6 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - buildTranscriptPresentation, getActivityHeadline, isMeaningfulItem, } from "./agentSessionTranscriptPresentation.ts"; @@ -80,69 +79,3 @@ test("isMeaningfulItem ignores lifecycle noise and metadata", () => { true, ); }); - -test("buildTranscriptPresentation marks running tools as active while working", () => { - const items = [ - makeMessage({ id: "msg:user", role: "user", text: "Please help" }), - makeTool({ id: "tool:running", status: "executing" }), - ]; - - const presentation = buildTranscriptPresentation(items, true); - - assert.equal(presentation.state, "tool_running"); - assert.equal(presentation.headline, "Send Message"); - assert.equal(presentation.counts.tools, 1); - assert.equal(presentation.counts.messages, 1); - assert.ok(presentation.activeItemIds.has("tool:running")); -}); - -test("buildTranscriptPresentation highlights assistant streaming while working", () => { - const items = [ - makeMessage({ id: "msg:assistant", role: "assistant", text: "Drafting" }), - ]; - - const presentation = buildTranscriptPresentation(items, true); - - assert.equal(presentation.state, "responding"); - assert.equal(presentation.headline, "Drafting"); - assert.ok(presentation.activeItemIds.has("msg:assistant")); -}); - -test("buildTranscriptPresentation surfaces lifecycle errors", () => { - const items = [ - makeTool({ - id: "tool:done", - status: "completed", - completedAt: "2026-06-14T19:00:05.000Z", - }), - { - id: "life:error", - type: "lifecycle", - title: "Turn error", - text: "timeout", - timestamp: "2026-06-14T19:00:06.000Z", - }, - ]; - - const presentation = buildTranscriptPresentation(items, false); - - assert.equal(presentation.state, "error"); - assert.equal(presentation.hasError, true); - assert.equal(presentation.headline, "Turn error"); -}); - -test("buildTranscriptPresentation returns idle state when not working", () => { - const items = [ - makeTool({ - id: "tool:done", - status: "completed", - completedAt: "2026-06-14T19:00:05.000Z", - }), - ]; - - const presentation = buildTranscriptPresentation(items, false); - - assert.equal(presentation.state, "idle"); - assert.equal(presentation.activeItemIds.size, 0); - assert.equal(presentation.headline, "Send Message"); -}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts index bb6fd4c9f..bf1d4988f 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts @@ -1,33 +1,6 @@ import { formatToolTitle } from "./agentSessionToolCatalog"; import type { TranscriptItem } from "./agentSessionTypes"; -export type TranscriptActivityCounts = { - tools: number; - toolErrors: number; - thoughts: number; - messages: number; - lifecycle: number; - metadata: number; -}; - -export type TranscriptActivityState = - | "idle" - | "responding" - | "thinking" - | "tool_running" - | "error"; - -export type TranscriptPresentation = { - headline: string; - state: TranscriptActivityState; - counts: TranscriptActivityCounts; - latestMeaningfulItem: TranscriptItem | null; - latestMeaningfulItemId: string | null; - activeItemIds: ReadonlySet; - lastUpdatedAt: string | null; - hasError: boolean; -}; - const LIFECYCLE_NOISE = new Set([ "turn started", "session ready", @@ -83,198 +56,3 @@ export function isMeaningfulItem(item: TranscriptItem): boolean { } return true; } - -function isToolRunning(item: Extract) { - return item.status === "executing" || item.status === "pending"; -} - -function isLifecycleError( - item: Extract, -) { - return item.title.toLowerCase().includes("error"); -} - -function countItems(items: TranscriptItem[]): TranscriptActivityCounts { - const counts: TranscriptActivityCounts = { - tools: 0, - toolErrors: 0, - thoughts: 0, - messages: 0, - lifecycle: 0, - metadata: 0, - }; - - for (const item of items) { - switch (item.type) { - case "tool": - counts.tools += 1; - if (item.isError || item.status === "failed") { - counts.toolErrors += 1; - } - break; - case "thought": - counts.thoughts += 1; - break; - case "message": - counts.messages += 1; - break; - case "lifecycle": - counts.lifecycle += 1; - break; - case "metadata": - counts.metadata += 1; - break; - } - } - - return counts; -} - -function findLatestMeaningfulItem( - items: TranscriptItem[], -): TranscriptItem | null { - for (let i = items.length - 1; i >= 0; i--) { - const item = items[i]; - if (isMeaningfulItem(item)) { - return item; - } - } - return null; -} - -function resolveActivityState( - latest: TranscriptItem | null, - hasError: boolean, - isWorking: boolean, -): TranscriptActivityState { - if (!isWorking) { - return hasError ? "error" : "idle"; - } - - if (hasError && latest?.type === "lifecycle" && isLifecycleError(latest)) { - return "error"; - } - - if (latest?.type === "tool" && isToolRunning(latest)) { - return "tool_running"; - } - - if (latest?.type === "thought") { - return "thinking"; - } - - if (latest?.type === "message" && latest.role === "assistant") { - return "responding"; - } - - if (latest?.type === "tool") { - return "tool_running"; - } - - return "idle"; -} - -function resolveHeadline( - latest: TranscriptItem | null, - state: TranscriptActivityState, - isWorking: boolean, -): string { - if (latest) { - const headline = getActivityHeadline(latest); - if (headline) { - return headline; - } - } - - if (isWorking) { - switch (state) { - case "tool_running": - return "Running a tool"; - case "thinking": - return "Thinking"; - case "responding": - return "Responding"; - case "error": - return "Encountered an error"; - default: - return "Working"; - } - } - - if (state === "error") { - return "Last turn ended with an error"; - } - - return "Waiting for activity"; -} - -function collectActiveItemIds( - items: TranscriptItem[], - isWorking: boolean, -): ReadonlySet { - if (!isWorking || items.length === 0) { - return new Set(); - } - - const active = new Set(); - - for (let i = items.length - 1; i >= 0; i--) { - const item = items[i]; - - if (item.type === "tool" && isToolRunning(item)) { - active.add(item.id); - break; - } - - if (item.type === "thought") { - active.add(item.id); - break; - } - - if (item.type === "message" && item.role === "assistant") { - active.add(item.id); - break; - } - } - - return active; -} - -function detectError(items: TranscriptItem[]): boolean { - for (let i = items.length - 1; i >= 0; i--) { - const item = items[i]; - if (!isMeaningfulItem(item)) { - continue; - } - if (item.type === "lifecycle" && isLifecycleError(item)) { - return true; - } - if (item.type === "tool" && (item.isError || item.status === "failed")) { - return true; - } - break; - } - return false; -} - -/** Derive presentation metadata for a transcript list. */ -export function buildTranscriptPresentation( - items: TranscriptItem[], - isWorking = false, -): TranscriptPresentation { - const latestMeaningfulItem = findLatestMeaningfulItem(items); - const hasError = detectError(items); - const state = resolveActivityState(latestMeaningfulItem, hasError, isWorking); - - return { - headline: resolveHeadline(latestMeaningfulItem, state, isWorking), - state, - counts: countItems(items), - latestMeaningfulItem, - latestMeaningfulItemId: latestMeaningfulItem?.id ?? null, - activeItemIds: collectActiveItemIds(items, isWorking), - lastUpdatedAt: - items.length > 0 ? (items[items.length - 1]?.timestamp ?? null) : null, - hasError, - }; -} From c2ed44239c4f6a763e7a4ef71d63ef3f73673818 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Mon, 29 Jun 2026 17:08:30 -0700 Subject: [PATCH 03/49] feat(desktop): rebuild agent activity feed taxonomy Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../agents/ui/AgentSessionToolItem.tsx | 2 +- .../agents/ui/AgentSessionTranscriptList.tsx | 65 ++ .../agents/ui/agentSessionToolClassifier.ts | 578 ++++++++++++++++++ .../ui/agentSessionToolSummary.test.mjs | 34 +- .../agents/ui/agentSessionToolSummary.ts | 562 ++--------------- .../agents/ui/agentSessionTranscript.test.mjs | 31 + .../agents/ui/agentSessionTranscript.ts | 97 ++- .../agentSessionTranscriptGrouping.test.mjs | 34 ++ .../ui/agentSessionTranscriptGrouping.ts | 82 ++- .../features/agents/ui/agentSessionTypes.ts | 44 ++ .../channels/ui/AgentSessionThreadPanel.tsx | 1 - 11 files changed, 1002 insertions(+), 528 deletions(-) create mode 100644 desktop/src/features/agents/ui/agentSessionToolClassifier.ts diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx index 773c9dd80..11a906a8b 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx @@ -73,7 +73,7 @@ export function ToolItem({ hasArgs={hasArgs} hasResult={hasResult} imagePreview={ - compactSummary.kind === "view_image" && isExpanded + compactSummary.thumbnailSrc != null && isExpanded ? { src: compactSummary.thumbnailSrc, title: compactSummary.preview, diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 3afea9ca7..ca580689a 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -186,6 +186,9 @@ function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) { if (segment.kind === "prompt") { return `turn:${turnId}:prompt`; } + if (segment.kind === "summary") { + return segment.summary.id; + } return segment.item.id; } @@ -214,6 +217,10 @@ function TranscriptTurnSegmentView({ return ; } + if (segment.kind === "summary") { + return ; + } + return ( ["summary"]; +}) { + return ( +
+ + {summary.label} + + + +
+ {summary.items.map((item) => ( +

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

+ ))} +
+
+ ); +} + function TurnPromptBlock({ context, profiles, @@ -491,6 +526,9 @@ function getTranscriptItemRowSpacing(item: TranscriptItem): string { if (item.type === "tool") { return "my-1"; } + if (item.type === "plan" || item.type === "lifecycle") { + return "my-1.5"; + } return "my-2"; } @@ -521,6 +559,9 @@ const TranscriptItemView = React.memo(function TranscriptItemView({ if (item.type === "thought") { return ; } + if (item.type === "plan") { + return ; + } if (item.type === "metadata") { return ; } @@ -643,6 +684,30 @@ function ThoughtItem({ ); } +function PlanItem({ + item, +}: { + item: Extract; +}) { + return ( +
+ + + {item.title} + + + +
+ +
+
+ ); +} + function MetadataItem({ item, }: { diff --git a/desktop/src/features/agents/ui/agentSessionToolClassifier.ts b/desktop/src/features/agents/ui/agentSessionToolClassifier.ts new file mode 100644 index 000000000..210449d23 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolClassifier.ts @@ -0,0 +1,578 @@ +import type { + AgentActivityDescriptor, + AgentActivityRenderClass, + AgentActivityTone, + TranscriptItem, +} from "./agentSessionTypes"; +import { + formatToolTitle, + getBuzzToolInfo, + normalizeToolNameText, +} from "./agentSessionToolCatalog"; +import { + asRecord, + getToolString, + getToolStringList, +} from "./agentSessionUtils"; + +type ToolItem = Extract; + +export type ToolClassificationInput = { + title: string; + toolName: string; + buzzToolName: string | null; + args: Record; + result: string; + isError: boolean; +}; + +type ToolClassifierProvider = ( + input: ToolClassificationInput, +) => AgentActivityDescriptor | null; + +const DEVELOPER_TOOL_BASES = new Set([ + "shell", + "read_file", + "view_image", + "str_replace", + "todo", + "stop", + "postcompact", +]); + +const BUZZ_CLI_GROUPS = new Set([ + "messages", + "channels", + "dms", + "reactions", + "canvas", + "feed", + "users", + "workflows", + "social", + "repos", + "upload", + "mem", + "notes", + "patches", + "pr", + "issues", + "emoji", + "pack", +]); + +const BUZZ_CLI_ADMIN_VERBS = new Set([ + "archive", + "unarchive", + "create", + "delete", + "remove", + "add-channel-member", + "remove-channel-member", + "set-channel-add-policy", +]); + +const BUZZ_CLI_READ_VERBS = new Set([ + "get", + "list", + "thread", + "search", + "members", + "runs", + "notes", +]); + +const TOOL_CLASS_LABELS: Record = { + message: "Message", + "relay-op": "Buzz relay op", + "file-edit": "File edit", + shell: "Shell command", + status: "Status", + thought: "Thought", + plan: "Plan", + permission: "Permission", + error: "Error", + generic: "Tool", + "raw-rail": "Raw event", + suppressed: "Suppressed", +}; + +const providers: ToolClassifierProvider[] = [ + classifyDeveloperHarnessTool, + classifyBuzzTool, + classifyGenericTool, +]; + +export function classifyTool( + input: ToolClassificationInput, +): AgentActivityDescriptor { + for (const provider of providers) { + const descriptor = provider(input); + if (descriptor) { + return input.isError || descriptor.renderClass === "error" + ? { + ...descriptor, + renderClass: "error", + label: descriptor.label.endsWith("failed") + ? descriptor.label + : `${descriptor.label} failed`, + } + : descriptor; + } + } + + return genericDescriptor(input); +} + +export function classifyToolItem(item: ToolItem): AgentActivityDescriptor { + return classifyTool({ + title: item.title, + toolName: item.toolName, + buzzToolName: item.buzzToolName, + args: item.args, + result: item.result, + isError: item.isError, + }); +} + +export function renderClassLabel(renderClass: AgentActivityRenderClass) { + return TOOL_CLASS_LABELS[renderClass]; +} + +function classifyDeveloperHarnessTool( + input: ToolClassificationInput, +): AgentActivityDescriptor | null { + const kind = resolveDeveloperToolKind(input); + if (!kind) return null; + + if (kind === "shell") { + const command = getToolString(input.args, ["command"]); + const buzzCli = command ? parseBuzzCliCommand(command) : null; + if (buzzCli) { + return buzzCli; + } + return { + renderClass: "shell", + label: "Ran command", + preview: command, + source: "harness", + groupKey: "shell:command", + }; + } + + if (kind === "read_file") { + const path = getToolString(input.args, ["path"]); + return { + renderClass: "generic", + label: "Read file", + preview: path, + source: "harness", + groupKey: "read_file", + }; + } + + if (kind === "view_image") { + const source = getToolString(input.args, ["source"]); + return { + renderClass: "generic", + label: "Viewed image", + preview: source ? basenameOrUrl(source) : null, + source: "harness", + groupKey: "view_image", + }; + } + + if (kind === "str_replace") { + const path = getToolString(input.args, ["path"]); + return { + renderClass: "file-edit", + label: "Edited file", + preview: path, + source: "harness", + groupKey: "file-edit:str_replace", + }; + } + + if (kind === "todo") { + return { + renderClass: "plan", + label: "Updated todos", + preview: getTodoPreview(input.args), + source: "harness", + groupKey: "plan:todo", + }; + } + + if (kind === "stop_hook") { + return { + renderClass: "suppressed", + label: "Checked todos", + preview: null, + source: "harness", + groupKey: "suppressed:stop-hook", + }; + } + + if (kind === "post_compact_hook") { + return { + renderClass: "status", + label: "Context compacted", + preview: null, + source: "harness", + groupKey: "status:post-compact", + }; + } + + return { + renderClass: "generic", + label: "Ran tool", + preview: genericPreview(input), + source: "harness", + groupKey: "generic:dev-mcp", + }; +} + +function classifyBuzzTool( + input: ToolClassificationInput, +): AgentActivityDescriptor | null { + const name = [input.buzzToolName, input.toolName, input.title].find( + (value) => value && getBuzzToolInfo(value), + ); + if (!name) return null; + + const info = getBuzzToolInfo(name); + if (!info) return null; + + const operation = normalizeToolNameText(name); + const label = formatToolTitle(name, input.title); + const preview = extractBuzzToolPreview(input.args); + return { + renderClass: isBuzzMessageSend(operation) ? "message" : "relay-op", + label, + preview, + tone: info.tone, + operation, + object: preview, + source: "mcp", + groupKey: `buzz:${operation}`, + }; +} + +function classifyGenericTool( + input: ToolClassificationInput, +): AgentActivityDescriptor { + return genericDescriptor(input); +} + +function genericDescriptor( + input: ToolClassificationInput, +): AgentActivityDescriptor { + return { + renderClass: "generic", + label: "Ran tool", + preview: genericPreview(input), + source: "fallback", + groupKey: `generic:${normalizeToolNameText(input.toolName || input.title)}`, + }; +} + +function resolveDeveloperToolKind( + input: ToolClassificationInput, +): + | "shell" + | "read_file" + | "view_image" + | "str_replace" + | "todo" + | "stop_hook" + | "post_compact_hook" + | "dev_mcp" + | null { + for (const value of [input.toolName, input.title, input.buzzToolName]) { + const kind = classifyDeveloperToolName(value); + if (kind) return kind; + } + return null; +} + +function classifyDeveloperToolName(value: string | null | undefined) { + if (!value) return null; + + const normalized = normalizeToolNameText(value); + const base = normalized.replace(/^buzz_dev_mcp_/, ""); + + if (base === "shell" || normalized.endsWith("_shell")) return "shell"; + if (base === "read_file" || normalized.endsWith("_read_file")) + return "read_file"; + if (base === "view_image" || normalized.endsWith("_view_image")) + return "view_image"; + if (base === "str_replace" || normalized.endsWith("_str_replace")) + return "str_replace"; + if (base === "todo") return "todo"; + if (base === "stop") return "stop_hook"; + if (base === "postcompact") return "post_compact_hook"; + if (DEVELOPER_TOOL_BASES.has(base) || normalized.includes("buzz_dev_mcp")) { + return "dev_mcp"; + } + return null; +} + +function parseBuzzCliCommand(command: string): AgentActivityDescriptor | null { + const tokens = tokenizeShellCommand(command); + const range = findBuzzCommand(tokens); + if (!range) return null; + + const group = tokens[range.groupIndex]; + const verb = tokens[range.verbIndex] ?? "run"; + const operation = `${group}.${verb}`; + const content = + group === "messages" && verb === "send" + ? extractBuzzCliSendMessageContent(tokens, range) + : null; + const preview = content ?? extractBuzzCliObjectPreview(tokens, range); + const tone = buzzCliTone(group, verb); + return { + renderClass: + group === "messages" && verb === "send" ? "message" : "relay-op", + label: titleForBuzzCli(group, verb), + preview, + tone, + operation, + object: preview, + source: "shell", + groupKey: `buzz-cli:${operation}`, + }; +} + +function titleForBuzzCli(group: string, verb: string) { + if (group === "messages" && verb === "send") return "Send Message"; + return [group, verb] + .map((part) => + part + .split(/[-_]+/) + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "), + ) + .filter(Boolean) + .join(" "); +} + +function buzzCliTone(group: string, verb: string): AgentActivityTone { + if (BUZZ_CLI_ADMIN_VERBS.has(verb)) return "admin"; + if (BUZZ_CLI_READ_VERBS.has(verb)) return "read"; + if (group === "feed" && verb === "get") return "read"; + return "write"; +} + +function extractBuzzCliSendMessageContent( + tokens: string[], + range: BuzzCommandRange, +): string | null { + const content = getFlagValue(tokens, range.verbIndex + 1, "--content"); + if (!content) return null; + if (content !== "-") return content; + return extractSimpleEchoPipeContent(tokens, range.buzzIndex) ?? null; +} + +function extractBuzzCliObjectPreview( + tokens: string[], + range: BuzzCommandRange, +): string | null { + const flagPreview = + getFlagValue(tokens, range.verbIndex + 1, "--channel") ?? + getFlagValue(tokens, range.verbIndex + 1, "--event") ?? + getFlagValue(tokens, range.verbIndex + 1, "--query") ?? + getFlagValue(tokens, range.verbIndex + 1, "--name") ?? + getFlagValue(tokens, range.verbIndex + 1, "--file"); + if (flagPreview) return flagPreview; + + const next = tokens[range.verbIndex + 1]; + return next && !isCommandSeparator(next) && !next.startsWith("-") + ? next + : null; +} + +type BuzzCommandRange = { + buzzIndex: number; + groupIndex: number; + verbIndex: number; +}; + +function findBuzzCommand(tokens: string[]): BuzzCommandRange | null { + for (let i = 0; i < tokens.length; i++) { + if (!isBuzzExecutable(tokens[i])) continue; + + for (let j = i + 1; j < tokens.length; j++) { + if (isCommandSeparator(tokens[j])) break; + if (tokens[j].startsWith("-")) { + if ( + !tokens[j].includes("=") && + tokens[j + 1]?.startsWith("-") === false + ) { + j += 1; + } + continue; + } + if (!BUZZ_CLI_GROUPS.has(tokens[j])) continue; + const verbIndex = j + 1; + if (!tokens[verbIndex] || isCommandSeparator(tokens[verbIndex])) { + return null; + } + return { buzzIndex: i, groupIndex: j, verbIndex }; + } + } + return null; +} + +export function tokenizeShellCommand(command: string): string[] { + const tokens: string[] = []; + let current = ""; + let quote: "'" | '"' | null = null; + let escaping = false; + + const pushCurrent = () => { + if (current.length > 0) { + tokens.push(current); + current = ""; + } + }; + + for (const char of command) { + if (escaping) { + current += char; + escaping = false; + continue; + } + if (char === "\\" && quote !== "'") { + escaping = true; + continue; + } + if (quote) { + if (char === quote) quote = null; + else current += char; + continue; + } + if (char === "'" || char === '"') { + quote = char; + continue; + } + if (/\s/.test(char)) { + pushCurrent(); + continue; + } + if (char === "|" || char === ";" || char === "&") { + pushCurrent(); + tokens.push(char); + continue; + } + current += char; + } + + if (escaping) current += "\\"; + pushCurrent(); + return tokens; +} + +function isBuzzExecutable(token: string) { + return token === "buzz" || token.split(/[\\/]/).pop() === "buzz"; +} + +function isCommandSeparator(token: string) { + return token === "|" || token === ";" || token === "&"; +} + +function getFlagValue(tokens: string[], start: number, flag: string) { + for (let i = start; i < tokens.length; i++) { + const token = tokens[i]; + if (isCommandSeparator(token)) return null; + if (token === flag) { + return tokens[i + 1] && !isCommandSeparator(tokens[i + 1]) + ? tokens[i + 1] + : null; + } + if (token.startsWith(`${flag}=`)) return token.slice(flag.length + 1); + } + return null; +} + +function extractSimpleEchoPipeContent( + tokens: string[], + buzzIndex: number, +): string | null { + const pipeIndex = tokens.lastIndexOf("|", buzzIndex); + if (pipeIndex <= 0) return null; + const echoStart = findSegmentStart(tokens, pipeIndex - 1); + const leftSegment = tokens.slice(echoStart, pipeIndex); + if (leftSegment[0] !== "echo") return null; + const contentTokens = leftSegment + .slice(1) + .filter((token) => !token.startsWith("-")); + return contentTokens.length > 0 ? contentTokens.join(" ") : null; +} + +function findSegmentStart(tokens: string[], beforeIndex: number) { + for (let i = beforeIndex; i >= 0; i--) { + if (isCommandSeparator(tokens[i])) return i + 1; + } + return 0; +} + +function extractBuzzToolPreview(args: Record): string | null { + const content = getToolString(args, ["content", "message", "text", "body"]); + if (content) return content; + const query = getToolString(args, ["query", "search"]); + if (query) return query; + const channelId = getToolString(args, ["channel_id", "channelId"]); + if (channelId) return channelId; + const workflowId = getToolString(args, ["workflow_id", "workflowId"]); + if (workflowId) return workflowId; + const pubkeys = getToolStringList(args, ["pubkeys", "pubkey"]); + if (pubkeys.length === 1) return pubkeys[0]; + if (pubkeys.length > 1) return `${pubkeys.length} users`; + return getToolString(args, ["event_id", "eventId", "name"]); +} + +function genericPreview(input: ToolClassificationInput): string | null { + return ( + getToolString(input.args, [ + "command", + "path", + "source", + "query", + "name", + "content", + "message", + ]) ?? (input.title ? input.title : null) + ); +} + +function isBuzzMessageSend(operation: string) { + return operation === "send_message" || operation === "messages_send"; +} + +function basenameOrUrl(source: string): string { + const trimmed = source.trim(); + if ( + trimmed.startsWith("data:image/") || + trimmed.startsWith("http://") || + trimmed.startsWith("https://") + ) { + return trimmed; + } + return trimmed.split(/[/\\]/).pop() ?? trimmed; +} + +function getTodoPreview(args: Record): string | null { + const todos = args.todos; + if (!Array.isArray(todos)) return "todo list"; + if (todos.length === 0) return "empty list"; + const first = todos[0]; + const firstText = + first && typeof first === "object" + ? getToolString(asRecord(first), ["text"]) + : null; + if (firstText) + return todos.length > 1 ? `${firstText} (+${todos.length - 1})` : firstText; + return `${todos.length} item${todos.length === 1 ? "" : "s"}`; +} diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs index fe25dc2ca..fdc230f23 100644 --- a/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs @@ -33,7 +33,7 @@ test("buildCompactToolSummary formats Buzz send_message preview", () => { }), ); - assert.equal(summary.kind, "buzz"); + assert.equal(summary.kind, "message"); assert.equal(summary.label, "Send Message"); assert.equal(summary.preview, "Hello team"); assert.equal(summary.presentation, "message"); @@ -50,7 +50,7 @@ test("buildCompactToolSummary treats buzz messages send commands as messages", ( }), ); - assert.equal(summary.kind, "shell"); + assert.equal(summary.kind, "message"); assert.equal(summary.label, "Send Message"); assert.equal(summary.preview, "@Ned are you working"); assert.equal(summary.presentation, "message"); @@ -155,3 +155,33 @@ test("buildCompactToolSummary uses running and failed labels", () => { "Edit failed", ); }); + +test("buildCompactToolSummary promotes non-send buzz CLI commands to relay ops", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "shell", + args: { + command: "buzz channels get --channel channel-1", + }, + }), + ); + + assert.equal(summary.kind, "relay-op"); + assert.equal(summary.label, "Channels Get"); + assert.equal(summary.preview, "channel-1"); + assert.equal(summary.presentation, "inline"); +}); + +test("buildCompactToolSummary promotes file edits and todos to first-class classes", () => { + assert.equal( + buildCompactToolSummary( + makeTool({ toolName: "str_replace", args: { path: "src/app.ts" } }), + ).kind, + "file-edit", + ); + assert.equal( + buildCompactToolSummary(makeTool({ toolName: "todo", args: { todos: [] } })) + .kind, + "plan", + ); +}); diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.ts b/desktop/src/features/agents/ui/agentSessionToolSummary.ts index 1513e1017..3592758e7 100644 --- a/desktop/src/features/agents/ui/agentSessionToolSummary.ts +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.ts @@ -1,27 +1,21 @@ import type { ToolStatus, TranscriptItem } from "./agentSessionTypes"; -import { - formatToolTitle, - getBuzzToolInfo, - isGenericToolTitle, - normalizeToolNameText, -} from "./agentSessionToolCatalog"; -import { - asRecord, - getToolString, - getToolStringList, -} from "./agentSessionUtils"; +import type { AgentActivityDescriptor } from "./agentSessionTypes"; +import { getToolString } from "./agentSessionUtils"; +import { classifyToolItem } from "./agentSessionToolClassifier"; export type CompactToolKind = + | "message" + | "relay-op" + | "file-edit" | "shell" - | "read_file" - | "view_image" - | "str_replace" - | "todo" - | "stop_hook" - | "post_compact_hook" - | "dev_mcp" - | "buzz" - | "generic"; + | "status" + | "thought" + | "plan" + | "permission" + | "error" + | "generic" + | "raw-rail" + | "suppressed"; export type CompactToolSummary = { kind: CompactToolKind; @@ -30,521 +24,63 @@ export type CompactToolSummary = { /** When set, the compact row renders a tiny image instead of text preview. */ thumbnailSrc: string | null; presentation: "inline" | "message"; + descriptor: AgentActivityDescriptor; }; -const DEVELOPER_TOOL_BASES = new Set([ - "shell", - "read_file", - "view_image", - "str_replace", - "todo", - "stop", - "postcompact", -]); - type ToolItem = Extract; /** Build the muted compact summary label and preview for any tool row. */ export function buildCompactToolSummary(item: ToolItem): CompactToolSummary { - const kind = resolveCompactToolKind(item); - const messageSendPreview = extractMessageSendPreview(item, kind); - if (messageSendPreview !== undefined) { - return { - kind, - label: compactMessageSendLabel(item.status, item.isError), - preview: messageSendPreview, - thumbnailSrc: null, - presentation: "message", - }; - } - - const { preview, thumbnailSrc } = extractCompactToolPreview(item, kind); + const descriptor = item.descriptor ?? classifyToolItem(item); + const thumbnailSrc = getThumbnailSrc(item, descriptor); + const failed = item.isError || item.status === "failed"; + const running = item.status === "executing" || item.status === "pending"; return { - kind, - label: compactToolLabel(kind, item, item.status, item.isError), - preview, + kind: descriptor.renderClass, + label: labelForStatus(descriptor, item.status, failed, running), + preview: descriptor.preview, thumbnailSrc, - presentation: "inline", + presentation: descriptor.renderClass === "message" ? "message" : "inline", + descriptor, }; } -function resolveCompactToolKind(item: ToolItem): CompactToolKind { - const developerKind = resolveDeveloperToolKind(item); - if (developerKind) { - return developerKind; - } - - for (const value of [item.buzzToolName, item.toolName, item.title]) { - if (value && getBuzzToolInfo(value)) { - return "buzz"; - } - } - - return "generic"; -} - -function resolveDeveloperToolKind(item: ToolItem): CompactToolKind | null { - for (const value of [item.toolName, item.title, item.buzzToolName]) { - const kind = classifyDeveloperToolName(value); - if (kind) return kind; - } - return null; -} - -function classifyDeveloperToolName( - value: string | null | undefined, -): CompactToolKind | null { - if (!value) return null; - - const normalized = normalizeToolNameText(value); - const base = stripMcpServerPrefix(normalized); - - if (base === "shell" || normalized.endsWith("_shell")) { - return "shell"; - } - if (base === "read_file") return "read_file"; - if (base === "view_image") return "view_image"; - if (base === "str_replace") return "str_replace"; - if (base === "todo") return "todo"; - if (base === "stop") return "stop_hook"; - if (base === "postcompact") return "post_compact_hook"; - - if (DEVELOPER_TOOL_BASES.has(base)) { - return "dev_mcp"; - } - - if (normalized.includes("buzz_dev_mcp")) { - return "dev_mcp"; - } - - return null; -} - -function stripMcpServerPrefix(normalized: string): string { - return normalized.replace(/^buzz_dev_mcp_/, ""); -} - -function compactToolLabel( - kind: CompactToolKind, - item: ToolItem, +function labelForStatus( + descriptor: AgentActivityDescriptor, status: ToolStatus, - isError: boolean, -): string { - const failed = isError || status === "failed"; - const running = status === "executing" || status === "pending"; - - if (kind === "buzz") { - const title = formatToolTitle( - item.buzzToolName ?? item.toolName, - item.title, - ); - if (failed) return `${title} failed`; - if (running) return title; - return title; - } - - const labels: Record< - Exclude, - { completed: string; running: string; failed: string } - > = { - generic: { - completed: "Ran tool", - running: "Running tool", - failed: "Tool failed", - }, - ...developerToolLabels(), - }; - - const set = labels[kind]; - if (failed) return set.failed; - if (running) return set.running; - return set.completed; -} - -function developerToolLabels(): Record< - Exclude, - { completed: string; running: string; failed: string } -> { - return { - shell: { - completed: "Ran command", - running: "Running command", - failed: "Command failed", - }, - read_file: { - completed: "Read file", - running: "Reading file", - failed: "Read failed", - }, - view_image: { - completed: "Viewed image", - running: "Viewing image", - failed: "View failed", - }, - str_replace: { - completed: "Edited file", - running: "Editing file", - failed: "Edit failed", - }, - todo: { - completed: "Updated todos", - running: "Updating todos", - failed: "Todo update failed", - }, - stop_hook: { - completed: "Checked todos", - running: "Checking todos", - failed: "Todo check failed", - }, - post_compact_hook: { - completed: "Synced todos", - running: "Syncing todos", - failed: "Todo sync failed", - }, - dev_mcp: { - completed: "Ran tool", - running: "Running tool", - failed: "Tool failed", - }, - }; -} - -function compactMessageSendLabel(status: ToolStatus, isError: boolean) { - if (isError || status === "failed") { - return "Send Message failed"; + failed: boolean, + running: boolean, +) { + const label = descriptor.label; + if (descriptor.groupKey === "file-edit:str_replace") { + if (failed) return "Edit failed"; + if (running) return "Editing file"; + return "Edited file"; } - return "Send Message"; -} - -type CompactToolPreview = { - preview: string | null; - thumbnailSrc: string | null; -}; - -function extractCompactToolPreview( - item: ToolItem, - kind: CompactToolKind, -): CompactToolPreview { - const args = item.args; - - switch (kind) { - case "shell": - return textPreview(getToolString(args, ["command"])); - case "read_file": - case "str_replace": - return textPreview(getToolString(args, ["path"])); - case "view_image": - return getViewImagePreview(getToolString(args, ["source"])); - case "todo": - return textPreview(getTodoPreview(args)); - case "stop_hook": - case "post_compact_hook": - return emptyPreview(); - case "dev_mcp": - case "generic": - return textPreview( - getToolString(args, [ - "command", - "path", - "source", - "query", - "name", - "content", - "message", - ]) ?? - (item.title && !isGenericToolTitle(item.title) ? item.title : null), - ); - case "buzz": - return textPreview(extractBuzzToolPreview(args)); + if (failed) { + return label.endsWith("failed") ? label : `${label} failed`; } + if (running) return label; + if (status === "completed") return label; + return label; } -function extractBuzzToolPreview(args: Record): string | null { - const content = getToolString(args, ["content", "message", "text", "body"]); - if (content) { - return content; - } - - const query = getToolString(args, ["query", "search"]); - if (query) { - return query; - } - - const channelId = getToolString(args, ["channel_id", "channelId"]); - if (channelId) { - return channelId; - } - - const workflowId = getToolString(args, ["workflow_id", "workflowId"]); - if (workflowId) { - return workflowId; - } - - const pubkeys = getToolStringList(args, ["pubkeys", "pubkey"]); - if (pubkeys.length === 1) { - return pubkeys[0]; - } - if (pubkeys.length > 1) { - return `${pubkeys.length} users`; - } - - return getToolString(args, ["event_id", "eventId", "name"]); -} - -function extractMessageSendPreview( +function getThumbnailSrc( item: ToolItem, - kind: CompactToolKind, -): string | null | undefined { - if (isBuzzSendMessageTool(item)) { - return extractBuzzToolMessageContent(item.args); - } - - if (kind !== "shell") { - return undefined; - } - - const command = getToolString(item.args, ["command"]); - return command ? extractBuzzCliSendMessageContent(command) : undefined; -} - -function isBuzzSendMessageTool(item: ToolItem) { - return [item.buzzToolName, item.toolName, item.title].some((value) => { - if (!value) return false; - return normalizeToolNameText(value) === "send_message"; - }); -} - -function extractBuzzToolMessageContent( - args: Record, + descriptor: AgentActivityDescriptor, ): string | null { - return getToolString(args, ["content", "message", "text", "body"]); -} - -function extractBuzzCliSendMessageContent( - command: string, -): string | null | undefined { - const tokens = tokenizeShellCommand(command); - const commandRange = findBuzzMessagesSendCommand(tokens); - if (!commandRange) { - return undefined; - } - - const content = getFlagValue(tokens, commandRange.sendIndex + 1, "--content"); - if (!content) { + const operation = + descriptor.operation ?? descriptor.groupKey ?? item.toolName; + if (!operation.includes("view_image") && item.toolName !== "view_image") { return null; } - if (content !== "-") { - return content; - } - - const pipedContent = extractSimpleEchoPipeContent( - tokens, - commandRange.buzzIndex, - ); - return pipedContent ?? null; -} - -function tokenizeShellCommand(command: string): string[] { - const tokens: string[] = []; - let current = ""; - let quote: "'" | '"' | null = null; - let escaping = false; - - const pushCurrent = () => { - if (current.length > 0) { - tokens.push(current); - current = ""; - } - }; - - for (const char of command) { - if (escaping) { - current += char; - escaping = false; - continue; - } - - if (char === "\\" && quote !== "'") { - escaping = true; - continue; - } - - if (quote) { - if (char === quote) { - quote = null; - } else { - current += char; - } - continue; - } - - if (char === "'" || char === '"') { - quote = char; - continue; - } - - if (/\s/.test(char)) { - pushCurrent(); - continue; - } - - if (char === "|" || char === ";" || char === "&") { - pushCurrent(); - tokens.push(char); - continue; - } - - current += char; - } - - if (escaping) { - current += "\\"; - } - pushCurrent(); - return tokens; -} - -function findBuzzMessagesSendCommand( - tokens: string[], -): { buzzIndex: number; sendIndex: number } | null { - for (let i = 0; i < tokens.length; i++) { - if (!isBuzzExecutable(tokens[i])) { - continue; - } - - const messagesIndex = tokens.indexOf("messages", i + 1); - if (messagesIndex === -1) { - continue; - } - if ( - messagesIndex > i && - hasCommandSeparator(tokens, i + 1, messagesIndex) - ) { - continue; - } - if (tokens[messagesIndex + 1] === "send") { - return { buzzIndex: i, sendIndex: messagesIndex + 1 }; - } - } - - return null; -} - -function isBuzzExecutable(token: string) { - return token === "buzz" || token.split(/[\\/]/).pop() === "buzz"; -} - -function hasCommandSeparator(tokens: string[], start: number, end: number) { - for (let i = start; i < end; i++) { - if (isCommandSeparator(tokens[i])) { - return true; - } - } - return false; -} - -function isCommandSeparator(token: string) { - return token === "|" || token === ";" || token === "&"; -} - -function getFlagValue(tokens: string[], start: number, flag: string) { - for (let i = start; i < tokens.length; i++) { - const token = tokens[i]; - if (isCommandSeparator(token)) { - return null; - } - if (token === flag) { - return tokens[i + 1] && !isCommandSeparator(tokens[i + 1]) - ? tokens[i + 1] - : null; - } - if (token.startsWith(`${flag}=`)) { - return token.slice(flag.length + 1); - } - } - return null; -} - -function extractSimpleEchoPipeContent( - tokens: string[], - buzzIndex: number, -): string | null { - const pipeIndex = tokens.lastIndexOf("|", buzzIndex); - if (pipeIndex <= 0) { - return null; - } - - const echoStart = findSegmentStart(tokens, pipeIndex - 1); - const leftSegment = tokens.slice(echoStart, pipeIndex); - if (leftSegment[0] !== "echo") { - return null; - } - - const contentTokens = leftSegment - .slice(1) - .filter((token) => !token.startsWith("-")); - return contentTokens.length > 0 ? contentTokens.join(" ") : null; -} - -function findSegmentStart(tokens: string[], beforeIndex: number) { - for (let i = beforeIndex; i >= 0; i--) { - if (isCommandSeparator(tokens[i])) { - return i + 1; - } - } - return 0; -} - -function textPreview(preview: string | null): CompactToolPreview { - return { preview, thumbnailSrc: null }; -} - -function emptyPreview(): CompactToolPreview { - return { preview: null, thumbnailSrc: null }; -} - -function getViewImagePreview(source: string | null): CompactToolPreview { - if (!source) { - return emptyPreview(); - } + const source = getToolString(item.args, ["source"]); + if (!source) return null; const trimmed = source.trim(); - if ( - trimmed.startsWith("data:image/") || + return trimmed.startsWith("data:image/") || trimmed.startsWith("http://") || trimmed.startsWith("https://") - ) { - return { - preview: trimmed, - thumbnailSrc: trimmed, - }; - } - - const basename = trimmed.split(/[/\\]/).pop() ?? trimmed; - return { - preview: basename, - thumbnailSrc: null, - }; -} - -function getTodoPreview(args: Record): string | null { - const todos = args.todos; - if (!Array.isArray(todos)) { - return "todo list"; - } - if (todos.length === 0) { - return "empty list"; - } - - const first = todos[0]; - const firstText = - first && typeof first === "object" - ? getToolString(asRecord(first), ["text"]) - : null; - - if (firstText) { - return todos.length > 1 ? `${firstText} (+${todos.length - 1})` : firstText; - } - - return `${todos.length} item${todos.length === 1 ? "" : "s"}`; + ? trimmed + : null; } diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index d61529305..e38e8fd22 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -381,3 +381,34 @@ test("buildTranscript preserves channel, turn, and session ids through message u assert.equal(message.turnId, "turn-identity"); assert.equal(message.sessionId, "sess-identity"); }); + +test("buildTranscript promotes ACP plan updates to first-class plan items", () => { + const items = buildTranscript([ + sessionUpdate(90, { + sessionUpdate: "plan", + content: { type: "text", text: "- [ ] Build registry" }, + }), + ]); + + assert.equal(items.length, 1); + assert.equal(items[0].type, "plan"); + assert.equal(items[0].renderClass, "plan"); + assert.match(items[0].text, /Build registry/); +}); + +test("buildTranscript stores first-class render class descriptors for tool items", () => { + const [item] = toolItems([ + acpToolUpdate(91, { + sessionUpdate: "tool_call", + toolCallId: "call-edit", + status: "completed", + title: "str_replace", + kind: "str_replace", + rawInput: { path: "src/app.ts" }, + }), + ]); + + assert.equal(item.renderClass, "file-edit"); + assert.equal(item.descriptor.label, "Edited file"); + assert.equal(item.descriptor.preview, "src/app.ts"); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index 22f0b9461..f360f34a6 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -9,6 +9,7 @@ import { isGenericToolTitle, normalizeToolStatus, } from "./agentSessionToolCatalog"; +import { classifyTool } from "./agentSessionToolClassifier"; import { asRecord, asString } from "./agentSessionUtils"; import { describeTurnStarted, @@ -149,6 +150,7 @@ function upsertMessage( pushItem(d, { id: newKey, type: "message", + renderClass: "message", role, title, text, @@ -186,9 +188,62 @@ function upsertTextItem( return; } sealOpenMessages(d); + if (type === "thought") { + pushItem(d, { + id, + type: "thought", + renderClass: "thought", + title, + text, + timestamp, + channelId: ctx.channelId, + turnId: ctx.turnId, + sessionId: ctx.sessionId, + acpSource, + }); + return; + } + + pushItem(d, { + id, + type: "lifecycle", + renderClass: title.toLowerCase().includes("error") ? "error" : "status", + title, + text, + timestamp, + channelId: ctx.channelId, + turnId: ctx.turnId, + sessionId: ctx.sessionId, + acpSource, + }); +} + +function upsertPlan( + d: TranscriptDraft, + id: string, + title: string, + text: string, + timestamp: string, + ctx: TranscriptItemContext, + acpSource?: string, +) { + const existing = d.itemsById.get(id); + if (existing?.type === "plan") { + replaceItem(d, id, { + ...existing, + text, + channelId: ctx.channelId, + turnId: ctx.turnId ?? existing.turnId, + sessionId: ctx.sessionId ?? existing.sessionId, + acpSource: acpSource ?? existing.acpSource, + }); + return; + } + sealOpenMessages(d); pushItem(d, { id, - type, + type: "plan", + renderClass: "plan", title, text, timestamp, @@ -224,6 +279,7 @@ function upsertMetadata( pushItem(d, { id, type: "metadata", + renderClass: "raw-rail", title, sections, timestamp, @@ -274,15 +330,28 @@ function upsertTool( updatedToolName = toolName; } const mergedStatus = mergeToolStatus(existing.status, status); + const updatedArgs = Object.keys(args).length > 0 ? args : existing.args; + const updatedResult = result || existing.result; + const updatedIsError = isError || existing.isError; + const descriptor = classifyTool({ + title: updatedTitle, + toolName: updatedToolName, + buzzToolName: updatedBuzzToolName, + args: updatedArgs, + result: updatedResult, + isError: updatedIsError || mergedStatus === "failed", + }); replaceItem(d, id, { ...existing, + renderClass: descriptor.renderClass, + descriptor, title: updatedTitle, toolName: updatedToolName, buzzToolName: updatedBuzzToolName, status: mergedStatus, - args: Object.keys(args).length > 0 ? args : existing.args, - result: result || existing.result, - isError: isError || existing.isError, + args: updatedArgs, + result: updatedResult, + isError: updatedIsError, completedAt: isTerminalToolStatus(mergedStatus) && existing.completedAt == null ? timestamp @@ -294,12 +363,23 @@ function upsertTool( }); return; } + const resolvedToolName = canonicalBuzzToolName ?? toolName; + const descriptor = classifyTool({ + title, + toolName: resolvedToolName, + buzzToolName: canonicalBuzzToolName, + args, + result, + isError: isError || status === "failed", + }); sealOpenMessages(d); pushItem(d, { id, type: "tool", + renderClass: descriptor.renderClass, + descriptor, title, - toolName: canonicalBuzzToolName ?? toolName, + toolName: resolvedToolName, buzzToolName: canonicalBuzzToolName, status, args, @@ -451,7 +531,7 @@ export function processTranscriptEvent( parsedPrompt.userTitle, parsedPrompt.userText, event.timestamp, - channelId, + ctx, parsedPrompt.userPubkey, ); } @@ -462,7 +542,7 @@ export function processTranscriptEvent( "Prompt context", parsedPrompt.sections, event.timestamp, - channelId, + ctx, ); } } @@ -551,10 +631,9 @@ export function processTranscriptEvent( updateType, ); } else if (updateType === "plan") { - upsertTextItem( + upsertPlan( d, `plan:${ch}:${turnKey}`, - "thought", "Plan", extractContentText(update.content) || JSON.stringify(update, null, 2), event.timestamp, diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs index 7639a97c6..c59a0a214 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs @@ -211,3 +211,37 @@ test("buildTranscriptDisplayBlocks passes through items without turnId", () => { assert.equal(blocks[0]?.kind, "single"); assert.equal(blocks[0]?.item.id, "orphan"); }); + +test("buildTranscriptDisplayBlocks groups same-kind tool runs within a turn", () => { + const items = [1, 2, 3].map((index) => ({ + id: `tool:${index}`, + type: "tool", + renderClass: "generic", + descriptor: { + renderClass: "generic", + label: "Read file", + preview: `file-${index}.ts`, + groupKey: "read_file", + }, + title: "read_file", + toolName: "read_file", + buzzToolName: null, + status: "completed", + args: { path: `file-${index}.ts` }, + result: "", + isError: false, + timestamp: "2026-06-18T00:00:00Z", + startedAt: "2026-06-18T00:00:00Z", + completedAt: "2026-06-18T00:00:01Z", + turnId: "turn-1", + sessionId: "sess-1", + channelId: "chan-1", + })); + + const [block] = buildTranscriptDisplayBlocks(items); + + assert.equal(block.kind, "turn"); + assert.equal(block.segments.length, 1); + assert.equal(block.segments[0].kind, "summary"); + assert.equal(block.segments[0].summary.label, "Read 3 files"); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts index fd2877f27..537addaeb 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts @@ -1,7 +1,9 @@ import type { TranscriptItem } from "./agentSessionTypes"; +import { classifyToolItem } from "./agentSessionToolClassifier"; export type TranscriptTurnSegment = | { kind: "item"; item: TranscriptItem } + | { kind: "summary"; summary: TranscriptSameKindSummary } | { kind: "setup"; items: Extract[] } | { kind: "prompt"; @@ -14,6 +16,14 @@ export type TranscriptDisplayBlock = | { kind: "single"; item: TranscriptItem } | { kind: "turn"; turnId: string; segments: TranscriptTurnSegment[] }; +export type TranscriptSameKindSummary = { + id: string; + label: string; + count: number; + items: TranscriptItem[]; + timestamp: string; +}; + function isUserPrompt( item: TranscriptItem, ): item is Extract { @@ -67,7 +77,9 @@ function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] { const activity = items.filter((item) => !consumed.has(item)); if (!userPrompt) { - return activity.map((item) => ({ kind: "item", item })); + return groupSameKindSegments( + activity.map((item) => ({ kind: "item", item })), + ); } const segments: TranscriptTurnSegment[] = [ @@ -87,7 +99,71 @@ function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] { segments.push({ kind: "item", item }); } - return segments; + return groupSameKindSegments(segments); +} + +function groupSameKindSegments( + segments: TranscriptTurnSegment[], +): TranscriptTurnSegment[] { + const grouped: TranscriptTurnSegment[] = []; + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + if (segment.kind !== "item") { + grouped.push(segment); + continue; + } + const key = sameKindKey(segment.item); + if (!key) { + grouped.push(segment); + continue; + } + const run = [segment.item]; + let j = i + 1; + while (j < segments.length) { + const next = segments[j]; + if (next.kind !== "item" || sameKindKey(next.item) !== key) break; + run.push(next.item); + j += 1; + } + if (run.length >= 3) { + grouped.push({ + kind: "summary", + summary: { + id: `summary:${key}:${run[0].id}`, + label: sameKindLabel(run[0], run.length), + count: run.length, + items: run, + timestamp: run[0].timestamp, + }, + }); + i = j - 1; + } else { + grouped.push(...run.map((item) => ({ kind: "item" as const, item }))); + i = j - 1; + } + } + return grouped; +} + +function sameKindKey(item: TranscriptItem): string | null { + if (item.type !== "tool") return null; + const descriptor = item.descriptor ?? classifyToolItem(item); + const renderClass = item.renderClass ?? descriptor.renderClass; + if (renderClass === "message" || renderClass === "file-edit") { + return null; + } + return descriptor.groupKey ?? renderClass; +} + +function sameKindLabel(item: TranscriptItem, count: number): string { + if (item.type !== "tool") return `${count} items`; + const descriptor = item.descriptor ?? classifyToolItem(item); + const renderClass = item.renderClass ?? descriptor.renderClass; + const label = descriptor.label; + if (label === "Read file") return `Read ${count} files`; + if (label === "Ran command") return `Ran ${count} commands`; + if (renderClass === "relay-op") return `${count} Buzz relay ops`; + return `${label} ×${count}`; } /** @@ -165,6 +241,8 @@ export function flattenDisplayBlocks( if (segment.context) { result.push(segment.context); } + } else if (segment.kind === "summary") { + result.push(...segment.summary.items); } else { result.push(...segment.items); } diff --git a/desktop/src/features/agents/ui/agentSessionTypes.ts b/desktop/src/features/agents/ui/agentSessionTypes.ts index c4061bb1d..4fcdc7c1e 100644 --- a/desktop/src/features/agents/ui/agentSessionTypes.ts +++ b/desktop/src/features/agents/ui/agentSessionTypes.ts @@ -20,6 +20,34 @@ export type ConnectionState = export type ToolStatus = "executing" | "completed" | "failed" | "pending"; +export type AgentActivityRenderClass = + | "message" + | "relay-op" + | "file-edit" + | "shell" + | "status" + | "thought" + | "plan" + | "permission" + | "error" + | "generic" + | "raw-rail" + | "suppressed"; + +export type AgentActivityTone = "read" | "write" | "admin" | "neutral"; + +export type AgentActivityDescriptor = { + renderClass: AgentActivityRenderClass; + label: string; + preview: string | null; + tone?: AgentActivityTone; + operation?: string; + object?: string | null; + source?: "mcp" | "shell" | "acp" | "harness" | "fallback"; + groupKey?: string; + reason?: string; +}; + /** Observer/ACP wire label for dev-only transcript debugging. */ export type TranscriptAcpSource = string; @@ -34,6 +62,7 @@ export type TranscriptItem = | ({ id: string; type: "message"; + renderClass: "message"; role: "assistant" | "user"; title: string; text: string; @@ -44,6 +73,16 @@ export type TranscriptItem = | ({ id: string; type: "thought"; + renderClass: "thought"; + title: string; + text: string; + timestamp: string; + acpSource?: TranscriptAcpSource; + } & TranscriptItemIdentity) + | ({ + id: string; + type: "plan"; + renderClass: "plan"; title: string; text: string; timestamp: string; @@ -52,14 +91,17 @@ export type TranscriptItem = | ({ id: string; type: "lifecycle"; + renderClass: "status" | "permission" | "error"; title: string; text: string; timestamp: string; + descriptor?: AgentActivityDescriptor; acpSource?: TranscriptAcpSource; } & TranscriptItemIdentity) | ({ id: string; type: "metadata"; + renderClass: "raw-rail"; title: string; sections: PromptSection[]; timestamp: string; @@ -68,6 +110,8 @@ export type TranscriptItem = | ({ id: string; type: "tool"; + renderClass: AgentActivityRenderClass; + descriptor: AgentActivityDescriptor; title: string; toolName: string; buzzToolName: string | null; diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index b9fa2a294..d9649f355 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -230,7 +230,6 @@ 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.` } - isWorking={isWorking} profiles={profiles} rawLayout="exclusive" showHeader={false} From d7d96351db1b93552aebd9f428f21f7a099952e6 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Mon, 29 Jun 2026 17:33:33 -0700 Subject: [PATCH 04/49] feat(desktop): move raw feed toggle into activity settings Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../channels/ui/AgentSessionThreadPanel.tsx | 63 ++++++++++--------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index d9649f355..8e5f2b106 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -21,11 +21,12 @@ import { Button } from "@/shared/ui/button"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { DropdownMenu, + DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; -import { Switch } from "@/shared/ui/switch"; import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions"; type AgentSessionThreadPanelProps = { @@ -97,34 +98,6 @@ export function AgentSessionThreadPanel({ const agentHeaderActions = ( {isLive ? ( -
- - -
- ) : null} - {isLive && isWorking ? ( + + ); +} + +function PromptContextDialog({ + context, + onOpenChange, + open, + setup, +}: { + context: Extract | null; + onOpenChange: (open: boolean) => void; + open: boolean; + setup: Extract[]; +}) { + if (!open || !context || context.sections.length === 0) { return null; } + const setupText = formatPromptSetupSummary(setup); + return ( -

- {setupText} -

+ + +
+ + Prompt context + {setupText ? ( + {setupText} + ) : null} + + +
+ +
+
+
+
); } +function formatPromptSetupSummary( + items: Extract[], +) { + const label = formatTurnSetupLabel(items); + const detail = turnSetupDetail(items); + return [label, detail].filter(Boolean).join(" · "); +} + function TurnSetupFooter({ context = null, contextOpen = false, From e76cbcda29024f1e9966b3b8ae67e75794c4e463 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 29 Jun 2026 23:55:24 -0700 Subject: [PATCH 31/49] feat(desktop): link sent activity messages - Add sent-message link extraction for completed activity tool rows using channel identity plus returned message event ids. - Route clickable sent-message footer timestamps through the existing in-app channel navigation instead of adding a separate action. - Replace custom transcript timestamp tooltips with native title attributes and update debug fixture message ids to exercise the openable state. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../agents/ui/AgentSessionToolItem.tsx | 112 +++++++++++++++++- .../TranscriptTimestamp.tsx | 15 +-- .../agents/ui/debugAgentActivityFixture.ts | 6 +- 3 files changed, 121 insertions(+), 12 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx index a9f114a12..0ab73d153 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx @@ -2,6 +2,8 @@ import * as React from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; import { ChevronDown, Send } from "lucide-react"; +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { buildMessageLink } from "@/features/messages/lib/messageLink"; import { cn } from "@/shared/lib/cn"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import type { AgentActivityAction, TranscriptItem } from "./agentSessionTypes"; @@ -18,9 +20,12 @@ import { type ActivityRowLabelParts, } from "./activityRenderClasses/ActivityRow"; import { + asRecord, formatCodeValue, + getToolString, getToolDurationDisplay, isInlineImageData, + parseToolResultValue, } from "./agentSessionUtils"; export function ToolItem({ @@ -35,6 +40,19 @@ export function ToolItem({ const buzzTool = getBuzzToolInfo(canonicalToolName); const compactSummary = buildCompactToolSummary(item); const duration = getToolDurationDisplay(item); + const messageLink = getSentMessageLink(item); + const { goChannel } = useAppNavigation(); + const openSentMessage = React.useCallback( + (event: React.MouseEvent) => { + if (!messageLink) return; + event.preventDefault(); + event.stopPropagation(); + void goChannel(messageLink.channelId, { + messageId: messageLink.messageId, + }); + }, + [goChannel, messageLink], + ); const handleToggle = React.useCallback( (event: React.SyntheticEvent) => { setIsExpanded(event.currentTarget.open); @@ -62,6 +80,8 @@ export function ToolItem({ duration={duration} isError={item.isError || item.status === "failed"} label={compactSummary.label} + messageLink={messageLink} + onOpenMessage={openSentMessage} preview={compactSummary.preview} /> ) : ( @@ -233,11 +253,15 @@ function CompactMessageSummary({ duration, isError, label, + messageLink, + onOpenMessage, preview, }: { duration: string | null; isError: boolean; label: string; + messageLink: SentMessageLink | null; + onOpenMessage: (event: React.MouseEvent) => void; preview: string | null; }) { const mutedTone = compactSummaryTone(); @@ -262,7 +286,21 @@ function CompactMessageSummary({ {label}
{duration ? ( - {duration} + messageLink ? ( + + {duration} + + ) : ( + + {duration} + + ) ) : null} , +): SentMessageLink | null { + if (item.status !== "completed" || item.isError) { + return null; + } + + if (item.descriptor?.renderClass !== "message") { + return null; + } + + const channelId = + item.channelId ?? getToolString(item.args, ["channel_id", "channelId"]); + if (!channelId) { + return null; + } + + const resultRecord = getMessageSendResultRecord(item.result); + if (!resultRecord || resultRecord.accepted === false) { + return null; + } + + const messageId = getToolString(resultRecord, [ + "event_id", + "eventId", + "message_id", + "messageId", + ]); + if (!messageId) { + return null; + } + + return { + channelId, + href: buildMessageLink({ channelId, messageId }), + messageId, + }; +} + +function getMessageSendResultRecord( + result: string, +): Record | null { + const parsed = parseToolResultValue(result); + const directRecord = asRecord(parsed); + if (getMessageEventId(directRecord)) { + return directRecord; + } + + const stdout = getToolString(directRecord, ["stdout"]); + if (!stdout) { + return null; + } + + const stdoutRecord = asRecord(parseToolResultValue(stdout)); + return getMessageEventId(stdoutRecord) ? stdoutRecord : null; +} + +function getMessageEventId(record: Record) { + return getToolString(record, [ + "event_id", + "eventId", + "message_id", + "messageId", + ]); +} + function ViewImageToolPreview({ src, title, diff --git a/desktop/src/features/agents/ui/activityRenderClasses/TranscriptTimestamp.tsx b/desktop/src/features/agents/ui/activityRenderClasses/TranscriptTimestamp.tsx index e8a637d67..a95a302cd 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/TranscriptTimestamp.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/TranscriptTimestamp.tsx @@ -1,4 +1,3 @@ -import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { formatTranscriptTime } from "../agentSessionUtils"; const fullDateTimeFormat = new Intl.DateTimeFormat(undefined, { @@ -19,13 +18,11 @@ export function TranscriptTimestamp({ timestamp }: { timestamp: string }) { ? timestamp : fullDateTimeFormat.format(date); return ( - - - - {formatted} - - - {fullDateTime} - + + {formatted} + ); } diff --git a/desktop/src/features/agents/ui/debugAgentActivityFixture.ts b/desktop/src/features/agents/ui/debugAgentActivityFixture.ts index 27e2a75c0..bc663b406 100644 --- a/desktop/src/features/agents/ui/debugAgentActivityFixture.ts +++ b/desktop/src/features/agents/ui/debugAgentActivityFixture.ts @@ -569,7 +569,9 @@ export const DEBUG_AGENT_ACTIVITY_FIXTURE: TranscriptItem[] = [ workdir: workspacePath, timeout_ms: 120000, }, - result: shellResultJson('{"accepted":true,"event_id":"debug-event"}\n'), + result: shellResultJson( + '{"accepted":true,"event_id":"debug-openable-message"}\n', + ), isError: false, timestamp: timestamp(12), startedAt: timestamp(12), @@ -834,7 +836,7 @@ export const DEBUG_AGENT_ACTIVITY_FIXTURE: TranscriptItem[] = [ timeout_ms: 120000, }, result: shellResultJson( - '{"accepted":true,"event_id":"debug-pushed-report"}\n', + '{"accepted":true,"event_id":"debug-openable-pushed-report"}\n', ), isError: false, timestamp: timestamp(25.2), From f8b6821deb931404dbae7020487dabef8abfcd42 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 29 Jun 2026 23:59:15 -0700 Subject: [PATCH 32/49] fix(desktop): simplify prompt context footer - Remove prompt setup hover tooltips from the activity footer so the setup text only appears inside the context modal. - Collapse the footer context affordance to the checks icon while preserving the accessible toggle label. - Add the checks icon to the context modal subtitle row to keep the setup signal visible with the turn summary. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../agents/ui/AgentSessionTranscriptList.tsx | 39 +++++++------------ 1 file changed, 13 insertions(+), 26 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 7db0e9083..9dffee0c5 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -11,7 +11,6 @@ import { DialogTitle, } from "@/shared/ui/dialog"; import { Toggle } from "@/shared/ui/toggle"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import type { PromptSection, TranscriptItem } from "./agentSessionTypes"; import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem"; import { @@ -518,7 +517,10 @@ function PromptContextDialog({ Prompt context {setupText ? ( - {setupText} +
+ + {setupText} +
) : null}
@@ -572,8 +574,7 @@ function TurnSetupFooter({ size="xs" variant="ghost" > - {showSetup ?
{duration ? ( - messageLink ? ( - - {duration} - - ) : ( - - {duration} - - ) + {duration} ) : null} + @@ -545,12 +546,14 @@ function TurnSetupFooter({ context = null, contextOpen = false, items, + messageLink = null, onContextOpenChange, timestamp, }: { context?: Extract | null; contextOpen?: boolean; items: Extract[]; + messageLink?: { channelId: string; messageId: string } | null; onContextOpenChange?: (open: boolean) => void; timestamp: string; }) { @@ -561,7 +564,9 @@ function TurnSetupFooter({ const showContext = context != null && context.sections.length > 0; if (!showSetup && !showContext) { - return ; + return ( + + ); } const contextToggle = showContext ? ( @@ -594,11 +599,21 @@ function TurnSetupFooter({ ) : null} {showContext && !showSetup ? contextToggle : null} - +
); } +function getTranscriptMessageLink( + item: Extract, +) { + if (!item.channelId || !item.messageId) return null; + return { + channelId: item.channelId, + messageId: item.messageId, + }; +} + function TranscriptItemRow({ agentAvatarUrl, agentName, diff --git a/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx index 13ff3037e..8428d73b7 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx @@ -45,6 +45,7 @@ function MessageItem({ }) { const isAssistant = item.role === "assistant"; const text = item.text.trim(); + const messageLink = getTranscriptMessageLink(item); const agentProfile = profiles?.[normalizePubkey(agentPubkey)] ?? null; const assistantLabel = resolveUserLabel({ pubkey: agentPubkey, @@ -57,7 +58,12 @@ function MessageItem({ if (!isAssistant) { return ( } + footer={ + + } item={item} profiles={profiles} /> @@ -82,7 +88,10 @@ function MessageItem({ {assistantLabel} - +
@@ -91,3 +100,13 @@ function MessageItem({
); } + +function getTranscriptMessageLink( + item: Extract, +) { + if (!item.channelId || !item.messageId) return null; + return { + channelId: item.channelId, + messageId: item.messageId, + }; +} diff --git a/desktop/src/features/agents/ui/activityRenderClasses/TranscriptTimestamp.tsx b/desktop/src/features/agents/ui/activityRenderClasses/TranscriptTimestamp.tsx index a95a302cd..9112db304 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/TranscriptTimestamp.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/TranscriptTimestamp.tsx @@ -1,3 +1,8 @@ +import * as React from "react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { buildMessageLink } from "@/features/messages/lib/messageLink"; +import { cn } from "@/shared/lib/cn"; import { formatTranscriptTime } from "../agentSessionUtils"; const fullDateTimeFormat = new Intl.DateTimeFormat(undefined, { @@ -10,16 +15,64 @@ const fullDateTimeFormat = new Intl.DateTimeFormat(undefined, { second: "2-digit", }); -export function TranscriptTimestamp({ timestamp }: { timestamp: string }) { +export type TranscriptTimestampMessageLink = { + channelId: string; + messageId: string; +}; + +export function TranscriptTimestamp({ + className, + messageLink = null, + timestamp, +}: { + className?: string; + messageLink?: TranscriptTimestampMessageLink | null; + timestamp: string; +}) { const formatted = formatTranscriptTime(timestamp); + const { goChannel } = useAppNavigation(); + const href = messageLink ? buildMessageLink(messageLink) : null; + const openMessage = React.useCallback( + (event: React.MouseEvent) => { + if (!messageLink) return; + event.preventDefault(); + event.stopPropagation(); + void goChannel(messageLink.channelId, { + messageId: messageLink.messageId, + }); + }, + [goChannel, messageLink], + ); + if (!formatted) return null; const date = new Date(timestamp); const fullDateTime = Number.isNaN(date.getTime()) ? timestamp : fullDateTimeFormat.format(date); + + if (href) { + return ( + + {formatted} + + ); + } + return ( {formatted} diff --git a/desktop/src/features/agents/ui/agentSessionToolItemHelpers.test.mjs b/desktop/src/features/agents/ui/agentSessionToolItemHelpers.test.mjs index 52b548ec6..abd75f72b 100644 --- a/desktop/src/features/agents/ui/agentSessionToolItemHelpers.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionToolItemHelpers.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { formatDurationMs, + formatTranscriptTime, getToolDurationDisplay, isInlineImageData, parseToolResultValue, @@ -59,6 +60,10 @@ test("formatDurationMs carries a rounded 60s into the next minute", () => { assert.equal(formatDurationMs(89700), "1m 30s"); }); +test("formatTranscriptTime renders a short 12-hour time without seconds", () => { + assert.equal(formatTranscriptTime("2026-06-30T17:00:02.000"), "5:00 PM"); +}); + // ---- parseToolResultValue (JSON double-parse) ---- test("parseToolResultValue returns null for empty/whitespace", () => { diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index 76e50452d..f227aba20 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -13,6 +13,7 @@ const baseEvent = { sessionId: "sess-1", turnId: "turn-1", }; +const PROMPT_EVENT_ID = "c".repeat(64); function acpToolUpdate(seq, update) { return { @@ -75,7 +76,7 @@ test("buildTranscript renders Prompt context + user message for a multi-block se { type: "text", text: "[Context]\nScope: thread" }, { type: "text", - text: `[Buzz event: @mention]\nFrom: x (hex: ${"a".repeat(64)})\nContent: hello`, + text: `[Buzz event: @mention]\nEvent ID: ${PROMPT_EVENT_ID.toUpperCase()}\nFrom: x (hex: ${"a".repeat(64)})\nContent: hello`, }, ], }, @@ -94,6 +95,40 @@ test("buildTranscript renders Prompt context + user message for a multi-block se ["Agent Memory — core", "Context", "Buzz event: @mention"], "every section header is counted", ); + const userMessage = items.find((i) => i.type === "message"); + assert.equal(userMessage.messageId, PROMPT_EVENT_ID); +}); + +test("buildTranscript falls back to a single turn trigger id for older prompt frames", () => { + const promptEvent = { + ...baseEvent, + seq: 2, + payload: { + method: "session/prompt", + params: { + sessionId: "sess-1", + prompt: [ + { + type: "text", + text: `[Buzz event: @mention]\nFrom: x (hex: ${"a".repeat(64)})\nContent: hello`, + }, + ], + }, + }, + }; + const [userMessage] = buildTranscript([ + { + ...baseEvent, + seq: 1, + kind: "turn_started", + payload: { + triggeringEventIds: [PROMPT_EVENT_ID], + }, + }, + promptEvent, + ]).filter((candidate) => candidate.type === "message"); + + assert.equal(userMessage.messageId, PROMPT_EVENT_ID); }); test("buildTranscript keeps read_file activity categorized by the actual tool when output names Buzz tools", () => { @@ -236,6 +271,23 @@ test("buildTranscript preserves author pubkeys on user message chunks", () => { assert.equal(item.role, "user"); assert.equal(item.authorPubkey, authorPubkey); + assert.equal(item.messageId, null); +}); + +test("buildTranscript preserves real event ids on user message chunks", () => { + const authorPubkey = "b".repeat(64); + const messageId = "d".repeat(64); + const [item] = buildTranscript([ + sessionUpdate(26, { + sessionUpdate: "user_message_chunk", + messageId, + authorPubkey, + content: { type: "text", text: "this came from a channel message" }, + }), + ]).filter((candidate) => candidate.type === "message"); + + assert.equal(item.role, "user"); + assert.equal(item.messageId, messageId); }); test("buildTranscript de-duplicates repeated tool updates into one canonical row", () => { diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index 6f9089961..0a51f1a74 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -17,6 +17,7 @@ import { extractBlockText, extractContentText, extractPromptText, + extractTriggeringEventIds, extractToolArgs, extractToolIdentity, extractToolResult, @@ -31,6 +32,7 @@ export type TranscriptState = { itemsById: Map; activeMessageKey: Map; sealedKeys: Set; + triggeringEventIdsByTurn: Map; continuationSeq: number; latestSessionId: string | null; }; @@ -41,6 +43,7 @@ export function createEmptyTranscriptState(): TranscriptState { itemsById: new Map(), activeMessageKey: new Map(), sealedKeys: new Set(), + triggeringEventIdsByTurn: new Map(), continuationSeq: 0, latestSessionId: null, }; @@ -56,6 +59,7 @@ type TranscriptDraft = { itemsById: Map; activeMessageKey: Map; sealedKeys: Set; + triggeringEventIdsByTurn: Map; continuationSeq: number; latestSessionId: string | null; changed: boolean; @@ -67,6 +71,7 @@ function draftFrom(state: TranscriptState): TranscriptDraft { itemsById: state.itemsById, activeMessageKey: state.activeMessageKey, sealedKeys: state.sealedKeys, + triggeringEventIdsByTurn: state.triggeringEventIdsByTurn, continuationSeq: state.continuationSeq, latestSessionId: state.latestSessionId, changed: false, @@ -110,6 +115,34 @@ function sealOpenMessages(d: TranscriptDraft) { } } +function turnMapKey(channelKey: string, turnKey: string | number | null) { + return `${channelKey}:${turnKey ?? "unknown"}`; +} + +function rememberTriggeringEventIds( + d: TranscriptDraft, + channelKey: string, + turnKey: string | number | null, + ids: string[], +) { + if (ids.length === 0) return; + d.triggeringEventIdsByTurn = new Map(d.triggeringEventIdsByTurn); + d.triggeringEventIdsByTurn.set(turnMapKey(channelKey, turnKey), ids); +} + +function getSingleTriggeringEventId( + d: TranscriptDraft, + channelKey: string, + turnKey: string | number | null, +) { + const ids = d.triggeringEventIdsByTurn.get(turnMapKey(channelKey, turnKey)); + return ids?.length === 1 ? maybeNostrEventId(ids[0]) : null; +} + +function maybeNostrEventId(id: string | null | undefined) { + return id && /^[0-9a-fA-F]{64}$/.test(id) ? id : null; +} + type TranscriptItemContext = { channelId: string | null; turnId: string | null; @@ -126,6 +159,7 @@ function upsertMessage( ctx: TranscriptItemContext, authorPubkey: string | null = null, acpSource?: string, + messageId: string | null = null, ) { const currentKey = d.activeMessageKey.get(id); @@ -140,6 +174,7 @@ function upsertMessage( sessionId: ctx.sessionId ?? existing.sessionId, authorPubkey: authorPubkey ?? existing.authorPubkey, acpSource: acpSource ?? existing.acpSource, + messageId: messageId ?? existing.messageId, }); return; } @@ -155,6 +190,7 @@ function upsertMessage( title, text, timestamp, + messageId, channelId: ctx.channelId, turnId: ctx.turnId, sessionId: ctx.sessionId, @@ -447,6 +483,12 @@ export function processTranscriptEvent( }; if (event.kind === "turn_started") { + rememberTriggeringEventIds( + d, + ch, + event.turnId ?? event.seq, + extractTriggeringEventIds(event.payload), + ); upsertTextItem( d, `turn:${ch}:${event.turnId ?? event.seq}`, @@ -514,6 +556,8 @@ export function processTranscriptEvent( ctx, parsedPrompt.userPubkey, "session/prompt:user", + parsedPrompt.userEventId ?? + getSingleTriggeringEventId(d, ch, event.turnId ?? event.seq), ); } if (parsedPrompt.sections.length > 0) { @@ -566,6 +610,8 @@ export function processTranscriptEvent( event.timestamp, ctx, parsedPrompt.userPubkey, + undefined, + parsedPrompt.userEventId, ); } if (parsedPrompt.sections.length > 0) { @@ -604,6 +650,7 @@ export function processTranscriptEvent( const steerKey = `steer:${ch}:${event.turnId ?? event.seq}`; const authorPubkey = asString(update.authorPubkey); if (!d.itemsById.has(steerKey)) { + const channelMessageId = maybeNostrEventId(messageId); upsertMessage( d, `user:${ch}:${messageId ?? turnKey}`, @@ -614,6 +661,7 @@ export function processTranscriptEvent( ctx, authorPubkey, updateType, + channelMessageId, ); } } else if (updateType === "agent_thought_chunk") { @@ -688,6 +736,7 @@ export function processTranscriptEvent( itemsById: d.itemsById, activeMessageKey: d.activeMessageKey, sealedKeys: d.sealedKeys, + triggeringEventIdsByTurn: d.triggeringEventIdsByTurn, continuationSeq: d.continuationSeq, latestSessionId: d.latestSessionId, }; diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs index 76210f2cd..70b2bfffa 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs @@ -20,6 +20,7 @@ test("parsePromptText returns the empty/Prompt fallback for whitespace-only inpu userText: "", userTitle: "Prompt", userPubkey: null, + userEventId: null, }); }); @@ -36,14 +37,16 @@ test("parsePromptText wraps header-less free text in a single Prompt section", ( assert.equal(result.userText, ""); assert.equal(result.userTitle, "Buzz event"); assert.equal(result.userPubkey, null); + assert.equal(result.userEventId, null); }); -test("parsePromptText extracts content, hex pubkey, and a title-cased kind", () => { +test("parsePromptText extracts event id, content, hex pubkey, and a title-cased kind", () => { const text = [ "[System]", "system preamble here", "", "[Buzz event: @mention]", + `Event ID: ${HEX_UPPER}`, "Channel: demo", `From: Wes (hex: ${HEX})`, "Content: hello @Brain please look", @@ -53,6 +56,7 @@ test("parsePromptText extracts content, hex pubkey, and a title-cased kind", () assert.equal(result.userText, "hello @Brain please look"); assert.equal(result.userPubkey, HEX); + assert.equal(result.userEventId, HEX); // titleCase capitalizes after word boundaries but leaves the leading "@" // (a non-word char) in place: "@mention" -> "@Mention". assert.equal(result.userTitle, "@Mention"); @@ -92,6 +96,7 @@ test("parsePromptText preserves multiline event content in the user bubble text" ].join("\n"), ); assert.equal(result.userPubkey, HEX); + assert.equal(result.userEventId, null); }); test("parsePromptText lowercases the extracted hex pubkey", () => { diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts index ec2d05bda..5daf00d2b 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts @@ -18,6 +18,7 @@ export function parsePromptText(text: string): { userText: string; userTitle: string; userPubkey: string | null; + userEventId: string | null; } { const sections = parsePromptSections(text).filter( (s) => s.body.trim().length > 0, @@ -28,12 +29,13 @@ export function parsePromptText(text: string): { userText: text.trim(), userTitle: "Prompt", userPubkey: null, + userEventId: null, }; } const eventSection = sections.find((section) => { const title = section.title.toLowerCase(); - return title.startsWith("buzz event") || title.startsWith("buzz event"); + return title.startsWith("buzz event"); }); const eventContent = eventSection ? extractEventContent(eventSection.body) @@ -41,6 +43,7 @@ export function parsePromptText(text: string): { const eventAuthorPubkey = eventSection ? extractEventAuthorPubkey(eventSection.body) : null; + const eventId = eventSection ? extractEventId(eventSection.body) : null; const eventKind = eventSection?.title.split(":").slice(1).join(":").trim(); return { @@ -48,6 +51,7 @@ export function parsePromptText(text: string): { userText: eventContent, userTitle: eventKind ? titleCase(eventKind) : "Buzz event", userPubkey: eventAuthorPubkey, + userEventId: eventId, }; } @@ -167,6 +171,11 @@ function extractEventAuthorPubkey(body: string): string | null { return fromMatch?.[1]?.toLowerCase() ?? null; } +function extractEventId(body: string): string | null { + const eventIdMatch = body.match(/^Event ID:\s*([0-9a-fA-F]{64})\b/m); + return eventIdMatch?.[1]?.toLowerCase() ?? null; +} + export function extractContentText(value: unknown): string { if (typeof value === "string") return value; if (Array.isArray(value)) return value.map(extractBlockText).join("\n"); @@ -272,13 +281,17 @@ export function extractToolResult(update: Record): string { return extractBlockText(update.rawOutput); } -export function describeTurnStarted(payload: unknown): string { +export function extractTriggeringEventIds(payload: unknown): string[] { const record = asRecord(payload); - const ids = Array.isArray(record.triggeringEventIds) + return Array.isArray(record.triggeringEventIds) ? record.triggeringEventIds.filter( (id): id is string => typeof id === "string", ) : []; +} + +export function describeTurnStarted(payload: unknown): string { + const ids = extractTriggeringEventIds(payload); return ids.length > 0 ? `Triggered by ${ids.length === 1 ? "1 event" : `${ids.length} events`}.` : ""; diff --git a/desktop/src/features/agents/ui/agentSessionTypes.ts b/desktop/src/features/agents/ui/agentSessionTypes.ts index 2c5c4d4ea..2fb65817c 100644 --- a/desktop/src/features/agents/ui/agentSessionTypes.ts +++ b/desktop/src/features/agents/ui/agentSessionTypes.ts @@ -73,6 +73,7 @@ export type TranscriptItem = title: string; text: string; timestamp: string; + messageId?: string | null; acpSource?: TranscriptAcpSource; authorPubkey?: string | null; } & TranscriptItemIdentity) diff --git a/desktop/src/features/agents/ui/agentSessionUtils.ts b/desktop/src/features/agents/ui/agentSessionUtils.ts index 39cec560e..16f1bfa3c 100644 --- a/desktop/src/features/agents/ui/agentSessionUtils.ts +++ b/desktop/src/features/agents/ui/agentSessionUtils.ts @@ -165,31 +165,16 @@ export function shortenMiddle(value: string, maxLength: number) { return `${value.slice(0, edgeLength)}...${value.slice(-edgeLength)}`; } -const sameDayTimeFormat = new Intl.DateTimeFormat(undefined, { +const transcriptTimeFormat = new Intl.DateTimeFormat("en-US", { hour: "numeric", + hour12: true, minute: "2-digit", - second: "2-digit", -}); - -const crossDayTimeFormat = new Intl.DateTimeFormat(undefined, { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - second: "2-digit", }); export function formatTranscriptTime(isoTimestamp: string): string | null { const date = new Date(isoTimestamp); if (Number.isNaN(date.getTime())) return null; - const now = new Date(); - const sameDay = - date.getFullYear() === now.getFullYear() && - date.getMonth() === now.getMonth() && - date.getDate() === now.getDate(); - return sameDay - ? sameDayTimeFormat.format(date) - : crossDayTimeFormat.format(date); + return transcriptTimeFormat.format(date); } export function formatDuration( diff --git a/desktop/src/features/agents/ui/debugAgentActivityFixture.ts b/desktop/src/features/agents/ui/debugAgentActivityFixture.ts index bc663b406..992803fb6 100644 --- a/desktop/src/features/agents/ui/debugAgentActivityFixture.ts +++ b/desktop/src/features/agents/ui/debugAgentActivityFixture.ts @@ -12,6 +12,8 @@ const userPubkey = "1111111111111111111111111111111111111111111111111111111111111111"; const reviewerPubkey = "2222222222222222222222222222222222222222222222222222222222222222"; +export const DEBUG_AGENT_ACTIVITY_PROMPT_EVENT_ID = + "3333333333333333333333333333333333333333333333333333333333333333"; const baseTimestamp = Date.parse("2026-06-30T00:00:00.000Z"); const workspacePath = "/Users/tho/.buzz/REPOS/buzz-pr-3-activity-feed-rebuild"; export const DEBUG_AGENT_ACTIVITY_AGENT_NAME = "Fixture Agent"; @@ -407,6 +409,7 @@ export const DEBUG_AGENT_ACTIVITY_FIXTURE: TranscriptItem[] = [ title: "Taylor Ho", text: "@Agent audit the activity feed taxonomy and show me the risky spots before you edit.", timestamp: timestamp(2), + messageId: DEBUG_AGENT_ACTIVITY_PROMPT_EVENT_ID, acpSource: "session/prompt:user", authorPubkey: userPubkey, turnId, @@ -437,6 +440,7 @@ export const DEBUG_AGENT_ACTIVITY_FIXTURE: TranscriptItem[] = [ { title: "Buzz event: @mention", body: [ + `Event ID: ${DEBUG_AGENT_ACTIVITY_PROMPT_EVENT_ID}`, `From: Taylor Ho (hex: ${userPubkey})`, "Kind: channel message", "Content: @Agent audit the activity feed taxonomy and show me the risky spots before you edit.", diff --git a/desktop/src/features/agents/ui/debugAgentActivityRawFixture.ts b/desktop/src/features/agents/ui/debugAgentActivityRawFixture.ts index 5f428ab2d..2a4a2bb41 100644 --- a/desktop/src/features/agents/ui/debugAgentActivityRawFixture.ts +++ b/desktop/src/features/agents/ui/debugAgentActivityRawFixture.ts @@ -1,6 +1,9 @@ import type { ObserverEvent, TranscriptItem } from "./agentSessionTypes"; import { buildTranscript } from "./agentSessionTranscript"; -import { DEBUG_AGENT_ACTIVITY_FIXTURE } from "./debugAgentActivityFixture"; +import { + DEBUG_AGENT_ACTIVITY_FIXTURE, + DEBUG_AGENT_ACTIVITY_PROMPT_EVENT_ID, +} from "./debugAgentActivityFixture"; function rawContent(text: string) { return [ @@ -128,7 +131,7 @@ function payloadForItem(item: TranscriptItem): unknown { if (item.acpSource === "turn_started") { return { type: "turn_started", - triggeringEventIds: ["debug-trigger-event"], + triggeringEventIds: [DEBUG_AGENT_ACTIVITY_PROMPT_EVENT_ID], }; } From 8bde8c460ff5f1eccffcf3e4a79c01344ff906e4 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 30 Jun 2026 00:19:26 -0700 Subject: [PATCH 34/49] fix(desktop): hide non-message transcript timestamps - Add native title support to ActivityRow so non-message transcript rows can expose full timestamp details without rendering visible time text. - Remove visible timestamps from lifecycle/status/error, plan, thought, raw metadata, setup-only, grouped summary, and non-message tool rows. - Keep visible timestamps on actual message surfaces, including user/assistant transcript messages and sent-message activity bubbles. - Centralize full timestamp tooltip formatting in agentSessionUtils and reuse it from TranscriptTimestamp and row title call sites. - Preserve runtime duration labels on tool rows while moving wall-clock time into the row title. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../agents/ui/AgentSessionToolItem.tsx | 8 ++++++- .../agents/ui/AgentSessionTranscriptList.tsx | 24 ++++++++++++++----- .../ui/activityRenderClasses/ActivityRow.tsx | 4 ++++ .../LifecycleActivity.tsx | 8 +++---- .../ui/activityRenderClasses/PlanActivity.tsx | 14 +++++++---- .../activityRenderClasses/RawRailActivity.tsx | 8 ++++--- .../activityRenderClasses/ThoughtActivity.tsx | 8 ++++--- .../TranscriptTimestamp.tsx | 20 ++++------------ .../features/agents/ui/agentSessionUtils.ts | 18 ++++++++++++++ 9 files changed, 75 insertions(+), 37 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx index 0c5362610..97a672597 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx @@ -21,6 +21,7 @@ import { TranscriptTimestamp } from "./activityRenderClasses/TranscriptTimestamp import { asRecord, formatCodeValue, + formatTranscriptTimestampTitle, getToolString, getToolDurationDisplay, isInlineImageData, @@ -40,6 +41,7 @@ export function ToolItem({ const compactSummary = buildCompactToolSummary(item); const duration = getToolDurationDisplay(item); const messageLink = getSentMessageLink(item); + const timestampTitle = formatTranscriptTimestampTitle(item.timestamp); const handleToggle = React.useCallback( (event: React.SyntheticEvent) => { setIsExpanded(event.currentTarget.open); @@ -48,7 +50,11 @@ export function ToolItem({ ); return ( -
+
- | null; @@ -555,6 +557,7 @@ function TurnSetupFooter({ items: Extract[]; messageLink?: { channelId: string; messageId: string } | null; onContextOpenChange?: (open: boolean) => void; + showTimestamp?: boolean; timestamp: string; }) { const label = formatTurnSetupLabel(items); @@ -564,9 +567,9 @@ function TurnSetupFooter({ const showContext = context != null && context.sections.length > 0; if (!showSetup && !showContext) { - return ( + return showTimestamp ? ( - ); + ) : null; } const contextToggle = showContext ? ( @@ -599,7 +602,9 @@ function TurnSetupFooter({ ) : null} {showContext && !showSetup ? contextToggle : null} - + {showTimestamp ? ( + + ) : null}
); } @@ -651,8 +656,15 @@ function TurnSetupStatus({ } return ( -
- +
+
); } diff --git a/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx b/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx index de62c3c0b..f02ed57a1 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx @@ -20,6 +20,7 @@ type ActivityRowProps = { className?: string; openToneScope?: Exclude; testId?: string; + title?: string; }; type ActivityRowContentProps = { @@ -38,6 +39,7 @@ export function ActivityRow({ className, openToneScope = "tool", testId, + title, }: ActivityRowProps) { const childArray = React.Children.toArray(children); const summaryChildren = childArray.filter( @@ -50,6 +52,7 @@ export function ActivityRow({
{children}
@@ -64,6 +67,7 @@ export function ActivityRow({ className, )} data-testid={testId} + title={title} > {props.item.title} {props.item.text ? ( · {props.item.text} ) : null} -
); } return ( - + - ); } diff --git a/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx index 92662318a..d31b3e747 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx @@ -5,7 +5,7 @@ import { ActivityRowLabel, } from "./ActivityRow"; import { ToolActivity } from "./ToolActivity"; -import { TranscriptTimestamp } from "./TranscriptTimestamp"; +import { formatTranscriptTimestampTitle } from "../agentSessionUtils"; import type { ActivityRenderClassItemProps } from "./types"; export function PlanActivity(props: ActivityRenderClassItemProps) { @@ -18,21 +18,25 @@ export function PlanActivity(props: ActivityRenderClassItemProps) { if (props.item.isUpdate) { return ( - + - ); } return ( - + - + - {props.item.sections.map((section) => (
+ - diff --git a/desktop/src/features/agents/ui/activityRenderClasses/TranscriptTimestamp.tsx b/desktop/src/features/agents/ui/activityRenderClasses/TranscriptTimestamp.tsx index 9112db304..b544a391f 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/TranscriptTimestamp.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/TranscriptTimestamp.tsx @@ -3,17 +3,10 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { buildMessageLink } from "@/features/messages/lib/messageLink"; import { cn } from "@/shared/lib/cn"; -import { formatTranscriptTime } from "../agentSessionUtils"; - -const fullDateTimeFormat = new Intl.DateTimeFormat(undefined, { - weekday: "long", - year: "numeric", - month: "long", - day: "numeric", - hour: "numeric", - minute: "2-digit", - second: "2-digit", -}); +import { + formatTranscriptTime, + formatTranscriptTimestampTitle, +} from "../agentSessionUtils"; export type TranscriptTimestampMessageLink = { channelId: string; @@ -45,10 +38,7 @@ export function TranscriptTimestamp({ ); if (!formatted) return null; - const date = new Date(timestamp); - const fullDateTime = Number.isNaN(date.getTime()) - ? timestamp - : fullDateTimeFormat.format(date); + const fullDateTime = formatTranscriptTimestampTitle(timestamp); if (href) { return ( diff --git a/desktop/src/features/agents/ui/agentSessionUtils.ts b/desktop/src/features/agents/ui/agentSessionUtils.ts index 16f1bfa3c..28b613824 100644 --- a/desktop/src/features/agents/ui/agentSessionUtils.ts +++ b/desktop/src/features/agents/ui/agentSessionUtils.ts @@ -171,12 +171,30 @@ const transcriptTimeFormat = new Intl.DateTimeFormat("en-US", { minute: "2-digit", }); +const transcriptTitleTimeFormat = new Intl.DateTimeFormat(undefined, { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", +}); + export function formatTranscriptTime(isoTimestamp: string): string | null { const date = new Date(isoTimestamp); if (Number.isNaN(date.getTime())) return null; return transcriptTimeFormat.format(date); } +export function formatTranscriptTimestampTitle( + isoTimestamp: string, +): string | undefined { + const date = new Date(isoTimestamp); + if (Number.isNaN(date.getTime())) return isoTimestamp || undefined; + return transcriptTitleTimeFormat.format(date); +} + export function formatDuration( startIso: string, endIso: string, From 3e775b699b12567f9d8d6ec92b22efa57dc87992 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 30 Jun 2026 00:32:35 -0700 Subject: [PATCH 35/49] fix(desktop): mirror sent message transcript rows - Update AgentSessionToolItem so sent message activity renders as a left-side message bubble with the agent avatar, mirroring user prompt bubbles instead of using the generic tool accordion. - Replace inline sent-message expansion with a checkmark-triggered context dialog that uses rounded collapsible sections for message content, parameters, and results. - Keep sent-message timestamps linked to the underlying message while moving duration into the dialog subtitle and removing redundant footer text. - Pass agent identity through ToolActivity so message tool rows can resolve the correct avatar and display name. - Update the debug activity fixture to use realistic 64-character event IDs for sent-message link coverage. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../agents/ui/AgentSessionToolItem.tsx | 356 +++++++++++++++--- .../ui/activityRenderClasses/ToolActivity.tsx | 5 +- .../agents/ui/debugAgentActivityFixture.ts | 8 +- 3 files changed, 316 insertions(+), 53 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx index 97a672597..2d9b13c7d 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx @@ -1,9 +1,22 @@ import * as React from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; -import { ChevronDown, Send } from "lucide-react"; +import { CheckCheck, ChevronDown } from "lucide-react"; +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; import { cn } from "@/shared/lib/cn"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; import type { AgentActivityAction, TranscriptItem } from "./agentSessionTypes"; import { getBuzzToolInfo } from "./agentSessionToolCatalog"; import { @@ -18,6 +31,7 @@ import { type ActivityRowLabelParts, } from "./activityRenderClasses/ActivityRow"; import { TranscriptTimestamp } from "./activityRenderClasses/TranscriptTimestamp"; +import type { AgentTranscriptIdentityProps } from "./activityRenderClasses/types"; import { asRecord, formatCodeValue, @@ -29,9 +43,14 @@ import { } from "./agentSessionUtils"; export function ToolItem({ + agentAvatarUrl, + agentName, + agentPubkey, item, -}: { + profiles, +}: AgentTranscriptIdentityProps & { item: Extract; + profiles?: UserProfileLookup; }) { const [isExpanded, setIsExpanded] = React.useState(false); const hasArgs = Object.keys(item.args).length > 0; @@ -42,6 +61,14 @@ export function ToolItem({ const duration = getToolDurationDisplay(item); const messageLink = getSentMessageLink(item); const timestampTitle = formatTranscriptTimestampTitle(item.timestamp); + const agentProfile = profiles?.[normalizePubkey(agentPubkey)] ?? null; + const agentLabel = resolveUserLabel({ + pubkey: agentPubkey, + fallbackName: agentName, + profiles, + preferResolvedSelfLabel: true, + }); + const agentResolvedAvatarUrl = agentProfile?.avatarUrl ?? agentAvatarUrl; const handleToggle = React.useCallback( (event: React.SyntheticEvent) => { setIsExpanded(event.currentTarget.open); @@ -49,6 +76,32 @@ export function ToolItem({ [], ); + if (compactSummary.presentation === "message") { + return ( +
+ +
+ ); + } + return (
- {compactSummary.presentation === "message" ? ( - - ) : ( - - )} + ; + avatarUrl: string | null; + description?: string; + displayName: string; duration: string | null; + hasArgs: boolean; + hasResult: boolean; isError: boolean; label: string; messageLink: SentMessageLink | null; preview: string | null; + result: string; timestamp: string; }) { + const [detailsOpen, setDetailsOpen] = React.useState(false); const mutedTone = compactSummaryTone(); return ( -
-
-

- {preview || "Message content unavailable."} -

-
-
- - - {label} - - {duration ? ( - {duration} - ) : null} - - +
+ +
+
+

+ {preview || "Message content unavailable."} +

+
+
+ + +
+
+ + + ); +} + +function SentMessageContextDialog({ + args, + description, + duration, + hasArgs, + hasResult, + isError, + label, + onOpenChange, + open, + preview, + result, +}: { + args: Record; + description?: string; + duration: string | null; + hasArgs: boolean; + hasResult: boolean; + isError: boolean; + label: string; + onOpenChange: (open: boolean) => void; + open: boolean; + preview: string | null; + result: string; +}) { + const sections = buildSentMessageContextSections({ + args, + description, + hasArgs, + hasResult, + isError, + preview, + result, + }); + + return ( + + +
+ + Sent message context + + + {label} + {duration ? {duration} : null} + + +
+ +
+
+
+
+ ); +} + +type SentMessageContextSection = { + body: string; + title: string; +}; + +function buildSentMessageContextSections({ + args, + description, + hasArgs, + hasResult, + isError, + preview, + result, +}: { + args: Record; + description?: string; + hasArgs: boolean; + hasResult: boolean; + isError: boolean; + preview: string | null; + result: string; +}): SentMessageContextSection[] { + const sections: SentMessageContextSection[] = []; + if (preview) { + sections.push({ title: "Message", body: preview }); + } + if (description) { + sections.push({ title: "Tool", body: description }); + } + if (hasArgs) { + sections.push({ + title: "Parameters", + body: JSON.stringify(args, null, 2), + }); + } + if (hasResult) { + sections.push({ + title: isError ? "Error" : "Result", + body: formatCodeValue(result), + }); + } + if (sections.length === 0) { + sections.push({ + title: "Status", + body: "Waiting for tool details.", + }); + } + return sections; +} + +function SentMessageContextSections({ + sections, +}: { + sections: SentMessageContextSection[]; +}) { + return ( +
+ {sections.map((section) => ( + + ))}
); } +function SentMessageContextSectionAccordion({ + section, +}: { + section: SentMessageContextSection; +}) { + const [open, setOpen] = React.useState(false); + const body = section.body.trim(); + + return ( +
+ +
+ ); +} + type SentMessageLink = { channelId: string; messageId: string; diff --git a/desktop/src/features/agents/ui/activityRenderClasses/ToolActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/ToolActivity.tsx index 58861fd09..3ccb5c24d 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/ToolActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/ToolActivity.tsx @@ -1,10 +1,11 @@ import { ToolItem } from "../AgentSessionToolItem"; import type { ActivityRenderClassItemProps } from "./types"; -export function ToolActivity({ item }: ActivityRenderClassItemProps) { +export function ToolActivity(props: ActivityRenderClassItemProps) { + const { item } = props; if (item.type !== "tool") { return null; } - return ; + return ; } diff --git a/desktop/src/features/agents/ui/debugAgentActivityFixture.ts b/desktop/src/features/agents/ui/debugAgentActivityFixture.ts index 992803fb6..6f9abce9c 100644 --- a/desktop/src/features/agents/ui/debugAgentActivityFixture.ts +++ b/desktop/src/features/agents/ui/debugAgentActivityFixture.ts @@ -14,6 +14,10 @@ const reviewerPubkey = "2222222222222222222222222222222222222222222222222222222222222222"; export const DEBUG_AGENT_ACTIVITY_PROMPT_EVENT_ID = "3333333333333333333333333333333333333333333333333333333333333333"; +const DEBUG_AGENT_ACTIVITY_SENT_MESSAGE_EVENT_ID = + "4444444444444444444444444444444444444444444444444444444444444444"; +const DEBUG_AGENT_ACTIVITY_PUSHED_REPORT_EVENT_ID = + "5555555555555555555555555555555555555555555555555555555555555555"; const baseTimestamp = Date.parse("2026-06-30T00:00:00.000Z"); const workspacePath = "/Users/tho/.buzz/REPOS/buzz-pr-3-activity-feed-rebuild"; export const DEBUG_AGENT_ACTIVITY_AGENT_NAME = "Fixture Agent"; @@ -574,7 +578,7 @@ export const DEBUG_AGENT_ACTIVITY_FIXTURE: TranscriptItem[] = [ timeout_ms: 120000, }, result: shellResultJson( - '{"accepted":true,"event_id":"debug-openable-message"}\n', + `{"accepted":true,"event_id":"${DEBUG_AGENT_ACTIVITY_SENT_MESSAGE_EVENT_ID}"}\n`, ), isError: false, timestamp: timestamp(12), @@ -840,7 +844,7 @@ export const DEBUG_AGENT_ACTIVITY_FIXTURE: TranscriptItem[] = [ timeout_ms: 120000, }, result: shellResultJson( - '{"accepted":true,"event_id":"debug-openable-pushed-report"}\n', + `{"accepted":true,"event_id":"${DEBUG_AGENT_ACTIVITY_PUSHED_REPORT_EVENT_ID}"}\n`, ), isError: false, timestamp: timestamp(25.2), From 55287003d417d62a3beb0db98b391dbf77beb65a Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 30 Jun 2026 00:53:59 -0700 Subject: [PATCH 36/49] feat(desktop): polish agent activity feed rows - Replace the monolithic AgentSessionToolItem implementation with a barrel export and focused render modules for the main tool item, compact rows, sent-message context, shell output, todo summaries, image previews, detail blocks, and message-link helpers. - Render shell command activity details as a muted terminal-style block below the existing accordion label, with inline accent-colored terminal icon, muted command text, and foreground stdout output. - Parse shell tool result envelopes so stdout displays without exposing the raw JSON result payload, and add focused parser coverage for envelope and raw text cases. - Keep plan update progress summaries, such as 5/5 complete, in foreground while preserving muted surrounding label text. - Render todo tool summaries as static checkbox snapshots in a dedicated component, keeping the desktop file-size guard green. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../agents/ui/AgentSessionToolItem.tsx | 813 +----------------- .../CompactMessageSummary.tsx | 102 +++ .../CompactToolSummaryRow.tsx | 144 ++++ .../SentMessageContextDialog.tsx | 186 ++++ .../ShellCommandBlock.tsx | 31 + .../AgentSessionToolItem/TodoToolSummary.tsx | 178 ++++ .../AgentSessionToolItem/ToolDetailBlocks.tsx | 102 +++ .../ui/AgentSessionToolItem/ToolItem.tsx | 159 ++++ .../ViewImageToolPreview.tsx | 108 +++ .../ui/AgentSessionToolItem/messageLinks.ts | 76 ++ .../ui/activityRenderClasses/PlanActivity.tsx | 16 +- .../ui/agentSessionToolItemHelpers.test.mjs | 31 + .../features/agents/ui/agentSessionUtils.ts | 64 ++ 13 files changed, 1197 insertions(+), 813 deletions(-) create mode 100644 desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx create mode 100644 desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.tsx create mode 100644 desktop/src/features/agents/ui/AgentSessionToolItem/SentMessageContextDialog.tsx create mode 100644 desktop/src/features/agents/ui/AgentSessionToolItem/ShellCommandBlock.tsx create mode 100644 desktop/src/features/agents/ui/AgentSessionToolItem/TodoToolSummary.tsx create mode 100644 desktop/src/features/agents/ui/AgentSessionToolItem/ToolDetailBlocks.tsx create mode 100644 desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx create mode 100644 desktop/src/features/agents/ui/AgentSessionToolItem/ViewImageToolPreview.tsx create mode 100644 desktop/src/features/agents/ui/AgentSessionToolItem/messageLinks.ts diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx index 2d9b13c7d..c3d3fb3ec 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx @@ -1,812 +1 @@ -import * as React from "react"; -import * as DialogPrimitive from "@radix-ui/react-dialog"; -import { CheckCheck, ChevronDown } from "lucide-react"; - -import { - resolveUserLabel, - type UserProfileLookup, -} from "@/features/profile/lib/identity"; -import { cn } from "@/shared/lib/cn"; -import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; -import { normalizePubkey } from "@/shared/lib/pubkey"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/shared/ui/dialog"; -import { UserAvatar } from "@/shared/ui/UserAvatar"; -import type { AgentActivityAction, TranscriptItem } from "./agentSessionTypes"; -import { getBuzzToolInfo } from "./agentSessionToolCatalog"; -import { - buildCompactToolSummary, - type CompactFileEditSummary, -} from "./agentSessionToolSummary"; -import type { FileEditDiff } from "./agentSessionFileEditDiff"; -import { FileEditDiffBlock, hasFileEditLineDiff } from "./FileEditDiffView"; -import { - ActivityRowLabel, - splitActivityRowLabel, - type ActivityRowLabelParts, -} from "./activityRenderClasses/ActivityRow"; -import { TranscriptTimestamp } from "./activityRenderClasses/TranscriptTimestamp"; -import type { AgentTranscriptIdentityProps } from "./activityRenderClasses/types"; -import { - asRecord, - formatCodeValue, - formatTranscriptTimestampTitle, - getToolString, - getToolDurationDisplay, - isInlineImageData, - parseToolResultValue, -} from "./agentSessionUtils"; - -export function ToolItem({ - agentAvatarUrl, - agentName, - agentPubkey, - item, - profiles, -}: AgentTranscriptIdentityProps & { - item: Extract; - profiles?: UserProfileLookup; -}) { - const [isExpanded, setIsExpanded] = React.useState(false); - const hasArgs = Object.keys(item.args).length > 0; - const hasResult = item.result.trim().length > 0; - const canonicalToolName = item.buzzToolName ?? item.toolName; - const buzzTool = getBuzzToolInfo(canonicalToolName); - const compactSummary = buildCompactToolSummary(item); - const duration = getToolDurationDisplay(item); - const messageLink = getSentMessageLink(item); - const timestampTitle = formatTranscriptTimestampTitle(item.timestamp); - const agentProfile = profiles?.[normalizePubkey(agentPubkey)] ?? null; - const agentLabel = resolveUserLabel({ - pubkey: agentPubkey, - fallbackName: agentName, - profiles, - preferResolvedSelfLabel: true, - }); - const agentResolvedAvatarUrl = agentProfile?.avatarUrl ?? agentAvatarUrl; - const handleToggle = React.useCallback( - (event: React.SyntheticEvent) => { - setIsExpanded(event.currentTarget.open); - }, - [], - ); - - if (compactSummary.presentation === "message") { - return ( -
- -
- ); - } - - return ( -
-
- - - - - -
-
- ); -} - -function compactSummaryTone() { - return "text-muted-foreground/60 group-open:text-foreground"; -} - -function resolveImageSrc(source: string): string { - return isInlineImageData(source) ? source : rewriteRelayUrl(source); -} - -function CompactToolSummaryRow({ - action, - duration, - fileEditSummary, - label, - preview, - thumbnailSrc, -}: { - action: AgentActivityAction | null; - duration: string | null; - fileEditSummary: CompactFileEditSummary | null; - label: string; - preview: string | null; - thumbnailSrc: string | null; -}) { - const [thumbnailFailed, setThumbnailFailed] = React.useState(false); - const mutedTone = compactSummaryTone(); - const resolvedThumbnail = React.useMemo(() => { - if (!thumbnailSrc || thumbnailFailed) return null; - return resolveImageSrc(thumbnailSrc); - }, [thumbnailFailed, thumbnailSrc]); - const actionLabel = fileEditSummary - ? null - : getCompactToolActionLabel(action, label, preview); - - return ( - <> - {fileEditSummary ? ( - - ) : actionLabel ? ( - - ) : ( - - {label} - - )} - {!fileEditSummary && resolvedThumbnail ? ( - setThumbnailFailed(true)} - src={resolvedThumbnail} - title={preview ?? undefined} - /> - ) : !fileEditSummary && !actionLabel && preview ? ( - - {preview} - - ) : null} - {duration ? ( - {duration} - ) : null} - - - ); -} - -function getCompactToolActionLabel( - action: AgentActivityAction | null, - label: string, - preview: string | null, -): (ActivityRowLabelParts & { title?: string }) | null { - if (action) { - const object = action.object ?? preview ?? undefined; - return { - verb: action.verb, - object, - title: typeof object === "string" ? object : undefined, - }; - } - - const parts = splitActivityRowLabel(label); - if (!parts) return null; - - if (!preview) return parts; - - if ( - label === "Ran command" || - label === "Read file" || - label === "Updated todos" || - label === "Viewed image" - ) { - return { verb: parts.verb, object: preview, title: preview }; - } - - return parts; -} - -function CompactFileEditSummaryView({ - summary, -}: { - summary: CompactFileEditSummary; -}) { - return ( - - ); -} - -function CompactMessageSummary({ - args, - avatarUrl, - description, - displayName, - duration, - hasArgs, - hasResult, - isError, - label, - messageLink, - preview, - result, - timestamp, -}: { - args: Record; - avatarUrl: string | null; - description?: string; - displayName: string; - duration: string | null; - hasArgs: boolean; - hasResult: boolean; - isError: boolean; - label: string; - messageLink: SentMessageLink | null; - preview: string | null; - result: string; - timestamp: string; -}) { - const [detailsOpen, setDetailsOpen] = React.useState(false); - const mutedTone = compactSummaryTone(); - return ( - <> -
- -
-
-

- {preview || "Message content unavailable."} -

-
-
- - -
-
-
- - - ); -} - -function SentMessageContextDialog({ - args, - description, - duration, - hasArgs, - hasResult, - isError, - label, - onOpenChange, - open, - preview, - result, -}: { - args: Record; - description?: string; - duration: string | null; - hasArgs: boolean; - hasResult: boolean; - isError: boolean; - label: string; - onOpenChange: (open: boolean) => void; - open: boolean; - preview: string | null; - result: string; -}) { - const sections = buildSentMessageContextSections({ - args, - description, - hasArgs, - hasResult, - isError, - preview, - result, - }); - - return ( - - -
- - Sent message context - - - {label} - {duration ? {duration} : null} - - -
- -
-
-
-
- ); -} - -type SentMessageContextSection = { - body: string; - title: string; -}; - -function buildSentMessageContextSections({ - args, - description, - hasArgs, - hasResult, - isError, - preview, - result, -}: { - args: Record; - description?: string; - hasArgs: boolean; - hasResult: boolean; - isError: boolean; - preview: string | null; - result: string; -}): SentMessageContextSection[] { - const sections: SentMessageContextSection[] = []; - if (preview) { - sections.push({ title: "Message", body: preview }); - } - if (description) { - sections.push({ title: "Tool", body: description }); - } - if (hasArgs) { - sections.push({ - title: "Parameters", - body: JSON.stringify(args, null, 2), - }); - } - if (hasResult) { - sections.push({ - title: isError ? "Error" : "Result", - body: formatCodeValue(result), - }); - } - if (sections.length === 0) { - sections.push({ - title: "Status", - body: "Waiting for tool details.", - }); - } - return sections; -} - -function SentMessageContextSections({ - sections, -}: { - sections: SentMessageContextSection[]; -}) { - return ( -
- {sections.map((section) => ( - - ))} -
- ); -} - -function SentMessageContextSectionAccordion({ - section, -}: { - section: SentMessageContextSection; -}) { - const [open, setOpen] = React.useState(false); - const body = section.body.trim(); - - return ( -
- -
- ); -} - -type SentMessageLink = { - channelId: string; - messageId: string; -}; - -function getSentMessageLink( - item: Extract, -): SentMessageLink | null { - if (item.status !== "completed" || item.isError) { - return null; - } - - if (item.descriptor?.renderClass !== "message") { - return null; - } - - const channelId = - item.channelId ?? getToolString(item.args, ["channel_id", "channelId"]); - if (!channelId) { - return null; - } - - const resultRecord = getMessageSendResultRecord(item.result); - if (!resultRecord || resultRecord.accepted === false) { - return null; - } - - const messageId = getToolString(resultRecord, [ - "event_id", - "eventId", - "message_id", - "messageId", - ]); - if (!messageId) { - return null; - } - - return { - channelId, - messageId, - }; -} - -function getMessageSendResultRecord( - result: string, -): Record | null { - const parsed = parseToolResultValue(result); - const directRecord = asRecord(parsed); - if (getMessageEventId(directRecord)) { - return directRecord; - } - - const stdout = getToolString(directRecord, ["stdout"]); - if (!stdout) { - return null; - } - - const stdoutRecord = asRecord(parseToolResultValue(stdout)); - return getMessageEventId(stdoutRecord) ? stdoutRecord : null; -} - -function getMessageEventId(record: Record) { - return getToolString(record, [ - "event_id", - "eventId", - "message_id", - "messageId", - ]); -} - -function ViewImageToolPreview({ - src, - title, -}: { - src: string; - title: string | null; -}) { - const [lightboxOpen, setLightboxOpen] = React.useState(false); - const [imageFailed, setImageFailed] = React.useState(false); - const resolvedSrc = React.useMemo(() => resolveImageSrc(src), [src]); - const alt = title ?? "Viewed image"; - - if (imageFailed) { - return null; - } - - return ( - <> - {/* biome-ignore lint/a11y/useKeyWithClickEvents: opens lightbox on click */} - {alt} setLightboxOpen(true)} - onError={() => setImageFailed(true)} - src={resolvedSrc} - title={title ?? undefined} - /> - - - ); -} - -function ImageLightbox({ - alt, - onOpenChange, - open, - src, -}: { - alt: string; - onOpenChange: (open: boolean) => void; - open: boolean; - src: string; -}) { - return ( - - - - event.preventDefault()} - onPointerDownOutside={(event) => event.preventDefault()} - > - - {alt} - - - Full-size image preview. Press Escape or click outside the image to - close. - - - {alt} - - - - - - - ); -} - -function ToolDetailBlocks({ - args, - description, - fileEditDiff, - hasArgs, - hasResult, - imagePreview, - isError, - result, -}: { - args: Record; - description?: string; - fileEditDiff: FileEditDiff | null; - hasArgs: boolean; - hasResult: boolean; - imagePreview: { src: string | null; title: string | null } | null; - isError: boolean; - result: string; -}) { - const showFileEditDiff = - fileEditDiff && hasFileEditLineDiff(fileEditDiff) && !isError; - const showParameters = hasArgs && !showFileEditDiff; - - return ( -
- {description ? ( -

- {description} -

- ) : null} - {imagePreview?.src ? ( - - ) : null} - {showParameters ? ( - - ) : null} - {hasResult ? ( - showFileEditDiff ? ( - - ) : ( - - ) - ) : null} - {!showParameters && !hasResult ? ( -

- Waiting for tool details. -

- ) : null} -
- ); -} - -function ToolCodeBlock({ - label, - tone, - value, -}: { - label: string; - tone: "muted" | "error"; - value: string; -}) { - return ( -
-

- {label} -

-
-        {formatCodeValue(value)}
-      
-
- ); -} +export { ToolItem } from "./AgentSessionToolItem/ToolItem"; diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx new file mode 100644 index 000000000..9b786d8ab --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx @@ -0,0 +1,102 @@ +import * as React from "react"; +import { CheckCheck } from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { TranscriptTimestamp } from "../activityRenderClasses/TranscriptTimestamp"; +import { compactSummaryTone } from "./CompactToolSummaryRow"; +import type { SentMessageLink } from "./messageLinks"; +import { SentMessageContextDialog } from "./SentMessageContextDialog"; + +export function CompactMessageSummary({ + args, + avatarUrl, + description, + displayName, + duration, + hasArgs, + hasResult, + isError, + label, + messageLink, + preview, + result, + timestamp, +}: { + args: Record; + avatarUrl: string | null; + description?: string; + displayName: string; + duration: string | null; + hasArgs: boolean; + hasResult: boolean; + isError: boolean; + label: string; + messageLink: SentMessageLink | null; + preview: string | null; + result: string; + timestamp: string; +}) { + const [detailsOpen, setDetailsOpen] = React.useState(false); + const mutedTone = compactSummaryTone(); + return ( + <> +
+ +
+
+

+ {preview || "Message content unavailable."} +

+
+
+ + +
+
+
+ + + ); +} diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.tsx new file mode 100644 index 000000000..825adff68 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.tsx @@ -0,0 +1,144 @@ +import * as React from "react"; +import { ChevronDown } from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; +import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import type { AgentActivityAction } from "../agentSessionTypes"; +import type { CompactFileEditSummary } from "../agentSessionToolSummary"; +import { isInlineImageData } from "../agentSessionUtils"; +import { + ActivityRowLabel, + splitActivityRowLabel, + type ActivityRowLabelParts, +} from "../activityRenderClasses/ActivityRow"; + +export function compactSummaryTone() { + return "text-muted-foreground/60 group-open:text-foreground"; +} + +export function CompactToolSummaryRow({ + action, + duration, + fileEditSummary, + label, + preview, + thumbnailSrc, +}: { + action: AgentActivityAction | null; + duration: string | null; + fileEditSummary: CompactFileEditSummary | null; + label: string; + preview: string | null; + thumbnailSrc: string | null; +}) { + const [thumbnailFailed, setThumbnailFailed] = React.useState(false); + const mutedTone = compactSummaryTone(); + const resolvedThumbnail = React.useMemo(() => { + if (!thumbnailSrc || thumbnailFailed) return null; + return resolveImageSrc(thumbnailSrc); + }, [thumbnailFailed, thumbnailSrc]); + const actionLabel = fileEditSummary + ? null + : getCompactToolActionLabel(action, label, preview); + + return ( + <> + {fileEditSummary ? ( + + ) : actionLabel ? ( + + ) : ( + + {label} + + )} + {!fileEditSummary && resolvedThumbnail ? ( + setThumbnailFailed(true)} + src={resolvedThumbnail} + title={preview ?? undefined} + /> + ) : !fileEditSummary && !actionLabel && preview ? ( + + {preview} + + ) : null} + {duration ? ( + {duration} + ) : null} + + + ); +} + +function getCompactToolActionLabel( + action: AgentActivityAction | null, + label: string, + preview: string | null, +): (ActivityRowLabelParts & { title?: string }) | null { + if (action) { + const object = action.object ?? preview ?? undefined; + return { + verb: action.verb, + object, + title: typeof object === "string" ? object : undefined, + }; + } + + const parts = splitActivityRowLabel(label); + if (!parts) return null; + + if (!preview) return parts; + + if ( + label === "Ran command" || + label === "Read file" || + label === "Updated todos" || + label === "Viewed image" + ) { + return { verb: parts.verb, object: preview, title: preview }; + } + + return parts; +} + +function CompactFileEditSummaryView({ + summary, +}: { + summary: CompactFileEditSummary; +}) { + return ( + + ); +} + +function resolveImageSrc(source: string): string { + return isInlineImageData(source) ? source : rewriteRelayUrl(source); +} diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/SentMessageContextDialog.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/SentMessageContextDialog.tsx new file mode 100644 index 000000000..1ffe7036d --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/SentMessageContextDialog.tsx @@ -0,0 +1,186 @@ +import * as React from "react"; +import { CheckCheck, ChevronDown } from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { formatCodeValue } from "../agentSessionUtils"; + +export function SentMessageContextDialog({ + args, + description, + duration, + hasArgs, + hasResult, + isError, + label, + onOpenChange, + open, + preview, + result, +}: { + args: Record; + description?: string; + duration: string | null; + hasArgs: boolean; + hasResult: boolean; + isError: boolean; + label: string; + onOpenChange: (open: boolean) => void; + open: boolean; + preview: string | null; + result: string; +}) { + const sections = buildSentMessageContextSections({ + args, + description, + hasArgs, + hasResult, + isError, + preview, + result, + }); + + return ( + + +
+ + Sent message context + + + {label} + {duration ? {duration} : null} + + +
+ +
+
+
+
+ ); +} + +type SentMessageContextSection = { + body: string; + title: string; +}; + +function buildSentMessageContextSections({ + args, + description, + hasArgs, + hasResult, + isError, + preview, + result, +}: { + args: Record; + description?: string; + hasArgs: boolean; + hasResult: boolean; + isError: boolean; + preview: string | null; + result: string; +}): SentMessageContextSection[] { + const sections: SentMessageContextSection[] = []; + if (preview) { + sections.push({ title: "Message", body: preview }); + } + if (description) { + sections.push({ title: "Tool", body: description }); + } + if (hasArgs) { + sections.push({ + title: "Parameters", + body: JSON.stringify(args, null, 2), + }); + } + if (hasResult) { + sections.push({ + title: isError ? "Error" : "Result", + body: formatCodeValue(result), + }); + } + if (sections.length === 0) { + sections.push({ + title: "Status", + body: "Waiting for tool details.", + }); + } + return sections; +} + +function SentMessageContextSections({ + sections, +}: { + sections: SentMessageContextSection[]; +}) { + return ( +
+ {sections.map((section) => ( + + ))} +
+ ); +} + +function SentMessageContextSectionAccordion({ + section, +}: { + section: SentMessageContextSection; +}) { + const [open, setOpen] = React.useState(false); + const body = section.body.trim(); + + return ( +
+ +
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/ShellCommandBlock.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/ShellCommandBlock.tsx new file mode 100644 index 000000000..198762262 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/ShellCommandBlock.tsx @@ -0,0 +1,31 @@ +import { Terminal } from "lucide-react"; + +import { parseShellToolOutput } from "../agentSessionUtils"; + +export function ShellCommandBlock({ + command, + result, +}: { + command: string; + result: string; +}) { + const output = parseShellToolOutput(result); + const stdout = output.stdout.trimEnd(); + + return ( +
+

+ + {command} +

+ {stdout ? ( +
+          {stdout}
+        
+ ) : null} +
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/TodoToolSummary.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/TodoToolSummary.tsx new file mode 100644 index 000000000..736a24622 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/TodoToolSummary.tsx @@ -0,0 +1,178 @@ +import type { TranscriptItem } from "../agentSessionTypes"; +import type { CompactToolSummary } from "../agentSessionToolSummary"; +import { + asRecord, + formatTranscriptTimestampTitle, + getToolString, + parseToolResultValue, +} from "../agentSessionUtils"; +import { + ActivityRow, + ActivityRowContent, + ActivityRowLabel, +} from "../activityRenderClasses/ActivityRow"; + +type TodoDisplayItem = { + checked: boolean; + text: string; +}; + +export function TodoToolSummary({ + duration, + fallbackPreview, + item, +}: { + duration: string | null; + fallbackPreview: string | null; + item: Extract; +}) { + const todos = buildTodoDisplayItems(item.args, item.result, fallbackPreview); + const actionLabel = { + verb: "Updated", + object: fallbackPreview ?? "todos", + }; + + return ( + + + {duration ? ( + + {duration} + + ) : null} + + {todos.length > 0 ? ( +
+ {todos.map((todo, index) => ( + + ))} +
+ ) : ( +

No todos.

+ )} +
+
+ ); +} + +export function isTodoSummary(summary: CompactToolSummary) { + return ( + summary.descriptor.groupKey === "plan:todo" || + summary.descriptor.operation === "todo" + ); +} + +function TodoCheckboxRow({ todo }: { todo: TodoDisplayItem }) { + return ( +
+ + {todo.text} +
+ ); +} + +function buildTodoDisplayItems( + args: Record, + result: string, + fallbackPreview: string | null, +): TodoDisplayItem[] { + const argTodos = extractTodoItemsFromArgs(args); + if (argTodos.length > 0) { + return argTodos; + } + + const resultTodos = extractTodoItemsFromResult(result); + if (resultTodos.length > 0) { + return resultTodos; + } + + return fallbackPreview && fallbackPreview !== "empty list" + ? [{ checked: false, text: fallbackPreview }] + : []; +} + +function extractTodoItemsFromArgs( + args: Record, +): TodoDisplayItem[] { + const todos = args.todos; + if (!Array.isArray(todos)) { + return []; + } + + return todos.flatMap((todo) => { + if (!todo || typeof todo !== "object") { + return []; + } + + const record = asRecord(todo); + const text = getToolString(record, ["text", "content", "label", "title"]); + if (!text) { + return []; + } + + return [ + { + checked: getTodoChecked(record), + text, + }, + ]; + }); +} + +function extractTodoItemsFromResult(result: string): TodoDisplayItem[] { + const resultText = getTodoResultText(result); + if (!resultText) { + return []; + } + + return resultText.split(/\r?\n/).flatMap((line) => { + const match = line.match(/^\s*[-*]\s+\[([ xX])]\s+(.+?)\s*$/); + if (!match) { + return []; + } + + return [ + { + checked: match[1].toLowerCase() === "x", + text: match[2], + }, + ]; + }); +} + +function getTodoResultText(result: string): string { + const parsed = parseToolResultValue(result); + if (typeof parsed === "string") { + return parsed; + } + + const record = asRecord(parsed); + return getToolString(record, ["stdout", "result", "text"]) ?? result; +} + +function getTodoChecked(record: Record) { + if (typeof record.done === "boolean") { + return record.done; + } + if (typeof record.checked === "boolean") { + return record.checked; + } + + const status = getToolString(record, ["status"])?.toLowerCase(); + return status === "completed" || status === "done"; +} diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/ToolDetailBlocks.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/ToolDetailBlocks.tsx new file mode 100644 index 000000000..440188110 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/ToolDetailBlocks.tsx @@ -0,0 +1,102 @@ +import { cn } from "@/shared/lib/cn"; +import type { FileEditDiff } from "../agentSessionFileEditDiff"; +import { FileEditDiffBlock, hasFileEditLineDiff } from "../FileEditDiffView"; +import { formatCodeValue } from "../agentSessionUtils"; +import { ShellCommandBlock } from "./ShellCommandBlock"; +import { ViewImageToolPreview } from "./ViewImageToolPreview"; + +export function ToolDetailBlocks({ + args, + description, + fileEditDiff, + hasArgs, + hasResult, + imagePreview, + isError, + result, + shellCommand, +}: { + args: Record; + description?: string; + fileEditDiff: FileEditDiff | null; + hasArgs: boolean; + hasResult: boolean; + imagePreview: { src: string | null; title: string | null } | null; + isError: boolean; + result: string; + shellCommand: string | null; +}) { + const showFileEditDiff = + fileEditDiff && hasFileEditLineDiff(fileEditDiff) && !isError; + const showShellCommand = shellCommand != null && !showFileEditDiff; + const showParameters = hasArgs && !showFileEditDiff; + + return ( +
+ {description ? ( +

+ {description} +

+ ) : null} + {imagePreview?.src ? ( + + ) : null} + {showShellCommand ? ( + + ) : showParameters ? ( + + ) : null} + {!showShellCommand && hasResult ? ( + showFileEditDiff ? ( + + ) : ( + + ) + ) : null} + {!showShellCommand && !showParameters && !hasResult ? ( +

+ Waiting for tool details. +

+ ) : null} +
+ ); +} + +function ToolCodeBlock({ + label, + tone, + value, +}: { + label: string; + tone: "muted" | "error"; + value: string; +}) { + return ( +
+

+ {label} +

+
+        {formatCodeValue(value)}
+      
+
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx new file mode 100644 index 000000000..5b4c7e4fb --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx @@ -0,0 +1,159 @@ +import * as React from "react"; + +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import type { TranscriptItem } from "../agentSessionTypes"; +import { getBuzzToolInfo } from "../agentSessionToolCatalog"; +import { buildCompactToolSummary } from "../agentSessionToolSummary"; +import type { AgentTranscriptIdentityProps } from "../activityRenderClasses/types"; +import { + formatTranscriptTimestampTitle, + getToolDurationDisplay, + getToolString, +} from "../agentSessionUtils"; +import { CompactMessageSummary } from "./CompactMessageSummary"; +import { + CompactToolSummaryRow, + compactSummaryTone, +} from "./CompactToolSummaryRow"; +import { getSentMessageLink } from "./messageLinks"; +import { isTodoSummary, TodoToolSummary } from "./TodoToolSummary"; +import { ToolDetailBlocks } from "./ToolDetailBlocks"; + +export function ToolItem({ + agentAvatarUrl, + agentName, + agentPubkey, + item, + profiles, +}: AgentTranscriptIdentityProps & { + item: Extract; + profiles?: UserProfileLookup; +}) { + const [isExpanded, setIsExpanded] = React.useState(false); + const hasArgs = Object.keys(item.args).length > 0; + const hasResult = item.result.trim().length > 0; + const canonicalToolName = item.buzzToolName ?? item.toolName; + const buzzTool = getBuzzToolInfo(canonicalToolName); + const compactSummary = buildCompactToolSummary(item); + const duration = getToolDurationDisplay(item); + const messageLink = getSentMessageLink(item); + const timestampTitle = formatTranscriptTimestampTitle(item.timestamp); + const agentProfile = profiles?.[normalizePubkey(agentPubkey)] ?? null; + const agentLabel = resolveUserLabel({ + pubkey: agentPubkey, + fallbackName: agentName, + profiles, + preferResolvedSelfLabel: true, + }); + const agentResolvedAvatarUrl = agentProfile?.avatarUrl ?? agentAvatarUrl; + const handleToggle = React.useCallback( + (event: React.SyntheticEvent) => { + setIsExpanded(event.currentTarget.open); + }, + [], + ); + + if (compactSummary.presentation === "message") { + return ( +
+ +
+ ); + } + + if (isTodoSummary(compactSummary)) { + return ( +
+ +
+ ); + } + + return ( +
+
+ + + + + +
+
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/ViewImageToolPreview.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/ViewImageToolPreview.tsx new file mode 100644 index 000000000..78d37dcb7 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/ViewImageToolPreview.tsx @@ -0,0 +1,108 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; + +import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import { isInlineImageData } from "../agentSessionUtils"; + +export function ViewImageToolPreview({ + src, + title, +}: { + src: string; + title: string | null; +}) { + const [lightboxOpen, setLightboxOpen] = React.useState(false); + const [imageFailed, setImageFailed] = React.useState(false); + const resolvedSrc = React.useMemo(() => resolveImageSrc(src), [src]); + const alt = title ?? "Viewed image"; + + if (imageFailed) { + return null; + } + + return ( + <> + {/* biome-ignore lint/a11y/useKeyWithClickEvents: opens lightbox on click */} + {alt} setLightboxOpen(true)} + onError={() => setImageFailed(true)} + src={resolvedSrc} + title={title ?? undefined} + /> + + + ); +} + +function ImageLightbox({ + alt, + onOpenChange, + open, + src, +}: { + alt: string; + onOpenChange: (open: boolean) => void; + open: boolean; + src: string; +}) { + return ( + + + + event.preventDefault()} + onPointerDownOutside={(event) => event.preventDefault()} + > + + {alt} + + + Full-size image preview. Press Escape or click outside the image to + close. + + + {alt} + + + + + + + ); +} + +function resolveImageSrc(source: string): string { + return isInlineImageData(source) ? source : rewriteRelayUrl(source); +} diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/messageLinks.ts b/desktop/src/features/agents/ui/AgentSessionToolItem/messageLinks.ts new file mode 100644 index 000000000..ba483ceed --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/messageLinks.ts @@ -0,0 +1,76 @@ +import type { TranscriptItem } from "../agentSessionTypes"; +import { + asRecord, + getToolString, + parseToolResultValue, +} from "../agentSessionUtils"; + +export type SentMessageLink = { + channelId: string; + messageId: string; +}; + +export function getSentMessageLink( + item: Extract, +): SentMessageLink | null { + if (item.status !== "completed" || item.isError) { + return null; + } + + if (item.descriptor?.renderClass !== "message") { + return null; + } + + const channelId = + item.channelId ?? getToolString(item.args, ["channel_id", "channelId"]); + if (!channelId) { + return null; + } + + const resultRecord = getMessageSendResultRecord(item.result); + if (!resultRecord || resultRecord.accepted === false) { + return null; + } + + const messageId = getToolString(resultRecord, [ + "event_id", + "eventId", + "message_id", + "messageId", + ]); + if (!messageId) { + return null; + } + + return { + channelId, + messageId, + }; +} + +function getMessageSendResultRecord( + result: string, +): Record | null { + const parsed = parseToolResultValue(result); + const directRecord = asRecord(parsed); + if (getMessageEventId(directRecord)) { + return directRecord; + } + + const stdout = getToolString(directRecord, ["stdout"]); + if (!stdout) { + return null; + } + + const stdoutRecord = asRecord(parseToolResultValue(stdout)); + return getMessageEventId(stdoutRecord) ? stdoutRecord : null; +} + +function getMessageEventId(record: Record) { + return getToolString(record, [ + "event_id", + "eventId", + "message_id", + "messageId", + ]); +} diff --git a/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx index d31b3e747..3f7283312 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx @@ -23,7 +23,7 @@ export function PlanActivity(props: ActivityRenderClassItemProps) { title={formatTranscriptTimestampTitle(props.item.timestamp)} > } openToneScope="none" verb="Updated" /> @@ -46,3 +46,17 @@ export function PlanActivity(props: ActivityRenderClassItemProps) { ); } + +function PlanUpdateLabelObject({ text }: { text: string }) { + return ( + <> + plan + {text ? ( + <> + {" · "} + {text} + + ) : null} + + ); +} diff --git a/desktop/src/features/agents/ui/agentSessionToolItemHelpers.test.mjs b/desktop/src/features/agents/ui/agentSessionToolItemHelpers.test.mjs index abd75f72b..17d621da4 100644 --- a/desktop/src/features/agents/ui/agentSessionToolItemHelpers.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionToolItemHelpers.test.mjs @@ -6,6 +6,7 @@ import { formatTranscriptTime, getToolDurationDisplay, isInlineImageData, + parseShellToolOutput, parseToolResultValue, } from "./agentSessionUtils.ts"; @@ -92,6 +93,36 @@ test("parseToolResultValue returns null for invalid JSON", () => { assert.equal(parseToolResultValue("not json {"), null); }); +test("parseShellToolOutput extracts stdout from a shell result envelope", () => { + assert.deepEqual( + parseShellToolOutput( + JSON.stringify({ + exit_code: 0, + stdout: "4 files changed\n", + stderr: "", + timed_out: false, + }), + ), + { + exitCode: 0, + raw: "", + stderr: "", + stdout: "4 files changed\n", + timedOut: false, + }, + ); +}); + +test("parseShellToolOutput preserves non-envelope output as raw text", () => { + assert.deepEqual(parseShellToolOutput(JSON.stringify("plain output")), { + exitCode: null, + raw: "plain output", + stderr: "", + stdout: "", + timedOut: false, + }); +}); + // ---- getToolDurationDisplay (fallback chain) ---- const startedAt = "2026-06-14T19:00:00.000Z"; diff --git a/desktop/src/features/agents/ui/agentSessionUtils.ts b/desktop/src/features/agents/ui/agentSessionUtils.ts index 28b613824..76404eae8 100644 --- a/desktop/src/features/agents/ui/agentSessionUtils.ts +++ b/desktop/src/features/agents/ui/agentSessionUtils.ts @@ -50,6 +50,44 @@ export function formatCodeValue(value: string): string { } } +export type ShellToolOutput = { + exitCode: number | null; + raw: string; + stderr: string; + stdout: string; + timedOut: boolean; +}; + +export function parseShellToolOutput(result: string): ShellToolOutput { + const parsed = parseToolResultValue(result); + const record = asRecord(parsed); + const hasShellShape = + "stdout" in record || + "stderr" in record || + "exit_code" in record || + "exitCode" in record || + "timed_out" in record || + "timedOut" in record; + + if (!hasShellShape) { + return { + exitCode: null, + raw: typeof parsed === "string" ? parsed : result, + stderr: "", + stdout: "", + timedOut: false, + }; + } + + return { + exitCode: getToolNumber(record, ["exit_code", "exitCode"]), + raw: "", + stderr: getOptionalString(record, ["stderr"]), + stdout: getOptionalString(record, ["stdout"]), + timedOut: getOptionalBoolean(record, ["timed_out", "timedOut"]), + }; +} + export function titleCase(value: string): string { return value .replace(/[_-]+/g, " ") @@ -88,6 +126,32 @@ function getToolNumber( return null; } +function getOptionalBoolean( + record: Record, + keys: string[], +): boolean { + for (const key of keys) { + const value = record[key]; + if (typeof value === "boolean") { + return value; + } + } + return false; +} + +function getOptionalString( + record: Record, + keys: string[], +): string { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string") { + return value; + } + } + return ""; +} + /** Format a millisecond duration; negative input yields null. */ export function formatDurationMs(ms: number): string | null { if (ms < 0) return null; From 7d2a9795b815226ad244eb98d8fe52a9e5505ecd Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 30 Jun 2026 00:59:56 -0700 Subject: [PATCH 37/49] fix(desktop): align activity diff stats - Render activity row diff stats in a single inline-flex group so additions and deletions share the same baseline, line height, and font size. - Switch compact diff stat colors from hardcoded green/red Tailwind utilities to theme-backed status tokens. - Preserve the existing compact +N/-N summary behavior while improving light and dark theme consistency. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../agents/ui/activityRenderClasses/ActivityRow.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx b/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx index f02ed57a1..11fbe7ae9 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx @@ -156,14 +156,10 @@ ActivityRowContent.marker = ACTIVITY_ROW_CONTENT_MARKER; function ActivityRowStatsView({ stats }: { stats: ActivityRowStats }) { return ( - <> - - +{stats.additions} - - - -{stats.deletions} - - + + +{stats.additions} + -{stats.deletions} + ); } From d54e033a9641f93dfeca8990bdcb712d68f35437 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 30 Jun 2026 01:01:20 -0700 Subject: [PATCH 38/49] fix(desktop): wrap lifecycle error text inline - Update the agent lifecycle error row to render the alert icon, title, separator, and detail text in a single inline flow. - Remove the flex-row layout so long turn error messages can wrap naturally within the error card instead of forcing the detail text across one row. - Preserve the existing destructive tone, border, background, and timestamp tooltip behavior for lifecycle errors. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../agents/ui/activityRenderClasses/LifecycleActivity.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx index 3a2b33d86..c4616b673 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx @@ -21,14 +21,14 @@ export function LifecycleActivity(props: ActivityRenderClassItemProps) { if (isError) { return (
- + {props.item.title} {props.item.text ? ( - · {props.item.text} + · {props.item.text} ) : null}
); From 9771d2d0c62e278d43f38c8886b3e5e21f845cac Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 30 Jun 2026 01:05:10 -0700 Subject: [PATCH 39/49] fix(desktop): render suppressed activity as one-line rows - Replace the suppressed tool passthrough with a dedicated ActivityRow presenter - Preserve action labels, timestamps, and duration text without mounting generic tool details - Avoid extra expanded detail padding for stop-hook items like Checked todos Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../SuppressedActivity.tsx | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/agents/ui/activityRenderClasses/SuppressedActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/SuppressedActivity.tsx index cc2c306c9..099a7a40d 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/SuppressedActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/SuppressedActivity.tsx @@ -1,6 +1,39 @@ -import { ToolActivity } from "./ToolActivity"; +import { + formatTranscriptTimestampTitle, + getToolDurationDisplay, +} from "../agentSessionUtils"; +import { + ActivityRow, + ActivityRowLabel, + splitActivityRowLabel, +} from "./ActivityRow"; import type { ActivityRenderClassItemProps } from "./types"; export function SuppressedActivity(props: ActivityRenderClassItemProps) { - return ; + if (props.item.type !== "tool") { + return null; + } + + const action = props.item.descriptor.action; + const labelParts = + action ?? splitActivityRowLabel(props.item.descriptor.label); + const duration = getToolDurationDisplay(props.item); + + return ( + + + {duration ? ( + + {duration} + + ) : null} + + ); } From 25d3ed29d5efcd84d7bdab8eedc9a73d2bb627d5 Mon Sep 17 00:00:00 2001 From: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f Date: Tue, 30 Jun 2026 01:18:51 -0700 Subject: [PATCH 40/49] test(desktop): add activity debug-fixture screenshot specs Temporary Playwright specs that mount the activity panel via the mock bridge, enable the debug render-class fixture, and capture the full taxonomy (feed walk, cog menu, raw rail, expanded states) into __shots__. Travels with the temporary debug harness and is stripped alongside it. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/playwright.config.ts | 2 + .../tests/e2e/activity-debug-expanded.spec.ts | 102 ++++++++++++++ .../tests/e2e/activity-debug-fixture.spec.ts | 129 ++++++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 desktop/tests/e2e/activity-debug-expanded.spec.ts create mode 100644 desktop/tests/e2e/activity-debug-fixture.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 0c827cc95..0e1d07c32 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -19,6 +19,8 @@ export default defineConfig({ { name: "smoke", testMatch: [ + "**/activity-debug-fixture.spec.ts", + "**/activity-debug-expanded.spec.ts", "**/smoke.spec.ts", "**/navigation.spec.ts", "**/channels.spec.ts", diff --git a/desktop/tests/e2e/activity-debug-expanded.spec.ts b/desktop/tests/e2e/activity-debug-expanded.spec.ts new file mode 100644 index 000000000..f8c9cd6ae --- /dev/null +++ b/desktop/tests/e2e/activity-debug-expanded.spec.ts @@ -0,0 +1,102 @@ +import { expect, test } from "@playwright/test"; +import { installMockBridge } from "../helpers/bridge"; + +const AGENT = "ce".repeat(32); +const SHOTS = "tests/e2e/__shots__"; + +async function openAndDebug(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("app-sidebar").waitFor({ state: "visible" }); + await page.getByTestId("channel-engineering").click(); + await expect(page.getByTestId("chat-title")).toHaveText("engineering"); + await page.waitForTimeout(400); + await page.getByTestId("channel-members-trigger").first().click(); + await page.getByTestId("members-sidebar").waitFor({ state: "visible" }); + await page.waitForTimeout(300); + await page.getByTestId(`sidebar-member-menu-${AGENT}`).click({ force: true }); + await page.getByTestId(`sidebar-view-activity-${AGENT}`).click(); + await page + .getByTestId("agent-session-thread-panel") + .waitFor({ state: "visible" }); + await page.waitForTimeout(500); + await page.getByTestId("agent-session-settings-menu-trigger").click(); + await page.getByTestId("agent-session-toggle-debug-render-classes").click(); + // Dismiss the dropdown with an outside-click (Escape was leaving the menu open + // and overlapping the top of the expanded captures). Click the chat title area, + // which is well clear of the Activity panel, then confirm the menu is gone. + await page.getByTestId("chat-title").click({ force: true }); + await expect( + page.getByTestId("agent-session-toggle-debug-render-classes"), + ).toHaveCount(0); + await page.waitForTimeout(500); +} + +test("expanded disclosure rows", async ({ page }) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: AGENT, + name: "Cerberus", + status: "running", + channelNames: ["engineering"], + backend: { type: "local" }, + }, + ], + }); + await page.setViewportSize({ width: 1280, height: 1400 }); + await openAndDebug(page); + const panel = page.getByTestId("agent-session-thread-panel"); + + // Expand every collapsible disclosure row in the feed. + // Tool rows use native
; thought/prompt rows use aria-expanded buttons. + await panel.evaluate((root) => { + root.querySelectorAll("details").forEach((d) => { + (d as HTMLDetailsElement).open = true; + }); + }); + const expanders = panel.locator( + '[aria-expanded="false"]:not([data-testid="agent-session-settings-menu-trigger"])', + ); + const n = await expanders.count(); + for (let i = 0; i < n; i++) { + const el = expanders.nth(i); + try { + await el.click({ force: true, timeout: 1000 }); + } catch {} + await page.waitForTimeout(80); + } + // Belt-and-suspenders: ensure the settings dropdown is not open before we + // screenshot (the expander loop or a stray focus can reopen it). Click the + // chat title (well clear of the Activity panel) and confirm the menu is gone. + if ( + await page.getByTestId("agent-session-toggle-debug-render-classes").count() + ) { + await page.getByTestId("chat-title").click({ force: true }); + await expect( + page.getByTestId("agent-session-toggle-debug-render-classes"), + ).toHaveCount(0); + } + await page.waitForTimeout(500); + + const sc = panel.locator(".overflow-y-auto").first(); + const m = await sc.evaluate((el) => { + (el as HTMLElement).scrollTop = 0; + return { sh: el.scrollHeight, ch: el.clientHeight }; + }); + await page.waitForTimeout(300); + const step = Math.max(200, m.ch - 140); + let y = 0, + i = 0; + while (y < m.sh && i < 16) { + await sc.evaluate((el, top) => { + (el as HTMLElement).scrollTop = top; + }, y); + await page.waitForTimeout(300); + await panel.screenshot({ + path: `${SHOTS}/expanded-${String(i).padStart(2, "0")}.png`, + }); + y += step; + i += 1; + } + console.log("EXPMETRICS:" + JSON.stringify({ ...m, expanders: n, steps: i })); +}); diff --git a/desktop/tests/e2e/activity-debug-fixture.spec.ts b/desktop/tests/e2e/activity-debug-fixture.spec.ts new file mode 100644 index 000000000..db0e16bc3 --- /dev/null +++ b/desktop/tests/e2e/activity-debug-fixture.spec.ts @@ -0,0 +1,129 @@ +import { expect, test } from "@playwright/test"; +import { installMockBridge } from "../helpers/bridge"; + +const AGENT = "ce".repeat(32); +const SHOTS = "tests/e2e/__shots__"; + +async function openActivityPanel(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("app-sidebar").waitFor({ state: "visible" }); + await page.getByTestId("channel-engineering").click(); + await expect(page.getByTestId("chat-title")).toHaveText("engineering"); + await page.waitForTimeout(400); + await page.getByTestId("channel-members-trigger").first().click(); + await page.getByTestId("members-sidebar").waitFor({ state: "visible" }); + await page.waitForTimeout(300); + await page.getByTestId(`sidebar-member-menu-${AGENT}`).click({ force: true }); + await page.getByTestId(`sidebar-view-activity-${AGENT}`).click(); + await page + .getByTestId("agent-session-thread-panel") + .waitFor({ state: "visible" }); + await page.waitForTimeout(600); +} + +async function enableDebug(page: import("@playwright/test").Page) { + await page.getByTestId("agent-session-settings-menu-trigger").click(); + await page.getByTestId("agent-session-toggle-debug-render-classes").click(); + await page.keyboard.press("Escape"); + await page.waitForTimeout(500); +} + +function scroller(page: import("@playwright/test").Page) { + return page + .locator('[data-testid="agent-session-thread-panel"] .overflow-y-auto') + .first(); +} + +test("debug fixture taxonomy", async ({ page }) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: AGENT, + name: "Cerberus", + status: "running", + channelNames: ["engineering"], + backend: { type: "local" }, + }, + ], + }); + // Tall viewport so each scroll step grabs a big slice of the feed. + await page.setViewportSize({ width: 1280, height: 1400 }); + await openActivityPanel(page); + await enableDebug(page); + + const panel = page.getByTestId("agent-session-thread-panel"); + const sc = scroller(page); + + // Scroll to top, then walk down capturing the panel each step. + const metrics = await sc.evaluate((el) => { + (el as HTMLElement).scrollTop = 0; + return { sh: el.scrollHeight, ch: el.clientHeight }; + }); + await page.waitForTimeout(300); + + const step = Math.max(200, metrics.ch - 140); // overlap so nothing is lost at seams + let y = 0; + let i = 0; + while (y < metrics.sh) { + await sc.evaluate((el, top) => { + (el as HTMLElement).scrollTop = top; + }, y); + await page.waitForTimeout(350); + await panel.screenshot({ + path: `${SHOTS}/feed-${String(i).padStart(2, "0")}.png`, + }); + y += step; + i += 1; + if (i > 12) break; + } + console.log("METRICS:" + JSON.stringify({ ...metrics, steps: i })); +}); + +test("cog menu open", async ({ page }) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: AGENT, + name: "Cerberus", + status: "running", + channelNames: ["engineering"], + backend: { type: "local" }, + }, + ], + }); + await page.setViewportSize({ width: 1280, height: 900 }); + await openActivityPanel(page); + await page.getByTestId("agent-session-settings-menu-trigger").click(); + await page.waitForTimeout(400); + await page.screenshot({ path: `${SHOTS}/cog-menu.png` }); +}); + +test("raw rail on", async ({ page }) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: AGENT, + name: "Cerberus", + status: "running", + channelNames: ["engineering"], + backend: { type: "local" }, + }, + ], + }); + await page.setViewportSize({ width: 1280, height: 1400 }); + await openActivityPanel(page); + await enableDebug(page); + // Toggle raw feed on too + await page.getByTestId("agent-session-settings-menu-trigger").click(); + await page.getByTestId("agent-session-toggle-raw-feed").click(); + await page.keyboard.press("Escape"); + await page.waitForTimeout(600); + const sc = scroller(page); + await sc.evaluate((el) => { + (el as HTMLElement).scrollTop = 0; + }); + await page.waitForTimeout(300); + await page + .getByTestId("agent-session-thread-panel") + .screenshot({ path: `${SHOTS}/raw-rail.png` }); +}); From c864d4fc3be411b147f3044c292ca9acadb2c586 Mon Sep 17 00:00:00 2001 From: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f Date: Tue, 30 Jun 2026 01:53:28 -0700 Subject: [PATCH 41/49] fix(desktop): give permission activity its own distinct row treatment Permission lifecycle items rendered through the generic 'Status' branch of LifecycleActivity, so a 'Permission requested' interrupt looked identical to ordinary status rows. Add a dedicated permission branch (amber pill + ShieldCheck icon) mirroring the existing error-pill treatment, and a distinct transcript-permission-item testid so it can be targeted in tests and screenshots. Keeps the shared row vocabulary; no live-renderer or grouping changes. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../LifecycleActivity.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx index c4616b673..88cb3f055 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx @@ -1,4 +1,4 @@ -import { AlertCircle } from "lucide-react"; +import { AlertCircle, ShieldCheck } from "lucide-react"; import { formatTranscriptTimestampTitle } from "../agentSessionUtils"; import { ActivityRow, ActivityRowLabel } from "./ActivityRow"; @@ -16,8 +16,25 @@ export function LifecycleActivity(props: ActivityRenderClassItemProps) { const isError = props.item.renderClass === "error" || props.item.title.toLowerCase().includes("error"); + const isPermission = props.item.renderClass === "permission"; const timestampTitle = formatTranscriptTimestampTitle(props.item.timestamp); + if (isPermission) { + return ( +
+ + {props.item.title} + {props.item.text ? ( + · {props.item.text} + ) : null} +
+ ); + } + if (isError) { return (
Date: Tue, 30 Jun 2026 02:35:44 -0700 Subject: [PATCH 42/49] fix(desktop): surface permission transcript rows Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../ui/agentSessionToolClassifier.test.mjs | 70 +++++++ .../agents/ui/agentSessionToolClassifier.ts | 6 +- .../agents/ui/agentSessionTranscript.test.mjs | 88 +++++++++ .../agents/ui/agentSessionTranscript.ts | 181 +++++++++++++++++- .../agentSessionTranscriptGrouping.test.mjs | 41 ++++ .../ui/agentSessionTranscriptGrouping.ts | 12 -- .../features/channels/ui/BotActivityBar.tsx | 11 +- 7 files changed, 388 insertions(+), 21 deletions(-) create mode 100644 desktop/src/features/agents/ui/agentSessionToolClassifier.test.mjs diff --git a/desktop/src/features/agents/ui/agentSessionToolClassifier.test.mjs b/desktop/src/features/agents/ui/agentSessionToolClassifier.test.mjs new file mode 100644 index 000000000..77c8de489 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolClassifier.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + classifyTool, + extractSimpleEchoPipeContent, + parseBuzzCliCommand, + tokenizeShellCommand, +} from "./agentSessionToolClassifier.ts"; + +test("tokenizeShellCommand preserves quoted strings and command separators", () => { + assert.deepEqual( + tokenizeShellCommand( + 'echo "hello world" | buzz messages send --content - --channel agents; buzz feed get', + ), + [ + "echo", + "hello world", + "|", + "buzz", + "messages", + "send", + "--content", + "-", + "--channel", + "agents", + ";", + "buzz", + "feed", + "get", + ], + ); +}); + +test("extractSimpleEchoPipeContent reads the simple echo before a buzz pipe", () => { + const tokens = tokenizeShellCommand( + 'echo -n "Done. Eat my shorts." | buzz messages send --content - --channel agents', + ); + assert.equal( + extractSimpleEchoPipeContent(tokens, tokens.indexOf("buzz")), + "Done. Eat my shorts.", + ); +}); + +test("parseBuzzCliCommand promotes buzz message sends to message descriptors", () => { + const descriptor = parseBuzzCliCommand( + 'echo "Permission wired" | buzz messages send --channel agents --content -', + ); + + assert.equal(descriptor?.renderClass, "message"); + assert.equal(descriptor?.label, "Send Message"); + assert.equal(descriptor?.preview, "Permission wired"); + assert.equal(descriptor?.operation, "messages.send"); +}); + +test("classifyTool promotes buzz CLI shell commands to relay operations", () => { + const descriptor = classifyTool({ + title: "Shell", + toolName: "dev__shell", + buzzToolName: null, + args: { command: "buzz channels get --channel buzz-agent-observability" }, + result: "{}", + isError: false, + }); + + assert.equal(descriptor.renderClass, "relay-op"); + assert.equal(descriptor.label, "Channels Get"); + assert.equal(descriptor.preview, "buzz-agent-observability"); + assert.equal(descriptor.groupKey, "buzz-cli:channels.get"); +}); diff --git a/desktop/src/features/agents/ui/agentSessionToolClassifier.ts b/desktop/src/features/agents/ui/agentSessionToolClassifier.ts index 5293e7e2e..39e4a7636 100644 --- a/desktop/src/features/agents/ui/agentSessionToolClassifier.ts +++ b/desktop/src/features/agents/ui/agentSessionToolClassifier.ts @@ -334,7 +334,9 @@ function classifyDeveloperToolName(value: string | null | undefined) { return null; } -function parseBuzzCliCommand(command: string): AgentActivityDescriptor | null { +export function parseBuzzCliCommand( + command: string, +): AgentActivityDescriptor | null { const tokens = tokenizeShellCommand(command); const range = findBuzzCommand(tokens); if (!range) return null; @@ -562,7 +564,7 @@ function getFlagValue(tokens: string[], start: number, flag: string) { return null; } -function extractSimpleEchoPipeContent( +export function extractSimpleEchoPipeContent( tokens: string[], buzzIndex: number, ): string | null { diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index f227aba20..49d39d41f 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { buildTranscript } from "./agentSessionTranscript.ts"; import { formatToolTitle } from "./agentSessionToolCatalog.ts"; +import { DEBUG_AGENT_ACTIVITY_TRANSCRIPT } from "./debugAgentActivityRawFixture.ts"; const baseEvent = { seq: 1, @@ -479,3 +480,90 @@ test("buildTranscript stores first-class render class descriptors for tool items assert.equal(item.descriptor.label, "Edited file"); assert.equal(item.descriptor.preview, "src/app.ts"); }); + +test("buildTranscript surfaces session/request_permission as a permission lifecycle item", () => { + const transcript = buildTranscript([ + { + seq: 1, + timestamp: "2026-06-30T09:00:00.000Z", + kind: "acp_read", + agentIndex: 0, + channelId: "channel-1", + sessionId: "session-1", + turnId: "turn-1", + payload: { + jsonrpc: "2.0", + method: "session/request_permission", + params: { + toolCallId: "tool-1", + title: "Confirm force-with-lease push to block/buzz.", + options: [ + { optionId: "allow_once", kind: "allow_once", name: "Allow" }, + { optionId: "reject_once", kind: "reject_once", name: "Reject" }, + ], + }, + }, + }, + ]); + + assert.equal(transcript.length, 1); + assert.equal(transcript[0].type, "lifecycle"); + assert.equal(transcript[0].renderClass, "permission"); + assert.equal(transcript[0].title, "Permission requested"); + assert.match(transcript[0].text, /Confirm force-with-lease push/); +}); + +test("buildTranscript stamps completedAt when a terminal tool update is inserted first", () => { + const transcript = buildTranscript([ + { + seq: 1, + timestamp: "2026-06-30T09:00:00.000Z", + kind: "acp_read", + agentIndex: 0, + channelId: "channel-1", + sessionId: "session-1", + turnId: "turn-1", + payload: { + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + toolName: "dev__shell", + status: "completed", + rawInput: { command: "echo hi" }, + content: [{ type: "text", text: "hi" }], + }, + }, + }, + }, + ]); + + assert.equal(transcript[0].type, "tool"); + assert.equal(transcript[0].completedAt, "2026-06-30T09:00:00.000Z"); +}); + +test("debug raw fixture makes permission, free-form status, and raw rail screenshotable", () => { + assert.ok( + DEBUG_AGENT_ACTIVITY_TRANSCRIPT.some( + (item) => + item.id.startsWith("permission:") && item.renderClass === "permission", + ), + "permission request should flow through the reducer", + ); + assert.ok( + DEBUG_AGENT_ACTIVITY_TRANSCRIPT.some( + (item) => + item.renderClass === "status" && item.title === "Observer connected", + ), + "free-form status fixture should flow through the reducer", + ); + assert.ok( + DEBUG_AGENT_ACTIVITY_TRANSCRIPT.some( + (item) => item.type === "metadata" && item.renderClass === "raw-rail", + ), + "raw_json_rpc fixture should flow through the reducer", + ); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index 0a51f1a74..870b50be7 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -1,4 +1,6 @@ import type { + AgentActivityDescriptor, + AgentActivityRenderClass, ObserverEvent, PromptSection, ToolStatus, @@ -10,7 +12,7 @@ import { normalizeToolStatus, } from "./agentSessionToolCatalog"; import { classifyTool } from "./agentSessionToolClassifier"; -import { asRecord, asString } from "./agentSessionUtils"; +import { asRecord, asString, titleCase } from "./agentSessionUtils"; import { describeTurnStarted, describeSessionResolved, @@ -143,6 +145,69 @@ function maybeNostrEventId(id: string | null | undefined) { return id && /^[0-9a-fA-F]{64}$/.test(id) ? id : null; } +function stringifyPayload(value: unknown) { + try { + return JSON.stringify(value, null, 2) ?? String(value); + } catch { + return String(value); + } +} + +function describePermissionRequest(payload: Record) { + const params = asRecord(payload.params); + const title = + asString(params.title) ?? + asString(params.message) ?? + asString(params.reason) ?? + "Permission requested"; + const toolCallId = + asString(params.toolCallId) ?? asString(params.tool_call_id); + const options = Array.isArray(params.options) + ? params.options + .map((option) => { + const record = asRecord(option); + return ( + asString(record.name) ?? + asString(record.kind) ?? + asString(record.optionId) + ); + }) + .filter((option): option is string => Boolean(option)) + : []; + const detail = [title]; + if (toolCallId) detail.push(`Tool call: ${toolCallId}`); + if (options.length > 0) detail.push(`Options: ${options.join(", ")}`); + return { + title, + text: detail.join("\n"), + descriptor: { + renderClass: "permission" as const, + label: "Permission requested", + preview: title, + action: { verb: "Requested", object: title }, + tone: "admin" as const, + operation: "session/request_permission", + object: title, + source: "acp" as const, + groupKey: "permission:request", + }, + }; +} + +function describeFreeformStatus(payload: Record) { + const statusType = asString(payload.type) ?? asString(payload.status); + const title = + asString(payload.title) ?? (statusType ? titleCase(statusType) : null); + const text = asString(payload.text) ?? asString(payload.message); + if (!title || !text) return null; + return { statusType: statusType ?? title.toLowerCase(), title, text }; +} + +function rawPayloadTitle(payload: unknown) { + const record = asRecord(payload); + return asString(record.method) ?? asString(record.type) ?? "raw_json_rpc"; +} + type TranscriptItemContext = { channelId: string | null; turnId: string | null; @@ -240,13 +305,57 @@ function upsertTextItem( return; } + upsertLifecycleItem( + d, + id, + title.toLowerCase().includes("error") ? "error" : "status", + title, + text, + timestamp, + ctx, + acpSource, + ); +} + +function upsertLifecycleItem( + d: TranscriptDraft, + id: string, + renderClass: Extract< + AgentActivityRenderClass, + "status" | "permission" | "error" + >, + title: string, + text: string, + timestamp: string, + ctx: TranscriptItemContext, + acpSource?: string, + descriptor?: AgentActivityDescriptor, +) { + const existing = d.itemsById.get(id); + if (existing?.type === "lifecycle") { + replaceItem(d, id, { + ...existing, + renderClass, + title, + text: existing.text + text, + descriptor: descriptor ?? existing.descriptor, + channelId: ctx.channelId, + turnId: ctx.turnId ?? existing.turnId, + sessionId: ctx.sessionId ?? existing.sessionId, + acpSource: acpSource ?? existing.acpSource, + }); + return; + } + + sealOpenMessages(d); pushItem(d, { id, type: "lifecycle", - renderClass: title.toLowerCase().includes("error") ? "error" : "status", + renderClass, title, text, timestamp, + descriptor, channelId: ctx.channelId, turnId: ctx.turnId, sessionId: ctx.sessionId, @@ -456,7 +565,7 @@ function upsertTool( isError, timestamp, startedAt: timestamp, - completedAt: null, + completedAt: isTerminalToolStatus(status) ? timestamp : null, channelId: ctx.channelId, turnId: ctx.turnId, sessionId: ctx.sessionId, @@ -482,7 +591,22 @@ export function processTranscriptEvent( sessionId: event.sessionId ?? d.latestSessionId, }; - if (event.kind === "turn_started") { + if (event.kind === "raw_json_rpc") { + upsertMetadata( + d, + `raw-json-rpc:${ch}:${event.seq}`, + "Raw ACP payload", + [ + { + title: rawPayloadTitle(event.payload), + body: stringifyPayload(event.payload), + }, + ], + event.timestamp, + ctx, + event.kind, + ); + } else if (event.kind === "turn_started") { rememberTriggeringEventIds( d, ch, @@ -541,7 +665,20 @@ export function processTranscriptEvent( const payload = asRecord(event.payload); const method = asString(payload.method); - if (event.kind === "acp_write" && method === "session/prompt") { + if (method === "session/request_permission") { + const request = describePermissionRequest(payload); + upsertLifecycleItem( + d, + `permission:${ch}:${event.turnId ?? event.seq}`, + "permission", + "Permission requested", + request.text, + event.timestamp, + ctx, + "permission_request", + request.descriptor, + ); + } else if (event.kind === "acp_write" && method === "session/prompt") { const promptText = extractPromptText(payload); if (promptText) { const parsedPrompt = parsePromptText(promptText); @@ -723,6 +860,40 @@ export function processTranscriptEvent( updateType, `plan-update:${ch}:${turnKey}:${event.seq}`, ); + } else { + // Free-form observer status records are not part of the ACP session/update + // union. Surface only explicit title/text payloads; leave all other + // unknown frames out of the feed instead of guessing at semantics. + const status = describeFreeformStatus(payload); + if (status) { + upsertLifecycleItem( + d, + `status:${ch}:${event.turnId ?? event.seq}:${status.statusType}`, + "status", + status.title, + status.text, + event.timestamp, + ctx, + status.statusType, + ); + } + } + } else { + // Free-form observer status records are not part of the ACP JSON-RPC + // method set. Surface only explicit title/text payloads; leave all other + // unknown frames out of the feed instead of guessing at semantics. + const status = describeFreeformStatus(payload); + if (status) { + upsertLifecycleItem( + d, + `status:${ch}:${event.turnId ?? event.seq}:${status.statusType}`, + "status", + status.title, + status.text, + event.timestamp, + ctx, + status.statusType, + ); } } } diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs index 9c2180e85..e16f1cd77 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs @@ -284,3 +284,44 @@ test("buildTranscriptDisplayBlocks groups consecutive file edit tool runs", () = ["edit:1", "edit:2"], ); }); + +test("buildTranscriptDisplayBlocks keeps non-contiguous same-kind runs expanded", () => { + const mkTool = (id, label, renderClass = "generic", groupKey = label) => ({ + id, + type: "tool", + renderClass, + descriptor: { + renderClass, + label, + preview: id, + source: "harness", + groupKey, + }, + title: label, + toolName: label, + buzzToolName: null, + status: "completed", + args: {}, + result: "", + isError: false, + timestamp: "2026-06-18T00:00:00Z", + startedAt: "2026-06-18T00:00:00Z", + completedAt: "2026-06-18T00:00:01Z", + turnId: "turn-1", + sessionId: "sess-1", + channelId: "chan-1", + }); + + const [block] = buildTranscriptDisplayBlocks([ + mkTool("read-1", "Read file", "generic", "read_file"), + mkTool("shell-1", "Ran command", "shell", "shell:command"), + mkTool("read-2", "Read file", "generic", "read_file"), + mkTool("read-3", "Read file", "generic", "read_file"), + ]); + + assert.equal(block.kind, "turn"); + assert.deepEqual( + block.segments.map((segment) => segment.kind), + ["item", "item", "item", "item"], + ); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts index 9029570a2..18427bd0f 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts @@ -52,14 +52,6 @@ function isSetupLifecycle( ); } -function isErrorLifecycle( - item: TranscriptItem, -): item is Extract { - return ( - item.type === "lifecycle" && item.title.toLowerCase().includes("error") - ); -} - type TurnBucket = { turnId: string; items: TranscriptItem[]; @@ -93,10 +85,6 @@ function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] { ]; for (const item of activity) { - if (isErrorLifecycle(item)) { - segments.push({ kind: "item", item }); - continue; - } segments.push({ kind: "item", item }); } diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index 5f7b12a24..f6e390730 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -2,7 +2,10 @@ import * as React from "react"; import { Loader2 } from "lucide-react"; import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents"; -import { getActivityHeadline } from "@/features/agents/ui/agentSessionTranscriptPresentation"; +import { + getActivityHeadline, + isMeaningfulItem, +} from "@/features/agents/ui/agentSessionTranscriptPresentation"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ManagedAgent } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -65,7 +68,11 @@ export function BotActivityComposerAction({ : transcript; for (let i = scopedTranscript.length - 1; i >= 0; i--) { - const headline = getActivityHeadline(scopedTranscript[i]); + const item = scopedTranscript[i]; + if (!isMeaningfulItem(item)) { + continue; + } + const headline = getActivityHeadline(item); if (!headline || seen.has(headline)) { continue; } From 031765d7ac303cb5edf72db9e6b686780fa46d04 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Tue, 30 Jun 2026 02:45:14 -0700 Subject: [PATCH 43/49] fix(desktop): trim permission row copy Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../agents/ui/agentSessionTranscript.test.mjs | 14 ++++++++++---- .../features/agents/ui/agentSessionTranscript.ts | 3 ++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index 49d39d41f..a128e6f3b 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -546,13 +546,19 @@ test("buildTranscript stamps completedAt when a terminal tool update is inserted }); test("debug raw fixture makes permission, free-form status, and raw rail screenshotable", () => { + const permissionItem = DEBUG_AGENT_ACTIVITY_TRANSCRIPT.find( + (item) => + item.id.startsWith("permission:") && item.renderClass === "permission", + ); assert.ok( - DEBUG_AGENT_ACTIVITY_TRANSCRIPT.some( - (item) => - item.id.startsWith("permission:") && item.renderClass === "permission", - ), + permissionItem, "permission request should flow through the reducer", ); + assert.doesNotMatch( + permissionItem.text, + /^Permission requested\b/, + "permission detail should not duplicate the row title", + ); assert.ok( DEBUG_AGENT_ACTIVITY_TRANSCRIPT.some( (item) => diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index 870b50be7..18bb2fd03 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -174,7 +174,8 @@ function describePermissionRequest(payload: Record) { }) .filter((option): option is string => Boolean(option)) : []; - const detail = [title]; + const detail: string[] = []; + if (title !== "Permission requested") detail.push(title); if (toolCallId) detail.push(`Tool call: ${toolCallId}`); if (options.length > 0) detail.push(`Options: ${options.join(", ")}`); return { From 9c4d24e83b66046654562b718192d591707d76fd Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Tue, 30 Jun 2026 08:48:44 -0700 Subject: [PATCH 44/49] fix(desktop): tighten activity feed presentation Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../agents/ui/AgentSessionTranscriptList.tsx | 7 +--- .../activityRenderClasses/ErrorActivity.tsx | 6 --- .../FileEditActivity.tsx | 6 --- .../activityRenderClasses/GenericActivity.tsx | 6 --- .../PermissionActivity.tsx | 6 --- .../activityRenderClasses/RelayOpActivity.tsx | 6 --- .../activityRenderClasses/ShellActivity.tsx | 6 --- .../activityRenderClasses/StatusActivity.tsx | 6 --- .../TranscriptActivityItem.tsx | 25 +++++------ .../ui/agentSessionToolClassifier.test.mjs | 16 ++++++++ .../agents/ui/agentSessionToolClassifier.ts | 7 ---- .../agents/ui/agentSessionTranscript.test.mjs | 29 +++++++++++++ .../agents/ui/agentSessionTranscript.ts | 13 +++++- .../ui/agentSessionTranscriptGrouping.ts | 2 + ...gentSessionTranscriptPresentation.test.mjs | 41 ++++++++++++++++++- .../ui/agentSessionTranscriptPresentation.ts | 8 +++- 16 files changed, 117 insertions(+), 73 deletions(-) delete mode 100644 desktop/src/features/agents/ui/activityRenderClasses/ErrorActivity.tsx delete mode 100644 desktop/src/features/agents/ui/activityRenderClasses/FileEditActivity.tsx delete mode 100644 desktop/src/features/agents/ui/activityRenderClasses/GenericActivity.tsx delete mode 100644 desktop/src/features/agents/ui/activityRenderClasses/PermissionActivity.tsx delete mode 100644 desktop/src/features/agents/ui/activityRenderClasses/RelayOpActivity.tsx delete mode 100644 desktop/src/features/agents/ui/activityRenderClasses/ShellActivity.tsx delete mode 100644 desktop/src/features/agents/ui/activityRenderClasses/StatusActivity.tsx diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 7156b2658..9515be309 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -593,13 +593,10 @@ function TurnSetupFooter({ > {showContext && showSetup ? contextToggle : null} {!showContext && showSetup ? ( - + ) : null} {showContext && !showSetup ? contextToggle : null} {showTimestamp ? ( diff --git a/desktop/src/features/agents/ui/activityRenderClasses/ErrorActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/ErrorActivity.tsx deleted file mode 100644 index 1a9c7a24d..000000000 --- a/desktop/src/features/agents/ui/activityRenderClasses/ErrorActivity.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { LifecycleActivity } from "./LifecycleActivity"; -import type { ActivityRenderClassItemProps } from "./types"; - -export function ErrorActivity(props: ActivityRenderClassItemProps) { - return ; -} diff --git a/desktop/src/features/agents/ui/activityRenderClasses/FileEditActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/FileEditActivity.tsx deleted file mode 100644 index c151d0321..000000000 --- a/desktop/src/features/agents/ui/activityRenderClasses/FileEditActivity.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { ToolActivity } from "./ToolActivity"; -import type { ActivityRenderClassItemProps } from "./types"; - -export function FileEditActivity(props: ActivityRenderClassItemProps) { - return ; -} diff --git a/desktop/src/features/agents/ui/activityRenderClasses/GenericActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/GenericActivity.tsx deleted file mode 100644 index 39832700e..000000000 --- a/desktop/src/features/agents/ui/activityRenderClasses/GenericActivity.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { ToolActivity } from "./ToolActivity"; -import type { ActivityRenderClassItemProps } from "./types"; - -export function GenericActivity(props: ActivityRenderClassItemProps) { - return ; -} diff --git a/desktop/src/features/agents/ui/activityRenderClasses/PermissionActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/PermissionActivity.tsx deleted file mode 100644 index 7c5b73b78..000000000 --- a/desktop/src/features/agents/ui/activityRenderClasses/PermissionActivity.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { LifecycleActivity } from "./LifecycleActivity"; -import type { ActivityRenderClassItemProps } from "./types"; - -export function PermissionActivity(props: ActivityRenderClassItemProps) { - return ; -} diff --git a/desktop/src/features/agents/ui/activityRenderClasses/RelayOpActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/RelayOpActivity.tsx deleted file mode 100644 index f288cde8f..000000000 --- a/desktop/src/features/agents/ui/activityRenderClasses/RelayOpActivity.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { ToolActivity } from "./ToolActivity"; -import type { ActivityRenderClassItemProps } from "./types"; - -export function RelayOpActivity(props: ActivityRenderClassItemProps) { - return ; -} diff --git a/desktop/src/features/agents/ui/activityRenderClasses/ShellActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/ShellActivity.tsx deleted file mode 100644 index c2671cdb2..000000000 --- a/desktop/src/features/agents/ui/activityRenderClasses/ShellActivity.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { ToolActivity } from "./ToolActivity"; -import type { ActivityRenderClassItemProps } from "./types"; - -export function ShellActivity(props: ActivityRenderClassItemProps) { - return ; -} diff --git a/desktop/src/features/agents/ui/activityRenderClasses/StatusActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/StatusActivity.tsx deleted file mode 100644 index 091c03150..000000000 --- a/desktop/src/features/agents/ui/activityRenderClasses/StatusActivity.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { LifecycleActivity } from "./LifecycleActivity"; -import type { ActivityRenderClassItemProps } from "./types"; - -export function StatusActivity(props: ActivityRenderClassItemProps) { - return ; -} diff --git a/desktop/src/features/agents/ui/activityRenderClasses/TranscriptActivityItem.tsx b/desktop/src/features/agents/ui/activityRenderClasses/TranscriptActivityItem.tsx index 92f4b2159..0fe9f98bf 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/TranscriptActivityItem.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/TranscriptActivityItem.tsx @@ -1,32 +1,29 @@ import type { AgentActivityRenderClass } from "../agentSessionTypes"; -import { ErrorActivity } from "./ErrorActivity"; -import { FileEditActivity } from "./FileEditActivity"; -import { GenericActivity } from "./GenericActivity"; +import { LifecycleActivity } from "./LifecycleActivity"; import { MessageActivity } from "./MessageActivity"; -import { PermissionActivity } from "./PermissionActivity"; import { PlanActivity } from "./PlanActivity"; import { RawRailActivity } from "./RawRailActivity"; -import { RelayOpActivity } from "./RelayOpActivity"; -import { ShellActivity } from "./ShellActivity"; -import { StatusActivity } from "./StatusActivity"; import { SuppressedActivity } from "./SuppressedActivity"; import { ThoughtActivity } from "./ThoughtActivity"; +import { ToolActivity } from "./ToolActivity"; import type { ActivityRenderClassItemProps, ActivityRenderClassPresenter, } from "./types"; +// Exhaustive render-class routing. Several semantic classes intentionally share +// a presenter when their row treatment is the same. export const ACTIVITY_RENDER_CLASS_PRESENTERS = { message: MessageActivity, - "relay-op": RelayOpActivity, - "file-edit": FileEditActivity, - shell: ShellActivity, - status: StatusActivity, + "relay-op": ToolActivity, + "file-edit": ToolActivity, + shell: ToolActivity, + status: LifecycleActivity, thought: ThoughtActivity, plan: PlanActivity, - permission: PermissionActivity, - error: ErrorActivity, - generic: GenericActivity, + permission: LifecycleActivity, + error: LifecycleActivity, + generic: ToolActivity, "raw-rail": RawRailActivity, suppressed: SuppressedActivity, } satisfies Record; diff --git a/desktop/src/features/agents/ui/agentSessionToolClassifier.test.mjs b/desktop/src/features/agents/ui/agentSessionToolClassifier.test.mjs index 77c8de489..a32c14a76 100644 --- a/desktop/src/features/agents/ui/agentSessionToolClassifier.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionToolClassifier.test.mjs @@ -68,3 +68,19 @@ test("classifyTool promotes buzz CLI shell commands to relay operations", () => assert.equal(descriptor.preview, "buzz-agent-observability"); assert.equal(descriptor.groupKey, "buzz-cli:channels.get"); }); + +test("classifyTool falls back once to a generic descriptor", () => { + const descriptor = classifyTool({ + title: "Mystery", + toolName: "mcp__mystery", + buzzToolName: null, + args: { path: "notes.md" }, + result: "", + isError: false, + }); + + assert.equal(descriptor.renderClass, "generic"); + assert.equal(descriptor.label, "Ran tool"); + assert.equal(descriptor.preview, "notes.md"); + assert.equal(descriptor.source, "fallback"); +}); diff --git a/desktop/src/features/agents/ui/agentSessionToolClassifier.ts b/desktop/src/features/agents/ui/agentSessionToolClassifier.ts index 39e4a7636..d5acfec2c 100644 --- a/desktop/src/features/agents/ui/agentSessionToolClassifier.ts +++ b/desktop/src/features/agents/ui/agentSessionToolClassifier.ts @@ -101,7 +101,6 @@ const TOOL_CLASS_LABELS: Record = { const providers: ToolClassifierProvider[] = [ classifyDeveloperHarnessTool, classifyBuzzTool, - classifyGenericTool, ]; export function classifyTool( @@ -273,12 +272,6 @@ function classifyBuzzTool( }; } -function classifyGenericTool( - input: ToolClassificationInput, -): AgentActivityDescriptor { - return genericDescriptor(input); -} - function genericDescriptor( input: ToolClassificationInput, ): AgentActivityDescriptor { diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index a128e6f3b..4e0257062 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -573,3 +573,32 @@ test("debug raw fixture makes permission, free-form status, and raw rail screens "raw_json_rpc fixture should flow through the reducer", ); }); + +test("buildTranscript separates repeated lifecycle text", () => { + const events = [ + { + seq: 1, + timestamp: "2026-06-30T09:00:00.000Z", + kind: "turn_error", + agentIndex: 0, + channelId: "channel-1", + sessionId: "session-1", + turnId: "turn-1", + payload: { outcome: "recovered", error: "first" }, + }, + { + seq: 2, + timestamp: "2026-06-30T09:00:01.000Z", + kind: "turn_error", + agentIndex: 0, + channelId: "channel-1", + sessionId: "session-1", + turnId: "turn-1", + payload: { outcome: "recovered", error: "second" }, + }, + ]; + + const [item] = buildTranscript(events); + assert.equal(item.type, "lifecycle"); + assert.equal(item.text, "recovered: first\nrecovered: second"); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index 18bb2fd03..cc30a5268 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -281,7 +281,10 @@ function upsertTextItem( if (existing && existing.type === type) { replaceItem(d, id, { ...existing, - text: existing.text + text, + text: + type === "lifecycle" + ? joinLifecycleText(existing.text, text) + : existing.text + text, channelId: ctx.channelId, turnId: ctx.turnId ?? existing.turnId, sessionId: ctx.sessionId ?? existing.sessionId, @@ -318,6 +321,12 @@ function upsertTextItem( ); } +function joinLifecycleText(existing: string, next: string) { + if (!existing) return next; + if (!next) return existing; + return `${existing}\n${next}`; +} + function upsertLifecycleItem( d: TranscriptDraft, id: string, @@ -338,7 +347,7 @@ function upsertLifecycleItem( ...existing, renderClass, title, - text: existing.text + text, + text: joinLifecycleText(existing.text, text), descriptor: descriptor ?? existing.descriptor, channelId: ctx.channelId, turnId: ctx.turnId ?? existing.turnId, diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts index 18427bd0f..39869b06a 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts @@ -179,6 +179,8 @@ export function buildTranscriptDisplayBlocks( ): TranscriptDisplayBlock[] { const blocks: TranscriptDisplayBlock[] = []; const turnBuckets = new Map(); + // Callers pass a channel-scoped item stream; revisit this bare turnId bucket + // if grouping ever receives multi-channel transcript items. const displayOrder: Array< { kind: "single"; item: TranscriptItem } | { kind: "turn"; turnId: string } > = []; diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs index 790951a9f..8cf7a59cb 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs @@ -39,7 +39,7 @@ function makeMessage(overrides = {}) { } test("getActivityHeadline formats tool titles and assistant text", () => { - assert.equal(getActivityHeadline(makeTool()), "Send Message"); + assert.equal(getActivityHeadline(makeTool()), "Send Message · abc"); assert.equal( getActivityHeadline(makeMessage({ text: "First line\nSecond line" })), "First line", @@ -79,3 +79,42 @@ test("isMeaningfulItem ignores lifecycle noise and metadata", () => { true, ); }); + +test("getActivityHeadline uses semantic tool descriptors", () => { + assert.equal( + getActivityHeadline( + makeTool({ + title: "Shell", + toolName: "dev__shell", + buzzToolName: null, + args: { command: "buzz messages send --content hi" }, + descriptor: { + renderClass: "message", + label: "Send Message", + preview: "hi", + source: "shell", + groupKey: "buzz-cli:messages.send", + }, + }), + ), + "Send Message · hi", + ); +}); + +test("isMeaningfulItem ignores suppressed tools", () => { + assert.equal( + isMeaningfulItem( + makeTool({ + renderClass: "suppressed", + descriptor: { + renderClass: "suppressed", + label: "Checked todos", + preview: null, + source: "harness", + groupKey: "suppressed:stop-hook", + }, + }), + ), + false, + ); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts index bf1d4988f..5fec4f269 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts @@ -1,5 +1,5 @@ -import { formatToolTitle } from "./agentSessionToolCatalog"; import type { TranscriptItem } from "./agentSessionTypes"; +import { buildCompactToolSummary } from "./agentSessionToolSummary"; const LIFECYCLE_NOISE = new Set([ "turn started", @@ -10,7 +10,8 @@ const LIFECYCLE_NOISE = new Set([ /** Human-readable headline for a single transcript item. */ export function getActivityHeadline(item: TranscriptItem): string | null { if (item.type === "tool") { - return formatToolTitle(item.buzzToolName ?? item.toolName, item.title); + const summary = buildCompactToolSummary(item); + return [summary.label, summary.preview].filter(Boolean).join(" · "); } if (item.type === "message") { @@ -48,6 +49,9 @@ function isLifecycleNoise( /** Whether an item should contribute to the "Now" summary and headline scan. */ export function isMeaningfulItem(item: TranscriptItem): boolean { + if (item.type === "tool" && item.renderClass === "suppressed") { + return false; + } if (item.type === "lifecycle") { return !isLifecycleNoise(item); } From f0ef544724f9c6c124d811912729ec915a4ee2b3 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Tue, 30 Jun 2026 09:17:55 -0700 Subject: [PATCH 45/49] docs(desktop): keep activity fixture comments current Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/scripts/check-file-sizes.mjs | 6 +++--- desktop/src/features/agents/ui/debugAgentActivityFixture.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 8981e79fe..9a2603af8 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -131,9 +131,9 @@ const overrides = new Map([ // File still exceeds 1000 due to OpenAI/Anthropic discovery + subprocess // fallback. Queued to split into dedicated discovery modules. ["src-tauri/src/commands/agent_models.rs", 1066], - // activity-feed design fixture: temporary scaffold for render-class review - // that intentionally carries realistic prompt context and tool-heavy chatter. - // Remove this with debugAgentActivityFixture.ts before the PR merges. + // Kept activity-feed design fixture: realistic prompt context and tool-heavy + // chatter for render-class test/reference coverage. Queued to split with the + // rest of this list if it grows further. ["src/features/agents/ui/debugAgentActivityFixture.ts", 1025], ]); diff --git a/desktop/src/features/agents/ui/debugAgentActivityFixture.ts b/desktop/src/features/agents/ui/debugAgentActivityFixture.ts index 6f9abce9c..3760416a6 100644 --- a/desktop/src/features/agents/ui/debugAgentActivityFixture.ts +++ b/desktop/src/features/agents/ui/debugAgentActivityFixture.ts @@ -376,9 +376,9 @@ function userMessage( } /** - * Temporary design-debug fixture: one coherent turn that exercises every - * AgentActivityRenderClass and every TranscriptItem variant. Keep this isolated - * so it can be removed surgically before the PR merges. + * Design-debug fixture: one coherent turn that exercises every + * AgentActivityRenderClass and every TranscriptItem variant. Kept isolated as + * a render-class test/reference fixture. */ export const DEBUG_AGENT_ACTIVITY_FIXTURE: TranscriptItem[] = [ { From b5fb976fc1d912f5e6ac3f5b72fde426c9ceae0d Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Tue, 30 Jun 2026 09:20:34 -0700 Subject: [PATCH 46/49] fix(desktop): remove activity render debug toggle Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../channels/ui/AgentSessionThreadPanel.tsx | 66 +------------------ 1 file changed, 2 insertions(+), 64 deletions(-) diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index 7465939f9..572c7a780 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -3,15 +3,6 @@ import { Octagon, Settings, TerminalSquare } from "lucide-react"; import { toast } from "sonner"; import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel"; -import { - DEBUG_AGENT_ACTIVITY_AGENT_AVATAR_URL, - DEBUG_AGENT_ACTIVITY_AGENT_NAME, - DEBUG_AGENT_ACTIVITY_PROFILES, -} from "@/features/agents/ui/debugAgentActivityFixture"; -import { - DEBUG_AGENT_ACTIVITY_RAW_EVENTS, - DEBUG_AGENT_ACTIVITY_TRANSCRIPT, -} from "@/features/agents/ui/debugAgentActivityRawFixture"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import { cancelManagedAgentTurn } from "@/shared/api/agentControl"; import type { Channel } from "@/shared/api/types"; @@ -84,36 +75,6 @@ export function AgentSessionThreadPanel({ }, [rawFeedScopeKey], ); - const [showDebugRenderClasses, setShowDebugRenderClasses] = - React.useState(false); - const handleDebugRenderClassesChange = React.useCallback( - (checked: boolean) => { - setShowDebugRenderClasses(checked); - }, - [], - ); - const debugProfiles = React.useMemo( - () => - showDebugRenderClasses - ? { - ...(profiles ?? {}), - ...DEBUG_AGENT_ACTIVITY_PROFILES, - } - : profiles, - [profiles, showDebugRenderClasses], - ); - const debugAgent = React.useMemo( - () => - showDebugRenderClasses - ? { - ...agent, - avatarUrl: DEBUG_AGENT_ACTIVITY_AGENT_AVATAR_URL, - name: DEBUG_AGENT_ACTIVITY_AGENT_NAME, - } - : agent, - [agent, showDebugRenderClasses], - ); - async function handleInterruptTurn() { if (!channel) { return; @@ -162,23 +123,6 @@ export function AgentSessionThreadPanel({ className="min-w-56" onCloseAutoFocus={(event) => event.preventDefault()} > - - - - Debug: show all render classes - - - Temporary fixture for visual taxonomy review. - - - - From 618f550b94f862046ad40a9e23b7a66f6db3dc09 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Tue, 30 Jun 2026 09:58:14 -0700 Subject: [PATCH 47/49] test(desktop): remove dead activity debug smoke specs Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/playwright.config.ts | 2 - .../tests/e2e/activity-debug-expanded.spec.ts | 102 -------------- .../tests/e2e/activity-debug-fixture.spec.ts | 129 ------------------ desktop/tests/e2e/channels.spec.ts | 2 + 4 files changed, 2 insertions(+), 233 deletions(-) delete mode 100644 desktop/tests/e2e/activity-debug-expanded.spec.ts delete mode 100644 desktop/tests/e2e/activity-debug-fixture.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 0e1d07c32..0c827cc95 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -19,8 +19,6 @@ export default defineConfig({ { name: "smoke", testMatch: [ - "**/activity-debug-fixture.spec.ts", - "**/activity-debug-expanded.spec.ts", "**/smoke.spec.ts", "**/navigation.spec.ts", "**/channels.spec.ts", diff --git a/desktop/tests/e2e/activity-debug-expanded.spec.ts b/desktop/tests/e2e/activity-debug-expanded.spec.ts deleted file mode 100644 index f8c9cd6ae..000000000 --- a/desktop/tests/e2e/activity-debug-expanded.spec.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { expect, test } from "@playwright/test"; -import { installMockBridge } from "../helpers/bridge"; - -const AGENT = "ce".repeat(32); -const SHOTS = "tests/e2e/__shots__"; - -async function openAndDebug(page: import("@playwright/test").Page) { - await page.goto("/", { waitUntil: "domcontentloaded" }); - await page.getByTestId("app-sidebar").waitFor({ state: "visible" }); - await page.getByTestId("channel-engineering").click(); - await expect(page.getByTestId("chat-title")).toHaveText("engineering"); - await page.waitForTimeout(400); - await page.getByTestId("channel-members-trigger").first().click(); - await page.getByTestId("members-sidebar").waitFor({ state: "visible" }); - await page.waitForTimeout(300); - await page.getByTestId(`sidebar-member-menu-${AGENT}`).click({ force: true }); - await page.getByTestId(`sidebar-view-activity-${AGENT}`).click(); - await page - .getByTestId("agent-session-thread-panel") - .waitFor({ state: "visible" }); - await page.waitForTimeout(500); - await page.getByTestId("agent-session-settings-menu-trigger").click(); - await page.getByTestId("agent-session-toggle-debug-render-classes").click(); - // Dismiss the dropdown with an outside-click (Escape was leaving the menu open - // and overlapping the top of the expanded captures). Click the chat title area, - // which is well clear of the Activity panel, then confirm the menu is gone. - await page.getByTestId("chat-title").click({ force: true }); - await expect( - page.getByTestId("agent-session-toggle-debug-render-classes"), - ).toHaveCount(0); - await page.waitForTimeout(500); -} - -test("expanded disclosure rows", async ({ page }) => { - await installMockBridge(page, { - managedAgents: [ - { - pubkey: AGENT, - name: "Cerberus", - status: "running", - channelNames: ["engineering"], - backend: { type: "local" }, - }, - ], - }); - await page.setViewportSize({ width: 1280, height: 1400 }); - await openAndDebug(page); - const panel = page.getByTestId("agent-session-thread-panel"); - - // Expand every collapsible disclosure row in the feed. - // Tool rows use native
; thought/prompt rows use aria-expanded buttons. - await panel.evaluate((root) => { - root.querySelectorAll("details").forEach((d) => { - (d as HTMLDetailsElement).open = true; - }); - }); - const expanders = panel.locator( - '[aria-expanded="false"]:not([data-testid="agent-session-settings-menu-trigger"])', - ); - const n = await expanders.count(); - for (let i = 0; i < n; i++) { - const el = expanders.nth(i); - try { - await el.click({ force: true, timeout: 1000 }); - } catch {} - await page.waitForTimeout(80); - } - // Belt-and-suspenders: ensure the settings dropdown is not open before we - // screenshot (the expander loop or a stray focus can reopen it). Click the - // chat title (well clear of the Activity panel) and confirm the menu is gone. - if ( - await page.getByTestId("agent-session-toggle-debug-render-classes").count() - ) { - await page.getByTestId("chat-title").click({ force: true }); - await expect( - page.getByTestId("agent-session-toggle-debug-render-classes"), - ).toHaveCount(0); - } - await page.waitForTimeout(500); - - const sc = panel.locator(".overflow-y-auto").first(); - const m = await sc.evaluate((el) => { - (el as HTMLElement).scrollTop = 0; - return { sh: el.scrollHeight, ch: el.clientHeight }; - }); - await page.waitForTimeout(300); - const step = Math.max(200, m.ch - 140); - let y = 0, - i = 0; - while (y < m.sh && i < 16) { - await sc.evaluate((el, top) => { - (el as HTMLElement).scrollTop = top; - }, y); - await page.waitForTimeout(300); - await panel.screenshot({ - path: `${SHOTS}/expanded-${String(i).padStart(2, "0")}.png`, - }); - y += step; - i += 1; - } - console.log("EXPMETRICS:" + JSON.stringify({ ...m, expanders: n, steps: i })); -}); diff --git a/desktop/tests/e2e/activity-debug-fixture.spec.ts b/desktop/tests/e2e/activity-debug-fixture.spec.ts deleted file mode 100644 index db0e16bc3..000000000 --- a/desktop/tests/e2e/activity-debug-fixture.spec.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { expect, test } from "@playwright/test"; -import { installMockBridge } from "../helpers/bridge"; - -const AGENT = "ce".repeat(32); -const SHOTS = "tests/e2e/__shots__"; - -async function openActivityPanel(page: import("@playwright/test").Page) { - await page.goto("/", { waitUntil: "domcontentloaded" }); - await page.getByTestId("app-sidebar").waitFor({ state: "visible" }); - await page.getByTestId("channel-engineering").click(); - await expect(page.getByTestId("chat-title")).toHaveText("engineering"); - await page.waitForTimeout(400); - await page.getByTestId("channel-members-trigger").first().click(); - await page.getByTestId("members-sidebar").waitFor({ state: "visible" }); - await page.waitForTimeout(300); - await page.getByTestId(`sidebar-member-menu-${AGENT}`).click({ force: true }); - await page.getByTestId(`sidebar-view-activity-${AGENT}`).click(); - await page - .getByTestId("agent-session-thread-panel") - .waitFor({ state: "visible" }); - await page.waitForTimeout(600); -} - -async function enableDebug(page: import("@playwright/test").Page) { - await page.getByTestId("agent-session-settings-menu-trigger").click(); - await page.getByTestId("agent-session-toggle-debug-render-classes").click(); - await page.keyboard.press("Escape"); - await page.waitForTimeout(500); -} - -function scroller(page: import("@playwright/test").Page) { - return page - .locator('[data-testid="agent-session-thread-panel"] .overflow-y-auto') - .first(); -} - -test("debug fixture taxonomy", async ({ page }) => { - await installMockBridge(page, { - managedAgents: [ - { - pubkey: AGENT, - name: "Cerberus", - status: "running", - channelNames: ["engineering"], - backend: { type: "local" }, - }, - ], - }); - // Tall viewport so each scroll step grabs a big slice of the feed. - await page.setViewportSize({ width: 1280, height: 1400 }); - await openActivityPanel(page); - await enableDebug(page); - - const panel = page.getByTestId("agent-session-thread-panel"); - const sc = scroller(page); - - // Scroll to top, then walk down capturing the panel each step. - const metrics = await sc.evaluate((el) => { - (el as HTMLElement).scrollTop = 0; - return { sh: el.scrollHeight, ch: el.clientHeight }; - }); - await page.waitForTimeout(300); - - const step = Math.max(200, metrics.ch - 140); // overlap so nothing is lost at seams - let y = 0; - let i = 0; - while (y < metrics.sh) { - await sc.evaluate((el, top) => { - (el as HTMLElement).scrollTop = top; - }, y); - await page.waitForTimeout(350); - await panel.screenshot({ - path: `${SHOTS}/feed-${String(i).padStart(2, "0")}.png`, - }); - y += step; - i += 1; - if (i > 12) break; - } - console.log("METRICS:" + JSON.stringify({ ...metrics, steps: i })); -}); - -test("cog menu open", async ({ page }) => { - await installMockBridge(page, { - managedAgents: [ - { - pubkey: AGENT, - name: "Cerberus", - status: "running", - channelNames: ["engineering"], - backend: { type: "local" }, - }, - ], - }); - await page.setViewportSize({ width: 1280, height: 900 }); - await openActivityPanel(page); - await page.getByTestId("agent-session-settings-menu-trigger").click(); - await page.waitForTimeout(400); - await page.screenshot({ path: `${SHOTS}/cog-menu.png` }); -}); - -test("raw rail on", async ({ page }) => { - await installMockBridge(page, { - managedAgents: [ - { - pubkey: AGENT, - name: "Cerberus", - status: "running", - channelNames: ["engineering"], - backend: { type: "local" }, - }, - ], - }); - await page.setViewportSize({ width: 1280, height: 1400 }); - await openActivityPanel(page); - await enableDebug(page); - // Toggle raw feed on too - await page.getByTestId("agent-session-settings-menu-trigger").click(); - await page.getByTestId("agent-session-toggle-raw-feed").click(); - await page.keyboard.press("Escape"); - await page.waitForTimeout(600); - const sc = scroller(page); - await sc.evaluate((el) => { - (el as HTMLElement).scrollTop = 0; - }); - await page.waitForTimeout(300); - await page - .getByTestId("agent-session-thread-panel") - .screenshot({ path: `${SHOTS}/raw-rail.png` }); -}); diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 03f69b519..7d6423fea 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -962,8 +962,10 @@ test("shows and clears activity indicators for active channel agents", async ({ "alice", ); await expect(page.getByTestId("agent-transcript-now-summary")).toHaveCount(0); + await page.getByTestId("agent-session-settings-menu-trigger").click(); await expect(page.getByTestId("agent-session-stop-turn")).toBeVisible(); await expect(page.getByTestId("agent-session-stop-turn")).toBeDisabled(); + await page.keyboard.press("Escape"); await expect(page.getByTestId("agent-session-thread-panel")).toContainText( "No ACP activity yet", ); From 50a66b34ef20d28dbc24ed2fad442429f9c7867f Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Tue, 30 Jun 2026 11:14:13 -0700 Subject: [PATCH 48/49] test(e2e): keep wave huddle placeholder coverage Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/tests/e2e/mentions.spec.ts | 31 ++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index d0b6ff4e9..0a0c42b11 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -26,6 +26,8 @@ const CASEY_PROFILE_PUBKEY = "1111111111111111111111111111111111111111111111111111111111111111"; const PROFILE_ONLY_AGENT_PUBKEY = "8f83d6b7f3d74f7d933ae3a54dd8c6cc85c7f98e531c16e5a827b953441a8d67"; +const UNSEEDED_PROFILE_ONLY_AGENT_PUBKEY = + "7777777777777777777777777777777777777777777777777777777777777777"; const SYSTEM_MESSAGE_KIND = 40099; /** Locator scoped to the mention autocomplete dropdown inside the composer. */ @@ -1463,7 +1465,17 @@ test("wave attachment huddle passes the bot DM pubkey", async ({ page }) => { test("wave attachment huddle waits for placeholder profile-only bot data", async ({ page, }) => { - await installMockBridge(page, { usersBatchDelayMs: 2_000 }); + await installMockBridge(page, { + searchProfiles: [ + { + pubkey: UNSEEDED_PROFILE_ONLY_AGENT_PUBKEY, + displayName: "nova", + ownerPubkey: TEST_IDENTITIES.tyler.pubkey, + isAgent: true, + }, + ], + usersBatchDelayMs: 2_000, + }); await page.goto("/"); await page.getByTestId("channel-general").click(); @@ -1484,7 +1496,7 @@ test("wave attachment huddle waits for placeholder profile-only bot data", async }, { kind: SYSTEM_MESSAGE_KIND, - targetPubkey: PROFILE_ONLY_AGENT_PUBKEY, + targetPubkey: UNSEEDED_PROFILE_ONLY_AGENT_PUBKEY, }, ); await waitForTimelineSettled(page); @@ -1492,12 +1504,9 @@ test("wave attachment huddle waits for placeholder profile-only bot data", async const joinedRow = page .getByTestId("system-message-row") .filter({ hasText: "joined the channel" }); - const agentChip = joinedRow.locator( - "[data-mention].agent-mention-highlight", - { - hasText: "mira", - }, - ); + const agentChip = joinedRow.locator("[data-mention]", { + hasText: "77777777…7777", + }); await expect(agentChip).toBeVisible({ timeout: 5_000 }); await agentChip.hover(); @@ -1506,7 +1515,9 @@ test("wave attachment huddle waits for placeholder profile-only bot data", async ); await expect(profilePopover).toBeVisible(); await profilePopover - .getByTestId(`user-profile-popover-wave-${PROFILE_ONLY_AGENT_PUBKEY}`) + .getByTestId( + `user-profile-popover-wave-${UNSEEDED_PROFILE_ONLY_AGENT_PUBKEY}`, + ) .click(); const startHuddleButton = page @@ -1518,7 +1529,7 @@ test("wave attachment huddle waits for placeholder profile-only bot data", async await expect .poll(() => readStartHuddleMemberPubkeys(page)) - .toEqual(expect.arrayContaining([PROFILE_ONLY_AGENT_PUBKEY])); + .toEqual(expect.arrayContaining([UNSEEDED_PROFILE_ONLY_AGENT_PUBKEY])); }); test("wave attachment huddle waits for delayed bot DM pubkey", async ({ From 1c8053b675bf9d1c481b23e209699dfca512341a Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Tue, 30 Jun 2026 11:38:58 -0700 Subject: [PATCH 49/49] test(agents): remove debug activity fixtures Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/scripts/check-file-sizes.mjs | 1 - .../agents/ui/agentSessionTranscript.test.mjs | 47 +- .../ui/debugAgentActivityFixture.test.mjs | 75 -- .../agents/ui/debugAgentActivityFixture.ts | 1024 ----------------- .../ui/debugAgentActivityPlanFixture.ts | 29 - .../agents/ui/debugAgentActivityRawFixture.ts | 203 ---- 6 files changed, 42 insertions(+), 1337 deletions(-) delete mode 100644 desktop/src/features/agents/ui/debugAgentActivityFixture.test.mjs delete mode 100644 desktop/src/features/agents/ui/debugAgentActivityFixture.ts delete mode 100644 desktop/src/features/agents/ui/debugAgentActivityPlanFixture.ts delete mode 100644 desktop/src/features/agents/ui/debugAgentActivityRawFixture.ts diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 9a2603af8..41df4bde4 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -134,7 +134,6 @@ const overrides = new Map([ // Kept activity-feed design fixture: realistic prompt context and tool-heavy // chatter for render-class test/reference coverage. Queued to split with the // rest of this list if it grows further. - ["src/features/agents/ui/debugAgentActivityFixture.ts", 1025], ]); await runFileSizeCheck({ diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index 4e0257062..49b8104de 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -3,7 +3,6 @@ import test from "node:test"; import { buildTranscript } from "./agentSessionTranscript.ts"; import { formatToolTitle } from "./agentSessionToolCatalog.ts"; -import { DEBUG_AGENT_ACTIVITY_TRANSCRIPT } from "./debugAgentActivityRawFixture.ts"; const baseEvent = { seq: 1, @@ -545,8 +544,46 @@ test("buildTranscript stamps completedAt when a terminal tool update is inserted assert.equal(transcript[0].completedAt, "2026-06-30T09:00:00.000Z"); }); -test("debug raw fixture makes permission, free-form status, and raw rail screenshotable", () => { - const permissionItem = DEBUG_AGENT_ACTIVITY_TRANSCRIPT.find( +test("buildTranscript preserves permission, free-form status, and raw rail render classes", () => { + const transcript = buildTranscript([ + { + ...baseEvent, + seq: 1, + kind: "acp_read", + payload: { + method: "session/request_permission", + params: { + title: "Confirm force-with-lease push", + toolCallId: "tool-push", + options: [ + { optionId: "allow_once", kind: "allow_once", name: "Allow" }, + { optionId: "reject_once", kind: "reject_once", name: "Reject" }, + ], + }, + }, + }, + { + ...baseEvent, + seq: 2, + kind: "acp_read", + payload: { + type: "observer_connected", + title: "Observer connected", + text: "ACP stream attached", + }, + }, + { + ...baseEvent, + seq: 3, + kind: "raw_json_rpc", + payload: { + method: "workspace/diagnostic", + params: { ok: true }, + }, + }, + ]); + + const permissionItem = transcript.find( (item) => item.id.startsWith("permission:") && item.renderClass === "permission", ); @@ -560,14 +597,14 @@ test("debug raw fixture makes permission, free-form status, and raw rail screens "permission detail should not duplicate the row title", ); assert.ok( - DEBUG_AGENT_ACTIVITY_TRANSCRIPT.some( + transcript.some( (item) => item.renderClass === "status" && item.title === "Observer connected", ), "free-form status fixture should flow through the reducer", ); assert.ok( - DEBUG_AGENT_ACTIVITY_TRANSCRIPT.some( + transcript.some( (item) => item.type === "metadata" && item.renderClass === "raw-rail", ), "raw_json_rpc fixture should flow through the reducer", diff --git a/desktop/src/features/agents/ui/debugAgentActivityFixture.test.mjs b/desktop/src/features/agents/ui/debugAgentActivityFixture.test.mjs deleted file mode 100644 index e0b29545e..000000000 --- a/desktop/src/features/agents/ui/debugAgentActivityFixture.test.mjs +++ /dev/null @@ -1,75 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { DEBUG_AGENT_ACTIVITY_FIXTURE } from "./debugAgentActivityFixture.ts"; -import { - DEBUG_AGENT_ACTIVITY_RAW_EVENTS, - DEBUG_AGENT_ACTIVITY_TRANSCRIPT, -} from "./debugAgentActivityRawFixture.ts"; - -const expectedRenderClasses = new Set([ - "message", - "relay-op", - "file-edit", - "shell", - "status", - "thought", - "plan", - "permission", - "error", - "generic", - "raw-rail", - "suppressed", -]); - -const expectedItemTypes = new Set([ - "message", - "thought", - "plan", - "lifecycle", - "metadata", - "tool", -]); - -test("debug activity fixture covers every render class and item variant", () => { - const renderClasses = new Set( - DEBUG_AGENT_ACTIVITY_FIXTURE.map((item) => item.renderClass), - ); - const itemTypes = new Set( - DEBUG_AGENT_ACTIVITY_FIXTURE.map((item) => item.type), - ); - - assert.deepEqual(renderClasses, expectedRenderClasses); - assert.deepEqual(itemTypes, expectedItemTypes); -}); - -test("debug raw activity fixture aligns with transcript fixture", () => { - assert.equal( - DEBUG_AGENT_ACTIVITY_RAW_EVENTS.length, - DEBUG_AGENT_ACTIVITY_FIXTURE.length, - ); - - for (const [index, event] of DEBUG_AGENT_ACTIVITY_RAW_EVENTS.entries()) { - const item = DEBUG_AGENT_ACTIVITY_FIXTURE[index]; - assert.equal(event.seq, index + 1); - assert.equal(event.timestamp, item.timestamp); - assert.equal(event.channelId, item.channelId ?? null); - assert.equal(event.sessionId, item.sessionId ?? null); - assert.equal(event.turnId, item.turnId ?? null); - } -}); - -test("debug transcript fixture shows progressive plan upserts", () => { - const planItems = DEBUG_AGENT_ACTIVITY_TRANSCRIPT.filter( - (item) => item.type === "plan", - ); - const plan = planItems.find((item) => !item.isUpdate); - const updates = planItems.filter((item) => item.isUpdate); - - assert.ok(plan); - assert.match(plan.text, /5\. \[x\] Report the pushed SHA/); - assert.deepEqual( - updates.map((item) => item.text), - ["2/4 complete", "3/4 complete", "4/5 complete", "5/5 complete"], - ); -}); diff --git a/desktop/src/features/agents/ui/debugAgentActivityFixture.ts b/desktop/src/features/agents/ui/debugAgentActivityFixture.ts deleted file mode 100644 index 3760416a6..000000000 --- a/desktop/src/features/agents/ui/debugAgentActivityFixture.ts +++ /dev/null @@ -1,1024 +0,0 @@ -import type { - AgentActivityDescriptor, - TranscriptItem, -} from "./agentSessionTypes"; -import type { UserProfileLookup } from "@/features/profile/lib/identity"; -import { debugPlanUpdateItem } from "./debugAgentActivityPlanFixture"; - -const sessionId = "debug-session-render-classes"; -const turnId = "debug-turn-render-classes"; -const channelId = "debug-channel-render-classes"; -const userPubkey = - "1111111111111111111111111111111111111111111111111111111111111111"; -const reviewerPubkey = - "2222222222222222222222222222222222222222222222222222222222222222"; -export const DEBUG_AGENT_ACTIVITY_PROMPT_EVENT_ID = - "3333333333333333333333333333333333333333333333333333333333333333"; -const DEBUG_AGENT_ACTIVITY_SENT_MESSAGE_EVENT_ID = - "4444444444444444444444444444444444444444444444444444444444444444"; -const DEBUG_AGENT_ACTIVITY_PUSHED_REPORT_EVENT_ID = - "5555555555555555555555555555555555555555555555555555555555555555"; -const baseTimestamp = Date.parse("2026-06-30T00:00:00.000Z"); -const workspacePath = "/Users/tho/.buzz/REPOS/buzz-pr-3-activity-feed-rebuild"; -export const DEBUG_AGENT_ACTIVITY_AGENT_NAME = "Fixture Agent"; -export const DEBUG_AGENT_ACTIVITY_AGENT_AVATAR_URL = - "https://picsum.photos/seed/activity-agent-placeholder/200"; -export const DEBUG_AGENT_ACTIVITY_PROFILES: UserProfileLookup = { - [userPubkey]: debugProfile("Taylor Ho", "seedhash", "tho"), - [reviewerPubkey]: debugProfile("Avery", "activity-user-avery", "avery"), -}; - -function timestamp(seconds: number) { - return new Date(baseTimestamp + seconds * 1000).toISOString(); -} - -function debugProfile(displayName: string, seed: string, handle: string) { - return { - displayName, - avatarUrl: `https://picsum.photos/seed/${seed}/200`, - nip05Handle: `${handle}@buzz.local`, - isAgent: false, - ownerPubkey: null, - }; -} - -function descriptor( - renderClass: AgentActivityDescriptor["renderClass"], - label: string, - preview: string | null, - groupKey: string, - options: Partial = {}, -): AgentActivityDescriptor { - return { - renderClass, - label, - preview, - source: "fallback", - groupKey, - ...options, - }; -} - -type ToolFixtureOptions = { - id: string; - renderClass: AgentActivityDescriptor["renderClass"]; - label: string; - preview: string | null; - groupKey: string; - seconds: number; - title?: string; - toolName: string; - buzzToolName?: string | null; - args?: Record; - result?: string; - status?: Extract["status"]; - isError?: boolean; - completedSeconds?: number | null; - descriptorOptions?: Partial; -}; - -type FileEditScenario = { - oldStr: string; - newStr: string; - diff: string; -}; - -function toolItem({ - id, - renderClass, - label, - preview, - groupKey, - seconds, - title = label, - toolName, - buzzToolName = null, - args = {}, - result = "", - status = "completed", - isError = false, - completedSeconds = seconds + 0.4, - descriptorOptions = {}, -}: ToolFixtureOptions): Extract { - return { - id, - type: "tool", - renderClass, - descriptor: descriptor(renderClass, label, preview, groupKey, { - source: "harness", - ...descriptorOptions, - }), - title, - toolName, - buzzToolName, - status, - args, - result, - isError, - timestamp: timestamp(seconds), - startedAt: timestamp(seconds), - completedAt: completedSeconds == null ? null : timestamp(completedSeconds), - acpSource: "tool_call_update", - turnId, - sessionId, - channelId, - }; -} - -function fileEditScenario(path: string): FileEditScenario { - if (path.endsWith("VISION_ACTIVITY.md")) { - return { - oldStr: - "## Principles\n\nThe activity feed should make agent work legible without turning every event into chat.\n", - newStr: - "## Principles\n\nThe activity feed should make agent work legible without turning every event into chat. The common path is agent text, stdout, user feedback, then another tool pass.\n", - diff: " ## Principles\n \n-The activity feed should make agent work legible without turning every event into chat.\n+The activity feed should make agent work legible without turning every event into chat. The common path is agent text, stdout, user feedback, then another tool pass.\n", - }; - } - - if (path.endsWith("activityRenderClasses/ActivityRow.tsx")) { - return { - oldStr: - " const match = label.match(\n /^(Captured|Edited|Ran|Read|Updated|Viewed)\\s+(.+)$/,\n );\n", - newStr: - " const match = label.match(\n /^(Captured|Edited|Ran|Read|Updated|Viewed|Wrote)\\s+(.+)$/,\n );\n", - diff: " const match = label.match(\n- /^(Captured|Edited|Ran|Read|Updated|Viewed)\\s+(.+)$/,\n+ /^(Captured|Edited|Ran|Read|Updated|Viewed|Wrote)\\s+(.+)$/,\n );\n", - }; - } - - if (path.endsWith("AgentSessionThreadPanel.tsx")) { - return { - oldStr: - " showRaw={showRawFeed}\n transcriptOverride={\n showDebugRenderClasses ? DEBUG_AGENT_ACTIVITY_FIXTURE : undefined\n }\n", - newStr: - " showRaw={showRawFeed}\n transcriptOverride={debugTranscript}\n", - diff: " showRaw={showRawFeed}\n- transcriptOverride={\n- showDebugRenderClasses ? DEBUG_AGENT_ACTIVITY_FIXTURE : undefined\n- }\n+ transcriptOverride={debugTranscript}\n", - }; - } - - if (path.endsWith("ManagedAgentSessionPanel.tsx")) { - return { - oldStr: - " const displayTranscript = transcriptOverride ?? scopedTranscript;\n\n const scopedEvents = React.useMemo(\n", - newStr: - " const displayTranscript = transcriptOverride ?? scopedTranscript;\n const displayEventCount = transcriptOverride?.length ?? scopedEvents.length;\n\n const scopedEvents = React.useMemo(\n", - diff: " const displayTranscript = transcriptOverride ?? scopedTranscript;\n+ const displayEventCount = transcriptOverride?.length ?? scopedEvents.length;\n \n const scopedEvents = React.useMemo(\n", - }; - } - - if (path.endsWith("AgentSessionTranscriptList.tsx")) { - return { - oldStr: - ' {displayBlocks.map((block) => (\n (\n (\n+ {displayBlocks.map((block, index) => (\n = 3) {\n grouped.push({\n kind: "summary",\n', - newStr: - ' const minimumRunLength = key === "shell:command" ? 3 : 4;\n if (run.length >= minimumRunLength) {\n grouped.push({\n kind: "summary",\n', - diff: ' if (run.length >= 3) {\n+ const minimumRunLength = key === "shell:command" ? 3 : 4;\n+ if (run.length >= minimumRunLength) {\n grouped.push({\n', - }; - } - - if (path.endsWith("AgentSessionToolItem.tsx")) { - return { - oldStr: - ' {duration ? (\n {duration}\n ) : null}\n', - newStr: - ' {duration ? (\n \n {duration}\n \n ) : null}\n', - diff: ' {duration ? (\n- {duration}\n+ \n+ {duration}\n+ \n ) : null}\n', - }; - } - - if (path.endsWith("agentSessionToolSummary.ts")) { - return { - oldStr: - " return {\n label: descriptor.label,\n preview: descriptor.preview,\n", - newStr: - " return {\n label: descriptor.label,\n preview: descriptor.preview?.trim() || null,\n", - diff: " return {\n label: descriptor.label,\n- preview: descriptor.preview,\n+ preview: descriptor.preview?.trim() || null,\n", - }; - } - - return { - oldStr: - ' if (kind === "str_replace") {\n const path = getToolString(input.args, ["path"]);\n', - newStr: - ' if (kind === "str_replace") {\n const path = getToolString(input.args, ["path"]);\n const replaceAll = Boolean(input.args.replace_all);\n', - diff: ' if (kind === "str_replace") {\n const path = getToolString(input.args, ["path"]);\n+ const replaceAll = Boolean(input.args.replace_all);\n', - }; -} - -function fileEditItem( - id: string, - path: string, - seconds: number, - durationMs = 420, -): Extract { - const change = fileEditScenario(path); - return toolItem({ - id, - renderClass: "file-edit", - label: "Edited file", - preview: path, - groupKey: "file-edit:str_replace", - seconds, - title: "str_replace", - toolName: "dev__str_replace", - args: { - path, - old_str: change.oldStr, - new_str: change.newStr, - workdir: workspacePath, - }, - result: `Replaced 1 occurrence in ${workspacePath}/${path}.\n\n--- a/${workspacePath}/${path}\n+++ b/${workspacePath}/${path}\n@@\n${change.diff}`, - completedSeconds: seconds + durationMs / 1000, - descriptorOptions: { - operation: "str_replace", - object: path, - tone: "write", - }, - }); -} - -function shellResultJson( - stdout = "", - { - exitCode = 0, - stderr = "", - durationMs = 420, - }: { - exitCode?: number; - stderr?: string; - durationMs?: number; - } = {}, -) { - return JSON.stringify( - { - exit_code: exitCode, - stdout, - stderr, - timed_out: false, - duration_ms: durationMs, - stdout_truncated: false, - stderr_truncated: false, - stdout_artifact: null, - stderr_artifact: null, - notes: [], - }, - null, - 2, - ); -} - -function shellCommandItem( - id: string, - command: string, - seconds: number, - result = "", - durationMs = 420, -): Extract { - return toolItem({ - id, - renderClass: "shell", - label: "Ran command", - preview: command, - groupKey: "shell:command", - seconds, - title: "Shell", - toolName: "dev__shell", - args: { command, workdir: workspacePath, timeout_ms: 120000 }, - result: shellResultJson(result, { durationMs }), - completedSeconds: seconds + durationMs / 1000, - descriptorOptions: { - operation: "shell", - }, - }); -} - -function todoUpdateItem( - id: string, - preview: string, - seconds: number, -): Extract { - return toolItem({ - id, - renderClass: "plan", - label: "Updated todos", - preview, - groupKey: "plan:todo", - seconds, - title: "todo", - toolName: "dev__todo", - args: { - todos: [ - { - text: preview, - done: true, - }, - ], - }, - result: `- [x] ${preview}`, - descriptorOptions: { - operation: "todo", - tone: "write", - }, - }); -} - -function assistantMessage( - id: string, - text: string, - seconds: number, -): Extract { - return { - id, - type: "message", - renderClass: "message", - role: "assistant", - title: DEBUG_AGENT_ACTIVITY_AGENT_NAME, - text, - timestamp: timestamp(seconds), - acpSource: "agent_message_chunk", - turnId, - sessionId, - channelId, - }; -} - -function userMessage( - id: string, - authorPubkey: string, - title: string, - text: string, - seconds: number, -): Extract { - return { - id, - type: "message", - renderClass: "message", - role: "user", - title, - text, - timestamp: timestamp(seconds), - acpSource: "user_message_chunk", - authorPubkey, - turnId, - sessionId, - channelId, - }; -} - -/** - * Design-debug fixture: one coherent turn that exercises every - * AgentActivityRenderClass and every TranscriptItem variant. Kept isolated as - * a render-class test/reference fixture. - */ -export const DEBUG_AGENT_ACTIVITY_FIXTURE: TranscriptItem[] = [ - { - id: "debug:turn-started", - type: "lifecycle", - renderClass: "status", - title: "Turn started", - text: "Triggered by 1 event.", - timestamp: timestamp(0), - acpSource: "turn_started", - turnId, - sessionId, - channelId, - }, - { - id: "debug:session-resolved", - type: "lifecycle", - renderClass: "status", - title: "Session ready", - text: "Observer attached to the local agent session.", - timestamp: timestamp(1), - acpSource: "session_resolved", - turnId, - sessionId, - channelId, - }, - { - id: "debug:user-prompt", - type: "message", - renderClass: "message", - role: "user", - title: "Taylor Ho", - text: "@Agent audit the activity feed taxonomy and show me the risky spots before you edit.", - timestamp: timestamp(2), - messageId: DEBUG_AGENT_ACTIVITY_PROMPT_EVENT_ID, - acpSource: "session/prompt:user", - authorPubkey: userPubkey, - turnId, - sessionId, - channelId, - }, - { - id: "debug:prompt-context", - type: "metadata", - renderClass: "raw-rail", - title: "Prompt context", - sections: [ - { - title: "Agent Memory — core", - body: [ - "Prefer auditing the taxonomy before changing the renderer.", - "Keep reasoning, assistant/user messages, tool output, relay operations, and raw ACP payloads visually distinct.", - ].join("\n"), - }, - { - title: "Context", - body: [ - "Channel: buzz-agent-observability", - "Thread: live agent activity rebuild review", - "Goal: design the activity feed against realistic prompt context, boring chatter, and tool-heavy turns.", - ].join("\n"), - }, - { - title: "Buzz event: @mention", - body: [ - `Event ID: ${DEBUG_AGENT_ACTIVITY_PROMPT_EVENT_ID}`, - `From: Taylor Ho (hex: ${userPubkey})`, - "Kind: channel message", - "Content: @Agent audit the activity feed taxonomy and show me the risky spots before you edit.", - ].join("\n"), - }, - ], - timestamp: timestamp(2.2), - acpSource: "session/prompt:context", - turnId, - sessionId, - channelId, - }, - { - id: "debug:thought", - type: "thought", - renderClass: "thought", - title: "Thinking", - text: "I need to inspect the existing taxonomy, verify the render classes, then make the smallest safe change. Shell stdout is evidence, not reasoning.", - timestamp: timestamp(4), - acpSource: "agent_thought_chunk", - turnId, - sessionId, - channelId, - }, - assistantMessage( - "debug:assistant-shape", - "Let me think about the shape:\n\n1. Confirm how the current render classes group tool activity.\n2. Check the fixture against real ACP/MCP payloads.\n3. Keep the debug state noisy enough to resemble a normal agent turn.", - 4.4, - ), - shellCommandItem( - "debug:shell-tool", - "git status --short", - 4.8, - "## tho/activity-feed-rebuild...origin/main [ahead 4]\n", - ), - userMessage( - "debug:user-followup-location", - userPubkey, - "Taylor Ho", - "Yep, and make sure the fixture includes the boring chatter too — short status messages are most of what I see in real agent turns.", - 5.4, - ), - debugPlanUpdateItem( - "debug:plan-initial", - "1. [ ] Read the transcript components.\n2. [ ] Classify the observed tools.\n3. [ ] Patch the Activity header.\n4. [ ] Run desktop gates and report the pushed SHA.", - 5, - ), - assistantMessage( - "debug:assistant-ack-location", - "Makes sense. I’ll bias this toward the ordinary path: small observations, quick shell checks, file edits, and a final report instead of only taxonomy edge cases.", - 5.8, - ), - { - id: "debug:status", - type: "lifecycle", - renderClass: "status", - title: "Observer connected", - text: "Streaming normalized ACP activity.", - timestamp: timestamp(6), - acpSource: "observer_connected", - turnId, - sessionId, - channelId, - }, - assistantMessage( - "debug:assistant-after-status", - "The branch already has the activity-feed work stacked, so I’m going to keep this as a fixture-only pass and avoid touching the live transcript renderer.", - 8.8, - ), - { - id: "debug:relay-op-tool", - type: "tool", - renderClass: "relay-op", - descriptor: descriptor( - "relay-op", - "Channels Get", - "buzz-agent-observability", - "buzz-cli:channels.get", - { - operation: "channels.get", - object: "buzz-agent-observability", - source: "shell", - tone: "read", - }, - ), - title: "buzz channels get", - toolName: "dev__shell", - buzzToolName: null, - status: "completed", - args: { - command: "buzz channels get --channel buzz-agent-observability", - workdir: workspacePath, - timeout_ms: 120000, - }, - result: shellResultJson( - '{"name":"buzz-agent-observability","members":7}\n', - ), - isError: false, - timestamp: timestamp(10), - startedAt: timestamp(10), - completedAt: timestamp(11), - acpSource: "tool_call_update", - turnId, - sessionId, - channelId, - }, - { - id: "debug:message-tool", - type: "tool", - renderClass: "message", - descriptor: descriptor( - "message", - "Send Message", - "@Agent picked it up — testing the full taxonomy now.", - "buzz-cli:messages.send", - { - operation: "messages.send", - object: "@Agent picked it up — testing the full taxonomy now.", - source: "shell", - tone: "write", - }, - ), - title: "buzz messages send", - toolName: "dev__shell", - buzzToolName: null, - status: "completed", - args: { - command: - 'buzz messages send --channel buzz-agent-observability --content "@Agent picked it up — testing the full taxonomy now."', - workdir: workspacePath, - timeout_ms: 120000, - }, - result: shellResultJson( - `{"accepted":true,"event_id":"${DEBUG_AGENT_ACTIVITY_SENT_MESSAGE_EVENT_ID}"}\n`, - ), - isError: false, - timestamp: timestamp(12), - startedAt: timestamp(12), - completedAt: timestamp(13), - acpSource: "tool_call_update", - turnId, - sessionId, - channelId, - }, - userMessage( - "debug:user-followup-raw", - reviewerPubkey, - "Avery", - "Could you include one of the raw payload examples in the middle of the turn? That’s where I usually need to compare the compact row with the wire event.", - 13.4, - ), - debugPlanUpdateItem( - "debug:plan-after-inspection", - "1. [x] Read the transcript components.\n2. [x] Classify the observed tools.\n3. [ ] Patch the Activity header.\n4. [ ] Run desktop gates and report the pushed SHA.", - 13.6, - ), - assistantMessage( - "debug:assistant-before-edits", - "Good call. I’ll leave a raw ACP sample in the fixture and make the surrounding messages look like a real debugging exchange.", - 13.8, - ), - { - id: "debug:file-edit-tool", - type: "tool", - renderClass: "file-edit", - descriptor: descriptor( - "file-edit", - "Edited file", - "desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx", - "file-edit:str_replace", - { - operation: "str_replace", - object: "desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx", - source: "harness", - tone: "write", - }, - ), - title: "str_replace", - toolName: "dev__str_replace", - buzzToolName: null, - status: "completed", - args: { - path: "desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx", - old_str: "", - new_str: "", - workdir: workspacePath, - }, - result: `Replaced 1 occurrence in ${workspacePath}/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx.\n\n--- a/${workspacePath}/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx\n+++ b/${workspacePath}/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx\n@@\n-\n+\n`, - isError: false, - timestamp: timestamp(14), - startedAt: timestamp(14), - completedAt: timestamp(15), - acpSource: "tool_call_update", - turnId, - sessionId, - channelId, - }, - fileEditItem( - "debug:file-edit-thread-panel-2", - "desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx", - 15.2, - 180, - ), - fileEditItem( - "debug:file-edit-managed-panel-1", - "desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx", - 15.6, - 530, - ), - fileEditItem( - "debug:file-edit-transcript-list-1", - "desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx", - 16, - 260, - ), - fileEditItem( - "debug:file-edit-grouping-1", - "desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts", - 16.4, - 740, - ), - fileEditItem( - "debug:file-edit-tool-item-1", - "desktop/src/features/agents/ui/AgentSessionToolItem.tsx", - 16.8, - 310, - ), - fileEditItem( - "debug:file-edit-tool-summary-1", - "desktop/src/features/agents/ui/agentSessionToolSummary.ts", - 17.2, - 480, - ), - fileEditItem( - "debug:file-edit-classifier-1", - "desktop/src/features/agents/ui/agentSessionToolClassifier.ts", - 17.6, - 690, - ), - debugPlanUpdateItem( - "debug:plan-after-edits", - "1. [x] Read the transcript components.\n2. [x] Classify the observed tools.\n3. [x] Patch the Activity header.\n4. [ ] Run desktop gates and report the pushed SHA.", - 18, - ), - shellCommandItem( - "debug:shell-burst-1", - "git status --short", - 18.2, - "M desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx\n", - 96, - ), - shellCommandItem( - "debug:shell-burst-2", - "pnpm --dir desktop lint", - 18.6, - "Checked 812 files in 1.9s. No fixes applied.\n", - 1900, - ), - shellCommandItem( - "debug:shell-burst-3", - "node --import ./test-loader.mjs --experimental-strip-types --test src/features/agents/ui/debugAgentActivityFixture.test.mjs", - 19, - "1 test passed\n", - 183, - ), - shellCommandItem( - "debug:shell-burst-4", - "pnpm --dir desktop typecheck", - 19.4, - "Typecheck completed.\n", - 3600, - ), - shellCommandItem( - "debug:shell-burst-5", - "git diff --stat", - 19.8, - "4 files changed, 211 insertions(+), 38 deletions(-)\n", - 122, - ), - debugPlanUpdateItem( - "debug:plan-after-gates", - "1. [x] Read the transcript components.\n2. [x] Classify the observed tools.\n3. [x] Patch the Activity header.\n4. [x] Run desktop gates.\n5. [ ] Report the pushed SHA.", - 19.9, - ), - assistantMessage( - "debug:assistant-mid-check", - "The grouped command burst is now doing the thing I wanted: it compresses repetitive shell checks, but each child still has distinct stdout and timing when expanded.", - 20, - ), - shellCommandItem( - "debug:shell-burst-6", - "git add desktop/src/features/agents/ui desktop/src/features/channels/ui", - 20.2, - "", - 140, - ), - shellCommandItem( - "debug:shell-burst-7", - 'git commit -m "feat(desktop): add activity render-class debug fixture"', - 20.6, - "[tho/activity-feed-rebuild aa84200ad] feat(desktop): add activity render-class debug fixture\n", - 5530, - ), - toolItem({ - id: "debug:block-safe-github", - renderClass: "generic", - label: "Ran tool", - preview: "block-safe-github", - groupKey: "generic:block-safe-github", - seconds: 21.4, - title: "block-safe-github", - toolName: "block-safe-github", - result: "Remote origin is in the block GitHub org.", - descriptorOptions: { - operation: "block-safe-github", - }, - }), - userMessage( - "debug:user-followup-finish", - userPubkey, - "Taylor Ho", - "After the push check, include the exact branch and SHA in the channel message. That final report bubble is a common case.", - 21.7, - ), - fileEditItem("debug:single-edit-vision-doc", "VISION_ACTIVITY.md", 21.9, 210), - todoUpdateItem( - "debug:todo-after-first-check", - "Inspect transcript panel settings and debug activity fixture", - 22, - ), - shellCommandItem( - "debug:shell-push-burst-1", - "git status --short", - 22.8, - "", - 88, - ), - shellCommandItem( - "debug:shell-push-burst-2", - "git push -u origin HEAD", - 23.2, - "branch 'tho/activity-feed-rebuild' set up to track 'origin/tho/activity-feed-rebuild'.\n", - 5400, - ), - shellCommandItem( - "debug:shell-push-burst-3", - "git rev-parse --short=40 HEAD", - 23.6, - "aa84200ad266d16f81da2f9c347518a7525a3ef4\n", - 74, - ), - debugPlanUpdateItem( - "debug:plan-after-push", - "1. [x] Read the transcript components.\n2. [x] Classify the observed tools.\n3. [x] Patch the Activity header.\n4. [x] Run desktop gates.\n5. [x] Report the pushed SHA.", - 24, - ), - todoUpdateItem( - "debug:todo-after-push", - "Report branch, SHA, and commit to the requester", - 24.4, - ), - assistantMessage( - "debug:assistant-reporting", - "Everything is ready to report. I’m going to send a concise channel update with branch, SHA, and commit title, then stop.", - 24.8, - ), - fileEditItem( - "debug:single-edit-activity-row", - "desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx", - 25, - 160, - ), - { - id: "debug:message-tool-pushed-report", - type: "tool", - renderClass: "message", - descriptor: descriptor( - "message", - "Send Message", - "@Agent Done and pushed.\n\nBranch: `tho/activity-feed-rebuild`\nSHA: `aa84200ad266d16f81da2f9c347518a7525a3ef4`\nCommit: `feat(desktop): add activity render-class debug fixture`", - "buzz-cli:messages.send", - { - operation: "messages.send", - object: "@Agent Done and pushed.", - source: "shell", - tone: "write", - }, - ), - title: "buzz messages send", - toolName: "dev__shell", - buzzToolName: null, - status: "completed", - args: { - command: - 'buzz messages send --channel agents --content "@Agent Done and pushed."', - workdir: workspacePath, - timeout_ms: 120000, - }, - result: shellResultJson( - `{"accepted":true,"event_id":"${DEBUG_AGENT_ACTIVITY_PUSHED_REPORT_EVENT_ID}"}\n`, - ), - isError: false, - timestamp: timestamp(25.2), - startedAt: timestamp(25.2), - completedAt: timestamp(25.8), - acpSource: "tool_call_update", - turnId, - sessionId, - channelId, - }, - { - id: "debug:permission", - type: "lifecycle", - renderClass: "permission", - title: "Permission requested", - text: "Confirm force-with-lease push to block/buzz.", - timestamp: timestamp(16), - descriptor: descriptor( - "permission", - "Permission requested", - "force-with-lease push", - "permission:git-push", - { - source: "acp", - tone: "admin", - }, - ), - acpSource: "permission_request", - turnId, - sessionId, - channelId, - }, - { - id: "debug:error-tool", - type: "tool", - renderClass: "error", - descriptor: descriptor( - "error", - "Command failed", - "pnpm --dir desktop test -- --runInBand", - "error:shell", - { - operation: "shell", - source: "harness", - }, - ), - title: "Shell", - toolName: "dev__shell", - buzzToolName: null, - status: "failed", - args: { - command: "pnpm --dir desktop test -- --runInBand", - workdir: workspacePath, - timeout_ms: 120000, - }, - result: shellResultJson("", { - exitCode: 1, - stderr: "Unknown option: --runInBand\n", - durationMs: 913, - }), - isError: true, - timestamp: timestamp(19), - startedAt: timestamp(19), - completedAt: timestamp(20), - acpSource: "tool_call_update", - turnId, - sessionId, - channelId, - }, - { - id: "debug:suppressed-tool", - type: "tool", - renderClass: "suppressed", - descriptor: descriptor( - "suppressed", - "Checked todos", - null, - "suppressed:stop-hook", - { - operation: "stop_hook", - source: "harness", - }, - ), - title: "_Stop", - toolName: "dev___Stop", - buzzToolName: null, - status: "completed", - args: {}, - result: "", - isError: false, - timestamp: timestamp(21), - startedAt: timestamp(21), - completedAt: timestamp(21.4), - acpSource: "tool_call_update", - turnId, - sessionId, - channelId, - }, - { - id: "debug:raw-rail", - type: "metadata", - renderClass: "raw-rail", - title: "Raw ACP payload", - sections: [ - { - title: "tool_call_update", - body: JSON.stringify( - { - jsonrpc: "2.0", - method: "session/update", - params: { - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: "debug:file-edit-tool", - status: "completed", - content: [ - { - type: "content", - content: { - type: "text", - text: `Replaced 1 occurrence in ${workspacePath}/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx.\n\n--- a/${workspacePath}/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx\n+++ b/${workspacePath}/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx\n@@\n-\n+\n`, - }, - }, - ], - rawOutput: { - isError: false, - }, - }, - }, - }, - null, - 2, - ), - }, - ], - timestamp: timestamp(22), - acpSource: "raw_json_rpc", - turnId, - sessionId, - channelId, - }, - { - id: "debug:error-lifecycle", - type: "lifecycle", - renderClass: "error", - title: "Turn error recovered", - text: "Retried with the supported desktop test command.", - timestamp: timestamp(23), - descriptor: descriptor( - "error", - "Turn error recovered", - "unsupported test flag", - "error:turn", - { - source: "acp", - }, - ), - acpSource: "turn_error", - turnId, - sessionId, - channelId, - }, - { - id: "debug:assistant-message", - type: "message", - renderClass: "message", - role: "assistant", - title: "Agent", - text: "Done — the Activity header now keeps Raw inside the settings cog, and the desktop gates are green.", - timestamp: timestamp(25), - acpSource: "agent_message_chunk", - turnId, - sessionId, - channelId, - }, -]; diff --git a/desktop/src/features/agents/ui/debugAgentActivityPlanFixture.ts b/desktop/src/features/agents/ui/debugAgentActivityPlanFixture.ts deleted file mode 100644 index 64865d31f..000000000 --- a/desktop/src/features/agents/ui/debugAgentActivityPlanFixture.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { TranscriptItem } from "./agentSessionTypes"; - -const sessionId = "debug-session-render-classes"; -const turnId = "debug-turn-render-classes"; -const channelId = "debug-channel-render-classes"; -const baseTimestamp = Date.parse("2026-06-30T00:00:00.000Z"); - -function timestamp(seconds: number) { - return new Date(baseTimestamp + seconds * 1000).toISOString(); -} - -export function debugPlanUpdateItem( - id: string, - text: string, - seconds: number, -): Extract { - return { - id, - type: "plan", - renderClass: "plan", - title: "Plan updated", - text, - timestamp: timestamp(seconds), - acpSource: "plan", - turnId, - sessionId, - channelId, - }; -} diff --git a/desktop/src/features/agents/ui/debugAgentActivityRawFixture.ts b/desktop/src/features/agents/ui/debugAgentActivityRawFixture.ts deleted file mode 100644 index 2a4a2bb41..000000000 --- a/desktop/src/features/agents/ui/debugAgentActivityRawFixture.ts +++ /dev/null @@ -1,203 +0,0 @@ -import type { ObserverEvent, TranscriptItem } from "./agentSessionTypes"; -import { buildTranscript } from "./agentSessionTranscript"; -import { - DEBUG_AGENT_ACTIVITY_FIXTURE, - DEBUG_AGENT_ACTIVITY_PROMPT_EVENT_ID, -} from "./debugAgentActivityFixture"; - -function rawContent(text: string) { - return [ - { - type: "content", - content: { - type: "text", - text, - }, - }, - ]; -} - -function sessionUpdatePayload(update: Record) { - return { - jsonrpc: "2.0", - method: "session/update", - params: { - sessionId: update.sessionId, - update, - }, - }; -} - -function promptPayloadForMessage( - item: Extract, -) { - const promptContext = DEBUG_AGENT_ACTIVITY_FIXTURE.find( - (candidate): candidate is Extract => - candidate.type === "metadata" && - candidate.acpSource === "session/prompt:context" && - candidate.turnId === item.turnId, - ); - const sections = promptContext?.sections ?? [ - { - title: "Buzz event", - body: `From: ${item.title}\nContent: ${item.text}`, - }, - ]; - - return { - jsonrpc: "2.0", - method: "session/prompt", - params: { - sessionId: item.sessionId, - prompt: sections.map((section) => ({ - type: "text", - text: `[${section.title}]\n${section.body}`, - })), - }, - }; -} - -function payloadForItem(item: TranscriptItem): unknown { - if (item.type === "message") { - if (item.acpSource === "session/prompt:user") { - return promptPayloadForMessage(item); - } - - return sessionUpdatePayload({ - sessionId: item.sessionId, - sessionUpdate: - item.role === "assistant" - ? "agent_message_chunk" - : "user_message_chunk", - messageId: item.id, - authorPubkey: item.authorPubkey, - content: rawContent(item.text), - }); - } - - if (item.type === "thought") { - return sessionUpdatePayload({ - sessionId: item.sessionId, - sessionUpdate: "agent_thought_chunk", - messageId: item.id, - content: rawContent(item.text), - }); - } - - if (item.type === "plan") { - return sessionUpdatePayload({ - sessionId: item.sessionId, - sessionUpdate: "plan", - content: rawContent(item.text), - }); - } - - if (item.type === "metadata") { - if (item.acpSource === "raw_json_rpc") { - const rawBody = item.sections[0]?.body; - if (rawBody) { - try { - return JSON.parse(rawBody); - } catch { - return { type: "raw_json_rpc", body: rawBody }; - } - } - } - return { - jsonrpc: "2.0", - method: "session/prompt", - params: { - context: item.sections, - }, - }; - } - - if (item.type === "tool") { - return sessionUpdatePayload({ - sessionId: item.sessionId, - sessionUpdate: "tool_call_update", - toolCallId: item.id, - title: item.title, - toolName: item.toolName, - status: item.status, - rawInput: item.args, - content: rawContent(item.result), - rawOutput: { - isError: item.isError, - }, - }); - } - - if (item.acpSource === "turn_started") { - return { - type: "turn_started", - triggeringEventIds: [DEBUG_AGENT_ACTIVITY_PROMPT_EVENT_ID], - }; - } - - if (item.acpSource === "session_resolved") { - return { - sessionId: item.sessionId, - isNewSession: false, - }; - } - - if (item.acpSource === "permission_request") { - return { - jsonrpc: "2.0", - method: "session/request_permission", - params: { - toolCallId: item.id, - title: item.title, - options: [ - { optionId: "allow_once", kind: "allow_once", name: "Allow" }, - { optionId: "reject_once", kind: "reject_once", name: "Reject" }, - ], - }, - }; - } - - if (item.acpSource === "turn_error") { - return { - outcome: "recovered", - error: item.text, - }; - } - - return { - type: item.acpSource ?? item.renderClass, - title: item.title, - text: item.text, - }; -} - -function kindForItem(item: TranscriptItem) { - if (item.acpSource === "turn_started") return "turn_started"; - if (item.acpSource === "session_resolved") return "session_resolved"; - if (item.type === "message" && item.acpSource === "session/prompt:user") { - return "acp_write"; - } - if (item.acpSource === "permission_request") return "acp_read"; - if (item.acpSource === "turn_error") return "turn_error"; - if (item.acpSource === "raw_json_rpc") return "raw_json_rpc"; - if (item.type === "metadata" && item.acpSource === "session/prompt:context") { - return "acp_write"; - } - return "acp_read"; -} - -export const DEBUG_AGENT_ACTIVITY_RAW_EVENTS: ObserverEvent[] = - DEBUG_AGENT_ACTIVITY_FIXTURE.map((item, index) => ({ - seq: index + 1, - timestamp: item.timestamp, - kind: kindForItem(item), - agentIndex: 0, - channelId: item.channelId ?? null, - sessionId: item.sessionId ?? null, - turnId: item.turnId ?? null, - payload: payloadForItem(item), - })); - -export const DEBUG_AGENT_ACTIVITY_TRANSCRIPT = buildTranscript( - DEBUG_AGENT_ACTIVITY_RAW_EVENTS, -);