@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.
- package/CHANGELOG.md +18 -0
- package/dist/core/accepted-input.d.ts +12 -0
- package/dist/core/accepted-input.js +29 -1
- package/dist/core/assistant-client/transport.js +2 -0
- package/dist/core/events.d.ts +6 -0
- package/dist/core/messages.d.ts +2 -0
- package/dist/core/run-events/entry.js +15 -2
- package/dist/core/surface-recovery.d.ts +19 -0
- package/dist/core/surface-recovery.js +26 -0
- package/dist/core/tool-events.d.ts +6 -1
- package/dist/core/tool-events.js +74 -27
- package/dist/core/transport.d.ts +2 -0
- package/dist/core/turn-usage.d.ts +1 -1
- package/dist/core/turn-usage.js +5 -3
- package/dist/core/user-text-redaction.js +30 -4
- package/dist/embed-widget/browser/frontend-session-bridge.d.ts +14 -1
- package/dist/embed-widget/browser/frontend-session-bridge.js +120 -47
- package/dist/react/components/Composer.js +4 -2
- package/dist/react/components/ConversationList.js +2 -1
- package/dist/react/components/ConversationTitleEditor.js +2 -1
- package/dist/react/components/CopyMessageButton.d.ts +4 -1
- package/dist/react/components/CopyMessageButton.js +3 -2
- package/dist/react/components/ExtEventErrorBoundary.js +2 -1
- package/dist/react/components/FolderChips.d.ts +13 -0
- package/dist/react/components/FolderChips.js +32 -0
- package/dist/react/components/Icon.d.ts +1 -1
- package/dist/react/components/Icon.js +1 -0
- package/dist/react/components/McpUiSurfaceCard.js +12 -5
- package/dist/react/components/MessageList.d.ts +1 -0
- package/dist/react/components/MessageList.js +2 -1
- package/dist/react/components/MessageRow.js +2 -1
- package/dist/react/components/MidRunUserMessage.js +3 -2
- package/dist/react/components/ToolCard.js +3 -2
- package/dist/react/components/ToolCard.rules.js +2 -1
- package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +5 -3
- package/dist/react/features/chat-pane/components/ChatPane.js +1 -0
- package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.d.ts +15 -2
- package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.js +19 -3
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +47 -16
- package/dist/react/features/chat-pane/hooks/useChatPaneFileDrop.hooks.js +3 -2
- package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +20 -3
- package/dist/react/features/chat-pane/model-options.d.ts +10 -0
- package/dist/react/features/chat-pane/model-options.js +22 -3
- package/dist/react/features/chat-pane/styles.d.ts +1 -1
- package/dist/react/features/chat-pane/styles.js +23 -1
- package/dist/react/features/chat-pane/surface-recovery-stream.d.ts +12 -0
- package/dist/react/features/chat-pane/surface-recovery-stream.js +47 -0
- package/dist/react/features/chat-pane/types.d.ts +4 -0
- package/dist/react/hooks/composer-draft-cache.d.ts +13 -10
- package/dist/react/hooks/composer-draft-cache.js +39 -14
- package/dist/react/hooks/useComposer.d.ts +7 -0
- package/dist/react/hooks/useComposer.js +83 -8
- package/dist/react/hooks/useConversation.d.ts +2 -0
- package/dist/react/hooks/useConversation.js +31 -20
- package/dist/react/hooks/useRunStream.js +2 -1
- package/dist/react/hooks/useSurfaceRecovery.d.ts +23 -0
- package/dist/react/hooks/useSurfaceRecovery.js +84 -0
- package/dist/react/styles/reference.css +10 -5
- package/dist/store/legacy/sqlite/messages.d.ts +12 -10
- package/dist/store/legacy/sqlite/messages.js +4 -1
- package/dist/store/runtime/chat-expiry-sweep.js +3 -1
- package/dist/store/runtime/durable-run-store.js +35 -3
- package/dist/store/sql/store.js +5 -1
- 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.
|
|
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
|
|
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
|
-
|
|
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
|
|
40
|
-
|
|
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:
|
|
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:
|
|
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
|