diff --git a/plugins/web-ui/src/contexts.ts b/plugins/web-ui/src/contexts.ts index b3da5d1dc..44da7b7ed 100644 --- a/plugins/web-ui/src/contexts.ts +++ b/plugins/web-ui/src/contexts.ts @@ -797,6 +797,11 @@ function memberPicker(context: CoreContext): TemplateResult { .value=${contextsState.memberQuery} ?disabled=${contextsState.memberBusy} @input=${(event: InputEvent) => { + if (event.isComposing) return; + contextsState.memberQuery = (event.currentTarget as HTMLInputElement).value; + scheduleMemberSearch(context); + }} + @compositionend=${(event: CompositionEvent) => { contextsState.memberQuery = (event.currentTarget as HTMLInputElement).value; scheduleMemberSearch(context); }} diff --git a/src/directory/directory-store.ts b/src/directory/directory-store.ts index 8fe4b9bf2..f232e8f2e 100644 --- a/src/directory/directory-store.ts +++ b/src/directory/directory-store.ts @@ -84,6 +84,7 @@ function pickMatch( query: string, id: (t: T) => string, label: (t: T) => string, + matchIds: boolean, ): { kind: "one"; item: T } | { kind: "ambiguous"; items: T[] } | { kind: "none" } { const q = normDirectoryQuery(query); if (!q) return { kind: "none" }; @@ -92,8 +93,15 @@ function pickMatch( const exact = items.filter((t) => normDirectoryQuery(label(t)) === q); if (exact.length === 1) return { kind: "one", item: exact[0]! }; if (exact.length > 1) return { kind: "ambiguous", items: exact.slice(0, MAX_CANDIDATES) }; - const prefix = items.filter((t) => normDirectoryQuery(label(t)).startsWith(q)); - const pool = prefix.length ? prefix : items.filter((t) => normDirectoryQuery(label(t)).includes(q)); + const labelLc = (t: T) => normDirectoryQuery(label(t)); + const labelTiers = [ + items.filter((t) => labelLc(t).startsWith(q)), + items.filter((t) => labelLc(t).includes(q)), + ]; + const idTiers = matchIds + ? [items.filter((t) => id(t).toLowerCase().startsWith(q)), items.filter((t) => id(t).toLowerCase().includes(q))] + : []; + const pool = [...labelTiers, ...idTiers].find((tier) => tier.length > 0) ?? []; if (pool.length === 0) return { kind: "none" }; if (pool.length === 1) return { kind: "one", item: pool[0]! }; return { kind: "ambiguous", items: pool.slice(0, MAX_CANDIDATES) }; @@ -280,6 +288,7 @@ export function createDirectoryStore(): DirectoryStore { query, (x) => x.principalId, (x) => x.displayName, + true, ); if (m.kind === "one") return { kind: "one", member: m.item }; if (m.kind === "ambiguous") return { kind: "ambiguous", candidates: m.items }; @@ -291,6 +300,7 @@ export function createDirectoryStore(): DirectoryStore { query, (x) => x.channelId, (x) => x.name, + false, ); if (m.kind === "one") return { kind: "one", channel: m.item }; if (m.kind === "ambiguous") return { kind: "ambiguous", candidates: m.items }; diff --git a/src/directory/postgres-directory-store.ts b/src/directory/postgres-directory-store.ts index 563ef59e0..e0bbca6cf 100644 --- a/src/directory/postgres-directory-store.ts +++ b/src/directory/postgres-directory-store.ts @@ -179,6 +179,7 @@ export function createPostgresDirectoryStore(connectionString: string): Director labelLcCol: string, cols: string, map: (r: Record) => T, + matchIds: boolean, ): Promise<{ kind: "one"; item: T } | { kind: "ambiguous"; items: T[] } | { kind: "none" }> { const qn = normDirectoryQuery(query); if (!qn) return { kind: "none" }; @@ -195,15 +196,23 @@ export function createPostgresDirectoryStore(connectionString: string): Director if (exact.length > 1) return { kind: "ambiguous", items: exact.slice(0, MAX_CANDIDATES).map(map) }; const esc = likeEscape(qn); - let pool2 = await q( - `SELECT ${cols} ${from} AND ${labelLcCol} LIKE $2 ESCAPE '\\' ORDER BY ${labelLcCol}, ${idCol} LIMIT ${MAX_CANDIDATES + 1}`, - [orgId, `${esc}%`], - ); - if (!pool2.length) { + const tiers: Array = [ + [labelLcCol, `${esc}%`], + [labelLcCol, `%${esc}%`], + ...(matchIds + ? [ + [`lower(${idCol})`, `${esc}%`] as const, + [`lower(${idCol})`, `%${esc}%`] as const, + ] + : []), + ]; + let pool2: Record[] = []; + for (const [match, pattern] of tiers) { pool2 = await q( - `SELECT ${cols} ${from} AND ${labelLcCol} LIKE $2 ESCAPE '\\' ORDER BY ${labelLcCol}, ${idCol} LIMIT ${MAX_CANDIDATES + 1}`, - [orgId, `%${esc}%`], + `SELECT ${cols} ${from} AND ${match} LIKE $2 ESCAPE '\\' ORDER BY ${labelLcCol}, ${idCol} LIMIT ${MAX_CANDIDATES + 1}`, + [orgId, pattern], ); + if (pool2.length) break; } if (!pool2.length) return { kind: "none" }; if (pool2.length === 1) return { kind: "one", item: map(pool2[0]!) }; @@ -627,7 +636,7 @@ export function createPostgresDirectoryStore(connectionString: string): Director [orgId, query.trim()], ); if (bySlackId.length) return { kind: "one", member: memberRow(bySlackId[0]!) }; - const m = await pick(query, "directory_members", "principal_id", "display_name_lc", MEMBER_COLS, memberRow); + const m = await pick(query, "directory_members", "principal_id", "display_name_lc", MEMBER_COLS, memberRow, true); if (m.kind === "one") return { kind: "one", member: m.item }; if (m.kind === "ambiguous") return { kind: "ambiguous", candidates: m.items }; return { kind: "none" }; @@ -641,6 +650,7 @@ export function createPostgresDirectoryStore(connectionString: string): Director "name_lc", "channel_id, name, is_private, is_external", channelRow, + false, ); if (m.kind === "one") return { kind: "one", channel: m.item }; if (m.kind === "ambiguous") return { kind: "ambiguous", candidates: m.items }; diff --git a/test/directory-resolve.test.ts b/test/directory-resolve.test.ts index 371d1683f..5d1bfdc76 100644 --- a/test/directory-resolve.test.ts +++ b/test/directory-resolve.test.ts @@ -88,6 +88,46 @@ describe("GET /v1/directory/resolve (agent looks up a teammate's mention id)", a assert.ok(matches.every((m) => m.principalId)); }); + it("resolves an id substring to every matching member", async () => { + const res = await get("/v1/directory/resolve?q=acme.com"); + assert.equal(res.status, 200); + const { matches } = (await res.json()) as { matches: Array<{ principalId: string }> }; + assert.equal(matches.length, 4, "an id substring matches all four fixture members"); + }); + + it("resolves a unique email local-part prefix to a single match", async () => { + const res = await get("/v1/directory/resolve?q=carol@acm"); + assert.equal(res.status, 200); + const { matches } = (await res.json()) as { matches: Array<{ principalId: string }> }; + assert.equal(matches.length, 1); + assert.equal(matches[0]!.principalId, "carol@acme.com"); + }); + + it("keeps label-contains matches ahead of a lone id-prefix hit", async () => { + await built.app.upsertDirectory([ + { principalId: "carol@acme.com", displayName: "Carol Example", type: "internal", slackId: "U0CAROL" }, + { principalId: "alice@acme.com", displayName: "Alice", type: "internal", slackId: "U0ALICE" }, + { principalId: "jordan@acme.com", displayName: "Jordan", type: "internal", slackId: "U0JORDAN" }, + { principalId: "joan@acme.com", displayName: "Joan", type: "internal", slackId: "U0JOAN" }, + { principalId: "asmith@acme.com", displayName: "Alice Smith", type: "internal" }, + { principalId: "bsmith@acme.com", displayName: "Bob Smith", type: "internal" }, + { principalId: "smith.dave@acme.com", displayName: "Dave Jones", type: "internal" }, + ]); + const res = await get("/v1/directory/resolve?q=smith"); + await built.app.upsertDirectory([ + { principalId: "carol@acme.com", displayName: "Carol Example", type: "internal", slackId: "U0CAROL" }, + { principalId: "alice@acme.com", displayName: "Alice", type: "internal", slackId: "U0ALICE" }, + { principalId: "jordan@acme.com", displayName: "Jordan", type: "internal", slackId: "U0JORDAN" }, + { principalId: "joan@acme.com", displayName: "Joan", type: "internal", slackId: "U0JOAN" }, + ]); + assert.equal(res.status, 200); + const { matches } = (await res.json()) as { matches: Array<{ principalId: string }> }; + assert.ok(matches.length >= 2, "surname query stays ambiguous across the label-contains Smiths"); + const ids = matches.map((m) => m.principalId); + assert.ok(ids.includes("asmith@acme.com") && ids.includes("bsmith@acme.com")); + assert.ok(!ids.includes("smith.dave@acme.com"), "an id-prefix-only hit does not preempt label-contains matches"); + }); + it("returns an empty match set for an unknown name (agent falls back to plain text)", async () => { const res = await get("/v1/directory/resolve?q=nobody-here"); assert.equal(res.status, 200); diff --git a/test/directory-store.test.ts b/test/directory-store.test.ts index c2a2c39b5..7ab4dcd51 100644 --- a/test/directory-store.test.ts +++ b/test/directory-store.test.ts @@ -103,6 +103,10 @@ describe("channel resolution (agent → channel addressing, §10)", () => { it("returns none for an unknown channel", async () => { assert.equal((await (await dir()).resolveChannel("nonexistent")).kind, "none"); }); + + it("does not fall back to channel-id prefixes when no name matches", async () => { + assert.equal((await (await dir()).resolveChannel("c-e")).kind, "none"); + }); }); describe("member slackId (the real <@…> mention id for an email principal)", () => {