@proteos/ui 0.42.2 → 0.43.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.
@@ -0,0 +1,197 @@
1
+ import type { Message } from '@proteos/sdk'
2
+ import { useCallback, useEffect, useRef, useState } from 'react'
3
+
4
+ import {
5
+ DraftMessageComposer,
6
+ type DraftMessageClient,
7
+ type DraftReviewOutcome,
8
+ draftOutcomeFor,
9
+ useDraftMessageOutcome,
10
+ } from '../draft-message-composer'
11
+ import { ModalBody, ModalContent, ModalHeader, ModalTitle } from '../modal'
12
+ import { DraftMessageCard } from './draft-message-card'
13
+
14
+ /* =========================================================================
15
+ DraftMessageReview — the connected draft-review entry point: loads the
16
+ draft through the injected client, renders DraftMessageCard, and owns what
17
+ the preview action does.
18
+
19
+ The preview action is the seam. Left alone it opens a modal wrapping
20
+ DraftMessageComposer — the zero-config path the platform chat uses. Passed
21
+ `onPreview`, it hands the whole interaction over: the built-in modal is
22
+ never rendered, and the host settles the card by resolving the outcome.
23
+ That is how a sandboxed custom component reviews a draft — its iframe-local
24
+ modal would layer below the host chrome, so it instead asks the host to
25
+ open one (useComponentModal) and resolves with what the host closed with.
26
+
27
+ A draft that is ALREADY terminal when it loads settles immediately: the
28
+ human may have reviewed it in the platform UI before this card ever
29
+ rendered, and an un-reviewable card that never resolves would hang whatever
30
+ is waiting on the outcome (e.g. an agent turn).
31
+ ========================================================================= */
32
+
33
+ export interface DraftMessageReviewProps {
34
+ /** The status=draft message to review. */
35
+ messageId: string
36
+ /** Injected fetch surface — see createDraftMessageClient. */
37
+ client: DraftMessageClient
38
+ /**
39
+ * FULL REPLACEMENT for the preview action's default (open the built-in
40
+ * modal). When supplied the built-in modal is never rendered. Resolve with
41
+ * the outcome so the card settles, or with null when the reviewer dismissed
42
+ * without deciding (the card stays reviewable).
43
+ */
44
+ onPreview?: (messageId: string) => Promise<DraftReviewOutcome | null>
45
+ /** Fires at most once, when the draft reaches a terminal state — whether it
46
+ * was decided here, by `onPreview`, or before this card ever mounted. */
47
+ onOutcome?: (outcome: DraftReviewOutcome, message?: Message) => void
48
+ /** The draft could not be loaded. The card renders nothing in that case. */
49
+ onLoadError?: (error: Error) => void
50
+ /** Built-in modal heading. Default 'Review draft'. */
51
+ modalTitle?: string
52
+ /** Built-in modal panel width. Default 'max-w-3xl'. */
53
+ modalClassName?: string
54
+ /** Built-in modal overlay classes — this is where a host raises the z tier
55
+ * above the surface that launched it. */
56
+ overlayClassName?: string
57
+ previewLabel?: string
58
+ className?: string
59
+ }
60
+
61
+ export function DraftMessageReview({
62
+ messageId,
63
+ client,
64
+ onPreview,
65
+ onOutcome,
66
+ onLoadError,
67
+ modalTitle = 'Review draft',
68
+ modalClassName = 'max-w-3xl',
69
+ overlayClassName,
70
+ previewLabel,
71
+ className,
72
+ }: DraftMessageReviewProps) {
73
+ const [message, setMessage] = useState<Message | null>(null)
74
+ const [subject, setSubject] = useState('')
75
+ const [isOpen, setIsOpen] = useState(false)
76
+ const [isPreviewing, setIsPreviewing] = useState(false)
77
+ const [outcome, setOutcome] = useState<DraftReviewOutcome | null>(null)
78
+
79
+ // Latest-closure refs: the host's callbacks are referentially unstable (they
80
+ // routinely close over a react-query mutation), and depending on them would
81
+ // re-run the load effect on every host re-render.
82
+ const onOutcomeRef = useRef(onOutcome)
83
+ onOutcomeRef.current = onOutcome
84
+ const onLoadErrorRef = useRef(onLoadError)
85
+ onLoadErrorRef.current = onLoadError
86
+
87
+ // One-shot: the outcome can arrive from the built-in composer, from
88
+ // `onPreview`, from the load itself, or from the outcome store — the host
89
+ // must still hear about it exactly once.
90
+ const hasSettledRef = useRef(false)
91
+ const settle = useCallback((next: DraftReviewOutcome, settled?: Message) => {
92
+ setOutcome(next)
93
+ setIsOpen(false)
94
+ if (hasSettledRef.current) return
95
+ hasSettledRef.current = true
96
+ onOutcomeRef.current?.(next, settled)
97
+ }, [])
98
+
99
+ useEffect(() => {
100
+ let cancelled = false
101
+ void (async () => {
102
+ try {
103
+ const loaded = await client.getMessage(messageId)
104
+ if (cancelled) return
105
+ setMessage(loaded)
106
+ const terminal = draftOutcomeFor(loaded.status)
107
+ if (terminal) {
108
+ settle(terminal, loaded)
109
+ return
110
+ }
111
+ if (loaded.conversation_id) {
112
+ const conversation = await client.getConversation(loaded.conversation_id)
113
+ if (!cancelled) setSubject(conversation.subject ?? '')
114
+ }
115
+ } catch (error) {
116
+ if (cancelled) return
117
+ onLoadErrorRef.current?.(error instanceof Error ? error : new Error(String(error)))
118
+ }
119
+ })()
120
+ return () => {
121
+ cancelled = true
122
+ }
123
+ }, [client, messageId, settle])
124
+
125
+ // A composer for this same draft elsewhere in this JS realm (a detail panel,
126
+ // a sibling embed) settles the card too — no prop drilling between them.
127
+ const published = useDraftMessageOutcome(messageId)
128
+ useEffect(() => {
129
+ if (published) settle(published.type, published.message)
130
+ }, [published, settle])
131
+
132
+ const preview = () => {
133
+ if (!onPreview) {
134
+ setIsOpen(true)
135
+ return
136
+ }
137
+ setIsPreviewing(true)
138
+ void onPreview(messageId)
139
+ .then((result) => {
140
+ if (result) settle(result)
141
+ })
142
+ .finally(() => setIsPreviewing(false))
143
+ }
144
+
145
+ if (!message) return null
146
+
147
+ return (
148
+ <>
149
+ <DraftMessageCard
150
+ channel={message.channel}
151
+ title={titleFor(message, subject)}
152
+ recipients={toRecipients(message)}
153
+ outcome={outcome}
154
+ onPreview={preview}
155
+ isPreviewDisabled={isPreviewing}
156
+ {...(previewLabel !== undefined ? { previewLabel } : {})}
157
+ {...(className !== undefined ? { className } : {})}
158
+ />
159
+ {onPreview ? null : (
160
+ <ModalContent
161
+ isOpen={isOpen}
162
+ onOpenChange={setIsOpen}
163
+ modalClassName={modalClassName}
164
+ {...(overlayClassName !== undefined ? { className: overlayClassName } : {})}
165
+ >
166
+ <ModalHeader>
167
+ <ModalTitle>{modalTitle}</ModalTitle>
168
+ </ModalHeader>
169
+ <ModalBody className="min-h-0 overflow-y-auto">
170
+ <DraftMessageComposer
171
+ messageId={messageId}
172
+ client={client}
173
+ bodyClassName="max-h-[55dvh]"
174
+ onSent={(sent) => settle(draftOutcomeFor(sent.status) ?? 'sent', sent)}
175
+ onRejected={(rejected) => settle('rejected', rejected)}
176
+ onFailed={(failed) => settle('failed', failed)}
177
+ />
178
+ </ModalBody>
179
+ </ModalContent>
180
+ )}
181
+ </>
182
+ )
183
+ }
184
+
185
+ /** Subject first; an unsubjected draft (chat channels) leads with its opening
186
+ * line so the card still says something about the message. */
187
+ function titleFor(message: Message, subject: string): string {
188
+ const firstLine = message.content.find((block) => block.type === 'text')?.text?.split('\n')[0]
189
+ return subject || firstLine || 'Draft message'
190
+ }
191
+
192
+ function toRecipients(message: Message): string[] {
193
+ return (message.recipients ?? [])
194
+ .filter((recipient) => recipient.role === 'to')
195
+ .map((recipient) => recipient.name || recipient.email || recipient.external_id)
196
+ .filter((label): label is string => !!label)
197
+ }
@@ -0,0 +1,7 @@
1
+ export {
2
+ DRAFT_OUTCOME_LABEL,
3
+ DraftMessageCard,
4
+ type DraftMessageCardProps,
5
+ draftMessageCardStyles,
6
+ } from './draft-message-card'
7
+ export { DraftMessageReview, type DraftMessageReviewProps } from './draft-message-review'
@@ -8,6 +8,7 @@ import { Button } from '../button'
8
8
  import { type ComposerAttachment, type ComposerValue, MessageComposer } from '../message-composer'
9
9
  import { registerDraftMessageActions } from './draft-message-actions-store'
10
10
  import { publishDraftMessageOutcome } from './draft-message-outcome-store'
11
+ import { type TerminalKind, terminalKindFor } from './draft-status'
11
12
  import type { DraftMessageClient } from './types'
12
13
 
13
14
  /* =========================================================================
@@ -48,7 +49,6 @@ const styles = draftMessageComposerStyles()
48
49
 
49
50
  type Phase = 'loading' | 'load-error' | 'ready' | 'terminal'
50
51
  type Busy = 'idle' | 'saving' | 'sending' | 'rejecting'
51
- type TerminalKind = 'sent' | 'rejected' | 'failed' | 'not-draft'
52
52
 
53
53
  interface DraftSnapshot {
54
54
  text: string
@@ -92,22 +92,6 @@ function sendRecipientsFromMessage(message: Message, role: 'to' | 'cc' | 'bcc'):
92
92
  }))
93
93
  }
94
94
 
95
- function terminalKindFor(status: Message['status']): TerminalKind | null {
96
- switch (status) {
97
- case 'draft':
98
- return null
99
- case 'pending':
100
- case 'sent':
101
- return 'sent'
102
- case 'rejected':
103
- return 'rejected'
104
- case 'failed':
105
- return 'failed'
106
- default:
107
- return 'not-draft'
108
- }
109
- }
110
-
111
95
  export function DraftMessageComposer({
112
96
  messageId,
113
97
  client,
@@ -0,0 +1,49 @@
1
+ import type { Message } from '@proteos/sdk'
2
+
3
+ /* =========================================================================
4
+ Draft status → terminal state. One switch, two narrowings: the composer
5
+ needs to distinguish "this id isn't a draft at all" (it renders a distinct
6
+ terminal notice) while review surfaces only care whether a decision has
7
+ been reached. Both were duplicated before — keep them here so a new
8
+ Message['status'] value is handled in exactly one place.
9
+ ========================================================================= */
10
+
11
+ /** What a reviewed draft ended up as. The wire shape of the
12
+ * `present_message_draft` client tool's outcome, and of the value a
13
+ * host-rendered review modal closes with. */
14
+ export type DraftReviewOutcome = 'sent' | 'rejected' | 'failed'
15
+
16
+ /** Terminal states a draft id can be in, plus the "never was a draft" case. */
17
+ export type TerminalKind = DraftReviewOutcome | 'not-draft'
18
+
19
+ const OUTCOME_BY_STATUS: Partial<Record<Message['status'], DraftReviewOutcome>> = {
20
+ // `pending` is already handed to the transport — from the reviewer's side
21
+ // the decision was "send".
22
+ pending: 'sent',
23
+ sent: 'sent',
24
+ rejected: 'rejected',
25
+ failed: 'failed',
26
+ }
27
+
28
+ /** Terminal state of the message, or null while it is still reviewable. */
29
+ export function terminalKindFor(status: Message['status']): TerminalKind | null {
30
+ if (status === 'draft') return null
31
+ return OUTCOME_BY_STATUS[status] ?? 'not-draft'
32
+ }
33
+
34
+ /**
35
+ * The review-surface narrowing: the outcome a card should settle on, or null
36
+ * while the draft is still reviewable. A status that was never a draft
37
+ * (`received`, …) is not an outcome — it leaves the card pending rather than
38
+ * inventing a decision the human never made.
39
+ */
40
+ export function draftOutcomeFor(status: Message['status']): DraftReviewOutcome | null {
41
+ const kind = terminalKindFor(status)
42
+ return kind === null || kind === 'not-draft' ? null : kind
43
+ }
44
+
45
+ /** Narrows the `unknown` a host modal resolves with (a dismissed modal
46
+ * resolves `undefined`). */
47
+ export function isDraftReviewOutcome(value: unknown): value is DraftReviewOutcome {
48
+ return value === 'sent' || value === 'rejected' || value === 'failed'
49
+ }
@@ -10,6 +10,13 @@ export {
10
10
  type DraftMessageComposerProps,
11
11
  draftMessageComposerStyles,
12
12
  } from './draft-message-composer'
13
+ export {
14
+ type DraftReviewOutcome,
15
+ draftOutcomeFor,
16
+ isDraftReviewOutcome,
17
+ type TerminalKind,
18
+ terminalKindFor,
19
+ } from './draft-status'
13
20
  export {
14
21
  __resetDraftMessageOutcomes,
15
22
  publishDraftMessageOutcome,
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export * from './components/badge-multi-select'
7
7
  export * from './components/breadcrumbs'
8
8
  export * from './components/button'
9
9
  export * from './components/card'
10
+ export * from './components/channel-icon'
10
11
  export * from './components/checkbox'
11
12
  export * from './components/combobox'
12
13
  export * from './components/command'
@@ -14,6 +15,7 @@ export * from './components/currency-input'
14
15
  export * from './components/data-table'
15
16
  export * from './components/date-picker'
16
17
  export * from './components/date-time-picker'
18
+ export * from './components/draft-message-card'
17
19
  export * from './components/draft-message-composer'
18
20
  export * from './components/empty-state'
19
21
  export * from './components/error-animation'