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 {...@@ -211,7 +211,11 @@ import {
211 CommandPaletteMetaDot,211 CommandPaletteMetaDot,
212 ThreadCommandSubtitle,212 ThreadCommandSubtitle,
213} from "./ThreadCommandSubtitle";213} from "./ThreadCommandSubtitle";
214import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";214import {
215 ThreadRowLeadingStatus,
216 ThreadRowTrailingStatus,
217 ThreadSearchPullRequestNumber,
218} from "./ThreadStatusIndicators";
215import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server";219import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server";
216import {220import {
217 applyProviderInstanceSettings,221 applyProviderInstanceSettings,
...@@ -1323,7 +1327,16 @@ function OpenCommandPaletteDialog(props: {...@@ -1323,7 +1327,16 @@ function OpenCommandPaletteDialog(props: {
1323 sortOrder: clientSettings.sidebarThreadSortOrder,1327 sortOrder: clientSettings.sidebarThreadSortOrder,
1324 icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />,1328 icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />,
1325 renderLeadingContent: (thread) => <ThreadRowLeadingStatus thread={thread} />,1329 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 ),
1327 renderDescription: (thread, { projectTitle }) => {1340 renderDescription: (thread, { projectTitle }) => {
1328 const modelInstanceId =1341 const modelInstanceId =
1329 thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId;1342 thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId;
...@@ -1383,6 +1396,7 @@ function OpenCommandPaletteDialog(props: {...@@ -1383,6 +1396,7 @@ function OpenCommandPaletteDialog(props: {
1383 providerEntryByEnvironmentAndInstanceId,1396 providerEntryByEnvironmentAndInstanceId,
1384 threadContentMatchByKey,1397 threadContentMatchByKey,
1385 threadSearch.query,1398 threadSearch.query,
1399 threadSearchQuery,
1386 threads,1400 threads,
1387 ],1401 ],
1388 );1402 );
apps/web/src/components/Sidebar.logic.test.ts+22
...@@ -40,6 +40,7 @@ import {...@@ -40,6 +40,7 @@ import {
40 resolveThreadRowClassName,40 resolveThreadRowClassName,
41 resolveThreadStatusPill,41 resolveThreadStatusPill,
42 resolveWorkingStartedAt,42 resolveWorkingStartedAt,
43 pullRequestMatchesSearchQuery,
43 searchSidebarThreads,44 searchSidebarThreads,
44 shouldClearThreadSelectionOnMouseDown,45 shouldClearThreadSelectionOnMouseDown,
45 shouldShowSidebarV2Duration,46 shouldShowSidebarV2Duration,
...@@ -994,6 +995,27 @@ describe("resolveSidebarThreadStatus", () => {...@@ -994,6 +995,27 @@ describe("resolveSidebarThreadStatus", () => {
994 });995 });
995});996});
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
997describe("searchSidebarThreads", () => {1019describe("searchSidebarThreads", () => {
998 const searchThread = (id: string, title: string, project: string) => ({1020 const searchThread = (id: string, title: string, project: string) => ({
999 environmentId: localEnvironmentId,1021 environmentId: localEnvironmentId,
apps/web/src/components/Sidebar.logic.ts+16-1
...@@ -23,7 +23,7 @@ import {...@@ -23,7 +23,7 @@ import {
23 type ThreadSortInput,23 type ThreadSortInput,
24} from "../lib/threadSort";24} from "../lib/threadSort";
25import type { SidebarThreadSummary, Thread } from "../types";25import type { SidebarThreadSummary, Thread } from "../types";
26import { cn } from "../lib/utils";26import { cn, normalizeSearchText } from "../lib/utils";
27import { isLatestRunSettled } from "../session-logic";27import { isLatestRunSettled } from "../session-logic";
28import { resolveServerBackedAppStageLabel } from "../branding.logic";28import { resolveServerBackedAppStageLabel } from "../branding.logic";
2929
...@@ -1110,6 +1110,21 @@ export function searchSidebarThreads<...@@ -1110,6 +1110,21 @@ export function searchSidebarThreads<
1110 return [...titleMatches, ...contentMatches];1110 return [...titleMatches, ...contentMatches];
1111}1111}
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
1113export function filterSidebarProjectScopeItems<TItem extends { readonly value: string }>(input: {1128export function filterSidebarProjectScopeItems<TItem extends { readonly value: string }>(input: {
1114 items: readonly TItem[];1129 items: readonly TItem[];
1115 query: string;1130 query: string;
apps/web/src/components/Sidebar.tsx+36-4
...@@ -248,6 +248,7 @@ import {...@@ -248,6 +248,7 @@ import {
248 synchronizeTerminalPulse,248 synchronizeTerminalPulse,
249 type TerminalStatusIndicator,249 type TerminalStatusIndicator,
250 useLinkedThreadPullRequest,250 useLinkedThreadPullRequest,
251 ThreadSearchPullRequestNumber,
251} from "./ThreadStatusIndicators";252} from "./ThreadStatusIndicators";
252import { resolveSnoozePresets, snoozeWakeLabel, type SnoozePreset } from "./Sidebar.snooze";253import { resolveSnoozePresets, snoozeWakeLabel, type SnoozePreset } from "./Sidebar.snooze";
253import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon";254import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon";
...@@ -2233,6 +2234,7 @@ function latestRunDiff(...@@ -2233,6 +2234,7 @@ function latestRunDiff(
2233}2234}
22342235
2235const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {2236const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
2237 isSettled: boolean;
2236 thread: SidebarThreadSummary;2238 thread: SidebarThreadSummary;
2237 project: EnvironmentProject | null;2239 project: EnvironmentProject | null;
2238 projectDisplayName: string | null;2240 projectDisplayName: string | null;
...@@ -2332,11 +2334,12 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {...@@ -2332,11 +2334,12 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
2332 tabIndex={-1}2334 tabIndex={-1}
2333 aria-selected={props.isHighlighted}2335 aria-selected={props.isHighlighted}
2334 aria-current={accessibility.current}2336 aria-current={accessibility.current}
2337 aria-describedby={`${props.resultId}-pr`}
2335 aria-label={accessibility.label}2338 aria-label={accessibility.label}
2336 onMouseMove={props.onHighlight}2339 onMouseMove={props.onHighlight}
2337 onClick={props.onSelect}2340 onClick={props.onSelect}
2338 className={cn(2341 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",
2340 props.isHighlighted || props.isRouteActive2343 props.isHighlighted || props.isRouteActive
2341 ? "bg-sidebar-row-active text-sidebar-foreground"2344 ? "bg-sidebar-row-active text-sidebar-foreground"
2342 : "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground",2345 : "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground",
...@@ -2347,12 +2350,40 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {...@@ -2347,12 +2350,40 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
2347 }2350 }
2348 >2351 >
2349 {props.project ? (2352 {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 />
2351 ) : null}2363 ) : null}
2352 <span className="flex min-w-0 flex-1 flex-col">2364 <span className="flex min-w-0 flex-1 flex-col">
2353 <span className="flex min-w-0 items-center gap-2.5">2365 <span className="flex min-w-0 items-center gap-2.5">
2354 <span className="min-w-0 flex-1 truncate">{thread.title}</span>2366 <span
2355 <span className="shrink-0 text-xs text-muted-foreground/55 tabular-nums">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">
2356 {threadTimeLabel(thread)}2387 {threadTimeLabel(thread)}
2357 </span>2388 </span>
2358 </span>2389 </span>
...@@ -5167,6 +5198,7 @@ export default function Sidebar() {...@@ -5167,6 +5198,7 @@ export default function Sidebar() {
5167 );5198 );
5168 return (5199 return (
5169 <SidebarSearchResultRow5200 <SidebarSearchResultRow
5201 isSettled={settledThreadKeys.has(threadKey)}
5170 key={threadKey}5202 key={threadKey}
5171 thread={thread}5203 thread={thread}
5172 project={5204 project={
apps/web/src/components/ThreadStatusIndicators.tsx+84-1
...@@ -27,7 +27,13 @@ import {...@@ -27,7 +27,13 @@ import {
27 type ThreadPullRequestBadge,27 type ThreadPullRequestBadge,
28} from "@t3tools/shared/threadPullRequests";28} from "@t3tools/shared/threadPullRequests";
29import { useRender } from "@base-ui/react/use-render";29import { 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";
31import { cn } from "../lib/utils";37import { cn } from "../lib/utils";
3238
33import { parseChangeRequestUrl } from "../lib/openPullRequestLink";39import { parseChangeRequestUrl } from "../lib/openPullRequestLink";
...@@ -38,6 +44,7 @@ import { vcsEnvironment } from "../state/vcs";...@@ -38,6 +44,7 @@ import { vcsEnvironment } from "../state/vcs";
38import { useUiStateStore } from "../uiStateStore";44import { useUiStateStore } from "../uiStateStore";
39import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";45import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
40import {46import {
47 pullRequestMatchesSearchQuery,
41 resolveThreadLastVisitedAt,48 resolveThreadLastVisitedAt,
42 resolveThreadStatusPill,49 resolveThreadStatusPill,
43 type ThreadStatusPill,50 type ThreadStatusPill,
...@@ -1037,3 +1044,79 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma...@@ -1037,3 +1044,79 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma
1037 </span>1044 </span>
1038 );1045 );
1039}1046}
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", () => {...@@ -445,6 +445,31 @@ describe("chain selection and badge state", () => {
445});445});
446446
447describe("threadPullRequestSearchTerms", () => {447describe("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
448 it("includes completed and unsynced links but excludes dismissed links", () => {473 it("includes completed and unsynced links but excludes dismissed links", () => {
449 const terms = threadPullRequestSearchTerms({474 const terms = threadPullRequestSearchTerms({
450 pullRequests: [475 pullRequests: [
packages/shared/src/threadPullRequests.ts+17-8
...@@ -308,21 +308,30 @@ export function resolveThreadPullRequestBadge(...@@ -308,21 +308,30 @@ export function resolveThreadPullRequestBadge(
308 return { kind: "pull-request", others: visible.length - 1, state };308 return { kind: "pull-request", others: visible.length - 1, state };
309}309}
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. */
312export function threadPullRequestSearchTerms(thread: {312export function threadPullRequestSearchTerms(thread: {
313 readonly pullRequests?: ReadonlyArray<ThreadPullRequestLink> | undefined;313 readonly pullRequests?: ReadonlyArray<ThreadPullRequestLink> | undefined;
314 readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined;314 readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined;
315 readonly branchPullRequest?: ThreadLinkedPullRequest | null | undefined;
315}): string[] {316}): string[] {
317 const branch = thread.branchPullRequest;
318 const terms = branch
319 ? [`#${branch.number}`, `${branch.repository}#${branch.number}`, branch.url]
320 : [];
316 if (thread.pullRequests !== undefined && thread.pullRequests.length > 0) {321 if (thread.pullRequests !== undefined && thread.pullRequests.length > 0) {
317 return visibleThreadPullRequests(thread.pullRequests).flatMap((link) => [322 return terms.concat(
318 `#${link.number}`,323 visibleThreadPullRequests(thread.pullRequests).flatMap((link) => [
319 `${link.repository}#${link.number}`,324 `#${link.number}`,
320 link.url,325 `${link.repository}#${link.number}`,
321 link.snapshot?.title ?? "",326 link.url,
322 ]);327 link.snapshot?.title ?? "",
328 ]),
329 );
323 }330 }
324 const legacy = thread.linkedPullRequest;331 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;
326}335}
327336
328/** Older V2 event payloads stored one link; an explicit empty array means it was unlinked. */337/** Older V2 event payloads stored one link; an explicit empty array means it was unlinked. */