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";
262262import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
263263import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions";
264264import { projectEnvironment } from "../state/projects";
265import { linkedPullRequestDetailAtom } from "../state/pullRequests";
265266import { useEnvironmentQuery } from "../state/query";
266267import {
267268 environmentServerConfigsAtom,
......@@ -317,6 +318,7 @@ import {
317318} from "./chat/ThreadErrorBanner";
318319import {
319320 resolveDisplayedThreadPr,
321 threadPullRequestRefreshSource,
320322 threadChangeRequestSnapshotsAtom,
321323 useLinkedThreadPullRequest,
322324} from "./ThreadStatusIndicators";
......@@ -1745,7 +1747,7 @@ function ChatViewContent(props: ChatViewProps) {
17451747 // the tab is found again whether or not that surface was opened with an environment on it.
17461748 const activePullRequestSurfaceId =
17471749 activeRightPanelSurface?.kind === "pull-request" ? activeRightPanelSurface.id : undefined;
1748 const handlePullRequestTabStatusChange = useCallback(
1750 const updatePullRequestTabStatusFromPanel = useCallback(
17491751 (status: PullRequestTabStatus) => {
17501752 const id = activePullRequestSurfaceId;
17511753 if (id === undefined) return;
......@@ -1753,6 +1755,8 @@ function ChatViewContent(props: ChatViewProps) {
17531755 },
17541756 [activePullRequestSurfaceId],
17551757 );
1758 const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false });
1759 const sidebarPrRefreshKeyRef = useRef<string | null>(null);
17561760 const activeFileSurface =
17571761 activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null;
17581762 const activePreviewState = useThreadPreviewState(activeThreadRef);
......@@ -4594,6 +4598,62 @@ function ChatViewContent(props: ChatViewProps) {
45944598 linkedPullRequest: linkedThreadPullRequest,
45954599 linkedPullRequestStatus,
45964600 });
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 );
45974657 const activeThreadReferenceCopyTarget = useMemo(
45984658 () =>
45994659 activeThreadId === null || !isServerThread
apps/web/src/components/ThreadStatusIndicators.test.ts+56
......@@ -10,6 +10,7 @@ import {
1010 resolveDisplayedThreadPrProvider,
1111 resolveThreadPr,
1212 settledPrHoverColorClass,
13 threadPullRequestRefreshSource,
1314 threadChangeRequestSnapshotsAtom,
1415 type ThreadChangeRequestSnapshot,
1516} from "./ThreadStatusIndicators";
......@@ -56,6 +57,61 @@ function snapshotFor(
5657 return { branch, pr, sourceControlProvider };
5758}
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
59115describe("resolveThreadPr", () => {
60116 it("keeps local-checkout PR indicators scoped to the stored thread branch", () => {
61117 expect(
apps/web/src/components/ThreadStatusIndicators.tsx+26
......@@ -39,6 +39,32 @@ export interface TerminalStatusIndicator {
3939
4040export 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
4268export interface LinkedThreadPullRequestStatus {
4369 readonly pr: NonNullable<ThreadPr>;
4470 readonly sourceControlProvider: NonNullable<VcsStatusResult["sourceControlProvider"]>;