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:
Taylor Ho
2026-08-06 16:59:34 -07:00
parent 1399ec1d13
commit 1ed10f9629
8 changed files with 392 additions and 68 deletions
@@ -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>
+6
View File
@@ -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"]
}
]
}