fix(composer): make live-activity preview subtree inert to keyboard and AT

Wrap the ManagedAgentSessionPanel subtree in a native inert container so
compact transcript rows (which can render keyboard-focusable message
links) are removed from pointer, tab-order, and assistive-tech
interaction — CSS pointer-events-none only neutralized the mouse. The
overlay open button and Last-live pill stay accessible.

Also extract deriveLastLiveAt and make it archive-aware: the Last-live
pill now takes the newest across the live transcript window, archived
channel events, and the active-turn anchor, so it can no longer disagree
with archived rows visible in the preview.

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-05 16:41:55 -07:00
committed by Carl
parent 9029063810
commit bf67337a66
3 changed files with 166 additions and 41 deletions
@@ -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}
</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
className="h-full min-h-0"
data-testid="composer-live-activity-inert"
inert={true}
>
<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"
emptyDescription="Live activity will appear here."
emptyState="loading"
panelPadding={false}
profiles={profiles}
rawLayout="responsive"
showHeader={false}
showRaw={false}
transcriptContentClassName="py-2"
transcriptVariant="compactPreview"
/>
</div>
</div>
);
}
@@ -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);
});
@@ -30,3 +30,67 @@ export function resolveSelectedActivityAgent<T extends { pubkey: string }>({
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;
}