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:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-06-22 23:08:07 -04:00
co-authored by Will Pfleger
parent d5501b1a1c
commit 2d91b6f958
7 changed files with 532 additions and 332 deletions
+7 -1
View File
@@ -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")({
+228 -200
View File
@@ -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();
+143 -120
View File
@@ -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}
+91
View File
@@ -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",
);
});