@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.
- package/dist/{chunk-OV6YSKMP.cjs → chunk-2UTUZGMF.cjs} +17 -8
- package/dist/chunk-2UTUZGMF.cjs.map +1 -0
- package/dist/{chunk-EXQPK67M.cjs → chunk-3YWZNB2S.cjs} +10 -10
- package/dist/{chunk-EXQPK67M.cjs.map → chunk-3YWZNB2S.cjs.map} +1 -1
- package/dist/{chunk-CUGM36ZU.js → chunk-4DA64VEW.js} +4 -4
- package/dist/{chunk-CPJ7QBIV.cjs → chunk-4NUKFUPW.cjs} +7 -7
- package/dist/{chunk-CPJ7QBIV.cjs.map → chunk-4NUKFUPW.cjs.map} +1 -1
- package/dist/{chunk-K7YEA2LO.cjs → chunk-757VTCIH.cjs} +12 -12
- package/dist/{chunk-K7YEA2LO.cjs.map → chunk-757VTCIH.cjs.map} +1 -1
- package/dist/{chunk-7IYDTCKE.js → chunk-75S3RKYX.js} +6 -6
- package/dist/{chunk-GNOCB662.js → chunk-7OZ5P7Z2.js} +5 -5
- package/dist/{chunk-CTF5IQQZ.js → chunk-BGHQW5D5.js} +17 -8
- package/dist/chunk-BGHQW5D5.js.map +1 -0
- package/dist/{chunk-UBAXZQKE.js → chunk-C5RRTWYW.js} +20 -14
- package/dist/{chunk-UBAXZQKE.js.map → chunk-C5RRTWYW.js.map} +1 -1
- package/dist/{chunk-STVG6WV4.js → chunk-D5QSGV3I.js} +2 -2
- package/dist/{chunk-33HTSSCG.js → chunk-DIWJ6NBT.js} +3 -3
- package/dist/{chunk-X2ORWRBO.js → chunk-EWZPMPQP.js} +2 -2
- package/dist/{chunk-J7VFEMTJ.js → chunk-F5N2P2GD.js} +7 -7
- package/dist/{chunk-RBBFGN6B.cjs → chunk-F7EZBJTL.cjs} +5 -5
- package/dist/{chunk-RBBFGN6B.cjs.map → chunk-F7EZBJTL.cjs.map} +1 -1
- package/dist/{chunk-FBQSQRUY.cjs → chunk-FCGZIXI5.cjs} +75 -75
- package/dist/{chunk-FBQSQRUY.cjs.map → chunk-FCGZIXI5.cjs.map} +1 -1
- package/dist/{chunk-HEPOYA2M.js → chunk-FQDHQNSU.js} +31 -10
- package/dist/{chunk-HEPOYA2M.js.map → chunk-FQDHQNSU.js.map} +1 -1
- package/dist/{chunk-2752B3YY.cjs → chunk-FRXIDN6T.cjs} +3 -3
- package/dist/{chunk-2752B3YY.cjs.map → chunk-FRXIDN6T.cjs.map} +1 -1
- package/dist/{chunk-MP2UTOLN.js → chunk-JV7SNSYT.js} +5 -5
- package/dist/{chunk-OAS4JLF4.cjs → chunk-KQXKQ4PX.cjs} +22 -22
- package/dist/{chunk-OAS4JLF4.cjs.map → chunk-KQXKQ4PX.cjs.map} +1 -1
- package/dist/{chunk-RI44VAGW.cjs → chunk-KSDOCJ7B.cjs} +382 -382
- package/dist/{chunk-RI44VAGW.cjs.map → chunk-KSDOCJ7B.cjs.map} +1 -1
- package/dist/{chunk-DZR47TRO.cjs → chunk-QFGPSBAX.cjs} +33 -33
- package/dist/{chunk-DZR47TRO.cjs.map → chunk-QFGPSBAX.cjs.map} +1 -1
- package/dist/{chunk-G4EXCWR4.cjs → chunk-RLUXVMSD.cjs} +49 -49
- package/dist/{chunk-G4EXCWR4.cjs.map → chunk-RLUXVMSD.cjs.map} +1 -1
- package/dist/{chunk-L3O2HPOO.cjs → chunk-RSDTLURW.cjs} +31 -25
- package/dist/chunk-RSDTLURW.cjs.map +1 -0
- package/dist/{chunk-R5B4T26B.cjs → chunk-SJC2XUTE.cjs} +26 -26
- package/dist/{chunk-R5B4T26B.cjs.map → chunk-SJC2XUTE.cjs.map} +1 -1
- package/dist/{chunk-N4HJDYMG.js → chunk-SPI7DCD3.js} +2 -2
- package/dist/{chunk-JHSDZI3D.cjs → chunk-T7GIFC5Y.cjs} +31 -10
- package/dist/chunk-T7GIFC5Y.cjs.map +1 -0
- package/dist/{chunk-MB5JXDJK.js → chunk-ULOUTQBX.js} +4 -4
- package/dist/{chunk-EPH3FGIF.js → chunk-V4L2MYXY.js} +3 -3
- package/dist/{chunk-XZG2NZLL.cjs → chunk-VWYK6TSE.cjs} +10 -10
- package/dist/{chunk-XZG2NZLL.cjs.map → chunk-VWYK6TSE.cjs.map} +1 -1
- package/dist/{chunk-EWNKYH2D.js → chunk-WTNFYZB6.js} +3 -3
- package/dist/{chunk-D5FLAQQH.js → chunk-YC7DIARJ.js} +5 -5
- package/dist/{chunk-S3NK47ZJ.js → chunk-YDJBSKLB.js} +3 -3
- package/dist/{chunk-THWEZULI.cjs → chunk-YFU7BULM.cjs} +31 -31
- package/dist/{chunk-THWEZULI.cjs.map → chunk-YFU7BULM.cjs.map} +1 -1
- package/dist/{chunk-GPSL3KFX.cjs → chunk-ZFQ3ZHMR.cjs} +3 -3
- package/dist/{chunk-GPSL3KFX.cjs.map → chunk-ZFQ3ZHMR.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +16 -16
- package/dist/components/case-studies/index.js +4 -4
- package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-chat-attachments.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +2 -2
- package/dist/components/chat/index.js +1 -1
- package/dist/components/contact/index.cjs +5 -5
- package/dist/components/contact/index.js +4 -4
- package/dist/components/docs/index.cjs +8 -8
- package/dist/components/docs/index.js +7 -7
- package/dist/components/embeds/index.cjs +5 -5
- package/dist/components/embeds/index.js +4 -4
- package/dist/components/faq/index.cjs +5 -5
- package/dist/components/faq/index.js +4 -4
- package/dist/components/features/index.cjs +4 -4
- package/dist/components/features/index.js +3 -3
- package/dist/components/help-center-pages/index.cjs +76 -76
- package/dist/components/help-center-pages/index.js +15 -15
- package/dist/components/index.cjs +41 -41
- package/dist/components/index.js +14 -14
- package/dist/components/meeting-scheduler/index.cjs +31 -31
- package/dist/components/meeting-scheduler/index.js +3 -3
- package/dist/components/navigation/index.cjs +7 -7
- package/dist/components/navigation/index.js +6 -6
- package/dist/components/onboarding-guides/index.cjs +7 -7
- package/dist/components/onboarding-guides/index.js +6 -6
- package/dist/components/shared/onboarding/onboarding-walkthrough.d.ts +9 -1
- package/dist/components/shared/onboarding/onboarding-walkthrough.d.ts.map +1 -1
- package/dist/components/tickets/index.cjs +8 -8
- package/dist/components/tickets/index.js +7 -7
- package/dist/components/ui/index.cjs +4 -4
- package/dist/components/ui/index.js +3 -3
- package/dist/hooks/index.cjs +2 -2
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/ui/use-onboarding-state.d.ts +1 -0
- package/dist/hooks/ui/use-onboarding-state.d.ts.map +1 -1
- package/dist/index.cjs +8 -8
- package/dist/index.js +7 -7
- package/dist/utils/onboarding-storage.d.ts +5 -0
- package/dist/utils/onboarding-storage.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/__tests__/embeddable-chat-composer-scope.test.tsx +234 -0
- package/src/components/chat/embeddable-chat.tsx +63 -18
- package/src/components/chat/hooks/use-chat-attachments.ts +4 -1
- package/src/components/shared/onboarding/onboarding-walkthrough.tsx +26 -10
- package/src/hooks/ui/use-onboarding-state.ts +8 -6
- package/src/utils/__tests__/onboarding-storage.test.ts +42 -0
- package/src/utils/onboarding-storage.ts +15 -1
- package/dist/chunk-CTF5IQQZ.js.map +0 -1
- package/dist/chunk-JHSDZI3D.cjs.map +0 -1
- package/dist/chunk-L3O2HPOO.cjs.map +0 -1
- package/dist/chunk-OV6YSKMP.cjs.map +0 -1
- /package/dist/{chunk-CUGM36ZU.js.map → chunk-4DA64VEW.js.map} +0 -0
- /package/dist/{chunk-7IYDTCKE.js.map → chunk-75S3RKYX.js.map} +0 -0
- /package/dist/{chunk-GNOCB662.js.map → chunk-7OZ5P7Z2.js.map} +0 -0
- /package/dist/{chunk-STVG6WV4.js.map → chunk-D5QSGV3I.js.map} +0 -0
- /package/dist/{chunk-33HTSSCG.js.map → chunk-DIWJ6NBT.js.map} +0 -0
- /package/dist/{chunk-X2ORWRBO.js.map → chunk-EWZPMPQP.js.map} +0 -0
- /package/dist/{chunk-J7VFEMTJ.js.map → chunk-F5N2P2GD.js.map} +0 -0
- /package/dist/{chunk-MP2UTOLN.js.map → chunk-JV7SNSYT.js.map} +0 -0
- /package/dist/{chunk-N4HJDYMG.js.map → chunk-SPI7DCD3.js.map} +0 -0
- /package/dist/{chunk-MB5JXDJK.js.map → chunk-ULOUTQBX.js.map} +0 -0
- /package/dist/{chunk-EPH3FGIF.js.map → chunk-V4L2MYXY.js.map} +0 -0
- /package/dist/{chunk-EWNKYH2D.js.map → chunk-WTNFYZB6.js.map} +0 -0
- /package/dist/{chunk-D5FLAQQH.js.map → chunk-YC7DIARJ.js.map} +0 -0
- /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 {
|
|
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
|
-
//
|
|
1337
|
-
//
|
|
1338
|
-
//
|
|
1339
|
-
//
|
|
1340
|
-
//
|
|
1341
|
-
//
|
|
1342
|
-
//
|
|
1343
|
-
//
|
|
1344
|
-
//
|
|
1345
|
-
//
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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}>
|
|
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
|
*/
|