From 2d91b6f958735d7fb79ce01f8d4e2d9a685dcf70 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Mon, 22 Jun 2026 23:08:07 -0400 Subject: [PATCH] perf(views): cut synchronous first-render cost on channel, inbox, and agents mounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three nav-beachball view transitions were dominated by main-thread first-render work on mount, not chunk-load or data-stall. Each surface had redundant or eagerly-executed work removable without deferral: - Channel: buildVideoReviewCommentsByRootId walked the full message list twice per mount (ChannelPane + TimelineMessageList), O(N^2) in the ancestor walk and unconditional even in channels with no video. TimelineMessageList now gates the whole-map build behind messages.some(hasVideoAttachment); ChannelPane builds a single root's comments on demand. resolvedAgentPubkeys and channelNames were rebuilt per visible row (identical across rows) — hoisted to the list, memoized for stable refs, and added to MessageRow's memo comparator so the hoist doesn't defeat its memoization. - Inbox: InboxMessageRow re-parsed markdown on every HomeView re-render. Wrapped in React.memo with a field-level comparator — its callbacks are recreated inline by HomeView, so a reference comparator would be inert; it compares the render-affecting message fields instead. - Agents: 14 invisible dialogs mounted unconditionally, executing their queries and hooks while closed. Conditionally rendered on each dialog's own open-state, removing closed-dialog work from the mount path. Each dialog mounts already-open; Radix drives its enter transition off data-state on first commit (covered by a tracked behavioral test). - Closed the AgentsView double-lazy preload gap: preloadAgentsScreen now warms the inner AgentsView chunk too, so the first agents navigation doesn't hit a cold chunk. The dynamic import keeps AgentsView its own chunk rather than collapsing it into the index bundle. Measured before/after (median of 3 runs, 4x CPU throttle): script duration dropped on every surface (channel first -63ms, channel switch -60ms, inbox -23ms, agents -32ms). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src/app/routes/agents.tsx | 8 +- desktop/src/features/agents/ui/AgentsView.tsx | 428 ++++++++++-------- .../src/features/channels/ui/ChannelPane.tsx | 10 +- .../src/features/home/ui/InboxMessageRow.tsx | 263 ++++++----- .../src/features/messages/ui/MessageRow.tsx | 23 +- .../messages/ui/TimelineMessageList.tsx | 41 +- desktop/tests/e2e/agents-dialog.perf.ts | 91 ++++ 7 files changed, 532 insertions(+), 332 deletions(-) create mode 100644 desktop/tests/e2e/agents-dialog.perf.ts diff --git a/desktop/src/app/routes/agents.tsx b/desktop/src/app/routes/agents.tsx index 3e11a1a0a..4c8a08de0 100644 --- a/desktop/src/app/routes/agents.tsx +++ b/desktop/src/app/routes/agents.tsx @@ -13,9 +13,15 @@ const AgentsScreen = React.lazy(async () => { return { default: module.AgentsScreen }; }); -/** Warms the AgentsScreen route chunk so first navigation doesn't stall. */ +// AgentsScreen wraps a SECOND lazy boundary (AgentsView), so warming the route +// chunk alone still leaves AgentsView cold on first navigation. Warm both. The +// dynamic import() keeps AgentsView in its own chunk; the loader dedupes +// against AgentsScreen's own lazy import of the same module. +/** Warms the AgentsScreen route chunk (and its inner AgentsView) so first + * navigation doesn't stall. */ export function preloadAgentsScreen(): void { void importAgentsScreen(); + void import("@/features/agents/ui/AgentsView"); } export const Route = createFileRoute("/agents")({ diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 639bd3569..f7ba35813 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -180,218 +180,246 @@ export function AgentsView() { - { - agents.setLogAgentPubkey(result.agent.pubkey); - agents.setCreatedAgent(result); - }} - onOpenChange={agents.setIsCreateOpen} - open={agents.isCreateOpen} - /> - { - if (!open) { - agents.setAgentToAddToChannel(null); + {agents.isCreateOpen && ( + { + agents.setLogAgentPubkey(result.agent.pubkey); + agents.setCreatedAgent(result); + }} + onOpenChange={agents.setIsCreateOpen} + open={agents.isCreateOpen} + /> + )} + {agents.agentToAddToChannel !== null && ( + { + if (!open) { + agents.setAgentToAddToChannel(null); + } + }} + open={agents.agentToAddToChannel !== null} + /> + )} + {agents.createdAgent !== null && ( + { + if (!open) { + agents.setCreatedAgent(null); + } + }} + /> + )} + {personas.personaDialogState !== null && ( + - { - if (!open) { - agents.setCreatedAgent(null); + initialValues={personas.personaDialogState?.initialValues ?? null} + isImportPending={ + personas.personaImportActions.isApplyingPersonaImportUpdate } - }} - /> - { + if (!open) { + personas.setPersonaDialogState(null); + } + }} + onSubmit={personas.handleSubmit} + open={personas.personaDialogState !== null} + submitLabel={personas.personaDialogState?.submitLabel ?? "Save"} + title={personas.personaDialogState?.title ?? "Persona"} + /> + )} + {personas.personaToDelete !== null && ( + { + void personas.handleDelete(persona); + }} + onOpenChange={(open) => { + if (!open) { + personas.setPersonaToDelete(null); + } + }} + open={personas.personaToDelete !== null} + persona={personas.personaToDelete} + /> + )} + {personas.isCatalogDialogOpen && ( + { - if (!open) { - personas.setPersonaDialogState(null); } - }} - onSubmit={personas.handleSubmit} - open={personas.personaDialogState !== null} - submitLabel={personas.personaDialogState?.submitLabel ?? "Save"} - title={personas.personaDialogState?.title ?? "Persona"} - /> - { - void personas.handleDelete(persona); - }} - onOpenChange={(open) => { - if (!open) { - personas.setPersonaToDelete(null); - } - }} - open={personas.personaToDelete !== null} - persona={personas.personaToDelete} - /> - { - personas.clearFeedback("catalog"); - }} - onOpenChange={personas.setIsCatalogDialogOpen} - onSelectPersona={(persona, active) => { - void personas.handleSetActive(persona, active, "catalog"); - }} - open={personas.isCatalogDialogOpen} - personas={personas.catalogPersonas} - /> - { - if (!open) { - teamActions.setTeamDialogState(null); } - }} - onDeleteRemovedPersonas={teamActions.handleDeleteRemovedPersonas} - onSubmit={teamActions.handleTeamSubmit} - open={teamActions.teamDialogState !== null} - personas={personas.libraryPersonas} - submitLabel={teamActions.teamDialogState?.submitLabel ?? "Save"} - title={teamActions.teamDialogState?.title ?? "Team"} - /> - { - void teamActions.handleDeleteTeam(team); - }} - onOpenChange={(open) => { - if (!open) { - teamActions.setTeamToDelete(null); + feedbackNoticeMessage={ + personas.personaFeedbackSurface === "catalog" + ? personas.personaNoticeMessage + : null } - }} - open={teamActions.teamToDelete !== null} - team={teamActions.teamToDelete} - /> - { - if (!open) { - teamActions.setTeamToAddToChannel(null); + isLoading={personas.personasQuery.isLoading} + isPending={personas.setPersonaActiveMutation.isPending} + onClearFeedback={() => { + personas.clearFeedback("catalog"); + }} + onOpenChange={personas.setIsCatalogDialogOpen} + onSelectPersona={(persona, active) => { + void personas.handleSetActive(persona, active, "catalog"); + }} + open={personas.isCatalogDialogOpen} + personas={personas.catalogPersonas} + /> + )} + {teamActions.teamDialogState !== null && ( + - { - if (!open) { - personas.setBatchImportResult(null); + initialValues={teamActions.teamDialogState?.initialValues ?? null} + isImportPending={teamActions.isApplyingTeamImportUpdate} + isPending={ + teamActions.createTeamMutation.isPending || + teamActions.updateTeamMutation.isPending } - }} - open={personas.batchImportResult !== null} - result={personas.batchImportResult} - /> - { - if (!open) { - teamActions.setTeamImportPreview(null); + onImportUpdateFile={teamActions.handleEditDialogImportUpdateFile} + onOpenChange={(open) => { + if (!open) { + teamActions.setTeamDialogState(null); + } + }} + onDeleteRemovedPersonas={teamActions.handleDeleteRemovedPersonas} + onSubmit={teamActions.handleTeamSubmit} + open={teamActions.teamDialogState !== null} + personas={personas.libraryPersonas} + submitLabel={teamActions.teamDialogState?.submitLabel ?? "Save"} + title={teamActions.teamDialogState?.title ?? "Team"} + /> + )} + {teamActions.teamToDelete !== null && ( + { + void teamActions.handleDeleteTeam(team); + }} + onOpenChange={(open) => { + if (!open) { + teamActions.setTeamToDelete(null); + } + }} + open={teamActions.teamToDelete !== null} + team={teamActions.teamToDelete} + /> + )} + {teamActions.teamToAddToChannel !== null && ( + { + if (!open) { + teamActions.setTeamToAddToChannel(null); + } + }} + open={teamActions.teamToAddToChannel !== null} + personas={personas.libraryPersonas} + team={teamActions.teamToAddToChannel} + /> + )} + {personas.batchImportResult !== null && ( + { + if (!open) { + personas.setBatchImportResult(null); + } + }} + open={personas.batchImportResult !== null} + result={personas.batchImportResult} + /> + )} + {teamActions.teamImportPreview !== null && ( + { + if (!open) { + teamActions.setTeamImportPreview(null); + } + }} + open={teamActions.teamImportPreview !== null} + preview={teamActions.teamImportPreview?.preview ?? null} + /> + )} + {teamActions.teamImportTarget !== null && ( + - { - if (!open) { - teamActions.closeImportUpdateDialog(); + onApply={teamActions.handleTeamImportUpdateApply} + onClear={teamActions.clearImportUpdateAndReturnToEdit} + onOpenChange={(open) => { + if (!open) { + teamActions.closeImportUpdateDialog(); + } + }} + open={teamActions.teamImportTarget !== null} + personas={personas.libraryPersonas} + preview={teamActions.teamImportTargetPreview?.preview ?? null} + team={teamActions.teamImportTarget} + /> + )} + {personas.personaImportActions.personaImportTarget !== null && ( + - { - if (!open) { - personas.personaImportActions.closeImportUpdateDialog(); + isPending={ + personas.personaImportActions.isApplyingPersonaImportUpdate || + personas.updatePersonaMutation.isPending } - }} - open={personas.personaImportActions.personaImportTarget !== null} - persona={personas.personaImportActions.personaImportTarget} - preview={ - personas.personaImportActions.personaImportTargetPreview?.preview ?? - null - } - /> + onApply={personas.personaImportActions.handleImportUpdateApply} + onClear={ + personas.personaImportActions.clearImportUpdateAndReturnToEdit + } + onOpenChange={(open) => { + if (!open) { + personas.personaImportActions.closeImportUpdateDialog(); + } + }} + open={personas.personaImportActions.personaImportTarget !== null} + persona={personas.personaImportActions.personaImportTarget} + preview={ + personas.personaImportActions.personaImportTargetPreview?.preview ?? + null + } + /> + )} ); } diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index d910584e7..42b13d008 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -15,7 +15,7 @@ import { import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import { buildDirectMessageIntro } from "@/features/channels/lib/dmParticipantDisplay"; import { - buildVideoReviewCommentsByRootId, + buildVideoReviewCommentsForRoot, buildVideoReviewContextForMessage, } from "@/features/messages/lib/videoReviewContext"; import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding"; @@ -559,10 +559,6 @@ export const ChannelPane = React.memo(function ChannelPane({ return messages.filter((message) => !isWelcomeSetupSystemMessage(message)); }, [activeChannel, messages]); - const videoReviewCommentsByRootId = React.useMemo( - () => buildVideoReviewCommentsByRootId(messages), - [messages], - ); const activeVideoReviewCommentSender = activeChannel?.archivedAt ? undefined : onSendVideoReviewComment; @@ -575,7 +571,7 @@ export const ChannelPane = React.memo(function ChannelPane({ channelId: activeChannel?.id ?? null, channelName: activeChannel?.name, channelType: activeChannel?.channelType ?? null, - comments: videoReviewCommentsByRootId.get(threadHeadMessage.id) ?? [], + comments: buildVideoReviewCommentsForRoot(messages, threadHeadMessage.id), isSendingVideoReviewComment: isSending, message: threadHeadMessage, onSendVideoReviewComment: activeVideoReviewCommentSender, @@ -586,10 +582,10 @@ export const ChannelPane = React.memo(function ChannelPane({ activeChannel, activeVideoReviewCommentSender, isSending, + messages, onToggleReaction, profiles, threadHeadMessage, - videoReviewCommentsByRootId, ]); const isOverlay = useIsThreadPanelOverlay(); diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx index 4588e27c4..f7bc13d17 100644 --- a/desktop/src/features/home/ui/InboxMessageRow.tsx +++ b/desktop/src/features/home/ui/InboxMessageRow.tsx @@ -42,130 +42,153 @@ type InboxMessageRowProps = { ) => Promise; }; -export function InboxMessageRow({ - canReply, - channelId = null, - isFocusHighlightVisible, - message, - onSelectReplyTarget, - onToggleReaction, -}: InboxMessageRowProps) { - const timelineMessage = React.useMemo( - () => toTimelineMessage(message), - [message], - ); - const { customEmoji, emojiOnly } = useMessageEmoji( - message.content, - message.tags, - ); - const [badgeBurstEmoji, setBadgeBurstEmoji] = React.useState( - null, - ); - const { - reactions, - canToggle: canToggleReactions, - pending: reactionPending, - errorMessage: reactionErrorMessage, - select: handleReactionSelect, - } = useReactionHandler(timelineMessage, onToggleReaction); +export const InboxMessageRow = React.memo( + function InboxMessageRow({ + canReply, + channelId = null, + isFocusHighlightVisible, + message, + onSelectReplyTarget, + onToggleReaction, + }: InboxMessageRowProps) { + const timelineMessage = React.useMemo( + () => toTimelineMessage(message), + [message], + ); + const { customEmoji, emojiOnly } = useMessageEmoji( + message.content, + message.tags, + ); + const [badgeBurstEmoji, setBadgeBurstEmoji] = React.useState( + null, + ); + const { + reactions, + canToggle: canToggleReactions, + pending: reactionPending, + errorMessage: reactionErrorMessage, + select: handleReactionSelect, + } = useReactionHandler(timelineMessage, onToggleReaction); - return ( -
- {message.isSelected ? ( - + ); + }, + (prev, next) => + // Callbacks (onSelectReplyTarget, onToggleReaction) intentionally + // excluded: the parent (HomeView) recreates them as inline arrows every + // render, so including them would defeat the memo. They're invoked on + // interaction, never read during render. Compare the content-bearing + // message fields (displayMessages is rebuilt unmemoized upstream, so a + // reference check on `message` would never bite for pending replies). + prev.canReply === next.canReply && + prev.channelId === next.channelId && + prev.isFocusHighlightVisible === next.isFocusHighlightVisible && + prev.message.id === next.message.id && + prev.message.content === next.message.content && + prev.message.avatarUrl === next.message.avatarUrl && + prev.message.authorLabel === next.message.authorLabel && + prev.message.fullTimestampLabel === next.message.fullTimestampLabel && + prev.message.isSelected === next.message.isSelected && + prev.message.reactions === next.message.reactions && + prev.message.tags === next.message.tags && + prev.message.mentionNames === next.message.mentionNames, +); + +InboxMessageRow.displayName = "InboxMessageRow"; diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 63a67a2b1..99b703975 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -73,10 +73,16 @@ export const MessageRow = React.memo( searchQuery, showDepthGuides = true, agentPubkeys, + channelNames: channelNamesProp, + resolvedAgentPubkeys: resolvedAgentPubkeysProp, videoReviewContext, }: { agentPubkeys?: ReadonlySet; channelId?: string | null; + /** Hoisted from the timeline list so it's computed once, not per row. + * Omitted by callers (e.g. the thread panel) that render rows outside the + * virtualized list — those fall back to the per-row context read. */ + channelNames?: string[]; collapseDepthGuideActions?: ReadonlyArray; connectDescendants?: boolean; depthGuideDepths?: ReadonlyArray; @@ -112,6 +118,9 @@ export const MessageRow = React.memo( onReply?: (message: TimelineMessage) => void; onUnfollowThread?: (message: TimelineMessage) => void; profiles?: UserProfileLookup; + /** Hoisted from the timeline list (computed once); falls back to a per-row + * derivation when omitted. */ + resolvedAgentPubkeys?: ReadonlySet; searchQuery?: string; showDepthGuides?: boolean; videoReviewContext?: VideoReviewContext; @@ -140,6 +149,10 @@ export const MessageRow = React.memo( [profiles, message.tags], ); const resolvedAgentPubkeys = React.useMemo(() => { + if (resolvedAgentPubkeysProp) { + return resolvedAgentPubkeysProp; + } + const pubkeys = new Set(agentPubkeys ?? []); for (const [pubkey, profile] of Object.entries(profiles ?? {})) { @@ -149,7 +162,7 @@ export const MessageRow = React.memo( } return pubkeys; - }, [agentPubkeys, profiles]); + }, [agentPubkeys, profiles, resolvedAgentPubkeysProp]); const agentMentionPubkeysByName = React.useMemo(() => { if (!mentionPubkeysByName) { return undefined; @@ -178,8 +191,10 @@ export const MessageRow = React.memo( const { channels } = useChannelNavigation(); const channelNames = React.useMemo( - () => channels.filter((c) => c.channelType !== "dm").map((c) => c.name), - [channels], + () => + channelNamesProp ?? + channels.filter((c) => c.channelType !== "dm").map((c) => c.name), + [channelNamesProp, channels], ); const indentPx = getThreadReplyIndentPx(message.depth); @@ -747,6 +762,8 @@ export const MessageRow = React.memo( next.onCollapseDescendantsHoverChange && prev.profiles === next.profiles && prev.searchQuery === next.searchQuery && + prev.channelNames === next.channelNames && + prev.resolvedAgentPubkeys === next.resolvedAgentPubkeys && prev.videoReviewContext === next.videoReviewContext, ); diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 4e91a01a8..1b79da24b 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -12,11 +12,14 @@ import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; import { buildVideoReviewCommentsByRootId, buildVideoReviewContextForMessage, + hasVideoAttachment, } from "@/features/messages/lib/videoReviewContext"; import type { TimelineMessage } from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ChannelType } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; +import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { type ListVirtualizer, VirtualizedList, @@ -112,7 +115,10 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ [messages], ); const reviewCommentsByRootId = React.useMemo( - () => buildVideoReviewCommentsByRootId(messages), + () => + messages.some(hasVideoAttachment) + ? buildVideoReviewCommentsByRootId(messages) + : new Map(), [messages], ); // Contexts are memoized per message id so MessageRow/Markdown memo @@ -154,6 +160,27 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ reviewCommentsByRootId, ]); + // Hoisted from MessageRow: both depend only on list-level data (agent + // pubkeys + profiles, and the channel list), so computing them per visible + // row was N_visible × redundant work on every mount. Memoized here so the + // references stay stable across unrelated re-renders — MessageRow is + // React.memo, and a fresh Set/array per render would defeat its comparator. + const resolvedAgentPubkeys = React.useMemo(() => { + const pubkeys = new Set(agentPubkeys ?? []); + for (const [pubkey, profile] of Object.entries(profiles ?? {})) { + if (profile.isAgent) { + pubkeys.add(normalizePubkey(pubkey)); + } + } + return pubkeys; + }, [agentPubkeys, profiles]); + + const { channels } = useChannelNavigation(); + const channelNames = React.useMemo( + () => channels.filter((c) => c.channelType !== "dm").map((c) => c.name), + [channels], + ); + // The flattened item stream and its messageId -> itemIndex map are produced // together from ONE memo, keyed on the entries and the unread boundary (the // unread divider is its own item, so it shifts indices). A separate memo with @@ -192,6 +219,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ & { + channelNames: string[]; entry: MainTimelineEntry; footer: React.ReactNode; + resolvedAgentPubkeys: ReadonlySet; videoReviewContext: ReturnType; }; function MessageRowItem({ agentPubkeys, channelId, + channelNames, currentPubkey, entry, followThreadById, @@ -317,6 +351,7 @@ function MessageRowItem({ onReply, onToggleReaction, profiles, + resolvedAgentPubkeys, searchActiveMessageId, searchMatchingMessageIds, searchQuery, @@ -347,6 +382,7 @@ function MessageRowItem({ @@ -393,6 +430,7 @@ function MessageRowItem({ ` to + * `{isOpen && }`, so they now mount ALREADY OPEN. + * Mount-already-open is exactly where Radix can bite, so this proves at + * runtime (against the built dist) the three behaviors Paul required: + * 1. enter animation plays (data-state=open present + animate-in class) + * 2. focus-trap / portal works (focus moves into the portaled dialog) + * 3. open -> close -> reopen cycle (unmount on close, remount clean) + * + * Tracked regression guard under the perf project (serves dist on :4173). + * Assertions are behavioral (data-state, class presence, focus location, + * element count) — never timing thresholds — so it can't go red on render + * drift. + */ + +test("AGENTS-DIALOG: conditional-mount Radix behavior (enter anim, focus-trap, reopen)", async ({ + page, +}) => { + await installMockBridge(page, { + managedAgents: [ + { pubkey: "a".repeat(64), name: "Agent One", status: "running" }, + ], + }); + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + + await page.getByTestId("open-agents-view").click(); + await page.getByTestId("agents-library-personas").waitFor(); + + const openCreateDialog = async () => { + await page + .getByTestId("agents-library-personas") + .locator('button[aria-haspopup="menu"]', { hasText: "New" }) + .click(); + const item = page.getByRole("menuitem", { name: "Custom Agent" }); + await item.waitFor({ timeout: 5000 }); + // Let the dropdown's open animation settle so the item is stable, not + // mid-transition (Radix re-parents/animates menu content on open). + await page.waitForTimeout(300); + await item.click(); + }; + + // --- 1 + 2: open the dialog (mounts already-open), prove enter anim + focus --- + await openCreateDialog(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + + // (1) Enter animation: Radix drives the enter off data-state on first DOM + // commit. animate-in is the CSS enter keyframe class on the content. + const dataState = await dialog.getAttribute("data-state"); + expect(dataState).toBe("open"); + const className = (await dialog.getAttribute("class")) ?? ""; + expect(className).toContain("animate-in"); + expect(className).toContain("data-[state=open]:fade-in-0"); + + // (2) Focus-trap / portal: focus must move INTO the portaled dialog subtree. + const focusInside = await page.evaluate(() => { + const dlg = document.querySelector('[role="dialog"]'); + return !!dlg && dlg.contains(document.activeElement); + }); + expect(focusInside).toBe(true); + + // --- 3: open -> close -> reopen cycle --- + // Close via Escape (drives onOpenChange(false) -> state reset -> unmount). + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toHaveCount(0); + // Let the close/exit settle (focus returns to trigger, exit anim detaches) + // before re-driving the open flow. + await page.waitForTimeout(400); + + // Reopen: must mount clean again (proves the conditional remounts, the + // onOpenChange handler reset state, and no stale node lingered). + await openCreateDialog(); + await expect(page.getByRole("dialog")).toBeVisible(); + expect(await page.getByRole("dialog").getAttribute("data-state")).toBe( + "open", + ); + + // eslint-disable-next-line no-console + console.log( + "\n=== AGENTS-DIALOG CHECKS: enter-anim OK, focus-trap OK, reopen OK ===\n", + ); +});