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
502502 ? (project?.id ?? null)
503503 : null,
504504 pullRequestRepository: project?.repositoryIdentity?.displayName ?? null,
505 threadPullRequests: props.selectedThread.pullRequests,
505506 selectedProviderStatus,
506507 hasThread: true,
507508 hasCompactableConversation: props.hasCompactableConversation,
apps/mobile/src/features/threads/use-composer-command-menu.ts+10-1
......@@ -4,6 +4,7 @@ import type {
44 ProviderInteractionMode,
55 ServerProvider,
66 ThreadId,
7 ThreadPullRequestLink,
78} from "@t3tools/contracts";
89import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThreadItems";
910import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
......@@ -179,6 +180,7 @@ export function useComposerCommandMenu({
179180 projectCwd,
180181 pullRequestProjectId = null,
181182 pullRequestRepository = null,
183 threadPullRequests,
182184 selectedProviderStatus,
183185 hasThread,
184186 hasCompactableConversation,
......@@ -198,6 +200,8 @@ export function useComposerCommandMenu({
198200 readonly projectCwd: string | null;
199201 readonly pullRequestProjectId?: ProjectId | null;
200202 readonly pullRequestRepository?: string | null;
203 /** The thread's linked pull requests, offered in `#` suggestions from any repository. */
204 readonly threadPullRequests?: ReadonlyArray<ThreadPullRequestLink>;
201205 readonly selectedProviderStatus: ServerProvider | null;
202206 readonly hasThread: boolean;
203207 readonly hasCompactableConversation: boolean;
......@@ -357,6 +361,7 @@ export function useComposerCommandMenu({
357361 environmentId,
358362 projectId: pullRequestProjectId,
359363 repository: pullRequestRepository,
364 threadPullRequests,
360365 query: trigger?.kind === "pull-request" ? trigger.query : null,
361366 });
362367
......@@ -377,7 +382,11 @@ export function useComposerCommandMenu({
377382 isDraft: entry.isDraft,
378383 },
379384 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}`,
381390 }));
382391 }
383392
apps/mobile/src/state/queries.ts+16-2
......@@ -1,4 +1,7 @@
1import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches";
1import {
2 composerPullRequestsFromLinks,
3 filterComposerPullRequestMatches,
4} from "@t3tools/shared/composerPullRequestMatches";
25import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
36import { environmentSession } from "./session";
47import { useEnvironmentPresentation } from "./presentation";
......@@ -8,6 +11,7 @@ import type {
811 OrchestrationV2ProjectedTurnItem,
912 ProjectId,
1013 ThreadId,
14 ThreadPullRequestLink,
1115 VcsListRefsResult,
1216 VcsRef,
1317} from "@t3tools/contracts";
......@@ -81,9 +85,18 @@ export function useComposerPullRequestSearch(input: {
8185 environmentId: EnvironmentId | null;
8286 projectId: ProjectId | null;
8387 repository: string | null;
88 /** The thread's linked pull requests, offered in numeric searches from any repository. */
89 threadPullRequests?: ReadonlyArray<ThreadPullRequestLink> | undefined;
8490 query: string | null;
8591}) {
8692 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 );
87100 const ready =
88101 query === input.query &&
89102 query !== null &&
......@@ -122,6 +135,7 @@ export function useComposerPullRequestSearch(input: {
122135 if (numeric) {
123136 return filterComposerPullRequestMatches({
124137 entries: [...(exact.data ? [exact.data] : []), ...(list.data?.entries ?? [])],
138 linked,
125139 projectId: input.projectId!,
126140 repository: input.repository!,
127141 query: query ?? "",
......@@ -142,7 +156,7 @@ export function useComposerPullRequestSearch(input: {
142156 return [...unique.values()]
143157 .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
144158 .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]);
146160 return {
147161 entries,
148162 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) {
1187611876 pullRequestRepository={
1187711877 supportsPullRequests ? activeProjectRepository : null
1187811878 }
11879 threadPullRequests={(activeThreadShell ?? activeThread)?.pullRequests}
1187911880 restingControlsHost={restingComposerControlsHost}
1188011881 restingControlsHaveLeadingContext={
1188111882 mountComposerContextStrip &&
apps/web/src/components/chat/ChatComposer.tsx+26-3
......@@ -7,7 +7,10 @@ import { usePrimaryEnvironmentId } from "../../state/environments";
77import { useRightPanelStore } from "~/rightPanelStore";
88import { AttachmentFilePreview } from "../files/AttachmentFilePreview";
99import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog";
10import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches";
10import {
11 composerPullRequestsFromLinks,
12 filterComposerPullRequestMatches,
13} from "@t3tools/shared/composerPullRequestMatches";
1114import { importPastedComposerText, readPastedComposerContext } from "../composerInlineTokenPaste";
1215import { elementContextToPreviewAnnotation } from "../../lib/elementContext";
1316import { RefreshIcon } from "~/components/ui/refresh-icon";
......@@ -248,7 +251,11 @@ import { THREAD_CONTEXT_DROP_EVENT, threadContextDropTargetProps } from "./threa
248251import { readThreadShell, useThreadShells } from "~/state/entities";
249252import { requestConfirmDialog } from "~/confirmDialog";
250253import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
251import type { ComposerContextClipboardFragment, ComposerContextRecord } from "@t3tools/contracts";
254import type {
255 ComposerContextClipboardFragment,
256 ComposerContextRecord,
257 ThreadPullRequestLink,
258} from "@t3tools/contracts";
252259import { resolveAssetUrl } from "~/assets/assetUrls";
253260import { assetEnvironment } from "~/state/assets";
254261import { readPreparedConnection } from "~/state/session";
......@@ -1629,6 +1636,8 @@ export interface ChatComposerProps {
16291636 gitCwd: string | null;
16301637 pullRequestProjectId: ProjectId | null;
16311638 pullRequestRepository: string | null;
1639 /** The thread's linked pull requests, offered in `#` suggestions from any repository. */
1640 threadPullRequests: ReadonlyArray<ThreadPullRequestLink> | undefined;
16321641 restingControlsHost: HTMLDivElement | null;
16331642 restingControlsHaveLeadingContext: boolean;
16341643 onRestingControlsVisibilityChange: (visible: boolean) => void;
......@@ -1775,6 +1784,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
17751784 gitCwd,
17761785 pullRequestProjectId,
17771786 pullRequestRepository,
1787 threadPullRequests,
17781788 restingControlsHost,
17791789 restingControlsHaveLeadingContext,
17801790 onRestingControlsVisibilityChange,
......@@ -2688,6 +2698,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
26882698 }),
26892699 );
26902700
2701 const linkedPullRequests = useMemo(
2702 () =>
2703 pullRequestProjectId === null
2704 ? []
2705 : composerPullRequestsFromLinks(threadPullRequests ?? [], pullRequestProjectId),
2706 [pullRequestProjectId, threadPullRequests],
2707 );
2708
26912709 const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {
26922710 if (!composerTrigger) return [];
26932711 if (composerTrigger.kind === "path") {
......@@ -2799,6 +2817,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
27992817 const matches = /^\d*$/u.test(composerTrigger.query)
28002818 ? filterComposerPullRequestMatches({
28012819 entries: [...exactPullRequest, ...(pullRequestLookup.data?.entries ?? [])],
2820 linked: linkedPullRequests,
28022821 projectId: pullRequestProjectId,
28032822 repository: pullRequestRepository,
28042823 query: composerTrigger.query,
......@@ -2835,7 +2854,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
28352854 isDraft: pullRequest.isDraft,
28362855 },
28372856 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}`,
28392861 }));
28402862 }
28412863 return [];
......@@ -2846,6 +2868,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
28462868 environmentId,
28472869 environmentThreadShells,
28482870 exactPullRequestLookup.data,
2871 linkedPullRequests,
28492872 planModeUiEnabled,
28502873 pullRequestLookup.data,
28512874 pullRequestProjectId,
docs/user/composer.md+2-1
......@@ -257,7 +257,8 @@ excerpt, Terminal 1 lines 3-4" and similar to screen readers.
257257A pull request appears as its icon and number. Its color reflects whether it was open, draft,
258258merged, or closed when it was attached. Select it to inspect the captured title and branches,
259259then 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 list
260pull 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
261262by any part of its pull request numbers. A complete number is also resolved directly, even when that
262263pull request is older than the recent list. Type a single word after `#` to search pull requests in
263264the repository by text. Choose a result to insert it as a chip.
packages/shared/src/composerPullRequestMatches.test.ts+71-1
......@@ -1,6 +1,11 @@
11import { 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
510const entry = (number: number, updatedAt: string) => ({
611 number,
......@@ -46,3 +51,68 @@ describe("filterComposerPullRequestMatches", () => {
4651 expect(result).toHaveLength(1);
4752 });
4853});
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 @@
1import type { ThreadPullRequestLink } from "@t3tools/contracts";
2
3import { visibleThreadPullRequests } from "./threadPullRequests.ts";
4
15export interface ComposerPullRequestMatch {
26 readonly number: number;
37 readonly projectId: string;
......@@ -6,37 +10,78 @@ export interface ComposerPullRequestMatch {
610}
711
812/**
9 * Pull requests matching the numeric fragment typed after `#`, de-duplicated. An exact number
10 * match always outranks a substring match so the result limit can never drop the pull request
11 * the user typed in full; the rest stay newest first.
13 * Suggestion rows for the pull requests a thread links, from any repository. Built from the
14 * synced snapshot, so a link the server has not read yet is left out until it has one.
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.
1245 */
13export function filterComposerPullRequestMatches<Entry extends ComposerPullRequestMatch>(input: {
46export function filterComposerPullRequestMatches<
47 Entry extends ComposerPullRequestMatch,
48 Linked extends ComposerPullRequestMatch = never,
49>(input: {
1450 readonly entries: ReadonlyArray<Entry>;
51 readonly linked?: ReadonlyArray<Linked>;
1552 readonly projectId: string;
1653 readonly repository: string;
1754 readonly query: string;
1855 readonly limit: number;
19}): ReadonlyArray<Entry> {
56}): ReadonlyArray<Entry | Linked> {
2057 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));
2162 const matchingEntries = input.entries.filter(
2263 (entry) =>
2364 entry.projectId === input.projectId &&
2465 entry.repository.trim().toLowerCase() === repository &&
2566 String(entry.number).includes(input.query),
2667 );
27 const uniqueEntries = new Map<number, Entry>();
28 for (const entry of matchingEntries) {
29 if (!uniqueEntries.has(entry.number)) {
30 uniqueEntries.set(entry.number, entry);
68 // Project rows go first so a linked pull request the listing also has keeps the fresher row.
69 const uniqueEntries = new Map<string, Entry | Linked>();
70 for (const entry of [...matchingEntries, ...linked]) {
71 if (!uniqueEntries.has(keyOf(entry))) {
72 uniqueEntries.set(keyOf(entry), entry);
3173 }
3274 }
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));
3477 // `.sort()` on a copy, not `.toSorted()`: this runs on Hermes, which has no ES2023 array
3578 // methods, and reaching for one here crashed the composer as the suggestions loaded.
3679 return [...uniqueEntries.values()]
37 .sort((left, right) => {
38 const exactness = Number(isExactMatch(right)) - Number(isExactMatch(left));
39 return exactness !== 0 ? exactness : right.updatedAt.localeCompare(left.updatedAt);
40 })
80 .sort(
81 (left, right) =>
82 Number(isExactMatch(right)) - Number(isExactMatch(left)) ||
83 Number(isLinked(right)) - Number(isLinked(left)) ||
84 right.updatedAt.localeCompare(left.updatedAt),
85 )
4186 .slice(0, input.limit);
4287}