@proteos/ui 0.41.0 → 0.42.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteos/ui",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "license": "FSL-1.1-Apache-2.0",
5
5
  "description": "Proteos React component library (React Aria Components + Tailwind v4).",
6
6
  "repository": {
@@ -55,7 +55,7 @@
55
55
  "tailwind-merge": "^3.5.0",
56
56
  "tailwind-variants": "^3.2.2",
57
57
  "tailwindcss": "^4.2.4",
58
- "@proteos/sdk": "0.41.0"
58
+ "@proteos/sdk": "0.42.0"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@biomejs/biome": "^2.4.13",
@@ -77,8 +77,8 @@
77
77
  "typescript": "^6.0.3",
78
78
  "vite": "^8.0.10",
79
79
  "vitest": "^3.2.4",
80
- "@proteos/tsconfig": "0.0.0",
81
- "@proteos/biome-config": "0.0.0"
80
+ "@proteos/biome-config": "0.0.0",
81
+ "@proteos/tsconfig": "0.0.0"
82
82
  },
83
83
  "scripts": {
84
84
  "lint": "biome check .",
@@ -151,14 +151,20 @@ describe('DraftMessageComposer', () => {
151
151
  expect(screen.getByTestId('outcome')).toHaveTextContent('sent')
152
152
  })
153
153
 
154
- it('a failed delivery is terminal with no callback and no outcome', async () => {
154
+ it('a failed delivery is terminal, fires onFailed only and no outcome', async () => {
155
155
  const client = mockClient({
156
156
  sendDraft: vi.fn().mockResolvedValue(draftMessage({ status: 'failed', error: 'smtp 550' })),
157
157
  })
158
158
  const onSent = vi.fn()
159
+ const onFailed = vi.fn()
159
160
  render(
160
161
  <>
161
- <DraftMessageComposer messageId="draft-1" client={client} onSent={onSent} />
162
+ <DraftMessageComposer
163
+ messageId="draft-1"
164
+ client={client}
165
+ onSent={onSent}
166
+ onFailed={onFailed}
167
+ />
162
168
  <OutcomeProbe messageId="draft-1" />
163
169
  </>,
164
170
  )
@@ -166,6 +172,7 @@ describe('DraftMessageComposer', () => {
166
172
  await userEvent.click(screen.getByRole('button', { name: 'Send' }))
167
173
  expect(await screen.findByText(/Sending failed — smtp 550/)).toBeInTheDocument()
168
174
  expect(onSent).not.toHaveBeenCalled()
175
+ expect(onFailed).toHaveBeenCalledTimes(1)
169
176
  expect(screen.getByTestId('outcome')).toHaveTextContent('none')
170
177
  })
171
178
 
@@ -67,6 +67,8 @@ export interface DraftMessageComposerProps {
67
67
  onSent?: (message: Message) => void
68
68
  /** Fires once the draft was declined. */
69
69
  onRejected?: (message: Message) => void
70
+ /** Fires when the human approved the send but delivery failed. */
71
+ onFailed?: (message: Message) => void
70
72
  /**
71
73
  * Default true. When false the internal Send / Save draft / Reject row is
72
74
  * hidden — the composer becomes a pure preview/edit surface driven by
@@ -74,6 +76,9 @@ export interface DraftMessageComposerProps {
74
76
  */
75
77
  showActions?: boolean
76
78
  className?: string
79
+ /** Composes onto the body textarea (MessageComposer.bodyClassName) — review
80
+ * surfaces raise the growth cap so a whole email fits. */
81
+ bodyClassName?: string
77
82
  }
78
83
 
79
84
  /** Maps stored recipient snapshots back to editable send targets (contact
@@ -108,8 +113,10 @@ export function DraftMessageComposer({
108
113
  client,
109
114
  onSent,
110
115
  onRejected,
116
+ onFailed,
111
117
  showActions = true,
112
118
  className,
119
+ bodyClassName,
113
120
  }: DraftMessageComposerProps) {
114
121
  const [phase, setPhase] = useState<Phase>('loading')
115
122
  const [busy, setBusy] = useState<Busy>('idle')
@@ -266,6 +273,7 @@ export function DraftMessageComposer({
266
273
  // published — the timeline's failed-retry pattern owns what's next.
267
274
  setTerminalKind('failed')
268
275
  setPhase('terminal')
276
+ onFailed?.(result)
269
277
  return
270
278
  }
271
279
  setTerminalKind('sent')
@@ -288,6 +296,7 @@ export function DraftMessageComposer({
288
296
  client,
289
297
  messageId,
290
298
  onSent,
299
+ onFailed,
291
300
  areRecipientsEditable,
292
301
  conversation,
293
302
  to,
@@ -406,6 +415,7 @@ export function DraftMessageComposer({
406
415
  value={value}
407
416
  onValueChange={setValue}
408
417
  placeholder="Draft body…"
418
+ {...(bodyClassName !== undefined ? { bodyClassName } : {})}
409
419
  {...(isEmail && !isSubjectEditable ? { isSubjectReadOnly: true } : {})}
410
420
  onSend={async () => send()}
411
421
  {...(areRecipientsEditable && message.connection_id
@@ -135,6 +135,9 @@ export interface MessageComposerProps {
135
135
  showSendButton?: boolean
136
136
  /** Composes onto the outer box chrome (`messageComposerStyles`). */
137
137
  className?: string
138
+ /** Composes onto the body textarea — review surfaces override the default
139
+ * `max-h-48` growth cap (e.g. `max-h-[55dvh]`) so a whole email fits. */
140
+ bodyClassName?: string
138
141
  }
139
142
 
140
143
  export function MessageComposer({
@@ -163,6 +166,7 @@ export function MessageComposer({
163
166
  hasUploadingAttachments,
164
167
  showSendButton,
165
168
  className,
169
+ bodyClassName,
166
170
  }: MessageComposerProps) {
167
171
  const [isSending, setIsSending] = useState(false)
168
172
  const [sendError, setSendError] = useState<string | null>(null)
@@ -172,6 +176,13 @@ export function MessageComposer({
172
176
  const [showCcBcc, setShowCcBcc] = useState(false)
173
177
  const textareaRef = useRef<HTMLTextAreaElement>(null)
174
178
 
179
+ // Size-to-content on every text change — including a seeded value (a loaded
180
+ // draft body), which never fires onChange.
181
+ // biome-ignore lint/correctness/useExhaustiveDependencies: value.text is the trigger — the height reads the DOM, not the value.
182
+ useEffect(() => {
183
+ if (textareaRef.current) autoGrow(textareaRef.current)
184
+ }, [value.text])
185
+
175
186
  // Controlled-if-provided, per group: a provided prop wins over internal
176
187
  // state; setters mirror into internal state only when uncontrolled.
177
188
  const to = toProp ?? internalTo
@@ -402,14 +413,12 @@ export function MessageComposer({
402
413
  value={value.text}
403
414
  placeholder={placeholder}
404
415
  rows={1}
405
- onChange={(event) => {
406
- onValueChange({ ...value, text: event.target.value })
407
- autoGrow(event.currentTarget)
408
- }}
416
+ onChange={(event) => onValueChange({ ...value, text: event.target.value })}
409
417
  onKeyDown={onKeyDown}
410
418
  className={cn(
411
419
  'w-full resize-none bg-transparent outline-none',
412
420
  'min-h-[40px] max-h-48 text-[14px] leading-[1.5] text-ink placeholder:text-ink-4',
421
+ bodyClassName,
413
422
  )}
414
423
  data-conversations-composer
415
424
  />
@@ -474,7 +483,9 @@ export function MessageComposer({
474
483
  )
475
484
  }
476
485
 
486
+ // Sizes the textarea to its content; the CSS max-h on the element clips the
487
+ // growth (and scrolls) so the cap stays a style concern, overridable per host.
477
488
  function autoGrow(node: HTMLTextAreaElement) {
478
489
  node.style.height = 'auto'
479
- node.style.height = `${Math.min(node.scrollHeight, 192)}px`
490
+ node.style.height = `${node.scrollHeight}px`
480
491
  }