diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index d7a9d38ebe1e..895c73bbe266 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -1,3 +1,4 @@ +import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import { buildProjectGroups, derivePhysicalProjectKey, @@ -311,6 +312,9 @@ export function buildHomeThreadGroups(input: { : group.threads.filter( (thread) => thread.title.toLocaleLowerCase().includes(query) || + threadPullRequestSearchTerms(thread).some((term) => + term.toLocaleLowerCase().includes(query), + ) || input.matchedThreadKeys?.has( threadSearchMatchKey({ environmentId: thread.environmentId, diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 3629e63df462..e1cb8e9ece7f 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -1,3 +1,4 @@ +import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import { effectiveSnoozed, hasQueuedTurnStart, @@ -400,6 +401,9 @@ export function buildThreadListV2Items(input: { if ( query.length > 0 && !thread.title.toLocaleLowerCase().includes(query) && + !threadPullRequestSearchTerms(thread).some((term) => + term.toLocaleLowerCase().includes(query), + ) && input.matchedThreadKeys?.has( threadSearchMatchKey({ environmentId: thread.environmentId, diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 9ea4efbaad3a..e0f2dc23b523 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -508,3 +508,45 @@ describe("filterPinnedBrowseEntries", () => { }); }); }); + +it.each([ + "#10839", + "10839", + "pingdotgg/t3code#10839", + "https://github.com/pingdotgg/t3code/pull/10839", +])("finds linked threads from PR query %s", (query) => { + const items = buildThreadActionItems({ + threads: [ + makeThread({ + title: "Implementation", + pullRequests: [ + { + host: "github.com", + repository: "pingdotgg/t3code", + number: 10839, + url: "https://github.com/pingdotgg/t3code/pull/10839", + source: "manual", + linkedAt: "2026-09-08T00:00:00Z", + snapshot: null, + stack: null, + }, + ], + }), + makeThread({ id: ThreadId.make("unrelated"), title: "Other work" }), + ], + projectTitleById: new Map(), + sortOrder: "updated_at", + icon: null, + runThread: async () => undefined, + }); + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query, + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: items, + }); + expect(groups.flatMap((group) => group.items.map((item) => item.title))).toEqual([ + "Implementation", + ]); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index e91de65d5ca9..8d26e603c14f 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -1,3 +1,4 @@ +import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import { type FilesystemBrowseEntry, type KeybindingCommand, @@ -186,6 +187,7 @@ export type BuildThreadActionItemsThread = Pick< | "title" | "worktreePath" > & { + pullRequests?: SidebarThreadSummary["pullRequests"]; updatedAt: string; latestUserMessageAt?: string | null; }; @@ -240,6 +242,7 @@ export function buildThreadActionItems { }); }); -describe("searchSidebarThreadsByTitle", () => { +describe("searchSidebarThreads", () => { const threads = [ { id: "thread-1", title: "Fix workspace search", project: "Alpha" }, { id: "thread-2", title: "Review providers", project: "Workspace" }, @@ -810,15 +810,15 @@ describe("searchSidebarThreadsByTitle", () => { ]; it("matches thread titles case-insensitively and preserves their order", () => { - expect(searchSidebarThreadsByTitle(threads, "work")).toEqual([threads[0], threads[2]]); + expect(searchSidebarThreads(threads, "work")).toEqual([threads[0], threads[2]]); }); it("does not match project metadata", () => { - expect(searchSidebarThreadsByTitle(threads, "workspace")).toEqual([threads[0]]); + expect(searchSidebarThreads(threads, "workspace")).toEqual([threads[0]]); }); it("returns no results for an empty query", () => { - expect(searchSidebarThreadsByTitle(threads, " ")).toEqual([]); + expect(searchSidebarThreads(threads, " ")).toEqual([]); }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 9b59675e2eb1..0150eb473d51 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,3 +1,4 @@ +import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import * as React from "react"; import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; import { @@ -875,17 +876,20 @@ export { pinOrderKeyBetween, planPinnedReorder } from "@t3tools/client-runtime/s export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort"; /** - * Search the already-ordered sidebar thread collection by title only. + * Search the already-ordered sidebar thread collection by title or linked PR. * Keeping the input order means lifecycle ordering (active, snoozed, settled) * remains stable while the user narrows the list. */ -export function searchSidebarThreadsByTitle( - threads: readonly T[], - query: string, -): T[] { +export function searchSidebarThreads< + T extends { readonly title: string } & Parameters[0], +>(threads: readonly T[], query: string): T[] { const normalizedQuery = query.trim().toLowerCase(); if (normalizedQuery.length === 0) return []; - return threads.filter((thread) => thread.title.toLowerCase().includes(normalizedQuery)); + return threads.filter((thread) => + [thread.title, ...threadPullRequestSearchTerms(thread)].some((term) => + term.toLowerCase().includes(normalizedQuery), + ), + ); } export function filterSidebarProjectScopeItems(input: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 31730da523be..af6381a256b8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -166,7 +166,7 @@ import { resolveSidebarDropVerb, type SidebarDropVerb, resolveSidebarThreadStatus, - searchSidebarThreadsByTitle, + searchSidebarThreads, shouldCreateNewThreadInCurrentProject, shouldRecedeSidebarThread, resolveWorkingStartedAt, @@ -2675,7 +2675,7 @@ export default function Sidebar() { [activeThreads, pinnedThreads, settledThreads, snoozedThreads], ); const threadSearchResults = useMemo( - () => searchSidebarThreadsByTitle(searchableThreads, threadSearchQuery), + () => searchSidebarThreads(searchableThreads, threadSearchQuery), [searchableThreads, threadSearchQuery], ); const threadSearchResultOrderKey = threadSearchResults @@ -4407,7 +4407,7 @@ export default function Sidebar() { setActiveSearchResultIndex(0); }} onKeyDown={handleThreadSearchKeyDown} - placeholder="Search" + placeholder="Search threads or PRs" aria-label="Search threads" role="combobox" aria-autocomplete="list" diff --git a/packages/shared/src/threadPullRequests.test.ts b/packages/shared/src/threadPullRequests.test.ts index 20ac5aeea440..7369b526dad3 100644 --- a/packages/shared/src/threadPullRequests.test.ts +++ b/packages/shared/src/threadPullRequests.test.ts @@ -1,9 +1,14 @@ -import type { ThreadPullRequestLink, ThreadPullRequestSnapshot } from "@t3tools/contracts"; +import { + ProjectId, + type ThreadPullRequestLink, + type ThreadPullRequestSnapshot, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { legacyLinkedPullRequestOf, legacyThreadPullRequestKey, + threadPullRequestSearchTerms, resolveThreadCurrentPullRequest, resolveThreadPullRequestChains, resolveThreadPullRequestBadge, @@ -349,3 +354,34 @@ describe("chain selection and badge state", () => { ); }); }); + +describe("threadPullRequestSearchTerms", () => { + it("includes completed and unsynced links but excludes dismissed links", () => { + const terms = threadPullRequestSearchTerms({ + pullRequests: [ + link(12, { snapshot: snapshot({ title: "Fix login", state: "merged" }) }), + link(34), + link(56, { source: "stack-dismissed" }), + ], + }); + expect(terms).toContain("#12"); + expect(terms).toContain("pingdotgg/t3code#12"); + expect(terms).toContain("https://github.com/pingdotgg/t3code/pull/12"); + expect(terms).toContain("Fix login"); + expect(terms).toContain("#34"); + expect(terms.join(" ")).not.toContain("56"); + }); +}); + +it("searches the legacy projection when old environments decode to an empty links list", () => { + const linkedPullRequest = { + projectId: ProjectId.make("project"), + repository: "pingdotgg/t3code", + number: 12, + url: "https://github.com/pingdotgg/t3code/pull/12", + }; + expect(threadPullRequestSearchTerms({ pullRequests: [], linkedPullRequest })).toContain("#12"); + expect( + threadPullRequestSearchTerms({ pullRequests: [link(34)], linkedPullRequest }), + ).not.toContain("#12"); +}); diff --git a/packages/shared/src/threadPullRequests.ts b/packages/shared/src/threadPullRequests.ts index 60eb3a2033e7..424433b3457c 100644 --- a/packages/shared/src/threadPullRequests.ts +++ b/packages/shared/src/threadPullRequests.ts @@ -269,3 +269,20 @@ export function resolveThreadPullRequestBadge( } return { kind: "pull-request", others: visible.length - 1 }; } + +/** Search terms for visible PR links, including the legacy single-link projection. */ +export function threadPullRequestSearchTerms(thread: { + readonly pullRequests?: ReadonlyArray | undefined; + readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; +}): string[] { + 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 ?? "", + ]); + } + const legacy = thread.linkedPullRequest; + return legacy ? [`#${legacy.number}`, `${legacy.repository}#${legacy.number}`, legacy.url] : []; +}