diff --git a/desktop/src/features/channels/ui/ComposerLiveActivityFeed.tsx b/desktop/src/features/channels/ui/ComposerLiveActivityFeed.tsx index 607bda57a..4940e8f12 100644 --- a/desktop/src/features/channels/ui/ComposerLiveActivityFeed.tsx +++ b/desktop/src/features/channels/ui/ComposerLiveActivityFeed.tsx @@ -1,16 +1,18 @@ 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 { + useAgentTranscript, + useArchivedChannelEvents, +} 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"; +import { deriveLastLiveAt } from "./composerLiveActivity"; /** * Single-agent live activity preview for the composer "agents working" @@ -22,8 +24,10 @@ import type { BotActivityAgent } from "./BotActivityBar"; * 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. + * full activity view. The panel subtree is wrapped in a native `inert` + * container so its rows (which can include keyboard-focusable message links) + * are removed from pointer, keyboard-tab, AND assistive-tech interaction — + * CSS pointer-events alone would leave them tabbable behind the overlay. */ export function ComposerLiveActivityFeed({ agent, @@ -40,26 +44,12 @@ export function ComposerLiveActivityFeed({ }) { 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 archivedEvents = useArchivedChannelEvents(agent.pubkey, channelId); + const lastLiveAt = React.useMemo( + () => + deriveLastLiveAt({ activeTurns, archivedEvents, channelId, transcript }), + [activeTurns, archivedEvents, channelId, transcript], + ); const now = useNow(15_000); const lastLiveLabel = formatLastLiveLabel(lastLiveAt, now); @@ -92,21 +82,27 @@ export function ComposerLiveActivityFeed({ > {lastLiveLabel} - +
+ +
); } diff --git a/desktop/src/features/channels/ui/composerLiveActivity.test.mjs b/desktop/src/features/channels/ui/composerLiveActivity.test.mjs index 8726b5edb..16b9c4e24 100644 --- a/desktop/src/features/channels/ui/composerLiveActivity.test.mjs +++ b/desktop/src/features/channels/ui/composerLiveActivity.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { resolveSelectedActivityAgent } from "./composerLiveActivity.ts"; +import { + deriveLastLiveAt, + resolveSelectedActivityAgent, +} from "./composerLiveActivity.ts"; const alice = { pubkey: "ALICE-pubkey", name: "Alice" }; const bob = { pubkey: "bob-pubkey", name: "Bob" }; @@ -50,3 +53,65 @@ test("returns null with no working agents", () => { }); assert.equal(agent, null); }); + +const CHANNEL = "channel-1"; +const OTHER_CHANNEL = "channel-2"; + +test("deriveLastLiveAt prefers the newest channel-scoped transcript item", () => { + const lastLiveAt = deriveLastLiveAt({ + activeTurns: [], + archivedEvents: [], + channelId: CHANNEL, + transcript: [ + { channelId: CHANNEL, timestamp: "2026-07-23T00:00:01.000Z" }, + { channelId: OTHER_CHANNEL, timestamp: "2026-07-23T00:00:09.000Z" }, + { channelId: CHANNEL, timestamp: "2026-07-23T00:00:05.000Z" }, + ], + }); + assert.equal(lastLiveAt, Date.parse("2026-07-23T00:00:05.000Z")); +}); + +test("deriveLastLiveAt sees archived content the panel renders", () => { + // Regression: archived rows are visible in the preview even when the live + // transcript window is empty — the pill must not say "No activity yet". + const lastLiveAt = deriveLastLiveAt({ + activeTurns: [], + archivedEvents: [{ timestamp: "2026-07-20T10:00:00.000Z" }], + channelId: CHANNEL, + transcript: [], + }); + assert.equal(lastLiveAt, Date.parse("2026-07-20T10:00:00.000Z")); +}); + +test("deriveLastLiveAt takes the newest across live, archive, and turn anchor", () => { + const lastLiveAt = deriveLastLiveAt({ + activeTurns: [ + { anchorAt: Date.parse("2026-07-23T00:00:30.000Z"), channelId: CHANNEL }, + ], + archivedEvents: [{ timestamp: "2026-07-23T00:00:10.000Z" }], + channelId: CHANNEL, + transcript: [{ channelId: CHANNEL, timestamp: "2026-07-23T00:00:20.000Z" }], + }); + assert.equal(lastLiveAt, Date.parse("2026-07-23T00:00:30.000Z")); +}); + +test("deriveLastLiveAt falls back to the active-turn anchor with no items", () => { + const anchorAt = Date.parse("2026-07-23T00:01:00.000Z"); + const lastLiveAt = deriveLastLiveAt({ + activeTurns: [{ anchorAt, channelId: CHANNEL }], + archivedEvents: [], + channelId: CHANNEL, + transcript: [], + }); + assert.equal(lastLiveAt, anchorAt); +}); + +test("deriveLastLiveAt ignores other-channel turns and returns null when idle", () => { + const lastLiveAt = deriveLastLiveAt({ + activeTurns: [{ anchorAt: 1, channelId: OTHER_CHANNEL }], + archivedEvents: [], + channelId: CHANNEL, + transcript: [], + }); + assert.equal(lastLiveAt, null); +}); diff --git a/desktop/src/features/channels/ui/composerLiveActivity.ts b/desktop/src/features/channels/ui/composerLiveActivity.ts index 6ff72ab03..c07ea768b 100644 --- a/desktop/src/features/channels/ui/composerLiveActivity.ts +++ b/desktop/src/features/channels/ui/composerLiveActivity.ts @@ -30,3 +30,67 @@ export function resolveSelectedActivityAgent({ null ); } + +/** + * Latest activity timestamp (ms) for the composer preview's "Last live" pill. + * + * Reads the same sources the preview panel renders — the live transcript + * window AND the channel-scoped archive — so the pill can never claim + * "No activity yet" while archived rows are visible underneath. Falls back + * to the active-turn anchor when a turn is running but no items exist yet. + */ +export function deriveLastLiveAt({ + activeTurns, + archivedEvents, + channelId, + transcript, +}: { + activeTurns: readonly { anchorAt: number; channelId: string }[]; + archivedEvents: readonly { timestamp: string }[]; + channelId: string | null; + transcript: readonly { + channelId?: string | null; + timestamp: string; + }[]; +}): number | null { + let latest: number | null = null; + const record = (timestamp: number) => { + if (latest === null || timestamp > latest) { + latest = timestamp; + } + }; + + for (let index = transcript.length - 1; index >= 0; index -= 1) { + const item = transcript[index]; + if (!item || (channelId && item.channelId !== channelId)) { + continue; + } + const millis = Date.parse(item.timestamp); + if (!Number.isNaN(millis)) { + record(millis); + break; + } + } + + // Archived events are already channel-scoped by the store, sorted ascending. + for (let index = archivedEvents.length - 1; index >= 0; index -= 1) { + const event = archivedEvents[index]; + if (!event) { + continue; + } + const millis = Date.parse(event.timestamp); + if (!Number.isNaN(millis)) { + record(millis); + break; + } + } + + const channelTurn = channelId + ? activeTurns.find((turn) => turn.channelId === channelId) + : activeTurns[0]; + if (channelTurn) { + record(channelTurn.anchorAt); + } + + return latest; +}