authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-09-02 15:38:47-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 18:38:47-04:00
log1eb36b45ef7aa5c028380afb7266bcabf258a55e
tree366ddc5395c84f2108158abadb61fe63435f5c65
parentba3cb0773859334fe9f75016295bb3893c9e6044
signature Signed by PGP key B5690EEEBB952194

fix(web): show pull request state icons in tabs (#9112)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

7 files changed, 86 insertions(+), 134 deletions(-)

apps/web/src/components/ChatView.tsx+2-20
......@@ -150,7 +150,6 @@ import {
150150 selectActiveRightPanelSurface,
151151 selectThreadRightPanelState,
152152 type RightPanelSurface,
153 updatePullRequestTabStatus,
154153 useRightPanelStore,
155154} from "../rightPanelStore";
156155import {
......@@ -1786,21 +1785,6 @@ function ChatViewContent(props: ChatViewProps) {
17861785 const activeRightPanelSurface = useRightPanelStore((state) =>
17871786 selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef),
17881787 );
1789 const [pullRequestTabStatuses, setPullRequestTabStatuses] = useState<
1790 Record<string, PullRequestTabStatus>
1791 >({});
1792 // Keyed by the surface the panel is showing rather than by a key rebuilt from the status, so
1793 // the tab is found again whether or not that surface was opened with an environment on it.
1794 const activePullRequestSurfaceId =
1795 activeRightPanelSurface?.kind === "pull-request" ? activeRightPanelSurface.id : undefined;
1796 const updatePullRequestTabStatusFromPanel = useCallback(
1797 (status: PullRequestTabStatus) => {
1798 const id = activePullRequestSurfaceId;
1799 if (id === undefined) return;
1800 setPullRequestTabStatuses((current) => updatePullRequestTabStatus(current, id, status));
1801 },
1802 [activePullRequestSurfaceId],
1803 );
18041788 const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false });
18051789 const sidebarPrRefreshKeyRef = useRef<string | null>(null);
18061790 const activePreviewState = useThreadPreviewState(activeThreadRef);
......@@ -4683,7 +4667,6 @@ function ChatViewContent(props: ChatViewProps) {
46834667 });
46844668 const handlePullRequestTabStatusChange = useCallback(
46854669 (status: PullRequestTabStatus) => {
4686 updatePullRequestTabStatusFromPanel(status);
46874670 const source = threadPullRequestRefreshSource({
46884671 panel: status,
46894672 thread: {
......@@ -4734,7 +4717,6 @@ function ChatViewContent(props: ChatViewProps) {
47344717 linkedThreadPullRequest,
47354718 refreshVcsStatus,
47364719 threadRepository,
4737 updatePullRequestTabStatusFromPanel,
47384720 ],
47394721 );
47404722 const activeThreadReferenceCopyTarget = useMemo(
......@@ -7719,6 +7701,7 @@ function ChatViewContent(props: ChatViewProps) {
77197701 open={rightPanelOpen}
77207702 maximized={rightPanelMaximized}
77217703 surfaces={renderedRightPanelSurfaces}
7704 environmentId={activeThreadRef.environmentId}
77227705 activeSurfaceId={renderedRightPanelSurface?.id ?? null}
77237706 pendingSurfaceIds={pendingFileSurfaceIds}
77247707 previewSessions={activePreviewState.sessions}
......@@ -7744,7 +7727,6 @@ function ChatViewContent(props: ChatViewProps) {
77447727 filesAvailable={activeProject !== null}
77457728 pullRequestAvailable={pullRequestSurfaceAvailable}
77467729 agentsAvailable
7747 pullRequestStatuses={pullRequestTabStatuses}
77487730 liveAgentCount={agentPanelModel.liveCount}
77497731 >
77507732 {rightPanelContent}
......@@ -7768,6 +7750,7 @@ function ChatViewContent(props: ChatViewProps) {
77687750 ) : null
77697751 }
77707752 surfaces={renderedRightPanelSurfaces}
7753 environmentId={activeThreadRef.environmentId}
77717754 activeSurfaceId={renderedRightPanelSurface?.id ?? null}
77727755 pendingSurfaceIds={pendingFileSurfaceIds}
77737756 previewSessions={activePreviewState.sessions}
......@@ -7793,7 +7776,6 @@ function ChatViewContent(props: ChatViewProps) {
77937776 filesAvailable={activeProject !== null}
77947777 pullRequestAvailable={pullRequestSurfaceAvailable}
77957778 agentsAvailable
7796 pullRequestStatuses={pullRequestTabStatuses}
77977779 liveAgentCount={agentPanelModel.liveCount}
77987780 >
77997781 {rightPanelContent}
apps/web/src/components/RightPanelTabs.test.tsx+1
......@@ -97,6 +97,7 @@ function renderTabs(
9797 <RightPanelTabs
9898 mode="inline"
9999 surfaces={second ? [previewSurface, secondSurface] : [previewSurface]}
100 environmentId={null}
100101 activeSurfaceId={previewSurface.id}
101102 pendingSurfaceIds={new Set()}
102103 previewSessions={sessions}
apps/web/src/components/RightPanelTabs.tsx+63-19
......@@ -1,4 +1,10 @@
1import type { ContextMenuItem, PreviewSessionSnapshot, PullRequestState } from "@t3tools/contracts";
1import type {
2 ContextMenuItem,
3 EnvironmentId,
4 PreviewSessionSnapshot,
5 ProjectId,
6 PullRequestState,
7} from "@t3tools/contracts";
28import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
39import {
410 Bot,
......@@ -45,12 +51,15 @@ import { ScrollArea } from "~/components/ui/scroll-area";
4551import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button";
4652import { faviconUrlForOrigin } from "~/lib/favicon";
4753import { useTheme } from "~/hooks/useTheme";
54import { pullRequestEnvironment } from "~/state/pullRequests";
55import { useEnvironmentQuery } from "~/state/query";
4856import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
4957
5058import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell";
5159import { FaviconImage } from "./preview/PreviewFaviconIcon";
5260import { previewBridge } from "./preview/previewBridge";
5361import { PierreEntryIcon } from "./chat/PierreEntryIcon";
62import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation";
5463
5564interface RightPanelTabsProps {
5665 mode: PreviewPanelMode;
......@@ -62,6 +71,8 @@ interface RightPanelTabsProps {
6271 defaultWidth?: number;
6372 layoutControls?: ReactNode;
6473 surfaces: readonly RightPanelSurface[];
74 /** Fallback environment for surfaces that do not carry their own. */
75 environmentId: EnvironmentId | null;
6576 activeSurfaceId: string | null;
6677 pendingSurfaceIds: ReadonlySet<string>;
6778 previewSessions: Readonly<Record<string, PreviewSessionSnapshot>>;
......@@ -97,7 +108,7 @@ interface RightPanelTabsProps {
97108 filesAvailable: boolean;
98109 pullRequestAvailable: boolean;
99110 agentsAvailable: boolean;
100 pullRequestStatuses?: Readonly<Record<string, PullRequestTabStatus>>;
111 pullRequestStatusSeeds?: Readonly<Record<string, PullRequestTabStatusSeed>>;
101112 /** Running + waiting subagents; badges the Agents card in the empty state. */
102113 liveAgentCount: number;
103114 children: ReactNode;
......@@ -111,6 +122,8 @@ export interface PullRequestTabStatus {
111122 isDraft: boolean;
112123}
113124
125export type PullRequestTabStatusSeed = Pick<PullRequestTabStatus, "state" | "isDraft">;
126
114127export function shouldOpenDefaultBrowserProfileFromMenuClick(
115128 pointerType: string | undefined,
116129): boolean {
......@@ -570,13 +583,15 @@ function SurfaceIcon({
570583 sessions,
571584 desktopByTabId,
572585 theme,
573 pullRequestStatuses,
586 environmentId,
587 pullRequestStatusSeeds,
574588}: {
575589 surface: RightPanelSurface;
576590 sessions: Readonly<Record<string, PreviewSessionSnapshot>>;
577591 desktopByTabId: Readonly<Record<string, DesktopPreviewOverlay>>;
578592 theme: "light" | "dark";
579 pullRequestStatuses: Readonly<Record<string, PullRequestTabStatus>> | undefined;
593 environmentId: EnvironmentId | null;
594 pullRequestStatusSeeds: Readonly<Record<string, PullRequestTabStatusSeed>> | undefined;
580595}) {
581596 switch (surface.kind) {
582597 case "preview": {
......@@ -602,25 +617,53 @@ function SurfaceIcon({
602617 );
603618 case "terminal":
604619 return <TerminalSquare className="size-3 shrink-0" />;
605 case "pull-request": {
606 const status = pullRequestStatuses?.[surface.id] ?? null;
607 const toneClassName =
608 status?.state === "merged"
609 ? "text-violet-600 dark:text-violet-300/90"
610 : status?.state === "closed"
611 ? "text-red-600 dark:text-red-300/90"
612 : status?.isDraft
613 ? "text-zinc-500 dark:text-zinc-400/80"
614 : status?.state === "open"
615 ? "text-emerald-600 dark:text-emerald-300/90"
616 : "text-muted-foreground";
617 return <GitPullRequest className={cn("size-3 shrink-0", toneClassName)} />;
618 }
620 case "pull-request":
621 return (
622 <PullRequestSurfaceIcon
623 surface={surface}
624 environmentId={environmentId}
625 seed={pullRequestStatusSeeds?.[surface.id]}
626 />
627 );
619628 case "agents":
620629 return <Bot className="size-3 shrink-0" />;
621630 }
622631}
623632
633function PullRequestSurfaceIcon({
634 surface,
635 environmentId,
636 seed,
637}: {
638 surface: Extract<RightPanelSurface, { kind: "pull-request" }>;
639 environmentId: EnvironmentId | null;
640 seed: PullRequestTabStatusSeed | undefined;
641}) {
642 const resolvedEnvironmentId =
643 (surface.environmentId as EnvironmentId | undefined) ?? environmentId;
644 const detail = useEnvironmentQuery(
645 resolvedEnvironmentId === null
646 ? null
647 : pullRequestEnvironment.detail({
648 environmentId: resolvedEnvironmentId,
649 input: {
650 projectId: surface.projectId as ProjectId,
651 repository: surface.repository,
652 number: surface.number,
653 },
654 }),
655 ).data;
656 // Only state and draft reach the tab. A list seed cannot know mergeability, so feeding the
657 // full detail would flip an open tab to the conflict glyph the moment its read lands.
658 const status =
659 detail === null ? (seed ?? null) : { state: detail.state, isDraft: detail.isDraft };
660 if (status === null) {
661 return <GitPullRequest className="size-3 shrink-0 text-muted-foreground" />;
662 }
663 const presentation = resolvePullRequestState(status);
664 return <presentation.Icon className={cn("size-3 shrink-0", presentation.toneClassName)} />;
665}
666
624667export function RightPanelTabs(props: RightPanelTabsProps) {
625668 const ownsDesktopTitleBar = isElectron && props.mode === "inline";
626669 const browserProfiles = useBrowserDefaults().profiles;
......@@ -859,7 +902,8 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
859902 sessions={props.previewSessions}
860903 desktopByTabId={props.desktopByTabId}
861904 theme={resolvedTheme}
862 pullRequestStatuses={props.pullRequestStatuses}
905 environmentId={props.environmentId}
906 pullRequestStatusSeeds={props.pullRequestStatusSeeds}
863907 />
864908 {pending ? (
865909 <span
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+1-1
......@@ -450,7 +450,7 @@ export function PullRequestDetailPanel({
450450 onActed?: () => void;
451451 /** Page-owned detail columns use this to clear the selected pull request. */
452452 onClose?: () => void;
453 /** Keeps compact chrome, such as the right-panel tab, in step with refreshed host state. */
453 /** Keeps surrounding thread state in step with refreshed host state. */
454454 onStateChange?: (status: {
455455 projectId: string;
456456 repository: string;
apps/web/src/rightPanelStore.test.ts-57
......@@ -9,7 +9,6 @@ import {
99 selectActiveRightPanelSurface,
1010 selectSelectedRightPanelSurface,
1111 selectThreadRightPanelState,
12 updatePullRequestTabStatus,
1312 useRightPanelStore,
1413} from "./rightPanelStore";
1514
......@@ -485,62 +484,6 @@ describe("rightPanelStore", () => {
485484 ).toEqual([]);
486485 });
487486
488 describe("updatePullRequestTabStatus", () => {
489 const status = (isDraft: boolean) => ({
490 projectId: "project-a",
491 repository: "pingdotgg/t3code",
492 number: 4909,
493 state: "open" as const,
494 isDraft,
495 });
496
497 // Regression for the tab wearing no state: this failed when the status was written under a
498 // key rebuilt from the pull request while the tab strip reads it under the surface's own id.
499 it("keys a status under the same id a surface opened from an environment carries", () => {
500 const target = {
501 environmentId: "remote",
502 projectId: "project-a",
503 repository: "pingdotgg/t3code",
504 number: 4909,
505 };
506 useRightPanelStore.getState().openPullRequest(refA, target);
507 const surface = selectSelectedRightPanelSurface(
508 useRightPanelStore.getState().byThreadKey,
509 refA,
510 );
511 expect(surface).not.toBeNull();
512
513 const statuses = updatePullRequestTabStatus({}, surface!.id, status(false));
514 expect(statuses[surface!.id]).toEqual(status(false));
515 });
516
517 it("keys a status under the same id a thread surface with no environment carries", () => {
518 const target = { projectId: "project-a", repository: "pingdotgg/t3code", number: 4909 };
519 useRightPanelStore.getState().openPullRequest(refA, target);
520 const surface = selectSelectedRightPanelSurface(
521 useRightPanelStore.getState().byThreadKey,
522 refA,
523 );
524 expect(surface).not.toBeNull();
525
526 const statuses = updatePullRequestTabStatus({}, surface!.id, status(false));
527 expect(statuses[surface!.id]).toEqual(status(false));
528 });
529
530 it("returns the identical map when the tab's state and draft flag are unchanged", () => {
531 const first = updatePullRequestTabStatus({}, "pull-request:1", status(false));
532 const second = updatePullRequestTabStatus(first, "pull-request:1", status(false));
533 expect(second).toBe(first);
534 });
535
536 it("replaces the entry when the draft flag changes", () => {
537 const first = updatePullRequestTabStatus({}, "pull-request:1", status(false));
538 const second = updatePullRequestTabStatus(first, "pull-request:1", status(true));
539 expect(second).not.toBe(first);
540 expect(second["pull-request:1"]).toEqual(status(true));
541 });
542 });
543
544487 it("tracks one surface per terminal session", () => {
545488 useRightPanelStore.getState().openTerminal(refA, "term-1");
546489 useRightPanelStore.getState().openTerminal(refA, "term-2");
apps/web/src/rightPanelStore.ts-17
......@@ -196,23 +196,6 @@ export function pullRequestSurface(target: {
196196 };
197197}
198198
199/**
200 * A pull-request tab's status map with one entry set. Keyed by the surface the panel is showing
201 * rather than by a key rebuilt from the status, so the tab is found again whether or not that
202 * surface was opened with an environment on it. Returns the same map when the tab's own fields
203 * have not changed, so a caller can skip a re-render.
204 */
205export function updatePullRequestTabStatus<Status extends { state: unknown; isDraft: boolean }>(
206 statuses: Readonly<Record<string, Status>>,
207 surfaceId: string,
208 status: Status,
209): Readonly<Record<string, Status>> {
210 return statuses[surfaceId]?.state === status.state &&
211 statuses[surfaceId]?.isDraft === status.isDraft
212 ? statuses
213 : { ...statuses, [surfaceId]: status };
214}
215
216199const upsertSurface = (
217200 current: ThreadRightPanelState,
218201 surface: RightPanelSurface,
apps/web/src/routes/_chat.pull-requests.tsx+19-20
......@@ -91,7 +91,7 @@ import { PullRequestListEmptyState } from "../components/pullRequest/PullRequest
9191import { PullRequestListGhost } from "../components/pullRequest/PullRequestGhosts";
9292import { PullRequestRow } from "../components/pullRequest/PullRequestRow";
9393import { PullRequestsUnavailableState } from "../components/pullRequest/PullRequestsUnavailableState";
94import { RightPanelTabs, type PullRequestTabStatus } from "../components/RightPanelTabs";
94import { RightPanelTabs, type PullRequestTabStatusSeed } from "../components/RightPanelTabs";
9595import {
9696 WorkspaceBreadcrumb,
9797 WorkspaceBreadcrumbItem,
......@@ -109,10 +109,10 @@ import { useLiveRefresh } from "../hooks/useLiveRefresh";
109109import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations";
110110import { toSortableTimestamp } from "../lib/threadSort";
111111import {
112 pullRequestSurfaceId,
112113 selectActiveRightPanelSurface,
113114 selectSelectedRightPanelSurface,
114115 selectThreadRightPanelState,
115 updatePullRequestTabStatus,
116116 useRightPanelStore,
117117 type PullRequestSurface,
118118} from "../rightPanelStore";
......@@ -475,22 +475,6 @@ function PullRequestsRouteView() {
475475 (renderedPullRequestSurface?.environmentId as EnvironmentId | undefined) ??
476476 selectedProject?.environmentId ??
477477 null;
478 const [pullRequestTabStatuses, setPullRequestTabStatuses] = useState<
479 Record<string, PullRequestTabStatus>
480 >({});
481 // Keyed by the surface the panel is showing rather than by a key rebuilt from the status: a
482 // surface opened from this page carries the environment its row was listed under, and a key
483 // assembled from the pull request alone would never name that surface back.
484 const activePullRequestSurfaceId = activePullRequestSurface?.id;
485 const handlePullRequestTabStatusChange = useCallback(
486 (status: PullRequestTabStatus) => {
487 const id = activePullRequestSurfaceId;
488 if (id === undefined) return;
489 setPullRequestTabStatuses((current) => updatePullRequestTabStatus(current, id, status));
490 },
491 [activePullRequestSurfaceId],
492 );
493
494478 const updateSearch = useCallback(
495479 (patch: {
496480 [Key in keyof PullRequestsSearch]?: PullRequestsSearch[Key] | undefined;
......@@ -1182,6 +1166,21 @@ function PullRequestsRouteView() {
11821166 viewers,
11831167 ]);
11841168
1169 // Seed the first tab paint from list rows while each tab's cached detail read lands.
1170 const listedPullRequestTabStatuses = useMemo<Record<string, PullRequestTabStatusSeed>>(
1171 () =>
1172 Object.fromEntries(
1173 entries.map((entry) => [
1174 pullRequestSurfaceId(entry),
1175 {
1176 state: entry.state,
1177 isDraft: entry.isDraft,
1178 },
1179 ]),
1180 ),
1181 [entries],
1182 );
1183
11851184 const scrollRef = useRef<HTMLDivElement>(null);
11861185 const sentinelRef = useRef<HTMLDivElement>(null);
11871186 useEffect(() => {
......@@ -1889,6 +1888,7 @@ function PullRequestsRouteView() {
18891888 // it. SSR has no window, so fall back to a reasonable width.
18901889 defaultWidth={typeof window === "undefined" ? 640 : Math.floor(window.innerWidth / 2)}
18911890 surfaces={renderedRightPanelSurfaces}
1891 environmentId={panelEnvironmentId}
18921892 activeSurfaceId={renderedPullRequestSurface.id}
18931893 pendingSurfaceIds={EMPTY_PENDING_SURFACES}
18941894 previewSessions={EMPTY_PREVIEW_SESSIONS}
......@@ -1922,7 +1922,7 @@ function PullRequestsRouteView() {
19221922 pullRequestAvailable={false}
19231923 agentsAvailable={false}
19241924 liveAgentCount={0}
1925 pullRequestStatuses={pullRequestTabStatuses}
1925 pullRequestStatusSeeds={listedPullRequestTabStatuses}
19261926 >
19271927 <PullRequestDetailPanel
19281928 key={renderedPullRequestSurface.id}
......@@ -1941,7 +1941,6 @@ function PullRequestsRouteView() {
19411941 authoredQuery.refresh();
19421942 reviewingQuery.refresh();
19431943 }}
1944 onStateChange={handlePullRequestTabStatusChange}
19451944 />
19461945 </RightPanelTabs>
19471946 ) : null}