diff --git a/AGENTS.md b/AGENTS.md index 6d97f4dfd9..97b67d9cc1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -258,6 +258,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/playwright.config.ts b/desktop/playwright.config.ts index 4c2b6cd7fc..39b57e800f 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -97,6 +97,8 @@ export default defineConfig({ "**/thread-reply-anchor-roleplay.spec.ts", "**/threadpane-ultrawide.spec.ts", "**/thread-focus-mode.spec.ts", + "**/agent-activity-cover.spec.ts", + "**/agent-activity-cover-screenshots.spec.ts", "**/animated-avatar.spec.ts", "**/reminders.spec.ts", "**/reminder-click-repro.spec.ts", diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 468435e15e..4d64a1dd5a 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -75,7 +75,7 @@ import { import { useDueReminderBadgeCount } from "@/features/reminders/hooks"; import { useReminderNotifications } from "@/features/reminders/useReminderNotifications"; import { AppSidebar } from "@/features/sidebar/ui/AppSidebar"; -import { requestFocusedThreadClose } from "@/features/channels/focusedThreadCloseRequest"; +import { requestCoverDrawerClose } from "@/features/channels/coverDrawerCloseRequest"; import { CommunityRail } from "@/features/sidebar/ui/CommunityRail"; import { useChannelMutes } from "@/features/sidebar/lib/useChannelMutes"; import { useChannelStars } from "@/features/sidebar/lib/useChannelStars"; @@ -846,7 +846,7 @@ export function AppShell() { addCommunityDialog.onOpenChange } onNewMessage={goNewMessage} - onBackgroundClick={requestFocusedThreadClose} + onBackgroundClick={requestCoverDrawerClose} onCreateChannelOpenChange={setIsCreateChannelOpen} onOpenAddCommunity={addCommunityDialog.openDialog} onSendFeedback={() => setIsSendFeedbackOpen(true)} diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx index 424837a068..5a3af82346 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx @@ -52,10 +52,13 @@ export function CompactMessageSummary({ const { goChannel } = useAppNavigation(); const { openProfilePanel } = useProfilePanel(); const isCompactPreview = variant === "compactPreview"; - const shouldClampBubble = !isCompactPreview; + const isConversation = variant === "conversation"; + // Focus mode is a reading view, so sent messages remain fully visible there. + // The default activity surface keeps the compact clamp. + const shouldClampBubble = !isCompactPreview && !isConversation; const [bubbleRef, hasBubbleOverflow] = useTranscriptBubbleOverflow(shouldClampBubble); - const canOpenMessage = shouldClampBubble && messageLink !== null; + const canOpenMessage = !isCompactPreview && messageLink !== null; const mutedTone = compactSummaryTone(); const avatarClassName = cn( "mr-2 mt-1 shrink-0", @@ -135,14 +138,20 @@ export function CompactMessageSummary({ testId="transcript-agent-sent-avatar" /> )} -
+
0; const hasResult = item.result.trim().length > 0; const canonicalToolName = item.buzzToolName ?? item.toolName; @@ -57,7 +59,11 @@ export function ToolItem({ [], ); - if (compactSummary.presentation === "message") { + // Message presentations keep their readable bubble whenever they are standalone. + // The conversation grouping keeps message sends standalone rather than placing + // them on a work block rail; this guard preserves the muted row if another + // transcript composition explicitly embeds one in a rail. + 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 ( +