| 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,3 +1,4 @@ |
| 1 | import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; | ||
| 1 | import { | 2 | import { |
| 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 @@ |
| 1 | import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; | ||
| 1 | import { | 2 | import { |
| 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 | |||
| 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,3 +1,4 @@ |
| 1 | import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; | ||
| 1 | import { | 2 | import { |
| 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 | }); |
| 804 | 804 | ||
| 805 | describe("searchSidebarThreadsByTitle", () => { | 805 | describe("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 | ]; |
| 811 | 811 | ||
| 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 | }); |
| 815 | 815 | ||
| 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 | }); |
| 819 | 819 | ||
| 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 | }); |
| 824 | 824 |
apps/web/src/components/Sidebar.logic.ts+10-6| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; | ||
| 1 | import * as React from "react"; | 2 | import * as React from "react"; |
| 2 | import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; | 3 | import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; |
| 3 | import { | 4 | import { |
| ... | @@ -875,17 +876,20 @@ export { pinOrderKeyBetween, planPinnedReorder } from "@t3tools/client-runtime/s | ... | @@ -875,17 +876,20 @@ export { pinOrderKeyBetween, planPinnedReorder } from "@t3tools/client-runtime/s |
| 875 | export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort"; | 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 | * 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 | */ |
| 882 | export function searchSidebarThreadsByTitle<T extends { readonly title: string }>( | 883 | export 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 | } |
| 890 | 894 | ||
| 891 | export function filterSidebarProjectScopeItems<TItem extends { readonly value: string }>(input: { | 895 | export 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 = threadSearchResults | 2681 | 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 @@ |
| 1 | import type { ThreadPullRequestLink, ThreadPullRequestSnapshot } from "@t3tools/contracts"; | 1 | import { |
| 2 | ProjectId, | ||
| 3 | type ThreadPullRequestLink, | ||
| 4 | type ThreadPullRequestSnapshot, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | 6 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 7 | ||
| 4 | import { | 8 | import { |
| 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 | |||
| 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,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. */ | ||
| 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 | } |