authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-04 23:40:02-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 23:40:02-04:00
loga0eb23993a3bacf96d618918f43ea18f2da72390
tree4309939c82fd383884061ecc330943906ca6dc1a
parentb01771c2383bbcdf154b8c4e9f1a7d5e3867e2d0
signature Signed by PGP key B5690EEEBB952194

fix(web): prioritize open panel pull request when copying (#9877)


5 files changed, 108 insertions(+), 4 deletions(-)

apps/web/src/components/ChatView.tsx+10-1
......@@ -222,6 +222,7 @@ import {
222222import { useNowMinute } from "../hooks/useNowMinute";
223223import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations";
224224import { useNewThreadHandler } from "../hooks/useHandleNewThread";
225import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl";
225226import { useThreadActions } from "../hooks/useThreadActions";
226227import { resolveAppModelSelectionForInstance } from "../modelSelection";
227228import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm";
......@@ -5077,16 +5078,24 @@ export default function ChatView(props: ChatViewProps) {
50775078 threadRepository,
50785079 ],
50795080 );
5081 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(activeThreadRef);
50805082 const activeThreadReferenceCopyTarget = useMemo(
50815083 () =>
50825084 activeThreadId === null || !isServerThread
50835085 ? null
50845086 : resolveThreadReferenceCopyTarget({
50855087 threadId: activeThreadId,
5088 openPanelPullRequestUrl,
50865089 linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null,
50875090 detectedPullRequestUrl: activeThreadPr?.url ?? null,
50885091 }),
5089 [activeThreadId, activeThreadPr?.url, isServerThread, linkedThreadPullRequest?.url],
5092 [
5093 activeThreadId,
5094 activeThreadPr?.url,
5095 isServerThread,
5096 linkedThreadPullRequest?.url,
5097 openPanelPullRequestUrl,
5098 ],
50905099 );
50915100 const copyActiveThreadReference = useCallback(() => {
50925101 const target = activeThreadReferenceCopyTarget;
apps/web/src/components/CommandPalette.tsx+7-1
......@@ -71,6 +71,7 @@ import { useAtomValue } from "@effect/atom-react";
7171import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
7272import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps";
7373import { useHandleNewThread } from "../hooks/useHandleNewThread";
74import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl";
7475import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
7576import { useClientSettings } from "../hooks/useSettings";
7677import { useTheme } from "../hooks/useTheme";
......@@ -633,11 +634,15 @@ function OpenCommandPaletteDialog(props: {
633634 ),
634635 retainTerminalOnBranchMismatch: activeThread.worktreePath === null,
635636 })?.url ?? null);
637 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(
638 activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null,
639 );
636640 const activeThreadReferenceCopyTarget =
637641 activeThread == null
638642 ? null
639643 : resolveThreadReferenceCopyTarget({
640644 threadId: activeThread.id,
645 openPanelPullRequestUrl,
641646 linkedPullRequestUrl: activeThread.linkedPullRequest?.url ?? null,
642647 detectedPullRequestUrl,
643648 });
......@@ -2302,9 +2307,10 @@ function OpenCommandPaletteDialog(props: {
23022307 }
23032308 return;
23042309 }
2305 if (command === "thread.copyReference" && activeThreadReferenceCopyTarget !== null) {
2310 if (command === "thread.copyReference") {
23062311 event.preventDefault();
23072312 event.stopPropagation();
2313 if (activeThreadReferenceCopyTarget === null) return;
23082314 setOpen(false);
23092315 void copyActiveThreadReference();
23102316 return;
apps/web/src/hooks/useOpenPanelPullRequestUrl.ts created+59
......@@ -0,0 +1,59 @@
1import { scopeProjectRef } from "@t3tools/client-runtime/environment";
2import { EnvironmentId, ProjectId, type ScopedThreadRef } from "@t3tools/contracts";
3import { useMemo } from "react";
4
5import {
6 readPullRequestDetailSnapshot,
7 resolveDisplayedPullRequestDetail,
8} from "../components/pullRequest/pullRequestDetail.logic";
9import { gitHubPullRequestBrowserUrl } from "../lib/openPullRequestLink";
10import { selectActiveRightPanelSurface, useRightPanelStore } from "../rightPanelStore";
11import { useProject } from "../state/entities";
12import { pullRequestEnvironment } from "../state/pullRequests";
13import { useEnvironmentQuery } from "../state/query";
14
15export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) {
16 const surface = useRightPanelStore((state) =>
17 selectActiveRightPanelSurface(state.byThreadKey, threadRef),
18 );
19 const reference = surface?.kind === "pull-request" ? surface : null;
20 const environmentId = reference?.environmentId
21 ? EnvironmentId.make(reference.environmentId)
22 : threadRef?.environmentId;
23 const project = useProject(
24 reference && environmentId
25 ? scopeProjectRef(environmentId, ProjectId.make(reference.projectId))
26 : null,
27 );
28 const detail = useEnvironmentQuery(
29 reference && environmentId
30 ? pullRequestEnvironment.detail({
31 environmentId,
32 input: {
33 projectId: ProjectId.make(reference.projectId),
34 repository: reference.repository,
35 number: reference.number,
36 },
37 })
38 : null,
39 ).data;
40 const cachedDetail = useMemo(
41 () =>
42 reference && environmentId
43 ? readPullRequestDetailSnapshot(
44 typeof window === "undefined" ? undefined : window.localStorage,
45 environmentId,
46 reference,
47 )
48 : null,
49 [environmentId, reference],
50 );
51 return reference
52 ? (resolveDisplayedPullRequestDetail({ live: detail, cached: cachedDetail, reference })?.url ??
53 gitHubPullRequestBrowserUrl(
54 project?.repositoryIdentity,
55 reference.repository,
56 reference.number,
57 ))
58 : undefined;
59}
packages/shared/src/threadReference.test.ts+26
......@@ -3,6 +3,32 @@ import { describe, expect, it } from "vite-plus/test";
33import { resolveThreadReferenceCopyTarget } from "./threadReference.ts";
44
55describe("resolveThreadReferenceCopyTarget", () => {
6 it("does not copy another reference while the open panel URL is unavailable", () => {
7 expect(
8 resolveThreadReferenceCopyTarget({
9 threadId: "thread-1",
10 openPanelPullRequestUrl: null,
11 linkedPullRequestUrl: "https://github.com/t3/pr/12",
12 detectedPullRequestUrl: "https://github.com/t3/pr/13",
13 }),
14 ).toBeNull();
15 });
16
17 it("prefers the open panel pull request over linked and detected pull requests", () => {
18 expect(
19 resolveThreadReferenceCopyTarget({
20 threadId: "thread-1",
21 openPanelPullRequestUrl: "https://github.com/t3/pr/14",
22 linkedPullRequestUrl: "https://github.com/t3/pr/12",
23 detectedPullRequestUrl: "https://github.com/t3/pr/13",
24 }),
25 ).toMatchObject({
26 kind: "pull-request",
27 value: "https://github.com/t3/pr/14",
28 successTitle: "PR link copied",
29 });
30 });
31
632 it("prefers a durable linked pull request", () => {
733 expect(
834 resolveThreadReferenceCopyTarget({
packages/shared/src/threadReference.ts+6-2
......@@ -8,10 +8,14 @@ export interface ThreadReferenceCopyTarget {
88
99export function resolveThreadReferenceCopyTarget(input: {
1010 readonly threadId: string;
11 /** Undefined means no PR panel; null means its URL is not available yet. */
12 readonly openPanelPullRequestUrl?: string | null | undefined;
1113 readonly linkedPullRequestUrl?: string | null;
1214 readonly detectedPullRequestUrl?: string | null;
13}): ThreadReferenceCopyTarget {
14 const pullRequestUrl = input.linkedPullRequestUrl ?? input.detectedPullRequestUrl;
15}): ThreadReferenceCopyTarget | null {
16 if (input.openPanelPullRequestUrl === null) return null;
17 const pullRequestUrl =
18 input.openPanelPullRequestUrl ?? input.linkedPullRequestUrl ?? input.detectedPullRequestUrl;
1519 return pullRequestUrl
1620 ? {
1721 kind: "pull-request",