diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index d968a6cc6d4..047537ffed4 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -1,6 +1,7 @@ import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { threadMatchesAttributeQuery } from "@t3tools/shared/threadAttributeSearch"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -240,7 +241,20 @@ export function buildThreadListV2Items(input: { if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) { continue; } - if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue; + if ( + query.length > 0 && + !threadMatchesAttributeQuery( + { + title: thread.title, + branch: thread.branch, + originSource: thread.originSource ?? null, + participantSummaries: thread.participantSummaries ?? [], + }, + query, + ) + ) { + continue; + } const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; const changeRequestState = diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 83ae678e2f6..4968d7208e8 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -142,6 +142,54 @@ describe("buildThreadActionItems", () => { ]); }); + it("matches identity handles, PR numbers, and Jira keys in thread search", () => { + const threadItems = buildThreadActionItems({ + threads: [ + makeThread({ + id: ThreadId.make("thread-attributed"), + title: "Harden claim gate SA-49", + branch: "pr/9001-claim-gate", + originSource: { + channel: "desktop", + personId: "patroza", + username: "patroza", + location: { issueKey: "SA-49", number: 9001, kind: "pr" }, + }, + participantSummaries: [ + { + personId: "patroza", + username: "patroza", + firstChannel: "desktop", + firstParticipatedAt: "2026-03-20T00:00:00.000Z", + }, + ], + }), + makeThread({ + id: ThreadId.make("thread-other"), + title: "Unrelated cleanup", + }), + ], + projectTitleById: new Map([[PROJECT_ID, "Project"]]), + sortOrder: "updated_at", + icon: null, + runThread: async (_thread) => undefined, + }); + + for (const query of ["@patroza", "patroza@desktop", "@desktop", "#9001", "SA-49"]) { + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query, + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: threadItems, + }); + expect( + groups[0]?.items.map((item) => item.value), + query, + ).toEqual(["thread:thread-attributed"]); + } + }); + it("preserves thread project-name matches when there is no stronger title match", () => { const group: CommandPaletteGroup = { value: "threads-search", diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 9ab53c5e129..15968db3f4e 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -4,6 +4,7 @@ import { THREAD_JUMP_KEYBINDING_COMMANDS, } from "@t3tools/contracts"; import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import { buildThreadAttributeSearchTerms } from "@t3tools/shared/threadAttributeSearch"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; import { type ReactNode } from "react"; @@ -130,7 +131,15 @@ export function buildProjectActionItems(input: { export type BuildThreadActionItemsThread = Pick< SidebarThreadSummary, - "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title" + | "archivedAt" + | "branch" + | "createdAt" + | "environmentId" + | "id" + | "projectId" + | "title" + | "originSource" + | "participantSummaries" > & { updatedAt: string; latestUserMessageAt?: string | null; @@ -181,11 +190,20 @@ export function buildThreadActionItems { + it("includes identity handles and channels", () => { + const terms = buildThreadAttributeSearchTerms(sample); + expect(terms).toEqual( + expect.arrayContaining([ + "patroza", + "@patroza", + "patroza@discord", + "@discord", + "discord", + "julius", + "@julius", + "julius@desktop", + "@desktop", + "desktop", + "patrick roza", + ]), + ); + }); + + it("includes PR and Jira tokens", () => { + const terms = buildThreadAttributeSearchTerms(sample); + expect(terms).toEqual( + expect.arrayContaining(["#4521", "4521", "pr/4521", "pr-4521", "sa-123"]), + ); + }); + + it("includes title and branch", () => { + const terms = buildThreadAttributeSearchTerms(sample); + expect(terms).toEqual( + expect.arrayContaining(["fix gate sa-123 for multi-user claims", "pr/4521-identity-search"]), + ); + }); +}); + +describe("threadMatchesAttributeQuery", () => { + it.each([ + ["@patroza"], + ["patroza@discord"], + ["@desktop"], + ["#4521"], + ["4521"], + ["SA-123"], + ["sa-123"], + ["julius"], + ["multi-user"], + ])("matches %s", (query) => { + expect(threadMatchesAttributeQuery(sample, query)).toBe(true); + }); + + it("rejects unrelated queries", () => { + expect(threadMatchesAttributeQuery(sample, "@theo")).toBe(false); + expect(threadMatchesAttributeQuery(sample, "#9999")).toBe(false); + expect(threadMatchesAttributeQuery(sample, "ZZ-1")).toBe(false); + }); + + it("empty query matches all", () => { + expect(threadMatchesAttributeQuery(sample, " ")).toBe(true); + }); +}); + +describe("threadAttributeSearchMatches", () => { + it("matches partial username prefixes", () => { + const terms = buildThreadAttributeSearchTerms(sample); + expect(threadAttributeSearchMatches(terms, "@patr")).toBe(true); + }); +}); diff --git a/packages/shared/src/threadAttributeSearch.ts b/packages/shared/src/threadAttributeSearch.ts new file mode 100644 index 00000000000..bb6a7df30b3 --- /dev/null +++ b/packages/shared/src/threadAttributeSearch.ts @@ -0,0 +1,208 @@ +/** + * Search terms and match helpers for thread attributes beyond title/branch: + * identity handles (`@user`, `user@channel`, `@channel`), PR numbers (`#123`), + * and Jira keys (`SA-123`). + * + * Pure string helpers for web/mobile command palette and list filters. + * See docs/architecture/source-and-identity.md + */ + +export type ThreadAttributeSourceLike = { + readonly channel?: string | null | undefined; + readonly personId?: string | null | undefined; + readonly username?: string | null | undefined; + readonly location?: + | { + readonly number?: number | null | undefined; + readonly issueKey?: string | null | undefined; + readonly kind?: string | null | undefined; + } + | null + | undefined; +}; + +export type ThreadAttributeParticipantLike = { + readonly personId?: string | null | undefined; + readonly username?: string | null | undefined; + readonly name?: string | null | undefined; + readonly firstChannel?: string | null | undefined; +}; + +export type ThreadAttributeSearchInput = { + readonly title?: string | null | undefined; + readonly branch?: string | null | undefined; + readonly originSource?: ThreadAttributeSourceLike | null | undefined; + readonly participantSummaries?: ReadonlyArray | null | undefined; + /** Additional free-form terms (project title, etc.). */ + readonly extraTerms?: ReadonlyArray | null | undefined; +}; + +/** Jira-style issue keys: PROJ-123, SA-49, … */ +const JIRA_KEY_PATTERN = /\b([A-Za-z][A-Za-z0-9]+-\d+)\b/g; +/** Explicit PR markers in free text / branch names. */ +const PR_HASH_PATTERN = /#(\d+)\b/g; +const PR_SLUG_PATTERN = /\b(?:pr|pull)[-_\/]?(\d+)\b/gi; + +function addTerm(into: Set, raw: string | null | undefined): void { + if (raw === null || raw === undefined) return; + const trimmed = raw.trim().toLowerCase(); + if (trimmed.length === 0) return; + into.add(trimmed); +} + +function addPersonTerms( + into: Set, + person: { + readonly username?: string | null | undefined; + readonly personId?: string | null | undefined; + readonly name?: string | null | undefined; + readonly channel?: string | null | undefined; + }, +): void { + const username = person.username?.trim().toLowerCase() ?? ""; + const personId = person.personId?.trim().toLowerCase() ?? ""; + const channel = person.channel?.trim().toLowerCase() ?? ""; + const name = person.name?.trim().toLowerCase() ?? ""; + + if (username.length > 0) { + addTerm(into, username); + addTerm(into, `@${username}`); + if (channel.length > 0) { + addTerm(into, `${username}@${channel}`); + } + } + if (personId.length > 0 && personId !== username) { + addTerm(into, personId); + addTerm(into, `@${personId}`); + if (channel.length > 0) { + addTerm(into, `${personId}@${channel}`); + } + } + if (name.length > 0) { + addTerm(into, name); + } +} + +function addChannelTerms(into: Set, channel: string | null | undefined): void { + const normalized = channel?.trim().toLowerCase() ?? ""; + if (normalized.length === 0) return; + addTerm(into, normalized); + addTerm(into, `@${normalized}`); +} + +function addPrNumber(into: Set, value: number | string): void { + const digits = String(value).replace(/\D/g, ""); + if (digits.length === 0) return; + addTerm(into, digits); + addTerm(into, `#${digits}`); + addTerm(into, `pr-${digits}`); + addTerm(into, `pr/${digits}`); +} + +function extractFromText(into: Set, text: string | null | undefined): void { + if (text === null || text === undefined || text.trim().length === 0) return; + const source = text; + + for (const match of source.matchAll(JIRA_KEY_PATTERN)) { + const key = match[1]; + if (key !== undefined) addTerm(into, key); + } + for (const match of source.matchAll(PR_HASH_PATTERN)) { + const n = match[1]; + if (n !== undefined) addPrNumber(into, n); + } + for (const match of source.matchAll(PR_SLUG_PATTERN)) { + const n = match[1]; + if (n !== undefined) addPrNumber(into, n); + } +} + +/** + * Build a deduped, lowercased bag of search terms for a thread. + * Suitable for command-palette `searchTerms` and list filters. + */ +export function buildThreadAttributeSearchTerms( + input: ThreadAttributeSearchInput, +): ReadonlyArray { + const terms = new Set(); + + addTerm(terms, input.title); + addTerm(terms, input.branch); + if (input.branch !== null && input.branch !== undefined && input.branch.trim().length > 0) { + addTerm(terms, `#${input.branch.trim()}`); + } + + extractFromText(terms, input.title); + extractFromText(terms, input.branch); + + const origin = input.originSource ?? null; + if (origin !== null) { + addChannelTerms(terms, origin.channel); + addPersonTerms(terms, { + username: origin.username, + personId: origin.personId, + channel: origin.channel, + }); + if (origin.location?.number !== undefined && origin.location.number !== null) { + addPrNumber(terms, origin.location.number); + } + if (origin.location?.issueKey) { + addTerm(terms, origin.location.issueKey); + } + } + + for (const participant of input.participantSummaries ?? []) { + addPersonTerms(terms, { + username: participant.username, + personId: participant.personId, + name: participant.name, + channel: participant.firstChannel, + }); + addChannelTerms(terms, participant.firstChannel); + } + + for (const extra of input.extraTerms ?? []) { + addTerm(terms, extra); + extractFromText(terms, extra); + } + + return [...terms]; +} + +/** + * Whether any search term matches the query (substring, case-insensitive). + * Query is normalized the same way as terms (trim + lower). + */ +export function threadAttributeSearchMatches(terms: ReadonlyArray, query: string): boolean { + const normalizedQuery = query.trim().toLowerCase().replace(/\s+/g, " "); + if (normalizedQuery.length === 0) return true; + if (terms.length === 0) return false; + + // Direct term substring (covers @user, user@channel, #123, sa-123, title words). + for (const term of terms) { + if (term.includes(normalizedQuery) || normalizedQuery.includes(term)) { + // Prefer: query is a prefix/substring of a term (user typed partial handle). + if (term.includes(normalizedQuery)) return true; + } + } + + // Joined haystack for multi-word title queries. + const haystack = terms.join(" "); + if (haystack.includes(normalizedQuery)) return true; + + // `#42` vs bare `42` already both in terms when PR-linked. + // `@desktop` is stored as both `desktop` and `@desktop`. + return false; +} + +/** + * Convenience: build terms and match in one call. + */ +export function threadMatchesAttributeQuery( + input: ThreadAttributeSearchInput, + query: string, +): boolean { + const normalizedQuery = query.trim(); + if (normalizedQuery.length === 0) return true; + return threadAttributeSearchMatches(buildThreadAttributeSearchTerms(input), normalizedQuery); +}