From aec1a54fb680a0790238bef501c54fb3ad06cc7a Mon Sep 17 00:00:00 2001
From: Marat Fattakhov <161203mar@gmail.com>
Date: Fri, 28 Aug 2026 08:14:45 +0300
Subject: [PATCH] feat(pull-requests): link a PR to its thread from the PR
action menu
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A thread could already be linked to a pull request, but only from the thread
side. Standing in the PR detail panel — the place you land from the PR list or
from a review notification — there was no way to say "this PR belongs to that
thread", and after unlinking there was no way back at all.
Add link and unlink entries to the PR action menu, driven by the open thread
rather than the handoff attach target. Gating on the attach target would strand
the reader: once they unlink, the thread stops matching the PR by branch, the
panel falls back to page context, and the way back would vanish with it.
---
.../pullRequest/PullRequestDetailPanel.tsx | 127 +++++++++++++++++-
.../pullRequestDetail.logic.test.ts | 54 ++++++++
.../pullRequest/pullRequestDetail.logic.ts | 23 ++++
.../web/src/hooks/useThreadPullRequestLink.ts | 33 +++++
4 files changed, 234 insertions(+), 3 deletions(-)
create mode 100644 apps/web/src/hooks/useThreadPullRequestLink.ts
diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
index b7826d4f9a52..577660d4276a 100644
--- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
+++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
@@ -1,5 +1,8 @@
import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
-import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
+import {
+ isAtomCommandInterrupted,
+ squashAtomCommandFailure,
+} from "@t3tools/client-runtime/state/runtime";
import type {
EnvironmentId,
PullRequestAction,
@@ -8,6 +11,7 @@ import type {
PullRequestRef,
PullRequestState,
ScopedThreadRef,
+ ThreadLinkedPullRequest,
} from "@t3tools/contracts";
import {
ArrowDownUpIcon,
@@ -26,6 +30,7 @@ import {
GitPullRequestIcon,
HammerIcon,
LayersIcon,
+ Link2Icon,
MessageCircleQuestionIcon,
MessageSquareIcon,
LinkIcon,
@@ -35,6 +40,7 @@ import {
RefreshCwIcon,
ServerIcon,
TriangleAlertIcon,
+ UnlinkIcon,
} from "lucide-react";
import {
lazy,
@@ -51,12 +57,17 @@ import {
import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";
-import { changeRequestRepositoryUrl, gitHubPullRequestBrowserUrl } from "~/lib/openPullRequestLink";
+import { useThreadPullRequestLinkActions } from "~/hooks/useThreadPullRequestLink";
+import {
+ changeRequestRepositoryUrl,
+ gitHubPullRequestBrowserUrl,
+ matchesLinkedPullRequestUrl,
+} from "~/lib/openPullRequestLink";
import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";
import { cn } from "~/lib/utils";
import { readLocalApi } from "~/localApi";
import type { ReviewCommentContext } from "~/reviewCommentContext";
-import { useProjects } from "~/state/entities";
+import { useProjects, useServerConfigs, useThreadShell } from "~/state/entities";
import { useEnvironments } from "~/state/environments";
import { useEnvironmentQuery } from "~/state/query";
import { useLiveRefresh } from "~/hooks/useLiveRefresh";
@@ -109,6 +120,7 @@ import {
handoffReviewComments,
latestPullRequestReviewOutcomes,
isStackedPullRequestBase,
+ isThreadLinkedToPullRequest,
pullRequestActionMenuHasGroup,
pullRequestActionNeedsHostRefresh,
pullRequestComposerTarget,
@@ -715,6 +727,85 @@ export function PullRequestDetailPanel({
const attachTarget = pullRequestComposerTarget(context, composerDraftTarget);
const handoffLabels = pullRequestHandoffLabels(attachTarget !== null);
+ // Linking from the pull-request side needs a real thread to pin, and that is
+ // the open thread — not the handoff attach target. Gating on thread context
+ // would strand the reader: once they unlink, the thread stops matching this
+ // pull request by branch, the panel falls back to page context, and the way
+ // back would vanish with it.
+ const attachThreadRef =
+ composerDraftTarget !== undefined &&
+ composerDraftTarget !== null &&
+ typeof composerDraftTarget !== "string"
+ ? composerDraftTarget
+ : null;
+ const attachThread = useThreadShell(attachThreadRef);
+ const { linkPullRequest, unlinkPullRequest } = useThreadPullRequestLinkActions();
+ const threadLinkedPullRequest = attachThread?.linkedPullRequest ?? null;
+ const isLinkedToThisPullRequest = isThreadLinkedToPullRequest({
+ linkedPullRequest: threadLinkedPullRequest,
+ detail,
+ matchesUrl: (linked, targetUrl) =>
+ matchesLinkedPullRequestUrl(linked as ThreadLinkedPullRequest, targetUrl),
+ });
+ // Capabilities land after connect, so this read has to be reactive: an older
+ // server drops linkedPullRequest from thread.meta.update and still resolves,
+ // which would leave a success toast for a link that was never persisted.
+ const serverConfigs = useServerConfigs();
+ const supportsPullRequestLink =
+ attachThreadRef !== null &&
+ serverConfigs.get(attachThreadRef.environmentId)?.environment.capabilities
+ .threadPullRequestLinking === true;
+
+ // thread.meta.update is last-write-wins, so two of these in flight at once
+ // can land in the reverse order and leave the thread on the choice the
+ // reader made first. One at a time, and the item says so while it runs.
+ // Which operation is running, not just that one is: the thread shell picks
+ // up the new link before the command settles, so a label read off the live
+ // state would flip to the opposite verb mid-flight.
+ const [pullRequestLinkPending, setPullRequestLinkPending] = useState<"link" | "unlink" | null>(
+ null,
+ );
+
+ const linkPullRequestToThread = () => {
+ if (detail === null || attachThreadRef === null || pullRequestLinkPending !== null) return;
+ const link: ThreadLinkedPullRequest = {
+ projectId: detail.projectId,
+ repository: reference.repository,
+ number: detail.number,
+ url: detail.url,
+ };
+ setPullRequestLinkPending("link");
+ void linkPullRequest(attachThreadRef, link).then((result) => {
+ setPullRequestLinkPending(null);
+ if (result._tag === "Failure") {
+ if (!isAtomCommandInterrupted(result)) {
+ toastManager.add({ type: "error", title: "Could not link the pull request" });
+ }
+ return;
+ }
+ toastManager.add({
+ type: "success",
+ title: "Linked to this thread",
+ description: `#${detail.number} now drives this thread's badge and settle-on-merge.`,
+ });
+ });
+ };
+
+ const unlinkPullRequestFromThread = () => {
+ if (attachThreadRef === null || pullRequestLinkPending !== null) return;
+ setPullRequestLinkPending("unlink");
+ void unlinkPullRequest(attachThreadRef).then((result) => {
+ setPullRequestLinkPending(null);
+ if (result._tag === "Failure") {
+ if (!isAtomCommandInterrupted(result)) {
+ toastManager.add({ type: "error", title: "Could not unlink the pull request" });
+ }
+ return;
+ }
+ toastManager.add({ type: "success", title: "Unlinked from this thread" });
+ });
+ };
+
const writeTaskToComposer = (target: ScopedThreadRef | DraftId, task: ThreadTask) => {
const store = useComposerDraftStore.getState();
const draft = store.getComposerDraft(target);
@@ -1356,6 +1447,36 @@ export function PullRequestDetailPanel({
{handoff === "findings" ? "Preparing..." : handoffLabels.fixFindings}
+ {/* Pinning from the pull-request side: where the panel sits beside a thread,
+ that thread is one press away from following this pull request's state.
+ Unlink takes the item's place once this is the linked one. */}
+ {attachThreadRef !== null && detail !== null && supportsPullRequestLink ? (
+ // The running operation outranks the live state while it
+ // is in flight, so the item keeps the verb the reader
+ // pressed until the command settles.
+ (pullRequestLinkPending ?? (isLinkedToThisPullRequest ? "unlink" : "link")) ===
+ "unlink" ? (
+
+ ) : (
+
+ )
+ ) : null}
{pickableEnvironments.length > 0 ? (
{
}
});
});
+
+describe("isThreadLinkedToPullRequest", () => {
+ // The real matcher: host + repository + number, path-suffix tolerant.
+ const matchesUrl = (linked: { readonly url: string }, targetUrl: string) => {
+ const parse = (url: string) => /^(https:\/\/[^/]+\/[^/]+\/[^/]+)\/pull\/(\d+)/u.exec(url);
+ const left = parse(linked.url);
+ const right = parse(targetUrl);
+ return left !== null && right !== null && left[1] === right[1] && left[2] === right[2];
+ };
+ const detail = { number: 42, url: "https://github.com/acme/repo/pull/42" };
+
+ it("treats a link stored from a subpage as the same pull request", () => {
+ expect(
+ isThreadLinkedToPullRequest({
+ linkedPullRequest: { number: 42, url: "https://github.com/acme/repo/pull/42/files" },
+ detail,
+ matchesUrl,
+ }),
+ ).toBe(true);
+ });
+
+ it("does not claim a different pull request in the same repository", () => {
+ expect(
+ isThreadLinkedToPullRequest({
+ linkedPullRequest: { number: 43, url: "https://github.com/acme/repo/pull/43" },
+ detail,
+ matchesUrl,
+ }),
+ ).toBe(false);
+ });
+
+ it("still recognises its own link on a host the matcher cannot parse", () => {
+ // This menu stores the detail URL verbatim, so exact equality is the way
+ // back for a self-hosted forge the change-request parser does not know.
+ const selfHosted = { number: 7, url: "https://git.internal/team/repo/changes/7" };
+ expect(
+ isThreadLinkedToPullRequest({
+ linkedPullRequest: selfHosted,
+ detail: selfHosted,
+ matchesUrl,
+ }),
+ ).toBe(true);
+ });
+
+ it("is false while either side is missing", () => {
+ expect(isThreadLinkedToPullRequest({ linkedPullRequest: null, detail, matchesUrl })).toBe(
+ false,
+ );
+ expect(
+ isThreadLinkedToPullRequest({ linkedPullRequest: detail, detail: null, matchesUrl }),
+ ).toBe(false);
+ });
+});
diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts
index a616a8395239..9865f6577b42 100644
--- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts
+++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts
@@ -937,3 +937,26 @@ const ACTION_NEEDS_HOST_REFRESH: Record = {
export function pullRequestActionNeedsHostRefresh(action: PullRequestAction): boolean {
return ACTION_NEEDS_HOST_REFRESH[action];
}
+
+/**
+ * Whether the thread beside this panel is already pinned to the pull request on
+ * screen — the decision behind offering Unlink rather than a second Link.
+ *
+ * The stored link keeps whatever URL it was created from, which for an
+ * agent-written href is often a subpage, so the shared change-request match is
+ * the primary test. The exact-URL fallback covers hosts that match cannot
+ * parse: this menu stores the detail URL verbatim, so without it an
+ * unrecognised host would link once and never offer the way back.
+ */
+export function isThreadLinkedToPullRequest(input: {
+ readonly linkedPullRequest: { readonly number: number; readonly url: string } | null;
+ readonly detail: { readonly number: number; readonly url: string } | null;
+ readonly matchesUrl: (linked: { readonly url: string }, targetUrl: string) => boolean;
+}): boolean {
+ const { linkedPullRequest, detail } = input;
+ if (linkedPullRequest === null || detail === null) return false;
+ return (
+ input.matchesUrl(linkedPullRequest, detail.url) ||
+ (linkedPullRequest.number === detail.number && linkedPullRequest.url === detail.url)
+ );
+}
diff --git a/apps/web/src/hooks/useThreadPullRequestLink.ts b/apps/web/src/hooks/useThreadPullRequestLink.ts
new file mode 100644
index 000000000000..9ed02b0c6e78
--- /dev/null
+++ b/apps/web/src/hooks/useThreadPullRequestLink.ts
@@ -0,0 +1,33 @@
+import type { ScopedThreadRef, ThreadLinkedPullRequest } from "@t3tools/contracts";
+import { useCallback } from "react";
+
+import { threadEnvironment } from "../state/threads";
+import { useAtomCommand } from "../state/use-atom-command";
+
+/**
+ * Link and unlink, both through `thread.meta.update`. These are the only
+ * callers allowed to send `linkedPullRequest`: the command is multi-field, so
+ * any other caller spreading thread state would silently unlink.
+ */
+export function useThreadPullRequestLinkActions() {
+ const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
+ reportFailure: false,
+ });
+ const linkPullRequest = useCallback(
+ (threadRef: ScopedThreadRef, link: ThreadLinkedPullRequest) =>
+ updateThreadMetadata({
+ environmentId: threadRef.environmentId,
+ input: { threadId: threadRef.threadId, linkedPullRequest: link },
+ }),
+ [updateThreadMetadata],
+ );
+ const unlinkPullRequest = useCallback(
+ (threadRef: ScopedThreadRef) =>
+ updateThreadMetadata({
+ environmentId: threadRef.environmentId,
+ input: { threadId: threadRef.threadId, linkedPullRequest: null },
+ }),
+ [updateThreadMetadata],
+ );
+ return { linkPullRequest, unlinkPullRequest };
+}