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 };
+}