@flamingo-stack/openframe-frontend-core 0.0.588 → 0.0.589-1973.5571.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 (161) hide show
  1. package/dist/chat-protocol/events.d.ts +8 -2
  2. package/dist/chat-protocol/events.d.ts.map +1 -1
  3. package/dist/chat-protocol/index.cjs +188 -0
  4. package/dist/chat-protocol/index.cjs.map +1 -1
  5. package/dist/chat-protocol/index.js +188 -0
  6. package/dist/chat-protocol/index.js.map +1 -1
  7. package/dist/chat-protocol/nats-decoder.d.ts.map +1 -1
  8. package/dist/chat-protocol/source-metadata.d.ts +5 -0
  9. package/dist/chat-protocol/source-metadata.d.ts.map +1 -0
  10. package/dist/{chunk-GKPY7VLK.js → chunk-47NIV4FC.js} +4 -4
  11. package/dist/{chunk-D7YISHXC.js → chunk-4MVGFYRE.js} +534 -190
  12. package/dist/chunk-4MVGFYRE.js.map +1 -0
  13. package/dist/{chunk-I5CJJFEJ.js → chunk-4VDGIODG.js} +3 -3
  14. package/dist/{chunk-PAUJQV3C.cjs → chunk-627JVTID.cjs} +15 -15
  15. package/dist/{chunk-PAUJQV3C.cjs.map → chunk-627JVTID.cjs.map} +1 -1
  16. package/dist/{chunk-3OCYNMZ5.js → chunk-CQNILR7U.js} +3 -3
  17. package/dist/{chunk-BONXSRHZ.cjs → chunk-DPQGO4PH.cjs} +21 -21
  18. package/dist/{chunk-BONXSRHZ.cjs.map → chunk-DPQGO4PH.cjs.map} +1 -1
  19. package/dist/{chunk-A6X7EXP6.cjs → chunk-DVAAXDZV.cjs} +10 -10
  20. package/dist/{chunk-A6X7EXP6.cjs.map → chunk-DVAAXDZV.cjs.map} +1 -1
  21. package/dist/{chunk-CN36E4PK.js → chunk-EJEHIAOV.js} +2 -2
  22. package/dist/{chunk-G45RGWHF.cjs → chunk-EKKR5ZFB.cjs} +887 -543
  23. package/dist/chunk-EKKR5ZFB.cjs.map +1 -0
  24. package/dist/{chunk-IYDHV2CS.cjs → chunk-FIEOWSXH.cjs} +43 -43
  25. package/dist/{chunk-IYDHV2CS.cjs.map → chunk-FIEOWSXH.cjs.map} +1 -1
  26. package/dist/{chunk-CJOWLRUU.js → chunk-G4ND6ZZK.js} +2 -2
  27. package/dist/{chunk-KWTV3KGY.cjs → chunk-GDHQTVBP.cjs} +9 -9
  28. package/dist/{chunk-KWTV3KGY.cjs.map → chunk-GDHQTVBP.cjs.map} +1 -1
  29. package/dist/{chunk-ADANP3ZA.cjs → chunk-GOHHFX4O.cjs} +33 -33
  30. package/dist/{chunk-ADANP3ZA.cjs.map → chunk-GOHHFX4O.cjs.map} +1 -1
  31. package/dist/{chunk-KVNGYFFP.js → chunk-GU5TWGAJ.js} +3 -3
  32. package/dist/{chunk-EGYX55HT.cjs → chunk-HQKWJFFX.cjs} +159 -159
  33. package/dist/{chunk-EGYX55HT.cjs.map → chunk-HQKWJFFX.cjs.map} +1 -1
  34. package/dist/{chunk-NXZC6UTN.js → chunk-HZ6VUTLA.js} +2 -2
  35. package/dist/{chunk-WUCYI3BC.cjs → chunk-KH5KDKCE.cjs} +24 -24
  36. package/dist/{chunk-WUCYI3BC.cjs.map → chunk-KH5KDKCE.cjs.map} +1 -1
  37. package/dist/{chunk-JDINUWND.cjs → chunk-MQFA5DXU.cjs} +69 -69
  38. package/dist/{chunk-JDINUWND.cjs.map → chunk-MQFA5DXU.cjs.map} +1 -1
  39. package/dist/{chunk-5CHW7AMP.cjs → chunk-NHW2AM2X.cjs} +10 -10
  40. package/dist/{chunk-5CHW7AMP.cjs.map → chunk-NHW2AM2X.cjs.map} +1 -1
  41. package/dist/{chunk-KK3XU6YJ.cjs → chunk-NIHPN72N.cjs} +4 -4
  42. package/dist/{chunk-KK3XU6YJ.cjs.map → chunk-NIHPN72N.cjs.map} +1 -1
  43. package/dist/{chunk-EGUU3QCU.cjs → chunk-NNRRRWCD.cjs} +3 -3
  44. package/dist/{chunk-EGUU3QCU.cjs.map → chunk-NNRRRWCD.cjs.map} +1 -1
  45. package/dist/{chunk-4ZKAMPBX.js → chunk-OAY342OE.js} +2 -2
  46. package/dist/{chunk-TOYHZHTK.js → chunk-PDFH7CXB.js} +2 -2
  47. package/dist/{chunk-NIDPX52C.js → chunk-QIHBS5PM.js} +5 -5
  48. package/dist/{chunk-CNKVEVUN.js → chunk-SPOIDC23.js} +5 -5
  49. package/dist/{chunk-MENQLD57.cjs → chunk-T5FJOWHA.cjs} +5 -5
  50. package/dist/{chunk-MENQLD57.cjs.map → chunk-T5FJOWHA.cjs.map} +1 -1
  51. package/dist/{chunk-5FNWOF46.js → chunk-WZZSFOWG.js} +6 -6
  52. package/dist/{chunk-FVRRYJ6P.cjs → chunk-XIKSZSHC.cjs} +22 -22
  53. package/dist/{chunk-FVRRYJ6P.cjs.map → chunk-XIKSZSHC.cjs.map} +1 -1
  54. package/dist/{chunk-C6RJRSIM.cjs → chunk-XTMNO7MC.cjs} +3 -3
  55. package/dist/{chunk-C6RJRSIM.cjs.map → chunk-XTMNO7MC.cjs.map} +1 -1
  56. package/dist/{chunk-FQBR6SCA.js → chunk-XW4GOMK6.js} +3 -3
  57. package/dist/{chunk-77Y7R243.js → chunk-YEEMXD25.js} +5 -5
  58. package/dist/{chunk-LGBNHWDN.js → chunk-YTVTZ7JO.js} +3 -3
  59. package/dist/components/case-studies/index.cjs +9 -9
  60. package/dist/components/case-studies/index.js +3 -3
  61. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  62. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  63. package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
  64. package/dist/components/chat/entity-cards/dispatch.d.ts.map +1 -1
  65. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +4 -1
  66. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  67. package/dist/components/chat/hooks/use-slash-commands.d.ts +3 -3
  68. package/dist/components/chat/hooks/use-slash-commands.d.ts.map +1 -1
  69. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +2 -28
  70. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  71. package/dist/components/chat/index.cjs +2 -2
  72. package/dist/components/chat/index.js +1 -1
  73. package/dist/components/chat/remark-strip-citations.d.ts.map +1 -1
  74. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -1
  75. package/dist/components/chat/tool-execution-display.d.ts.map +1 -1
  76. package/dist/components/chat/types/component.types.d.ts +4 -0
  77. package/dist/components/chat/types/component.types.d.ts.map +1 -1
  78. package/dist/components/chat/types/message.types.d.ts +35 -1
  79. package/dist/components/chat/types/message.types.d.ts.map +1 -1
  80. package/dist/components/chat/types/unified-chat-state.types.d.ts +3 -3
  81. package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
  82. package/dist/components/chat/utils/chat-source-metadata.d.ts +5 -0
  83. package/dist/components/chat/utils/chat-source-metadata.d.ts.map +1 -0
  84. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  85. package/dist/components/contact/index.cjs +4 -4
  86. package/dist/components/contact/index.js +3 -3
  87. package/dist/components/docs/index.cjs +7 -7
  88. package/dist/components/docs/index.js +6 -6
  89. package/dist/components/embeds/index.cjs +4 -4
  90. package/dist/components/embeds/index.js +3 -3
  91. package/dist/components/faq/index.cjs +4 -4
  92. package/dist/components/faq/index.js +3 -3
  93. package/dist/components/features/index.cjs +3 -3
  94. package/dist/components/features/index.js +2 -2
  95. package/dist/components/help-center-pages/index.cjs +23 -23
  96. package/dist/components/help-center-pages/index.js +14 -14
  97. package/dist/components/index.cjs +36 -36
  98. package/dist/components/index.js +13 -13
  99. package/dist/components/meeting-scheduler/index.cjs +21 -21
  100. package/dist/components/meeting-scheduler/index.js +2 -2
  101. package/dist/components/navigation/index.cjs +6 -6
  102. package/dist/components/navigation/index.js +5 -5
  103. package/dist/components/onboarding-guides/index.cjs +6 -6
  104. package/dist/components/onboarding-guides/index.js +5 -5
  105. package/dist/components/tickets/index.cjs +7 -7
  106. package/dist/components/tickets/index.js +6 -6
  107. package/dist/components/ui/hover-dropdown.d.ts.map +1 -1
  108. package/dist/components/ui/index.cjs +3 -3
  109. package/dist/components/ui/index.js +2 -2
  110. package/dist/index.cjs +7 -7
  111. package/dist/index.js +6 -6
  112. package/dist/utils/index.cjs.map +1 -1
  113. package/dist/utils/index.js.map +1 -1
  114. package/package.json +2 -1
  115. package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +130 -0
  116. package/src/chat-protocol/__tests__/source-metadata.test.ts +74 -0
  117. package/src/chat-protocol/events.ts +9 -1
  118. package/src/chat-protocol/nats-decoder.ts +7 -0
  119. package/src/chat-protocol/source-metadata.ts +202 -0
  120. package/src/components/chat/__tests__/chat-message-list.test.tsx +70 -0
  121. package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +86 -0
  122. package/src/components/chat/__tests__/embeddable-chat-sources.test.tsx +411 -0
  123. package/src/components/chat/__tests__/remark-strip-citations.test.ts +52 -0
  124. package/src/components/chat/__tests__/tool-execution-display.test.tsx +93 -0
  125. package/src/components/chat/chat-message-enhanced.tsx +28 -13
  126. package/src/components/chat/chat-message-list.tsx +27 -22
  127. package/src/components/chat/embeddable-chat.tsx +55 -36
  128. package/src/components/chat/entity-cards/__tests__/dispatch-video-refs.test.tsx +74 -0
  129. package/src/components/chat/entity-cards/dispatch.tsx +86 -17
  130. package/src/components/chat/hooks/__tests__/use-slash-commands.test.ts +64 -0
  131. package/src/components/chat/hooks/use-nats-chat-adapter.ts +7 -0
  132. package/src/components/chat/hooks/use-slash-commands.ts +9 -5
  133. package/src/components/chat/hooks/use-sse-chat-adapter.ts +2 -29
  134. package/src/components/chat/remark-strip-citations.ts +5 -3
  135. package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +56 -0
  136. package/src/components/chat/stream/chat-stream-reducer.ts +31 -0
  137. package/src/components/chat/tool-execution-display.tsx +5 -2
  138. package/src/components/chat/types/component.types.ts +4 -0
  139. package/src/components/chat/types/message.types.ts +39 -0
  140. package/src/components/chat/types/unified-chat-state.types.ts +4 -3
  141. package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +57 -0
  142. package/src/components/chat/utils/chat-source-metadata.ts +19 -0
  143. package/src/components/chat/utils/process-historical-messages.ts +19 -1
  144. package/src/components/ui/hover-dropdown.tsx +8 -3
  145. package/dist/chunk-D7YISHXC.js.map +0 -1
  146. package/dist/chunk-G45RGWHF.cjs.map +0 -1
  147. /package/dist/{chunk-GKPY7VLK.js.map → chunk-47NIV4FC.js.map} +0 -0
  148. /package/dist/{chunk-I5CJJFEJ.js.map → chunk-4VDGIODG.js.map} +0 -0
  149. /package/dist/{chunk-3OCYNMZ5.js.map → chunk-CQNILR7U.js.map} +0 -0
  150. /package/dist/{chunk-CN36E4PK.js.map → chunk-EJEHIAOV.js.map} +0 -0
  151. /package/dist/{chunk-CJOWLRUU.js.map → chunk-G4ND6ZZK.js.map} +0 -0
  152. /package/dist/{chunk-KVNGYFFP.js.map → chunk-GU5TWGAJ.js.map} +0 -0
  153. /package/dist/{chunk-NXZC6UTN.js.map → chunk-HZ6VUTLA.js.map} +0 -0
  154. /package/dist/{chunk-4ZKAMPBX.js.map → chunk-OAY342OE.js.map} +0 -0
  155. /package/dist/{chunk-TOYHZHTK.js.map → chunk-PDFH7CXB.js.map} +0 -0
  156. /package/dist/{chunk-NIDPX52C.js.map → chunk-QIHBS5PM.js.map} +0 -0
  157. /package/dist/{chunk-CNKVEVUN.js.map → chunk-SPOIDC23.js.map} +0 -0
  158. /package/dist/{chunk-5FNWOF46.js.map → chunk-WZZSFOWG.js.map} +0 -0
  159. /package/dist/{chunk-FQBR6SCA.js.map → chunk-XW4GOMK6.js.map} +0 -0
  160. /package/dist/{chunk-77Y7R243.js.map → chunk-YEEMXD25.js.map} +0 -0
  161. /package/dist/{chunk-LGBNHWDN.js.map → chunk-YTVTZ7JO.js.map} +0 -0
@@ -0,0 +1,411 @@
1
+ import * as DialogPrimitive from '@radix-ui/react-dialog';
2
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3
+ import { fireEvent, render, screen } from '@testing-library/react';
4
+ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
5
+ import { decodeNatsChunk } from '../../../chat-protocol/nats-decoder';
6
+ import { type ChatRuntime, ChatRuntimeContext } from '../../../contexts/chat-runtime-context';
7
+ import { EmbeddableChat } from '../embeddable-chat';
8
+ import { createChatStreamReducer } from '../stream/chat-stream-reducer';
9
+ import type { UnifiedChatMessage, UnifiedChatState } from '../types/unified-chat-state.types';
10
+
11
+ vi.mock('@mux/mux-player-react', () => ({
12
+ default: ({ src }: { src?: string }) => <div data-testid="mux-player" data-src={src} />,
13
+ }));
14
+
15
+ const runtime: ChatRuntime = {
16
+ endpoints: {
17
+ chatStreamUrl: '/test/chat',
18
+ approvalToolUrl: '/test/approve',
19
+ commandsUrl: '/test/commands',
20
+ buildListUrl: () => null,
21
+ attachmentUploadUrl: '/test/upload',
22
+ attachmentViewUrlPrefix: '/test/view/',
23
+ identityUrl: '/test/identity',
24
+ },
25
+ navigation: { mode: 'host' },
26
+ source: 'openframe',
27
+ };
28
+
29
+ function stateFor(messages: UnifiedChatMessage[]): UnifiedChatState {
30
+ const noop = () => {};
31
+ const asyncNoop = async () => {};
32
+ return {
33
+ messages,
34
+ isLoading: false,
35
+ streamingPhase: 'idle',
36
+ sendMessage: asyncNoop,
37
+ stopMessage: noop,
38
+ clearMessages: noop,
39
+ discussRef: noop,
40
+ displayRef: noop,
41
+ currentProvider: null,
42
+ currentModelLabel: null,
43
+ currentContextWindowMaxTokens: null,
44
+ currentInputTokens: null,
45
+ currentOutputTokens: null,
46
+ currentCacheHitRatePct: null,
47
+ currentUsageBreakdown: null,
48
+ dialogs: [{ id: 'dialog-1', title: 'Sources' }],
49
+ activeDialogId: 'dialog-1',
50
+ selectDialog: noop,
51
+ startNewDialog: () => Promise.resolve(null),
52
+ deleteDialog: asyncNoop,
53
+ renameDialog: asyncNoop,
54
+ archiveDialog: asyncNoop,
55
+ isDialogsLoading: false,
56
+ dialogsError: false,
57
+ reloadDialogs: noop,
58
+ isMessagesLoading: false,
59
+ hasMoreDialogs: false,
60
+ loadMoreDialogs: asyncNoop,
61
+ hasMoreMessages: false,
62
+ loadMoreMessages: asyncNoop,
63
+ approveRequest: asyncNoop,
64
+ rejectRequest: asyncNoop,
65
+ dialogTokenUsage: null,
66
+ connectionState: 'connected',
67
+ };
68
+ }
69
+
70
+ function renderChat(messages: UnifiedChatMessage[]) {
71
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
72
+ return render(
73
+ <QueryClientProvider client={queryClient}>
74
+ <ChatRuntimeContext.Provider value={runtime}>
75
+ <DialogPrimitive.Root open>
76
+ <EmbeddableChat
77
+ shell="none"
78
+ defaultOpen
79
+ defaultActiveMode="mingo"
80
+ showInternalTrigger={false}
81
+ previewMode
82
+ mingoState={stateFor(messages)}
83
+ />
84
+ </DialogPrimitive.Root>
85
+ </ChatRuntimeContext.Provider>
86
+ </QueryClientProvider>,
87
+ );
88
+ }
89
+
90
+ beforeAll(() => {
91
+ vi.stubGlobal('matchMedia', () => ({
92
+ matches: false,
93
+ addEventListener: vi.fn(),
94
+ removeEventListener: vi.fn(),
95
+ }));
96
+ vi.stubGlobal(
97
+ 'ResizeObserver',
98
+ class {
99
+ observe(): void {}
100
+ disconnect(): void {}
101
+ unobserve(): void {}
102
+ },
103
+ );
104
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
105
+ new Response(JSON.stringify({ commands: [] }), {
106
+ status: 200,
107
+ headers: { 'Content-Type': 'application/json' },
108
+ }),
109
+ );
110
+ });
111
+
112
+ afterAll(() => {
113
+ vi.restoreAllMocks();
114
+ vi.unstubAllGlobals();
115
+ });
116
+
117
+ describe('EmbeddableChat source strip', () => {
118
+ it('keeps sources attached to their assistant turn after later messages', () => {
119
+ renderChat([
120
+ {
121
+ id: 'assistant-1',
122
+ role: 'assistant',
123
+ content: 'Use the first guide [1].',
124
+ sources: [
125
+ {
126
+ index: 1,
127
+ name: 'First turn source',
128
+ path: 'docs/first',
129
+ documentType: 'docs',
130
+ },
131
+ ],
132
+ },
133
+ {
134
+ id: 'assistant-2',
135
+ role: 'assistant',
136
+ content: 'Use the second guide [1].',
137
+ sources: [
138
+ {
139
+ index: 1,
140
+ name: 'Second turn source',
141
+ path: 'docs/second',
142
+ documentType: 'docs',
143
+ },
144
+ ],
145
+ },
146
+ {
147
+ id: 'assistant-3',
148
+ role: 'assistant',
149
+ content: 'Here is a plain follow-up answer.',
150
+ },
151
+ ]);
152
+
153
+ expect(screen.getByText('[1] First turn source')).toBeInTheDocument();
154
+ expect(screen.getByText('[1] Second turn source')).toBeInTheDocument();
155
+ });
156
+
157
+ it('renders live SOURCES metadata in citation order with uncited overflow', () => {
158
+ const reducer = createChatStreamReducer({ transport: 'nats' });
159
+ for (const chunk of [
160
+ { type: 'MESSAGE_START', streamSeq: 1 },
161
+ {
162
+ type: 'SOURCES',
163
+ payload: {
164
+ sources: [
165
+ { index: 1, name: 'First source', path: 'first', documentType: 'docs' },
166
+ { index: 2, name: 'Second source', path: 'second', documentType: 'docs' },
167
+ { index: 3, name: 'Third source', path: 'third', documentType: 'docs' },
168
+ { index: 4, name: 'Fourth source', path: 'fourth', documentType: 'docs' },
169
+ ],
170
+ videos: [
171
+ {
172
+ ref: '[card://video:mux-9b6586b494]',
173
+ title: 'Product overview',
174
+ url: 'https://stream.mux.com/product-overview.m3u8',
175
+ },
176
+ ],
177
+ },
178
+ streamSeq: 2,
179
+ },
180
+ {
181
+ type: 'TEXT',
182
+ text:
183
+ 'Compare the third source [3] with the first [1] and cite [3] once. ' +
184
+ 'Watch [card://video:mux-9b6586b494].',
185
+ streamSeq: 3,
186
+ },
187
+ { type: 'MESSAGE_END', streamSeq: 4 },
188
+ ]) {
189
+ const event = decodeNatsChunk(chunk);
190
+ if (event) reducer.apply(event);
191
+ }
192
+
193
+ renderChat(reducer.state.messages);
194
+
195
+ const chips = screen.getAllByTitle(/source$/i);
196
+ expect(chips.map(chip => chip.textContent)).toEqual(['[3] Third source', '[1] First source']);
197
+ expect(screen.queryByText('[2] Second source')).not.toBeInTheDocument();
198
+ const expander = screen.getByRole('button', { name: 'Show 2 additional retrieved sources' });
199
+ expect(expander).toHaveTextContent('+2 more retrieved sources');
200
+
201
+ fireEvent.click(expander);
202
+
203
+ expect(screen.getByText('[2] Second source')).toBeInTheDocument();
204
+ expect(screen.getByText('[4] Fourth source')).toBeInTheDocument();
205
+ expect(screen.getByTestId('mux-player')).toHaveAttribute(
206
+ 'data-src',
207
+ 'https://stream.mux.com/product-overview.m3u8',
208
+ );
209
+ });
210
+
211
+ it('keeps every uncited source accessible behind the top-three fallback', () => {
212
+ const reducer = createChatStreamReducer({ transport: 'nats' });
213
+ for (const chunk of [
214
+ { type: 'MESSAGE_START', streamSeq: 1 },
215
+ {
216
+ type: 'SOURCES',
217
+ payload: {
218
+ sources: Array.from({ length: 5 }, (_, index) => ({
219
+ index: index + 1,
220
+ name: `Source ${index + 1}`,
221
+ path: `source-${index + 1}`,
222
+ documentType: 'docs',
223
+ })),
224
+ },
225
+ streamSeq: 2,
226
+ },
227
+ { type: 'TEXT', text: 'Here are the relevant materials.', streamSeq: 3 },
228
+ { type: 'MESSAGE_END', streamSeq: 4 },
229
+ ]) {
230
+ const event = decodeNatsChunk(chunk);
231
+ if (event) reducer.apply(event);
232
+ }
233
+
234
+ renderChat(reducer.state.messages);
235
+
236
+ expect(screen.getByText('[1] Source 1')).toBeInTheDocument();
237
+ expect(screen.getByText('[3] Source 3')).toBeInTheDocument();
238
+ expect(screen.queryByText('[4] Source 4')).not.toBeInTheDocument();
239
+
240
+ const expander = screen.getByRole('button', { name: 'Show 2 additional retrieved sources' });
241
+ fireEvent.click(expander);
242
+
243
+ expect(screen.getByText('[4] Source 4')).toBeInTheDocument();
244
+ expect(screen.getByText('[5] Source 5')).toBeInTheDocument();
245
+ });
246
+
247
+ it('renders a markdown card from matching source metadata when hydration is unavailable', () => {
248
+ const reducer = createChatStreamReducer({ transport: 'nats' });
249
+ for (const chunk of [
250
+ { type: 'MESSAGE_START', streamSeq: 1 },
251
+ {
252
+ type: 'SOURCES',
253
+ payload: {
254
+ sources: [
255
+ {
256
+ index: 1,
257
+ id: 'install-agent',
258
+ name: 'Install the OpenFrame agent on Windows',
259
+ path: 'getting-started/install-agent.md',
260
+ documentType: 'markdown',
261
+ sourceRepo: 'openframe-docs',
262
+ },
263
+ ],
264
+ cards: [
265
+ {
266
+ ref: '[card://markdown:install-agent]',
267
+ entityType: 'markdown',
268
+ entityId: 'install-agent',
269
+ },
270
+ ],
271
+ },
272
+ streamSeq: 2,
273
+ },
274
+ {
275
+ type: 'TEXT',
276
+ text: 'Follow [card://markdown:install-agent].',
277
+ streamSeq: 3,
278
+ },
279
+ { type: 'MESSAGE_END', streamSeq: 4 },
280
+ ]) {
281
+ const event = decodeNatsChunk(chunk);
282
+ if (event) reducer.apply(event);
283
+ }
284
+
285
+ renderChat(reducer.state.messages);
286
+
287
+ expect(screen.getByText('Install the OpenFrame agent on Windows')).toBeInTheDocument();
288
+ expect(screen.queryByText('FAILED')).not.toBeInTheDocument();
289
+ });
290
+
291
+ it('renders a matched source card when its display name equals its id', () => {
292
+ const reducer = createChatStreamReducer({ transport: 'nats' });
293
+ for (const chunk of [
294
+ { type: 'MESSAGE_START', streamSeq: 1 },
295
+ {
296
+ type: 'SOURCES',
297
+ payload: {
298
+ sources: [
299
+ {
300
+ index: 1,
301
+ id: 'install-agent',
302
+ name: 'install-agent',
303
+ path: 'getting-started/install-agent.md',
304
+ documentType: 'markdown',
305
+ },
306
+ ],
307
+ cards: [
308
+ {
309
+ ref: '[card://markdown:install-agent]',
310
+ entityType: 'markdown',
311
+ entityId: 'install-agent',
312
+ },
313
+ ],
314
+ },
315
+ streamSeq: 2,
316
+ },
317
+ { type: 'TEXT', text: 'Follow [card://markdown:install-agent].', streamSeq: 3 },
318
+ { type: 'MESSAGE_END', streamSeq: 4 },
319
+ ]) {
320
+ const event = decodeNatsChunk(chunk);
321
+ if (event) reducer.apply(event);
322
+ }
323
+
324
+ renderChat(reducer.state.messages);
325
+
326
+ expect(screen.getAllByText('install-agent').length).toBeGreaterThan(0);
327
+ expect(screen.queryByText('FAILED')).not.toBeInTheDocument();
328
+ });
329
+
330
+ it('does not treat an arbitrary enriched ref as matched source metadata', () => {
331
+ renderChat([
332
+ {
333
+ id: 'assistant-1',
334
+ role: 'assistant',
335
+ content: 'Follow [card://markdown:unmatched-doc].',
336
+ refs: [
337
+ {
338
+ type: 'markdown',
339
+ id: 'unmatched-doc',
340
+ title: 'Plausible but unmatched document',
341
+ url: null,
342
+ metadata: { path: 'docs/unmatched.md' },
343
+ },
344
+ ],
345
+ },
346
+ ]);
347
+
348
+ expect(screen.getByText('FAILED')).toBeInTheDocument();
349
+ expect(screen.queryByText('Plausible but unmatched document')).not.toBeInTheDocument();
350
+ });
351
+
352
+ it('opens every item carried by a grouped source', () => {
353
+ const reducer = createChatStreamReducer({ transport: 'nats' });
354
+ for (const chunk of [
355
+ { type: 'MESSAGE_START', streamSeq: 1 },
356
+ {
357
+ type: 'SOURCES',
358
+ payload: {
359
+ sources: [
360
+ {
361
+ index: 1,
362
+ name: 'Case Studies (3 records)',
363
+ path: 'case-studies',
364
+ documentType: 'case-study',
365
+ items: [
366
+ {
367
+ id: 'case-study-1',
368
+ name: 'Northstar MSP',
369
+ path: 'case-studies/northstar',
370
+ documentType: 'case-study',
371
+ },
372
+ {
373
+ id: 'case-study-2',
374
+ name: 'Blue Harbor IT',
375
+ path: 'case-studies/blue-harbor',
376
+ documentType: 'case-study',
377
+ },
378
+ {
379
+ id: 'case-study-3',
380
+ name: 'Summit Technology',
381
+ path: 'case-studies/summit',
382
+ documentType: 'case-study',
383
+ },
384
+ ],
385
+ },
386
+ ],
387
+ },
388
+ streamSeq: 2,
389
+ },
390
+ { type: 'TEXT', text: 'Here are three customer stories [1].', streamSeq: 3 },
391
+ { type: 'MESSAGE_END', streamSeq: 4 },
392
+ ]) {
393
+ const event = decodeNatsChunk(chunk);
394
+ if (event) reducer.apply(event);
395
+ }
396
+
397
+ renderChat(reducer.state.messages);
398
+
399
+ const groupedSource = screen.getByText('[1] Case Studies (3 records)');
400
+ fireEvent.mouseEnter(groupedSource);
401
+ fireEvent.click(groupedSource);
402
+
403
+ expect(screen.getByText('Northstar MSP')).toBeInTheDocument();
404
+ expect(screen.getByText('Blue Harbor IT')).toBeInTheDocument();
405
+ expect(screen.getByText('Summit Technology')).toBeInTheDocument();
406
+
407
+ fireEvent.click(groupedSource);
408
+
409
+ expect(screen.queryByText('Northstar MSP')).not.toBeInTheDocument();
410
+ });
411
+ });
@@ -0,0 +1,52 @@
1
+ import type { Root } from 'mdast';
2
+ import { unified } from 'unified';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { remarkStripCitations } from '../remark-strip-citations';
5
+
6
+ describe('remarkStripCitations', () => {
7
+ it('removes a citation-only list without leaving broken punctuation', () => {
8
+ const tree: Root = {
9
+ type: 'root',
10
+ children: [
11
+ {
12
+ type: 'paragraph',
13
+ children: [{ type: 'text', value: 'Sources [1], [2], and [3] are cited.' }],
14
+ },
15
+ ],
16
+ };
17
+
18
+ const result = unified().use(remarkStripCitations).runSync(tree);
19
+
20
+ expect(result).toMatchObject({
21
+ children: [
22
+ {
23
+ children: [{ type: 'text', value: 'Sources are cited.' }],
24
+ },
25
+ ],
26
+ });
27
+ });
28
+
29
+ it('keeps prose punctuation while removing individual citations', () => {
30
+ const tree: Root = {
31
+ type: 'root',
32
+ children: [
33
+ {
34
+ type: 'paragraph',
35
+ children: [
36
+ { type: 'text', value: 'Install on Windows [1], verify the service [2], and confirm Devices [3].' },
37
+ ],
38
+ },
39
+ ],
40
+ };
41
+
42
+ const result = unified().use(remarkStripCitations).runSync(tree);
43
+
44
+ expect(result).toMatchObject({
45
+ children: [
46
+ {
47
+ children: [{ type: 'text', value: 'Install on Windows, verify the service, and confirm Devices.' }],
48
+ },
49
+ ],
50
+ });
51
+ });
52
+ });
@@ -0,0 +1,93 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
3
+ import { decodeNatsChunk } from '../../../chat-protocol/nats-decoder';
4
+ import { createChatStreamReducer } from '../stream/chat-stream-reducer';
5
+ import { ToolExecutionDisplay } from '../tool-execution-display';
6
+ import type { ToolExecutionData } from '../types';
7
+
8
+ const execution = (overrides: Partial<ToolExecutionData>): ToolExecutionData => ({
9
+ type: 'EXECUTING_TOOL',
10
+ integratedToolType: 'OPENFRAME',
11
+ toolFunction: 'search_docs',
12
+ ...overrides,
13
+ });
14
+
15
+ beforeAll(() => {
16
+ vi.stubGlobal(
17
+ 'ResizeObserver',
18
+ class {
19
+ observe(): void {}
20
+ disconnect(): void {}
21
+ unobserve(): void {}
22
+ },
23
+ );
24
+ });
25
+
26
+ afterAll(() => {
27
+ vi.unstubAllGlobals();
28
+ });
29
+
30
+ describe('ToolExecutionDisplay remote read activity', () => {
31
+ it('keeps a completed remote read concise through the real wire pipeline', () => {
32
+ const reducer = createChatStreamReducer({ transport: 'nats' });
33
+ for (const chunk of [
34
+ {
35
+ type: 'EXECUTING_TOOL',
36
+ integratedToolType: null,
37
+ toolFunction: 'remote_read',
38
+ title: 'Searching product documentation',
39
+ parameters: null,
40
+ toolExecutionRequestId: 'remote-read-1',
41
+ },
42
+ {
43
+ type: 'EXECUTED_TOOL',
44
+ integratedToolType: null,
45
+ toolFunction: 'remote_read',
46
+ result: null,
47
+ success: true,
48
+ toolExecutionRequestId: 'remote-read-1',
49
+ },
50
+ ]) {
51
+ const event = decodeNatsChunk(chunk);
52
+ if (event) reducer.apply(event);
53
+ }
54
+ const toolSegment = reducer.state.messages
55
+ .flatMap(message => message.segments ?? [])
56
+ .find(segment => segment.type === 'tool_execution');
57
+
58
+ expect(toolSegment?.type).toBe('tool_execution');
59
+ if (toolSegment?.type !== 'tool_execution') return;
60
+ render(<ToolExecutionDisplay message={toolSegment.data} />);
61
+
62
+ expect(screen.getByText('Searching product documentation')).toBeInTheDocument();
63
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
64
+ expect(screen.queryByText('Remote read')).not.toBeInTheDocument();
65
+ });
66
+
67
+ it('keeps a typed local tool expandable when its body is absent', () => {
68
+ render(
69
+ <ToolExecutionDisplay
70
+ message={execution({
71
+ integratedToolType: 'OPENFRAME',
72
+ toolTitle: 'Refresh device inventory',
73
+ })}
74
+ />,
75
+ );
76
+
77
+ expect(screen.getByRole('button')).toBeInTheDocument();
78
+ });
79
+
80
+ it('keeps a backend-described remote read concise while running', () => {
81
+ render(
82
+ <ToolExecutionDisplay
83
+ message={execution({
84
+ integratedToolType: undefined,
85
+ toolTitle: 'Searching product documentation',
86
+ })}
87
+ />,
88
+ );
89
+
90
+ expect(screen.getByText('Searching product documentation')).toBeInTheDocument();
91
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
92
+ });
93
+ });
@@ -103,6 +103,7 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
103
103
  renderContextItem,
104
104
  renderMention,
105
105
  renderEntityCard,
106
+ refs,
106
107
  onAskSelect,
107
108
  NavLinkAnchor,
108
109
  ...props
@@ -127,6 +128,11 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
127
128
  // literal `[card://...]` URL.
128
129
  const hasMarkerSupport = !!renderEntityCard;
129
130
 
131
+ const refsByKey = useMemo(
132
+ () => new Map((refs ?? []).map(reference => [`${reference.type}:${reference.id}`, reference])),
133
+ [refs],
134
+ );
135
+
130
136
  const segments = useMemo(() => normalizeContent(content), [content]);
131
137
 
132
138
  const askRuns = useMemo(() => groupAskRuns(segments), [segments]);
@@ -180,7 +186,14 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
180
186
  // (and its open menu) survives across chunks. Invalidated per key when the
181
187
  // render fn identity changes.
182
188
  const renderedCardNodeCache = useRef(
183
- new Map<string, { render: ((ref: ChatRef) => React.ReactNode) | undefined; node: React.ReactNode }>(),
189
+ new Map<
190
+ string,
191
+ {
192
+ render: ((ref: ChatRef) => React.ReactNode) | undefined;
193
+ reference: ChatRef;
194
+ node: React.ReactNode;
195
+ }
196
+ >(),
184
197
  );
185
198
 
186
199
  /**
@@ -242,10 +255,17 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
242
255
  // menu / re-fetching) on every stream chunk. Also dedups the same
243
256
  // key emitted twice within one message.
244
257
  let entry = cache.get(key);
245
- if (!entry || entry.render !== render) {
246
- // The marker is the ONLY data on the wire: cards hydrate by id
247
- // from the host's per-object APIs, so a minimal {type, id}
248
- // descriptor is all the renderer needs to mount the loader.
258
+ const enrichedRef = refsByKey.get(key);
259
+ const refForRender: ChatRef = enrichedRef ?? {
260
+ type: cardType,
261
+ id: cardId,
262
+ title: cardId,
263
+ url: null,
264
+ };
265
+ if (!entry || entry.render !== render || entry.reference !== refForRender) {
266
+ // Prefer same-turn SOURCES metadata when available. Otherwise
267
+ // cards hydrate by id from the host's per-object APIs, so a
268
+ // minimal {type, id} descriptor still mounts the loader.
249
269
  // For types with nothing to fetch the host's render() returns
250
270
  // null and we fall through to the bare-cardId fallback in the
251
271
  // `<a card://…>` override below.
@@ -257,13 +277,7 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
257
277
  // the id rather than `undefined`) and `url` to null (matches
258
278
  // the no-link semantics fetch-mode cards rely on — they
259
279
  // resolve their own URL after fetch).
260
- const refForRender: ChatRef = {
261
- type: cardType,
262
- id: cardId,
263
- title: cardId,
264
- url: null,
265
- };
266
- entry = { render, node: render(refForRender) };
280
+ entry = { render, reference: refForRender, node: render(refForRender) };
267
281
  cache.set(key, entry);
268
282
  }
269
283
  const rendered = entry.node;
@@ -326,7 +340,7 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
326
340
  }
327
341
  });
328
342
  return { inlineByKey, partsBySegment, usedKeys };
329
- }, [hasMarkerSupport, renderEntityCard, segments]);
343
+ }, [hasMarkerSupport, refsByKey, renderEntityCard, segments]);
330
344
 
331
345
  // Drop cached nodes for markers no longer present so the cache can't grow
332
346
  // unbounded as a long message's markers change. Deliberately an EFFECT,
@@ -716,6 +730,7 @@ const MemoizedChatMessageEnhanced = memo(ChatMessageEnhanced, (prevProps, nextPr
716
730
  // equality holds across streaming chunks.
717
731
  prevProps.renderMention === nextProps.renderMention &&
718
732
  prevProps.renderEntityCard === nextProps.renderEntityCard &&
733
+ prevProps.refs === nextProps.refs &&
719
734
  // Same stability contract as the renderers above: hosts pass a `useCallback`
720
735
  // (EmbeddableChat passes its memoized `handleSend`), so this holds across
721
736
  // streaming chunks instead of re-rendering every ask card per chunk.