Skip to content
Open
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
5 changes: 5 additions & 0 deletions plugins/web-ui/src/contexts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}}
Expand Down
14 changes: 12 additions & 2 deletions src/directory/directory-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ function pickMatch<T>(
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" };
Expand All @@ -92,8 +93,15 @@ function pickMatch<T>(
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) };
Expand Down Expand Up @@ -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 };
Expand All @@ -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 };
Expand Down
26 changes: 18 additions & 8 deletions src/directory/postgres-directory-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ export function createPostgresDirectoryStore(connectionString: string): Director
labelLcCol: string,
cols: string,
map: (r: Record<string, unknown>) => T,
matchIds: boolean,
): Promise<{ kind: "one"; item: T } | { kind: "ambiguous"; items: T[] } | { kind: "none" }> {
const qn = normDirectoryQuery(query);
if (!qn) return { kind: "none" };
Expand All @@ -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<readonly [string, string]> = [
[labelLcCol, `${esc}%`],
[labelLcCol, `%${esc}%`],
...(matchIds
? [
[`lower(${idCol})`, `${esc}%`] as const,
[`lower(${idCol})`, `%${esc}%`] as const,
]
: []),
];
let pool2: Record<string, unknown>[] = [];
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]!) };
Expand Down Expand Up @@ -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" };
Expand All @@ -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 };
Expand Down
40 changes: 40 additions & 0 deletions test/directory-resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions test/directory-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)", () => {
Expand Down