authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 02:55:31-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 02:55:31-07:00
log0425c32571e033c068b8caebfd984acce9f8735b
treebfa1bd874fe8d54f7650d221692c7ec72193316b
parent3417bc3856a25fa772f969faeaf8428a58010d47
signature Signed by PGP key B5690EEEBB952194

fix(clients): # suggestions include pull requests linked to the thread (#19357)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

8 files changed, 186 insertions(+), 22 deletions(-)

apps/mobile/src/features/threads/ThreadComposer.tsx+1
...@@ -502,6 +502,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -502,6 +502,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
502 ? (project?.id ?? null)502 ? (project?.id ?? null)
503 : null,503 : null,
504 pullRequestRepository: project?.repositoryIdentity?.displayName ?? null,504 pullRequestRepository: project?.repositoryIdentity?.displayName ?? null,
505 threadPullRequests: props.selectedThread.pullRequests,
505 selectedProviderStatus,506 selectedProviderStatus,
506 hasThread: true,507 hasThread: true,
507 hasCompactableConversation: props.hasCompactableConversation,508 hasCompactableConversation: props.hasCompactableConversation,
apps/mobile/src/features/threads/use-composer-command-menu.ts+10-1
...@@ -4,6 +4,7 @@ import type {...@@ -4,6 +4,7 @@ import type {
4 ProviderInteractionMode,4 ProviderInteractionMode,
5 ServerProvider,5 ServerProvider,
6 ThreadId,6 ThreadId,
7 ThreadPullRequestLink,
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
8import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThreadItems";9import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThreadItems";
9import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";10import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
...@@ -179,6 +180,7 @@ export function useComposerCommandMenu({...@@ -179,6 +180,7 @@ export function useComposerCommandMenu({
179 projectCwd,180 projectCwd,
180 pullRequestProjectId = null,181 pullRequestProjectId = null,
181 pullRequestRepository = null,182 pullRequestRepository = null,
183 threadPullRequests,
182 selectedProviderStatus,184 selectedProviderStatus,
183 hasThread,185 hasThread,
184 hasCompactableConversation,186 hasCompactableConversation,
...@@ -198,6 +200,8 @@ export function useComposerCommandMenu({...@@ -198,6 +200,8 @@ export function useComposerCommandMenu({
198 readonly projectCwd: string | null;200 readonly projectCwd: string | null;
199 readonly pullRequestProjectId?: ProjectId | null;201 readonly pullRequestProjectId?: ProjectId | null;
200 readonly pullRequestRepository?: string | null;202 readonly pullRequestRepository?: string | null;
203 /** The thread's linked pull requests, offered in `#` suggestions from any repository. */
204 readonly threadPullRequests?: ReadonlyArray<ThreadPullRequestLink>;
201 readonly selectedProviderStatus: ServerProvider | null;205 readonly selectedProviderStatus: ServerProvider | null;
202 readonly hasThread: boolean;206 readonly hasThread: boolean;
203 readonly hasCompactableConversation: boolean;207 readonly hasCompactableConversation: boolean;
...@@ -357,6 +361,7 @@ export function useComposerCommandMenu({...@@ -357,6 +361,7 @@ export function useComposerCommandMenu({
357 environmentId,361 environmentId,
358 projectId: pullRequestProjectId,362 projectId: pullRequestProjectId,
359 repository: pullRequestRepository,363 repository: pullRequestRepository,
364 threadPullRequests,
360 query: trigger?.kind === "pull-request" ? trigger.query : null,365 query: trigger?.kind === "pull-request" ? trigger.query : null,
361 });366 });
362367
...@@ -377,7 +382,11 @@ export function useComposerCommandMenu({...@@ -377,7 +382,11 @@ export function useComposerCommandMenu({
377 isDraft: entry.isDraft,382 isDraft: entry.isDraft,
378 },383 },
379 label: `#${entry.number}`,384 label: `#${entry.number}`,
380 description: `${entry.isDraft ? "Draft" : entry.state} · ${entry.title}`,385 description: `${entry.isDraft ? "Draft" : entry.state} · ${
386 entry.repository.toLowerCase() === pullRequestRepository?.toLowerCase()
387 ? ""
388 : `${entry.repository} · `
389 }${entry.title}`,
381 }));390 }));
382 }391 }
383392
apps/mobile/src/state/queries.ts+16-2
...@@ -1,4 +1,7 @@...@@ -1,4 +1,7 @@
1import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches";1import {
2 composerPullRequestsFromLinks,
3 filterComposerPullRequestMatches,
4} from "@t3tools/shared/composerPullRequestMatches";
2import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";5import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
3import { environmentSession } from "./session";6import { environmentSession } from "./session";
4import { useEnvironmentPresentation } from "./presentation";7import { useEnvironmentPresentation } from "./presentation";
...@@ -8,6 +11,7 @@ import type {...@@ -8,6 +11,7 @@ import type {
8 OrchestrationV2ProjectedTurnItem,11 OrchestrationV2ProjectedTurnItem,
9 ProjectId,12 ProjectId,
10 ThreadId,13 ThreadId,
14 ThreadPullRequestLink,
11 VcsListRefsResult,15 VcsListRefsResult,
12 VcsRef,16 VcsRef,
13} from "@t3tools/contracts";17} from "@t3tools/contracts";
...@@ -81,9 +85,18 @@ export function useComposerPullRequestSearch(input: {...@@ -81,9 +85,18 @@ export function useComposerPullRequestSearch(input: {
81 environmentId: EnvironmentId | null;85 environmentId: EnvironmentId | null;
82 projectId: ProjectId | null;86 projectId: ProjectId | null;
83 repository: string | null;87 repository: string | null;
88 /** The thread's linked pull requests, offered in numeric searches from any repository. */
89 threadPullRequests?: ReadonlyArray<ThreadPullRequestLink> | undefined;
84 query: string | null;90 query: string | null;
85}) {91}) {
86 const query = useDebouncedValue(input.query, 180);92 const query = useDebouncedValue(input.query, 180);
93 const linked = useMemo(
94 () =>
95 input.projectId === null
96 ? []
97 : composerPullRequestsFromLinks(input.threadPullRequests ?? [], input.projectId),
98 [input.projectId, input.threadPullRequests],
99 );
87 const ready =100 const ready =
88 query === input.query &&101 query === input.query &&
89 query !== null &&102 query !== null &&
...@@ -122,6 +135,7 @@ export function useComposerPullRequestSearch(input: {...@@ -122,6 +135,7 @@ export function useComposerPullRequestSearch(input: {
122 if (numeric) {135 if (numeric) {
123 return filterComposerPullRequestMatches({136 return filterComposerPullRequestMatches({
124 entries: [...(exact.data ? [exact.data] : []), ...(list.data?.entries ?? [])],137 entries: [...(exact.data ? [exact.data] : []), ...(list.data?.entries ?? [])],
138 linked,
125 projectId: input.projectId!,139 projectId: input.projectId!,
126 repository: input.repository!,140 repository: input.repository!,
127 query: query ?? "",141 query: query ?? "",
...@@ -142,7 +156,7 @@ export function useComposerPullRequestSearch(input: {...@@ -142,7 +156,7 @@ export function useComposerPullRequestSearch(input: {
142 return [...unique.values()]156 return [...unique.values()]
143 .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))157 .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
144 .slice(0, 20);158 .slice(0, 20);
145 }, [ready, exact.data, list.data, input.projectId, input.repository, numeric, query]);159 }, [ready, exact.data, list.data, linked, input.projectId, input.repository, numeric, query]);
146 return {160 return {
147 entries,161 entries,
148 isPending: input.query !== null && (query !== input.query || list.isPending || exact.isPending),162 isPending: input.query !== null && (query !== input.query || list.isPending || exact.isPending),
apps/web/src/components/ChatView.tsx+1
...@@ -11876,6 +11876,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -11876,6 +11876,7 @@ export default function ChatView(props: ChatViewProps) {
11876 pullRequestRepository={11876 pullRequestRepository={
11877 supportsPullRequests ? activeProjectRepository : null11877 supportsPullRequests ? activeProjectRepository : null
11878 }11878 }
11879 threadPullRequests={(activeThreadShell ?? activeThread)?.pullRequests}
11879 restingControlsHost={restingComposerControlsHost}11880 restingControlsHost={restingComposerControlsHost}
11880 restingControlsHaveLeadingContext={11881 restingControlsHaveLeadingContext={
11881 mountComposerContextStrip &&11882 mountComposerContextStrip &&
apps/web/src/components/chat/ChatComposer.tsx+26-3
...@@ -7,7 +7,10 @@ import { usePrimaryEnvironmentId } from "../../state/environments";...@@ -7,7 +7,10 @@ import { usePrimaryEnvironmentId } from "../../state/environments";
7import { useRightPanelStore } from "~/rightPanelStore";7import { useRightPanelStore } from "~/rightPanelStore";
8import { AttachmentFilePreview } from "../files/AttachmentFilePreview";8import { AttachmentFilePreview } from "../files/AttachmentFilePreview";
9import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog";9import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog";
10import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches";10import {
11 composerPullRequestsFromLinks,
12 filterComposerPullRequestMatches,
13} from "@t3tools/shared/composerPullRequestMatches";
11import { importPastedComposerText, readPastedComposerContext } from "../composerInlineTokenPaste";14import { importPastedComposerText, readPastedComposerContext } from "../composerInlineTokenPaste";
12import { elementContextToPreviewAnnotation } from "../../lib/elementContext";15import { elementContextToPreviewAnnotation } from "../../lib/elementContext";
13import { RefreshIcon } from "~/components/ui/refresh-icon";16import { RefreshIcon } from "~/components/ui/refresh-icon";
...@@ -248,7 +251,11 @@ import { THREAD_CONTEXT_DROP_EVENT, threadContextDropTargetProps } from "./threa...@@ -248,7 +251,11 @@ import { THREAD_CONTEXT_DROP_EVENT, threadContextDropTargetProps } from "./threa
248import { readThreadShell, useThreadShells } from "~/state/entities";251import { readThreadShell, useThreadShells } from "~/state/entities";
249import { requestConfirmDialog } from "~/confirmDialog";252import { requestConfirmDialog } from "~/confirmDialog";
250import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";253import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
251import type { ComposerContextClipboardFragment, ComposerContextRecord } from "@t3tools/contracts";254import type {
255 ComposerContextClipboardFragment,
256 ComposerContextRecord,
257 ThreadPullRequestLink,
258} from "@t3tools/contracts";
252import { resolveAssetUrl } from "~/assets/assetUrls";259import { resolveAssetUrl } from "~/assets/assetUrls";
253import { assetEnvironment } from "~/state/assets";260import { assetEnvironment } from "~/state/assets";
254import { readPreparedConnection } from "~/state/session";261import { readPreparedConnection } from "~/state/session";
...@@ -1629,6 +1636,8 @@ export interface ChatComposerProps {...@@ -1629,6 +1636,8 @@ export interface ChatComposerProps {
1629 gitCwd: string | null;1636 gitCwd: string | null;
1630 pullRequestProjectId: ProjectId | null;1637 pullRequestProjectId: ProjectId | null;
1631 pullRequestRepository: string | null;1638 pullRequestRepository: string | null;
1639 /** The thread's linked pull requests, offered in `#` suggestions from any repository. */
1640 threadPullRequests: ReadonlyArray<ThreadPullRequestLink> | undefined;
1632 restingControlsHost: HTMLDivElement | null;1641 restingControlsHost: HTMLDivElement | null;
1633 restingControlsHaveLeadingContext: boolean;1642 restingControlsHaveLeadingContext: boolean;
1634 onRestingControlsVisibilityChange: (visible: boolean) => void;1643 onRestingControlsVisibilityChange: (visible: boolean) => void;
...@@ -1775,6 +1784,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1775,6 +1784,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1775 gitCwd,1784 gitCwd,
1776 pullRequestProjectId,1785 pullRequestProjectId,
1777 pullRequestRepository,1786 pullRequestRepository,
1787 threadPullRequests,
1778 restingControlsHost,1788 restingControlsHost,
1779 restingControlsHaveLeadingContext,1789 restingControlsHaveLeadingContext,
1780 onRestingControlsVisibilityChange,1790 onRestingControlsVisibilityChange,
...@@ -2688,6 +2698,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2688,6 +2698,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2688 }),2698 }),
2689 );2699 );
26902700
2701 const linkedPullRequests = useMemo(
2702 () =>
2703 pullRequestProjectId === null
2704 ? []
2705 : composerPullRequestsFromLinks(threadPullRequests ?? [], pullRequestProjectId),
2706 [pullRequestProjectId, threadPullRequests],
2707 );
2708
2691 const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {2709 const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {
2692 if (!composerTrigger) return [];2710 if (!composerTrigger) return [];
2693 if (composerTrigger.kind === "path") {2711 if (composerTrigger.kind === "path") {
...@@ -2799,6 +2817,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2799,6 +2817,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2799 const matches = /^\d*$/u.test(composerTrigger.query)2817 const matches = /^\d*$/u.test(composerTrigger.query)
2800 ? filterComposerPullRequestMatches({2818 ? filterComposerPullRequestMatches({
2801 entries: [...exactPullRequest, ...(pullRequestLookup.data?.entries ?? [])],2819 entries: [...exactPullRequest, ...(pullRequestLookup.data?.entries ?? [])],
2820 linked: linkedPullRequests,
2802 projectId: pullRequestProjectId,2821 projectId: pullRequestProjectId,
2803 repository: pullRequestRepository,2822 repository: pullRequestRepository,
2804 query: composerTrigger.query,2823 query: composerTrigger.query,
...@@ -2835,7 +2854,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2835,7 +2854,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2835 isDraft: pullRequest.isDraft,2854 isDraft: pullRequest.isDraft,
2836 },2855 },
2837 label: `#${pullRequest.number}`,2856 label: `#${pullRequest.number}`,
2838 description: pullRequest.title,2857 description:
2858 pullRequest.repository.trim().toLowerCase() === pullRequestRepository.trim().toLowerCase()
2859 ? pullRequest.title
2860 : `${pullRequest.repository} · ${pullRequest.title}`,
2839 }));2861 }));
2840 }2862 }
2841 return [];2863 return [];
...@@ -2846,6 +2868,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2846,6 +2868,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2846 environmentId,2868 environmentId,
2847 environmentThreadShells,2869 environmentThreadShells,
2848 exactPullRequestLookup.data,2870 exactPullRequestLookup.data,
2871 linkedPullRequests,
2849 planModeUiEnabled,2872 planModeUiEnabled,
2850 pullRequestLookup.data,2873 pullRequestLookup.data,
2851 pullRequestProjectId,2874 pullRequestProjectId,
docs/user/composer.md+2-1
...@@ -257,7 +257,8 @@ excerpt, Terminal 1 lines 3-4" and similar to screen readers....@@ -257,7 +257,8 @@ excerpt, Terminal 1 lines 3-4" and similar to screen readers.
257A pull request appears as its icon and number. Its color reflects whether it was open, draft,257A pull request appears as its icon and number. Its color reflects whether it was open, draft,
258merged, or closed when it was attached. Select it to inspect the captured title and branches,258merged, or closed when it was attached. Select it to inspect the captured title and branches,
259then choose **Open pull request** to visit the pull request. On web and desktop, type `#` to browse the newest259then choose **Open pull request** to visit the pull request. On web and desktop, type `#` to browse the newest
260pull requests in the current project's repository. Continue typing digits to filter the recent list260pull requests in the current project's repository. Pull requests linked to the thread are included
261from any repository and listed first, after any exact number match. Continue typing digits to filter the recent list
261by any part of its pull request numbers. A complete number is also resolved directly, even when that262by any part of its pull request numbers. A complete number is also resolved directly, even when that
262pull request is older than the recent list. Type a single word after `#` to search pull requests in263pull request is older than the recent list. Type a single word after `#` to search pull requests in
263the repository by text. Choose a result to insert it as a chip.264the repository by text. Choose a result to insert it as a chip.
packages/shared/src/composerPullRequestMatches.test.ts+71-1
...@@ -1,6 +1,11 @@...@@ -1,6 +1,11 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import { filterComposerPullRequestMatches } from "./composerPullRequestMatches.ts";3import type { ThreadPullRequestLink } from "@t3tools/contracts";
4
5import {
6 composerPullRequestsFromLinks,
7 filterComposerPullRequestMatches,
8} from "./composerPullRequestMatches.ts";
49
5const entry = (number: number, updatedAt: string) => ({10const entry = (number: number, updatedAt: string) => ({
6 number,11 number,
...@@ -46,3 +51,68 @@ describe("filterComposerPullRequestMatches", () => {...@@ -46,3 +51,68 @@ describe("filterComposerPullRequestMatches", () => {
46 expect(result).toHaveLength(1);51 expect(result).toHaveLength(1);
47 });52 });
48});53});
54
55describe("linked pull requests", () => {
56 const link = (
57 repository: string,
58 number: number,
59 overrides: Partial<ThreadPullRequestLink> = {},
60 ): ThreadPullRequestLink => ({
61 host: "github.com",
62 repository,
63 number,
64 url: `https://github.com/${repository}/pull/${number}`,
65 source: "agent",
66 linkedAt: "2026-01-01T00:00:00.000Z",
67 snapshot: {
68 state: "open",
69 title: `${repository} ${number}`,
70 headBranch: "feature",
71 baseBranch: "main",
72 isDraft: false,
73 updatedAt: "2026-01-02T00:00:00.000Z",
74 syncedAt: "2026-01-02T00:00:00.000Z",
75 },
76 stack: null,
77 ...overrides,
78 });
79
80 it("offers a linked pull request from another repository above the project's own", () => {
81 const result = filterComposerPullRequestMatches({
82 entries: [entry(4, "2026-01-03"), { ...entry(4, "2026-01-04"), repository: "owner/other" }],
83 linked: composerPullRequestsFromLinks([link("owner/repo-b", 4)], "p1"),
84 projectId: "p1",
85 repository: "owner/repo",
86 query: "4",
87 limit: 10,
88 });
89 expect(result.map((match) => `${match.repository}#${match.number}`)).toEqual([
90 "owner/repo-b#4",
91 "owner/repo#4",
92 ]);
93 });
94
95 it("keeps one row for a linked pull request the project listing already has", () => {
96 const result = filterComposerPullRequestMatches({
97 entries: [entry(4, "2026-01-03")],
98 linked: composerPullRequestsFromLinks([link("Owner/Repo", 4)], "p1"),
99 projectId: "p1",
100 repository: "owner/repo",
101 query: "",
102 limit: 10,
103 });
104 expect(result).toEqual([entry(4, "2026-01-03")]);
105 });
106
107 it("skips links that are not synced yet or were dismissed", () => {
108 expect(
109 composerPullRequestsFromLinks(
110 [
111 link("owner/repo-b", 4, { snapshot: null }),
112 link("owner/repo-b", 5, { source: "stack-dismissed" }),
113 ],
114 "p1",
115 ),
116 ).toEqual([]);
117 });
118});
packages/shared/src/composerPullRequestMatches.ts+59-14
...@@ -1,3 +1,7 @@...@@ -1,3 +1,7 @@
1import type { ThreadPullRequestLink } from "@t3tools/contracts";
2
3import { visibleThreadPullRequests } from "./threadPullRequests.ts";
4
1export interface ComposerPullRequestMatch {5export interface ComposerPullRequestMatch {
2 readonly number: number;6 readonly number: number;
3 readonly projectId: string;7 readonly projectId: string;
...@@ -6,37 +10,78 @@ export interface ComposerPullRequestMatch {...@@ -6,37 +10,78 @@ export interface ComposerPullRequestMatch {
6}10}
711
8/**12/**
9 * Pull requests matching the numeric fragment typed after `#`, de-duplicated. An exact number13 * Suggestion rows for the pull requests a thread links, from any repository. Built from the
10 * match always outranks a substring match so the result limit can never drop the pull request14 * synced snapshot, so a link the server has not read yet is left out until it has one.
11 * the user typed in full; the rest stay newest first.15 */
16export function composerPullRequestsFromLinks(
17 links: ReadonlyArray<ThreadPullRequestLink>,
18 projectId: string,
19) {
20 return visibleThreadPullRequests(links).flatMap((link) =>
21 link.snapshot === null
22 ? []
23 : [
24 {
25 projectId,
26 repository: link.repository,
27 number: link.number,
28 url: link.url,
29 title: link.snapshot.title,
30 headBranch: link.snapshot.headBranch,
31 baseBranch: link.snapshot.baseBranch,
32 state: link.snapshot.state,
33 isDraft: link.snapshot.isDraft,
34 updatedAt: link.snapshot.updatedAt ?? link.linkedAt,
35 },
36 ],
37 );
38}
39
40/**
41 * Pull requests matching the numeric fragment typed after `#`, de-duplicated. `entries` are kept
42 * to the project's repository; `linked` rows (the thread's own pull requests) may come from any
43 * repository. An exact number match always outranks a substring match so the result limit can
44 * never drop the pull request the user typed in full; then linked rows lead, newest first.
12 */45 */
13export function filterComposerPullRequestMatches<Entry extends ComposerPullRequestMatch>(input: {46export function filterComposerPullRequestMatches<
47 Entry extends ComposerPullRequestMatch,
48 Linked extends ComposerPullRequestMatch = never,
49>(input: {
14 readonly entries: ReadonlyArray<Entry>;50 readonly entries: ReadonlyArray<Entry>;
51 readonly linked?: ReadonlyArray<Linked>;
15 readonly projectId: string;52 readonly projectId: string;
16 readonly repository: string;53 readonly repository: string;
17 readonly query: string;54 readonly query: string;
18 readonly limit: number;55 readonly limit: number;
19}): ReadonlyArray<Entry> {56}): ReadonlyArray<Entry | Linked> {
20 const repository = input.repository.trim().toLowerCase();57 const repository = input.repository.trim().toLowerCase();
58 const keyOf = (entry: ComposerPullRequestMatch) =>
59 `${entry.repository.trim().toLowerCase()}#${entry.number}`;
60 const linked = (input.linked ?? []).filter((entry) => String(entry.number).includes(input.query));
61 const linkedKeys = new Set(linked.map(keyOf));
21 const matchingEntries = input.entries.filter(62 const matchingEntries = input.entries.filter(
22 (entry) =>63 (entry) =>
23 entry.projectId === input.projectId &&64 entry.projectId === input.projectId &&
24 entry.repository.trim().toLowerCase() === repository &&65 entry.repository.trim().toLowerCase() === repository &&
25 String(entry.number).includes(input.query),66 String(entry.number).includes(input.query),
26 );67 );
27 const uniqueEntries = new Map<number, Entry>();68 // Project rows go first so a linked pull request the listing also has keeps the fresher row.
28 for (const entry of matchingEntries) {69 const uniqueEntries = new Map<string, Entry | Linked>();
29 if (!uniqueEntries.has(entry.number)) {70 for (const entry of [...matchingEntries, ...linked]) {
30 uniqueEntries.set(entry.number, entry);71 if (!uniqueEntries.has(keyOf(entry))) {
72 uniqueEntries.set(keyOf(entry), entry);
31 }73 }
32 }74 }
33 const isExactMatch = (entry: Entry) => String(entry.number) === input.query;75 const isExactMatch = (entry: ComposerPullRequestMatch) => String(entry.number) === input.query;
76 const isLinked = (entry: ComposerPullRequestMatch) => linkedKeys.has(keyOf(entry));
34 // `.sort()` on a copy, not `.toSorted()`: this runs on Hermes, which has no ES2023 array77 // `.sort()` on a copy, not `.toSorted()`: this runs on Hermes, which has no ES2023 array
35 // methods, and reaching for one here crashed the composer as the suggestions loaded.78 // methods, and reaching for one here crashed the composer as the suggestions loaded.
36 return [...uniqueEntries.values()]79 return [...uniqueEntries.values()]
37 .sort((left, right) => {80 .sort(
38 const exactness = Number(isExactMatch(right)) - Number(isExactMatch(left));81 (left, right) =>
39 return exactness !== 0 ? exactness : right.updatedAt.localeCompare(left.updatedAt);82 Number(isExactMatch(right)) - Number(isExactMatch(left)) ||
40 })83 Number(isLinked(right)) - Number(isLinked(left)) ||
84 right.updatedAt.localeCompare(left.updatedAt),
85 )
41 .slice(0, input.limit);86 .slice(0, input.limit);
42}87}