@flamingo-stack/openframe-frontend-core 0.0.678 → 0.0.679-2445.7368.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 (101) hide show
  1. package/dist/{chunk-EXQPK67M.cjs → chunk-25Y3UR3R.cjs} +10 -10
  2. package/dist/{chunk-EXQPK67M.cjs.map → chunk-25Y3UR3R.cjs.map} +1 -1
  3. package/dist/{chunk-EPH3FGIF.js → chunk-2YMFBKYR.js} +3 -3
  4. package/dist/{chunk-RI44VAGW.cjs → chunk-3LW6AJMJ.cjs} +338 -338
  5. package/dist/{chunk-RI44VAGW.cjs.map → chunk-3LW6AJMJ.cjs.map} +1 -1
  6. package/dist/{chunk-MB5JXDJK.js → chunk-4ZKZSWX4.js} +3 -3
  7. package/dist/{chunk-33HTSSCG.js → chunk-6LN7ZNHT.js} +3 -3
  8. package/dist/{chunk-S3NK47ZJ.js → chunk-7WGUMITJ.js} +2 -2
  9. package/dist/{chunk-UBAXZQKE.js → chunk-CDMZOXS7.js} +3 -3
  10. package/dist/{chunk-XZG2NZLL.cjs → chunk-CGYFCEHM.cjs} +10 -10
  11. package/dist/{chunk-XZG2NZLL.cjs.map → chunk-CGYFCEHM.cjs.map} +1 -1
  12. package/dist/{chunk-RBBFGN6B.cjs → chunk-CY3XHQM3.cjs} +5 -5
  13. package/dist/{chunk-RBBFGN6B.cjs.map → chunk-CY3XHQM3.cjs.map} +1 -1
  14. package/dist/{chunk-STVG6WV4.js → chunk-EEL4RGHX.js} +2 -2
  15. package/dist/{chunk-HEPOYA2M.js → chunk-FQDHQNSU.js} +31 -10
  16. package/dist/{chunk-HEPOYA2M.js.map → chunk-FQDHQNSU.js.map} +1 -1
  17. package/dist/{chunk-D5FLAQQH.js → chunk-FUBOKAYO.js} +5 -5
  18. package/dist/{chunk-L3O2HPOO.cjs → chunk-GZIYE35W.cjs} +15 -15
  19. package/dist/{chunk-L3O2HPOO.cjs.map → chunk-GZIYE35W.cjs.map} +1 -1
  20. package/dist/{chunk-GPSL3KFX.cjs → chunk-H27EHTLX.cjs} +3 -3
  21. package/dist/{chunk-GPSL3KFX.cjs.map → chunk-H27EHTLX.cjs.map} +1 -1
  22. package/dist/{chunk-CPJ7QBIV.cjs → chunk-HA3SBMR2.cjs} +4 -4
  23. package/dist/{chunk-CPJ7QBIV.cjs.map → chunk-HA3SBMR2.cjs.map} +1 -1
  24. package/dist/{chunk-7IYDTCKE.js → chunk-HLMMG6Q7.js} +5 -5
  25. package/dist/{chunk-OAS4JLF4.cjs → chunk-HTNUTEXN.cjs} +22 -22
  26. package/dist/{chunk-OAS4JLF4.cjs.map → chunk-HTNUTEXN.cjs.map} +1 -1
  27. package/dist/{chunk-GNOCB662.js → chunk-ICGGFQQU.js} +4 -4
  28. package/dist/{chunk-X2ORWRBO.js → chunk-IPZCEAAH.js} +2 -2
  29. package/dist/{chunk-DZR47TRO.cjs → chunk-KNPUHPIF.cjs} +33 -33
  30. package/dist/{chunk-DZR47TRO.cjs.map → chunk-KNPUHPIF.cjs.map} +1 -1
  31. package/dist/{chunk-N4HJDYMG.js → chunk-L4NLXAET.js} +2 -2
  32. package/dist/{chunk-THWEZULI.cjs → chunk-MGSWL3L3.cjs} +26 -26
  33. package/dist/{chunk-THWEZULI.cjs.map → chunk-MGSWL3L3.cjs.map} +1 -1
  34. package/dist/{chunk-J7VFEMTJ.js → chunk-NQCID4VF.js} +6 -6
  35. package/dist/{chunk-FBQSQRUY.cjs → chunk-QU7SDFKO.cjs} +71 -71
  36. package/dist/{chunk-FBQSQRUY.cjs.map → chunk-QU7SDFKO.cjs.map} +1 -1
  37. package/dist/{chunk-R5B4T26B.cjs → chunk-RJYYGWJL.cjs} +22 -22
  38. package/dist/{chunk-R5B4T26B.cjs.map → chunk-RJYYGWJL.cjs.map} +1 -1
  39. package/dist/{chunk-JHSDZI3D.cjs → chunk-T7GIFC5Y.cjs} +31 -10
  40. package/dist/chunk-T7GIFC5Y.cjs.map +1 -0
  41. package/dist/{chunk-K7YEA2LO.cjs → chunk-TKL5JKZS.cjs} +10 -10
  42. package/dist/{chunk-K7YEA2LO.cjs.map → chunk-TKL5JKZS.cjs.map} +1 -1
  43. package/dist/{chunk-2752B3YY.cjs → chunk-UAPSU6YW.cjs} +3 -3
  44. package/dist/{chunk-2752B3YY.cjs.map → chunk-UAPSU6YW.cjs.map} +1 -1
  45. package/dist/{chunk-CUGM36ZU.js → chunk-UKZAW4FE.js} +3 -3
  46. package/dist/{chunk-MP2UTOLN.js → chunk-XSSYG7KU.js} +5 -5
  47. package/dist/{chunk-G4EXCWR4.cjs → chunk-ZB34GLOZ.cjs} +45 -45
  48. package/dist/{chunk-G4EXCWR4.cjs.map → chunk-ZB34GLOZ.cjs.map} +1 -1
  49. package/dist/{chunk-EWNKYH2D.js → chunk-ZRN244JV.js} +2 -2
  50. package/dist/components/case-studies/index.cjs +14 -14
  51. package/dist/components/case-studies/index.js +3 -3
  52. package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
  53. package/dist/components/chat/hooks/use-chat-attachments.d.ts.map +1 -1
  54. package/dist/components/chat/index.cjs +2 -2
  55. package/dist/components/chat/index.js +1 -1
  56. package/dist/components/contact/index.cjs +4 -4
  57. package/dist/components/contact/index.js +3 -3
  58. package/dist/components/docs/index.cjs +7 -7
  59. package/dist/components/docs/index.js +6 -6
  60. package/dist/components/embeds/index.cjs +4 -4
  61. package/dist/components/embeds/index.js +3 -3
  62. package/dist/components/faq/index.cjs +4 -4
  63. package/dist/components/faq/index.js +3 -3
  64. package/dist/components/features/index.cjs +3 -3
  65. package/dist/components/features/index.js +2 -2
  66. package/dist/components/help-center-pages/index.cjs +73 -73
  67. package/dist/components/help-center-pages/index.js +14 -14
  68. package/dist/components/index.cjs +39 -39
  69. package/dist/components/index.js +13 -13
  70. package/dist/components/meeting-scheduler/index.cjs +25 -25
  71. package/dist/components/meeting-scheduler/index.js +2 -2
  72. package/dist/components/navigation/index.cjs +6 -6
  73. package/dist/components/navigation/index.js +5 -5
  74. package/dist/components/onboarding-guides/index.cjs +6 -6
  75. package/dist/components/onboarding-guides/index.js +5 -5
  76. package/dist/components/tickets/index.cjs +7 -7
  77. package/dist/components/tickets/index.js +6 -6
  78. package/dist/components/ui/index.cjs +3 -3
  79. package/dist/components/ui/index.js +2 -2
  80. package/dist/index.cjs +7 -7
  81. package/dist/index.js +6 -6
  82. package/package.json +1 -1
  83. package/src/components/chat/__tests__/embeddable-chat-composer-scope.test.tsx +234 -0
  84. package/src/components/chat/embeddable-chat.tsx +63 -18
  85. package/src/components/chat/hooks/use-chat-attachments.ts +4 -1
  86. package/dist/chunk-JHSDZI3D.cjs.map +0 -1
  87. /package/dist/{chunk-EPH3FGIF.js.map → chunk-2YMFBKYR.js.map} +0 -0
  88. /package/dist/{chunk-MB5JXDJK.js.map → chunk-4ZKZSWX4.js.map} +0 -0
  89. /package/dist/{chunk-33HTSSCG.js.map → chunk-6LN7ZNHT.js.map} +0 -0
  90. /package/dist/{chunk-S3NK47ZJ.js.map → chunk-7WGUMITJ.js.map} +0 -0
  91. /package/dist/{chunk-UBAXZQKE.js.map → chunk-CDMZOXS7.js.map} +0 -0
  92. /package/dist/{chunk-STVG6WV4.js.map → chunk-EEL4RGHX.js.map} +0 -0
  93. /package/dist/{chunk-D5FLAQQH.js.map → chunk-FUBOKAYO.js.map} +0 -0
  94. /package/dist/{chunk-7IYDTCKE.js.map → chunk-HLMMG6Q7.js.map} +0 -0
  95. /package/dist/{chunk-GNOCB662.js.map → chunk-ICGGFQQU.js.map} +0 -0
  96. /package/dist/{chunk-X2ORWRBO.js.map → chunk-IPZCEAAH.js.map} +0 -0
  97. /package/dist/{chunk-N4HJDYMG.js.map → chunk-L4NLXAET.js.map} +0 -0
  98. /package/dist/{chunk-J7VFEMTJ.js.map → chunk-NQCID4VF.js.map} +0 -0
  99. /package/dist/{chunk-CUGM36ZU.js.map → chunk-UKZAW4FE.js.map} +0 -0
  100. /package/dist/{chunk-MP2UTOLN.js.map → chunk-XSSYG7KU.js.map} +0 -0
  101. /package/dist/{chunk-EWNKYH2D.js.map → chunk-ZRN244JV.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(