diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 7df2ed4c6f3..96e3fd70173 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -25,7 +25,8 @@ import { issueChatLongThreadTranscriptsByRunId, } from "../fixtures/issueChatLongThreadFixture"; -const { markdownEditorFocusMock } = vi.hoisted(() => ({ +const { markdownBodyRenderMock, markdownEditorFocusMock } = vi.hoisted(() => ({ + markdownBodyRenderMock: vi.fn(), markdownEditorFocusMock: vi.fn(), })); @@ -66,7 +67,10 @@ vi.mock("../lib/issue-chat-scroll", async (importOriginal) => { }); vi.mock("./MarkdownBody", () => ({ - MarkdownBody: ({ children }: { children: ReactNode }) =>
{children}
, + MarkdownBody: ({ children }: { children: ReactNode }) => { + markdownBodyRenderMock(children); + return
{children}
; + }, })); vi.mock("./MarkdownEditor", () => ({ @@ -291,6 +295,7 @@ describe("IssueChatThread", () => { captureComposerViewportSnapshotMock.mockClear(); restoreComposerViewportSnapshotMock.mockClear(); shouldPreserveComposerViewportMock.mockClear(); + markdownBodyRenderMock.mockClear(); }); it("drops the count heading and does not use an internal scrollbox", () => { @@ -358,6 +363,139 @@ describe("IssueChatThread", () => { }); }); + it("does not re-render long-thread markdown rows for unrelated layout updates", () => { + const root = createRoot(container); + const onAdd = async () => {}; + const hasOutputForRun = (runId: string) => issueChatLongThreadTranscriptsByRunId.has(runId); + + act(() => { + root.render( + + + , + ); + }); + + expect(markdownBodyRenderMock).toHaveBeenCalled(); + markdownBodyRenderMock.mockClear(); + + act(() => { + root.render( + + + , + ); + }); + + expect(markdownBodyRenderMock).not.toHaveBeenCalled(); + + act(() => { + root.unmount(); + }); + }); + + it("does not re-render unchanged markdown when feedback votes change", () => { + const root = createRoot(container); + const onAdd = async () => {}; + const onVote = async () => {}; + const comments = [{ + id: "comment-agent-feedback", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: "agent-1", + authorUserId: null, + body: "Agent summary with **markdown**", + createdAt: new Date("2026-04-06T12:00:00.000Z"), + updatedAt: new Date("2026-04-06T12:00:00.000Z"), + }]; + + act(() => { + root.render( + + + , + ); + }); + + expect(markdownBodyRenderMock).toHaveBeenCalled(); + markdownBodyRenderMock.mockClear(); + + act(() => { + root.render( + + + , + ); + }); + + expect(markdownBodyRenderMock).not.toHaveBeenCalled(); + + act(() => { + root.unmount(); + }); + }); + it("shows explicit follow-up badges and event copy", () => { const root = createRoot(container); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index e533ed23b32..ec47106a228 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -12,6 +12,7 @@ import { createContext, Component, forwardRef, + memo, useContext, useEffect, useImperativeHandle, @@ -107,24 +108,20 @@ import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loa import { IssueBlockedNotice } from "./IssueBlockedNotice"; interface IssueChatMessageContext { - feedbackVoteByTargetId: Map; feedbackDataSharingPreference: FeedbackDataSharingPreference; feedbackTermsUrl: string | null; agentMap?: Map; currentUserId?: string | null; userLabelMap?: ReadonlyMap | null; userProfileMap?: ReadonlyMap | null; - activeRunIds: ReadonlySet; onVote?: ( commentId: string, vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }, ) => Promise; onStopRun?: (runId: string) => Promise; - stoppingRunId?: string | null; onInterruptQueued?: (runId: string) => Promise; onCancelQueued?: (commentId: string) => void; - interruptingQueuedRunId?: string | null; onImageClick?: (src: string) => void; onAcceptInteraction?: ( interaction: SuggestTasksInteraction | RequestConfirmationInteraction, @@ -141,10 +138,8 @@ interface IssueChatMessageContext { } const IssueChatCtx = createContext({ - feedbackVoteByTargetId: new Map(), feedbackDataSharingPreference: "prompt", feedbackTermsUrl: null, - activeRunIds: new Set(), }); export function resolveAssistantMessageFoldedState(args: { @@ -209,6 +204,18 @@ function useLiveElapsed(startMs: number | null | undefined, active: boolean): st return formatDurationWords(Date.now() - startMs); } +function useStableEvent unknown>(callback: T | undefined): T | undefined { + const callbackRef = useRef(callback); + useLayoutEffect(() => { + callbackRef.current = callback; + }, [callback]); + + return useMemo(() => { + if (!callback) return undefined; + return ((...args: Parameters) => callbackRef.current?.(...args)) as T; + }, [Boolean(callback)]); +} + interface CommentReassignment { assigneeAgentId: string | null; assigneeUserId: string | null; @@ -547,7 +554,7 @@ function commentDateLabel(date: Date | string | undefined): string { return formatShortDate(date); } -function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) { +const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) { const { onImageClick } = useContext(IssueChatCtx); return ( ); -} +}); function humanizeValue(value: string | null) { if (!value) return "None"; @@ -1047,7 +1054,7 @@ function getThreadMessageCopyText(message: ThreadMessage) { .join("\n\n"); } -function IssueChatTextParts({ +const IssueChatTextParts = memo(function IssueChatTextParts({ message, recessed = false, }: { @@ -1067,7 +1074,7 @@ function IssueChatTextParts({ ))} ); -} +}); function groupAssistantParts( content: readonly ThreadMessage["content"][number][], @@ -1105,16 +1112,17 @@ function groupAssistantParts( return groups; } -function IssueChatAssistantParts({ +const IssueChatAssistantParts = memo(function IssueChatAssistantParts({ message, hasCoT, }: { message: ThreadMessage; hasCoT: boolean; }) { + const groupedParts = useMemo(() => groupAssistantParts(message.content), [message.content]); return ( <> - {groupAssistantParts(message.content).map((group) => { + {groupedParts.map((group) => { if (group.type === "text") { return ( ); -} +}); -function IssueChatUserMessage({ message }: { message: ThreadMessage }) { +function IssueChatUserMessage({ + message, + isInterruptingQueuedRun, +}: { + message: ThreadMessage; + isInterruptingQueuedRun: boolean; +}) { const { onInterruptQueued, onCancelQueued, - interruptingQueuedRunId, currentUserId, userProfileMap, } = useContext(IssueChatCtx); @@ -1201,10 +1214,10 @@ function IssueChatUserMessage({ message }: { message: ThreadMessage }) { size="sm" variant="outline" className="h-6 border-red-300 px-2 text-[11px] text-red-700 hover:bg-red-50 hover:text-red-800 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/10" - disabled={interruptingQueuedRunId === queueTargetRunId} + disabled={isInterruptingQueuedRun} onClick={() => void onInterruptQueued(queueTargetRunId)} > - {interruptingQueuedRunId === queueTargetRunId ? "Interrupting..." : "Interrupt"} + {isInterruptingQueuedRun ? "Interrupting..." : "Interrupt"} ) : null} {onCancelQueued ? ( @@ -1290,16 +1303,23 @@ function IssueChatUserMessage({ message }: { message: ThreadMessage }) { ); } -function IssueChatAssistantMessage({ message }: { message: ThreadMessage }) { +function IssueChatAssistantMessage({ + message, + activeVote, + isRunActive, + isStoppingRun, +}: { + message: ThreadMessage; + activeVote: FeedbackVoteValue | null; + isRunActive: boolean; + isStoppingRun: boolean; +}) { const { - feedbackVoteByTargetId, feedbackDataSharingPreference, feedbackTermsUrl, onVote, agentMap, - activeRunIds, onStopRun, - stoppingRunId, } = useContext(IssueChatCtx); const custom = message.metadata.custom as Record; const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined; @@ -1321,7 +1341,7 @@ function IssueChatAssistantMessage({ message }: { message: ThreadMessage }) { const waitingText = typeof custom.waitingText === "string" ? custom.waitingText : ""; const isRunning = message.role === "assistant" && message.status?.type === "running"; const runHref = runId && runAgentId ? `/agents/${runAgentId}/runs/${runId}` : null; - const canStopRun = canStopIssueChatRun({ runId, runStatus, activeRunIds }); + const canStopRun = Boolean(runId) && (isRunActive || runStatus === "queued" || runStatus === "running"); const chainOfThoughtLabel = typeof custom.chainOfThoughtLabel === "string" ? custom.chainOfThoughtLabel : null; const hasCoT = message.content.some((p) => p.type === "reasoning" || p.type === "tool-call"); const isFoldable = !isRunning && !!chainOfThoughtLabel; @@ -1355,7 +1375,6 @@ function IssueChatAssistantMessage({ message }: { message: ThreadMessage }) { await onVote(commentId, vote, options); }; - const activeVote = commentId ? feedbackVoteByTargetId.get(commentId) ?? null : null; const followUpRequested = custom.followUpRequested === true; return ( @@ -1493,14 +1512,14 @@ function IssueChatAssistantMessage({ message }: { message: ThreadMessage }) { {canStopRun && onStopRun && runId ? ( { void onStopRun(runId); }} > - {stoppingRunId === runId ? "Stopping…" : "Stop run"} + {isStoppingRun ? "Stopping…" : "Stop run"} ) : null} {runHref ? ( @@ -2022,6 +2041,123 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) { return null; } +function issueChatMessageCustom(message: ThreadMessage): Record { + return (message.metadata?.custom ?? {}) as Record; +} + +function issueChatMessageKind(message: ThreadMessage): string { + const custom = issueChatMessageCustom(message); + return typeof custom.kind === "string" ? custom.kind : message.role; +} + +function issueChatMessageCommentId(message: ThreadMessage): string | null { + const custom = issueChatMessageCustom(message); + return typeof custom.commentId === "string" ? custom.commentId : null; +} + +function issueChatMessageRunId(message: ThreadMessage): string | null { + const custom = issueChatMessageCustom(message); + return typeof custom.runId === "string" ? custom.runId : null; +} + +function issueChatMessageQueueTargetRunId(message: ThreadMessage): string | null { + const custom = issueChatMessageCustom(message); + return typeof custom.queueTargetRunId === "string" ? custom.queueTargetRunId : null; +} + +function issueChatMessageActiveVote( + message: ThreadMessage, + feedbackVoteByTargetId: ReadonlyMap, +): FeedbackVoteValue | null { + const commentId = issueChatMessageCommentId(message); + return commentId ? feedbackVoteByTargetId.get(commentId) ?? null : null; +} + +function issueChatMessageRunIsActive( + message: ThreadMessage, + activeRunIds: ReadonlySet, +): boolean { + const runId = issueChatMessageRunId(message); + return Boolean(runId && activeRunIds.has(runId)); +} + +function issueChatMessageRunIsStopping( + message: ThreadMessage, + stoppingRunId: string | null | undefined, +): boolean { + const runId = issueChatMessageRunId(message); + return Boolean(runId && stoppingRunId === runId); +} + +function issueChatMessageQueuedRunIsInterrupting( + message: ThreadMessage, + interruptingQueuedRunId: string | null | undefined, +): boolean { + const queueTargetRunId = issueChatMessageQueueTargetRunId(message); + return Boolean(queueTargetRunId && interruptingQueuedRunId === queueTargetRunId); +} + +interface IssueChatMessageRowProps { + message: ThreadMessage; + feedbackVoteByTargetId: ReadonlyMap; + activeRunIds: ReadonlySet; + stoppingRunId?: string | null; + interruptingQueuedRunId?: string | null; +} + +const IssueChatMessageRow = memo(function IssueChatMessageRow({ + message, + feedbackVoteByTargetId, + activeRunIds, + stoppingRunId, + interruptingQueuedRunId, +}: IssueChatMessageRowProps) { + const kind = issueChatMessageKind(message); + const activeVote = issueChatMessageActiveVote(message, feedbackVoteByTargetId); + const isRunActive = issueChatMessageRunIsActive(message, activeRunIds); + const isStoppingRun = issueChatMessageRunIsStopping(message, stoppingRunId); + const isInterruptingQueuedRun = issueChatMessageQueuedRunIsInterrupting(message, interruptingQueuedRunId); + const renderedMessage = message.role === "user" + ? ( + + ) + : message.role === "assistant" + ? ( + + ) + : ; + + return ( +
+ {renderedMessage} +
+ ); +}, areIssueChatMessageRowPropsEqual); + +function areIssueChatMessageRowPropsEqual( + prev: IssueChatMessageRowProps, + next: IssueChatMessageRowProps, +) { + if (prev.message !== next.message) return false; + if (issueChatMessageActiveVote(prev.message, prev.feedbackVoteByTargetId) !== issueChatMessageActiveVote(next.message, next.feedbackVoteByTargetId)) return false; + if (issueChatMessageRunIsActive(prev.message, prev.activeRunIds) !== issueChatMessageRunIsActive(next.message, next.activeRunIds)) return false; + if (issueChatMessageRunIsStopping(prev.message, prev.stoppingRunId) !== issueChatMessageRunIsStopping(next.message, next.stoppingRunId)) return false; + if (issueChatMessageQueuedRunIsInterrupting(prev.message, prev.interruptingQueuedRunId) !== issueChatMessageQueuedRunIsInterrupting(next.message, next.interruptingQueuedRunId)) return false; + return true; +} + const IssueChatComposer = forwardRef(function IssueChatComposer({ onImageUpload, onAttachImage, @@ -2704,46 +2840,47 @@ export function IssueChatThread({ bottomAnchorRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); } + const stableOnVote = useStableEvent(onVote); + const stableOnStopRun = useStableEvent(onStopRun); + const stableOnInterruptQueued = useStableEvent(onInterruptQueued); + const stableOnCancelQueued = useStableEvent(onCancelQueued); + const stableOnImageClick = useStableEvent(onImageClick); + const stableOnAcceptInteraction = useStableEvent(onAcceptInteraction); + const stableOnRejectInteraction = useStableEvent(onRejectInteraction); + const stableOnSubmitInteractionAnswers = useStableEvent(onSubmitInteractionAnswers); + const chatCtx = useMemo( () => ({ - feedbackVoteByTargetId, feedbackDataSharingPreference, feedbackTermsUrl, agentMap, currentUserId, userLabelMap, userProfileMap, - activeRunIds, - onVote, - onStopRun, - stoppingRunId, - onInterruptQueued, - onCancelQueued, - interruptingQueuedRunId, - onImageClick, - onAcceptInteraction, - onRejectInteraction, - onSubmitInteractionAnswers, + onVote: stableOnVote, + onStopRun: stableOnStopRun, + onInterruptQueued: stableOnInterruptQueued, + onCancelQueued: stableOnCancelQueued, + onImageClick: stableOnImageClick, + onAcceptInteraction: stableOnAcceptInteraction, + onRejectInteraction: stableOnRejectInteraction, + onSubmitInteractionAnswers: stableOnSubmitInteractionAnswers, }), [ - feedbackVoteByTargetId, feedbackDataSharingPreference, feedbackTermsUrl, agentMap, currentUserId, userLabelMap, userProfileMap, - activeRunIds, - onVote, - onStopRun, - stoppingRunId, - onInterruptQueued, - onCancelQueued, - interruptingQueuedRunId, - onImageClick, - onAcceptInteraction, - onRejectInteraction, - onSubmitInteractionAnswers, + stableOnVote, + stableOnStopRun, + stableOnInterruptQueued, + stableOnCancelQueued, + stableOnImageClick, + stableOnAcceptInteraction, + stableOnRejectInteraction, + stableOnSubmitInteractionAnswers, ], ); @@ -2752,10 +2889,13 @@ export function IssueChatThread({ ?? (variant === "embedded" ? "No run output yet." : "This issue conversation is empty. Start with a message below."); - const errorBoundaryResetKey = useMemo( - () => messages.map((message) => `${message.id}:${message.role}:${message.content.length}:${message.status?.type ?? "none"}`).join("|"), - [messages], - ); + const previousErrorBoundaryMessagesRef = useRef(null); + const errorBoundaryResetVersionRef = useRef(0); + if (previousErrorBoundaryMessagesRef.current !== messages) { + previousErrorBoundaryMessagesRef.current = messages; + errorBoundaryResetVersionRef.current += 1; + } + const errorBoundaryResetKey = String(errorBoundaryResetVersionRef.current); return ( @@ -2797,25 +2937,16 @@ export function IssueChatThread({ // Keep transcript rendering independent from assistant-ui's // index-scoped message providers; live transcripts can shrink // or regroup while the runtime still holds stale indices. - messages.map((message) => { - const custom = message.metadata?.custom as Record | undefined; - const kind = typeof custom?.kind === "string" ? custom.kind : message.role; - const renderedMessage = message.role === "user" - ? - : message.role === "assistant" - ? - : ; - return ( -
- {renderedMessage} -
- ); - }) + messages.map((message) => ( + + )) )} {showComposer ? (