@qoretechnologies/reqraft 0.10.20 → 0.10.26

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 (139) hide show
  1. package/.claude/CLAUDE.md +11 -8
  2. package/dist/components/composer/Composer.d.ts +108 -0
  3. package/dist/components/composer/Composer.d.ts.map +1 -0
  4. package/dist/components/composer/Composer.js +238 -0
  5. package/dist/components/composer/Composer.js.map +1 -0
  6. package/dist/components/composer/helpers.d.ts +21 -0
  7. package/dist/components/composer/helpers.d.ts.map +1 -0
  8. package/dist/components/composer/helpers.js +75 -0
  9. package/dist/components/composer/helpers.js.map +1 -0
  10. package/dist/components/composer/index.d.ts +3 -0
  11. package/dist/components/composer/index.d.ts.map +1 -0
  12. package/dist/components/composer/index.js +19 -0
  13. package/dist/components/composer/index.js.map +1 -0
  14. package/dist/components/form/engine/FormEngine.d.ts +1 -1
  15. package/dist/components/imageLightbox/ImageLightbox.d.ts +21 -0
  16. package/dist/components/imageLightbox/ImageLightbox.d.ts.map +1 -0
  17. package/dist/components/imageLightbox/ImageLightbox.js +169 -0
  18. package/dist/components/imageLightbox/ImageLightbox.js.map +1 -0
  19. package/dist/components/imageLightbox/index.d.ts +2 -0
  20. package/dist/components/imageLightbox/index.d.ts.map +1 -0
  21. package/dist/components/imageLightbox/index.js +18 -0
  22. package/dist/components/imageLightbox/index.js.map +1 -0
  23. package/dist/components/searchFilterBar/SearchFilterBar.d.ts +33 -0
  24. package/dist/components/searchFilterBar/SearchFilterBar.d.ts.map +1 -0
  25. package/dist/components/searchFilterBar/SearchFilterBar.js +76 -0
  26. package/dist/components/searchFilterBar/SearchFilterBar.js.map +1 -0
  27. package/dist/components/searchFilterBar/index.d.ts +2 -0
  28. package/dist/components/searchFilterBar/index.d.ts.map +1 -0
  29. package/dist/components/searchFilterBar/index.js +18 -0
  30. package/dist/components/searchFilterBar/index.js.map +1 -0
  31. package/dist/components/supportTicket/AttachmentChips.d.ts +10 -0
  32. package/dist/components/supportTicket/AttachmentChips.d.ts.map +1 -0
  33. package/dist/components/supportTicket/AttachmentChips.js +122 -0
  34. package/dist/components/supportTicket/AttachmentChips.js.map +1 -0
  35. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts +43 -0
  36. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts.map +1 -0
  37. package/dist/components/supportTicket/InterfaceReferenceTags.js +31 -0
  38. package/dist/components/supportTicket/InterfaceReferenceTags.js.map +1 -0
  39. package/dist/components/supportTicket/ReferencePicker.d.ts +87 -0
  40. package/dist/components/supportTicket/ReferencePicker.d.ts.map +1 -0
  41. package/dist/components/supportTicket/ReferencePicker.js +295 -0
  42. package/dist/components/supportTicket/ReferencePicker.js.map +1 -0
  43. package/dist/components/supportTicket/TicketHeader.d.ts +86 -0
  44. package/dist/components/supportTicket/TicketHeader.d.ts.map +1 -0
  45. package/dist/components/supportTicket/TicketHeader.js +149 -0
  46. package/dist/components/supportTicket/TicketHeader.js.map +1 -0
  47. package/dist/components/supportTicket/TicketMetaTags.d.ts +32 -0
  48. package/dist/components/supportTicket/TicketMetaTags.d.ts.map +1 -0
  49. package/dist/components/supportTicket/TicketMetaTags.js +21 -0
  50. package/dist/components/supportTicket/TicketMetaTags.js.map +1 -0
  51. package/dist/components/supportTicket/TicketReplyBox.d.ts +63 -0
  52. package/dist/components/supportTicket/TicketReplyBox.d.ts.map +1 -0
  53. package/dist/components/supportTicket/TicketReplyBox.js +115 -0
  54. package/dist/components/supportTicket/TicketReplyBox.js.map +1 -0
  55. package/dist/components/supportTicket/attachmentAccess.d.ts +37 -0
  56. package/dist/components/supportTicket/attachmentAccess.d.ts.map +1 -0
  57. package/dist/components/supportTicket/attachmentAccess.js +132 -0
  58. package/dist/components/supportTicket/attachmentAccess.js.map +1 -0
  59. package/dist/components/supportTicket/fileToAttachment.d.ts +9 -0
  60. package/dist/components/supportTicket/fileToAttachment.d.ts.map +1 -0
  61. package/dist/components/supportTicket/fileToAttachment.js +28 -0
  62. package/dist/components/supportTicket/fileToAttachment.js.map +1 -0
  63. package/dist/components/supportTicket/index.d.ts +11 -0
  64. package/dist/components/supportTicket/index.d.ts.map +1 -0
  65. package/dist/components/supportTicket/index.js +27 -0
  66. package/dist/components/supportTicket/index.js.map +1 -0
  67. package/dist/components/supportTicket/meta.d.ts +71 -0
  68. package/dist/components/supportTicket/meta.d.ts.map +1 -0
  69. package/dist/components/supportTicket/meta.js +115 -0
  70. package/dist/components/supportTicket/meta.js.map +1 -0
  71. package/dist/components/supportTicket/ticketModel.d.ts +102 -0
  72. package/dist/components/supportTicket/ticketModel.d.ts.map +1 -0
  73. package/dist/components/supportTicket/ticketModel.js +3 -0
  74. package/dist/components/supportTicket/ticketModel.js.map +1 -0
  75. package/dist/components/ticketReferences/TicketReferences.d.ts +52 -0
  76. package/dist/components/ticketReferences/TicketReferences.d.ts.map +1 -0
  77. package/dist/components/ticketReferences/TicketReferences.js +492 -0
  78. package/dist/components/ticketReferences/TicketReferences.js.map +1 -0
  79. package/dist/components/ticketReferences/index.d.ts +3 -0
  80. package/dist/components/ticketReferences/index.d.ts.map +1 -0
  81. package/dist/components/ticketReferences/index.js +6 -0
  82. package/dist/components/ticketReferences/index.js.map +1 -0
  83. package/dist/components/ticketThread/TicketThread.d.ts +57 -0
  84. package/dist/components/ticketThread/TicketThread.d.ts.map +1 -0
  85. package/dist/components/ticketThread/TicketThread.js +299 -0
  86. package/dist/components/ticketThread/TicketThread.js.map +1 -0
  87. package/dist/components/ticketThread/index.d.ts +3 -0
  88. package/dist/components/ticketThread/index.d.ts.map +1 -0
  89. package/dist/components/ticketThread/index.js +7 -0
  90. package/dist/components/ticketThread/index.js.map +1 -0
  91. package/dist/helpers/common.d.ts +5 -0
  92. package/dist/helpers/common.d.ts.map +1 -1
  93. package/dist/helpers/common.js +20 -1
  94. package/dist/helpers/common.js.map +1 -1
  95. package/dist/helpers/scrollbar.d.ts +2 -0
  96. package/dist/helpers/scrollbar.d.ts.map +1 -0
  97. package/dist/helpers/scrollbar.js +25 -0
  98. package/dist/helpers/scrollbar.js.map +1 -0
  99. package/dist/index.d.ts +8 -1
  100. package/dist/index.d.ts.map +1 -1
  101. package/dist/index.js +10 -1
  102. package/dist/index.js.map +1 -1
  103. package/package.json +1 -1
  104. package/src/components/composer/Composer.stories.tsx +238 -0
  105. package/src/components/composer/Composer.tsx +545 -0
  106. package/src/components/composer/helpers.ts +74 -0
  107. package/src/components/composer/index.ts +2 -0
  108. package/src/components/imageLightbox/ImageLightbox.stories.tsx +238 -0
  109. package/src/components/imageLightbox/ImageLightbox.tsx +236 -0
  110. package/src/components/imageLightbox/index.ts +1 -0
  111. package/src/components/searchFilterBar/SearchFilterBar.tsx +131 -0
  112. package/src/components/searchFilterBar/index.ts +1 -0
  113. package/src/components/supportTicket/AttachmentChips.stories.tsx +171 -0
  114. package/src/components/supportTicket/AttachmentChips.tsx +147 -0
  115. package/src/components/supportTicket/InterfaceReferenceTags.stories.tsx +171 -0
  116. package/src/components/supportTicket/InterfaceReferenceTags.tsx +96 -0
  117. package/src/components/supportTicket/ReferencePicker.stories.tsx +519 -0
  118. package/src/components/supportTicket/ReferencePicker.tsx +563 -0
  119. package/src/components/supportTicket/TicketConversation.stories.tsx +121 -0
  120. package/src/components/supportTicket/TicketHeader.stories.tsx +183 -0
  121. package/src/components/supportTicket/TicketHeader.tsx +247 -0
  122. package/src/components/supportTicket/TicketMetaTags.stories.tsx +103 -0
  123. package/src/components/supportTicket/TicketMetaTags.tsx +85 -0
  124. package/src/components/supportTicket/TicketReplyBox.stories.tsx +351 -0
  125. package/src/components/supportTicket/TicketReplyBox.tsx +146 -0
  126. package/src/components/supportTicket/attachmentAccess.ts +97 -0
  127. package/src/components/supportTicket/fileToAttachment.ts +24 -0
  128. package/src/components/supportTicket/index.ts +10 -0
  129. package/src/components/supportTicket/meta.ts +185 -0
  130. package/src/components/supportTicket/ticketModel.ts +131 -0
  131. package/src/components/ticketReferences/TicketReferences.stories.tsx +315 -0
  132. package/src/components/ticketReferences/TicketReferences.tsx +886 -0
  133. package/src/components/ticketReferences/index.ts +2 -0
  134. package/src/components/ticketThread/TicketThread.stories.tsx +828 -0
  135. package/src/components/ticketThread/TicketThread.tsx +704 -0
  136. package/src/components/ticketThread/index.ts +6 -0
  137. package/src/helpers/common.ts +19 -0
  138. package/src/helpers/scrollbar.ts +32 -0
  139. package/src/index.tsx +8 -1
@@ -0,0 +1,828 @@
1
+ import { StoryObj } from '@storybook/react-vite';
2
+ import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
3
+ import styled from 'styled-components';
4
+
5
+ import { thinScrollbar } from '../../helpers/scrollbar';
6
+ import { StoryMeta } from '../../types';
7
+ import { ITicketThreadMessage, TicketThread } from './TicketThread';
8
+
9
+ /* The drawer body's scroll area — the thin Qorus scrollbar rather than the platform
10
+ * default, matching what the app's ThreadScroll should carry. */
11
+ const DrawerScroll = styled.div`
12
+ flex: 1;
13
+ min-height: 0;
14
+ overflow-y: auto;
15
+ padding: 14px;
16
+ ${thinScrollbar}
17
+ `;
18
+
19
+ /*
20
+ * The thread deliberately sits at the BOTTOM of its scroll area, against the composer,
21
+ * the way every chat surface behaves. That only reads correctly inside the frame it
22
+ * ships in: a fixed-height drawer body with the reply box beneath it. Rendered bare on
23
+ * Storybook's full-height canvas the messages look like they fell into a void, with a
24
+ * large empty gap above and nothing to sit against.
25
+ *
26
+ * This decorator supplies that frame — a drawer-height scroll area with the top edge of
27
+ * a composer below it — so the bottom-alignment is legible as "a short conversation
28
+ * waiting above the reply box" rather than a broken layout. It carries NO text, so the
29
+ * `Loading` (asserts empty text) and `Empty` (asserts its own copy) stories are
30
+ * unaffected.
31
+ */
32
+ const DRAWER_HEIGHT = 520;
33
+ const inDrawer = (Story: () => JSX.Element) => (
34
+ <div
35
+ style={{
36
+ height: DRAWER_HEIGHT,
37
+ display: 'flex',
38
+ flexDirection: 'column',
39
+ border: '1px solid rgba(255,255,255,0.1)',
40
+ borderRadius: 8,
41
+ overflow: 'hidden',
42
+ }}
43
+ >
44
+ <DrawerScroll>
45
+ <Story />
46
+ </DrawerScroll>
47
+ {/* the composer's edge — the surface the thread rests against, no text */}
48
+ <div
49
+ style={{
50
+ height: 44,
51
+ flex: 'none',
52
+ borderTop: '1px solid rgba(255,255,255,0.1)',
53
+ background: 'rgba(255,255,255,0.02)',
54
+ }}
55
+ />
56
+ </div>
57
+ );
58
+
59
+ const NOW = Date.parse('2026-06-30T12:00:00Z');
60
+ const ago = (mins: number) => new Date(NOW - mins * 60_000).toISOString();
61
+
62
+ /* A stand-in screenshot the mock `fetchAttachmentUrl` serves — an inline SVG data URL,
63
+ * so the story needs no network and no fixture file. */
64
+ const screenshot = (label: string, hue: number): string =>
65
+ `data:image/svg+xml,${encodeURIComponent(
66
+ `<svg xmlns='http://www.w3.org/2000/svg' width='320' height='200'>` +
67
+ `<rect width='320' height='200' fill='hsl(${hue},38%,16%)'/>` +
68
+ `<rect x='16' y='16' width='288' height='30' rx='6' fill='hsl(${hue},45%,30%)'/>` +
69
+ `<rect x='16' y='62' width='170' height='12' rx='4' fill='hsl(${hue},30%,46%)'/>` +
70
+ `<rect x='16' y='86' width='250' height='12' rx='4' fill='hsl(${hue},24%,38%)'/>` +
71
+ `<rect x='16' y='110' width='210' height='12' rx='4' fill='hsl(${hue},24%,38%)'/>` +
72
+ `<text x='16' y='180' fill='white' font-family='sans-serif' font-size='13' opacity='0.7'>${label}</text>` +
73
+ `</svg>`
74
+ )}`;
75
+
76
+
77
+ const THREAD: ITicketThreadMessage[] = [
78
+ {
79
+ message_id: 'm1',
80
+ author_type: 'customer',
81
+ author_id: 'acme',
82
+ body: 'Our nightly **deploy** started failing after the 3.2 upgrade.',
83
+ created: ago(180),
84
+ attachments: [
85
+ {
86
+ attachment_id: 'att-1',
87
+ filename: 'deploy-trace.log',
88
+ content_type: 'text/plain',
89
+ size_bytes: 12_800,
90
+ },
91
+ ],
92
+ // no `resolveInterfaceIcon` is passed in these base stories, so this chip
93
+ // exercises the built-in default per-kind icon.
94
+ referenced_interfaces: [{ interface_kind: 'workflow', interface_name: 'nightly-deploy:1.2' }],
95
+ },
96
+ {
97
+ message_id: 'm2',
98
+ author_type: 'staff',
99
+ author_id: 'nick',
100
+ body: 'Thanks — that trace points at the datasource pool. Can you confirm the pool size?',
101
+ created: ago(120),
102
+ },
103
+ {
104
+ message_id: 'm3',
105
+ author_type: 'staff',
106
+ author_id: 'nick',
107
+ body: 'Reproduced locally; the pool default dropped to 1 in 3.2.',
108
+ internal: true,
109
+ created: ago(90),
110
+ },
111
+ {
112
+ message_id: 'm4',
113
+ author_type: 'system',
114
+ author_id: 'system',
115
+ body: 'Priority raised to **high**.',
116
+ created: ago(60),
117
+ },
118
+ ];
119
+
120
+ // A thread whose messages reference interfaces on the customer's instance —
121
+ // several at once, including two of the same kind (workflow), plus a job on a
122
+ // follow-up message. Referenced interfaces are `{kind, name}` snapshots.
123
+ const REFERENCED_THREAD: ITicketThreadMessage[] = [
124
+ {
125
+ message_id: 'r1',
126
+ author_type: 'customer',
127
+ author_id: 'acme',
128
+ body: 'These two workflows and the notification service all fail together.',
129
+ created: ago(45),
130
+ referenced_interfaces: [
131
+ { interface_kind: 'workflow', interface_name: 'order-processing:1.0', reference_id: 'ref-1' },
132
+ { interface_kind: 'workflow', interface_name: 'invoice-sync:2.3', reference_id: 'ref-2' },
133
+ { interface_kind: 'service', interface_name: 'notification-svc', reference_id: 'ref-3' },
134
+ ],
135
+ },
136
+ {
137
+ message_id: 'r2',
138
+ author_type: 'customer',
139
+ author_id: 'acme',
140
+ body: 'And this job kicks off the follow-up.',
141
+ created: ago(20),
142
+ referenced_interfaces: [
143
+ { interface_kind: 'job', interface_name: 'nightly-cleanup', reference_id: 'ref-4' },
144
+ ],
145
+ },
146
+ ];
147
+
148
+ const meta = {
149
+ component: TicketThread,
150
+ title: 'Components/TicketThread',
151
+ decorators: [inDrawer],
152
+ args: {
153
+ messages: THREAD,
154
+ onDownloadAttachment: fn(),
155
+ },
156
+ } as StoryMeta<typeof TicketThread>;
157
+
158
+ export default meta;
159
+ type Story = StoryObj<typeof meta>;
160
+
161
+ /**
162
+ * The customer viewpoint: the customer's own messages sit on the right under
163
+ * "You", staff appear under a masked "Qorus Support" label, and internal notes
164
+ * are never delivered to this surface (the server filters them). Clicking an
165
+ * attachment chip calls `onDownloadAttachment` with its id + filename.
166
+ */
167
+ export const CustomerView: Story = {
168
+ parameters: {
169
+ docs: {
170
+ description: {
171
+ story:
172
+ "Renders the thread from the customer's viewpoint: the customer's own messages sit on the right under 'You', staff appear under the masked 'Qorus Support' label, internal notes are filtered out, and clicking an attachment chip calls onDownloadAttachment with its id and filename.",
173
+ },
174
+ },
175
+ },
176
+ args: {
177
+ viewerRole: 'customer',
178
+ // the server never sends internal notes to the customer
179
+ messages: THREAD.filter((m) => !m.internal),
180
+ },
181
+ async play({ canvasElement, args }) {
182
+ const canvas = within(canvasElement);
183
+
184
+ await expect(canvas.getByText('You')).toBeInTheDocument();
185
+ await expect(canvas.getByText('Qorus Support')).toBeInTheDocument();
186
+ // internal notes are filtered out for the customer
187
+ await expect(canvas.queryByText('Internal note')).not.toBeInTheDocument();
188
+
189
+ await userEvent.click(canvas.getAllByText('deploy-trace.log')[0]);
190
+ await waitFor(() =>
191
+ expect(args.onDownloadAttachment).toHaveBeenCalledWith('att-1', 'deploy-trace.log')
192
+ );
193
+ },
194
+ };
195
+
196
+ /**
197
+ * Messages can reference interfaces on the customer's instance. Each reference
198
+ * renders as a chip under the message — the interface kind as the chip's key and
199
+ * the interface name as its label — and several references (including two of the
200
+ * same kind) can attach to one message. When the consumer passes
201
+ * `onInterfaceClick` (the customer view, which can open the interface on its own
202
+ * instance) the chips are clickable; `resolveInterfaceIcon` supplies a per-kind
203
+ * icon. The staff view omits `onInterfaceClick`, leaving the chips static.
204
+ */
205
+ /**
206
+ * TICKET-level references (picked when filing) echo as chips on the OPENING message —
207
+ * without the echo, readers scanned the thread, saw no chips, and concluded the ticket
208
+ * referenced nothing. Deduped: `order-sync:1.2` is both a ticket reference and a
209
+ * message-level ref on the opening message, and must render exactly once; the reply's
210
+ * own refs are untouched.
211
+ */
212
+ export const TicketReferencesEchoOnOpeningMessage: Story = {
213
+ parameters: {
214
+ docs: {
215
+ description: {
216
+ story:
217
+ 'Renders the thread with ticketReferences wired — the ticket-level refs (a qog and a workflow) appear as chips on the opening message only, deduped against that message\'s own refs, while the reply keeps just its own.',
218
+ },
219
+ },
220
+ },
221
+ args: {
222
+ viewerRole: 'customer',
223
+ messages: [
224
+ {
225
+ message_id: 'e1',
226
+ author_type: 'customer',
227
+ author_id: 'acme',
228
+ body: 'The qog fails right after start; the workflow it feeds never runs.',
229
+ created: ago(45),
230
+ // overlaps with one ticket-level ref — must not double-render
231
+ referenced_interfaces: [
232
+ { interface_kind: 'workflow', interface_name: 'order-sync:1.2', reference_id: 'ref-m1' },
233
+ ],
234
+ },
235
+ {
236
+ message_id: 'e2',
237
+ author_type: 'staff',
238
+ author_id: 'support-1',
239
+ body: 'Looking into it now.',
240
+ created: ago(20),
241
+ },
242
+ ],
243
+ ticketReferences: [
244
+ { interface_kind: 'qog', interface_name: 'log-writer', reference_id: 'ref-t1' },
245
+ { interface_kind: 'workflow', interface_name: 'order-sync:1.2', reference_id: 'ref-t2' },
246
+ ],
247
+ },
248
+ async play({ canvasElement }) {
249
+ const canvas = within(canvasElement);
250
+ // the ticket-level qog reference shows in the CONVERSATION now
251
+ await expect(await canvas.findByText('log-writer')).toBeInTheDocument();
252
+ // the overlapping workflow renders exactly once (ticket ref deduped against message ref)
253
+ await expect(canvas.getAllByText('order-sync:1.2')).toHaveLength(1);
254
+ // the staff reply carries no chips — the echo is scoped to the opening message
255
+ const chips = canvasElement.querySelectorAll('.reqore-tag');
256
+ await expect(chips.length).toBe(2);
257
+ },
258
+ };
259
+
260
+ export const WithInterfaceReferences: Story = {
261
+ parameters: {
262
+ docs: {
263
+ description: {
264
+ story:
265
+ 'Renders a customer thread whose messages carry interface-reference chips — several per message, including two workflows — where clicking a chip calls onInterfaceClick with that interface.',
266
+ },
267
+ },
268
+ },
269
+ args: {
270
+ viewerRole: 'customer',
271
+ messages: REFERENCED_THREAD,
272
+ onInterfaceClick: fn(),
273
+ resolveInterfaceIcon: (kind) =>
274
+ kind === 'workflow' ? 'GitBranchLine' : kind === 'service' ? 'ServerLine' : 'TimerLine',
275
+ },
276
+ async play({ canvasElement, args }) {
277
+ const canvas = within(canvasElement);
278
+
279
+ // every reference renders, including two workflows on the same message
280
+ await expect(canvas.getByText('order-processing:1.0')).toBeInTheDocument();
281
+ await expect(canvas.getByText('invoice-sync:2.3')).toBeInTheDocument();
282
+ await expect(canvas.getByText('notification-svc')).toBeInTheDocument();
283
+ await expect(canvas.getByText('nightly-cleanup')).toBeInTheDocument();
284
+
285
+ // clicking a chip opens that specific interface
286
+ await userEvent.click(canvas.getByText('invoice-sync:2.3'));
287
+ await waitFor(() =>
288
+ expect(args.onInterfaceClick).toHaveBeenCalledWith(
289
+ expect.objectContaining({
290
+ interface_kind: 'workflow',
291
+ interface_name: 'invoice-sync:2.3',
292
+ })
293
+ )
294
+ );
295
+ },
296
+ };
297
+
298
+ /**
299
+ * The staff viewpoint: staff messages sit on the right, the customer on the
300
+ * left, and staff-only internal notes render amber + bordered under an "Internal
301
+ * note" label so they can never be mistaken for a customer-visible reply.
302
+ */
303
+ export const StaffView: Story = {
304
+ parameters: {
305
+ docs: {
306
+ description: {
307
+ story:
308
+ "Renders the thread from the staff viewpoint: staff messages sit on the right, the customer on the left, and staff-only internal notes render amber and bordered under an 'Internal note' label.",
309
+ },
310
+ },
311
+ },
312
+ args: {
313
+ viewerRole: 'staff',
314
+ messages: THREAD,
315
+ },
316
+ async play({ canvasElement }) {
317
+ const canvas = within(canvasElement);
318
+
319
+ await expect(canvas.getByText('Internal note')).toBeInTheDocument();
320
+ await expect(canvas.getByText(/Support · nick/)).toBeInTheDocument();
321
+ await expect(canvas.getByText(/Customer · acme/)).toBeInTheDocument();
322
+ // the reference chip renders with the built-in default icon (no resolver passed)
323
+ await expect(canvas.getByText('nightly-deploy:1.2')).toBeInTheDocument();
324
+ },
325
+ };
326
+
327
+ export const Loading: Story = {
328
+ parameters: {
329
+ docs: {
330
+ description: {
331
+ story:
332
+ "The FIRST load — loading with nothing to show yet — renders a conversation-shaped skeleton: aria-busy, labelled 'Loading conversation', carrying no message content, rather than a spinner. `messages` is empty deliberately; that is what makes this a first load rather than a refetch (see RefetchKeepsTheConversation).",
333
+ },
334
+ },
335
+ },
336
+ args: { loading: true, messages: [] },
337
+ async play({ canvasElement }) {
338
+ // the loading state is a conversation-shaped skeleton, not a spinner
339
+ const skeleton = canvasElement.querySelector('[aria-busy="true"]');
340
+ await expect(skeleton).not.toBeNull();
341
+ await expect(skeleton).toHaveAttribute('aria-label', 'Loading conversation');
342
+ // no real message content renders while loading
343
+ await expect(canvasElement.textContent).toBe('');
344
+ },
345
+ };
346
+
347
+ export const RefetchKeepsTheConversation: Story = {
348
+ parameters: {
349
+ docs: {
350
+ description: {
351
+ story:
352
+ 'Loading with messages already on screen is a REFETCH, not a first load, and must leave the conversation alone. Every send triggers one — and where the thread is live, so does the other party sending — so blanking it here replaces a conversation the reader is in the middle of with pulsing placeholders, at the worst possible moment. The new message simply appears when the refetch resolves.',
353
+ },
354
+ },
355
+ },
356
+ args: { loading: true },
357
+ async play({ canvasElement }) {
358
+ const canvas = within(canvasElement);
359
+ // no skeleton: the thread is already showing something worth keeping
360
+ await expect(canvasElement.querySelector('[aria-busy="true"]')).toBeNull();
361
+ // ...and the existing messages are still on screen. Asserted on message bodies
362
+ // rather than author labels, which vary with `viewerRole`; getAllByText because a
363
+ // body can legitimately appear in more than one node.
364
+ await expect(canvas.getAllByText(/nightly/).length).toBeGreaterThan(0);
365
+ await expect(canvas.getAllByText(/datasource pool/).length).toBeGreaterThan(0);
366
+ },
367
+ };
368
+
369
+ export const Empty: Story = {
370
+ parameters: {
371
+ docs: {
372
+ description: {
373
+ story:
374
+ "Renders the empty state with no messages, showing the 'No messages yet' placeholder instead of a thread.",
375
+ },
376
+ },
377
+ },
378
+ args: { messages: [] },
379
+ async play({ canvasElement }) {
380
+ await expect(within(canvasElement).getByText('No messages yet')).toBeInTheDocument();
381
+ },
382
+ };
383
+
384
+ /* ── comprehensive coverage ──────────────────────────────────────────────────
385
+ * The stories above cover the two viewpoints and the happy path. The ones below
386
+ * exercise the full render surface a message can carry — every interface-reference
387
+ * kind, the markdown a body supports, the spread of attachment types, every system
388
+ * event, a grouped burst, and the overflow case — so a change to any of those is
389
+ * visible in review instead of only showing up against real data.
390
+ * ─────────────────────────────────────────────────────────────────────────── */
391
+
392
+ /* Every interface kind the reference vocabulary defines, on one message, so each
393
+ * chip's built-in per-kind glyph is on screen at once. Names are illustrative. */
394
+ const EVERY_KIND: IInterfaceReference[] = [
395
+ { interface_kind: 'workflow', interface_name: 'order-sync:1.2' },
396
+ { interface_kind: 'service', interface_name: 'notification-svc' },
397
+ { interface_kind: 'job', interface_name: 'nightly-recon' },
398
+ { interface_kind: 'fsm', interface_name: 'onboarding-flow' },
399
+ { interface_kind: 'qog', interface_name: 'stripe-billing' },
400
+ { interface_kind: 'connection', interface_name: 'pgsql-omq' },
401
+ { interface_kind: 'mapper', interface_name: 'csv-to-order' },
402
+ { interface_kind: 'class', interface_name: 'OrderUtils' },
403
+ { interface_kind: 'value-map', interface_name: 'country-codes' },
404
+ { interface_kind: 'ai-collection', interface_name: 'kb-support' },
405
+ { interface_kind: 'ai-endpoint', interface_name: 'gpt-router' },
406
+ { interface_kind: 'ai-guardrail', interface_name: 'pii-filter' },
407
+ // an unknown kind falls back to the generic glyph rather than breaking
408
+ { interface_kind: 'mystery', interface_name: 'future-thing' },
409
+ ];
410
+
411
+ /** Every referenceable interface kind as a chip on a single message — one of each,
412
+ * plus an unknown kind that must fall back to the generic glyph. */
413
+ export const EveryReferenceKind: Story = {
414
+ parameters: {
415
+ docs: {
416
+ description: {
417
+ story:
418
+ 'Renders a single message carrying a chip for every referenceable interface kind, one of each, plus an unknown kind that falls back to the generic glyph.',
419
+ },
420
+ },
421
+ },
422
+ args: {
423
+ viewerRole: 'customer',
424
+ messages: [
425
+ {
426
+ message_id: 'refs',
427
+ author_type: 'customer',
428
+ author_id: 'acme',
429
+ body: 'For context, this touches most of our instance:',
430
+ created: ago(20),
431
+ referenced_interfaces: EVERY_KIND,
432
+ },
433
+ ],
434
+ },
435
+ async play({ canvasElement }) {
436
+ const canvas = within(canvasElement);
437
+ for (const reference of EVERY_KIND) {
438
+ await expect(canvas.getByText(reference.interface_name)).toBeInTheDocument();
439
+ }
440
+ },
441
+ };
442
+
443
+ /** The markdown a body supports: emphasis, inline code, a fenced block, both list
444
+ * kinds, and a link — so a formatting regression shows up here, not in the wild. */
445
+ export const RichFormatting: Story = {
446
+ parameters: {
447
+ docs: {
448
+ description: {
449
+ story:
450
+ 'Renders a message whose markdown body exercises emphasis, inline code, a fenced code block, both list kinds, and a link, so a formatting regression surfaces here.',
451
+ },
452
+ },
453
+ },
454
+ args: {
455
+ viewerRole: 'customer',
456
+ messages: [
457
+ {
458
+ message_id: 'md',
459
+ author_type: 'staff',
460
+ author_id: 'nick',
461
+ created: ago(5),
462
+ body: [
463
+ 'Here is what I found — the **datasource pool** collapses to `1` under load.',
464
+ '',
465
+ 'Steps to reproduce:',
466
+ '',
467
+ '1. Deploy `order-sync:1.2`',
468
+ '2. Drive 40+ concurrent orders',
469
+ '3. Watch the pool starve',
470
+ '',
471
+ 'What to check:',
472
+ '',
473
+ '- the connection `pgsql-omq`',
474
+ '- the `max-pool-size` option',
475
+ '',
476
+ '```qore',
477
+ 'int size = pool.getSize();',
478
+ 'if (size < 4) log("pool starved: " + size);',
479
+ '```',
480
+ '',
481
+ 'Full write-up in the [runbook](https://docs.qoretechnologies.com).',
482
+ ].join('\n'),
483
+ },
484
+ ],
485
+ },
486
+ async play({ canvasElement }) {
487
+ const canvas = within(canvasElement);
488
+ // the fenced block and a list item both render
489
+ await expect(canvas.getByText(/pool starved/)).toBeInTheDocument();
490
+ await expect(canvas.getByText(/Drive 40\+ concurrent orders/)).toBeInTheDocument();
491
+ // the link is a real anchor
492
+ const link = canvas.getByText('runbook').closest('a');
493
+ await expect(link).toHaveAttribute('href', 'https://docs.qoretechnologies.com');
494
+ },
495
+ };
496
+
497
+ /** A spread of attachment types and sizes on one message — each chip shows its
498
+ * own formatted size and a content-type tooltip. */
499
+ export const AttachmentTypes: Story = {
500
+ parameters: {
501
+ docs: {
502
+ description: {
503
+ story:
504
+ 'Renders one message with a spread of attachment types and sizes, where the image previews inline as a thumbnail while the rest stay download chips showing their formatted sizes.',
505
+ },
506
+ },
507
+ },
508
+ args: {
509
+ viewerRole: 'customer',
510
+ // the image among these previews inline; the rest stay download chips
511
+ fetchAttachmentUrl: async () => screenshot('screenshot', 210),
512
+ messages: [
513
+ {
514
+ message_id: 'att',
515
+ author_type: 'customer',
516
+ author_id: 'acme',
517
+ body: 'Attaching everything I gathered:',
518
+ created: ago(8),
519
+ attachments: [
520
+ { attachment_id: 'a1', filename: 'screenshot.png', content_type: 'image/png', size_bytes: 842_000 },
521
+ { attachment_id: 'a2', filename: 'deploy-trace.log', content_type: 'text/plain', size_bytes: 12_800 },
522
+ { attachment_id: 'a3', filename: 'incident-report.pdf', content_type: 'application/pdf', size_bytes: 3_400_000 },
523
+ { attachment_id: 'a4', filename: 'pool-config.yaml', content_type: 'application/x-yaml', size_bytes: 640 },
524
+ { attachment_id: 'a5', filename: 'heap-dump.hprof', content_type: 'application/octet-stream', size_bytes: 1_750_000_000 },
525
+ ],
526
+ },
527
+ ],
528
+ },
529
+ async play({ canvasElement }) {
530
+ const canvas = within(canvasElement);
531
+ // the image renders as a thumbnail (an <img>, not a filename chip) …
532
+ await waitFor(() => {
533
+ const img = canvasElement.querySelector('img[alt="screenshot.png"]');
534
+ if (!img) {
535
+ throw new Error('image thumbnail not rendered yet');
536
+ }
537
+ });
538
+ // … while the non-image files stay chips, each with its per-unit size
539
+ await expect(canvas.getByText('deploy-trace.log')).toBeInTheDocument();
540
+ await expect(canvas.getByText('640 B')).toBeInTheDocument();
541
+ await expect(canvas.getByText('1.6 GB')).toBeInTheDocument();
542
+ },
543
+ };
544
+
545
+ /** The system-message range — created, assigned, priority, status, reopened,
546
+ * resolved — each a centred muted line rather than a card. */
547
+ export const SystemEvents: Story = {
548
+ parameters: {
549
+ docs: {
550
+ description: {
551
+ story:
552
+ 'Renders the full range of system messages — opened, assigned, priority, status, reopened, resolved — each as a centred muted line rather than a card.',
553
+ },
554
+ },
555
+ },
556
+ args: {
557
+ viewerRole: 'customer',
558
+ messages: [
559
+ { message_id: 's1', author_type: 'system', author_id: 'system', body: 'Ticket opened.', created: ago(300) },
560
+ { message_id: 's2', author_type: 'system', author_id: 'system', body: 'Assigned to Qorus Support.', created: ago(280) },
561
+ { message_id: 'c1', author_type: 'customer', author_id: 'acme', body: 'Any update on this?', created: ago(200) },
562
+ { message_id: 's3', author_type: 'system', author_id: 'system', body: 'Priority raised to **High**.', created: ago(190) },
563
+ { message_id: 's4', author_type: 'system', author_id: 'system', body: 'Status changed to **Awaiting your reply**.', created: ago(120) },
564
+ { message_id: 's5', author_type: 'system', author_id: 'system', body: 'Ticket reopened.', created: ago(60) },
565
+ { message_id: 's6', author_type: 'system', author_id: 'system', body: 'Ticket resolved.', created: ago(5) },
566
+ ],
567
+ },
568
+ async play({ canvasElement }) {
569
+ const canvas = within(canvasElement);
570
+ await expect(canvas.getByText(/Ticket opened/)).toBeInTheDocument();
571
+ await expect(canvas.getByText(/Ticket resolved/)).toBeInTheDocument();
572
+ },
573
+ };
574
+
575
+ /** A burst of consecutive same-author replies collapses under a single timestamp
576
+ * (one `ReqoreBubbleGroup`) rather than stamping every card. */
577
+ export const GroupedBurst: Story = {
578
+ parameters: {
579
+ docs: {
580
+ description: {
581
+ story:
582
+ 'Renders a burst of consecutive same-author staff replies collapsed under a single timestamp, rather than stamping every card.',
583
+ },
584
+ },
585
+ },
586
+ args: {
587
+ viewerRole: 'staff',
588
+ messages: [
589
+ { message_id: 'g0', author_type: 'customer', author_id: 'acme', body: 'The deploy is still failing.', created: ago(30) },
590
+ { message_id: 'g1', author_type: 'staff', author_id: 'nick', body: 'Looking now.', created: ago(9) },
591
+ { message_id: 'g2', author_type: 'staff', author_id: 'nick', body: 'Reproduced it locally.', created: ago(8) },
592
+ { message_id: 'g3', author_type: 'staff', author_id: 'nick', body: 'The pool default dropped to 1 in 3.2.', created: ago(7) },
593
+ { message_id: 'g4', author_type: 'staff', author_id: 'nick', body: 'Fix is going out in 3.2.1 — I will confirm here.', created: ago(6) },
594
+ ],
595
+ },
596
+ };
597
+
598
+ /** An internal note sits amber under "Internal note" for staff, next to a normal
599
+ * reply — the two must never be confusable. Customer never receives it. */
600
+ export const InternalNote: Story = {
601
+ parameters: {
602
+ docs: {
603
+ description: {
604
+ story:
605
+ "Renders a staff thread where an internal note sits amber under 'Internal note' beside a normal reply, so the two can never be confused.",
606
+ },
607
+ },
608
+ },
609
+ args: {
610
+ viewerRole: 'staff',
611
+ messages: [
612
+ { message_id: 'i0', author_type: 'customer', author_id: 'acme', body: 'Is there a workaround while I wait?', created: ago(40) },
613
+ {
614
+ message_id: 'i1',
615
+ author_type: 'staff',
616
+ author_id: 'nick',
617
+ internal: true,
618
+ body: 'Root cause is the 3.2 pool default. Do **not** promise a date yet — patch is unverified.',
619
+ created: ago(30),
620
+ },
621
+ { message_id: 'i2', author_type: 'staff', author_id: 'nick', body: 'You can raise `max-pool-size` to 8 as a temporary workaround.', created: ago(20) },
622
+ ],
623
+ },
624
+ async play({ canvasElement }) {
625
+ const canvas = within(canvasElement);
626
+ await expect(canvas.getByText('Internal note')).toBeInTheDocument();
627
+ },
628
+ };
629
+
630
+ /** A single message — the footer count reads "1 message", not "1 messages". */
631
+ export const SingleMessage: Story = {
632
+ parameters: {
633
+ docs: {
634
+ description: {
635
+ story:
636
+ "Renders a thread with a single message, so the footer count reads '1 message' rather than '1 messages'.",
637
+ },
638
+ },
639
+ },
640
+ args: {
641
+ viewerRole: 'customer',
642
+ messages: [
643
+ { message_id: 'one', author_type: 'customer', author_id: 'acme', body: 'Just opened this — nothing else yet.', created: ago(2) },
644
+ ],
645
+ },
646
+ async play({ canvasElement }) {
647
+ await expect(within(canvasElement).getByText('1 message')).toBeInTheDocument();
648
+ },
649
+ };
650
+
651
+ /** A long conversation that overflows the drawer body — confirms it scrolls and
652
+ * the latest message sits at the bottom against the composer. */
653
+ export const LongThread: Story = {
654
+ parameters: {
655
+ docs: {
656
+ description: {
657
+ story:
658
+ 'Renders a 16-message conversation that overflows the drawer body, confirming it scrolls and auto-scrolls to the newest message resting at the bottom against the composer.',
659
+ },
660
+ },
661
+ },
662
+ args: {
663
+ viewerRole: 'customer',
664
+ messages: Array.from({ length: 16 }, (_, i) => ({
665
+ message_id: `l${i}`,
666
+ author_type: (i % 2 === 0 ? 'customer' : 'staff') as TTicketMessageAuthor,
667
+ author_id: i % 2 === 0 ? 'acme' : 'nick',
668
+ body:
669
+ i % 2 === 0
670
+ ? `Follow-up ${i / 2 + 1}: still seeing the failure after the last change.`
671
+ : 'Thanks — pushing another fix and will report back.',
672
+ created: ago((16 - i) * 15),
673
+ })),
674
+ },
675
+ async play({ canvasElement }) {
676
+ const canvas = within(canvasElement);
677
+ const footer = await canvas.findByText('16 messages');
678
+ // The 16-message thread overflows the 520px drawer body — walk up to the scroll area.
679
+ let scroller = footer.parentElement;
680
+ while (scroller && scroller.scrollHeight <= scroller.clientHeight) {
681
+ scroller = scroller.parentElement;
682
+ }
683
+ if (!scroller) {
684
+ throw new Error('no scroll container around the overflowing thread');
685
+ }
686
+ const scrollArea = scroller;
687
+ // On open the thread auto-scrolls to the newest message: the area rests at its END,
688
+ // not at the top (scrollTop 0) on the oldest message. Drop the scroll effect in
689
+ // TicketThread and scrollTop stays 0 — this assertion then fails.
690
+ await waitFor(() => {
691
+ expect(scrollArea.scrollTop).toBeGreaterThan(0);
692
+ expect(scrollArea.scrollTop + scrollArea.clientHeight).toBeGreaterThanOrEqual(
693
+ scrollArea.scrollHeight - 4
694
+ );
695
+ });
696
+ },
697
+ };
698
+
699
+ /**
700
+ * Image attachments preview inline: a screenshot renders as a thumbnail the reader can
701
+ * click to enlarge (`fetchAttachmentUrl` supplies the bytes), while a non-image on the
702
+ * same message stays a download chip. Without `fetchAttachmentUrl` every attachment —
703
+ * images included — falls back to the chip, which is what `CustomerView` shows.
704
+ */
705
+ export const ImagePreview: Story = {
706
+ parameters: {
707
+ docs: {
708
+ description: {
709
+ story:
710
+ 'Renders a message whose image attachments preview inline as thumbnails — clicking one opens the enlarge view — while a non-image on the same message stays a download chip.',
711
+ },
712
+ },
713
+ },
714
+ args: {
715
+ viewerRole: 'customer',
716
+ fetchAttachmentUrl: async (id: string) =>
717
+ screenshot(id === 'img-1' ? 'before' : 'after', id === 'img-1' ? 265 : 205),
718
+ messages: [
719
+ {
720
+ message_id: 'img',
721
+ author_type: 'customer',
722
+ author_id: 'acme',
723
+ body: 'Here is the canvas before and after the upgrade, plus the server log:',
724
+ created: ago(6),
725
+ attachments: [
726
+ { attachment_id: 'img-1', filename: 'before.png', content_type: 'image/png', size_bytes: 512_000 },
727
+ { attachment_id: 'img-2', filename: 'after.png', content_type: 'image/png', size_bytes: 486_000 },
728
+ { attachment_id: 'log-1', filename: 'server.log', content_type: 'text/plain', size_bytes: 24_800 },
729
+ ],
730
+ },
731
+ ],
732
+ },
733
+ async play({ canvasElement }) {
734
+ const canvas = within(canvasElement);
735
+ // the two images render as thumbnails …
736
+ const thumb = await waitFor(() => {
737
+ const img = canvasElement.querySelector<HTMLImageElement>('img[alt="before.png"]');
738
+ if (!img) {
739
+ throw new Error('thumbnail not rendered yet');
740
+ }
741
+ return img;
742
+ });
743
+ // … while the non-image stays a download chip
744
+ await expect(canvas.getByText('server.log')).toBeInTheDocument();
745
+ // clicking a thumbnail opens the enlarge view
746
+ await userEvent.click(thumb);
747
+ await waitFor(() => expect(document.querySelector('[role="dialog"]')).not.toBeNull());
748
+ },
749
+ };
750
+
751
+ /**
752
+ * One message carrying the whole stack at once — a formatted body, image thumbnails,
753
+ * file chips, and interface references — so the vertical order and spacing of all four
754
+ * blocks together is reviewable in one place, not only in isolation. Body first, then
755
+ * the two attachment rows (images, then files), then the reference chips.
756
+ */
757
+ export const Everything: Story = {
758
+ parameters: {
759
+ docs: {
760
+ description: {
761
+ story:
762
+ 'Renders one message carrying the whole stack at once — a formatted body, image thumbnails, file chips, and interface references — so the order and spacing of all four blocks is reviewable together.',
763
+ },
764
+ },
765
+ },
766
+ args: {
767
+ viewerRole: 'customer',
768
+ fetchAttachmentUrl: async (id: string) =>
769
+ screenshot(id === 'e-img-1' ? 'canvas' : 'console', id === 'e-img-1' ? 265 : 200),
770
+ resolveInterfaceIcon: (kind) =>
771
+ kind === 'workflow'
772
+ ? 'GitBranchLine'
773
+ : kind === 'service'
774
+ ? 'ServerLine'
775
+ : kind === 'job'
776
+ ? 'CalendarLine'
777
+ : 'Plug2Line',
778
+ onInterfaceClick: fn(),
779
+ messages: [
780
+ {
781
+ message_id: 'everything',
782
+ author_type: 'customer',
783
+ author_id: 'acme',
784
+ created: ago(4),
785
+ body: [
786
+ 'The nightly **deploy** fails at the mapper step. Two screenshots, the trace, the',
787
+ 'config, and the interfaces involved:',
788
+ '',
789
+ '- reproduces every run since `3.2`',
790
+ '- only when the pool drops below `4`',
791
+ ].join('\n'),
792
+ attachments: [
793
+ { attachment_id: 'e-img-1', filename: 'canvas-error.png', content_type: 'image/png', size_bytes: 512_000 },
794
+ { attachment_id: 'e-img-2', filename: 'console.png', content_type: 'image/png', size_bytes: 388_000 },
795
+ { attachment_id: 'e-file-1', filename: 'deploy-trace.log', content_type: 'text/plain', size_bytes: 12_800 },
796
+ { attachment_id: 'e-file-2', filename: 'pool-config.yaml', content_type: 'application/x-yaml', size_bytes: 640 },
797
+ ],
798
+ referenced_interfaces: [
799
+ { interface_kind: 'workflow', interface_name: 'order-sync:1.2', reference_id: 'e-ref-1' },
800
+ { interface_kind: 'service', interface_name: 'notification-svc', reference_id: 'e-ref-2' },
801
+ { interface_kind: 'job', interface_name: 'nightly-recon', reference_id: 'e-ref-3' },
802
+ { interface_kind: 'connection', interface_name: 'pgsql-omq', reference_id: 'e-ref-4' },
803
+ ],
804
+ },
805
+ {
806
+ message_id: 'everything-reply',
807
+ author_type: 'staff',
808
+ author_id: 'nick',
809
+ created: ago(1),
810
+ body: 'Got it — the screenshots confirm the mapper NPE. Looking at the pool default now.',
811
+ },
812
+ ],
813
+ },
814
+ async play({ canvasElement }) {
815
+ const canvas = within(canvasElement);
816
+ // an image thumbnail …
817
+ await waitFor(() => {
818
+ if (!canvasElement.querySelector('img[alt="canvas-error.png"]')) {
819
+ throw new Error('thumbnail not rendered yet');
820
+ }
821
+ });
822
+ // … a file chip …
823
+ await expect(canvas.getByText('deploy-trace.log')).toBeInTheDocument();
824
+ // … and an interface reference, all on the one message
825
+ await expect(canvas.getByText('order-sync:1.2')).toBeInTheDocument();
826
+ await expect(canvas.getByText('pgsql-omq')).toBeInTheDocument();
827
+ },
828
+ };