authorgravatar for 269035359+t3-code[bot]@users.noreply.github.comt3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> 2026-09-01 17:19:27-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 17:19:27-04:00
log692eb1a5792b9930959b19805acf2bf2611318c9
tree42926af5e927cbd8c56e7222d20faf2ca76ed755
parent163d50846bc8989b5858802bb02253254dcee0dd
signature Signed by PGP key B5690EEEBB952194

fix(web): sync sidebar PR state from open panel (#9092)

Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> Co-authored-by: maria <254055478+maria-rcks@users.noreply.github.com>

3 files changed, 143 insertions(+), 1 deletions(-)

apps/web/src/components/ChatView.tsx+61-1
...@@ -262,6 +262,7 @@ import { environmentCatalog } from "../connection/catalog";...@@ -262,6 +262,7 @@ import { environmentCatalog } from "../connection/catalog";
262import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";262import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
263import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions";263import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions";
264import { projectEnvironment } from "../state/projects";264import { projectEnvironment } from "../state/projects";
265import { linkedPullRequestDetailAtom } from "../state/pullRequests";
265import { useEnvironmentQuery } from "../state/query";266import { useEnvironmentQuery } from "../state/query";
266import {267import {
267 environmentServerConfigsAtom,268 environmentServerConfigsAtom,
...@@ -317,6 +318,7 @@ import {...@@ -317,6 +318,7 @@ import {
317} from "./chat/ThreadErrorBanner";318} from "./chat/ThreadErrorBanner";
318import {319import {
319 resolveDisplayedThreadPr,320 resolveDisplayedThreadPr,
321 threadPullRequestRefreshSource,
320 threadChangeRequestSnapshotsAtom,322 threadChangeRequestSnapshotsAtom,
321 useLinkedThreadPullRequest,323 useLinkedThreadPullRequest,
322} from "./ThreadStatusIndicators";324} from "./ThreadStatusIndicators";
...@@ -1745,7 +1747,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1745,7 +1747,7 @@ function ChatViewContent(props: ChatViewProps) {
1745 // the tab is found again whether or not that surface was opened with an environment on it.1747 // the tab is found again whether or not that surface was opened with an environment on it.
1746 const activePullRequestSurfaceId =1748 const activePullRequestSurfaceId =
1747 activeRightPanelSurface?.kind === "pull-request" ? activeRightPanelSurface.id : undefined;1749 activeRightPanelSurface?.kind === "pull-request" ? activeRightPanelSurface.id : undefined;
1748 const handlePullRequestTabStatusChange = useCallback(1750 const updatePullRequestTabStatusFromPanel = useCallback(
1749 (status: PullRequestTabStatus) => {1751 (status: PullRequestTabStatus) => {
1750 const id = activePullRequestSurfaceId;1752 const id = activePullRequestSurfaceId;
1751 if (id === undefined) return;1753 if (id === undefined) return;
...@@ -1753,6 +1755,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1753,6 +1755,8 @@ function ChatViewContent(props: ChatViewProps) {
1753 },1755 },
1754 [activePullRequestSurfaceId],1756 [activePullRequestSurfaceId],
1755 );1757 );
1758 const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false });
1759 const sidebarPrRefreshKeyRef = useRef<string | null>(null);
1756 const activeFileSurface =1760 const activeFileSurface =
1757 activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null;1761 activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null;
1758 const activePreviewState = useThreadPreviewState(activeThreadRef);1762 const activePreviewState = useThreadPreviewState(activeThreadRef);
...@@ -4594,6 +4598,62 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4594,6 +4598,62 @@ function ChatViewContent(props: ChatViewProps) {
4594 linkedPullRequest: linkedThreadPullRequest,4598 linkedPullRequest: linkedThreadPullRequest,
4595 linkedPullRequestStatus,4599 linkedPullRequestStatus,
4596 });4600 });
4601 const handlePullRequestTabStatusChange = useCallback(
4602 (status: PullRequestTabStatus) => {
4603 updatePullRequestTabStatusFromPanel(status);
4604 const source = threadPullRequestRefreshSource({
4605 panel: status,
4606 thread: {
4607 repository: threadRepository,
4608 number: linkedThreadPullRequest?.number ?? activeThreadPr?.number ?? null,
4609 state: activeThreadPr?.state ?? null,
4610 linked: linkedThreadPullRequest !== null,
4611 },
4612 });
4613 if (source === null) {
4614 sidebarPrRefreshKeyRef.current = null;
4615 return;
4616 }
4617 const refreshKey = `${activeThreadKey}:${source}:${status.repository}#${status.number}:${status.state}`;
4618 if (sidebarPrRefreshKeyRef.current === refreshKey) return;
4619 sidebarPrRefreshKeyRef.current = refreshKey;
4620
4621 if (source === "linked-detail" && activeThreadRef && linkedThreadPullRequest) {
4622 appAtomRegistry.refresh(
4623 linkedPullRequestDetailAtom({
4624 environmentId: activeThreadRef.environmentId,
4625 input: {
4626 projectId: linkedThreadPullRequest.projectId,
4627 repository: linkedThreadPullRequest.repository,
4628 number: linkedThreadPullRequest.number,
4629 },
4630 }),
4631 );
4632 return;
4633 }
4634 if (source === "vcs" && activeThreadRef && gitCwd !== null) {
4635 void refreshVcsStatus({
4636 environmentId: activeThreadRef.environmentId,
4637 input: { cwd: gitCwd },
4638 }).then(() => {
4639 if (sidebarPrRefreshKeyRef.current === refreshKey) {
4640 sidebarPrRefreshKeyRef.current = null;
4641 }
4642 });
4643 }
4644 },
4645 [
4646 activeThreadKey,
4647 activeThreadPr?.number,
4648 activeThreadPr?.state,
4649 activeThreadRef,
4650 gitCwd,
4651 linkedThreadPullRequest,
4652 refreshVcsStatus,
4653 threadRepository,
4654 updatePullRequestTabStatusFromPanel,
4655 ],
4656 );
4597 const activeThreadReferenceCopyTarget = useMemo(4657 const activeThreadReferenceCopyTarget = useMemo(
4598 () =>4658 () =>
4599 activeThreadId === null || !isServerThread4659 activeThreadId === null || !isServerThread
apps/web/src/components/ThreadStatusIndicators.test.ts+56
...@@ -10,6 +10,7 @@ import {...@@ -10,6 +10,7 @@ import {
10 resolveDisplayedThreadPrProvider,10 resolveDisplayedThreadPrProvider,
11 resolveThreadPr,11 resolveThreadPr,
12 settledPrHoverColorClass,12 settledPrHoverColorClass,
13 threadPullRequestRefreshSource,
13 threadChangeRequestSnapshotsAtom,14 threadChangeRequestSnapshotsAtom,
14 type ThreadChangeRequestSnapshot,15 type ThreadChangeRequestSnapshot,
15} from "./ThreadStatusIndicators";16} from "./ThreadStatusIndicators";
...@@ -56,6 +57,61 @@ function snapshotFor(...@@ -56,6 +57,61 @@ function snapshotFor(
56 return { branch, pr, sourceControlProvider };57 return { branch, pr, sourceControlProvider };
57}58}
5859
60describe("threadPullRequestRefreshSource", () => {
61 const panel = { repository: "pingdotgg/t3code", number: 42, state: "merged" as const };
62
63 it("refreshes the VCS stream when the open panel is newer than an inferred sidebar PR", () => {
64 expect(
65 threadPullRequestRefreshSource({
66 panel,
67 thread: { repository: "pingdotgg/t3code", number: 42, state: "open", linked: false },
68 }),
69 ).toBe("vcs");
70 });
71
72 it("refreshes linked detail when the open panel is newer than a linked sidebar PR", () => {
73 expect(
74 threadPullRequestRefreshSource({
75 panel,
76 thread: { repository: "pingdotgg/t3code", number: 42, state: "open", linked: true },
77 }),
78 ).toBe("linked-detail");
79 });
80
81 it("refreshes when the sidebar has not resolved state yet", () => {
82 expect(
83 threadPullRequestRefreshSource({
84 panel,
85 thread: { repository: "pingdotgg/t3code", number: 42, state: null, linked: false },
86 }),
87 ).toBe("vcs");
88 });
89
90 it("does nothing once sidebar state matches or the panel shows another PR", () => {
91 expect(
92 threadPullRequestRefreshSource({
93 panel,
94 thread: { repository: "pingdotgg/t3code", number: 42, state: "merged", linked: false },
95 }),
96 ).toBeNull();
97 expect(
98 threadPullRequestRefreshSource({
99 panel,
100 thread: { repository: "pingdotgg/t3code", number: 41, state: "open", linked: false },
101 }),
102 ).toBeNull();
103 });
104
105 it("matches repository identity without case sensitivity", () => {
106 expect(
107 threadPullRequestRefreshSource({
108 panel: { ...panel, repository: "PingDotGG/T3Code" },
109 thread: { repository: "pingdotgg/t3code", number: 42, state: "open", linked: false },
110 }),
111 ).toBe("vcs");
112 });
113});
114
59describe("resolveThreadPr", () => {115describe("resolveThreadPr", () => {
60 it("keeps local-checkout PR indicators scoped to the stored thread branch", () => {116 it("keeps local-checkout PR indicators scoped to the stored thread branch", () => {
61 expect(117 expect(
apps/web/src/components/ThreadStatusIndicators.tsx+26
...@@ -39,6 +39,32 @@ export interface TerminalStatusIndicator {...@@ -39,6 +39,32 @@ export interface TerminalStatusIndicator {
3939
40export type ThreadPr = VcsStatusResult["pr"];40export type ThreadPr = VcsStatusResult["pr"];
4141
42export type ThreadPullRequestRefreshSource = "linked-detail" | "vcs";
43
44/** Refresh only when the panel has newer state for this thread's own pull request. */
45export function threadPullRequestRefreshSource(input: {
46 readonly panel: {
47 readonly repository: string;
48 readonly number: number;
49 readonly state: NonNullable<ThreadPr>["state"];
50 };
51 readonly thread: {
52 readonly repository: string | null;
53 readonly number: number | null;
54 readonly state: NonNullable<ThreadPr>["state"] | null;
55 readonly linked: boolean;
56 };
57}): ThreadPullRequestRefreshSource | null {
58 if (
59 input.thread.repository?.toLowerCase() !== input.panel.repository.toLowerCase() ||
60 input.thread.number !== input.panel.number ||
61 input.thread.state === input.panel.state
62 ) {
63 return null;
64 }
65 return input.thread.linked ? "linked-detail" : "vcs";
66}
67
42export interface LinkedThreadPullRequestStatus {68export interface LinkedThreadPullRequestStatus {
43 readonly pr: NonNullable<ThreadPr>;69 readonly pr: NonNullable<ThreadPr>;
44 readonly sourceControlProvider: NonNullable<VcsStatusResult["sourceControlProvider"]>;70 readonly sourceControlProvider: NonNullable<VcsStatusResult["sourceControlProvider"]>;