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