diff --git a/crates/buzz-relay/src/api/agents.rs b/crates/buzz-relay/src/api/agents.rs new file mode 100644 index 000000000..fa78cc4fa --- /dev/null +++ b/crates/buzz-relay/src/api/agents.rs @@ -0,0 +1,71 @@ +//! Agent ownership lookup — GET /api/agents/:pubkey/ownership (NIP-98 auth). +//! +//! Returns the relay-authoritative `agent_owner_pubkey` mapping and whether +//! the authenticated caller is the registered owner. Used by the desktop to +//! gate observer activity visibility without relying on channel membership or +//! local managed-agent store state. + +use std::sync::Arc; + +use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::Json, +}; +use serde::Serialize; + +use crate::state::AppState; + +use super::bridge::{canonical_url, check_nip98_replay, verify_bridge_auth}; +use super::{api_error, internal_error}; + +#[derive(Debug, Serialize)] +pub struct AgentOwnershipResponse { + pub agent_pubkey: String, + pub owner_pubkey: Option, + pub is_owner: bool, +} + +/// Resolve whether the authenticated user owns `agent_pubkey` per relay DB. +pub async fn get_agent_ownership( + State(state): State>, + headers: HeaderMap, + Path(agent_pubkey): Path, +) -> Result, (StatusCode, Json)> { + let agent_hex = agent_pubkey.trim().to_ascii_lowercase(); + if agent_hex.len() != 64 || !agent_hex.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(api_error(StatusCode::BAD_REQUEST, "invalid agent pubkey")); + } + + let agent_bytes = hex::decode(&agent_hex) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid agent pubkey hex"))?; + + let path = format!("/api/agents/{agent_hex}/ownership"); + let url = canonical_url(&state.config.relay_url, &path); + let (actor_pubkey, event_id_bytes) = + verify_bridge_auth(&headers, "GET", &url, None, state.config.require_auth_token)?; + check_nip98_replay(&state, event_id_bytes)?; + + let actor_bytes = actor_pubkey.to_bytes().to_vec(); + let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + super::relay_members::enforce_relay_membership(&state, &actor_bytes, auth_tag).await?; + + let owner_pubkey = state + .db + .get_agent_channel_policy(&agent_bytes) + .await + .map_err(|e| internal_error(&format!("ownership lookup failed: {e}")))? + .and_then(|(_policy, owner)| owner); + + let is_owner = state + .db + .is_agent_owner(&agent_bytes, &actor_bytes) + .await + .map_err(|e| internal_error(&format!("ownership check failed: {e}")))?; + + Ok(Json(AgentOwnershipResponse { + agent_pubkey: agent_hex, + owner_pubkey: owner_pubkey.map(hex::encode), + is_owner, + })) +} diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 342d0e341..99f6dbda3 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -24,7 +24,7 @@ use super::{api_error, internal_error, not_found}; /// /// Returns the authenticated public key and an event ID for replay detection. /// For X-Pubkey dev mode, the event ID is a zero hash (no replay concern). -fn verify_bridge_auth( +pub(crate) fn verify_bridge_auth( headers: &HeaderMap, method: &str, url: &str, @@ -73,7 +73,7 @@ fn verify_bridge_auth( /// /// Uses moka's `entry` API for atomic insert-if-absent — no race window /// between "check if seen" and "mark as seen". -fn check_nip98_replay( +pub(crate) fn check_nip98_replay( state: &AppState, event_id_bytes: [u8; 32], ) -> Result<(), (StatusCode, Json)> { @@ -95,7 +95,7 @@ fn check_nip98_replay( } /// Reconstruct the canonical URL for NIP-98 verification from the relay config. -fn canonical_url(relay_url: &str, path: &str) -> String { +pub(crate) fn canonical_url(relay_url: &str, path: &str) -> String { let base = relay_url .trim() .trim_end_matches('/') diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index e7c1b6fd7..6d519a162 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -1,5 +1,6 @@ //! HTTP API — media, git, NIP-05, and the Nostr HTTP bridge. +pub mod agents; pub mod bridge; pub mod events; pub mod git; diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 226592a07..703a5b109 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -64,6 +64,10 @@ pub fn build_router(state: Arc) -> Router { .route("/events", post(api::bridge::submit_event)) .route("/query", post(api::bridge::query_events)) .route("/count", post(api::bridge::count_events)) + .route( + "/api/agents/{pubkey}/ownership", + get(api::agents::get_agent_ownership), + ) // Webhook trigger (secret-authenticated, no NIP-98) .route("/hooks/{id}", post(api::bridge::workflow_webhook)) // Huddle audio WebSocket route diff --git a/desktop/src-tauri/src/commands/agent_ownership.rs b/desktop/src-tauri/src/commands/agent_ownership.rs new file mode 100644 index 000000000..a607d06d7 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_ownership.rs @@ -0,0 +1,38 @@ +//! Relay-authoritative agent ownership lookup for activity visibility gates. + +use reqwest::Method; +use serde::Serialize; +use tauri::State; + +use crate::{ + app_state::AppState, + relay::{get_relay_json, relay_api_base_url_with_override}, +}; + +#[derive(Debug, Serialize, serde::Deserialize)] +pub struct AgentOwnershipStatus { + /// Lowercase hex pubkey of the queried agent. + pub agent_pubkey: String, + /// Lowercase hex owner pubkey from relay `agent_owner_pubkey`, if set. + pub owner_pubkey: Option, + /// True iff the current workspace identity is the relay-recorded owner. + pub is_owner: bool, +} + +/// Resolve whether the current identity owns `agent_pubkey` per relay DB. +#[tauri::command] +pub async fn resolve_agent_ownership( + agent_pubkey: String, + state: State<'_, AppState>, +) -> Result { + let agent_hex = agent_pubkey.trim().to_ascii_lowercase(); + if agent_hex.len() != 64 { + return Err("agent pubkey must be 64 hex characters".to_string()); + } + + let api_base = relay_api_base_url_with_override(&state); + let path = format!("/api/agents/{agent_hex}/ownership"); + let url = format!("{api_base}{path}"); + + get_relay_json::(&state, Method::GET, &url, &[]).await +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 559577bf7..698ea6cbc 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ mod agent_discovery; mod agent_models; +mod agent_ownership; mod agent_settings; mod agents; mod canvas; @@ -29,6 +30,7 @@ mod workspace; pub use agent_discovery::*; pub use agent_models::*; +pub use agent_ownership::*; pub use agent_settings::*; pub use agents::*; pub use canvas::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 10a8f98a0..715a5f257 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -749,6 +749,7 @@ pub fn run() { unarchive_identity, list_archived_identities, resolve_oa_owner, + resolve_agent_ownership, list_relay_agents, list_managed_agents, create_managed_agent, 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-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 332eb4f33..adc0e4178 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -281,6 +281,32 @@ pub async fn query_relay_at( parse_json_response(response).await } +// ── HTTP bridge: GET (JSON) ───────────────────────────────────────────────── + +/// Execute an authenticated GET against the relay HTTP API and deserialize JSON. +pub async fn get_relay_json( + state: &AppState, + method: Method, + url: &str, + body: &[u8], +) -> Result { + let auth = build_nip98_auth_header(&method, url, body, state)?; + + let response = state + .http_client + .request(method, url) + .header("Authorization", auth) + .send() + .await + .map_err(|e| classify_request_error(&e))?; + + if !response.status().is_success() { + return Err(relay_error_message(response).await); + } + + parse_json_response(response).await +} + // ── Command response parsing ──────────────────────────────────────────────── /// Parse a command-event OK message of the form `"response:"`. diff --git a/desktop/src/features/agents/hooks/useCanViewAgentActivity.ts b/desktop/src/features/agents/hooks/useCanViewAgentActivity.ts new file mode 100644 index 000000000..33f0d2d48 --- /dev/null +++ b/desktop/src/features/agents/hooks/useCanViewAgentActivity.ts @@ -0,0 +1,44 @@ +import { useQuery } from "@tanstack/react-query"; + +import { useIsManagedAgent } from "@/features/agent-memory/hooks"; +import { resolveCanViewAgentActivity } from "@/features/agents/lib/canViewAgentActivity"; +import { resolveAgentOwnership } from "@/shared/api/tauriAgentOwnership"; + +export const agentOwnershipQueryKey = (agentPubkey: string) => + ["agentOwnership", agentPubkey.toLowerCase()] as const; + +export function useAgentOwnershipQuery( + agentPubkey: string | null | undefined, + enabled = true, +) { + return useQuery({ + enabled: enabled && Boolean(agentPubkey), + queryKey: agentOwnershipQueryKey(agentPubkey ?? ""), + queryFn: () => resolveAgentOwnership(agentPubkey as string), + staleTime: 60_000, + }); +} + +/** + * Relay-authoritative gate for observer activity visibility. + * + * Returns `{ canView, isLoading }`. While ownership is loading, locally + * managed agents may show activity optimistically; the final answer always + * comes from relay `is_agent_owner`. + */ +export function useCanViewAgentActivity( + agentPubkey: string | null | undefined, + options?: { enabled?: boolean }, +) { + const enabled = (options?.enabled ?? true) && Boolean(agentPubkey); + const ownershipQuery = useAgentOwnershipQuery(agentPubkey, enabled); + const isManagedAgent = useIsManagedAgent(enabled ? agentPubkey : null); + + return resolveCanViewAgentActivity({ + relayOwnership: ownershipQuery.data, + isManagedAgent, + isOwnershipLoading: ownershipQuery.isLoading, + isOwnershipError: ownershipQuery.isError, + isManagedLoading: isManagedAgent === undefined, + }); +} diff --git a/desktop/src/features/agents/lib/agentSessionOwnershipResolution.test.mjs b/desktop/src/features/agents/lib/agentSessionOwnershipResolution.test.mjs new file mode 100644 index 000000000..70a7b4df8 --- /dev/null +++ b/desktop/src/features/agents/lib/agentSessionOwnershipResolution.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + getChannelAgentSessionAgents, + resolveOpenAgentSessionAgent, +} from "../../channels/lib/agentSessionCandidates.ts"; + +const agent = (pubkey, source) => ({ + pubkey, + name: pubkey.slice(0, 8), + status: "deployed", + agentSource: source, + canInterruptTurn: source === "managed", +}); + +test("resolveOpenAgentSessionAgent prefers channel-scoped candidate", () => { + const channelAgent = agent("aa".repeat(32), "managed"); + const otherAgent = agent("bb".repeat(32), "relay"); + + const resolved = resolveOpenAgentSessionAgent({ + allAgentCandidates: [channelAgent, otherAgent], + channelAgentSessionAgents: [channelAgent], + openAgentSessionPubkey: channelAgent.pubkey, + }); + + assert.equal(resolved, channelAgent); +}); + +test("resolveOpenAgentSessionAgent falls back to owned agent outside channel list", () => { + const ownedAgent = agent("cc".repeat(32), "relay"); + + const resolved = resolveOpenAgentSessionAgent({ + allAgentCandidates: [ownedAgent], + channelAgentSessionAgents: [], + openAgentSessionPubkey: ownedAgent.pubkey, + }); + + assert.equal(resolved, ownedAgent); +}); + +test("resolveOpenAgentSessionAgent synthesizes minimal agent when metadata is stale", () => { + const pubkey = "dd".repeat(32); + + const resolved = resolveOpenAgentSessionAgent({ + allAgentCandidates: [], + channelAgentSessionAgents: [], + openAgentSessionPubkey: pubkey, + }); + + assert.deepEqual(resolved, { + pubkey, + name: pubkey.slice(0, 8), + status: "deployed", + agentSource: "relay", + canInterruptTurn: false, + }); +}); + +test("getChannelAgentSessionAgents keeps managed agents visible in channel membership", () => { + const activeChannel = { + id: "channel-1", + name: "general", + }; + const candidates = [agent("ee".repeat(32), "managed")]; + + const visible = getChannelAgentSessionAgents({ + activeChannel, + activeChannelId: activeChannel.id, + agents: candidates, + channelMembers: [ + { + pubkey: candidates[0].pubkey, + role: "bot", + displayName: "Scout", + }, + ], + }); + + assert.equal(visible.length, 1); + assert.equal(visible[0]?.pubkey, candidates[0].pubkey); +}); diff --git a/desktop/src/features/agents/lib/canViewAgentActivity.test.mjs b/desktop/src/features/agents/lib/canViewAgentActivity.test.mjs new file mode 100644 index 000000000..5fad347de --- /dev/null +++ b/desktop/src/features/agents/lib/canViewAgentActivity.test.mjs @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveCanViewAgentActivity } from "./canViewAgentActivity.ts"; + +test("resolveCanViewAgentActivity returns true when relay confirms ownership", () => { + const result = resolveCanViewAgentActivity({ + relayOwnership: { + agentPubkey: "aa".repeat(32), + ownerPubkey: "bb".repeat(32), + isOwner: true, + }, + isManagedAgent: false, + isOwnershipLoading: false, + isOwnershipError: false, + isManagedLoading: false, + }); + + assert.equal(result.canView, true); + assert.equal(result.isLoading, false); +}); + +test("resolveCanViewAgentActivity returns false when relay denies ownership", () => { + const result = resolveCanViewAgentActivity({ + relayOwnership: { + agentPubkey: "aa".repeat(32), + ownerPubkey: "bb".repeat(32), + isOwner: false, + }, + isManagedAgent: true, + isOwnershipLoading: false, + isOwnershipError: false, + isManagedLoading: false, + }); + + assert.equal(result.canView, false); + assert.equal(result.isLoading, false); +}); + +test("resolveCanViewAgentActivity optimistically allows locally managed agents while loading", () => { + const result = resolveCanViewAgentActivity({ + relayOwnership: undefined, + isManagedAgent: true, + isOwnershipLoading: true, + isOwnershipError: false, + isManagedLoading: false, + }); + + assert.equal(result.canView, true); + assert.equal(result.isLoading, true); +}); + +test("resolveCanViewAgentActivity stays closed for non-managed agents while loading", () => { + const result = resolveCanViewAgentActivity({ + relayOwnership: undefined, + isManagedAgent: false, + isOwnershipLoading: true, + isOwnershipError: false, + isManagedLoading: false, + }); + + assert.equal(result.canView, false); + assert.equal(result.isLoading, true); +}); + +test("resolveCanViewAgentActivity keeps locally managed agents visible when ownership lookup errors", () => { + const result = resolveCanViewAgentActivity({ + relayOwnership: undefined, + isManagedAgent: true, + isOwnershipLoading: false, + isOwnershipError: true, + isManagedLoading: false, + }); + + assert.equal(result.canView, true); + assert.equal(result.isLoading, false); +}); + +test("resolveCanViewAgentActivity stays closed for non-managed agents when ownership lookup errors", () => { + const result = resolveCanViewAgentActivity({ + relayOwnership: undefined, + isManagedAgent: false, + isOwnershipLoading: false, + isOwnershipError: true, + isManagedLoading: false, + }); + + assert.equal(result.canView, false); + assert.equal(result.isLoading, false); +}); diff --git a/desktop/src/features/agents/lib/canViewAgentActivity.ts b/desktop/src/features/agents/lib/canViewAgentActivity.ts new file mode 100644 index 000000000..fea48ecfb --- /dev/null +++ b/desktop/src/features/agents/lib/canViewAgentActivity.ts @@ -0,0 +1,45 @@ +import type { AgentOwnershipStatus } from "@/shared/api/tauriAgentOwnership"; + +export type CanViewAgentActivityInput = { + relayOwnership: AgentOwnershipStatus | undefined; + isManagedAgent: boolean | undefined; + isOwnershipLoading: boolean; + isOwnershipError: boolean; + isManagedLoading: boolean; +}; + +export type CanViewAgentActivityResult = { + canView: boolean; + isLoading: boolean; +}; + +/** + * Unified predicate for Show Activity / Activity log ingresses. + * + * Final permission comes from relay `is_agent_owner`. While the relay lookup + * is in flight, locally managed agents may show activity optimistically. + */ +export function resolveCanViewAgentActivity({ + relayOwnership, + isManagedAgent, + isOwnershipLoading, + isOwnershipError, + isManagedLoading, +}: CanViewAgentActivityInput): CanViewAgentActivityResult { + if (relayOwnership?.isOwner === true) { + return { canView: true, isLoading: false }; + } + + if (relayOwnership?.isOwner === false) { + return { canView: false, isLoading: false }; + } + + const isLoading = + isOwnershipLoading || (isManagedAgent === undefined && isManagedLoading); + + if (isManagedAgent === true && (isOwnershipLoading || isOwnershipError)) { + return { canView: true, isLoading }; + } + + return { canView: false, isLoading }; +} diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index ee8483dc8..cc8905d0a 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -41,6 +41,7 @@ const snapshotByAgent = new Map(); // Normalized pubkeys of agents we are actively managing. Only events whose // "agent" tag matches an entry here will be decrypted (defense-in-depth). const knownAgentPubkeys = new Set(); +const knownAgentPubkeysByBridge = new Map>(); let connectionState: ConnectionState = "idle"; let errorMessage: string | null = null; @@ -275,6 +276,7 @@ export function getAgentTranscript( export function useManagedAgentObserverBridge( agents: readonly Pick[], ) { + const bridgeIdRef = React.useRef(Symbol("managed-agent-observer")); const hasActiveAgent = React.useMemo( () => agents.some( @@ -285,10 +287,27 @@ export function useManagedAgentObserverBridge( // Keep the trusted-pubkey set in sync with the current managed agent list. React.useEffect(() => { + const bridgeId = bridgeIdRef.current; + knownAgentPubkeysByBridge.set( + bridgeId, + new Set(agents.map((agent) => normalizePubkey(agent.pubkey))), + ); knownAgentPubkeys.clear(); - for (const agent of agents) { - knownAgentPubkeys.add(normalizePubkey(agent.pubkey)); + for (const pubkeys of knownAgentPubkeysByBridge.values()) { + for (const pubkey of pubkeys) { + knownAgentPubkeys.add(pubkey); + } } + + return () => { + knownAgentPubkeysByBridge.delete(bridgeId); + knownAgentPubkeys.clear(); + for (const pubkeys of knownAgentPubkeysByBridge.values()) { + for (const pubkey of pubkeys) { + knownAgentPubkeys.add(pubkey); + } + } + }; }, [agents]); React.useEffect(() => { @@ -309,6 +328,7 @@ export function resetAgentObserverStore() { transcriptByAgent.clear(); snapshotByAgent.clear(); knownAgentPubkeys.clear(); + knownAgentPubkeysByBridge.clear(); connectionState = "idle"; errorMessage = null; notifyListeners(); diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx index 09411f2e6..c06c8add8 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx @@ -1,30 +1,13 @@ import * as React from "react"; -import { ArrowUpRight, ChevronDown, Wrench } from "lucide-react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { ChevronDown } 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 { - formatToolTitle, - getBuzzToolInfo, - getToolStatusDisplay, -} from "./agentSessionToolCatalog"; -import { - asRecord, - formatCodeValue, - formatDuration, - formatTranscriptTime, - getResultArray, - getToolString, - getToolStringList, - shortenMiddle, -} from "./agentSessionUtils"; +import { getBuzzToolInfo } from "./agentSessionToolCatalog"; +import { buildCompactToolSummary } from "./agentSessionToolSummary"; +import { asRecord, formatCodeValue, formatDuration } from "./agentSessionUtils"; export function ToolItem({ item, @@ -32,14 +15,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 = getToolDuration(item); const handleToggle = React.useCallback( (event: React.SyntheticEvent) => { setIsExpanded(event.currentTarget.open); @@ -48,40 +29,27 @@ export function ToolItem({ ); return ( -
+
- - {ToolIcon ? ( - - ) : null} - - {toolTitle} - - {buzzTool ? ( - - ) : null} - {showStatus ? ( - - - {status.label} - - ) : null} - - + + @@ -97,11 +73,218 @@ export function ToolItem({ ); } +function compactSummaryTone() { + return "text-muted-foreground/60 group-open:text-muted-foreground"; +} + +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 resolveImageSrc(source: string): string { + if (source.startsWith("data:image/")) { + return source; + } + return rewriteRelayUrl(source); +} + +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 getToolDuration(item: Extract) { + 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); +} + +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; +} + +function formatDurationMs(ms: number) { + 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`; +} + function ToolDetailBlocks({ args, description, hasArgs, hasResult, + imagePreview, isError, result, }: { @@ -109,6 +292,7 @@ function ToolDetailBlocks({ description?: string; hasArgs: boolean; hasResult: boolean; + imagePreview: { src: string | null; title: string | null } | null; isError: boolean; result: string; }) { @@ -119,6 +303,12 @@ function ToolDetailBlocks({ {description}

) : null} + {imagePreview?.src ? ( + + ) : null} {hasArgs ? (
;
-}) {
-  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;
@@ -421,8 +376,3 @@ function parseToolResultValue(result: string): unknown {
     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 ef11f463f..3d4f5c299 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 ( +
+ +
+
+

{text}

+ {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}

+

{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 4ff97a5bb..327213ff2 100644 --- a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx @@ -25,10 +25,14 @@ 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 +43,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, @@ -93,13 +99,17 @@ export function ManagedAgentSessionPanel({ ) : null} @@ -143,26 +153,49 @@ 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[]; }) { + if (showRaw && rawLayout === "exclusive") { + return ( + <> + + + {errorMessage ? ( +

+ + {errorMessage} +

+ ) : null} + + ); + } + return ( <> {!hasObserver ? ( @@ -172,18 +205,23 @@ function SessionBody({ ) : (
- {showRaw ? : null} + {showRaw && rawLayout === "responsive" ? ( + + ) : 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/agentSessionToolSummary.test.mjs b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs new file mode 100644 index 000000000..7b416eb59 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs @@ -0,0 +1,122 @@ +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"); +}); + +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"); +}); + +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..f82fbd4c2 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.ts @@ -0,0 +1,326 @@ +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; +}; + +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 { preview, thumbnailSrc } = extractCompactToolPreview(item, kind); + return { + kind, + label: compactToolLabel(kind, item, item.status, item.isError), + preview, + thumbnailSrc, + }; +} + +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 base === "shell" ? "shell" : "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", + }, + }; +} + +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 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..d350ca299 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts @@ -0,0 +1,211 @@ +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; + } + if (isSetupLifecycle(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/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/channels/lib/agentSessionCandidates.test.mjs b/desktop/src/features/channels/lib/agentSessionCandidates.test.mjs new file mode 100644 index 000000000..d02725117 --- /dev/null +++ b/desktop/src/features/channels/lib/agentSessionCandidates.test.mjs @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildChannelAgentSessionCandidates, + getChannelAgentSessionAgents, +} from "./agentSessionCandidates.ts"; + +const CHANNEL = { + id: "channel-1", + name: "general", + channelType: "stream", + visibility: "private", + description: "", + topic: null, + purpose: null, + memberCount: 0, + memberPubkeys: [], + lastMessageAt: null, + archivedAt: null, + participants: [], + participantPubkeys: [], + isMember: true, + ttlSeconds: null, + ttlDeadline: null, +}; + +function member(overrides) { + return { + pubkey: "aa".repeat(32), + role: "member", + isAgent: false, + joinedAt: "2024-01-01T00:00:00Z", + displayName: "Agent", + ...overrides, + }; +} + +test("buildChannelAgentSessionCandidates includes members marked isAgent", () => { + const candidates = buildChannelAgentSessionCandidates({ + channelMembers: [ + member({ + pubkey: "11".repeat(32), + role: "member", + isAgent: true, + displayName: "Ned", + }), + ], + managedAgents: [], + relayAgents: [], + }); + + assert.deepEqual( + candidates.map((agent) => ({ + name: agent.name, + pubkey: agent.pubkey, + source: agent.agentSource, + })), + [{ name: "Ned", pubkey: "11".repeat(32), source: "member-bot" }], + ); +}); + +test("getChannelAgentSessionAgents keeps isAgent member candidates in channel scope", () => { + const channelMembers = [ + member({ + pubkey: "22".repeat(32), + role: "member", + isAgent: true, + displayName: "Ned", + }), + ]; + const candidates = buildChannelAgentSessionCandidates({ + channelMembers, + managedAgents: [], + relayAgents: [], + }); + + const scoped = getChannelAgentSessionAgents({ + activeChannel: CHANNEL, + activeChannelId: CHANNEL.id, + agents: candidates, + channelMembers, + }); + + assert.equal(scoped.length, 1); + assert.equal(scoped[0].pubkey, "22".repeat(32)); +}); diff --git a/desktop/src/features/channels/lib/agentSessionCandidates.ts b/desktop/src/features/channels/lib/agentSessionCandidates.ts new file mode 100644 index 000000000..92aa6811b --- /dev/null +++ b/desktop/src/features/channels/lib/agentSessionCandidates.ts @@ -0,0 +1,168 @@ +import type { + Channel, + ChannelMember, + ManagedAgent, + RelayAgent, +} from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export type ChannelAgentSessionAgent = Pick< + ManagedAgent, + "pubkey" | "name" | "status" +> & { + agentSource: "managed" | "member-bot" | "relay"; + avatarUrl?: string | null; + canInterruptTurn: boolean; + channelIds?: string[]; + channels?: string[]; +}; + +function relayStatusToManagedStatus( + status: RelayAgent["status"], +): ManagedAgent["status"] { + return status === "offline" ? "stopped" : "deployed"; +} + +function isAgentChannelMember(member: ChannelMember) { + return member.role === "bot" || member.isAgent; +} + +export function buildChannelAgentSessionCandidates({ + channelMembers, + managedAgents, + relayAgents, +}: { + channelMembers?: ChannelMember[]; + managedAgents: ManagedAgent[]; + relayAgents: RelayAgent[]; +}): ChannelAgentSessionAgent[] { + const byPubkey = new Map(); + + for (const agent of relayAgents) { + byPubkey.set(normalizePubkey(agent.pubkey), { + pubkey: agent.pubkey, + name: agent.name, + status: relayStatusToManagedStatus(agent.status), + agentSource: "relay", + canInterruptTurn: false, + channelIds: agent.channelIds, + channels: agent.channels, + }); + } + + for (const agent of managedAgents) { + const key = normalizePubkey(agent.pubkey); + const existing = byPubkey.get(key); + byPubkey.set(key, { + pubkey: agent.pubkey, + name: agent.name, + status: agent.status, + agentSource: "managed", + avatarUrl: agent.avatarUrl, + canInterruptTurn: true, + channelIds: existing?.channelIds, + channels: existing?.channels, + }); + } + + for (const member of channelMembers ?? []) { + const key = normalizePubkey(member.pubkey); + if (!isAgentChannelMember(member) || byPubkey.has(key)) { + continue; + } + + byPubkey.set(key, { + pubkey: member.pubkey, + name: member.displayName ?? member.pubkey.slice(0, 8), + status: "deployed", + agentSource: "member-bot", + canInterruptTurn: false, + }); + } + + return [...byPubkey.values()]; +} + +export function getChannelAgentSessionAgents({ + activeChannel, + activeChannelId, + agents, + channelMembers, +}: { + activeChannel: Channel | null; + activeChannelId: string | null; + agents: ChannelAgentSessionAgent[]; + channelMembers?: ChannelMember[]; +}): ChannelAgentSessionAgent[] { + if (!activeChannelId || !activeChannel) { + return []; + } + + const memberPubkeys = channelMembers + ? new Set(channelMembers.map((member) => normalizePubkey(member.pubkey))) + : null; + const botMemberPubkeys = channelMembers + ? new Set( + channelMembers + .filter(isAgentChannelMember) + .map((member) => normalizePubkey(member.pubkey)), + ) + : null; + + return agents.filter((agent) => { + const normalizedPubkey = normalizePubkey(agent.pubkey); + const channelIds = agent.channelIds ?? []; + const channels = agent.channels ?? []; + const hasDeclaredChannelScope = + channelIds.length > 0 || channels.length > 0; + const matchesDeclaredChannel = + channelIds.includes(activeChannelId) || + channels.includes(activeChannel.name); + + if (agent.agentSource === "member-bot") { + return botMemberPubkeys?.has(normalizedPubkey) ?? matchesDeclaredChannel; + } + + if (agent.agentSource === "managed") { + return memberPubkeys?.has(normalizedPubkey) ?? matchesDeclaredChannel; + } + + if (matchesDeclaredChannel) { + return true; + } + + return ( + !hasDeclaredChannelScope && Boolean(memberPubkeys?.has(normalizedPubkey)) + ); + }); +} + +export function resolveOpenAgentSessionAgent({ + allAgentCandidates, + channelAgentSessionAgents, + openAgentSessionPubkey, +}: { + allAgentCandidates: ChannelAgentSessionAgent[]; + channelAgentSessionAgents: ChannelAgentSessionAgent[]; + openAgentSessionPubkey: string | null; +}): ChannelAgentSessionAgent | null { + if (!openAgentSessionPubkey) { + return null; + } + + const normalized = normalizePubkey(openAgentSessionPubkey); + return ( + channelAgentSessionAgents.find( + (agent) => normalizePubkey(agent.pubkey) === normalized, + ) ?? + allAgentCandidates.find( + (agent) => normalizePubkey(agent.pubkey) === normalized, + ) ?? { + pubkey: openAgentSessionPubkey, + name: openAgentSessionPubkey.slice(0, 8), + status: "deployed", + agentSource: "relay", + canInterruptTurn: false, + } + ); +} diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index c35be5657..1c402a5cb 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 d4ee6ff1e..a4582ddde 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/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index df53071ae..e2f965c80 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -119,6 +119,7 @@ type ChannelPaneProps = { personaLookup?: Map; profiles?: UserProfileLookup; openThreadHeadId: string | null; + openAgentSessionAgent: ChannelAgentSessionAgent | null; openAgentSessionPubkey: string | null; profilePanelPubkey?: string | null; threadHeadMessage: TimelineMessage | null; @@ -241,6 +242,7 @@ export const ChannelPane = React.memo(function ChannelPane({ personaLookup, profiles, openThreadHeadId, + openAgentSessionAgent, openAgentSessionPubkey, profilePanelPubkey, targetMessageId, @@ -597,16 +599,7 @@ export const ChannelPane = React.memo(function ChannelPane({ const isOverlay = useIsThreadPanelOverlay(); const useSplitAuxiliaryPane = !isSinglePanelView && !isOverlay; - - const selectedAgent = React.useMemo( - () => - openAgentSessionPubkey - ? (agentSessionAgents.find( - (agent) => agent.pubkey === openAgentSessionPubkey, - ) ?? null) - : null, - [agentSessionAgents, openAgentSessionPubkey], - ); + const selectedAgent = openAgentSessionAgent; return (
{!isSinglePanelView ? ( diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 9e4737957..834420555 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -58,7 +58,10 @@ import { mergeAgentNamesIntoProfiles, useChannelActivityTyping, } from "./useChannelActivityTyping"; -import { useChannelAgentSessions } from "./useChannelAgentSessions"; +import { + buildChannelAgentSessionCandidates, + useChannelAgentSessions, +} from "./useChannelAgentSessions"; import { useChannelProfilePanel } from "./useChannelProfilePanel"; import { useChannelRouteTarget } from "./useChannelRouteTarget"; import type { ChannelScreenProps } from "./ChannelScreen.types"; @@ -178,25 +181,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(); @@ -218,6 +202,36 @@ 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({ + channelMembers, + managedAgents, + relayAgents, + }), + [channelMembers, managedAgents, relayAgents], + ); const { botTypingEntries, channelAgentSessionAgents: activeChannelAgentSessionAgents, @@ -406,14 +420,15 @@ export function ChannelScreen({ channelAgentSessionAgents, closeAgentSession: handleCloseAgentSession, openAgentSession: handleOpenAgentSession, + openAgentSessionAgent, openAgentSessionPubkey, openThreadAndCloseAgentSession: handleOpenThreadAndCloseAgentSession, } = useChannelAgentSessions({ activeChannel, activeChannelId, + agentCandidates: allAgentSessionCandidates, channelMembers, handleOpenThread, - managedAgents: activeChannelAgentSessionAgents, setExpandedThreadReplyIds, setOpenThreadHeadId, setProfilePanelPubkey, @@ -638,6 +653,7 @@ export function ChannelScreen({ } onThreadPanelResizeStart={handleThreadPanelResizeStart} onToggleReaction={effectiveToggleReaction} + openAgentSessionAgent={openAgentSessionAgent} openAgentSessionPubkey={openAgentSessionPubkey} openThreadHeadId={openThreadHeadId} profilePanelPubkey={profilePanelPubkey} diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index 69974cb30..cabe1ad5d 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -9,6 +9,7 @@ import { Trash2, } from "lucide-react"; +import { useCanViewAgentActivity } from "@/features/agents/hooks/useCanViewAgentActivity"; import { getManagedAgentPrimaryActionLabel, isManagedAgentActive, @@ -106,13 +107,13 @@ export function MembersSidebarMemberCard({ }: MembersSidebarMemberCardProps) { const roleLabel = formatRoleLabel(member, memberIsBot); const disabled = isActionPending || isArchived; - const canViewActivity = - memberIsBot && - managedAgent?.backend.type === "local" && - Boolean(onViewActivity); + const { canView: canViewActivity } = useCanViewAgentActivity(member.pubkey, { + enabled: Boolean(onViewActivity), + }); + const canShowActivity = canViewActivity && Boolean(onViewActivity); const hasActions = memberIsBot - ? Boolean(managedAgent) || canRemoveMember || canViewActivity - : canRemoveMember || canChangeRole; + ? Boolean(managedAgent) || canRemoveMember || canShowActivity + : canRemoveMember || canChangeRole || canShowActivity; const memberIdentity = ( <> @@ -188,7 +189,7 @@ export function MembersSidebarMemberCard({ & { - agentSource: "managed" | "member-bot" | "relay"; - canInterruptTurn: boolean; - channelIds?: string[]; - channels?: string[]; -}; +import { + type ChannelAgentSessionAgent, + getChannelAgentSessionAgents, + resolveOpenAgentSessionAgent, +} from "../lib/agentSessionCandidates"; + +export type { ChannelAgentSessionAgent } from "../lib/agentSessionCandidates"; +export { + buildChannelAgentSessionCandidates, + getChannelAgentSessionAgents, + resolveOpenAgentSessionAgent, +} from "../lib/agentSessionCandidates"; type UseChannelAgentSessionsOptions = { activeChannel: Channel | null; activeChannelId: string | null; + agentCandidates: ChannelAgentSessionAgent[]; channelMembers?: ChannelMember[]; handleOpenThread: (message: TimelineMessage) => void; - managedAgents: ChannelAgentSessionAgent[]; setExpandedThreadReplyIds: (value: Set) => void; setOpenThreadHeadId: (value: string | null) => void; setProfilePanelPubkey: (value: string | null) => void; @@ -32,127 +31,12 @@ type UseChannelAgentSessionsOptions = { setThreadScrollTargetId: (value: string | null) => void; }; -function relayStatusToManagedStatus( - status: RelayAgent["status"], -): ManagedAgent["status"] { - return status === "offline" ? "stopped" : "deployed"; -} - -export function buildChannelAgentSessionCandidates({ - channelMembers, - managedAgents, - relayAgents, -}: { - channelMembers?: ChannelMember[]; - managedAgents: ManagedAgent[]; - relayAgents: RelayAgent[]; -}): ChannelAgentSessionAgent[] { - const byPubkey = new Map(); - - for (const agent of relayAgents) { - byPubkey.set(normalizePubkey(agent.pubkey), { - pubkey: agent.pubkey, - name: agent.name, - status: relayStatusToManagedStatus(agent.status), - agentSource: "relay", - canInterruptTurn: false, - channelIds: agent.channelIds, - channels: agent.channels, - }); - } - - for (const agent of managedAgents) { - const key = normalizePubkey(agent.pubkey); - const existing = byPubkey.get(key); - byPubkey.set(key, { - pubkey: agent.pubkey, - name: agent.name, - status: agent.status, - agentSource: "managed", - canInterruptTurn: true, - channelIds: existing?.channelIds, - channels: existing?.channels, - }); - } - - for (const member of channelMembers ?? []) { - const key = normalizePubkey(member.pubkey); - if (member.role !== "bot" || byPubkey.has(key)) { - continue; - } - - byPubkey.set(key, { - pubkey: member.pubkey, - name: member.displayName ?? member.pubkey.slice(0, 8), - status: "deployed", - agentSource: "member-bot", - canInterruptTurn: false, - }); - } - - return [...byPubkey.values()]; -} - -export function getChannelAgentSessionAgents({ - activeChannel, - activeChannelId, - agents, - channelMembers, -}: { - activeChannel: Channel | null; - activeChannelId: string | null; - agents: ChannelAgentSessionAgent[]; - channelMembers?: ChannelMember[]; -}): ChannelAgentSessionAgent[] { - if (!activeChannelId || !activeChannel) { - return []; - } - - const memberPubkeys = channelMembers - ? new Set(channelMembers.map((member) => normalizePubkey(member.pubkey))) - : null; - const botMemberPubkeys = channelMembers - ? new Set( - channelMembers - .filter((member) => member.role === "bot") - .map((member) => normalizePubkey(member.pubkey)), - ) - : null; - - return agents.filter((agent) => { - const normalizedPubkey = normalizePubkey(agent.pubkey); - const channelIds = agent.channelIds ?? []; - const channels = agent.channels ?? []; - const hasDeclaredChannelScope = - channelIds.length > 0 || channels.length > 0; - const matchesDeclaredChannel = - channelIds.includes(activeChannelId) || - channels.includes(activeChannel.name); - - if (agent.agentSource === "member-bot") { - return botMemberPubkeys?.has(normalizedPubkey) ?? matchesDeclaredChannel; - } - - if (agent.agentSource === "managed") { - return memberPubkeys?.has(normalizedPubkey) ?? matchesDeclaredChannel; - } - - if (matchesDeclaredChannel) { - return true; - } - - return ( - !hasDeclaredChannelScope && Boolean(memberPubkeys?.has(normalizedPubkey)) - ); - }); -} - export function useChannelAgentSessions({ activeChannel, activeChannelId, + agentCandidates, channelMembers, handleOpenThread, - managedAgents, setExpandedThreadReplyIds, setOpenThreadHeadId, setProfilePanelPubkey, @@ -168,10 +52,22 @@ export function useChannelAgentSessions({ getChannelAgentSessionAgents({ activeChannel, activeChannelId, - agents: managedAgents, + agents: agentCandidates, channelMembers, }), - [activeChannel, activeChannelId, channelMembers, managedAgents], + [activeChannel, activeChannelId, agentCandidates, channelMembers], + ); + + const ownershipQuery = useAgentOwnershipQuery(openAgentSessionPubkey); + + const openAgentSessionAgent = React.useMemo( + () => + resolveOpenAgentSessionAgent({ + allAgentCandidates: agentCandidates, + channelAgentSessionAgents, + openAgentSessionPubkey, + }), + [agentCandidates, channelAgentSessionAgents, openAgentSessionPubkey], ); const closeAgentSession = React.useCallback(() => { @@ -210,22 +106,38 @@ export function useChannelAgentSessions({ ); React.useEffect(() => { - if ( - openAgentSessionPubkey && - !channelAgentSessionAgents.some( - (agent) => - normalizePubkey(agent.pubkey) === - normalizePubkey(openAgentSessionPubkey), - ) - ) { + if (!openAgentSessionPubkey) { + return; + } + + const inChannelList = channelAgentSessionAgents.some( + (agent) => + normalizePubkey(agent.pubkey) === + normalizePubkey(openAgentSessionPubkey), + ); + if (inChannelList) { + return; + } + + if (ownershipQuery.isLoading || ownershipQuery.data === undefined) { + return; + } + + if (!ownershipQuery.data.isOwner) { setOpenAgentSessionPubkey(null); } - }, [channelAgentSessionAgents, openAgentSessionPubkey]); + }, [ + channelAgentSessionAgents, + openAgentSessionPubkey, + ownershipQuery.data, + ownershipQuery.isLoading, + ]); return { channelAgentSessionAgents, closeAgentSession, openAgentSession, + openAgentSessionAgent, openAgentSessionPubkey, openThreadAndCloseAgentSession, selectAgentSession, diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index f71a5f575..b083516de 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -6,6 +6,7 @@ import { useAgentMemoryQuery, useIsManagedAgent, } from "@/features/agent-memory/hooks"; +import { useCanViewAgentActivity } from "@/features/agents/hooks/useCanViewAgentActivity"; import { MemoryRefreshButton } from "@/features/agent-memory/ui/MemorySection"; import { useRelayAgentsQuery, @@ -178,6 +179,9 @@ export function UserProfilePanel({ ); const isBot = Boolean(relayAgent || managedAgent); const isOwner = useIsManagedAgent(isBot ? pubkey : null); + const { canView: canViewActivity } = useCanViewAgentActivity(pubkey, { + enabled: Boolean(onOpenAgentSession), + }); // Populate the active-turns store for this agent so useActiveAgentTurns works // even if the Agents page hasn't been visited yet. @@ -196,7 +200,6 @@ export function UserProfilePanel({ }); const isSelf = currentPubkey !== undefined && pubkeyLower === currentPubkey.toLowerCase(); - const canViewActivity = isOwner === true && Boolean(onOpenAgentSession); const isFollowing = !isSelf && (contactListQuery.data?.contacts.some( @@ -317,7 +320,7 @@ export function UserProfilePanel({ {view === "summary" ? ( 0; const metadataFields = [ ...buildPublicFields({ @@ -185,7 +186,7 @@ export function ProfileSummaryView({
) : null} - {showMemoriesIngress || showChannelsIngress || canViewActivity ? ( + {showMemoriesIngress || showChannelsIngress || canShowActivity ? (
{showMemoriesIngress ? ( ) : null} - {canViewActivity ? ( + {canShowActivity ? ( a.pubkey === pubkey); const managedAgent = managedAgentsQuery.data?.find( (a) => a.pubkey === pubkey, ); - const canViewActivity = role === "bot" && Boolean(onOpenAgentSession); const profile = profileQuery.data; const presenceStatus = presenceQuery.data?.[pubkey.toLowerCase()]; const userStatus = userStatusQuery.data?.[pubkey.toLowerCase()]; - const activeTurns = useActiveAgentTurns(role === "bot" ? pubkey : null); + const activeTurns = useActiveAgentTurns(pubkey); + const canShowActivity = canViewActivity || activeTurns.length > 0; const channelsQuery = useChannelsQuery(); const channelIdToName = React.useMemo(() => { const map: Record = {}; @@ -274,7 +279,7 @@ export function UserProfilePopover({

) : null} - {canViewActivity ? ( + {canShowActivity && onOpenAgentSession ? (