mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(composer): live activity preview for the selected working agent
Rebuild of the composer live-activity experiment (old draft PR #1575) on current main primitives. Behind the new off-by-default composerLiveActivity preview feature, the agents-working popover shows one selected working agent's channel-scoped feed at a time: - ComposerLiveActivityFeed reuses ManagedAgentSessionPanel with the compactPreview transcript variant (same primitive as the profile activity embed), so live/archive merging, projection, anchored scrolling, and idle handling stay owned by that surface. - Agent switching via pill tabs (role=tablist) under the preview; selection resolves explicit tab > open session pane > first working agent, and falls through when the selected agent stops working. - Whole preview is one click target with a Last-live recency pill; transcript rows underneath are inert, avoiding nested interactive controls inside a clickable shell. - formatLastLiveLabel extracted from UserProfilePanelTabs into profile/lib/lastLiveLabel so both surfaces share the recency label. With the experiment off, the popover renders the existing agent list unchanged. Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -9,12 +9,16 @@ 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 { Button } from "@/shared/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { Shimmer } from "@/shared/ui/Shimmer";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { ComposerLiveActivityFeed } from "./ComposerLiveActivityFeed";
|
||||
import { resolveSelectedActivityAgent } from "./composerLiveActivity";
|
||||
|
||||
export type BotActivityAgent = Pick<ManagedAgent, "pubkey" | "name">;
|
||||
export type BotActivityAgent = Pick<ManagedAgent, "pubkey" | "name" | "status">;
|
||||
|
||||
type BotActivityBarProps = {
|
||||
agents: BotActivityAgent[];
|
||||
@@ -40,6 +44,13 @@ export function BotActivityComposerAction({
|
||||
variant = "toolbar",
|
||||
}: BotActivityBarProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const liveActivityEnabled = useFeatureEnabled("composerLiveActivity");
|
||||
// Which working agent's feed the live-activity preview shows. Explicit tab
|
||||
// selection first; falls back to the open session pane, then the first
|
||||
// working agent (see resolveSelectedActivityAgent).
|
||||
const [selectedActivityPubkey, setSelectedActivityPubkey] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const hoverTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
@@ -53,6 +64,22 @@ export function BotActivityComposerAction({
|
||||
}, [agents, workingBotPubkeys]);
|
||||
const singleWorkingAgent =
|
||||
workingAgents.length === 1 ? (workingAgents[0] ?? null) : null;
|
||||
const selectedActivityAgent = React.useMemo(
|
||||
() =>
|
||||
liveActivityEnabled
|
||||
? resolveSelectedActivityAgent({
|
||||
openAgentSessionPubkey,
|
||||
selectedPubkey: selectedActivityPubkey,
|
||||
workingAgents,
|
||||
})
|
||||
: null,
|
||||
[
|
||||
liveActivityEnabled,
|
||||
openAgentSessionPubkey,
|
||||
selectedActivityPubkey,
|
||||
workingAgents,
|
||||
],
|
||||
);
|
||||
const transcript = useAgentTranscript(
|
||||
Boolean(singleWorkingAgent),
|
||||
singleWorkingAgent?.pubkey,
|
||||
@@ -220,52 +247,112 @@ export function BotActivityComposerAction({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align={isInline ? "start" : "end"}
|
||||
className="w-64 p-1"
|
||||
className={cn(
|
||||
liveActivityEnabled ? "flex w-80 flex-col gap-1.5 p-2" : "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">
|
||||
Agents working
|
||||
</div>
|
||||
<div className="mt-1 flex flex-col 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",
|
||||
isSelected
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
)}
|
||||
data-testid={`bot-activity-composer-item-${agent.pubkey}`}
|
||||
key={agent.pubkey}
|
||||
onClick={() => {
|
||||
{liveActivityEnabled ? (
|
||||
<>
|
||||
{selectedActivityAgent ? (
|
||||
<ComposerLiveActivityFeed
|
||||
agent={selectedActivityAgent}
|
||||
channelId={channelId}
|
||||
className="h-48 rounded-lg border border-border/60 bg-background"
|
||||
onOpenAgentSession={(pubkey) => {
|
||||
clearHoverTimer();
|
||||
setOpen(false);
|
||||
onOpenAgentSession(agent.pubkey, channelId);
|
||||
onOpenAgentSession(pubkey, channelId);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={agentAvatarUrl(agent)}
|
||||
className="shrink-0"
|
||||
displayName={agent.name}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{agent.name}</span>
|
||||
<span className="shrink-0 whitespace-nowrap text-xs font-medium opacity-80">
|
||||
View activity
|
||||
</span>
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground/70" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
profiles={profiles}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
aria-label="Working agents"
|
||||
className="flex w-full flex-wrap items-center justify-start gap-1.5"
|
||||
role="tablist"
|
||||
>
|
||||
{workingAgents.map((agent) => {
|
||||
const isSelected =
|
||||
selectedActivityAgent?.pubkey.toLowerCase() ===
|
||||
agent.pubkey.toLowerCase();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-selected={isSelected}
|
||||
className="max-w-full shrink-0 rounded-full px-2"
|
||||
data-testid={`bot-activity-composer-item-${agent.pubkey}`}
|
||||
key={agent.pubkey}
|
||||
onClick={() =>
|
||||
setSelectedActivityPubkey(agent.pubkey.toLowerCase())
|
||||
}
|
||||
role="tab"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={isSelected ? "secondary" : "ghost"}
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={agentAvatarUrl(agent)}
|
||||
className="!h-[18px] !w-[18px] shrink-0 text-3xs"
|
||||
displayName={agent.name}
|
||||
size="xs"
|
||||
/>
|
||||
<span className="max-w-28 truncate">{agent.name}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
Agents working
|
||||
</div>
|
||||
<div className="mt-1 flex flex-col 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",
|
||||
isSelected
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
)}
|
||||
data-testid={`bot-activity-composer-item-${agent.pubkey}`}
|
||||
key={agent.pubkey}
|
||||
onClick={() => {
|
||||
clearHoverTimer();
|
||||
setOpen(false);
|
||||
onOpenAgentSession(agent.pubkey, channelId);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={agentAvatarUrl(agent)}
|
||||
className="shrink-0"
|
||||
displayName={agent.name}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{agent.name}
|
||||
</span>
|
||||
<span className="shrink-0 whitespace-nowrap text-xs font-medium opacity-80">
|
||||
View activity
|
||||
</span>
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground/70" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore";
|
||||
import { scopeByChannel } from "@/features/agents/ui/agentSessionPanelLayout";
|
||||
import { isMeaningfulItem } from "@/features/agents/ui/agentSessionTranscriptPresentation";
|
||||
import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel";
|
||||
import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents";
|
||||
import { formatLastLiveLabel } from "@/features/profile/lib/lastLiveLabel";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { useNow } from "@/shared/lib/useNow";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import type { BotActivityAgent } from "./BotActivityBar";
|
||||
|
||||
/**
|
||||
* Single-agent live activity preview for the composer "agents working"
|
||||
* popover. Preview-gated behind the `composerLiveActivity` feature.
|
||||
*
|
||||
* Renders the selected working agent's channel-scoped transcript with the
|
||||
* same compact primitive as the profile activity embed
|
||||
* (`ManagedAgentSessionPanel` + `compactPreview`), so projection, live/archive
|
||||
* merging, scrolling, and idle handling stay owned by that surface.
|
||||
*
|
||||
* The whole preview is ONE click target: an overlay button opens the agent's
|
||||
* full activity view, and transcript rows underneath are made inert. This
|
||||
* avoids nesting interactive transcript controls inside a clickable shell.
|
||||
*/
|
||||
export function ComposerLiveActivityFeed({
|
||||
agent,
|
||||
channelId,
|
||||
className,
|
||||
onOpenAgentSession,
|
||||
profiles,
|
||||
}: {
|
||||
agent: BotActivityAgent;
|
||||
channelId: string | null;
|
||||
className?: string;
|
||||
onOpenAgentSession: (pubkey: string) => void;
|
||||
profiles?: UserProfileLookup;
|
||||
}) {
|
||||
const activeTurns = useActiveAgentTurns(agent.pubkey);
|
||||
const transcript = useAgentTranscript(true, agent.pubkey);
|
||||
const lastLiveAt = React.useMemo(() => {
|
||||
const scoped = scopeByChannel(transcript, channelId).filter(
|
||||
isMeaningfulItem,
|
||||
);
|
||||
for (let index = scoped.length - 1; index >= 0; index -= 1) {
|
||||
const item = scoped[index];
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
const millis = Date.parse(item.timestamp);
|
||||
if (!Number.isNaN(millis)) {
|
||||
return millis;
|
||||
}
|
||||
}
|
||||
|
||||
const channelTurn = channelId
|
||||
? activeTurns.find((turn) => turn.channelId === channelId)
|
||||
: activeTurns[0];
|
||||
return channelTurn?.anchorAt ?? null;
|
||||
}, [activeTurns, channelId, transcript]);
|
||||
|
||||
const now = useNow(15_000);
|
||||
const lastLiveLabel = formatLastLiveLabel(lastLiveAt, now);
|
||||
const openLabel = `Open ${agent.name}'s full activity. Last live ${lastLiveLabel}.`;
|
||||
const avatarUrl = profiles?.[agent.pubkey.toLowerCase()]?.avatarUrl ?? null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
data-testid="composer-live-activity-feed"
|
||||
>
|
||||
<button
|
||||
aria-label={openLabel}
|
||||
className="absolute inset-0 z-10 cursor-pointer rounded-lg transition-colors hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
data-testid="composer-live-activity-open"
|
||||
onClick={() => onOpenAgentSession(agent.pubkey)}
|
||||
type="button"
|
||||
/>
|
||||
<Button
|
||||
aria-label={openLabel}
|
||||
className="absolute right-2 top-2 z-20 rounded-full bg-primary px-2.5 text-xs font-semibold text-primary-foreground hover:bg-primary/90"
|
||||
data-testid="composer-live-activity-last-live"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpenAgentSession(agent.pubkey);
|
||||
}}
|
||||
size="xs"
|
||||
title={`Last live ${lastLiveLabel}`}
|
||||
type="button"
|
||||
>
|
||||
{lastLiveLabel}
|
||||
</Button>
|
||||
<ManagedAgentSessionPanel
|
||||
agent={{ ...agent, avatarUrl }}
|
||||
autoTail={true}
|
||||
channelId={channelId}
|
||||
className="relative z-0 h-full min-h-0 border-0 bg-transparent px-3 text-xs shadow-none **:data-message-id:pointer-events-none"
|
||||
emptyDescription="Live activity will appear here."
|
||||
emptyState="loading"
|
||||
panelPadding={false}
|
||||
profiles={profiles}
|
||||
rawLayout="responsive"
|
||||
showHeader={false}
|
||||
showRaw={false}
|
||||
transcriptContentClassName="py-2"
|
||||
transcriptVariant="compactPreview"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { resolveSelectedActivityAgent } from "./composerLiveActivity.ts";
|
||||
|
||||
const alice = { pubkey: "ALICE-pubkey", name: "Alice" };
|
||||
const bob = { pubkey: "bob-pubkey", name: "Bob" };
|
||||
|
||||
test("explicit selection wins, case-insensitively", () => {
|
||||
const agent = resolveSelectedActivityAgent({
|
||||
openAgentSessionPubkey: "bob-pubkey",
|
||||
selectedPubkey: "alice-PUBKEY",
|
||||
workingAgents: [alice, bob],
|
||||
});
|
||||
assert.equal(agent, alice);
|
||||
});
|
||||
|
||||
test("falls back to the open session agent", () => {
|
||||
const agent = resolveSelectedActivityAgent({
|
||||
openAgentSessionPubkey: "bob-pubkey",
|
||||
selectedPubkey: null,
|
||||
workingAgents: [alice, bob],
|
||||
});
|
||||
assert.equal(agent, bob);
|
||||
});
|
||||
|
||||
test("falls back to the first working agent", () => {
|
||||
const agent = resolveSelectedActivityAgent({
|
||||
openAgentSessionPubkey: "gone-pubkey",
|
||||
selectedPubkey: "also-gone",
|
||||
workingAgents: [alice, bob],
|
||||
});
|
||||
assert.equal(agent, alice);
|
||||
});
|
||||
|
||||
test("selection that stopped working falls through", () => {
|
||||
const agent = resolveSelectedActivityAgent({
|
||||
openAgentSessionPubkey: null,
|
||||
selectedPubkey: "bob-pubkey",
|
||||
workingAgents: [alice],
|
||||
});
|
||||
assert.equal(agent, alice);
|
||||
});
|
||||
|
||||
test("returns null with no working agents", () => {
|
||||
const agent = resolveSelectedActivityAgent({
|
||||
openAgentSessionPubkey: null,
|
||||
selectedPubkey: null,
|
||||
workingAgents: [],
|
||||
});
|
||||
assert.equal(agent, null);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Selection logic for the composer live-activity preview.
|
||||
*
|
||||
* The popover shows ONE working agent's feed at a time. Resolution order:
|
||||
* explicit tab selection, then the agent whose session pane is already open,
|
||||
* then the first working agent. A selection that stops working (its agent
|
||||
* leaves the list) silently falls through to the next candidate rather than
|
||||
* pinning a dead tab.
|
||||
*/
|
||||
export function resolveSelectedActivityAgent<T extends { pubkey: string }>({
|
||||
openAgentSessionPubkey,
|
||||
selectedPubkey,
|
||||
workingAgents,
|
||||
}: {
|
||||
openAgentSessionPubkey: string | null;
|
||||
selectedPubkey: string | null;
|
||||
workingAgents: readonly T[];
|
||||
}): T | null {
|
||||
const findByPubkey = (pubkey: string | null) =>
|
||||
pubkey
|
||||
? (workingAgents.find(
|
||||
(agent) => agent.pubkey.toLowerCase() === pubkey.toLowerCase(),
|
||||
) ?? null)
|
||||
: null;
|
||||
|
||||
return (
|
||||
findByPubkey(selectedPubkey) ??
|
||||
findByPubkey(openAgentSessionPubkey) ??
|
||||
workingAgents[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { formatLastLiveLabel } from "./lastLiveLabel.ts";
|
||||
|
||||
const NOW = Date.parse("2026-07-23T12:00:00.000Z");
|
||||
|
||||
test("formatLastLiveLabel reports missing activity", () => {
|
||||
assert.equal(formatLastLiveLabel(null, NOW), "No activity yet");
|
||||
});
|
||||
|
||||
test("formatLastLiveLabel clamps future timestamps to Just now", () => {
|
||||
assert.equal(formatLastLiveLabel(NOW + 5_000, NOW), "Just now");
|
||||
});
|
||||
|
||||
test("formatLastLiveLabel buckets by elapsed time", () => {
|
||||
assert.equal(formatLastLiveLabel(NOW - 30 * 1000, NOW), "Just now");
|
||||
assert.equal(formatLastLiveLabel(NOW - 5 * 60 * 1000, NOW), "5m ago");
|
||||
assert.equal(formatLastLiveLabel(NOW - 3 * 60 * 60 * 1000, NOW), "3h ago");
|
||||
assert.equal(
|
||||
formatLastLiveLabel(NOW - 2 * 24 * 60 * 60 * 1000, NOW),
|
||||
"2d ago",
|
||||
);
|
||||
assert.equal(
|
||||
formatLastLiveLabel(NOW - 3 * 7 * 24 * 60 * 60 * 1000, NOW),
|
||||
"3w ago",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Human-readable recency label for an activity surface's "Last live" pill.
|
||||
* `null` means the agent has never produced observable activity.
|
||||
*/
|
||||
export 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`;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
AgentInstructionRow,
|
||||
} from "@/features/profile/ui/UserProfilePanelAgentDetails";
|
||||
import type { ProfileActivityAgent } from "@/features/profile/lib/profileActivityAgent";
|
||||
import { formatLastLiveLabel } from "@/features/profile/lib/lastLiveLabel";
|
||||
import { resolveActivityChannelId } from "@/features/profile/lib/profileActivityCarousel";
|
||||
import {
|
||||
type ProfileActivityFeedScope,
|
||||
@@ -755,36 +756,6 @@ function LiveActivityOpenButton({
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Tooltip>
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
"name": "Agent-managed profiles",
|
||||
"description": "Let agents manage their own relay name and avatar instead of restoring the desktop copy",
|
||||
"platforms": ["desktop"]
|
||||
},
|
||||
{
|
||||
"id": "composerLiveActivity",
|
||||
"name": "Composer live activity",
|
||||
"description": "Live activity preview for the selected working agent in the agents-working popover below the chat composer",
|
||||
"platforms": ["desktop"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user