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;
+}