diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index d685a9610..cd3dded72 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -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; +export type BotActivityAgent = Pick; 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 | 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({ event.preventDefault()} side="top" sideOffset={8} > -
- Agents working -
-
- {workingAgents.map((agent) => { - const isSelected = selectedPubkey === agent.pubkey.toLowerCase(); - - return ( - - ); - })} -
+ profiles={profiles} + /> + ) : null} +
+ {workingAgents.map((agent) => { + const isSelected = + selectedActivityAgent?.pubkey.toLowerCase() === + agent.pubkey.toLowerCase(); + + return ( + + ); + })} +
+ + ) : ( + <> +
+ Agents working +
+
+ {workingAgents.map((agent) => { + const isSelected = + selectedPubkey === agent.pubkey.toLowerCase(); + + return ( + + ); + })} +
+ + )}
); diff --git a/desktop/src/features/channels/ui/ComposerLiveActivityFeed.tsx b/desktop/src/features/channels/ui/ComposerLiveActivityFeed.tsx new file mode 100644 index 000000000..607bda57a --- /dev/null +++ b/desktop/src/features/channels/ui/ComposerLiveActivityFeed.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/desktop/src/features/channels/ui/composerLiveActivity.test.mjs b/desktop/src/features/channels/ui/composerLiveActivity.test.mjs new file mode 100644 index 000000000..8726b5edb --- /dev/null +++ b/desktop/src/features/channels/ui/composerLiveActivity.test.mjs @@ -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); +}); diff --git a/desktop/src/features/channels/ui/composerLiveActivity.ts b/desktop/src/features/channels/ui/composerLiveActivity.ts new file mode 100644 index 000000000..6ff72ab03 --- /dev/null +++ b/desktop/src/features/channels/ui/composerLiveActivity.ts @@ -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({ + 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 + ); +} diff --git a/desktop/src/features/profile/lib/lastLiveLabel.test.mjs b/desktop/src/features/profile/lib/lastLiveLabel.test.mjs new file mode 100644 index 000000000..e52193cc8 --- /dev/null +++ b/desktop/src/features/profile/lib/lastLiveLabel.test.mjs @@ -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", + ); +}); diff --git a/desktop/src/features/profile/lib/lastLiveLabel.ts b/desktop/src/features/profile/lib/lastLiveLabel.ts new file mode 100644 index 000000000..7013a2cfa --- /dev/null +++ b/desktop/src/features/profile/lib/lastLiveLabel.ts @@ -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`; +} diff --git a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx index be6d5add4..25ab0b3be 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx @@ -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 ( diff --git a/preview-features.json b/preview-features.json index 388f1c39b..de4c61246 100644 --- a/preview-features.json +++ b/preview-features.json @@ -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"] } ] }