mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user