From fdfc5413834686d49a9959e0b3b6a5c026e3ef28 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Tue, 30 Jun 2026 01:42:55 -0700 Subject: [PATCH 01/20] feat(profile): embed live activity feed Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../agents/ui/AgentSessionTranscriptList.tsx | 13 +++ .../agents/ui/ManagedAgentSessionPanel.tsx | 6 ++ .../profile/ui/UserProfilePanelSections.tsx | 1 + .../profile/ui/UserProfilePanelTabs.tsx | 99 +++++++++++++++++-- 4 files changed, 112 insertions(+), 7 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 9515be309..286ea9601 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -67,10 +67,12 @@ export function AgentSessionTranscriptList({ agentAvatarUrl, agentName, agentPubkey, + autoTail = false, emptyDescription, items, profiles, }: AgentTranscriptIdentityProps & { + autoTail?: boolean; emptyDescription: string; items: TranscriptItem[]; profiles?: UserProfileLookup; @@ -79,6 +81,16 @@ export function AgentSessionTranscriptList({ () => buildTranscriptDisplayBlocks(items), [items], ); + const tailRef = React.useRef(null); + const latestItemId = items.length > 0 ? items[items.length - 1]?.id : null; + + React.useEffect(() => { + if (!autoTail || !latestItemId) { + return; + } + + tailRef.current?.scrollIntoView({ block: "end" }); + }, [autoTail, latestItemId]); if (items.length === 0) { return ( @@ -112,6 +124,7 @@ export function AgentSessionTranscriptList({ /> ))} + {autoTail ? ); diff --git a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx index ae4909cb4..35ef7780c 100644 --- a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx @@ -34,6 +34,7 @@ type ManagedAgentSessionPanelProps = { agent: Pick & { avatarUrl?: string | null; }; + autoTail?: boolean; channelId?: string | null; className?: string; emptyDescription?: string; @@ -47,6 +48,7 @@ type ManagedAgentSessionPanelProps = { export function ManagedAgentSessionPanel({ agent, + autoTail = false, channelId = null, className, emptyDescription = "Mention this agent in a channel to watch the next turn.", @@ -106,6 +108,7 @@ export function ManagedAgentSessionPanel({ agentName={agent.name} agentPubkey={agent.pubkey} connectionState={connectionState} + autoTail={autoTail} emptyDescription={emptyDescription} errorMessage={errorMessage} events={displayEvents} @@ -159,6 +162,7 @@ function SessionBody({ agentAvatarUrl, agentName, agentPubkey, + autoTail, connectionState, emptyDescription, errorMessage, @@ -173,6 +177,7 @@ function SessionBody({ agentAvatarUrl: string | null; agentName: string; agentPubkey: string; + autoTail: boolean; connectionState: ConnectionState; emptyDescription: string; errorMessage: string | null; @@ -224,6 +229,7 @@ function SessionBody({ emptyDescription={emptyDescription} items={transcript} profiles={profiles} + autoTail={autoTail} /> {rawRail.mode === "side" ? : null} diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index a257fa447..4882fde5a 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -401,6 +401,7 @@ export function ProfileSummaryView({ void; pubkey: string | null; showActivityIngress: boolean; @@ -281,6 +285,8 @@ export function ProfileInfoTabContent({ ] : agentInfoFields; const hasInfoFields = infoFields.length > 0; + const activeTurns = useActiveAgentTurns(managedAgent?.pubkey ?? null); + const showLiveActivityEmbed = showActivityIngress && activeTurns.length > 0; if (!hasInfoFields && !showActivityIngress) { return null; @@ -289,19 +295,98 @@ export function ProfileInfoTabContent({ return (
{showActivityIngress ? ( - + showLiveActivityEmbed && managedAgent ? ( + + ) : ( + + ) ) : null} {hasInfoFields ? : null}
); } +function ProfileLiveActivityEmbed({ + managedAgent, + onOpenActivity, +}: { + managedAgent: ManagedAgent; + onOpenActivity: () => void; +}) { + const handleClick = React.useCallback( + (event: React.MouseEvent) => { + if (event.defaultPrevented) { + return; + } + + const target = event.target as HTMLElement | null; + const interactiveTarget = target?.closest( + "button, a, [role='button'], [role='link']", + ); + if (interactiveTarget && interactiveTarget !== event.currentTarget) { + return; + } + + onOpenActivity(); + }, + [onOpenActivity], + ); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.defaultPrevented) { + return; + } + + const target = event.target as HTMLElement | null; + const interactiveTarget = target?.closest( + "button, a, [role='button'], [role='link']", + ); + if (interactiveTarget && interactiveTarget !== event.currentTarget) { + return; + } + + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onOpenActivity(); + } + }, + [onOpenActivity], + ); + + return ( + // biome-ignore lint/a11y/useSemanticElements: The embedded transcript contains its own buttons and links, so the clickable shell cannot be a semantic button. +
+ +
+ ); +} + function ArchiveStatusTooltip() { return ( From 6f8e809b47bbb13f5299f52480e6980e775f8254 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Tue, 30 Jun 2026 01:51:49 -0700 Subject: [PATCH 02/20] feat(profile): scope live activity by channel Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../profile/ui/UserProfilePanelSections.tsx | 2 + .../profile/ui/UserProfilePanelTabs.tsx | 85 ++++++++++++++++++- 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 4882fde5a..de54ba81e 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -399,7 +399,9 @@ export function ProfileSummaryView({ ) : null} {activeTab === "info" ? ( ; isArchived: boolean; managedAgent?: ManagedAgent; onOpenActivity: () => void; @@ -285,7 +291,6 @@ export function ProfileInfoTabContent({ ] : agentInfoFields; const hasInfoFields = infoFields.length > 0; - const activeTurns = useActiveAgentTurns(managedAgent?.pubkey ?? null); const showLiveActivityEmbed = showActivityIngress && activeTurns.length > 0; if (!hasInfoFields && !showActivityIngress) { @@ -297,6 +302,8 @@ export function ProfileInfoTabContent({ {showActivityIngress ? ( showLiveActivityEmbed && managedAgent ? ( @@ -316,12 +323,39 @@ export function ProfileInfoTabContent({ } function ProfileLiveActivityEmbed({ + activeTurns, + channelIdToName, managedAgent, onOpenActivity, }: { + activeTurns: ActiveTurnSummary[]; + channelIdToName: Record; managedAgent: ManagedAgent; onOpenActivity: () => void; }) { + const [selectedChannelId, setSelectedChannelId] = React.useState< + string | null + >(() => activeTurns[0]?.channelId ?? null); + const now = useNow(1000); + + React.useEffect(() => { + if (activeTurns.length === 0) { + setSelectedChannelId(null); + return; + } + + if (!activeTurns.some((turn) => turn.channelId === selectedChannelId)) { + setSelectedChannelId(activeTurns[0]?.channelId ?? null); + } + }, [activeTurns, selectedChannelId]); + + const selectedTurn = + activeTurns.find((turn) => turn.channelId === selectedChannelId) ?? + activeTurns[0] ?? + null; + const activeChannelId = selectedTurn?.channelId ?? null; + const showSwitcher = activeTurns.length > 1; + const handleClick = React.useCallback( (event: React.MouseEvent) => { if (event.defaultPrevented) { @@ -367,17 +401,60 @@ function ProfileLiveActivityEmbed({ // biome-ignore lint/a11y/useSemanticElements: The embedded transcript contains its own buttons and links, so the clickable shell cannot be a semantic button.
+ {showSwitcher ? ( +
+
+ Live activity + {selectedTurn ? ( + + {formatElapsed(now - selectedTurn.anchorAt)} + + ) : null} +
+
+ {activeTurns.map((turn) => { + const isSelected = turn.channelId === activeChannelId; + const channelName = + channelIdToName[turn.channelId] ?? turn.channelId; + + return ( + + ); + })} +
+
+ ) : null} Date: Tue, 30 Jun 2026 02:18:59 -0700 Subject: [PATCH 03/20] fix(profile): harden live activity embed Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../agents/ui/AgentSessionTranscriptList.tsx | 31 +++-- .../agents/ui/ManagedAgentSessionPanel.tsx | 4 + .../channels/ui/AgentSessionThreadPanel.tsx | 9 +- .../features/channels/ui/BotActivityBar.tsx | 4 +- .../src/features/channels/ui/ChannelPane.tsx | 18 ++- .../features/channels/ui/ChannelPane.types.ts | 3 +- .../features/channels/ui/ChannelScreen.tsx | 4 + .../channels/ui/useChannelAgentSessions.ts | 11 +- .../ui/useChannelPanelHistoryState.ts | 17 ++- .../features/messages/ui/useAnchoredScroll.ts | 4 +- .../features/profile/ui/UserProfilePanel.tsx | 11 +- .../profile/ui/UserProfilePanelSections.tsx | 2 +- .../profile/ui/UserProfilePanelTabs.tsx | 116 ++++++++---------- .../shared/context/AgentSessionContext.tsx | 6 +- 14 files changed, 139 insertions(+), 101 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 286ea9601..f3d7b92cc 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { CheckCheck, ChevronDown, Radio } from "lucide-react"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { useAnchoredScroll } from "@/features/messages/ui/useAnchoredScroll"; import { cn } from "@/shared/lib/cn"; import { Dialog, @@ -71,26 +72,27 @@ export function AgentSessionTranscriptList({ emptyDescription, items, profiles, + scrollScopeKey, }: AgentTranscriptIdentityProps & { autoTail?: boolean; emptyDescription: string; items: TranscriptItem[]; profiles?: UserProfileLookup; + scrollScopeKey?: string | null; }) { const displayBlocks = React.useMemo( () => buildTranscriptDisplayBlocks(items), [items], ); - const tailRef = React.useRef(null); - const latestItemId = items.length > 0 ? items[items.length - 1]?.id : null; - - React.useEffect(() => { - if (!autoTail || !latestItemId) { - return; - } - - tailRef.current?.scrollIntoView({ block: "end" }); - }, [autoTail, latestItemId]); + const scrollContainerRef = React.useRef(null); + const contentRef = React.useRef(null); + const anchoredScroll = useAnchoredScroll({ + channelId: autoTail ? (scrollScopeKey ?? agentPubkey) : null, + contentRef, + isLoading: false, + messages: items, + scrollContainerRef, + }); if (items.length === 0) { return ( @@ -103,16 +105,22 @@ export function AgentSessionTranscriptList({ } return ( -
+
{displayBlocks.map((block) => (
))} - {autoTail ?
); diff --git a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx index 35ef7780c..90c21b938 100644 --- a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx @@ -109,6 +109,7 @@ export function ManagedAgentSessionPanel({ agentPubkey={agent.pubkey} connectionState={connectionState} autoTail={autoTail} + channelId={channelId} emptyDescription={emptyDescription} errorMessage={errorMessage} events={displayEvents} @@ -164,6 +165,7 @@ function SessionBody({ agentPubkey, autoTail, connectionState, + channelId, emptyDescription, errorMessage, events, @@ -178,6 +180,7 @@ function SessionBody({ agentName: string; agentPubkey: string; autoTail: boolean; + channelId: string | null; connectionState: ConnectionState; emptyDescription: string; errorMessage: string | null; @@ -229,6 +232,7 @@ function SessionBody({ emptyDescription={emptyDescription} items={transcript} profiles={profiles} + scrollScopeKey={`${agentPubkey}:${channelId ?? "all"}`} autoTail={autoTail} /> {rawRail.mode === "side" ? : null} diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index 572c7a780..fbd8a6213 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -32,6 +32,7 @@ import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions"; type AgentSessionThreadPanelProps = { agent: ChannelAgentSessionAgent; channel: Channel | null; + channelId?: string | null; canInterruptTurn: boolean; isWorking: boolean; layout?: "standalone" | "split"; @@ -47,6 +48,7 @@ export function AgentSessionThreadPanel({ agent, canInterruptTurn, channel, + channelId = null, isWorking, layout = "standalone", isSinglePanelView = false, @@ -62,7 +64,8 @@ export function AgentSessionThreadPanel({ useEscapeKey(onClose, isOverlay || isSinglePanelView); const { ref: scrollRef, onScroll } = useStickToBottom(); - const rawFeedScopeKey = `${agent.pubkey}:${channel?.id ?? "all"}`; + const sessionChannelId = channelId ?? channel?.id ?? null; + const rawFeedScopeKey = `${agent.pubkey}:${sessionChannelId ?? "all"}`; const [rawFeedState, setRawFeedState] = React.useState(() => ({ scopeKey: rawFeedScopeKey, show: false, @@ -223,10 +226,10 @@ export function AgentSessionThreadPanel({ > ; type BotActivityBarProps = { agents: BotActivityAgent[]; channelId?: string | null; - onOpenAgentSession: (pubkey: string) => void; + onOpenAgentSession: (pubkey: string, channelId?: string | null) => void; openAgentSessionPubkey: string | null; profiles?: UserProfileLookup; typingBotPubkeys: string[]; @@ -237,7 +237,7 @@ export function BotActivityComposerAction({ onClick={() => { clearHoverTimer(); setOpen(false); - onOpenAgentSession(agent.pubkey); + onOpenAgentSession(agent.pubkey, channelId); }} type="button" > diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index f89245e53..26ba86e10 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -118,6 +118,7 @@ export const ChannelPane = React.memo(function ChannelPane({ profiles, openThreadHeadId, shouldShowThreadSkeleton, + openAgentSessionChannelId, openAgentSessionPubkey, onProfilePanelViewChange, onProfilePanelTabChange, @@ -822,13 +823,18 @@ export const ChannelPane = React.memo(function ChannelPane({ agent={selectedAgent} canInterruptTurn={selectedAgent.canInterruptTurn} channel={ - agentSessionSelection.isAgentInActivityList({ - activityAgents, - selectedAgent, - }) - ? activeChannel - : null + openAgentSessionChannelId + ? activeChannel?.id === openAgentSessionChannelId + ? activeChannel + : null + : agentSessionSelection.isAgentInActivityList({ + activityAgents, + selectedAgent, + }) + ? activeChannel + : null } + channelId={openAgentSessionChannelId} isWorking={botTypingEntries.some( (entry) => entry.pubkey.toLowerCase() === diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 02b441ff6..bb38989d2 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -56,7 +56,7 @@ export type ChannelPaneProps = { onMarkRead?: (message: TimelineMessage) => void; onExpandThreadReplies: (message: TimelineMessage) => void; onJoinChannel?: () => Promise; - onOpenAgentSession: (pubkey: string) => void; + onOpenAgentSession: (pubkey: string, channelId?: string | null) => void; onOpenDm?: (pubkeys: string[]) => Promise | void; onOpenMembers?: () => void; onOpenProfilePanel: (pubkey: string) => void; @@ -94,6 +94,7 @@ export type ChannelPaneProps = { profiles?: UserProfileLookup; openThreadHeadId: string | null; shouldShowThreadSkeleton: boolean; + openAgentSessionChannelId: string | null; openAgentSessionPubkey: string | null; onProfilePanelViewChange: ( view: ProfilePanelView, diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 5197c6717..4899c8d0a 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -107,12 +107,14 @@ export function ChannelScreen({ const { channelManagementOpen, clearMessageRouteTarget, + openAgentSessionChannelId, openAgentSessionPubkey, openThreadHeadId, profilePanelPubkey, profilePanelTab, profilePanelView, setChannelManagementOpen, + setOpenAgentSessionChannelId, setOpenAgentSessionPubkey, setOpenThreadHeadId, setProfilePanelTab, @@ -591,6 +593,7 @@ export function ChannelScreen({ profilePanelPubkey, setChannelManagementOpen, setExpandedThreadReplyIds, + setOpenAgentSessionChannelId, setOpenAgentSessionPubkey, setOpenThreadHeadId, setProfilePanelPubkey, @@ -927,6 +930,7 @@ export function ChannelScreen({ onThreadPanelResizeStart={handleThreadPanelResizeStart} onTargetReached={handleTargetReached} onToggleReaction={effectiveToggleReaction} + openAgentSessionChannelId={openAgentSessionChannelId} openAgentSessionPubkey={openAgentSessionPubkey} openThreadHeadId={effectiveOpenThreadHeadId} shouldShowThreadSkeleton={shouldShowThreadSkeleton} diff --git a/desktop/src/features/channels/ui/useChannelAgentSessions.ts b/desktop/src/features/channels/ui/useChannelAgentSessions.ts index b4a8708fa..aed40a39d 100644 --- a/desktop/src/features/channels/ui/useChannelAgentSessions.ts +++ b/desktop/src/features/channels/ui/useChannelAgentSessions.ts @@ -31,6 +31,7 @@ type UseChannelAgentSessionsOptions = { profilePanelPubkey?: string | null; setChannelManagementOpen: (open: boolean) => void; setExpandedThreadReplyIds: (value: Set) => void; + setOpenAgentSessionChannelId: PanelValueSetter; setOpenAgentSessionPubkey: PanelValueSetter; setOpenThreadHeadId: (value: string | null) => void; setProfilePanelPubkey: (value: string | null) => void; @@ -164,6 +165,7 @@ export function useChannelAgentSessions({ profilePanelPubkey = null, setChannelManagementOpen, setExpandedThreadReplyIds, + setOpenAgentSessionChannelId, setOpenAgentSessionPubkey, setOpenThreadHeadId, setProfilePanelPubkey, @@ -187,17 +189,19 @@ export function useChannelAgentSessions({ }, [setOpenAgentSessionPubkey]); const openAgentSession = React.useCallback( - (pubkey: string) => { + (pubkey: string, channelId?: string | null) => { setOpenThreadHeadId(null); setExpandedThreadReplyIds(new Set()); setThreadScrollTargetId(null); setThreadReplyTargetId(null); setChannelManagementOpen(false); setOpenAgentSessionPubkey(pubkey); + setOpenAgentSessionChannelId(channelId ?? null); }, [ setChannelManagementOpen, setExpandedThreadReplyIds, + setOpenAgentSessionChannelId, setOpenAgentSessionPubkey, setOpenThreadHeadId, setThreadReplyTargetId, @@ -206,10 +210,11 @@ export function useChannelAgentSessions({ ); const selectAgentSession = React.useCallback( - (pubkey: string) => { + (pubkey: string, channelId?: string | null) => { setOpenAgentSessionPubkey(pubkey); + setOpenAgentSessionChannelId(channelId ?? null); }, - [setOpenAgentSessionPubkey], + [setOpenAgentSessionChannelId, setOpenAgentSessionPubkey], ); const openThreadAndCloseAgentSession = React.useCallback( diff --git a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts index 028d935f1..15a201f44 100644 --- a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts +++ b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts @@ -18,7 +18,8 @@ import { * * Params: `thread` (open thread head id), `profile` (profile panel pubkey), * `profileView` (profile panel focused view), `profileTab` (profile summary - * tab), `agentSession` (agent session panel pubkey), `channelManagement` + * tab), `agentSession` (agent session panel pubkey), `agentSessionChannel` + * (optional channel scope for the agent session panel), `channelManagement` * (presence flag for the channel-management panel — open/closed only, so it * carries a sentinel `"1"` rather than an id). */ @@ -32,6 +33,7 @@ export type PanelValueSetter = ( const CHANNEL_SEARCH_KEYS = [ "agentSession", + "agentSessionChannel", "channelManagement", "messageId", "profile", @@ -75,7 +77,16 @@ export function useChannelPanelHistoryState() { ); const setOpenAgentSessionPubkey = React.useCallback( - (value, options) => applyPatch({ agentSession: value }, options), + (value, options) => + applyPatch( + { agentSession: value, agentSessionChannel: value ? undefined : null }, + options, + ), + [applyPatch], + ); + + const setOpenAgentSessionChannelId = React.useCallback( + (value, options) => applyPatch({ agentSessionChannel: value }, options), [applyPatch], ); @@ -97,12 +108,14 @@ export function useChannelPanelHistoryState() { return { channelManagementOpen: values.channelManagement != null, clearMessageRouteTarget, + openAgentSessionChannelId: values.agentSessionChannel, openAgentSessionPubkey: values.agentSession, openThreadHeadId: values.thread, profilePanelPubkey: values.profile, profilePanelTab: profilePanelTabFromSearch(values.profileTab), profilePanelView: profilePanelViewFromSearch(values.profileView), setChannelManagementOpen, + setOpenAgentSessionChannelId, setOpenAgentSessionPubkey, setOpenThreadHeadId, setProfilePanelTab, diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index 6a18a9684..1dbb76362 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -1,7 +1,5 @@ import * as React from "react"; -import type { TimelineMessage } from "@/features/messages/types"; - /** * Distance (in CSS pixels) below which we consider the scroll position * "at the bottom" of the message list. Tight enough that the user has to @@ -44,7 +42,7 @@ type UseAnchoredScrollOptions = { isLoading: boolean; /** Source of truth for the rendered list. Used to detect new-at-bottom * arrivals and to seed/refresh the anchor pre-render. */ - messages: TimelineMessage[]; + messages: Array<{ id: string }>; /** When set, scroll to and highlight this message on mount and on change. */ targetMessageId?: string | null; diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index c2c31b1e8..5ebe58794 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -684,10 +684,13 @@ export function UserProfilePanel({ ], ); - const handleOpenActivity = React.useCallback(() => { - if (!effectivePubkey) return; - onOpenAgentSession?.(effectivePubkey); - }, [effectivePubkey, onOpenAgentSession]); + const handleOpenActivity = React.useCallback( + (channelId?: string | null) => { + if (!effectivePubkey) return; + onOpenAgentSession?.(effectivePubkey, channelId ?? null); + }, + [effectivePubkey, onOpenAgentSession], + ); const handleOpenChannel = React.useCallback( (channelId: string) => { diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index de54ba81e..c453b974a 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -96,7 +96,7 @@ export type ProfileSummaryViewProps = { agentSettingsFields: ProfileField[]; diagnosticsFields: ProfileField[]; onAddToChannel: () => void; - onOpenActivity: () => void; + onOpenActivity: (channelId?: string | null) => void; onOpenChannel: (channelId: string) => void; onOpenDiagnostics: () => void; onOpenInstructions: () => void; diff --git a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx index fb3d5bbc1..450faca4d 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx @@ -274,7 +274,7 @@ export function ProfileInfoTabContent({ channelIdToName: Record; isArchived: boolean; managedAgent?: ManagedAgent; - onOpenActivity: () => void; + onOpenActivity: (channelId?: string | null) => void; pubkey: string | null; showActivityIngress: boolean; }) { @@ -311,7 +311,7 @@ export function ProfileInfoTabContent({ onOpenActivity(null)} testId={`user-profile-view-activity-${pubkey}`} trailing="View" /> @@ -331,7 +331,7 @@ function ProfileLiveActivityEmbed({ activeTurns: ActiveTurnSummary[]; channelIdToName: Record; managedAgent: ManagedAgent; - onOpenActivity: () => void; + onOpenActivity: (channelId?: string | null) => void; }) { const [selectedChannelId, setSelectedChannelId] = React.useState< string | null @@ -356,68 +356,21 @@ function ProfileLiveActivityEmbed({ const activeChannelId = selectedTurn?.channelId ?? null; const showSwitcher = activeTurns.length > 1; - const handleClick = React.useCallback( - (event: React.MouseEvent) => { - if (event.defaultPrevented) { - return; - } - - const target = event.target as HTMLElement | null; - const interactiveTarget = target?.closest( - "button, a, [role='button'], [role='link']", - ); - if (interactiveTarget && interactiveTarget !== event.currentTarget) { - return; - } - - onOpenActivity(); - }, - [onOpenActivity], - ); - - const handleKeyDown = React.useCallback( - (event: React.KeyboardEvent) => { - if (event.defaultPrevented) { - return; - } - - const target = event.target as HTMLElement | null; - const interactiveTarget = target?.closest( - "button, a, [role='button'], [role='link']", - ); - if (interactiveTarget && interactiveTarget !== event.currentTarget) { - return; - } - - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onOpenActivity(); - } - }, - [onOpenActivity], - ); - return ( - // biome-ignore lint/a11y/useSemanticElements: The embedded transcript contains its own buttons and links, so the clickable shell cannot be a semantic button. -
{showSwitcher ? (
-
- Live activity - {selectedTurn ? ( - - {formatElapsed(now - selectedTurn.anchorAt)} - - ) : null} -
+
- ) : null} + ) : ( +
+ +
+ )} + + ); +} + +function LiveActivityEmbedHeader({ + activeChannelId, + elapsedLabel, + onOpenActivity, +}: { + activeChannelId: string | null; + elapsedLabel: string | null; + onOpenActivity: (channelId?: string | null) => void; +}) { + return ( +
+ Live activity +
+ {elapsedLabel ? ( + {elapsedLabel} + ) : null} + +
); } diff --git a/desktop/src/shared/context/AgentSessionContext.tsx b/desktop/src/shared/context/AgentSessionContext.tsx index 8fd4903c4..666773ce7 100644 --- a/desktop/src/shared/context/AgentSessionContext.tsx +++ b/desktop/src/shared/context/AgentSessionContext.tsx @@ -1,7 +1,9 @@ import * as React from "react"; type AgentSessionContextValue = { - onOpenAgentSession: ((pubkey: string) => void) | null; + onOpenAgentSession: + | ((pubkey: string, channelId?: string | null) => void) + | null; }; const AgentSessionContext = React.createContext({ @@ -13,7 +15,7 @@ export function AgentSessionProvider({ onOpenAgentSession, }: { children: React.ReactNode; - onOpenAgentSession: (pubkey: string) => void; + onOpenAgentSession: (pubkey: string, channelId?: string | null) => void; }) { const value = React.useMemo( () => ({ onOpenAgentSession }), From fca491e5e3ca679c5d6f466b0953d652a877d451 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Tue, 30 Jun 2026 17:01:28 -0700 Subject: [PATCH 04/20] fix(profile): use declared owner helper for activity gates Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/src/features/profile/ui/UserProfilePanel.tsx | 6 ++---- .../src/features/profile/ui/UserProfilePopover.tsx | 11 +++++------ 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 5ebe58794..67b3edd5e 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -53,6 +53,7 @@ import { useUserProfileQuery, useUsersBatchQuery, } from "@/features/profile/hooks"; +import { ownsAuthorAgent } from "@/features/profile/lib/identity"; import { AgentInfoFocusedView, AgentInstructionsFocusedView, @@ -269,10 +270,7 @@ export function UserProfilePanel({ // the relay routes and the client decrypts those frames with the owner's OWN // key, so the agent's seckey is never needed. Computed here (before the gates // that consume it) so visibility keys off declared ownership, not key custody. - const isCurrentUserOwner = - currentPubkey !== undefined && - ownerPubkey !== null && - ownerPubkey.toLowerCase() === currentPubkey.toLowerCase(); + const isCurrentUserOwner = ownsAuthorAgent(profile, currentPubkey); // The viewer may see owner-scoped data if they declared-own the agent OR they // manage it locally (older agents may not advertise an owner pubkey). Every // real boundary is server-side, so this only controls what UI we paint. diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index 735c61c59..9b22dad3c 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -19,7 +19,10 @@ import { import { useIsManagedAgent } from "@/features/agent-memory/hooks"; import { useIdentityQuery } from "@/shared/api/hooks"; import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; -import { truncatePubkey } from "@/features/profile/lib/identity"; +import { + ownsAuthorAgent, + truncatePubkey, +} from "@/features/profile/lib/identity"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; import { usePresenceQuery } from "@/features/presence/hooks"; import { useUserStatusQuery } from "@/features/user-status/hooks"; @@ -210,7 +213,6 @@ export function UserProfilePopover({ // shape as the pane/sidebar/memory fixes. Every real boundary is server-side; // this only decides whether to paint the "View activity log" button. const isOwner = useIsManagedAgent(isBotProfile ? pubkey : null); - const ownerPubkey = profile?.ownerPubkey ?? null; const identityQuery = useIdentityQuery(); const currentPubkey = identityQuery.data?.pubkey; const isSelf = @@ -218,10 +220,7 @@ export function UserProfilePopover({ currentPubkey.toLowerCase() === pubkey.toLowerCase(); const showProfileActions = currentPubkey !== undefined && !isSelf; const selfProfileQuery = useProfileQuery(open && showProfileActions); - const isCurrentUserOwner = - currentPubkey !== undefined && - ownerPubkey !== null && - ownerPubkey.toLowerCase() === currentPubkey.toLowerCase(); + const isCurrentUserOwner = ownsAuthorAgent(profile, currentPubkey); const viewerIsOwner = isCurrentUserOwner || isOwner === true; const canViewActivity = isBotProfile && viewerIsOwner && Boolean(onOpenAgentSession); From d5df33fd1a52714b2b86215e46b255e181a4d326 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 30 Jun 2026 20:31:19 -0700 Subject: [PATCH 05/20] fix(profile): render live activity for owned relay agents - Synthesize a minimal profile activity agent for declared-owned relay agents so observer and active-turn bridges track the same pubkey. - Relax the profile live activity embed to accept the shared activity-agent shape instead of requiring a full local managed-agent record. - Extract the activity-agent resolver from the oversized profile panel to keep desktop file-size guards intact. - Add an E2E regression covering a viewer-owned relay-only agent switching from the static activity row to the live activity embed. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../profile/lib/profileActivityAgent.ts | 44 ++++++++++++++++ .../features/profile/ui/UserProfilePanel.tsx | 51 +++++++----------- .../profile/ui/UserProfilePanelSections.tsx | 5 +- .../profile/ui/UserProfilePanelTabs.tsx | 17 +++--- desktop/tests/e2e/channels.spec.ts | 52 +++++++++++++++++++ 5 files changed, 129 insertions(+), 40 deletions(-) create mode 100644 desktop/src/features/profile/lib/profileActivityAgent.ts diff --git a/desktop/src/features/profile/lib/profileActivityAgent.ts b/desktop/src/features/profile/lib/profileActivityAgent.ts new file mode 100644 index 000000000..2bc4fe446 --- /dev/null +++ b/desktop/src/features/profile/lib/profileActivityAgent.ts @@ -0,0 +1,44 @@ +import type { ManagedAgent, RelayAgent } from "@/shared/api/types"; + +export type ProfileActivityAgent = Pick< + ManagedAgent, + "pubkey" | "name" | "status" +> & { + avatarUrl?: string | null; +}; + +export function resolveProfileActivityAgent({ + effectivePubkey, + isBot, + managedAgent, + profile, + relayAgent, + viewerIsOwner, +}: { + effectivePubkey: string | null; + isBot: boolean; + managedAgent: ManagedAgent | undefined; + profile: { avatarUrl?: string | null; displayName?: string | null } | null; + relayAgent: RelayAgent | undefined; + viewerIsOwner: boolean; +}): ProfileActivityAgent | null { + if (managedAgent) { + return { + avatarUrl: managedAgent.avatarUrl, + name: managedAgent.name, + pubkey: managedAgent.pubkey, + status: managedAgent.status, + }; + } + + if (!viewerIsOwner || !effectivePubkey || !isBot) { + return null; + } + + return { + avatarUrl: profile?.avatarUrl ?? null, + name: relayAgent?.name ?? profile?.displayName?.trim() ?? "Agent", + pubkey: effectivePubkey, + status: relayAgent?.status === "offline" ? "stopped" : "deployed", + }; +} diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 67b3edd5e..f26ede972 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -54,6 +54,7 @@ import { useUsersBatchQuery, } from "@/features/profile/hooks"; import { ownsAuthorAgent } from "@/features/profile/lib/identity"; +import { resolveProfileActivityAgent } from "@/features/profile/lib/profileActivityAgent"; import { AgentInfoFocusedView, AgentInstructionsFocusedView, @@ -91,7 +92,6 @@ import type { Channel, CreateManagedAgentInput, CreatePersonaInput, - ManagedAgent, UpdatePersonaInput, } from "@/shared/api/types"; import { UserProfilePanelFrame } from "@/features/profile/ui/UserProfilePanelFrame"; @@ -276,38 +276,26 @@ export function UserProfilePanel({ // real boundary is server-side, so this only controls what UI we paint. const viewerIsOwner = isCurrentUserOwner || isOwner === true; - // Populate the active-turns store for this agent so useActiveAgentTurns works - // even if the Agents page hasn't been visited yet. - const bridgeAgents = React.useMemo( + const activityAgent = React.useMemo( () => - managedAgent - ? [{ pubkey: managedAgent.pubkey, status: managedAgent.status }] - : [], - [managedAgent], + resolveProfileActivityAgent({ + effectivePubkey, + isBot, + managedAgent, + profile: profile ?? null, + relayAgent, + viewerIsOwner, + }), + [effectivePubkey, isBot, managedAgent, profile, relayAgent, viewerIsOwner], ); - // The observer bridge subscribes on the OWNER's own pubkey and decrypts the - // agent's telemetry with the owner's key — no agent seckey needed. It only - // decrypts frames whose agent pubkey is "known", and only subscribes when an - // agent is running/deployed. For a remote agent we own but don't manage - // locally, `managedAgent` is undefined, so we seed the bridge from the relay - // agent (treated as "deployed") when the viewer is the declared owner. This - // mirrors what the composer-area ingress already does in ChannelScreen. - const observerBridgeAgents = React.useMemo(() => { - if (managedAgent) { - return [{ pubkey: managedAgent.pubkey, status: managedAgent.status }]; - } - if (viewerIsOwner && relayAgent) { - return [ - { - pubkey: relayAgent.pubkey, - status: "deployed" as ManagedAgent["status"], - }, - ]; - } - return []; - }, [managedAgent, relayAgent, viewerIsOwner]); - useActiveAgentTurnsBridge(bridgeAgents); - useManagedAgentObserverBridge(observerBridgeAgents); + const activityBridgeAgents = React.useMemo( + () => (activityAgent ? [activityAgent] : []), + [activityAgent], + ); + // Populate the active-turns store for this agent so useActiveAgentTurns works + // even if the Agents page hasn't been visited yet. + useActiveAgentTurnsBridge(activityBridgeAgents); + useManagedAgentObserverBridge(activityBridgeAgents); const canEditAgent = isOwner === true && (managedAgent !== undefined || @@ -844,6 +832,7 @@ export function UserProfilePanel({ isFollowing={isFollowing} isOwner={viewerIsOwner} isSelf={isSelf} + activityAgent={activityAgent} managedAgent={managedAgent} memoriesLoading={memoryQuery.isLoading} memoryCount={memoryCount} diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index c453b974a..eb083ec6f 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -23,6 +23,7 @@ import { AgentConfigPanel } from "@/features/agents/ui/AgentConfigPanel"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { getPresenceLabel } from "@/features/presence/lib/presence"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; +import type { ProfileActivityAgent } from "@/features/profile/lib/profileActivityAgent"; import type { useFollowMutation, useUnfollowMutation, @@ -64,6 +65,7 @@ export { AgentInstructionsFocusedView } from "@/features/profile/ui/UserProfileP // ── Summary view ───────────────────────────────────────────────────────────── export type ProfileSummaryViewProps = { + activityAgent: ProfileActivityAgent | null; canAddToChannel: boolean; canEditAgent: boolean; canOpenAgentLogs: boolean; @@ -173,6 +175,7 @@ function RuntimeTabStatusDot({ status }: { status: RuntimeTabStatus }) { } export function ProfileSummaryView({ + activityAgent, canAddToChannel, canEditAgent, canOpenAgentLogs, @@ -400,10 +403,10 @@ export function ProfileSummaryView({ {activeTab === "info" ? ( ; isArchived: boolean; - managedAgent?: ManagedAgent; onOpenActivity: (channelId?: string | null) => void; pubkey: string | null; showActivityIngress: boolean; @@ -300,11 +301,11 @@ export function ProfileInfoTabContent({ return (
{showActivityIngress ? ( - showLiveActivityEmbed && managedAgent ? ( + showLiveActivityEmbed && activityAgent ? ( ) : ( @@ -324,13 +325,13 @@ export function ProfileInfoTabContent({ function ProfileLiveActivityEmbed({ activeTurns, + activityAgent, channelIdToName, - managedAgent, onOpenActivity, }: { activeTurns: ActiveTurnSummary[]; + activityAgent: ProfileActivityAgent; channelIdToName: Record; - managedAgent: ManagedAgent; onOpenActivity: (channelId?: string | null) => void; }) { const [selectedChannelId, setSelectedChannelId] = React.useState< @@ -360,7 +361,7 @@ function ProfileLiveActivityEmbed({
{showSwitcher ? (
@@ -414,7 +415,7 @@ function ProfileLiveActivityEmbed({
)} void; __BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: { category: "mention" | "needs_action" | "activity" | "agent_activity"; channel_id: string | null; @@ -1016,6 +1022,52 @@ test("members sidebar exposes view-activity for a viewer-owned relay agent", asy ).toBeVisible(); }); +test("profile renders live activity for a viewer-owned relay agent", async ({ + page, +}) => { + await page.goto("/"); + + await openMembersSidebar(page, "agents"); + await page + .getByTestId(`sidebar-member-open-profile-${OWNED_RELAY_AGENT_PUBKEY}`) + .click(); + await expect(page.getByTestId("members-sidebar")).not.toBeVisible(); + await expect( + page.getByTestId(`user-profile-view-activity-${OWNED_RELAY_AGENT_PUBKEY}`), + ).toBeVisible(); + + await page.waitForFunction( + () => + typeof (window as MockFeedWindow).__BUZZ_E2E_SEED_ACTIVE_TURNS__ === + "function", + ); + await page.evaluate( + ({ agentPubkey, channelId }) => { + const seedActiveTurns = (window as MockFeedWindow) + .__BUZZ_E2E_SEED_ACTIVE_TURNS__; + if (!seedActiveTurns) { + throw new Error("Mock active-turn helper is not installed."); + } + seedActiveTurns({ + agentPubkey, + channelId, + turnId: "owned-relay-profile-turn", + }); + }, + { + agentPubkey: OWNED_RELAY_AGENT_PUBKEY, + channelId: AGENTS_CHANNEL_ID, + }, + ); + + const liveActivity = page.getByTestId( + `user-profile-live-activity-${OWNED_RELAY_AGENT_PUBKEY}`, + ); + await expect(liveActivity).toBeVisible(); + await expect(liveActivity).toContainText("Live activity"); + await expect(liveActivity).toContainText("Open full activity"); +}); + test("typing indicator shows avatars and maintains stable name order", async ({ page, }) => { From e81b51cd108c88174121115da8994dca640ed7cf Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 30 Jun 2026 20:41:29 -0700 Subject: [PATCH 06/20] feat(profile): persist live activity embed after turn completes Derive profile embed scope from observer feed data so the pane stays mounted after turn_completed, matching full activity panel behavior without changing active-turn store semantics for working badges. - Add profileActivityFeedScope helper subscribing to observer + active-turn stores - Render embed when feed has content, not only during live turns - Switch header to "Recent activity" and drop elapsed timer after completion - Export syncAgentObserverEvents for test harness replay - Extend E2E seed helper with turn_completed and regression coverage Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../src/features/agents/observerRelayStore.ts | 13 ++ .../profile/lib/profileActivityFeedScope.ts | 188 ++++++++++++++++++ .../profile/ui/UserProfilePanelTabs.tsx | 71 ++++--- desktop/src/testing/e2eBridge.ts | 27 +-- desktop/tests/e2e/channels.spec.ts | 28 +++ 5 files changed, 291 insertions(+), 36 deletions(-) create mode 100644 desktop/src/features/profile/lib/profileActivityFeedScope.ts diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 6aa397edb..25152d8b4 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -424,6 +424,19 @@ export function useManagedAgentObserverBridge( }, [queryClient]); } +/** + * Synchronize the observer store with a sorted buffer of events for one agent. + * Used by test harnesses and replay bridges that already hold decoded frames. + */ +export function syncAgentObserverEvents( + agentPubkey: string, + events: ObserverEvent[], +) { + for (const event of events) { + appendAgentEvent(agentPubkey, event); + } +} + export function resetAgentObserverStore() { generation += 1; const unsubscribe = unsubscribeRelay; diff --git a/desktop/src/features/profile/lib/profileActivityFeedScope.ts b/desktop/src/features/profile/lib/profileActivityFeedScope.ts new file mode 100644 index 000000000..8f0857bcf --- /dev/null +++ b/desktop/src/features/profile/lib/profileActivityFeedScope.ts @@ -0,0 +1,188 @@ +import * as React from "react"; + +import type { ActiveTurnSummary } from "@/features/agents/activeAgentTurnsStore"; +import { subscribeActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; +import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; +import { + getAgentObserverSnapshot, + getAgentTranscript, + subscribeAgentObserverStore, +} from "@/features/agents/observerRelayStore"; +import type { + ObserverEvent, + TranscriptItem, +} from "@/features/agents/ui/agentSessionTypes"; +import type { ProfileActivityAgent } from "@/features/profile/lib/profileActivityAgent"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export type ProfileActivityFeedScope = { + /** Distinct channel ids to surface in the embed switcher. */ + channelIds: string[]; + /** Whether the observer feed has any events or transcript for this agent. */ + hasFeedContent: boolean; + /** True while the active-turn store reports live work for this agent. */ + isLive: boolean; + /** Preferred channel scope when no explicit selection exists yet. */ + preferredChannelId: string | null; +}; + +const cachedScopes = new Map(); + +function channelIdsEqual( + left: readonly string[], + right: readonly string[], +): boolean { + if (left.length !== right.length) { + return false; + } + + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) { + return false; + } + } + + return true; +} + +function scopesEqual( + left: ProfileActivityFeedScope, + right: ProfileActivityFeedScope, +): boolean { + return ( + left.hasFeedContent === right.hasFeedContent && + left.isLive === right.isLive && + left.preferredChannelId === right.preferredChannelId && + channelIdsEqual(left.channelIds, right.channelIds) + ); +} + +function stableFeedScope( + cacheKey: string, + next: ProfileActivityFeedScope, +): ProfileActivityFeedScope { + const cached = cachedScopes.get(cacheKey); + if (cached && scopesEqual(cached, next)) { + return cached; + } + + cachedScopes.set(cacheKey, next); + return next; +} + +function collectChannelIdsFromFeed( + events: readonly ObserverEvent[], + transcript: readonly TranscriptItem[], +): string[] { + const channelIds = new Set(); + for (const event of events) { + if (event.channelId) { + channelIds.add(event.channelId); + } + } + for (const item of transcript) { + if (item.channelId) { + channelIds.add(item.channelId); + } + } + return [...channelIds].sort((left, right) => left.localeCompare(right)); +} + +function deriveLatestChannelId( + events: readonly ObserverEvent[], + transcript: readonly TranscriptItem[], +): string | null { + for (let index = transcript.length - 1; index >= 0; index -= 1) { + const channelId = transcript[index]?.channelId; + if (channelId) { + return channelId; + } + } + + for (let index = events.length - 1; index >= 0; index -= 1) { + const channelId = events[index]?.channelId; + if (channelId) { + return channelId; + } + } + + return null; +} + +export function deriveProfileActivityFeedScope({ + activeTurns, + events, + transcript, +}: { + activeTurns: readonly ActiveTurnSummary[]; + events: readonly ObserverEvent[]; + transcript: readonly TranscriptItem[]; +}): ProfileActivityFeedScope { + const hasFeedContent = events.length > 0 || transcript.length > 0; + const isLive = activeTurns.length > 0; + + if (isLive) { + const channelIds = [...activeTurns] + .map((turn) => turn.channelId) + .sort((left, right) => left.localeCompare(right)); + + return { + channelIds, + hasFeedContent: true, + isLive: true, + preferredChannelId: channelIds[0] ?? null, + }; + } + + const feedChannelIds = collectChannelIdsFromFeed(events, transcript); + const latestChannelId = deriveLatestChannelId(events, transcript); + + return { + channelIds: feedChannelIds, + hasFeedContent, + isLive: false, + preferredChannelId: latestChannelId, + }; +} + +export function useProfileActivityFeedScope( + activityAgent: ProfileActivityAgent | null, + activeTurns: readonly ActiveTurnSummary[], +): ProfileActivityFeedScope { + const agentCacheKey = activityAgent + ? normalizePubkey(activityAgent.pubkey) + : "none"; + const hasObserver = + activityAgent !== null && isManagedAgentActive(activityAgent); + + const getSnapshot = React.useCallback(() => { + if (!activityAgent || !hasObserver) { + return stableFeedScope( + agentCacheKey, + deriveProfileActivityFeedScope({ + activeTurns, + events: [], + transcript: [], + }), + ); + } + + const { events } = getAgentObserverSnapshot(activityAgent.pubkey, true); + const transcript = getAgentTranscript(activityAgent.pubkey, true); + return stableFeedScope( + agentCacheKey, + deriveProfileActivityFeedScope({ activeTurns, events, transcript }), + ); + }, [activeTurns, activityAgent, agentCacheKey, hasObserver]); + + const snapshot = React.useSyncExternalStore((onStoreChange) => { + const unsubscribeObserver = subscribeAgentObserverStore(onStoreChange); + const unsubscribeTurns = subscribeActiveAgentTurns(onStoreChange); + return () => { + unsubscribeObserver(); + unsubscribeTurns(); + }; + }, getSnapshot); + + return snapshot; +} diff --git a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx index 8dadde39f..32c6a5b73 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx @@ -10,6 +10,10 @@ import { AgentInstructionRow, } from "@/features/profile/ui/UserProfilePanelAgentDetails"; import type { ProfileActivityAgent } from "@/features/profile/lib/profileActivityAgent"; +import { + type ProfileActivityFeedScope, + useProfileActivityFeedScope, +} from "@/features/profile/lib/profileActivityFeedScope"; import { type ProfileField, ProfileFieldGroup, @@ -292,7 +296,9 @@ export function ProfileInfoTabContent({ ] : agentInfoFields; const hasInfoFields = infoFields.length > 0; - const showLiveActivityEmbed = showActivityIngress && activeTurns.length > 0; + const feedScope = useProfileActivityFeedScope(activityAgent, activeTurns); + const showLiveActivityEmbed = + showActivityIngress && (feedScope.isLive || feedScope.hasFeedContent); if (!hasInfoFields && !showActivityIngress) { return null; @@ -306,6 +312,7 @@ export function ProfileInfoTabContent({ activeTurns={activeTurns} activityAgent={activityAgent} channelIdToName={channelIdToName} + feedScope={feedScope} onOpenActivity={onOpenActivity} /> ) : ( @@ -327,35 +334,42 @@ function ProfileLiveActivityEmbed({ activeTurns, activityAgent, channelIdToName, + feedScope, onOpenActivity, }: { activeTurns: ActiveTurnSummary[]; activityAgent: ProfileActivityAgent; channelIdToName: Record; + feedScope: ProfileActivityFeedScope; onOpenActivity: (channelId?: string | null) => void; }) { const [selectedChannelId, setSelectedChannelId] = React.useState< string | null - >(() => activeTurns[0]?.channelId ?? null); - const now = useNow(1000); + >(() => feedScope.preferredChannelId); + const now = useNow(feedScope.isLive ? 1000 : 86_400_000); + + const switcherChannelIds = feedScope.isLive + ? activeTurns.map((turn) => turn.channelId) + : feedScope.channelIds; React.useEffect(() => { - if (activeTurns.length === 0) { - setSelectedChannelId(null); + if (selectedChannelId && switcherChannelIds.includes(selectedChannelId)) { return; } - if (!activeTurns.some((turn) => turn.channelId === selectedChannelId)) { - setSelectedChannelId(activeTurns[0]?.channelId ?? null); - } - }, [activeTurns, selectedChannelId]); + setSelectedChannelId( + feedScope.preferredChannelId ?? switcherChannelIds[0] ?? null, + ); + }, [feedScope.preferredChannelId, selectedChannelId, switcherChannelIds]); - const selectedTurn = - activeTurns.find((turn) => turn.channelId === selectedChannelId) ?? - activeTurns[0] ?? - null; - const activeChannelId = selectedTurn?.channelId ?? null; - const showSwitcher = activeTurns.length > 1; + const selectedTurn = feedScope.isLive + ? (activeTurns.find((turn) => turn.channelId === selectedChannelId) ?? + activeTurns[0] ?? + null) + : null; + const activeChannelId = + selectedChannelId ?? feedScope.preferredChannelId ?? null; + const showSwitcher = switcherChannelIds.length > 1; return (
- {activeTurns.map((turn) => { - const isSelected = turn.channelId === activeChannelId; - const channelName = - channelIdToName[turn.channelId] ?? turn.channelId; + {switcherChannelIds.map((channelId) => { + const isSelected = channelId === activeChannelId; + const channelName = channelIdToName[channelId] ?? channelId; return (
@@ -431,15 +450,19 @@ function ProfileLiveActivityEmbed({ function LiveActivityEmbedHeader({ activeChannelId, elapsedLabel, + isLive, onOpenActivity, }: { activeChannelId: string | null; elapsedLabel: string | null; + isLive: boolean; onOpenActivity: (channelId?: string | null) => void; }) { return (
- Live activity + + {isLive ? "Live activity" : "Recent activity"} +
{elapsedLabel ? ( {elapsedLabel} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index e4c6b9933..8c5d9ca88 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -8,6 +8,7 @@ import { relayClient } from "@/shared/api/relayClient"; import type { ConnectionState } from "@/shared/api/relayClientShared"; import type { RelayEvent } from "@/shared/api/types"; import { syncAgentTurnsFromEvents } from "@/features/agents/activeAgentTurnsStore"; +import { syncAgentObserverEvents } from "@/features/agents/observerRelayStore"; import { CUSTOM_EMOJI_SET_D_TAG, KIND_EMOJI_SET, @@ -662,6 +663,7 @@ declare global { agentPubkey: string; channelId: string; turnId: string; + kind?: "turn_started" | "turn_completed"; }) => void; __BUZZ_E2E_EMIT_MOCK_READ_STATE__?: (input: { clientId: string; @@ -6927,20 +6929,21 @@ export function maybeInstallE2eTauriMocks() { agentPubkey, channelId, turnId, + kind = "turn_started", }) => { seedTurnSeq += 1; - syncAgentTurnsFromEvents(agentPubkey, [ - { - seq: seedTurnSeq, - timestamp: new Date().toISOString(), - kind: "turn_started", - agentIndex: 0, - channelId, - sessionId: null, - turnId, - payload: null, - }, - ]); + const event = { + seq: seedTurnSeq, + timestamp: new Date().toISOString(), + kind, + agentIndex: 0, + channelId, + sessionId: null, + turnId, + payload: null, + }; + syncAgentTurnsFromEvents(agentPubkey, [event]); + syncAgentObserverEvents(agentPubkey, [event]); }; const meshNodeStatus = ( state: "off" | "running", diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 765710456..ae2fbdec7 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -22,6 +22,7 @@ type MockFeedWindow = Window & { agentPubkey: string; channelId: string; turnId: string; + kind?: "turn_started" | "turn_completed"; }) => void; __BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: { category: "mention" | "needs_action" | "activity" | "agent_activity"; @@ -1066,6 +1067,33 @@ test("profile renders live activity for a viewer-owned relay agent", async ({ await expect(liveActivity).toBeVisible(); await expect(liveActivity).toContainText("Live activity"); await expect(liveActivity).toContainText("Open full activity"); + + await page.evaluate( + ({ agentPubkey, channelId, turnId }) => { + const seedActiveTurns = (window as MockFeedWindow) + .__BUZZ_E2E_SEED_ACTIVE_TURNS__; + if (!seedActiveTurns) { + throw new Error("Mock active-turn helper is not installed."); + } + seedActiveTurns({ + agentPubkey, + channelId, + turnId, + kind: "turn_completed", + }); + }, + { + agentPubkey: OWNED_RELAY_AGENT_PUBKEY, + channelId: AGENTS_CHANNEL_ID, + turnId: "owned-relay-profile-turn", + }, + ); + + await expect(liveActivity).toBeVisible(); + await expect(liveActivity).toContainText("Recent activity"); + await expect( + page.getByTestId(`user-profile-view-activity-${OWNED_RELAY_AGENT_PUBKEY}`), + ).not.toBeVisible(); }); test("typing indicator shows avatars and maintains stable name order", async ({ From b046319f3e757d95c80d4687ff72493d08e7841c Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 30 Jun 2026 20:56:44 -0700 Subject: [PATCH 07/20] feat(profile): make activity preview an ingress - Turn the embedded profile activity preview into a full-card ingress that opens the full activity feed scoped to the currently selected channel. - Replace the explicit open-full label with a flattened accent pill that displays friendly last-live relative time such as "Just now", "1m ago", and "1h ago". - Track latest activity timestamps per channel so persisted and live previews can render accurate last-live labels. - Disable embedded transcript row pointer events and auto-tail the compact transcript so preview clicks route to the activity feed while new turns stay visible. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../agents/ui/AgentSessionTranscriptList.tsx | 8 ++ .../profile/lib/profileActivityFeedScope.ts | 79 ++++++++++ .../profile/ui/UserProfilePanelTabs.tsx | 136 +++++++++++------- 3 files changed, 168 insertions(+), 55 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index f3d7b92cc..94743355e 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -94,6 +94,14 @@ export function AgentSessionTranscriptList({ scrollContainerRef, }); + React.useLayoutEffect(() => { + if (!autoTail || items.length === 0) { + return; + } + + anchoredScroll.scrollToBottom("auto"); + }, [anchoredScroll.scrollToBottom, autoTail, items]); + if (items.length === 0) { return (
diff --git a/desktop/src/features/profile/lib/profileActivityFeedScope.ts b/desktop/src/features/profile/lib/profileActivityFeedScope.ts index 8f0857bcf..596c04ec9 100644 --- a/desktop/src/features/profile/lib/profileActivityFeedScope.ts +++ b/desktop/src/features/profile/lib/profileActivityFeedScope.ts @@ -22,6 +22,8 @@ export type ProfileActivityFeedScope = { hasFeedContent: boolean; /** True while the active-turn store reports live work for this agent. */ isLive: boolean; + /** Latest observed activity timestamp, keyed by channel id. */ + latestActivityAtByChannel: Record; /** Preferred channel scope when no explicit selection exists yet. */ preferredChannelId: string | null; }; @@ -53,10 +55,33 @@ function scopesEqual( left.hasFeedContent === right.hasFeedContent && left.isLive === right.isLive && left.preferredChannelId === right.preferredChannelId && + latestActivityByChannelEqual( + left.latestActivityAtByChannel, + right.latestActivityAtByChannel, + ) && channelIdsEqual(left.channelIds, right.channelIds) ); } +function latestActivityByChannelEqual( + left: Record, + right: Record, +): boolean { + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) { + return false; + } + + for (const key of leftKeys) { + if (left[key] !== right[key]) { + return false; + } + } + + return true; +} + function stableFeedScope( cacheKey: string, next: ProfileActivityFeedScope, @@ -109,6 +134,53 @@ function deriveLatestChannelId( return null; } +function parseTimestampMillis(timestamp: string): number | null { + const millis = Date.parse(timestamp); + return Number.isNaN(millis) ? null : millis; +} + +function collectLatestActivityAtByChannel({ + activeTurns, + events, + transcript, +}: { + activeTurns: readonly ActiveTurnSummary[]; + events: readonly ObserverEvent[]; + transcript: readonly TranscriptItem[]; +}): Record { + const latestActivityAtByChannel: Record = {}; + + const record = (channelId: string | null | undefined, timestamp: number) => { + if (!channelId) { + return; + } + const previous = latestActivityAtByChannel[channelId]; + if (previous === undefined || timestamp > previous) { + latestActivityAtByChannel[channelId] = timestamp; + } + }; + + for (const turn of activeTurns) { + record(turn.channelId, turn.anchorAt); + } + + for (const event of events) { + const timestamp = parseTimestampMillis(event.timestamp); + if (timestamp !== null) { + record(event.channelId, timestamp); + } + } + + for (const item of transcript) { + const timestamp = parseTimestampMillis(item.timestamp); + if (timestamp !== null) { + record(item.channelId, timestamp); + } + } + + return latestActivityAtByChannel; +} + export function deriveProfileActivityFeedScope({ activeTurns, events, @@ -120,6 +192,11 @@ export function deriveProfileActivityFeedScope({ }): ProfileActivityFeedScope { const hasFeedContent = events.length > 0 || transcript.length > 0; const isLive = activeTurns.length > 0; + const latestActivityAtByChannel = collectLatestActivityAtByChannel({ + activeTurns, + events, + transcript, + }); if (isLive) { const channelIds = [...activeTurns] @@ -130,6 +207,7 @@ export function deriveProfileActivityFeedScope({ channelIds, hasFeedContent: true, isLive: true, + latestActivityAtByChannel, preferredChannelId: channelIds[0] ?? null, }; } @@ -141,6 +219,7 @@ export function deriveProfileActivityFeedScope({ channelIds: feedChannelIds, hasFeedContent, isLive: false, + latestActivityAtByChannel, preferredChannelId: latestChannelId, }; } diff --git a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx index 32c6a5b73..829c81ee4 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx @@ -4,7 +4,6 @@ import { Activity, Archive, ChevronRight, Info, Wrench } from "lucide-react"; import type { ActiveTurnSummary } from "@/features/agents/activeAgentTurnsStore"; import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel"; -import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; import { AgentDetailsRows, AgentInstructionRow, @@ -346,7 +345,7 @@ function ProfileLiveActivityEmbed({ const [selectedChannelId, setSelectedChannelId] = React.useState< string | null >(() => feedScope.preferredChannelId); - const now = useNow(feedScope.isLive ? 1000 : 86_400_000); + const now = useNow(1000); const switcherChannelIds = feedScope.isLive ? activeTurns.map((turn) => turn.channelId) @@ -370,28 +369,39 @@ function ProfileLiveActivityEmbed({ const activeChannelId = selectedChannelId ?? feedScope.preferredChannelId ?? null; const showSwitcher = switcherChannelIds.length > 1; + const lastLiveAt = + (activeChannelId + ? feedScope.latestActivityAtByChannel[activeChannelId] + : undefined) ?? + selectedTurn?.anchorAt ?? + null; + const lastLiveLabel = formatLastLiveLabel(lastLiveAt, now); + const openSelectedActivity = React.useCallback(() => { + onOpenActivity(activeChannelId); + }, [activeChannelId, onOpenActivity]); return (
+ -
-
+ ); } +function formatLastLiveLabel(timestamp: number | null, now: number): string { + if (timestamp === null) { + return "No activity yet"; + } + + const elapsedMs = Math.max(0, now - timestamp); + const totalSeconds = Math.floor(elapsedMs / 1000); + if (totalSeconds < 60) { + return "Just now"; + } + + const totalMinutes = Math.floor(totalSeconds / 60); + if (totalMinutes < 60) { + return `${totalMinutes}m ago`; + } + + const totalHours = Math.floor(totalMinutes / 60); + if (totalHours < 24) { + return `${totalHours}h ago`; + } + + const totalDays = Math.floor(totalHours / 24); + if (totalDays < 7) { + return `${totalDays}d ago`; + } + + const totalWeeks = Math.floor(totalDays / 7); + return `${totalWeeks}w ago`; +} + function ArchiveStatusTooltip() { return ( From 037d28d27345d81f1aac29ff3e1da65bb5088a6a Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 30 Jun 2026 22:07:21 -0700 Subject: [PATCH 08/20] fix(profile): polish activity preview layout - Tighten the embedded activity preview so auto-tailed transcripts use a real flex height chain and include scrollable bottom breathing room. - Flatten compact preview message rendering by adding data-role hooks for assistant and user message shells, avatars, and bubbles. - Scope compact preview typography and line-height overrides so embedded activity rows read denser without changing the full activity feed. - Restyle the preview surface with a muted fill, top and bottom gradient overlays, a Latest Activity footer label, and a floating last-live pill. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../agents/ui/AgentSessionTranscriptList.tsx | 2 +- .../agents/ui/ManagedAgentSessionPanel.tsx | 2 ++ .../activityRenderClasses/MessageActivity.tsx | 16 ++++++++++++--- .../UserMessageBubble.tsx | 3 +++ .../profile/ui/UserProfilePanelTabs.tsx | 20 +++++++++++++------ 5 files changed, 33 insertions(+), 10 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 94743355e..bd074568e 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -121,7 +121,7 @@ export function AgentSessionTranscriptList({
diff --git a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx index 90c21b938..be2f528be 100644 --- a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx @@ -91,6 +91,7 @@ export function ManagedAgentSessionPanel({
@@ -223,6 +224,7 @@ function SessionBody({ rawRail.mode === "side" ? "mt-4 grid gap-4 xl:grid-cols-[minmax(0,1fr)_20rem]" : "mt-0", + autoTail && "min-h-0 flex-1 overflow-hidden", )} > -
-
+
+
-
+
diff --git a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx index a49cec218..462a6c448 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx @@ -45,6 +45,7 @@ export function UserMessageBubble({ @@ -53,12 +54,14 @@ export function UserMessageBubble({ "group relative flex max-w-[85%] min-w-0 flex-col items-end gap-1", className, )} + data-role="user-message-shell" >
{children} diff --git a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx index 829c81ee4..b7e461987 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx @@ -383,7 +383,7 @@ function ProfileLiveActivityEmbed({ return (
); } @@ -462,7 +470,7 @@ function LiveActivityOpenButton({ return (
); @@ -176,6 +180,7 @@ function SessionBody({ rawLayout, showRaw, transcript, + transcriptVariant, }: { agentAvatarUrl: string | null; agentName: string; @@ -192,6 +197,7 @@ function SessionBody({ rawLayout: "responsive" | "exclusive"; showRaw: boolean; transcript: TranscriptItem[]; + transcriptVariant: AgentSessionTranscriptVariant; }) { const rawRail = resolveRawRailLayout(showRaw, rawLayout); @@ -236,6 +242,7 @@ function SessionBody({ profiles={profiles} scrollScopeKey={`${agentPubkey}:${channelId ?? "all"}`} autoTail={autoTail} + variant={transcriptVariant} /> {rawRail.mode === "side" ? : null}
diff --git a/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx index d2aa84d97..eb1289d68 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx @@ -5,6 +5,8 @@ import { import { normalizePubkey } from "@/shared/lib/pubkey"; import { Markdown } from "@/shared/ui/markdown"; import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { cn } from "@/shared/lib/cn"; +import { useAgentSessionTranscriptVariant } from "../agentSessionTranscriptContext"; import type { TranscriptItem } from "../agentSessionTypes"; import { ToolActivity } from "./ToolActivity"; import { TranscriptTimestamp } from "./TranscriptTimestamp"; @@ -43,6 +45,8 @@ function MessageItem({ item: Extract; profiles?: UserProfileLookup; }) { + const variant = useAgentSessionTranscriptVariant(); + const isCompactPreview = variant === "compactPreview"; const isAssistant = item.role === "assistant"; const text = item.text.trim(); const messageLink = getTranscriptMessageLink(item); @@ -76,35 +80,35 @@ function MessageItem({ data-role="assistant-message" data-testid="transcript-assistant-message" > -
+
+ {isCompactPreview ? null : ( +
+ + + {assistantLabel} + + +
+ )}
- - - {assistantLabel} - - -
-
-
diff --git a/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx index 3f7283312..5f27b3265 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx @@ -38,10 +38,7 @@ export function PlanActivity(props: ActivityRenderClassItemProps) { > - + ); diff --git a/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx index fab78de10..53e94489b 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx @@ -23,7 +23,10 @@ export function ThoughtActivity(props: ActivityRenderClassItemProps) { > - + ); diff --git a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx index 462a6c448..600c030cd 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx @@ -7,6 +7,7 @@ import { import { cn } from "@/shared/lib/cn"; import { Markdown } from "@/shared/ui/markdown"; import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { useAgentSessionTranscriptVariant } from "../agentSessionTranscriptContext"; import type { TranscriptItem } from "../agentSessionTypes"; export function UserMessageBubble({ @@ -24,6 +25,8 @@ export function UserMessageBubble({ item: Extract; profiles?: UserProfileLookup; }) { + const variant = useAgentSessionTranscriptVariant(); + const isCompactPreview = variant === "compactPreview"; const text = item.text.trim(); const authorProfile = item.authorPubkey ? profiles?.[item.authorPubkey.toLowerCase()] @@ -38,32 +41,41 @@ export function UserMessageBubble({ return (
- + {isCompactPreview ? null : ( + + )}
- + {children}
{footer} diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptContext.ts b/desktop/src/features/agents/ui/agentSessionTranscriptContext.ts new file mode 100644 index 000000000..fd5554390 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionTranscriptContext.ts @@ -0,0 +1,13 @@ +import * as React from "react"; + +export type AgentSessionTranscriptVariant = "default" | "compactPreview"; + +const AgentSessionTranscriptVariantContext = + React.createContext("default"); + +export const AgentSessionTranscriptVariantProvider = + AgentSessionTranscriptVariantContext.Provider; + +export function useAgentSessionTranscriptVariant() { + return React.useContext(AgentSessionTranscriptVariantContext); +} diff --git a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx index 7b801d179..43008462b 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx @@ -437,11 +437,12 @@ function ProfileLiveActivityEmbed({ agent={activityAgent} autoTail={true} channelId={activeChannelId} - className="relative z-0 min-h-0 flex-1 border-0 bg-transparent px-4 py-0 text-xs shadow-none **:data-message-id:pointer-events-none **:data-[role=assistant-message-body]:max-w-full! **:data-[role=assistant-message-shell]:max-w-full! **:data-[role=user-message]:justify-start! **:data-[role=user-message-avatar]:hidden **:data-[role=user-message-bubble]:rounded-none! **:data-[role=user-message-bubble]:bg-transparent! **:data-[role=user-message-bubble]:p-0! **:data-[role=user-message-shell]:max-w-full! **:data-[role=user-message-shell]:items-start! [&_[data-role=assistant-message]_*]:text-xs [&_[data-role=assistant-message]_*]:leading-4 [&_[data-role=user-message]_*]:text-xs [&_[data-role=user-message]_*]:leading-4" + className="relative z-0 min-h-0 flex-1 border-0 bg-transparent px-4 py-0 text-xs shadow-none **:data-message-id:pointer-events-none" emptyDescription="Live activity will appear here." rawLayout="responsive" showHeader={false} showRaw={false} + transcriptVariant="compactPreview" />
@@ -196,7 +196,7 @@ function TranscriptDisplayBlockView({ return (
diff --git a/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx index eb1289d68..3d3ad3531 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx @@ -1,19 +1,12 @@ -import { - resolveUserLabel, - type UserProfileLookup, -} from "@/features/profile/lib/identity"; -import { normalizePubkey } from "@/shared/lib/pubkey"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { Markdown } from "@/shared/ui/markdown"; -import { UserAvatar } from "@/shared/ui/UserAvatar"; import { cn } from "@/shared/lib/cn"; import { useAgentSessionTranscriptVariant } from "../agentSessionTranscriptContext"; +import { formatTranscriptTimestampTitle } from "../agentSessionUtils"; import type { TranscriptItem } from "../agentSessionTypes"; import { ToolActivity } from "./ToolActivity"; import { TranscriptTimestamp } from "./TranscriptTimestamp"; -import type { - ActivityRenderClassItemProps, - AgentTranscriptIdentityProps, -} from "./types"; +import type { ActivityRenderClassItemProps } from "./types"; import { UserMessageBubble } from "./UserMessageBubble"; export function MessageActivity(props: ActivityRenderClassItemProps) { @@ -24,24 +17,13 @@ export function MessageActivity(props: ActivityRenderClassItemProps) { return null; } - return ( - - ); + return ; } function MessageItem({ - agentAvatarUrl, - agentName, - agentPubkey, item, profiles, -}: AgentTranscriptIdentityProps & { +}: { item: Extract; profiles?: UserProfileLookup; }) { @@ -50,14 +32,6 @@ function MessageItem({ const isAssistant = item.role === "assistant"; const text = item.text.trim(); const messageLink = getTranscriptMessageLink(item); - const agentProfile = profiles?.[normalizePubkey(agentPubkey)] ?? null; - const assistantLabel = resolveUserLabel({ - pubkey: agentPubkey, - fallbackName: agentName, - profiles, - preferResolvedSelfLabel: true, - }); - const assistantAvatarUrl = agentProfile?.avatarUrl ?? agentAvatarUrl; if (!isAssistant) { return ( @@ -81,32 +55,15 @@ function MessageItem({ data-testid="transcript-assistant-message" >
- {isCompactPreview ? null : ( -
- - - {assistantLabel} - - -
- )}
diff --git a/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx index 5f27b3265..334a1b080 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx @@ -38,7 +38,10 @@ export function PlanActivity(props: ActivityRenderClassItemProps) { > - + ); diff --git a/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx index 53e94489b..8dfaea86e 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx @@ -22,9 +22,9 @@ export function ThoughtActivity(props: ActivityRenderClassItemProps) { title={formatTranscriptTimestampTitle(props.item.timestamp)} > - + diff --git a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx index 600c030cd..3b3e60a17 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx @@ -72,7 +72,7 @@ export function UserMessageBubble({ )} > From 67be8850c0ef2765ee4a2a8719d935ccdcaec0da Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 30 Jun 2026 23:34:35 -0700 Subject: [PATCH 12/20] feat(agents): polish runtime activity message cards - Restyle runtime activity user messages as transparent bordered cards and agent sent-message cards as muted bubbles. - Add overflow-aware max-height clamping with matching bottom fades for clipped transcript cards. - Open source channel messages when clicking non-compact transcript bubbles that have message links. - Make non-compact user and agent avatars open the matching profile panel while keeping compact preview avatars passive. - Pass the agent pubkey through compact message summaries so avatar profile navigation can target the right agent. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../CompactMessageSummary.tsx | 135 ++++++++++++++++-- .../ui/AgentSessionToolItem/ToolItem.tsx | 1 + .../UserMessageBubble.tsx | 101 ++++++++++++- .../useTranscriptBubbleOverflow.ts | 34 +++++ 4 files changed, 254 insertions(+), 17 deletions(-) create mode 100644 desktop/src/features/agents/ui/activityRenderClasses/useTranscriptBubbleOverflow.ts diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx index 790c15fb3..f2047ebf7 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx @@ -1,11 +1,14 @@ import * as React from "react"; import { CheckCheck } from "lucide-react"; +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { cn } from "@/shared/lib/cn"; +import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; import { Markdown } from "@/shared/ui/markdown"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { useAgentSessionTranscriptVariant } from "../agentSessionTranscriptContext"; import { TranscriptTimestamp } from "../activityRenderClasses/TranscriptTimestamp"; +import { useTranscriptBubbleOverflow } from "../activityRenderClasses/useTranscriptBubbleOverflow"; import { compactSummaryTone } from "./CompactToolSummaryRow"; import type { SentMessageLink } from "./messageLinks"; import { SentMessageContextDialog } from "./SentMessageContextDialog"; @@ -22,6 +25,7 @@ export function CompactMessageSummary({ label, messageLink, preview, + pubkey, result, timestamp, }: { @@ -36,38 +40,136 @@ export function CompactMessageSummary({ label: string; messageLink: SentMessageLink | null; preview: string | null; + pubkey: string; result: string; timestamp: string; }) { const [detailsOpen, setDetailsOpen] = React.useState(false); const variant = useAgentSessionTranscriptVariant(); + const { goChannel } = useAppNavigation(); + const { openProfilePanel } = useProfilePanel(); const isCompactPreview = variant === "compactPreview"; + const shouldClampBubble = !isCompactPreview; + const [bubbleRef, hasBubbleOverflow] = + useTranscriptBubbleOverflow(shouldClampBubble); + const canOpenMessage = shouldClampBubble && messageLink !== null; const mutedTone = compactSummaryTone(); + const avatarClassName = cn( + "mr-2 mt-1 shrink-0", + isCompactPreview ? "size-5" : "size-7", + ); + const handleBubbleClick = React.useCallback( + (event: React.MouseEvent) => { + if (!messageLink || isNestedInteractiveTarget(event)) return; + event.preventDefault(); + event.stopPropagation(); + void goChannel(messageLink.channelId, { + messageId: messageLink.messageId, + }); + }, + [goChannel, messageLink], + ); + const handleBubbleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if ( + !messageLink || + isNestedInteractiveTarget(event) || + (event.key !== "Enter" && event.key !== " ") + ) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + void goChannel(messageLink.channelId, { + messageId: messageLink.messageId, + }); + }, + [goChannel, messageLink], + ); + const bubbleLinkProps = canOpenMessage + ? { + onClick: handleBubbleClick, + onKeyDown: handleBubbleKeyDown, + role: "link" as const, + tabIndex: 0, + } + : {}; return ( <>
- + {openProfilePanel && !isCompactPreview ? ( + + ) : ( + + )}
+ {hasBubbleOverflow ? ( + + ) : null}
); } + +function isNestedInteractiveTarget( + event: React.MouseEvent | React.KeyboardEvent, +) { + const target = + event.target instanceof Element + ? event.target.closest( + "a,button,input,select,textarea,summary,[role='button'],[role='link']", + ) + : null; + + return target !== null && target !== event.currentTarget; +} diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx index 5b4c7e4fb..6a42dc3f9 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx @@ -77,6 +77,7 @@ export function ToolItem({ label={compactSummary.label} messageLink={messageLink} preview={compactSummary.preview} + pubkey={agentPubkey} result={item.result} timestamp={item.timestamp} /> diff --git a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx index 3b3e60a17..6c80daa8b 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx @@ -1,14 +1,17 @@ -import type * as React from "react"; +import * as React from "react"; import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { cn } from "@/shared/lib/cn"; +import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; import { Markdown } from "@/shared/ui/markdown"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { useAgentSessionTranscriptVariant } from "../agentSessionTranscriptContext"; import type { TranscriptItem } from "../agentSessionTypes"; +import { useTranscriptBubbleOverflow } from "./useTranscriptBubbleOverflow"; export function UserMessageBubble({ bubbleClassName, @@ -26,8 +29,17 @@ export function UserMessageBubble({ profiles?: UserProfileLookup; }) { const variant = useAgentSessionTranscriptVariant(); + const { goChannel } = useAppNavigation(); + const { openProfilePanel } = useProfilePanel(); const isCompactPreview = variant === "compactPreview"; + const shouldClampBubble = !isCompactPreview; + const [bubbleRef, hasBubbleOverflow] = + useTranscriptBubbleOverflow(shouldClampBubble); const text = item.text.trim(); + const messageLink = + shouldClampBubble && item.channelId && item.messageId + ? { channelId: item.channelId, messageId: item.messageId } + : null; const authorProfile = item.authorPubkey ? profiles?.[item.authorPubkey.toLowerCase()] : null; @@ -38,6 +50,43 @@ export function UserMessageBubble({ profiles, }) : item.title || "User"; + const handleBubbleClick = React.useCallback( + (event: React.MouseEvent) => { + if (!messageLink || isNestedInteractiveTarget(event)) return; + event.preventDefault(); + event.stopPropagation(); + void goChannel(messageLink.channelId, { + messageId: messageLink.messageId, + }); + }, + [goChannel, messageLink], + ); + const handleBubbleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if ( + !messageLink || + isNestedInteractiveTarget(event) || + (event.key !== "Enter" && event.key !== " ") + ) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + void goChannel(messageLink.channelId, { + messageId: messageLink.messageId, + }); + }, + [goChannel, messageLink], + ); + const bubbleLinkProps = messageLink + ? { + onClick: handleBubbleClick, + onKeyDown: handleBubbleKeyDown, + role: "link" as const, + tabIndex: 0, + } + : {}; return (
- {isCompactPreview ? null : ( + {isCompactPreview ? null : item.authorPubkey && openProfilePanel ? ( + + ) : ( )}
{children} + {hasBubbleOverflow ? ( + + ) : null}
{footer}
); } + +function isNestedInteractiveTarget( + event: React.MouseEvent | React.KeyboardEvent, +) { + const target = + event.target instanceof Element + ? event.target.closest( + "a,button,input,select,textarea,summary,[role='button'],[role='link']", + ) + : null; + + return target !== null && target !== event.currentTarget; +} diff --git a/desktop/src/features/agents/ui/activityRenderClasses/useTranscriptBubbleOverflow.ts b/desktop/src/features/agents/ui/activityRenderClasses/useTranscriptBubbleOverflow.ts new file mode 100644 index 000000000..92d228827 --- /dev/null +++ b/desktop/src/features/agents/ui/activityRenderClasses/useTranscriptBubbleOverflow.ts @@ -0,0 +1,34 @@ +import * as React from "react"; + +export function useTranscriptBubbleOverflow(enabled: boolean) { + const ref = React.useRef(null); + const [hasOverflow, setHasOverflow] = React.useState(false); + + React.useLayoutEffect(() => { + const element = ref.current; + if (!enabled || !element) { + setHasOverflow(false); + return; + } + + const updateOverflow = () => { + setHasOverflow(element.scrollHeight > element.clientHeight + 1); + }; + + updateOverflow(); + + if (typeof ResizeObserver === "undefined") { + return; + } + + const observer = new ResizeObserver(updateOverflow); + observer.observe(element); + if (element.firstElementChild) { + observer.observe(element.firstElementChild); + } + + return () => observer.disconnect(); + }, [enabled]); + + return [ref, hasOverflow] as const; +} From 41fdb2a3e02dafa4dea51e051421f5a5182d5a8c Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 30 Jun 2026 23:38:19 -0700 Subject: [PATCH 13/20] fix(profile): expand compact activity preview - Increase the profile live activity embed from h-48 to h-56 so the compact transcript preview shows another h-8 of content. - Use tighter compact-preview transcript spacing for top-level rows and turn groups while preserving the full activity feed spacing. - Keep the existing preview ingress overlay and auto-tail behavior unchanged so clicks still open the full activity feed. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../agents/ui/AgentSessionTranscriptList.tsx | 14 ++++++++++++-- .../features/profile/ui/UserProfilePanelTabs.tsx | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 4571a608e..a049e3e02 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -16,6 +16,7 @@ import type { PromptSection, TranscriptItem } from "./agentSessionTypes"; import { AgentSessionTranscriptVariantProvider, type AgentSessionTranscriptVariant, + useAgentSessionTranscriptVariant, } from "./agentSessionTranscriptContext"; import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem"; import { @@ -118,6 +119,8 @@ export function AgentSessionTranscriptList({ ); } + const isCompactPreview = variant === "compactPreview"; + return (
@@ -182,6 +189,9 @@ function TranscriptDisplayBlockView({ block: TranscriptDisplayBlock; profiles?: UserProfileLookup; }) { + const variant = useAgentSessionTranscriptVariant(); + const isCompactPreview = variant === "compactPreview"; + if (block.kind === "single") { return ( diff --git a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx index 43008462b..f63f179a6 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx @@ -383,7 +383,7 @@ function ProfileLiveActivityEmbed({ return (
From 5abdd407955f611ee36f95d52505030ad5f6ae04 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 30 Jun 2026 23:49:34 -0700 Subject: [PATCH 15/20] fix(profile): refine activity preview label - Increase the Latest Activity overlay label to text-base and the scoped channel label to text-sm for better readability. - Expand the lower gradient overlay from h-28 to h-36 so the larger two-line label has enough contrast over transcript content. - Remove the top gradient overlay so the compact activity preview content remains unobscured above the footer label. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/src/features/profile/ui/UserProfilePanelTabs.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx index 920414242..f9cfbb278 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx @@ -451,15 +451,14 @@ function ProfileLiveActivityEmbed({ aria-hidden="true" className="pointer-events-none absolute inset-0 z-20" > -
-
+
- + Latest Activity {activeChannelName ? ( #{activeChannelName} From 13528eb91ffba4ef973459840f00f0448ad016b1 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 1 Jul 2026 00:05:13 -0700 Subject: [PATCH 16/20] feat(profile): add in-frame activity carousel with lazy channel slides Replace the profile activity channel chip row with an Embla carousel that switches channel previews inside the clipped embed card, with progress dots and a fixed overlay that updates from the active slide index. - Add shadcn-style Carousel wrapper and embla-carousel-react dependency - Refactor ProfileLiveActivityEmbed to one slide per channel with lazy-mounted ManagedAgentSessionPanel instances (data-mounted on each slide) - Keep overlay gradient, Latest Activity label, channel name, and dots static while only slide content translates within the rounded frame - Update channels E2E for current labels and add multi-channel dot switching coverage including lazy-mount assertions Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/package.json | 1 + .../profile/ui/UserProfilePanelTabs.tsx | 301 ++++++++++++++---- desktop/src/shared/ui/carousel.tsx | 265 +++++++++++++++ desktop/tests/e2e/channels.spec.ts | 83 ++++- pnpm-lock.yaml | 31 +- 5 files changed, 610 insertions(+), 71 deletions(-) create mode 100644 desktop/src/shared/ui/carousel.tsx diff --git a/desktop/package.json b/desktop/package.json index 64a8efdde..882711f72 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -59,6 +59,7 @@ "@tiptap/starter-kit": "^3.22.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "embla-carousel-react": "^8.6.0", "emoji-mart": "^5.6.0", "jdenticon": "^3.3.0", "lucide-react": "^1.0.0", diff --git a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx index f9cfbb278..fb7bf855e 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx @@ -23,6 +23,12 @@ import type { ManagedAgent } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { useNow } from "@/shared/lib/useNow"; import { Button } from "@/shared/ui/button"; +import { + Carousel, + type CarouselApi, + CarouselContent, + CarouselItem, +} from "@/shared/ui/carousel"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; export function ProfileIngressRow({ @@ -342,36 +348,100 @@ function ProfileLiveActivityEmbed({ feedScope: ProfileActivityFeedScope; onOpenActivity: (channelId?: string | null) => void; }) { - const [selectedChannelId, setSelectedChannelId] = React.useState< - string | null - >(() => feedScope.preferredChannelId); const now = useNow(1000); + const [carouselApi, setCarouselApi] = React.useState(); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const [mountedChannelIds, setMountedChannelIds] = React.useState>( + () => new Set(), + ); - const switcherChannelIds = feedScope.isLive - ? activeTurns.map((turn) => turn.channelId) - : feedScope.channelIds; + const slides = React.useMemo(() => { + const channelIds = feedScope.isLive + ? activeTurns.map((turn) => turn.channelId) + : feedScope.channelIds; + return [...new Set(channelIds)]; + }, [activeTurns, feedScope.channelIds, feedScope.isLive]); + + const preferredIndex = React.useMemo(() => { + const preferredChannelId = feedScope.preferredChannelId; + if (!preferredChannelId) { + return 0; + } + const index = slides.indexOf(preferredChannelId); + return index >= 0 ? index : 0; + }, [feedScope.preferredChannelId, slides]); React.useEffect(() => { - if (selectedChannelId && switcherChannelIds.includes(selectedChannelId)) { + if (slides.length === 0) { + setSelectedIndex(0); return; } - setSelectedChannelId( - feedScope.preferredChannelId ?? switcherChannelIds[0] ?? null, - ); - }, [feedScope.preferredChannelId, selectedChannelId, switcherChannelIds]); + setSelectedIndex((current) => { + if (current < slides.length) { + return current; + } + return Math.max(0, slides.length - 1); + }); + }, [slides.length]); + + React.useEffect(() => { + if (!carouselApi || slides.length === 0) { + return; + } + + const syncSelectedIndex = () => { + setSelectedIndex(carouselApi.selectedScrollSnap()); + }; + + syncSelectedIndex(); + carouselApi.on("select", syncSelectedIndex); + carouselApi.on("reInit", syncSelectedIndex); + + return () => { + carouselApi.off("select", syncSelectedIndex); + carouselApi.off("reInit", syncSelectedIndex); + }; + }, [carouselApi, slides.length]); + + React.useEffect(() => { + if (!carouselApi || slides.length === 0) { + return; + } + + const currentChannelId = slides[carouselApi.selectedScrollSnap()]; + if (currentChannelId && slides.includes(currentChannelId)) { + return; + } + + carouselApi.scrollTo(preferredIndex, true); + }, [carouselApi, preferredIndex, slides]); + + const activeChannelId = slides[selectedIndex] ?? slides[0] ?? null; + + React.useEffect(() => { + if (!activeChannelId) { + return; + } + + setMountedChannelIds((current) => { + if (current.has(activeChannelId)) { + return current; + } + const next = new Set(current); + next.add(activeChannelId); + return next; + }); + }, [activeChannelId]); const selectedTurn = feedScope.isLive - ? (activeTurns.find((turn) => turn.channelId === selectedChannelId) ?? + ? (activeTurns.find((turn) => turn.channelId === activeChannelId) ?? activeTurns[0] ?? null) : null; - const activeChannelId = - selectedChannelId ?? feedScope.preferredChannelId ?? null; const activeChannelName = activeChannelId ? (channelIdToName[activeChannelId] ?? activeChannelId) : null; - const showSwitcher = switcherChannelIds.length > 1; const lastLiveAt = (activeChannelId ? feedScope.latestActivityAtByChannel[activeChannelId] @@ -383,6 +453,56 @@ function ProfileLiveActivityEmbed({ onOpenActivity(activeChannelId); }, [activeChannelId, onOpenActivity]); + const handleDotSelect = React.useCallback( + (index: number) => { + carouselApi?.scrollTo(index); + }, + [carouselApi], + ); + + if (slides.length === 0) { + return ( +
+
+ ); + } + return (
- {showSwitcher ? ( -
-
- {switcherChannelIds.map((channelId) => { - const isSelected = channelId === activeChannelId; - const channelName = channelIdToName[channelId] ?? channelId; - - return ( - - ); - })} -
-
- ) : null} - -
); } +function ActivityCarouselDots({ + channelIdToName, + onSelect, + selectedIndex, + slides, +}: { + channelIdToName: Record; + onSelect: (index: number) => void; + selectedIndex: number; + slides: string[]; +}) { + if (slides.length === 0) { + return null; + } + + return ( +
+ {slides.map((channelId, index) => { + const isSelected = index === selectedIndex; + const channelName = channelIdToName[channelId] ?? channelId; + + return ( +
+ ); +} + function LiveActivityOpenButton({ activeChannelId, label, diff --git a/desktop/src/shared/ui/carousel.tsx b/desktop/src/shared/ui/carousel.tsx new file mode 100644 index 000000000..fc31f4e2d --- /dev/null +++ b/desktop/src/shared/ui/carousel.tsx @@ -0,0 +1,265 @@ +import * as React from "react"; +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from "embla-carousel-react"; +import { ArrowLeft, ArrowRight } from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; + +type CarouselApi = UseEmblaCarouselType[1]; +type UseCarouselParameters = Parameters; +type CarouselOptions = UseCarouselParameters[0]; +type CarouselPlugin = UseCarouselParameters[1]; + +type CarouselProps = { + opts?: CarouselOptions; + plugins?: CarouselPlugin; + orientation?: "horizontal" | "vertical"; + setApi?: (api: CarouselApi) => void; +}; + +type CarouselContextProps = { + carouselRef: ReturnType[0]; + api: ReturnType[1]; + scrollPrev: () => void; + scrollNext: () => void; + canScrollPrev: boolean; + canScrollNext: boolean; +} & CarouselProps; + +const CarouselContext = React.createContext(null); + +function useCarousel() { + const context = React.useContext(CarouselContext); + + if (!context) { + throw new Error("useCarousel must be used within a "); + } + + return context; +} + +const Carousel = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & CarouselProps +>( + ( + { + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props + }, + ref, + ) => { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, + plugins, + ); + const [canScrollPrev, setCanScrollPrev] = React.useState(false); + const [canScrollNext, setCanScrollNext] = React.useState(false); + + const onSelect = React.useCallback((carouselApi: CarouselApi) => { + if (!carouselApi) { + return; + } + + setCanScrollPrev(carouselApi.canScrollPrev()); + setCanScrollNext(carouselApi.canScrollNext()); + }, []); + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev(); + }, [api]); + + const scrollNext = React.useCallback(() => { + api?.scrollNext(); + }, [api]); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault(); + scrollPrev(); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + scrollNext(); + } + }, + [scrollPrev, scrollNext], + ); + + React.useEffect(() => { + if (!api || !setApi) { + return; + } + + setApi(api); + }, [api, setApi]); + + React.useEffect(() => { + if (!api) { + return; + } + + onSelect(api); + api.on("reInit", onSelect); + api.on("select", onSelect); + + return () => { + api?.off("select", onSelect); + }; + }, [api, onSelect]); + + return ( + + {/* biome-ignore lint/a11y/useSemanticElements: shadcn carousel pattern */} +
+ {children} +
+
+ ); + }, +); +Carousel.displayName = "Carousel"; + +const CarouselContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { carouselRef, orientation } = useCarousel(); + + return ( +
+
+
+ ); +}); +CarouselContent.displayName = "CarouselContent"; + +const CarouselItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, children, ...props }, ref) => { + const { orientation } = useCarousel(); + + return ( + // biome-ignore lint/a11y/useSemanticElements: shadcn carousel pattern +
+ {children} +
+ ); +}); +CarouselItem.displayName = "CarouselItem"; + +const CarouselPrevious = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { canScrollPrev, orientation, scrollPrev } = useCarousel(); + + return ( + + ); +}); +CarouselPrevious.displayName = "CarouselPrevious"; + +const CarouselNext = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { canScrollNext, orientation, scrollNext } = useCarousel(); + + return ( + + ); +}); +CarouselNext.displayName = "CarouselNext"; + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +}; diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index ae2fbdec7..a713b40ee 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -1065,8 +1065,11 @@ test("profile renders live activity for a viewer-owned relay agent", async ({ `user-profile-live-activity-${OWNED_RELAY_AGENT_PUBKEY}`, ); await expect(liveActivity).toBeVisible(); - await expect(liveActivity).toContainText("Live activity"); - await expect(liveActivity).toContainText("Open full activity"); + await expect(liveActivity).toContainText("Latest Activity"); + await expect(liveActivity).toContainText("#agents"); + await expect( + page.getByTestId(`user-profile-activity-dot-${AGENTS_CHANNEL_ID}`), + ).toBeVisible(); await page.evaluate( ({ agentPubkey, channelId, turnId }) => { @@ -1090,12 +1093,86 @@ test("profile renders live activity for a viewer-owned relay agent", async ({ ); await expect(liveActivity).toBeVisible(); - await expect(liveActivity).toContainText("Recent activity"); + await expect(liveActivity).toContainText("Latest Activity"); await expect( page.getByTestId(`user-profile-view-activity-${OWNED_RELAY_AGENT_PUBKEY}`), ).not.toBeVisible(); }); +test("profile activity carousel switches channels via progress dots", async ({ + page, +}) => { + await page.goto("/"); + + await openMembersSidebar(page, "agents"); + await page + .getByTestId(`sidebar-member-open-profile-${OWNED_RELAY_AGENT_PUBKEY}`) + .click(); + await expect(page.getByTestId("members-sidebar")).not.toBeVisible(); + await expect( + page.getByTestId(`user-profile-view-activity-${OWNED_RELAY_AGENT_PUBKEY}`), + ).toBeVisible(); + + await page.waitForFunction( + () => + typeof (window as MockFeedWindow).__BUZZ_E2E_SEED_ACTIVE_TURNS__ === + "function", + ); + + await page.evaluate( + ({ agentPubkey, channels }) => { + const seedActiveTurns = (window as MockFeedWindow) + .__BUZZ_E2E_SEED_ACTIVE_TURNS__; + if (!seedActiveTurns) { + throw new Error("Mock active-turn helper is not installed."); + } + + for (const [index, channelId] of channels.entries()) { + seedActiveTurns({ + agentPubkey, + channelId, + turnId: `owned-relay-profile-turn-${index}`, + }); + } + }, + { + agentPubkey: OWNED_RELAY_AGENT_PUBKEY, + channels: [AGENTS_CHANNEL_ID, GENERAL_CHANNEL_ID], + }, + ); + + const liveActivity = page.getByTestId( + `user-profile-live-activity-${OWNED_RELAY_AGENT_PUBKEY}`, + ); + await expect(liveActivity).toBeVisible(); + await expect(liveActivity).toContainText("#agents"); + + await expect( + page.getByTestId(`user-profile-activity-dot-${AGENTS_CHANNEL_ID}`), + ).toBeVisible(); + await expect( + page.getByTestId(`user-profile-activity-dot-${GENERAL_CHANNEL_ID}`), + ).toBeVisible(); + + await expect( + page.getByTestId(`user-profile-activity-slide-${GENERAL_CHANNEL_ID}`), + ).toHaveAttribute("data-mounted", "false"); + + await page + .getByTestId(`user-profile-activity-dot-${GENERAL_CHANNEL_ID}`) + .click(); + + await expect( + page.getByTestId("user-profile-activity-channel-label"), + ).toContainText("#general"); + await expect( + page.getByTestId(`user-profile-activity-slide-${GENERAL_CHANNEL_ID}`), + ).toHaveAttribute("data-mounted", "true"); + await expect( + page.getByTestId(`user-profile-activity-slide-${AGENTS_CHANNEL_ID}`), + ).toHaveAttribute("data-mounted", "true"); +}); + test("typing indicator shows avatars and maintains stable name order", async ({ page, }) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5cf5c5091..205043006 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,6 +132,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + embla-carousel-react: + specifier: ^8.6.0 + version: 8.6.0(react@19.2.7) emoji-mart: specifier: ^5.6.0 version: 5.6.0 @@ -1641,7 +1644,6 @@ packages: engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.11.2': resolution: {integrity: sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==} @@ -2089,6 +2091,19 @@ packages: electron-to-chromium@1.5.361: resolution: {integrity: sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==} + embla-carousel-react@8.6.0: + resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==} + peerDependencies: + react: ^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + embla-carousel-reactive-utils@8.6.0: + resolution: {integrity: sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==} + peerDependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: + resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + emoji-mart@5.6.0: resolution: {integrity: sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==} @@ -3063,7 +3078,7 @@ packages: engines: {node: '>= 0.4'} wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=} yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -4739,6 +4754,18 @@ snapshots: electron-to-chromium@1.5.361: {} + embla-carousel-react@8.6.0(react@19.2.7): + dependencies: + embla-carousel: 8.6.0 + embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) + react: 19.2.7 + + embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: {} + emoji-mart@5.6.0: {} enhanced-resolve@5.21.5: From df07da3ffb0c7a3b544010f41fef30d537c70878 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 1 Jul 2026 00:12:02 -0700 Subject: [PATCH 17/20] fix(profile): refine compact activity preview readability - Make compact activity feed typography consistently use compact text sizing across tool rows, sent-message previews, todo summaries, and shared activity row labels. - Avoid conflicting `text-sm` and `text-xs` classes in compact message containers so compact sizing wins reliably. - Remove the top overlay fade from the profile activity card and strengthen the bottom fade for better label and carousel-dot readability. - Preserve the refined carousel dot hit targets and compact activity label treatment in the profile preview card. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../CompactMessageSummary.tsx | 6 ++-- .../CompactToolSummaryRow.tsx | 17 +++++++-- .../AgentSessionToolItem/TodoToolSummary.tsx | 23 ++++++++++-- .../ui/activityRenderClasses/ActivityRow.tsx | 10 ++++-- .../activityRenderClasses/MessageActivity.tsx | 4 +-- .../UserMessageBubble.tsx | 4 ++- .../profile/ui/UserProfilePanelTabs.tsx | 35 +++++++++++-------- 7 files changed, 74 insertions(+), 25 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx index f2047ebf7..78a988783 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx @@ -135,11 +135,13 @@ export function CompactMessageSummary({
{ if (!thumbnailSrc || thumbnailFailed) return null; @@ -53,7 +56,13 @@ export function CompactToolSummaryRow({ verb={actionLabel.verb} /> ) : ( - + {label} )} @@ -69,7 +78,11 @@ export function CompactToolSummaryRow({ /> ) : !fileEditSummary && !actionLabel && preview ? ( {preview} diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/TodoToolSummary.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/TodoToolSummary.tsx index 736a24622..0ac9a9195 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/TodoToolSummary.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/TodoToolSummary.tsx @@ -1,5 +1,7 @@ +import { cn } from "@/shared/lib/cn"; import type { TranscriptItem } from "../agentSessionTypes"; import type { CompactToolSummary } from "../agentSessionToolSummary"; +import { useAgentSessionTranscriptVariant } from "../agentSessionTranscriptContext"; import { asRecord, formatTranscriptTimestampTitle, @@ -27,6 +29,8 @@ export function TodoToolSummary({ item: Extract; }) { const todos = buildTodoDisplayItems(item.args, item.result, fallbackPreview); + const variant = useAgentSessionTranscriptVariant(); + const isCompactPreview = variant === "compactPreview"; const actionLabel = { verb: "Updated", object: fallbackPreview ?? "todos", @@ -57,7 +61,14 @@ export function TodoToolSummary({ ))}
) : ( -

No todos.

+

+ No todos. +

)} @@ -72,8 +83,16 @@ export function isTodoSummary(summary: CompactToolSummary) { } function TodoCheckboxRow({ todo }: { todo: TodoDisplayItem }) { + const variant = useAgentSessionTranscriptVariant(); + const isCompactPreview = variant === "compactPreview"; + return ( -
+
diff --git a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx index 6c80daa8b..556326df0 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx @@ -104,7 +104,9 @@ export function UserMessageBubble({ onClick={(event) => { event.preventDefault(); event.stopPropagation(); - openProfilePanel(item.authorPubkey); + if (item.authorPubkey) { + openProfilePanel(item.authorPubkey); + } }} type="button" > diff --git a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx index fb7bf855e..fe339f2f1 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx @@ -455,9 +455,13 @@ function ProfileLiveActivityEmbed({ const handleDotSelect = React.useCallback( (index: number) => { - carouselApi?.scrollTo(index); + const targetIndex = + slides.length === 2 && index === selectedIndex + ? (selectedIndex + 1) % slides.length + : index; + carouselApi?.scrollTo(targetIndex); }, - [carouselApi], + [carouselApi, selectedIndex, slides.length], ); if (slides.length === 0) { @@ -490,8 +494,7 @@ function ProfileLiveActivityEmbed({ transcriptVariant="compactPreview" />
-
-
+
Latest Activity @@ -562,10 +565,9 @@ function ProfileLiveActivityEmbed({
-
-
+
- + Latest Activity {activeChannelName ? ( @@ -619,12 +621,7 @@ function ActivityCarouselDots({ ); })}
From 14b0132f5caf0546d5d597953553208637a17bdc Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 1 Jul 2026 00:19:02 -0700 Subject: [PATCH 18/20] fix(profile): hide activity carousel dots for single-channel embeds Only render pagination when two or more channel slides exist, and update the single-channel profile activity E2E to assert dots stay hidden. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/src/features/profile/ui/UserProfilePanelTabs.tsx | 6 +++--- desktop/tests/e2e/channels.spec.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx index fe339f2f1..7bccc37da 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx @@ -486,7 +486,7 @@ function ProfileLiveActivityEmbed({ agent={activityAgent} autoTail={true} channelId={null} - className="relative z-0 min-h-0 flex-1 border-0 bg-transparent px-4 py-0 text-xs shadow-none **:data-message-id:pointer-events-none" + className="relative z-0 min-h-0 flex-1 border-0 bg-transparent px-4 py-4 text-xs shadow-none **:data-message-id:pointer-events-none" emptyDescription="Live activity will appear here." rawLayout="responsive" showHeader={false} @@ -549,7 +549,7 @@ function ProfileLiveActivityEmbed({ agent={activityAgent} autoTail={true} channelId={channelId} - className="h-full min-h-0 border-0 bg-transparent px-4 py-0 text-xs shadow-none **:data-message-id:pointer-events-none" + className="h-full min-h-0 border-0 bg-transparent px-4 py-4 text-xs shadow-none **:data-message-id:pointer-events-none" emptyDescription="Live activity will appear here." rawLayout="responsive" showHeader={false} @@ -603,7 +603,7 @@ function ActivityCarouselDots({ selectedIndex: number; slides: string[]; }) { - if (slides.length === 0) { + if (slides.length <= 1) { return null; } diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index a713b40ee..db81b6166 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -1069,7 +1069,7 @@ test("profile renders live activity for a viewer-owned relay agent", async ({ await expect(liveActivity).toContainText("#agents"); await expect( page.getByTestId(`user-profile-activity-dot-${AGENTS_CHANNEL_ID}`), - ).toBeVisible(); + ).toHaveCount(0); await page.evaluate( ({ agentPubkey, channelId, turnId }) => { From e5970b9445ac3fcdecb5380f44b4a65074d5f203 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 1 Jul 2026 00:22:10 -0700 Subject: [PATCH 19/20] fix(agents): left-align grouped activity summaries - Make expandable activity row summaries full-width instead of shrink-wrapped so grouped labels stay aligned to the left edge in compact profile previews. - Preserve the existing row spacing, chevron behavior, and open-state coloring while preventing centered-looking group labels such as `Ran 4 commands`. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../features/agents/ui/activityRenderClasses/ActivityRow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx b/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx index 8db26e207..c5cafa8de 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx @@ -72,7 +72,7 @@ export function ActivityRow({ > Date: Wed, 1 Jul 2026 00:26:40 -0700 Subject: [PATCH 20/20] fix(profile): show pending activity state in embed - Add a loading empty state for agent transcripts so active profile activity embeds show a spinner while waiting for ACP events to arrive. - Pass the live pending state from the profile activity feed into the managed session panel with copy that sets expectations for incoming events. - Move compact embed vertical padding from the panel wrapper to the transcript scroll container so transcript content can scroll and clip against the card bounds correctly. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../agents/ui/AgentSessionTranscriptList.tsx | 38 ++++++++++++++++--- .../agents/ui/ManagedAgentSessionPanel.tsx | 17 ++++++++- .../profile/ui/UserProfilePanelTabs.tsx | 16 ++++++-- 3 files changed, 61 insertions(+), 10 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index a049e3e02..68b1f2201 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -11,6 +11,7 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; +import { Spinner } from "@/shared/ui/spinner"; import { Toggle } from "@/shared/ui/toggle"; import type { PromptSection, TranscriptItem } from "./agentSessionTypes"; import { @@ -50,6 +51,8 @@ const TRANSCRIPT_ACP_SOURCE_STORAGE_KEY = "buzz:show-transcript-acp-source"; */ const SHOW_TRANSCRIPT_ACP_SOURCE = shouldShowTranscriptAcpSource(); +export type AgentSessionTranscriptEmptyState = "idle" | "loading"; + function shouldShowTranscriptAcpSource() { const envValue = import.meta.env.VITE_SHOW_TRANSCRIPT_ACP_SOURCE; if (envValue === "1" || envValue === "true") { @@ -75,15 +78,19 @@ export function AgentSessionTranscriptList({ agentPubkey, autoTail = false, emptyDescription, + emptyState = "idle", items, profiles, + scrollContainerClassName, scrollScopeKey, variant = "default", }: AgentTranscriptIdentityProps & { autoTail?: boolean; emptyDescription: string; + emptyState?: AgentSessionTranscriptEmptyState; items: TranscriptItem[]; profiles?: UserProfileLookup; + scrollContainerClassName?: string; scrollScopeKey?: string | null; variant?: AgentSessionTranscriptVariant; }) { @@ -109,12 +116,33 @@ export function AgentSessionTranscriptList({ anchoredScroll.scrollToBottom("auto"); }, [anchoredScroll.scrollToBottom, autoTail, items]); + const scrollContainerClassNames = cn( + "w-full", + autoTail ? "h-full overflow-y-auto" : null, + scrollContainerClassName, + ); + if (items.length === 0) { + const isLoading = emptyState === "loading"; + return ( -
- -

No ACP activity yet

-

{emptyDescription}

+
+
+ {isLoading ? ( + + ) : ( + + )} +

+ {isLoading ? "Waiting for ACP activity" : "No ACP activity yet"} +

+

+ {emptyDescription} +

+
); } @@ -123,7 +151,7 @@ export function AgentSessionTranscriptList({ return (
diff --git a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx index 1f10bda15..d1dbf9308 100644 --- a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx @@ -14,7 +14,10 @@ import { cn } from "@/shared/lib/cn"; import { Badge } from "@/shared/ui/badge"; import { Skeleton } from "@/shared/ui/skeleton"; import { Spinner } from "@/shared/ui/spinner"; -import { AgentSessionTranscriptList } from "./AgentSessionTranscriptList"; +import { + AgentSessionTranscriptList, + type AgentSessionTranscriptEmptyState, +} from "./AgentSessionTranscriptList"; import { RawEventRail } from "./RawEventRail"; import type { ConnectionState, @@ -39,9 +42,11 @@ type ManagedAgentSessionPanelProps = { channelId?: string | null; className?: string; emptyDescription?: string; + emptyState?: AgentSessionTranscriptEmptyState; rawLayout?: "responsive" | "exclusive"; showHeader?: boolean; showRaw?: boolean; + transcriptScrollContainerClassName?: string; transcriptVariant?: AgentSessionTranscriptVariant; profiles?: UserProfileLookup; rawEventsOverride?: ObserverEvent[]; @@ -54,9 +59,11 @@ export function ManagedAgentSessionPanel({ channelId = null, className, emptyDescription = "Mention this agent in a channel to watch the next turn.", + emptyState = "idle", rawLayout = "responsive", showHeader = true, showRaw = true, + transcriptScrollContainerClassName, transcriptVariant = "default", profiles, rawEventsOverride, @@ -115,6 +122,7 @@ export function ManagedAgentSessionPanel({ autoTail={autoTail} channelId={channelId} emptyDescription={emptyDescription} + emptyState={emptyState} errorMessage={errorMessage} events={displayEvents} hasObserver={hasObserver} @@ -123,6 +131,7 @@ export function ManagedAgentSessionPanel({ rawLayout={rawLayout} showRaw={showRaw} transcript={displayTranscript} + transcriptScrollContainerClassName={transcriptScrollContainerClassName} transcriptVariant={transcriptVariant} />
@@ -172,6 +181,7 @@ function SessionBody({ connectionState, channelId, emptyDescription, + emptyState, errorMessage, events, hasObserver, @@ -180,6 +190,7 @@ function SessionBody({ rawLayout, showRaw, transcript, + transcriptScrollContainerClassName, transcriptVariant, }: { agentAvatarUrl: string | null; @@ -189,6 +200,7 @@ function SessionBody({ channelId: string | null; connectionState: ConnectionState; emptyDescription: string; + emptyState: AgentSessionTranscriptEmptyState; errorMessage: string | null; events: ObserverEvent[]; hasObserver: boolean; @@ -197,6 +209,7 @@ function SessionBody({ rawLayout: "responsive" | "exclusive"; showRaw: boolean; transcript: TranscriptItem[]; + transcriptScrollContainerClassName?: string; transcriptVariant: AgentSessionTranscriptVariant; }) { const rawRail = resolveRawRailLayout(showRaw, rawLayout); @@ -238,8 +251,10 @@ function SessionBody({ agentName={agentName} agentPubkey={agentPubkey} emptyDescription={emptyDescription} + emptyState={emptyState} items={transcript} profiles={profiles} + scrollContainerClassName={transcriptScrollContainerClassName} scrollScopeKey={`${agentPubkey}:${channelId ?? "all"}`} autoTail={autoTail} variant={transcriptVariant} diff --git a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx index 7bccc37da..cb99df6c8 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx @@ -449,6 +449,10 @@ function ProfileLiveActivityEmbed({ selectedTurn?.anchorAt ?? null; const lastLiveLabel = formatLastLiveLabel(lastLiveAt, now); + const emptyState = feedScope.isLive ? "loading" : "idle"; + const emptyDescription = feedScope.isLive + ? "Events will appear here shortly." + : "Live activity will appear here."; const openSelectedActivity = React.useCallback(() => { onOpenActivity(activeChannelId); }, [activeChannelId, onOpenActivity]); @@ -486,11 +490,13 @@ function ProfileLiveActivityEmbed({ agent={activityAgent} autoTail={true} channelId={null} - className="relative z-0 min-h-0 flex-1 border-0 bg-transparent px-4 py-4 text-xs shadow-none **:data-message-id:pointer-events-none" - emptyDescription="Live activity will appear here." + className="relative z-0 min-h-0 flex-1 border-0 bg-transparent px-4 text-xs shadow-none **:data-message-id:pointer-events-none" + emptyDescription={emptyDescription} + emptyState={emptyState} rawLayout="responsive" showHeader={false} showRaw={false} + transcriptScrollContainerClassName="py-4" transcriptVariant="compactPreview" />
@@ -549,11 +555,13 @@ function ProfileLiveActivityEmbed({ agent={activityAgent} autoTail={true} channelId={channelId} - className="h-full min-h-0 border-0 bg-transparent px-4 py-4 text-xs shadow-none **:data-message-id:pointer-events-none" - emptyDescription="Live activity will appear here." + className="h-full min-h-0 border-0 bg-transparent px-4 text-xs shadow-none **:data-message-id:pointer-events-none" + emptyDescription={emptyDescription} + emptyState={emptyState} rawLayout="responsive" showHeader={false} showRaw={false} + transcriptScrollContainerClassName="py-4" transcriptVariant="compactPreview" /> ) : (