From 29e69e964b8dcc14483f4d8cd5743f37a63dd55d Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Tue, 2 Jun 2026 10:39:09 -0500 Subject: [PATCH] feat(invites): smart recipient autocomplete combobox (Task 3, done) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes Task-invite-recipient-autocomplete. The invite dialog's plain email input is replaced with a debounced combobox that surfaces the four real cases — existing member, pending invite, known user from a sibling workspace, brand-new email — before the inviter hits send. Subagent ran in parallel while the main thread shipped Task 2's UI; file-level non-overlap held (subagent stayed in apps/web/components/teams/invite-recipient-combobox.tsx and the shared types; main thread stayed in invite-dialog.tsx and the teams page). This commit folds the subagent's deliverable in plus the two-line wire-up that swaps the input for the combobox. Files (5 by subagent + 1 wire-up by main thread): @tasks/shared: * packages/shared/src/types/invite-suggestions.ts — InviteSuggestion union + pure mergeInviteSuggestions ranker. Lives in shared so client + server consume one type definition. * packages/shared/src/types/invite-suggestions.test.ts — 9 vitest cases covering kind ordering, dedupe (known_user vs member by userId, vs pending_invite by lowercased email), new_email suppression when other kinds cover the typed address, the 10- result limit, and email normalization. * packages/shared/src/types/index.ts — re-export. apps/web: * apps/web/server/routers/invites.ts — new `suggestRecipient` procedure on workspaceProcedure (owner/admin only). Implements the four kinds with the tenancy fence wired as a two-step query: first SELECT DISTINCT workspace_id FROM workspace_members WHERE user_id = inviter (the inviter's workspace pool), then inArray(workspaceMembers.workspaceId, pool) + ne(users.id, inviter) on the candidate join. Read the procedure JSDoc for the full set of invariants. All user-typed patterns escape through escapeIlike with the ESCAPE '\\' clause (mirrors search.ts). No existing exports modified. * apps/web/components/teams/invite-recipient-combobox.tsx — standalone controlled combobox. 200ms debounce, min-2-char gate, distinct row styling per kind, ArrowUp/Down/Enter/Esc keyboard nav, outside-click close. * apps/web/components/teams/invite-dialog.tsx (wire-up) — Input swapped for InviteRecipientCombobox. Added an onFocusExistingMember prop so a future teams-page integration can scroll/focus the matching row when a `member` suggestion is picked; for now the dialog just closes cleanly on member-pick. Gates: 0 lint errors / 15 warnings (14 baseline + 1 incidental from earlier teams-page work, none from this task's files); 6/6 type-check; 23/23 tests (14 baseline + 9 new). Acceptance criteria all met except the live-DB tenancy-fence integration test (skipped because apps/web has no vitest harness; unblocked by Task-bootstrap-vitest-for-apps-web P2). Co-authored-by: Cursor --- apps/web/components/teams/invite-dialog.tsx | 40 +- .../teams/invite-recipient-combobox.tsx | 349 ++++++++++++++++++ apps/web/server/routers/invites.ts | 222 ++++++++++- packages/shared/src/types/index.ts | 9 + .../src/types/invite-suggestions.test.ts | 164 ++++++++ .../shared/src/types/invite-suggestions.ts | 130 +++++++ .../Task-invite-recipient-autocomplete.md | 35 +- 7 files changed, 922 insertions(+), 27 deletions(-) create mode 100644 apps/web/components/teams/invite-recipient-combobox.tsx create mode 100644 packages/shared/src/types/invite-suggestions.test.ts create mode 100644 packages/shared/src/types/invite-suggestions.ts diff --git a/apps/web/components/teams/invite-dialog.tsx b/apps/web/components/teams/invite-dialog.tsx index 7db637d..b054097 100644 --- a/apps/web/components/teams/invite-dialog.tsx +++ b/apps/web/components/teams/invite-dialog.tsx @@ -13,6 +13,7 @@ import { SheetTitle, SheetTrigger, } from "@/components/ui/sheet"; +import { InviteRecipientCombobox } from "@/components/teams/invite-recipient-combobox"; import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; @@ -22,6 +23,13 @@ interface InviteDialogProps { workspaceSlug: string; /** Children render as the trigger; default is a primary "Invite" button. */ children?: React.ReactNode; + /** + * Optional. Called when the inviter picks an "already a member" suggestion + * from the autocomplete. Lets the teams page close the dialog and scroll + * the existing row into view. If omitted, the combobox just closes its + * dropdown and the user has to clear the input manually. + */ + onFocusExistingMember?: (userId: string) => void; } /** @@ -29,11 +37,15 @@ interface InviteDialogProps { * accept URL on success so the inviter can copy/paste it into chat / DM * until the transactional email send is wired up (filed follow-up). * - * The email input is a plain `` in this task; the smart recipient - * autocomplete from `Task-invite-recipient-autocomplete` will swap it for - * a combobox in a separate commit. + * The email input is the `InviteRecipientCombobox` from Task 3 — typing + * surfaces existing members, pending invites, and known users from the + * inviter's other workspaces before the "Send invite to " fallback. */ -export function InviteDialog({ workspaceSlug, children }: InviteDialogProps) { +export function InviteDialog({ + workspaceSlug, + children, + onFocusExistingMember, +}: InviteDialogProps) { const utils = api.useUtils(); const [open, setOpen] = React.useState(false); const [email, setEmail] = React.useState(""); @@ -175,17 +187,21 @@ export function InviteDialog({ workspaceSlug, children }: InviteDialogProps) {
- setEmail(e.target.value)} - required + onChange={setEmail} + placeholder="alex@example.com" disabled={createMut.isPending} + onSelectSuggestion={(s) => { + if (s.kind === "member") { + setOpen(false); + onFocusExistingMember?.(s.userId); + } + }} />
diff --git a/apps/web/components/teams/invite-recipient-combobox.tsx b/apps/web/components/teams/invite-recipient-combobox.tsx new file mode 100644 index 0000000..aada459 --- /dev/null +++ b/apps/web/components/teams/invite-recipient-combobox.tsx @@ -0,0 +1,349 @@ +"use client"; + +import * as React from "react"; +import { Clock, Loader2, Mail, UserCheck, UserPlus } from "lucide-react"; + +import { Input } from "@/components/ui/input"; +import { api } from "@/lib/trpc"; +import { cn } from "@/lib/utils"; +import type { InviteSuggestion } from "@tasks/shared"; + +/** + * Standalone combobox for the invite recipient field. Fully controlled — + * the parent owns the raw email string via `value`/`onChange` and may + * react to a non-`new_email` pick (existing member, pending invite, + * known user from a sibling workspace) via `onSelectSuggestion`. + * + * Behavior: + * - Debounces the query 200ms before hitting `invites.suggestRecipient`. + * - Shows nothing for queries shorter than 2 characters. + * - Distinct row styling per `kind` so the inviter can tell at a glance + * whether they're about to send a fresh invite vs. re-discover someone + * who's already in the workspace. + * - Keyboard: ArrowUp/Down to highlight, Enter to pick, Esc to close. + * - For a `new_email` pick we just fill the input via `onChange` and let + * the parent submit the form. For every other kind we also call + * `onSelectSuggestion` so the parent can e.g. close the dialog and + * scroll to the matching member row. + */ +export interface InviteRecipientComboboxProps { + workspaceSlug: string; + value: string; + onChange: (email: string) => void; + /** + * Called when the user picks an existing-member / pending-invite / + * known-user row. NOT called for `new_email`; for that kind, only + * `onChange(suggestion.email)` runs. + */ + onSelectSuggestion?: (suggestion: InviteSuggestion) => void; + placeholder?: string; + disabled?: boolean; + /** Optional id so an external `