@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteos/ui",
3
- "version": "0.31.0",
3
+ "version": "0.32.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.31.0"
58
+ "@proteos/sdk": "0.32.0"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@biomejs/biome": "^2.4.13",
@@ -0,0 +1,27 @@
1
+ import type { ConversationClient } from '@proteos/sdk'
2
+
3
+ import type { DraftMessageClient } from './types'
4
+
5
+ /**
6
+ * One-liner adapter from the sdk ConversationClient (web app:
7
+ * `useSdk().conversation`; custom components: `useSdk().conversations`) to
8
+ * the composer's injected client. Type-only sdk import — no runtime coupling.
9
+ */
10
+ export function createDraftMessageClient(
11
+ conversation: Pick<ConversationClient, 'messages' | 'conversations' | 'connections'>,
12
+ ): DraftMessageClient {
13
+ return {
14
+ getMessage: (messageId) => conversation.messages.get(messageId),
15
+ getConversation: (conversationId) => conversation.conversations.get(conversationId),
16
+ updateDraft: (messageId, patch) => conversation.messages.updateDraft(messageId, patch),
17
+ sendDraft: (messageId) => conversation.messages.sendDraft(messageId),
18
+ rejectDraft: (messageId) => conversation.messages.rejectDraft(messageId),
19
+ searchRecipients: async (connectionId, q) =>
20
+ (
21
+ await conversation.connections.listContactAddresses(connectionId, {
22
+ page_size: 100,
23
+ ...(q ? { q } : {}),
24
+ })
25
+ ).data,
26
+ }
27
+ }
@@ -0,0 +1,62 @@
1
+ import { act, renderHook } from '@testing-library/react'
2
+ import { beforeEach, describe, expect, it } from 'vitest'
3
+
4
+ import {
5
+ __resetDraftMessageActions,
6
+ type DraftMessageActions,
7
+ registerDraftMessageActions,
8
+ useDraftMessageActions,
9
+ } from './draft-message-actions-store'
10
+
11
+ const actions = (isBusy = false): DraftMessageActions => ({
12
+ send: () => Promise.resolve(),
13
+ reject: () => Promise.resolve(),
14
+ saveDraft: () => Promise.resolve(),
15
+ isBusy,
16
+ })
17
+
18
+ describe('draft-message-actions-store', () => {
19
+ beforeEach(() => {
20
+ __resetDraftMessageActions()
21
+ })
22
+
23
+ it('exposes registered actions and null after unregister', () => {
24
+ const { result } = renderHook(() => useDraftMessageActions('m-1'))
25
+ expect(result.current).toBeNull()
26
+
27
+ let unregister = () => {}
28
+ act(() => {
29
+ unregister = registerDraftMessageActions('m-1', actions())
30
+ })
31
+ expect(result.current).not.toBeNull()
32
+ expect(result.current?.isBusy).toBe(false)
33
+
34
+ act(() => unregister())
35
+ expect(result.current).toBeNull()
36
+ })
37
+
38
+ it('re-registration updates isBusy for subscribers', () => {
39
+ const { result } = renderHook(() => useDraftMessageActions('m-1'))
40
+ act(() => {
41
+ registerDraftMessageActions('m-1', actions(false))
42
+ })
43
+ act(() => {
44
+ registerDraftMessageActions('m-1', actions(true))
45
+ })
46
+ expect(result.current?.isBusy).toBe(true)
47
+ })
48
+
49
+ it('last-registered wins; a stale unregister does not clear the winner', () => {
50
+ const { result } = renderHook(() => useDraftMessageActions('m-1'))
51
+ const first = actions(false)
52
+ const second = actions(true)
53
+ let unregisterFirst = () => {}
54
+ act(() => {
55
+ unregisterFirst = registerDraftMessageActions('m-1', first)
56
+ registerDraftMessageActions('m-1', second)
57
+ })
58
+ act(() => unregisterFirst())
59
+ expect(result.current).not.toBeNull()
60
+ expect(result.current?.isBusy).toBe(true)
61
+ })
62
+ })
@@ -0,0 +1,81 @@
1
+ import { useSyncExternalStore } from 'react'
2
+
3
+ /* =========================================================================
4
+ Draft-actions registry — how EXTERNAL controls (a task-list row's
5
+ "Execute" button, any sibling in the same realm) drive a mounted
6
+ DraftMessageComposer without a ref. The composer registers its imperative
7
+ handlers per message id on mount and unregisters on unmount; callers grab
8
+ them with useDraftMessageActions and get real promises back. Outcomes
9
+ additionally flow through the outcome store + onSent/onRejected.
10
+
11
+ Same per-JS-realm scope as the outcome store (per page in the web app,
12
+ per iframe in the component runtime). Multiple composers mounted for one
13
+ message id: the last-registered wins.
14
+ ========================================================================= */
15
+
16
+ export interface DraftMessageActions {
17
+ /** Persists dirty edits, then delivers — the internal Send button's path. */
18
+ send(): Promise<void>
19
+ /** Declines the draft. Bypasses the inline confirm — the external UI owns confirmation. */
20
+ reject(): Promise<void>
21
+ /** Persists the current edits without sending. */
22
+ saveDraft(): Promise<void>
23
+ /** True while the composer is saving/sending/rejecting — disable external buttons. */
24
+ isBusy: boolean
25
+ }
26
+
27
+ const actionsByMessageId = new Map<string, DraftMessageActions>()
28
+ const listenersByMessageId = new Map<string, Set<() => void>>()
29
+
30
+ function notify(messageId: string): void {
31
+ for (const listener of listenersByMessageId.get(messageId) ?? []) listener()
32
+ }
33
+
34
+ /**
35
+ * Registers a composer's handlers for its message id; returns the
36
+ * unregister. Re-register (a fresh object) whenever isBusy flips so
37
+ * subscribers re-render. Unregistering only clears the entry while it still
38
+ * owns it (last-registered wins across overlapping mounts).
39
+ */
40
+ export function registerDraftMessageActions(
41
+ messageId: string,
42
+ actions: DraftMessageActions,
43
+ ): () => void {
44
+ actionsByMessageId.set(messageId, actions)
45
+ notify(messageId)
46
+ return () => {
47
+ if (actionsByMessageId.get(messageId) === actions) {
48
+ actionsByMessageId.delete(messageId)
49
+ notify(messageId)
50
+ }
51
+ }
52
+ }
53
+
54
+ /**
55
+ * The mounted composer's actions for this draft, or null while none is
56
+ * mounted (disable the external button).
57
+ */
58
+ export function useDraftMessageActions(messageId: string): DraftMessageActions | null {
59
+ return useSyncExternalStore(
60
+ (listener) => {
61
+ let listeners = listenersByMessageId.get(messageId)
62
+ if (!listeners) {
63
+ listeners = new Set()
64
+ listenersByMessageId.set(messageId, listeners)
65
+ }
66
+ listeners.add(listener)
67
+ return () => {
68
+ listeners.delete(listener)
69
+ if (listeners.size === 0) listenersByMessageId.delete(messageId)
70
+ }
71
+ },
72
+ () => actionsByMessageId.get(messageId) ?? null,
73
+ () => null,
74
+ )
75
+ }
76
+
77
+ /** Test-only escape hatch — clears every registration and listener. */
78
+ export function __resetDraftMessageActions(): void {
79
+ actionsByMessageId.clear()
80
+ listenersByMessageId.clear()
81
+ }
@@ -0,0 +1,175 @@
1
+ import type { ContactAddress, Conversation, Message } from '@proteos/sdk'
2
+ import type { Meta, StoryObj } from '@storybook/react'
3
+
4
+ import { Button } from '../button'
5
+ import { useDraftMessageActions } from './draft-message-actions-store'
6
+ import { DraftMessageComposer } from './draft-message-composer'
7
+ import { useDraftMessageOutcome } from './draft-message-outcome-store'
8
+ import type { DraftMessageClient } from './types'
9
+
10
+ /* Self-contained mock client — every story runs without a backend. */
11
+
12
+ function fixtureMessage(overrides: Partial<Message> = {}): Message {
13
+ return {
14
+ id: 'draft-1',
15
+ org_id: 'org-1',
16
+ conversation_id: 'conv-1',
17
+ connection_id: 'conn-1',
18
+ channel: 'email',
19
+ direction: 'outbound',
20
+ external_message_id: '',
21
+ sender: { name: 'Ava (agent)' },
22
+ recipients: [
23
+ { external_id: 'alice@example.com', name: 'Alice Prieto', role: 'to' },
24
+ { external_id: 'bob@example.com', name: 'Bob Ortega', role: 'cc' },
25
+ ],
26
+ content: [
27
+ {
28
+ type: 'text',
29
+ text: 'Hi Alice,\n\nsharing the quarterly numbers ahead of our call — headline: +18% QoQ.\n\nBest,\nAva',
30
+ },
31
+ ],
32
+ status: 'draft',
33
+ occurred_at: new Date().toISOString(),
34
+ metadata: {},
35
+ error: '',
36
+ created_at: new Date().toISOString(),
37
+ created_by: { type: 'agent', id: 'agent-1' },
38
+ updated_at: new Date().toISOString(),
39
+ updated_by: { type: 'agent', id: 'agent-1' },
40
+ ...overrides,
41
+ }
42
+ }
43
+
44
+ const directory: ContactAddress[] = [
45
+ { name: 'Alice Prieto', value: 'alice@example.com' } as ContactAddress,
46
+ { name: 'Bob Ortega', value: 'bob@example.com' } as ContactAddress,
47
+ { name: 'Carol Nguyen', value: 'carol@example.com' } as ContactAddress,
48
+ ]
49
+
50
+ function client(
51
+ options: {
52
+ message?: Partial<Message>
53
+ conversation?: Partial<Conversation>
54
+ sendResult?: Partial<Message>
55
+ loadDelayMs?: number
56
+ } = {},
57
+ ): DraftMessageClient {
58
+ const message = fixtureMessage(options.message)
59
+ const conversation = {
60
+ id: 'conv-1',
61
+ subject: 'Quarterly numbers',
62
+ status: 'draft',
63
+ channel: message.channel,
64
+ ...options.conversation,
65
+ } as Conversation
66
+ const delay = <T,>(value: T): Promise<T> =>
67
+ new Promise((resolve) => setTimeout(() => resolve(value), options.loadDelayMs ?? 250))
68
+ return {
69
+ getMessage: () => delay(message),
70
+ getConversation: () => delay(conversation),
71
+ updateDraft: (_, patch) =>
72
+ delay(fixtureMessage({ ...options.message, content: patch.content })),
73
+ sendDraft: () =>
74
+ delay(fixtureMessage({ ...options.message, status: 'sent', ...options.sendResult })),
75
+ rejectDraft: () => delay(fixtureMessage({ ...options.message, status: 'rejected' })),
76
+ searchRecipients: (_, q) =>
77
+ delay(
78
+ directory.filter((address) => (address.name ?? '').toLowerCase().includes(q.toLowerCase())),
79
+ ),
80
+ }
81
+ }
82
+
83
+ const meta: Meta<typeof DraftMessageComposer> = {
84
+ title: 'Conversations/DraftMessageComposer',
85
+ component: DraftMessageComposer,
86
+ parameters: { layout: 'padded' },
87
+ }
88
+ export default meta
89
+
90
+ type Story = StoryObj<typeof DraftMessageComposer>
91
+
92
+ /** Originate email draft: subject + recipients editable. */
93
+ export const EmailDraft: Story = {
94
+ args: { messageId: 'draft-1', client: client() },
95
+ }
96
+
97
+ /** Reply draft into a live thread: subject locked (derived from the thread). */
98
+ export const EmailReplyDraft: Story = {
99
+ args: {
100
+ messageId: 'draft-1',
101
+ client: client({ conversation: { status: 'active', subject: 're: Quarterly numbers' } }),
102
+ },
103
+ }
104
+
105
+ /** Chat draft (Slack): body only, recipients read-only. */
106
+ export const ChatDraft: Story = {
107
+ args: {
108
+ messageId: 'draft-1',
109
+ client: client({
110
+ message: {
111
+ channel: 'slack',
112
+ recipients: [{ external_id: 'U123', name: 'Alice Prieto', role: 'to' }],
113
+ },
114
+ conversation: { status: 'active', subject: '', channel: 'slack' },
115
+ }),
116
+ },
117
+ }
118
+
119
+ /** The human sends, delivery fails — terminal failure surface. */
120
+ export const SendFails: Story = {
121
+ args: {
122
+ messageId: 'draft-1',
123
+ client: client({ sendResult: { status: 'failed', error: 'smtp 550 mailbox unavailable' } }),
124
+ },
125
+ }
126
+
127
+ /** Already decided elsewhere. */
128
+ export const AlreadyRejected: Story = {
129
+ args: { messageId: 'draft-1', client: client({ message: { status: 'rejected' } }) },
130
+ }
131
+
132
+ export const SlowLoad: Story = {
133
+ args: { messageId: 'draft-1', client: client({ loadDelayMs: 3000 }) },
134
+ }
135
+
136
+ /** Task-list embedding: hidden internal actions, an external Execute button
137
+ * drives send via useDraftMessageActions, outcome observed by a sibling. */
138
+ export const ExternalControls: Story = {
139
+ render: () => {
140
+ const storyClient = client()
141
+ function TaskRow() {
142
+ const actions = useDraftMessageActions('draft-1')
143
+ const outcome = useDraftMessageOutcome('draft-1')
144
+ return (
145
+ <div className="flex items-center gap-3 border border-rule rounded-[10px] px-3 py-2">
146
+ <span className="text-[13px] text-ink flex-1">Task: send the quarterly update</span>
147
+ <span className="font-mono text-[11px] text-ink-3">
148
+ {outcome?.type ?? 'pending review'}
149
+ </span>
150
+ <Button
151
+ size="sm"
152
+ isDisabled={!actions || actions.isBusy || !!outcome}
153
+ onPress={() => void actions?.send()}
154
+ >
155
+ Execute task
156
+ </Button>
157
+ <Button
158
+ size="sm"
159
+ variant="ghost"
160
+ isDisabled={!actions || actions.isBusy || !!outcome}
161
+ onPress={() => void actions?.reject()}
162
+ >
163
+ Discard
164
+ </Button>
165
+ </div>
166
+ )
167
+ }
168
+ return (
169
+ <div className="flex flex-col gap-3 max-w-2xl">
170
+ <TaskRow />
171
+ <DraftMessageComposer messageId="draft-1" client={storyClient} showActions={false} />
172
+ </div>
173
+ )
174
+ },
175
+ }
@@ -0,0 +1,276 @@
1
+ import type { Conversation, Message } from '@proteos/sdk'
2
+ import { render, screen, waitFor } from '@testing-library/react'
3
+ import userEvent from '@testing-library/user-event'
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
5
+
6
+ import { __resetDraftMessageActions, useDraftMessageActions } from './draft-message-actions-store'
7
+ import { DraftMessageComposer } from './draft-message-composer'
8
+ import { __resetDraftMessageOutcomes, useDraftMessageOutcome } from './draft-message-outcome-store'
9
+ import type { DraftMessageClient } from './types'
10
+
11
+ function draftMessage(overrides: Partial<Message> = {}): Message {
12
+ return {
13
+ id: 'draft-1',
14
+ org_id: 'org-1',
15
+ conversation_id: 'conv-1',
16
+ connection_id: 'conn-1',
17
+ channel: 'email',
18
+ direction: 'outbound',
19
+ external_message_id: '',
20
+ sender: { name: 'Ava Agent' },
21
+ recipients: [{ external_id: 'a@x.io', name: 'Alice', role: 'to' }],
22
+ content: [{ type: 'text', text: 'agent draft body' }],
23
+ status: 'draft',
24
+ occurred_at: '2026-07-22T10:00:00Z',
25
+ metadata: {},
26
+ error: '',
27
+ created_at: '2026-07-22T10:00:00Z',
28
+ created_by: { type: 'agent', id: 'agent-1' },
29
+ updated_at: '2026-07-22T10:00:00Z',
30
+ updated_by: { type: 'agent', id: 'agent-1' },
31
+ ...overrides,
32
+ }
33
+ }
34
+
35
+ function draftConversation(overrides: Partial<Conversation> = {}): Conversation {
36
+ return {
37
+ id: 'conv-1',
38
+ subject: 'quarterly numbers',
39
+ status: 'draft',
40
+ channel: 'email',
41
+ ...overrides,
42
+ } as Conversation
43
+ }
44
+
45
+ function mockClient(overrides: Partial<DraftMessageClient> = {}): DraftMessageClient {
46
+ return {
47
+ getMessage: vi.fn().mockResolvedValue(draftMessage()),
48
+ getConversation: vi.fn().mockResolvedValue(draftConversation()),
49
+ updateDraft: vi
50
+ .fn()
51
+ .mockImplementation((_, patch) => Promise.resolve(draftMessage({ content: patch.content }))),
52
+ sendDraft: vi.fn().mockResolvedValue(draftMessage({ status: 'sent' })),
53
+ rejectDraft: vi.fn().mockResolvedValue(draftMessage({ status: 'rejected' })),
54
+ searchRecipients: vi.fn().mockResolvedValue([]),
55
+ ...overrides,
56
+ }
57
+ }
58
+
59
+ function OutcomeProbe({ messageId }: { messageId: string }) {
60
+ const outcome = useDraftMessageOutcome(messageId)
61
+ return <output data-testid="outcome">{outcome?.type ?? 'none'}</output>
62
+ }
63
+
64
+ function ExternalControls({ messageId }: { messageId: string }) {
65
+ const actions = useDraftMessageActions(messageId)
66
+ return (
67
+ <button
68
+ type="button"
69
+ disabled={!actions || actions.isBusy}
70
+ onClick={() => void actions?.send()}
71
+ >
72
+ Execute task
73
+ </button>
74
+ )
75
+ }
76
+
77
+ describe('DraftMessageComposer', () => {
78
+ beforeEach(() => {
79
+ __resetDraftMessageOutcomes()
80
+ __resetDraftMessageActions()
81
+ })
82
+
83
+ it('loads the draft into the composer (email: body + subject)', async () => {
84
+ render(<DraftMessageComposer messageId="draft-1" client={mockClient()} />)
85
+ expect(await screen.findByDisplayValue('agent draft body')).toBeInTheDocument()
86
+ expect(screen.getByDisplayValue('quarterly numbers')).toBeInTheDocument()
87
+ expect(screen.getByText('Draft · awaiting review')).toBeInTheDocument()
88
+ })
89
+
90
+ it('renders terminal cards for already-decided drafts', async () => {
91
+ render(
92
+ <DraftMessageComposer
93
+ messageId="draft-1"
94
+ client={mockClient({
95
+ getMessage: vi.fn().mockResolvedValue(draftMessage({ status: 'rejected' })),
96
+ })}
97
+ />,
98
+ )
99
+ expect(await screen.findByText('This draft was rejected.')).toBeInTheDocument()
100
+ })
101
+
102
+ it('renders a load error with retry', async () => {
103
+ const getMessage = vi
104
+ .fn()
105
+ .mockRejectedValueOnce(new Error('boom'))
106
+ .mockResolvedValue(draftMessage())
107
+ render(<DraftMessageComposer messageId="draft-1" client={mockClient({ getMessage })} />)
108
+ expect(await screen.findByText(/boom/)).toBeInTheDocument()
109
+ await userEvent.click(screen.getByRole('button', { name: 'retry' }))
110
+ expect(await screen.findByDisplayValue('agent draft body')).toBeInTheDocument()
111
+ })
112
+
113
+ it('Save draft persists the edited body as one text block', async () => {
114
+ const client = mockClient()
115
+ render(<DraftMessageComposer messageId="draft-1" client={client} />)
116
+ const body = await screen.findByDisplayValue('agent draft body')
117
+ await userEvent.clear(body)
118
+ await userEvent.type(body, 'sharper body')
119
+ await userEvent.click(screen.getByRole('button', { name: 'Save draft' }))
120
+ await waitFor(() => expect(client.updateDraft).toHaveBeenCalledTimes(1))
121
+ const patch = vi.mocked(client.updateDraft).mock.calls[0]?.[1]
122
+ expect(patch?.content).toEqual([{ type: 'text', text: 'sharper body' }])
123
+ expect(patch?.subject).toBe('quarterly numbers')
124
+ })
125
+
126
+ it('Send persists dirty edits first, fires onSent and the outcome store', async () => {
127
+ const client = mockClient()
128
+ const calls: string[] = []
129
+ vi.mocked(client.updateDraft).mockImplementation(async (_, patch) => {
130
+ calls.push('update')
131
+ return draftMessage({ content: patch.content })
132
+ })
133
+ vi.mocked(client.sendDraft).mockImplementation(async () => {
134
+ calls.push('send')
135
+ return draftMessage({ status: 'sent' })
136
+ })
137
+ const onSent = vi.fn()
138
+ render(
139
+ <>
140
+ <DraftMessageComposer messageId="draft-1" client={client} onSent={onSent} />
141
+ <OutcomeProbe messageId="draft-1" />
142
+ </>,
143
+ )
144
+ const body = await screen.findByDisplayValue('agent draft body')
145
+ await userEvent.type(body, ' — edited')
146
+ await userEvent.click(screen.getByRole('button', { name: 'Send' }))
147
+
148
+ await screen.findByText('This draft was sent.')
149
+ expect(calls).toEqual(['update', 'send'])
150
+ expect(onSent).toHaveBeenCalledTimes(1)
151
+ expect(screen.getByTestId('outcome')).toHaveTextContent('sent')
152
+ })
153
+
154
+ it('a failed delivery is terminal with no callback and no outcome', async () => {
155
+ const client = mockClient({
156
+ sendDraft: vi.fn().mockResolvedValue(draftMessage({ status: 'failed', error: 'smtp 550' })),
157
+ })
158
+ const onSent = vi.fn()
159
+ render(
160
+ <>
161
+ <DraftMessageComposer messageId="draft-1" client={client} onSent={onSent} />
162
+ <OutcomeProbe messageId="draft-1" />
163
+ </>,
164
+ )
165
+ await screen.findByDisplayValue('agent draft body')
166
+ await userEvent.click(screen.getByRole('button', { name: 'Send' }))
167
+ expect(await screen.findByText(/Sending failed — smtp 550/)).toBeInTheDocument()
168
+ expect(onSent).not.toHaveBeenCalled()
169
+ expect(screen.getByTestId('outcome')).toHaveTextContent('none')
170
+ })
171
+
172
+ it('a transport failure keeps the edits and shows a banner', async () => {
173
+ const client = mockClient({ sendDraft: vi.fn().mockRejectedValue(new Error('network down')) })
174
+ render(<DraftMessageComposer messageId="draft-1" client={client} />)
175
+ const body = await screen.findByDisplayValue('agent draft body')
176
+ await userEvent.type(body, ' more')
177
+ await userEvent.click(screen.getByRole('button', { name: 'Send' }))
178
+ expect(await screen.findByText(/network down — your edits are kept/)).toBeInTheDocument()
179
+ expect(screen.getByDisplayValue('agent draft body more')).toBeInTheDocument()
180
+ })
181
+
182
+ it('removing every recipient ships explicit empty groups (clear, not keep)', async () => {
183
+ const client = mockClient()
184
+ render(<DraftMessageComposer messageId="draft-1" client={client} />)
185
+ await screen.findByDisplayValue('agent draft body')
186
+ // Remove the only To chip, then save.
187
+ await userEvent.click(screen.getByRole('button', { name: 'Remove' }))
188
+ await userEvent.click(screen.getByRole('button', { name: 'Save draft' }))
189
+ await waitFor(() => expect(client.updateDraft).toHaveBeenCalledTimes(1))
190
+ const patch = vi.mocked(client.updateDraft).mock.calls[0]?.[1]
191
+ expect(patch?.to).toEqual([])
192
+ expect(patch?.cc).toEqual([])
193
+ expect(patch?.bcc).toEqual([])
194
+ })
195
+
196
+ it('an originate draft with no recipients refuses to send', async () => {
197
+ const client = mockClient()
198
+ render(<DraftMessageComposer messageId="draft-1" client={client} showActions={false} />)
199
+ render(<ExternalControls messageId="draft-1" />)
200
+ await screen.findByDisplayValue('agent draft body')
201
+ await userEvent.click(screen.getByRole('button', { name: 'Remove' }))
202
+ const execute = screen.getByRole('button', { name: 'Execute task' })
203
+ await waitFor(() => expect(execute).toBeEnabled())
204
+ await userEvent.click(execute)
205
+ expect(await screen.findByText(/Add at least one recipient/)).toBeInTheDocument()
206
+ expect(client.sendDraft).not.toHaveBeenCalled()
207
+ })
208
+
209
+ it('Reject is a two-step confirm, fires onRejected and the outcome store', async () => {
210
+ const client = mockClient()
211
+ const onRejected = vi.fn()
212
+ render(
213
+ <>
214
+ <DraftMessageComposer messageId="draft-1" client={client} onRejected={onRejected} />
215
+ <OutcomeProbe messageId="draft-1" />
216
+ </>,
217
+ )
218
+ await screen.findByDisplayValue('agent draft body')
219
+ await userEvent.click(screen.getByRole('button', { name: 'Reject' }))
220
+ expect(client.rejectDraft).not.toHaveBeenCalled()
221
+ await userEvent.click(screen.getByRole('button', { name: 'Confirm reject' }))
222
+ await screen.findByText('This draft was rejected.')
223
+ expect(onRejected).toHaveBeenCalledTimes(1)
224
+ expect(screen.getByTestId('outcome')).toHaveTextContent('rejected')
225
+ })
226
+
227
+ it('chat drafts render without subject or recipient rows', async () => {
228
+ const client = mockClient({
229
+ getMessage: vi.fn().mockResolvedValue(draftMessage({ channel: 'slack' })),
230
+ getConversation: vi.fn().mockResolvedValue({
231
+ id: 'conv-1',
232
+ subject: '',
233
+ status: 'active',
234
+ channel: 'slack',
235
+ } as Conversation),
236
+ })
237
+ render(<DraftMessageComposer messageId="draft-1" client={client} />)
238
+ expect(await screen.findByDisplayValue('agent draft body')).toBeInTheDocument()
239
+ expect(screen.queryByPlaceholderText('Subject')).not.toBeInTheDocument()
240
+ })
241
+
242
+ it('showActions=false hides the action row and lets a sibling drive send', async () => {
243
+ const client = mockClient()
244
+ render(
245
+ <>
246
+ <DraftMessageComposer messageId="draft-1" client={client} showActions={false} />
247
+ <ExternalControls messageId="draft-1" />
248
+ <OutcomeProbe messageId="draft-1" />
249
+ </>,
250
+ )
251
+ await screen.findByDisplayValue('agent draft body')
252
+ expect(screen.queryByRole('button', { name: 'Reject' })).not.toBeInTheDocument()
253
+ expect(screen.queryByRole('button', { name: 'Save draft' })).not.toBeInTheDocument()
254
+ expect(screen.queryByRole('button', { name: 'Send' })).not.toBeInTheDocument()
255
+
256
+ const execute = screen.getByRole('button', { name: 'Execute task' })
257
+ await waitFor(() => expect(execute).toBeEnabled())
258
+ await userEvent.click(execute)
259
+ await screen.findByText('This draft was sent.')
260
+ expect(screen.getByTestId('outcome')).toHaveTextContent('sent')
261
+ })
262
+
263
+ it('unregisters its actions after reaching a terminal state', async () => {
264
+ const client = mockClient()
265
+ render(
266
+ <>
267
+ <DraftMessageComposer messageId="draft-1" client={client} />
268
+ <ExternalControls messageId="draft-1" />
269
+ </>,
270
+ )
271
+ await screen.findByDisplayValue('agent draft body')
272
+ await userEvent.click(screen.getByRole('button', { name: 'Send' }))
273
+ await screen.findByText('This draft was sent.')
274
+ expect(screen.getByRole('button', { name: 'Execute task' })).toBeDisabled()
275
+ })
276
+ })