| author | |
| committer | |
| log | 0425c32571e033c068b8caebfd984acce9f8735b |
| tree | bfa1bd874fe8d54f7650d221692c7ec72193316b |
| parent | 3417bc3856a25fa772f969faeaf8428a58010d47 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 8 | import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThreadItems"; | 9 | import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThreadItems"; |
| 9 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; | 10 | import 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 | }); |
| 362 | 367 | ||
| ... | @@ -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 | } |
| 383 | 392 |
apps/mobile/src/state/queries.ts+16-2| ... | @@ -1,4 +1,7 @@ | ... | @@ -1,4 +1,7 @@ |
| 1 | import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches"; | 1 | import { |
| 2 | composerPullRequestsFromLinks, | ||
| 3 | filterComposerPullRequestMatches, | ||
| 4 | } from "@t3tools/shared/composerPullRequestMatches"; | ||
| 2 | import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; | 5 | import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; |
| 3 | import { environmentSession } from "./session"; | 6 | import { environmentSession } from "./session"; |
| 4 | import { useEnvironmentPresentation } from "./presentation"; | 7 | import { 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 : null | 11877 | 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"; |
| 7 | import { useRightPanelStore } from "~/rightPanelStore"; | 7 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 8 | import { AttachmentFilePreview } from "../files/AttachmentFilePreview"; | 8 | import { AttachmentFilePreview } from "../files/AttachmentFilePreview"; |
| 9 | import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; | 9 | import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; |
| 10 | import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches"; | 10 | import { |
| 11 | composerPullRequestsFromLinks, | ||
| 12 | filterComposerPullRequestMatches, | ||
| 13 | } from "@t3tools/shared/composerPullRequestMatches"; | ||
| 11 | import { importPastedComposerText, readPastedComposerContext } from "../composerInlineTokenPaste"; | 14 | import { importPastedComposerText, readPastedComposerContext } from "../composerInlineTokenPaste"; |
| 12 | import { elementContextToPreviewAnnotation } from "../../lib/elementContext"; | 15 | import { elementContextToPreviewAnnotation } from "../../lib/elementContext"; |
| 13 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | 16 | import { 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 |
| 248 | import { readThreadShell, useThreadShells } from "~/state/entities"; | 251 | import { readThreadShell, useThreadShells } from "~/state/entities"; |
| 249 | import { requestConfirmDialog } from "~/confirmDialog"; | 252 | import { requestConfirmDialog } from "~/confirmDialog"; |
| 250 | import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; | 253 | import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; |
| 251 | import type { ComposerContextClipboardFragment, ComposerContextRecord } from "@t3tools/contracts"; | 254 | import type { |
| 255 | ComposerContextClipboardFragment, | ||
| 256 | ComposerContextRecord, | ||
| 257 | ThreadPullRequestLink, | ||
| 258 | } from "@t3tools/contracts"; | ||
| 252 | import { resolveAssetUrl } from "~/assets/assetUrls"; | 259 | import { resolveAssetUrl } from "~/assets/assetUrls"; |
| 253 | import { assetEnvironment } from "~/state/assets"; | 260 | import { assetEnvironment } from "~/state/assets"; |
| 254 | import { readPreparedConnection } from "~/state/session"; | 261 | import { 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 | ); |
| 2690 | 2700 | ||
| 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. |
| 257 | A pull request appears as its icon and number. Its color reflects whether it was open, draft, | 257 | A pull request appears as its icon and number. Its color reflects whether it was open, draft, |
| 258 | merged, or closed when it was attached. Select it to inspect the captured title and branches, | 258 | merged, or closed when it was attached. Select it to inspect the captured title and branches, |
| 259 | then choose **Open pull request** to visit the pull request. On web and desktop, type `#` to browse the newest | 259 | then choose **Open pull request** to visit the pull request. On web and desktop, type `#` to browse the newest |
| 260 | pull requests in the current project's repository. Continue typing digits to filter the recent list | 260 | pull requests in the current project's repository. Pull requests linked to the thread are included |
| 261 | from any repository and listed first, after any exact number match. Continue typing digits to filter the recent list | ||
| 261 | by any part of its pull request numbers. A complete number is also resolved directly, even when that | 262 | by any part of its pull request numbers. A complete number is also resolved directly, even when that |
| 262 | pull request is older than the recent list. Type a single word after `#` to search pull requests in | 263 | pull request is older than the recent list. Type a single word after `#` to search pull requests in |
| 263 | the repository by text. Choose a result to insert it as a chip. | 264 | the 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { filterComposerPullRequestMatches } from "./composerPullRequestMatches.ts"; | 3 | import type { ThreadPullRequestLink } from "@t3tools/contracts"; |
| 4 | |||
| 5 | import { | ||
| 6 | composerPullRequestsFromLinks, | ||
| 7 | filterComposerPullRequestMatches, | ||
| 8 | } from "./composerPullRequestMatches.ts"; | ||
| 4 | 9 | ||
| 5 | const entry = (number: number, updatedAt: string) => ({ | 10 | const 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 | |||
| 55 | describe("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 @@ |
| 1 | import type { ThreadPullRequestLink } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | import { visibleThreadPullRequests } from "./threadPullRequests.ts"; | ||
| 4 | |||
| 1 | export interface ComposerPullRequestMatch { | 5 | export 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 | } |
| 7 | 11 | ||
| 8 | /** | 12 | /** |
| 9 | * Pull requests matching the numeric fragment typed after `#`, de-duplicated. An exact number | 13 | * 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 request | 14 | * 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 | */ |
| 16 | export 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 | */ |
| 13 | export function filterComposerPullRequestMatches<Entry extends ComposerPullRequestMatch>(input: { | 46 | export 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 array | 77 | // `.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 | } |