@actuate-media/cms-admin 0.12.0 → 0.13.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 (157) hide show
  1. package/dist/__tests__/components/comment-mark.test.d.ts +2 -0
  2. package/dist/__tests__/components/comment-mark.test.d.ts.map +1 -0
  3. package/dist/__tests__/components/comment-mark.test.js +89 -0
  4. package/dist/__tests__/components/comment-mark.test.js.map +1 -0
  5. package/dist/__tests__/components/comment-side-panel.test.d.ts +2 -0
  6. package/dist/__tests__/components/comment-side-panel.test.d.ts.map +1 -0
  7. package/dist/__tests__/components/comment-side-panel.test.js +291 -0
  8. package/dist/__tests__/components/comment-side-panel.test.js.map +1 -0
  9. package/dist/__tests__/components/notification-bell.test.d.ts +2 -0
  10. package/dist/__tests__/components/notification-bell.test.d.ts.map +1 -0
  11. package/dist/__tests__/components/notification-bell.test.js +346 -0
  12. package/dist/__tests__/components/notification-bell.test.js.map +1 -0
  13. package/dist/__tests__/components/offline-status.test.d.ts +2 -0
  14. package/dist/__tests__/components/offline-status.test.d.ts.map +1 -0
  15. package/dist/__tests__/components/offline-status.test.js +56 -0
  16. package/dist/__tests__/components/offline-status.test.js.map +1 -0
  17. package/dist/__tests__/components/presence-chips-helpers.test.d.ts +2 -0
  18. package/dist/__tests__/components/presence-chips-helpers.test.d.ts.map +1 -0
  19. package/dist/__tests__/components/presence-chips-helpers.test.js +35 -0
  20. package/dist/__tests__/components/presence-chips-helpers.test.js.map +1 -0
  21. package/dist/__tests__/lib/active-editor.test.d.ts +2 -0
  22. package/dist/__tests__/lib/active-editor.test.d.ts.map +1 -0
  23. package/dist/__tests__/lib/active-editor.test.js +57 -0
  24. package/dist/__tests__/lib/active-editor.test.js.map +1 -0
  25. package/dist/__tests__/lib/coauthor-client.test.d.ts +2 -0
  26. package/dist/__tests__/lib/coauthor-client.test.d.ts.map +1 -0
  27. package/dist/__tests__/lib/coauthor-client.test.js +173 -0
  28. package/dist/__tests__/lib/coauthor-client.test.js.map +1 -0
  29. package/dist/__tests__/lib/collaboration-provider.test.d.ts +2 -0
  30. package/dist/__tests__/lib/collaboration-provider.test.d.ts.map +1 -0
  31. package/dist/__tests__/lib/collaboration-provider.test.js +335 -0
  32. package/dist/__tests__/lib/collaboration-provider.test.js.map +1 -0
  33. package/dist/__tests__/lib/comment-anchor.test.d.ts +2 -0
  34. package/dist/__tests__/lib/comment-anchor.test.d.ts.map +1 -0
  35. package/dist/__tests__/lib/comment-anchor.test.js +173 -0
  36. package/dist/__tests__/lib/comment-anchor.test.js.map +1 -0
  37. package/dist/__tests__/lib/comments-client.test.d.ts +2 -0
  38. package/dist/__tests__/lib/comments-client.test.d.ts.map +1 -0
  39. package/dist/__tests__/lib/comments-client.test.js +202 -0
  40. package/dist/__tests__/lib/comments-client.test.js.map +1 -0
  41. package/dist/__tests__/lib/notifications-client.test.d.ts +2 -0
  42. package/dist/__tests__/lib/notifications-client.test.d.ts.map +1 -0
  43. package/dist/__tests__/lib/notifications-client.test.js +283 -0
  44. package/dist/__tests__/lib/notifications-client.test.js.map +1 -0
  45. package/dist/__tests__/lib/slash-command.test.d.ts +2 -0
  46. package/dist/__tests__/lib/slash-command.test.d.ts.map +1 -0
  47. package/dist/__tests__/lib/slash-command.test.js +123 -0
  48. package/dist/__tests__/lib/slash-command.test.js.map +1 -0
  49. package/dist/actuate-admin.css +1 -1
  50. package/dist/components/AICoauthorBubbleMenu.d.ts +18 -0
  51. package/dist/components/AICoauthorBubbleMenu.d.ts.map +1 -0
  52. package/dist/components/AICoauthorBubbleMenu.js +113 -0
  53. package/dist/components/AICoauthorBubbleMenu.js.map +1 -0
  54. package/dist/components/CoauthorResultPopover.d.ts +28 -0
  55. package/dist/components/CoauthorResultPopover.d.ts.map +1 -0
  56. package/dist/components/CoauthorResultPopover.js +42 -0
  57. package/dist/components/CoauthorResultPopover.js.map +1 -0
  58. package/dist/components/CommandPalette.d.ts +1 -1
  59. package/dist/components/CommandPalette.d.ts.map +1 -1
  60. package/dist/components/CommandPalette.js +138 -40
  61. package/dist/components/CommandPalette.js.map +1 -1
  62. package/dist/components/CommentMark.d.ts +54 -0
  63. package/dist/components/CommentMark.d.ts.map +1 -0
  64. package/dist/components/CommentMark.js +104 -0
  65. package/dist/components/CommentMark.js.map +1 -0
  66. package/dist/components/CommentSidePanel.d.ts +86 -0
  67. package/dist/components/CommentSidePanel.d.ts.map +1 -0
  68. package/dist/components/CommentSidePanel.js +165 -0
  69. package/dist/components/CommentSidePanel.js.map +1 -0
  70. package/dist/components/LivePreview.d.ts.map +1 -1
  71. package/dist/components/LivePreview.js +80 -16
  72. package/dist/components/LivePreview.js.map +1 -1
  73. package/dist/components/NotificationBell.d.ts +66 -0
  74. package/dist/components/NotificationBell.d.ts.map +1 -0
  75. package/dist/components/NotificationBell.js +142 -0
  76. package/dist/components/NotificationBell.js.map +1 -0
  77. package/dist/components/OfflineStatus.d.ts +39 -0
  78. package/dist/components/OfflineStatus.d.ts.map +1 -0
  79. package/dist/components/OfflineStatus.js +47 -0
  80. package/dist/components/OfflineStatus.js.map +1 -0
  81. package/dist/components/PresenceChips.d.ts +31 -0
  82. package/dist/components/PresenceChips.d.ts.map +1 -0
  83. package/dist/components/PresenceChips.js +58 -0
  84. package/dist/components/PresenceChips.js.map +1 -0
  85. package/dist/components/TipTapEditor.d.ts +61 -1
  86. package/dist/components/TipTapEditor.d.ts.map +1 -1
  87. package/dist/components/TipTapEditor.js +211 -8
  88. package/dist/components/TipTapEditor.js.map +1 -1
  89. package/dist/components/ui/Badge.d.ts +1 -1
  90. package/dist/index.d.ts +8 -0
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js +4 -0
  93. package/dist/index.js.map +1 -1
  94. package/dist/lib/active-editor.d.ts +6 -0
  95. package/dist/lib/active-editor.d.ts.map +1 -0
  96. package/dist/lib/active-editor.js +35 -0
  97. package/dist/lib/active-editor.js.map +1 -0
  98. package/dist/lib/api.d.ts +8 -0
  99. package/dist/lib/api.d.ts.map +1 -1
  100. package/dist/lib/api.js +10 -0
  101. package/dist/lib/api.js.map +1 -1
  102. package/dist/lib/coauthor-client.d.ts +72 -0
  103. package/dist/lib/coauthor-client.d.ts.map +1 -0
  104. package/dist/lib/coauthor-client.js +98 -0
  105. package/dist/lib/coauthor-client.js.map +1 -0
  106. package/dist/lib/collaboration-provider.d.ts +142 -0
  107. package/dist/lib/collaboration-provider.d.ts.map +1 -0
  108. package/dist/lib/collaboration-provider.js +168 -0
  109. package/dist/lib/collaboration-provider.js.map +1 -0
  110. package/dist/lib/comment-anchor.d.ts +86 -0
  111. package/dist/lib/comment-anchor.d.ts.map +1 -0
  112. package/dist/lib/comment-anchor.js +139 -0
  113. package/dist/lib/comment-anchor.js.map +1 -0
  114. package/dist/lib/comments-client.d.ts +106 -0
  115. package/dist/lib/comments-client.d.ts.map +1 -0
  116. package/dist/lib/comments-client.js +138 -0
  117. package/dist/lib/comments-client.js.map +1 -0
  118. package/dist/lib/notifications-client.d.ts +121 -0
  119. package/dist/lib/notifications-client.d.ts.map +1 -0
  120. package/dist/lib/notifications-client.js +173 -0
  121. package/dist/lib/notifications-client.js.map +1 -0
  122. package/dist/lib/slash-command.d.ts +39 -0
  123. package/dist/lib/slash-command.d.ts.map +1 -0
  124. package/dist/lib/slash-command.js +50 -0
  125. package/dist/lib/slash-command.js.map +1 -0
  126. package/package.json +12 -3
  127. package/src/__tests__/components/comment-mark.test.ts +99 -0
  128. package/src/__tests__/components/comment-side-panel.test.tsx +343 -0
  129. package/src/__tests__/components/notification-bell.test.tsx +379 -0
  130. package/src/__tests__/components/offline-status.test.tsx +66 -0
  131. package/src/__tests__/components/presence-chips-helpers.test.ts +44 -0
  132. package/src/__tests__/lib/active-editor.test.ts +70 -0
  133. package/src/__tests__/lib/coauthor-client.test.ts +213 -0
  134. package/src/__tests__/lib/collaboration-provider.test.ts +425 -0
  135. package/src/__tests__/lib/comment-anchor.test.ts +188 -0
  136. package/src/__tests__/lib/comments-client.test.ts +239 -0
  137. package/src/__tests__/lib/notifications-client.test.ts +339 -0
  138. package/src/__tests__/lib/slash-command.test.ts +146 -0
  139. package/src/components/AICoauthorBubbleMenu.tsx +187 -0
  140. package/src/components/CoauthorResultPopover.tsx +193 -0
  141. package/src/components/CommandPalette.tsx +397 -209
  142. package/src/components/CommentMark.ts +138 -0
  143. package/src/components/CommentSidePanel.tsx +521 -0
  144. package/src/components/LivePreview.tsx +117 -11
  145. package/src/components/NotificationBell.tsx +314 -0
  146. package/src/components/OfflineStatus.tsx +90 -0
  147. package/src/components/PresenceChips.tsx +150 -0
  148. package/src/components/TipTapEditor.tsx +310 -12
  149. package/src/index.ts +20 -0
  150. package/src/lib/active-editor.ts +39 -0
  151. package/src/lib/api.ts +11 -0
  152. package/src/lib/coauthor-client.ts +160 -0
  153. package/src/lib/collaboration-provider.ts +306 -0
  154. package/src/lib/comment-anchor.ts +179 -0
  155. package/src/lib/comments-client.ts +208 -0
  156. package/src/lib/notifications-client.ts +292 -0
  157. package/src/lib/slash-command.ts +67 -0
@@ -0,0 +1,339 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ const cmsApiMock = vi.fn()
4
+ vi.mock('../../lib/api.js', () => ({
5
+ cmsApi: (...args: unknown[]) => cmsApiMock(...args),
6
+ // SSE uses EventSource, which doesn't go through cmsApi. The
7
+ // helper resolves the URL via getApiBase() — return the production
8
+ // default so tests don't need to know the prefix.
9
+ getApiBase: () => '/api/cms',
10
+ }))
11
+
12
+ const {
13
+ listNotifications,
14
+ markAllNotificationsRead,
15
+ markNotificationRead,
16
+ notificationSummary,
17
+ subscribeNotificationStream,
18
+ unreadCount,
19
+ } = await import('../../lib/notifications-client.js')
20
+ import type {
21
+ EventSourceLike,
22
+ Notification,
23
+ NotificationStreamOptions,
24
+ } from '../../lib/notifications-client.js'
25
+
26
+ function row(overrides: Partial<Notification> = {}): Notification {
27
+ return {
28
+ id: 'n-1',
29
+ userId: 'user-1',
30
+ kind: 'comment_reply',
31
+ documentId: 'doc-1',
32
+ commentId: 'c-1',
33
+ payload: {
34
+ kind: 'comment_reply',
35
+ actorId: 'someone',
36
+ actorName: 'Someone',
37
+ excerpt: 'hi',
38
+ },
39
+ readAt: null,
40
+ createdAt: '2026-01-01T00:00:00.000Z',
41
+ ...overrides,
42
+ }
43
+ }
44
+
45
+ beforeEach(() => {
46
+ cmsApiMock.mockReset()
47
+ })
48
+
49
+ describe('listNotifications', () => {
50
+ it('GETs /notifications without query when no options', async () => {
51
+ cmsApiMock.mockResolvedValueOnce({ data: [row()], status: 200 })
52
+ const out = await listNotifications()
53
+ expect(out.ok).toBe(true)
54
+ expect(cmsApiMock).toHaveBeenCalledWith('/notifications', { method: 'GET' })
55
+ })
56
+
57
+ it('appends ?includeRead and ?pageSize when set', async () => {
58
+ cmsApiMock.mockResolvedValueOnce({ data: [], status: 200 })
59
+ await listNotifications({ includeRead: true, pageSize: 25 })
60
+ expect(cmsApiMock).toHaveBeenCalledWith('/notifications?includeRead=true&pageSize=25', {
61
+ method: 'GET',
62
+ })
63
+ })
64
+
65
+ it('returns the data array unchanged', async () => {
66
+ const rows = [row({ id: 'a' }), row({ id: 'b' })]
67
+ cmsApiMock.mockResolvedValueOnce({ data: rows, status: 200 })
68
+ const out = await listNotifications()
69
+ expect(out.ok).toBe(true)
70
+ if (!out.ok) return
71
+ expect(out.result.map((n) => n.id)).toEqual(['a', 'b'])
72
+ })
73
+
74
+ it('propagates errors as { ok: false }', async () => {
75
+ cmsApiMock.mockResolvedValueOnce({ error: 'nope', status: 500 })
76
+ const out = await listNotifications()
77
+ expect(out.ok).toBe(false)
78
+ if (out.ok) return
79
+ expect(out.error).toBe('nope')
80
+ expect(out.status).toBe(500)
81
+ })
82
+ })
83
+
84
+ describe('unreadCount', () => {
85
+ it('returns the count from the envelope', async () => {
86
+ cmsApiMock.mockResolvedValueOnce({ data: { count: 7 }, status: 200 })
87
+ const out = await unreadCount()
88
+ expect(out.ok).toBe(true)
89
+ if (!out.ok) return
90
+ expect(out.result).toBe(7)
91
+ })
92
+
93
+ it('propagates failure when the server returns no data', async () => {
94
+ cmsApiMock.mockResolvedValueOnce({ status: 503 })
95
+ const out = await unreadCount()
96
+ expect(out.ok).toBe(false)
97
+ })
98
+ })
99
+
100
+ describe('markNotificationRead', () => {
101
+ it('POSTs to the id-specific endpoint', async () => {
102
+ cmsApiMock.mockResolvedValueOnce({ data: row({ readAt: 'now' }), status: 200 })
103
+ const out = await markNotificationRead('n-1')
104
+ expect(out.ok).toBe(true)
105
+ expect(cmsApiMock).toHaveBeenCalledWith('/notifications/n-1/read', {
106
+ method: 'POST',
107
+ body: '{}',
108
+ })
109
+ })
110
+
111
+ it('URL-encodes weird ids', async () => {
112
+ cmsApiMock.mockResolvedValueOnce({ data: row(), status: 200 })
113
+ await markNotificationRead('n/1?')
114
+ expect(cmsApiMock).toHaveBeenCalledWith(
115
+ `/notifications/${encodeURIComponent('n/1?')}/read`,
116
+ expect.anything(),
117
+ )
118
+ })
119
+
120
+ it('rejects an empty id without hitting the wire', async () => {
121
+ const out = await markNotificationRead('')
122
+ expect(out.ok).toBe(false)
123
+ expect(cmsApiMock).not.toHaveBeenCalled()
124
+ })
125
+ })
126
+
127
+ describe('markAllNotificationsRead', () => {
128
+ it('POSTs /notifications/read-all and returns the count', async () => {
129
+ cmsApiMock.mockResolvedValueOnce({ data: { count: 4 }, status: 200 })
130
+ const out = await markAllNotificationsRead()
131
+ expect(out.ok).toBe(true)
132
+ if (!out.ok) return
133
+ expect(out.result).toBe(4)
134
+ expect(cmsApiMock).toHaveBeenCalledWith('/notifications/read-all', {
135
+ method: 'POST',
136
+ body: '{}',
137
+ })
138
+ })
139
+
140
+ it('propagates a server error', async () => {
141
+ cmsApiMock.mockResolvedValueOnce({ error: 'CSRF', status: 403 })
142
+ const out = await markAllNotificationsRead()
143
+ expect(out.ok).toBe(false)
144
+ })
145
+ })
146
+
147
+ describe('notificationSummary', () => {
148
+ it('formats comment_reply with the actor + excerpt', () => {
149
+ expect(
150
+ notificationSummary(
151
+ row({
152
+ payload: {
153
+ kind: 'comment_reply',
154
+ actorId: 'a',
155
+ actorName: 'Alice',
156
+ excerpt: 'good point',
157
+ },
158
+ }),
159
+ ),
160
+ ).toBe('Alice replied: "good point"')
161
+ })
162
+
163
+ it('formats comment_resolved with rootExcerpt', () => {
164
+ expect(
165
+ notificationSummary(
166
+ row({
167
+ kind: 'comment_resolved',
168
+ payload: {
169
+ kind: 'comment_resolved',
170
+ actorId: 'a',
171
+ actorName: 'Bob',
172
+ rootExcerpt: 'fix this',
173
+ },
174
+ }),
175
+ ),
176
+ ).toBe('Bob resolved your thread: "fix this"')
177
+ })
178
+
179
+ it('formats comment_mention with excerpt', () => {
180
+ expect(
181
+ notificationSummary(
182
+ row({
183
+ kind: 'comment_mention',
184
+ payload: {
185
+ kind: 'comment_mention',
186
+ actorId: 'a',
187
+ actorName: 'Carol',
188
+ excerpt: 'cc @you',
189
+ },
190
+ }),
191
+ ),
192
+ ).toBe('Carol mentioned you: "cc @you"')
193
+ })
194
+
195
+ it('falls back to "Someone" when actorName is null', () => {
196
+ expect(
197
+ notificationSummary(
198
+ row({
199
+ payload: { kind: 'comment_reply', actorId: null, actorName: null, excerpt: 'x' },
200
+ }),
201
+ ),
202
+ ).toMatch(/^Someone replied/)
203
+ })
204
+ })
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // SSE push channel — subscribeNotificationStream
208
+ // ---------------------------------------------------------------------------
209
+
210
+ /**
211
+ * In-memory `EventSource` double. Tests drive the wire by invoking
212
+ * `emit(event, payload)`; the subscriber dispatches the right
213
+ * listener. We deliberately keep the shape minimal — `EventSource`
214
+ * has dozens of properties our admin doesn't touch.
215
+ */
216
+ function makeFakeSource() {
217
+ const listeners = new Map<string, Set<(e: MessageEvent | Event) => void>>()
218
+ let closed = false
219
+ const source: EventSourceLike = {
220
+ addEventListener: (type, listener) => {
221
+ if (closed) return
222
+ let set = listeners.get(type)
223
+ if (!set) {
224
+ set = new Set()
225
+ listeners.set(type, set)
226
+ }
227
+ set.add(listener)
228
+ },
229
+ removeEventListener: (type, listener) => {
230
+ listeners.get(type)?.delete(listener)
231
+ },
232
+ close: () => {
233
+ closed = true
234
+ listeners.clear()
235
+ },
236
+ }
237
+ return {
238
+ source,
239
+ listenerCount: (type: string) => listeners.get(type)?.size ?? 0,
240
+ isClosed: () => closed,
241
+ emit(type: string, payload: unknown) {
242
+ const set = listeners.get(type)
243
+ if (!set) return
244
+ const event =
245
+ typeof payload === 'string'
246
+ ? ({ data: payload } as MessageEvent)
247
+ : payload === undefined
248
+ ? (new Event(type) as Event)
249
+ : ({ data: JSON.stringify(payload) } as MessageEvent)
250
+ for (const l of [...set]) l(event)
251
+ },
252
+ }
253
+ }
254
+
255
+ function startStream(
256
+ fake: ReturnType<typeof makeFakeSource>,
257
+ overrides: Partial<NotificationStreamOptions> = {},
258
+ ) {
259
+ return subscribeNotificationStream({
260
+ onNotification: () => undefined,
261
+ factory: () => fake.source,
262
+ ...overrides,
263
+ })
264
+ }
265
+
266
+ describe('subscribeNotificationStream', () => {
267
+ it('dispatches `notification` frames as Notification objects', () => {
268
+ const fake = makeFakeSource()
269
+ const seen: Notification[] = []
270
+ startStream(fake, { onNotification: (n) => seen.push(n) })
271
+ fake.emit('notification', row({ id: 'sse-1' }))
272
+ expect(seen.map((n) => n.id)).toEqual(['sse-1'])
273
+ })
274
+
275
+ it('dispatches `ready` frames once via onReady', () => {
276
+ const fake = makeFakeSource()
277
+ const readys: Array<{ userId: string }> = []
278
+ startStream(fake, { onReady: (p) => readys.push(p) })
279
+ fake.emit('ready', { userId: 'user-1' })
280
+ expect(readys).toEqual([{ userId: 'user-1' }])
281
+ })
282
+
283
+ it('ignores malformed payloads (defence against wire drift)', () => {
284
+ const fake = makeFakeSource()
285
+ const seen: Notification[] = []
286
+ startStream(fake, { onNotification: (n) => seen.push(n) })
287
+ // Missing id + payload — must not throw, must not deliver.
288
+ fake.emit('notification', { userId: 'user-1', createdAt: 'now' })
289
+ fake.emit('notification', 'not json')
290
+ expect(seen).toEqual([])
291
+ })
292
+
293
+ it('removes its listeners and closes the underlying source on close()', () => {
294
+ const fake = makeFakeSource()
295
+ const handle = startStream(fake)
296
+ expect(fake.listenerCount('notification')).toBe(1)
297
+ expect(fake.listenerCount('ready')).toBe(1)
298
+ expect(fake.listenerCount('error')).toBe(1)
299
+ handle.close()
300
+ expect(fake.isClosed()).toBe(true)
301
+ // Second close() is a no-op — guards against double-unmount loops.
302
+ handle.close()
303
+ expect(fake.isClosed()).toBe(true)
304
+ })
305
+
306
+ it('returns a no-op handle when no EventSource is available', () => {
307
+ // No factory + no global EventSource (vitest jsdom env doesn't
308
+ // ship one). The subscriber must NOT throw — admin code must
309
+ // remain mountable in SSR / Node environments.
310
+ const handle = subscribeNotificationStream({
311
+ onNotification: () => undefined,
312
+ factory: () => null,
313
+ })
314
+ expect(() => handle.close()).not.toThrow()
315
+ })
316
+
317
+ it('forwards error events to onError without crashing the page', () => {
318
+ const fake = makeFakeSource()
319
+ const errors: Event[] = []
320
+ startStream(fake, { onError: (e) => errors.push(e) })
321
+ fake.emit('error', undefined)
322
+ expect(errors).toHaveLength(1)
323
+ })
324
+
325
+ it('builds the stream URL from getApiBase() so consumers with a custom prefix connect to the right host', () => {
326
+ // Custom factory inspects the URL it's handed. Verifies the
327
+ // subscriber composes /<base>/notifications/stream rather than
328
+ // hardcoding the production prefix.
329
+ const seen: string[] = []
330
+ subscribeNotificationStream({
331
+ onNotification: () => undefined,
332
+ factory: (url) => {
333
+ seen.push(url)
334
+ return null
335
+ },
336
+ })
337
+ expect(seen).toEqual(['/api/cms/notifications/stream'])
338
+ })
339
+ })
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ parseAiSlashCommand,
4
+ isAiSlashCommandInput,
5
+ orderedAiActions,
6
+ } from '../../lib/slash-command.js'
7
+
8
+ describe('parseAiSlashCommand', () => {
9
+ it('parses /ai rewrite <text>', () => {
10
+ expect(parseAiSlashCommand('/ai rewrite hello world')).toEqual({
11
+ action: 'rewrite',
12
+ text: 'hello world',
13
+ })
14
+ })
15
+
16
+ it('parses every canonical action', () => {
17
+ expect(parseAiSlashCommand('/ai expand foo')).toEqual({ action: 'expand', text: 'foo' })
18
+ expect(parseAiSlashCommand('/ai compress bar')).toEqual({ action: 'compress', text: 'bar' })
19
+ expect(parseAiSlashCommand('/ai proofread baz')).toEqual({ action: 'proofread', text: 'baz' })
20
+ })
21
+
22
+ it('is case-insensitive on both prefix and action', () => {
23
+ expect(parseAiSlashCommand('/AI Rewrite ALL CAPS')).toEqual({
24
+ action: 'rewrite',
25
+ text: 'ALL CAPS',
26
+ })
27
+ expect(parseAiSlashCommand('/Ai PROOFREAD x')).toEqual({ action: 'proofread', text: 'x' })
28
+ })
29
+
30
+ it('tolerates leading whitespace', () => {
31
+ expect(parseAiSlashCommand(' /ai rewrite x')).toEqual({ action: 'rewrite', text: 'x' })
32
+ })
33
+
34
+ it('returns empty text when nothing follows the action', () => {
35
+ expect(parseAiSlashCommand('/ai rewrite')).toEqual({ action: 'rewrite', text: '' })
36
+ expect(parseAiSlashCommand('/ai rewrite ')).toEqual({ action: 'rewrite', text: '' })
37
+ })
38
+
39
+ it('trims surrounding whitespace from the payload', () => {
40
+ expect(parseAiSlashCommand('/ai rewrite hello ')).toEqual({
41
+ action: 'rewrite',
42
+ text: 'hello',
43
+ })
44
+ })
45
+
46
+ it('preserves interior whitespace in the payload', () => {
47
+ expect(parseAiSlashCommand('/ai rewrite hello world')).toEqual({
48
+ action: 'rewrite',
49
+ text: 'hello world',
50
+ })
51
+ })
52
+
53
+ it('returns null for unknown actions', () => {
54
+ expect(parseAiSlashCommand('/ai summarize foo')).toBeNull()
55
+ expect(parseAiSlashCommand('/ai delete foo')).toBeNull()
56
+ })
57
+
58
+ it('returns null for non-AI slash commands', () => {
59
+ expect(parseAiSlashCommand('/help')).toBeNull()
60
+ expect(parseAiSlashCommand('/admin x')).toBeNull()
61
+ })
62
+
63
+ it('returns null for plain input without a slash', () => {
64
+ expect(parseAiSlashCommand('ai rewrite hello')).toBeNull()
65
+ expect(parseAiSlashCommand('hello world')).toBeNull()
66
+ expect(parseAiSlashCommand('')).toBeNull()
67
+ })
68
+
69
+ it('returns null when /ai is the entire input', () => {
70
+ expect(parseAiSlashCommand('/ai')).toBeNull()
71
+ expect(parseAiSlashCommand('/ai ')).toBeNull()
72
+ })
73
+
74
+ it('returns null for non-string inputs', () => {
75
+ // @ts-expect-error — runtime guard
76
+ expect(parseAiSlashCommand(null)).toBeNull()
77
+ // @ts-expect-error — runtime guard
78
+ expect(parseAiSlashCommand(undefined)).toBeNull()
79
+ // @ts-expect-error — runtime guard
80
+ expect(parseAiSlashCommand(42)).toBeNull()
81
+ })
82
+ })
83
+
84
+ describe('isAiSlashCommandInput', () => {
85
+ it('returns true while the user is mid-typing the prefix', () => {
86
+ expect(isAiSlashCommandInput('/')).toBe(true)
87
+ expect(isAiSlashCommandInput('/a')).toBe(true)
88
+ expect(isAiSlashCommandInput('/ai')).toBe(true)
89
+ expect(isAiSlashCommandInput('/ai ')).toBe(true)
90
+ expect(isAiSlashCommandInput('/ai re')).toBe(true)
91
+ expect(isAiSlashCommandInput('/ai rewrite something')).toBe(true)
92
+ })
93
+
94
+ it('returns true when the user typed AI with capitals', () => {
95
+ expect(isAiSlashCommandInput('/AI')).toBe(true)
96
+ expect(isAiSlashCommandInput('/Ai rew')).toBe(true)
97
+ })
98
+
99
+ it('returns false for ordinary text', () => {
100
+ expect(isAiSlashCommandInput('')).toBe(false)
101
+ expect(isAiSlashCommandInput('hello')).toBe(false)
102
+ expect(isAiSlashCommandInput('ai rewrite')).toBe(false)
103
+ })
104
+
105
+ it('returns false for non-AI slash inputs', () => {
106
+ expect(isAiSlashCommandInput('/help')).toBe(false)
107
+ expect(isAiSlashCommandInput('/bots')).toBe(false)
108
+ })
109
+ })
110
+
111
+ describe('orderedAiActions', () => {
112
+ it('returns the canonical order when nothing is parsed', () => {
113
+ expect(orderedAiActions('')).toEqual(['rewrite', 'expand', 'compress', 'proofread'])
114
+ expect(orderedAiActions('/ai')).toEqual(['rewrite', 'expand', 'compress', 'proofread'])
115
+ })
116
+
117
+ it('hoists the matched action to the front', () => {
118
+ expect(orderedAiActions('/ai compress foo')).toEqual([
119
+ 'compress',
120
+ 'rewrite',
121
+ 'expand',
122
+ 'proofread',
123
+ ])
124
+ expect(orderedAiActions('/ai proofread bar')).toEqual([
125
+ 'proofread',
126
+ 'rewrite',
127
+ 'expand',
128
+ 'compress',
129
+ ])
130
+ })
131
+
132
+ it('returns canonical order for an unknown action', () => {
133
+ expect(orderedAiActions('/ai summarize foo')).toEqual([
134
+ 'rewrite',
135
+ 'expand',
136
+ 'compress',
137
+ 'proofread',
138
+ ])
139
+ })
140
+
141
+ it('contains every canonical action exactly once', () => {
142
+ const result = orderedAiActions('/ai rewrite x')
143
+ expect(result.length).toBe(4)
144
+ expect(new Set(result).size).toBe(4)
145
+ })
146
+ })
@@ -0,0 +1,187 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useState } from 'react'
4
+ import { BubbleMenu, type Editor } from '@tiptap/react'
5
+ import { Loader2, Maximize2, Minimize2, Sparkles, SpellCheck, Wand2 } from 'lucide-react'
6
+ import { toast } from 'sonner'
7
+ import {
8
+ COAUTHOR_ACTIONS,
9
+ coauthorActionLabel,
10
+ coauthorText,
11
+ type CoauthorAction,
12
+ type ProofreadChange,
13
+ } from '../lib/coauthor-client.js'
14
+ import { CoauthorResultPopover } from './CoauthorResultPopover.js'
15
+
16
+ export interface AICoauthorBubbleMenuProps {
17
+ editor: Editor | null
18
+ }
19
+
20
+ const ACTION_ICONS: Record<CoauthorAction, typeof Sparkles> = {
21
+ rewrite: Wand2,
22
+ expand: Maximize2,
23
+ compress: Minimize2,
24
+ proofread: SpellCheck,
25
+ }
26
+
27
+ interface ResultState {
28
+ open: boolean
29
+ loading: boolean
30
+ action: CoauthorAction
31
+ before: string
32
+ after: string
33
+ error?: string
34
+ changes?: ProofreadChange[]
35
+ from: number
36
+ to: number
37
+ }
38
+
39
+ const INITIAL: ResultState = {
40
+ open: false,
41
+ loading: false,
42
+ action: 'rewrite',
43
+ before: '',
44
+ after: '',
45
+ from: 0,
46
+ to: 0,
47
+ }
48
+
49
+ /**
50
+ * Floating menu that appears above the current text selection inside the
51
+ * TipTap editor. Renders four AI co-author actions
52
+ * (rewrite / expand / compress / proofread) and hands the selected text to
53
+ * `/ai/coauthor`. When the server returns, opens the shared
54
+ * `CoauthorResultPopover` with a before/after diff and Apply / Copy /
55
+ * Discard actions.
56
+ *
57
+ * The popover, not the bubble itself, holds the result — by design — so
58
+ * the user can scroll the editor, see the source of truth, and explicitly
59
+ * choose what to do with the AI output.
60
+ */
61
+ export function AICoauthorBubbleMenu({ editor }: AICoauthorBubbleMenuProps) {
62
+ const [state, setState] = useState<ResultState>(INITIAL)
63
+ const [pendingAction, setPendingAction] = useState<CoauthorAction | null>(null)
64
+
65
+ const runAction = useCallback(
66
+ async (action: CoauthorAction) => {
67
+ if (!editor) return
68
+ const { from, to, empty } = editor.state.selection
69
+ if (empty) return
70
+ const before = editor.state.doc.textBetween(from, to, '\n', '\n')
71
+ if (!before.trim()) {
72
+ toast.error('Select text to use the AI co-author.')
73
+ return
74
+ }
75
+
76
+ setPendingAction(action)
77
+ setState({
78
+ open: true,
79
+ loading: true,
80
+ action,
81
+ before,
82
+ after: '',
83
+ from,
84
+ to,
85
+ })
86
+
87
+ const outcome = await coauthorText(action, before)
88
+ if (!outcome.ok) {
89
+ setState((prev) => ({ ...prev, loading: false, error: outcome.error }))
90
+ toast.error(outcome.error)
91
+ setPendingAction(null)
92
+ return
93
+ }
94
+
95
+ setState((prev) => ({
96
+ ...prev,
97
+ loading: false,
98
+ after: outcome.result.text,
99
+ changes: outcome.result.changes,
100
+ error: undefined,
101
+ }))
102
+ setPendingAction(null)
103
+ },
104
+ [editor],
105
+ )
106
+
107
+ const handleApply = useCallback(() => {
108
+ if (!editor) return
109
+ editor.chain().focus().insertContentAt({ from: state.from, to: state.to }, state.after).run()
110
+ toast.success(`${coauthorActionLabel(state.action)} applied.`)
111
+ setState(INITIAL)
112
+ }, [editor, state.action, state.from, state.to, state.after])
113
+
114
+ const handleCopy = useCallback(async () => {
115
+ try {
116
+ await navigator.clipboard.writeText(state.after)
117
+ toast.success('Copied to clipboard.')
118
+ } catch {
119
+ toast.error('Clipboard unavailable — copy the text manually.')
120
+ }
121
+ }, [state.after])
122
+
123
+ const handleDiscard = useCallback(() => {
124
+ setState(INITIAL)
125
+ setPendingAction(null)
126
+ }, [])
127
+
128
+ if (!editor) return null
129
+
130
+ return (
131
+ <>
132
+ <BubbleMenu
133
+ editor={editor}
134
+ shouldShow={({ editor: ed, from, to }) => {
135
+ if (state.open) return false
136
+ if (from === to) return false
137
+ if (!ed.isEditable) return false
138
+ return true
139
+ }}
140
+ tippyOptions={{ duration: 100, placement: 'top' }}
141
+ className="flex items-center gap-0.5 rounded-md border border-gray-200 bg-white p-1 shadow-md"
142
+ >
143
+ <span className="flex items-center gap-1 px-1.5 text-xs font-medium text-gray-500">
144
+ <Sparkles className="h-3 w-3" aria-hidden />
145
+ AI
146
+ </span>
147
+ <span className="mx-0.5 h-4 w-px bg-gray-200" />
148
+ {COAUTHOR_ACTIONS.map((id) => {
149
+ const Icon = ACTION_ICONS[id]
150
+ const isPending = pendingAction === id
151
+ const anyPending = pendingAction !== null
152
+ return (
153
+ <button
154
+ key={id}
155
+ type="button"
156
+ onClick={() => runAction(id)}
157
+ disabled={anyPending}
158
+ className="flex items-center gap-1 rounded px-2 py-1 text-xs text-gray-700 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
159
+ title={`AI ${coauthorActionLabel(id)}`}
160
+ >
161
+ {isPending ? (
162
+ <Loader2 className="h-3 w-3 animate-spin" aria-hidden />
163
+ ) : (
164
+ <Icon className="h-3 w-3" aria-hidden />
165
+ )}
166
+ {coauthorActionLabel(id)}
167
+ </button>
168
+ )
169
+ })}
170
+ </BubbleMenu>
171
+
172
+ <CoauthorResultPopover
173
+ open={state.open}
174
+ loading={state.loading}
175
+ action={state.action}
176
+ before={state.before}
177
+ after={state.after}
178
+ error={state.error}
179
+ changes={state.changes}
180
+ applyLabel="Replace selection"
181
+ onApply={handleApply}
182
+ onCopy={handleCopy}
183
+ onDiscard={handleDiscard}
184
+ />
185
+ </>
186
+ )
187
+ }