Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 =
Expand Down
48 changes: 48 additions & 0 deletions apps/web/src/components/CommandPalette.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 20 additions & 2 deletions apps/web/src/components/CommandPalette.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -181,11 +190,20 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr
const leadingContent = input.renderLeadingContent?.(thread);
const trailingContent = input.renderTrailingContent?.(thread);

const attributeTerms = buildThreadAttributeSearchTerms({
title: thread.title,
branch: thread.branch,
originSource: thread.originSource ?? null,
participantSummaries: thread.participantSummaries ?? [],
extraTerms: [projectTitle],
});

return Object.assign(
{
kind: "action" as const,
value: `thread:${thread.id}`,
searchTerms: [thread.title, projectTitle ?? ``, thread.branch ?? ``],
// Title/project first so rankCommandPaletteItemMatch still prefers title hits.
searchTerms: [thread.title, projectTitle ?? ``, thread.branch ?? ``, ...attributeTerms],
title: thread.title,
description: descriptionParts.join(` · `),
timestamp: formatRelativeTimeLabel(
Expand Down
14 changes: 14 additions & 0 deletions docs/architecture/source-and-identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,20 @@ Goal: reduce mis-clicks on the wrong person when the map is large; still **close

Sidebar / command palette: Mine | Theirs; Starter | Participant; source chips.

### Thread attribute search

Command palette + mobile list search index more than title:

| Query | Matches |
| ----------------- | ---------------------------------------------------------------- |
| `@patroza` | participant / origin username or personId |
| `patroza@desktop` | person@firstChannel / origin handle |
| `@desktop` | origin or participant channel |
| `#123` / `pr/123` | PR number from branch, title, or `originSource.location.number` |
| `SA-123` | Jira key from title, branch, or `originSource.location.issueKey` |

Implementation: `@t3tools/shared/threadAttributeSearch` builds lowercased term bags; clients reuse the existing substring filter. Terms are empty until source stamping (PR3) fills shell fields — title/branch/Jira-in-title still work immediately.

## API sketch (contracts)

New module `packages/contracts/src/identity.ts` (see initial draft in repo):
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@
"types": "./src/identityMap.ts",
"import": "./src/identityMap.ts"
},
"./threadAttributeSearch": {
"types": "./src/threadAttributeSearch.ts",
"import": "./src/threadAttributeSearch.ts"
},
"./projectScripts": {
"types": "./src/projectScripts.ts",
"import": "./src/projectScripts.ts"
Expand Down
104 changes: 104 additions & 0 deletions packages/shared/src/threadAttributeSearch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vite-plus/test";

import {
buildThreadAttributeSearchTerms,
threadAttributeSearchMatches,
threadMatchesAttributeQuery,
} from "./threadAttributeSearch.ts";

const sample = {
title: "Fix gate SA-123 for multi-user claims",
branch: "pr/4521-identity-search",
originSource: {
channel: "discord" as const,
personId: "patroza",
username: "patroza",
location: {
issueKey: "SA-123",
number: 4521,
kind: "pr" as const,
},
},
participantSummaries: [
{
personId: "patroza",
username: "patroza",
name: "Patrick Roza",
firstChannel: "discord" as const,
},
{
personId: "julius",
username: "julius",
firstChannel: "desktop" as const,
},
],
extraTerms: ["t3-code"],
};

describe("buildThreadAttributeSearchTerms", () => {
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);
});
});
Loading