@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,208 @@
1
+ /**
2
+ * Comments REST client — thin, typed wrapper around the
3
+ * `/documents/:id/comments` + `/comments/:id` endpoints shipped in slice 4
4
+ * of Phase 3 (see `packages/cms-core/src/realtime/comments.ts`).
5
+ *
6
+ * The client lives in `cms-admin` so the side panel, the bubble-menu
7
+ * "comment on selection" action, and any future MCP/AI hooks all share one
8
+ * request path, error model, and DTO shape.
9
+ *
10
+ * Mirrors the server contract:
11
+ * POST /documents/:documentId/comments -> { body, anchor?, parentId? }
12
+ * GET /documents/:documentId/comments?… -> { data: CommentDTO[] }
13
+ * PATCH /comments/:id -> { body }
14
+ * POST /comments/:id/resolve -> 200 with updated DTO
15
+ * POST /comments/:id/reopen -> 200 with updated DTO
16
+ * DELETE /comments/:id -> 200 with soft-deleted DTO
17
+ */
18
+
19
+ import { cmsApi } from './api.js'
20
+
21
+ /**
22
+ * Yjs relative-position anchor returned by the server. The encoding is
23
+ * intentionally opaque on the wire (Base64 of `Y.encodeRelativePosition`);
24
+ * `lib/comment-anchor.ts` handles the round-trip when slice 5 paints a
25
+ * highlight in the editor.
26
+ */
27
+ export interface CommentAnchor {
28
+ from: string
29
+ to: string
30
+ }
31
+
32
+ /**
33
+ * Public DTO returned by every comments endpoint. Mirrors `CommentDTO` in
34
+ * `@actuate-media/cms-core/realtime`; we duplicate the shape here so
35
+ * `cms-admin` doesn't take a hard dependency on the server-side service
36
+ * layer (which carries Prisma types we don't want pulled into the bundle).
37
+ */
38
+ export interface Comment {
39
+ id: string
40
+ documentId: string
41
+ parentId: string | null
42
+ userId: string | null
43
+ body: string
44
+ anchor: CommentAnchor | null
45
+ resolvedAt: string | null
46
+ resolvedById: string | null
47
+ deletedAt: string | null
48
+ createdAt: string
49
+ updatedAt: string
50
+ isResolved: boolean
51
+ }
52
+
53
+ export type CommentOutcome<T> =
54
+ | { ok: true; result: T }
55
+ | { ok: false; error: string; status: number }
56
+
57
+ export interface CreateCommentInput {
58
+ documentId: string
59
+ body: string
60
+ anchor?: CommentAnchor | null
61
+ parentId?: string | null
62
+ }
63
+
64
+ export interface ListCommentsOptions {
65
+ /** Default: false. Resolved threads are filtered out unless requested. */
66
+ includeResolved?: boolean
67
+ /** Default: false. Admin-only; the server rejects non-admin callers. */
68
+ includeDeleted?: boolean
69
+ /** Default: server-side cap (100). Hard upper bound is 200. */
70
+ pageSize?: number
71
+ }
72
+
73
+ function encodeOptions(opts: ListCommentsOptions = {}): string {
74
+ const params = new URLSearchParams()
75
+ if (opts.includeResolved) params.set('includeResolved', 'true')
76
+ if (opts.includeDeleted) params.set('includeDeleted', 'true')
77
+ if (opts.pageSize !== undefined) params.set('pageSize', String(opts.pageSize))
78
+ const qs = params.toString()
79
+ return qs ? `?${qs}` : ''
80
+ }
81
+
82
+ function failure(error: string, status: number): { ok: false; error: string; status: number } {
83
+ return { ok: false, error, status }
84
+ }
85
+
86
+ function envelope<T>(res: { data?: unknown; error?: string; status: number }): CommentOutcome<T> {
87
+ if (res.error || res.data === undefined) {
88
+ return failure(res.error ?? `Request failed (${res.status})`, res.status)
89
+ }
90
+ return { ok: true, result: res.data as T }
91
+ }
92
+
93
+ /**
94
+ * Create a top-level comment or a reply. Pass `parentId` to thread the
95
+ * comment under an existing one — the server enforces single-level
96
+ * threading (replies cannot themselves be parents).
97
+ */
98
+ export async function createComment(input: CreateCommentInput): Promise<CommentOutcome<Comment>> {
99
+ if (typeof input.documentId !== 'string' || input.documentId.length === 0) {
100
+ return failure('documentId is required', 0)
101
+ }
102
+ if (typeof input.body !== 'string' || input.body.trim().length === 0) {
103
+ return failure('Comment cannot be empty', 0)
104
+ }
105
+
106
+ const body: Record<string, unknown> = { body: input.body.trim() }
107
+ if (input.anchor) body.anchor = input.anchor
108
+ if (input.parentId) body.parentId = input.parentId
109
+
110
+ const res = await cmsApi<Comment>(`/documents/${encodeURIComponent(input.documentId)}/comments`, {
111
+ method: 'POST',
112
+ body: JSON.stringify(body),
113
+ })
114
+ return envelope<Comment>(res)
115
+ }
116
+
117
+ /**
118
+ * List comments for a document. Active comments (not resolved, not deleted)
119
+ * are returned by default; pass `includeResolved` to keep resolved threads
120
+ * in the response. The server rejects `includeDeleted` for non-admins.
121
+ */
122
+ export async function listComments(
123
+ documentId: string,
124
+ opts: ListCommentsOptions = {},
125
+ ): Promise<CommentOutcome<Comment[]>> {
126
+ if (typeof documentId !== 'string' || documentId.length === 0) {
127
+ return failure('documentId is required', 0)
128
+ }
129
+ const path = `/documents/${encodeURIComponent(documentId)}/comments${encodeOptions(opts)}`
130
+ const res = await cmsApi<Comment[]>(path, { method: 'GET' })
131
+ return envelope<Comment[]>(res)
132
+ }
133
+
134
+ /** Edit a comment body. Only the author or an admin is permitted. */
135
+ export async function updateComment(id: string, body: string): Promise<CommentOutcome<Comment>> {
136
+ if (!id) return failure('comment id is required', 0)
137
+ if (typeof body !== 'string' || body.trim().length === 0) {
138
+ return failure('Comment cannot be empty', 0)
139
+ }
140
+ const res = await cmsApi<Comment>(`/comments/${encodeURIComponent(id)}`, {
141
+ method: 'PATCH',
142
+ body: JSON.stringify({ body: body.trim() }),
143
+ })
144
+ return envelope<Comment>(res)
145
+ }
146
+
147
+ /** Resolve a thread. Write-role or the author may resolve. */
148
+ export async function resolveComment(id: string): Promise<CommentOutcome<Comment>> {
149
+ if (!id) return failure('comment id is required', 0)
150
+ const res = await cmsApi<Comment>(`/comments/${encodeURIComponent(id)}/resolve`, {
151
+ method: 'POST',
152
+ body: '{}',
153
+ })
154
+ return envelope<Comment>(res)
155
+ }
156
+
157
+ /** Re-open a resolved thread. Same permission as resolve. */
158
+ export async function reopenComment(id: string): Promise<CommentOutcome<Comment>> {
159
+ if (!id) return failure('comment id is required', 0)
160
+ const res = await cmsApi<Comment>(`/comments/${encodeURIComponent(id)}/reopen`, {
161
+ method: 'POST',
162
+ body: '{}',
163
+ })
164
+ return envelope<Comment>(res)
165
+ }
166
+
167
+ /**
168
+ * Soft-delete a comment. The row is preserved so its replies don't lose
169
+ * their thread root — the server simply stops returning it from the list
170
+ * endpoint unless an admin asks for `includeDeleted`.
171
+ */
172
+ export async function deleteComment(id: string): Promise<CommentOutcome<Comment>> {
173
+ if (!id) return failure('comment id is required', 0)
174
+ const res = await cmsApi<Comment>(`/comments/${encodeURIComponent(id)}`, {
175
+ method: 'DELETE',
176
+ })
177
+ return envelope<Comment>(res)
178
+ }
179
+
180
+ /**
181
+ * Sort comments into thread groups suitable for rendering. Roots are
182
+ * returned in chronological order; replies are ordered the same way and
183
+ * attached to their root. Replies whose root is missing (e.g. the root was
184
+ * filtered out by `includeResolved=false`) are dropped — the UI can fetch
185
+ * them explicitly if it needs to render orphaned replies.
186
+ */
187
+ export interface CommentThread {
188
+ root: Comment
189
+ replies: Comment[]
190
+ }
191
+
192
+ export function groupCommentsIntoThreads(comments: readonly Comment[]): CommentThread[] {
193
+ const roots = new Map<string, CommentThread>()
194
+ const sorted = [...comments].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt))
195
+
196
+ for (const c of sorted) {
197
+ if (c.parentId === null) {
198
+ roots.set(c.id, { root: c, replies: [] })
199
+ }
200
+ }
201
+ for (const c of sorted) {
202
+ if (c.parentId !== null) {
203
+ const root = roots.get(c.parentId)
204
+ if (root) root.replies.push(c)
205
+ }
206
+ }
207
+ return Array.from(roots.values())
208
+ }
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Notifications REST client — typed wrapper around the slice-6 endpoints
3
+ * (`/notifications`, `/notifications/unread-count`,
4
+ * `/notifications/:id/read`, `/notifications/read-all`).
5
+ *
6
+ * Lives in `cms-admin` so the bell, the toast layer, and any future
7
+ * background sync agent share one request path, error model, and DTO
8
+ * shape. The wire format mirrors `NotificationDTO` in
9
+ * `@actuate-media/cms-core/realtime/notifications.ts`; we duplicate the
10
+ * shape here to avoid taking a hard dependency on the server module
11
+ * (which carries Prisma types that don't belong in the admin bundle).
12
+ */
13
+
14
+ import { cmsApi, getApiBase } from './api.js'
15
+
16
+ export type NotificationKind = 'comment_reply' | 'comment_resolved' | 'comment_mention'
17
+
18
+ export interface CommentReplyPayload {
19
+ kind: 'comment_reply'
20
+ actorId: string | null
21
+ actorName: string | null
22
+ excerpt: string
23
+ }
24
+
25
+ export interface CommentResolvedPayload {
26
+ kind: 'comment_resolved'
27
+ actorId: string | null
28
+ actorName: string | null
29
+ rootExcerpt: string
30
+ }
31
+
32
+ export interface CommentMentionPayload {
33
+ kind: 'comment_mention'
34
+ actorId: string | null
35
+ actorName: string | null
36
+ excerpt: string
37
+ }
38
+
39
+ export type NotificationPayload =
40
+ | CommentReplyPayload
41
+ | CommentResolvedPayload
42
+ | CommentMentionPayload
43
+
44
+ export interface Notification {
45
+ id: string
46
+ userId: string
47
+ kind: NotificationKind
48
+ documentId: string | null
49
+ commentId: string | null
50
+ payload: NotificationPayload
51
+ readAt: string | null
52
+ createdAt: string
53
+ }
54
+
55
+ export type NotificationOutcome<T> =
56
+ | { ok: true; result: T }
57
+ | { ok: false; error: string; status: number }
58
+
59
+ export interface ListNotificationsOptions {
60
+ /** Default: false. When true the response includes already-read rows. */
61
+ includeRead?: boolean
62
+ /** Default: server-side cap. Hard upper bound is 200. */
63
+ pageSize?: number
64
+ }
65
+
66
+ function failure(error: string, status: number): { ok: false; error: string; status: number } {
67
+ return { ok: false, error, status }
68
+ }
69
+
70
+ function envelope<T>(res: {
71
+ data?: unknown
72
+ error?: string
73
+ status: number
74
+ }): NotificationOutcome<T> {
75
+ if (res.error || res.data === undefined) {
76
+ return failure(res.error ?? `Request failed (${res.status})`, res.status)
77
+ }
78
+ return { ok: true, result: res.data as T }
79
+ }
80
+
81
+ function buildQuery(opts: ListNotificationsOptions = {}): string {
82
+ const params = new URLSearchParams()
83
+ if (opts.includeRead) params.set('includeRead', 'true')
84
+ if (opts.pageSize !== undefined) params.set('pageSize', String(opts.pageSize))
85
+ const qs = params.toString()
86
+ return qs ? `?${qs}` : ''
87
+ }
88
+
89
+ export async function listNotifications(
90
+ opts: ListNotificationsOptions = {},
91
+ ): Promise<NotificationOutcome<Notification[]>> {
92
+ const res = await cmsApi<Notification[]>(`/notifications${buildQuery(opts)}`, {
93
+ method: 'GET',
94
+ })
95
+ return envelope<Notification[]>(res)
96
+ }
97
+
98
+ export async function unreadCount(): Promise<NotificationOutcome<number>> {
99
+ const res = await cmsApi<{ count: number }>(`/notifications/unread-count`, {
100
+ method: 'GET',
101
+ })
102
+ if (res.error || !res.data) {
103
+ return failure(res.error ?? `Request failed (${res.status})`, res.status)
104
+ }
105
+ return { ok: true, result: res.data.count }
106
+ }
107
+
108
+ export async function markNotificationRead(id: string): Promise<NotificationOutcome<Notification>> {
109
+ if (!id) return failure('notification id is required', 0)
110
+ const res = await cmsApi<Notification>(`/notifications/${encodeURIComponent(id)}/read`, {
111
+ method: 'POST',
112
+ body: '{}',
113
+ })
114
+ return envelope<Notification>(res)
115
+ }
116
+
117
+ export async function markAllNotificationsRead(): Promise<NotificationOutcome<number>> {
118
+ const res = await cmsApi<{ count: number }>(`/notifications/read-all`, {
119
+ method: 'POST',
120
+ body: '{}',
121
+ })
122
+ if (res.error || !res.data) {
123
+ return failure(res.error ?? `Request failed (${res.status})`, res.status)
124
+ }
125
+ return { ok: true, result: res.data.count }
126
+ }
127
+
128
+ /**
129
+ * Subscribe to the per-user SSE push channel that ships notifications
130
+ * the instant they're persisted server-side. Replaces the 30-second
131
+ * poll loop in the notification bell.
132
+ *
133
+ * The browser's native `EventSource` handles reconnection for us — on
134
+ * a server-initiated close (the stream caps at ~4 minutes by design),
135
+ * the connection re-opens automatically. We DO NOT abort the poll
136
+ * fallback in callers: SSE is a latency-win, the poll is what keeps
137
+ * multi-node deployments correct (each node only pushes to its own
138
+ * subscribers).
139
+ *
140
+ * Returns a `close()` function that callers MUST invoke on unmount —
141
+ * otherwise the browser keeps the connection open and we leak event
142
+ * listeners.
143
+ *
144
+ * `EventSource` itself isn't available in the JSDOM test environment;
145
+ * callers can inject a `factory` to substitute a test double. In
146
+ * production we fall back to the global `EventSource` when no factory
147
+ * is supplied.
148
+ */
149
+ export interface NotificationStreamOptions {
150
+ /** Called for every `event: notification` frame. */
151
+ onNotification: (n: Notification) => void
152
+ /** Called once on the `event: ready` frame. */
153
+ onReady?: (payload: { userId: string }) => void
154
+ /** Called on connection errors. Useful for falling back to the poll loop. */
155
+ onError?: (event: Event) => void
156
+ /**
157
+ * Optional factory so unit tests can inject a fake `EventSource`,
158
+ * or a null sentinel when the environment lacks one (SSR, JSDOM
159
+ * without a polyfill). Production code can rely on the default
160
+ * (global `EventSource`).
161
+ */
162
+ factory?: (url: string) => EventSourceLike | null
163
+ }
164
+
165
+ /**
166
+ * Minimal subset of the `EventSource` interface our subscriber relies
167
+ * on. Browsers ship the full DOM type; the admin only needs add/remove
168
+ * event listeners plus `close()`.
169
+ */
170
+ export interface EventSourceLike {
171
+ addEventListener: (type: string, listener: (event: MessageEvent | Event) => void) => void
172
+ removeEventListener?: (type: string, listener: (event: MessageEvent | Event) => void) => void
173
+ close: () => void
174
+ }
175
+
176
+ export interface NotificationStreamHandle {
177
+ /** Tear down the EventSource and stop receiving events. Idempotent. */
178
+ close: () => void
179
+ }
180
+
181
+ export function subscribeNotificationStream(
182
+ opts: NotificationStreamOptions,
183
+ ): NotificationStreamHandle {
184
+ // The `cmsApi()` helper handles base-path / origin resolution for
185
+ // fetch calls, but EventSource takes a raw URL. We mirror its
186
+ // logic locally — `/api/cms/notifications/stream` is correct for
187
+ // both the local dev server and any production deploy that mounts
188
+ // the CMS API at the default prefix. Custom prefixes are handled by
189
+ // the consumer override (factory).
190
+ // Resolve against `setApiBase()` so a consumer mounting the CMS at
191
+ // a non-default prefix (e.g. `/cms-api`) still hits the right SSE
192
+ // endpoint — `EventSource` doesn't go through `cmsApi()`, so we
193
+ // can't rely on its url-construction logic.
194
+ const url = `${getApiBase()}/notifications/stream`
195
+ const make = opts.factory ?? defaultEventSourceFactory
196
+ const source = make(url)
197
+ if (!source) {
198
+ // No EventSource available (SSR? JSDOM without polyfill? Or the
199
+ // injected factory deliberately returns null in tests). Return a
200
+ // no-op handle so callers can compose without conditional logic.
201
+ return { close: () => {} }
202
+ }
203
+ let closed = false
204
+
205
+ const onReady = (event: MessageEvent | Event) => {
206
+ if (!opts.onReady) return
207
+ const data = parseEventData(event)
208
+ if (data && typeof (data as { userId?: unknown }).userId === 'string') {
209
+ opts.onReady({ userId: (data as { userId: string }).userId })
210
+ }
211
+ }
212
+ const onNotification = (event: MessageEvent | Event) => {
213
+ const data = parseEventData(event)
214
+ if (data && isNotification(data)) {
215
+ opts.onNotification(data)
216
+ }
217
+ }
218
+ const onError = (event: Event) => {
219
+ opts.onError?.(event)
220
+ }
221
+
222
+ source.addEventListener('ready', onReady)
223
+ source.addEventListener('notification', onNotification)
224
+ source.addEventListener('error', onError)
225
+
226
+ return {
227
+ close: () => {
228
+ if (closed) return
229
+ closed = true
230
+ source.removeEventListener?.('ready', onReady)
231
+ source.removeEventListener?.('notification', onNotification)
232
+ source.removeEventListener?.('error', onError)
233
+ source.close()
234
+ },
235
+ }
236
+ }
237
+
238
+ function defaultEventSourceFactory(url: string): EventSourceLike | null {
239
+ if (typeof EventSource === 'undefined') return null
240
+ // `withCredentials: true` so the admin session cookie travels with
241
+ // the connection upgrade — every notification stream requires auth
242
+ // and the same-origin cookie is the only credential we trust here.
243
+ return new EventSource(url, { withCredentials: true }) as unknown as EventSourceLike
244
+ }
245
+
246
+ function parseEventData(event: MessageEvent | Event): unknown {
247
+ // EventSource emits MessageEvents with `data: string`. Plain
248
+ // `Event` (e.g. the `error` event) has no payload; ignore.
249
+ const data = (event as MessageEvent).data
250
+ if (typeof data !== 'string') return null
251
+ try {
252
+ return JSON.parse(data)
253
+ } catch {
254
+ return null
255
+ }
256
+ }
257
+
258
+ function isNotification(value: unknown): value is Notification {
259
+ if (!value || typeof value !== 'object') return false
260
+ const v = value as Record<string, unknown>
261
+ // Defensive: don't trust the wire blindly — verify the discriminant
262
+ // properties our consumers will read. A drift in the server-side
263
+ // wire format must surface as "missing fields" in the test suite,
264
+ // not as runtime errors deep in the bell's render path.
265
+ return (
266
+ typeof v.id === 'string' &&
267
+ typeof v.userId === 'string' &&
268
+ typeof v.createdAt === 'string' &&
269
+ !!v.payload &&
270
+ typeof (v.payload as { kind?: unknown }).kind === 'string'
271
+ )
272
+ }
273
+
274
+ /**
275
+ * Cheap single-line summary for the bell list — the server gives us a
276
+ * structured payload, but the dropdown wants one human-readable string
277
+ * per row. Falls back to `Notification` when an unknown kind sneaks in
278
+ * (defensive against future server-side additions).
279
+ */
280
+ export function notificationSummary(notification: Notification): string {
281
+ const actor = notification.payload.actorName ?? 'Someone'
282
+ switch (notification.payload.kind) {
283
+ case 'comment_reply':
284
+ return `${actor} replied: "${notification.payload.excerpt}"`
285
+ case 'comment_resolved':
286
+ return `${actor} resolved your thread: "${notification.payload.rootExcerpt}"`
287
+ case 'comment_mention':
288
+ return `${actor} mentioned you: "${notification.payload.excerpt}"`
289
+ default:
290
+ return 'Notification'
291
+ }
292
+ }
@@ -0,0 +1,67 @@
1
+ import { COAUTHOR_ACTIONS, isCoauthorAction, type CoauthorAction } from './coauthor-client.js'
2
+
3
+ /**
4
+ * Cmd+K slash-command parsing for the AI co-author.
5
+ *
6
+ * Recognized syntax (case-insensitive, leading whitespace tolerated):
7
+ *
8
+ * /ai <action> [text...]
9
+ *
10
+ * `<action>` is one of `COAUTHOR_ACTIONS`. `text` is the payload handed to
11
+ * `/ai/coauthor`; it may be empty while the user is still typing.
12
+ *
13
+ * The parser is pure so we can unit-test every edge case (case folding,
14
+ * trailing whitespace, partial input, unknown actions) without spinning up
15
+ * `cmdk` or the rest of the palette.
16
+ */
17
+
18
+ export interface AiSlashCommand {
19
+ action: CoauthorAction
20
+ text: string
21
+ }
22
+
23
+ const SLASH_HEAD = /^\s*\/\s*ai(?:\s+|$)/i
24
+
25
+ /**
26
+ * Returns the matched `{ action, text }` when `input` is a complete
27
+ * slash command (`/ai rewrite ...`), or `null` otherwise. `text` may be
28
+ * empty when the user has typed `/ai rewrite` with no payload yet.
29
+ */
30
+ export function parseAiSlashCommand(input: string): AiSlashCommand | null {
31
+ if (typeof input !== 'string') return null
32
+ if (!SLASH_HEAD.test(input)) return null
33
+ const after = input.replace(SLASH_HEAD, '')
34
+ if (after.length === 0) return null
35
+
36
+ const firstWhitespace = after.search(/\s/)
37
+ const head = (firstWhitespace === -1 ? after : after.slice(0, firstWhitespace)).toLowerCase()
38
+ if (!isCoauthorAction(head)) return null
39
+
40
+ const tail = firstWhitespace === -1 ? '' : after.slice(firstWhitespace + 1)
41
+ return { action: head, text: tail.trim() }
42
+ }
43
+
44
+ /**
45
+ * Returns true when `input` looks like the start of an AI slash command
46
+ * — even when the action isn't typed yet (`/`, `/a`, `/ai`, `/ai re`).
47
+ * The palette uses this to switch into "AI mode" (hide nav results,
48
+ * show the four action suggestions).
49
+ */
50
+ export function isAiSlashCommandInput(input: string): boolean {
51
+ if (typeof input !== 'string') return false
52
+ const trimmed = input.trim()
53
+ if (trimmed.length === 0) return false
54
+ if (trimmed === '/') return true
55
+ return /^\/\s*a(i\b|i?[a-z]*|)?(\s.*)?$/i.test(trimmed)
56
+ }
57
+
58
+ /**
59
+ * Ordered list of actions to show in AI mode. When the user has typed a
60
+ * valid command, that command's action is hoisted to the top so it gets
61
+ * the default keyboard focus (and Enter runs it).
62
+ */
63
+ export function orderedAiActions(input: string): CoauthorAction[] {
64
+ const parsed = parseAiSlashCommand(input)
65
+ if (!parsed) return [...COAUTHOR_ACTIONS]
66
+ return [parsed.action, ...COAUTHOR_ACTIONS.filter((a) => a !== parsed.action)]
67
+ }