mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
perf(views): cut synchronous first-render cost on channel, inbox, and agents mounts
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 <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
co-authored by
Will Pfleger
parent
d5501b1a1c
commit
2d91b6f958
@@ -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")({
|
||||
|
||||
@@ -180,218 +180,246 @@ export function AgentsView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreateAgentDialog
|
||||
onCreated={(result) => {
|
||||
agents.setLogAgentPubkey(result.agent.pubkey);
|
||||
agents.setCreatedAgent(result);
|
||||
}}
|
||||
onOpenChange={agents.setIsCreateOpen}
|
||||
open={agents.isCreateOpen}
|
||||
/>
|
||||
<AddAgentToChannelDialog
|
||||
agent={agents.agentToAddToChannel}
|
||||
onAdded={agents.handleAddedToChannel}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
agents.setAgentToAddToChannel(null);
|
||||
{agents.isCreateOpen && (
|
||||
<CreateAgentDialog
|
||||
onCreated={(result) => {
|
||||
agents.setLogAgentPubkey(result.agent.pubkey);
|
||||
agents.setCreatedAgent(result);
|
||||
}}
|
||||
onOpenChange={agents.setIsCreateOpen}
|
||||
open={agents.isCreateOpen}
|
||||
/>
|
||||
)}
|
||||
{agents.agentToAddToChannel !== null && (
|
||||
<AddAgentToChannelDialog
|
||||
agent={agents.agentToAddToChannel}
|
||||
onAdded={agents.handleAddedToChannel}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
agents.setAgentToAddToChannel(null);
|
||||
}
|
||||
}}
|
||||
open={agents.agentToAddToChannel !== null}
|
||||
/>
|
||||
)}
|
||||
{agents.createdAgent !== null && (
|
||||
<SecretRevealDialog
|
||||
created={agents.createdAgent}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
agents.setCreatedAgent(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{personas.personaDialogState !== null && (
|
||||
<PersonaDialog
|
||||
description={personas.personaDialogState?.description ?? ""}
|
||||
error={
|
||||
personas.updatePersonaMutation.error instanceof Error
|
||||
? personas.updatePersonaMutation.error
|
||||
: personas.createPersonaMutation.error instanceof Error
|
||||
? personas.createPersonaMutation.error
|
||||
: null
|
||||
}
|
||||
}}
|
||||
open={agents.agentToAddToChannel !== null}
|
||||
/>
|
||||
<SecretRevealDialog
|
||||
created={agents.createdAgent}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
agents.setCreatedAgent(null);
|
||||
initialValues={personas.personaDialogState?.initialValues ?? null}
|
||||
isImportPending={
|
||||
personas.personaImportActions.isApplyingPersonaImportUpdate
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<PersonaDialog
|
||||
description={personas.personaDialogState?.description ?? ""}
|
||||
error={
|
||||
personas.updatePersonaMutation.error instanceof Error
|
||||
? personas.updatePersonaMutation.error
|
||||
: personas.createPersonaMutation.error instanceof Error
|
||||
? personas.createPersonaMutation.error
|
||||
isPending={
|
||||
personas.createPersonaMutation.isPending ||
|
||||
personas.updatePersonaMutation.isPending
|
||||
}
|
||||
runtimes={personas.acpRuntimesQuery.data ?? []}
|
||||
runtimesLoading={personas.acpRuntimesQuery.isLoading}
|
||||
onImportUpdateFile={
|
||||
personas.personaImportActions.handleEditDialogImportUpdateFile
|
||||
}
|
||||
onOpenChange={(open) => {
|
||||
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 && (
|
||||
<PersonaDeleteDialog
|
||||
onConfirm={(persona) => {
|
||||
void personas.handleDelete(persona);
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
personas.setPersonaToDelete(null);
|
||||
}
|
||||
}}
|
||||
open={personas.personaToDelete !== null}
|
||||
persona={personas.personaToDelete}
|
||||
/>
|
||||
)}
|
||||
{personas.isCatalogDialogOpen && (
|
||||
<PersonaCatalogDialog
|
||||
error={
|
||||
personas.personasQuery.error instanceof Error
|
||||
? personas.personasQuery.error
|
||||
: null
|
||||
}
|
||||
initialValues={personas.personaDialogState?.initialValues ?? null}
|
||||
isImportPending={
|
||||
personas.personaImportActions.isApplyingPersonaImportUpdate
|
||||
}
|
||||
isPending={
|
||||
personas.createPersonaMutation.isPending ||
|
||||
personas.updatePersonaMutation.isPending
|
||||
}
|
||||
runtimes={personas.acpRuntimesQuery.data ?? []}
|
||||
runtimesLoading={personas.acpRuntimesQuery.isLoading}
|
||||
onImportUpdateFile={
|
||||
personas.personaImportActions.handleEditDialogImportUpdateFile
|
||||
}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
personas.setPersonaDialogState(null);
|
||||
}
|
||||
}}
|
||||
onSubmit={personas.handleSubmit}
|
||||
open={personas.personaDialogState !== null}
|
||||
submitLabel={personas.personaDialogState?.submitLabel ?? "Save"}
|
||||
title={personas.personaDialogState?.title ?? "Persona"}
|
||||
/>
|
||||
<PersonaDeleteDialog
|
||||
onConfirm={(persona) => {
|
||||
void personas.handleDelete(persona);
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
personas.setPersonaToDelete(null);
|
||||
}
|
||||
}}
|
||||
open={personas.personaToDelete !== null}
|
||||
persona={personas.personaToDelete}
|
||||
/>
|
||||
<PersonaCatalogDialog
|
||||
error={
|
||||
personas.personasQuery.error instanceof Error
|
||||
? personas.personasQuery.error
|
||||
: null
|
||||
}
|
||||
feedbackErrorMessage={
|
||||
personas.personaFeedbackSurface === "catalog"
|
||||
? personas.personaErrorMessage
|
||||
: null
|
||||
}
|
||||
feedbackNoticeMessage={
|
||||
personas.personaFeedbackSurface === "catalog"
|
||||
? personas.personaNoticeMessage
|
||||
: 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}
|
||||
/>
|
||||
<TeamDialog
|
||||
description={teamActions.teamDialogState?.description ?? ""}
|
||||
error={
|
||||
teamActions.updateTeamMutation.error instanceof Error
|
||||
? teamActions.updateTeamMutation.error
|
||||
: teamActions.createTeamMutation.error instanceof Error
|
||||
? teamActions.createTeamMutation.error
|
||||
feedbackErrorMessage={
|
||||
personas.personaFeedbackSurface === "catalog"
|
||||
? personas.personaErrorMessage
|
||||
: null
|
||||
}
|
||||
initialValues={teamActions.teamDialogState?.initialValues ?? null}
|
||||
isImportPending={teamActions.isApplyingTeamImportUpdate}
|
||||
isPending={
|
||||
teamActions.createTeamMutation.isPending ||
|
||||
teamActions.updateTeamMutation.isPending
|
||||
}
|
||||
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"}
|
||||
/>
|
||||
<TeamDeleteDialog
|
||||
onConfirm={(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}
|
||||
/>
|
||||
<AddTeamToChannelDialog
|
||||
onDeployed={teamActions.handleTeamDeployed}
|
||||
onOpenChange={(open) => {
|
||||
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 && (
|
||||
<TeamDialog
|
||||
description={teamActions.teamDialogState?.description ?? ""}
|
||||
error={
|
||||
teamActions.updateTeamMutation.error instanceof Error
|
||||
? teamActions.updateTeamMutation.error
|
||||
: teamActions.createTeamMutation.error instanceof Error
|
||||
? teamActions.createTeamMutation.error
|
||||
: null
|
||||
}
|
||||
}}
|
||||
open={teamActions.teamToAddToChannel !== null}
|
||||
personas={personas.libraryPersonas}
|
||||
team={teamActions.teamToAddToChannel}
|
||||
/>
|
||||
<BatchImportDialog
|
||||
fileName={personas.batchImportFileName}
|
||||
onComplete={personas.handleBatchImportComplete}
|
||||
onOpenChange={(open) => {
|
||||
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}
|
||||
/>
|
||||
<TeamImportDialog
|
||||
fileName={teamActions.teamImportPreview?.fileName ?? ""}
|
||||
onComplete={teamActions.handleTeamImportComplete}
|
||||
onOpenChange={(open) => {
|
||||
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 && (
|
||||
<TeamDeleteDialog
|
||||
onConfirm={(team) => {
|
||||
void teamActions.handleDeleteTeam(team);
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
teamActions.setTeamToDelete(null);
|
||||
}
|
||||
}}
|
||||
open={teamActions.teamToDelete !== null}
|
||||
team={teamActions.teamToDelete}
|
||||
/>
|
||||
)}
|
||||
{teamActions.teamToAddToChannel !== null && (
|
||||
<AddTeamToChannelDialog
|
||||
onDeployed={teamActions.handleTeamDeployed}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
teamActions.setTeamToAddToChannel(null);
|
||||
}
|
||||
}}
|
||||
open={teamActions.teamToAddToChannel !== null}
|
||||
personas={personas.libraryPersonas}
|
||||
team={teamActions.teamToAddToChannel}
|
||||
/>
|
||||
)}
|
||||
{personas.batchImportResult !== null && (
|
||||
<BatchImportDialog
|
||||
fileName={personas.batchImportFileName}
|
||||
onComplete={personas.handleBatchImportComplete}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
personas.setBatchImportResult(null);
|
||||
}
|
||||
}}
|
||||
open={personas.batchImportResult !== null}
|
||||
result={personas.batchImportResult}
|
||||
/>
|
||||
)}
|
||||
{teamActions.teamImportPreview !== null && (
|
||||
<TeamImportDialog
|
||||
fileName={teamActions.teamImportPreview?.fileName ?? ""}
|
||||
onComplete={teamActions.handleTeamImportComplete}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
teamActions.setTeamImportPreview(null);
|
||||
}
|
||||
}}
|
||||
open={teamActions.teamImportPreview !== null}
|
||||
preview={teamActions.teamImportPreview?.preview ?? null}
|
||||
/>
|
||||
)}
|
||||
{teamActions.teamImportTarget !== null && (
|
||||
<TeamImportUpdateDialog
|
||||
fileName={teamActions.teamImportTargetPreview?.fileName ?? ""}
|
||||
isPending={
|
||||
teamActions.isApplyingTeamImportUpdate ||
|
||||
teamActions.updateTeamMutation.isPending
|
||||
}
|
||||
}}
|
||||
open={teamActions.teamImportPreview !== null}
|
||||
preview={teamActions.teamImportPreview?.preview ?? null}
|
||||
/>
|
||||
<TeamImportUpdateDialog
|
||||
fileName={teamActions.teamImportTargetPreview?.fileName ?? ""}
|
||||
isPending={
|
||||
teamActions.isApplyingTeamImportUpdate ||
|
||||
teamActions.updateTeamMutation.isPending
|
||||
}
|
||||
onApply={teamActions.handleTeamImportUpdateApply}
|
||||
onClear={teamActions.clearImportUpdateAndReturnToEdit}
|
||||
onOpenChange={(open) => {
|
||||
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 && (
|
||||
<PersonaImportUpdateDialog
|
||||
fileName={
|
||||
personas.personaImportActions.personaImportTargetPreview
|
||||
?.fileName ?? ""
|
||||
}
|
||||
}}
|
||||
open={teamActions.teamImportTarget !== null}
|
||||
personas={personas.libraryPersonas}
|
||||
preview={teamActions.teamImportTargetPreview?.preview ?? null}
|
||||
team={teamActions.teamImportTarget}
|
||||
/>
|
||||
<PersonaImportUpdateDialog
|
||||
fileName={
|
||||
personas.personaImportActions.personaImportTargetPreview?.fileName ??
|
||||
""
|
||||
}
|
||||
isPending={
|
||||
personas.personaImportActions.isApplyingPersonaImportUpdate ||
|
||||
personas.updatePersonaMutation.isPending
|
||||
}
|
||||
onApply={personas.personaImportActions.handleImportUpdateApply}
|
||||
onClear={personas.personaImportActions.clearImportUpdateAndReturnToEdit}
|
||||
onOpenChange={(open) => {
|
||||
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
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -42,130 +42,153 @@ type InboxMessageRowProps = {
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
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<string | null>(
|
||||
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<string | null>(
|
||||
null,
|
||||
);
|
||||
const {
|
||||
reactions,
|
||||
canToggle: canToggleReactions,
|
||||
pending: reactionPending,
|
||||
errorMessage: reactionErrorMessage,
|
||||
select: handleReactionSelect,
|
||||
} = useReactionHandler(timelineMessage, onToggleReaction);
|
||||
|
||||
return (
|
||||
<div className="relative px-5 py-2">
|
||||
{message.isSelected ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 inset-y-1 transition-opacity duration-1000",
|
||||
isFocusHighlightVisible
|
||||
? "bg-primary/[0.07] opacity-100"
|
||||
: "bg-primary/[0.07] opacity-0",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<article
|
||||
className={cn(
|
||||
"group/message relative flex items-start gap-2.5 px-0 py-0",
|
||||
!message.isSelected && "hover:bg-muted/20",
|
||||
)}
|
||||
data-testid={
|
||||
message.isSelected
|
||||
? "home-inbox-selected-message"
|
||||
: "home-inbox-context-message"
|
||||
}
|
||||
>
|
||||
{canReply || canToggleReactions ? (
|
||||
<div className="absolute right-2 top-1 z-10 sm:top-0 sm:-translate-y-1/2">
|
||||
<MessageActionBar
|
||||
channelId={channelId}
|
||||
message={timelineMessage}
|
||||
onReactionSelect={
|
||||
canToggleReactions ? handleReactionSelect : undefined
|
||||
}
|
||||
onReactionBadgeBurstRequest={
|
||||
reactionPending ? undefined : setBadgeBurstEmoji
|
||||
}
|
||||
onReply={
|
||||
canReply ? () => onSelectReplyTarget(message) : undefined
|
||||
}
|
||||
reactionErrorMessage={reactionErrorMessage}
|
||||
reactions={reactions}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="relative shrink-0">
|
||||
<UserAvatar
|
||||
avatarUrl={message.avatarUrl}
|
||||
className="h-8 w-8 shrink-0"
|
||||
displayName={message.authorLabel}
|
||||
size="md"
|
||||
return (
|
||||
<div className="relative px-5 py-2">
|
||||
{message.isSelected ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 inset-y-1 transition-opacity duration-1000",
|
||||
isFocusHighlightVisible
|
||||
? "bg-primary/[0.07] opacity-100"
|
||||
: "bg-primary/[0.07] opacity-0",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<article
|
||||
className={cn(
|
||||
"group/message relative flex items-start gap-2.5 px-0 py-0",
|
||||
!message.isSelected && "hover:bg-muted/20",
|
||||
)}
|
||||
data-testid={
|
||||
message.isSelected
|
||||
? "home-inbox-selected-message"
|
||||
: "home-inbox-context-message"
|
||||
}
|
||||
>
|
||||
{canReply || canToggleReactions ? (
|
||||
<div className="absolute right-2 top-1 z-10 sm:top-0 sm:-translate-y-1/2">
|
||||
<MessageActionBar
|
||||
channelId={channelId}
|
||||
message={timelineMessage}
|
||||
onReactionSelect={
|
||||
canToggleReactions ? handleReactionSelect : undefined
|
||||
}
|
||||
onReactionBadgeBurstRequest={
|
||||
reactionPending ? undefined : setBadgeBurstEmoji
|
||||
}
|
||||
onReply={
|
||||
canReply ? () => onSelectReplyTarget(message) : undefined
|
||||
}
|
||||
reactionErrorMessage={reactionErrorMessage}
|
||||
reactions={reactions}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0">
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{message.authorLabel}
|
||||
</p>
|
||||
<p className="shrink-0 text-xs font-normal tabular-nums text-muted-foreground/55">
|
||||
{message.fullTimestampLabel}
|
||||
</p>
|
||||
<div className="relative shrink-0">
|
||||
<UserAvatar
|
||||
avatarUrl={message.avatarUrl}
|
||||
className="h-8 w-8 shrink-0"
|
||||
displayName={message.authorLabel}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-0.5">
|
||||
<Markdown
|
||||
className={cn(
|
||||
"max-w-full text-left text-sm text-foreground",
|
||||
emojiOnly &&
|
||||
"text-4xl leading-tight [&_p]:leading-tight [&_img[data-custom-emoji]]:h-[1.45em] [&_img[data-custom-emoji]]:align-middle [&_button:has(img[data-custom-emoji])]:align-middle",
|
||||
)}
|
||||
content={message.content}
|
||||
customEmoji={customEmoji}
|
||||
mentionNames={message.mentionNames}
|
||||
/>
|
||||
<MessageReactions
|
||||
canToggle={canToggleReactions}
|
||||
messageId={message.id}
|
||||
onSelect={(emoji) => {
|
||||
void handleReactionSelect(emoji);
|
||||
}}
|
||||
burstEmojiOnRender={badgeBurstEmoji}
|
||||
onBurstEmojiRendered={(emoji) => {
|
||||
setBadgeBurstEmoji((current) =>
|
||||
current === emoji ? null : current,
|
||||
);
|
||||
}}
|
||||
pending={reactionPending}
|
||||
reactions={reactions}
|
||||
/>
|
||||
{reactionErrorMessage ? (
|
||||
<p className="mt-1.5 text-xs text-destructive">
|
||||
{reactionErrorMessage}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0">
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{message.authorLabel}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="shrink-0 text-xs font-normal tabular-nums text-muted-foreground/55">
|
||||
{message.fullTimestampLabel}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-0.5">
|
||||
<Markdown
|
||||
className={cn(
|
||||
"max-w-full text-left text-sm text-foreground",
|
||||
emojiOnly &&
|
||||
"text-4xl leading-tight [&_p]:leading-tight [&_img[data-custom-emoji]]:h-[1.45em] [&_img[data-custom-emoji]]:align-middle [&_button:has(img[data-custom-emoji])]:align-middle",
|
||||
)}
|
||||
content={message.content}
|
||||
customEmoji={customEmoji}
|
||||
mentionNames={message.mentionNames}
|
||||
/>
|
||||
<MessageReactions
|
||||
canToggle={canToggleReactions}
|
||||
messageId={message.id}
|
||||
onSelect={(emoji) => {
|
||||
void handleReactionSelect(emoji);
|
||||
}}
|
||||
burstEmojiOnRender={badgeBurstEmoji}
|
||||
onBurstEmojiRendered={(emoji) => {
|
||||
setBadgeBurstEmoji((current) =>
|
||||
current === emoji ? null : current,
|
||||
);
|
||||
}}
|
||||
pending={reactionPending}
|
||||
reactions={reactions}
|
||||
/>
|
||||
{reactionErrorMessage ? (
|
||||
<p className="mt-1.5 text-xs text-destructive">
|
||||
{reactionErrorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(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";
|
||||
|
||||
@@ -73,10 +73,16 @@ export const MessageRow = React.memo(
|
||||
searchQuery,
|
||||
showDepthGuides = true,
|
||||
agentPubkeys,
|
||||
channelNames: channelNamesProp,
|
||||
resolvedAgentPubkeys: resolvedAgentPubkeysProp,
|
||||
videoReviewContext,
|
||||
}: {
|
||||
agentPubkeys?: ReadonlySet<string>;
|
||||
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<ThreadDepthGuideAction>;
|
||||
connectDescendants?: boolean;
|
||||
depthGuideDepths?: ReadonlyArray<number>;
|
||||
@@ -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<string>;
|
||||
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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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<string, TimelineMessage[]>(),
|
||||
[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({
|
||||
<MessageRowItem
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
channelNames={channelNames}
|
||||
currentPubkey={currentPubkey}
|
||||
entry={item.entry}
|
||||
followThreadById={followThreadById}
|
||||
@@ -204,6 +232,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
onReply={onReply}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
resolvedAgentPubkeys={resolvedAgentPubkeys}
|
||||
searchActiveMessageId={searchActiveMessageId}
|
||||
searchMatchingMessageIds={searchMatchingMessageIds}
|
||||
searchQuery={searchQuery}
|
||||
@@ -219,6 +248,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
[
|
||||
agentPubkeys,
|
||||
channelId,
|
||||
channelNames,
|
||||
currentPubkey,
|
||||
followThreadById,
|
||||
highlightedMessageId,
|
||||
@@ -230,6 +260,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
onReply,
|
||||
onToggleReaction,
|
||||
profiles,
|
||||
resolvedAgentPubkeys,
|
||||
searchActiveMessageId,
|
||||
searchMatchingMessageIds,
|
||||
searchQuery,
|
||||
@@ -297,14 +328,17 @@ type MessageRowItemProps = Pick<
|
||||
| "threadUnreadCounts"
|
||||
| "unfollowThreadById"
|
||||
> & {
|
||||
channelNames: string[];
|
||||
entry: MainTimelineEntry;
|
||||
footer: React.ReactNode;
|
||||
resolvedAgentPubkeys: ReadonlySet<string>;
|
||||
videoReviewContext: ReturnType<typeof buildVideoReviewContextForMessage>;
|
||||
};
|
||||
|
||||
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({
|
||||
<MessageRow
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
channelNames={channelNames}
|
||||
highlighted={false}
|
||||
hoverBackground={false}
|
||||
isFollowingThread={
|
||||
@@ -369,6 +405,7 @@ function MessageRowItem({
|
||||
: undefined
|
||||
}
|
||||
profiles={profiles}
|
||||
resolvedAgentPubkeys={resolvedAgentPubkeys}
|
||||
showDepthGuides={false}
|
||||
videoReviewContext={videoReviewContext}
|
||||
/>
|
||||
@@ -393,6 +430,7 @@ function MessageRowItem({
|
||||
<MessageRow
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
channelNames={channelNames}
|
||||
highlighted={message.id === highlightedMessageId || isSearchActive}
|
||||
message={message}
|
||||
onDelete={canDelete}
|
||||
@@ -401,6 +439,7 @@ function MessageRowItem({
|
||||
onToggleReaction={onToggleReaction}
|
||||
onReply={onReply}
|
||||
profiles={profiles}
|
||||
resolvedAgentPubkeys={resolvedAgentPubkeys}
|
||||
searchQuery={isSearchMatch ? searchQuery : undefined}
|
||||
showDepthGuides={false}
|
||||
videoReviewContext={videoReviewContext}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
/**
|
||||
* Radix behavioral verification for the AgentsView conditional-mount change.
|
||||
*
|
||||
* The dialogs were switched from always-mounted `<Dialog open={false}>` to
|
||||
* `{isOpen && <Dialog open={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",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user