From 7a9634568cdeb8f5363bc99042a24ebff9df0e1c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:19:04 -0700 Subject: [PATCH] feat(desktop): agent-to-agent messages render as attributed cards, not user bubbles (#85855) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(desktop): render agent-to-agent messages as attributed cards, not user bubbles Bot-to-bot deliveries arrive on the user role (alternation requires it) but are not the human speaking — they rendered as if the user typed them. Detect the delivery prefix ('Message from 🤖 : …', emoji-less, and the legacy bracket form) and render an attributed inter-agent card: left-aligned, robot + sender header, 'agent message' label, body through the same minimal markdown pipeline. Anchored regex cannot fire mid-prose. Same pattern as ProcessNotificationNote. Presentational only; content/roles/caching untouched. * reshape: inter-agent card -> Grok-style compact timeline notice Per maintainer screenshot: the delivery renders as a subtle centered '🤖 Message from ' notice (ProcessNotificationNote's shape), with the delivered text behind a 'show message' expander instead of a full-width card. The recipient's reply remains a normal assistant message below it. * feat: sender avatar on the inter-agent notice The delivery prefix may carry the sender's profile handle — 'Message from 🤖 (@): …'. The notice resolves it through profiles.list (has_avatar) + profiles.get_asset and renders the sender's actual avatar in place of the 🤖 glyph, with module-level memoization (one resolution per sender per window) and inflight de-dup. Glyph fallback covers handle-less prefixes, older gateways without profiles.*, avatarless profiles, and failures. 'hermes' resolves to the primary profile by convention. Tests 6/6. * lint: sort the $gateway import (perfectionist/sort-imports) --- .../thread/agent-message.test.tsx | 50 ++++++ .../assistant-ui/thread/user-message.tsx | 149 +++++++++++++++++- 2 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/components/assistant-ui/thread/agent-message.test.tsx diff --git a/apps/desktop/src/components/assistant-ui/thread/agent-message.test.tsx b/apps/desktop/src/components/assistant-ui/thread/agent-message.test.tsx new file mode 100644 index 0000000000..f6beedaa58 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/agent-message.test.tsx @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' + +import { AGENT_MESSAGE_RE } from './user-message' + +// Agent-to-agent deliveries render as a compact attributed timeline notice, +// not a user bubble. This pins the detection contract: the Bot Mode prefix +// ("Message from 🤖 : …"), the handle-carrying form +// ("Message from 🤖 (@): …"), and the legacy bracket form +// all match; human prose that merely mentions the phrase does not. +describe('agent message detection', () => { + it('matches the Bot Mode delivery prefix with sender and body', () => { + const m = AGENT_MESSAGE_RE.exec('Message from 🤖 Hermes: hello there') + + expect(m?.[1]?.trim()).toBe('Hermes') + expect(m?.[4]).toBe('hello there') + }) + + it('captures the @handle when present', () => { + const m = AGENT_MESSAGE_RE.exec('Message from 🤖 Eats Tests (@mr-tester): run them all') + + expect(m?.[1]?.trim()).toBe('Eats Tests') + expect(m?.[2]).toBe('mr-tester') + expect(m?.[4]).toBe('run them all') + }) + + it('matches without the robot emoji', () => { + const m = AGENT_MESSAGE_RE.exec('Message from Turquoise: ready to work') + + expect(m?.[1]?.trim()).toBe('Turquoise') + expect(m?.[4]).toBe('ready to work') + }) + + it('matches the legacy bracket form', () => { + const m = AGENT_MESSAGE_RE.exec("[Message from agent 'turqoise'] ping") + + expect(m?.[3]).toBe('turqoise') + expect(m?.[4]).toBe('ping') + }) + + it('spans multi-line bodies', () => { + const m = AGENT_MESSAGE_RE.exec('Message from 🤖 Dev: line one\nline two') + + expect(m?.[4]).toBe('line one\nline two') + }) + + it('does not match prose that merely contains the phrase', () => { + expect(AGENT_MESSAGE_RE.test('I got a Message from 🤖 Hermes: earlier')).toBe(false) + expect(AGENT_MESSAGE_RE.test('can you explain what Message from means?')).toBe(false) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/thread/user-message.tsx b/apps/desktop/src/components/assistant-ui/thread/user-message.tsx index e71d189428..a130de0376 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-message.tsx @@ -1,5 +1,5 @@ import { ActionBarPrimitive, BranchPickerPrimitive, MessagePrimitive, useAuiState } from '@assistant-ui/react' -import { type FC, type ReactNode, useCallback, useRef, useState } from 'react' +import { type FC, type ReactNode, useCallback, useEffect, useRef, useState } from 'react' import { DirectiveContent } from '@/components/assistant-ui/directive-text' import { messageAttachmentRefs, messageContentText } from '@/components/assistant-ui/thread/content' @@ -13,6 +13,7 @@ import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { StopFilled } from '@/lib/icons' import { cn } from '@/lib/utils' +import { $gateway } from '@/store/gateway' import { notifyThreadEditOpen } from '@/store/thread-scroll' import { isWatchWindow } from '@/store/windows' @@ -77,6 +78,139 @@ export const StopGlyph = : …", the Bot Mode / +// multi-profile convention; optional "(@)" carries the sender's +// profile name for avatar resolution; legacy "[Message from agent +// ''] …" too). They arrive on the user role because the recipient's +// turn runs on it, but they are NOT the human speaking — render them as a +// compact attributed timeline notice instead of a user bubble. +export const AGENT_MESSAGE_RE = + /^(?:Message from (?:🤖\s*)?([^:\n(]{1,64}?)(?:\s*\(@([a-z0-9][a-z0-9_-]{0,63})\))?:\s*|\[Message from agent '([^']{1,64})'\]\s*)([\s\S]*)$/u + +// sender handle -> avatar data URL (null = known absent). Module-level so a +// chat full of notices from one bot resolves once. Profiles change rarely; +// stale entries only persist for the window's lifetime. +const agentAvatarCache = new Map() +const agentAvatarInflight = new Map>() + +async function resolveAgentAvatar(handle: string): Promise { + const key = handle.trim().toLowerCase() + + if (!key) { + return null + } + + if (agentAvatarCache.has(key)) { + return agentAvatarCache.get(key) ?? null + } + + const inflight = agentAvatarInflight.get(key) + + if (inflight) { + return inflight + } + + const run = (async (): Promise => { + try { + const gateway = $gateway.get() + + if (!gateway) { + return null + } + + const res = await gateway.request<{ profiles?: Array<{ has_avatar?: boolean; name: string }> }>( + 'profiles.list', + { include_sessions: false } + ) + const profiles = res?.profiles ?? [] + let profile = profiles.find(p => p.name.toLowerCase() === key) + + // 'hermes' is the conventional alias for the primary profile. + if (!profile && key === 'hermes') { + profile = profiles.find(p => p.name === 'default') + } + + if (!profile?.has_avatar) { + return null + } + + const asset = await gateway.request<{ data?: string; found?: boolean }>('profiles.get_asset', { + asset: 'avatar', + name: profile.name + }) + + return asset?.found && asset.data ? asset.data : null + } catch { + // Older gateway (no profiles.* RPCs) or transient failure — the 🤖 + // glyph fallback is always correct. + return null + } finally { + agentAvatarInflight.delete(key) + } + })() + + agentAvatarInflight.set(key, run) + const out = await run + agentAvatarCache.set(key, out) + + return out +} + +const AgentMessageNote: FC<{ text: string }> = ({ text }) => { + const match = AGENT_MESSAGE_RE.exec(text) + const sender = (match?.[1] || match?.[3] || 'agent').trim() + const handle = (match?.[2] || match?.[3] || sender).trim() + const body = (match?.[4] || '').trim() + const [avatar, setAvatar] = useState(() => agentAvatarCache.get(handle.toLowerCase()) ?? null) + + useEffect(() => { + let live = true + + void resolveAgentAvatar(handle).then(url => { + if (live && url) { + setAvatar(url) + } + }) + + return () => { + live = false + } + }, [handle]) + + // Grok-bots shape: an inter-agent delivery is a timeline EVENT, not a + // conversation bubble — a subtle centered notice ("Message from 🤖 X"), + // with the delivered text one click away instead of shouting in the + // transcript. The recipient's reply below it stays a normal assistant + // message, so the exchange still reads in order. + return ( +
+ + {avatar ? ( + + ) : ( + + 🤖 + + )} + Message from {sender} + + {body && ( +
+ + show message + +
+ +
+
+ )} +
+ ) +} + const ProcessNotificationNote: FC<{ text: string }> = ({ text }) => { const body = text.replace(/^\[IMPORTANT:\s*/, '').replace(/\]$/, '') const newline = body.indexOf('\n') @@ -229,6 +363,19 @@ export const UserMessage: FC<{ ) } + // Agent-to-agent delivery, not a human prompt — attributed inter-agent card. + if (AGENT_MESSAGE_RE.test(messageText.trim())) { + return ( + + + + ) + } + const hasBody = messageText.trim().length > 0 const isLatestUser = messageId === latestUserId const showStop = !readOnly && isLatestUser && threadRunning && Boolean(onCancel)