diff --git a/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx b/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx
index bdcfebcf549a..5ae5f8abd8bf 100644
--- a/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx
+++ b/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx
@@ -75,8 +75,7 @@ interface ResolvedLink {
/**
* Which pull request an input names, or why it cannot. A URL carries its own host and
- * repository and may point at any repository on a host this environment has a project for; a
- * bare `#123` can only mean the thread's own repository.
+ * repository; a bare `#123` can only mean the thread's own repository.
*/
export function resolveLinkPullRequestInput(input: {
readonly reference: string;
@@ -204,8 +203,8 @@ function LinkPullRequestDialog({
Link pull request
- Attach a pull request to this thread. A full URL can point at any repository on a host
- this environment has a project for.
+ Attach a pull request to this thread by its URL, or by its number for this thread's
+ repository.
diff --git a/apps/web/src/hooks/usePullRequestLinking.test.ts b/apps/web/src/hooks/usePullRequestLinking.test.ts
new file mode 100644
index 000000000000..f50659eaf295
--- /dev/null
+++ b/apps/web/src/hooks/usePullRequestLinking.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { canLinkChangeRequest } from "./usePullRequestLinking";
+import { parseChangeRequestUrl } from "~/lib/openPullRequestLink";
+
+describe("canLinkChangeRequest (#9435 / #9440)", () => {
+ // A non-Git parent workspace (a folder of several child repositories that is not itself a
+ // checkout) registers as a project with no `repositoryIdentity` at all.
+ const nonGitProject = { id: "scratch-project" } as never;
+ const link = parseChangeRequestUrl("https://github.com/pingdotgg/t3code/pull/15111")!;
+
+ it("links a pasted pull request URL when the server tracks links on the thread, even with no matching project", () => {
+ // `multiple` mode (the `threadPullRequests` capability) stores a link's host, repository
+ // and number on the thread directly, so no project needs to match for the link itself.
+ expect(canLinkChangeRequest("multiple", [nonGitProject], link)).toBe(true);
+ expect(canLinkChangeRequest("multiple", [], link)).toBe(true);
+ });
+
+ it("still requires a matching project in single-link mode, where the link needs a projectId", () => {
+ expect(canLinkChangeRequest("single", [nonGitProject], link)).toBe(false);
+ expect(canLinkChangeRequest("single", [], link)).toBe(false);
+ });
+
+ it("never links when the environment does not support pull request linking", () => {
+ expect(canLinkChangeRequest("unsupported", [nonGitProject], link)).toBe(false);
+ });
+});
diff --git a/apps/web/src/hooks/usePullRequestLinking.ts b/apps/web/src/hooks/usePullRequestLinking.ts
index f6c076f3009d..bf281ccbbc48 100644
--- a/apps/web/src/hooks/usePullRequestLinking.ts
+++ b/apps/web/src/hooks/usePullRequestLinking.ts
@@ -6,6 +6,7 @@ import type {
ThreadLinkedPullRequest,
ThreadPullRequestLink,
} from "@t3tools/contracts";
+import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
@@ -20,14 +21,32 @@ import {
} from "@t3tools/shared/threadPullRequests";
import {
findProjectForChangeRequest,
- findProjectOnChangeRequestHost,
matchesLinkedPullRequestUrl,
parseChangeRequestUrl,
+ type ChangeRequestLink,
} from "~/lib/openPullRequestLink";
import { useProjects, useServerConfigs } from "~/state/entities";
import { threadEnvironment } from "~/state/threads";
import { useAtomCommand } from "~/state/use-atom-command";
+/**
+ * Whether a parsed change-request link can be linked to a thread. `multiple` mode (the
+ * `threadPullRequests` capability) stores a link's host, repository and number on the thread
+ * directly and needs no project at all; a freshly linked pull request already starts with a
+ * null snapshot until some project can sync it, so a host nothing here is checked out from is
+ * the same case, not a reason to refuse the link. `single` mode still needs a matching project,
+ * since that is where its required `projectId` comes from.
+ */
+export function canLinkChangeRequest(
+ mode: ReturnType,
+ projects: ReadonlyArray,
+ parsed: ChangeRequestLink,
+): boolean {
+ if (mode === "unsupported") return false;
+ if (mode === "multiple") return true;
+ return findProjectForChangeRequest(projects, parsed) !== undefined;
+}
+
/** Routes link actions through the command advertised by this environment. */
export function usePullRequestLinking(environmentId: EnvironmentId | null | undefined) {
const configs = useServerConfigs();
@@ -44,13 +63,7 @@ export function usePullRequestLinking(environmentId: EnvironmentId | null | unde
);
const canLink = (url: string) => {
const parsed = parseChangeRequestUrl(url);
- if (parsed === null || mode === "unsupported") return false;
- return (
- (mode === "multiple" ? findProjectOnChangeRequestHost : findProjectForChangeRequest)(
- environmentProjects,
- parsed,
- ) !== undefined
- );
+ return parsed !== null && canLinkChangeRequest(mode, environmentProjects, parsed);
};
const isLinked = (
thread: {