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/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/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx index 9b786d8ab..78a988783 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx @@ -1,9 +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"; @@ -20,6 +25,7 @@ export function CompactMessageSummary({ label, messageLink, preview, + pubkey, result, timestamp, }: { @@ -34,34 +40,138 @@ 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 ? ( + + ) : ( + + )} +
-

- {preview || "Message content unavailable."} -

+ + {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/CompactToolSummaryRow.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.tsx index 825adff68..7a1330058 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.tsx @@ -3,6 +3,7 @@ import { ChevronDown } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import { useAgentSessionTranscriptVariant } from "../agentSessionTranscriptContext"; import type { AgentActivityAction } from "../agentSessionTypes"; import type { CompactFileEditSummary } from "../agentSessionToolSummary"; import { isInlineImageData } from "../agentSessionUtils"; @@ -32,6 +33,8 @@ export function CompactToolSummaryRow({ thumbnailSrc: string | null; }) { const [thumbnailFailed, setThumbnailFailed] = React.useState(false); + const variant = useAgentSessionTranscriptVariant(); + const isCompactPreview = variant === "compactPreview"; const mutedTone = compactSummaryTone(); const resolvedThumbnail = React.useMemo(() => { 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/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 9515be309..68b1f2201 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, @@ -10,8 +11,14 @@ 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 { + AgentSessionTranscriptVariantProvider, + type AgentSessionTranscriptVariant, + useAgentSessionTranscriptVariant, +} from "./agentSessionTranscriptContext"; import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem"; import { ActivityRow, @@ -44,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") { @@ -67,51 +76,113 @@ export function AgentSessionTranscriptList({ agentAvatarUrl, agentName, 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; }) { const displayBlocks = React.useMemo( () => buildTranscriptDisplayBlocks(items), [items], ); + const scrollContainerRef = React.useRef(null); + const contentRef = React.useRef(null); + const anchoredScroll = useAnchoredScroll({ + channelId: autoTail ? (scrollScopeKey ?? agentPubkey) : null, + contentRef, + isLoading: false, + messages: items, + scrollContainerRef, + }); + + React.useLayoutEffect(() => { + if (!autoTail || items.length === 0) { + return; + } + + 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} +

+
); } + const isCompactPreview = variant === "compactPreview"; + return ( -
+
- {displayBlocks.map((block) => ( -
- -
- ))} + + {displayBlocks.map((block) => ( +
+ +
+ ))} +
); @@ -146,6 +217,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/agents/ui/ManagedAgentSessionPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx index ae4909cb4..d1dbf9308 100644 --- a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx @@ -14,13 +14,17 @@ 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, ObserverEvent, TranscriptItem, } from "./agentSessionTypes"; +import type { AgentSessionTranscriptVariant } from "./agentSessionTranscriptContext"; import { deriveLatestSessionId, resolveDisplayEvents, @@ -34,12 +38,16 @@ type ManagedAgentSessionPanelProps = { agent: Pick & { avatarUrl?: string | null; }; + autoTail?: boolean; channelId?: string | null; className?: string; emptyDescription?: string; + emptyState?: AgentSessionTranscriptEmptyState; rawLayout?: "responsive" | "exclusive"; showHeader?: boolean; showRaw?: boolean; + transcriptScrollContainerClassName?: string; + transcriptVariant?: AgentSessionTranscriptVariant; profiles?: UserProfileLookup; rawEventsOverride?: ObserverEvent[]; transcriptOverride?: TranscriptItem[]; @@ -47,12 +55,16 @@ type ManagedAgentSessionPanelProps = { export function ManagedAgentSessionPanel({ agent, + autoTail = false, 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, transcriptOverride, @@ -89,6 +101,7 @@ export function ManagedAgentSessionPanel({
@@ -106,7 +119,10 @@ export function ManagedAgentSessionPanel({ agentName={agent.name} agentPubkey={agent.pubkey} connectionState={connectionState} + autoTail={autoTail} + channelId={channelId} emptyDescription={emptyDescription} + emptyState={emptyState} errorMessage={errorMessage} events={displayEvents} hasObserver={hasObserver} @@ -115,6 +131,8 @@ export function ManagedAgentSessionPanel({ rawLayout={rawLayout} showRaw={showRaw} transcript={displayTranscript} + transcriptScrollContainerClassName={transcriptScrollContainerClassName} + transcriptVariant={transcriptVariant} />
); @@ -159,8 +177,11 @@ function SessionBody({ agentAvatarUrl, agentName, agentPubkey, + autoTail, connectionState, + channelId, emptyDescription, + emptyState, errorMessage, events, hasObserver, @@ -169,12 +190,17 @@ function SessionBody({ rawLayout, showRaw, transcript, + transcriptScrollContainerClassName, + transcriptVariant, }: { agentAvatarUrl: string | null; agentName: string; agentPubkey: string; + autoTail: boolean; + channelId: string | null; connectionState: ConnectionState; emptyDescription: string; + emptyState: AgentSessionTranscriptEmptyState; errorMessage: string | null; events: ObserverEvent[]; hasObserver: boolean; @@ -183,6 +209,8 @@ function SessionBody({ rawLayout: "responsive" | "exclusive"; showRaw: boolean; transcript: TranscriptItem[]; + transcriptScrollContainerClassName?: string; + transcriptVariant: AgentSessionTranscriptVariant; }) { const rawRail = resolveRawRailLayout(showRaw, rawLayout); @@ -215,6 +243,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", )} > {rawRail.mode === "side" ? : null}
diff --git a/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx b/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx index 11fbe7ae9..c5cafa8de 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { ChevronDown } from "lucide-react"; import { cn } from "@/shared/lib/cn"; +import { useAgentSessionTranscriptVariant } from "../agentSessionTranscriptContext"; export type ActivityRowLabelParts = { verb: string; @@ -71,7 +72,7 @@ export function ActivityRow({ > - ); + return ; } function MessageItem({ - agentAvatarUrl, - agentName, - agentPubkey, item, profiles, -}: AgentTranscriptIdentityProps & { +}: { 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); - const agentProfile = profiles?.[normalizePubkey(agentPubkey)] ?? null; - const assistantLabel = resolveUserLabel({ - pubkey: agentPubkey, - fallbackName: agentName, - profiles, - preferResolvedSelfLabel: true, - }); - const assistantAvatarUrl = agentProfile?.avatarUrl ?? agentAvatarUrl; if (!isAssistant) { return ( @@ -77,24 +55,17 @@ function MessageItem({ data-testid="transcript-assistant-message" >
-
- + - - {assistantLabel} - - -
-
-
diff --git a/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx index 3f7283312..334a1b080 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx @@ -39,8 +39,8 @@ 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..8dfaea86e 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx @@ -22,8 +22,11 @@ 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 a49cec218..556326df0 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx @@ -1,13 +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, @@ -24,7 +28,18 @@ export function UserMessageBubble({ item: Extract; 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; @@ -35,36 +50,125 @@ 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 : 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; +} 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/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/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/lib/profileActivityFeedScope.ts b/desktop/src/features/profile/lib/profileActivityFeedScope.ts new file mode 100644 index 000000000..596c04ec9 --- /dev/null +++ b/desktop/src/features/profile/lib/profileActivityFeedScope.ts @@ -0,0 +1,267 @@ +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; + /** Latest observed activity timestamp, keyed by channel id. */ + latestActivityAtByChannel: Record; + /** 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 && + 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, +): 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; +} + +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, + transcript, +}: { + activeTurns: readonly ActiveTurnSummary[]; + events: readonly ObserverEvent[]; + transcript: readonly TranscriptItem[]; +}): 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] + .map((turn) => turn.channelId) + .sort((left, right) => left.localeCompare(right)); + + return { + channelIds, + hasFeedContent: true, + isLive: true, + latestActivityAtByChannel, + preferredChannelId: channelIds[0] ?? null, + }; + } + + const feedChannelIds = collectChannelIdsFromFeed(events, transcript); + const latestChannelId = deriveLatestChannelId(events, transcript); + + return { + channelIds: feedChannelIds, + hasFeedContent, + isLive: false, + latestActivityAtByChannel, + 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/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index c2c31b1e8..f26ede972 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -53,6 +53,8 @@ import { useUserProfileQuery, useUsersBatchQuery, } from "@/features/profile/hooks"; +import { ownsAuthorAgent } from "@/features/profile/lib/identity"; +import { resolveProfileActivityAgent } from "@/features/profile/lib/profileActivityAgent"; import { AgentInfoFocusedView, AgentInstructionsFocusedView, @@ -90,7 +92,6 @@ import type { Channel, CreateManagedAgentInput, CreatePersonaInput, - ManagedAgent, UpdatePersonaInput, } from "@/shared/api/types"; import { UserProfilePanelFrame } from "@/features/profile/ui/UserProfilePanelFrame"; @@ -269,47 +270,32 @@ 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. 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 || @@ -684,10 +670,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) => { @@ -843,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 a257fa447..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; @@ -96,7 +98,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; @@ -173,6 +175,7 @@ function RuntimeTabStatusDot({ status }: { status: RuntimeTabStatus }) { } export function ProfileSummaryView({ + activityAgent, canAddToChannel, canEditAgent, canOpenAgentLogs, @@ -399,7 +402,10 @@ export function ProfileSummaryView({ ) : null} {activeTab === "info" ? ( ; isArchived: boolean; - onOpenActivity: () => void; + onOpenActivity: (channelId?: string | null) => void; pubkey: string | null; showActivityIngress: boolean; }) { @@ -281,6 +301,9 @@ export function ProfileInfoTabContent({ ] : agentInfoFields; const hasInfoFields = infoFields.length > 0; + const feedScope = useProfileActivityFeedScope(activityAgent, activeTurns); + const showLiveActivityEmbed = + showActivityIngress && (feedScope.isLive || feedScope.hasFeedContent); if (!hasInfoFields && !showActivityIngress) { return null; @@ -289,19 +312,405 @@ export function ProfileInfoTabContent({ return (
{showActivityIngress ? ( - + showLiveActivityEmbed && activityAgent ? ( + + ) : ( + onOpenActivity(null)} + testId={`user-profile-view-activity-${pubkey}`} + trailing="View" + /> + ) ) : null} {hasInfoFields ? : null}
); } +function ProfileLiveActivityEmbed({ + activeTurns, + activityAgent, + channelIdToName, + feedScope, + onOpenActivity, +}: { + activeTurns: ActiveTurnSummary[]; + activityAgent: ProfileActivityAgent; + channelIdToName: Record; + feedScope: ProfileActivityFeedScope; + onOpenActivity: (channelId?: string | null) => void; +}) { + const now = useNow(1000); + const [carouselApi, setCarouselApi] = React.useState(); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const [mountedChannelIds, setMountedChannelIds] = React.useState>( + () => new Set(), + ); + + 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 (slides.length === 0) { + setSelectedIndex(0); + return; + } + + 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 === activeChannelId) ?? + activeTurns[0] ?? + null) + : null; + const activeChannelName = activeChannelId + ? (channelIdToName[activeChannelId] ?? activeChannelId) + : null; + const lastLiveAt = + (activeChannelId + ? feedScope.latestActivityAtByChannel[activeChannelId] + : undefined) ?? + 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]); + + const handleDotSelect = React.useCallback( + (index: number) => { + const targetIndex = + slides.length === 2 && index === selectedIndex + ? (selectedIndex + 1) % slides.length + : index; + carouselApi?.scrollTo(targetIndex); + }, + [carouselApi, selectedIndex, slides.length], + ); + + if (slides.length === 0) { + return ( +
+
+ ); + } + + return ( +
+
+ ); +} + +function ActivityCarouselDots({ + channelIdToName, + onSelect, + selectedIndex, + slides, +}: { + channelIdToName: Record; + onSelect: (index: number) => void; + selectedIndex: number; + slides: string[]; +}) { + if (slides.length <= 1) { + return null; + } + + return ( +
+ {slides.map((channelId, index) => { + const isSelected = index === selectedIndex; + const channelName = channelIdToName[channelId] ?? channelId; + + return ( + + ); + })} +
+ ); +} + +function LiveActivityOpenButton({ + activeChannelId, + label, + onOpenActivity, +}: { + activeChannelId: string | null; + label: string; + onOpenActivity: (channelId?: string | null) => void; +}) { + 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 ( 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); 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 }), 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/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 12491e5c2..43397f73e 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -72,7 +72,6 @@ import type { ImetaEntry, MarkdownProps, MarkdownRuntime, - MarkdownVariant, } from "./markdown/types"; import { SpoilerInline } from "./markdown/SpoilerInline"; import { @@ -1515,17 +1514,10 @@ function ImageBlock({ alt, dim, resolvedSrc, src }: ImageBlockProps) { } function createMarkdownComponents( - variant: MarkdownVariant, runtimeRef: React.RefObject, interactive = true, mediaInset = false, ): Components { - const paragraphClassName = - variant === "tight" - ? "leading-5" - : variant === "compact" - ? "leading-6" - : "leading-[inherit]"; const listItemClassName = "[&_p]:inline"; const listClassName = "space-y-1 pl-6 marker:text-muted-foreground/80"; @@ -1770,10 +1762,10 @@ function createMarkdownComponents( } if (hasBlockMedia(childArray)) { - return
{children}
; + return
{children}
; } - return

{children}

; + return

{children}

; }, pre: ({ children }) => { if (!interactive) return {children}; @@ -1937,7 +1929,6 @@ function createMarkdownComponents( function MarkdownInner({ channelNames, className, - compact = false, content, customEmoji, imetaByUrl, @@ -1947,7 +1938,6 @@ function MarkdownInner({ mentionNames, mentionPubkeysByName, searchQuery, - tight = false, videoReviewContext, }: MarkdownProps) { const { channels: rawChannels } = useChannelNavigation(); @@ -1988,16 +1978,9 @@ function MarkdownInner({ onOpenMessageLink, }); - const variant: MarkdownVariant = tight - ? "tight" - : compact - ? "compact" - : "default"; - const components = React.useMemo( - () => - createMarkdownComponents(variant, runtimeRef, interactive, mediaInset), - [variant, runtimeRef, interactive, mediaInset], + () => createMarkdownComponents(runtimeRef, interactive, mediaInset), + [runtimeRef, interactive, mediaInset], ); // biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable @@ -2052,7 +2035,7 @@ function MarkdownInner({ className={cn( MESSAGE_MARKDOWN_CLASS, [ - "max-w-none [overflow-wrap:anywhere] text-sm leading-5 text-foreground", + "max-w-none wrap-anywhere", "[&>*:first-child]:mt-0 [&>*:last-child]:mb-0", "[&>*+*]:mt-3", "[&>p+p]:mt-1.5", @@ -2065,13 +2048,6 @@ function MarkdownInner({ "[&>*+hr]:mt-4 [&>hr+*]:mt-4", "[&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5", ].join(" "), - // Variant overrides: density tweaks for agent-session transcript surfaces. - // Layered after the base owl-spacing set so tailwind-merge lets the - // narrower leading + tighter inter-block gaps win for compact/tight. - variant === "compact" && - "leading-6 [&>*+*]:mt-2 [&>*+h1]:mt-3 [&>*+h2]:mt-3 [&>*+h3]:mt-3 [&>*+blockquote]:mt-3 [&>blockquote+*]:mt-3 [&>*+[data-code-block]]:mt-3 [&>[data-code-block]+*]:mt-3 [&>*+[data-table-block]]:mt-3 [&>[data-table-block]+*]:mt-3 [&>*+hr]:mt-3.5 [&>hr+*]:mt-3.5 [&>p+ul]:mt-1 [&>p+ol]:mt-1 [&>div+ul]:mt-1 [&>div+ol]:mt-1", - variant === "tight" && - "leading-5 [&>*+*]:mt-2 [&>*+h1]:mt-2.5 [&>*+h2]:mt-2.5 [&>*+h3]:mt-2.5 [&>*+blockquote]:mt-3 [&>blockquote+*]:mt-3 [&>*+[data-code-block]]:mt-3 [&>[data-code-block]+*]:mt-3 [&>*+[data-table-block]]:mt-3 [&>[data-table-block]+*]:mt-3 [&>*+hr]:mt-3.5 [&>hr+*]:mt-3.5 [&>p+ul]:mt-1 [&>p+ol]:mt-1 [&>div+ul]:mt-1 [&>div+ol]:mt-1", className, )} > @@ -2097,11 +2073,9 @@ export const Markdown = React.memo( (prev, next) => prev.content === next.content && prev.className === next.className && - prev.compact === next.compact && prev.customEmoji === next.customEmoji && prev.interactive === next.interactive && prev.mediaInset === next.mediaInset && - prev.tight === next.tight && prev.agentMentionPubkeysByName === next.agentMentionPubkeysByName && prev.mentionPubkeysByName === next.mentionPubkeysByName && shallowArrayEqual(prev.mentionNames, next.mentionNames) && diff --git a/desktop/src/shared/ui/markdown/types.ts b/desktop/src/shared/ui/markdown/types.ts index 144814cd7..3eac5b53f 100644 --- a/desktop/src/shared/ui/markdown/types.ts +++ b/desktop/src/shared/ui/markdown/types.ts @@ -35,7 +35,6 @@ export type MarkdownRuntime = { export type MarkdownProps = { channelNames?: string[]; className?: string; - compact?: boolean; content: string; customEmoji?: CustomEmoji[]; imetaByUrl?: ImetaLookup; @@ -45,8 +44,5 @@ export type MarkdownProps = { mentionPubkeysByName?: Record; mediaInset?: boolean; searchQuery?: string; - tight?: boolean; videoReviewContext?: VideoReviewContext; }; - -export type MarkdownVariant = "default" | "compact" | "tight"; 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 7d6423fea..db81b6166 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -8,6 +8,7 @@ import { } from "../helpers/bridge"; const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; +const AGENTS_CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301"; const MOCK_IDENTITY_PUBKEY = "deadbeef".repeat(8); // Relay-only agent owned by the mock viewer (see e2eBridge.ts // OWNED_RELAY_AGENT_PUBKEY). Classified as a bot via mockRelayAgents and @@ -17,6 +18,12 @@ const OWNED_RELAY_AGENT_PUBKEY = "a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00"; type MockFeedWindow = Window & { + __BUZZ_E2E_SEED_ACTIVE_TURNS__?: (input: { + 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"; channel_id: string | null; @@ -1016,6 +1023,156 @@ 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("Latest Activity"); + await expect(liveActivity).toContainText("#agents"); + await expect( + page.getByTestId(`user-profile-activity-dot-${AGENTS_CHANNEL_ID}`), + ).toHaveCount(0); + + 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("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: