diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index d412e790e..8cc93e005 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1356,6 +1356,7 @@ pub fn build_managed_agent_summary( max_turn_duration_seconds: record.max_turn_duration_seconds, parallelism: record.parallelism, system_prompt: effective_prompt, + avatar_url: record.avatar_url.clone(), model: effective_model, mcp_toolsets: record.mcp_toolsets.clone(), env_vars: record.env_vars.clone(), diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index e9d733e54..4bd10fe48 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -224,6 +224,7 @@ pub struct ManagedAgentSummary { pub max_turn_duration_seconds: Option, pub parallelism: u32, pub system_prompt: Option, + pub avatar_url: Option, pub model: Option, pub mcp_toolsets: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx index 78dd98304..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 2f1f47e1f..a7d3b99a9 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}

@@ -35,37 +93,423 @@ export function AgentSessionTranscriptList({ } return ( -
- {items.map((item) => ( -
- +
+ {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 ( +
+ +
+
+ + {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 +525,12 @@ const TranscriptItemView = React.memo(function TranscriptItemView({ }); function MessageItem({ + agentAvatarUrl, agentName, + agentPubkey, item, profiles, -}: { - agentName: string; +}: AgentTranscriptIdentityProps & { item: Extract; profiles?: UserProfileLookup; }) { @@ -101,34 +546,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} @@ -142,7 +607,7 @@ function MessageItem({ ) : ( <> -

{text}

+ )} @@ -158,7 +623,10 @@ function ThoughtItem({ item: Extract; }) { return ( -
+
{item.title} @@ -178,11 +646,14 @@ function MetadataItem({ item: Extract; }) { return ( -
+
- - {item.title} - + + {item.title} + {item.sections.length} section{item.sections.length === 1 ? "" : "s"} @@ -198,7 +669,7 @@ function MetadataItem({ {section.title} -
+            
               {section.body.trim() || "No metadata."}
             
@@ -217,12 +688,20 @@ function LifecycleItem({ return (
+ {isError ? ( + + ) : ( + + )} {item.title} - {item.text ? - {item.text} : null} + {item.text ? · {item.text} : null}
); @@ -248,7 +727,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 3defb7d2b..e3ba558d1 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, @@ -52,29 +63,21 @@ export function ManagedAgentSessionPanel({ // Filter transcript items by channelId (lightweight — items now carry channelId) const scopedTranscript = React.useMemo( - () => - channelId - ? transcript.filter((item) => item.channelId === channelId) - : transcript, + () => scopeByChannel(transcript, channelId), [channelId, transcript], ); // Filter raw events by channelId for the RawEventRail const scopedEvents = React.useMemo( - () => - channelId - ? events.filter((event) => event.channelId === channelId) - : events, + () => scopeByChannel(events, channelId), [channelId, events], ); // Derive latestSessionId from channel-scoped 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 (
@@ -143,26 +150,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 ? ( @@ -172,18 +204,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 74a655b77..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.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs new file mode 100644 index 000000000..a5cd0c96b --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildTranscript } from "./agentSessionTranscript.ts"; + +const turnId = "turn-abc"; +const sessionId = "sess-1"; +const channelId = "channel-1"; +const baseTimestamp = "2026-06-14T22:20:23.000Z"; + +function makeTurnEvents() { + return [ + { + seq: 1, + timestamp: baseTimestamp, + kind: "turn_started", + agentIndex: 0, + channelId, + sessionId: null, + turnId, + payload: { triggeringEventIds: ["event-1"] }, + }, + { + seq: 2, + timestamp: baseTimestamp, + kind: "session_resolved", + agentIndex: 0, + channelId, + sessionId, + turnId, + payload: { sessionId, isNewSession: false }, + }, + { + seq: 3, + timestamp: baseTimestamp, + kind: "acp_write", + agentIndex: 0, + channelId, + sessionId, + turnId, + payload: { + method: "session/prompt", + params: { + sessionId, + prompt: [ + { + type: "text", + text: "[Buzz event: message]\nContent: @Ned deliberate, wider pass\nFrom: Tyler hex: abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd", + }, + ], + }, + }, + }, + { + seq: 4, + timestamp: "2026-06-14T22:20:47.000Z", + kind: "acp_read", + agentIndex: 0, + channelId, + sessionId, + turnId, + payload: { + method: "session/update", + params: { + update: { + sessionUpdate: "agent_message_chunk", + messageId: "msg-1", + content: [{ type: "text", text: "On it." }], + }, + }, + }, + }, + ]; +} + +test("buildTranscript attaches turnId and sessionId to generated items", () => { + const items = buildTranscript(makeTurnEvents()); + + assert.ok(items.length >= 4); + for (const item of items) { + assert.equal(item.turnId, turnId); + assert.equal(item.channelId, channelId); + } + + const sessionResolved = items.find( + (item) => + item.type === "lifecycle" && item.acpSource === "session_resolved", + ); + assert.equal(sessionResolved?.sessionId, sessionId); + + const userPrompt = items.find( + (item) => + item.type === "message" && + item.role === "user" && + item.acpSource === "session/prompt:user", + ); + assert.ok(userPrompt); + assert.equal(userPrompt.sessionId, sessionId); +}); + +test("buildTranscript tags assistant chunks with agent_message_chunk", () => { + const items = buildTranscript([ + { + seq: 1, + timestamp: "2026-06-14T20:47:14.000Z", + kind: "acp_read", + agentIndex: 0, + channelId: "channel-1", + sessionId: "sess-1", + turnId: "turn-1", + payload: { + method: "session/update", + params: { + update: { + sessionUpdate: "agent_message_chunk", + messageId: "msg-1", + content: [{ type: "text", text: "Marge is summoned." }], + }, + }, + }, + }, + ]); + + assert.equal(items.length, 1); + assert.equal(items[0]?.type, "message"); + assert.equal(items[0]?.acpSource, "agent_message_chunk"); +}); + +test("buildTranscript tags thought chunks with agent_thought_chunk", () => { + const items = buildTranscript([ + { + seq: 2, + timestamp: "2026-06-14T20:47:15.000Z", + kind: "acp_read", + agentIndex: 0, + channelId: "channel-1", + sessionId: "sess-1", + turnId: "turn-1", + payload: { + method: "session/update", + params: { + update: { + sessionUpdate: "agent_thought_chunk", + messageId: "thought-1", + content: [{ type: "text", text: "Considering next step." }], + }, + }, + }, + }, + ]); + + assert.equal(items.length, 1); + assert.equal(items[0]?.type, "thought"); + assert.equal(items[0]?.acpSource, "agent_thought_chunk"); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index 60cf7f62b..945945526 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -108,6 +108,12 @@ function sealOpenMessages(d: TranscriptDraft) { } } +type TranscriptItemContext = { + channelId: string | null; + turnId: string | null; + sessionId: string | null; +}; + function upsertMessage( d: TranscriptDraft, id: string, @@ -115,8 +121,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); @@ -126,8 +133,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; } @@ -142,8 +152,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); @@ -156,15 +169,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( @@ -173,15 +204,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( @@ -195,7 +244,8 @@ function upsertTool( result: string, isError: boolean, timestamp: string, - channelId: string | null, + ctx: TranscriptItemContext, + acpSource?: string, ) { const existing = d.itemsById.get(id); const canonicalBuzzToolName = @@ -224,7 +274,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; } @@ -242,7 +295,10 @@ function upsertTool( timestamp, startedAt: timestamp, completedAt: null, - channelId, + channelId: ctx.channelId, + turnId: ctx.turnId, + sessionId: ctx.sessionId, + acpSource, }); } @@ -258,6 +314,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( @@ -267,7 +328,8 @@ export function processTranscriptEvent( "Turn started", describeTurnStarted(event.payload), event.timestamp, - channelId, + ctx, + event.kind, ); } else if (event.kind === "session_resolved") { upsertTextItem( @@ -277,7 +339,8 @@ export function processTranscriptEvent( "Session ready", describeSessionResolved(event.payload), event.timestamp, - channelId, + ctx, + event.kind, ); } else if (event.kind === "acp_parse_error") { upsertTextItem( @@ -287,7 +350,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); @@ -302,7 +366,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); @@ -320,8 +385,9 @@ export function processTranscriptEvent( parsedPrompt.userTitle, parsedPrompt.userText, event.timestamp, - channelId, + ctx, parsedPrompt.userPubkey, + "session/prompt:user", ); } if (parsedPrompt.sections.length > 0) { @@ -331,7 +397,8 @@ export function processTranscriptEvent( "Prompt context", parsedPrompt.sections, event.timestamp, - channelId, + ctx, + "session/prompt:context", ); } } @@ -350,7 +417,9 @@ export function processTranscriptEvent( "Assistant", extractContentText(update.content), event.timestamp, - channelId, + ctx, + null, + updateType, ); } else if (updateType === "user_message_chunk") { upsertMessage( @@ -360,7 +429,9 @@ export function processTranscriptEvent( "User", extractContentText(update.content), event.timestamp, - channelId, + ctx, + null, + updateType, ); } else if (updateType === "agent_thought_chunk") { upsertTextItem( @@ -370,7 +441,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}`; @@ -386,7 +458,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}`; @@ -405,7 +478,8 @@ export function processTranscriptEvent( extractToolResult(update), status === "failed", event.timestamp, - channelId, + ctx, + updateType, ); } else if (updateType === "plan") { upsertTextItem( @@ -415,7 +489,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 e84af1468..1f7b4b5a2 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs @@ -65,6 +65,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 00c13c056..b74f8cd8a 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts @@ -85,9 +85,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/lib/agentSessionCandidates.ts b/desktop/src/features/channels/lib/agentSessionCandidates.ts index a77db42da..92aa6811b 100644 --- a/desktop/src/features/channels/lib/agentSessionCandidates.ts +++ b/desktop/src/features/channels/lib/agentSessionCandidates.ts @@ -11,6 +11,7 @@ export type ChannelAgentSessionAgent = Pick< "pubkey" | "name" | "status" > & { agentSource: "managed" | "member-bot" | "relay"; + avatarUrl?: string | null; canInterruptTurn: boolean; channelIds?: string[]; channels?: string[]; @@ -57,6 +58,7 @@ export function buildChannelAgentSessionCandidates({ name: agent.name, status: agent.status, agentSource: "managed", + avatarUrl: agent.avatarUrl, canInterruptTurn: true, channelIds: existing?.channelIds, channels: existing?.channels, diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index ae633bbf3..fe8c58b0b 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -1,4 +1,5 @@ -import { ArrowLeft, CircleDot, Octagon, X } from "lucide-react"; +import * as React from "react"; +import { ArrowLeft, CircleDot, Octagon, TerminalSquare, X } from "lucide-react"; import { toast } from "sonner"; import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel"; @@ -25,6 +26,7 @@ import { PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS, } from "@/shared/ui/OverlayPanelBackdrop"; import { THREAD_PANEL_MIN_WIDTH_PX } from "@/shared/hooks/useThreadPanelWidth"; +import { Switch } from "@/shared/ui/switch"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions"; @@ -60,6 +62,19 @@ export function AgentSessionThreadPanel({ useEscapeKey(onClose, isOverlay || isSinglePanelView); const { ref: scrollRef, onScroll } = useStickToBottom(); + const rawFeedScopeKey = `${agent.pubkey}:${channel.id}`; + 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() { try { @@ -79,20 +94,43 @@ export function AgentSessionThreadPanel({ const agentHeaderActions = (
{isLive && isWorking ? ( - + Live ) : null} + {isLive ? ( +
+ + +
+ ) : null} {isLive && isWorking ? ( - Activity + + {showRawFeed ? "Raw ACP Activity" : "Activity"} + {agentHeaderActions} @@ -161,9 +201,11 @@ export function AgentSessionThreadPanel({ channelId={channel.id} className="border-0 bg-transparent p-0 shadow-none" emptyDescription={`Mention ${agent.name} in the 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 a289200ad..ad992960a 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, @@ -184,16 +171,17 @@ export function BotActivityComposerAction({ className={cn( "border border-background", isInline - ? "!h-[18px] !w-[18px] shadow-xs ring-1 ring-primary/25 text-[7px]" - : "!h-5 !w-5 text-[8px]", + ? "!h-[18px] !w-[18px] shadow-xs ring-1 ring-primary/25 text-xs leading-none" + : "shrink-0", )} displayName={agent.name} key={agent.pubkey} + size="xs" /> ))} {typingAgents.length > 2 ? ( - + +{typingAgents.length - 2} ) : null} @@ -240,8 +228,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 7f8582b0b..a99e3e2d0 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -185,25 +185,6 @@ export function ChannelScreen({ : [], [activeChannel], ); - const messageProfilePubkeys = React.useMemo( - () => [ - ...new Set([ - ...messageAuthorPubkeys, - ...messageMentionPubkeys, - ...activeDmParticipantPubkeys, - ...typingEntries.map((entry) => entry.pubkey), - ]), - ], - [ - activeDmParticipantPubkeys, - messageAuthorPubkeys, - messageMentionPubkeys, - typingEntries, - ], - ); - const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, { - enabled: messageProfilePubkeys.length > 0, - }); const channelMembersQuery = useChannelMembersQuery(activeChannel?.id ?? null); const channelMembers = channelMembersQuery.data; const managedAgentsQuery = useManagedAgentsQuery(); @@ -225,6 +206,27 @@ export function ChannelScreen({ } return pubkeys; }, [channelMembers, managedAgents, relayAgents]); + const messageProfilePubkeys = React.useMemo( + () => [ + ...new Set([ + ...messageAuthorPubkeys, + ...messageMentionPubkeys, + ...activeDmParticipantPubkeys, + ...agentPubkeys, + ...typingEntries.map((entry) => entry.pubkey), + ]), + ], + [ + activeDmParticipantPubkeys, + agentPubkeys, + messageAuthorPubkeys, + messageMentionPubkeys, + typingEntries, + ], + ); + const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, { + enabled: messageProfilePubkeys.length > 0, + }); const allAgentSessionCandidates = React.useMemo( () => buildChannelAgentSessionCandidates({ diff --git a/desktop/src/features/channels/ui/useChannelActivityTyping.ts b/desktop/src/features/channels/ui/useChannelActivityTyping.ts index 007f090d3..d3943dbb1 100644 --- a/desktop/src/features/channels/ui/useChannelActivityTyping.ts +++ b/desktop/src/features/channels/ui/useChannelActivityTyping.ts @@ -98,7 +98,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], @@ -108,5 +108,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 11e25da4d..0954193e8 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -45,10 +45,7 @@ import type { OpenDmInput, } from "@/shared/api/types"; -type RawIdentity = { - pubkey: string; - display_name: string; -}; +type RawIdentity = { pubkey: string; display_name: string }; type RawProfile = { pubkey: string; @@ -213,6 +210,7 @@ export type RawManagedAgent = { max_turn_duration_seconds: number | null; parallelism: number; system_prompt: string | null; + avatar_url?: string | null; model: string | null; mcp_toolsets: string | null; env_vars?: Record; @@ -868,6 +866,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, mcpToolsets: agent.mcp_toolsets, envVars: agent.env_vars ?? {}, diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 653a1be99..99df2caa4 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -282,6 +282,7 @@ export type ManagedAgent = { maxTurnDurationSeconds: number | null; parallelism: number; systemPrompt: string | null; + avatarUrl: string | null; model: string | null; mcpToolsets: string | null; /** Per-agent env vars. Layered on top of persona envVars. */ diff --git a/desktop/src/shared/ui/UserAvatar.tsx b/desktop/src/shared/ui/UserAvatar.tsx index cab6a8fd6..8b7ede461 100644 --- a/desktop/src/shared/ui/UserAvatar.tsx +++ b/desktop/src/shared/ui/UserAvatar.tsx @@ -6,8 +6,8 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/shared/ui/avatar"; type UserAvatarSize = "xs" | "sm" | "md"; const sizeClasses: Record = { - xs: "h-5 w-5 text-[8px]", - sm: "h-6 w-6 text-[9px]", + xs: "h-5 w-5 text-xs", + sm: "h-6 w-6 text-xs", md: "h-10 w-10 text-xs", }; diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 61e498f9e..3a3eb50fc 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -224,6 +224,7 @@ type MarkdownProps = { agentMentionPubkeysByName?: Record; mentionNames?: string[]; mentionPubkeysByName?: Record; + mediaInset?: boolean; searchQuery?: string; tight?: boolean; videoReviewContext?: VideoReviewContext; @@ -748,6 +749,7 @@ function createMarkdownComponents( variant: MarkdownVariant, runtimeRef: React.RefObject, interactive = true, + mediaInset = false, ): Components { const paragraphClassName = variant === "tight" @@ -898,7 +900,12 @@ function createMarkdownComponents( if (resolvedSrc?.endsWith(".mp4")) { const entry = src ? imetaByUrl?.get(src) : undefined; return ( - + + ); @@ -1115,6 +1128,7 @@ function MarkdownInner({ imetaByUrl, interactive = true, agentMentionPubkeysByName, + mediaInset = false, mentionNames, mentionPubkeysByName, searchQuery, @@ -1161,8 +1175,9 @@ function MarkdownInner({ }); const components = React.useMemo( - () => createMarkdownComponents(variant, runtimeRef, interactive), - [variant, runtimeRef, interactive], + () => + createMarkdownComponents(variant, runtimeRef, interactive, mediaInset), + [variant, runtimeRef, interactive, mediaInset], ); // biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable @@ -1276,6 +1291,7 @@ export const Markdown = React.memo( 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 && 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 f3481d234..8b8e59103 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -38,6 +38,7 @@ type MockCommandAvailability = { type MockManagedAgentSeed = { pubkey: string; name: string; + avatarUrl?: string | null; personaId?: string | null; status?: RawManagedAgent["status"]; channelNames?: string[]; @@ -358,6 +359,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"; @@ -873,6 +875,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, @@ -962,6 +965,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, @@ -4747,6 +4751,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 6f4f53327..905756859 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -773,6 +773,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) => {