authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-09 15:50:59-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-09 15:50:59-07:00
logf0401c629038100edafc496aca79fdc53d1d4db8
treedda6da7553350e49546c22e5e0969716ea591d0f
parentafb84898be3bf9b83bd4cb98c608b79c433579cc
signature Signed by PGP key B5690EEEBB952194

feat(search): find threads by linked pull request (#10870)


9 files changed, 125 insertions(+), 15 deletions(-)

apps/mobile/src/features/home/homeThreadList.ts+4
......@@ -1,3 +1,4 @@
1import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests";
12import {
23 buildProjectGroups,
34 derivePhysicalProjectKey,
......@@ -311,6 +312,9 @@ export function buildHomeThreadGroups(input: {
311312 : group.threads.filter(
312313 (thread) =>
313314 thread.title.toLocaleLowerCase().includes(query) ||
315 threadPullRequestSearchTerms(thread).some((term) =>
316 term.toLocaleLowerCase().includes(query),
317 ) ||
314318 input.matchedThreadKeys?.has(
315319 threadSearchMatchKey({
316320 environmentId: thread.environmentId,
apps/mobile/src/features/threads/threadListV2.ts+4
......@@ -1,3 +1,4 @@
1import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests";
12import {
23 effectiveSnoozed,
34 hasQueuedTurnStart,
......@@ -400,6 +401,9 @@ export function buildThreadListV2Items(input: {
400401 if (
401402 query.length > 0 &&
402403 !thread.title.toLocaleLowerCase().includes(query) &&
404 !threadPullRequestSearchTerms(thread).some((term) =>
405 term.toLocaleLowerCase().includes(query),
406 ) &&
403407 input.matchedThreadKeys?.has(
404408 threadSearchMatchKey({
405409 environmentId: thread.environmentId,
apps/web/src/components/CommandPalette.logic.test.ts+42
......@@ -508,3 +508,45 @@ describe("filterPinnedBrowseEntries", () => {
508508 });
509509 });
510510});
511
512it.each([
513 "#10839",
514 "10839",
515 "pingdotgg/t3code#10839",
516 "https://github.com/pingdotgg/t3code/pull/10839",
517])("finds linked threads from PR query %s", (query) => {
518 const items = buildThreadActionItems({
519 threads: [
520 makeThread({
521 title: "Implementation",
522 pullRequests: [
523 {
524 host: "github.com",
525 repository: "pingdotgg/t3code",
526 number: 10839,
527 url: "https://github.com/pingdotgg/t3code/pull/10839",
528 source: "manual",
529 linkedAt: "2026-09-08T00:00:00Z",
530 snapshot: null,
531 stack: null,
532 },
533 ],
534 }),
535 makeThread({ id: ThreadId.make("unrelated"), title: "Other work" }),
536 ],
537 projectTitleById: new Map(),
538 sortOrder: "updated_at",
539 icon: null,
540 runThread: async () => undefined,
541 });
542 const groups = filterCommandPaletteGroups({
543 activeGroups: [],
544 query,
545 isInSubmenu: false,
546 projectSearchItems: [],
547 threadSearchItems: items,
548 });
549 expect(groups.flatMap((group) => group.items.map((item) => item.title))).toEqual([
550 "Implementation",
551 ]);
552});
apps/web/src/components/CommandPalette.logic.ts+3
......@@ -1,3 +1,4 @@
1import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests";
12import {
23 type FilesystemBrowseEntry,
34 type KeybindingCommand,
......@@ -186,6 +187,7 @@ export type BuildThreadActionItemsThread = Pick<
186187 | "title"
187188 | "worktreePath"
188189> & {
190 pullRequests?: SidebarThreadSummary["pullRequests"];
189191 updatedAt: string;
190192 latestUserMessageAt?: string | null;
191193};
......@@ -240,6 +242,7 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr
240242 value: `thread:${thread.id}`,
241243 searchTerms: [
242244 thread.title,
245 ...threadPullRequestSearchTerms(thread),
243246 projectTitle ?? ``,
244247 thread.branch ?? ``,
245248 contentMatch?.snippet ?? ``,
apps/web/src/components/Sidebar.logic.test.ts+5-5
......@@ -27,7 +27,7 @@ import {
2727 resolveSidebarThreadStatus,
2828 resolveThreadStatusPill,
2929 resolveWorkingStartedAt,
30 searchSidebarThreadsByTitle,
30 searchSidebarThreads,
3131 formatWorkingDurationLabel,
3232 shouldClearThreadSelectionOnMouseDown,
3333 shouldRecedeSidebarThread,
......@@ -802,7 +802,7 @@ describe("resolveSidebarThreadStatus", () => {
802802 });
803803});
804804
805describe("searchSidebarThreadsByTitle", () => {
805describe("searchSidebarThreads", () => {
806806 const threads = [
807807 { id: "thread-1", title: "Fix workspace search", project: "Alpha" },
808808 { id: "thread-2", title: "Review providers", project: "Workspace" },
......@@ -810,15 +810,15 @@ describe("searchSidebarThreadsByTitle", () => {
810810 ];
811811
812812 it("matches thread titles case-insensitively and preserves their order", () => {
813 expect(searchSidebarThreadsByTitle(threads, "work")).toEqual([threads[0], threads[2]]);
813 expect(searchSidebarThreads(threads, "work")).toEqual([threads[0], threads[2]]);
814814 });
815815
816816 it("does not match project metadata", () => {
817 expect(searchSidebarThreadsByTitle(threads, "workspace")).toEqual([threads[0]]);
817 expect(searchSidebarThreads(threads, "workspace")).toEqual([threads[0]]);
818818 });
819819
820820 it("returns no results for an empty query", () => {
821 expect(searchSidebarThreadsByTitle(threads, " ")).toEqual([]);
821 expect(searchSidebarThreads(threads, " ")).toEqual([]);
822822 });
823823});
824824
apps/web/src/components/Sidebar.logic.ts+10-6
......@@ -1,3 +1,4 @@
1import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests";
12import * as React from "react";
23import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable";
34import {
......@@ -875,17 +876,20 @@ export { pinOrderKeyBetween, planPinnedReorder } from "@t3tools/client-runtime/s
875876export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort";
876877
877878/**
878 * Search the already-ordered sidebar thread collection by title only.
879 * Search the already-ordered sidebar thread collection by title or linked PR.
879880 * Keeping the input order means lifecycle ordering (active, snoozed, settled)
880881 * remains stable while the user narrows the list.
881882 */
882export function searchSidebarThreadsByTitle<T extends { readonly title: string }>(
883 threads: readonly T[],
884 query: string,
885): T[] {
883export function searchSidebarThreads<
884 T extends { readonly title: string } & Parameters<typeof threadPullRequestSearchTerms>[0],
885>(threads: readonly T[], query: string): T[] {
886886 const normalizedQuery = query.trim().toLowerCase();
887887 if (normalizedQuery.length === 0) return [];
888 return threads.filter((thread) => thread.title.toLowerCase().includes(normalizedQuery));
888 return threads.filter((thread) =>
889 [thread.title, ...threadPullRequestSearchTerms(thread)].some((term) =>
890 term.toLowerCase().includes(normalizedQuery),
891 ),
892 );
889893}
890894
891895export function filterSidebarProjectScopeItems<TItem extends { readonly value: string }>(input: {
apps/web/src/components/Sidebar.tsx+3-3
......@@ -166,7 +166,7 @@ import {
166166 resolveSidebarDropVerb,
167167 type SidebarDropVerb,
168168 resolveSidebarThreadStatus,
169 searchSidebarThreadsByTitle,
169 searchSidebarThreads,
170170 shouldCreateNewThreadInCurrentProject,
171171 shouldRecedeSidebarThread,
172172 resolveWorkingStartedAt,
......@@ -2675,7 +2675,7 @@ export default function Sidebar() {
26752675 [activeThreads, pinnedThreads, settledThreads, snoozedThreads],
26762676 );
26772677 const threadSearchResults = useMemo(
2678 () => searchSidebarThreadsByTitle(searchableThreads, threadSearchQuery),
2678 () => searchSidebarThreads(searchableThreads, threadSearchQuery),
26792679 [searchableThreads, threadSearchQuery],
26802680 );
26812681 const threadSearchResultOrderKey = threadSearchResults
......@@ -4407,7 +4407,7 @@ export default function Sidebar() {
44074407 setActiveSearchResultIndex(0);
44084408 }}
44094409 onKeyDown={handleThreadSearchKeyDown}
4410 placeholder="Search"
4410 placeholder="Search threads or PRs"
44114411 aria-label="Search threads"
44124412 role="combobox"
44134413 aria-autocomplete="list"
packages/shared/src/threadPullRequests.test.ts+37-1
......@@ -1,9 +1,14 @@
1import type { ThreadPullRequestLink, ThreadPullRequestSnapshot } from "@t3tools/contracts";
1import {
2 ProjectId,
3 type ThreadPullRequestLink,
4 type ThreadPullRequestSnapshot,
5} from "@t3tools/contracts";
26import { describe, expect, it } from "vite-plus/test";
37
48import {
59 legacyLinkedPullRequestOf,
610 legacyThreadPullRequestKey,
11 threadPullRequestSearchTerms,
712 resolveThreadCurrentPullRequest,
813 resolveThreadPullRequestChains,
914 resolveThreadPullRequestBadge,
......@@ -349,3 +354,34 @@ describe("chain selection and badge state", () => {
349354 );
350355 });
351356});
357
358describe("threadPullRequestSearchTerms", () => {
359 it("includes completed and unsynced links but excludes dismissed links", () => {
360 const terms = threadPullRequestSearchTerms({
361 pullRequests: [
362 link(12, { snapshot: snapshot({ title: "Fix login", state: "merged" }) }),
363 link(34),
364 link(56, { source: "stack-dismissed" }),
365 ],
366 });
367 expect(terms).toContain("#12");
368 expect(terms).toContain("pingdotgg/t3code#12");
369 expect(terms).toContain("https://github.com/pingdotgg/t3code/pull/12");
370 expect(terms).toContain("Fix login");
371 expect(terms).toContain("#34");
372 expect(terms.join(" ")).not.toContain("56");
373 });
374});
375
376it("searches the legacy projection when old environments decode to an empty links list", () => {
377 const linkedPullRequest = {
378 projectId: ProjectId.make("project"),
379 repository: "pingdotgg/t3code",
380 number: 12,
381 url: "https://github.com/pingdotgg/t3code/pull/12",
382 };
383 expect(threadPullRequestSearchTerms({ pullRequests: [], linkedPullRequest })).toContain("#12");
384 expect(
385 threadPullRequestSearchTerms({ pullRequests: [link(34)], linkedPullRequest }),
386 ).not.toContain("#12");
387});
packages/shared/src/threadPullRequests.ts+17
......@@ -269,3 +269,20 @@ export function resolveThreadPullRequestBadge(
269269 }
270270 return { kind: "pull-request", others: visible.length - 1 };
271271}
272
273/** Search terms for visible PR links, including the legacy single-link projection. */
274export function threadPullRequestSearchTerms(thread: {
275 readonly pullRequests?: ReadonlyArray<ThreadPullRequestLink> | undefined;
276 readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined;
277}): string[] {
278 if (thread.pullRequests !== undefined && thread.pullRequests.length > 0) {
279 return visibleThreadPullRequests(thread.pullRequests).flatMap((link) => [
280 `#${link.number}`,
281 `${link.repository}#${link.number}`,
282 link.url,
283 link.snapshot?.title ?? "",
284 ]);
285 }
286 const legacy = thread.linkedPullRequest;
287 return legacy ? [`#${legacy.number}`, `${legacy.repository}#${legacy.number}`, legacy.url] : [];
288}