Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions desktop/src/features/agents/ui/useObserverEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,37 @@ export function useAgentTranscript(
return React.useSyncExternalStore(subscribeToStore, getSnapshot);
}

/**
* Live transcripts for several agents at once, index-aligned with
* `agentPubkeys`. The snapshot is cached so `useSyncExternalStore` only sees
* a new array when at least one underlying per-agent transcript changed —
* required to avoid render loops.
*/
export function useAgentTranscripts(
enabled: boolean,
agentPubkeys: readonly string[],
): TranscriptItem[][] {
const cacheRef = React.useRef<TranscriptItem[][] | null>(null);

const getSnapshot = React.useCallback(() => {
const next = agentPubkeys.map((pubkey) =>
getAgentTranscript(pubkey, enabled),
);
const cached = cacheRef.current;
if (
cached &&
cached.length === next.length &&
cached.every((transcript, index) => transcript === next[index])
) {
return cached;
}
cacheRef.current = next;
return next;
}, [agentPubkeys, enabled]);

return React.useSyncExternalStore(subscribeToStore, getSnapshot);
}

const ARCHIVED_EVENTS_PAGE_SIZE = 50;

/**
Expand Down
46 changes: 40 additions & 6 deletions desktop/src/features/channels/ui/BotActivityBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import {
} from "@/features/agents/ui/agentSessionTranscriptPresentation";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ManagedAgent } from "@/shared/api/types";
import { useFeatureEnabled } from "@/shared/features";
import { cn } from "@/shared/lib/cn";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Shimmer } from "@/shared/ui/Shimmer";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { ComposerLiveActivityFeed } from "./ComposerLiveActivityFeed";

export type BotActivityAgent = Pick<ManagedAgent, "pubkey" | "name">;

Expand Down Expand Up @@ -40,6 +42,7 @@ export function BotActivityComposerAction({
variant = "toolbar",
}: BotActivityBarProps) {
const [open, setOpen] = React.useState(false);
const liveActivityEnabled = useFeatureEnabled("composerLiveActivity");
const hoverTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null,
);
Expand Down Expand Up @@ -217,24 +220,50 @@ export function BotActivityComposerAction({
</PopoverTrigger>
<PopoverContent
align={isInline ? "start" : "end"}
className="w-64 p-1"
className={cn(liveActivityEnabled ? "w-80 p-0" : "w-64 p-1")}
onMouseEnter={keepOpen}
onMouseLeave={closeWithDelay}
onOpenAutoFocus={(event) => event.preventDefault()}
side="top"
sideOffset={8}
>
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
<div
className={cn(
"text-xs font-medium text-muted-foreground",
liveActivityEnabled ? "px-3 pb-1 pt-2" : "px-2 py-1",
)}
>
Agents working
</div>
<div className="mt-1 flex flex-col gap-1">
{liveActivityEnabled ? (
<ComposerLiveActivityFeed
agents={workingAgents}
channelId={channelId}
className="h-48"
onOpenAgentSession={(pubkey) => {
clearHoverTimer();
setOpen(false);
onOpenAgentSession(pubkey, channelId);
}}
profiles={profiles}
/>
) : null}
<div
className={cn(
"flex flex-col",
liveActivityEnabled
? "gap-0.5 border-t border-border/60 p-1"
: "mt-1 gap-1",
)}
>
{workingAgents.map((agent) => {
const isSelected = selectedPubkey === agent.pubkey.toLowerCase();

return (
<button
className={cn(
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors",
"flex w-full items-center gap-2 rounded-lg px-2 text-left transition-colors",
liveActivityEnabled ? "py-1 text-xs" : "py-1.5 text-sm",
isSelected
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent hover:text-accent-foreground",
Expand All @@ -252,10 +281,15 @@ export function BotActivityComposerAction({
avatarUrl={agentAvatarUrl(agent)}
className="shrink-0"
displayName={agent.name}
size="sm"
size={liveActivityEnabled ? "xs" : "sm"}
/>
<span className="min-w-0 flex-1 truncate">{agent.name}</span>
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground/70" />
<Loader2
className={cn(
"shrink-0 animate-spin text-muted-foreground/70",
liveActivityEnabled ? "h-3.5 w-3.5" : "h-4 w-4",
)}
/>
</button>
);
})}
Expand Down
144 changes: 144 additions & 0 deletions desktop/src/features/channels/ui/ComposerLiveActivityFeed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import * as React from "react";

import { AgentSessionTranscriptVariantProvider } from "@/features/agents/ui/agentSessionTranscriptContext";
import { TranscriptActivityItem } from "@/features/agents/ui/activityRenderClasses/TranscriptActivityItem";
import { useAgentTranscripts } from "@/features/agents/ui/useObserverEvents";
import { useAnchoredScroll } from "@/features/messages/ui/useAnchoredScroll";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { cn } from "@/shared/lib/cn";
import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import {
groupLiveActivity,
mergeLiveActivity,
type LiveActivityAgent,
} from "./composerLiveActivity";

/**
* Merged multi-agent live activity preview for the composer popover.
*
* Interleaves the working agents' transcripts (scoped to the current channel)
* into one chronological stream, badges each same-agent run with the agent's
* identity, and auto-tails streaming updates. Rendering reuses the compact
* transcript presenters from the full activity view.
*/
export function ComposerLiveActivityFeed({
agents,
channelId,
className,
onOpenAgentSession,
profiles,
}: {
agents: LiveActivityAgent[];
channelId: string | null;
className?: string;
onOpenAgentSession: (pubkey: string) => void;
profiles?: UserProfileLookup;
}) {
const agentPubkeys = React.useMemo(
() => agents.map((agent) => agent.pubkey),
[agents],
);
const transcripts = useAgentTranscripts(agents.length > 0, agentPubkeys);
const entries = React.useMemo(
() => mergeLiveActivity(agents, transcripts, channelId),
[agents, channelId, transcripts],
);
const groups = React.useMemo(() => groupLiveActivity(entries), [entries]);

const scrollContainerRef = React.useRef<HTMLDivElement>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
// Stable identity per entries snapshot — useAnchoredScroll keys effects on
// `messages`, so a fresh array every render would re-run them needlessly.
const anchoredMessages = React.useMemo(
() => entries.map((entry) => ({ id: entry.key })),
[entries],
);
const anchoredScroll = useAnchoredScroll({
channelId: `composer-live-activity:${channelId ?? "all"}`,
contentRef,
isLoading: false,
messages: anchoredMessages,
scrollContainerRef,
});

const agentAvatarUrl = (agent: LiveActivityAgent) =>
profiles?.[agent.pubkey.toLowerCase()]?.avatarUrl ?? null;

if (entries.length === 0) {
return (
<div
className={cn(
"flex flex-col items-center justify-center px-6 text-center",
className,
)}
data-testid="composer-live-activity-empty"
>
<FuzzyLogo
ariaLabel="Waiting for agent activity"
className="mx-auto text-muted-foreground"
fuzz={false}
loop
/>
</div>
);
}

return (
<div
className={cn("overflow-y-auto overscroll-contain", className)}
data-testid="composer-live-activity-feed"
onScroll={anchoredScroll.onScroll}
ref={scrollContainerRef}
>
<div
aria-label="Live agent activity"
aria-live="polite"
className="flex w-full flex-col gap-2 px-3 py-2"
ref={contentRef}
role="log"
>
<AgentSessionTranscriptVariantProvider value="compactPreview">
{groups.map((group) => (
<div
className="flex flex-col gap-1"
data-message-id={group.key}
data-testid={`composer-live-activity-group-${group.agent.pubkey}`}
key={group.key}
>
<button
aria-label={`Open ${group.agent.name} activity`}
className="flex w-fit max-w-full items-center gap-1.5 rounded-md px-1 py-0.5 text-left transition-colors hover:bg-accent"
onClick={() => onOpenAgentSession(group.agent.pubkey)}
type="button"
>
<UserAvatar
avatarUrl={agentAvatarUrl(group.agent)}
className="!h-[18px] !w-[18px] shrink-0 text-3xs"
displayName={group.agent.name}
size="xs"
/>
<span className="truncate text-xs font-semibold text-muted-foreground">
{group.agent.name}
</span>
</button>
<div className="flex min-w-0 flex-col gap-1 pl-2">
{group.entries.map((entry) => (
<div data-message-id={entry.key} key={entry.key}>
<TranscriptActivityItem
agentAvatarUrl={agentAvatarUrl(group.agent)}
agentName={group.agent.name}
agentPubkey={group.agent.pubkey}
item={entry.item}
profiles={profiles}
/>
</div>
))}
</div>
</div>
))}
</AgentSessionTranscriptVariantProvider>
</div>
</div>
);
}
Loading
Loading