From 2865d3f99facc7f55ea7b0cd0bb4716314025059 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 14 Jun 2026 20:30:39 -0700 Subject: [PATCH 01/20] feat(agents): gate activity by relay ownership - Add a relay ownership endpoint backed by agent_owner_pubkey and is_agent_owner so desktop activity visibility uses the same source of truth as observer telemetry authorization. - Add a Tauri ownership command, frontend API wrapper, and useCanViewAgentActivity hook with local managed-agent optimism only while relay ownership is loading. - Replace profile popover, profile panel, and members sidebar activity gates so owned agents can open activity across different builds and worktrees without relying on local managed-agent lists. - Refactor channel agent session candidate resolution so owned agents can keep an activity panel open when channel metadata is stale, while preserving local-only lifecycle controls. - Add focused desktop unit coverage for the ownership predicate and stale-metadata session resolution path, plus E2E bridge support for ownership mocks. --- crates/buzz-relay/src/api/agents.rs | 71 +++++++ crates/buzz-relay/src/api/bridge.rs | 6 +- crates/buzz-relay/src/api/mod.rs | 1 + crates/buzz-relay/src/router.rs | 4 + .../src-tauri/src/commands/agent_ownership.rs | 38 ++++ desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/relay.rs | 26 +++ .../agents/hooks/useCanViewAgentActivity.ts | 43 ++++ .../agentSessionOwnershipResolution.test.mjs | 82 ++++++++ .../agents/lib/canViewAgentActivity.test.mjs | 60 ++++++ .../agents/lib/canViewAgentActivity.ts | 43 ++++ .../channels/lib/agentSessionCandidates.ts | 162 ++++++++++++++ .../src/features/channels/ui/ChannelPane.tsx | 13 +- .../features/channels/ui/ChannelScreen.tsx | 18 +- .../channels/ui/MembersSidebarMemberCard.tsx | 15 +- .../channels/ui/useChannelActivityTyping.ts | 2 +- .../channels/ui/useChannelAgentSessions.ts | 198 +++++------------- .../features/profile/ui/UserProfilePanel.tsx | 7 +- .../profile/ui/UserProfilePopover.tsx | 8 +- desktop/src/shared/api/tauriAgentOwnership.ts | 34 +++ desktop/src/testing/e2eBridge.ts | 16 ++ 22 files changed, 680 insertions(+), 170 deletions(-) create mode 100644 crates/buzz-relay/src/api/agents.rs create mode 100644 desktop/src-tauri/src/commands/agent_ownership.rs create mode 100644 desktop/src/features/agents/hooks/useCanViewAgentActivity.ts create mode 100644 desktop/src/features/agents/lib/agentSessionOwnershipResolution.test.mjs create mode 100644 desktop/src/features/agents/lib/canViewAgentActivity.test.mjs create mode 100644 desktop/src/features/agents/lib/canViewAgentActivity.ts create mode 100644 desktop/src/features/channels/lib/agentSessionCandidates.ts create mode 100644 desktop/src/shared/api/tauriAgentOwnership.ts 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/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..a6bc220fd --- /dev/null +++ b/desktop/src/features/agents/hooks/useCanViewAgentActivity.ts @@ -0,0 +1,43 @@ +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, + 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..cfd3c8d87 --- /dev/null +++ b/desktop/src/features/agents/lib/canViewAgentActivity.test.mjs @@ -0,0 +1,60 @@ +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, + 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, + 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, + 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, + isManagedLoading: false, + }); + + assert.equal(result.canView, false); + assert.equal(result.isLoading, true); +}); diff --git a/desktop/src/features/agents/lib/canViewAgentActivity.ts b/desktop/src/features/agents/lib/canViewAgentActivity.ts new file mode 100644 index 000000000..436a68169 --- /dev/null +++ b/desktop/src/features/agents/lib/canViewAgentActivity.ts @@ -0,0 +1,43 @@ +import type { AgentOwnershipStatus } from "@/shared/api/tauriAgentOwnership"; + +export type CanViewAgentActivityInput = { + relayOwnership: AgentOwnershipStatus | undefined; + isManagedAgent: boolean | undefined; + isOwnershipLoading: 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, + 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) { + return { canView: true, isLoading: true }; + } + + return { canView: false, isLoading }; +} diff --git a/desktop/src/features/channels/lib/agentSessionCandidates.ts b/desktop/src/features/channels/lib/agentSessionCandidates.ts new file mode 100644 index 000000000..9606eb706 --- /dev/null +++ b/desktop/src/features/channels/lib/agentSessionCandidates.ts @@ -0,0 +1,162 @@ +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"; + canInterruptTurn: boolean; + channelIds?: string[]; + channels?: string[]; +}; + +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 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/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..749e2fa4e 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"; @@ -218,6 +221,15 @@ export function ChannelScreen({ } return pubkeys; }, [channelMembers, managedAgents, relayAgents]); + const allAgentSessionCandidates = React.useMemo( + () => + buildChannelAgentSessionCandidates({ + channelMembers, + managedAgents, + relayAgents, + }), + [channelMembers, managedAgents, relayAgents], + ); const { botTypingEntries, channelAgentSessionAgents: activeChannelAgentSessionAgents, @@ -406,14 +418,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 +651,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" ? ( 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()]; @@ -274,7 +278,7 @@ export function UserProfilePopover({

) : null} - {canViewActivity ? ( + {canViewActivity && onOpenAgentSession ? (
) : null} - {showMemoriesIngress || showChannelsIngress || canViewActivity ? ( + {showMemoriesIngress || showChannelsIngress || canShowActivity ? (
{showMemoriesIngress ? ( ) : null} - {canViewActivity ? ( + {canShowActivity ? ( 0; const channelsQuery = useChannelsQuery(); const channelIdToName = React.useMemo(() => { const map: Record = {}; @@ -278,7 +279,7 @@ export function UserProfilePopover({

) : null} - {canViewActivity && onOpenAgentSession ? ( + {canShowActivity && onOpenAgentSession ? ( - -
- {visible.length === 0 ? ( -

No raw events yet.

) : (
- {visible.map((event) => ( + {events.map((event) => (
- - #{event.seq}{" "} + + + #{event.seq} + {" "} {describeRawEvent(event)} -
+                
                   {JSON.stringify(event.payload, null, 2)}
                 
@@ -48,6 +30,6 @@ export function RawEventRail({ events }: { events: ObserverEvent[] }) {
)}
- +
); } diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index 180b4c032..d349b35f3 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 { @@ -87,6 +102,32 @@ export function AgentSessionThreadPanel({ Live ) : null} + {isLive ? ( +
+ + +
+ ) : null} {isLive && isWorking ? ( @@ -140,7 +181,9 @@ export function AgentSessionThreadPanel({ > - Activity + + {showRawFeed ? "Raw ACP Activity" : "Activity"} + {agentHeaderActions} @@ -164,9 +207,10 @@ export function AgentSessionThreadPanel({ emptyDescription={`Mention ${agent.name} in the channel to see its work here.`} isWorking={isWorking} profiles={profiles} + rawLayout="exclusive" showHeader={false} showInterventionHint={canInterruptTurn} - showRaw={false} + showRaw={showRawFeed} /> ); From 27a522800561c9c11f9ad59e63d38982c0c43ab9 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 14 Jun 2026 23:28:44 -0700 Subject: [PATCH 06/20] feat(agents): group ACP turn prompts into a user-first transcript bundle - Add turnId/sessionId to transcript items and a presentation-only grouping helper - Render each turn as user prompt first, with setup status and context integrated inline - Show turn setup as a CheckCheck tooltip; expose prompt context via a footer toggle - Expand context sections inside the message bubble; drop the outer grouping border - Add unit tests for turn metadata attachment and grouped display ordering --- .../agents/ui/AgentSessionTranscriptList.tsx | 403 +++++++++++++++++- .../agents/ui/agentSessionTranscript.test.mjs | 95 +++++ .../agents/ui/agentSessionTranscript.ts | 83 ++-- .../agentSessionTranscriptGrouping.test.mjs | 198 +++++++++ .../ui/agentSessionTranscriptGrouping.ts | 208 +++++++++ .../features/agents/ui/agentSessionTypes.ts | 32 +- 6 files changed, 957 insertions(+), 62 deletions(-) create mode 100644 desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs create mode 100644 desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 709b5a5df..1e9994321 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -3,6 +3,7 @@ import { AlertCircle, Bot, Brain, + CheckCheck, ChevronDown, CircleDot, Loader2, @@ -19,9 +20,18 @@ import { cn } from "@/shared/lib/cn"; import { Badge } from "@/shared/ui/badge"; import { Markdown } from "@/shared/ui/markdown"; import { Shimmer } from "@/shared/ui/Shimmer"; +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 { buildTranscriptPresentation } from "./agentSessionTranscriptPresentation"; import { formatTranscriptTime } from "./agentSessionUtils"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; @@ -50,6 +60,10 @@ export function AgentSessionTranscriptList({ () => buildTranscriptPresentation(items, isWorking), [items, isWorking], ); + const displayBlocks = React.useMemo( + () => buildTranscriptDisplayBlocks(items), + [items], + ); if (items.length === 0) { return ( @@ -81,26 +95,15 @@ export function AgentSessionTranscriptList({ className={cn("w-full", compact ? "py-0.5" : "py-1")} role="log" > - {items.map((item) => ( -
- {SHOW_TRANSCRIPT_ACP_SOURCE && item.acpSource ? ( - - ) : null} - -
+ {displayBlocks.map((block) => ( + ))} @@ -315,6 +318,351 @@ function getStateLabel( } } +function getDisplayBlockKey(block: TranscriptDisplayBlock) { + if (block.kind === "single") { + return block.item.id; + } + return `turn:${block.turnId}`; +} + +function TranscriptDisplayBlockView({ + activeItemIds, + agentName, + block, + compact, + profiles, +}: { + activeItemIds: ReadonlySet; + agentName: string; + block: TranscriptDisplayBlock; + compact: boolean; + 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({ + activeItemIds, + agentName, + compact, + profiles, + segment, +}: { + activeItemIds: ReadonlySet; + agentName: string; + compact: boolean; + profiles?: UserProfileLookup; + segment: TranscriptTurnSegment; +}) { + if (segment.kind === "prompt") { + return ( + + ); + } + + if (segment.kind === "setup") { + return ; + } + + return ( + + ); +} + +function TurnPromptBlock({ + compact, + context, + profiles, + setup, + user, +}: { + compact: boolean; + context: Extract | null; + profiles?: UserProfileLookup; + setup: Extract[]; + user: Extract; +}) { + return ( +
+ {SHOW_TRANSCRIPT_ACP_SOURCE ? ( +
+ + {context ? ( + + ) : null} +
+ ) : null} + +
+ ); +} + +function PromptUserMessage({ + compact, + context = null, + item, + profiles, + setup = [], +}: { + compact: boolean; + 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 }: { sections: PromptSection[] }) { + return ( +
+ {sections.map((section) => ( +
+ + {section.title} + + +
+            {section.body.trim() || "No metadata."}
+          
+
+ ))} +
+ ); +} + +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 ; + } + + return ( +
+ {showSetup ? ( + + + + + +

{tooltipText}

+
+
+ ) : null} + {showContext ? ( + + Context + + {context.sections.length} + + + ) : null} + +
+ ); +} + +function TranscriptItemRow({ + activeItemIds, + agentName, + compact, + item, + profiles, +}: { + activeItemIds: ReadonlySet; + agentName: string; + compact: boolean; + item: TranscriptItem; + profiles?: UserProfileLookup; +}) { + return ( +
+ {SHOW_TRANSCRIPT_ACP_SOURCE && item.acpSource ? ( + + ) : null} + +
+ ); +} + +function TurnSetupStatus({ + compact, + items, +}: { + compact: boolean; + items: Extract[]; +}) { + const timestamp = turnSetupTimestamp(items); + if (items.length === 0 || !timestamp) { + return null; + } + + return ( +
+ +
+ ); +} + function getItemSpacingClass(item: TranscriptItem) { if (item.type === "lifecycle") { return "mt-2 first:mt-0"; @@ -495,16 +843,25 @@ function ThoughtItem({ function MetadataItem({ compact, + embedded = false, item, }: { compact: boolean; + embedded?: boolean; item: Extract; }) { return (
diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index dda17b174..a5cd0c96b 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -3,6 +3,101 @@ 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([ { diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index 62d642f59..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,7 +121,7 @@ function upsertMessage( title: string, text: string, timestamp: string, - channelId: string | null, + ctx: TranscriptItemContext, authorPubkey: string | null = null, acpSource?: string, ) { @@ -127,7 +133,9 @@ 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, }); @@ -144,7 +152,9 @@ function upsertMessage( title, text, timestamp, - channelId, + channelId: ctx.channelId, + turnId: ctx.turnId, + sessionId: ctx.sessionId, authorPubkey, acpSource, }); @@ -159,7 +169,7 @@ function upsertTextItem( title: string, text: string, timestamp: string, - channelId: string | null, + ctx: TranscriptItemContext, acpSource?: string, ) { const existing = d.itemsById.get(id); @@ -167,13 +177,25 @@ function upsertTextItem( replaceItem(d, id, { ...existing, text: existing.text + text, - channelId, + 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, acpSource }); + pushItem(d, { + id, + type, + title, + text, + timestamp, + channelId: ctx.channelId, + turnId: ctx.turnId, + sessionId: ctx.sessionId, + acpSource, + }); } function upsertMetadata( @@ -182,7 +204,7 @@ function upsertMetadata( title: string, sections: PromptSection[], timestamp: string, - channelId: string | null, + ctx: TranscriptItemContext, acpSource?: string, ) { const existing = d.itemsById.get(id); @@ -190,7 +212,9 @@ function upsertMetadata( replaceItem(d, id, { ...existing, sections, - channelId, + channelId: ctx.channelId, + turnId: ctx.turnId ?? existing.turnId, + sessionId: ctx.sessionId ?? existing.sessionId, acpSource: acpSource ?? existing.acpSource, }); return; @@ -202,7 +226,9 @@ function upsertMetadata( title, sections, timestamp, - channelId, + channelId: ctx.channelId, + turnId: ctx.turnId, + sessionId: ctx.sessionId, acpSource, }); } @@ -218,7 +244,7 @@ function upsertTool( result: string, isError: boolean, timestamp: string, - channelId: string | null, + ctx: TranscriptItemContext, acpSource?: string, ) { const existing = d.itemsById.get(id); @@ -248,7 +274,9 @@ 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; @@ -267,7 +295,9 @@ function upsertTool( timestamp, startedAt: timestamp, completedAt: null, - channelId, + channelId: ctx.channelId, + turnId: ctx.turnId, + sessionId: ctx.sessionId, acpSource, }); } @@ -284,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( @@ -293,7 +328,7 @@ export function processTranscriptEvent( "Turn started", describeTurnStarted(event.payload), event.timestamp, - channelId, + ctx, event.kind, ); } else if (event.kind === "session_resolved") { @@ -304,7 +339,7 @@ export function processTranscriptEvent( "Session ready", describeSessionResolved(event.payload), event.timestamp, - channelId, + ctx, event.kind, ); } else if (event.kind === "acp_parse_error") { @@ -315,7 +350,7 @@ 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") { @@ -331,7 +366,7 @@ export function processTranscriptEvent( title, `${outcome}: ${error}`, event.timestamp, - channelId, + ctx, event.kind, ); } else if (event.kind === "acp_read" || event.kind === "acp_write") { @@ -350,7 +385,7 @@ export function processTranscriptEvent( parsedPrompt.userTitle, parsedPrompt.userText, event.timestamp, - channelId, + ctx, parsedPrompt.userPubkey, "session/prompt:user", ); @@ -362,7 +397,7 @@ export function processTranscriptEvent( "Prompt context", parsedPrompt.sections, event.timestamp, - channelId, + ctx, "session/prompt:context", ); } @@ -382,7 +417,7 @@ export function processTranscriptEvent( "Assistant", extractContentText(update.content), event.timestamp, - channelId, + ctx, null, updateType, ); @@ -394,7 +429,7 @@ export function processTranscriptEvent( "User", extractContentText(update.content), event.timestamp, - channelId, + ctx, null, updateType, ); @@ -406,7 +441,7 @@ export function processTranscriptEvent( "Thinking", extractContentText(update.content), event.timestamp, - channelId, + ctx, updateType, ); } else if (updateType === "tool_call") { @@ -423,7 +458,7 @@ export function processTranscriptEvent( extractToolResult(update), false, event.timestamp, - channelId, + ctx, updateType, ); } else if (updateType === "tool_call_update") { @@ -443,7 +478,7 @@ export function processTranscriptEvent( extractToolResult(update), status === "failed", event.timestamp, - channelId, + ctx, updateType, ); } else if (updateType === "plan") { @@ -454,7 +489,7 @@ 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..593c3104c --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs @@ -0,0 +1,198 @@ +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"); +}); + +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 keeps lifecycle visible when prompt is missing", () => { + const rawItems = [ + lifecycle("turn", "Turn started", "turn_started", "turn-1"), + lifecycle("session", "Session ready", "session_resolved", "turn-1"), + ]; + + const blocks = buildTranscriptDisplayBlocks(rawItems); + const displayOrder = flattenDisplayBlocks(blocks).map((item) => item.id); + + assert.deepEqual(displayOrder, ["turn", "session"]); +}); + +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..96caa6d97 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts @@ -0,0 +1,208 @@ +import type { TranscriptItem } from "./agentSessionTypes"; + +export type TranscriptTurnSegment = + | { kind: "item"; item: TranscriptItem } + | { kind: "setup"; items: Extract[] } + | { + kind: "prompt"; + user: Extract; + context: Extract | null; + setup: Extract[]; + }; + +export type TranscriptDisplayBlock = + | { kind: "single"; item: TranscriptItem } + | { kind: "turn"; turnId: string; segments: TranscriptTurnSegment[] }; + +function isUserPrompt( + item: TranscriptItem, +): item is Extract { + return ( + item.type === "message" && + item.role === "user" && + item.acpSource === "session/prompt:user" + ); +} + +function isPromptContext( + item: TranscriptItem, +): item is Extract { + return ( + item.type === "metadata" && item.acpSource === "session/prompt:context" + ); +} + +function isSetupLifecycle( + item: TranscriptItem, +): item is Extract { + return ( + item.type === "lifecycle" && + (item.acpSource === "turn_started" || item.acpSource === "session_resolved") + ); +} + +function isErrorLifecycle( + item: TranscriptItem, +): item is Extract { + return ( + item.type === "lifecycle" && item.title.toLowerCase().includes("error") + ); +} + +type TurnBucket = { + turnId: string; + items: TranscriptItem[]; +}; + +function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] { + const userPrompt = items.find(isUserPrompt) ?? null; + const setupLifecycle = items.filter(isSetupLifecycle); + const promptContext = items.find(isPromptContext) ?? null; + const consumed = new Set(); + + if (userPrompt) consumed.add(userPrompt); + for (const item of setupLifecycle) consumed.add(item); + if (promptContext) consumed.add(promptContext); + + const activity = items.filter((item) => !consumed.has(item)); + + if (!userPrompt) { + return items.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; + } + + blocks.push({ + kind: "turn", + turnId: entry.turnId, + segments: classifyTurnItems(bucket.items), + }); + } + + 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/agentSessionTypes.ts b/desktop/src/features/agents/ui/agentSessionTypes.ts index 90fb2500e..c4061bb1d 100644 --- a/desktop/src/features/agents/ui/agentSessionTypes.ts +++ b/desktop/src/features/agents/ui/agentSessionTypes.ts @@ -23,8 +23,15 @@ 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"; @@ -33,36 +40,32 @@ export type TranscriptItem = timestamp: string; acpSource?: TranscriptAcpSource; authorPubkey?: string | null; - channelId?: string | null; - } - | { + } & TranscriptItemIdentity) + | ({ id: string; type: "thought"; title: string; text: string; timestamp: string; acpSource?: TranscriptAcpSource; - channelId?: string | null; - } - | { + } & TranscriptItemIdentity) + | ({ id: string; type: "lifecycle"; title: string; text: string; timestamp: string; acpSource?: TranscriptAcpSource; - channelId?: string | null; - } - | { + } & TranscriptItemIdentity) + | ({ id: string; type: "metadata"; title: string; sections: PromptSection[]; timestamp: string; acpSource?: TranscriptAcpSource; - channelId?: string | null; - } - | { + } & TranscriptItemIdentity) + | ({ id: string; type: "tool"; title: string; @@ -76,8 +79,7 @@ export type TranscriptItem = startedAt: string; completedAt: string | null; acpSource?: TranscriptAcpSource; - channelId?: string | null; - }; + } & TranscriptItemIdentity); export type PromptSection = { title: string; From e731798fd076605bab633dd4d3c6efbdbecd01b0 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 14 Jun 2026 23:40:53 -0700 Subject: [PATCH 07/20] feat(agents): show agent avatars in activity transcripts - Add managed agent avatar URLs to the Tauri summary and frontend ManagedAgent mapping so configured persona photos are available in channel activity views. - Carry agent pubkey and avatar data through channel session candidates, ManagedAgentSessionPanel, and AgentSessionTranscriptList. - Replace the assistant transcript bot glyph with UserAvatar, using synced profile photos first and managed-agent avatar URLs as fallback. - Include known agent pubkeys in channel profile lookups and preserve managed-agent avatars when merging agent names into profile data. - Update the E2E mock bridge managed-agent shape so seeded and created mock agents retain avatar URLs. - Polish the assistant activity row with slightly wider spacing and text-xs/font-semibold labels for the agent name and metadata. --- .../src-tauri/src/managed_agents/runtime.rs | 1 + desktop/src-tauri/src/managed_agents/types.rs | 1 + .../agents/ui/AgentSessionTranscriptList.tsx | 107 ++++++++++++------ .../agents/ui/ManagedAgentSessionPanel.tsx | 12 +- .../channels/lib/agentSessionCandidates.ts | 2 + .../features/channels/ui/ChannelScreen.tsx | 40 +++---- .../channels/ui/useChannelActivityTyping.ts | 12 +- desktop/src/shared/api/tauri.ts | 7 +- desktop/src/shared/api/types.ts | 1 + desktop/src/testing/e2eBridge.ts | 5 + 10 files changed, 129 insertions(+), 59 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index d412e790e..8cc93e005 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1356,6 +1356,7 @@ pub fn build_managed_agent_summary( max_turn_duration_seconds: record.max_turn_duration_seconds, parallelism: record.parallelism, system_prompt: effective_prompt, + avatar_url: record.avatar_url.clone(), model: effective_model, mcp_toolsets: record.mcp_toolsets.clone(), env_vars: record.env_vars.clone(), diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index e9d733e54..4bd10fe48 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -224,6 +224,7 @@ pub struct ManagedAgentSummary { pub max_turn_duration_seconds: Option, pub parallelism: u32, pub system_prompt: Option, + pub avatar_url: Option, pub model: Option, pub mcp_toolsets: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 1e9994321..c05505eec 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -17,6 +17,7 @@ import { type UserProfileLookup, } from "@/features/profile/lib/identity"; import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { Badge } from "@/shared/ui/badge"; import { Markdown } from "@/shared/ui/markdown"; import { Shimmer } from "@/shared/ui/Shimmer"; @@ -39,16 +40,23 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; /** Dev-only: surface the observer wire label that produced each transcript row. */ const SHOW_TRANSCRIPT_ACP_SOURCE = import.meta.env.DEV; +type AgentTranscriptIdentityProps = { + agentAvatarUrl: string | null; + agentName: string; + agentPubkey: string; +}; + export function AgentSessionTranscriptList({ + agentAvatarUrl, agentName, + agentPubkey, compact = false, emptyDescription, isWorking = false, items, profiles, showInterventionHint = false, -}: { - agentName: string; +}: AgentTranscriptIdentityProps & { compact?: boolean; emptyDescription: string; isWorking?: boolean; @@ -98,7 +106,9 @@ export function AgentSessionTranscriptList({ {displayBlocks.map((block) => ( @@ -182,15 +192,15 @@ function TranscriptNowSummary({
-

+

Now

- · -

{agentName}

+ · +

{agentName}

{lastUpdated ? ( <> - · -

+ · +

{lastUpdated}

@@ -210,7 +220,7 @@ function TranscriptNowSummary({

{showInterventionHint && isWorking ? ( -

+

Use Stop{" "} above to interrupt this turn without stopping the agent process.

@@ -254,7 +264,7 @@ function ActivityCountBadge({ }) { return ( {count} {label} @@ -327,13 +337,14 @@ function getDisplayBlockKey(block: TranscriptDisplayBlock) { function TranscriptDisplayBlockView({ activeItemIds, + agentAvatarUrl, agentName, + agentPubkey, block, compact, profiles, -}: { +}: AgentTranscriptIdentityProps & { activeItemIds: ReadonlySet; - agentName: string; block: TranscriptDisplayBlock; compact: boolean; profiles?: UserProfileLookup; @@ -342,7 +353,9 @@ function TranscriptDisplayBlockView({ return ( ( ; - agentName: string; compact: boolean; profiles?: UserProfileLookup; segment: TranscriptTurnSegment; @@ -412,7 +428,9 @@ function TranscriptTurnSegmentView({ return ( @@ -534,7 +552,7 @@ function PromptContextSections({ sections }: { sections: PromptSection[] }) { {section.title} -
+          
             {section.body.trim() || "No metadata."}
           
@@ -590,7 +608,7 @@ function TurnSetupFooter({ {showContext ? ( ; - agentName: string; compact: boolean; item: TranscriptItem; profiles?: UserProfileLookup; @@ -634,7 +653,9 @@ function TranscriptItemRow({ ) : null} ; @@ -734,6 +759,14 @@ 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 (
@@ -764,14 +797,20 @@ function MessageItem({ )} > {isAssistant ? ( -
- - +
+ + + {assistantLabel} - {agentName} {isActive ? ( @@ -824,7 +863,7 @@ function ThoughtItem({ {item.title} {isActive ? ( @@ -868,7 +907,7 @@ function MetadataItem({ {item.title} - + {item.sections.length} section{item.sections.length === 1 ? "" : "s"} @@ -884,7 +923,7 @@ function MetadataItem({ {section.title} -
+            
               {section.body.trim() || "No metadata."}
             
@@ -942,7 +981,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 8f67ca870..b43207ce3 100644 --- a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx @@ -25,7 +25,9 @@ import { shorten } from "./agentSessionUtils"; import { useObserverEvents, useAgentTranscript } from "./useObserverEvents"; type ManagedAgentSessionPanelProps = { - agent: Pick; + agent: Pick & { + avatarUrl?: string | null; + }; channelId?: string | null; className?: string; compact?: boolean; @@ -101,7 +103,9 @@ export function ManagedAgentSessionPanel({ ) : null} & { agentSource: "managed" | "member-bot" | "relay"; + avatarUrl?: string | null; canInterruptTurn: boolean; channelIds?: string[]; channels?: string[]; @@ -53,6 +54,7 @@ export function buildChannelAgentSessionCandidates({ name: agent.name, status: agent.status, agentSource: "managed", + avatarUrl: agent.avatarUrl, canInterruptTurn: true, channelIds: existing?.channelIds, channels: existing?.channels, diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 749e2fa4e..834420555 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -181,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(); @@ -221,6 +202,27 @@ export function ChannelScreen({ } return pubkeys; }, [channelMembers, managedAgents, relayAgents]); + const messageProfilePubkeys = React.useMemo( + () => [ + ...new Set([ + ...messageAuthorPubkeys, + ...messageMentionPubkeys, + ...activeDmParticipantPubkeys, + ...agentPubkeys, + ...typingEntries.map((entry) => entry.pubkey), + ]), + ], + [ + activeDmParticipantPubkeys, + agentPubkeys, + messageAuthorPubkeys, + messageMentionPubkeys, + typingEntries, + ], + ); + const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, { + enabled: messageProfilePubkeys.length > 0, + }); const allAgentSessionCandidates = React.useMemo( () => buildChannelAgentSessionCandidates({ diff --git a/desktop/src/features/channels/ui/useChannelActivityTyping.ts b/desktop/src/features/channels/ui/useChannelActivityTyping.ts index 007f090d3..d3943dbb1 100644 --- a/desktop/src/features/channels/ui/useChannelActivityTyping.ts +++ b/desktop/src/features/channels/ui/useChannelActivityTyping.ts @@ -98,7 +98,7 @@ export function mergeAgentNamesIntoProfiles( relayAgents: RelayAgent[], ): UserProfileLookup { const merged = { ...profiles }; - for (const agent of [...relayAgents, ...managedAgents]) { + for (const agent of relayAgents) { const key = normalizePubkey(agent.pubkey); merged[key] = { ...merged[key], @@ -108,5 +108,15 @@ export function mergeAgentNamesIntoProfiles( isAgent: true, }; } + for (const agent of managedAgents) { + const key = normalizePubkey(agent.pubkey); + merged[key] = { + ...merged[key], + displayName: merged[key]?.displayName || agent.name, + avatarUrl: merged[key]?.avatarUrl ?? agent.avatarUrl, + nip05Handle: merged[key]?.nip05Handle ?? null, + isAgent: true, + }; + } return merged; } diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 11e25da4d..0954193e8 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -45,10 +45,7 @@ import type { OpenDmInput, } from "@/shared/api/types"; -type RawIdentity = { - pubkey: string; - display_name: string; -}; +type RawIdentity = { pubkey: string; display_name: string }; type RawProfile = { pubkey: string; @@ -213,6 +210,7 @@ export type RawManagedAgent = { max_turn_duration_seconds: number | null; parallelism: number; system_prompt: string | null; + avatar_url?: string | null; model: string | null; mcp_toolsets: string | null; env_vars?: Record; @@ -868,6 +866,7 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent { maxTurnDurationSeconds: agent.max_turn_duration_seconds, parallelism: agent.parallelism, systemPrompt: agent.system_prompt, + avatarUrl: agent.avatar_url ?? null, model: agent.model, mcpToolsets: agent.mcp_toolsets, envVars: agent.env_vars ?? {}, diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 653a1be99..99df2caa4 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -282,6 +282,7 @@ export type ManagedAgent = { maxTurnDurationSeconds: number | null; parallelism: number; systemPrompt: string | null; + avatarUrl: string | null; model: string | null; mcpToolsets: string | null; /** Per-agent env vars. Layered on top of persona envVars. */ diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index f3481d234..8b8e59103 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -38,6 +38,7 @@ type MockCommandAvailability = { type MockManagedAgentSeed = { pubkey: string; name: string; + avatarUrl?: string | null; personaId?: string | null; status?: RawManagedAgent["status"]; channelNames?: string[]; @@ -358,6 +359,7 @@ type RawManagedAgent = { max_turn_duration_seconds: number | null; parallelism: number; system_prompt: string | null; + avatar_url: string | null; model: string | null; env_vars?: Record; status: "running" | "stopped" | "deployed" | "not_deployed"; @@ -873,6 +875,7 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent { max_turn_duration_seconds: agent.max_turn_duration_seconds ?? null, parallelism: agent.parallelism, system_prompt: agent.system_prompt, + avatar_url: agent.avatar_url ?? null, model: agent.model, env_vars: { ...(agent.env_vars ?? {}) }, status: agent.status, @@ -962,6 +965,7 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent { max_turn_duration_seconds: null, parallelism: 1, system_prompt: null, + avatar_url: seed.avatarUrl ?? null, model: null, env_vars: {}, status, @@ -4747,6 +4751,7 @@ async function handleCreateManagedAgent( max_turn_duration_seconds: args.input.maxTurnDurationSeconds ?? null, parallelism: args.input.parallelism ?? 1, system_prompt: args.input.systemPrompt?.trim() || null, + avatar_url: avatarUrl, model: args.input.model?.trim() || null, env_vars: { ...(args.input.envVars ?? {}) }, status: args.input.spawnAfterCreate ? "running" : "stopped", From 001236cb739e7e4d10370a17b2a9df66c83c2d1b Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 14 Jun 2026 23:46:21 -0700 Subject: [PATCH 08/20] refactor(agents): standardize activity transcript typography to text-xs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace arbitrary text-[7px]–text-[11px] sizes with Tailwind text-xs across the agent activity/session surface - AgentSessionTranscriptList, AgentSessionThreadPanel, AgentSessionToolItem, RawEventRail, and BotActivityBar - UserAvatar xs/sm size tokens now use text-xs so transcript and activity-bar avatars need no per-call font overrides --- .../src/features/agents/ui/AgentSessionToolItem.tsx | 8 ++++---- .../features/agents/ui/AgentSessionTranscriptList.tsx | 2 +- desktop/src/features/agents/ui/RawEventRail.tsx | 2 +- .../features/channels/ui/AgentSessionThreadPanel.tsx | 9 +++------ desktop/src/features/channels/ui/BotActivityBar.tsx | 10 ++++++---- desktop/src/shared/ui/UserAvatar.tsx | 4 ++-- 6 files changed, 17 insertions(+), 18 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx index 268c1835c..770e6b94a 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx @@ -81,7 +81,7 @@ export function ToolItem({ {isActive ? ( @@ -220,7 +220,7 @@ function ToolTimestamp({ return ( - + {time} {duration ? ` · ${duration}` : null} @@ -282,7 +282,7 @@ function BuzzToolInlineAction({ 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; @@ -682,8 +378,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 e8402fee6..dcd3fa9a1 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -15,7 +15,6 @@ import { } from "@/features/profile/lib/identity"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; -import { Badge } from "@/shared/ui/badge"; import { Markdown } from "@/shared/ui/markdown"; import { Toggle } from "@/shared/ui/toggle"; import { UserAvatar } from "@/shared/ui/UserAvatar"; @@ -29,12 +28,35 @@ import { type TranscriptDisplayBlock, type TranscriptTurnSegment, } from "./agentSessionTranscriptGrouping"; -import { buildTranscriptPresentation } from "./agentSessionTranscriptPresentation"; import { formatTranscriptTime } from "./agentSessionUtils"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; -/** Dev-only: surface the observer wire label that produced each transcript row. */ -const SHOW_TRANSCRIPT_ACP_SOURCE = import.meta.env.DEV; +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; @@ -48,7 +70,6 @@ export function AgentSessionTranscriptList({ agentPubkey, compact = false, emptyDescription, - isWorking = false, items, profiles, }: AgentTranscriptIdentityProps & { @@ -58,10 +79,6 @@ export function AgentSessionTranscriptList({ items: TranscriptItem[]; profiles?: UserProfileLookup; }) { - const presentation = React.useMemo( - () => buildTranscriptPresentation(items, isWorking), - [items, isWorking], - ); const displayBlocks = React.useMemo( () => buildTranscriptDisplayBlocks(items), [items], @@ -92,7 +109,6 @@ export function AgentSessionTranscriptList({ > {displayBlocks.map((block) => ( ; block: TranscriptDisplayBlock; compact: boolean; profiles?: UserProfileLookup; @@ -143,7 +157,6 @@ function TranscriptDisplayBlockView({ if (block.kind === "single") { return ( {block.segments.map((segment) => ( ; compact: boolean; profiles?: UserProfileLookup; segment: TranscriptTurnSegment; @@ -218,7 +228,6 @@ function TranscriptTurnSegmentView({ return (

{text}

{contextOpen && context ? ( - + ) : null}
[]; +}) { return (
+ {sections.map((section) => (
[]; +}) { + 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, @@ -375,12 +414,35 @@ function TurnSetupFooter({ return ; } + const contextToggle = showContext ? ( + + {showSetup ? + ) : null; + return (
- {showSetup ? ( + {showContext && showSetup ? ( + + {contextToggle} + +

{tooltipText}

+
+
+ ) : null} + {!showContext && showSetup ? (
); } function TranscriptItemRow({ - activeItemIds, agentAvatarUrl, agentName, agentPubkey, @@ -422,7 +472,6 @@ function TranscriptItemRow({ item, profiles, }: AgentTranscriptIdentityProps & { - activeItemIds: ReadonlySet; compact: boolean; item: TranscriptItem; profiles?: UserProfileLookup; @@ -444,7 +493,6 @@ function TranscriptItemRow({ agentName={agentName} agentPubkey={agentPubkey} compact={compact} - isActive={activeItemIds.has(item.id)} item={item} profiles={profiles} /> @@ -486,12 +534,10 @@ const TranscriptItemView = React.memo(function TranscriptItemView({ agentName, agentPubkey, compact, - isActive, item, profiles, }: AgentTranscriptIdentityProps & { compact: boolean; - isActive: boolean; item: TranscriptItem; profiles?: UserProfileLookup; }) { @@ -502,17 +548,16 @@ const TranscriptItemView = React.memo(function TranscriptItemView({ agentName={agentName} agentPubkey={agentPubkey} compact={compact} - isActive={isActive} item={item} profiles={profiles} /> ); } if (item.type === "tool") { - return ; + return ; } if (item.type === "thought") { - return ; + return ; } if (item.type === "metadata") { return ; @@ -525,12 +570,10 @@ function MessageItem({ agentName, agentPubkey, compact, - isActive, item, profiles, }: AgentTranscriptIdentityProps & { compact: boolean; - isActive: boolean; item: Extract; profiles?: UserProfileLookup; }) { @@ -561,9 +604,6 @@ function MessageItem({ "flex animate-in fade-in duration-200 motion-reduce:animate-none", isAssistant ? "flex-row" : "flex-row items-start justify-end", compact ? "px-0 py-0.5" : "px-1 py-1", - isAssistant && - isActive && - "rounded-lg border border-primary/15 bg-primary/3 px-2 py-1.5", )} data-role={isAssistant ? "assistant-message" : "user-message"} data-testid={ @@ -596,15 +636,6 @@ function MessageItem({ {assistantLabel} - {isActive ? ( - - - Live - - ) : null}
) : null} @@ -630,11 +661,9 @@ function MessageItem({ function ThoughtItem({ compact, - isActive, item, }: { compact: boolean; - isActive: boolean; item: Extract; }) { return ( @@ -642,22 +671,12 @@ function ThoughtItem({ className={cn( "group not-prose w-full rounded-md border border-transparent", compact ? "px-0" : "px-1", - isActive && "border-primary/15 bg-primary/3 px-2 py-1", )} data-testid="transcript-thought-item" > - + {item.title} - {isActive ? ( - - - Live - - ) : null} From 84a8f0cec9be4899a45ad0f5ceffcc31af8b7ba4 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 15 Jun 2026 01:03:23 -0700 Subject: [PATCH 18/20] fix(agents): group activity tools and hide orphan prompt context - Update desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts to group consecutive tool calls into transcript tool groups and suppress setup/context-only turn noise when no user prompt bubble exists - Update desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx and AgentSessionToolItem.tsx to render grouped tool rows with tighter activity-feed spacing - Expand desktop/src/features/agents/ui/agentSessionToolSummary.ts so Buzz and generic tools use the compact summary presentation with useful previews - Adjust desktop/src/shared/ui/toggle.tsx to support the ghost prompt-context toggle styling used by the transcript footer - Add focused unit coverage for grouped tools, Buzz tool summaries, promptless turns, and setup/context-only turns --- .../agents/ui/AgentSessionToolItem.tsx | 10 +- .../agents/ui/AgentSessionTranscriptList.tsx | 105 +++++++++++--- .../ui/agentSessionToolSummary.test.mjs | 37 ++--- .../agents/ui/agentSessionToolSummary.ts | 128 +++++++++++++++--- .../agentSessionTranscriptGrouping.test.mjs | 34 ++++- .../ui/agentSessionTranscriptGrouping.ts | 38 +++++- desktop/src/shared/ui/toggle.tsx | 4 +- 7 files changed, 281 insertions(+), 75 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx index 9fc4b1111..683896cfd 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx @@ -11,9 +11,11 @@ import { asRecord, formatCodeValue, formatDuration } from "./agentSessionUtils"; export function ToolItem({ compact = false, + grouped = false, item, }: { compact?: boolean; + grouped?: boolean; item: Extract; }) { const [isExpanded, setIsExpanded] = React.useState(false); @@ -32,7 +34,11 @@ export function ToolItem({ return (
diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index dcd3fa9a1..cb4abf84f 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -155,6 +155,14 @@ function TranscriptDisplayBlockView({ profiles?: UserProfileLookup; }) { if (block.kind === "single") { + if (block.item.type === "tool") { + return ( +
+ +
+ ); + } + return ( - {block.segments.map((segment) => ( - ( +
+ > + +
))}
); } +function getTurnSegmentSpacing( + previous: TranscriptTurnSegment | undefined, + segment: TranscriptTurnSegment, + compact: boolean, +): string | undefined { + if (!previous) { + return undefined; + } + + const involvesTool = + previous.kind === "tool_group" || segment.kind === "tool_group"; + if (involvesTool) { + return compact ? "mt-3" : "mt-3.5"; + } + + return compact ? "mt-2" : "mt-2.5"; +} + function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) { if (segment.kind === "setup") { return `turn:${turnId}:setup`; @@ -195,6 +229,9 @@ function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) { if (segment.kind === "prompt") { return `turn:${turnId}:prompt`; } + if (segment.kind === "tool_group") { + return `turn:${turnId}:tools:${segment.items.map((item) => item.id).join("+")}`; + } return segment.item.id; } @@ -226,12 +263,17 @@ function TranscriptTurnSegmentView({ return ; } + if (segment.kind === "tool_group") { + return ; + } + return ( @@ -252,10 +294,7 @@ function TurnPromptBlock({ user: Extract; }) { return ( -
+
{SHOW_TRANSCRIPT_ACP_SOURCE ? (
@@ -464,24 +503,42 @@ function TurnSetupFooter({ ); } +function ToolCallGroup({ + compact, + items, +}: { + compact: boolean; + items: Extract[]; +}) { + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} + function TranscriptItemRow({ agentAvatarUrl, agentName, agentPubkey, compact, + embedded = false, item, profiles, }: AgentTranscriptIdentityProps & { compact: boolean; + embedded?: boolean; item: TranscriptItem; profiles?: UserProfileLookup; }) { return (
@@ -513,7 +570,7 @@ function TurnSetupStatus({ } return ( -
+
); @@ -521,10 +578,10 @@ function TurnSetupStatus({ function getItemSpacingClass(item: TranscriptItem) { if (item.type === "lifecycle") { - return "mt-2 first:mt-0"; + return "mt-1.5 first:mt-0"; } if (item.type === "metadata" || item.type === "thought") { - return "mt-2 first:mt-0"; + return "mt-1.5 first:mt-0"; } return undefined; } @@ -603,7 +660,13 @@ function MessageItem({ className={cn( "flex animate-in fade-in duration-200 motion-reduce:animate-none", isAssistant ? "flex-row" : "flex-row items-start justify-end", - compact ? "px-0 py-0.5" : "px-1 py-1", + isAssistant + ? compact + ? "px-0 py-0" + : "px-1 py-0" + : compact + ? "px-0 py-0.5" + : "px-1 py-1", )} data-role={isAssistant ? "assistant-message" : "user-message"} data-testid={ diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs index d24100ffc..7b416eb59 100644 --- a/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs @@ -1,10 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { - buildCompactToolSummary, - isCompactDeveloperTool, -} from "./agentSessionToolSummary.ts"; +import { buildCompactToolSummary } from "./agentSessionToolSummary.ts"; const baseTimestamp = "2026-06-14T19:00:00.000Z"; @@ -26,29 +23,19 @@ function makeTool(overrides = {}) { }; } -test("isCompactDeveloperTool returns false for Buzz relay tools", () => { - assert.equal( - isCompactDeveloperTool( - makeTool({ - toolName: "send_message", - buzzToolName: "send_message", - title: "Send Message", - }), - ), - false, +test("buildCompactToolSummary formats Buzz send_message preview", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "send_message", + buzzToolName: "send_message", + title: "Send Message", + args: { content: "Hello team" }, + }), ); -}); -test("isCompactDeveloperTool detects buzz-dev-mcp shell tools", () => { - assert.equal( - isCompactDeveloperTool( - makeTool({ - toolName: "buzz-dev-mcp__shell", - title: "buzz-dev-mcp__shell", - }), - ), - true, - ); + assert.equal(summary.kind, "buzz"); + assert.equal(summary.label, "Send Message"); + assert.equal(summary.preview, "Hello team"); }); test("buildCompactToolSummary formats shell command preview", () => { diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.ts b/desktop/src/features/agents/ui/agentSessionToolSummary.ts index 70aaccbbf..96b024469 100644 --- a/desktop/src/features/agents/ui/agentSessionToolSummary.ts +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.ts @@ -1,9 +1,15 @@ import type { ToolStatus, TranscriptItem } from "./agentSessionTypes"; import { + formatToolTitle, getBuzzToolInfo, + isGenericToolTitle, normalizeToolNameText, } from "./agentSessionToolCatalog"; -import { asRecord, getToolString } from "./agentSessionUtils"; +import { + asRecord, + getToolString, + getToolStringList, +} from "./agentSessionUtils"; export type CompactToolKind = | "shell" @@ -13,7 +19,9 @@ export type CompactToolKind = | "todo" | "stop_hook" | "post_compact_hook" - | "dev_mcp"; + | "dev_mcp" + | "buzz" + | "generic"; export type CompactToolSummary = { kind: CompactToolKind; @@ -35,26 +43,33 @@ const DEVELOPER_TOOL_BASES = new Set([ type ToolItem = Extract; -/** Whether this tool row should use the muted compact developer summary. */ -export function isCompactDeveloperTool(item: ToolItem): boolean { - if (item.buzzToolName && getBuzzToolInfo(item.buzzToolName)) { - return false; - } - return resolveDeveloperToolKind(item) !== null; -} - -/** Build the compact summary label and preview for developer MCP tool rows. */ +/** Build the muted compact summary label and preview for any tool row. */ export function buildCompactToolSummary(item: ToolItem): CompactToolSummary { - const kind = resolveDeveloperToolKind(item) ?? "dev_mcp"; + const kind = resolveCompactToolKind(item); const { preview, thumbnailSrc } = extractCompactToolPreview(item, kind); return { kind, - label: compactToolLabel(kind, item.status, item.isError), + 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); @@ -98,16 +113,51 @@ function stripMcpServerPrefix(normalized: string): string { 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< CompactToolKind, { completed: string; running: string; failed: string } > = { + generic: { + completed: "Ran tool", + running: "Running tool", + failed: "Tool failed", + }, + ...developerToolLabels(), + }; + + const labelsMap = labels as Record< + CompactToolKind, + { completed: string; running: string; failed: string } + >; + + const set = labelsMap[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", @@ -149,11 +199,6 @@ function compactToolLabel( failed: "Tool failed", }, }; - - const set = labels[kind]; - if (failed) return set.failed; - if (running) return set.running; - return set.completed; } type CompactToolPreview = { @@ -181,11 +226,54 @@ function extractCompactToolPreview( case "post_compact_hook": return emptyPreview(); case "dev_mcp": + case "generic": return textPreview( - getToolString(args, ["command", "path", "source", "query", "name"]) ?? - null, + 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 { diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs index 83f1fb534..07ba3b2e5 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs @@ -124,6 +124,32 @@ test("buildTranscriptDisplayBlocks bundles user prompt, setup, and context toget 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, "tool_group"); +}); + +test("buildTranscriptDisplayBlocks groups consecutive tool calls", () => { + const rawItems = [ + userPrompt("prompt", "run these", "turn-1"), + toolCall("tool-1", "turn-1"), + toolCall("tool-2", "turn-1"), + assistantMessage("assistant", "Done.", "turn-1"), + toolCall("tool-3", "turn-1"), + ]; + + const blocks = buildTranscriptDisplayBlocks(rawItems); + const turnBlock = blocks[0]; + assert.equal(turnBlock?.kind, "turn"); + assert.equal(turnBlock.segments[1]?.kind, "tool_group"); + assert.deepEqual( + turnBlock.segments[1]?.items?.map((item) => item.id), + ["tool-1", "tool-2"], + ); + assert.equal(turnBlock.segments[2]?.kind, "item"); + assert.equal(turnBlock.segments[3]?.kind, "tool_group"); + assert.deepEqual( + turnBlock.segments[3]?.items?.map((item) => item.id), + ["tool-3"], + ); }); test("buildTranscriptDisplayBlocks collapses setup lifecycle inside prompt bundle", () => { @@ -146,23 +172,25 @@ test("buildTranscriptDisplayBlocks collapses setup lifecycle inside prompt bundl ); }); -test("buildTranscriptDisplayBlocks hides setup lifecycle when prompt is missing", () => { +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, ["context"]); + assert.deepEqual(displayOrder, ["tool"]); }); -test("buildTranscriptDisplayBlocks drops setup-only turns", () => { +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); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts index 74fa75e5c..3a840e22f 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts @@ -2,6 +2,7 @@ import type { TranscriptItem } from "./agentSessionTypes"; export type TranscriptTurnSegment = | { kind: "item"; item: TranscriptItem } + | { kind: "tool_group"; items: Extract[] } | { kind: "setup"; items: Extract[] } | { kind: "prompt"; @@ -62,12 +63,14 @@ function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] { if (userPrompt) consumed.add(userPrompt); for (const item of setupLifecycle) consumed.add(item); - if (userPrompt && promptContext) consumed.add(promptContext); + if (promptContext) consumed.add(promptContext); const activity = items.filter((item) => !consumed.has(item)); if (!userPrompt) { - return activity.map((item) => ({ kind: "item", item })); + return groupConsecutiveToolSegments( + activity.map((item) => ({ kind: "item", item })), + ); } const segments: TranscriptTurnSegment[] = [ @@ -90,7 +93,34 @@ function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] { segments.push({ kind: "item", item }); } - return segments; + return groupConsecutiveToolSegments(segments); +} + +function groupConsecutiveToolSegments( + segments: TranscriptTurnSegment[], +): TranscriptTurnSegment[] { + const grouped: TranscriptTurnSegment[] = []; + let toolBuffer: Extract[] = []; + + const flushTools = () => { + if (toolBuffer.length === 0) { + return; + } + grouped.push({ kind: "tool_group", items: toolBuffer }); + toolBuffer = []; + }; + + for (const segment of segments) { + if (segment.kind === "item" && segment.item.type === "tool") { + toolBuffer.push(segment.item); + continue; + } + flushTools(); + grouped.push(segment); + } + + flushTools(); + return grouped; } /** @@ -162,6 +192,8 @@ export function flattenDisplayBlocks( for (const segment of block.segments) { if (segment.kind === "item") { result.push(segment.item); + } else if (segment.kind === "tool_group") { + result.push(...segment.items); } else if (segment.kind === "prompt") { result.push(segment.user); result.push(...segment.setup); diff --git a/desktop/src/shared/ui/toggle.tsx b/desktop/src/shared/ui/toggle.tsx index 939b2df16..e6e9eac4f 100644 --- a/desktop/src/shared/ui/toggle.tsx +++ b/desktop/src/shared/ui/toggle.tsx @@ -10,12 +10,14 @@ const toggleVariants = cva( variants: { variant: { default: "bg-transparent", + ghost: + "bg-transparent hover:bg-muted/70 hover:text-foreground data-[state=on]:bg-muted data-[state=on]:text-foreground", outline: "border border-input/40 bg-background hover:bg-muted/70 data-[state=on]:bg-muted data-[state=on]:text-foreground", }, size: { default: "h-9 px-3 min-w-9", - xs: "h-5 min-h-0 min-w-0 gap-1 rounded-md px-1.5 text-xs font-medium", + xs: "h-5 min-h-0 min-w-0 gap-1 rounded-md px-1.5 text-xs font-medium [&_svg]:size-3.5", sm: "h-8 px-2 min-w-8", lg: "h-10 px-3 min-w-10", }, From 9f6510682248684cd7d0cb0c516ac8204fc115c6 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 15 Jun 2026 01:14:10 -0700 Subject: [PATCH 19/20] refactor(agents): drop transcript compact mode and simplify row spacing - Remove the compact layout prop from AgentSessionTranscriptList, ManagedAgentSessionPanel, and AgentSessionThreadPanel; always render the tighter transcript density - Revert consecutive tool-call grouping (tool_group segments, ToolCallGroup, segment spacing helpers) and render each tool as a normal transcript row - Add symmetric per-type row spacing: my-2.5 for messages, my-1 for tools, my-2 for other items - Simplify ToolItem wrapper padding now that grouped/compact props are gone --- .../agents/ui/AgentSessionToolItem.tsx | 10 +- .../agents/ui/AgentSessionTranscriptList.tsx | 180 +++--------------- .../agents/ui/ManagedAgentSessionPanel.tsx | 6 - .../agentSessionTranscriptGrouping.test.mjs | 25 --- .../ui/agentSessionTranscriptGrouping.ts | 36 +--- .../channels/ui/AgentSessionThreadPanel.tsx | 1 - 6 files changed, 31 insertions(+), 227 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx index 683896cfd..c06c8add8 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx @@ -10,12 +10,8 @@ import { buildCompactToolSummary } from "./agentSessionToolSummary"; import { asRecord, formatCodeValue, formatDuration } from "./agentSessionUtils"; export function ToolItem({ - compact = false, - grouped = false, item, }: { - compact?: boolean; - grouped?: boolean; item: Extract; }) { const [isExpanded, setIsExpanded] = React.useState(false); @@ -34,11 +30,7 @@ export function ToolItem({ return (
+

No ACP activity yet

{emptyDescription}

@@ -104,7 +97,7 @@ export function AgentSessionTranscriptList({
{displayBlocks.map((block) => ( @@ -113,7 +106,6 @@ export function AgentSessionTranscriptList({ agentName={agentName} agentPubkey={agentPubkey} block={block} - compact={compact} key={getDisplayBlockKey(block)} profiles={profiles} /> @@ -147,28 +139,17 @@ function TranscriptDisplayBlockView({ agentName, agentPubkey, block, - compact, profiles, }: AgentTranscriptIdentityProps & { block: TranscriptDisplayBlock; - compact: boolean; profiles?: UserProfileLookup; }) { if (block.kind === "single") { - if (block.item.type === "tool") { - return ( -
- -
- ); - } - return ( @@ -177,51 +158,24 @@ function TranscriptDisplayBlockView({ return (
- {block.segments.map((segment, index) => ( -
( + - -
+ profiles={profiles} + segment={segment} + /> ))}
); } -function getTurnSegmentSpacing( - previous: TranscriptTurnSegment | undefined, - segment: TranscriptTurnSegment, - compact: boolean, -): string | undefined { - if (!previous) { - return undefined; - } - - const involvesTool = - previous.kind === "tool_group" || segment.kind === "tool_group"; - if (involvesTool) { - return compact ? "mt-3" : "mt-3.5"; - } - - return compact ? "mt-2" : "mt-2.5"; -} - function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) { if (segment.kind === "setup") { return `turn:${turnId}:setup`; @@ -229,9 +183,6 @@ function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) { if (segment.kind === "prompt") { return `turn:${turnId}:prompt`; } - if (segment.kind === "tool_group") { - return `turn:${turnId}:tools:${segment.items.map((item) => item.id).join("+")}`; - } return segment.item.id; } @@ -239,18 +190,15 @@ function TranscriptTurnSegmentView({ agentAvatarUrl, agentName, agentPubkey, - compact, profiles, segment, }: AgentTranscriptIdentityProps & { - compact: boolean; profiles?: UserProfileLookup; segment: TranscriptTurnSegment; }) { if (segment.kind === "prompt") { return ( ; - } - - if (segment.kind === "tool_group") { - return ; + return ; } return ( @@ -272,8 +216,6 @@ function TranscriptTurnSegmentView({ agentAvatarUrl={agentAvatarUrl} agentName={agentName} agentPubkey={agentPubkey} - compact={compact} - embedded item={segment.item} profiles={profiles} /> @@ -281,13 +223,11 @@ function TranscriptTurnSegmentView({ } function TurnPromptBlock({ - compact, context, profiles, setup, user, }: { - compact: boolean; context: Extract | null; profiles?: UserProfileLookup; setup: Extract[]; @@ -304,7 +244,6 @@ function TurnPromptBlock({
) : null} | null; item: Extract; profiles?: UserProfileLookup; @@ -353,12 +290,7 @@ function PromptUserMessage({ size="xs" />
-
+

{text}

{contextOpen && context ? ( @@ -503,43 +435,19 @@ function TurnSetupFooter({ ); } -function ToolCallGroup({ - compact, - items, -}: { - compact: boolean; - items: Extract[]; -}) { - return ( -
- {items.map((item) => ( - - ))} -
- ); -} - function TranscriptItemRow({ agentAvatarUrl, agentName, agentPubkey, - compact, - embedded = false, item, profiles, }: AgentTranscriptIdentityProps & { - compact: boolean; - embedded?: boolean; item: TranscriptItem; profiles?: UserProfileLookup; }) { return (
{SHOW_TRANSCRIPT_ACP_SOURCE && item.acpSource ? ( @@ -549,7 +457,6 @@ function TranscriptItemRow({ agentAvatarUrl={agentAvatarUrl} agentName={agentName} agentPubkey={agentPubkey} - compact={compact} item={item} profiles={profiles} /> @@ -558,10 +465,8 @@ function TranscriptItemRow({ } function TurnSetupStatus({ - compact, items, }: { - compact: boolean; items: Extract[]; }) { const timestamp = turnSetupTimestamp(items); @@ -570,31 +475,29 @@ function TurnSetupStatus({ } return ( -
+
); } -function getItemSpacingClass(item: TranscriptItem) { - if (item.type === "lifecycle") { - return "mt-1.5 first:mt-0"; +function getTranscriptItemRowSpacing(item: TranscriptItem): string { + if (item.type === "message") { + return "my-2.5"; } - if (item.type === "metadata" || item.type === "thought") { - return "mt-1.5 first:mt-0"; + if (item.type === "tool") { + return "my-1"; } - return undefined; + return "my-2"; } const TranscriptItemView = React.memo(function TranscriptItemView({ agentAvatarUrl, agentName, agentPubkey, - compact, item, profiles, }: AgentTranscriptIdentityProps & { - compact: boolean; item: TranscriptItem; profiles?: UserProfileLookup; }) { @@ -604,20 +507,19 @@ const TranscriptItemView = React.memo(function TranscriptItemView({ agentAvatarUrl={agentAvatarUrl} agentName={agentName} agentPubkey={agentPubkey} - compact={compact} item={item} profiles={profiles} /> ); } if (item.type === "tool") { - return ; + return ; } if (item.type === "thought") { - return ; + return ; } if (item.type === "metadata") { - return ; + return ; } return ; }); @@ -626,11 +528,9 @@ function MessageItem({ agentAvatarUrl, agentName, agentPubkey, - compact, item, profiles, }: AgentTranscriptIdentityProps & { - compact: boolean; item: Extract; profiles?: UserProfileLookup; }) { @@ -659,14 +559,9 @@ function MessageItem({
; }) { return (
@@ -751,27 +641,13 @@ function ThoughtItem({ } function MetadataItem({ - compact, - embedded = false, item, }: { - compact: boolean; - embedded?: boolean; item: Extract; }) { return (
diff --git a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx index 74aae7ef0..327213ff2 100644 --- a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx @@ -30,7 +30,6 @@ type ManagedAgentSessionPanelProps = { }; channelId?: string | null; className?: string; - compact?: boolean; emptyDescription?: string; isWorking?: boolean; rawLayout?: "responsive" | "exclusive"; @@ -43,7 +42,6 @@ export function ManagedAgentSessionPanel({ agent, channelId = null, className, - compact = false, emptyDescription = "Mention this agent in a channel to watch the next turn.", isWorking = false, rawLayout = "responsive", @@ -104,7 +102,6 @@ export function ManagedAgentSessionPanel({ agentAvatarUrl={agent.avatarUrl ?? null} agentName={agent.name} agentPubkey={agent.pubkey} - compact={compact} connectionState={connectionState} emptyDescription={emptyDescription} errorMessage={errorMessage} @@ -159,7 +156,6 @@ function SessionBody({ agentAvatarUrl, agentName, agentPubkey, - compact, connectionState, emptyDescription, errorMessage, @@ -174,7 +170,6 @@ function SessionBody({ agentAvatarUrl: string | null; agentName: string; agentPubkey: string; - compact: boolean; connectionState: ConnectionState; emptyDescription: string; errorMessage: string | null; @@ -219,7 +214,6 @@ function SessionBody({ agentAvatarUrl={agentAvatarUrl} agentName={agentName} agentPubkey={agentPubkey} - compact={compact} emptyDescription={emptyDescription} isWorking={isWorking} items={transcript} diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs index 07ba3b2e5..7639a97c6 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs @@ -124,32 +124,7 @@ test("buildTranscriptDisplayBlocks bundles user prompt, setup, and context toget 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, "tool_group"); -}); - -test("buildTranscriptDisplayBlocks groups consecutive tool calls", () => { - const rawItems = [ - userPrompt("prompt", "run these", "turn-1"), - toolCall("tool-1", "turn-1"), - toolCall("tool-2", "turn-1"), - assistantMessage("assistant", "Done.", "turn-1"), - toolCall("tool-3", "turn-1"), - ]; - - const blocks = buildTranscriptDisplayBlocks(rawItems); - const turnBlock = blocks[0]; - assert.equal(turnBlock?.kind, "turn"); - assert.equal(turnBlock.segments[1]?.kind, "tool_group"); - assert.deepEqual( - turnBlock.segments[1]?.items?.map((item) => item.id), - ["tool-1", "tool-2"], - ); assert.equal(turnBlock.segments[2]?.kind, "item"); - assert.equal(turnBlock.segments[3]?.kind, "tool_group"); - assert.deepEqual( - turnBlock.segments[3]?.items?.map((item) => item.id), - ["tool-3"], - ); }); test("buildTranscriptDisplayBlocks collapses setup lifecycle inside prompt bundle", () => { diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts index 3a840e22f..d350ca299 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts @@ -2,7 +2,6 @@ import type { TranscriptItem } from "./agentSessionTypes"; export type TranscriptTurnSegment = | { kind: "item"; item: TranscriptItem } - | { kind: "tool_group"; items: Extract[] } | { kind: "setup"; items: Extract[] } | { kind: "prompt"; @@ -68,9 +67,7 @@ function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] { const activity = items.filter((item) => !consumed.has(item)); if (!userPrompt) { - return groupConsecutiveToolSegments( - activity.map((item) => ({ kind: "item", item })), - ); + return activity.map((item) => ({ kind: "item", item })); } const segments: TranscriptTurnSegment[] = [ @@ -93,34 +90,7 @@ function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] { segments.push({ kind: "item", item }); } - return groupConsecutiveToolSegments(segments); -} - -function groupConsecutiveToolSegments( - segments: TranscriptTurnSegment[], -): TranscriptTurnSegment[] { - const grouped: TranscriptTurnSegment[] = []; - let toolBuffer: Extract[] = []; - - const flushTools = () => { - if (toolBuffer.length === 0) { - return; - } - grouped.push({ kind: "tool_group", items: toolBuffer }); - toolBuffer = []; - }; - - for (const segment of segments) { - if (segment.kind === "item" && segment.item.type === "tool") { - toolBuffer.push(segment.item); - continue; - } - flushTools(); - grouped.push(segment); - } - - flushTools(); - return grouped; + return segments; } /** @@ -192,8 +162,6 @@ export function flattenDisplayBlocks( for (const segment of block.segments) { if (segment.kind === "item") { result.push(segment.item); - } else if (segment.kind === "tool_group") { - result.push(...segment.items); } else if (segment.kind === "prompt") { result.push(segment.user); result.push(...segment.setup); diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index 536d36415..1c402a5cb 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -200,7 +200,6 @@ export function AgentSessionThreadPanel({ agent={agent} channelId={channel.id} className="border-0 bg-transparent p-0 shadow-none" - compact emptyDescription={`Mention ${agent.name} in the channel to see its work here.`} isWorking={isWorking} profiles={profiles} From 781a286338d20ff552c0fe7307eca9109d910a25 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Mon, 15 Jun 2026 01:50:14 -0700 Subject: [PATCH 20/20] fix(desktop): make compact tool label map type honest The labels Record was annotated Record but only spreads generic + developerToolLabels(), which excludes the buzz kind. buzz is handled by an early return in compactToolLabel, so it never indexes this map. Narrow the annotation to Exclude so tsc accepts it, and drop the redundant labelsMap re-cast. No runtime behavior change. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../src/features/agents/ui/agentSessionToolSummary.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.ts b/desktop/src/features/agents/ui/agentSessionToolSummary.ts index 96b024469..f82fbd4c2 100644 --- a/desktop/src/features/agents/ui/agentSessionToolSummary.ts +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.ts @@ -131,7 +131,7 @@ function compactToolLabel( } const labels: Record< - CompactToolKind, + Exclude, { completed: string; running: string; failed: string } > = { generic: { @@ -142,12 +142,7 @@ function compactToolLabel( ...developerToolLabels(), }; - const labelsMap = labels as Record< - CompactToolKind, - { completed: string; running: string; failed: string } - >; - - const set = labelsMap[kind]; + const set = labels[kind]; if (failed) return set.failed; if (running) return set.running; return set.completed;