@open-mercato/ui 0.8.1-develop.7296.1.2111d779db → 0.9.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 (85) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/backend/CrudForm.js +19 -6
  3. package/dist/backend/CrudForm.js.map +2 -2
  4. package/dist/backend/DataTable.js +62 -10
  5. package/dist/backend/DataTable.js.map +2 -2
  6. package/dist/backend/RowActions.js +54 -5
  7. package/dist/backend/RowActions.js.map +2 -2
  8. package/dist/backend/charts/KpiCard.js +2 -1
  9. package/dist/backend/charts/KpiCard.js.map +2 -2
  10. package/dist/backend/detail/AttachmentsSection.js +17 -5
  11. package/dist/backend/detail/AttachmentsSection.js.map +2 -2
  12. package/dist/backend/detail/NotesSection.js +38 -20
  13. package/dist/backend/detail/NotesSection.js.map +2 -2
  14. package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js +11 -1
  15. package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js.map +2 -2
  16. package/dist/backend/icons/lucideRegistry.generated.js +26 -0
  17. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  18. package/dist/backend/injection/eventBridge.js +33 -0
  19. package/dist/backend/injection/eventBridge.js.map +2 -2
  20. package/dist/backend/inputs/LookupSelect.js +20 -3
  21. package/dist/backend/inputs/LookupSelect.js.map +2 -2
  22. package/dist/backend/inputs/PhoneNumberField.js +1 -1
  23. package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
  24. package/dist/backend/inputs/TagsInput.js +20 -2
  25. package/dist/backend/inputs/TagsInput.js.map +2 -2
  26. package/dist/backend/messages/EmailThreadsPanel.js +2 -1
  27. package/dist/backend/messages/EmailThreadsPanel.js.map +2 -2
  28. package/dist/backend/messages/MessageComposer.js.map +2 -2
  29. package/dist/backend/messages/index.js.map +2 -2
  30. package/dist/backend/messages/message-compose-form-groups.js +24 -0
  31. package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
  32. package/dist/backend/messages/useMessageCompose.js +27 -0
  33. package/dist/backend/messages/useMessageCompose.js.map +2 -2
  34. package/dist/backend/messages/useMessageComposeOperations.js +1 -0
  35. package/dist/backend/messages/useMessageComposeOperations.js.map +2 -2
  36. package/dist/backend/notifications/NotificationItem.js +8 -1
  37. package/dist/backend/notifications/NotificationItem.js.map +2 -2
  38. package/dist/portal/PortalShell.js +2 -2
  39. package/dist/portal/PortalShell.js.map +2 -2
  40. package/dist/primitives/calendar.js +69 -14
  41. package/dist/primitives/calendar.js.map +2 -2
  42. package/package.json +4 -5
  43. package/src/backend/CrudForm.tsx +30 -8
  44. package/src/backend/DataTable.tsx +95 -20
  45. package/src/backend/RowActions.tsx +114 -6
  46. package/src/backend/__tests__/AttachmentsSection.upload.test.tsx +183 -0
  47. package/src/backend/__tests__/CrudForm.fieldInjection.test.tsx +139 -2
  48. package/src/backend/__tests__/CrudForm.hiddenGroups.test.tsx +1 -1
  49. package/src/backend/__tests__/DataTable.extensions.test.tsx +30 -0
  50. package/src/backend/__tests__/DataTable.perspectiveReconcile.test.tsx +320 -2
  51. package/src/backend/__tests__/DataTable.stickyResponsive.test.tsx +19 -0
  52. package/src/backend/__tests__/DevRuntimeDiagnosticsBanner.test.tsx +117 -0
  53. package/src/backend/__tests__/NotesSection.test.tsx +138 -0
  54. package/src/backend/__tests__/RowActions.test.tsx +202 -0
  55. package/src/backend/charts/KpiCard.tsx +1 -0
  56. package/src/backend/charts/__tests__/KpiCard.test.tsx +20 -0
  57. package/src/backend/detail/AttachmentsSection.tsx +17 -5
  58. package/src/backend/detail/NotesSection.tsx +122 -66
  59. package/src/backend/dev/DevRuntimeDiagnosticsBanner.tsx +24 -1
  60. package/src/backend/icons/lucideRegistry.generated.tsx +26 -0
  61. package/src/backend/injection/__tests__/eventBridge.test.tsx +47 -1
  62. package/src/backend/injection/eventBridge.ts +35 -0
  63. package/src/backend/inputs/LookupSelect.tsx +32 -3
  64. package/src/backend/inputs/PhoneNumberField.tsx +1 -1
  65. package/src/backend/inputs/TagsInput.tsx +24 -3
  66. package/src/backend/inputs/__tests__/LookupSelect.test.tsx +166 -1
  67. package/src/backend/inputs/__tests__/PhoneNumberField.typeahead.test.tsx +43 -0
  68. package/src/backend/inputs/__tests__/TagsInput.test.tsx +81 -0
  69. package/src/backend/messages/EmailThreadsPanel.tsx +21 -1
  70. package/src/backend/messages/MessageComposer.tsx +1 -0
  71. package/src/backend/messages/__tests__/EmailThreadsPanel.senderLabel.test.tsx +99 -0
  72. package/src/backend/messages/__tests__/MessageComposer.test.tsx +44 -0
  73. package/src/backend/messages/__tests__/useMessageCompose.test.tsx +69 -0
  74. package/src/backend/messages/__tests__/useMessageComposeSender.test.tsx +208 -0
  75. package/src/backend/messages/index.ts +1 -0
  76. package/src/backend/messages/message-compose-form-groups.tsx +41 -0
  77. package/src/backend/messages/message-composer.types.ts +26 -0
  78. package/src/backend/messages/useMessageCompose.ts +46 -0
  79. package/src/backend/messages/useMessageComposeOperations.ts +8 -0
  80. package/src/backend/notifications/NotificationItem.tsx +7 -0
  81. package/src/backend/notifications/__tests__/NotificationItem.customRenderer.test.tsx +139 -0
  82. package/src/portal/PortalShell.tsx +7 -4
  83. package/src/portal/__tests__/PortalShell.test.tsx +29 -1
  84. package/src/primitives/__tests__/calendar.test.tsx +174 -1
  85. package/src/primitives/calendar.tsx +168 -96
@@ -0,0 +1,202 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { screen, fireEvent } from '@testing-library/react'
7
+ import { RowActions, type RowActionItem } from '../RowActions'
8
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
9
+
10
+ // Regression coverage for the QA findings on PR #6685:
11
+ // #6718 — the menu could not be driven with the arrow keys, so reaching an
12
+ // item meant Tabbing through every other row's trigger first.
13
+ // #6719 — the panel had a fixed `w-44`, so the Polish "Zarejestruj push
14
+ // ponownie" overhung its right border by ~10px.
15
+ // #6719 is the same defect ActionsDropdown carried as #3580, and is fixed the
16
+ // same way here.
17
+ const LONG_POLISH_LABEL = 'Zarejestruj push ponownie'
18
+ const TRIGGER_LABEL = 'Open actions'
19
+
20
+ function renderMenu(items: RowActionItem[]) {
21
+ return renderWithProviders(<RowActions items={items} />, { dict: {} })
22
+ }
23
+
24
+ function openMenu() {
25
+ // The panel needs two renders: the click opens the menu, and measuring the
26
+ // trigger sets the anchor that actually renders the portal. Focus lands as
27
+ // part of that second commit — no timer involved, so nothing to advance here.
28
+ fireEvent.click(screen.getByRole('button', { name: TRIGGER_LABEL }))
29
+ }
30
+
31
+ const THREE_ITEMS: RowActionItem[] = [
32
+ { id: 'set-primary', label: 'Set as primary', onSelect: jest.fn() },
33
+ { id: 'poll-now', label: 'Poll now', onSelect: jest.fn() },
34
+ { id: 'disconnect', label: 'Disconnect', destructive: true, onSelect: jest.fn() },
35
+ ]
36
+
37
+ function itemNamed(name: string) {
38
+ return screen.getByRole('menuitem', { name })
39
+ }
40
+
41
+ describe('RowActions — panel width (#6719)', () => {
42
+
43
+ it('grows to fit a long label instead of clipping it with a fixed width', () => {
44
+ renderMenu([{ id: 'register-push', label: LONG_POLISH_LABEL, onSelect: jest.fn() }])
45
+ openMenu()
46
+
47
+ const menu = screen.getByRole('menu')
48
+ expect(menu.className).toContain('w-max')
49
+ expect(menu.className).toContain('min-w-44')
50
+ expect(menu.className).toContain('max-w-xs')
51
+ // The bug was a hard `w-44` that longer localized labels overflowed.
52
+ expect(menu.className).not.toMatch(/(^|\s)w-44(\s|$)/)
53
+
54
+ expect(screen.getByText(LONG_POLISH_LABEL)).toBeInTheDocument()
55
+ })
56
+
57
+ it('stacks items as flex children so w-max measures the widest one', () => {
58
+ renderMenu(THREE_ITEMS)
59
+ openMenu()
60
+
61
+ // Load-bearing, not cosmetic. Button is `inline-flex`, so under a block
62
+ // panel the items are inline-level and `max-content` is all of them laid
63
+ // end to end — far past `max-w-xs`, which pinned the panel to the cap
64
+ // regardless of its labels. Blockifying them makes `w-max` mean what it says.
65
+ const menu = screen.getByRole('menu')
66
+ expect(menu.className).toContain('flex')
67
+ expect(menu.className).toContain('flex-col')
68
+ })
69
+
70
+ it('lets an over-long label wrap rather than overflow the panel', () => {
71
+ renderMenu([{ id: 'register-push', label: LONG_POLISH_LABEL, onSelect: jest.fn() }])
72
+ openMenu()
73
+
74
+ const item = itemNamed(LONG_POLISH_LABEL)
75
+ // Button is `whitespace-nowrap` by default — that default is what pushed the
76
+ // label past the border, so it has to be overridden here.
77
+ expect(item.className).toContain('whitespace-normal')
78
+ expect(item.className).toContain('h-auto')
79
+ })
80
+ })
81
+
82
+ describe('RowActions — keyboard navigation (#6718)', () => {
83
+
84
+ it('focuses the first item when the menu opens', () => {
85
+ renderMenu(THREE_ITEMS)
86
+ openMenu()
87
+
88
+ expect(document.activeElement).toBe(itemNamed('Set as primary'))
89
+ })
90
+
91
+ it('moves down the list with ArrowDown and wraps at the end', () => {
92
+ renderMenu(THREE_ITEMS)
93
+ openMenu()
94
+
95
+ fireEvent.keyDown(document, { key: 'ArrowDown' })
96
+ expect(document.activeElement).toBe(itemNamed('Poll now'))
97
+
98
+ fireEvent.keyDown(document, { key: 'ArrowDown' })
99
+ expect(document.activeElement).toBe(itemNamed('Disconnect'))
100
+
101
+ fireEvent.keyDown(document, { key: 'ArrowDown' })
102
+ expect(document.activeElement).toBe(itemNamed('Set as primary'))
103
+ })
104
+
105
+ it('moves up the list with ArrowUp and wraps at the start', () => {
106
+ renderMenu(THREE_ITEMS)
107
+ openMenu()
108
+
109
+ fireEvent.keyDown(document, { key: 'ArrowUp' })
110
+ expect(document.activeElement).toBe(itemNamed('Disconnect'))
111
+
112
+ fireEvent.keyDown(document, { key: 'ArrowUp' })
113
+ expect(document.activeElement).toBe(itemNamed('Poll now'))
114
+ })
115
+
116
+ it('jumps to the first and last item with Home and End', () => {
117
+ renderMenu(THREE_ITEMS)
118
+ openMenu()
119
+
120
+ fireEvent.keyDown(document, { key: 'End' })
121
+ expect(document.activeElement).toBe(itemNamed('Disconnect'))
122
+
123
+ fireEvent.keyDown(document, { key: 'Home' })
124
+ expect(document.activeElement).toBe(itemNamed('Set as primary'))
125
+ })
126
+
127
+ it('keeps Tab inside the menu instead of escaping to the next row', () => {
128
+ renderMenu(THREE_ITEMS)
129
+ openMenu()
130
+
131
+ fireEvent.keyDown(document, { key: 'Tab' })
132
+ expect(document.activeElement).toBe(itemNamed('Poll now'))
133
+
134
+ fireEvent.keyDown(document, { key: 'Tab', shiftKey: true })
135
+ expect(document.activeElement).toBe(itemNamed('Set as primary'))
136
+ })
137
+
138
+ it('does not steal focus when the menu opens on hover (#6771)', () => {
139
+ // The menu also opens on hover. Moving focus into it on *every* open meant
140
+ // that brushing the mouse over ⋯ while typing in a list's search field
141
+ // pulled the caret out of the field and dropped the next keystrokes.
142
+ renderMenu(THREE_ITEMS)
143
+ const field = document.createElement('input')
144
+ document.body.appendChild(field)
145
+ field.focus()
146
+
147
+ fireEvent.pointerEnter(screen.getByRole('button', { name: TRIGGER_LABEL }), { pointerType: 'mouse' })
148
+
149
+ expect(screen.getByRole('menu')).toBeInTheDocument()
150
+ expect(document.activeElement).toBe(field)
151
+ field.remove()
152
+ })
153
+
154
+ it('leaves typing alone while a hover-opened menu is showing (#6771)', () => {
155
+ // The key handler is on `document`, so without scoping it would also
156
+ // swallow the arrows and Home/End of someone typing in that field.
157
+ renderMenu(THREE_ITEMS)
158
+ const field = document.createElement('input')
159
+ document.body.appendChild(field)
160
+ field.focus()
161
+ fireEvent.pointerEnter(screen.getByRole('button', { name: TRIGGER_LABEL }), { pointerType: 'mouse' })
162
+
163
+ for (const key of ['ArrowDown', 'ArrowUp', 'Home', 'End']) {
164
+ fireEvent.keyDown(document, { key })
165
+ expect(document.activeElement).toBe(field)
166
+ }
167
+ field.remove()
168
+ })
169
+
170
+ it('does not reset the highlighted item when the page scrolls', () => {
171
+ // Repositioning on scroll updates the anchor rect; that must not be read as
172
+ // a fresh open and send focus back to the first item.
173
+ renderMenu(THREE_ITEMS)
174
+ openMenu()
175
+ fireEvent.keyDown(document, { key: 'End' })
176
+ expect(document.activeElement).toBe(itemNamed('Disconnect'))
177
+
178
+ fireEvent.scroll(window)
179
+
180
+ expect(document.activeElement).toBe(itemNamed('Disconnect'))
181
+ })
182
+
183
+ it('closes on Escape and returns focus to the trigger', () => {
184
+ renderMenu(THREE_ITEMS)
185
+ openMenu()
186
+
187
+ fireEvent.keyDown(document, { key: 'Escape' })
188
+
189
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument()
190
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: TRIGGER_LABEL }))
191
+ })
192
+
193
+ it('still runs the action when an item is activated', () => {
194
+ const onSelect = jest.fn()
195
+ renderMenu([{ id: 'poll-now', label: 'Poll now', onSelect }])
196
+ openMenu()
197
+
198
+ fireEvent.click(itemNamed('Poll now'))
199
+
200
+ expect(onSelect).toHaveBeenCalledTimes(1)
201
+ })
202
+ })
@@ -138,6 +138,7 @@ export function KpiCard({
138
138
  <div className={wrapperClass}>
139
139
  {headerRow}
140
140
  <p className="text-2xl sm:text-3xl font-semibold tracking-tight text-card-foreground">--</p>
141
+ {footer != null && <div className="mt-3">{footer}</div>}
141
142
  </div>
142
143
  )
143
144
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { render, screen } from '@testing-library/react'
7
+ import { KpiCard } from '../KpiCard'
8
+
9
+ describe('KpiCard', () => {
10
+ it('keeps the footer when there is no value, because the footer is what explains the dash', () => {
11
+ render(<KpiCard title="Latest NPS" value={null} footer={<span>Never answered a survey</span>} />)
12
+ expect(screen.getByText('--')).toBeTruthy()
13
+ expect(screen.getByText('Never answered a survey')).toBeTruthy()
14
+ })
15
+
16
+ it('renders the footer beside a value as before', () => {
17
+ render(<KpiCard title="Orders" value={3} footer={<span>last 30 days</span>} />)
18
+ expect(screen.getByText('last 30 days')).toBeTruthy()
19
+ })
20
+ })
@@ -61,6 +61,7 @@ function AttachmentsSectionImpl({
61
61
  const [deleteOpen, setDeleteOpen] = React.useState(false)
62
62
  const [deleteTarget, setDeleteTarget] = React.useState<AttachmentItem | null>(null)
63
63
  const fileInputRef = React.useRef<HTMLInputElement | null>(null)
64
+ const uploadingRef = React.useRef(false)
64
65
 
65
66
  const load = React.useCallback(async (targetPage = 1, replace = true) => {
66
67
  if (!recordId) return
@@ -121,9 +122,12 @@ function AttachmentsSectionImpl({
121
122
 
122
123
  const acceptFiles = React.useCallback(
123
124
  async (files: FileList | null) => {
124
- if (!files || !files.length || !recordId) return
125
+ if (!files || !files.length || !recordId || uploadingRef.current) return
126
+ uploadingRef.current = true
125
127
  setError(null)
126
128
  setIsUploading(true)
129
+ let uploadedCount = 0
130
+ let uploadError: string | null = null
127
131
  try {
128
132
  for (const file of Array.from(files)) {
129
133
  const fd = new FormData()
@@ -139,12 +143,19 @@ function AttachmentsSectionImpl({
139
143
  const message = call.result?.error || t('attachments.library.upload.failed', 'Upload failed.')
140
144
  throw new Error(message)
141
145
  }
146
+ uploadedCount += 1
142
147
  }
143
- await load(1, true)
144
- onChanged?.()
145
148
  } catch (err: any) {
146
- setError(err?.message || t('attachments.library.upload.failed', 'Upload failed.'))
149
+ uploadError = err?.message || t('attachments.library.upload.failed', 'Upload failed.')
150
+ }
151
+ try {
152
+ if (uploadedCount > 0) {
153
+ await load(1, true)
154
+ onChanged?.()
155
+ }
147
156
  } finally {
157
+ if (uploadError) setError(uploadError)
158
+ uploadingRef.current = false
148
159
  setIsUploading(false)
149
160
  if (fileInputRef.current) {
150
161
  fileInputRef.current.value = ''
@@ -271,12 +282,13 @@ function AttachmentsSectionImpl({
271
282
  type="file"
272
283
  multiple
273
284
  className="hidden"
285
+ disabled={isUploading}
274
286
  onChange={(event) => void acceptFiles(event.target.files)}
275
287
  />
276
288
  </div>
277
289
  )}
278
290
 
279
- {error ? <p className="text-xs font-medium text-status-error-text">{error}</p> : null}
291
+ {error ? <p role="alert" className="text-xs font-medium text-status-error-text">{error}</p> : null}
280
292
 
281
293
  {loading ? (
282
294
  <div className="text-sm text-muted-foreground">{t('attachments.library.loading', 'Loading attachments…')}</div>
@@ -31,6 +31,9 @@ type Translator = (key: string, fallback?: string, params?: Record<string, strin
31
31
 
32
32
  const NOTES_PAGE_SIZE = 20
33
33
 
34
+ const noteBodyClassName =
35
+ 'break-words text-foreground [&>*]:mb-2 [&>*:last-child]:mb-0 [&_ul]:ml-4 [&_ul]:list-disc [&_ol]:ml-4 [&_ol]:list-decimal [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:text-xs'
36
+
34
37
  const isTestEnv =
35
38
  typeof process !== 'undefined' &&
36
39
  (process.env.NODE_ENV === 'test' || typeof process.env.JEST_WORKER_ID !== 'undefined')
@@ -58,6 +61,10 @@ export type CommentSummary = {
58
61
  authorEmail?: string | null
59
62
  dealId?: string | null
60
63
  dealTitle?: string | null
64
+ /** Generic per-note context (e.g. the child record a note came from), rendered like the linked deal. */
65
+ contextLabel?: string | null
66
+ /** Optional link for `contextLabel`; without it the label renders as plain text. */
67
+ contextHref?: string | null
61
68
  appearanceIcon?: string | null
62
69
  appearanceColor?: string | null
63
70
  }
@@ -221,6 +228,18 @@ export type NotesSectionProps<C = unknown> = {
221
228
  hideMarkdownToggle?: boolean
222
229
  /** Hide the appearance (icon/color) affordances: the palette buttons and the appearance dialog. */
223
230
  disableAppearance?: boolean
231
+ /**
232
+ * Per-note edit permission. Returning `false` hides the note's edit and appearance buttons
233
+ * and disables click-to-edit on its body. Presentational only — the server stays the authority.
234
+ */
235
+ canEditNote?: (note: CommentSummary) => boolean
236
+ /** Per-note delete permission. Returning `false` hides the note's delete button. */
237
+ canDeleteNote?: (note: CommentSummary) => boolean
238
+ /**
239
+ * Hide the composer, the add actions (including the `onActionChange` action) and every
240
+ * per-note mutation control. Notes and "load more" still render.
241
+ */
242
+ readOnly?: boolean
224
243
  }
225
244
 
226
245
  export function sanitizeHexColor(value: string | null): string | null {
@@ -329,6 +348,9 @@ function NotesSectionImpl<C = unknown>({
329
348
  forceMarkdown,
330
349
  hideMarkdownToggle,
331
350
  disableAppearance,
351
+ canEditNote,
352
+ canDeleteNote,
353
+ readOnly = false,
332
354
  }: NotesSectionProps<C>) {
333
355
  const { confirm, ConfirmDialogElement } = useConfirmDialog()
334
356
  const t = React.useMemo<Translator>(() => translator ?? ((key, fallback) => fallback ?? key), [translator])
@@ -470,7 +492,7 @@ function NotesSectionImpl<C = unknown>({
470
492
  const textareaRef = React.useRef<HTMLTextAreaElement | null>(null)
471
493
  const formRef = React.useRef<HTMLFormElement | null>(null)
472
494
  const focusComposer = React.useCallback(() => {
473
- if (!hasEntity) return
495
+ if (!hasEntity || readOnly) return
474
496
  setComposerOpen(true)
475
497
  window.requestAnimationFrame(() => {
476
498
  if (isMarkdownActive) {
@@ -486,7 +508,7 @@ function NotesSectionImpl<C = unknown>({
486
508
  element.focus()
487
509
  element.scrollIntoView({ behavior: 'smooth', block: 'center' })
488
510
  })
489
- }, [formRef, hasEntity, isMarkdownActive])
511
+ }, [formRef, hasEntity, isMarkdownActive, readOnly])
490
512
  const [appearanceDialogState, setAppearanceDialogState] = React.useState<
491
513
  | { mode: 'create'; icon: string | null; color: string | null }
492
514
  | { mode: 'edit'; noteId: string; icon: string | null; color: string | null }
@@ -582,7 +604,7 @@ function NotesSectionImpl<C = unknown>({
582
604
 
583
605
  React.useEffect(() => {
584
606
  if (!onActionChange) return
585
- if (!notes.length) {
607
+ if (!notes.length || readOnly) {
586
608
  onActionChange(null)
587
609
  return
588
610
  }
@@ -593,7 +615,7 @@ function NotesSectionImpl<C = unknown>({
593
615
  icon: <Plus className="mr-2 h-4 w-4" />,
594
616
  })
595
617
  return () => onActionChange(null)
596
- }, [onActionChange, addActionLabel, focusComposer, hasEntity, isLoading, isSubmitting, notes.length])
618
+ }, [onActionChange, addActionLabel, focusComposer, hasEntity, isLoading, isSubmitting, notes.length, readOnly])
597
619
 
598
620
  const adjustTextareaSize = React.useCallback((element: HTMLTextAreaElement | null) => {
599
621
  if (!element) return
@@ -629,12 +651,12 @@ function NotesSectionImpl<C = unknown>({
629
651
  }, [notes.length, pagedMode])
630
652
 
631
653
  React.useEffect(() => {
632
- if (hasEntity) return
654
+ if (hasEntity && !readOnly) return
633
655
  setComposerOpen(false)
634
656
  setDraftBody('')
635
657
  setDraftIcon(null)
636
658
  setDraftColor(null)
637
- }, [hasEntity])
659
+ }, [hasEntity, readOnly])
638
660
 
639
661
  const visibleNotes = React.useMemo(
640
662
  () => (pagedMode ? notes : notes.slice(0, visibleCount)),
@@ -1167,7 +1189,7 @@ function NotesSectionImpl<C = unknown>({
1167
1189
  {loadError ? <ErrorMessage label={loadError} className="mt-3" /> : null}
1168
1190
 
1169
1191
  <div className="space-y-3">
1170
- {!composerOpen && hasVisibleNotes && !onActionChange ? (
1192
+ {!composerOpen && hasVisibleNotes && !onActionChange && !readOnly ? (
1171
1193
  <div className="flex justify-end">
1172
1194
  <Button
1173
1195
  type="button"
@@ -1190,11 +1212,14 @@ function NotesSectionImpl<C = unknown>({
1190
1212
  visibleNotes.map((note) => {
1191
1213
  const author = noteAuthorLabel(note)
1192
1214
  const isAppearanceSaving = appearanceDialogSaving && editingAppearanceNoteId === note.id
1193
- const isEditingContent = contentEditor.id === note.id
1215
+ const canEdit = !readOnly && (canEditNote ? canEditNote(note) : true)
1216
+ const canDelete = !readOnly && (canDeleteNote ? canDeleteNote(note) : true)
1217
+ const isEditingContent = canEdit && contentEditor.id === note.id
1194
1218
  const displayIcon = note.appearanceIcon ?? null
1195
1219
  const displayColor = note.appearanceColor ?? null
1196
1220
  const timestampValue = note.createdAt
1197
1221
  const fallbackTimestampLabel = formatDateTime(note.createdAt) ?? emptyLabel
1222
+ const contextLabel = note.contextLabel && note.contextLabel.length ? note.contextLabel : null
1198
1223
  return (
1199
1224
  <div key={note.id} className="group space-y-2 rounded-lg border bg-card p-4">
1200
1225
  <div className="flex flex-wrap items-start justify-between gap-3">
@@ -1221,59 +1246,77 @@ function NotesSectionImpl<C = unknown>({
1221
1246
  </a>
1222
1247
  </div>
1223
1248
  ) : null}
1224
- </div>
1225
- <div
1226
- className={`flex items-center gap-2 transition-opacity ${
1227
- isEditingContent ? 'opacity-100' : 'opacity-100 md:opacity-0 md:group-hover:opacity-100 focus-within:opacity-100'
1228
- }`}
1229
- >
1230
- <Button
1231
- type="button"
1232
- variant="ghost"
1233
- size="icon"
1234
- onClick={() => setContentEditor({ id: note.id, value: note.body })}
1235
- >
1236
- <Pencil className="h-4 w-4" />
1237
- </Button>
1238
- {showAppearanceControls ? (
1239
- <Button
1240
- type="button"
1241
- variant="ghost"
1242
- size="icon"
1243
- onClick={(event) => {
1244
- event.stopPropagation()
1245
- setAppearanceDialogError(null)
1246
- setAppearanceDialogState({
1247
- mode: 'edit',
1248
- noteId: note.id,
1249
- icon: note.appearanceIcon ?? null,
1250
- color: note.appearanceColor ?? null,
1251
- })
1252
- }}
1253
- disabled={appearanceDialogSaving && editingAppearanceNoteId === note.id}
1254
- >
1255
- {isAppearanceSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Palette className="h-4 w-4" />}
1256
- </Button>
1249
+ {contextLabel ? (
1250
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
1251
+ <ArrowUpRightSquare className="h-3.5 w-3.5" />
1252
+ {note.contextHref ? (
1253
+ <a href={note.contextHref} className="font-medium text-foreground hover:underline">
1254
+ {contextLabel}
1255
+ </a>
1256
+ ) : (
1257
+ <span className="font-medium text-foreground">{contextLabel}</span>
1258
+ )}
1259
+ </div>
1257
1260
  ) : null}
1258
- <Button
1259
- type="button"
1260
- variant="ghost"
1261
- size="icon"
1262
- onClick={(event) => {
1263
- event.stopPropagation()
1264
- void handleDeleteNote(note)
1265
- }}
1266
- disabled={deletingNoteId === note.id}
1267
- >
1268
- {deletingNoteId === note.id ? (
1269
- <span className="relative flex h-4 w-4 items-center justify-center text-destructive">
1270
- <span className="absolute h-4 w-4 animate-spin rounded-full border border-destructive border-t-transparent" />
1271
- </span>
1272
- ) : (
1273
- <Trash2 className="h-4 w-4" />
1274
- )}
1275
- </Button>
1276
1261
  </div>
1262
+ {canEdit || canDelete ? (
1263
+ <div
1264
+ className={`flex items-center gap-2 transition-opacity ${
1265
+ isEditingContent ? 'opacity-100' : 'opacity-100 md:opacity-0 md:group-hover:opacity-100 focus-within:opacity-100'
1266
+ }`}
1267
+ >
1268
+ {canEdit ? (
1269
+ <Button
1270
+ type="button"
1271
+ variant="ghost"
1272
+ size="icon"
1273
+ onClick={() => setContentEditor({ id: note.id, value: note.body })}
1274
+ >
1275
+ <Pencil className="h-4 w-4" />
1276
+ </Button>
1277
+ ) : null}
1278
+ {showAppearanceControls && canEdit ? (
1279
+ <Button
1280
+ type="button"
1281
+ variant="ghost"
1282
+ size="icon"
1283
+ onClick={(event) => {
1284
+ event.stopPropagation()
1285
+ setAppearanceDialogError(null)
1286
+ setAppearanceDialogState({
1287
+ mode: 'edit',
1288
+ noteId: note.id,
1289
+ icon: note.appearanceIcon ?? null,
1290
+ color: note.appearanceColor ?? null,
1291
+ })
1292
+ }}
1293
+ disabled={appearanceDialogSaving && editingAppearanceNoteId === note.id}
1294
+ >
1295
+ {isAppearanceSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Palette className="h-4 w-4" />}
1296
+ </Button>
1297
+ ) : null}
1298
+ {canDelete ? (
1299
+ <Button
1300
+ type="button"
1301
+ variant="ghost"
1302
+ size="icon"
1303
+ onClick={(event) => {
1304
+ event.stopPropagation()
1305
+ void handleDeleteNote(note)
1306
+ }}
1307
+ disabled={deletingNoteId === note.id}
1308
+ >
1309
+ {deletingNoteId === note.id ? (
1310
+ <span className="relative flex h-4 w-4 items-center justify-center text-destructive">
1311
+ <span className="absolute h-4 w-4 animate-spin rounded-full border border-destructive border-t-transparent" />
1312
+ </span>
1313
+ ) : (
1314
+ <Trash2 className="h-4 w-4" />
1315
+ )}
1316
+ </Button>
1317
+ ) : null}
1318
+ </div>
1319
+ ) : null}
1277
1320
  </div>
1278
1321
  {isEditingContent ? (
1279
1322
  <div className="space-y-2" onKeyDown={handleContentEditorKeyDown}>
@@ -1325,7 +1368,7 @@ function NotesSectionImpl<C = unknown>({
1325
1368
  </Button>
1326
1369
  </div>
1327
1370
  </div>
1328
- ) : (
1371
+ ) : canEdit ? (
1329
1372
  <div
1330
1373
  role="button"
1331
1374
  tabIndex={0}
@@ -1335,7 +1378,16 @@ function NotesSectionImpl<C = unknown>({
1335
1378
  >
1336
1379
  <MarkdownPreview
1337
1380
  remarkPlugins={markdownPlugins}
1338
- className="break-words text-foreground [&>*]:mb-2 [&>*:last-child]:mb-0 [&_ul]:ml-4 [&_ul]:list-disc [&_ol]:ml-4 [&_ol]:list-decimal [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:text-xs"
1381
+ className={noteBodyClassName}
1382
+ >
1383
+ {note.body}
1384
+ </MarkdownPreview>
1385
+ </div>
1386
+ ) : (
1387
+ <div className="text-sm">
1388
+ <MarkdownPreview
1389
+ remarkPlugins={markdownPlugins}
1390
+ className={noteBodyClassName}
1339
1391
  >
1340
1392
  {note.body}
1341
1393
  </MarkdownPreview>
@@ -1348,11 +1400,15 @@ function NotesSectionImpl<C = unknown>({
1348
1400
  <TabEmptyState
1349
1401
  title={emptyState.title}
1350
1402
  description={emptyState.description}
1351
- action={{
1352
- label: emptyState.actionLabel,
1353
- onClick: focusComposer,
1354
- disabled: isSubmitting || !hasEntity,
1355
- }}
1403
+ action={
1404
+ readOnly
1405
+ ? undefined
1406
+ : {
1407
+ label: emptyState.actionLabel,
1408
+ onClick: focusComposer,
1409
+ disabled: isSubmitting || !hasEntity,
1410
+ }
1411
+ }
1356
1412
  />
1357
1413
  )}
1358
1414
  {isLoading || (pagedMode ? !hasMore : visibleCount >= notes.length) ? null : (
@@ -130,6 +130,25 @@ function useRuntimeStatus(token: string | null): RuntimeStatus | null {
130
130
  return status
131
131
  }
132
132
 
133
+ // The banner floats above every dialog and sheet but lives outside their DOM,
134
+ // so an open layer treats a press or focus on it as an outside interaction and
135
+ // closes itself instead of letting the banner handle it. The layers listen on
136
+ // `document`, so these events are stopped in React's capture phase at the
137
+ // banner root: that follows the React tree, which also covers the migrate
138
+ // confirmation portaled to <body>. Buttons act on `click`, which is untouched;
139
+ // a bubble-phase pointer-down, mouse-down, touch-start or focus handler added
140
+ // inside the banner would never fire.
141
+ function stopOutsideDismiss(event: React.SyntheticEvent): void {
142
+ event.nativeEvent.stopPropagation()
143
+ }
144
+
145
+ const OUTSIDE_DISMISS_SHIELD = {
146
+ onPointerDownCapture: stopOutsideDismiss,
147
+ onMouseDownCapture: stopOutsideDismiss,
148
+ onTouchStartCapture: stopOutsideDismiss,
149
+ onFocusCapture: stopOutsideDismiss,
150
+ }
151
+
133
152
  function reloadPage(): void {
134
153
  if (typeof window === 'undefined') return
135
154
  try {
@@ -251,6 +270,7 @@ export function DevRuntimeDiagnosticsBanner() {
251
270
 
252
271
  return (
253
272
  <div
273
+ {...OUTSIDE_DISMISS_SHIELD}
254
274
  data-testid="dev-runtime-diagnostics-banner"
255
275
  data-health={status.health}
256
276
  role={status.health === 'unavailable' ? 'alert' : 'status'}
@@ -260,7 +280,10 @@ export function DevRuntimeDiagnosticsBanner() {
260
280
  // very high z-index, so the banner stacks ABOVE the bubble rather than
261
281
  // trying to outrank it. `max-w-4xl` keeps the action row on one line on
262
282
  // desktop; it still wraps (never scrolls) once the viewport is narrow.
263
- className={`fixed inset-x-3 bottom-20 z-banner flex flex-col gap-2 rounded-lg border px-4 py-3 text-sm shadow-lg sm:inset-x-auto sm:right-4 sm:max-w-4xl ${TONE_CLASSES[tone]}`}
283
+ // `pointer-events-auto` keeps it clickable while a modal dialog sets
284
+ // `pointer-events: none` on <body>; without it a click falls through to
285
+ // the dialog overlay underneath.
286
+ className={`pointer-events-auto fixed inset-x-3 bottom-20 z-banner flex flex-col gap-2 rounded-lg border px-4 py-3 text-sm shadow-lg sm:inset-x-auto sm:right-4 sm:max-w-4xl ${TONE_CLASSES[tone]}`}
264
287
  >
265
288
  <div className="flex items-start justify-between gap-2">
266
289
  <div className="min-w-0 flex-1">