From 84aa31b92fd8388bab598aaefa216dafb2a23c43 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 10 Sep 2026 12:48:43 -0400 Subject: [PATCH 1/3] feat(web): unlink a pull request from its thread A thread linked to a pull request settles itself when that PR merges. The only way to undo the link was to right-click the original PR URL in the chat transcript, which is unreachable once that message scrolls away. Adds "Unlink from thread" to the PR number's context menu in both sidebars, and a PR chip in the chat header beside the git actions that shows the linked number and carries the same open/unlink behavior. The item only appears for the number the thread is actually linked to, so a PR read off the thread's branch still offers just copy and open. No server or contract change: thread.meta.update already accepts a null linkedPullRequest. Co-Authored-By: Claude Opus 5 --- apps/web/src/components/LegacySidebar.tsx | 25 +++++ apps/web/src/components/Sidebar.tsx | 26 ++++++ apps/web/src/components/chat/ChatHeader.tsx | 6 +- .../pullRequest/ThreadPullRequestPill.tsx | 93 +++++++++++++++++++ .../pullRequestLinkContextMenu.test.ts | 18 ++++ .../pullRequest/pullRequestLinkContextMenu.ts | 40 +++++++- .../pullRequest/useUnlinkThreadPullRequest.ts | 43 +++++++++ 7 files changed, 245 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/pullRequest/ThreadPullRequestPill.tsx create mode 100644 apps/web/src/components/pullRequest/useUnlinkThreadPullRequest.ts diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 189ffaa0fb9e..9b20b0b3da66 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -22,6 +22,11 @@ import { ThreadWorktreeIndicator, useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; +import { + openOnHostLabel, + showPullRequestLinkContextMenu, +} from "./pullRequest/pullRequestLinkContextMenu"; +import { useUnlinkThreadPullRequest } from "./pullRequest/useUnlinkThreadPullRequest"; import { ProjectFavicon } from "./ProjectFavicon"; import { useAtomValue } from "@effect/atom-react"; import { autoAnimate } from "@formkit/auto-animate"; @@ -462,6 +467,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr thread.environmentId, thread.linkedPullRequest, ); + const unlinkThreadPullRequest = useUnlinkThreadPullRequest(threadRef); const pr = thread.linkedPullRequest == null ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data }) @@ -470,6 +476,24 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr pr, linkedPullRequestStatus?.sourceControlProvider ?? gitStatus.data?.sourceControlProvider, ); + // Same gesture as the current sidebar's number: without it the right-click bubbles to the row + // and offers the thread menu, which has nothing to say about the pull request under the cursor. + const prSourceControlProvider = + linkedPullRequestStatus?.sourceControlProvider ?? gitStatus.data?.sourceControlProvider; + const handlePrContextMenu = useCallback( + (event: React.MouseEvent) => { + if (!prStatus) return; + event.preventDefault(); + event.stopPropagation(); + void showPullRequestLinkContextMenu({ + url: prStatus.url, + openLabel: openOnHostLabel(prSourceControlProvider?.kind ?? ""), + position: { x: event.clientX, y: event.clientY }, + unlinkFromThread: thread.linkedPullRequest == null ? null : unlinkThreadPullRequest, + }); + }, + [prSourceControlProvider, prStatus, thread.linkedPullRequest, unlinkThreadPullRequest], + ); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; const threadMetaClassName = isConfirmingArchive @@ -713,6 +737,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`} onPointerDown={(event) => event.stopPropagation()} onClick={handlePrClick} + onContextMenu={handlePrContextMenu} > diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a5df7bc53538..6f887bdd2b1f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -168,6 +168,11 @@ import { snoozeWakeLabel, type SnoozePreset, } from "./Sidebar.snooze"; +import { + openOnHostLabel, + showPullRequestLinkContextMenu, +} from "./pullRequest/pullRequestLinkContextMenu"; +import { useUnlinkThreadPullRequest } from "./pullRequest/useUnlinkThreadPullRequest"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; @@ -794,6 +799,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const openPrLink = useOpenPrLink(); + const unlinkThreadPullRequest = useUnlinkThreadPullRequest(threadRef); const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: thread.environmentId, threadId: thread.id, @@ -1117,6 +1123,25 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }, [onThreadActivate, openPrLink, openPullRequestsInRightPanel, pr, props.isActive, threadRef], ); + // Right-clicking the number reaches the pull request itself. Without this the event bubbles to + // the row and opens the thread menu, which is why the link a thread settles on had nowhere to be + // undone except the message the agent originally wrote it in. + const handlePrContextMenu = useCallback( + (event: ReactMouseEvent) => { + if (!pr?.url) return; + event.preventDefault(); + event.stopPropagation(); + void showPullRequestLinkContextMenu({ + url: pr.url, + openLabel: openOnHostLabel(prProvider?.kind ?? ""), + position: { x: event.clientX, y: event.clientY }, + // Only the linked number can be unlinked: the same badge also shows a pull request read + // off the thread's branch, and that one is a fact about git, not a choice to undo. + unlinkFromThread: thread.linkedPullRequest == null ? null : unlinkThreadPullRequest, + }); + }, + [pr, prProvider, thread.linkedPullRequest, unlinkThreadPullRequest], + ); // All sidebar rows share one surface model. Live threads used to look // like elevated cards while settled threads were plain rows, leaving neither @@ -1191,6 +1216,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { rel="noopener noreferrer" onPointerDown={(event) => event.stopPropagation()} onClick={handlePrClick} + onContextMenu={handlePrContextMenu} className={cn( // Sidebar chrome follows the interface font; tabular digits keep the // number from reflowing as PR states stream in. diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index dbba327489ac..d661512d4366 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -23,6 +23,7 @@ import { type MouseEvent as ReactMouseEvent, } from "react"; import GitActionsControl from "../GitActionsControl"; +import { ThreadPullRequestPill } from "../pullRequest/ThreadPullRequestPill"; import { isTrailingDoubleClick } from "../Sidebar.logic"; import { type DraftId } from "~/composerDraftStore"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -403,11 +404,14 @@ export const ChatHeader = memo(function ChatHeader({ {activeProjectName && ( )} + {/* Beside the git actions rather than among them: it reports what this thread is tied to, + and it is the only place in the thread that says so. Renders nothing when unlinked. */} + ); diff --git a/apps/web/src/components/pullRequest/ThreadPullRequestPill.tsx b/apps/web/src/components/pullRequest/ThreadPullRequestPill.tsx new file mode 100644 index 000000000000..da999035b7ed --- /dev/null +++ b/apps/web/src/components/pullRequest/ThreadPullRequestPill.tsx @@ -0,0 +1,93 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { useCallback, type MouseEvent as ReactMouseEvent } from "react"; + +import { cn } from "~/lib/utils"; +import { useOpenPrLink } from "~/lib/openPullRequestLink"; +import { useThreadShell } from "~/state/entities"; + +import { + ChangeRequestStatusIcon, + PrStatusTooltipContent, + prStatusIndicator, + useLinkedThreadPullRequest, +} from "../ThreadStatusIndicators"; +import { buttonVariants } from "../ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu"; +import { useUnlinkThreadPullRequest } from "./useUnlinkThreadPullRequest"; + +/** + * The pull request a thread is linked to, sitting beside the git actions in the header. + * + * Only the link is shown here, never a pull request merely read off the thread's branch: this + * chip exists because the link is otherwise invisible from the thread you are reading, and it is + * the link — not the branch — that settles the thread once the pull request merges. It behaves + * like the sidebar's number, down to the right-click that undoes it. + */ +export function ThreadPullRequestPill({ threadRef }: { readonly threadRef: ScopedThreadRef }) { + const linkedPullRequest = useThreadShell(threadRef)?.linkedPullRequest ?? null; + const linkedStatus = useLinkedThreadPullRequest(threadRef.environmentId, linkedPullRequest); + const openPrLink = useOpenPrLink(threadRef); + const unlinkThreadPullRequest = useUnlinkThreadPullRequest(threadRef); + + const url = linkedPullRequest?.url ?? null; + const handleClick = useCallback( + (event: ReactMouseEvent) => { + if (url !== null) openPrLink(event, url); + }, + [openPrLink, url], + ); + const handleContextMenu = useCallback( + (event: ReactMouseEvent) => { + if (url === null) return; + event.preventDefault(); + event.stopPropagation(); + void showPullRequestLinkContextMenu({ + url, + openLabel: openOnHostLabel(linkedStatus?.sourceControlProvider.kind ?? ""), + position: { x: event.clientX, y: event.clientY }, + unlinkFromThread: unlinkThreadPullRequest, + }); + }, + [linkedStatus, unlinkThreadPullRequest, url], + ); + + if (linkedPullRequest === null || url === null) return null; + + // The number comes from the link itself, so the chip renders at once and only takes on its + // open/merged/closed colour once the provider answers. Waiting for that would blink a control + // in and out of the header on every thread switch. + const status = prStatusIndicator(linkedStatus?.pr ?? null, linkedStatus?.sourceControlProvider); + const label = `#${linkedPullRequest.number}`; + + return ( + + + } + > + + {label} + + + {status ? : `Pull request ${label}`} + + + ); +} diff --git a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.test.ts b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.test.ts index db105f97fe95..b969a03f5730 100644 --- a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.test.ts @@ -10,6 +10,24 @@ describe("pull request link context menu", () => { ]); }); + it("leaves unlinking out until the caller says this number is the thread's own", () => { + expect(pullRequestLinkContextMenuItems("Open on GitHub", false)).toHaveLength(2); + expect( + pullRequestLinkContextMenuItems("Open on GitHub", false).some( + (item) => item.id === "unlink-from-thread", + ), + ).toBe(false); + }); + + it("puts unlinking last, behind a divider, so a misclick lands on copy instead", () => { + const items = pullRequestLinkContextMenuItems("Open on GitHub", true); + expect(items).toEqual([ + { id: "copy-link", label: "Copy link", icon: "copy" }, + { id: "open-external", label: "Open on GitHub" }, + { id: "unlink-from-thread", label: "Unlink from thread", separatorBefore: true }, + ]); + }); + it("names every host it knows, and says nothing false about one it does not", () => { expect(openOnHostLabel("github")).toBe("Open on GitHub"); expect(openOnHostLabel("gitlab")).toBe("Open on GitLab"); diff --git a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts index 16b749445d4c..f011b062e867 100644 --- a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts +++ b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts @@ -5,7 +5,7 @@ import { readLocalApi } from "~/localApi"; import { toastManager } from "../ui/toast"; -export type PullRequestLinkContextMenuAction = "copy-link" | "open-external"; +export type PullRequestLinkContextMenuAction = "copy-link" | "open-external" | "unlink-from-thread"; /** Named for the host rather than "externally": the point is where you will land. */ export const OPEN_ON_HOST_LABELS: Partial> = { @@ -18,14 +18,29 @@ export const OPEN_ON_HOST_LABELS: Partial> = { export const openOnHostLabel = (provider: string): string => OPEN_ON_HOST_LABELS[provider] ?? "Open on host"; -/** Copy first: it is the reason to right-click a number rather than click it. */ +/** + * Copy first: it is the reason to right-click a number rather than click it. + * + * Unlinking comes last, behind a divider, because it is the one item here that changes the thread + * rather than the clipboard or the browser — and because a thread only settles on its own once a + * link exists, so reaching for it is rare next to the two above it. + */ export function pullRequestLinkContextMenuItems( openLabel: string, + canUnlinkFromThread = false, ): readonly ContextMenuItem[] { - return [ + const items: ContextMenuItem[] = [ { id: "copy-link", label: "Copy link", icon: "copy" }, { id: "open-external", label: openLabel }, ]; + if (canUnlinkFromThread) { + items.push({ + id: "unlink-from-thread", + label: "Unlink from thread", + separatorBefore: true, + }); + } + return items; } /** @@ -41,16 +56,25 @@ export async function showPullRequestLinkContextMenu({ url, openLabel, position, + unlinkFromThread, }: { readonly url: string; readonly openLabel: string; readonly position: { readonly x: number; readonly y: number }; + /** + * Absent where the number being right-clicked is not the one its thread is linked to — a pull + * request read off a branch, a row on the list page, a server that does not record links at all. + */ + readonly unlinkFromThread?: (() => Promise) | null | undefined; }): Promise { const api = readLocalApi(); if (!api) return; let action: PullRequestLinkContextMenuAction | null = null; try { - action = await api.contextMenu.show(pullRequestLinkContextMenuItems(openLabel), position); + action = await api.contextMenu.show( + pullRequestLinkContextMenuItems(openLabel, unlinkFromThread != null), + position, + ); } catch { // A menu that could not be shown has already cost the reader their right-click; there is // nothing to say about it that a second popup would not make worse. @@ -59,10 +83,16 @@ export async function showPullRequestLinkContextMenu({ try { if (action === "copy-link") await writeTextToClipboard(url, "link"); else if (action === "open-external") await api.shell.openExternal(url); + else if (action === "unlink-from-thread") await unlinkFromThread?.(); } catch { toastManager.add({ type: "error", - title: action === "copy-link" ? "Could not copy the link" : "Could not open the link", + title: + action === "copy-link" + ? "Could not copy the link" + : action === "unlink-from-thread" + ? "Could not unlink the pull request" + : "Could not open the link", }); } } diff --git a/apps/web/src/components/pullRequest/useUnlinkThreadPullRequest.ts b/apps/web/src/components/pullRequest/useUnlinkThreadPullRequest.ts new file mode 100644 index 000000000000..580a92c775fa --- /dev/null +++ b/apps/web/src/components/pullRequest/useUnlinkThreadPullRequest.ts @@ -0,0 +1,43 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useCallback } from "react"; + +import { useServerConfigs } from "~/state/entities"; +import { threadEnvironment } from "~/state/threads"; +import { useAtomCommand } from "~/state/use-atom-command"; + +/** + * Clears the pull request a thread is linked to, or null where this server cannot store the link + * in the first place. + * + * Null is the answer a caller leaves the action out of its menu for: the link is what makes a + * thread settle when its pull request merges, so offering to undo it on a server that never + * recorded one would promise something nothing can honour. + */ +export function useUnlinkThreadPullRequest( + threadRef: ScopedThreadRef | null | undefined, +): (() => Promise) | null { + const serverConfigs = useServerConfigs(); + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + const unlink = useCallback(async () => { + if (threadRef == null) return; + const result = await updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, linkedPullRequest: null }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + throw squashAtomCommandFailure(result); + } + }, [threadRef, updateThreadMetadata]); + + if (threadRef == null) return null; + return serverConfigs.get(threadRef.environmentId)?.environment.capabilities + .threadPullRequestLinking === true + ? unlink + : null; +} From 24d0e7c871b22451bc494c74876d347212bd43ff Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Tue, 15 Sep 2026 08:59:45 -0400 Subject: [PATCH 2/3] docs(user): name the new places a pull request can be unlinked The sidebar row's number and the thread header's chip now carry the same unlink action as the link in the conversation, so the paragraph that only described the conversation link was no longer the whole story. Co-Authored-By: Claude Opus 5 --- docs/user/thread-sidebar.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index f0fbfdccbd6c..a43893546754 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -15,7 +15,12 @@ away. Its timestamps do not change. Other threads keep their positions. Right-click a pull request link in a thread and choose **Link to thread** to show that pull request in the sidebar. The thread settles when the linked pull request merges if **Auto-settle merged -threads** is enabled. Right-click the same link and choose **Unlink from thread** to remove it. +threads** is enabled. + +A linked pull request shows its number in the thread's sidebar row and beside the git actions in +the thread header. Click either one to open the pull request. Right-click either one, or the +original link in the conversation, and choose **Unlink from thread** to remove the link and stop +the thread settling when that pull request merges. On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu and choose **Move up** or **Move down**. The order is stored by the server and appears on your From 740d677db96178276a9383cdbc2c71df8b5377cf Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Tue, 15 Sep 2026 09:03:04 -0400 Subject: [PATCH 3/3] fix(web): only unlink the pull request the menu was opened on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A context menu stays open for as long as it takes to read, and in that time the agent can write a newer pull request link or another device can change the thread. Clearing whatever the thread holds by then would unlink something nobody chose. The unlink callback now takes the URL the reader acted on, re-reads the thread shell, and drops the request unless that is still the link the thread holds — the same guard the transcript path already applies in ChatMarkdown. Co-Authored-By: Claude Opus 5 --- .../pullRequestLinkContextMenu.test.ts | 28 ++++++++++++++- .../pullRequest/pullRequestLinkContextMenu.ts | 7 ++-- .../pullRequest/useUnlinkThreadPullRequest.ts | 35 ++++++++++++------- 3 files changed, 55 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.test.ts b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.test.ts index b969a03f5730..c381899799ec 100644 --- a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { openOnHostLabel, pullRequestLinkContextMenuItems } from "./pullRequestLinkContextMenu"; +import { + openOnHostLabel, + pullRequestLinkContextMenuItems, + showPullRequestLinkContextMenu, +} from "./pullRequestLinkContextMenu"; describe("pull request link context menu", () => { it("offers the copy first and the host's own page after it", () => { @@ -28,6 +32,28 @@ describe("pull request link context menu", () => { ]); }); + it("tells the unlink callback which url was acted on, so a stale menu can decline", async () => { + const acted: string[] = []; + // These suites run on node, so the desktop bridge the menu reaches for is stood up here + // rather than in a DOM. Only `contextMenu.show` is exercised, and it answers from the bridge. + const globals = globalThis as { window?: unknown }; + const previousWindow = globals.window; + globals.window = { desktopBridge: { showContextMenu: async () => "unlink-from-thread" } }; + try { + await showPullRequestLinkContextMenu({ + url: "https://github.com/pingdotgg/t3code/pull/23", + openLabel: "Open on GitHub", + position: { x: 0, y: 0 }, + unlinkFromThread: async (url) => { + acted.push(url); + }, + }); + } finally { + globals.window = previousWindow; + } + expect(acted).toEqual(["https://github.com/pingdotgg/t3code/pull/23"]); + }); + it("names every host it knows, and says nothing false about one it does not", () => { expect(openOnHostLabel("github")).toBe("Open on GitHub"); expect(openOnHostLabel("gitlab")).toBe("Open on GitLab"); diff --git a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts index f011b062e867..fde9cf7c7fcf 100644 --- a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts +++ b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts @@ -64,8 +64,11 @@ export async function showPullRequestLinkContextMenu({ /** * Absent where the number being right-clicked is not the one its thread is linked to — a pull * request read off a branch, a row on the list page, a server that does not record links at all. + * + * Handed the URL the menu was opened on, so it can decline once that is no longer the link the + * thread holds. */ - readonly unlinkFromThread?: (() => Promise) | null | undefined; + readonly unlinkFromThread?: ((url: string) => Promise) | null | undefined; }): Promise { const api = readLocalApi(); if (!api) return; @@ -83,7 +86,7 @@ export async function showPullRequestLinkContextMenu({ try { if (action === "copy-link") await writeTextToClipboard(url, "link"); else if (action === "open-external") await api.shell.openExternal(url); - else if (action === "unlink-from-thread") await unlinkFromThread?.(); + else if (action === "unlink-from-thread") await unlinkFromThread?.(url); } catch { toastManager.add({ type: "error", diff --git a/apps/web/src/components/pullRequest/useUnlinkThreadPullRequest.ts b/apps/web/src/components/pullRequest/useUnlinkThreadPullRequest.ts index 580a92c775fa..af92dce207d9 100644 --- a/apps/web/src/components/pullRequest/useUnlinkThreadPullRequest.ts +++ b/apps/web/src/components/pullRequest/useUnlinkThreadPullRequest.ts @@ -5,7 +5,8 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { useCallback } from "react"; -import { useServerConfigs } from "~/state/entities"; +import { matchesLinkedPullRequestUrl } from "~/lib/openPullRequestLink"; +import { readThreadShell, useServerConfigs } from "~/state/entities"; import { threadEnvironment } from "~/state/threads"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -16,24 +17,34 @@ import { useAtomCommand } from "~/state/use-atom-command"; * Null is the answer a caller leaves the action out of its menu for: the link is what makes a * thread settle when its pull request merges, so offering to undo it on a server that never * recorded one would promise something nothing can honour. + * + * The returned function is given the URL the reader acted on, and drops the request when that is + * no longer the link the thread holds. A context menu is open for as long as it takes to read it, + * and in that time the agent can write a newer pull request link or another device can change the + * thread — clearing whatever happens to be there by then would unlink something nobody chose. */ export function useUnlinkThreadPullRequest( threadRef: ScopedThreadRef | null | undefined, -): (() => Promise) | null { +): ((url: string) => Promise) | null { const serverConfigs = useServerConfigs(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); - const unlink = useCallback(async () => { - if (threadRef == null) return; - const result = await updateThreadMetadata({ - environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId, linkedPullRequest: null }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - throw squashAtomCommandFailure(result); - } - }, [threadRef, updateThreadMetadata]); + const unlink = useCallback( + async (url: string) => { + if (threadRef == null) return; + const current = readThreadShell(threadRef)?.linkedPullRequest; + if (current == null || !matchesLinkedPullRequestUrl(current, url)) return; + const result = await updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, linkedPullRequest: null }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + throw squashAtomCommandFailure(result); + } + }, + [threadRef, updateThreadMetadata], + ); if (threadRef == null) return null; return serverConfigs.get(threadRef.environmentId)?.environment.capabilities