@flamingo-stack/openframe-frontend-core 0.0.678 → 0.0.679-snapshot.20260929011258

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 (120) hide show
  1. package/dist/{chunk-OV6YSKMP.cjs → chunk-2UTUZGMF.cjs} +17 -8
  2. package/dist/chunk-2UTUZGMF.cjs.map +1 -0
  3. package/dist/{chunk-EXQPK67M.cjs → chunk-3YWZNB2S.cjs} +10 -10
  4. package/dist/{chunk-EXQPK67M.cjs.map → chunk-3YWZNB2S.cjs.map} +1 -1
  5. package/dist/{chunk-CUGM36ZU.js → chunk-4DA64VEW.js} +4 -4
  6. package/dist/{chunk-CPJ7QBIV.cjs → chunk-4NUKFUPW.cjs} +7 -7
  7. package/dist/{chunk-CPJ7QBIV.cjs.map → chunk-4NUKFUPW.cjs.map} +1 -1
  8. package/dist/{chunk-K7YEA2LO.cjs → chunk-757VTCIH.cjs} +12 -12
  9. package/dist/{chunk-K7YEA2LO.cjs.map → chunk-757VTCIH.cjs.map} +1 -1
  10. package/dist/{chunk-7IYDTCKE.js → chunk-75S3RKYX.js} +6 -6
  11. package/dist/{chunk-GNOCB662.js → chunk-7OZ5P7Z2.js} +5 -5
  12. package/dist/{chunk-CTF5IQQZ.js → chunk-BGHQW5D5.js} +17 -8
  13. package/dist/chunk-BGHQW5D5.js.map +1 -0
  14. package/dist/{chunk-UBAXZQKE.js → chunk-C5RRTWYW.js} +20 -14
  15. package/dist/{chunk-UBAXZQKE.js.map → chunk-C5RRTWYW.js.map} +1 -1
  16. package/dist/{chunk-STVG6WV4.js → chunk-D5QSGV3I.js} +2 -2
  17. package/dist/{chunk-33HTSSCG.js → chunk-DIWJ6NBT.js} +3 -3
  18. package/dist/{chunk-X2ORWRBO.js → chunk-EWZPMPQP.js} +2 -2
  19. package/dist/{chunk-J7VFEMTJ.js → chunk-F5N2P2GD.js} +7 -7
  20. package/dist/{chunk-RBBFGN6B.cjs → chunk-F7EZBJTL.cjs} +5 -5
  21. package/dist/{chunk-RBBFGN6B.cjs.map → chunk-F7EZBJTL.cjs.map} +1 -1
  22. package/dist/{chunk-FBQSQRUY.cjs → chunk-FCGZIXI5.cjs} +75 -75
  23. package/dist/{chunk-FBQSQRUY.cjs.map → chunk-FCGZIXI5.cjs.map} +1 -1
  24. package/dist/{chunk-HEPOYA2M.js → chunk-FQDHQNSU.js} +31 -10
  25. package/dist/{chunk-HEPOYA2M.js.map → chunk-FQDHQNSU.js.map} +1 -1
  26. package/dist/{chunk-2752B3YY.cjs → chunk-FRXIDN6T.cjs} +3 -3
  27. package/dist/{chunk-2752B3YY.cjs.map → chunk-FRXIDN6T.cjs.map} +1 -1
  28. package/dist/{chunk-MP2UTOLN.js → chunk-JV7SNSYT.js} +5 -5
  29. package/dist/{chunk-OAS4JLF4.cjs → chunk-KQXKQ4PX.cjs} +22 -22
  30. package/dist/{chunk-OAS4JLF4.cjs.map → chunk-KQXKQ4PX.cjs.map} +1 -1
  31. package/dist/{chunk-RI44VAGW.cjs → chunk-KSDOCJ7B.cjs} +382 -382
  32. package/dist/{chunk-RI44VAGW.cjs.map → chunk-KSDOCJ7B.cjs.map} +1 -1
  33. package/dist/{chunk-DZR47TRO.cjs → chunk-QFGPSBAX.cjs} +33 -33
  34. package/dist/{chunk-DZR47TRO.cjs.map → chunk-QFGPSBAX.cjs.map} +1 -1
  35. package/dist/{chunk-G4EXCWR4.cjs → chunk-RLUXVMSD.cjs} +49 -49
  36. package/dist/{chunk-G4EXCWR4.cjs.map → chunk-RLUXVMSD.cjs.map} +1 -1
  37. package/dist/{chunk-L3O2HPOO.cjs → chunk-RSDTLURW.cjs} +31 -25
  38. package/dist/chunk-RSDTLURW.cjs.map +1 -0
  39. package/dist/{chunk-R5B4T26B.cjs → chunk-SJC2XUTE.cjs} +26 -26
  40. package/dist/{chunk-R5B4T26B.cjs.map → chunk-SJC2XUTE.cjs.map} +1 -1
  41. package/dist/{chunk-N4HJDYMG.js → chunk-SPI7DCD3.js} +2 -2
  42. package/dist/{chunk-JHSDZI3D.cjs → chunk-T7GIFC5Y.cjs} +31 -10
  43. package/dist/chunk-T7GIFC5Y.cjs.map +1 -0
  44. package/dist/{chunk-MB5JXDJK.js → chunk-ULOUTQBX.js} +4 -4
  45. package/dist/{chunk-EPH3FGIF.js → chunk-V4L2MYXY.js} +3 -3
  46. package/dist/{chunk-XZG2NZLL.cjs → chunk-VWYK6TSE.cjs} +10 -10
  47. package/dist/{chunk-XZG2NZLL.cjs.map → chunk-VWYK6TSE.cjs.map} +1 -1
  48. package/dist/{chunk-EWNKYH2D.js → chunk-WTNFYZB6.js} +3 -3
  49. package/dist/{chunk-D5FLAQQH.js → chunk-YC7DIARJ.js} +5 -5
  50. package/dist/{chunk-S3NK47ZJ.js → chunk-YDJBSKLB.js} +3 -3
  51. package/dist/{chunk-THWEZULI.cjs → chunk-YFU7BULM.cjs} +31 -31
  52. package/dist/{chunk-THWEZULI.cjs.map → chunk-YFU7BULM.cjs.map} +1 -1
  53. package/dist/{chunk-GPSL3KFX.cjs → chunk-ZFQ3ZHMR.cjs} +3 -3
  54. package/dist/{chunk-GPSL3KFX.cjs.map → chunk-ZFQ3ZHMR.cjs.map} +1 -1
  55. package/dist/components/case-studies/index.cjs +16 -16
  56. package/dist/components/case-studies/index.js +4 -4
  57. package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
  58. package/dist/components/chat/hooks/use-chat-attachments.d.ts.map +1 -1
  59. package/dist/components/chat/index.cjs +2 -2
  60. package/dist/components/chat/index.js +1 -1
  61. package/dist/components/contact/index.cjs +5 -5
  62. package/dist/components/contact/index.js +4 -4
  63. package/dist/components/docs/index.cjs +8 -8
  64. package/dist/components/docs/index.js +7 -7
  65. package/dist/components/embeds/index.cjs +5 -5
  66. package/dist/components/embeds/index.js +4 -4
  67. package/dist/components/faq/index.cjs +5 -5
  68. package/dist/components/faq/index.js +4 -4
  69. package/dist/components/features/index.cjs +4 -4
  70. package/dist/components/features/index.js +3 -3
  71. package/dist/components/help-center-pages/index.cjs +76 -76
  72. package/dist/components/help-center-pages/index.js +15 -15
  73. package/dist/components/index.cjs +41 -41
  74. package/dist/components/index.js +14 -14
  75. package/dist/components/meeting-scheduler/index.cjs +31 -31
  76. package/dist/components/meeting-scheduler/index.js +3 -3
  77. package/dist/components/navigation/index.cjs +7 -7
  78. package/dist/components/navigation/index.js +6 -6
  79. package/dist/components/onboarding-guides/index.cjs +7 -7
  80. package/dist/components/onboarding-guides/index.js +6 -6
  81. package/dist/components/shared/onboarding/onboarding-walkthrough.d.ts +9 -1
  82. package/dist/components/shared/onboarding/onboarding-walkthrough.d.ts.map +1 -1
  83. package/dist/components/tickets/index.cjs +8 -8
  84. package/dist/components/tickets/index.js +7 -7
  85. package/dist/components/ui/index.cjs +4 -4
  86. package/dist/components/ui/index.js +3 -3
  87. package/dist/hooks/index.cjs +2 -2
  88. package/dist/hooks/index.js +1 -1
  89. package/dist/hooks/ui/use-onboarding-state.d.ts +1 -0
  90. package/dist/hooks/ui/use-onboarding-state.d.ts.map +1 -1
  91. package/dist/index.cjs +8 -8
  92. package/dist/index.js +7 -7
  93. package/dist/utils/onboarding-storage.d.ts +5 -0
  94. package/dist/utils/onboarding-storage.d.ts.map +1 -1
  95. package/package.json +1 -1
  96. package/src/components/chat/__tests__/embeddable-chat-composer-scope.test.tsx +234 -0
  97. package/src/components/chat/embeddable-chat.tsx +63 -18
  98. package/src/components/chat/hooks/use-chat-attachments.ts +4 -1
  99. package/src/components/shared/onboarding/onboarding-walkthrough.tsx +26 -10
  100. package/src/hooks/ui/use-onboarding-state.ts +8 -6
  101. package/src/utils/__tests__/onboarding-storage.test.ts +42 -0
  102. package/src/utils/onboarding-storage.ts +15 -1
  103. package/dist/chunk-CTF5IQQZ.js.map +0 -1
  104. package/dist/chunk-JHSDZI3D.cjs.map +0 -1
  105. package/dist/chunk-L3O2HPOO.cjs.map +0 -1
  106. package/dist/chunk-OV6YSKMP.cjs.map +0 -1
  107. /package/dist/{chunk-CUGM36ZU.js.map → chunk-4DA64VEW.js.map} +0 -0
  108. /package/dist/{chunk-7IYDTCKE.js.map → chunk-75S3RKYX.js.map} +0 -0
  109. /package/dist/{chunk-GNOCB662.js.map → chunk-7OZ5P7Z2.js.map} +0 -0
  110. /package/dist/{chunk-STVG6WV4.js.map → chunk-D5QSGV3I.js.map} +0 -0
  111. /package/dist/{chunk-33HTSSCG.js.map → chunk-DIWJ6NBT.js.map} +0 -0
  112. /package/dist/{chunk-X2ORWRBO.js.map → chunk-EWZPMPQP.js.map} +0 -0
  113. /package/dist/{chunk-J7VFEMTJ.js.map → chunk-F5N2P2GD.js.map} +0 -0
  114. /package/dist/{chunk-MP2UTOLN.js.map → chunk-JV7SNSYT.js.map} +0 -0
  115. /package/dist/{chunk-N4HJDYMG.js.map → chunk-SPI7DCD3.js.map} +0 -0
  116. /package/dist/{chunk-MB5JXDJK.js.map → chunk-ULOUTQBX.js.map} +0 -0
  117. /package/dist/{chunk-EPH3FGIF.js.map → chunk-V4L2MYXY.js.map} +0 -0
  118. /package/dist/{chunk-EWNKYH2D.js.map → chunk-WTNFYZB6.js.map} +0 -0
  119. /package/dist/{chunk-D5FLAQQH.js.map → chunk-YC7DIARJ.js.map} +0 -0
  120. /package/dist/{chunk-S3NK47ZJ.js.map → chunk-YDJBSKLB.js.map} +0 -0
@@ -0,0 +1,234 @@
1
+ /**
2
+ * The composer belongs to the conversation on screen — pinned.
3
+ *
4
+ * `ChatInput` is an uncontrolled contenteditable that owns its draft, and the
5
+ * slash-command menu is derived from that draft (`/` at the start keeps it
6
+ * open). The panel already dropped the staged context chips on a dialog
7
+ * change, but the text stayed, so opening another conversation — from the
8
+ * list, or from a notification the host resolves straight into the store —
9
+ * landed with the previous one's half-typed `/` and its menu still open over
10
+ * the new thread. The composer is now keyed on the conversation: leaving one
11
+ * remounts it fresh in the same commit.
12
+ *
13
+ * The one id change that must NOT reset it: a send from the new-chat surface
14
+ * creates the dialog, and the id the panel then lands on is this conversation
15
+ * being named. The editor stays typable while that first turn streams, so a
16
+ * follow-up already being typed has to survive.
17
+ */
18
+
19
+ import * as DialogPrimitive from '@radix-ui/react-dialog';
20
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
21
+ import { act, fireEvent, render, screen } from '@testing-library/react';
22
+ import { createRef } from 'react';
23
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
24
+
25
+ // Identity + every other authed fetch: a promise that never settles keeps the
26
+ // panel offline and free of state updates landing outside `act`.
27
+ vi.mock('../../../utils/embed-authed-fetch', () => ({
28
+ embedAuthedFetch: vi.fn(() => new Promise(() => {})),
29
+ }));
30
+
31
+ // One canned command, so `/` opens a real suggestions menu.
32
+ vi.mock('../hooks/use-slash-commands', async importOriginal => {
33
+ const actual = await importOriginal<Record<string, unknown>>();
34
+ return {
35
+ ...actual,
36
+ fetchSlashCommands: vi.fn(() =>
37
+ Promise.resolve([
38
+ {
39
+ id: 'roadmap',
40
+ label: 'ClickUp Roadmap',
41
+ description: 'Browse the public ClickUp roadmap',
42
+ argumentHint: '[task id or name]',
43
+ actions: [{ id: 'recent', label: 'Recent' }],
44
+ },
45
+ ]),
46
+ ),
47
+ };
48
+ });
49
+
50
+ import { ChatRuntimeContext, type ChatRuntime } from '../../../contexts/chat-runtime-context';
51
+ import { EmbeddableChat, type EmbeddableChatHandle } from '../embeddable-chat';
52
+ import type { UnifiedChatState } from '../types/unified-chat-state.types';
53
+
54
+ const runtime: ChatRuntime = {
55
+ endpoints: {
56
+ chatStreamUrl: '/api/docs/chat',
57
+ approvalToolUrl: '/api/chat/agent/confirm-tool',
58
+ commandsUrl: '/api/docs/commands',
59
+ buildListUrl: () => null,
60
+ attachmentUploadUrl: '/api/storage/generate-upload-url',
61
+ attachmentViewUrlPrefix: '/api/storage/view/chat-attachments/',
62
+ identityUrl: '/api/chat/identity',
63
+ },
64
+ navigation: { mode: 'host' },
65
+ };
66
+
67
+ const MESSAGE: UnifiedChatState['messages'][number] = {
68
+ id: 'm-1',
69
+ role: 'user',
70
+ content: 'hello',
71
+ timestamp: new Date('2026-01-01T00:00:00Z'),
72
+ };
73
+
74
+ /** The injected-state path (`mingoState`): the panel renders this object and
75
+ * opens no transport, so the test drives `activeDialogId` by re-rendering. */
76
+ function createState(overrides: Partial<UnifiedChatState>): UnifiedChatState {
77
+ const noop = () => {};
78
+ const asyncNoop = async () => {};
79
+ return {
80
+ messages: [],
81
+ isLoading: false,
82
+ streamingPhase: 'idle',
83
+ sendMessage: asyncNoop,
84
+ stopMessage: noop,
85
+ clearMessages: noop,
86
+ discussRef: noop,
87
+ displayRef: noop,
88
+ currentProvider: 'anthropic',
89
+ currentModelLabel: 'Claude',
90
+ currentContextWindowMaxTokens: null,
91
+ currentInputTokens: null,
92
+ currentOutputTokens: null,
93
+ currentCacheHitRatePct: null,
94
+ currentUsageBreakdown: null,
95
+ dialogs: [
96
+ { id: 'd-1', title: 'First' },
97
+ { id: 'd-2', title: 'Second' },
98
+ ],
99
+ activeDialogId: null,
100
+ selectDialog: noop,
101
+ startNewDialog: () => Promise.resolve(null),
102
+ deleteDialog: asyncNoop,
103
+ renameDialog: asyncNoop,
104
+ archiveDialog: asyncNoop,
105
+ isDialogsLoading: false,
106
+ dialogsError: false,
107
+ reloadDialogs: noop,
108
+ isMessagesLoading: false,
109
+ hasMoreDialogs: false,
110
+ loadMoreDialogs: asyncNoop,
111
+ hasMoreMessages: false,
112
+ loadMoreMessages: asyncNoop,
113
+ approveRequest: asyncNoop,
114
+ rejectRequest: asyncNoop,
115
+ dialogTokenUsage: null,
116
+ connectionState: 'connected',
117
+ ...overrides,
118
+ };
119
+ }
120
+
121
+ function Harness({ state, handleRef }: { state: UnifiedChatState; handleRef?: React.Ref<EmbeddableChatHandle> }) {
122
+ return (
123
+ <ChatRuntimeContext.Provider value={runtime}>
124
+ <DialogPrimitive.Root open>
125
+ <EmbeddableChat
126
+ ref={handleRef}
127
+ shell="none"
128
+ defaultOpen
129
+ defaultActiveMode="mingo"
130
+ showInternalTrigger={false}
131
+ mingoState={state}
132
+ mingoDialogCapabilities={{ canRename: true, canArchive: true }}
133
+ />
134
+ </DialogPrimitive.Root>
135
+ </ChatRuntimeContext.Provider>
136
+ );
137
+ }
138
+
139
+ function renderPanel(state: UnifiedChatState, handleRef?: React.Ref<EmbeddableChatHandle>) {
140
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
141
+ const view = render(
142
+ <QueryClientProvider client={client}>
143
+ <Harness state={state} handleRef={handleRef} />
144
+ </QueryClientProvider>,
145
+ );
146
+ return {
147
+ ...view,
148
+ rerender: (next: UnifiedChatState) =>
149
+ view.rerender(
150
+ <QueryClientProvider client={client}>
151
+ <Harness state={next} handleRef={handleRef} />
152
+ </QueryClientProvider>,
153
+ ),
154
+ };
155
+ }
156
+
157
+ const editor = () => screen.getByRole('textbox', { name: 'Ask a question...' });
158
+
159
+ /** Type into the uncontrolled editor the way the browser does: mutate the DOM,
160
+ * then fire `input` so the component reads it back. */
161
+ function type(text: string) {
162
+ const el = editor();
163
+ el.textContent = `${el.textContent ?? ''}${text}`;
164
+ fireEvent.input(el);
165
+ }
166
+
167
+ const menu = () => screen.queryByRole('menu', { name: 'Slash command suggestions' });
168
+
169
+ beforeEach(() => {
170
+ vi.useRealTimers();
171
+ });
172
+
173
+ describe('EmbeddableChat — the composer belongs to the conversation on screen', () => {
174
+ it('opening another dialog drops the draft and the slash menu it kept open', async () => {
175
+ const { rerender } = renderPanel(createState({ activeDialogId: 'd-1', messages: [MESSAGE] }));
176
+
177
+ type('/');
178
+ expect(await screen.findByRole('menu', { name: 'Slash command suggestions' })).toBeInTheDocument();
179
+ expect(editor()).toHaveTextContent('/');
180
+
181
+ rerender(createState({ activeDialogId: 'd-2', messages: [MESSAGE] }));
182
+
183
+ expect(menu()).not.toBeInTheDocument();
184
+ expect(editor()).toHaveTextContent('');
185
+ });
186
+
187
+ it('opening a dialog from the new-chat surface drops the draft typed there', async () => {
188
+ const handle = createRef<EmbeddableChatHandle>();
189
+ const { rerender } = renderPanel(createState({ activeDialogId: null }), handle);
190
+ // Narrow layout: the list has no composer; the compose view does.
191
+ act(() => handle.current?.startNewChat());
192
+
193
+ type('/');
194
+ expect(await screen.findByRole('menu', { name: 'Slash command suggestions' })).toBeInTheDocument();
195
+
196
+ rerender(createState({ activeDialogId: 'd-1', messages: [MESSAGE] }));
197
+
198
+ expect(menu()).not.toBeInTheDocument();
199
+ expect(editor()).toHaveTextContent('');
200
+ });
201
+
202
+ it('a new chat being named by its first send keeps the follow-up already being typed', async () => {
203
+ let settleSend: () => void = () => {};
204
+ const sendMessage = vi.fn<UnifiedChatState['sendMessage']>(
205
+ () =>
206
+ new Promise<void>(resolve => {
207
+ settleSend = resolve;
208
+ }),
209
+ );
210
+ const handle = createRef<EmbeddableChatHandle>();
211
+ const { rerender } = renderPanel(createState({ activeDialogId: null, sendMessage }), handle);
212
+ act(() => handle.current?.startNewChat());
213
+
214
+ type('first question');
215
+ fireEvent.keyDown(editor(), { key: 'Enter' });
216
+ expect(sendMessage.mock.calls[0]?.[0]).toBe('first question');
217
+ expect(editor()).toHaveTextContent('');
218
+
219
+ // The host is creating the dialog; the user is already typing the next one.
220
+ type('and also');
221
+ rerender(createState({ activeDialogId: 'd-new', messages: [MESSAGE], sendMessage }));
222
+ expect(editor()).toHaveTextContent('and also');
223
+
224
+ await act(async () => {
225
+ settleSend();
226
+ await Promise.resolve();
227
+ });
228
+ expect(editor()).toHaveTextContent('and also');
229
+
230
+ // A real switch afterwards still resets.
231
+ rerender(createState({ activeDialogId: 'd-1', messages: [MESSAGE], sendMessage }));
232
+ expect(editor()).toHaveTextContent('');
233
+ });
234
+ });
@@ -95,7 +95,11 @@ import { SourceActionButton } from './source-action-button';
95
95
  import type { ChatInputRef, SlashCommandActionId } from './types/component.types';
96
96
  import type { ChatContextItem, ChatContextPickerConfig } from './types/context-item.types';
97
97
  import type { MessageSegment, Message } from './types/message.types';
98
- import type { ChatDialogCapabilities, UnifiedChatState } from './types/unified-chat-state.types';
98
+ import type {
99
+ ChatDialogCapabilities,
100
+ UnifiedChatState,
101
+ UnifiedSendMessageOptions,
102
+ } from './types/unified-chat-state.types';
99
103
  import { formatChatAttachmentMarkdownForBubble } from './utils/chat-attachment-markdown';
100
104
  import { resolveHrefForRuntime } from './utils/chat-nav-resolution';
101
105
  import { chatChipClass } from './utils/chip-styles';
@@ -1215,7 +1219,7 @@ function EmbeddableChatInner({
1215
1219
  const {
1216
1220
  messages: rawMessages,
1217
1221
  isLoading: chatLoading,
1218
- sendMessage,
1222
+ sendMessage: sendMessageRaw,
1219
1223
  discussRef,
1220
1224
  stopMessage,
1221
1225
  clearMessages,
@@ -1257,6 +1261,29 @@ function EmbeddableChatInner({
1257
1261
  // adapters already know whether they own a list. Absent = single-thread.
1258
1262
  const historyListMode = dialogCapabilities !== undefined;
1259
1263
 
1264
+ // A send from the new-chat surface asks the host to create the dialog, and
1265
+ // the id the panel then lands on is THIS conversation being named — not the
1266
+ // user leaving it. The editor stays typable while the first turn streams, so
1267
+ // a follow-up already being typed has to survive that transition; the
1268
+ // composer-scope reset below reads this flag to tell the two apart. Armed in
1269
+ // the send's own event (one batch with the composer's own clear, so it is
1270
+ // set by the render the id lands in) and dropped once the send settles either
1271
+ // way: a create that failed would otherwise leave it armed for the next real
1272
+ // switch. Every send path goes through here — the composer, a clarification
1273
+ // card, a quick-action chip, the Guide launcher prompt.
1274
+ const [namingNewChat, setNamingNewChat] = useState(false);
1275
+ const sendMessage = useCallback(
1276
+ async (text: string, options?: UnifiedSendMessageOptions) => {
1277
+ if (activeDialogId == null) setNamingNewChat(true);
1278
+ try {
1279
+ return await sendMessageRaw(text, options);
1280
+ } finally {
1281
+ setNamingNewChat(false);
1282
+ }
1283
+ },
1284
+ [activeDialogId, sendMessageRaw],
1285
+ );
1286
+
1260
1287
  // Non-optional view of the same object for the JSX below. `canRename` /
1261
1288
  // `canArchive` default OFF: the ⋯ menu must never advertise an action
1262
1289
  // nothing has wired.
@@ -1333,25 +1360,42 @@ function EmbeddableChatInner({
1333
1360
  // Source of truth = the input text — deleting a token's chip drops its item.
1334
1361
  const mentionKeysRef = useRef<Set<string>>(new Set());
1335
1362
 
1336
- // Staged picker selection is per-conversation and Mingo-only. Clear it (and the
1337
- // mention bookkeeping) whenever the active dialog changes or the mode toggles,
1338
- // so unsent chips can't bleed into another conversation — or into a Guide send,
1339
- // where the SSE transport silently drops `contextItems`. Mirrors the post-send
1340
- // reset in `handleSend`.
1341
- // The two STATE halves are adjusted while rendering (React's documented
1342
- // pattern for a reset the render already has the answer to) — the composer
1343
- // draws its chips from `contextItems`, so clearing from an effect committed
1344
- // one frame of the previous conversation's chips under the new one. The ref
1345
- // bookkeeping stays in an effect: a ref must not be written during render.
1346
- const [contextClearedFor, setContextClearedFor] = useState({ activeDialogId, activeMode });
1347
- if (contextClearedFor.activeDialogId !== activeDialogId || contextClearedFor.activeMode !== activeMode) {
1348
- setContextClearedFor({ activeDialogId, activeMode });
1349
- setContextItems(NO_CONTEXT_ITEMS);
1350
- setMentionQuery(null);
1363
+ // The composer belongs to the conversation on screen. Leaving it — another
1364
+ // dialog opened from the list, a notification or a link, back to the list, a
1365
+ // new chat, the Mingo ⇄ Guide toggle — drops everything unsent: the draft
1366
+ // text (and with it the slash-command menu that text keeps open), the staged
1367
+ // context chips and the `@` / `+` picker, and the staged uploads. Nothing
1368
+ // typed for one conversation may bleed into another — or into a Guide send,
1369
+ // where the SSE transport silently drops `contextItems`. Mirrors the
1370
+ // post-send reset in `handleSend`.
1371
+ // The draft lives in an UNCONTROLLED contenteditable, so it is dropped the
1372
+ // React way: `composerKey` remounts the composer subtree, in the same commit
1373
+ // the new conversation renders in. An effect-time `clear()` painted one frame
1374
+ // of the old draft — menu open — under the new conversation, and so did
1375
+ // clearing the chips from an effect. So the STATE halves are adjusted while
1376
+ // rendering (React's documented pattern for a reset the render already has
1377
+ // the answer to); the ref and attachment bookkeeping stays in an effect: a ref
1378
+ // must not be written during render, and aborting uploads is a side effect.
1379
+ // The one id change that is NOT a switch: `null → id` while a send from the
1380
+ // new-chat surface is naming it (`namingNewChat` above).
1381
+ const scopeDialogId = activeDialogId ?? null;
1382
+ const [composerScope, setComposerScope] = useState({ dialogId: scopeDialogId, mode: activeMode });
1383
+ const [composerKey, setComposerKey] = useState(0);
1384
+ if (composerScope.dialogId !== scopeDialogId || composerScope.mode !== activeMode) {
1385
+ const named =
1386
+ namingNewChat && composerScope.dialogId == null && scopeDialogId != null && composerScope.mode === activeMode;
1387
+ setComposerScope({ dialogId: scopeDialogId, mode: activeMode });
1388
+ if (!named) {
1389
+ setComposerKey(k => k + 1);
1390
+ setContextItems(NO_CONTEXT_ITEMS);
1391
+ setContextPickerOpen(false);
1392
+ setMentionQuery(null);
1393
+ }
1351
1394
  }
1352
1395
  useEffect(() => {
1353
1396
  mentionKeysRef.current.clear();
1354
- }, [activeDialogId, activeMode]);
1397
+ clearAttachments();
1398
+ }, [composerKey, clearAttachments]);
1355
1399
 
1356
1400
  // Map each entity type to its backend mention marker (host-declared on the
1357
1401
  // entity type). Drives the committed `@marker:id` token; missing markers fall
@@ -2661,6 +2705,7 @@ function EmbeddableChatInner({
2661
2705
  />
2662
2706
 
2663
2707
  <ChatComposer
2708
+ key={composerKey}
2664
2709
  archived={isViewingArchived}
2665
2710
  lock={composerLock}
2666
2711
  inputRef={chatInputRef}
@@ -204,7 +204,10 @@ export function useChatAttachments(): UseChatAttachmentsApi {
204
204
  const clear = useCallback(() => {
205
205
  for (const ctrl of controllersRef.current.values()) ctrl.abort();
206
206
  controllersRef.current.clear();
207
- setAttachments([]);
207
+ // Same array back when there is nothing staged, so React bails out of the
208
+ // update: the composer-scope effect calls this on mount and on every
209
+ // conversation switch, and an empty strip must not cost a render.
210
+ setAttachments(prev => (prev.length === 0 ? prev : []));
208
211
  }, []);
209
212
 
210
213
  const uploadOne = useCallback(
@@ -9,6 +9,14 @@ import { OnboardingStepCard } from './onboarding-step-card';
9
9
 
10
10
  export interface OnboardingWalkthroughProps {
11
11
  steps: OnboardingStepConfig[];
12
+ /** Heading above the steps. Defaults to "Get Started". */
13
+ title?: string;
14
+ /**
15
+ * What to render once the walkthrough is dismissed. Receives `restart`,
16
+ * which clears every completed/skipped step and shows the walkthrough
17
+ * again. Omitted: a dismissed walkthrough renders nothing.
18
+ */
19
+ renderDismissed?: (restart: () => void) => React.ReactNode;
12
20
  onDismiss?: () => void;
13
21
  storageKey?: string;
14
22
  className?: string;
@@ -26,6 +34,8 @@ export interface OnboardingWalkthroughProps {
26
34
 
27
35
  export function OnboardingWalkthrough({
28
36
  steps,
37
+ title = 'Get Started',
38
+ renderDismissed,
29
39
  onDismiss,
30
40
  storageKey = 'openframe-onboarding-state',
31
41
  className,
@@ -38,6 +48,7 @@ export function OnboardingWalkthrough({
38
48
  markComplete,
39
49
  markSkipped,
40
50
  dismissOnboarding,
51
+ resetOnboarding,
41
52
  isStepComplete,
42
53
  isStepSkipped,
43
54
  allStepsComplete,
@@ -63,8 +74,11 @@ export function OnboardingWalkthrough({
63
74
  // Skip if no completion status
64
75
  if (!completionStatus) return;
65
76
 
66
- // Create a stable key from completion status to detect actual changes
67
- const statusKey = JSON.stringify(completionStatus);
77
+ // Create a stable key from completion status to detect actual changes.
78
+ // An empty state (first load, or after a restart) is keyed by when it was
79
+ // written, so a restart applies the same status again.
80
+ const isFreshState = state.completedSteps.length === 0 && state.skippedSteps.length === 0;
81
+ const statusKey = `${JSON.stringify(completionStatus)}|${isFreshState ? state.lastUpdated : ''}`;
68
82
 
69
83
  // Skip if we've already processed this exact status
70
84
  if (lastCompletionStatusRef.current === statusKey) return;
@@ -86,7 +100,6 @@ export function OnboardingWalkthrough({
86
100
  // Mark as in progress to prevent concurrent calls
87
101
  autoMarkingInProgressRef.current = true;
88
102
 
89
- console.log('📊 Auto-marking steps as complete from hook data:', completedStepIds);
90
103
  markMultipleComplete(completedStepIds);
91
104
 
92
105
  // Update the last processed status key
@@ -101,14 +114,20 @@ export function OnboardingWalkthrough({
101
114
  // Even if no steps to mark, update the status key to prevent re-processing
102
115
  lastCompletionStatusRef.current = statusKey;
103
116
  }
104
- }, [completionStatus, isLoadingCompletion, state.completedSteps, markMultipleComplete]);
117
+ }, [
118
+ completionStatus,
119
+ isLoadingCompletion,
120
+ state.completedSteps,
121
+ state.skippedSteps,
122
+ state.lastUpdated,
123
+ markMultipleComplete,
124
+ ]);
105
125
 
106
126
  // useCallback must be called unconditionally (before any early returns)
107
127
  const handleStepAction = useCallback(
108
128
  async (step: OnboardingStepConfig) => {
109
129
  // Prevent duplicate action handling for the same step
110
130
  if (actionInProgressRef.current.has(step.id)) {
111
- console.log(`⏳ Action already in progress for "${step.id}", skipping`);
112
131
  return;
113
132
  }
114
133
 
@@ -120,13 +139,11 @@ export function OnboardingWalkthrough({
120
139
 
121
140
  // Skip if already completed (could have been auto-marked while action was running)
122
141
  if (state.completedSteps.includes(step.id)) {
123
- console.log(`✓ Step "${step.id}" already completed, skipping mark`);
124
142
  return;
125
143
  }
126
144
 
127
145
  // Auto-mark Knowledge Base as complete (no verification needed)
128
146
  if (step.id === 'knowledge-base') {
129
- console.log(`🎯 Auto-completing Knowledge Base`);
130
147
  markComplete(step.id);
131
148
  return;
132
149
  }
@@ -135,7 +152,6 @@ export function OnboardingWalkthrough({
135
152
  if (step.checkComplete) {
136
153
  try {
137
154
  const isComplete = await step.checkComplete();
138
- console.log(`🔍 Post-action check for "${step.id}": isComplete=${isComplete}`);
139
155
 
140
156
  // Double-check it wasn't completed while we were checking
141
157
  if (isComplete && !state.completedSteps.includes(step.id)) {
@@ -166,7 +182,7 @@ export function OnboardingWalkthrough({
166
182
  // ALL hooks have been called above - now safe to do conditional returns
167
183
  // Don't render if dismissed
168
184
  if (state.dismissed) {
169
- return null;
185
+ return renderDismissed ? <>{renderDismissed(resetOnboarding)}</> : null;
170
186
  }
171
187
 
172
188
  const isAllComplete = allStepsComplete(steps);
@@ -175,7 +191,7 @@ export function OnboardingWalkthrough({
175
191
  <div className={cn('w-full space-y-4', className)}>
176
192
  {/* Header - responsive: stacks on mobile */}
177
193
  <div className="flex flex-col items-start justify-between gap-3 md:flex-row md:items-center md:gap-0">
178
- <h2 className={SECTION_HEADING_CLASS}>Get Started</h2>
194
+ <h2 className={SECTION_HEADING_CLASS}>{title}</h2>
179
195
 
180
196
  {isLoadingCompletion ? (
181
197
  <div className="h-[48px] w-full animate-pulse rounded-[6px] bg-ods-border md:w-[160px]" />
@@ -8,6 +8,7 @@ import {
8
8
  markStepSkipped as storageMarkSkipped,
9
9
  dismissOnboarding as storageDismiss,
10
10
  markMultipleComplete as storageMarkMultiple,
11
+ resetOnboardingState as storageReset,
11
12
  type OnboardingState,
12
13
  } from '../../utils/onboarding-storage';
13
14
  import { readLocalStorageUpdateDetail } from '../../utils/storage-event';
@@ -42,7 +43,6 @@ export function useOnboardingState(storageKey: string = 'openframe-onboarding-st
42
43
  const newState = loadOnboardingState(storageKey);
43
44
  setState(newState);
44
45
  forceUpdate(prev => prev + 1);
45
- console.log('🔄 State updated from storage event:', newState);
46
46
  };
47
47
 
48
48
  window.addEventListener('localStorageUpdate', handleStorageUpdate);
@@ -53,7 +53,6 @@ export function useOnboardingState(storageKey: string = 'openframe-onboarding-st
53
53
 
54
54
  const markComplete = useCallback(
55
55
  (stepId: string) => {
56
- console.log(`🎯 markComplete called for: "${stepId}"`);
57
56
  const newState = storageMarkComplete(storageKey, stepId);
58
57
  setState(newState);
59
58
  forceUpdate(prev => prev + 1);
@@ -63,7 +62,6 @@ export function useOnboardingState(storageKey: string = 'openframe-onboarding-st
63
62
 
64
63
  const markSkipped = useCallback(
65
64
  (stepId: string) => {
66
- console.log(`⏭️ markSkipped called for: "${stepId}"`);
67
65
  const newState = storageMarkSkipped(storageKey, stepId);
68
66
  setState(newState);
69
67
  forceUpdate(prev => prev + 1);
@@ -72,19 +70,22 @@ export function useOnboardingState(storageKey: string = 'openframe-onboarding-st
72
70
  );
73
71
 
74
72
  const dismissOnboarding = useCallback(() => {
75
- console.log(`🚫 dismissOnboarding called`);
76
73
  const newState = storageDismiss(storageKey);
77
74
  setState(newState);
78
75
  forceUpdate(prev => prev + 1);
79
76
  }, [storageKey]);
80
77
 
78
+ const resetOnboarding = useCallback(() => {
79
+ const newState = storageReset(storageKey);
80
+ setState(newState);
81
+ forceUpdate(prev => prev + 1);
82
+ }, [storageKey]);
83
+
81
84
  const markMultipleComplete = useCallback(
82
85
  (stepIds: string[]) => {
83
- console.log(`🎯 markMultipleComplete called for:`, stepIds);
84
86
  const newState = storageMarkMultiple(storageKey, stepIds);
85
87
  setState(newState);
86
88
  forceUpdate(prev => prev + 1);
87
- console.log(`📝 State after batch:`, newState);
88
89
  },
89
90
  [storageKey],
90
91
  );
@@ -115,6 +116,7 @@ export function useOnboardingState(storageKey: string = 'openframe-onboarding-st
115
116
  markComplete,
116
117
  markSkipped,
117
118
  dismissOnboarding,
119
+ resetOnboarding,
118
120
  isStepComplete,
119
121
  isStepSkipped,
120
122
  allStepsComplete,
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The reset that brings a dismissed walkthrough back, round-tripped through
3
+ * real localStorage: reading the state back is the only proof the write stuck.
4
+ */
5
+ import { afterEach, describe, expect, it } from 'vitest';
6
+ import {
7
+ dismissOnboarding,
8
+ loadOnboardingState,
9
+ markStepComplete,
10
+ markStepSkipped,
11
+ resetOnboardingState,
12
+ } from '../onboarding-storage';
13
+
14
+ const KEY = 'onboarding-storage-test';
15
+
16
+ afterEach(() => localStorage.clear());
17
+
18
+ describe('resetOnboardingState', () => {
19
+ it('clears completed and skipped steps and undoes a dismissal', () => {
20
+ markStepComplete(KEY, 'install');
21
+ markStepSkipped(KEY, 'video');
22
+ dismissOnboarding(KEY);
23
+ expect(loadOnboardingState(KEY).dismissed).toBe(true);
24
+
25
+ const reset = resetOnboardingState(KEY);
26
+
27
+ expect(reset).toMatchObject({ completedSteps: [], skippedSteps: [], dismissed: false });
28
+ expect(loadOnboardingState(KEY)).toMatchObject({ completedSteps: [], skippedSteps: [], dismissed: false });
29
+ });
30
+
31
+ it('tells other hook instances through the localStorageUpdate event', () => {
32
+ const seen: string[] = [];
33
+ const listener = (event: Event) => seen.push((event as CustomEvent<{ key: string }>).detail.key);
34
+ window.addEventListener('localStorageUpdate', listener);
35
+ try {
36
+ resetOnboardingState(KEY);
37
+ } finally {
38
+ window.removeEventListener('localStorageUpdate', listener);
39
+ }
40
+ expect(seen).toEqual([KEY]);
41
+ });
42
+ });
@@ -26,7 +26,6 @@ export function saveOnboardingState(key: string, state: OnboardingState): void {
26
26
 
27
27
  try {
28
28
  localStorage.setItem(key, JSON.stringify(state));
29
- console.log('💾 Saved onboarding state:', state);
30
29
 
31
30
  // Dispatch custom event for cross-tab sync
32
31
  if (typeof window !== 'undefined') {
@@ -110,6 +109,21 @@ export function dismissOnboarding(key: string): OnboardingState {
110
109
  return newState;
111
110
  }
112
111
 
112
+ /**
113
+ * Reset the walkthrough to its initial state: no step completed or skipped,
114
+ * not dismissed. How a dismissed walkthrough is brought back.
115
+ */
116
+ export function resetOnboardingState(key: string): OnboardingState {
117
+ const newState: OnboardingState = {
118
+ completedSteps: [],
119
+ skippedSteps: [],
120
+ dismissed: false,
121
+ lastUpdated: new Date().toISOString(),
122
+ };
123
+ saveOnboardingState(key, newState);
124
+ return newState;
125
+ }
126
+
113
127
  /**
114
128
  * Check if a step is complete
115
129
  */