diff --git a/AGENTS.md b/AGENTS.md index b1f11bd3db..0c43bd473c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -256,6 +256,24 @@ scripts build the required E2E bridge before running Playwright. See [TESTING.md](TESTING.md) for the full multi-agent E2E guide. +### Never assert a DOM element against `null` with `assert.equal` + +In the desktop jsdom tests (`desktop/src/**/*.test.mjs`), write + +```js +assert.ok(container.querySelector(sel) === null, "no header row"); +``` + +**not** `assert.equal(container.querySelector(sel), null, ...)`. Both pass +identically, but when the `assert.equal` form *fails*, node serializes the +matched element and its entire subtree to build a diff. On a real transcript +that exhausts memory and the runner dies with SIGKILL after ~100s instead of +printing the assertion message — so a genuine regression is unreadable and +looks like a hang or an OOM in unrelated code. The `assert.ok(x === null)` form +fails in milliseconds with the message you wrote. + +Comparing `getAttribute(...)` to `null` is fine — attributes are strings. + ### PR Screenshots > **Do NOT use `buzz upload`, the relay media endpoint, or any third-party diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx index e0f2ce9fbc..85a484deb5 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx @@ -10,6 +10,7 @@ import type { TranscriptItem } from "../agentSessionTypes"; import { getBuzzToolInfo } from "../agentSessionToolCatalog"; import { buildCompactToolSummary } from "../agentSessionToolSummary"; import type { AgentTranscriptIdentityProps } from "../activityRenderClasses/types"; +import { useIsInsideWorkBlockRail } from "../agentSessionTranscriptContext"; import { formatTranscriptTimestampTitle, getToolDurationDisplay, @@ -34,6 +35,7 @@ export function ToolItem({ profiles?: UserProfileLookup; }) { const [isExpanded, setIsExpanded] = React.useState(false); + const insideWorkBlockRail = useIsInsideWorkBlockRail(); const hasArgs = Object.keys(item.args).length > 0; const hasResult = item.result.trim().length > 0; const canonicalToolName = item.buzzToolName ?? item.toolName; @@ -57,7 +59,10 @@ export function ToolItem({ [], ); - if (compactSummary.presentation === "message") { + // On a work block's rail a posted message is a step the agent took, so it + // takes the same muted, expandable row as every other step. Everywhere else + // it keeps the bubble: see `useIsInsideWorkBlockRail`. + if (compactSummary.presentation === "message" && !insideWorkBlockRail) { return (
+ {source} + + ); +} + +export function TurnPromptBlock({ + context, + profiles, + setup, + user, +}: { + context: Extract | null; + profiles?: UserProfileLookup; + setup: Extract[]; + user: Extract; +}) { + return ( +
+ {SHOW_TRANSCRIPT_ACP_SOURCE ? ( +
+ + {context ? ( + + ) : null} +
+ ) : null} + +
+ ); +} + +function PromptUserMessage({ + context = null, + item, + profiles, + setup = [], +}: { + context?: Extract | null; + item: Extract; + profiles?: UserProfileLookup; + setup?: Extract[]; +}) { + const variant = useAgentSessionTranscriptVariant(); + const [contextOpen, setContextOpen] = React.useState(false); + const contextSections = React.useMemo( + () => [...(context?.sections ?? [])], + [context], + ); + + return ( + <> + 0} + items={setup} + messageLink={getTranscriptMessageLink(item)} + onContextOpenChange={setContextOpen} + timestamp={item.timestamp} + /> + } + item={item} + profiles={profiles} + /> + + + ); +} + +function PromptContextDialog({ + onOpenChange, + open, + sections, + setup, +}: { + onOpenChange: (open: boolean) => void; + open: boolean; + sections: PromptSection[]; + setup: Extract[]; +}) { + if (!open || sections.length === 0) { + return null; + } + + const setupText = formatPromptSetupSummary(setup); + + return ( + + +
+ + Prompt context + {setupText ? ( +
+ + {setupText} +
+ ) : null} +
+
+ +
+
+
+
+ ); +} + +function formatPromptSetupSummary( + items: Extract[], +) { + const label = formatTurnSetupLabel(items); + const detail = turnSetupDetail(items); + return [label, detail].filter(Boolean).join(" · "); +} + +function TurnSetupFooter({ + contextOpen = false, + hasContext = false, + items, + messageLink = null, + onContextOpenChange, + showTimestamp = true, + timestamp, +}: { + contextOpen?: boolean; + hasContext?: boolean; + items: Extract[]; + messageLink?: { channelId: string; messageId: string } | null; + onContextOpenChange?: (open: boolean) => void; + showTimestamp?: boolean; + timestamp: string; +}) { + const label = formatTurnSetupLabel(items); + const detail = turnSetupDetail(items); + const tooltipText = [label, detail].filter(Boolean).join(" · "); + const showSetup = items.length > 0; + const showContext = hasContext && onContextOpenChange != null; + + if (!showSetup && !showContext) { + return showTimestamp ? ( + + ) : null; + } + + return ( +
+ {showContext ? ( + + + ) : ( + + + {tooltipText} + + )} + {showTimestamp ? ( + + ) : null} +
+ ); +} + +export function getTranscriptMessageLink( + item: Extract, +) { + if (!item.channelId || !item.messageId) return null; + return { + channelId: item.channelId, + messageId: item.messageId, + }; +} + +export function TurnSetupStatus({ + items, +}: { + items: Extract[]; +}) { + const variant = useAgentSessionTranscriptVariant(); + const timestamp = turnSetupTimestamp(items); + if (items.length === 0 || !timestamp) { + return null; + } + + // Focus mode recedes turn setup to a quiet centered divider: the checks-icon + // summary is ingress plumbing, not something a reader judges the turn by. + if (variant === "conversation") { + return ( + + ); + } + + return ( +
+ +
+ ); +} + +/** + * Horizontal rule rendered between session runs in the observer transcript. + * + * Three label states (based on live-frame observation, not harness affinity): + * - `"current"` — most recent session observed via the live relay subscription. + * - `"most-recent"` — newest visible session with no matching live frames + * (loaded from archive or session ended before observation). + * - `"earlier"` — an older session preceding the most-recent one. + */ +export function SessionBoundaryDivider({ + labelState, + sessionStartTimestamp, +}: { + labelState: "current" | "most-recent" | "earlier"; + sessionStartTimestamp: string; +}) { + const variant = useAgentSessionTranscriptVariant(); + const label = + labelState === "current" + ? "Latest live-observed session" + : labelState === "most-recent" + ? "Most recent observed session" + : "Earlier observed session"; + const formattedDate = new Date(sessionStartTimestamp).toLocaleString(); + + if (variant === "conversation") { + return ( +