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 {...@@ -222,6 +222,7 @@ import {
222import { useNowMinute } from "../hooks/useNowMinute";222import { useNowMinute } from "../hooks/useNowMinute";
223import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations";223import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations";
224import { useNewThreadHandler } from "../hooks/useHandleNewThread";224import { useNewThreadHandler } from "../hooks/useHandleNewThread";
225import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl";
225import { useThreadActions } from "../hooks/useThreadActions";226import { useThreadActions } from "../hooks/useThreadActions";
226import { resolveAppModelSelectionForInstance } from "../modelSelection";227import { resolveAppModelSelectionForInstance } from "../modelSelection";
227import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm";228import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm";
...@@ -5077,16 +5078,24 @@ export default function ChatView(props: ChatViewProps) {...@@ -5077,16 +5078,24 @@ export default function ChatView(props: ChatViewProps) {
5077 threadRepository,5078 threadRepository,
5078 ],5079 ],
5079 );5080 );
5081 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(activeThreadRef);
5080 const activeThreadReferenceCopyTarget = useMemo(5082 const activeThreadReferenceCopyTarget = useMemo(
5081 () =>5083 () =>
5082 activeThreadId === null || !isServerThread5084 activeThreadId === null || !isServerThread
5083 ? null5085 ? null
5084 : resolveThreadReferenceCopyTarget({5086 : resolveThreadReferenceCopyTarget({
5085 threadId: activeThreadId,5087 threadId: activeThreadId,
5088 openPanelPullRequestUrl,
5086 linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null,5089 linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null,
5087 detectedPullRequestUrl: activeThreadPr?.url ?? null,5090 detectedPullRequestUrl: activeThreadPr?.url ?? null,
5088 }),5091 }),
5089 [activeThreadId, activeThreadPr?.url, isServerThread, linkedThreadPullRequest?.url],5092 [
5093 activeThreadId,
5094 activeThreadPr?.url,
5095 isServerThread,
5096 linkedThreadPullRequest?.url,
5097 openPanelPullRequestUrl,
5098 ],
5090 );5099 );
5091 const copyActiveThreadReference = useCallback(() => {5100 const copyActiveThreadReference = useCallback(() => {
5092 const target = activeThreadReferenceCopyTarget;5101 const target = activeThreadReferenceCopyTarget;
apps/web/src/components/CommandPalette.tsx+7-1
...@@ -71,6 +71,7 @@ import { useAtomValue } from "@effect/atom-react";...@@ -71,6 +71,7 @@ import { useAtomValue } from "@effect/atom-react";
71import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";71import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
72import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps";72import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps";
73import { useHandleNewThread } from "../hooks/useHandleNewThread";73import { useHandleNewThread } from "../hooks/useHandleNewThread";
74import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl";
74import { writeTextToClipboard } from "../hooks/useCopyToClipboard";75import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
75import { useClientSettings } from "../hooks/useSettings";76import { useClientSettings } from "../hooks/useSettings";
76import { useTheme } from "../hooks/useTheme";77import { useTheme } from "../hooks/useTheme";
...@@ -633,11 +634,15 @@ function OpenCommandPaletteDialog(props: {...@@ -633,11 +634,15 @@ function OpenCommandPaletteDialog(props: {
633 ),634 ),
634 retainTerminalOnBranchMismatch: activeThread.worktreePath === null,635 retainTerminalOnBranchMismatch: activeThread.worktreePath === null,
635 })?.url ?? null);636 })?.url ?? null);
637 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(
638 activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null,
639 );
636 const activeThreadReferenceCopyTarget =640 const activeThreadReferenceCopyTarget =
637 activeThread == null641 activeThread == null
638 ? null642 ? null
639 : resolveThreadReferenceCopyTarget({643 : resolveThreadReferenceCopyTarget({
640 threadId: activeThread.id,644 threadId: activeThread.id,
645 openPanelPullRequestUrl,
641 linkedPullRequestUrl: activeThread.linkedPullRequest?.url ?? null,646 linkedPullRequestUrl: activeThread.linkedPullRequest?.url ?? null,
642 detectedPullRequestUrl,647 detectedPullRequestUrl,
643 });648 });
...@@ -2302,9 +2307,10 @@ function OpenCommandPaletteDialog(props: {...@@ -2302,9 +2307,10 @@ function OpenCommandPaletteDialog(props: {
2302 }2307 }
2303 return;2308 return;
2304 }2309 }
2305 if (command === "thread.copyReference" && activeThreadReferenceCopyTarget !== null) {2310 if (command === "thread.copyReference") {
2306 event.preventDefault();2311 event.preventDefault();
2307 event.stopPropagation();2312 event.stopPropagation();
2313 if (activeThreadReferenceCopyTarget === null) return;
2308 setOpen(false);2314 setOpen(false);
2309 void copyActiveThreadReference();2315 void copyActiveThreadReference();
2310 return;2316 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";...@@ -3,6 +3,32 @@ import { describe, expect, it } from "vite-plus/test";
3import { resolveThreadReferenceCopyTarget } from "./threadReference.ts";3import { resolveThreadReferenceCopyTarget } from "./threadReference.ts";
44
5describe("resolveThreadReferenceCopyTarget", () => {5describe("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
6 it("prefers a durable linked pull request", () => {32 it("prefers a durable linked pull request", () => {
7 expect(33 expect(
8 resolveThreadReferenceCopyTarget({34 resolveThreadReferenceCopyTarget({
packages/shared/src/threadReference.ts+6-2
...@@ -8,10 +8,14 @@ export interface ThreadReferenceCopyTarget {...@@ -8,10 +8,14 @@ export interface ThreadReferenceCopyTarget {
88
9export function resolveThreadReferenceCopyTarget(input: {9export function resolveThreadReferenceCopyTarget(input: {
10 readonly threadId: string;10 readonly threadId: string;
11 /** Undefined means no PR panel; null means its URL is not available yet. */
12 readonly openPanelPullRequestUrl?: string | null | undefined;
11 readonly linkedPullRequestUrl?: string | null;13 readonly linkedPullRequestUrl?: string | null;
12 readonly detectedPullRequestUrl?: string | null;14 readonly detectedPullRequestUrl?: string | null;
13}): ThreadReferenceCopyTarget {15}): ThreadReferenceCopyTarget | null {
14 const pullRequestUrl = input.linkedPullRequestUrl ?? input.detectedPullRequestUrl;16 if (input.openPanelPullRequestUrl === null) return null;
17 const pullRequestUrl =
18 input.openPanelPullRequestUrl ?? input.linkedPullRequestUrl ?? input.detectedPullRequestUrl;
15 return pullRequestUrl19 return pullRequestUrl
16 ? {20 ? {
17 kind: "pull-request",21 kind: "pull-request",