@jini-ai/chat 0.6.6 → 0.6.10

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 (160) hide show
  1. package/CHANGELOG.md +129 -1
  2. package/dist/core/accepted-input.d.ts +54 -0
  3. package/dist/core/accepted-input.js +165 -0
  4. package/dist/core/agent-plugin-chat-state.d.ts +43 -0
  5. package/dist/core/agent-plugin-chat-state.js +48 -0
  6. package/dist/core/assistant-client/a2ui-action-poster.js +32 -28
  7. package/dist/core/assistant-client/messages.d.ts +1 -1
  8. package/dist/core/assistant-client/messages.js +2 -2
  9. package/dist/core/assistant-client/transport.d.ts +2 -2
  10. package/dist/core/assistant-client/transport.js +187 -98
  11. package/dist/core/assistant-content.d.ts +2 -1
  12. package/dist/core/assistant-content.js +9 -11
  13. package/dist/core/browser/page-actions.js +4 -2
  14. package/dist/core/composer-history.d.ts +16 -9
  15. package/dist/core/composer-history.js +28 -16
  16. package/dist/core/conversation-title.d.ts +3 -0
  17. package/dist/core/conversation-title.js +3 -0
  18. package/dist/core/durable-projection.js +20 -13
  19. package/dist/core/durable-runs/continuation.d.ts +4 -0
  20. package/dist/core/durable-runs/continuation.js +31 -6
  21. package/dist/core/durable-runs/ledger-contracts.d.ts +2 -1
  22. package/dist/core/durable-runs/ports.d.ts +6 -2
  23. package/dist/core/index.d.ts +2 -0
  24. package/dist/core/index.js +2 -0
  25. package/dist/core/messages.d.ts +2 -0
  26. package/dist/core/partial-json.d.ts +6 -7
  27. package/dist/core/partial-json.js +81 -47
  28. package/dist/core/persistence/title.js +4 -2
  29. package/dist/core/question-answer.d.ts +23 -0
  30. package/dist/core/question-answer.js +66 -0
  31. package/dist/core/question-form/format.d.ts +6 -6
  32. package/dist/core/question-form/format.js +16 -17
  33. package/dist/core/question-form/normalize.d.ts +18 -0
  34. package/dist/core/question-form/normalize.js +112 -122
  35. package/dist/core/question-form/scan.js +141 -203
  36. package/dist/core/run-activity.js +62 -49
  37. package/dist/core/run-events/entry.d.ts +10 -1
  38. package/dist/core/run-events/entry.js +117 -87
  39. package/dist/core/todos.d.ts +1 -1
  40. package/dist/core/todos.js +60 -85
  41. package/dist/core/tool-events.js +14 -12
  42. package/dist/core/transcript.js +62 -16
  43. package/dist/core/turn-usage.js +54 -32
  44. package/dist/core/user-text-redaction.js +3 -0
  45. package/dist/core/util/manifest.js +77 -82
  46. package/dist/core/util/markdown-context.js +51 -53
  47. package/dist/core/util/parser.js +136 -144
  48. package/dist/core/util/pointer.js +16 -16
  49. package/dist/core/util/strip.js +57 -67
  50. package/dist/core/util/validate.js +15 -17
  51. package/dist/embed-widget/browser/session-store.js +21 -12
  52. package/dist/embed-widget/transports/fetch-sse.js +108 -95
  53. package/dist/embed-widget/vanilla/index.js +81 -70
  54. package/dist/react/agent-bridge/frontend-session-bridge.js +80 -52
  55. package/dist/react/components/AttachmentPreviewModal.js +25 -1
  56. package/dist/react/components/ChatSettingsDialog.d.ts +13 -0
  57. package/dist/react/components/ChatSettingsDialog.hooks.d.ts +115 -0
  58. package/dist/react/components/ChatSettingsDialog.hooks.js +40 -0
  59. package/dist/react/components/ChatSettingsDialog.js +72 -0
  60. package/dist/react/components/ChatSettingsSegmented.d.ts +18 -0
  61. package/dist/react/components/ChatSettingsSegmented.js +11 -0
  62. package/dist/react/components/Composer.d.ts +5 -1
  63. package/dist/react/components/Composer.js +126 -82
  64. package/dist/react/components/ConversationList.js +25 -39
  65. package/dist/react/components/ConversationTitleEditor.d.ts +27 -0
  66. package/dist/react/components/ConversationTitleEditor.js +54 -0
  67. package/dist/react/components/Icon.d.ts +1 -1
  68. package/dist/react/components/Icon.js +25 -45
  69. package/dist/react/components/Markdown.js +134 -123
  70. package/dist/react/components/McpUiSurfaceCard.d.ts +2 -2
  71. package/dist/react/components/McpUiSurfaceCard.js +30 -9
  72. package/dist/react/components/MessageList.d.ts +13 -13
  73. package/dist/react/components/MessageList.hooks.d.ts +6 -1
  74. package/dist/react/components/MessageList.hooks.js +19 -3
  75. package/dist/react/components/MessageList.js +34 -10
  76. package/dist/react/components/MessageRow.d.ts +1 -1
  77. package/dist/react/components/MessageRow.js +75 -47
  78. package/dist/react/components/MessageRow.usage.js +16 -11
  79. package/dist/react/components/MidRunUserMessage.js +16 -3
  80. package/dist/react/components/QuestionForm.js +148 -76
  81. package/dist/react/components/QuestionsPanel.js +1 -1
  82. package/dist/react/components/TodoCard.js +12 -6
  83. package/dist/react/components/ToolCallGroup.d.ts +4 -3
  84. package/dist/react/components/ToolCallGroup.js +7 -5
  85. package/dist/react/components/ToolCard.js +47 -40
  86. package/dist/react/components/composer-discovery.js +6 -1
  87. package/dist/react/components/mcp-ui-surface-state.d.ts +6 -11
  88. package/dist/react/components/mcp-ui-surface-state.js +23 -8
  89. package/dist/react/ext-event-renderer-registry.d.ts +8 -0
  90. package/dist/react/ext-event-renderer-registry.js +5 -2
  91. package/dist/react/features/chat-pane/components/AgentPluginUsage.d.ts +7 -0
  92. package/dist/react/features/chat-pane/components/AgentPluginUsage.js +45 -0
  93. package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +55 -19
  94. package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
  95. package/dist/react/features/chat-pane/components/ChatPane.js +192 -74
  96. package/dist/react/features/chat-pane/components/RuntimeDropUp.d.ts +29 -0
  97. package/dist/react/features/chat-pane/components/RuntimeDropUp.js +45 -0
  98. package/dist/react/features/chat-pane/create-daemon-attachment-uploader.js +80 -47
  99. package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.js +44 -40
  100. package/dist/react/features/chat-pane/create-mid-run-message-poster.d.ts +6 -6
  101. package/dist/react/features/chat-pane/create-mid-run-message-poster.js +8 -8
  102. package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +3 -3
  103. package/dist/react/features/chat-pane/create-typed-answer-poster.js +3 -3
  104. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.d.ts +6 -1
  105. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js +11 -5
  106. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +12 -7
  107. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +340 -149
  108. package/dist/react/features/chat-pane/hooks/useChatPaneControlsHeight.hooks.js +7 -6
  109. package/dist/react/features/chat-pane/hooks/useChatPaneNewTabLinks.hooks.js +28 -10
  110. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.d.ts +3 -3
  111. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +60 -12
  112. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.d.ts +49 -0
  113. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +143 -0
  114. package/dist/react/features/chat-pane/model-options.d.ts +14 -0
  115. package/dist/react/features/chat-pane/model-options.js +45 -25
  116. package/dist/react/features/chat-pane/rules.d.ts +8 -9
  117. package/dist/react/features/chat-pane/rules.js +84 -46
  118. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  119. package/dist/react/features/chat-pane/styles.js +152 -36
  120. package/dist/react/features/chat-pane/types.d.ts +19 -9
  121. package/dist/react/hooks/assistant-chats.js +12 -5
  122. package/dist/react/hooks/composer-draft-cache.js +116 -118
  123. package/dist/react/hooks/composer-history-storage.js +5 -5
  124. package/dist/react/hooks/fab-position.js +7 -4
  125. package/dist/react/hooks/useAnsweredCards.d.ts +21 -4
  126. package/dist/react/hooks/useAnsweredCards.js +38 -11
  127. package/dist/react/hooks/useArtifactStream.js +28 -22
  128. package/dist/react/hooks/useComposer.d.ts +20 -0
  129. package/dist/react/hooks/useComposer.js +22 -9
  130. package/dist/react/hooks/useComposerHistory.hooks.d.ts +6 -1
  131. package/dist/react/hooks/useComposerHistory.hooks.js +39 -19
  132. package/dist/react/hooks/useComposerHistoryKeys.hooks.js +24 -14
  133. package/dist/react/hooks/useConversation.d.ts +7 -0
  134. package/dist/react/hooks/useConversation.js +23 -10
  135. package/dist/react/hooks/useExtEventGroups.js +39 -39
  136. package/dist/react/hooks/useQuestionForms.js +21 -26
  137. package/dist/react/hooks/useRunStream.js +10 -2
  138. package/dist/react/index.d.ts +7 -1
  139. package/dist/react/index.js +9 -0
  140. package/dist/react/message-blocks.d.ts +6 -4
  141. package/dist/react/message-blocks.js +94 -72
  142. package/dist/react/slots.d.ts +2 -2
  143. package/dist/react/tool-call-display.d.ts +12 -6
  144. package/dist/react/tool-call-display.js +53 -24
  145. package/dist/server/durable-runs/recover.js +132 -80
  146. package/dist/server/run-finalizer/entry.js +25 -15
  147. package/dist/store/legacy/sqlite/conversations.d.ts +11 -11
  148. package/dist/store/legacy/sqlite/conversations.js +69 -98
  149. package/dist/store/legacy/sqlite/messages.d.ts +10 -2
  150. package/dist/store/legacy/sqlite/messages.js +113 -71
  151. package/dist/store/runtime/chat-history-store.js +9 -3
  152. package/dist/store/runtime/durable-run-store.d.ts +2 -2
  153. package/dist/store/runtime/durable-run-store.js +165 -117
  154. package/dist/store/runtime/history-tools.js +118 -82
  155. package/dist/store/runtime/run-ledger.d.ts +4 -1
  156. package/dist/store/runtime/run-ledger.js +56 -16
  157. package/dist/store/sql/cursor.js +38 -18
  158. package/dist/store/sql/store.d.ts +18 -0
  159. package/dist/store/sql/store.js +46 -15
  160. package/package.json +18 -16
@@ -66,6 +66,18 @@ const stagedAttachments = new Map();
66
66
  // attachments-only signature: `useComposer` persists the list and never sees a batch id, while
67
67
  // `useChatPane` (the one caller that uploads in batches) records the batch here.
68
68
  const stagedBatchIds = new Map();
69
+ /**
70
+ * Sets `key` in one of the in-memory tiers, evicting the oldest-inserted entry first if this would
71
+ * introduce a new key past {@link MAX_CACHED_CONVERSATION_DRAFTS}.
72
+ * @complexity Time/space: O(1) amortized.
73
+ */
74
+ function setBounded(map, { key, value }) {
75
+ if (!map.has(key) && map.size >= MAX_CACHED_CONVERSATION_DRAFTS) {
76
+ // `size >= MAX_CACHED_CONVERSATION_DRAFTS > 0`, so there is always a first key to evict.
77
+ map.delete(map.keys().next().value);
78
+ }
79
+ map.set(key, value);
80
+ }
69
81
  /**
70
82
  * The origin's `localStorage`, or `null` when it cannot be used.
71
83
  *
@@ -83,32 +95,29 @@ function storage() {
83
95
  }
84
96
  }
85
97
  /**
86
- * Reads and validates the stored draft for `conversationId`.
98
+ * Reads the entry at `prefix + conversationId` and hands its parsed JSON to `decode`.
87
99
  *
88
- * Anything that does not parse as this module's own envelope is treated as foreign or corrupt and
89
- * deleted rather than returned — a stale format, another tool writing under a colliding key, or a
90
- * truncated write must not surface as composer text.
100
+ * Anything `decode` rejects (returns `null` for) is treated as foreign or corrupt and deleted rather
101
+ * than returned — a stale format, another tool writing under a colliding key, or a truncated write
102
+ * must not surface as composer state.
91
103
  *
92
- * @returns The stored draft, or `null` when there is none, storage is unavailable, or the entry
104
+ * @returns The decoded value, or `null` when there is no entry, storage is unavailable, or the entry
93
105
  * failed validation.
94
- * @complexity Time/space: O(n) in the stored draft's length (the JSON parse).
106
+ * @complexity Time/space: O(n) in the stored entry's length (the JSON parse) plus `decode`.
95
107
  */
96
- function readStoredDraft(conversationId) {
108
+ function readStoredEnvelope(prefix, { conversationId, decode }) {
97
109
  const store = storage();
98
110
  if (!store)
99
111
  return null;
100
- const key = `${COMPOSER_DRAFT_STORAGE_PREFIX}${conversationId}`;
112
+ const key = `${prefix}${conversationId}`;
101
113
  try {
102
114
  const raw = store.getItem(key);
103
115
  if (raw === null)
104
116
  return null;
105
- const parsed = JSON.parse(raw);
106
- const draft = parsed?.d;
107
- if (typeof draft !== 'string' || draft === '') {
117
+ const decoded = decode(JSON.parse(raw));
118
+ if (decoded === null)
108
119
  store.removeItem(key);
109
- return null;
110
- }
111
- return draft;
120
+ return decoded;
112
121
  }
113
122
  catch {
114
123
  // A parse failure leaves the bad entry behind on purpose when `removeItem` is what threw —
@@ -122,6 +131,22 @@ function readStoredDraft(conversationId) {
122
131
  return null;
123
132
  }
124
133
  }
134
+ /**
135
+ * Validates a parsed draft envelope.
136
+ * @returns Its draft, or `null` when it is not this module's envelope or holds a blank draft.
137
+ * @complexity Time/space: O(1).
138
+ */
139
+ function decodeStoredDraft(parsed) {
140
+ const draft = parsed?.d;
141
+ return typeof draft === 'string' && draft !== '' ? draft : null;
142
+ }
143
+ /**
144
+ * Reads and validates the stored draft for `conversationId`; see {@link readStoredEnvelope}.
145
+ * @complexity Time/space: O(n) in the stored draft's length (the JSON parse).
146
+ */
147
+ function readStoredDraft(conversationId) {
148
+ return readStoredEnvelope(COMPOSER_DRAFT_STORAGE_PREFIX, { conversationId, decode: decodeStoredDraft });
149
+ }
125
150
  /**
126
151
  * Deletes every stored draft past the cap, oldest write first, so an operator who abandons drafts
127
152
  * in many conversations cannot fill the origin's quota and silently break persistence for the
@@ -138,21 +163,7 @@ function pruneStored(prefix, keep) {
138
163
  if (!store)
139
164
  return;
140
165
  try {
141
- const entries = [];
142
- for (let i = 0; i < store.length; i += 1) {
143
- const key = store.key(i);
144
- if (key === null || !key.startsWith(prefix))
145
- continue;
146
- let at = 0;
147
- try {
148
- const parsed = JSON.parse(store.getItem(key) ?? '');
149
- at = typeof parsed?.t === 'number' ? parsed.t : 0;
150
- }
151
- catch {
152
- at = 0;
153
- }
154
- entries.push({ key, at });
155
- }
166
+ const entries = storedEntries(store, { prefix });
156
167
  if (entries.length <= keep)
157
168
  return;
158
169
  entries.sort((a, b) => a.at - b.at);
@@ -164,44 +175,78 @@ function pruneStored(prefix, keep) {
164
175
  }
165
176
  }
166
177
  /**
167
- * Writes or removes the stored copy for `conversationId`.
178
+ * Every stored key under `prefix`, with its envelope's write timestamp (`0` when the envelope does
179
+ * not parse or carries none).
180
+ * @complexity Time/space: O(n) in the number of stored keys.
181
+ */
182
+ function storedEntries(store, { prefix }) {
183
+ const entries = [];
184
+ for (let i = 0; i < store.length; i += 1) {
185
+ const key = store.key(i);
186
+ if (key?.startsWith(prefix))
187
+ entries.push({ key, at: storedTimestamp(store, { key }) });
188
+ }
189
+ return entries;
190
+ }
191
+ /**
192
+ * The write timestamp of the envelope at `key`, or `0` when it cannot be read or parsed.
193
+ * @complexity Time/space: O(n) in the stored entry's length.
194
+ */
195
+ function storedTimestamp(store, { key }) {
196
+ try {
197
+ // `String(null)` is `'null'`, which parses to `null` and so reads as timestamp `0` — the same
198
+ // answer a missing entry got before, without a branch for a key `store.key` just returned.
199
+ const parsed = JSON.parse(String(store.getItem(key)));
200
+ return typeof parsed?.t === 'number' ? parsed.t : 0;
201
+ }
202
+ catch {
203
+ return 0;
204
+ }
205
+ }
206
+ /**
207
+ * Writes or removes the stored entry at `prefix + conversationId`.
168
208
  *
169
- * @param draft A blank draft removes the entry. A quota rejection is swallowed: the in-memory tier
170
- * has already accepted the draft, so the composer keeps working and only reload-survival is lost.
171
- * @complexity Time O(n) in the draft's length, plus a prune (see {@link pruneStored}) only
172
- * when this introduces a new key at the cap. Space O(n) in the draft's length.
209
+ * @param envelope `null` removes the entry. A quota rejection is swallowed: the in-memory tier
210
+ * has already accepted the value, so the composer keeps working and only reload-survival is lost.
211
+ * @complexity Time O(n) in the envelope's size, plus a prune (see {@link pruneStored}) only
212
+ * when this introduces a new key at the cap. Space O(n) in the envelope's size.
173
213
  */
174
- function writeStoredDraft(conversationId, draft) {
214
+ function writeStoredEnvelope(prefix, { conversationId, envelope }) {
175
215
  const store = storage();
176
216
  if (!store)
177
217
  return;
178
- const key = `${COMPOSER_DRAFT_STORAGE_PREFIX}${conversationId}`;
218
+ const key = `${prefix}${conversationId}`;
179
219
  try {
180
- if (draft === '') {
220
+ if (envelope === null) {
181
221
  store.removeItem(key);
182
222
  return;
183
223
  }
184
224
  if (store.getItem(key) === null)
185
- pruneStored(COMPOSER_DRAFT_STORAGE_PREFIX, MAX_CACHED_CONVERSATION_DRAFTS - 1);
186
- const envelope = { v: 1, t: Date.now(), d: draft };
225
+ pruneStored(prefix, MAX_CACHED_CONVERSATION_DRAFTS - 1);
187
226
  store.setItem(key, JSON.stringify(envelope));
188
227
  }
189
228
  catch {
190
229
  /* blocked, full, or unusable storage — memory-only is the documented degraded mode */
230
+ /* (on a removal: blocked storage; the in-memory tier is already correct) */
191
231
  }
192
232
  }
233
+ /**
234
+ * Writes or removes the stored copy for `conversationId`.
235
+ *
236
+ * @param draft A blank draft removes the entry; see {@link writeStoredEnvelope}.
237
+ * @complexity Time O(n) in the draft's length; see {@link writeStoredEnvelope}.
238
+ */
239
+ function writeStoredDraft(conversationId, draft) {
240
+ const envelope = draft === '' ? null : { v: 1, t: Date.now(), d: draft };
241
+ writeStoredEnvelope(COMPOSER_DRAFT_STORAGE_PREFIX, { conversationId, envelope });
242
+ }
193
243
  /**
194
244
  * Records `draft` in the in-memory tier, evicting the oldest-inserted conversation first if this
195
245
  * would introduce a new entry past {@link MAX_CACHED_CONVERSATION_DRAFTS}.
196
246
  * @complexity Time/space: O(1) amortized.
197
247
  */
198
248
  function rememberInMemory(conversationId, draft) {
199
- if (!drafts.has(conversationId) && drafts.size >= MAX_CACHED_CONVERSATION_DRAFTS) {
200
- const oldest = drafts.keys().next().value;
201
- if (oldest !== undefined)
202
- drafts.delete(oldest);
203
- }
204
- drafts.set(conversationId, draft);
249
+ setBounded(drafts, { key: conversationId, value: draft });
205
250
  }
206
251
  /**
207
252
  * Reads the cached draft for `conversationId`, in memory first and storage second.
@@ -239,13 +284,8 @@ export function readCachedDraft({ conversationId }) {
239
284
  export function writeCachedDraft({ conversationId, draft }) {
240
285
  if (!conversationId)
241
286
  return;
242
- if (draft.trim() === '') {
243
- drafts.delete(conversationId);
244
- writeStoredDraft(conversationId, '');
245
- return;
246
- }
247
- rememberInMemory(conversationId, draft);
248
- writeStoredDraft(conversationId, draft);
287
+ rememberCachedDraft({ conversationId: conversationId, draft: draft });
288
+ writeStoredDraft(conversationId, draft.trim() === '' ? '' : draft);
249
289
  }
250
290
  /**
251
291
  * Stores `draft` for `conversationId` in the in-memory tier only, leaving `localStorage` alone.
@@ -299,11 +339,11 @@ function isChatAttachment(candidate) {
299
339
  return false;
300
340
  if (kind !== 'image' && kind !== 'file')
301
341
  return false;
302
- if (size !== undefined && typeof size !== 'number')
303
- return false;
304
- if (order !== undefined && typeof order !== 'number')
305
- return false;
306
- return true;
342
+ return isOptionalNumber(size) && isOptionalNumber(order);
343
+ }
344
+ /** @complexity Time/space: O(1). */
345
+ function isOptionalNumber(value) {
346
+ return value === undefined || typeof value === 'number';
307
347
  }
308
348
  /**
309
349
  * Reads the stored attachment references (and their upload batch) for `conversationId`.
@@ -317,37 +357,21 @@ function isChatAttachment(candidate) {
317
357
  * @complexity Time/space: O(n) in the number of stored references.
318
358
  */
319
359
  function readStoredAttachments(conversationId) {
320
- const store = storage();
321
- if (!store)
360
+ return readStoredEnvelope(COMPOSER_ATTACHMENTS_STORAGE_PREFIX, { conversationId, decode: decodeStoredAttachments });
361
+ }
362
+ /**
363
+ * Validates a parsed attachments envelope; see {@link readStoredAttachments} for the rules.
364
+ * @complexity Time/space: O(n) in the number of stored references.
365
+ */
366
+ function decodeStoredAttachments(parsed) {
367
+ const list = parsed?.a;
368
+ const batchId = parsed?.b;
369
+ if (!Array.isArray(list))
322
370
  return null;
323
- const key = `${COMPOSER_ATTACHMENTS_STORAGE_PREFIX}${conversationId}`;
324
- try {
325
- const raw = store.getItem(key);
326
- if (raw === null)
327
- return null;
328
- const parsed = JSON.parse(raw);
329
- const list = parsed?.a;
330
- const batchId = parsed?.b;
331
- if (!Array.isArray(list)) {
332
- store.removeItem(key);
333
- return null;
334
- }
335
- const valid = list.filter(isChatAttachment);
336
- if (valid.length === 0) {
337
- store.removeItem(key);
338
- return null;
339
- }
340
- return { attachments: valid, batchId: typeof batchId === 'string' && batchId !== '' ? batchId : null };
341
- }
342
- catch {
343
- try {
344
- store.removeItem(key);
345
- }
346
- catch {
347
- /* storage is unusable; the in-memory tier still serves this conversation */
348
- }
371
+ const valid = list.filter(isChatAttachment);
372
+ if (valid.length === 0)
349
373
  return null;
350
- }
374
+ return { attachments: valid, batchId: typeof batchId === 'string' && batchId !== '' ? batchId : null };
351
375
  }
352
376
  /**
353
377
  * Reads the cached attachment references for `conversationId`, in memory first, storage second.
@@ -407,12 +431,7 @@ export function readCachedAttachmentBatchId({ conversationId }, _options = {}) {
407
431
  export function writeCachedAttachmentBatchId({ conversationId, batchId }, _options = {}) {
408
432
  if (!conversationId)
409
433
  return;
410
- if (!stagedBatchIds.has(conversationId) && stagedBatchIds.size >= MAX_CACHED_CONVERSATION_DRAFTS) {
411
- const oldest = stagedBatchIds.keys().next().value;
412
- if (oldest !== undefined)
413
- stagedBatchIds.delete(oldest);
414
- }
415
- stagedBatchIds.set(conversationId, batchId);
434
+ setBounded(stagedBatchIds, { key: conversationId, value: batchId });
416
435
  }
417
436
  /**
418
437
  * Stores `attachments` for `conversationId` in both tiers.
@@ -424,38 +443,17 @@ export function writeCachedAttachmentBatchId({ conversationId, batchId }, _optio
424
443
  export function writeCachedAttachments({ conversationId, attachments }) {
425
444
  if (!conversationId)
426
445
  return;
427
- const key = `${COMPOSER_ATTACHMENTS_STORAGE_PREFIX}${conversationId}`;
428
- const store = storage();
429
446
  if (attachments.length === 0) {
430
447
  stagedAttachments.delete(conversationId);
431
448
  // A sent or cleared turn's batch is spent; the next upload starts a fresh one.
432
449
  stagedBatchIds.delete(conversationId);
433
- try {
434
- store?.removeItem(key);
435
- }
436
- catch {
437
- /* blocked storage; the in-memory tier is already correct */
438
- }
439
- return;
440
- }
441
- if (!stagedAttachments.has(conversationId) && stagedAttachments.size >= MAX_CACHED_CONVERSATION_DRAFTS) {
442
- const oldest = stagedAttachments.keys().next().value;
443
- if (oldest !== undefined)
444
- stagedAttachments.delete(oldest);
445
- }
446
- stagedAttachments.set(conversationId, attachments);
447
- if (!store)
450
+ writeStoredEnvelope(COMPOSER_ATTACHMENTS_STORAGE_PREFIX, { conversationId, envelope: null });
448
451
  return;
449
- try {
450
- if (store.getItem(key) === null)
451
- pruneStored(COMPOSER_ATTACHMENTS_STORAGE_PREFIX, MAX_CACHED_CONVERSATION_DRAFTS - 1);
452
- const batchId = stagedBatchIds.get(conversationId);
453
- const envelope = { v: 1, t: Date.now(), a: attachments, ...(batchId === undefined ? {} : { b: batchId }) };
454
- store.setItem(key, JSON.stringify(envelope));
455
- }
456
- catch {
457
- /* blocked, full, or unusable storage — memory-only is the documented degraded mode */
458
452
  }
453
+ setBounded(stagedAttachments, { key: conversationId, value: attachments });
454
+ const batchId = stagedBatchIds.get(conversationId);
455
+ const envelope = { v: 1, t: Date.now(), a: attachments, ...(batchId === undefined ? {} : { b: batchId }) };
456
+ writeStoredEnvelope(COMPOSER_ATTACHMENTS_STORAGE_PREFIX, { conversationId, envelope });
459
457
  }
460
458
  /**
461
459
  * Test-only: empties the cache so test cases cannot leak drafts into each other.
@@ -7,13 +7,13 @@ export function createBrowserComposerHistoryStorage(_required, { storage, prefix
7
7
  return storage ?? globalThis.localStorage;
8
8
  }
9
9
  return {
10
- async read({ scope }, _optional = {}) {
10
+ async read({ scope }, { cap = 100 } = {}) {
11
11
  try {
12
12
  const raw = resolveStorage()?.getItem(prefix + encodeURIComponent(scope));
13
13
  if (raw) {
14
14
  const parsed = JSON.parse(raw);
15
15
  if (Array.isArray(parsed)) {
16
- const entries = normalizeComposerHistory({ entries: parsed.filter((value) => typeof value === 'string') });
16
+ const entries = normalizeComposerHistory({ entries: parsed.filter((value) => typeof value === 'string') }, { cap });
17
17
  memory.set(scope, entries);
18
18
  return entries;
19
19
  }
@@ -22,10 +22,10 @@ export function createBrowserComposerHistoryStorage(_required, { storage, prefix
22
22
  catch {
23
23
  // Storage denial/corruption must not disable draft editing or in-memory recall.
24
24
  }
25
- return memory.get(scope) ?? [];
25
+ return normalizeComposerHistory({ entries: memory.get(scope) ?? [] }, { cap });
26
26
  },
27
- async write({ scope, entries }, _optional = {}) {
28
- const normalized = normalizeComposerHistory({ entries });
27
+ async write({ scope, entries }, { cap = 100 } = {}) {
28
+ const normalized = normalizeComposerHistory({ entries }, { cap });
29
29
  memory.set(scope, normalized);
30
30
  try {
31
31
  resolveStorage()?.setItem(prefix + encodeURIComponent(scope), JSON.stringify(normalized));
@@ -116,6 +116,13 @@ export function useFabPosition(options, _optional = {}) {
116
116
  const draggingRef = useRef(false);
117
117
  const didDragRef = useRef(false);
118
118
  const liveRef = useRef(null);
119
+ /**
120
+ * Set by `onPointerDown` immediately BEFORE it attaches the document `pointermove`/`pointerup`/
121
+ * `pointercancel` listeners, and cleared by `handlePointerUp` in the same synchronous step that
122
+ * detaches them — so whenever either handler below can run, a drag start is recorded. That
123
+ * invariant is why the handlers read it with a cast rather than an `if (!start) return` guard: the
124
+ * guard that used to sit there could never be taken.
125
+ */
119
126
  const dragStart = useRef(null);
120
127
  /** Bumped to force a re-render on every pointermove and on resize — `liveRef`/`window.innerWidth`
121
128
  * are read directly in `style`'s `useMemo` below, neither of which React tracks as a reactive
@@ -141,8 +148,6 @@ export function useFabPosition(options, _optional = {}) {
141
148
  // never depend on state that would force a re-subscribe mid-gesture.
142
149
  const handlePointerMove = useCallback((e) => {
143
150
  const start = dragStart.current;
144
- if (!start)
145
- return;
146
151
  const dx = e.clientX - start.startX;
147
152
  const dy = e.clientY - start.startY;
148
153
  if (!draggingRef.current && Math.hypot(dx, dy) > DRAG_THRESHOLD_PX) {
@@ -159,8 +164,6 @@ export function useFabPosition(options, _optional = {}) {
159
164
  }, []);
160
165
  const handlePointerUp = useCallback((e) => {
161
166
  const start = dragStart.current;
162
- if (!start)
163
- return;
164
167
  e.target.releasePointerCapture?.(start.pointerId);
165
168
  document.removeEventListener("pointermove", handlePointerMove);
166
169
  document.removeEventListener("pointerup", handlePointerUp);
@@ -1,20 +1,37 @@
1
- import type { IsAnsweredCard } from '../../core/index.js';
1
+ import { type ChatRunStatus } from '../../core/messages.js';
2
+ import type { ExtEventGroup } from './useExtEventGroups.js';
3
+ import type { AgentEvent, IsAnsweredCard } from '../../core/index.js';
2
4
  export interface AnsweredCards {
5
+ /** Live rendering state; a terminal run overrides a stale streaming prop. */
6
+ runStreaming: boolean;
3
7
  /** Whether the slot's card was answered. */
4
8
  isAnswered(slot: string): boolean;
5
9
  /** Records the slot's card as answered. Idempotent. */
6
- markAnswered(slot: string): void;
10
+ markAnswered(slot: string, answerText?: string): void;
11
+ /** The receipt text takes precedence over the transient delivery text. */
12
+ answerText(slot: string): string | undefined;
13
+ /** Whether a user_message event already renders this answer. */
14
+ isRecorded(slot: string): boolean;
7
15
  /** The same answer as `isAnswered`, keyed by an event — for `deriveRunActivity`. Stable while nothing new is answered. */
8
16
  isAnsweredCard: IsAnsweredCard;
9
17
  }
10
18
  /**
11
19
  * @param slotOf - Maps an ext event to its render slot (`extEventSlot`).
12
- * @complexity O(1) per lookup; O(answered cards) space.
20
+ * @param events - Accepted-input-projected events, including persisted or optimistic answer receipts.
21
+ * @param runStreaming - Host streaming state, constrained by the message's runStatus.
22
+ * @param runStatus - Persisted run lifecycle, including terminal status on reload.
23
+ * @param groups - Groups carrying their original tool id; equal answer text is never identity.
24
+ * @returns Slot lookups and an idempotent delivery recorder; only that recorder changes React state.
25
+ * @complexity O(events + groups) projection, O(1) lookups; O(events + answered cards) space.
13
26
  */
14
- export declare function useAnsweredCards({ slotOf }: {
27
+ export declare function useAnsweredCards({ slotOf, events, groups, runStreaming, runStatus }: {
28
+ runStreaming: boolean;
29
+ runStatus: ChatRunStatus | undefined;
15
30
  slotOf: (card: {
16
31
  name: string;
17
32
  data: unknown;
18
33
  }) => string;
34
+ events?: readonly AgentEvent[];
35
+ groups?: readonly ExtEventGroup[];
19
36
  }): AnsweredCards;
20
37
  //# sourceMappingURL=useAnsweredCards.d.ts.map
@@ -3,21 +3,48 @@
3
3
  *
4
4
  * Which of a message's cards the person has already answered, by ext-event slot. A held-open
5
5
  * question's tool call stays open after the click while the confirmed work runs, so the event
6
- * stream alone still reads "waiting for an answer"; the card reports its own delivered answer here
7
- * and the activity line and countdown clear at once (owner, 2026-10-11).
6
+ * stream alone can still read "waiting for an answer"; persisted receipts or the card delivery report
7
+ * clear the activity line and countdown. The receipt owns the bubble once it arrives.
8
8
  */
9
- import { useCallback, useState } from 'react';
9
+ import { useCallback, useMemo, useState } from 'react';
10
+ import { isTerminalRunStatus } from '../../core/messages.js';
11
+ import { acceptedInputEvent } from '../../core/accepted-input.js';
10
12
  /**
11
13
  * @param slotOf - Maps an ext event to its render slot (`extEventSlot`).
12
- * @complexity O(1) per lookup; O(answered cards) space.
14
+ * @param events - Accepted-input-projected events, including persisted or optimistic answer receipts.
15
+ * @param runStreaming - Host streaming state, constrained by the message's runStatus.
16
+ * @param runStatus - Persisted run lifecycle, including terminal status on reload.
17
+ * @param groups - Groups carrying their original tool id; equal answer text is never identity.
18
+ * @returns Slot lookups and an idempotent delivery recorder; only that recorder changes React state.
19
+ * @complexity O(events + groups) projection, O(1) lookups; O(events + answered cards) space.
13
20
  */
14
- export function useAnsweredCards({ slotOf }) {
15
- const [answered, setAnswered] = useState(() => new Set());
16
- const markAnswered = useCallback((slot) => {
17
- setAnswered((current) => (current.has(slot) ? current : new Set(current).add(slot)));
21
+ export function useAnsweredCards({ slotOf, events = [], groups = [], runStreaming, runStatus }) {
22
+ const [answered, setAnswered] = useState(() => new Map());
23
+ // MOVE: Match by tool identity so receipt replay hides only its own card, even for equal answers.
24
+ const recorded = useMemo(() => {
25
+ const receipts = new Map();
26
+ for (const event of events) {
27
+ const receipt = acceptedInputEvent({ event });
28
+ if (receipt?.data.toolUseId)
29
+ receipts.set(receipt.data.toolUseId, receipt.data.text);
30
+ }
31
+ const slots = new Map();
32
+ for (const group of groups) {
33
+ const text = group.call?.id === undefined ? undefined : receipts.get(group.call.id);
34
+ if (text !== undefined)
35
+ slots.set(group.slot, text);
36
+ }
37
+ return slots;
38
+ }, [events, groups]);
39
+ const markAnswered = useCallback((slot, answerText) => {
40
+ setAnswered((current) => (current.has(slot) ? current : new Map(current).set(slot, answerText)));
18
41
  }, []);
19
- const isAnswered = useCallback((slot) => answered.has(slot), [answered]);
20
- const isAnsweredCard = useCallback((card) => answered.has(slotOf(card)), [answered, slotOf]);
21
- return { isAnswered, markAnswered, isAnsweredCard };
42
+ const isAnswered = useCallback((slot) => recorded.has(slot) || answered.has(slot), [answered, recorded]);
43
+ const answerText = useCallback((slot) => recorded.get(slot) ?? answered.get(slot), [answered, recorded]);
44
+ const isRecorded = useCallback((slot) => recorded.has(slot), [recorded]);
45
+ const isAnsweredCard = useCallback((card) => isAnswered(slotOf(card)), [isAnswered, slotOf]);
46
+ // MOVE: Finished runs must keep orphaned questions closed even when the host still reports streaming.
47
+ return { isAnswered, markAnswered, isAnsweredCard, answerText, isRecorded,
48
+ runStreaming: runStreaming && runStatus !== undefined && !isTerminalRunStatus({ status: runStatus }) };
22
49
  }
23
50
  //# sourceMappingURL=useAnsweredCards.js.map
@@ -14,12 +14,33 @@
14
14
  */
15
15
  import { useMemo } from 'react';
16
16
  import { parseArtifacts, splitStreamingArtifact } from '../../core/index.js';
17
- function toFile(item) {
18
- return { name: item.identifier || item.title || 'artifact', kind: item.artifactType, content: item.content };
17
+ function toItem(base, { registry }) {
18
+ const file = { name: base.identifier || base.title || 'artifact', kind: base.artifactType, content: base.content };
19
+ return { ...base, file, match: registry?.resolve({ file }) ?? null };
20
+ }
21
+ /**
22
+ * Completed artifacts in `head`, oldest first. Only `artifact:start` (for the
23
+ * metadata) and `artifact:end` (whose `fullContent` is the whole body) are
24
+ * needed — `artifact:chunk` deltas are already folded into `fullContent`.
25
+ * `createArtifactParser` never nests and always emits an `artifact:end` with
26
+ * the same identifier right after its `artifact:start`'s chunks, so `meta` is
27
+ * always set when an end arrives.
28
+ */
29
+ function collectCompleted(head, { registry }) {
30
+ const items = [];
31
+ let meta = null;
32
+ for (const ev of parseArtifacts({ content: head })) {
33
+ if (ev.type === 'artifact:start') {
34
+ meta = { identifier: ev.identifier, artifactType: ev.artifactType, title: ev.title };
35
+ }
36
+ else if (ev.type === 'artifact:end') {
37
+ items.push(toItem({ ...meta, content: ev.fullContent, status: 'complete' }, { registry }));
38
+ }
39
+ }
40
+ return items;
19
41
  }
20
42
  export function useArtifactStream({ content }, { registry } = {}) {
21
43
  return useMemo(() => {
22
- const items = [];
23
44
  // `splitStreamingArtifact` is the authority on "is there a trailing,
24
45
  // still-open artifact": split first so `parseArtifacts` (a one-shot
25
46
  // feed+flush that synthesizes an `artifact:end` for whatever is still
@@ -29,27 +50,12 @@ export function useArtifactStream({ content }, { registry } = {}) {
29
50
  // once (wrongly) as "complete" via the synthetic flush, and again (correctly)
30
51
  // as "streaming" below.
31
52
  const { head, live } = splitStreamingArtifact({ content: content });
32
- let current = null;
33
- for (const ev of parseArtifacts({ content: head })) {
34
- if (ev.type === 'artifact:start') {
35
- current = { identifier: ev.identifier, artifactType: ev.artifactType, title: ev.title, content: '' };
36
- }
37
- else if (ev.type === 'artifact:chunk' && current && current.identifier === ev.identifier) {
38
- current.content += ev.delta;
39
- }
40
- else if (ev.type === 'artifact:end' && current && current.identifier === ev.identifier) {
41
- const finished = { ...current, content: ev.fullContent, status: 'complete' };
42
- items.push({ ...finished, file: toFile(finished), match: registry?.resolve({ file: toFile(finished) }) ?? null });
43
- current = null;
44
- }
45
- }
53
+ const items = collectCompleted(head, { registry });
46
54
  // A trailing artifact whose close tag hasn't streamed in yet — surfaced
47
55
  // separately so a live preview can render mid-generation.
48
- if (live) {
49
- const streaming = { identifier: live.identifier, artifactType: live.artifactType, title: live.title, content: live.content, status: 'streaming' };
50
- items.push({ ...streaming, file: toFile(streaming), match: registry?.resolve({ file: toFile(streaming) }) ?? null });
51
- }
52
- return { items, current: items.length > 0 ? items[items.length - 1] : null };
56
+ if (live)
57
+ items.push(toItem({ ...live, status: 'streaming' }, { registry }));
58
+ return { items, current: items.at(-1) ?? null };
53
59
  }, [content, registry]);
54
60
  }
55
61
  //# sourceMappingURL=useArtifactStream.js.map
@@ -13,6 +13,8 @@ export interface UseComposerOptions {
13
13
  /** Scopes recent prompts across conversations. Hosts with multiple users must supply an id. */
14
14
  historyScope?: string;
15
15
  historyStorage?: ComposerHistoryStoragePort;
16
+ /** Recall retention; defaults to 100, with existing duplicate collapse. */
17
+ historyCap?: number;
16
18
  initialAgent?: AgentSelection;
17
19
  project?: ProjectContextValue;
18
20
  composerSlots?: ComposerSlots;
@@ -41,7 +43,25 @@ export interface UseComposerOptions {
41
43
  * Captured in a ref, so an inline arrow is safe and will not re-run the restore on every render.
42
44
  */
43
45
  validateAttachments?: (attachments: readonly ChatAttachment[]) => Promise<readonly ChatAttachment[]>;
46
+ /**
47
+ * Where the page-exit signals that flush a pending draft write come from (`pagehide` on the
48
+ * window, `visibilitychange` to hidden on the document). Defaults to the browser's own
49
+ * {@link browserPageLifecycle}; `null` means there is no page to leave (a non-DOM renderer runs
50
+ * effects without `window`), so only unmount, send and switch flush. Captured once at mount.
51
+ */
52
+ pageLifecycle?: ComposerPageLifecycle | null;
53
+ }
54
+ /** The page-exit event sources {@link useComposer} listens to, injectable so a host or test can supply its own. */
55
+ export interface ComposerPageLifecycle {
56
+ readonly window: Pick<Window, 'addEventListener' | 'removeEventListener'>;
57
+ readonly document: Pick<Document, 'addEventListener' | 'removeEventListener' | 'visibilityState'>;
44
58
  }
59
+ /**
60
+ * The real window/document pair, or `null` when this runtime has no `window` — a non-DOM React
61
+ * renderer still runs effects, and subscribing there would throw.
62
+ * @complexity Time/space: O(1).
63
+ */
64
+ export declare function browserPageLifecycle(): ComposerPageLifecycle | null;
45
65
  export interface MentionPopoverState {
46
66
  open: boolean;
47
67
  query: string;