@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,470 @@
1
+ import type { Conversation, Message, SendRecipient, UpdateDraftRequest } from '@proteos/sdk'
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
3
+ import { Button as AriaButton } from 'react-aria-components'
4
+ import { tv } from 'tailwind-variants'
5
+
6
+ import { cn } from '../../lib/cn'
7
+ import { Button } from '../button'
8
+ import { type ComposerAttachment, type ComposerValue, MessageComposer } from '../message-composer'
9
+ import { registerDraftMessageActions } from './draft-message-actions-store'
10
+ import { publishDraftMessageOutcome } from './draft-message-outcome-store'
11
+ import type { DraftMessageClient } from './types'
12
+
13
+ /* =========================================================================
14
+ DraftMessageComposer — the human-review surface for an agent-drafted
15
+ outbound message (status=draft). Pass a draft's message id + an injected
16
+ client (createDraftMessageClient); it loads the draft into the shared
17
+ MessageComposer, lets the human edit body / subject-on-originate-drafts /
18
+ recipients-on-email-drafts, then Send (persists dirty edits first) or
19
+ Reject. Outcomes reach the host three ways: onSent/onRejected callbacks,
20
+ the keyed outcome store (useDraftMessageOutcome — sibling components), and
21
+ the actions registry (useDraftMessageActions — external Send/Reject
22
+ buttons, e.g. a task list's "Execute"; pair with showActions={false}).
23
+
24
+ Known v1 limits: attachments are display-only; a `failed` delivery is a
25
+ terminal surface here (host-side retry = a new send, matching the
26
+ timeline's failed-retry pattern).
27
+ ========================================================================= */
28
+
29
+ export const draftMessageComposerStyles = tv({
30
+ slots: {
31
+ root: 'flex flex-col gap-1.5',
32
+ header: 'flex items-center gap-2 px-[18px] pt-2',
33
+ badge: [
34
+ 'inline-flex items-center gap-1 rounded-pill border border-rule px-2 py-0.5',
35
+ 'font-mono text-[10px] uppercase tracking-[0.08em] text-ink-3 bg-surface-2',
36
+ ],
37
+ sender: 'text-[12.5px] text-ink-3 truncate',
38
+ actions: 'flex items-center justify-between gap-2 px-[18px] pb-3',
39
+ terminal: [
40
+ 'mx-[18px] my-3 rounded-[10px] border border-rule bg-surface-2 px-3.5 py-2.5',
41
+ 'text-[12.5px] text-ink-3',
42
+ ],
43
+ error: 'px-[18px] font-mono text-[11px] text-danger',
44
+ },
45
+ })
46
+
47
+ const styles = draftMessageComposerStyles()
48
+
49
+ type Phase = 'loading' | 'load-error' | 'ready' | 'terminal'
50
+ type Busy = 'idle' | 'saving' | 'sending' | 'rejecting'
51
+ type TerminalKind = 'sent' | 'rejected' | 'failed' | 'not-draft'
52
+
53
+ interface DraftSnapshot {
54
+ text: string
55
+ subject: string
56
+ to: SendRecipient[]
57
+ cc: SendRecipient[]
58
+ bcc: SendRecipient[]
59
+ }
60
+
61
+ export interface DraftMessageComposerProps {
62
+ /** The status=draft message to review. */
63
+ messageId: string
64
+ /** Injected fetch surface — see createDraftMessageClient. */
65
+ client: DraftMessageClient
66
+ /** Fires once the draft delivered (send resolved pending/sent). */
67
+ onSent?: (message: Message) => void
68
+ /** Fires once the draft was declined. */
69
+ onRejected?: (message: Message) => void
70
+ /**
71
+ * Default true. When false the internal Send / Save draft / Reject row is
72
+ * hidden — the composer becomes a pure preview/edit surface driven by
73
+ * external controls via useDraftMessageActions.
74
+ */
75
+ showActions?: boolean
76
+ className?: string
77
+ }
78
+
79
+ /** Maps stored recipient snapshots back to editable send targets (contact
80
+ * addresses only; snapshots without an external id cannot be re-addressed). */
81
+ function sendRecipientsFromMessage(message: Message, role: 'to' | 'cc' | 'bcc'): SendRecipient[] {
82
+ return (message.recipients ?? [])
83
+ .filter((recipient) => recipient.role === role && !!recipient.external_id)
84
+ .map((recipient) => ({
85
+ kind: 'contact-address' as const,
86
+ external_id: recipient.external_id as string,
87
+ }))
88
+ }
89
+
90
+ function terminalKindFor(status: Message['status']): TerminalKind | null {
91
+ switch (status) {
92
+ case 'draft':
93
+ return null
94
+ case 'pending':
95
+ case 'sent':
96
+ return 'sent'
97
+ case 'rejected':
98
+ return 'rejected'
99
+ case 'failed':
100
+ return 'failed'
101
+ default:
102
+ return 'not-draft'
103
+ }
104
+ }
105
+
106
+ export function DraftMessageComposer({
107
+ messageId,
108
+ client,
109
+ onSent,
110
+ onRejected,
111
+ showActions = true,
112
+ className,
113
+ }: DraftMessageComposerProps) {
114
+ const [phase, setPhase] = useState<Phase>('loading')
115
+ const [busy, setBusy] = useState<Busy>('idle')
116
+ const [terminalKind, setTerminalKind] = useState<TerminalKind>('not-draft')
117
+ const [message, setMessage] = useState<Message | null>(null)
118
+ const [conversation, setConversation] = useState<Conversation | null>(null)
119
+ const [loadError, setLoadError] = useState<string | null>(null)
120
+ const [actionError, setActionError] = useState<string | null>(null)
121
+ const [isConfirmingReject, setIsConfirmingReject] = useState(false)
122
+ const [value, setValue] = useState<ComposerValue>({ text: '' })
123
+ const [to, setTo] = useState<SendRecipient[]>([])
124
+ const [cc, setCc] = useState<SendRecipient[]>([])
125
+ const [bcc, setBcc] = useState<SendRecipient[]>([])
126
+ // State (not a ref): isDirty/buildPatch must recompute when a save moves it.
127
+ const [snapshot, setSnapshot] = useState<DraftSnapshot>({
128
+ text: '',
129
+ subject: '',
130
+ to: [],
131
+ cc: [],
132
+ bcc: [],
133
+ })
134
+ const [loadNonce, setLoadNonce] = useState(0)
135
+
136
+ const isEmail = message?.channel === 'email'
137
+ const isSubjectEditable = isEmail && conversation?.status === 'draft'
138
+ const areRecipientsEditable = isEmail && !conversation?.room_id
139
+
140
+ const seed = useCallback((draft: Message, thread: Conversation | null) => {
141
+ const text = draft.content.find((block) => block.type === 'text')?.text ?? ''
142
+ const subject = thread?.subject ?? ''
143
+ const seededTo = sendRecipientsFromMessage(draft, 'to')
144
+ const seededCc = sendRecipientsFromMessage(draft, 'cc')
145
+ const seededBcc = sendRecipientsFromMessage(draft, 'bcc')
146
+ setSnapshot({ text, subject, to: seededTo, cc: seededCc, bcc: seededBcc })
147
+ setValue({ text, ...(draft.channel === 'email' ? { subject } : {}) })
148
+ setTo(seededTo)
149
+ setCc(seededCc)
150
+ setBcc(seededBcc)
151
+ }, [])
152
+
153
+ // Load (and reload on retry): the message, then its conversation.
154
+ // biome-ignore lint/correctness/useExhaustiveDependencies: loadNonce is the retry trigger — bumping it re-runs the load.
155
+ useEffect(() => {
156
+ let isStale = false
157
+ setPhase('loading')
158
+ setLoadError(null)
159
+ setActionError(null)
160
+ client
161
+ .getMessage(messageId)
162
+ .then(async (draft) => {
163
+ const thread = draft.conversation_id
164
+ ? await client.getConversation(draft.conversation_id)
165
+ : null
166
+ if (isStale) return
167
+ setMessage(draft)
168
+ setConversation(thread)
169
+ const terminal = terminalKindFor(draft.status)
170
+ if (terminal) {
171
+ setTerminalKind(terminal)
172
+ setPhase('terminal')
173
+ return
174
+ }
175
+ seed(draft, thread)
176
+ setPhase('ready')
177
+ })
178
+ .catch((error: unknown) => {
179
+ if (isStale) return
180
+ setLoadError(error instanceof Error ? error.message : 'Loading the draft failed')
181
+ setPhase('load-error')
182
+ })
183
+ return () => {
184
+ isStale = true
185
+ }
186
+ }, [client, messageId, seed, loadNonce])
187
+
188
+ const isDirty = useMemo(() => {
189
+ return (
190
+ value.text !== snapshot.text ||
191
+ (isSubjectEditable && (value.subject ?? '') !== snapshot.subject) ||
192
+ JSON.stringify(to) !== JSON.stringify(snapshot.to) ||
193
+ JSON.stringify(cc) !== JSON.stringify(snapshot.cc) ||
194
+ JSON.stringify(bcc) !== JSON.stringify(snapshot.bcc)
195
+ )
196
+ }, [value, to, cc, bcc, snapshot, isSubjectEditable])
197
+
198
+ const buildPatch = useCallback((): UpdateDraftRequest => {
199
+ const current = message as Message
200
+ const isBodyEdited = value.text !== snapshot.text
201
+ const hasRecipientEdit =
202
+ areRecipientsEditable &&
203
+ (JSON.stringify(to) !== JSON.stringify(snapshot.to) ||
204
+ JSON.stringify(cc) !== JSON.stringify(snapshot.cc) ||
205
+ JSON.stringify(bcc) !== JSON.stringify(snapshot.bcc))
206
+ const attachmentRefs = (current.attachments ?? []).map((attachment) => attachment.file)
207
+ return {
208
+ // Body edits collapse to one text block; an untouched body echoes the
209
+ // stored blocks so an agent-provided html part survives the edit.
210
+ content: isBodyEdited
211
+ ? [{ type: 'text' as const, text: value.text.trim() }]
212
+ : current.content,
213
+ ...(isSubjectEditable ? { subject: value.subject ?? '' } : {}),
214
+ // Presence-based replacement: any recipient edit ships ALL three groups
215
+ // verbatim — [] is an explicit clear, an omitted group keeps the stored
216
+ // one. Shipping only non-empty groups would silently resurrect removed
217
+ // recipients at send time.
218
+ ...(hasRecipientEdit ? { to, cc, bcc } : {}),
219
+ ...(attachmentRefs.length > 0 ? { attachments: attachmentRefs } : {}),
220
+ }
221
+ }, [message, value, to, cc, bcc, snapshot, areRecipientsEditable, isSubjectEditable])
222
+
223
+ const persistDirtyEdits = useCallback(async (): Promise<Message> => {
224
+ const current = message as Message
225
+ if (!isDirty) return current
226
+ const updated = await client.updateDraft(messageId, buildPatch())
227
+ setMessage(updated)
228
+ setConversation((thread) =>
229
+ thread && isSubjectEditable ? { ...thread, subject: value.subject ?? '' } : thread,
230
+ )
231
+ setSnapshot({ text: value.text, subject: value.subject ?? '', to, cc, bcc })
232
+ return updated
233
+ }, [message, isDirty, client, messageId, buildPatch, isSubjectEditable, value, to, cc, bcc])
234
+
235
+ const saveDraft = useCallback(async () => {
236
+ if (phase !== 'ready' || busy !== 'idle') return
237
+ setBusy('saving')
238
+ setActionError(null)
239
+ try {
240
+ await persistDirtyEdits()
241
+ } catch (error) {
242
+ setActionError(error instanceof Error ? error.message : 'Saving the draft failed')
243
+ } finally {
244
+ setBusy('idle')
245
+ }
246
+ }, [phase, busy, persistDirtyEdits])
247
+
248
+ const send = useCallback(async () => {
249
+ if (phase !== 'ready' || busy !== 'idle') return
250
+ // An originate draft addresses people directly — sending it with every
251
+ // recipient removed can never deliver. (Reply drafts may legitimately go
252
+ // recipient-less: they deliver into the thread.) Guards the actions
253
+ // registry too, which bypasses the composer's own canSend gating.
254
+ if (areRecipientsEditable && conversation?.status === 'draft' && to.length === 0) {
255
+ setActionError('Add at least one recipient before sending')
256
+ return
257
+ }
258
+ setBusy('sending')
259
+ setActionError(null)
260
+ try {
261
+ await persistDirtyEdits()
262
+ const result = await client.sendDraft(messageId)
263
+ setMessage(result)
264
+ if (result.status === 'failed') {
265
+ // Delivery failed AFTER the human approved: terminal here, no outcome
266
+ // published — the timeline's failed-retry pattern owns what's next.
267
+ setTerminalKind('failed')
268
+ setPhase('terminal')
269
+ return
270
+ }
271
+ setTerminalKind('sent')
272
+ setPhase('terminal')
273
+ publishDraftMessageOutcome(messageId, { type: 'sent', message: result })
274
+ onSent?.(result)
275
+ } catch (error) {
276
+ // Transport/validation failure keeps the edits — the text is the
277
+ // reviewer's work. Rethrow so the composer's onSend rejects too:
278
+ // MessageComposer only clears its value on a RESOLVED send.
279
+ setActionError(error instanceof Error ? error.message : 'Sending the draft failed')
280
+ throw error
281
+ } finally {
282
+ setBusy('idle')
283
+ }
284
+ }, [
285
+ phase,
286
+ busy,
287
+ persistDirtyEdits,
288
+ client,
289
+ messageId,
290
+ onSent,
291
+ areRecipientsEditable,
292
+ conversation,
293
+ to,
294
+ ])
295
+
296
+ const reject = useCallback(
297
+ async (isConfirmed: boolean) => {
298
+ if (phase !== 'ready' || busy !== 'idle') return
299
+ if (!isConfirmed) {
300
+ setIsConfirmingReject(true)
301
+ return
302
+ }
303
+ setBusy('rejecting')
304
+ setActionError(null)
305
+ try {
306
+ // Deliberately ignores dirty edits — declining discards them.
307
+ const result = await client.rejectDraft(messageId)
308
+ setMessage(result)
309
+ setTerminalKind('rejected')
310
+ setPhase('terminal')
311
+ publishDraftMessageOutcome(messageId, { type: 'rejected', message: result })
312
+ onRejected?.(result)
313
+ } catch (error) {
314
+ setActionError(error instanceof Error ? error.message : 'Rejecting the draft failed')
315
+ } finally {
316
+ setBusy('idle')
317
+ setIsConfirmingReject(false)
318
+ }
319
+ },
320
+ [phase, busy, client, messageId, onRejected],
321
+ )
322
+
323
+ // Actions registry: external controls (task-list Execute buttons, any
324
+ // sibling in this realm) drive the mounted composer. Latest-closure refs so
325
+ // the registered handlers never go stale; re-registered on busy flips so
326
+ // subscribers see isBusy.
327
+ const sendRef = useRef(send)
328
+ const rejectRef = useRef(reject)
329
+ const saveRef = useRef(saveDraft)
330
+ sendRef.current = send
331
+ rejectRef.current = reject
332
+ saveRef.current = saveDraft
333
+ useEffect(() => {
334
+ if (phase !== 'ready') return
335
+ return registerDraftMessageActions(messageId, {
336
+ // Failures surface via actionError/outcomes — external drivers get a
337
+ // settled promise, not a rejection to remember to catch.
338
+ send: () => sendRef.current().catch(() => undefined),
339
+ // External UIs own their confirmation — bypass the inline confirm.
340
+ reject: () => rejectRef.current(true),
341
+ saveDraft: () => saveRef.current(),
342
+ isBusy: busy !== 'idle',
343
+ })
344
+ }, [messageId, phase, busy])
345
+
346
+ const attachments = useMemo<ComposerAttachment[]>(
347
+ () =>
348
+ (message?.attachments ?? []).map((attachment) => ({
349
+ localId: attachment.id,
350
+ name: attachment.file.name || attachment.file.id,
351
+ status: 'ready' as const,
352
+ ...(attachment.file.content_type ? { contentType: attachment.file.content_type } : {}),
353
+ })),
354
+ [message?.attachments],
355
+ )
356
+
357
+ if (phase === 'loading') {
358
+ return (
359
+ <div className={cn(styles.root(), className)}>
360
+ <p className={styles.terminal()}>Loading draft…</p>
361
+ </div>
362
+ )
363
+ }
364
+
365
+ if (phase === 'load-error') {
366
+ return (
367
+ <div className={cn(styles.root(), className)}>
368
+ <p className={styles.terminal()}>
369
+ {loadError ?? 'Loading the draft failed.'}{' '}
370
+ <AriaButton
371
+ onPress={() => setLoadNonce((nonce) => nonce + 1)}
372
+ className="inline underline cursor-pointer outline-none focus-visible:shadow-[var(--shadow-focus)]"
373
+ >
374
+ retry
375
+ </AriaButton>
376
+ </p>
377
+ </div>
378
+ )
379
+ }
380
+
381
+ if (phase === 'terminal' || !message) {
382
+ const line =
383
+ terminalKind === 'sent'
384
+ ? 'This draft was sent.'
385
+ : terminalKind === 'rejected'
386
+ ? 'This draft was rejected.'
387
+ : terminalKind === 'failed'
388
+ ? `Sending failed${message?.error ? ` — ${message.error}` : ''}.`
389
+ : 'This message is not a reviewable draft.'
390
+ return (
391
+ <div className={cn(styles.root(), className)} data-draft-terminal={terminalKind}>
392
+ <p className={styles.terminal()}>{line}</p>
393
+ </div>
394
+ )
395
+ }
396
+
397
+ return (
398
+ <div className={cn(styles.root(), className)}>
399
+ <div className={styles.header()}>
400
+ <span className={styles.badge()}>Draft · awaiting review</span>
401
+ <span className={styles.sender()}>from {message.sender.name}</span>
402
+ </div>
403
+
404
+ <MessageComposer
405
+ mode={isEmail ? 'email' : 'chat'}
406
+ value={value}
407
+ onValueChange={setValue}
408
+ placeholder="Draft body…"
409
+ {...(isEmail && !isSubjectEditable ? { isSubjectReadOnly: true } : {})}
410
+ onSend={async () => send()}
411
+ {...(areRecipientsEditable && message.connection_id
412
+ ? {
413
+ connectionId: message.connection_id,
414
+ searchRecipients: client.searchRecipients,
415
+ showToField: true,
416
+ showRecipientFields: true,
417
+ to,
418
+ cc,
419
+ bcc,
420
+ onToChange: setTo,
421
+ onCcChange: setCc,
422
+ onBccChange: setBcc,
423
+ }
424
+ : {})}
425
+ {...(attachments.length > 0 ? { attachments } : {})}
426
+ showSendButton={showActions}
427
+ />
428
+
429
+ {actionError ? <p className={styles.error()}>{actionError} — your edits are kept.</p> : null}
430
+
431
+ {showActions ? (
432
+ <div className={styles.actions()}>
433
+ {isConfirmingReject ? (
434
+ <div className="flex items-center gap-2">
435
+ <span className="text-[12.5px] text-ink-3">Reject draft?</span>
436
+ <Button
437
+ size="sm"
438
+ variant="ghost"
439
+ onPress={() => void reject(true)}
440
+ isDisabled={busy !== 'idle'}
441
+ >
442
+ Confirm reject
443
+ </Button>
444
+ <Button size="sm" variant="subtle" onPress={() => setIsConfirmingReject(false)}>
445
+ Cancel
446
+ </Button>
447
+ </div>
448
+ ) : (
449
+ <Button
450
+ size="sm"
451
+ variant="ghost"
452
+ onPress={() => void reject(false)}
453
+ isDisabled={busy !== 'idle'}
454
+ >
455
+ Reject
456
+ </Button>
457
+ )}
458
+ <Button
459
+ size="sm"
460
+ variant="subtle"
461
+ onPress={() => void saveDraft()}
462
+ isDisabled={busy !== 'idle' || !isDirty}
463
+ >
464
+ {busy === 'saving' ? 'Saving…' : 'Save draft'}
465
+ </Button>
466
+ </div>
467
+ ) : null}
468
+ </div>
469
+ )
470
+ }
@@ -0,0 +1,59 @@
1
+ import { act, renderHook } from '@testing-library/react'
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+
4
+ import {
5
+ __resetDraftMessageOutcomes,
6
+ publishDraftMessageOutcome,
7
+ subscribeDraftMessageOutcome,
8
+ useDraftMessageOutcome,
9
+ } from './draft-message-outcome-store'
10
+ import type { DraftMessageOutcome } from './types'
11
+
12
+ const sentOutcome = (id: string): DraftMessageOutcome => ({
13
+ type: 'sent',
14
+ message: { id } as DraftMessageOutcome['message'],
15
+ })
16
+
17
+ describe('draft-message-outcome-store', () => {
18
+ beforeEach(() => {
19
+ __resetDraftMessageOutcomes()
20
+ })
21
+
22
+ it('notifies subscribers of the message id, not others', () => {
23
+ const onMine = vi.fn()
24
+ const onOther = vi.fn()
25
+ subscribeDraftMessageOutcome('m-1', onMine)
26
+ subscribeDraftMessageOutcome('m-2', onOther)
27
+
28
+ publishDraftMessageOutcome('m-1', sentOutcome('m-1'))
29
+
30
+ expect(onMine).toHaveBeenCalledTimes(1)
31
+ expect(onOther).not.toHaveBeenCalled()
32
+ })
33
+
34
+ it('replays the last outcome to a late subscriber', () => {
35
+ publishDraftMessageOutcome('m-1', sentOutcome('m-1'))
36
+
37
+ const { result } = renderHook(() => useDraftMessageOutcome('m-1'))
38
+ expect(result.current?.type).toBe('sent')
39
+ expect(result.current?.message.id).toBe('m-1')
40
+ })
41
+
42
+ it('re-renders the hook when the outcome lands', () => {
43
+ const { result } = renderHook(() => useDraftMessageOutcome('m-9'))
44
+ expect(result.current).toBeNull()
45
+
46
+ act(() => {
47
+ publishDraftMessageOutcome('m-9', { type: 'rejected', message: { id: 'm-9' } as never })
48
+ })
49
+ expect(result.current?.type).toBe('rejected')
50
+ })
51
+
52
+ it('unsubscribes cleanly', () => {
53
+ const listener = vi.fn()
54
+ const unsubscribe = subscribeDraftMessageOutcome('m-1', listener)
55
+ unsubscribe()
56
+ publishDraftMessageOutcome('m-1', sentOutcome('m-1'))
57
+ expect(listener).not.toHaveBeenCalled()
58
+ })
59
+ })
@@ -0,0 +1,56 @@
1
+ import { useSyncExternalStore } from 'react'
2
+
3
+ import type { DraftMessageOutcome } from './types'
4
+
5
+ /* =========================================================================
6
+ Draft-outcome pub/sub — how SIBLING components learn a draft was sent or
7
+ rejected without prop drilling (the show-details-store pattern, keyed by
8
+ message id, plus last-value replay so a subscriber mounting AFTER the
9
+ outcome still sees it).
10
+
11
+ Scope: a per-JS-realm module singleton. In the web app that is the whole
12
+ page. In the component runtime the import map makes @proteos/ui a
13
+ singleton PER IFRAME — sibling components inside the SAME custom component
14
+ share outcomes; separate custom components are separate iframes and do
15
+ not (cross-iframe reaction stays host-side via query invalidation).
16
+ ========================================================================= */
17
+
18
+ const listenersByMessageId = new Map<string, Set<() => void>>()
19
+ const lastOutcomeByMessageId = new Map<string, DraftMessageOutcome>()
20
+
21
+ export function publishDraftMessageOutcome(messageId: string, outcome: DraftMessageOutcome): void {
22
+ lastOutcomeByMessageId.set(messageId, outcome)
23
+ for (const listener of listenersByMessageId.get(messageId) ?? []) listener()
24
+ }
25
+
26
+ export function subscribeDraftMessageOutcome(messageId: string, listener: () => void): () => void {
27
+ let listeners = listenersByMessageId.get(messageId)
28
+ if (!listeners) {
29
+ listeners = new Set()
30
+ listenersByMessageId.set(messageId, listeners)
31
+ }
32
+ listeners.add(listener)
33
+ return () => {
34
+ listeners.delete(listener)
35
+ if (listeners.size === 0) listenersByMessageId.delete(messageId)
36
+ }
37
+ }
38
+
39
+ /**
40
+ * The last outcome for this draft, or null while it is still under review.
41
+ * Re-renders when the outcome lands — including for subscribers that mounted
42
+ * after it (last-value replay).
43
+ */
44
+ export function useDraftMessageOutcome(messageId: string): DraftMessageOutcome | null {
45
+ return useSyncExternalStore(
46
+ (listener) => subscribeDraftMessageOutcome(messageId, listener),
47
+ () => lastOutcomeByMessageId.get(messageId) ?? null,
48
+ () => null,
49
+ )
50
+ }
51
+
52
+ /** Test-only escape hatch — clears every stored outcome and listener. */
53
+ export function __resetDraftMessageOutcomes(): void {
54
+ listenersByMessageId.clear()
55
+ lastOutcomeByMessageId.clear()
56
+ }
@@ -0,0 +1,19 @@
1
+ export { createDraftMessageClient } from './create-draft-message-client'
2
+ export {
3
+ __resetDraftMessageActions,
4
+ type DraftMessageActions,
5
+ registerDraftMessageActions,
6
+ useDraftMessageActions,
7
+ } from './draft-message-actions-store'
8
+ export {
9
+ DraftMessageComposer,
10
+ type DraftMessageComposerProps,
11
+ draftMessageComposerStyles,
12
+ } from './draft-message-composer'
13
+ export {
14
+ __resetDraftMessageOutcomes,
15
+ publishDraftMessageOutcome,
16
+ subscribeDraftMessageOutcome,
17
+ useDraftMessageOutcome,
18
+ } from './draft-message-outcome-store'
19
+ export type { DraftMessageClient, DraftMessageOutcome } from './types'
@@ -0,0 +1,32 @@
1
+ import type { ContactAddress, Conversation, Message, UpdateDraftRequest } from '@proteos/sdk'
2
+
3
+ /* =========================================================================
4
+ DraftMessageComposer contracts — types-only @proteos/sdk imports, so the
5
+ lib stays fetch-agnostic (the data-table InfiniteQueryLike precedent): the
6
+ host injects the client, the component never talks to the network itself.
7
+ ========================================================================= */
8
+
9
+ /**
10
+ * The minimal fetch surface DraftMessageComposer needs, injected per host:
11
+ * the web app builds it from `useSdk().conversation`, custom components from
12
+ * `useSdk().conversations` — both via {@link createDraftMessageClient}.
13
+ */
14
+ export interface DraftMessageClient {
15
+ /** One message with projections — also resolves terminal states by id. */
16
+ getMessage(messageId: string): Promise<Message>
17
+ /** The draft's conversation: subject, status (draft ⇒ subject editable), room. */
18
+ getConversation(conversationId: string): Promise<Conversation>
19
+ /** Full-replacement draft edit (PUT semantics — send the complete state). */
20
+ updateDraft(messageId: string, patch: UpdateDraftRequest): Promise<Message>
21
+ /** Deliver the draft (draft → pending → sent/failed). 409 on a lost race. */
22
+ sendDraft(messageId: string): Promise<Message>
23
+ /** Decline the draft (draft → rejected, terminal). 409 on a lost race. */
24
+ rejectDraft(messageId: string): Promise<Message>
25
+ /** Directory search backing the To/Cc/Bcc pickers (email drafts only). */
26
+ searchRecipients(connectionId: string, q: string): Promise<ContactAddress[]>
27
+ }
28
+
29
+ /** What happened to a reviewed draft — the payload of the outcome store. */
30
+ export type DraftMessageOutcome =
31
+ | { type: 'sent'; message: Message }
32
+ | { type: 'rejected'; message: Message }
@@ -0,0 +1,13 @@
1
+ export type {
2
+ ComposerAttachment,
3
+ ComposerConnectionPicker,
4
+ ComposerSendInput,
5
+ ComposerValue,
6
+ MessageComposerProps,
7
+ SearchRecipients,
8
+ } from './message-composer'
9
+ export {
10
+ MAX_COMPOSER_ATTACHMENT_BYTES,
11
+ MessageComposer,
12
+ messageComposerStyles,
13
+ } from './message-composer'