@proteos/ui 0.42.2 → 0.42.3
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.
- package/dist/index.cjs +373 -157
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +152 -2
- package/dist/index.d.ts +152 -2
- package/dist/index.js +366 -159
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/components/channel-icon/channel-icon.stories.tsx +44 -0
- package/src/components/channel-icon/channel-icon.test.tsx +26 -0
- package/src/components/channel-icon/channel-icon.tsx +67 -0
- package/src/components/channel-icon/index.ts +2 -0
- package/src/components/draft-message-card/draft-message-card.stories.tsx +94 -0
- package/src/components/draft-message-card/draft-message-card.test.tsx +43 -0
- package/src/components/draft-message-card/draft-message-card.tsx +111 -0
- package/src/components/draft-message-card/draft-message-review.test.tsx +170 -0
- package/src/components/draft-message-card/draft-message-review.tsx +197 -0
- package/src/components/draft-message-card/index.ts +7 -0
- package/src/components/draft-message-composer/draft-message-composer.tsx +1 -17
- package/src/components/draft-message-composer/draft-status.ts +49 -0
- package/src/components/draft-message-composer/index.ts +7 -0
- package/src/index.ts +2 -0
|
@@ -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
|
+
}
|
|
@@ -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'
|