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 {...@@ -97,7 +97,11 @@ import {
97import { onOpenCommandPalette } from "../commandPaletteBus";97import { onOpenCommandPalette } from "../commandPaletteBus";
98import { isPreviewFocused } from "../lib/previewFocus";98import { isPreviewFocused } from "../lib/previewFocus";
99import { isTerminalFocused } from "../lib/terminalFocus";99import { isTerminalFocused } from "../lib/terminalFocus";
100import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore";100import {
101 PULL_REQUESTS_PANEL_REF,
102 selectActiveRightPanel,
103 useRightPanelStore,
104} from "../rightPanelStore";
101import { getLatestThreadForProject, sortThreads } from "../lib/threadSort";105import { getLatestThreadForProject, sortThreads } from "../lib/threadSort";
102import {106import {
103 cn,107 cn,
...@@ -599,17 +603,25 @@ function OpenCommandPaletteDialog(props: {...@@ -599,17 +603,25 @@ function OpenCommandPaletteDialog(props: {
599 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =603 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =
600 useHandleNewThread();604 useHandleNewThread();
601 const projects = useProjects();605 const projects = useProjects();
602 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(606 const referenceThreadRef =
603 activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null,607 pathname === "/pull-requests"
604 );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);
605 const activeThreadReferenceCopyTarget =617 const activeThreadReferenceCopyTarget =
606 activeThread == null618 referenceThreadRef === null || (pathname === "/pull-requests" && !openPanelPullRequestUrl)
607 ? null619 ? null
608 : resolveThreadReferenceCopyTarget({620 : resolveThreadReferenceCopyTarget({
609 threadId: activeThread.id,621 threadId: referenceThreadRef.threadId,
610 openPanelPullRequestUrl,622 openPanelPullRequestUrl,
611 linkedPullRequestUrl:623 linkedPullRequestUrl:
612 activeThread.linkedPullRequest?.url ?? activeThread.branchPullRequest?.url ?? null,624 activeThread?.linkedPullRequest?.url ?? activeThread?.branchPullRequest?.url ?? null,
613 });625 });
614 const copyActiveThreadReference = useCallback(async () => {626 const copyActiveThreadReference = useCallback(async () => {
615 const target = activeThreadReferenceCopyTarget;627 const target = activeThreadReferenceCopyTarget;
apps/web/src/hooks/useOpenPanelPullRequestUrl.ts+1
...@@ -50,6 +50,7 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) {...@@ -50,6 +50,7 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) {
50 );50 );
51 return reference51 return reference
52 ? (resolveDisplayedPullRequestDetail({ live: detail, cached: cachedDetail, reference })?.url ??52 ? (resolveDisplayedPullRequestDetail({ live: detail, cached: cachedDetail, reference })?.url ??
53 reference.url ??
53 gitHubPullRequestBrowserUrl(54 gitHubPullRequestBrowserUrl(
54 project?.repositoryIdentity,55 project?.repositoryIdentity,
55 reference.repository,56 reference.repository,
apps/web/src/rightPanelStore.test.ts+6
...@@ -587,6 +587,8 @@ describe("rightPanelStore", () => {...@@ -587,6 +587,8 @@ describe("rightPanelStore", () => {
587 const second = { projectId: "project-a", repository: "pingdotgg/t3code", number: 4910 };587 const second = { projectId: "project-a", repository: "pingdotgg/t3code", number: 4910 };
588 useRightPanelStore.getState().openPullRequest(refA, first);588 useRightPanelStore.getState().openPullRequest(refA, first);
589 useRightPanelStore.getState().openPullRequest(refA, second);589 useRightPanelStore.getState().openPullRequest(refA, second);
590 const url = "https://gitlab.example.com/pingdotgg/t3code/-/merge_requests/4909";
591 useRightPanelStore.getState().openPullRequest(refA, { ...first, url });
590 useRightPanelStore.getState().openPullRequest(refA, first);592 useRightPanelStore.getState().openPullRequest(refA, first);
591593
592 const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA);594 const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA);
...@@ -595,6 +597,10 @@ describe("rightPanelStore", () => {...@@ -595,6 +597,10 @@ describe("rightPanelStore", () => {
595 pullRequestSurfaceId(second),597 pullRequestSurfaceId(second),
596 ]);598 ]);
597 expect(state.activeSurfaceId).toBe(pullRequestSurfaceId(first));599 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");
598 });604 });
599605
600 it("keeps one pull request read from two servers as two tabs", () => {606 it("keeps one pull request read from two servers as two tabs", () => {
apps/web/src/rightPanelStore.ts+34-5
...@@ -7,8 +7,13 @@...@@ -7,8 +7,13 @@
7 * terminal surfaces point at terminal session ids, file surfaces point at7 * terminal surfaces point at terminal session ids, file surfaces point at
8 * workspace paths, and diff/files remain singleton surfaces.8 * workspace paths, and diff/files remain singleton surfaces.
9 */9 */
10import { scopedThreadKey } from "@t3tools/client-runtime/environment";10import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
11import type { ChatFileAttachment, ScopedThreadRef } from "@t3tools/contracts";11import {
12 EnvironmentId,
13 ThreadId,
14 type ChatFileAttachment,
15 type ScopedThreadRef,
16} from "@t3tools/contracts";
12import { create } from "zustand";17import { create } from "zustand";
13import { createJSONStorage, persist } from "zustand/middleware";18import { createJSONStorage, persist } from "zustand/middleware";
1419
...@@ -65,6 +70,7 @@ export type RightPanelSurface =...@@ -65,6 +70,7 @@ export type RightPanelSurface =
65 projectId: string;70 projectId: string;
66 repository: string;71 repository: string;
67 number: number;72 number: number;
73 url?: string;
68 }74 }
69 | { id: "agents"; kind: "agents" };75 | { id: "agents"; kind: "agents" };
7076
...@@ -74,8 +80,14 @@ const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";...@@ -74,8 +80,14 @@ const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";
74// v11 stops persisting the pull-request list's shared panel, so a restart opens the page fresh.80// v11 stops persisting the pull-request list's shared panel, so a restart opens the page fresh.
75const RIGHT_PANEL_STORAGE_VERSION = 11;81const 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
77/**89/**
78 * The pull-request list's shared panel (see PULL_REQUESTS_PANEL_ID in the route) is session90 * The pull-request list's shared panel is session
79 * state: reopening the app should show the list, not last session's tabs and detail fetches.91 * state: reopening the app should show the list, not last session's tabs and detail fetches.
80 */92 */
81const isPullRequestsPanelKey = (threadKey: string) => threadKey.endsWith(":pull-requests-panel");93const isPullRequestsPanelKey = (threadKey: string) => threadKey.endsWith(":pull-requests-panel");
...@@ -109,7 +121,13 @@ interface RightPanelStoreState {...@@ -109,7 +121,13 @@ interface RightPanelStoreState {
109 openAttachment: (ref: ScopedThreadRef, attachment: ChatFileAttachment) => void;121 openAttachment: (ref: ScopedThreadRef, attachment: ChatFileAttachment) => void;
110 openPullRequest: (122 openPullRequest: (
111 ref: ScopedThreadRef,123 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 },
113 ) => void;131 ) => void;
114 openTerminal: (ref: ScopedThreadRef, terminalId: string) => void;132 openTerminal: (ref: ScopedThreadRef, terminalId: string) => void;
115 splitTerminal: (133 splitTerminal: (
...@@ -210,6 +228,7 @@ export function pullRequestSurface(target: {...@@ -210,6 +228,7 @@ export function pullRequestSurface(target: {
210 projectId: string;228 projectId: string;
211 repository: string;229 repository: string;
212 number: number;230 number: number;
231 url?: string;
213}): PullRequestSurface {232}): PullRequestSurface {
214 return {233 return {
215 id: pullRequestSurfaceId(target),234 id: pullRequestSurfaceId(target),
...@@ -218,6 +237,7 @@ export function pullRequestSurface(target: {...@@ -218,6 +237,7 @@ export function pullRequestSurface(target: {
218 projectId: target.projectId,237 projectId: target.projectId,
219 repository: target.repository,238 repository: target.repository,
220 number: target.number,239 number: target.number,
240 ...(typeof target.url === "string" ? { url: target.url } : {}),
221 };241 };
222}242}
223243
...@@ -444,7 +464,16 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -444,7 +464,16 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
444 openPullRequest: (ref, target) =>464 openPullRequest: (ref, target) =>
445 set((state) =>465 set((state) =>
446 userAction(state, scopedThreadKey(ref), (current) => {466 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;
448 }),477 }),
449 ),478 ),
450 openFile: (ref, relativePath, line) =>479 openFile: (ref, relativePath, line) =>
apps/web/src/routes/_chat.pull-requests.tsx+30-21
...@@ -1,7 +1,6 @@...@@ -1,7 +1,6 @@
1import { RefreshIcon } from "~/components/ui/refresh-icon";1import { RefreshIcon } from "~/components/ui/refresh-icon";
2import { Spinner } from "~/components/ui/spinner";2import { Spinner } from "~/components/ui/spinner";
3import { scopeThreadRef } from "@t3tools/client-runtime/environment";3import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contracts";
4import { pullRequestHostOf, resolveEnvironmentMachineKind, ThreadId } from "@t3tools/contracts";
5import type {4import type {
6 EnvironmentId,5 EnvironmentId,
7 ProjectId,6 ProjectId,
...@@ -120,8 +119,12 @@ import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../...@@ -120,8 +119,12 @@ import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../
120import { SidebarInset } from "../components/ui/sidebar";119import { SidebarInset } from "../components/ui/sidebar";
121import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";120import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";
122import { useLiveRefresh } from "../hooks/useLiveRefresh";121import { useLiveRefresh } from "../hooks/useLiveRefresh";
122import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl";
123import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
124import { toastManager } from "../components/ui/toast";
123import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations";125import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations";
124import {126import {
127 PULL_REQUESTS_PANEL_REF,
125 pullRequestSurfaceId,128 pullRequestSurfaceId,
126 selectActiveRightPanelSurface,129 selectActiveRightPanelSurface,
127 selectSelectedRightPanelSurface,130 selectSelectedRightPanelSurface,
...@@ -205,15 +208,6 @@ const PAGE_SIZE = 99;...@@ -205,15 +208,6 @@ const PAGE_SIZE = 99;
205const MAX_PAGE_SIZE = 500;208const MAX_PAGE_SIZE = 500;
206/** Stable empty map so the memos below do not see a new object on every render. */209/** Stable empty map so the memos below do not see a new object on every render. */
207const EMPTY_VIEWERS: PullRequestListResult["viewers"] = {};210const 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;
217/** Stable so a read that is not wanted right now does not re-key on every render. */211/** Stable so a read that is not wanted right now does not re-key on every render. */
218const NO_LIST_TARGETS: ReadonlyArray<EnvironmentQueryTarget<PullRequestListInput>> = [];212const NO_LIST_TARGETS: ReadonlyArray<EnvironmentQueryTarget<PullRequestListInput>> = [];
219const EMPTY_PREVIEW_SESSIONS = {};213const EMPTY_PREVIEW_SESSIONS = {};
...@@ -424,13 +418,8 @@ function PullRequestsRouteView() {...@@ -424,13 +418,8 @@ function PullRequestsRouteView() {
424 // read from, so tabs from two of them sit side by side instead of replacing each other. Its ref418 // read from, so tabs from two of them sit side by side instead of replacing each other. Its ref
425 // uses a fixed sentinel environment, not whichever server happens to sort first, so the tab419 // uses a fixed sentinel environment, not whichever server happens to sort first, so the tab
426 // strip survives a capable server disconnecting or losing the pull-requests capability.420 // strip survives a capable server disconnecting or losing the pull-requests capability.
427 const rightPanelRef = useMemo(421 const rightPanelRef = capableEnvironments.length === 0 ? null : PULL_REQUESTS_PANEL_REF;
428 () =>422 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(rightPanelRef);
429 capableEnvironments.length === 0
430 ? null
431 : scopeThreadRef(PULL_REQUESTS_PANEL_ENVIRONMENT_ID, PULL_REQUESTS_PANEL_ID),
432 [capableEnvironments.length],
433 );
434 const rightPanelState = useRightPanelStore((state) =>423 const rightPanelState = useRightPanelStore((state) =>
435 selectThreadRightPanelState(state.byThreadKey, rightPanelRef),424 selectThreadRightPanelState(state.byThreadKey, rightPanelRef),
436 );425 );
...@@ -453,7 +442,7 @@ function PullRequestsRouteView() {...@@ -453,7 +442,7 @@ function PullRequestsRouteView() {
453 rightPanelState.isOpen && selectedPullRequestSurface !== null,442 rightPanelState.isOpen && selectedPullRequestSurface !== null,
454 rightPanelPresenceValue,443 rightPanelPresenceValue,
455 panelAnimationsActive,444 panelAnimationsActive,
456 rightPanelRef === null ? null : PULL_REQUESTS_PANEL_ID,445 rightPanelRef?.threadId ?? null,
457 panelAnimationDurationMs,446 panelAnimationDurationMs,
458 );447 );
459 const rightPanelPresent = rightPanelPresence.present;448 const rightPanelPresent = rightPanelPresence.present;
...@@ -1852,8 +1841,27 @@ function PullRequestsRouteView() {...@@ -1852,8 +1841,27 @@ function PullRequestsRouteView() {
1852 selectSurfaceInUrl(null);1841 selectSurfaceInUrl(null);
1853 };1842 };
18541843
1855 // This page has no ChatView, so the shared panel handles `rightPanel.close`1844 // This page has no ChatView, so it handles the shared panel shortcuts itself.
1856 // itself. With nothing open the event falls through to its native meaning.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 });
1857 const closeActiveSurfaceFromShortcut = useEffectEvent((event: KeyboardEvent) => {1865 const closeActiveSurfaceFromShortcut = useEffectEvent((event: KeyboardEvent) => {
1858 if (activePullRequestSurface === null) return;1866 if (activePullRequestSurface === null) return;
1859 event.preventDefault();1867 event.preventDefault();
...@@ -1867,6 +1875,7 @@ function PullRequestsRouteView() {...@@ -1867,6 +1875,7 @@ function PullRequestsRouteView() {
1867 context: { terminalFocus: isTerminalFocused() },1875 context: { terminalFocus: isTerminalFocused() },
1868 });1876 });
1869 if (command === "rightPanel.close") closeActiveSurfaceFromShortcut(event);1877 if (command === "rightPanel.close") closeActiveSurfaceFromShortcut(event);
1878 if (command === "thread.copyReference") copyPullRequestFromShortcut(event);
1870 };1879 };
1871 window.addEventListener("keydown", onKeyDown);1880 window.addEventListener("keydown", onKeyDown);
1872 return () => window.removeEventListener("keydown", onKeyDown);1881 return () => window.removeEventListener("keydown", onKeyDown);