authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-07 20:47:13-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-07 20:47:13-03:00
logea2983afbbcd5ad6ee2e7db80c2a9270ff4964a9
tree22faea49e626fbea449a785c76f299042cdbf86a
parentd64335bb5913c21869633a4236d9409911e3da52
signature Signed by PGP key B5690EEEBB952194

fix(web): copy selected pull request link from PR page (#10615)


5 files changed, 90 insertions(+), 33 deletions(-)

apps/web/src/components/CommandPalette.tsx+19-7
......@@ -97,7 +97,11 @@ import {
9797import { onOpenCommandPalette } from "../commandPaletteBus";
9898import { isPreviewFocused } from "../lib/previewFocus";
9999import { isTerminalFocused } from "../lib/terminalFocus";
100import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore";
100import {
101 PULL_REQUESTS_PANEL_REF,
102 selectActiveRightPanel,
103 useRightPanelStore,
104} from "../rightPanelStore";
101105import { getLatestThreadForProject, sortThreads } from "../lib/threadSort";
102106import {
103107 cn,
......@@ -599,17 +603,25 @@ function OpenCommandPaletteDialog(props: {
599603 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =
600604 useHandleNewThread();
601605 const projects = useProjects();
602 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(
603 activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null,
604 );
606 const referenceThreadRef =
607 pathname === "/pull-requests"
608 ? environments.some(
609 (environment) => environment.serverConfig?.environment.capabilities.pullRequests === true,
610 )
611 ? PULL_REQUESTS_PANEL_REF
612 : null
613 : activeThread
614 ? scopeThreadRef(activeThread.environmentId, activeThread.id)
615 : null;
616 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(referenceThreadRef);
605617 const activeThreadReferenceCopyTarget =
606 activeThread == null
618 referenceThreadRef === null || (pathname === "/pull-requests" && !openPanelPullRequestUrl)
607619 ? null
608620 : resolveThreadReferenceCopyTarget({
609 threadId: activeThread.id,
621 threadId: referenceThreadRef.threadId,
610622 openPanelPullRequestUrl,
611623 linkedPullRequestUrl:
612 activeThread.linkedPullRequest?.url ?? activeThread.branchPullRequest?.url ?? null,
624 activeThread?.linkedPullRequest?.url ?? activeThread?.branchPullRequest?.url ?? null,
613625 });
614626 const copyActiveThreadReference = useCallback(async () => {
615627 const target = activeThreadReferenceCopyTarget;
apps/web/src/hooks/useOpenPanelPullRequestUrl.ts+1
......@@ -50,6 +50,7 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) {
5050 );
5151 return reference
5252 ? (resolveDisplayedPullRequestDetail({ live: detail, cached: cachedDetail, reference })?.url ??
53 reference.url ??
5354 gitHubPullRequestBrowserUrl(
5455 project?.repositoryIdentity,
5556 reference.repository,
apps/web/src/rightPanelStore.test.ts+6
......@@ -587,6 +587,8 @@ describe("rightPanelStore", () => {
587587 const second = { projectId: "project-a", repository: "pingdotgg/t3code", number: 4910 };
588588 useRightPanelStore.getState().openPullRequest(refA, first);
589589 useRightPanelStore.getState().openPullRequest(refA, second);
590 const url = "https://gitlab.example.com/pingdotgg/t3code/-/merge_requests/4909";
591 useRightPanelStore.getState().openPullRequest(refA, { ...first, url });
590592 useRightPanelStore.getState().openPullRequest(refA, first);
591593
592594 const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA);
......@@ -595,6 +597,10 @@ describe("rightPanelStore", () => {
595597 pullRequestSurfaceId(second),
596598 ]);
597599 expect(state.activeSurfaceId).toBe(pullRequestSurfaceId(first));
600 expect(
601 selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA),
602 ).toMatchObject({ url });
603 expect(state.surfaces[1]).not.toHaveProperty("url");
598604 });
599605
600606 it("keeps one pull request read from two servers as two tabs", () => {
apps/web/src/rightPanelStore.ts+34-5
......@@ -7,8 +7,13 @@
77 * terminal surfaces point at terminal session ids, file surfaces point at
88 * workspace paths, and diff/files remain singleton surfaces.
99 */
10import { scopedThreadKey } from "@t3tools/client-runtime/environment";
11import type { ChatFileAttachment, ScopedThreadRef } from "@t3tools/contracts";
10import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
11import {
12 EnvironmentId,
13 ThreadId,
14 type ChatFileAttachment,
15 type ScopedThreadRef,
16} from "@t3tools/contracts";
1217import { create } from "zustand";
1318import { createJSONStorage, persist } from "zustand/middleware";
1419
......@@ -65,6 +70,7 @@ export type RightPanelSurface =
6570 projectId: string;
6671 repository: string;
6772 number: number;
73 url?: string;
6874 }
6975 | { id: "agents"; kind: "agents" };
7076
......@@ -74,8 +80,14 @@ const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";
7480// v11 stops persisting the pull-request list's shared panel, so a restart opens the page fresh.
7581const RIGHT_PANEL_STORAGE_VERSION = 11;
7682
83/** A fixed workspace-level ref: each PR surface carries its own real environment. */
84export const PULL_REQUESTS_PANEL_REF = scopeThreadRef(
85 EnvironmentId.make("pull-requests-panel"),
86 ThreadId.make("pull-requests-panel"),
87);
88
7789/**
78 * The pull-request list's shared panel (see PULL_REQUESTS_PANEL_ID in the route) is session
90 * The pull-request list's shared panel is session
7991 * state: reopening the app should show the list, not last session's tabs and detail fetches.
8092 */
8193const isPullRequestsPanelKey = (threadKey: string) => threadKey.endsWith(":pull-requests-panel");
......@@ -109,7 +121,13 @@ interface RightPanelStoreState {
109121 openAttachment: (ref: ScopedThreadRef, attachment: ChatFileAttachment) => void;
110122 openPullRequest: (
111123 ref: ScopedThreadRef,
112 target: { environmentId?: string; projectId: string; repository: string; number: number },
124 target: {
125 environmentId?: string;
126 projectId: string;
127 repository: string;
128 number: number;
129 url?: string;
130 },
113131 ) => void;
114132 openTerminal: (ref: ScopedThreadRef, terminalId: string) => void;
115133 splitTerminal: (
......@@ -210,6 +228,7 @@ export function pullRequestSurface(target: {
210228 projectId: string;
211229 repository: string;
212230 number: number;
231 url?: string;
213232}): PullRequestSurface {
214233 return {
215234 id: pullRequestSurfaceId(target),
......@@ -218,6 +237,7 @@ export function pullRequestSurface(target: {
218237 projectId: target.projectId,
219238 repository: target.repository,
220239 number: target.number,
240 ...(typeof target.url === "string" ? { url: target.url } : {}),
221241 };
222242}
223243
......@@ -444,7 +464,16 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
444464 openPullRequest: (ref, target) =>
445465 set((state) =>
446466 userAction(state, scopedThreadKey(ref), (current) => {
447 return upsertSurface(current, pullRequestSurface(target));
467 const surface = pullRequestSurface(target);
468 const next = upsertSurface(current, surface);
469 return target.url
470 ? {
471 ...next,
472 surfaces: next.surfaces.map((entry) =>
473 entry.id === surface.id ? surface : entry,
474 ),
475 }
476 : next;
448477 }),
449478 ),
450479 openFile: (ref, relativePath, line) =>
apps/web/src/routes/_chat.pull-requests.tsx+30-21
......@@ -1,7 +1,6 @@
11import { RefreshIcon } from "~/components/ui/refresh-icon";
22import { Spinner } from "~/components/ui/spinner";
3import { scopeThreadRef } from "@t3tools/client-runtime/environment";
4import { pullRequestHostOf, resolveEnvironmentMachineKind, ThreadId } from "@t3tools/contracts";
3import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contracts";
54import type {
65 EnvironmentId,
76 ProjectId,
......@@ -120,8 +119,12 @@ import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../
120119import { SidebarInset } from "../components/ui/sidebar";
121120import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";
122121import { useLiveRefresh } from "../hooks/useLiveRefresh";
122import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl";
123import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
124import { toastManager } from "../components/ui/toast";
123125import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations";
124126import {
127 PULL_REQUESTS_PANEL_REF,
125128 pullRequestSurfaceId,
126129 selectActiveRightPanelSurface,
127130 selectSelectedRightPanelSurface,
......@@ -205,15 +208,6 @@ const PAGE_SIZE = 99;
205208const MAX_PAGE_SIZE = 500;
206209/** Stable empty map so the memos below do not see a new object on every render. */
207210const EMPTY_VIEWERS: PullRequestListResult["viewers"] = {};
208/** The list owns one environment-scoped right panel rather than borrowing a real thread's. */
209const PULL_REQUESTS_PANEL_ID = ThreadId.make("pull-requests-panel");
210/**
211 * A fixed sentinel, not a real server: the panel is one workspace-level surface list (each
212 * surface already carries the server it was read from), so its store key must not move when a
213 * capable server disconnects or reconnects. Real environment ids are server-generated UUIDs, so
214 * this string can never collide with one.
215 */
216const PULL_REQUESTS_PANEL_ENVIRONMENT_ID = "pull-requests-panel" as EnvironmentId;
217211/** Stable so a read that is not wanted right now does not re-key on every render. */
218212const NO_LIST_TARGETS: ReadonlyArray<EnvironmentQueryTarget<PullRequestListInput>> = [];
219213const EMPTY_PREVIEW_SESSIONS = {};
......@@ -424,13 +418,8 @@ function PullRequestsRouteView() {
424418 // read from, so tabs from two of them sit side by side instead of replacing each other. Its ref
425419 // uses a fixed sentinel environment, not whichever server happens to sort first, so the tab
426420 // strip survives a capable server disconnecting or losing the pull-requests capability.
427 const rightPanelRef = useMemo(
428 () =>
429 capableEnvironments.length === 0
430 ? null
431 : scopeThreadRef(PULL_REQUESTS_PANEL_ENVIRONMENT_ID, PULL_REQUESTS_PANEL_ID),
432 [capableEnvironments.length],
433 );
421 const rightPanelRef = capableEnvironments.length === 0 ? null : PULL_REQUESTS_PANEL_REF;
422 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(rightPanelRef);
434423 const rightPanelState = useRightPanelStore((state) =>
435424 selectThreadRightPanelState(state.byThreadKey, rightPanelRef),
436425 );
......@@ -453,7 +442,7 @@ function PullRequestsRouteView() {
453442 rightPanelState.isOpen && selectedPullRequestSurface !== null,
454443 rightPanelPresenceValue,
455444 panelAnimationsActive,
456 rightPanelRef === null ? null : PULL_REQUESTS_PANEL_ID,
445 rightPanelRef?.threadId ?? null,
457446 panelAnimationDurationMs,
458447 );
459448 const rightPanelPresent = rightPanelPresence.present;
......@@ -1852,8 +1841,27 @@ function PullRequestsRouteView() {
18521841 selectSurfaceInUrl(null);
18531842 };
18541843
1855 // This page has no ChatView, so the shared panel handles `rightPanel.close`
1856 // itself. With nothing open the event falls through to its native meaning.
1844 // This page has no ChatView, so it handles the shared panel shortcuts itself.
1845 const copyPullRequestFromShortcut = useEffectEvent((event: KeyboardEvent) => {
1846 if (!openPanelPullRequestUrl) return;
1847 event.preventDefault();
1848 event.stopPropagation();
1849 if (event.repeat) return;
1850 const url = openPanelPullRequestUrl;
1851 void writeTextToClipboard(url, "pull request link").then(
1852 (didCopy) => {
1853 if (didCopy)
1854 toastManager.add({ type: "success", title: "PR link copied", description: url });
1855 },
1856 (error) => {
1857 toastManager.add({
1858 type: "error",
1859 title: "Failed to copy PR link",
1860 description: error instanceof Error ? error.message : "An error occurred.",
1861 });
1862 },
1863 );
1864 });
18571865 const closeActiveSurfaceFromShortcut = useEffectEvent((event: KeyboardEvent) => {
18581866 if (activePullRequestSurface === null) return;
18591867 event.preventDefault();
......@@ -1867,6 +1875,7 @@ function PullRequestsRouteView() {
18671875 context: { terminalFocus: isTerminalFocused() },
18681876 });
18691877 if (command === "rightPanel.close") closeActiveSurfaceFromShortcut(event);
1878 if (command === "thread.copyReference") copyPullRequestFromShortcut(event);
18701879 };
18711880 window.addEventListener("keydown", onKeyDown);
18721881 return () => window.removeEventListener("keydown", onKeyDown);