authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-23 18:38:05-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-23 18:38:05-07:00
logfc3f78f5d917fdeee6271d2f6fd42961369c7d9f
tree26b9e30707cc3bd9f6f8d3fcee98aaee79c23e46
parent2f41c073a2f846e9a96082e76cd1b2c3a7e44c18
signature Signed by PGP key B5690EEEBB952194

fix: tone down branch-mismatch banner (#4416)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

3 files changed, 236 insertions(+), 86 deletions(-)

apps/web/src/components/ChatView.logic.test.ts+59
...@@ -12,16 +12,20 @@ import type { Thread } from "../types";...@@ -12,16 +12,20 @@ import type { Thread } from "../types";
12import {12import {
13 MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,13 MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
14 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,14 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
15 branchMismatchKey,
15 buildExpiredTerminalContextToastCopy,16 buildExpiredTerminalContextToastCopy,
16 buildThreadTurnInterruptInput,17 buildThreadTurnInterruptInput,
17 createLocalDispatchSnapshot,18 createLocalDispatchSnapshot,
18 deriveComposerSendState,19 deriveComposerSendState,
20 dismissBranchMismatchForSession,
19 getStartedThreadModelChangeBlockReason,21 getStartedThreadModelChangeBlockReason,
20 hasServerAcknowledgedLocalDispatch,22 hasServerAcknowledgedLocalDispatch,
23 isBranchMismatchDismissedForSession,
21 reconcileMountedTerminalThreadIds,24 reconcileMountedTerminalThreadIds,
22 reconcileRetainedMountedThreadIds,25 reconcileRetainedMountedThreadIds,
23 resolveThreadMetadataUpdateForNextTurn,26 resolveThreadMetadataUpdateForNextTurn,
24 resolveSendEnvMode,27 resolveSendEnvMode,
28 shouldShowBranchMismatchBanner,
25 shouldWriteThreadErrorToCurrentServerThread,29 shouldWriteThreadErrorToCurrentServerThread,
26} from "./ChatView.logic";30} from "./ChatView.logic";
2731
...@@ -292,6 +296,61 @@ describe("resolveSendEnvMode", () => {...@@ -292,6 +296,61 @@ describe("resolveSendEnvMode", () => {
292 });296 });
293});297});
294298
299describe("branchMismatchKey", () => {
300 it("builds a key from thread id and both branches", () => {
301 expect(branchMismatchKey("thread-1", { threadBranch: "feat/a", currentBranch: "feat/b" })).toBe(
302 "thread-1:feat/a:feat/b",
303 );
304 });
305
306 it("returns null without a thread or mismatch", () => {
307 expect(branchMismatchKey(null, { threadBranch: "a", currentBranch: "b" })).toBeNull();
308 expect(branchMismatchKey("thread-1", null)).toBeNull();
309 });
310});
311
312describe("shouldShowBranchMismatchBanner", () => {
313 const base = {
314 hasMismatch: true,
315 isDismissed: false,
316 composerHasContent: false,
317 wasShownForCurrentMismatch: false,
318 };
319
320 it("stays hidden during passive browsing (even though the composer autofocuses)", () => {
321 expect(shouldShowBranchMismatchBanner(base)).toBe(false);
322 });
323
324 it("shows once the composer has draft content", () => {
325 expect(shouldShowBranchMismatchBanner({ ...base, composerHasContent: true })).toBe(true);
326 });
327
328 it("stays mounted after the draft clears once shown for the current mismatch", () => {
329 expect(shouldShowBranchMismatchBanner({ ...base, wasShownForCurrentMismatch: true })).toBe(
330 true,
331 );
332 });
333
334 it("never shows when dismissed or without a mismatch", () => {
335 expect(
336 shouldShowBranchMismatchBanner({ ...base, composerHasContent: true, isDismissed: true }),
337 ).toBe(false);
338 expect(
339 shouldShowBranchMismatchBanner({ ...base, composerHasContent: true, hasMismatch: false }),
340 ).toBe(false);
341 });
342});
343
344describe("session branch mismatch dismissal", () => {
345 it("tracks dismissed keys and treats other keys as active", () => {
346 expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(false);
347 dismissBranchMismatchForSession("t1:a:b");
348 expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(true);
349 expect(isBranchMismatchDismissedForSession("t1:a:c")).toBe(false);
350 expect(isBranchMismatchDismissedForSession(null)).toBe(false);
351 });
352});
353
295describe("reconcileMountedTerminalThreadIds", () => {354describe("reconcileMountedTerminalThreadIds", () => {
296 it("keeps open threads and makes the active thread most recent", () => {355 it("keeps open threads and makes the active thread most recent", () => {
297 expect(356 expect(
apps/web/src/components/ChatView.logic.ts+40
...@@ -289,6 +289,46 @@ export function buildExpiredTerminalContextToastCopy(...@@ -289,6 +289,46 @@ export function buildExpiredTerminalContextToastCopy(
289 };289 };
290}290}
291291
292export function branchMismatchKey(
293 threadId: string | null,
294 mismatch: { threadBranch: string; currentBranch: string } | null,
295): string | null {
296 if (!threadId || !mismatch) {
297 return null;
298 }
299 return `${threadId}:${mismatch.threadBranch}:${mismatch.currentBranch}`;
300}
301
302// The mismatch banner only matters when the user is about to send: passive
303// reading of an old thread carries no risk (the branch picker tint already
304// covers ambient awareness). Draft content is the intent signal — composer
305// focus is useless here because ChatView autofocuses the composer on every
306// thread open. `wasShownForCurrentMismatch` keeps the banner mounted once
307// revealed so it doesn't flicker away when the draft is cleared.
308export function shouldShowBranchMismatchBanner(input: {
309 hasMismatch: boolean;
310 isDismissed: boolean;
311 composerHasContent: boolean;
312 wasShownForCurrentMismatch: boolean;
313}): boolean {
314 if (!input.hasMismatch || input.isDismissed) {
315 return false;
316 }
317 return input.composerHasContent || input.wasShownForCurrentMismatch;
318}
319
320// Session-scoped (module-level so it survives ChatView remounts, e.g. route
321// changes). Durable cross-device dismissal is planned as a server-side ack.
322const sessionDismissedBranchMismatchKeys = new Set<string>();
323
324export function dismissBranchMismatchForSession(key: string): void {
325 sessionDismissedBranchMismatchKeys.add(key);
326}
327
328export function isBranchMismatchDismissedForSession(key: string | null): boolean {
329 return key !== null && sessionDismissedBranchMismatchKeys.has(key);
330}
331
292export function threadHasStarted(thread: Thread | null | undefined): boolean {332export function threadHasStarted(thread: Thread | null | undefined): boolean {
293 return Boolean(333 return Boolean(
294 thread && (thread.latestTurn !== null || thread.messages.length > 0 || thread.session !== null),334 thread && (thread.latestTurn !== null || thread.messages.length > 0 || thread.session !== null),
apps/web/src/components/ChatView.tsx+137-86
...@@ -138,7 +138,7 @@ import { BranchToolbar } from "./BranchToolbar";...@@ -138,7 +138,7 @@ import { BranchToolbar } from "./BranchToolbar";
138import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";138import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
139import PlanSidebar from "./PlanSidebar";139import PlanSidebar from "./PlanSidebar";
140import ThreadTerminalDrawer from "./ThreadTerminalDrawer";140import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
141import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react";141import { ChevronDownIcon, GitBranchIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react";
142import { cn, randomHex } from "~/lib/utils";142import { cn, randomHex } from "~/lib/utils";
143import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";143import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
144import { stackedThreadToast, toastManager } from "./ui/toast";144import { stackedThreadToast, toastManager } from "./ui/toast";
...@@ -231,13 +231,17 @@ import {...@@ -231,13 +231,17 @@ import {
231} from "./chat/draftHeroTransition";231} from "./chat/draftHeroTransition";
232import {232import {
233 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,233 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
234 branchMismatchKey,
234 buildExpiredTerminalContextToastCopy,235 buildExpiredTerminalContextToastCopy,
235 buildLocalDraftThread,236 buildLocalDraftThread,
236 buildThreadTurnInterruptInput,237 buildThreadTurnInterruptInput,
237 collectUserMessageBlobPreviewUrls,238 collectUserMessageBlobPreviewUrls,
238 createLocalDispatchSnapshot,239 createLocalDispatchSnapshot,
239 deriveComposerSendState,240 deriveComposerSendState,
241 dismissBranchMismatchForSession,
240 hasServerAcknowledgedLocalDispatch,242 hasServerAcknowledgedLocalDispatch,
243 isBranchMismatchDismissedForSession,
244 shouldShowBranchMismatchBanner,
241 getStartedThreadModelChangeBlockReason,245 getStartedThreadModelChangeBlockReason,
242 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,246 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
243 LastInvokedScriptByProjectSchema,247 LastInvokedScriptByProjectSchema,
...@@ -260,6 +264,16 @@ import { RightPanelSheet } from "./RightPanelSheet";...@@ -260,6 +264,16 @@ import { RightPanelSheet } from "./RightPanelSheet";
260import { previewEnvironment } from "../state/preview";264import { previewEnvironment } from "../state/preview";
261import { useAtomCommand } from "../state/use-atom-command";265import { useAtomCommand } from "../state/use-atom-command";
262import { Button } from "./ui/button";266import { Button } from "./ui/button";
267import {
268 AlertDialog,
269 AlertDialogClose,
270 AlertDialogDescription,
271 AlertDialogFooter,
272 AlertDialogHeader,
273 AlertDialogPopup,
274 AlertDialogTitle,
275} from "./ui/alert-dialog";
276import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
263import { ServerUpdateAction } from "./ServerUpdateAction";277import { ServerUpdateAction } from "./ServerUpdateAction";
264import {278import {
265 buildVersionMismatchDismissalKey,279 buildVersionMismatchDismissalKey,
...@@ -3809,52 +3823,57 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3809,52 +3823,57 @@ function ChatViewContent(props: ChatViewProps) {
3809 : null,3823 : null,
3810 [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread],3824 [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread],
3811 );3825 );
3812 const [branchRepairAction, setBranchRepairAction] = useState<3826 const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false);
3813 "update-thread" | "switch-checkout" | null3827 const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false);
3814 >(null);3828 // Once revealed for a given mismatch, the banner stays mounted until the
3815 const handleUpdateThreadToCheckout = useCallback(async () => {3829 // mismatch changes or resolves, so clearing the draft doesn't flicker it.
3816 if (!activeThread || !localCheckoutBranchMismatch || branchRepairAction !== null) {3830 const [revealedBranchMismatchKey, setRevealedBranchMismatchKey] = useState<string | null>(null);
3817 return;3831 // Dismissal lives in a module-level set (survives remounts); this tick just
3818 }3832 // forces a re-render so the banner leaves immediately.
3819 setBranchRepairAction("update-thread");3833 const [, setBranchMismatchDismissTick] = useState(0);
3820 const updateResult = await updateThreadMetadata({3834 const composerHasDraftContent = useComposerDraftStore((store) => {
3821 environmentId,3835 const draft = store.getComposerDraft(composerDraftTarget);
3822 input: {3836 return Boolean(
3823 threadId: activeThread.id,3837 draft &&
3824 branch: localCheckoutBranchMismatch.currentBranch,3838 (draft.prompt.trim().length > 0 ||
3825 worktreePath: null,3839 draft.images.length > 0 ||
3826 },3840 draft.terminalContexts.length > 0 ||
3827 });3841 draft.elementContexts.length > 0 ||
3828 setBranchRepairAction(null);3842 draft.previewAnnotations.length > 0 ||
3829 if (updateResult._tag === "Failure" && !isAtomCommandInterrupted(updateResult)) {3843 draft.reviewComments.length > 0),
3830 toastManager.add(3844 );
3831 stackedThreadToast({3845 });
3832 type: "error",3846 const activeBranchMismatchKey = branchMismatchKey(
3833 title: "Failed to update thread branch",3847 activeThread?.id ?? null,
3834 description: chatActionErrorMessage(squashAtomCommandFailure(updateResult)),
3835 }),
3836 );
3837 return;
3838 }
3839 scheduleComposerFocus();
3840 }, [
3841 activeThread,
3842 branchRepairAction,
3843 environmentId,
3844 localCheckoutBranchMismatch,3848 localCheckoutBranchMismatch,
3845 scheduleComposerFocus,3849 );
3846 updateThreadMetadata,3850 const showBranchMismatchBanner = shouldShowBranchMismatchBanner({
3847 ]);3851 hasMismatch: localCheckoutBranchMismatch !== null,
3852 isDismissed: isBranchMismatchDismissedForSession(activeBranchMismatchKey),
3853 composerHasContent: composerHasDraftContent,
3854 wasShownForCurrentMismatch:
3855 revealedBranchMismatchKey !== null && revealedBranchMismatchKey === activeBranchMismatchKey,
3856 });
3857 useEffect(() => {
3858 setRevealedBranchMismatchKey((revealed) => {
3859 if (showBranchMismatchBanner) {
3860 return activeBranchMismatchKey;
3861 }
3862 // Hysteresis is scoped to an uninterrupted mismatch: reset when the
3863 // mismatch resolves or changes so a recurrence re-gates on intent.
3864 return revealed !== null && revealed !== activeBranchMismatchKey ? null : revealed;
3865 });
3866 }, [activeBranchMismatchKey, showBranchMismatchBanner]);
3848 const handleSwitchCheckoutToThread = useCallback(async () => {3867 const handleSwitchCheckoutToThread = useCallback(async () => {
3849 if (3868 if (
3850 !activeProjectCwd ||3869 !activeProjectCwd ||
3851 !activeThread ||3870 !activeThread ||
3852 !localCheckoutBranchMismatch ||3871 !localCheckoutBranchMismatch ||
3853 branchRepairAction !== null3872 isRestoringThreadBranch
3854 ) {3873 ) {
3855 return;3874 return;
3856 }3875 }
3857 setBranchRepairAction("switch-checkout");3876 setIsRestoringThreadBranch(true);
3858 const checkoutResult = await switchGitRef({3877 const checkoutResult = await switchGitRef({
3859 environmentId,3878 environmentId,
3860 input: {3879 input: {
...@@ -3863,7 +3882,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3863,7 +3882,7 @@ function ChatViewContent(props: ChatViewProps) {
3863 },3882 },
3864 });3883 });
3865 if (checkoutResult._tag === "Failure") {3884 if (checkoutResult._tag === "Failure") {
3866 setBranchRepairAction(null);3885 setIsRestoringThreadBranch(false);
3867 if (!isAtomCommandInterrupted(checkoutResult)) {3886 if (!isAtomCommandInterrupted(checkoutResult)) {
3868 toastManager.add(3887 toastManager.add(
3869 stackedThreadToast({3888 stackedThreadToast({
...@@ -3883,7 +3902,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3883,7 +3902,7 @@ function ChatViewContent(props: ChatViewProps) {
3883 input: { threadId: activeThread.id, branch: nextBranch, worktreePath: null },3902 input: { threadId: activeThread.id, branch: nextBranch, worktreePath: null },
3884 });3903 });
3885 if (updateResult._tag === "Failure") {3904 if (updateResult._tag === "Failure") {
3886 setBranchRepairAction(null);3905 setIsRestoringThreadBranch(false);
3887 if (!isAtomCommandInterrupted(updateResult)) {3906 if (!isAtomCommandInterrupted(updateResult)) {
3888 toastManager.add(3907 toastManager.add(
3889 stackedThreadToast({3908 stackedThreadToast({
...@@ -3898,76 +3917,78 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3898,76 +3917,78 @@ function ChatViewContent(props: ChatViewProps) {
3898 }3917 }
3899 }3918 }
3900 gitStatusQuery.refresh();3919 gitStatusQuery.refresh();
3901 setBranchRepairAction(null);3920 setIsRestoringThreadBranch(false);
3902 scheduleComposerFocus();3921 scheduleComposerFocus();
3903 }, [3922 }, [
3904 activeProjectCwd,3923 activeProjectCwd,
3905 activeThread,3924 activeThread,
3906 branchRepairAction,
3907 environmentId,3925 environmentId,
3908 gitStatusQuery,3926 gitStatusQuery,
3927 isRestoringThreadBranch,
3909 localCheckoutBranchMismatch,3928 localCheckoutBranchMismatch,
3910 scheduleComposerFocus,3929 scheduleComposerFocus,
3911 switchGitRef,3930 switchGitRef,
3912 updateThreadMetadata,3931 updateThreadMetadata,
3913 ]);3932 ]);
3933 const handleRestoreThreadBranch = useCallback(() => {
3934 if (gitStatusQuery.data?.hasWorkingTreeChanges) {
3935 setBranchRestoreConfirmOpen(true);
3936 return;
3937 }
3938 void handleSwitchCheckoutToThread();
3939 }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]);
3914 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {3940 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
3915 if (!localCheckoutBranchMismatch) {3941 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
3916 return systemComposerBannerItems;3942 return systemComposerBannerItems;
3917 }3943 }
3918 const isRepairingBranch = branchRepairAction !== null;
3919 return [3944 return [
3920 ...systemComposerBannerItems,3945 ...systemComposerBannerItems,
3921 {3946 {
3922 id: `branch-mismatch:${activeThread?.id ?? "unknown"}:${localCheckoutBranchMismatch.threadBranch}:${localCheckoutBranchMismatch.currentBranch}`,3947 id: `branch-mismatch:${activeBranchMismatchKey}`,
3923 variant: "warning",3948 variant: "info",
3924 icon: <TriangleAlertIcon />,3949 icon: <GitBranchIcon />,
3925 title: "You're on a different branch",3950 title: (
3926 className:3951 <span className="flex min-w-0 items-baseline gap-1.5">
3927 "text-base sm:text-sm [&>div]:items-start max-sm:[&>div]:flex-wrap max-sm:[&>div>div:last-child]:w-full max-sm:[&>div>div:last-child]:self-start dark:shadow-none",3952 <span className="shrink-0 font-normal text-muted-foreground">Branch changed — was</span>
3928 actionClassName:3953 <Tooltip>
3929 "max-sm:w-full max-sm:border-t max-sm:border-border/60 max-sm:pt-2 max-sm:pl-6 sm:border-l sm:border-border/60 sm:pl-3",3954 <TooltipTrigger
3930 description: (3955 render={
3931 <p className="text-pretty">3956 <code className="min-w-0 truncate font-medium text-foreground">
3932 This thread is on{" "}3957 {localCheckoutBranchMismatch.threadBranch}
3933 <code className="font-medium text-foreground">3958 </code>
3934 {localCheckoutBranchMismatch.threadBranch}3959 }
3935 </code>3960 />
3936 , but you're currently checked out at{" "}3961 <TooltipPopup side="top" className="max-w-80">
3937 <code className="font-medium text-foreground">3962 This thread last ran on {localCheckoutBranchMismatch.threadBranch}. Sending will
3938 {localCheckoutBranchMismatch.currentBranch}3963 continue on {localCheckoutBranchMismatch.currentBranch}.
3939 </code>3964 </TooltipPopup>
3940 . Sending a message will update the thread.3965 </Tooltip>
3941 </p>3966 </span>
3942 ),3967 ),
3968 className: "dark:shadow-none",
3943 actions: (3969 actions: (
3944 <>3970 <Button
3945 <Button3971 size="xs"
3946 size="xs"3972 variant="ghost"
3947 variant="outline"3973 disabled={isRestoringThreadBranch}
3948 disabled={isRepairingBranch}3974 onClick={handleRestoreThreadBranch}
3949 onClick={() => void handleUpdateThreadToCheckout()}3975 >
3950 >3976 {isRestoringThreadBranch ? "Restoring..." : "Restore branch"}
3951 {branchRepairAction === "update-thread" ? "Moving..." : "Move thread here"}3977 </Button>
3952 </Button>
3953 <Button
3954 size="xs"
3955 variant="ghost"
3956 disabled={isRepairingBranch}
3957 onClick={() => void handleSwitchCheckoutToThread()}
3958 >
3959 {branchRepairAction === "switch-checkout" ? "Switching..." : "Checkout thread branch"}
3960 </Button>
3961 </>
3962 ),3978 ),
3979 dismissLabel: "Dismiss branch change notice",
3980 onDismiss: () => {
3981 dismissBranchMismatchForSession(activeBranchMismatchKey);
3982 setBranchMismatchDismissTick((tick) => tick + 1);
3983 },
3963 },3984 },
3964 ];3985 ];
3965 }, [3986 }, [
3966 activeThread?.id,3987 activeBranchMismatchKey,
3967 branchRepairAction,3988 handleRestoreThreadBranch,
3968 handleSwitchCheckoutToThread,3989 isRestoringThreadBranch,
3969 handleUpdateThreadToCheckout,
3970 localCheckoutBranchMismatch,3990 localCheckoutBranchMismatch,
3991 showBranchMismatchBanner,
3971 systemComposerBannerItems,3992 systemComposerBannerItems,
3972 ]);3993 ]);
39733994
...@@ -5726,6 +5747,36 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5726,6 +5747,36 @@ function ChatViewContent(props: ChatViewProps) {
5726 </div>5747 </div>
5727 </div>5748 </div>
57285749
5750 <AlertDialog open={branchRestoreConfirmOpen} onOpenChange={setBranchRestoreConfirmOpen}>
5751 <AlertDialogPopup>
5752 <AlertDialogHeader>
5753 <AlertDialogTitle>
5754 Switch to{" "}
5755 <code className="font-medium">
5756 {localCheckoutBranchMismatch?.threadBranch ?? ""}
5757 </code>
5758 ?
5759 </AlertDialogTitle>
5760 <AlertDialogDescription>
5761 You have uncommitted changes. They'll carry over to the other branch, or block
5762 the switch if they conflict.
5763 </AlertDialogDescription>
5764 </AlertDialogHeader>
5765 <AlertDialogFooter>
5766 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
5767 <Button
5768 variant="default"
5769 onClick={() => {
5770 setBranchRestoreConfirmOpen(false);
5771 void handleSwitchCheckoutToThread();
5772 }}
5773 >
5774 Switch branch
5775 </Button>
5776 </AlertDialogFooter>
5777 </AlertDialogPopup>
5778 </AlertDialog>
5779
5729 {pullRequestDialogState ? (5780 {pullRequestDialogState ? (
5730 <PullRequestThreadDialog5781 <PullRequestThreadDialog
5731 key={pullRequestDialogState.key}5782 key={pullRequestDialogState.key}