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 @@...@@ -1,3 +1,4 @@
1import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests";
1import {2import {
2 buildProjectGroups,3 buildProjectGroups,
3 derivePhysicalProjectKey,4 derivePhysicalProjectKey,
...@@ -311,6 +312,9 @@ export function buildHomeThreadGroups(input: {...@@ -311,6 +312,9 @@ export function buildHomeThreadGroups(input: {
311 : group.threads.filter(312 : group.threads.filter(
312 (thread) =>313 (thread) =>
313 thread.title.toLocaleLowerCase().includes(query) ||314 thread.title.toLocaleLowerCase().includes(query) ||
315 threadPullRequestSearchTerms(thread).some((term) =>
316 term.toLocaleLowerCase().includes(query),
317 ) ||
314 input.matchedThreadKeys?.has(318 input.matchedThreadKeys?.has(
315 threadSearchMatchKey({319 threadSearchMatchKey({
316 environmentId: thread.environmentId,320 environmentId: thread.environmentId,
apps/mobile/src/features/threads/threadListV2.ts+4
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests";
1import {2import {
2 effectiveSnoozed,3 effectiveSnoozed,
3 hasQueuedTurnStart,4 hasQueuedTurnStart,
...@@ -400,6 +401,9 @@ export function buildThreadListV2Items(input: {...@@ -400,6 +401,9 @@ export function buildThreadListV2Items(input: {
400 if (401 if (
401 query.length > 0 &&402 query.length > 0 &&
402 !thread.title.toLocaleLowerCase().includes(query) &&403 !thread.title.toLocaleLowerCase().includes(query) &&
404 !threadPullRequestSearchTerms(thread).some((term) =>
405 term.toLocaleLowerCase().includes(query),
406 ) &&
403 input.matchedThreadKeys?.has(407 input.matchedThreadKeys?.has(
404 threadSearchMatchKey({408 threadSearchMatchKey({
405 environmentId: thread.environmentId,409 environmentId: thread.environmentId,
apps/web/src/components/CommandPalette.logic.test.ts+42
...@@ -508,3 +508,45 @@ describe("filterPinnedBrowseEntries", () => {...@@ -508,3 +508,45 @@ describe("filterPinnedBrowseEntries", () => {
508 });508 });
509 });509 });
510});510});
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 @@...@@ -1,3 +1,4 @@
1import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests";
1import {2import {
2 type FilesystemBrowseEntry,3 type FilesystemBrowseEntry,
3 type KeybindingCommand,4 type KeybindingCommand,
...@@ -186,6 +187,7 @@ export type BuildThreadActionItemsThread = Pick<...@@ -186,6 +187,7 @@ export type BuildThreadActionItemsThread = Pick<
186 | "title"187 | "title"
187 | "worktreePath"188 | "worktreePath"
188> & {189> & {
190 pullRequests?: SidebarThreadSummary["pullRequests"];
189 updatedAt: string;191 updatedAt: string;
190 latestUserMessageAt?: string | null;192 latestUserMessageAt?: string | null;
191};193};
...@@ -240,6 +242,7 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr...@@ -240,6 +242,7 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr
240 value: `thread:${thread.id}`,242 value: `thread:${thread.id}`,
241 searchTerms: [243 searchTerms: [
242 thread.title,244 thread.title,
245 ...threadPullRequestSearchTerms(thread),
243 projectTitle ?? ``,246 projectTitle ?? ``,
244 thread.branch ?? ``,247 thread.branch ?? ``,
245 contentMatch?.snippet ?? ``,248 contentMatch?.snippet ?? ``,
apps/web/src/components/Sidebar.logic.test.ts+5-5
...@@ -27,7 +27,7 @@ import {...@@ -27,7 +27,7 @@ import {
27 resolveSidebarThreadStatus,27 resolveSidebarThreadStatus,
28 resolveThreadStatusPill,28 resolveThreadStatusPill,
29 resolveWorkingStartedAt,29 resolveWorkingStartedAt,
30 searchSidebarThreadsByTitle,30 searchSidebarThreads,
31 formatWorkingDurationLabel,31 formatWorkingDurationLabel,
32 shouldClearThreadSelectionOnMouseDown,32 shouldClearThreadSelectionOnMouseDown,
33 shouldRecedeSidebarThread,33 shouldRecedeSidebarThread,
...@@ -802,7 +802,7 @@ describe("resolveSidebarThreadStatus", () => {...@@ -802,7 +802,7 @@ describe("resolveSidebarThreadStatus", () => {
802 });802 });
803});803});
804804
805describe("searchSidebarThreadsByTitle", () => {805describe("searchSidebarThreads", () => {
806 const threads = [806 const threads = [
807 { id: "thread-1", title: "Fix workspace search", project: "Alpha" },807 { id: "thread-1", title: "Fix workspace search", project: "Alpha" },
808 { id: "thread-2", title: "Review providers", project: "Workspace" },808 { id: "thread-2", title: "Review providers", project: "Workspace" },
...@@ -810,15 +810,15 @@ describe("searchSidebarThreadsByTitle", () => {...@@ -810,15 +810,15 @@ describe("searchSidebarThreadsByTitle", () => {
810 ];810 ];
811811
812 it("matches thread titles case-insensitively and preserves their order", () => {812 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]]);
814 });814 });
815815
816 it("does not match project metadata", () => {816 it("does not match project metadata", () => {
817 expect(searchSidebarThreadsByTitle(threads, "workspace")).toEqual([threads[0]]);817 expect(searchSidebarThreads(threads, "workspace")).toEqual([threads[0]]);
818 });818 });
819819
820 it("returns no results for an empty query", () => {820 it("returns no results for an empty query", () => {
821 expect(searchSidebarThreadsByTitle(threads, " ")).toEqual([]);821 expect(searchSidebarThreads(threads, " ")).toEqual([]);
822 });822 });
823});823});
824824
apps/web/src/components/Sidebar.logic.ts+10-6
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests";
1import * as React from "react";2import * as React from "react";
2import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable";3import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable";
3import {4import {
...@@ -875,17 +876,20 @@ export { pinOrderKeyBetween, planPinnedReorder } from "@t3tools/client-runtime/s...@@ -875,17 +876,20 @@ export { pinOrderKeyBetween, planPinnedReorder } from "@t3tools/client-runtime/s
875export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort";876export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort";
876877
877/**878/**
878 * Search the already-ordered sidebar thread collection by title only.879 * Search the already-ordered sidebar thread collection by title or linked PR.
879 * Keeping the input order means lifecycle ordering (active, snoozed, settled)880 * Keeping the input order means lifecycle ordering (active, snoozed, settled)
880 * remains stable while the user narrows the list.881 * remains stable while the user narrows the list.
881 */882 */
882export function searchSidebarThreadsByTitle<T extends { readonly title: string }>(883export function searchSidebarThreads<
883 threads: readonly T[],884 T extends { readonly title: string } & Parameters<typeof threadPullRequestSearchTerms>[0],
884 query: string,885>(threads: readonly T[], query: string): T[] {
885): T[] {
886 const normalizedQuery = query.trim().toLowerCase();886 const normalizedQuery = query.trim().toLowerCase();
887 if (normalizedQuery.length === 0) return [];887 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 );
889}893}
890894
891export function filterSidebarProjectScopeItems<TItem extends { readonly value: string }>(input: {895export function filterSidebarProjectScopeItems<TItem extends { readonly value: string }>(input: {
apps/web/src/components/Sidebar.tsx+3-3
...@@ -166,7 +166,7 @@ import {...@@ -166,7 +166,7 @@ import {
166 resolveSidebarDropVerb,166 resolveSidebarDropVerb,
167 type SidebarDropVerb,167 type SidebarDropVerb,
168 resolveSidebarThreadStatus,168 resolveSidebarThreadStatus,
169 searchSidebarThreadsByTitle,169 searchSidebarThreads,
170 shouldCreateNewThreadInCurrentProject,170 shouldCreateNewThreadInCurrentProject,
171 shouldRecedeSidebarThread,171 shouldRecedeSidebarThread,
172 resolveWorkingStartedAt,172 resolveWorkingStartedAt,
...@@ -2675,7 +2675,7 @@ export default function Sidebar() {...@@ -2675,7 +2675,7 @@ export default function Sidebar() {
2675 [activeThreads, pinnedThreads, settledThreads, snoozedThreads],2675 [activeThreads, pinnedThreads, settledThreads, snoozedThreads],
2676 );2676 );
2677 const threadSearchResults = useMemo(2677 const threadSearchResults = useMemo(
2678 () => searchSidebarThreadsByTitle(searchableThreads, threadSearchQuery),2678 () => searchSidebarThreads(searchableThreads, threadSearchQuery),
2679 [searchableThreads, threadSearchQuery],2679 [searchableThreads, threadSearchQuery],
2680 );2680 );
2681 const threadSearchResultOrderKey = threadSearchResults2681 const threadSearchResultOrderKey = threadSearchResults
...@@ -4407,7 +4407,7 @@ export default function Sidebar() {...@@ -4407,7 +4407,7 @@ export default function Sidebar() {
4407 setActiveSearchResultIndex(0);4407 setActiveSearchResultIndex(0);
4408 }}4408 }}
4409 onKeyDown={handleThreadSearchKeyDown}4409 onKeyDown={handleThreadSearchKeyDown}
4410 placeholder="Search"4410 placeholder="Search threads or PRs"
4411 aria-label="Search threads"4411 aria-label="Search threads"
4412 role="combobox"4412 role="combobox"
4413 aria-autocomplete="list"4413 aria-autocomplete="list"
packages/shared/src/threadPullRequests.test.ts+37-1
...@@ -1,9 +1,14 @@...@@ -1,9 +1,14 @@
1import type { ThreadPullRequestLink, ThreadPullRequestSnapshot } from "@t3tools/contracts";1import {
2 ProjectId,
3 type ThreadPullRequestLink,
4 type ThreadPullRequestSnapshot,
5} from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";6import { describe, expect, it } from "vite-plus/test";
37
4import {8import {
5 legacyLinkedPullRequestOf,9 legacyLinkedPullRequestOf,
6 legacyThreadPullRequestKey,10 legacyThreadPullRequestKey,
11 threadPullRequestSearchTerms,
7 resolveThreadCurrentPullRequest,12 resolveThreadCurrentPullRequest,
8 resolveThreadPullRequestChains,13 resolveThreadPullRequestChains,
9 resolveThreadPullRequestBadge,14 resolveThreadPullRequestBadge,
...@@ -349,3 +354,34 @@ describe("chain selection and badge state", () => {...@@ -349,3 +354,34 @@ describe("chain selection and badge state", () => {
349 );354 );
350 });355 });
351});356});
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(...@@ -269,3 +269,20 @@ export function resolveThreadPullRequestBadge(
269 }269 }
270 return { kind: "pull-request", others: visible.length - 1 };270 return { kind: "pull-request", others: visible.length - 1 };
271}271}
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}