diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index f84ad7cbce0184197c50ade672d739109df49b23..a45f319423fe205ccb14429d1032c0303042bc38 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -502,6 +502,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ? (project?.id ?? null) : null, pullRequestRepository: project?.repositoryIdentity?.displayName ?? null, + threadPullRequests: props.selectedThread.pullRequests, selectedProviderStatus, hasThread: true, hasCompactableConversation: props.hasCompactableConversation, diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index a02b95b50666d271402c637f8fbf106f65ce87f4..cb702af060589c8c7f4bb8b93ba08ebc92a25a67 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -4,6 +4,7 @@ import type { ProviderInteractionMode, ServerProvider, ThreadId, + ThreadPullRequestLink, } from "@t3tools/contracts"; import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThreadItems"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; @@ -179,6 +180,7 @@ export function useComposerCommandMenu({ projectCwd, pullRequestProjectId = null, pullRequestRepository = null, + threadPullRequests, selectedProviderStatus, hasThread, hasCompactableConversation, @@ -198,6 +200,8 @@ export function useComposerCommandMenu({ readonly projectCwd: string | null; readonly pullRequestProjectId?: ProjectId | null; readonly pullRequestRepository?: string | null; + /** The thread's linked pull requests, offered in `#` suggestions from any repository. */ + readonly threadPullRequests?: ReadonlyArray; readonly selectedProviderStatus: ServerProvider | null; readonly hasThread: boolean; readonly hasCompactableConversation: boolean; @@ -357,6 +361,7 @@ export function useComposerCommandMenu({ environmentId, projectId: pullRequestProjectId, repository: pullRequestRepository, + threadPullRequests, query: trigger?.kind === "pull-request" ? trigger.query : null, }); @@ -377,7 +382,11 @@ export function useComposerCommandMenu({ isDraft: entry.isDraft, }, label: `#${entry.number}`, - description: `${entry.isDraft ? "Draft" : entry.state} · ${entry.title}`, + description: `${entry.isDraft ? "Draft" : entry.state} · ${ + entry.repository.toLowerCase() === pullRequestRepository?.toLowerCase() + ? "" + : `${entry.repository} · ` + }${entry.title}`, })); } diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index 41aa35ed43674ed394b3dd9c559944b8129736ca..4c359072be27bb80f44877bd4aa6aa129034ed92 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -1,4 +1,7 @@ -import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches"; +import { + composerPullRequestsFromLinks, + filterComposerPullRequestMatches, +} from "@t3tools/shared/composerPullRequestMatches"; import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; import { environmentSession } from "./session"; import { useEnvironmentPresentation } from "./presentation"; @@ -8,6 +11,7 @@ import type { OrchestrationV2ProjectedTurnItem, ProjectId, ThreadId, + ThreadPullRequestLink, VcsListRefsResult, VcsRef, } from "@t3tools/contracts"; @@ -81,9 +85,18 @@ export function useComposerPullRequestSearch(input: { environmentId: EnvironmentId | null; projectId: ProjectId | null; repository: string | null; + /** The thread's linked pull requests, offered in numeric searches from any repository. */ + threadPullRequests?: ReadonlyArray | undefined; query: string | null; }) { const query = useDebouncedValue(input.query, 180); + const linked = useMemo( + () => + input.projectId === null + ? [] + : composerPullRequestsFromLinks(input.threadPullRequests ?? [], input.projectId), + [input.projectId, input.threadPullRequests], + ); const ready = query === input.query && query !== null && @@ -122,6 +135,7 @@ export function useComposerPullRequestSearch(input: { if (numeric) { return filterComposerPullRequestMatches({ entries: [...(exact.data ? [exact.data] : []), ...(list.data?.entries ?? [])], + linked, projectId: input.projectId!, repository: input.repository!, query: query ?? "", @@ -142,7 +156,7 @@ export function useComposerPullRequestSearch(input: { return [...unique.values()] .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) .slice(0, 20); - }, [ready, exact.data, list.data, input.projectId, input.repository, numeric, query]); + }, [ready, exact.data, list.data, linked, input.projectId, input.repository, numeric, query]); return { entries, isPending: input.query !== null && (query !== input.query || list.isPending || exact.isPending), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 270f1f26ac5ec7477681852c1fa7e234c66ce3de..712e4b9bb61b0abaadd1c035f9e79e805c0047f6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -11876,6 +11876,7 @@ export default function ChatView(props: ChatViewProps) { pullRequestRepository={ supportsPullRequests ? activeProjectRepository : null } + threadPullRequests={(activeThreadShell ?? activeThread)?.pullRequests} restingControlsHost={restingComposerControlsHost} restingControlsHaveLeadingContext={ mountComposerContextStrip && diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 58bc715c1a9b3835fc99d3de34bd4cd00075ba97..80ef5ecd054b585046faf93678566a2e89b004b4 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -7,7 +7,10 @@ import { usePrimaryEnvironmentId } from "../../state/environments"; import { useRightPanelStore } from "~/rightPanelStore"; import { AttachmentFilePreview } from "../files/AttachmentFilePreview"; import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; -import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches"; +import { + composerPullRequestsFromLinks, + filterComposerPullRequestMatches, +} from "@t3tools/shared/composerPullRequestMatches"; import { importPastedComposerText, readPastedComposerContext } from "../composerInlineTokenPaste"; import { elementContextToPreviewAnnotation } from "../../lib/elementContext"; import { RefreshIcon } from "~/components/ui/refresh-icon"; @@ -248,7 +251,11 @@ import { THREAD_CONTEXT_DROP_EVENT, threadContextDropTargetProps } from "./threa import { readThreadShell, useThreadShells } from "~/state/entities"; import { requestConfirmDialog } from "~/confirmDialog"; import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; -import type { ComposerContextClipboardFragment, ComposerContextRecord } from "@t3tools/contracts"; +import type { + ComposerContextClipboardFragment, + ComposerContextRecord, + ThreadPullRequestLink, +} from "@t3tools/contracts"; import { resolveAssetUrl } from "~/assets/assetUrls"; import { assetEnvironment } from "~/state/assets"; import { readPreparedConnection } from "~/state/session"; @@ -1629,6 +1636,8 @@ export interface ChatComposerProps { gitCwd: string | null; pullRequestProjectId: ProjectId | null; pullRequestRepository: string | null; + /** The thread's linked pull requests, offered in `#` suggestions from any repository. */ + threadPullRequests: ReadonlyArray | undefined; restingControlsHost: HTMLDivElement | null; restingControlsHaveLeadingContext: boolean; onRestingControlsVisibilityChange: (visible: boolean) => void; @@ -1775,6 +1784,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) gitCwd, pullRequestProjectId, pullRequestRepository, + threadPullRequests, restingControlsHost, restingControlsHaveLeadingContext, onRestingControlsVisibilityChange, @@ -2688,6 +2698,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }), ); + const linkedPullRequests = useMemo( + () => + pullRequestProjectId === null + ? [] + : composerPullRequestsFromLinks(threadPullRequests ?? [], pullRequestProjectId), + [pullRequestProjectId, threadPullRequests], + ); + const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; if (composerTrigger.kind === "path") { @@ -2799,6 +2817,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const matches = /^\d*$/u.test(composerTrigger.query) ? filterComposerPullRequestMatches({ entries: [...exactPullRequest, ...(pullRequestLookup.data?.entries ?? [])], + linked: linkedPullRequests, projectId: pullRequestProjectId, repository: pullRequestRepository, query: composerTrigger.query, @@ -2835,7 +2854,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isDraft: pullRequest.isDraft, }, label: `#${pullRequest.number}`, - description: pullRequest.title, + description: + pullRequest.repository.trim().toLowerCase() === pullRequestRepository.trim().toLowerCase() + ? pullRequest.title + : `${pullRequest.repository} · ${pullRequest.title}`, })); } return []; @@ -2846,6 +2868,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) environmentId, environmentThreadShells, exactPullRequestLookup.data, + linkedPullRequests, planModeUiEnabled, pullRequestLookup.data, pullRequestProjectId, diff --git a/docs/user/composer.md b/docs/user/composer.md index 9fe7cb28d8c276721fdcc9134ae03633b0ad0a28..7e1be24eda3677ebc0aa00a90fe0cd2220d576e6 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -257,7 +257,8 @@ excerpt, Terminal 1 lines 3-4" and similar to screen readers. A pull request appears as its icon and number. Its color reflects whether it was open, draft, merged, or closed when it was attached. Select it to inspect the captured title and branches, then choose **Open pull request** to visit the pull request. On web and desktop, type `#` to browse the newest -pull requests in the current project's repository. Continue typing digits to filter the recent list +pull requests in the current project's repository. Pull requests linked to the thread are included +from any repository and listed first, after any exact number match. Continue typing digits to filter the recent list by any part of its pull request numbers. A complete number is also resolved directly, even when that pull request is older than the recent list. Type a single word after `#` to search pull requests in the repository by text. Choose a result to insert it as a chip. diff --git a/packages/shared/src/composerPullRequestMatches.test.ts b/packages/shared/src/composerPullRequestMatches.test.ts index dd1048f881470fdfaa09845412a2ed8ab374f06e..5e2eb3657c27f10afa997d58d4634b0f414bcb1b 100644 --- a/packages/shared/src/composerPullRequestMatches.test.ts +++ b/packages/shared/src/composerPullRequestMatches.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; -import { filterComposerPullRequestMatches } from "./composerPullRequestMatches.ts"; +import type { ThreadPullRequestLink } from "@t3tools/contracts"; + +import { + composerPullRequestsFromLinks, + filterComposerPullRequestMatches, +} from "./composerPullRequestMatches.ts"; const entry = (number: number, updatedAt: string) => ({ number, @@ -46,3 +51,68 @@ describe("filterComposerPullRequestMatches", () => { expect(result).toHaveLength(1); }); }); + +describe("linked pull requests", () => { + const link = ( + repository: string, + number: number, + overrides: Partial = {}, + ): ThreadPullRequestLink => ({ + host: "github.com", + repository, + number, + url: `https://github.com/${repository}/pull/${number}`, + source: "agent", + linkedAt: "2026-01-01T00:00:00.000Z", + snapshot: { + state: "open", + title: `${repository} ${number}`, + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: "2026-01-02T00:00:00.000Z", + syncedAt: "2026-01-02T00:00:00.000Z", + }, + stack: null, + ...overrides, + }); + + it("offers a linked pull request from another repository above the project's own", () => { + const result = filterComposerPullRequestMatches({ + entries: [entry(4, "2026-01-03"), { ...entry(4, "2026-01-04"), repository: "owner/other" }], + linked: composerPullRequestsFromLinks([link("owner/repo-b", 4)], "p1"), + projectId: "p1", + repository: "owner/repo", + query: "4", + limit: 10, + }); + expect(result.map((match) => `${match.repository}#${match.number}`)).toEqual([ + "owner/repo-b#4", + "owner/repo#4", + ]); + }); + + it("keeps one row for a linked pull request the project listing already has", () => { + const result = filterComposerPullRequestMatches({ + entries: [entry(4, "2026-01-03")], + linked: composerPullRequestsFromLinks([link("Owner/Repo", 4)], "p1"), + projectId: "p1", + repository: "owner/repo", + query: "", + limit: 10, + }); + expect(result).toEqual([entry(4, "2026-01-03")]); + }); + + it("skips links that are not synced yet or were dismissed", () => { + expect( + composerPullRequestsFromLinks( + [ + link("owner/repo-b", 4, { snapshot: null }), + link("owner/repo-b", 5, { source: "stack-dismissed" }), + ], + "p1", + ), + ).toEqual([]); + }); +}); diff --git a/packages/shared/src/composerPullRequestMatches.ts b/packages/shared/src/composerPullRequestMatches.ts index 0aaae9a0ac1b6bae35d58a58046c7d6524be9c2b..b844a2739edfdfee834ca8972f76640b69b69bfd 100644 --- a/packages/shared/src/composerPullRequestMatches.ts +++ b/packages/shared/src/composerPullRequestMatches.ts @@ -1,3 +1,7 @@ +import type { ThreadPullRequestLink } from "@t3tools/contracts"; + +import { visibleThreadPullRequests } from "./threadPullRequests.ts"; + export interface ComposerPullRequestMatch { readonly number: number; readonly projectId: string; @@ -6,37 +10,78 @@ export interface ComposerPullRequestMatch { } /** - * Pull requests matching the numeric fragment typed after `#`, de-duplicated. An exact number - * match always outranks a substring match so the result limit can never drop the pull request - * the user typed in full; the rest stay newest first. + * Suggestion rows for the pull requests a thread links, from any repository. Built from the + * synced snapshot, so a link the server has not read yet is left out until it has one. */ -export function filterComposerPullRequestMatches(input: { +export function composerPullRequestsFromLinks( + links: ReadonlyArray, + projectId: string, +) { + return visibleThreadPullRequests(links).flatMap((link) => + link.snapshot === null + ? [] + : [ + { + projectId, + repository: link.repository, + number: link.number, + url: link.url, + title: link.snapshot.title, + headBranch: link.snapshot.headBranch, + baseBranch: link.snapshot.baseBranch, + state: link.snapshot.state, + isDraft: link.snapshot.isDraft, + updatedAt: link.snapshot.updatedAt ?? link.linkedAt, + }, + ], + ); +} + +/** + * Pull requests matching the numeric fragment typed after `#`, de-duplicated. `entries` are kept + * to the project's repository; `linked` rows (the thread's own pull requests) may come from any + * repository. An exact number match always outranks a substring match so the result limit can + * never drop the pull request the user typed in full; then linked rows lead, newest first. + */ +export function filterComposerPullRequestMatches< + Entry extends ComposerPullRequestMatch, + Linked extends ComposerPullRequestMatch = never, +>(input: { readonly entries: ReadonlyArray; + readonly linked?: ReadonlyArray; readonly projectId: string; readonly repository: string; readonly query: string; readonly limit: number; -}): ReadonlyArray { +}): ReadonlyArray { const repository = input.repository.trim().toLowerCase(); + const keyOf = (entry: ComposerPullRequestMatch) => + `${entry.repository.trim().toLowerCase()}#${entry.number}`; + const linked = (input.linked ?? []).filter((entry) => String(entry.number).includes(input.query)); + const linkedKeys = new Set(linked.map(keyOf)); const matchingEntries = input.entries.filter( (entry) => entry.projectId === input.projectId && entry.repository.trim().toLowerCase() === repository && String(entry.number).includes(input.query), ); - const uniqueEntries = new Map(); - for (const entry of matchingEntries) { - if (!uniqueEntries.has(entry.number)) { - uniqueEntries.set(entry.number, entry); + // Project rows go first so a linked pull request the listing also has keeps the fresher row. + const uniqueEntries = new Map(); + for (const entry of [...matchingEntries, ...linked]) { + if (!uniqueEntries.has(keyOf(entry))) { + uniqueEntries.set(keyOf(entry), entry); } } - const isExactMatch = (entry: Entry) => String(entry.number) === input.query; + const isExactMatch = (entry: ComposerPullRequestMatch) => String(entry.number) === input.query; + const isLinked = (entry: ComposerPullRequestMatch) => linkedKeys.has(keyOf(entry)); // `.sort()` on a copy, not `.toSorted()`: this runs on Hermes, which has no ES2023 array // methods, and reaching for one here crashed the composer as the suggestions loaded. return [...uniqueEntries.values()] - .sort((left, right) => { - const exactness = Number(isExactMatch(right)) - Number(isExactMatch(left)); - return exactness !== 0 ? exactness : right.updatedAt.localeCompare(left.updatedAt); - }) + .sort( + (left, right) => + Number(isExactMatch(right)) - Number(isExactMatch(left)) || + Number(isLinked(right)) - Number(isLinked(left)) || + right.updatedAt.localeCompare(left.updatedAt), + ) .slice(0, input.limit); }