@actuate-media/cms-admin 0.13.0 → 0.14.1

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 (34) hide show
  1. package/dist/__tests__/components/comment-side-panel.test.js +93 -0
  2. package/dist/__tests__/components/comment-side-panel.test.js.map +1 -1
  3. package/dist/__tests__/components/mentionable-textarea.test.d.ts +2 -0
  4. package/dist/__tests__/components/mentionable-textarea.test.d.ts.map +1 -0
  5. package/dist/__tests__/components/mentionable-textarea.test.js +190 -0
  6. package/dist/__tests__/components/mentionable-textarea.test.js.map +1 -0
  7. package/dist/__tests__/lib/mention-picker.test.d.ts +2 -0
  8. package/dist/__tests__/lib/mention-picker.test.d.ts.map +1 -0
  9. package/dist/__tests__/lib/mention-picker.test.js +122 -0
  10. package/dist/__tests__/lib/mention-picker.test.js.map +1 -0
  11. package/dist/actuate-admin.css +1 -1
  12. package/dist/components/CommentSidePanel.d.ts +9 -1
  13. package/dist/components/CommentSidePanel.d.ts.map +1 -1
  14. package/dist/components/CommentSidePanel.js +8 -7
  15. package/dist/components/CommentSidePanel.js.map +1 -1
  16. package/dist/components/MentionableTextarea.d.ts +68 -0
  17. package/dist/components/MentionableTextarea.d.ts.map +1 -0
  18. package/dist/components/MentionableTextarea.js +181 -0
  19. package/dist/components/MentionableTextarea.js.map +1 -0
  20. package/dist/components/NotificationBell.d.ts.map +1 -1
  21. package/dist/components/NotificationBell.js +32 -11
  22. package/dist/components/NotificationBell.js.map +1 -1
  23. package/dist/lib/mention-picker.d.ts +69 -0
  24. package/dist/lib/mention-picker.d.ts.map +1 -0
  25. package/dist/lib/mention-picker.js +138 -0
  26. package/dist/lib/mention-picker.js.map +1 -0
  27. package/package.json +2 -2
  28. package/src/__tests__/components/comment-side-panel.test.tsx +134 -0
  29. package/src/__tests__/components/mentionable-textarea.test.tsx +226 -0
  30. package/src/__tests__/lib/mention-picker.test.ts +145 -0
  31. package/src/components/CommentSidePanel.tsx +37 -14
  32. package/src/components/MentionableTextarea.tsx +328 -0
  33. package/src/components/NotificationBell.tsx +31 -11
  34. package/src/lib/mention-picker.ts +180 -0
@@ -341,3 +341,137 @@ describe('CommentSidePanel — reply, edit, resolve, delete', () => {
341
341
  expect(onActive).toHaveBeenCalledWith('c1')
342
342
  })
343
343
  })
344
+
345
+ // ---------------------------------------------------------------------------
346
+ // Mention picker integration — Phase 7 carry-over
347
+ // ---------------------------------------------------------------------------
348
+
349
+ describe('CommentSidePanel — mention picker integration', () => {
350
+ /**
351
+ * Drive a `<textarea>` the same way the picker test does. The
352
+ * comment panel's three composer surfaces (new thread, reply,
353
+ * edit) are all `<MentionableTextarea>` now, so this helper works
354
+ * uniformly across them.
355
+ */
356
+ async function typeAt(el: HTMLTextAreaElement, value: string, caret = value.length) {
357
+ await act(async () => {
358
+ el.focus()
359
+ const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set
360
+ if (setter) setter.call(el, value)
361
+ else el.value = value
362
+ el.setSelectionRange(caret, caret)
363
+ el.dispatchEvent(new Event('input', { bubbles: true }))
364
+ })
365
+ }
366
+
367
+ function fakeMentionSearch() {
368
+ return vi.fn(async (q: string) => {
369
+ const all = [
370
+ { id: 'u_alice', name: 'Alice Anderson', email: 'alice@example.com' },
371
+ { id: 'u_bob', name: 'Bob Brown', email: 'bob@example.com' },
372
+ ]
373
+ const ql = q.toLowerCase()
374
+ return all.filter(
375
+ (u) => u.name.toLowerCase().includes(ql) || u.email.toLowerCase().includes(ql),
376
+ )
377
+ })
378
+ }
379
+
380
+ it('opens the mention picker from the new-thread composer and submits @<userId>', async () => {
381
+ const fake = createFakeApi()
382
+ const mentionSearch = fakeMentionSearch()
383
+ render(
384
+ <CommentSidePanel
385
+ documentId="doc-1"
386
+ currentUserId="current"
387
+ api={fake.api}
388
+ mentionSearch={mentionSearch}
389
+ />,
390
+ )
391
+ await waitFor(() => expect(screen.getByText('No comments yet.')).toBeTruthy())
392
+
393
+ const composer = screen.getByTestId('comment-composer') as HTMLTextAreaElement
394
+ await typeAt(composer, '@ali')
395
+ await waitFor(() => expect(screen.getByTestId('comment-composer-picker')).toBeTruthy())
396
+ await act(async () => {
397
+ fireEvent.mouseDown(screen.getByTestId('comment-composer-picker-row-u_alice'))
398
+ })
399
+ expect((composer as HTMLTextAreaElement).value).toBe('@u_alice ')
400
+
401
+ // Continue typing then submit — the resulting comment body must
402
+ // carry the `@<userId>` token so `extractMentionedUserIds` picks
403
+ // it up server-side.
404
+ await typeAt(composer, '@u_alice please review')
405
+ await act(async () => {
406
+ fireEvent.click(screen.getByTestId('comment-submit'))
407
+ })
408
+ expect(fake.api.create).toHaveBeenCalledWith(
409
+ expect.objectContaining({ body: '@u_alice please review' }),
410
+ )
411
+ })
412
+
413
+ it('opens the mention picker from a reply input and inserts @<userId>', async () => {
414
+ const fake = createFakeApi()
415
+ fake.rows.push(comment({ id: 'root', body: 'root' }))
416
+ const mentionSearch = fakeMentionSearch()
417
+ render(
418
+ <CommentSidePanel
419
+ documentId="doc-1"
420
+ currentUserId="current"
421
+ api={fake.api}
422
+ mentionSearch={mentionSearch}
423
+ />,
424
+ )
425
+ await waitFor(() => expect(screen.getByTestId('comment-thread-root')).toBeTruthy())
426
+
427
+ const reply = screen.getByTestId('reply-input-root') as HTMLTextAreaElement
428
+ await typeAt(reply, '@bo')
429
+ await waitFor(() => expect(screen.getByTestId('reply-input-root-picker')).toBeTruthy())
430
+ fireEvent.keyDown(reply, { key: 'Enter' })
431
+ await waitFor(() => expect(reply.value).toBe('@u_bob '))
432
+ })
433
+
434
+ it('opens the mention picker from the edit-textarea while editing a comment', async () => {
435
+ const fake = createFakeApi()
436
+ fake.rows.push(comment({ id: 'c1', userId: 'current', body: 'before' }))
437
+ const mentionSearch = fakeMentionSearch()
438
+ render(
439
+ <CommentSidePanel
440
+ documentId="doc-1"
441
+ currentUserId="current"
442
+ api={fake.api}
443
+ mentionSearch={mentionSearch}
444
+ />,
445
+ )
446
+ await waitFor(() => expect(screen.getByTestId('comment-thread-c1')).toBeTruthy())
447
+ fireEvent.click(screen.getByTestId('edit-c1'))
448
+ const edit = screen.getByTestId('edit-textarea-c1') as HTMLTextAreaElement
449
+ await typeAt(edit, '@al')
450
+ await waitFor(() => expect(screen.getByTestId('edit-textarea-c1-picker')).toBeTruthy())
451
+ await act(async () => {
452
+ fireEvent.mouseDown(screen.getByTestId('edit-textarea-c1-picker-row-u_alice'))
453
+ })
454
+ expect(edit.value).toBe('@u_alice ')
455
+ })
456
+
457
+ it('escape closes the picker without committing a selection', async () => {
458
+ const fake = createFakeApi()
459
+ const mentionSearch = fakeMentionSearch()
460
+ render(
461
+ <CommentSidePanel
462
+ documentId="doc-1"
463
+ currentUserId="current"
464
+ api={fake.api}
465
+ mentionSearch={mentionSearch}
466
+ />,
467
+ )
468
+ await waitFor(() => expect(screen.getByText('No comments yet.')).toBeTruthy())
469
+
470
+ const composer = screen.getByTestId('comment-composer') as HTMLTextAreaElement
471
+ await typeAt(composer, '@al')
472
+ await waitFor(() => expect(screen.getByTestId('comment-composer-picker')).toBeTruthy())
473
+ fireEvent.keyDown(composer, { key: 'Escape' })
474
+ expect(screen.queryByTestId('comment-composer-picker')).toBeNull()
475
+ expect((composer as HTMLTextAreaElement).value).toBe('@al')
476
+ })
477
+ })
@@ -0,0 +1,226 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import React, { useState } from 'react'
4
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
5
+
6
+ import { MentionableTextarea } from '../../components/MentionableTextarea.js'
7
+ import type { MentionUser } from '../../lib/mention-picker.js'
8
+
9
+ /**
10
+ * Mount the textarea against a state-managing host so we exercise the
11
+ * controlled-value contract. Returning the live `value` lets each
12
+ * test inspect what the parent ends up with after a selection.
13
+ */
14
+ function Host({
15
+ initialValue = '',
16
+ search,
17
+ testid = 'ta',
18
+ }: {
19
+ initialValue?: string
20
+ search: (q: string) => Promise<MentionUser[]>
21
+ testid?: string
22
+ }) {
23
+ const [value, setValue] = useState(initialValue)
24
+ return (
25
+ <>
26
+ <MentionableTextarea value={value} onChange={setValue} search={search} data-testid={testid} />
27
+ <output data-testid="value">{value}</output>
28
+ </>
29
+ )
30
+ }
31
+
32
+ function fakeSearch(rows: MentionUser[]) {
33
+ return vi.fn(async (q: string) => {
34
+ const ql = q.toLowerCase()
35
+ return rows.filter(
36
+ (u) => u.name.toLowerCase().includes(ql) || u.email.toLowerCase().includes(ql),
37
+ )
38
+ })
39
+ }
40
+
41
+ const ROWS: MentionUser[] = [
42
+ { id: 'u_alice', name: 'Alice Anderson', email: 'alice@example.com' },
43
+ { id: 'u_bob', name: 'Bob Brown', email: 'bob@example.com' },
44
+ { id: 'u_carol', name: 'Carol Chen', email: 'carol@example.com' },
45
+ ]
46
+
47
+ async function typeAt(testid: string, value: string, caret = value.length) {
48
+ const ta = screen.getByTestId(testid) as HTMLTextAreaElement
49
+ await act(async () => {
50
+ // Manually drive the textarea: testing-library's
51
+ // `fireEvent.change` resets `selectionStart` to 0 in happy-dom,
52
+ // which would defeat the mention picker (caret 0 → no mention).
53
+ // We bypass React's value-tracker the same way testing-library
54
+ // does, then set the caret BEFORE dispatching `input` so the
55
+ // React handler sees the correct selection.
56
+ ta.focus()
57
+ const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set
58
+ if (setter) setter.call(ta, value)
59
+ else ta.value = value
60
+ ta.setSelectionRange(caret, caret)
61
+ ta.dispatchEvent(new Event('input', { bubbles: true }))
62
+ })
63
+ }
64
+
65
+ describe('MentionableTextarea — picker dropdown', () => {
66
+ it('does not show the picker before @ is typed', async () => {
67
+ const search = fakeSearch(ROWS)
68
+ render(<Host search={search} />)
69
+ await typeAt('ta', 'hello world')
70
+ expect(screen.queryByTestId('ta-picker')).toBeNull()
71
+ })
72
+
73
+ it('opens the picker the instant @ is typed and shows the empty state', async () => {
74
+ const search = fakeSearch(ROWS)
75
+ render(<Host search={search} />)
76
+ await typeAt('ta', '@')
77
+ await waitFor(() => expect(screen.getByTestId('ta-picker')).toBeTruthy())
78
+ })
79
+
80
+ it('populates the dropdown with users that match the query', async () => {
81
+ const search = fakeSearch(ROWS)
82
+ render(<Host search={search} />)
83
+ await typeAt('ta', '@ali')
84
+ await waitFor(() => expect(screen.getByTestId('ta-picker-row-u_alice')).toBeTruthy())
85
+ // Bob/Carol should NOT match `ali`.
86
+ expect(screen.queryByTestId('ta-picker-row-u_bob')).toBeNull()
87
+ })
88
+
89
+ it('shows "No matches" when the query has no hits', async () => {
90
+ const search = fakeSearch(ROWS)
91
+ render(<Host search={search} />)
92
+ await typeAt('ta', '@zzzz')
93
+ await waitFor(() => expect(screen.getByText(/No matches/i)).toBeTruthy())
94
+ })
95
+
96
+ it('closes the picker on Escape without inserting anything', async () => {
97
+ const search = fakeSearch(ROWS)
98
+ render(<Host search={search} />)
99
+ await typeAt('ta', '@al')
100
+ await waitFor(() => expect(screen.getByTestId('ta-picker')).toBeTruthy())
101
+ fireEvent.keyDown(screen.getByTestId('ta'), { key: 'Escape' })
102
+ expect(screen.queryByTestId('ta-picker')).toBeNull()
103
+ expect(screen.getByTestId('value').textContent).toBe('@al')
104
+ })
105
+
106
+ it('inserts @<userId> + trailing space when a row is clicked', async () => {
107
+ const search = fakeSearch(ROWS)
108
+ render(<Host search={search} />)
109
+ await typeAt('ta', '@ali')
110
+ await waitFor(() => expect(screen.getByTestId('ta-picker-row-u_alice')).toBeTruthy())
111
+ await act(async () => {
112
+ // We use mousedown intentionally (the component listens for
113
+ // mousedown to avoid losing focus before commit).
114
+ fireEvent.mouseDown(screen.getByTestId('ta-picker-row-u_alice'))
115
+ })
116
+ expect(screen.getByTestId('value').textContent).toBe('@u_alice ')
117
+ expect(screen.queryByTestId('ta-picker')).toBeNull()
118
+ })
119
+
120
+ it('Enter commits the highlighted row', async () => {
121
+ const search = fakeSearch(ROWS)
122
+ render(<Host search={search} />)
123
+ await typeAt('ta', '@b')
124
+ await waitFor(() => expect(screen.getByTestId('ta-picker-row-u_bob')).toBeTruthy())
125
+ await act(async () => {
126
+ fireEvent.keyDown(screen.getByTestId('ta'), { key: 'Enter' })
127
+ })
128
+ expect(screen.getByTestId('value').textContent).toBe('@u_bob ')
129
+ })
130
+
131
+ it('ArrowDown then Enter commits the second result', async () => {
132
+ const search = fakeSearch(ROWS)
133
+ render(<Host search={search} />)
134
+ // Query 'e' hits Alice/Bob/Carol's emails — three matches.
135
+ await typeAt('ta', '@e')
136
+ await waitFor(() => expect(screen.getByTestId('ta-picker-row-u_alice')).toBeTruthy())
137
+ fireEvent.keyDown(screen.getByTestId('ta'), { key: 'ArrowDown' })
138
+ fireEvent.keyDown(screen.getByTestId('ta'), { key: 'ArrowDown' })
139
+ fireEvent.keyDown(screen.getByTestId('ta'), { key: 'ArrowUp' }) // back to row 2
140
+ await act(async () => {
141
+ fireEvent.keyDown(screen.getByTestId('ta'), { key: 'Enter' })
142
+ })
143
+ // Highlight after Down/Down/Up: 0 → 1 → 2 → 1. Row index 1 should
144
+ // be Bob (alphabetised by name? Actually fakeSearch returns in
145
+ // ROWS order — Alice, Bob, Carol). So commit = u_bob.
146
+ expect(screen.getByTestId('value').textContent).toBe('@u_bob ')
147
+ })
148
+
149
+ it('keyboard does NOT swallow Enter when the picker is closed', async () => {
150
+ const search = fakeSearch(ROWS)
151
+ const onKeyDown = vi.fn()
152
+ const Wrapper = () => {
153
+ const [v, setV] = useState('')
154
+ return (
155
+ <MentionableTextarea
156
+ value={v}
157
+ onChange={setV}
158
+ search={search}
159
+ onKeyDown={onKeyDown}
160
+ data-testid="ta"
161
+ />
162
+ )
163
+ }
164
+ render(<Wrapper />)
165
+ fireEvent.keyDown(screen.getByTestId('ta'), { key: 'Enter' })
166
+ expect(onKeyDown).toHaveBeenCalledTimes(1)
167
+ })
168
+
169
+ it('does NOT commit a stale row when Enter fires before a new query resolves (PR #159 review)', async () => {
170
+ // Regression for PR #159 Copilot finding: typing `@a`, getting
171
+ // Alice in the dropdown, then quickly typing `@bo` and hitting
172
+ // Enter before the `@bo` query resolves used to commit Alice
173
+ // (from the previous query) instead of doing nothing or
174
+ // committing Bob. The fix clears `users` on every new query so
175
+ // Enter has no row to commit until the new results land.
176
+ let resolveSlow: (rows: MentionUser[]) => void = () => undefined
177
+ const search = vi.fn((q: string) => {
178
+ if (q === 'bo') {
179
+ return new Promise<MentionUser[]>((resolve) => {
180
+ resolveSlow = resolve
181
+ })
182
+ }
183
+ return Promise.resolve(ROWS.filter((u) => u.name.toLowerCase().includes(q.toLowerCase())))
184
+ })
185
+ render(<Host search={search} />)
186
+ await typeAt('ta', '@a')
187
+ await waitFor(() => expect(screen.getByTestId('ta-picker-row-u_alice')).toBeTruthy())
188
+ // Now type the new query that hangs. Enter should NOT commit.
189
+ await typeAt('ta', '@bo')
190
+ fireEvent.keyDown(screen.getByTestId('ta'), { key: 'Enter' })
191
+ // Value should still be `@bo` — no token was inserted.
192
+ expect(screen.getByTestId('value').textContent).toBe('@bo')
193
+ // Resolve the hung query and confirm Bob is then committable.
194
+ await act(async () => {
195
+ resolveSlow(ROWS.filter((u) => u.name.toLowerCase().includes('bo')))
196
+ })
197
+ await waitFor(() => expect(screen.getByTestId('ta-picker-row-u_bob')).toBeTruthy())
198
+ })
199
+
200
+ it('drops stale responses when typing quickly (race protection)', async () => {
201
+ let resolveSlow: (rows: MentionUser[]) => void = () => undefined
202
+ const search = vi.fn((q: string) => {
203
+ if (q === 'a') {
204
+ return new Promise<MentionUser[]>((resolve) => {
205
+ resolveSlow = resolve
206
+ })
207
+ }
208
+ // Fast path for `ali`.
209
+ return Promise.resolve(ROWS.filter((u) => u.name.toLowerCase().includes(q.toLowerCase())))
210
+ })
211
+ render(<Host search={search} />)
212
+ // Trigger the slow request.
213
+ await typeAt('ta', '@a')
214
+ // Trigger the fast request which will resolve first.
215
+ await typeAt('ta', '@ali')
216
+ await waitFor(() => expect(screen.getByTestId('ta-picker-row-u_alice')).toBeTruthy())
217
+ // Now resolve the older request with the wrong rows — should NOT
218
+ // appear in the dropdown.
219
+ await act(async () => {
220
+ resolveSlow(ROWS)
221
+ })
222
+ // Carol should still be filtered out (the fast `ali` query is
223
+ // what won the race).
224
+ expect(screen.queryByTestId('ta-picker-row-u_carol')).toBeNull()
225
+ })
226
+ })
@@ -0,0 +1,145 @@
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
+ getApiBase: () => '/api/cms',
7
+ }))
8
+
9
+ const { detectMentionContext, insertMentionToken, searchMentionUsers } =
10
+ await import('../../lib/mention-picker.js')
11
+
12
+ beforeEach(() => {
13
+ cmsApiMock.mockReset()
14
+ })
15
+
16
+ describe('detectMentionContext', () => {
17
+ it('returns null when there is no @ before the caret', () => {
18
+ expect(detectMentionContext('hello world', 5)).toBeNull()
19
+ })
20
+
21
+ it('matches a fresh @ at the start of the field', () => {
22
+ expect(detectMentionContext('@ali', 4)).toEqual({ start: 0, end: 4, query: 'ali' })
23
+ })
24
+
25
+ it('matches @ after a leading space', () => {
26
+ expect(detectMentionContext('hey @bob', 8)).toEqual({ start: 4, end: 8, query: 'bob' })
27
+ })
28
+
29
+ it('opens immediately with an empty query the moment @ is typed', () => {
30
+ expect(detectMentionContext('@', 1)).toEqual({ start: 0, end: 1, query: '' })
31
+ })
32
+
33
+ it('does NOT open mid-word for email-like input (`foo@bar`)', () => {
34
+ expect(detectMentionContext('foo@bar', 7)).toBeNull()
35
+ })
36
+
37
+ it('terminates on a whitespace character between @ and caret', () => {
38
+ expect(detectMentionContext('@ali ce', 7)).toBeNull()
39
+ })
40
+
41
+ it('terminates on a non-mention punctuation character', () => {
42
+ expect(detectMentionContext('@ali.ce', 7)).toBeNull()
43
+ })
44
+
45
+ it('supports underscores and hyphens (id-shape charset)', () => {
46
+ expect(detectMentionContext('@user_id-42', 11)).toEqual({
47
+ start: 0,
48
+ end: 11,
49
+ query: 'user_id-42',
50
+ })
51
+ })
52
+
53
+ it('matches when the caret is mid-query (not at end of value)', () => {
54
+ // value: "@ali rest", caret after the `i`
55
+ expect(detectMentionContext('@ali rest', 4)).toEqual({ start: 0, end: 4, query: 'ali' })
56
+ })
57
+
58
+ it('handles newline as a boundary', () => {
59
+ expect(detectMentionContext('line\n@bob', 9)).toEqual({ start: 5, end: 9, query: 'bob' })
60
+ })
61
+
62
+ it('returns null when the caret is at offset 0', () => {
63
+ expect(detectMentionContext('@ali', 0)).toBeNull()
64
+ })
65
+
66
+ it('returns null when the caret is past the end of value', () => {
67
+ expect(detectMentionContext('@ali', 99)).toBeNull()
68
+ })
69
+
70
+ it('rejects queries that exceed the 64-char server cap', () => {
71
+ const long = 'a'.repeat(65)
72
+ expect(detectMentionContext(`@${long}`, 1 + long.length)).toBeNull()
73
+ })
74
+
75
+ it('returns null for non-string input (defensive)', () => {
76
+ // @ts-expect-error — runtime guard against caller misuse
77
+ expect(detectMentionContext(undefined, 0)).toBeNull()
78
+ })
79
+ })
80
+
81
+ describe('insertMentionToken', () => {
82
+ it('replaces the @<query> slice with @<userId> + trailing space', () => {
83
+ const ctx = { start: 0, end: 4, query: 'ali' }
84
+ expect(insertMentionToken('@ali rest', ctx, 'user_123')).toEqual({
85
+ value: '@user_123 rest',
86
+ caret: '@user_123 '.length,
87
+ })
88
+ })
89
+
90
+ it('does not duplicate a trailing space when one is already present', () => {
91
+ const ctx = { start: 0, end: 4, query: 'ali' }
92
+ const out = insertMentionToken('@ali extra', ctx, 'u1')
93
+ expect(out.value).toBe('@u1 extra')
94
+ expect(out.value.slice(out.caret)).toBe('extra')
95
+ })
96
+
97
+ it('preserves text before the @ token', () => {
98
+ const ctx = { start: 4, end: 8, query: 'bob' }
99
+ expect(insertMentionToken('hey @bob', ctx, 'u_99').value).toBe('hey @u_99 ')
100
+ })
101
+
102
+ it('places the caret immediately past the inserted token + space', () => {
103
+ const ctx = { start: 0, end: 1, query: '' }
104
+ const out = insertMentionToken('@', ctx, 'u_xyz')
105
+ expect(out.value).toBe('@u_xyz ')
106
+ expect(out.caret).toBe(out.value.length)
107
+ })
108
+ })
109
+
110
+ describe('searchMentionUsers', () => {
111
+ it('short-circuits an empty query without hitting the wire', async () => {
112
+ const out = await searchMentionUsers('')
113
+ expect(out).toEqual({ ok: true, result: [] })
114
+ expect(cmsApiMock).not.toHaveBeenCalled()
115
+ })
116
+
117
+ it('short-circuits a whitespace-only query', async () => {
118
+ const out = await searchMentionUsers(' ')
119
+ expect(out).toEqual({ ok: true, result: [] })
120
+ expect(cmsApiMock).not.toHaveBeenCalled()
121
+ })
122
+
123
+ it('URL-encodes the query and forwards the result array', async () => {
124
+ cmsApiMock.mockResolvedValueOnce({
125
+ data: [{ id: 'u-1', name: 'Alice', email: 'a@x.com' }],
126
+ status: 200,
127
+ })
128
+ const out = await searchMentionUsers('a li ce')
129
+ expect(cmsApiMock).toHaveBeenCalledWith(`/users/search?q=${encodeURIComponent('a li ce')}`, {
130
+ method: 'GET',
131
+ })
132
+ expect(out.ok).toBe(true)
133
+ if (!out.ok) return
134
+ expect(out.result.map((u) => u.id)).toEqual(['u-1'])
135
+ })
136
+
137
+ it('propagates server errors as { ok: false }', async () => {
138
+ cmsApiMock.mockResolvedValueOnce({ error: 'CSRF', status: 403 })
139
+ const out = await searchMentionUsers('ali')
140
+ expect(out.ok).toBe(false)
141
+ if (out.ok) return
142
+ expect(out.error).toBe('CSRF')
143
+ expect(out.status).toBe(403)
144
+ })
145
+ })
@@ -15,6 +15,8 @@ import {
15
15
  type CommentThread,
16
16
  type CommentOutcome,
17
17
  } from '../lib/comments-client.js'
18
+ import type { MentionUser } from '../lib/mention-picker.js'
19
+ import { MentionableTextarea } from './MentionableTextarea.js'
18
20
 
19
21
  /**
20
22
  * Side-panel view of the slice-4 comments API. Renders threaded comments
@@ -69,6 +71,13 @@ export interface CommentSidePanelProps {
69
71
  * should leave this unset.
70
72
  */
71
73
  api?: CommentsApi
74
+ /**
75
+ * Override the mention search — tests pass a synchronous fake so
76
+ * the picker is deterministic without HTTP mocking. Production
77
+ * callers leave this unset and the textarea uses the real
78
+ * `/users/search` REST helper.
79
+ */
80
+ mentionSearch?: (query: string) => Promise<MentionUser[]>
72
81
  className?: string
73
82
  }
74
83
 
@@ -111,6 +120,7 @@ export function CommentSidePanel({
111
120
  onActiveCommentChange,
112
121
  onError,
113
122
  api = defaultApi,
123
+ mentionSearch,
114
124
  className,
115
125
  }: CommentSidePanelProps): React.JSX.Element {
116
126
  const [comments, setComments] = useState<Comment[]>([])
@@ -236,13 +246,14 @@ export function CommentSidePanel({
236
246
  </header>
237
247
 
238
248
  <section className="flex flex-col gap-1.5">
239
- <textarea
249
+ <MentionableTextarea
240
250
  className="w-full resize-none rounded border border-gray-300 p-2 text-sm focus:border-blue-500 focus:outline-none"
241
251
  rows={3}
242
- placeholder="Write a comment…"
252
+ placeholder="Write a comment… Type @ to mention a teammate."
243
253
  value={draft}
244
- onChange={(e) => setDraft(e.target.value)}
254
+ onChange={setDraft}
245
255
  data-testid="comment-composer"
256
+ search={mentionSearch}
246
257
  />
247
258
  <div className="flex justify-end">
248
259
  <button
@@ -276,6 +287,7 @@ export function CommentSidePanel({
276
287
  onEdit={(id, body) => handleEdit(id, body)}
277
288
  onToggleResolve={(id, alreadyResolved) => handleResolve(id, alreadyResolved)}
278
289
  onDelete={(id) => handleDelete(id)}
290
+ mentionSearch={mentionSearch}
279
291
  />
280
292
  ))}
281
293
  </ul>
@@ -295,6 +307,7 @@ interface CommentThreadViewProps {
295
307
  onEdit: (id: string, body: string) => Promise<boolean>
296
308
  onToggleResolve: (id: string, alreadyResolved: boolean) => void
297
309
  onDelete: (id: string) => void
310
+ mentionSearch?: (query: string) => Promise<MentionUser[]>
298
311
  }
299
312
 
300
313
  function CommentThreadView({
@@ -307,6 +320,7 @@ function CommentThreadView({
307
320
  onEdit,
308
321
  onToggleResolve,
309
322
  onDelete,
323
+ mentionSearch,
310
324
  }: CommentThreadViewProps): React.JSX.Element {
311
325
  const [replyDraft, setReplyDraft] = useState('')
312
326
  const cardClass = `rounded border bg-gray-50 p-2 ${
@@ -327,6 +341,7 @@ function CommentThreadView({
327
341
  onEdit={onEdit}
328
342
  onToggleResolve={() => onToggleResolve(thread.root.id, thread.root.isResolved)}
329
343
  onDelete={() => onDelete(thread.root.id)}
344
+ mentionSearch={mentionSearch}
330
345
  />
331
346
  {thread.replies.length > 0 && (
332
347
  <ul className="mt-2 flex flex-col gap-1 border-l-2 border-gray-200 pl-2">
@@ -341,20 +356,25 @@ function CommentThreadView({
341
356
  // expose delete here.
342
357
  onToggleResolve={null}
343
358
  onDelete={() => onDelete(reply.id)}
359
+ mentionSearch={mentionSearch}
344
360
  />
345
361
  </li>
346
362
  ))}
347
363
  </ul>
348
364
  )}
349
- <div className="mt-2 flex gap-1">
350
- <input
351
- className="flex-1 rounded border border-gray-300 px-2 py-1 text-xs focus:border-blue-500 focus:outline-none"
352
- placeholder="Reply…"
353
- value={replyDraft}
354
- onChange={(e) => setReplyDraft(e.target.value)}
355
- data-testid={`reply-input-${thread.root.id}`}
356
- onClick={(e) => e.stopPropagation()}
357
- />
365
+ <div className="mt-2 flex items-start gap-1">
366
+ <div className="flex-1">
367
+ <MentionableTextarea
368
+ className="w-full resize-none rounded border border-gray-300 px-2 py-1 text-xs focus:border-blue-500 focus:outline-none"
369
+ placeholder="Reply… Type @ to mention."
370
+ rows={1}
371
+ value={replyDraft}
372
+ onChange={setReplyDraft}
373
+ data-testid={`reply-input-${thread.root.id}`}
374
+ onClick={(e) => e.stopPropagation()}
375
+ search={mentionSearch}
376
+ />
377
+ </div>
358
378
  <button
359
379
  type="button"
360
380
  className="rounded bg-gray-700 px-2 py-1 text-xs font-medium text-white hover:bg-gray-600 disabled:opacity-50"
@@ -380,6 +400,7 @@ interface CommentRowProps {
380
400
  onEdit: (id: string, body: string) => Promise<boolean>
381
401
  onToggleResolve: null | (() => void)
382
402
  onDelete: () => void
403
+ mentionSearch?: (query: string) => Promise<MentionUser[]>
383
404
  }
384
405
 
385
406
  function CommentRow({
@@ -389,6 +410,7 @@ function CommentRow({
389
410
  onEdit,
390
411
  onToggleResolve,
391
412
  onDelete,
413
+ mentionSearch,
392
414
  }: CommentRowProps): React.JSX.Element {
393
415
  const [editing, setEditing] = useState(false)
394
416
  const [draft, setDraft] = useState(comment.body)
@@ -409,13 +431,14 @@ function CommentRow({
409
431
  {meta}
410
432
  {editing ? (
411
433
  <>
412
- <textarea
434
+ <MentionableTextarea
413
435
  className="w-full resize-none rounded border border-gray-300 p-1 text-sm"
414
436
  rows={3}
415
437
  value={draft}
416
- onChange={(e) => setDraft(e.target.value)}
438
+ onChange={setDraft}
417
439
  data-testid={`edit-textarea-${comment.id}`}
418
440
  onClick={(e) => e.stopPropagation()}
441
+ search={mentionSearch}
419
442
  />
420
443
  <div className="flex gap-1">
421
444
  <button