@jini-ai/chat 0.6.16 → 0.6.19

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 (64) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/core/accepted-input.d.ts +12 -0
  3. package/dist/core/accepted-input.js +29 -1
  4. package/dist/core/assistant-client/transport.js +2 -0
  5. package/dist/core/events.d.ts +6 -0
  6. package/dist/core/messages.d.ts +2 -0
  7. package/dist/core/run-events/entry.js +15 -2
  8. package/dist/core/surface-recovery.d.ts +19 -0
  9. package/dist/core/surface-recovery.js +26 -0
  10. package/dist/core/tool-events.d.ts +6 -1
  11. package/dist/core/tool-events.js +74 -27
  12. package/dist/core/transport.d.ts +2 -0
  13. package/dist/core/turn-usage.d.ts +1 -1
  14. package/dist/core/turn-usage.js +5 -3
  15. package/dist/core/user-text-redaction.js +30 -4
  16. package/dist/embed-widget/browser/frontend-session-bridge.d.ts +14 -1
  17. package/dist/embed-widget/browser/frontend-session-bridge.js +120 -47
  18. package/dist/react/components/Composer.js +4 -2
  19. package/dist/react/components/ConversationList.js +2 -1
  20. package/dist/react/components/ConversationTitleEditor.js +2 -1
  21. package/dist/react/components/CopyMessageButton.d.ts +4 -1
  22. package/dist/react/components/CopyMessageButton.js +3 -2
  23. package/dist/react/components/ExtEventErrorBoundary.js +2 -1
  24. package/dist/react/components/FolderChips.d.ts +13 -0
  25. package/dist/react/components/FolderChips.js +32 -0
  26. package/dist/react/components/Icon.d.ts +1 -1
  27. package/dist/react/components/Icon.js +1 -0
  28. package/dist/react/components/McpUiSurfaceCard.js +12 -5
  29. package/dist/react/components/MessageList.d.ts +1 -0
  30. package/dist/react/components/MessageList.js +2 -1
  31. package/dist/react/components/MessageRow.js +2 -1
  32. package/dist/react/components/MidRunUserMessage.js +3 -2
  33. package/dist/react/components/ToolCard.js +3 -2
  34. package/dist/react/components/ToolCard.rules.js +2 -1
  35. package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +5 -3
  36. package/dist/react/features/chat-pane/components/ChatPane.js +1 -0
  37. package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.d.ts +15 -2
  38. package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.js +19 -3
  39. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +47 -16
  40. package/dist/react/features/chat-pane/hooks/useChatPaneFileDrop.hooks.js +3 -2
  41. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +20 -3
  42. package/dist/react/features/chat-pane/model-options.d.ts +10 -0
  43. package/dist/react/features/chat-pane/model-options.js +22 -3
  44. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  45. package/dist/react/features/chat-pane/styles.js +23 -1
  46. package/dist/react/features/chat-pane/surface-recovery-stream.d.ts +12 -0
  47. package/dist/react/features/chat-pane/surface-recovery-stream.js +47 -0
  48. package/dist/react/features/chat-pane/types.d.ts +4 -0
  49. package/dist/react/hooks/composer-draft-cache.d.ts +13 -10
  50. package/dist/react/hooks/composer-draft-cache.js +39 -14
  51. package/dist/react/hooks/useComposer.d.ts +7 -0
  52. package/dist/react/hooks/useComposer.js +83 -8
  53. package/dist/react/hooks/useConversation.d.ts +2 -0
  54. package/dist/react/hooks/useConversation.js +31 -20
  55. package/dist/react/hooks/useRunStream.js +2 -1
  56. package/dist/react/hooks/useSurfaceRecovery.d.ts +23 -0
  57. package/dist/react/hooks/useSurfaceRecovery.js +84 -0
  58. package/dist/react/styles/reference.css +10 -5
  59. package/dist/store/legacy/sqlite/messages.d.ts +12 -10
  60. package/dist/store/legacy/sqlite/messages.js +4 -1
  61. package/dist/store/runtime/chat-expiry-sweep.js +3 -1
  62. package/dist/store/runtime/durable-run-store.js +35 -3
  63. package/dist/store/sql/store.js +5 -1
  64. package/package.json +12 -12
@@ -59,6 +59,8 @@ export const COMPOSER_DRAFT_STORAGE_PREFIX = 'jini.chat.composer-draft.v1.';
59
59
  * so a corrupt or unwritable attachment entry can never cost the operator their words, and vice
60
60
  * versa. See {@link readCachedAttachments} for what these references are and are not.
61
61
  */
62
+ const COMPOSER_FOLDERS_STORAGE_PREFIX = 'jini.chat.composer-folders.v1.';
63
+ const folderDrafts = new Map();
62
64
  export const COMPOSER_ATTACHMENTS_STORAGE_PREFIX = 'jini.chat.composer-attachments.v1.';
63
65
  const drafts = new Map();
64
66
  const stagedAttachments = new Map();
@@ -214,31 +216,33 @@ function storedTimestamp(store, { key }) {
214
216
  function writeStoredEnvelope(prefix, { conversationId, envelope }) {
215
217
  const store = storage();
216
218
  if (!store)
217
- return;
219
+ return false;
218
220
  const key = `${prefix}${conversationId}`;
219
221
  try {
220
222
  if (envelope === null) {
221
223
  store.removeItem(key);
222
- return;
224
+ return true;
223
225
  }
224
226
  if (store.getItem(key) === null)
225
227
  pruneStored(prefix, MAX_CACHED_CONVERSATION_DRAFTS - 1);
226
228
  store.setItem(key, JSON.stringify(envelope));
229
+ return true;
227
230
  }
228
231
  catch {
229
232
  /* blocked, full, or unusable storage — memory-only is the documented degraded mode */
230
233
  /* (on a removal: blocked storage; the in-memory tier is already correct) */
234
+ return false;
231
235
  }
232
236
  }
233
237
  /**
234
- * Writes or removes the stored copy for `conversationId`.
238
+ * Writes or removes the stored copy for `conversationId`; returns false on storage failure.
235
239
  *
236
240
  * @param draft A blank draft removes the entry; see {@link writeStoredEnvelope}.
237
241
  * @complexity Time O(n) in the draft's length; see {@link writeStoredEnvelope}.
238
242
  */
239
243
  function writeStoredDraft(conversationId, draft) {
240
244
  const envelope = draft === '' ? null : { v: 1, t: Date.now(), d: draft };
241
- writeStoredEnvelope(COMPOSER_DRAFT_STORAGE_PREFIX, { conversationId, envelope });
245
+ return writeStoredEnvelope(COMPOSER_DRAFT_STORAGE_PREFIX, { conversationId, envelope });
242
246
  }
243
247
  /**
244
248
  * Records `draft` in the in-memory tier, evicting the oldest-inserted conversation first if this
@@ -272,7 +276,7 @@ export function readCachedDraft({ conversationId }) {
272
276
  return stored;
273
277
  }
274
278
  /**
275
- * Stores `draft` for `conversationId` in both tiers.
279
+ * Stores `draft` for `conversationId` in both tiers. Returns whether durable storage accepted it.
276
280
  *
277
281
  * @param conversationId No-ops when absent (`null`/`undefined`) — there is nothing to key on yet.
278
282
  * `useComposer` covers that window by adopting the typed text once an id arrives; see its own doc.
@@ -281,11 +285,11 @@ export function readCachedDraft({ conversationId }) {
281
285
  * writes `''` here.
282
286
  * @complexity Time/space: O(n) in the draft's length; see {@link writeStoredDraft}.
283
287
  */
284
- export function writeCachedDraft({ conversationId, draft }) {
288
+ export function writeCachedDraft({ conversationId, draft }, _options = {}) {
285
289
  if (!conversationId)
286
- return;
290
+ return false;
287
291
  rememberCachedDraft({ conversationId: conversationId, draft: draft });
288
- writeStoredDraft(conversationId, draft.trim() === '' ? '' : draft);
292
+ return writeStoredDraft(conversationId, draft.trim() === '' ? '' : draft);
289
293
  }
290
294
  /**
291
295
  * Stores `draft` for `conversationId` in the in-memory tier only, leaving `localStorage` alone.
@@ -434,26 +438,25 @@ export function writeCachedAttachmentBatchId({ conversationId, batchId }, _optio
434
438
  setBounded(stagedBatchIds, { key: conversationId, value: batchId });
435
439
  }
436
440
  /**
437
- * Stores `attachments` for `conversationId` in both tiers.
441
+ * Stores `attachments` for `conversationId` in both tiers. Returns whether storage accepted it.
438
442
  *
439
443
  * @param attachments An empty list deletes the entry, so a sent or cleared turn leaves nothing to
440
444
  * restore — the same rule the draft text follows.
441
445
  * @complexity Time/space: O(n) in the number of references.
442
446
  */
443
- export function writeCachedAttachments({ conversationId, attachments }) {
447
+ export function writeCachedAttachments({ conversationId, attachments }, _options = {}) {
444
448
  if (!conversationId)
445
- return;
449
+ return false;
446
450
  if (attachments.length === 0) {
447
451
  stagedAttachments.delete(conversationId);
448
452
  // A sent or cleared turn's batch is spent; the next upload starts a fresh one.
449
453
  stagedBatchIds.delete(conversationId);
450
- writeStoredEnvelope(COMPOSER_ATTACHMENTS_STORAGE_PREFIX, { conversationId, envelope: null });
451
- return;
454
+ return writeStoredEnvelope(COMPOSER_ATTACHMENTS_STORAGE_PREFIX, { conversationId, envelope: null });
452
455
  }
453
456
  setBounded(stagedAttachments, { key: conversationId, value: attachments });
454
457
  const batchId = stagedBatchIds.get(conversationId);
455
458
  const envelope = { v: 1, t: Date.now(), a: attachments, ...(batchId === undefined ? {} : { b: batchId }) };
456
- writeStoredEnvelope(COMPOSER_ATTACHMENTS_STORAGE_PREFIX, { conversationId, envelope });
459
+ return writeStoredEnvelope(COMPOSER_ATTACHMENTS_STORAGE_PREFIX, { conversationId, envelope });
457
460
  }
458
461
  /**
459
462
  * Test-only: empties the cache so test cases cannot leak drafts into each other.
@@ -465,11 +468,33 @@ export function writeCachedAttachments({ conversationId, attachments }) {
465
468
  */
466
469
  export function __resetComposerDraftCacheForTests(options) {
467
470
  drafts.clear();
471
+ folderDrafts.clear();
468
472
  stagedAttachments.clear();
469
473
  stagedBatchIds.clear();
470
474
  if (options?.keepStorage === true)
471
475
  return;
476
+ pruneStored(COMPOSER_FOLDERS_STORAGE_PREFIX, 0);
472
477
  pruneStored(COMPOSER_DRAFT_STORAGE_PREFIX, 0);
473
478
  pruneStored(COMPOSER_ATTACHMENTS_STORAGE_PREFIX, 0);
474
479
  }
480
+ /** Folder drafts share the bounded cache owner, but never upload liveness or TTL checks. */
481
+ export function readCachedFolders({ conversationId }, _options = {}) {
482
+ if (!conversationId)
483
+ return [];
484
+ const cached = folderDrafts.get(conversationId);
485
+ if (cached)
486
+ return [...cached];
487
+ const stored = readStoredEnvelope(COMPOSER_FOLDERS_STORAGE_PREFIX, { conversationId, decode: parsed => {
488
+ const value = parsed;
489
+ return value?.v === 1 && Array.isArray(value.folders) && value.folders.every(path => typeof path === 'string') ? value.folders : null;
490
+ } });
491
+ return stored ?? [];
492
+ }
493
+ /** Persist exact strings independently of text and attachment validation; report storage failure. */
494
+ export function writeCachedFolders({ conversationId, folders }, _options = {}) {
495
+ if (!conversationId)
496
+ return false;
497
+ setBounded(folderDrafts, { key: conversationId, value: [...folders] });
498
+ return writeStoredEnvelope(COMPOSER_FOLDERS_STORAGE_PREFIX, { conversationId, envelope: folders.length ? { v: 1, t: Date.now(), folders } : null });
499
+ }
475
500
  //# sourceMappingURL=composer-draft-cache.js.map
@@ -72,6 +72,13 @@ export interface UseComposerResult {
72
72
  /** Optional for compatibility with host-created composer controllers. */
73
73
  history?: ComposerHistoryController;
74
74
  setDraft: (next: string) => void;
75
+ folders?: string[];
76
+ addFolders: (input: {
77
+ folders: readonly string[];
78
+ }, options?: {}) => void;
79
+ removeFolder: (input: {
80
+ path: string;
81
+ }, options?: {}) => void;
75
82
  attachments: ChatAttachment[];
76
83
  /** Uploads via `project.uploadFiles` (when supplied) and stages the results; no-ops (with a rejected promise) when no upload port is wired. */
77
84
  addAttachments: (files: File[]) => Promise<void>;
@@ -29,7 +29,7 @@
29
29
  */
30
30
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
31
31
  import { useComposerHistory } from './useComposerHistory.hooks.js';
32
- import { readCachedAttachments, readCachedDraft, rememberCachedDraft, writeCachedAttachments, writeCachedDraft, } from './composer-draft-cache.js';
32
+ import { readCachedFolders, writeCachedFolders, readCachedAttachments, readCachedDraft, rememberCachedDraft, writeCachedAttachments, writeCachedDraft, } from './composer-draft-cache.js';
33
33
  import { cacheAttachmentPreviewSource } from './attachment-preview-cache.js';
34
34
  /**
35
35
  * The real window/document pair, or `null` when this runtime has no `window` — a non-DOM React
@@ -49,16 +49,19 @@ const EMPTY_MENTION = { open: false, query: '', results: [] };
49
49
  */
50
50
  export const DRAFT_PERSIST_DEBOUNCE_MS = 800;
51
51
  /**
52
- * One pending durable draft write at a time, trailing-edge debounced. Flushes on unmount and when
52
+ * One pending durable draft write at a time, trailing-edge debounced. Release checks flush and
53
+ * consult the draft owner without adding a browser reload prompt. Flushes on unmount and when
53
54
  * the page is hidden (`pagehide`, or `visibilitychange` to hidden — the last event a mobile browser
54
55
  * reliably delivers before discarding the tab).
55
56
  * @param pageLifecycle The page-exit event sources, captured at mount; `undefined` resolves the
56
57
  * browser's own, `null` subscribes to nothing (unmount still flushes).
57
58
  * @complexity Time/space: O(1) per call.
58
59
  */
59
- function useDeferredDraftWrite(pageLifecycle) {
60
+ function useDeferredDraftWrite(pageLifecycle, canRelease) {
60
61
  const [lifecycle] = useState(() => (pageLifecycle === undefined ? browserPageLifecycle() : pageLifecycle));
61
62
  const pendingRef = useRef(null);
63
+ const canReleaseRef = useRef(canRelease);
64
+ canReleaseRef.current = canRelease;
62
65
  const cancel = useCallback(() => {
63
66
  const pending = pendingRef.current;
64
67
  if (!pending)
@@ -91,10 +94,23 @@ function useDeferredDraftWrite(pageLifecycle) {
91
94
  if (doc.visibilityState === 'hidden')
92
95
  flush();
93
96
  };
97
+ const onReleaseCheck = (event) => {
98
+ try {
99
+ flush();
100
+ if (!canReleaseRef.current())
101
+ event.preventDefault();
102
+ }
103
+ catch {
104
+ // Host persistence may throw; a release must never discard the only surviving copy.
105
+ event.preventDefault();
106
+ }
107
+ };
94
108
  page.addEventListener('pagehide', flush);
109
+ page.addEventListener('jini:release-check', onReleaseCheck);
95
110
  doc.addEventListener('visibilitychange', onVisibilityChange);
96
111
  return () => {
97
112
  page.removeEventListener('pagehide', flush);
113
+ page.removeEventListener('jini:release-check', onReleaseCheck);
98
114
  doc.removeEventListener('visibilitychange', onVisibilityChange);
99
115
  flush();
100
116
  };
@@ -104,10 +120,40 @@ function useDeferredDraftWrite(pageLifecycle) {
104
120
  export function useComposer(options = {}) {
105
121
  const { project, composerSlots, persistence, conversationId, validateAttachments } = options;
106
122
  const [draft, setDraftState] = useState(() => options.initialDraft ?? persistence?.read() ?? readCachedDraft({ conversationId: conversationId }) ?? '');
123
+ const [folders, setFolders] = useState(() => readCachedFolders({ conversationId }));
124
+ // Persist inside the state transition with its conversation key: switching cannot write an old
125
+ // render's folders over the newly selected conversation, and a pick survives immediate unmount.
126
+ const addFolders = useCallback(({ folders: picked }, _options = {}) => {
127
+ setFolders(previous => {
128
+ const next = [...new Set([...previous, ...picked])];
129
+ writeCachedFolders({ conversationId, folders: next });
130
+ return next;
131
+ });
132
+ }, [conversationId]);
133
+ const removeFolder = useCallback(({ path }, _options = {}) => {
134
+ setFolders(previous => {
135
+ const next = previous.filter(folder => folder !== path);
136
+ writeCachedFolders({ conversationId, folders: next });
137
+ return next;
138
+ });
139
+ }, [conversationId]);
107
140
  const [attachments, setAttachments] = useState([]);
108
141
  const [agent, setAgent] = useState(options.initialAgent);
109
142
  const [mention, setMention] = useState(EMPTY_MENTION);
110
- const deferredWrite = useDeferredDraftWrite(options.pageLifecycle);
143
+ const pendingUploadsRef = useRef(0);
144
+ const deferredWrite = useDeferredDraftWrite(options.pageLifecycle, () => {
145
+ if (pendingUploadsRef.current > 0)
146
+ return false;
147
+ // Retry even after the debounce: the cache's memory tier cannot survive guest destruction.
148
+ // Empty values are durable deletions too. A failed remove must not resurrect a cleared draft
149
+ // after this guest is destroyed and storage becomes readable again.
150
+ const textSaved = conversationId ? writeCachedDraft({ conversationId, draft }) : draft.trim() === '';
151
+ const foldersSaved = conversationId ? writeCachedFolders({ conversationId, folders }) : folders.length === 0;
152
+ // Without the host's liveness validator, a remount deliberately does not restore files.
153
+ const filesSaved = canReleaseAttachments({ conversationId, attachments, validateAttachments,
154
+ hydratedFor: attachmentsHydratedForRef.current });
155
+ return textSaved && foldersSaved && filesSaved;
156
+ });
111
157
  const commitDraft = useCallback((next) => {
112
158
  setDraftState(next);
113
159
  rememberCachedDraft({ conversationId: conversationId, draft: next });
@@ -164,8 +210,13 @@ export function useComposer(options = {}) {
164
210
  // reason — nothing was switched away from.
165
211
  if (previousConversationId == null && conversationId != null && restored === null && draftRef.current.trim() !== '') {
166
212
  writeCachedDraft({ conversationId: conversationId, draft: draftRef.current });
213
+ writeCachedFolders({ conversationId, folders });
167
214
  return;
168
215
  }
216
+ if (previousConversationId == null && conversationId != null)
217
+ writeCachedFolders({ conversationId, folders });
218
+ else
219
+ setFolders(readCachedFolders({ conversationId }));
169
220
  setDraftState(restored ?? '');
170
221
  setAttachments([]);
171
222
  setMention(EMPTY_MENTION);
@@ -241,7 +292,14 @@ export function useComposer(options = {}) {
241
292
  if (!project?.uploadFiles) {
242
293
  throw new Error('addAttachments requires ProjectContextValue.uploadFiles to be wired by the host');
243
294
  }
244
- const uploaded = await project.uploadFiles(files);
295
+ pendingUploadsRef.current++;
296
+ let uploaded;
297
+ try {
298
+ uploaded = await project.uploadFiles(files);
299
+ }
300
+ finally {
301
+ pendingUploadsRef.current--;
302
+ }
245
303
  setAttachments((prev) => [...prev, ...uploaded]);
246
304
  // `uploadFiles` resolves 1:1 with `files`, in order, on success (see
247
305
  // `create-daemon-attachment-uploader.ts`'s "preserved order" doc) - any failure rejects the
@@ -284,14 +342,19 @@ export function useComposer(options = {}) {
284
342
  }, [draft, setDraft]);
285
343
  const reset = useCallback(() => {
286
344
  setDraft('');
345
+ setFolders([]);
346
+ writeCachedFolders({ conversationId, folders: [] });
287
347
  clearAttachments();
288
348
  closeMention();
289
- }, [clearAttachments, closeMention, setDraft]);
290
- const canSubmit = draft.trim().length > 0 || attachments.length > 0;
349
+ }, [clearAttachments, closeMention, setDraft, conversationId]);
350
+ const canSubmit = draft.trim().length > 0 || attachments.length > 0 || folders.length > 0;
291
351
  return useMemo(() => ({
292
352
  draft,
293
353
  history,
294
354
  setDraft,
355
+ folders,
356
+ addFolders,
357
+ removeFolder,
295
358
  attachments,
296
359
  addAttachments,
297
360
  addAttachment,
@@ -306,6 +369,18 @@ export function useComposer(options = {}) {
306
369
  canSubmit,
307
370
  reset,
308
371
  flushDraft: deferredWrite.flush,
309
- }), [draft, history, setDraft, attachments, addAttachments, addAttachment, removeAttachment, clearAttachments, agent, mention, openMention, closeMention, selectMention, canSubmit, reset, deferredWrite.flush]);
372
+ }), [draft, history, setDraft, folders, addFolders, removeFolder, attachments, addAttachments, addAttachment, removeAttachment, clearAttachments, agent, mention, openMention, closeMention, selectMention, canSubmit, reset, deferredWrite.flush]);
373
+ }
374
+ /** Never erase a pending restore; once settled, confirm persistence of empty lists as well.
375
+ * Unkeyed empty composers have no stored entry to delete. O(staged files), bounded by the cache.
376
+ */
377
+ function canReleaseAttachments({ conversationId, attachments, validateAttachments, hydratedFor }, _options = {}) {
378
+ if (!conversationId)
379
+ return attachments.length === 0;
380
+ if (hydratedFor !== conversationId)
381
+ return false;
382
+ if (attachments.length > 0 && !validateAttachments)
383
+ return false;
384
+ return writeCachedAttachments({ conversationId, attachments });
310
385
  }
311
386
  //# sourceMappingURL=useComposer.js.map
@@ -27,6 +27,8 @@ export interface SendMessageOptions {
27
27
  }) => void;
28
28
  /** Non-secret signal from a composer that already sanitized the content before this boundary. */
29
29
  secretRedaction?: ChatMessage['secretRedaction'];
30
+ /** Picked directory paths, kept byte-exact and separate from upload references. */
31
+ folders?: string[];
30
32
  attachments?: ChatAttachment[];
31
33
  context?: RunContext;
32
34
  agentId?: string;
@@ -1,3 +1,4 @@
1
+ import { formatErrorForLog } from '@jini-ai/core';
1
2
  import { acceptedInputUserEvent, projectAcceptedInputEvents } from '../../core/accepted-input.js';
2
3
  import { redactUserHistory, redactUserMessage } from '../../core/user-text-redaction.js';
3
4
  import { preserveAssistantContent, mergeRunEvents } from '../../core/durable-projection.js';
@@ -32,9 +33,34 @@ function pendingUserMessage({ content, sendOptions, createMessageId }, options =
32
33
  content,
33
34
  createdAt: Date.now(),
34
35
  ...(sendOptions.secretRedaction ? { secretRedaction: sendOptions.secretRedaction } : {}),
36
+ ...(sendOptions.folders ? { folders: sendOptions.folders } : {}),
35
37
  ...(sendOptions.attachments ? { attachments: sendOptions.attachments } : {}),
36
38
  } }, options).message;
37
39
  }
40
+ /** The queued assistant bubble a send opens; the run's events fill it in. */
41
+ function pendingAssistantMessage({ id }, { agentId } = {}) {
42
+ return {
43
+ id,
44
+ role: 'assistant',
45
+ content: '',
46
+ ...(agentId !== undefined ? { agentId } : {}),
47
+ runStatus: 'queued',
48
+ createdAt: Date.now(),
49
+ startedAt: Date.now(),
50
+ };
51
+ }
52
+ /** What `run.start` receives for one send: only the options the caller actually set. */
53
+ function runStartInput({ assistantMessageId, history, conversationId, sendOptions }, { agentId } = {}) {
54
+ return {
55
+ assistantMessageId,
56
+ history,
57
+ ...(agentId !== undefined ? { agentId } : {}),
58
+ conversationId,
59
+ ...(sendOptions.folders !== undefined ? { folders: sendOptions.folders } : {}),
60
+ ...(sendOptions.attachments !== undefined ? { attachments: sendOptions.attachments } : {}),
61
+ ...(sendOptions.context !== undefined ? { context: sendOptions.context } : {}),
62
+ };
63
+ }
38
64
  export function useConversation(options) {
39
65
  const { transport, conversationId = null, agentId, createMessageId = defaultCreateMessageId } = options;
40
66
  const [messages, setMessagesState] = useState(() => redactUserHistory({ history: options.initialMessages ?? [] }, { ...(options.redactUserText ? { redactUserText: options.redactUserText } : {}) }));
@@ -65,7 +91,7 @@ export function useConversation(options) {
65
91
  }
66
92
  catch (error) {
67
93
  if (!disposed)
68
- console.error('[chat] conversation refresh failed', error);
94
+ console.error('[chat] conversation refresh failed', formatErrorForLog({ error }));
69
95
  }
70
96
  finally {
71
97
  pending = false;
@@ -183,20 +209,11 @@ export function useConversation(options) {
183
209
  // permanently unset.
184
210
  useEffect(() => {
185
211
  applyRunToAssistantMessage();
186
- // eslint-disable-next-line react-hooks/exhaustive-deps
187
212
  }, [run.events, run.status, run.runId]);
188
213
  const sendMessage = useCallback(async (content, sendOptions = {}) => {
189
214
  const userMessage = pendingUserMessage({ content, sendOptions, createMessageId }, options);
190
215
  const resolvedAgentId = sendOptions.agentId ?? agentId;
191
- const assistantMessage = {
192
- id: createMessageId(),
193
- role: 'assistant',
194
- content: '',
195
- ...(resolvedAgentId !== undefined ? { agentId: resolvedAgentId } : {}),
196
- runStatus: 'queued',
197
- createdAt: Date.now(),
198
- startedAt: Date.now(),
199
- };
216
+ const assistantMessage = pendingAssistantMessage({ id: createMessageId() }, { agentId: resolvedAgentId });
200
217
  activeAssistantIdRef.current = assistantMessage.id;
201
218
  setSendRefused(false);
202
219
  const history = redactUserHistory({ history: [...messagesRef.current, userMessage] }, options);
@@ -208,21 +225,14 @@ export function useConversation(options) {
208
225
  // (host stuck-chat investigation, 2026-09-27).
209
226
  setMessagesState((prev) => [...prev, userMessage, assistantMessage]);
210
227
  setScrollIntent(true);
211
- const accepted = await run.start({
212
- assistantMessageId: assistantMessage.id,
213
- history,
214
- ...(resolvedAgentId !== undefined ? { agentId: resolvedAgentId } : {}),
215
- conversationId,
216
- ...(sendOptions.attachments !== undefined ? { attachments: sendOptions.attachments } : {}),
217
- ...(sendOptions.context !== undefined ? { context: sendOptions.context } : {}),
218
- });
228
+ const accepted = await run.start(runStartInput({ assistantMessageId: assistantMessage.id, history, conversationId, sendOptions }, { agentId: resolvedAgentId }));
219
229
  if (!accepted) {
220
230
  // The bubbles go, but the reason must not: it was rendered only on the assistant bubble.
221
231
  setMessagesState(prev => prev.filter(message => message.id !== userMessage.id && message.id !== assistantMessage.id));
222
232
  setSendRefused(true);
223
233
  return;
224
234
  }
225
- const inputs = sendOptions.acceptedInputs ?? [{ id: userMessage.id, text: userMessage.content }];
235
+ const inputs = sendOptions.acceptedInputs ?? [{ id: userMessage.id, text: userMessage.content, ...(userMessage.folders ? { folders: userMessage.folders } : {}) }];
226
236
  setMessagesState(prev => prev.map(message => message.id === userMessage.id ? { ...message, inputPending: false } : message));
227
237
  sendOptions.onAccepted?.({ inputs });
228
238
  }, [agentId, conversationId, createMessageId, run, options.redactUserText, options.onSecretRedacted]);
@@ -254,6 +264,7 @@ export function useConversation(options) {
254
264
  history,
255
265
  ...(resolvedAgentId !== undefined ? { agentId: resolvedAgentId } : {}),
256
266
  conversationId,
267
+ ...(priorUser.folders !== undefined ? { folders: priorUser.folders } : {}),
257
268
  ...(priorUser.attachments !== undefined ? { attachments: priorUser.attachments } : {}),
258
269
  });
259
270
  }, [agentId, conversationId, run, options.redactUserText, options.onSecretRedacted]);
@@ -1,3 +1,4 @@
1
+ import { formatErrorForLog } from '@jini-ai/core';
1
2
  /**
2
3
  * @module useRunStream
3
4
  *
@@ -20,7 +21,7 @@
20
21
  import { mergeRunEvents } from '../../core/durable-projection.js';
21
22
  import { useCallback, useEffect, useRef, useState } from 'react';
22
23
  function reportRunStreamHostEffectFailure(effectName, error) {
23
- console.error(`[@jini-ai/chat] useRunStream ${effectName} host effect failed:`, error);
24
+ console.error(`[@jini-ai/chat] useRunStream ${effectName} host effect failed:`, formatErrorForLog({ error }));
24
25
  }
25
26
  /**
26
27
  * Fire-and-forget teardown call to `transport.stopRun`. Invokes it synchronously, then observes
@@ -0,0 +1,23 @@
1
+ import { type McpUiToolCallHandler, type UIResource } from '@jini-ai/ui/mcp-ui';
2
+ import type { ExtEventCall } from '../ext-event-renderer-registry.js';
3
+ /** Restore only an unanswered call: completed decisions must never execute twice. */
4
+ export declare function surfaceNeedsRecovery({ call, runStreaming }: {
5
+ call: ExtEventCall | undefined;
6
+ runStreaming: boolean;
7
+ }, _options?: {}): boolean;
8
+ /** Nonvisual card lifecycle: mount/focus restores the server's original invocation; late clicks
9
+ * use the same stream. A component owns its subscription and aborts it on teardown.
10
+ * @complexity O(card document size) per emission; O(1) subscriptions per visible card.
11
+ */
12
+ export declare function useSurfaceRecovery({ resource, call, runStreaming, onToolCall }: {
13
+ resource: UIResource | undefined;
14
+ call: ExtEventCall | undefined;
15
+ runStreaming: boolean;
16
+ onToolCall: McpUiToolCallHandler | undefined;
17
+ }, _options?: {}): {
18
+ resource: UIResource | undefined;
19
+ restored: boolean;
20
+ live: boolean;
21
+ onToolCall: McpUiToolCallHandler | undefined;
22
+ };
23
+ //# sourceMappingURL=useSurfaceRecovery.d.ts.map
@@ -0,0 +1,84 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { parseUIResource } from '@jini-ai/ui/mcp-ui';
3
+ import { recoverableSurfaceResource, surfaceRecoveryInfo, SURFACE_EXCHANGE_META } from '../../core/surface-recovery.js';
4
+ /** Restore only an unanswered call: completed decisions must never execute twice. */
5
+ export function surfaceNeedsRecovery({ call, runStreaming }, _options = {}) {
6
+ if (!call)
7
+ return false;
8
+ if (!call.result)
9
+ return !runStreaming;
10
+ return call.result.isError || /\b(expired|abandoned|did not respond|run ended)\b/i.test(call.result.content);
11
+ }
12
+ function recoveryCaller(call) {
13
+ if (!call || !('restore' in call) || !('answer' in call))
14
+ return undefined;
15
+ if (typeof call.restore !== 'function' || typeof call.answer !== 'function')
16
+ return undefined;
17
+ return call;
18
+ }
19
+ /** Nonvisual card lifecycle: mount/focus restores the server's original invocation; late clicks
20
+ * use the same stream. A component owns its subscription and aborts it on teardown.
21
+ * @complexity O(card document size) per emission; O(1) subscriptions per visible card.
22
+ */
23
+ export function useSurfaceRecovery({ resource, call, runStreaming, onToolCall }, _options = {}) {
24
+ const [restored, setRestored] = useState();
25
+ const [live, setLive] = useState(false);
26
+ const pending = useRef(undefined);
27
+ const answerController = useRef(undefined);
28
+ const completed = useRef(false);
29
+ const info = surfaceRecoveryInfo({ value: resource?.resource._meta?.[SURFACE_EXCHANGE_META] });
30
+ const caller = info ? recoveryCaller(onToolCall) : undefined;
31
+ const needsRecovery = surfaceNeedsRecovery({ call, runStreaming });
32
+ const onSurface = (emission) => {
33
+ if (emission.channel !== 'mcp-ui')
34
+ return;
35
+ const next = parseUIResource(recoverableSurfaceResource({ resource: emission.payload['resource'], exchange: emission.payload['surfaceExchange'] }));
36
+ if (next) {
37
+ setRestored(next);
38
+ setLive(true);
39
+ }
40
+ };
41
+ useEffect(() => {
42
+ if (!caller || !info || !needsRecovery)
43
+ return;
44
+ let disposed = false;
45
+ const restore = () => {
46
+ if (pending.current || disposed || completed.current)
47
+ return;
48
+ const controller = new AbortController();
49
+ pending.current = controller;
50
+ void caller.restore({ ...info, signal: controller.signal, onSurface: emission => { if (!disposed)
51
+ onSurface(emission); } })
52
+ .then(() => { if (!disposed)
53
+ completed.current = true; })
54
+ .catch(() => { })
55
+ .finally(() => { if (!disposed) {
56
+ pending.current = undefined;
57
+ setLive(false);
58
+ } });
59
+ };
60
+ restore();
61
+ window.addEventListener('focus', restore);
62
+ return () => {
63
+ disposed = true;
64
+ window.removeEventListener('focus', restore);
65
+ pending.current?.abort();
66
+ pending.current = undefined;
67
+ answerController.current?.abort();
68
+ answerController.current = undefined;
69
+ };
70
+ }, [caller, info?.exchangeId, needsRecovery]);
71
+ useEffect(() => () => answerController.current?.abort(), []);
72
+ const toolCall = caller ? async (submitted) => {
73
+ const controller = new AbortController();
74
+ answerController.current = controller;
75
+ try {
76
+ return await caller.answer({ call: submitted, signal: controller.signal, onSurface });
77
+ }
78
+ finally {
79
+ answerController.current = undefined;
80
+ }
81
+ } : onToolCall;
82
+ return { resource: restored ?? resource, restored: restored !== undefined, live, onToolCall: toolCall };
83
+ }
84
+ //# sourceMappingURL=useSurfaceRecovery.js.map
@@ -36,11 +36,10 @@
36
36
  }
37
37
 
38
38
  /* Lets a long agent name inside (e.g. an AgentRuntimePicker trigger) shrink and ellipsis instead
39
- of forcing the row wider — flex items default to min-width:auto, which blocks shrinking below
40
- content size unless every ancestor in the chain opts out. Docked into a narrow sidebar (< 400px)
41
- is exactly where this stops being cosmetic. */
39
+ of forcing the row wider, while retaining the fixed controls' intrinsic minimum. A zero floor
40
+ let the effort selector overflow this wrapper across the Send/Stop hit targets in a dock. */
42
41
  .jini-composer-footer-accessories {
43
- min-width: 0;
42
+ min-width: min-content;
44
43
  }
45
44
 
46
45
  /* The picker, plus menu and send share this <div>; flex keeps them on one line, and it wraps
@@ -48,7 +47,7 @@
48
47
  .jini-composer-footer-trailing {
49
48
  display: flex;
50
49
  align-items: center;
51
- min-width: 0;
50
+ min-width: min-content;
52
51
  margin-left: auto;
53
52
  }
54
53
 
@@ -328,3 +327,9 @@
328
327
  * deleted silently) so a future reader who remembers seeing "queued" in this file finds the pointer
329
328
  * instead of concluding the removal was accidental.
330
329
  */
330
+
331
+ .jini-composer-attachments, .jini-folder-chips__list { display: flex; flex-wrap: wrap; }
332
+ .jini-folder-chips, .jini-folder-chips__name { min-width: 0; max-width: 100%; overflow-wrap: anywhere; }
333
+ .jini-folder-chips__toggle, .jini-folder-chips__item { display: flex; align-items: center; }
334
+ .jini-folder-chips__list { list-style: none; }
335
+ .jini-folder-chips svg, .jini-folder-chips button { flex-shrink: 0; }
@@ -25,6 +25,12 @@ export declare function listMessages({ db, conversationId }: {
25
25
  db: SqliteDb;
26
26
  conversationId: string;
27
27
  }): {
28
+ sessionMode: ChatSessionMode | undefined;
29
+ runContext: any;
30
+ createdAt: any;
31
+ startedAt: any;
32
+ endedAt: any;
33
+ folders?: string[];
28
34
  id: any;
29
35
  role: any;
30
36
  content: any;
@@ -35,11 +41,6 @@ export declare function listMessages({ db, conversationId }: {
35
41
  lastRunEventId: any;
36
42
  events: any;
37
43
  attachments: any;
38
- sessionMode: ChatSessionMode | undefined;
39
- runContext: any;
40
- createdAt: any;
41
- startedAt: any;
42
- endedAt: any;
43
44
  }[];
44
45
  /**
45
46
  * Thrown by `upsertMessage` when the caller presents a message `id` together with a
@@ -150,6 +151,12 @@ export declare function deleteMessage({ db, id }: {
150
151
  }): void;
151
152
  /** @internal Converts a raw SQLite row to a plain object with JSON columns parsed and null coerced to undefined. */
152
153
  declare function normalizeMessage(row: DbRow): {
154
+ sessionMode: ChatSessionMode | undefined;
155
+ runContext: any;
156
+ createdAt: any;
157
+ startedAt: any;
158
+ endedAt: any;
159
+ folders?: string[];
153
160
  id: any;
154
161
  role: any;
155
162
  content: any;
@@ -160,11 +167,6 @@ declare function normalizeMessage(row: DbRow): {
160
167
  lastRunEventId: any;
161
168
  events: any;
162
169
  attachments: any;
163
- sessionMode: ChatSessionMode | undefined;
164
- runContext: any;
165
- createdAt: any;
166
- startedAt: any;
167
- endedAt: any;
168
170
  };
169
171
  export {};
170
172
  //# sourceMappingURL=messages.d.ts.map