@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
|
@@ -27,13 +27,13 @@ import { decodeSseFrames } from '@jini-ai/core/primitives';
|
|
|
27
27
|
* - **The bind token never leaves this module except to the host.** It arrives on the `attached`
|
|
28
28
|
* event and must ride to run-start; it deliberately does not appear in any URL (see
|
|
29
29
|
* `@jini-ai/daemon`'s `FrontendSessionHandle`).
|
|
30
|
-
* - **Read the bind token at send time, not once.**
|
|
31
|
-
* daemon restart, a sleeping laptop, or an ordinary network blip
|
|
30
|
+
* - **Read the bind token at send time, not once.** After a
|
|
31
|
+
* daemon restart, a sleeping laptop, or an ordinary network blip, each reattach mints a new
|
|
32
32
|
* session and a new token. A host that captured `ready`'s token keeps sending a dead one, every
|
|
33
33
|
* later run fails to bind, and the only trace is a daemon-side log line: the agent is simply told
|
|
34
34
|
* "no frontend is bound to this run". `ready` therefore answers "has it attached at least once";
|
|
35
35
|
* {@link FrontendSessionBridge.bindToken} answers "what is valid now", and only the latter
|
|
36
|
-
* belongs in a run request.
|
|
36
|
+
* belongs in a run request. Park/failure closes the transport; hosts explicitly rebind after wake.
|
|
37
37
|
*/
|
|
38
38
|
import { CHAT_CAPABILITIES } from '../../core/index.js';
|
|
39
39
|
import { PAGE_CAPABILITIES, executePageCapability } from '@jini-ai/agentic';
|
|
@@ -119,6 +119,8 @@ function readFrame(data) {
|
|
|
119
119
|
*/
|
|
120
120
|
export function createFrontendSessionBridge({ baseUrl: base, openStream, request, fetch, endpointPrefix = '/api/frontend-sessions', headers }, options = {}) {
|
|
121
121
|
const capabilities = claimedCapabilities(options);
|
|
122
|
+
// 0.6.x compatibility: fetch was optional for published hosts;
|
|
123
|
+
// remove when fetch injection becomes required in the next minor.
|
|
122
124
|
// The only caller (`respond`) never passes its own signal, so the deadline is the whole signal.
|
|
123
125
|
const send = request ?? (({ url, init, timeoutMs }) => (fetch ?? globalThis.fetch)(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }));
|
|
124
126
|
let sessionId;
|
|
@@ -138,6 +140,9 @@ export function createFrontendSessionBridge({ baseUrl: base, openStream, request
|
|
|
138
140
|
/** Invocation ids already run here, so a stream reconnect cannot double-click a button. */
|
|
139
141
|
const executed = new Set();
|
|
140
142
|
const pending = new Map();
|
|
143
|
+
const attachments = new Set();
|
|
144
|
+
let source;
|
|
145
|
+
let closed = false;
|
|
141
146
|
// A page driver owns one live screen. Serialize its observations, effect and acknowledgement;
|
|
142
147
|
// otherwise a second invocation could overwrite the first one's host-bound navigation plan.
|
|
143
148
|
let pageQueue = Promise.resolve();
|
|
@@ -148,7 +153,29 @@ export function createFrontendSessionBridge({ baseUrl: base, openStream, request
|
|
|
148
153
|
pending.clear();
|
|
149
154
|
};
|
|
150
155
|
const query = capabilities.map((id) => `capability=${encodeURIComponent(id)}`).join('&');
|
|
151
|
-
|
|
156
|
+
/** Invalidate before any reopen; queued and running page work cannot cross attachments.
|
|
157
|
+
* O(pending invocations + attachment waiters), with no retained settled waiters. */
|
|
158
|
+
function invalidate({ error }) {
|
|
159
|
+
abortPending();
|
|
160
|
+
pageQueue = Promise.resolve();
|
|
161
|
+
// The daemon deletes a session's bind token the instant that connection drops, so by the time
|
|
162
|
+
// this fires the token in hand is already gone from the registry. Holding it until the next
|
|
163
|
+
// `attached` frame leaves a window where `bindToken()` hands back something dead, and a run
|
|
164
|
+
// started in that window fails to bind — reported to the agent only as "no frontend is bound
|
|
165
|
+
// to this run", with the real reason (a reconnect it never saw) nowhere in view.
|
|
166
|
+
// Reporting no token is the honest answer for a surface that is, right now, not attached.
|
|
167
|
+
currentBindToken = undefined;
|
|
168
|
+
sessionId = undefined;
|
|
169
|
+
for (const waiter of attachments)
|
|
170
|
+
waiter.reject(error);
|
|
171
|
+
attachments.clear();
|
|
172
|
+
}
|
|
173
|
+
function disconnect({ error }) {
|
|
174
|
+
const previous = source;
|
|
175
|
+
source = undefined;
|
|
176
|
+
invalidate({ error });
|
|
177
|
+
previous?.close();
|
|
178
|
+
}
|
|
152
179
|
async function respond(invocationId, body) {
|
|
153
180
|
if (sessionId === undefined)
|
|
154
181
|
throw new Error('the surface is not attached yet');
|
|
@@ -169,7 +196,9 @@ export function createFrontendSessionBridge({ baseUrl: base, openStream, request
|
|
|
169
196
|
const args = { capabilityId: action.capabilityId, input };
|
|
170
197
|
if (options.pageApproval && !options.pageApproval.isDirect(args)) {
|
|
171
198
|
await respond(action.invocationId, { phase: 'approval-pending' });
|
|
199
|
+
signal.throwIfAborted();
|
|
172
200
|
const answer = await options.pageApproval.request({ ...args, input: structuredClone(input), signal });
|
|
201
|
+
signal.throwIfAborted();
|
|
173
202
|
// Report the decision before effects, including decline/expiry; the server audits it.
|
|
174
203
|
await respond(action.invocationId, { phase: 'execution-started', decision: answer.confirmed ? 'approved' : answer.reason });
|
|
175
204
|
if (!answer.confirmed)
|
|
@@ -197,6 +226,8 @@ export function createFrontendSessionBridge({ baseUrl: base, openStream, request
|
|
|
197
226
|
}
|
|
198
227
|
catch (error) {
|
|
199
228
|
pending.delete(action.invocationId);
|
|
229
|
+
if (controller.signal.aborted)
|
|
230
|
+
return;
|
|
200
231
|
await respondError(action.invocationId, errorMessage(error)).catch(options.onError ?? (() => undefined));
|
|
201
232
|
return;
|
|
202
233
|
}
|
|
@@ -207,6 +238,7 @@ export function createFrontendSessionBridge({ baseUrl: base, openStream, request
|
|
|
207
238
|
try {
|
|
208
239
|
signal.throwIfAborted();
|
|
209
240
|
const projected = await options.onBeforeResponse?.({ action, output });
|
|
241
|
+
signal.throwIfAborted();
|
|
210
242
|
const acceptedOutput = projected ?? output;
|
|
211
243
|
await respondSuccess(action.invocationId, acceptedOutput);
|
|
212
244
|
signal.throwIfAborted();
|
|
@@ -250,44 +282,60 @@ export function createFrontendSessionBridge({ baseUrl: base, openStream, request
|
|
|
250
282
|
else
|
|
251
283
|
void serveLocally(action, controller);
|
|
252
284
|
}
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
285
|
+
function connect() {
|
|
286
|
+
const current = openStream({ url: `${base}${endpointPrefix}/stream?${query}`, ...(headers ? { headers: headers() } : {}) });
|
|
287
|
+
source = current;
|
|
288
|
+
current.addEventListener('message', (event) => {
|
|
289
|
+
if (source !== current)
|
|
258
290
|
return;
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
291
|
+
const frame = readFrame(event.data);
|
|
292
|
+
switch (frame.kind) {
|
|
293
|
+
case 'cancelled':
|
|
294
|
+
pending.get(frame.invocationId)?.abort();
|
|
295
|
+
return;
|
|
296
|
+
case 'attached':
|
|
297
|
+
abortPending();
|
|
298
|
+
sessionId = frame.sessionId;
|
|
299
|
+
currentBindToken = frame.bindToken;
|
|
300
|
+
// A no-op after the first attach — which is exactly why `currentBindToken` exists.
|
|
301
|
+
resolveReady({ sessionId, bindToken: currentBindToken });
|
|
302
|
+
for (const waiter of attachments)
|
|
303
|
+
waiter.resolve({ sessionId, bindToken: currentBindToken });
|
|
304
|
+
attachments.clear();
|
|
305
|
+
return;
|
|
306
|
+
case 'refused': {
|
|
307
|
+
const error = new Error(frame.message);
|
|
308
|
+
rejectReady(error);
|
|
309
|
+
disconnect({ error });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
case 'malformed':
|
|
313
|
+
options.onError?.(frame.error);
|
|
314
|
+
return;
|
|
315
|
+
case 'invocation':
|
|
316
|
+
handleInvocation(frame.action);
|
|
317
|
+
return;
|
|
318
|
+
case 'ignored':
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
current.addEventListener('parked', () => {
|
|
323
|
+
if (source === current)
|
|
324
|
+
disconnect({ error: new Error('the surface is parked') });
|
|
325
|
+
});
|
|
326
|
+
// Intentional visibility pauses keep their transport owner, which reopens on visibility/activity.
|
|
327
|
+
current.addEventListener('suspended', () => {
|
|
328
|
+
if (source === current)
|
|
329
|
+
invalidate({ error: new Error('the surface is suspended') });
|
|
330
|
+
});
|
|
331
|
+
current.addEventListener('error', (event) => {
|
|
332
|
+
if (source !== current)
|
|
276
333
|
return;
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
// this fires the token in hand is already gone from the registry. Holding it until the next
|
|
283
|
-
// `attached` frame leaves a window where `bindToken()` hands back something dead, and a run
|
|
284
|
-
// started in that window fails to bind — reported to the agent only as "no frontend is bound
|
|
285
|
-
// to this run", with the real reason (a reconnect it never saw) nowhere in view.
|
|
286
|
-
// Reporting no token is the honest answer for a surface that is, right now, not attached.
|
|
287
|
-
currentBindToken = undefined;
|
|
288
|
-
sessionId = undefined;
|
|
289
|
-
options.onError?.(event);
|
|
290
|
-
});
|
|
334
|
+
disconnect({ error: new Error('the frontend stream failed') });
|
|
335
|
+
options.onError?.(event);
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
connect();
|
|
291
339
|
const bridgeAccess = {
|
|
292
340
|
subscribe(onAction) {
|
|
293
341
|
chatListeners.add(onAction);
|
|
@@ -301,21 +349,46 @@ export function createFrontendSessionBridge({ baseUrl: base, openStream, request
|
|
|
301
349
|
bridgeAccess,
|
|
302
350
|
ready,
|
|
303
351
|
bindToken: () => currentBindToken,
|
|
352
|
+
async rebind({ signal } = {}, _optional = {}) {
|
|
353
|
+
signal?.throwIfAborted();
|
|
354
|
+
if (closed)
|
|
355
|
+
throw new Error('the frontend bridge is closed');
|
|
356
|
+
if (sessionId !== undefined && currentBindToken !== undefined)
|
|
357
|
+
return { sessionId, bindToken: currentBindToken };
|
|
358
|
+
return new Promise((resolve, reject) => {
|
|
359
|
+
const cleanup = () => { attachments.delete(waiter); signal?.removeEventListener('abort', abort); };
|
|
360
|
+
const waiter = {
|
|
361
|
+
resolve: (value) => { cleanup(); resolve(value); },
|
|
362
|
+
reject: (error) => { cleanup(); reject(error); },
|
|
363
|
+
};
|
|
364
|
+
const abort = () => waiter.reject(signal?.reason);
|
|
365
|
+
attachments.add(waiter);
|
|
366
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
367
|
+
if (!source) {
|
|
368
|
+
try {
|
|
369
|
+
connect();
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
disconnect({ error });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
},
|
|
304
377
|
close() {
|
|
305
|
-
|
|
306
|
-
source.close();
|
|
307
|
-
chatListeners.clear();
|
|
378
|
+
closed = true;
|
|
308
379
|
// A closed bridge serves nothing, so binding a run to it would strand that run rather than
|
|
309
380
|
// fail it — this is what stops a torn-down surface (a StrictMode remount, a route change)
|
|
310
381
|
// from handing its dead token to the next run.
|
|
311
|
-
|
|
312
|
-
|
|
382
|
+
disconnect({ error: new Error('the frontend bridge is closed') });
|
|
383
|
+
chatListeners.clear();
|
|
313
384
|
},
|
|
314
385
|
};
|
|
315
386
|
}
|
|
316
387
|
/** Authenticated streaming replacement for EventSource when credentials belong in headers.
|
|
317
388
|
* Uses the shared SSE decoder; reconnect creates a new attachment and clears the old token.
|
|
318
389
|
* O(stream bytes), bounded frame buffer; close aborts fetch/readers and the reconnect timer. */
|
|
390
|
+
// 0.6.x compatibility: existing hosts may omit fetch;
|
|
391
|
+
// remove when fetch injection becomes required in the next minor.
|
|
319
392
|
export function openFetchFrontendStream({ url, headers }, { fetch: send = globalThis.fetch, reconnectMs = 1000 } = {}) {
|
|
320
393
|
const events = new EventTarget();
|
|
321
394
|
const abort = new AbortController();
|
|
@@ -331,14 +404,14 @@ export function openFetchFrontendStream({ url, headers }, { fetch: send = global
|
|
|
331
404
|
for await (const frame of decodeSseFrames({ source: streamChunks(response.body) }, { maxBufferChars: 128_000 })) {
|
|
332
405
|
delay = reconnectMs; // A delivered event proves the connection recovered.
|
|
333
406
|
if (!abort.signal.aborted)
|
|
334
|
-
events.dispatchEvent(new MessageEvent('message', { data: frame.data }));
|
|
407
|
+
events.dispatchEvent(new MessageEvent(frame.event === 'parked' ? 'parked' : 'message', { data: frame.data }));
|
|
335
408
|
}
|
|
336
409
|
}
|
|
337
410
|
catch { /* The stream error has one value-free notification below. */ }
|
|
338
411
|
if (abort.signal.aborted)
|
|
339
412
|
return;
|
|
340
413
|
events.dispatchEvent(new Event('error'));
|
|
341
|
-
if (retryable) {
|
|
414
|
+
if (retryable && !abort.signal.aborted) {
|
|
342
415
|
retry = setTimeout(() => { void connect(); }, delay);
|
|
343
416
|
delay = Math.min(delay * 2, 30_000);
|
|
344
417
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { formatErrorForLog } from '@jini-ai/core';
|
|
2
3
|
/**
|
|
3
4
|
* @module Composer
|
|
4
5
|
*
|
|
@@ -12,6 +13,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
12
13
|
* the slots a host supplies; it does not itself know what a "library
|
|
13
14
|
* picker" or "session mode" is.
|
|
14
15
|
*/
|
|
16
|
+
import { FolderChips } from './FolderChips.js';
|
|
15
17
|
import { useEffect, useLayoutEffect, useRef, useState, } from 'react';
|
|
16
18
|
import { RemixIcon } from '@jini-ai/ui';
|
|
17
19
|
import { useT } from '../hooks/context.js';
|
|
@@ -20,7 +22,7 @@ import { AttachmentTray } from './AttachmentTray.js';
|
|
|
20
22
|
import { ComposerDiscoveryMenu, ComposerSlashMenu } from './ComposerDiscovery.js';
|
|
21
23
|
import { appendComposerDiscovery, composerDiscoveryMenuPosition, composerSlashMenuPosition, filterComposerDiscovery, parseComposerSlashQuery, replaceComposerSlashTrigger, resolveComposerSlashInvocation, resolveComposerSlashKeyAction, } from './composer-discovery.js';
|
|
22
24
|
function reportComposerHostEffectFailure(effectName, error) {
|
|
23
|
-
console.error(`[@jini-ai/chat] Composer ${effectName} host effect failed:`, error);
|
|
25
|
+
console.error(`[@jini-ai/chat] Composer ${effectName} host effect failed:`, formatErrorForLog({ error }));
|
|
24
26
|
}
|
|
25
27
|
/**
|
|
26
28
|
* Invokes host code synchronously, then observes either its synchronous result or async failure.
|
|
@@ -320,7 +322,7 @@ export function Composer({ composer, onSend, disabled = false, sendDisabled = fa
|
|
|
320
322
|
event.preventDefault();
|
|
321
323
|
runComposerHostEffect('attachmentPicker.onFiles', () => attachmentPicker.onFiles(files));
|
|
322
324
|
}
|
|
323
|
-
return (_jsxs("div", { className: "jini-composer", children: [_jsx(AccessorySlot, { className: "jini-composer-leading", children: leadingAccessories }), _jsx(AttachmentTray, { attachments: composer.attachments, onRemove: composer.removeAttachment }), _jsx("textarea", { ref: textareaRef, className: "jini-composer-input", value: composer.draft, placeholder: placeholder ?? t('Send a message…'), disabled: disabled, onChange: (e) => {
|
|
325
|
+
return (_jsxs("div", { className: "jini-composer", children: [_jsx(AccessorySlot, { className: "jini-composer-leading", children: leadingAccessories }), _jsxs("div", { className: "jini-composer-attachments", children: [_jsx(AttachmentTray, { attachments: composer.attachments, onRemove: composer.removeAttachment }), _jsx(FolderChips, { folders: composer.folders, onRemove: composer.removeFolder })] }), _jsx("textarea", { ref: textareaRef, className: "jini-composer-input", value: composer.draft, placeholder: placeholder ?? t('Send a message…'), disabled: disabled, onChange: (e) => {
|
|
324
326
|
composer.setDraft(e.target.value);
|
|
325
327
|
setSlashActiveIndex(0);
|
|
326
328
|
setDismissedSlashDraft(null);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { formatErrorForLog } from '@jini-ai/core';
|
|
2
3
|
/**
|
|
3
4
|
* @module ConversationList
|
|
4
5
|
*
|
|
@@ -33,7 +34,7 @@ import { ConversationTitleEditor } from './ConversationTitleEditor.js';
|
|
|
33
34
|
*/
|
|
34
35
|
const DOUBLE_CLICK_GRACE_MS = 500;
|
|
35
36
|
function reportConversationListHostEffectFailure(effectName, error) {
|
|
36
|
-
console.error(`[@jini-ai/chat] ConversationList ${effectName} host effect failed:`, error);
|
|
37
|
+
console.error(`[@jini-ai/chat] ConversationList ${effectName} host effect failed:`, formatErrorForLog({ error }));
|
|
37
38
|
}
|
|
38
39
|
/** Invokes a host callback and reports a synchronous throw or async rejection without leaving it unhandled. */
|
|
39
40
|
function runConversationListHostEffect(effectName, effect) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { formatErrorForLog } from '@jini-ai/core';
|
|
2
3
|
import { useRef, useState } from 'react';
|
|
3
4
|
import { MAX_CONVERSATION_TITLE_LENGTH } from '../../core/conversation-title.js';
|
|
4
5
|
import { useT } from '../hooks/context.js';
|
|
@@ -28,7 +29,7 @@ export function ConversationTitleEditor({ title, onCommit, onCancel, className =
|
|
|
28
29
|
await onCommit(next);
|
|
29
30
|
}
|
|
30
31
|
catch (error) {
|
|
31
|
-
console.error('[@jini-ai/chat] ConversationTitleEditor onCommit host effect failed:', error);
|
|
32
|
+
console.error('[@jini-ai/chat] ConversationTitleEditor onCommit host effect failed:', formatErrorForLog({ error }));
|
|
32
33
|
}
|
|
33
34
|
};
|
|
34
35
|
return _jsx("input", { autoFocus: true, className: className, "aria-label": t('Rename chat'), "data-testid": testId, maxLength: MAX_CONVERSATION_TITLE_LENGTH, value: draft, onChange: (event) => setDraft(event.currentTarget.value), onBlur: () => { void finish(); }, onKeyDown: (event) => {
|
|
@@ -3,6 +3,8 @@ interface CopyMessageButtonProps {
|
|
|
3
3
|
text: string;
|
|
4
4
|
/** Accessible name while idle; swaps to a "Copied" label once the copy lands. */
|
|
5
5
|
label: string;
|
|
6
|
+
/** Optional hover tooltip; icon-only buttons outside a message action row have no other visible label. */
|
|
7
|
+
title?: string | undefined;
|
|
6
8
|
}
|
|
7
9
|
/**
|
|
8
10
|
* Quiet, icon-only copy affordance shared by the user- and assistant-message
|
|
@@ -11,8 +13,9 @@ interface CopyMessageButtonProps {
|
|
|
11
13
|
* the icon swap and the accessible announcement of the transient state.
|
|
12
14
|
* @param props.text - Exact string to copy, verbatim.
|
|
13
15
|
* @param props.label - Idle accessible name (already translated by the caller).
|
|
16
|
+
* @param props.title - Optional hover tooltip (already translated); swaps to "Copied" like the label.
|
|
14
17
|
* @complexity O(1) — one child hook call, no branching beyond the copied-state read.
|
|
15
18
|
*/
|
|
16
|
-
export declare function CopyMessageButton({ text, label }: CopyMessageButtonProps): import("react").JSX.Element;
|
|
19
|
+
export declare function CopyMessageButton({ text, label, title }: CopyMessageButtonProps): import("react").JSX.Element;
|
|
17
20
|
export {};
|
|
18
21
|
//# sourceMappingURL=CopyMessageButton.d.ts.map
|
|
@@ -10,12 +10,13 @@ import { Icon } from './Icon.js';
|
|
|
10
10
|
* the icon swap and the accessible announcement of the transient state.
|
|
11
11
|
* @param props.text - Exact string to copy, verbatim.
|
|
12
12
|
* @param props.label - Idle accessible name (already translated by the caller).
|
|
13
|
+
* @param props.title - Optional hover tooltip (already translated); swaps to "Copied" like the label.
|
|
13
14
|
* @complexity O(1) — one child hook call, no branching beyond the copied-state read.
|
|
14
15
|
*/
|
|
15
|
-
export function CopyMessageButton({ text, label }) {
|
|
16
|
+
export function CopyMessageButton({ text, label, title }) {
|
|
16
17
|
const t = useT();
|
|
17
18
|
const { copied, copy } = useCopyToClipboard();
|
|
18
|
-
return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "jini-message-action-btn", "aria-label": copied ? t('Copied') : label, onClick: () => {
|
|
19
|
+
return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "jini-message-action-btn", "aria-label": copied ? t('Copied') : label, title: title && (copied ? t('Copied') : title), onClick: () => {
|
|
19
20
|
void copy(text);
|
|
20
21
|
}, children: _jsx(Icon, { name: copied ? 'check' : 'copy' }) }), _jsx("span", { className: "jini-message-copy-status", "aria-live": "polite", children: copied ? t('Copied to clipboard') : '' })] }));
|
|
21
22
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { formatErrorForLog } from '@jini-ai/core';
|
|
2
3
|
/**
|
|
3
4
|
* @module ExtEventErrorBoundary
|
|
4
5
|
*
|
|
@@ -25,7 +26,7 @@ export class ExtEventErrorBoundary extends Component {
|
|
|
25
26
|
// React already surfaces this in the dev console via its own error-boundary reporting; this
|
|
26
27
|
// adds the ext-event `name` React's generic report has no way to know, and gives a host a
|
|
27
28
|
// hook to route it into its own error reporting.
|
|
28
|
-
console.error(`[ExtEventErrorBoundary] ext event "${this.props.name}" failed to render:`, error);
|
|
29
|
+
console.error(`[ExtEventErrorBoundary] ext event "${this.props.name}" failed to render:`, formatErrorForLog({ error }));
|
|
29
30
|
this.props.onError?.(error, info);
|
|
30
31
|
}
|
|
31
32
|
render() {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
type OnRemove = (input: {
|
|
2
|
+
path: string;
|
|
3
|
+
}, options?: {}) => void;
|
|
4
|
+
/**
|
|
5
|
+
* Shared composer/sent-message folder indicator; references never participate in uploads.
|
|
6
|
+
* One folder shows its chip directly; a "Folders (n)" toggle would only repeat its name.
|
|
7
|
+
*/
|
|
8
|
+
export declare function FolderChips({ folders, onRemove }: {
|
|
9
|
+
folders?: readonly string[] | undefined;
|
|
10
|
+
onRemove?: OnRemove | undefined;
|
|
11
|
+
}): import("react").JSX.Element | null;
|
|
12
|
+
export {};
|
|
13
|
+
//# sourceMappingURL=FolderChips.d.ts.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useId, useState } from 'react';
|
|
3
|
+
import { useT } from '../hooks/context.js';
|
|
4
|
+
import { CopyMessageButton } from './CopyMessageButton.js';
|
|
5
|
+
import { Icon } from './Icon.js';
|
|
6
|
+
/** Display only: splitting both separators never changes the exact path carried or copied. */
|
|
7
|
+
function folderName({ path }, _options = {}) {
|
|
8
|
+
return path.split(/[\\/]/).filter(Boolean).at(-1) ?? path;
|
|
9
|
+
}
|
|
10
|
+
/** One folder: last name (full path on hover), copy, and × when removable. */
|
|
11
|
+
function FolderChip({ path, onRemove }) {
|
|
12
|
+
const t = useT();
|
|
13
|
+
const copyLabel = t('Copy folder path: {path}', { path });
|
|
14
|
+
const removeLabel = t('Remove folder: {path}', { path });
|
|
15
|
+
return _jsxs("li", { className: "jini-folder-chips__item", children: [_jsx(Icon, { name: "folder" }), _jsx("span", { className: "jini-folder-chips__name", title: path, "aria-label": path, children: folderName({ path }) }), _jsx(CopyMessageButton, { text: path, label: copyLabel, title: copyLabel }), onRemove && _jsx("button", { type: "button", className: "jini-folder-chips__remove", title: removeLabel, "aria-label": removeLabel, onClick: () => onRemove({ path }), children: _jsx(Icon, { name: "close" }) })] });
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Shared composer/sent-message folder indicator; references never participate in uploads.
|
|
19
|
+
* One folder shows its chip directly; a "Folders (n)" toggle would only repeat its name.
|
|
20
|
+
*/
|
|
21
|
+
export function FolderChips({ folders = [], onRemove }) {
|
|
22
|
+
const t = useT();
|
|
23
|
+
const [expanded, setExpanded] = useState(false);
|
|
24
|
+
const id = useId();
|
|
25
|
+
if (folders.length === 0)
|
|
26
|
+
return null;
|
|
27
|
+
const list = (listId) => _jsx("ul", { id: listId, className: "jini-folder-chips__list", children: folders.map(path => _jsx(FolderChip, { path: path, onRemove: onRemove }, path)) });
|
|
28
|
+
if (folders.length === 1)
|
|
29
|
+
return _jsx("div", { className: "jini-folder-chips", children: list() });
|
|
30
|
+
return _jsxs("div", { className: "jini-folder-chips", children: [_jsxs("button", { type: "button", className: "jini-folder-chips__toggle", "aria-expanded": expanded, "aria-controls": id, onClick: () => setExpanded(value => !value), children: [_jsx(Icon, { name: "folder" }), _jsx("span", { children: t('Folders ({count})', { count: folders.length }) }), _jsx(Icon, { name: expanded ? 'chevron-down' : 'chevron-right' })] }), expanded && list(id)] });
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=FolderChips.js.map
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* AgentIcon and WorkingDirPicker.
|
|
8
8
|
*/
|
|
9
9
|
import type { SVGProps } from 'react';
|
|
10
|
-
export type IconName = 'spinner' | 'check' | 'close' | 'chevron-down' | 'chevron-left' | 'chevron-right' | 'attach' | 'file' | 'image' | 'x' | 'comment' | 'search' | 'plus' | 'pencil' | 'copy' | 'retry';
|
|
10
|
+
export type IconName = 'spinner' | 'check' | 'close' | 'chevron-down' | 'chevron-left' | 'chevron-right' | 'attach' | 'folder' | 'file' | 'image' | 'x' | 'comment' | 'search' | 'plus' | 'pencil' | 'copy' | 'retry';
|
|
11
11
|
interface IconProps extends Omit<SVGProps<SVGSVGElement>, 'name'> {
|
|
12
12
|
name: IconName;
|
|
13
13
|
size?: number | string;
|
|
@@ -15,6 +15,7 @@ const ICON_GLYPHS = {
|
|
|
15
15
|
'chevron-left': _jsx("path", { d: "m15 18-6-6 6-6" }),
|
|
16
16
|
'chevron-right': _jsx("path", { d: "m9 18 6-6-6-6" }),
|
|
17
17
|
attach: _jsx("path", { d: "m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" }),
|
|
18
|
+
folder: _jsx("path", { d: "M3 7V5a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" }),
|
|
18
19
|
file: _jsxs(_Fragment, { children: [_jsx("path", { d: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" }), _jsx("path", { d: "M14 2v6h6" }), _jsx("path", { d: "M8 13h8M8 17h6" })] }),
|
|
19
20
|
image: _jsxs(_Fragment, { children: [_jsx("rect", { width: "18", height: "18", x: "3", y: "3", rx: "2" }), _jsx("circle", { cx: "9", cy: "9", r: "2" }), _jsx("path", { d: "m21 15-5-5L5 21" })] }),
|
|
20
21
|
pencil: _jsxs(_Fragment, { children: [_jsx("path", { d: "m16 3 5 5L8 21H3v-5L16 3Z" }), _jsx("path", { d: "m14 5 5 5" })] }),
|
|
@@ -71,6 +71,7 @@ import { useT } from '../hooks/context.js';
|
|
|
71
71
|
import { useSurfaceExpiry } from '../hooks/useSurfaceExpiry.js';
|
|
72
72
|
import { closedSurfaceLabel, reportDeliveredAnswer } from './mcp-ui-surface-state.js';
|
|
73
73
|
import { surfaceQuestionInput } from './mcp-ui-question-input.js';
|
|
74
|
+
import { useSurfaceRecovery } from '../hooks/useSurfaceRecovery.js';
|
|
74
75
|
/** The `kind: 'ext'` event name this renderer claims. */
|
|
75
76
|
export const MCP_UI_EXT_EVENT_NAME = 'mcp-ui';
|
|
76
77
|
/** Collapses the event stream to the newest resource per `ui://` URI, in first-appearance order. */
|
|
@@ -147,18 +148,24 @@ function surfaceInteraction({ resource, answered, onToolCall, onAnswered, onAnsw
|
|
|
147
148
|
/** Registered against `ext-event-renderer-registry.ts`'s `'mcp-ui'` name — see module doc. */
|
|
148
149
|
export function McpUiSurfaceCard({ events, sandboxProxyUrl, onToolCall, onOpenLink, maxHeight, call, runStreaming, expiryClock, answered, onAnswered, onAnswerText, answerText, answerRecorded }) {
|
|
149
150
|
const t = useT();
|
|
150
|
-
const
|
|
151
|
+
const originalResources = useMemo(() => latestResourcesByUri(events), [events]);
|
|
152
|
+
const recovery = useSurfaceRecovery({ resource: originalResources.length === 1 ? originalResources[0] : undefined,
|
|
153
|
+
call, runStreaming, onToolCall });
|
|
154
|
+
const resources = recovery.restored && recovery.resource ? [recovery.resource] : originalResources;
|
|
151
155
|
// One card per slot is the registered shape (`mcpUiSurfaceSlotKey`), so `call` belongs to exactly
|
|
152
156
|
// that card. A group holding several URIs has no single call to judge them by, and stays live.
|
|
153
157
|
const single = resources.length === 1 ? resources[0] : undefined;
|
|
154
|
-
const
|
|
155
|
-
const
|
|
158
|
+
const currentEvents = recovery.restored && recovery.resource ? [recovery.resource] : events;
|
|
159
|
+
const currentCall = recovery.restored ? undefined : call;
|
|
160
|
+
const { displayCall, input, standalone } = useSurfaceQuestionCall({ events: currentEvents, call: currentCall });
|
|
161
|
+
const { expiresAtMs, toolCall } = surfaceInteraction({ resource: single, answered, onToolCall: recovery.onToolCall, onAnswered, onAnswerText, call: displayCall });
|
|
156
162
|
const expiry = useSurfaceExpiry({ expiresAtMs }, expiryClock === undefined ? {} : { clock: expiryClock });
|
|
157
163
|
const closed = single === undefined
|
|
158
164
|
? undefined
|
|
159
|
-
: closedSurfaceLabel({ t, call, runStreaming
|
|
165
|
+
: closedSurfaceLabel({ t, call: currentCall, runStreaming: recovery.live || runStreaming,
|
|
166
|
+
documentsForUri: currentEvents.length, expired: expiry?.expired === true });
|
|
160
167
|
// A completed call answered the original document; a replacement outcome or retry owns its view.
|
|
161
|
-
const answer = (
|
|
168
|
+
const answer = (currentEvents.length === 1 ? questionAnswerFromResult({ result: currentCall?.result, input }) : undefined) ?? answerText;
|
|
162
169
|
return _jsx(SurfaceCardContent, { resources: resources, single: single, answerRecorded: answerRecorded, answer: answer, closed: closed, call: displayCall, standalone: standalone, t: t, sandboxProxyUrl: sandboxProxyUrl, toolCall: toolCall, onOpenLink: onOpenLink, maxHeight: maxHeight, expiry: expiry });
|
|
163
170
|
}
|
|
164
171
|
/** A standalone form's call may lack the question; its current document supplies the title and
|
|
@@ -10,6 +10,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
10
10
|
* scroll-anchoring state lives in `useMessageListAutoScroll`
|
|
11
11
|
* (`MessageList.hooks.ts`); see that file for why it is presentational.
|
|
12
12
|
*/
|
|
13
|
+
import { FolderChips } from './FolderChips.js';
|
|
13
14
|
import { memo, useMemo } from 'react';
|
|
14
15
|
import { projectAcceptedInputMessages } from '../../core/accepted-input.js';
|
|
15
16
|
import { isTerminalRunStatus } from '../../core/index.js';
|
|
@@ -55,6 +56,6 @@ function QueuedMessage({ prompt, renderAttachment }) {
|
|
|
55
56
|
const t = useT();
|
|
56
57
|
const attachments = prompt.attachments ?? [];
|
|
57
58
|
const gallery = useAttachmentGallery({ attachments });
|
|
58
|
-
return (_jsxs("div", { className: "jini-chat-pane__queued", role: "status", "aria-label": t('Message queued — not yet sent, will send once the current run finishes'), "data-testid": "chat-pane-queued", "data-agent-element": "chat-message-pending", "data-agent-role": "region", "data-agent-label": "A message queued to send once the current run finishes", children: [_jsx("div", { children: t('Queued — sends when this reply finishes') }), _jsx("div", { className: "jini-chat-pane__queued-text", children: prompt.text }), attachments.length > 0 && _jsx("div", { className: "jini-message-attachments", children: attachments.map(attachment => _jsx(SentAttachmentButton, { attachment: attachment, onOpen: gallery.open, renderAttachment: renderAttachment }, attachment.path)) }), gallery.opened !== null && _jsx(AttachmentPreviewModal, { attachment: gallery.opened, onClose: gallery.close, navigation: gallery.navigation }), _jsx("div", { className: "jini-chat-pane__queued-actions", children: _jsx("button", { type: "button", className: "jini-chat-pane__queued-cancel", onClick: () => prompt.onCancel(), title: t('Cancel queued message'), children: t('Cancel') }) })] }));
|
|
59
|
+
return (_jsxs("div", { className: "jini-chat-pane__queued", role: "status", "aria-label": t('Message queued — not yet sent, will send once the current run finishes'), "data-testid": "chat-pane-queued", "data-agent-element": "chat-message-pending", "data-agent-role": "region", "data-agent-label": "A message queued to send once the current run finishes", children: [_jsx("div", { children: t('Queued — sends when this reply finishes') }), prompt.folders?.length ? _jsx("div", { className: "jini-message-attachments", children: _jsx(FolderChips, { folders: prompt.folders }) }) : null, _jsx("div", { className: "jini-chat-pane__queued-text", children: prompt.text }), attachments.length > 0 && _jsx("div", { className: "jini-message-attachments", children: attachments.map(attachment => _jsx(SentAttachmentButton, { attachment: attachment, onOpen: gallery.open, renderAttachment: renderAttachment }, attachment.path)) }), gallery.opened !== null && _jsx(AttachmentPreviewModal, { attachment: gallery.opened, onClose: gallery.close, navigation: gallery.navigation }), _jsx("div", { className: "jini-chat-pane__queued-actions", children: _jsx("button", { type: "button", className: "jini-chat-pane__queued-cancel", onClick: () => prompt.onCancel(), title: t('Cancel queued message'), children: t('Cancel') }) })] }));
|
|
59
60
|
}
|
|
60
61
|
//# sourceMappingURL=MessageList.js.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { FolderChips } from './FolderChips.js';
|
|
2
3
|
import { projectAcceptedInputEvents } from '../../core/accepted-input.js';
|
|
3
4
|
import { MidRunUserMessage, MID_RUN_USER_MESSAGE_EXT_EVENT_NAME } from './MidRunUserMessage.js';
|
|
4
5
|
import { useSurfaceAnswerContexts } from './mcp-ui-question-input.js';
|
|
@@ -184,7 +185,7 @@ function UserMessageRow({ message, renderAttachment }) {
|
|
|
184
185
|
// different messages opening their own attachments independently is fine and expected.
|
|
185
186
|
// Previous/next steps through this message's images only.
|
|
186
187
|
const gallery = useAttachmentGallery({ attachments: message.attachments ?? NO_ATTACHMENTS });
|
|
187
|
-
return (_jsxs("div", { className: "jini-message jini-message-user", "data-message-id": message.id, "data-agent-element": `chat-message-${message.id}`, "data-agent-role": "region", "data-agent-label": "A message from the user", children: [message.attachments
|
|
188
|
+
return (_jsxs("div", { className: "jini-message jini-message-user", "data-message-id": message.id, "data-agent-element": `chat-message-${message.id}`, "data-agent-role": "region", "data-agent-label": "A message from the user", children: [(message.attachments?.length || message.folders?.length) ? (_jsxs("div", { className: "jini-message-attachments", children: [_jsx(FolderChips, { folders: message.folders }), (message.attachments ?? []).map((a) => (_jsx(SentAttachmentButton, { attachment: a, onOpen: gallery.open, renderAttachment: renderAttachment }, a.path)))] })) : null, _jsx("div", { className: "jini-message-content", children: message.content }), _jsx("div", { className: "jini-message-actions jini-message-actions--user", children: _jsx(CopyMessageButton, { text: message.content, label: t('Copy message') }) }), gallery.opened ? (_jsx(AttachmentPreviewModal, { attachment: gallery.opened, onClose: gallery.close, navigation: gallery.navigation })) : null] }));
|
|
188
189
|
}
|
|
189
190
|
function AssistantMessageRow({ message, usageSummary, showUsageTotal, showUsageLine, toolCallDisplay, showThinking, runStreaming, runSending, sendingLabel, runSucceeded, questionFormInteractive, questionFormSubmittedAnswers, onQuestionFormSubmit, projectFileNames, onRequestOpenFile, }) {
|
|
190
191
|
const registry = useExtEventRendererRegistry();
|
|
@@ -10,6 +10,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
10
10
|
* It is stored as an event of the assistant turn, not a `ChatMessage` of its own, because the agent folded it
|
|
11
11
|
* into the turn it was already running — there is no separate reply to hang off it.
|
|
12
12
|
*/
|
|
13
|
+
import { FolderChips } from './FolderChips.js';
|
|
13
14
|
import { acceptedInputEvent } from '../../core/accepted-input.js';
|
|
14
15
|
import { SentAttachmentButton } from './SentAttachment.js';
|
|
15
16
|
import { CopyMessageButton } from './CopyMessageButton.js';
|
|
@@ -37,7 +38,7 @@ export function MidRunUserMessage({ events, runStreaming = false, transcriptRow
|
|
|
37
38
|
return null;
|
|
38
39
|
const receipt = acceptedInputEvent({ event: { kind: 'ext', name: MID_RUN_USER_MESSAGE_EVENT_NAME, data } });
|
|
39
40
|
// MOVE: A single live delivery may contain several queued inputs; display their original receipts.
|
|
40
|
-
const inputs = receipt?.data.inputs ?? [{ id: field(data, 'id') ?? 'mid-run', text }];
|
|
41
|
+
const inputs = receipt?.data.inputs ?? [receipt?.data ?? { id: field(data, 'id') ?? 'mid-run', text }];
|
|
41
42
|
// A run that ended without reading the message hands it to the next turn; it is no longer queued here.
|
|
42
43
|
return _jsx(_Fragment, { children: inputs.map(input => _jsx(MidRunInput, { input: input, transcriptRow: transcriptRow, queued: runStreaming && (input.deliveryState ?? receipt?.data.deliveryState) === 'queued' }, input.id)) });
|
|
43
44
|
}
|
|
@@ -48,7 +49,7 @@ function MidRunInput({ input, queued, transcriptRow }) {
|
|
|
48
49
|
const t = useT();
|
|
49
50
|
const attachments = input.attachments ?? [];
|
|
50
51
|
const gallery = useAttachmentGallery({ attachments });
|
|
51
|
-
return (_jsxs("div", { className: `jini-message jini-message-user${transcriptRow ? '' : ' jini-message-user--mid-run'}`, "data-agent-role": "region", "data-agent-label": "A message from the user, sent while the agent was working", children: [queued && _jsx("div", { className: "jini-message-user__queued", role: "status", children: t('Queued — read after the current step') }), _jsx("div", { className: "jini-message-content", children: input.text }), attachments.length > 0 && _jsx("div", { className: "jini-message-attachments", children: attachments.map(attachment => _jsx(SentAttachmentButton, { attachment: attachment, onOpen: gallery.open }, attachment.path)) }), _jsx("div", { className: "jini-message-actions jini-message-actions--user", children: _jsx(CopyMessageButton, { text: input.text, label: t('Copy message') }) }), gallery.opened !== null && _jsx(AttachmentPreviewModal, { attachment: gallery.opened, onClose: gallery.close, navigation: gallery.navigation })] }));
|
|
52
|
+
return (_jsxs("div", { className: `jini-message jini-message-user${transcriptRow ? '' : ' jini-message-user--mid-run'}`, "data-agent-role": "region", "data-agent-label": "A message from the user, sent while the agent was working", children: [queued && _jsx("div", { className: "jini-message-user__queued", role: "status", children: t('Queued — read after the current step') }), input.folders?.length ? _jsx("div", { className: "jini-message-attachments", children: _jsx(FolderChips, { folders: input.folders }) }) : null, _jsx("div", { className: "jini-message-content", children: input.text }), attachments.length > 0 && _jsx("div", { className: "jini-message-attachments", children: attachments.map(attachment => _jsx(SentAttachmentButton, { attachment: attachment, onOpen: gallery.open }, attachment.path)) }), _jsx("div", { className: "jini-message-actions jini-message-actions--user", children: _jsx(CopyMessageButton, { text: input.text, label: t('Copy message') }) }), gallery.opened !== null && _jsx(AttachmentPreviewModal, { attachment: gallery.opened, onClose: gallery.close, navigation: gallery.navigation })] }));
|
|
52
53
|
}
|
|
53
54
|
/**
|
|
54
55
|
* Registers {@link MidRunUserMessage} against the ext-event registry.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { formatErrorForLog } from '@jini-ai/core';
|
|
2
3
|
const RUNNING_TITLE_CLASS = ' shimmer-text';
|
|
3
4
|
/**
|
|
4
5
|
* @module ToolCard
|
|
@@ -79,7 +80,7 @@ function renderRegistered({ use, card, registry }) {
|
|
|
79
80
|
return node;
|
|
80
81
|
}
|
|
81
82
|
catch (err) {
|
|
82
|
-
console.error(`[ToolCard] custom renderer for "${use.name}" threw; falling back`, err);
|
|
83
|
+
console.error(`[ToolCard] custom renderer for "${use.name}" threw; falling back`, formatErrorForLog({ error: err }));
|
|
83
84
|
}
|
|
84
85
|
return undefined;
|
|
85
86
|
}
|
|
@@ -124,7 +125,7 @@ function renderToolCardBody(args) {
|
|
|
124
125
|
* The head every Jini/MCP/unknown tool row shares: status, "<Humanized Name>" (announced as
|
|
125
126
|
* "Tool call · <Humanized Name>"), and the
|
|
126
127
|
* target ({@link toolRowTarget}). One place, so a tool id the family table doesn't know (`content_post_update`,
|
|
127
|
-
* `
|
|
128
|
+
* `mcp__acme__theme_list_files`) still reads as a label and never as its raw name and JSON args.
|
|
128
129
|
*/
|
|
129
130
|
function ToolRowHead({ name, input, result, runStreaming, runSucceeded }) {
|
|
130
131
|
const t = useT();
|
|
@@ -48,7 +48,8 @@ function resultName(content) {
|
|
|
48
48
|
const obj = parsed;
|
|
49
49
|
if (isErrorResult(obj))
|
|
50
50
|
return undefined;
|
|
51
|
-
|
|
51
|
+
// The nested level gets the same error check: `{ result: { error, name: 'NotFoundError' } }` is still a failure.
|
|
52
|
+
const nested = Object.values(obj).filter((v) => v != null && typeof v === 'object' && !Array.isArray(v) && !isErrorResult(v));
|
|
52
53
|
return firstReadable(obj, RESULT_NAME_KEYS) ?? nested.map((v) => firstReadable(v, RESULT_NAME_KEYS)).find(Boolean);
|
|
53
54
|
}
|
|
54
55
|
/** A batch member's own target: a readable string member, or an object member's summary key. O(k). */
|