@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.
- package/dist/index.cjs +1302 -516
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +259 -1
- package/dist/index.d.ts +259 -1
- package/dist/index.js +1292 -520
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/draft-message-composer/create-draft-message-client.ts +27 -0
- package/src/components/draft-message-composer/draft-message-actions-store.test.ts +62 -0
- package/src/components/draft-message-composer/draft-message-actions-store.ts +81 -0
- package/src/components/draft-message-composer/draft-message-composer.stories.tsx +175 -0
- package/src/components/draft-message-composer/draft-message-composer.test.tsx +276 -0
- package/src/components/draft-message-composer/draft-message-composer.tsx +470 -0
- package/src/components/draft-message-composer/draft-message-outcome-store.test.ts +59 -0
- package/src/components/draft-message-composer/draft-message-outcome-store.ts +56 -0
- package/src/components/draft-message-composer/index.ts +19 -0
- package/src/components/draft-message-composer/types.ts +32 -0
- package/src/components/message-composer/index.ts +13 -0
- package/src/components/message-composer/message-composer.stories.tsx +155 -0
- package/src/components/message-composer/message-composer.test.tsx +320 -0
- package/src/components/message-composer/message-composer.tsx +480 -0
- package/src/components/recipient-select/index.ts +2 -0
- package/src/components/recipient-select/recipient-select.test.tsx +139 -0
- package/src/components/recipient-select/recipient-select.tsx +94 -0
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proteos/ui",
|
|
3
|
-
"version": "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.
|
|
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
|
+
})
|