From f09a7c725bdf97a6dd341d7f3887c2874dd68ff9 Mon Sep 17 00:00:00 2001 From: tris203 Date: Tue, 22 Sep 2026 21:42:56 +0100 Subject: [PATCH 1/2] fix(search): find threads by linked PR number --- apps/web/src/components/CommandPalette.tsx | 17 +++- apps/web/src/components/Sidebar.tsx | 40 ++++++++- .../src/components/ThreadStatusIndicators.tsx | 89 ++++++++++++++++++- .../shared/src/threadPullRequests.test.ts | 25 ++++++ packages/shared/src/threadPullRequests.ts | 25 ++++-- 5 files changed, 181 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index aad0e71a06b0..e1f670474696 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -189,7 +189,11 @@ import { CommandPaletteMetaDot, ThreadCommandSubtitle, } from "./ThreadCommandSubtitle"; -import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; +import { + ThreadRowLeadingStatus, + ThreadRowTrailingStatus, + ThreadSearchPullRequestNumber, +} from "./ThreadStatusIndicators"; import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; import { applyProviderInstanceSettings, @@ -1387,7 +1391,16 @@ function OpenCommandPaletteDialog(props: { sortOrder: clientSettings.sidebarThreadSortOrder, icon: , renderLeadingContent: (thread) => , - renderTrailingContent: (thread) => , + renderTrailingContent: (thread) => ( + <> + + + + ), renderDescription: (thread, { projectTitle }) => { const modelInstanceId = thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9280947c5e47..febc50f98506 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -233,6 +233,7 @@ import { synchronizeTerminalPulse, type TerminalStatusIndicator, useLinkedThreadPullRequest, + ThreadSearchPullRequestNumber, } from "./ThreadStatusIndicators"; import { resolveSnoozePresets, snoozeWakeLabel, type SnoozePreset } from "./Sidebar.snooze"; import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; @@ -2129,6 +2130,7 @@ function latestRunDiff( } const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { + isSettled: boolean; thread: SidebarThreadSummary; project: EnvironmentProject | null; projectDisplayName: string | null; @@ -2228,11 +2230,12 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { tabIndex={-1} aria-selected={props.isHighlighted} aria-current={accessibility.current} + aria-describedby={`${props.resultId}-pr`} aria-label={accessibility.label} onMouseMove={props.onHighlight} onClick={props.onSelect} className={cn( - "flex min-h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1 text-left text-sm outline-none", + "group/sidebar-row group/v2-row flex min-h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1 text-left text-sm outline-none", props.isHighlighted || props.isRouteActive ? "bg-sidebar-row-active text-sidebar-foreground" : "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground", @@ -2243,12 +2246,40 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { } > {props.project ? ( - + ) : null} - {thread.title} - + + {thread.title} + + + + + {threadTimeLabel(thread)} @@ -4857,6 +4888,7 @@ export default function Sidebar() { ); return ( ); } + +/** Number presentation for compact thread search results. */ +function ThreadPullRequestNumber({ + number, + className, + ...props +}: ComponentProps<"span"> & { number: number }) { + return ( + + #{number} + + ); +} + +function settledPrHoverColorClass(state: NonNullable["state"], isDraft = false): string { + switch (state) { + case "open": + if (isDraft) { + return "group-hover/sidebar-row:text-zinc-500 dark:group-hover/sidebar-row:text-zinc-400/80"; + } + return "group-hover/sidebar-row:text-emerald-600 dark:group-hover/sidebar-row:text-emerald-300/90"; + case "merged": + return "group-hover/sidebar-row:text-violet-600 dark:group-hover/sidebar-row:text-violet-300/90"; + case "closed": + return "group-hover/sidebar-row:text-red-600 dark:group-hover/sidebar-row:text-red-300/90"; + } +} + +export function ThreadSearchPullRequestNumber({ + thread, + query, + enabled = true, + settled = false, +}: { + thread: SidebarThreadSummary; + query: string; + enabled?: boolean; + settled?: boolean; +}) { + const normalizedQuery = query.trim().toLowerCase(); + const matches = (candidate: Parameters[0]) => + normalizedQuery.length > 0 && + threadPullRequestSearchTerms(candidate).some((term) => + term.toLowerCase().includes(normalizedQuery), + ); + const matchedLink = visibleThreadPullRequests(thread.pullRequests).find((pr) => + matches({ pullRequests: [pr] }), + ); + const fallbackCandidates = + thread.pullRequests.length === 0 + ? [thread.linkedPullRequest, thread.branchPullRequest] + : [thread.branchPullRequest]; + const legacyReference = matchedLink + ? undefined + : fallbackCandidates.find((pr) => pr != null && matches({ linkedPullRequest: pr })); + const legacyStatus = useLinkedThreadPullRequest( + thread.environmentId, + legacyReference, + enabled, + undefined, + legacyReference, + ); + const reference = matchedLink ?? legacyReference; + const linked = matchedLink ? linkedPullRequestSnapshotStatus(matchedLink) : legacyStatus; + const status = prStatusIndicator(linked?.pr ?? null, linked?.sourceControlProvider); + return reference ? ( + + ) : null; +} diff --git a/packages/shared/src/threadPullRequests.test.ts b/packages/shared/src/threadPullRequests.test.ts index a7042a7c8828..2ac94fce121d 100644 --- a/packages/shared/src/threadPullRequests.test.ts +++ b/packages/shared/src/threadPullRequests.test.ts @@ -445,6 +445,31 @@ describe("chain selection and badge state", () => { }); describe("threadPullRequestSearchTerms", () => { + it.each([undefined, [], [link(34)]])( + "searches the branch PR alongside existing links (%j)", + (pullRequests) => { + const branchPullRequest = { + projectId: ProjectId.make("project"), + repository: "pingdotgg/t3code", + number: 11809, + url: "https://github.com/pingdotgg/t3code/pull/11809", + }; + const terms = threadPullRequestSearchTerms({ + pullRequests, + branchPullRequest, + linkedPullRequest: { + ...branchPullRequest, + number: 12, + url: "https://github.com/pingdotgg/t3code/pull/12", + }, + }); + for (const query of ["11809", "#11809", "pingdotgg/t3code#11809", branchPullRequest.url]) { + expect(terms.some((term) => term.includes(query))).toBe(true); + } + expect(terms).toContain(pullRequests?.length ? "#34" : "#12"); + }, + ); + it("includes completed and unsynced links but excludes dismissed links", () => { const terms = threadPullRequestSearchTerms({ pullRequests: [ diff --git a/packages/shared/src/threadPullRequests.ts b/packages/shared/src/threadPullRequests.ts index d74927237534..97b800d8682f 100644 --- a/packages/shared/src/threadPullRequests.ts +++ b/packages/shared/src/threadPullRequests.ts @@ -308,21 +308,30 @@ export function resolveThreadPullRequestBadge( return { kind: "pull-request", others: visible.length - 1, state }; } -/** Search terms for visible PR links, including the legacy single-link projection. */ +/** Search terms for visible PR links, the legacy projection, and the branch PR. */ export function threadPullRequestSearchTerms(thread: { readonly pullRequests?: ReadonlyArray | undefined; readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; + readonly branchPullRequest?: ThreadLinkedPullRequest | null | undefined; }): string[] { + const branch = thread.branchPullRequest; + const terms = branch + ? [`#${branch.number}`, `${branch.repository}#${branch.number}`, branch.url] + : []; if (thread.pullRequests !== undefined && thread.pullRequests.length > 0) { - return visibleThreadPullRequests(thread.pullRequests).flatMap((link) => [ - `#${link.number}`, - `${link.repository}#${link.number}`, - link.url, - link.snapshot?.title ?? "", - ]); + return terms.concat( + visibleThreadPullRequests(thread.pullRequests).flatMap((link) => [ + `#${link.number}`, + `${link.repository}#${link.number}`, + link.url, + link.snapshot?.title ?? "", + ]), + ); } const legacy = thread.linkedPullRequest; - return legacy ? [`#${legacy.number}`, `${legacy.repository}#${legacy.number}`, legacy.url] : []; + return legacy + ? terms.concat([`#${legacy.number}`, `${legacy.repository}#${legacy.number}`, legacy.url]) + : terms; } /** Older V2 event payloads stored one link; an explicit empty array means it was unlinked. */ From 26cc71e42ede918adba590734c2c7dd0b7d6e718 Mon Sep 17 00:00:00 2001 From: tris203 Date: Thu, 1 Oct 2026 22:04:39 +0100 Subject: [PATCH 2/2] fix(search): show the PR number for multi-token searches The command palette matches each whitespace-separated token, but the PR number shown on a result required the whole query as one substring, so 'owner/repo #123' found the thread without naming the PR. --- apps/web/src/components/Sidebar.logic.test.ts | 22 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 17 +++++++++++++- .../src/components/ThreadStatusIndicators.tsx | 14 +++++------- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 33bdb2129bf9..e81806309f28 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -37,6 +37,7 @@ import { resolveThreadRowClassName, resolveThreadStatusPill, resolveWorkingStartedAt, + pullRequestMatchesSearchQuery, searchSidebarThreads, shouldClearThreadSelectionOnMouseDown, shouldShowSidebarV2Duration, @@ -991,6 +992,27 @@ describe("resolveSidebarThreadStatus", () => { }); }); +describe("pullRequestMatchesSearchQuery", () => { + const branchPullRequest = { + projectId: ProjectId.make("project"), + repository: "pingdotgg/t3code", + number: 123, + url: "https://github.com/pingdotgg/t3code/pull/123", + }; + + it("matches when every query token appears in the PR's terms", () => { + expect(pullRequestMatchesSearchQuery({ branchPullRequest }, "123")).toBe(true); + expect(pullRequestMatchesSearchQuery({ branchPullRequest }, "pingdotgg/t3code #123")).toBe( + true, + ); + }); + + it("rejects queries with a token the PR does not carry, and blank queries", () => { + expect(pullRequestMatchesSearchQuery({ branchPullRequest }, "rebase 123")).toBe(false); + expect(pullRequestMatchesSearchQuery({ branchPullRequest }, " ")).toBe(false); + }); +}); + describe("searchSidebarThreads", () => { const searchThread = (id: string, title: string, project: string) => ({ environmentId: localEnvironmentId, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index ad2c9a1fedc8..24e16995055f 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -22,7 +22,7 @@ import { type ThreadSortInput, } from "../lib/threadSort"; import type { SidebarThreadSummary, Thread } from "../types"; -import { cn } from "../lib/utils"; +import { cn, normalizeSearchText } from "../lib/utils"; import { isLatestRunSettled } from "../session-logic"; import { resolveServerBackedAppStageLabel } from "../branding.logic"; @@ -1083,6 +1083,21 @@ export function searchSidebarThreads< return [...titleMatches, ...contentMatches]; } +/** + * Whether a PR's search terms contain every whitespace-separated token of the + * query. Search results use this to pick the PR number to show, so it has to + * accept the multi-token queries the command palette matches on. + */ +export function pullRequestMatchesSearchQuery( + pullRequest: Parameters[0], + query: string, +): boolean { + const normalizedQuery = normalizeSearchText(query); + if (normalizedQuery.length === 0) return false; + const haystack = normalizeSearchText(threadPullRequestSearchTerms(pullRequest).join(" ")); + return normalizedQuery.split(" ").every((token) => haystack.includes(token)); +} + export function filterSidebarProjectScopeItems(input: { items: readonly TItem[]; query: string; diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 7b8a240761c9..eae01225203b 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -25,7 +25,6 @@ import { resolveThreadPullRequestChains, visibleThreadPullRequests, type ThreadPullRequestBadge, - threadPullRequestSearchTerms, } from "@t3tools/shared/threadPullRequests"; import { useRender } from "@base-ui/react/use-render"; import { @@ -45,6 +44,7 @@ import { vcsEnvironment } from "../state/vcs"; import { useUiStateStore } from "../uiStateStore"; import { resolveChangeRequestPresentation } from "../sourceControlPresentation"; import { + pullRequestMatchesSearchQuery, resolveThreadLastVisitedAt, resolveThreadStatusPill, type ThreadStatusPill, @@ -1086,14 +1086,8 @@ export function ThreadSearchPullRequestNumber({ enabled?: boolean; settled?: boolean; }) { - const normalizedQuery = query.trim().toLowerCase(); - const matches = (candidate: Parameters[0]) => - normalizedQuery.length > 0 && - threadPullRequestSearchTerms(candidate).some((term) => - term.toLowerCase().includes(normalizedQuery), - ); const matchedLink = visibleThreadPullRequests(thread.pullRequests).find((pr) => - matches({ pullRequests: [pr] }), + pullRequestMatchesSearchQuery({ pullRequests: [pr] }, query), ); const fallbackCandidates = thread.pullRequests.length === 0 @@ -1101,7 +1095,9 @@ export function ThreadSearchPullRequestNumber({ : [thread.branchPullRequest]; const legacyReference = matchedLink ? undefined - : fallbackCandidates.find((pr) => pr != null && matches({ linkedPullRequest: pr })); + : fallbackCandidates.find( + (pr) => pr != null && pullRequestMatchesSearchQuery({ linkedPullRequest: pr }, query), + ); const legacyStatus = useLinkedThreadPullRequest( thread.environmentId, legacyReference,