@paramms/chat-widget 0.1.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,93 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi } from 'vitest'
3
+ import { asUserId, asConversationId } from '../protocol/index.js'
4
+ import { ChatStore } from '../store.js'
5
+ import { Renderer } from '../renderer.js'
6
+
7
+ const me = 'alice'
8
+ const cid = asConversationId('c1')
9
+
10
+ function openedConv() {
11
+ return {
12
+ type: 'opened' as const,
13
+ conversation: {
14
+ id: cid, tenantId: 't' as never, profileId: 'p' as never,
15
+ guestId: asUserId(me), participants: [asUserId(me)],
16
+ state: 'open', lastSeq: 0, createdAt: 0, updatedAt: 0,
17
+ },
18
+ }
19
+ }
20
+
21
+ function setup() {
22
+ const root = document.createElement('div')
23
+ const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn() }
24
+ const store = new ChatStore(asUserId(me))
25
+ const r = new Renderer(root, me, h)
26
+ store.apply(openedConv())
27
+ r.render(store)
28
+ const textarea = root.querySelector('textarea') as HTMLTextAreaElement
29
+ return { root, h, textarea }
30
+ }
31
+
32
+ describe('Renderer — IME composition guard (CJK input)', () => {
33
+ it('does NOT send when Enter confirms an IME composition candidate', () => {
34
+ const { h, textarea } = setup()
35
+ textarea.value = '음식'
36
+ // Simulates the keydown fired by the IME when the user presses Enter to
37
+ // CONFIRM a composed Korean/Japanese/Chinese candidate — not to submit
38
+ // the message. isComposing: true is how browsers signal this.
39
+ const ev = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
40
+ Object.defineProperty(ev, 'isComposing', { value: true })
41
+ textarea.dispatchEvent(ev)
42
+ expect(h.onSend).not.toHaveBeenCalled()
43
+ })
44
+
45
+ it('falls back to keyCode 229 for IMEs that do not set isComposing', () => {
46
+ const { h, textarea } = setup()
47
+ textarea.value = '음식'
48
+ const ev = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
49
+ Object.defineProperty(ev, 'isComposing', { value: false })
50
+ Object.defineProperty(ev, 'keyCode', { value: 229 })
51
+ textarea.dispatchEvent(ev)
52
+ expect(h.onSend).not.toHaveBeenCalled()
53
+ })
54
+
55
+ it('still sends normally on a real (non-IME) Enter press', () => {
56
+ const { h, textarea } = setup()
57
+ textarea.value = 'hello'
58
+ const ev = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
59
+ Object.defineProperty(ev, 'isComposing', { value: false })
60
+ textarea.dispatchEvent(ev)
61
+ expect(h.onSend).toHaveBeenCalledTimes(1)
62
+ expect(h.onSend).toHaveBeenCalledWith('hello')
63
+ })
64
+
65
+ it('does not produce a second send for the real Enter that follows an IME-confirm Enter', () => {
66
+ // Reproduces the exact reported bug: typing "음식" via IME, where Enter
67
+ // fires once to confirm the composition and again to submit — only the
68
+ // second, real Enter should trigger a send, and with the FULL text.
69
+ const { h, textarea } = setup()
70
+
71
+ textarea.value = '음식'
72
+ const composingEnter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
73
+ Object.defineProperty(composingEnter, 'isComposing', { value: true })
74
+ textarea.dispatchEvent(composingEnter)
75
+
76
+ // Real submit keystroke, composition has ended.
77
+ const realEnter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
78
+ Object.defineProperty(realEnter, 'isComposing', { value: false })
79
+ textarea.dispatchEvent(realEnter)
80
+
81
+ expect(h.onSend).toHaveBeenCalledTimes(1)
82
+ expect(h.onSend).toHaveBeenCalledWith('음식')
83
+ })
84
+
85
+ it('shift+Enter still inserts a newline (not a send) regardless of composition state', () => {
86
+ const { h, textarea } = setup()
87
+ textarea.value = 'line one'
88
+ const ev = new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, bubbles: true, cancelable: true })
89
+ Object.defineProperty(ev, 'isComposing', { value: false })
90
+ textarea.dispatchEvent(ev)
91
+ expect(h.onSend).not.toHaveBeenCalled()
92
+ })
93
+ })
@@ -0,0 +1,58 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi } from 'vitest'
3
+ import { asUserId, asConversationId, asMessageId, asActionId } from '../protocol/index.js'
4
+ import { ChatStore } from '../store.js'
5
+ import { Renderer } from '../renderer.js'
6
+
7
+ const me = 'alice'
8
+ const cid = asConversationId('c1')
9
+
10
+ describe('Renderer (jsdom)', () => {
11
+ it('renders messages and actions, and wires send/invoke', () => {
12
+ const root = document.createElement('div')
13
+ const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn() }
14
+ const store = new ChatStore(asUserId(me))
15
+ const r = new Renderer(root, me, h)
16
+
17
+ store.apply({ type: 'opened', conversation: { id: cid, tenantId: 't' as never, profileId: 'p' as never, guestId: asUserId(me), participants: [asUserId(me)], state: 'open', lastSeq: 0, createdAt: 0, updatedAt: 0 } })
18
+ store.apply({ type: 'message', message: { id: asMessageId('s1'), conversationId: cid, seq: 1, senderId: asUserId('bob'), senderRole: 'agent', content: { kind: 'text', text: 'Welcome!' }, ts: 1 } })
19
+ store.apply({ type: 'manifest', conversationId: cid, version: 1, actions: [{ id: asActionId('ai'), label: 'Ask AI', audience: 'guest', surface: 'toolbar' }] })
20
+ r.render(store)
21
+
22
+ expect(root.querySelector('.ocw-scroll')!.textContent).toContain('Welcome!')
23
+ const actionBtn = root.querySelector('.ocw-chip') as HTMLButtonElement
24
+ expect(actionBtn.textContent).toBe('Ask AI')
25
+
26
+ actionBtn.click()
27
+ expect(h.onInvoke).toHaveBeenCalledWith('ai')
28
+
29
+ // read receipt fired for the other party's message
30
+ expect(h.onReadUpTo).toHaveBeenCalledWith(1)
31
+
32
+ const input = root.querySelector('.ocw-input textarea') as HTMLTextAreaElement
33
+ input.value = 'hello there'
34
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
35
+ expect(h.onSend).toHaveBeenCalledWith('hello there')
36
+ })
37
+
38
+ it('form action opens an inline form with typed inputs and invokes with values', () => {
39
+ const root = document.createElement('div')
40
+ const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn() }
41
+ const store = new ChatStore(asUserId(me))
42
+ const r = new Renderer(root, me, h)
43
+ store.apply({ type: 'opened', conversation: { id: cid, tenantId: 't' as never, profileId: 'p' as never, guestId: asUserId(me), participants: [asUserId(me)], state: 'open', lastSeq: 0, createdAt: 0, updatedAt: 0 } })
44
+ store.apply({ type: 'manifest', conversationId: cid, version: 1, actions: [
45
+ { id: asActionId('appointment'), label: 'Schedule', audience: 'guest', surface: 'toolbar',
46
+ input: [{ name: 'time', label: 'Time', type: 'date', required: true }] },
47
+ ] })
48
+ r.render(store)
49
+
50
+ ;(root.querySelector('.ocw-chip') as HTMLButtonElement).click()
51
+ const dateInput = root.querySelector('.ocw-form-input') as HTMLInputElement
52
+ expect(dateInput.type).toBe('datetime-local') // real date+time picker, not a prompt
53
+ dateInput.value = '2026-06-10T14:30'
54
+ ;(root.querySelector('.ocw-form-submit') as HTMLButtonElement).click()
55
+ expect(h.onInvoke).toHaveBeenCalledWith('appointment', { time: '2026-06-10T14:30' })
56
+ expect(root.querySelector('.ocw-form')).toBeNull() // form closes after submit
57
+ })
58
+ })
@@ -0,0 +1,441 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi } from 'vitest'
3
+ import { asUserId, asConversationId, asMessageId, asActionId } from '../protocol/index.js'
4
+ import { ChatStore } from '../store.js'
5
+ import { Renderer } from '../renderer.js'
6
+
7
+ const me = 'alice'
8
+ const cid = asConversationId('c1')
9
+
10
+ function openedConv() {
11
+ return {
12
+ type: 'opened' as const,
13
+ conversation: {
14
+ id: cid, tenantId: 't' as never, profileId: 'p' as never,
15
+ guestId: asUserId(me), participants: [asUserId(me)],
16
+ state: 'open', lastSeq: 0, createdAt: 0, updatedAt: 0,
17
+ },
18
+ }
19
+ }
20
+
21
+ // ── Reaction UI ───────────────────────────────────────────────────────────────
22
+
23
+ describe('Renderer — reaction UI', () => {
24
+ it('renders existing reaction pills from message data', () => {
25
+ const root = document.createElement('div')
26
+ const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onReact: vi.fn() }
27
+ const store = new ChatStore(asUserId(me))
28
+ const r = new Renderer(root, me, h)
29
+ store.apply(openedConv())
30
+ store.apply({
31
+ type: 'message', message: {
32
+ id: asMessageId('m1'), conversationId: cid, seq: 1,
33
+ senderId: asUserId('bob'), senderRole: 'agent',
34
+ content: { kind: 'text', text: 'Hi!' }, ts: 1,
35
+ reactions: { '👍': [asUserId('carol')], '❤️': [asUserId('alice'), asUserId('dave')] },
36
+ },
37
+ })
38
+ r.render(store)
39
+
40
+ const pills = root.querySelectorAll('.ocw-react-pill')
41
+ expect(pills.length).toBe(2)
42
+ expect(Array.from(pills).map(p => p.textContent)).toEqual(
43
+ expect.arrayContaining(['👍 1', '❤️ 2']),
44
+ )
45
+ })
46
+
47
+ it('marks my own reaction pill with "mine" class', () => {
48
+ const root = document.createElement('div')
49
+ const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onReact: vi.fn() }
50
+ const store = new ChatStore(asUserId(me))
51
+ const r = new Renderer(root, me, h)
52
+ store.apply(openedConv())
53
+ store.apply({
54
+ type: 'message', message: {
55
+ id: asMessageId('m1'), conversationId: cid, seq: 1,
56
+ senderId: asUserId('bob'), senderRole: 'agent',
57
+ content: { kind: 'text', text: 'Hi!' }, ts: 1,
58
+ reactions: { '👍': [asUserId(me)] },
59
+ },
60
+ })
61
+ r.render(store)
62
+ const pill = root.querySelector('.ocw-react-pill') as HTMLElement
63
+ expect(pill.classList.contains('mine')).toBe(true)
64
+ })
65
+
66
+ it('clicking an existing reaction pill calls onReact with remove=true if already reacted', () => {
67
+ const root = document.createElement('div')
68
+ const onReact = vi.fn()
69
+ const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onReact }
70
+ const store = new ChatStore(asUserId(me))
71
+ const r = new Renderer(root, me, h)
72
+ store.apply(openedConv())
73
+ store.apply({
74
+ type: 'message', message: {
75
+ id: asMessageId('m1'), conversationId: cid, seq: 1,
76
+ senderId: asUserId('bob'), senderRole: 'agent',
77
+ content: { kind: 'text', text: 'Hi!' }, ts: 1,
78
+ reactions: { '👍': [asUserId(me)] },
79
+ },
80
+ })
81
+ r.render(store)
82
+ ;(root.querySelector('.ocw-react-pill') as HTMLButtonElement).click()
83
+ expect(onReact).toHaveBeenCalledWith('m1', '👍', true) // remove=true (was mine)
84
+ })
85
+
86
+ it('shows add-reaction (+) button for non-deleted messages with seq > 0', () => {
87
+ const root = document.createElement('div')
88
+ const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onReact: vi.fn() }
89
+ const store = new ChatStore(asUserId(me))
90
+ const r = new Renderer(root, me, h)
91
+ store.apply(openedConv())
92
+ store.apply({ type: 'message', message: { id: asMessageId('m2'), conversationId: cid, seq: 1, senderId: asUserId('bob'), senderRole: 'agent', content: { kind: 'text', text: 'yo' }, ts: 1 } })
93
+ r.render(store)
94
+ expect(root.querySelector('.ocw-react-btn')).not.toBeNull()
95
+ })
96
+
97
+ it('clicking + button toggles picker visibility', () => {
98
+ const root = document.createElement('div')
99
+ const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onReact: vi.fn() }
100
+ const store = new ChatStore(asUserId(me))
101
+ const r = new Renderer(root, me, h)
102
+ store.apply(openedConv())
103
+ store.apply({ type: 'message', message: { id: asMessageId('m3'), conversationId: cid, seq: 1, senderId: asUserId('bob'), senderRole: 'agent', content: { kind: 'text', text: 'yo' }, ts: 1 } })
104
+ r.render(store)
105
+ const addBtn = root.querySelector('.ocw-react-btn') as HTMLButtonElement
106
+ const picker = root.querySelector('.ocw-react-picker') as HTMLElement
107
+ expect(picker.style.display).toBe('none')
108
+ addBtn.click()
109
+ expect(picker.style.display).toBe('flex')
110
+ })
111
+
112
+ it('clicking a picker emoji calls onReact with remove=false (new reaction)', () => {
113
+ const root = document.createElement('div')
114
+ const onReact = vi.fn()
115
+ const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onReact }
116
+ const store = new ChatStore(asUserId(me))
117
+ const r = new Renderer(root, me, h)
118
+ store.apply(openedConv())
119
+ store.apply({ type: 'message', message: { id: asMessageId('m4'), conversationId: cid, seq: 2, senderId: asUserId('bob'), senderRole: 'agent', content: { kind: 'text', text: 'hello' }, ts: 2 } })
120
+ r.render(store)
121
+ ;(root.querySelector('.ocw-react-btn') as HTMLButtonElement).click()
122
+ const pickerBtns = root.querySelectorAll('.ocw-react-picker button')
123
+ expect(pickerBtns.length).toBeGreaterThan(0)
124
+ ;(pickerBtns[0] as HTMLButtonElement).click()
125
+ expect(onReact).toHaveBeenCalledWith('m4', expect.any(String), false)
126
+ })
127
+
128
+ it('does not render reaction UI when onReact is not provided', () => {
129
+ const root = document.createElement('div')
130
+ const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn() }
131
+ const store = new ChatStore(asUserId(me))
132
+ const r = new Renderer(root, me, h)
133
+ store.apply(openedConv())
134
+ store.apply({
135
+ type: 'message', message: {
136
+ id: asMessageId('m5'), conversationId: cid, seq: 1,
137
+ senderId: asUserId('bob'), senderRole: 'agent',
138
+ content: { kind: 'text', text: 'Hi!' }, ts: 1,
139
+ reactions: { '👍': [asUserId('carol')] },
140
+ },
141
+ })
142
+ r.render(store)
143
+ expect(root.querySelector('.ocw-react-btn')).toBeNull()
144
+ expect(root.querySelector('.ocw-react-pill')).toBeNull()
145
+ // Falls back to plain text reaction display
146
+ expect(root.querySelector('.ocw-react')?.textContent).toContain('👍')
147
+ })
148
+ })
149
+
150
+ // ── CSAT panel ────────────────────────────────────────────────────────────────
151
+
152
+ describe('Renderer — CSAT panel', () => {
153
+ it('shows star rating panel when state is terminal (resolved)', () => {
154
+ const root = document.createElement('div')
155
+ const onCsat = vi.fn()
156
+ const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onCsat }
157
+ const store = new ChatStore(asUserId(me))
158
+ const r = new Renderer(root, me, h)
159
+ store.apply({ type: 'opened', conversation: { id: cid, tenantId: 't' as never, profileId: 'p' as never, guestId: asUserId(me), participants: [asUserId(me)], state: 'resolved', lastSeq: 1, createdAt: 0, updatedAt: 0 } })
160
+ store.apply({ type: 'message', message: { id: asMessageId('m1'), conversationId: cid, seq: 1, senderId: asUserId('bob'), senderRole: 'agent', content: { kind: 'text', text: 'Done!' }, ts: 1 } })
161
+ r.render(store)
162
+ const panel = root.querySelector('.ocw-csat') as HTMLElement
163
+ expect(panel.style.display).not.toBe('none')
164
+ expect(panel.querySelectorAll('.ocw-csat-star').length).toBe(5)
165
+ })
166
+
167
+ it('does not show CSAT when state is not terminal', () => {
168
+ const root = document.createElement('div')
169
+ const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onCsat: vi.fn() }
170
+ const store = new ChatStore(asUserId(me))
171
+ const r = new Renderer(root, me, h)
172
+ store.apply({ type: 'opened', conversation: { id: cid, tenantId: 't' as never, profileId: 'p' as never, guestId: asUserId(me), participants: [asUserId(me)], state: 'open', lastSeq: 0, createdAt: 0, updatedAt: 0 } })
173
+ r.render(store)
174
+ const panel = root.querySelector('.ocw-csat') as HTMLElement
175
+ expect(panel.style.display).toBe('none')
176
+ })
177
+
178
+ it('clicking a star calls onCsat with the correct score and replaces panel', () => {
179
+ const root = document.createElement('div')
180
+ const onCsat = vi.fn()
181
+ const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onCsat }
182
+ const store = new ChatStore(asUserId(me))
183
+ const r = new Renderer(root, me, h)
184
+ store.apply({ type: 'opened', conversation: { id: cid, tenantId: 't' as never, profileId: 'p' as never, guestId: asUserId(me), participants: [asUserId(me)], state: 'resolved', lastSeq: 1, createdAt: 0, updatedAt: 0 } })
185
+ store.apply({ type: 'message', message: { id: asMessageId('m1'), conversationId: cid, seq: 1, senderId: asUserId('bob'), senderRole: 'agent', content: { kind: 'text', text: 'Done!' }, ts: 1 } })
186
+ r.render(store)
187
+
188
+ const stars = root.querySelectorAll('.ocw-csat-star')
189
+ ;(stars[3] as HTMLButtonElement).click() // 4th star = score 4
190
+ expect(onCsat).toHaveBeenCalledWith(4)
191
+ // Panel replaced with thank-you
192
+ expect(root.querySelector('.ocw-csat')!.textContent).toContain('4★')
193
+ expect(root.querySelector('.ocw-csat-star')).toBeNull()
194
+ })
195
+
196
+ it('does not show CSAT when onCsat handler not provided', () => {
197
+ const root = document.createElement('div')
198
+ const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn() }
199
+ const store = new ChatStore(asUserId(me))
200
+ const r = new Renderer(root, me, h)
201
+ store.apply({ type: 'opened', conversation: { id: cid, tenantId: 't' as never, profileId: 'p' as never, guestId: asUserId(me), participants: [asUserId(me)], state: 'resolved', lastSeq: 1, createdAt: 0, updatedAt: 0 } })
202
+ store.apply({ type: 'message', message: { id: asMessageId('m1'), conversationId: cid, seq: 1, senderId: asUserId('bob'), senderRole: 'agent', content: { kind: 'text', text: 'Done!' }, ts: 1 } })
203
+ r.render(store)
204
+ expect((root.querySelector('.ocw-csat') as HTMLElement).style.display).toBe('none')
205
+ })
206
+
207
+ it('CSAT does not show again after being submitted', () => {
208
+ const root = document.createElement('div')
209
+ const onCsat = vi.fn()
210
+ const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onCsat }
211
+ const store = new ChatStore(asUserId(me))
212
+ const r = new Renderer(root, me, h)
213
+ store.apply({ type: 'opened', conversation: { id: cid, tenantId: 't' as never, profileId: 'p' as never, guestId: asUserId(me), participants: [asUserId(me)], state: 'resolved', lastSeq: 1, createdAt: 0, updatedAt: 0 } })
214
+ store.apply({ type: 'message', message: { id: asMessageId('m1'), conversationId: cid, seq: 1, senderId: asUserId('bob'), senderRole: 'agent', content: { kind: 'text', text: 'Done!' }, ts: 1 } })
215
+ r.render(store)
216
+ ;(root.querySelectorAll('.ocw-csat-star')[0] as HTMLButtonElement).click()
217
+ // Re-render: CSAT should remain in thank-you state, not re-show stars
218
+ r.render(store)
219
+ expect(root.querySelector('.ocw-csat-star')).toBeNull()
220
+ expect(onCsat).toHaveBeenCalledTimes(1)
221
+ })
222
+
223
+ it('shows for other terminal states: sold, closed, issued, checked_out', () => {
224
+ for (const state of ['sold', 'closed', 'issued', 'checked_out']) {
225
+ const root = document.createElement('div')
226
+ const onCsat = vi.fn()
227
+ const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onCsat }
228
+ const store = new ChatStore(asUserId(me))
229
+ const r = new Renderer(root, me, h)
230
+ store.apply({ type: 'opened', conversation: { id: cid, tenantId: 't' as never, profileId: 'p' as never, guestId: asUserId(me), participants: [asUserId(me)], state, lastSeq: 1, createdAt: 0, updatedAt: 0 } })
231
+ store.apply({ type: 'message', message: { id: asMessageId('mx'), conversationId: cid, seq: 1, senderId: asUserId('agent'), senderRole: 'agent', content: { kind: 'text', text: 'done' }, ts: 1 } })
232
+ r.render(store)
233
+ expect((root.querySelector('.ocw-csat') as HTMLElement).style.display).not.toBe('none'), `CSAT should show for state="${state}"`
234
+ }
235
+ })
236
+ })
237
+
238
+ // ── Select field type ─────────────────────────────────────────────────────────
239
+
240
+ describe('Renderer — select field in form actions', () => {
241
+ it('renders a <select> element for select-type fields', () => {
242
+ const root = document.createElement('div')
243
+ const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn() }
244
+ const store = new ChatStore(asUserId(me))
245
+ const r = new Renderer(root, me, h)
246
+ store.apply(openedConv())
247
+ store.apply({
248
+ type: 'manifest', conversationId: cid, version: 1, actions: [
249
+ {
250
+ id: asActionId('book'), label: 'Book Room', audience: 'guest', surface: 'toolbar',
251
+ input: [
252
+ { name: 'room_type', label: 'Room Type', type: 'select', required: true, options: ['Standard', 'Deluxe', 'Suite'] },
253
+ ],
254
+ },
255
+ ],
256
+ })
257
+ r.render(store)
258
+ ;(root.querySelector('.ocw-chip') as HTMLButtonElement).click()
259
+
260
+ const sel = root.querySelector('.ocw-form-input') as HTMLSelectElement
261
+ expect(sel.tagName).toBe('SELECT')
262
+ const options = Array.from(sel.options).map(o => o.value)
263
+ expect(options).toEqual(expect.arrayContaining(['Standard', 'Deluxe', 'Suite']))
264
+ })
265
+
266
+ it('select field invokes with chosen value on submit', () => {
267
+ const root = document.createElement('div')
268
+ const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn() }
269
+ const store = new ChatStore(asUserId(me))
270
+ const r = new Renderer(root, me, h)
271
+ store.apply(openedConv())
272
+ store.apply({
273
+ type: 'manifest', conversationId: cid, version: 1, actions: [
274
+ {
275
+ id: asActionId('tier'), label: 'Choose Tier', audience: 'guest', surface: 'toolbar',
276
+ input: [{ name: 'tier', label: 'Tier', type: 'select', required: true, options: ['Basic', 'Pro', 'Enterprise'] }],
277
+ },
278
+ ],
279
+ })
280
+ r.render(store)
281
+ ;(root.querySelector('.ocw-chip') as HTMLButtonElement).click()
282
+
283
+ const sel = root.querySelector('.ocw-form-input') as HTMLSelectElement
284
+ sel.value = 'Pro'
285
+ ;(root.querySelector('.ocw-form-submit') as HTMLButtonElement).click()
286
+ expect(h.onInvoke).toHaveBeenCalledWith('tier', { tier: 'Pro' })
287
+ })
288
+
289
+ it('mixes select and text fields in the same form', () => {
290
+ const root = document.createElement('div')
291
+ const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn() }
292
+ const store = new ChatStore(asUserId(me))
293
+ const r = new Renderer(root, me, h)
294
+ store.apply(openedConv())
295
+ store.apply({
296
+ type: 'manifest', conversationId: cid, version: 1, actions: [
297
+ {
298
+ id: asActionId('booking'), label: 'Book', audience: 'guest', surface: 'toolbar',
299
+ input: [
300
+ { name: 'type', label: 'Type', type: 'select', required: true, options: ['A', 'B'] },
301
+ { name: 'note', label: 'Note', type: 'text', required: false },
302
+ ],
303
+ },
304
+ ],
305
+ })
306
+ r.render(store)
307
+ ;(root.querySelector('.ocw-chip') as HTMLButtonElement).click()
308
+
309
+ const formInputs = root.querySelectorAll('.ocw-form-input')
310
+ expect(formInputs.length).toBe(2)
311
+ expect(formInputs[0]!.tagName).toBe('SELECT')
312
+ expect(formInputs[1]!.tagName).toBe('INPUT')
313
+ })
314
+ })
315
+
316
+ // ── History / load-more ───────────────────────────────────────────────────────
317
+
318
+ describe('Renderer — load-more history', () => {
319
+ it('shows load-more button when store.hasMoreHistory and onLoadMore provided', () => {
320
+ const root = document.createElement('div')
321
+ const onLoadMore = vi.fn()
322
+ const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onLoadMore }
323
+ const store = new ChatStore(asUserId(me))
324
+ const r = new Renderer(root, me, h)
325
+ store.apply(openedConv())
326
+ // Fake hasMoreHistory by injecting a synthetic history frame
327
+ store.apply({ type: 'history', conversationId: cid, messages: [], hasMore: true })
328
+ r.render(store)
329
+ const btn = root.querySelector('.ocw-load-more') as HTMLButtonElement
330
+ expect(btn).not.toBeNull()
331
+ btn.click()
332
+ expect(onLoadMore).toHaveBeenCalledTimes(1)
333
+ })
334
+
335
+ it('hides load-more button when hasMoreHistory is false', () => {
336
+ const root = document.createElement('div')
337
+ const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onLoadMore: vi.fn() }
338
+ const store = new ChatStore(asUserId(me))
339
+ const r = new Renderer(root, me, h)
340
+ store.apply(openedConv())
341
+ store.apply({ type: 'history', conversationId: cid, messages: [], hasMore: false })
342
+ r.render(store)
343
+ expect(root.querySelector('.ocw-load-more')).toBeNull()
344
+ })
345
+
346
+ it('hides load-more button when onLoadMore not provided', () => {
347
+ const root = document.createElement('div')
348
+ const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn() }
349
+ const store = new ChatStore(asUserId(me))
350
+ const r = new Renderer(root, me, h)
351
+ store.apply(openedConv())
352
+ store.apply({ type: 'history', conversationId: cid, messages: [], hasMore: true })
353
+ r.render(store)
354
+ expect(root.querySelector('.ocw-load-more')).toBeNull()
355
+ })
356
+ })
357
+
358
+ // ── Edit / delete context menu ────────────────────────────────────────────────
359
+
360
+ describe('Renderer — edit/delete context menu', () => {
361
+ it('shows edit and delete buttons on own non-deleted messages', () => {
362
+ const root = document.createElement('div')
363
+ const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onEdit: vi.fn(), onDelete: vi.fn() }
364
+ const store = new ChatStore(asUserId(me))
365
+ const r = new Renderer(root, me, h)
366
+ store.apply(openedConv())
367
+ store.apply({ type: 'message', message: { id: asMessageId('m1'), conversationId: cid, seq: 1, senderId: asUserId(me), senderRole: 'guest', content: { kind: 'text', text: 'my msg' }, ts: 1 } })
368
+ r.render(store)
369
+ const menu = root.querySelector('.ocw-msg-menu')
370
+ expect(menu).not.toBeNull()
371
+ expect(menu!.querySelectorAll('button').length).toBe(2)
372
+ })
373
+
374
+ it('delete button calls onDelete with message id', () => {
375
+ const root = document.createElement('div')
376
+ const onDelete = vi.fn()
377
+ const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onDelete }
378
+ const store = new ChatStore(asUserId(me))
379
+ const r = new Renderer(root, me, h)
380
+ store.apply(openedConv())
381
+ store.apply({ type: 'message', message: { id: asMessageId('m2'), conversationId: cid, seq: 2, senderId: asUserId(me), senderRole: 'guest', content: { kind: 'text', text: 'bye' }, ts: 2 } })
382
+ r.render(store)
383
+ const delBtn = root.querySelector('.ocw-msg-menu .del') as HTMLButtonElement
384
+ expect(delBtn).not.toBeNull()
385
+ delBtn.click()
386
+ expect(onDelete).toHaveBeenCalledWith('m2')
387
+ })
388
+
389
+ it('does not show context menu on other users messages', () => {
390
+ const root = document.createElement('div')
391
+ const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn(), onEdit: vi.fn(), onDelete: vi.fn() }
392
+ const store = new ChatStore(asUserId(me))
393
+ const r = new Renderer(root, me, h)
394
+ store.apply(openedConv())
395
+ store.apply({ type: 'message', message: { id: asMessageId('m3'), conversationId: cid, seq: 1, senderId: asUserId('bob'), senderRole: 'agent', content: { kind: 'text', text: 'hi' }, ts: 1 } })
396
+ r.render(store)
397
+ expect(root.querySelector('.ocw-msg-menu')).toBeNull()
398
+ })
399
+
400
+ it('does not show context menu when handlers not provided', () => {
401
+ const root = document.createElement('div')
402
+ const h = { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn() }
403
+ const store = new ChatStore(asUserId(me))
404
+ const r = new Renderer(root, me, h)
405
+ store.apply(openedConv())
406
+ store.apply({ type: 'message', message: { id: asMessageId('m4'), conversationId: cid, seq: 1, senderId: asUserId(me), senderRole: 'guest', content: { kind: 'text', text: 'hi' }, ts: 1 } })
407
+ r.render(store)
408
+ expect(root.querySelector('.ocw-msg-menu')).toBeNull()
409
+ })
410
+ })
411
+
412
+ // ── Connection status indicator ───────────────────────────────────────────────
413
+
414
+ describe('Renderer — connection status', () => {
415
+ it('setConnStatus("connecting") shows connecting label', () => {
416
+ const root = document.createElement('div')
417
+ const r = new Renderer(root, me, { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn() })
418
+ r.setConnStatus('connecting')
419
+ const el = root.querySelector('.ocw-conn-status') as HTMLElement
420
+ expect(el.style.display).not.toBe('none')
421
+ expect(el.textContent).toContain('connecting')
422
+ })
423
+
424
+ it('setConnStatus("reconnecting") shows warn class', () => {
425
+ const root = document.createElement('div')
426
+ const r = new Renderer(root, me, { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn() })
427
+ r.setConnStatus('reconnecting')
428
+ const el = root.querySelector('.ocw-conn-status') as HTMLElement
429
+ expect(el.classList.contains('warn')).toBe(true)
430
+ expect(el.textContent).toContain('reconnecting')
431
+ })
432
+
433
+ it('setConnStatus("open") hides the indicator', () => {
434
+ const root = document.createElement('div')
435
+ const r = new Renderer(root, me, { onSend: vi.fn(), onInvoke: vi.fn(), onTyping: vi.fn(), onReadUpTo: vi.fn() })
436
+ r.setConnStatus('connecting')
437
+ r.setConnStatus('open')
438
+ const el = root.querySelector('.ocw-conn-status') as HTMLElement
439
+ expect(el.style.display).toBe('none')
440
+ })
441
+ })