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 ? (