@proteos/ui 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/dist/index.cjs +1302 -516
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +259 -1
  4. package/dist/index.d.ts +259 -1
  5. package/dist/index.js +1292 -520
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/components/draft-message-composer/create-draft-message-client.ts +27 -0
  9. package/src/components/draft-message-composer/draft-message-actions-store.test.ts +62 -0
  10. package/src/components/draft-message-composer/draft-message-actions-store.ts +81 -0
  11. package/src/components/draft-message-composer/draft-message-composer.stories.tsx +175 -0
  12. package/src/components/draft-message-composer/draft-message-composer.test.tsx +276 -0
  13. package/src/components/draft-message-composer/draft-message-composer.tsx +470 -0
  14. package/src/components/draft-message-composer/draft-message-outcome-store.test.ts +59 -0
  15. package/src/components/draft-message-composer/draft-message-outcome-store.ts +56 -0
  16. package/src/components/draft-message-composer/index.ts +19 -0
  17. package/src/components/draft-message-composer/types.ts +32 -0
  18. package/src/components/message-composer/index.ts +13 -0
  19. package/src/components/message-composer/message-composer.stories.tsx +155 -0
  20. package/src/components/message-composer/message-composer.test.tsx +320 -0
  21. package/src/components/message-composer/message-composer.tsx +480 -0
  22. package/src/components/recipient-select/index.ts +2 -0
  23. package/src/components/recipient-select/recipient-select.test.tsx +139 -0
  24. package/src/components/recipient-select/recipient-select.tsx +94 -0
  25. package/src/index.ts +3 -0
@@ -0,0 +1,480 @@
1
+ import type { ContactAddress, SendRecipient } from '@proteos/sdk'
2
+ import { Paperclip, SendHorizontal } from 'lucide-react'
3
+ import { type KeyboardEvent, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
4
+ import { Button as AriaButton, FileTrigger } from 'react-aria-components'
5
+ import { tv } from 'tailwind-variants'
6
+
7
+ import { cn } from '../../lib/cn'
8
+ import { AttachmentChip } from '../attachment-chip/attachment-chip'
9
+ import { Button } from '../button/button'
10
+ import { RecipientSelect } from '../recipient-select/recipient-select'
11
+ import { Select } from '../select/select'
12
+
13
+ /* =========================================================================
14
+ MessageComposer — shared chrome (Field contract: rule border, inset
15
+ shadow, accent + halo on focus), channel-aware behavior:
16
+ chat : ↵ send, ⇧↵ newline
17
+ email : subject row, ⌘↵ send
18
+ The active binding is printed as a mono hint next to send. A failed send
19
+ NEVER clears the input. Disabled states replace the composer with honest
20
+ copy.
21
+
22
+ Fetch-agnostic: text + subject are controlled (`value`/`onValueChange` —
23
+ draft persistence lives with the host), the To/Cc/Bcc directory search is
24
+ injected (`searchRecipients`), and attachments are presentational — the
25
+ host owns its upload queue and maps it onto `ComposerAttachment`.
26
+ ========================================================================= */
27
+
28
+ /** Controlled text + subject. Draft persistence lives with the host. */
29
+ export interface ComposerValue {
30
+ text: string
31
+ subject?: string
32
+ }
33
+
34
+ export interface ComposerSendInput {
35
+ text: string
36
+ subject?: string
37
+ /** Only when the composer owns addressing (`showToField` — compose). */
38
+ to?: SendRecipient[]
39
+ cc?: SendRecipient[]
40
+ bcc?: SendRecipient[]
41
+ }
42
+
43
+ /** The compose view's connection switcher, rendered as the composer's top row. */
44
+ export interface ComposerConnectionPicker {
45
+ options: ReadonlyArray<{
46
+ id: string
47
+ /** Rich render (channel icon + name); `textValue` carries the plain name. */
48
+ label: ReactNode
49
+ textValue: string
50
+ }>
51
+ selectedId: string
52
+ onSelect: (connectionId: string) => void
53
+ }
54
+
55
+ /** Presentational attachment chip input — hosts map their own upload model onto it. */
56
+ export interface ComposerAttachment {
57
+ localId: string
58
+ name: string
59
+ /** Bytes when known — server-loaded attachments may not know their size. */
60
+ sizeInBytes?: number
61
+ contentType?: string
62
+ status: 'uploading' | 'ready' | 'error'
63
+ errorMessage?: string
64
+ }
65
+
66
+ /** Directory search backing the To/Cc/Bcc pickers — injected per host (the lib never fetches). */
67
+ export type SearchRecipients = (connectionId: string, q: string) => Promise<ContactAddress[]>
68
+
69
+ /** Per-file cap for conversation-channel sends (the backend rejects larger
70
+ * with `attachment_too_large`; Slack additionally caps at 10 files/message,
71
+ * surfaced through the send error line). */
72
+ export const MAX_COMPOSER_ATTACHMENT_BYTES = 25 * 1024 * 1024
73
+
74
+ /** Outer box chrome — exported so hosts can re-apply the same shell. */
75
+ export const messageComposerStyles = tv({
76
+ base: [
77
+ 'rounded-[10px] border border-rule bg-surface shadow-[var(--shadow-inset)]',
78
+ 'transition-[border-color,box-shadow] duration-150',
79
+ 'focus-within:border-accent focus-within:shadow-[var(--shadow-focus)]',
80
+ ],
81
+ })
82
+
83
+ /** Shared row-label chrome: fixed width so the Via/To/Cc/Bcc controls all
84
+ * start at the same x (Subject keeps its own natural label). */
85
+ const ROW_LABEL = 'w-7 shrink-0 font-mono text-[10px] uppercase tracking-[0.08em] text-ink-4'
86
+
87
+ export interface MessageComposerProps {
88
+ mode: 'chat' | 'email'
89
+ /** Controlled text+subject (draft persistence lives with the host). */
90
+ value: ComposerValue
91
+ onValueChange: (value: ComposerValue) => void
92
+ placeholder: string
93
+ /** Email replies arrive with `re: <subject>` prefilled. */
94
+ subjectPrefill?: string
95
+ /** When true (email mode), the Subject row renders the subject as static
96
+ * text — reply drafts where the subject is derived from the thread and
97
+ * must not be edited. The Cc/Bcc reveal stays functional. Default false. */
98
+ isSubjectReadOnly?: boolean
99
+ /** When set, the composer is replaced by this banner (ended conversation,
100
+ * read-only transcript). */
101
+ disabledReason?: ReactNode
102
+ onSend: (input: ComposerSendInput) => Promise<unknown>
103
+ /** Connection whose directory backs the To/Cc/Bcc pickers. */
104
+ connectionId?: string
105
+ /** Compose only: a connection switcher as the composer's top row. */
106
+ connectionPicker?: ComposerConnectionPicker
107
+ /** Compose only: the composer owns addressing — a To recipient row; the
108
+ * picks arrive on `ComposerSendInput.to` and gate send. */
109
+ showToField?: boolean
110
+ /** Email originate only: reveal Cc/Bcc recipient rows. */
111
+ showRecipientFields?: boolean
112
+ /** Required for the To/Cc/Bcc rows to render (with `connectionId`). */
113
+ searchRecipients?: SearchRecipients
114
+ /** Controlled-if-provided recipients; internal state otherwise. Internal
115
+ * recipient state resets when `connectionId` changes and clears after a
116
+ * successful send — controlled parents own their own resets. */
117
+ to?: SendRecipient[]
118
+ cc?: SendRecipient[]
119
+ bcc?: SendRecipient[]
120
+ onToChange?: (next: SendRecipient[]) => void
121
+ onCcChange?: (next: SendRecipient[]) => void
122
+ onBccChange?: (next: SendRecipient[]) => void
123
+ /** The host-owned pending queue, rendered as chips above the input. */
124
+ attachments?: ComposerAttachment[]
125
+ /** When set, the paperclip renders and picked files flow to the host. */
126
+ onAddFiles?: (files: File[]) => void
127
+ onRemoveAttachment?: (localId: string) => void
128
+ /** True while any attachment is still uploading — blocks send. */
129
+ hasUploadingAttachments?: boolean
130
+ /**
131
+ * Default true. When false: the send button AND the keybinding hint are
132
+ * hidden and the ↵/⌘↵ submit combo is disabled — submission is fully
133
+ * delegated to an external driver (e.g. the draft review surface).
134
+ */
135
+ showSendButton?: boolean
136
+ /** Composes onto the outer box chrome (`messageComposerStyles`). */
137
+ className?: string
138
+ }
139
+
140
+ export function MessageComposer({
141
+ mode,
142
+ value,
143
+ onValueChange,
144
+ placeholder,
145
+ subjectPrefill,
146
+ isSubjectReadOnly,
147
+ disabledReason,
148
+ onSend,
149
+ connectionId,
150
+ connectionPicker,
151
+ showToField,
152
+ showRecipientFields,
153
+ searchRecipients,
154
+ to: toProp,
155
+ cc: ccProp,
156
+ bcc: bccProp,
157
+ onToChange,
158
+ onCcChange,
159
+ onBccChange,
160
+ attachments,
161
+ onAddFiles,
162
+ onRemoveAttachment,
163
+ hasUploadingAttachments,
164
+ showSendButton,
165
+ className,
166
+ }: MessageComposerProps) {
167
+ const [isSending, setIsSending] = useState(false)
168
+ const [sendError, setSendError] = useState<string | null>(null)
169
+ const [internalTo, setInternalTo] = useState<SendRecipient[]>([])
170
+ const [internalCc, setInternalCc] = useState<SendRecipient[]>([])
171
+ const [internalBcc, setInternalBcc] = useState<SendRecipient[]>([])
172
+ const [showCcBcc, setShowCcBcc] = useState(false)
173
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
174
+
175
+ // Controlled-if-provided, per group: a provided prop wins over internal
176
+ // state; setters mirror into internal state only when uncontrolled.
177
+ const to = toProp ?? internalTo
178
+ const cc = ccProp ?? internalCc
179
+ const bcc = bccProp ?? internalBcc
180
+ const setTo = (next: SendRecipient[]) => {
181
+ if (toProp === undefined) setInternalTo(next)
182
+ onToChange?.(next)
183
+ }
184
+ const setCc = (next: SendRecipient[]) => {
185
+ if (ccProp === undefined) setInternalCc(next)
186
+ onCcChange?.(next)
187
+ }
188
+ const setBcc = (next: SendRecipient[]) => {
189
+ if (bccProp === undefined) setInternalBcc(next)
190
+ onBccChange?.(next)
191
+ }
192
+
193
+ // The recipient rows need a directory to search — both halves, or nothing.
194
+ const directory =
195
+ searchRecipients !== undefined && connectionId ? { searchRecipients, connectionId } : undefined
196
+ const canPickRecipients =
197
+ mode === 'email' && (showRecipientFields ?? false) && directory !== undefined
198
+ const needsTo = (showToField ?? false) && directory !== undefined
199
+ const isSendButtonShown = showSendButton ?? true
200
+
201
+ // INTERNAL recipients belong to one connection's directory — switching (the
202
+ // compose picker) drops them. Controlled parents own their own resets.
203
+ // biome-ignore lint/correctness/useExhaustiveDependencies: connectionId is the trigger — the pickers must reset exactly when the directory changes.
204
+ useEffect(() => {
205
+ setInternalTo([])
206
+ setInternalCc([])
207
+ setInternalBcc([])
208
+ }, [connectionId])
209
+
210
+ const subject = value.subject ?? subjectPrefill ?? ''
211
+ const readyCount = (attachments ?? []).filter(
212
+ (attachment) => attachment.status === 'ready',
213
+ ).length
214
+ const canSend =
215
+ (value.text.trim() !== '' || readyCount > 0) &&
216
+ (mode !== 'email' || subject.trim() !== '') &&
217
+ (!needsTo || to.length > 0) &&
218
+ !(hasUploadingAttachments ?? false)
219
+
220
+ const submit = useCallback(async () => {
221
+ if (!canSend || isSending) return
222
+ setIsSending(true)
223
+ setSendError(null)
224
+ try {
225
+ await onSend({
226
+ text: value.text.trim(),
227
+ ...(mode === 'email' ? { subject: subject.trim() } : {}),
228
+ ...(needsTo ? { to } : {}),
229
+ ...(cc.length > 0 ? { cc } : {}),
230
+ ...(bcc.length > 0 ? { bcc } : {}),
231
+ })
232
+ // Success: hand the cleared value back (subject key omitted) and drop
233
+ // INTERNAL recipients only — controlled recipients stay the parent's.
234
+ onValueChange({ text: '' })
235
+ setInternalTo([])
236
+ setInternalCc([])
237
+ setInternalBcc([])
238
+ } catch (error) {
239
+ // Never clear the input on failure — the text is the user's work.
240
+ setSendError(error instanceof Error ? error.message : 'Send failed')
241
+ } finally {
242
+ setIsSending(false)
243
+ }
244
+ }, [canSend, isSending, onSend, value.text, mode, subject, needsTo, to, cc, bcc, onValueChange])
245
+
246
+ if (disabledReason) {
247
+ return (
248
+ <div className="px-[18px] py-3">
249
+ <p
250
+ className={cn(
251
+ 'rounded-[10px] border border-rule bg-surface-2 px-3.5 py-2.5',
252
+ 'text-[12.5px] text-ink-3 text-center',
253
+ )}
254
+ >
255
+ {disabledReason}
256
+ </p>
257
+ </div>
258
+ )
259
+ }
260
+
261
+ const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
262
+ if (!isSendButtonShown) return
263
+ const isSendCombo =
264
+ mode === 'email'
265
+ ? event.key === 'Enter' && (event.metaKey || event.ctrlKey)
266
+ : event.key === 'Enter' && !event.shiftKey
267
+ if (isSendCombo) {
268
+ event.preventDefault()
269
+ void submit()
270
+ }
271
+ }
272
+
273
+ const ccBccReveal =
274
+ canPickRecipients && !showCcBcc ? (
275
+ <AriaButton
276
+ onPress={() => setShowCcBcc(true)}
277
+ className="shrink-0 font-mono text-[10px] uppercase tracking-[0.08em] text-ink-4 cursor-pointer outline-none hover:text-ink-3 focus-visible:shadow-[var(--shadow-focus)] rounded"
278
+ >
279
+ Cc/Bcc
280
+ </AriaButton>
281
+ ) : null
282
+
283
+ return (
284
+ <div className="px-[18px] pb-3.5 pt-1">
285
+ <div className={cn(messageComposerStyles(), className)}>
286
+ {connectionPicker ? (
287
+ <div className="flex items-center gap-2 border-b border-rule-2 px-3.5 py-1.5">
288
+ <span className={ROW_LABEL}>Via</span>
289
+ <Select
290
+ aria-label="Connection"
291
+ size="sm"
292
+ options={connectionPicker.options.map((option) => ({
293
+ id: option.id,
294
+ label: option.label,
295
+ textValue: option.textValue,
296
+ }))}
297
+ selectedKey={connectionPicker.selectedId}
298
+ onSelectionChange={(key) => connectionPicker.onSelect(String(key))}
299
+ triggerClassName="font-mono text-[11px]"
300
+ popoverClassName="min-w-[var(--trigger-width)] w-max max-w-80"
301
+ />
302
+ </div>
303
+ ) : null}
304
+
305
+ {mode === 'email' ? (
306
+ isSubjectReadOnly ? (
307
+ <div className="flex items-center gap-2 border-b border-rule-2 px-3.5 py-1.5">
308
+ <span className="font-mono text-[10px] uppercase tracking-[0.08em] text-ink-4">
309
+ Subject
310
+ </span>
311
+ <span className="flex-1 min-w-0 truncate text-[13px] text-ink">{subject}</span>
312
+ {ccBccReveal}
313
+ </div>
314
+ ) : (
315
+ <label className="flex items-center gap-2 border-b border-rule-2 px-3.5 py-1.5">
316
+ <span className="font-mono text-[10px] uppercase tracking-[0.08em] text-ink-4">
317
+ Subject
318
+ </span>
319
+ <input
320
+ value={subject}
321
+ onChange={(event) => onValueChange({ ...value, subject: event.target.value })}
322
+ placeholder="Subject"
323
+ className="flex-1 bg-transparent outline-none text-[13px] text-ink placeholder:text-ink-4"
324
+ />
325
+ {ccBccReveal}
326
+ </label>
327
+ )
328
+ ) : null}
329
+
330
+ {needsTo && directory ? (
331
+ <div className="flex items-center gap-2 border-b border-rule-2 px-3.5 py-1.5">
332
+ <span className={ROW_LABEL}>To</span>
333
+ <div className="flex-1 min-w-0">
334
+ <RecipientSelect
335
+ connectionId={directory.connectionId}
336
+ searchRecipients={directory.searchRecipients}
337
+ placeholder="Pick recipients…"
338
+ value={to}
339
+ onChange={setTo}
340
+ />
341
+ </div>
342
+ </div>
343
+ ) : null}
344
+
345
+ {canPickRecipients && directory && showCcBcc ? (
346
+ <>
347
+ <div className="flex items-center gap-2 border-b border-rule-2 px-3.5 py-1.5">
348
+ <span className={ROW_LABEL}>Cc</span>
349
+ <div className="flex-1 min-w-0">
350
+ <RecipientSelect
351
+ connectionId={directory.connectionId}
352
+ searchRecipients={directory.searchRecipients}
353
+ placeholder="Add cc…"
354
+ value={cc}
355
+ onChange={setCc}
356
+ />
357
+ </div>
358
+ </div>
359
+ <div className="flex items-center gap-2 border-b border-rule-2 px-3.5 py-1.5">
360
+ <span className={ROW_LABEL}>Bcc</span>
361
+ <div className="flex-1 min-w-0">
362
+ <RecipientSelect
363
+ connectionId={directory.connectionId}
364
+ searchRecipients={directory.searchRecipients}
365
+ placeholder="Add bcc…"
366
+ value={bcc}
367
+ onChange={setBcc}
368
+ />
369
+ </div>
370
+ </div>
371
+ </>
372
+ ) : null}
373
+
374
+ {attachments && attachments.length > 0 ? (
375
+ // Single horizontally-scrollable row so the chips never grow the composer tall.
376
+ <div className="flex gap-1.5 overflow-x-auto px-3.5 pt-2 pb-0.5">
377
+ {attachments.map((attachment) => (
378
+ <AttachmentChip
379
+ key={attachment.localId}
380
+ name={attachment.name}
381
+ status={attachment.status}
382
+ {...(attachment.sizeInBytes !== undefined
383
+ ? { sizeInBytes: attachment.sizeInBytes }
384
+ : {})}
385
+ {...(attachment.contentType ? { contentType: attachment.contentType } : {})}
386
+ {...(attachment.errorMessage ? { errorMessage: attachment.errorMessage } : {})}
387
+ {...(onRemoveAttachment
388
+ ? { onRemove: () => onRemoveAttachment(attachment.localId) }
389
+ : {})}
390
+ className="shrink-0"
391
+ />
392
+ ))}
393
+ </div>
394
+ ) : null}
395
+
396
+ {/* Stacked like Ask Proteos: the input is its own full-width row (typing
397
+ grows it upward), the actions sit in a row beneath — clip on the left,
398
+ send on the right — so the text always starts at the box's left edge. */}
399
+ <div className="flex flex-col gap-2 px-3.5 py-2">
400
+ <textarea
401
+ ref={textareaRef}
402
+ value={value.text}
403
+ placeholder={placeholder}
404
+ rows={1}
405
+ onChange={(event) => {
406
+ onValueChange({ ...value, text: event.target.value })
407
+ autoGrow(event.currentTarget)
408
+ }}
409
+ onKeyDown={onKeyDown}
410
+ className={cn(
411
+ 'w-full resize-none bg-transparent outline-none',
412
+ 'min-h-[40px] max-h-48 text-[14px] leading-[1.5] text-ink placeholder:text-ink-4',
413
+ )}
414
+ data-conversations-composer
415
+ />
416
+
417
+ <div className="flex items-center justify-between gap-2">
418
+ {onAddFiles ? (
419
+ <FileTrigger
420
+ allowsMultiple
421
+ onSelect={(files) => {
422
+ if (files) onAddFiles(Array.from(files))
423
+ }}
424
+ >
425
+ <Button
426
+ variant="subtle"
427
+ size="icon-md"
428
+ aria-label="Attach files"
429
+ className="shrink-0"
430
+ >
431
+ <Paperclip className="size-4" aria-hidden />
432
+ </Button>
433
+ </FileTrigger>
434
+ ) : (
435
+ <span />
436
+ )}
437
+
438
+ {isSendButtonShown ? (
439
+ <div className="flex items-center gap-2">
440
+ <span className="font-mono text-[10px] text-ink-4 select-none" aria-hidden>
441
+ {mode === 'email' ? '⌘↵' : '↵'}
442
+ </span>
443
+ <AriaButton
444
+ aria-label="Send"
445
+ isDisabled={!canSend || isSending}
446
+ onPress={() => void submit()}
447
+ className={cn(
448
+ 'grid place-items-center size-8 rounded-[8px] cursor-pointer',
449
+ 'bg-accent text-tile-fg transition-opacity duration-150',
450
+ 'disabled:opacity-40 disabled:cursor-default',
451
+ 'outline-none focus-visible:shadow-[var(--shadow-focus)]',
452
+ )}
453
+ >
454
+ <SendHorizontal className="size-3.5" />
455
+ </AriaButton>
456
+ </div>
457
+ ) : null}
458
+ </div>
459
+ </div>
460
+ </div>
461
+
462
+ {sendError ? (
463
+ <p className="mt-1.5 font-mono text-[11px] text-danger">
464
+ {sendError} — your message is kept above.{' '}
465
+ <AriaButton
466
+ onPress={() => void submit()}
467
+ className="inline underline cursor-pointer outline-none focus-visible:shadow-[var(--shadow-focus)]"
468
+ >
469
+ retry
470
+ </AriaButton>
471
+ </p>
472
+ ) : null}
473
+ </div>
474
+ )
475
+ }
476
+
477
+ function autoGrow(node: HTMLTextAreaElement) {
478
+ node.style.height = 'auto'
479
+ node.style.height = `${Math.min(node.scrollHeight, 192)}px`
480
+ }
@@ -0,0 +1,2 @@
1
+ export type { RecipientSelectProps } from './recipient-select'
2
+ export { RecipientSelect } from './recipient-select'
@@ -0,0 +1,139 @@
1
+ import type { ContactAddress, SendRecipient } from '@proteos/sdk'
2
+ import { render, screen, waitFor } from '@testing-library/react'
3
+ import userEvent from '@testing-library/user-event'
4
+ import { useState } from 'react'
5
+ import { describe, expect, it, vi } from 'vitest'
6
+
7
+ import { RecipientSelect } from './recipient-select'
8
+
9
+ function contactAddress(overrides: Partial<ContactAddress> = {}): ContactAddress {
10
+ return {
11
+ id: 'addr-1',
12
+ value: 'alice@example.com',
13
+ raw_value: 'alice@example.com',
14
+ name: 'Alice',
15
+ ...overrides,
16
+ } as ContactAddress
17
+ }
18
+
19
+ function deferred<T>() {
20
+ let resolve!: (value: T) => void
21
+ const promise = new Promise<T>((r) => {
22
+ resolve = r
23
+ })
24
+ return { promise, resolve }
25
+ }
26
+
27
+ function Harness({
28
+ searchRecipients,
29
+ onChange,
30
+ initial = [],
31
+ }: {
32
+ searchRecipients: (connectionId: string, q: string) => Promise<ContactAddress[]>
33
+ onChange?: (next: SendRecipient[]) => void
34
+ initial?: SendRecipient[]
35
+ }) {
36
+ const [value, setValue] = useState<SendRecipient[]>(initial)
37
+ return (
38
+ <RecipientSelect
39
+ connectionId="conn-1"
40
+ searchRecipients={searchRecipients}
41
+ placeholder="Pick recipients…"
42
+ value={value}
43
+ onChange={(next) => {
44
+ setValue(next)
45
+ onChange?.(next)
46
+ }}
47
+ />
48
+ )
49
+ }
50
+
51
+ describe('RecipientSelect', () => {
52
+ it('loads the directory on mount and emits contact-address recipients on pick', async () => {
53
+ const searchRecipients = vi
54
+ .fn()
55
+ .mockResolvedValue([
56
+ contactAddress(),
57
+ contactAddress({ id: 'addr-2', value: 'bob@example.com', raw_value: '', name: 'Bob' }),
58
+ ])
59
+ const onChange = vi.fn()
60
+ render(<Harness searchRecipients={searchRecipients} onChange={onChange} />)
61
+
62
+ await waitFor(() => expect(searchRecipients).toHaveBeenCalledWith('conn-1', ''))
63
+
64
+ await userEvent.click(screen.getByRole('combobox'))
65
+ await userEvent.click(await screen.findByRole('option', { name: 'Alice' }))
66
+ expect(onChange).toHaveBeenCalledWith([
67
+ { kind: 'contact-address', external_id: 'alice@example.com' },
68
+ ])
69
+ // Bob has no raw_value — the canonical value is the identifier.
70
+ await userEvent.click(screen.getByRole('option', { name: 'Bob' }))
71
+ expect(onChange).toHaveBeenLastCalledWith([
72
+ { kind: 'contact-address', external_id: 'alice@example.com' },
73
+ { kind: 'contact-address', external_id: 'bob@example.com' },
74
+ ])
75
+ })
76
+
77
+ it('debounces the search text into the injected search', async () => {
78
+ const searchRecipients = vi.fn().mockResolvedValue([contactAddress()])
79
+ render(<Harness searchRecipients={searchRecipients} />)
80
+
81
+ await userEvent.click(screen.getByRole('combobox'))
82
+ await userEvent.type(screen.getByLabelText('Search options'), 'ali')
83
+
84
+ await waitFor(() => expect(searchRecipients).toHaveBeenCalledWith('conn-1', 'ali'))
85
+ // Debounced: never one call per keystroke ('a', 'al').
86
+ expect(searchRecipients).not.toHaveBeenCalledWith('conn-1', 'a')
87
+ expect(searchRecipients).not.toHaveBeenCalledWith('conn-1', 'al')
88
+ })
89
+
90
+ it('always includes already-selected ids so their chips render off-page', async () => {
91
+ const searchRecipients = vi.fn().mockResolvedValue([])
92
+ render(
93
+ <Harness
94
+ searchRecipients={searchRecipients}
95
+ initial={[{ kind: 'contact-address', external_id: 'ghost@example.com' }]}
96
+ />,
97
+ )
98
+ expect(await screen.findByText('ghost@example.com')).toBeInTheDocument()
99
+ })
100
+
101
+ it('ignores stale responses that resolve after a newer search', async () => {
102
+ const initial = deferred<ContactAddress[]>()
103
+ const searchRecipients = vi
104
+ .fn()
105
+ .mockImplementationOnce(() => initial.promise)
106
+ .mockResolvedValue([contactAddress()])
107
+ render(<Harness searchRecipients={searchRecipients} />)
108
+
109
+ await userEvent.click(screen.getByRole('combobox'))
110
+ await userEvent.type(screen.getByLabelText('Search options'), 'ali')
111
+ // The newer 'ali' search lands first…
112
+ await screen.findByRole('option', { name: 'Alice' })
113
+
114
+ // …then the stale mount-time search resolves and must be dropped.
115
+ initial.resolve([
116
+ contactAddress({ id: 'addr-9', value: 'stale@example.com', name: 'Alistair Stale' }),
117
+ ])
118
+ await waitFor(() =>
119
+ expect(screen.queryByRole('option', { name: 'Alistair Stale' })).not.toBeInTheDocument(),
120
+ )
121
+ expect(screen.getByRole('option', { name: 'Alice' })).toBeInTheDocument()
122
+ })
123
+
124
+ it('keeps the previous options when a search fails (best-effort)', async () => {
125
+ const searchRecipients = vi
126
+ .fn()
127
+ .mockResolvedValueOnce([contactAddress()])
128
+ .mockRejectedValue(new Error('directory_unavailable'))
129
+ render(<Harness searchRecipients={searchRecipients} />)
130
+
131
+ await userEvent.click(screen.getByRole('combobox'))
132
+ await screen.findByRole('option', { name: 'Alice' })
133
+
134
+ // 'ali' still matches Alice locally, so the kept option stays visible.
135
+ await userEvent.type(screen.getByLabelText('Search options'), 'ali')
136
+ await waitFor(() => expect(searchRecipients).toHaveBeenCalledWith('conn-1', 'ali'))
137
+ expect(screen.getByRole('option', { name: 'Alice' })).toBeInTheDocument()
138
+ })
139
+ })