authorgravatar for admin@snappeh.comTristan Knight <admin@snappeh.com> 2026-10-11 04:43:18+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 03:43:18+00:00
loga908a7ac8780c5b4d6fb0bed8945d640f69d2d1d
tree367babe6c013299f964edb766deecc55f8533bad
parent6cf05264257093476a41f73439b1fa745427a007
signature Signed by PGP key B5690EEEBB952194

fix(search): find threads by their branch PR number (#14658)

Co-authored-by: maria-rcks <maria@kuuro.net>

7 files changed, 216 insertions(+), 16 deletions(-)

apps/web/src/components/CommandPalette.tsx+16-2
......@@ -211,7 +211,11 @@ import {
211211 CommandPaletteMetaDot,
212212 ThreadCommandSubtitle,
213213} from "./ThreadCommandSubtitle";
214import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";
214import {
215 ThreadRowLeadingStatus,
216 ThreadRowTrailingStatus,
217 ThreadSearchPullRequestNumber,
218} from "./ThreadStatusIndicators";
215219import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server";
216220import {
217221 applyProviderInstanceSettings,
......@@ -1323,7 +1327,16 @@ function OpenCommandPaletteDialog(props: {
13231327 sortOrder: clientSettings.sidebarThreadSortOrder,
13241328 icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />,
13251329 renderLeadingContent: (thread) => <ThreadRowLeadingStatus thread={thread} />,
1326 renderTrailingContent: (thread) => <ThreadRowTrailingStatus thread={thread} />,
1330 renderTrailingContent: (thread) => (
1331 <>
1332 <ThreadSearchPullRequestNumber
1333 thread={thread}
1334 query={threadSearchQuery}
1335 settled={thread.settledOverride === "settled"}
1336 />
1337 <ThreadRowTrailingStatus thread={thread} />
1338 </>
1339 ),
13271340 renderDescription: (thread, { projectTitle }) => {
13281341 const modelInstanceId =
13291342 thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId;
......@@ -1383,6 +1396,7 @@ function OpenCommandPaletteDialog(props: {
13831396 providerEntryByEnvironmentAndInstanceId,
13841397 threadContentMatchByKey,
13851398 threadSearch.query,
1399 threadSearchQuery,
13861400 threads,
13871401 ],
13881402 );
apps/web/src/components/Sidebar.logic.test.ts+22
......@@ -40,6 +40,7 @@ import {
4040 resolveThreadRowClassName,
4141 resolveThreadStatusPill,
4242 resolveWorkingStartedAt,
43 pullRequestMatchesSearchQuery,
4344 searchSidebarThreads,
4445 shouldClearThreadSelectionOnMouseDown,
4546 shouldShowSidebarV2Duration,
......@@ -994,6 +995,27 @@ describe("resolveSidebarThreadStatus", () => {
994995 });
995996});
996997
998describe("pullRequestMatchesSearchQuery", () => {
999 const branchPullRequest = {
1000 projectId: ProjectId.make("project"),
1001 repository: "pingdotgg/t3code",
1002 number: 123,
1003 url: "https://github.com/pingdotgg/t3code/pull/123",
1004 };
1005
1006 it("matches when every query token appears in the PR's terms", () => {
1007 expect(pullRequestMatchesSearchQuery({ branchPullRequest }, "123")).toBe(true);
1008 expect(pullRequestMatchesSearchQuery({ branchPullRequest }, "pingdotgg/t3code #123")).toBe(
1009 true,
1010 );
1011 });
1012
1013 it("rejects queries with a token the PR does not carry, and blank queries", () => {
1014 expect(pullRequestMatchesSearchQuery({ branchPullRequest }, "rebase 123")).toBe(false);
1015 expect(pullRequestMatchesSearchQuery({ branchPullRequest }, " ")).toBe(false);
1016 });
1017});
1018
9971019describe("searchSidebarThreads", () => {
9981020 const searchThread = (id: string, title: string, project: string) => ({
9991021 environmentId: localEnvironmentId,
apps/web/src/components/Sidebar.logic.ts+16-1
......@@ -23,7 +23,7 @@ import {
2323 type ThreadSortInput,
2424} from "../lib/threadSort";
2525import type { SidebarThreadSummary, Thread } from "../types";
26import { cn } from "../lib/utils";
26import { cn, normalizeSearchText } from "../lib/utils";
2727import { isLatestRunSettled } from "../session-logic";
2828import { resolveServerBackedAppStageLabel } from "../branding.logic";
2929
......@@ -1110,6 +1110,21 @@ export function searchSidebarThreads<
11101110 return [...titleMatches, ...contentMatches];
11111111}
11121112
1113/**
1114 * Whether a PR's search terms contain every whitespace-separated token of the
1115 * query. Search results use this to pick the PR number to show, so it has to
1116 * accept the multi-token queries the command palette matches on.
1117 */
1118export function pullRequestMatchesSearchQuery(
1119 pullRequest: Parameters<typeof threadPullRequestSearchTerms>[0],
1120 query: string,
1121): boolean {
1122 const normalizedQuery = normalizeSearchText(query);
1123 if (normalizedQuery.length === 0) return false;
1124 const haystack = normalizeSearchText(threadPullRequestSearchTerms(pullRequest).join(" "));
1125 return normalizedQuery.split(" ").every((token) => haystack.includes(token));
1126}
1127
11131128export function filterSidebarProjectScopeItems<TItem extends { readonly value: string }>(input: {
11141129 items: readonly TItem[];
11151130 query: string;
apps/web/src/components/Sidebar.tsx+36-4
......@@ -248,6 +248,7 @@ import {
248248 synchronizeTerminalPulse,
249249 type TerminalStatusIndicator,
250250 useLinkedThreadPullRequest,
251 ThreadSearchPullRequestNumber,
251252} from "./ThreadStatusIndicators";
252253import { resolveSnoozePresets, snoozeWakeLabel, type SnoozePreset } from "./Sidebar.snooze";
253254import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon";
......@@ -2233,6 +2234,7 @@ function latestRunDiff(
22332234}
22342235
22352236const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
2237 isSettled: boolean;
22362238 thread: SidebarThreadSummary;
22372239 project: EnvironmentProject | null;
22382240 projectDisplayName: string | null;
......@@ -2332,11 +2334,12 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
23322334 tabIndex={-1}
23332335 aria-selected={props.isHighlighted}
23342336 aria-current={accessibility.current}
2337 aria-describedby={`${props.resultId}-pr`}
23352338 aria-label={accessibility.label}
23362339 onMouseMove={props.onHighlight}
23372340 onClick={props.onSelect}
23382341 className={cn(
2339 "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",
2342 "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",
23402343 props.isHighlighted || props.isRouteActive
23412344 ? "bg-sidebar-row-active text-sidebar-foreground"
23422345 : "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground",
......@@ -2347,12 +2350,40 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
23472350 }
23482351 >
23492352 {props.project ? (
2350 <ProjectFavicon project={props.project} className="size-4 shrink-0" />
2353 <ProjectFavicon
2354 project={props.project}
2355 className={cn(
2356 "size-4 shrink-0 transition-opacity",
2357 props.isSettled &&
2358 !props.isRouteActive &&
2359 !props.isHighlighted &&
2360 "opacity-40 grayscale group-hover/sidebar-row:opacity-100 group-hover/sidebar-row:grayscale-0",
2361 )}
2362 />
23512363 ) : null}
23522364 <span className="flex min-w-0 flex-1 flex-col">
23532365 <span className="flex min-w-0 items-center gap-2.5">
2354 <span className="min-w-0 flex-1 truncate">{thread.title}</span>
2355 <span className="shrink-0 text-xs text-muted-foreground/55 tabular-nums">
2366 <span
2367 className={cn(
2368 "min-w-0 flex-1 truncate",
2369 props.isSettled
2370 ? "font-normal text-secondary-label/70 group-hover/sidebar-row:text-foreground"
2371 : "font-medium text-foreground",
2372 (props.isHighlighted || props.isRouteActive) && "text-foreground",
2373 thread.titleRegeneration != null && "opacity-55",
2374 )}
2375 >
2376 {thread.title}
2377 </span>
2378 <span id={`${props.resultId}-pr`} className="contents">
2379 <ThreadSearchPullRequestNumber
2380 thread={thread}
2381 query={props.searchQuery}
2382 enabled={leaseLiveStatus}
2383 settled={props.isSettled}
2384 />
2385 </span>
2386 <span className="w-8 shrink-0 text-right text-xs text-muted-foreground/55 tabular-nums">
23562387 {threadTimeLabel(thread)}
23572388 </span>
23582389 </span>
......@@ -5167,6 +5198,7 @@ export default function Sidebar() {
51675198 );
51685199 return (
51695200 <SidebarSearchResultRow
5201 isSettled={settledThreadKeys.has(threadKey)}
51705202 key={threadKey}
51715203 thread={thread}
51725204 project={
apps/web/src/components/ThreadStatusIndicators.tsx+84-1
......@@ -27,7 +27,13 @@ import {
2727 type ThreadPullRequestBadge,
2828} from "@t3tools/shared/threadPullRequests";
2929import { useRender } from "@base-ui/react/use-render";
30import { type ReactNode, type AnimationEvent, type MouseEvent, type ReactElement } from "react";
30import {
31 type ReactNode,
32 type AnimationEvent,
33 type MouseEvent,
34 type ReactElement,
35 type ComponentProps,
36} from "react";
3137import { cn } from "../lib/utils";
3238
3339import { parseChangeRequestUrl } from "../lib/openPullRequestLink";
......@@ -38,6 +44,7 @@ import { vcsEnvironment } from "../state/vcs";
3844import { useUiStateStore } from "../uiStateStore";
3945import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
4046import {
47 pullRequestMatchesSearchQuery,
4148 resolveThreadLastVisitedAt,
4249 resolveThreadStatusPill,
4350 type ThreadStatusPill,
......@@ -1037,3 +1044,79 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma
10371044 </span>
10381045 );
10391046}
1047
1048/** Number presentation for compact thread search results. */
1049function ThreadPullRequestNumber({
1050 number,
1051 className,
1052 ...props
1053}: ComponentProps<"span"> & { number: number }) {
1054 return (
1055 <span {...props} className={cn("shrink-0 text-xs tabular-nums", className)}>
1056 #{number}
1057 </span>
1058 );
1059}
1060
1061function settledPrHoverColorClass(state: NonNullable<ThreadPr>["state"], isDraft = false): string {
1062 switch (state) {
1063 case "open":
1064 if (isDraft) {
1065 return "group-hover/sidebar-row:text-zinc-500 dark:group-hover/sidebar-row:text-zinc-400/80";
1066 }
1067 return "group-hover/sidebar-row:text-emerald-600 dark:group-hover/sidebar-row:text-emerald-300/90";
1068 case "merged":
1069 return "group-hover/sidebar-row:text-violet-600 dark:group-hover/sidebar-row:text-violet-300/90";
1070 case "closed":
1071 return "group-hover/sidebar-row:text-red-600 dark:group-hover/sidebar-row:text-red-300/90";
1072 }
1073}
1074
1075export function ThreadSearchPullRequestNumber({
1076 thread,
1077 query,
1078 enabled = true,
1079 settled = false,
1080}: {
1081 thread: SidebarThreadSummary;
1082 query: string;
1083 enabled?: boolean;
1084 settled?: boolean;
1085}) {
1086 const matchedLink = visibleThreadPullRequests(thread.pullRequests).find((pr) =>
1087 pullRequestMatchesSearchQuery({ pullRequests: [pr] }, query),
1088 );
1089 const fallbackCandidates =
1090 thread.pullRequests.length === 0
1091 ? [thread.linkedPullRequest, thread.branchPullRequest]
1092 : [thread.branchPullRequest];
1093 const legacyReference = matchedLink
1094 ? undefined
1095 : fallbackCandidates.find(
1096 (pr) => pr != null && pullRequestMatchesSearchQuery({ linkedPullRequest: pr }, query),
1097 );
1098 const legacyStatus = useLinkedThreadPullRequest(
1099 thread.environmentId,
1100 legacyReference,
1101 enabled,
1102 undefined,
1103 legacyReference,
1104 );
1105 const reference = matchedLink ?? legacyReference;
1106 const linked = matchedLink ? linkedPullRequestSnapshotStatus(matchedLink) : legacyStatus;
1107 const status = prStatusIndicator(linked?.pr ?? null, linked?.sourceControlProvider);
1108 return reference ? (
1109 <ThreadPullRequestNumber
1110 number={reference.number}
1111 className={
1112 settled
1113 ? cn(
1114 "text-secondary-label transition-colors",
1115 linked?.pr && settledPrHoverColorClass(linked.pr.state, linked.pr.isDraft),
1116 )
1117 : (status?.colorClass ?? "text-secondary-label")
1118 }
1119 aria-label={status?.tooltip ?? `Pull request #${reference.number}`}
1120 />
1121 ) : null;
1122}
packages/shared/src/threadPullRequests.test.ts+25
......@@ -445,6 +445,31 @@ describe("chain selection and badge state", () => {
445445});
446446
447447describe("threadPullRequestSearchTerms", () => {
448 it.each([undefined, [], [link(34)]])(
449 "searches the branch PR alongside existing links (%j)",
450 (pullRequests) => {
451 const branchPullRequest = {
452 projectId: ProjectId.make("project"),
453 repository: "pingdotgg/t3code",
454 number: 11809,
455 url: "https://github.com/pingdotgg/t3code/pull/11809",
456 };
457 const terms = threadPullRequestSearchTerms({
458 pullRequests,
459 branchPullRequest,
460 linkedPullRequest: {
461 ...branchPullRequest,
462 number: 12,
463 url: "https://github.com/pingdotgg/t3code/pull/12",
464 },
465 });
466 for (const query of ["11809", "#11809", "pingdotgg/t3code#11809", branchPullRequest.url]) {
467 expect(terms.some((term) => term.includes(query))).toBe(true);
468 }
469 expect(terms).toContain(pullRequests?.length ? "#34" : "#12");
470 },
471 );
472
448473 it("includes completed and unsynced links but excludes dismissed links", () => {
449474 const terms = threadPullRequestSearchTerms({
450475 pullRequests: [
packages/shared/src/threadPullRequests.ts+17-8
......@@ -308,21 +308,30 @@ export function resolveThreadPullRequestBadge(
308308 return { kind: "pull-request", others: visible.length - 1, state };
309309}
310310
311/** Search terms for visible PR links, including the legacy single-link projection. */
311/** Search terms for visible PR links, the legacy projection, and the branch PR. */
312312export function threadPullRequestSearchTerms(thread: {
313313 readonly pullRequests?: ReadonlyArray<ThreadPullRequestLink> | undefined;
314314 readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined;
315 readonly branchPullRequest?: ThreadLinkedPullRequest | null | undefined;
315316}): string[] {
317 const branch = thread.branchPullRequest;
318 const terms = branch
319 ? [`#${branch.number}`, `${branch.repository}#${branch.number}`, branch.url]
320 : [];
316321 if (thread.pullRequests !== undefined && thread.pullRequests.length > 0) {
317 return visibleThreadPullRequests(thread.pullRequests).flatMap((link) => [
318 `#${link.number}`,
319 `${link.repository}#${link.number}`,
320 link.url,
321 link.snapshot?.title ?? "",
322 ]);
322 return terms.concat(
323 visibleThreadPullRequests(thread.pullRequests).flatMap((link) => [
324 `#${link.number}`,
325 `${link.repository}#${link.number}`,
326 link.url,
327 link.snapshot?.title ?? "",
328 ]),
329 );
323330 }
324331 const legacy = thread.linkedPullRequest;
325 return legacy ? [`#${legacy.number}`, `${legacy.repository}#${legacy.number}`, legacy.url] : [];
332 return legacy
333 ? terms.concat([`#${legacy.number}`, `${legacy.repository}#${legacy.number}`, legacy.url])
334 : terms;
326335}
327336
328337/** Older V2 event payloads stored one link; an explicit empty array means it was unlinked. */