@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
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { pickerModelValue, pickerCatalogNote, pickerModelOptions, shortModelLabel } from '../model-options.js';
2
+ import { friendlyModelLabel, pickerModelValue, pickerCatalogNote, pickerModelOptions, shortModelLabel } from '../model-options.js';
3
3
  import { useId } from 'react';
4
4
  import { createPortal } from 'react-dom';
5
5
  import { AgentIcon, RemixIcon, SearchableModelSelect } from '@jini-ai/ui';
@@ -62,9 +62,11 @@ function RuntimeAgentRow({ agent, active, noteId, onChange, agentIconBasePath, t
62
62
  function RuntimeBarMenus({ selectedAgent, value, onChange, modelLabel, t }) {
63
63
  if (!selectedAgent)
64
64
  return null;
65
- const models = pickerModelOptions(selectedAgent);
65
+ // Raw catalog ids (an offline fallback list's rows) read as friendly names in the bar and its list.
66
+ const models = pickerModelOptions(selectedAgent).map((row) => ({ ...row, label: friendlyModelLabel({ label: row.label }) }));
66
67
  const efforts = selectedAgent.reasoningOptions ?? [];
67
- return (_jsxs(_Fragment, { children: [models.length > 0 ? (_jsx(RuntimeDropUp, { label: t('Model'), valueLabel: modelLabel,
68
+ const modelName = friendlyModelLabel({ label: modelLabel });
69
+ return (_jsxs(_Fragment, { children: [models.length > 0 ? (_jsx(RuntimeDropUp, { label: t('Model'), valueLabel: modelName,
68
70
  // The agent icon sits right beside it, so the bar drops the vendor word and caps the rest.
69
71
  triggerLabel: shortModelLabel({ label: modelLabel, agentId: selectedAgent.id }), value: pickerModelValue(selectedAgent, value.model), options: models, note: pickerCatalogNote(selectedAgent, t), width: 260, onSelect: (model) => onChange({
70
72
  agentId: value.agentId,
@@ -271,6 +271,7 @@ function useComposerHandlePublisher({ pane }, { composerHandle }) {
271
271
  if (!composerHandle)
272
272
  return undefined;
273
273
  composerHandle.current = {
274
+ addFolders: pane.composer.addFolders,
274
275
  insertText: (text) => {
275
276
  pane.composer.setDraft(appendComposerDiscovery({ draft: composerDraftRef.current, insertText: text }));
276
277
  },
@@ -34,7 +34,20 @@
34
34
  * single-use token minted into the dialog is what proves a human saw it. Adding a client-side
35
35
  * allow-list would suggest a protection that is not real.
36
36
  */
37
- import type { McpUiToolCallHandler } from '@jini-ai/ui/mcp-ui';
37
+ import type { McpUiToolCall, McpUiToolCallHandler } from '@jini-ai/ui/mcp-ui';
38
+ import type { SurfaceEmission } from '@jini-ai/core';
39
+ import type { SurfaceRecoveryInfo } from '../../../core/surface-recovery.js';
40
+ export interface SurfaceRecoveryCallbacks {
41
+ readonly onSurface: (emission: SurfaceEmission) => void;
42
+ readonly signal: AbortSignal;
43
+ }
44
+ /** Callable as before; lifecycle controllers can also restore without replaying browser input. */
45
+ export type RecoverableMcpUiToolCaller = McpUiToolCallHandler & {
46
+ restore(required: SurfaceRecoveryInfo & SurfaceRecoveryCallbacks, options?: Record<string, never>): Promise<unknown>;
47
+ answer(required: {
48
+ call: McpUiToolCall;
49
+ } & SurfaceRecoveryCallbacks, options?: Record<string, never>): Promise<unknown>;
50
+ };
38
51
  export interface CreateMcpUiToolCallerOptions {
39
52
  /**
40
53
  * Path appended to `baseUrl`. Defaults to `/api/mcp-ui/tool-calls`.
@@ -69,5 +82,5 @@ export interface McpUiToolCallRequest {
69
82
  export declare function createMcpUiToolCaller({ baseUrl, fetch }: {
70
83
  baseUrl: string;
71
84
  fetch: typeof globalThis.fetch;
72
- }, options?: CreateMcpUiToolCallerOptions): McpUiToolCallHandler;
85
+ }, options?: CreateMcpUiToolCallerOptions): RecoverableMcpUiToolCaller;
73
86
  //# sourceMappingURL=create-mcp-ui-tool-caller.d.ts.map
@@ -1,3 +1,4 @@
1
+ import { readSurfaceRecoveryStream } from './surface-recovery-stream.js';
1
2
  const DEFAULT_PATH = '/api/mcp-ui/tool-calls';
2
3
  const DEFAULT_TIMEOUT_MS = 30_000;
3
4
  function errorTextFrom(body, status) {
@@ -35,7 +36,7 @@ function timeoutAware(error, timeoutMs) {
35
36
  : error;
36
37
  }
37
38
  /** One POST of a confirmed action and its parsed answer; a non-2xx response rejects via {@link errorFrom}. */
38
- async function postToolCall({ call, endpoint, headers, signal, fetch }) {
39
+ async function postToolCall({ call, endpoint, headers, signal, fetch, onSurface, exchangeId, restore }) {
39
40
  // `{name, arguments}` on the MCP-UI side becomes `{toolName, params}` on the wire: `arguments`
40
41
  // is a reserved word in a JS function scope and reads badly in a server handler, and the
41
42
  // renamed pair matches what the reference implementation's redemption route already accepts.
@@ -48,9 +49,12 @@ async function postToolCall({ call, endpoint, headers, signal, fetch }) {
48
49
  // Same-origin cookie session is the usual authenticator for the human's second step.
49
50
  credentials: 'same-origin',
50
51
  headers: { ...headers, 'content-type': 'application/json' },
51
- body: JSON.stringify(request),
52
+ body: JSON.stringify({ ...request, ...(restore ? { exchangeId, restore } : {}) }),
52
53
  signal,
53
54
  });
55
+ if (response.ok && response.headers.get('content-type')?.includes('application/x-ndjson')) {
56
+ return readSurfaceRecoveryStream({ response, onSurface });
57
+ }
54
58
  const raw = await response.text();
55
59
  let parsed = raw;
56
60
  if (raw.length > 0) {
@@ -83,12 +87,24 @@ export function createMcpUiToolCaller({ baseUrl, fetch }, options = {}) {
83
87
  const path = options.path ?? DEFAULT_PATH;
84
88
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
85
89
  const endpoint = `${baseUrl.replace(/\/$/u, '')}${path}`;
86
- return async (call) => {
90
+ const answer = async (call) => {
87
91
  const controller = new AbortController();
88
92
  const timer = setTimeout(() => controller.abort(), timeoutMs);
89
93
  return postToolCall({ call, endpoint, headers: options.headers, signal: controller.signal, fetch })
90
94
  .catch((error) => { throw timeoutAware(error, timeoutMs); })
91
95
  .finally(() => clearTimeout(timer));
92
96
  };
97
+ return Object.assign(answer, {
98
+ // Recovery streams deliberately have no reading-time deadline. The controller cancels on
99
+ // unmount; the server's executing-call lease handles run cancellation independently.
100
+ restore: (required) => postToolCall({
101
+ call: { name: required.toolId, arguments: {} }, endpoint, headers: options.headers, fetch,
102
+ signal: required.signal, onSurface: required.onSurface, exchangeId: required.exchangeId, restore: true,
103
+ }),
104
+ answer: (required) => postToolCall({
105
+ call: required.call, endpoint, headers: options.headers, fetch,
106
+ signal: required.signal, onSurface: required.onSurface,
107
+ }),
108
+ });
93
109
  }
94
110
  //# sourceMappingURL=create-mcp-ui-tool-caller.js.map
@@ -50,12 +50,12 @@ const ATTACHMENTS_ONLY_PROMPT = 'Review the attached file(s).';
50
50
  */
51
51
  function composerPrompt(composer) {
52
52
  return composer.draft.trim()
53
- || (composer.attachments.length > 0 ? ATTACHMENTS_ONLY_PROMPT : '');
53
+ || (composer.attachments.length > 0 || composer.folders?.length ? ATTACHMENTS_ONLY_PROMPT : '');
54
54
  }
55
55
  /** History hydration can replace a controller without an edit. Compare the accepted empty draft
56
56
  * and staged files so holding Enter cannot requeue an attachment-only submission on that render. */
57
57
  function isRepeatedComposerSubmission({ composer, submitted }, _options = {}) {
58
- return submitted === composer || (submitted !== null && composer.draft.trim() === '' && submitted.attachments === composer.attachments);
58
+ return submitted === composer || (submitted !== null && composer.draft.trim() === '' && submitted.attachments === composer.attachments && submitted.folders === composer.folders);
59
59
  }
60
60
  function composerDeliveryBlocked({ delivering, starting, composer, submitted }, _options = {}) {
61
61
  return delivering || starting || isRepeatedComposerSubmission({ composer, submitted });
@@ -262,6 +262,20 @@ export function useChatPane(options) {
262
262
  queued, next: { text: input.text, secretRedacted: input.secretRedaction?.secretRedacted ?? false,
263
263
  count: input.secretRedaction?.count ?? 0 },
264
264
  }), null), [queuedInputs]);
265
+ const releaseStateRef = useRef({ streaming: conversation.isStreaming, queued: queuedInputs.length > 0 });
266
+ releaseStateRef.current = { streaming: conversation.isStreaming, queued: queuedInputs.length > 0 };
267
+ useEffect(() => {
268
+ if (typeof window === 'undefined')
269
+ return;
270
+ const onReleaseCheck = (event) => {
271
+ const { streaming, queued } = releaseStateRef.current;
272
+ // Read mutable operation locks at dispatch time, including the gap before React renders.
273
+ if (streaming || queued || activeUploadsRef.current.size > 0 || startingPromptRef.current || deliveringTypedAnswerRef.current)
274
+ event.preventDefault();
275
+ };
276
+ window.addEventListener('jini:release-check', onReleaseCheck);
277
+ return () => window.removeEventListener('jini:release-check', onReleaseCheck);
278
+ }, []);
265
279
  // Release consumed text/files locally as well as hiding them; next-turn fallback uses this same projection.
266
280
  useEffect(() => {
267
281
  if (queuedInputs.length !== storedQueuedInputs.length)
@@ -331,6 +345,8 @@ export function useChatPane(options) {
331
345
  latestComposerRef.current = composer;
332
346
  const latestDraftRef = useRef(composer.draft);
333
347
  latestDraftRef.current = composer.draft;
348
+ const latestFoldersRef = useRef(composer.folders ?? []);
349
+ latestFoldersRef.current = composer.folders ?? [];
334
350
  const latestAttachmentsRef = useRef(composer.attachments);
335
351
  latestAttachmentsRef.current = composer.attachments;
336
352
  useEffect(() => {
@@ -440,6 +456,7 @@ export function useChatPane(options) {
440
456
  const restoreQueuedDraft = useCallback(({ inputs, text, attachments }, _options = {}) => {
441
457
  if (inputs === undefined)
442
458
  return;
459
+ latestComposerRef.current.addFolders({ folders: inputs.flatMap(input => input.folders ?? []) });
443
460
  latestComposerRef.current.setDraft([text, latestDraftRef.current].filter(Boolean).join('\n\n'));
444
461
  const staged = new Set(latestAttachmentsRef.current.map(attachment => attachment.path));
445
462
  for (const attachment of attachments) {
@@ -449,20 +466,21 @@ export function useChatPane(options) {
449
466
  }, []);
450
467
  /** A refused composer send is kept for Retry and goes back only into a composer that is still
451
468
  * empty: text typed (or files staged) while it was sending are never overwritten. */
452
- const restoreRefusedDraft = useCallback(({ redaction, draft, attachments }, _options = {}) => {
453
- refusedSendRef.current = { redaction, draft, attachments };
469
+ const restoreRefusedDraft = useCallback(({ redaction, draft, attachments, folders = [] }, _options = {}) => {
470
+ refusedSendRef.current = { redaction, draft, attachments, folders };
471
+ latestComposerRef.current.addFolders({ folders });
454
472
  if (latestDraftRef.current.trim() !== '' || latestAttachmentsRef.current.length > 0)
455
473
  return;
456
474
  latestComposerRef.current.setDraft(draft);
457
475
  for (const attachment of attachments)
458
476
  latestComposerRef.current.addAttachment(attachment);
459
477
  }, []);
460
- const finishPromptSend = useCallback(({ accepted, inputs, redaction, submittedDraft, attachments, composerDraft }, _options = {}) => {
478
+ const finishPromptSend = useCallback(({ accepted, inputs, redaction, submittedDraft, attachments, composerDraft, folders }, _options = {}) => {
461
479
  if (!accepted) {
462
480
  if (composerDraft === undefined)
463
481
  restoreQueuedDraft({ inputs, text: redaction.text, attachments });
464
482
  else
465
- restoreRefusedDraft({ redaction, draft: composerDraft, attachments });
483
+ restoreRefusedDraft({ redaction, draft: composerDraft, attachments, folders });
466
484
  return;
467
485
  }
468
486
  refusedSendRef.current = null;
@@ -471,11 +489,17 @@ export function useChatPane(options) {
471
489
  latestComposerRef.current.setDraft('');
472
490
  latestComposerRef.current.closeMention();
473
491
  }
492
+ // Composer/queued sends already took these references. A later pick, even of the same path,
493
+ // belongs to the next draft and must survive this send's asynchronous acceptance.
494
+ if (composerDraft === undefined && inputs === undefined) {
495
+ for (const path of folders)
496
+ latestComposerRef.current.removeFolder({ path });
497
+ }
474
498
  consumeDeliveredAttachments({ attachments });
475
499
  }, [consumeDeliveredAttachments, restoreQueuedDraft, restoreRefusedDraft]);
476
500
  /** `composerDraft`: the composer text this send already took out of the composer, so the
477
501
  * composer is never cleared again on acceptance and a refusal hands the text back. */
478
- const sendGuardedPrompt = useCallback(async ({ redaction, inputs, attachments = composer.attachments, composerDraft }, _options = {}) => {
502
+ const sendGuardedPrompt = useCallback(async ({ redaction, inputs, attachments = composer.attachments, composerDraft, folders = inputs ? inputs.flatMap(input => input.folders ?? []) : composer.folders ?? [] }, _options = {}) => {
479
503
  const trimmed = redaction.text.trim();
480
504
  assertPromptSendable({ trimmed, sendBlocker });
481
505
  if (startingPromptRef.current)
@@ -494,6 +518,7 @@ export function useChatPane(options) {
494
518
  // Empty attachment arrays remain absent at the transport boundary.
495
519
  attachments: attachments.length === 0 ? undefined : attachments,
496
520
  context,
521
+ folders: folders.length ? folders : undefined,
497
522
  acceptedInputs: inputs,
498
523
  onAccepted: ({ inputs: acceptedInputs }) => {
499
524
  accepted = true;
@@ -503,7 +528,7 @@ export function useChatPane(options) {
503
528
  } }));
504
529
  if (!mountedRef.current || generation !== typedAnswerGenerationRef.current)
505
530
  return;
506
- finishPromptSend({ accepted, inputs, redaction, submittedDraft, attachments, composerDraft });
531
+ finishPromptSend({ accepted, inputs, redaction, submittedDraft, attachments, composerDraft, folders });
507
532
  }
508
533
  finally {
509
534
  startingPromptRef.current = false;
@@ -520,24 +545,27 @@ export function useChatPane(options) {
520
545
  return;
521
546
  const draft = latestDraftRef.current;
522
547
  const attachments = latestAttachmentsRef.current;
548
+ const folders = latestFoldersRef.current;
549
+ for (const path of folders)
550
+ latestComposerRef.current.removeFolder({ path });
523
551
  latestComposerRef.current.setDraft('');
524
552
  latestComposerRef.current.closeMention();
525
553
  for (const attachment of attachments)
526
554
  latestComposerRef.current.removeAttachment(attachment.path);
527
555
  const redaction = guard.redact({ text: prompt.trim() }, {});
528
556
  try {
529
- await sendGuardedPrompt({ redaction, attachments, composerDraft: draft });
557
+ await sendGuardedPrompt({ redaction, attachments, folders, composerDraft: draft });
530
558
  }
531
559
  catch (error) {
532
560
  // A host hook that throws (run context, activity) must not cost the typed text either.
533
- restoreRefusedDraft({ redaction, draft, attachments });
561
+ restoreRefusedDraft({ redaction, draft, attachments, folders });
534
562
  throw error;
535
563
  }
536
564
  }, [guard.redact, restoreRefusedDraft, sendBlocker, sendGuardedPrompt]);
537
565
  // MOVE: Every queue path retains per-message receipts; appendQueuedTurn owns their one-turn delivery.
538
566
  // A turn queued against another conversation is not joined — it would never be flushed there anyway.
539
567
  const enqueueTurn = useCallback(({ turn, inputs, attachments = composer.attachments }, _options = {}) => {
540
- const accepted = inputs ?? [{ id: createChatLocalId({}), text: turn.text, attachments,
568
+ const accepted = inputs ?? [{ id: createChatLocalId({}), text: turn.text, attachments, folders: composer.folders ?? [],
541
569
  ...(turn.secretRedacted ? { secretRedaction: { secretRedacted: true, count: turn.count } } : {}) }];
542
570
  const sameConversation = queuedConversationIdRef.current === options.conversationId;
543
571
  queuedConversationIdRef.current = options.conversationId;
@@ -550,7 +578,9 @@ export function useChatPane(options) {
550
578
  // MOVE: Take files out of the composer without rotating their batch: every queued message ships together.
551
579
  for (const attachment of attachments)
552
580
  latestComposerRef.current.removeAttachment(attachment.path);
553
- }, [composer.attachments, options.conversationId, recordAcceptedInput]);
581
+ for (const path of composer.folders ?? [])
582
+ latestComposerRef.current.removeFolder({ path });
583
+ }, [composer.folders, composer.attachments, options.conversationId, recordAcceptedInput]);
554
584
  const answerWaitingQuestion = useCallback(async ({ deliver, questionId, text }) => {
555
585
  const safeText = guard.redactText(text);
556
586
  const generation = typedAnswerGenerationRef.current;
@@ -647,7 +677,8 @@ export function useChatPane(options) {
647
677
  queuedForLiveDeliveryRef.current = queuedTurn === null || queuedForLiveDeliveryRef.current;
648
678
  }
649
679
  // MOVE: The existing queue owns its files; a later steer must join it to preserve send order.
650
- const target = uploadPending || queuedInputs.some(input => input.liveMessageId === undefined) ? null : findMidRunTarget({
680
+ // Folder context is attached on ordinary run start; live messages have no such envelope.
681
+ const target = uploadPending || composer.folders?.length || queuedInputs.some(input => input.liveMessageId === undefined) ? null : findMidRunTarget({
651
682
  deliver: options.deliverMidRunMessage,
652
683
  blocker: sendBlocker,
653
684
  attachmentCount: composer.attachments.length,
@@ -686,7 +717,7 @@ export function useChatPane(options) {
686
717
  await sendOrdinaryTurn(prompt);
687
718
  return;
688
719
  }
689
- const typedAnswer = findTypedAnswerTarget({
720
+ const typedAnswer = composer.folders?.length ? null : findTypedAnswerTarget({
690
721
  deliver: options.deliverTypedAnswer,
691
722
  blocker: sendBlocker,
692
723
  attachmentCount: composer.attachments.length,
@@ -715,7 +746,7 @@ export function useChatPane(options) {
715
746
  }
716
747
  if (sendBlocker !== null || startingPromptRef.current)
717
748
  return;
718
- await sendGuardedPrompt({ redaction: refused.redaction, attachments: refused.attachments, composerDraft: refused.draft });
749
+ await sendGuardedPrompt({ redaction: refused.redaction, attachments: refused.attachments, composerDraft: refused.draft, folders: refused.folders });
719
750
  }, [send, sendBlocker, sendGuardedPrompt]);
720
751
  const sendAsNewMessage = useCallback(async () => {
721
752
  const prompt = composerPrompt(composer);
@@ -773,7 +804,7 @@ export function useChatPane(options) {
773
804
  }
774
805
  if (activeUploadCount > 0 || attachmentError !== null)
775
806
  return;
776
- const target = queuedForLiveDeliveryRef.current ? findMidRunTarget({
807
+ const target = queuedForLiveDeliveryRef.current && !queuedInputs.some(input => input.folders?.length) ? findMidRunTarget({
777
808
  deliver: options.deliverMidRunMessage,
778
809
  blocker: sendBlocker, attachmentCount: queuedInputs.flatMap(input => input.attachments).length, messages: conversation.messages,
779
810
  }) : null;
@@ -1,3 +1,4 @@
1
+ import { formatErrorForLog } from '@jini-ai/core';
1
2
  import { useCallback, } from 'react';
2
3
  import { useFileDropTarget } from '@jini-ai/ui';
3
4
  function carriesFiles(event) {
@@ -12,11 +13,11 @@ function runOnFilesHostEffect(onFiles, files) {
12
13
  result = onFiles(files);
13
14
  }
14
15
  catch (error) {
15
- console.error('[@jini-ai/chat] useChatPaneFileDrop onFiles host effect failed:', error);
16
+ console.error('[@jini-ai/chat] useChatPaneFileDrop onFiles host effect failed:', formatErrorForLog({ error }));
16
17
  return;
17
18
  }
18
19
  void Promise.resolve(result).catch((error) => {
19
- console.error('[@jini-ai/chat] useChatPaneFileDrop onFiles host effect failed:', error);
20
+ console.error('[@jini-ai/chat] useChatPaneFileDrop onFiles host effect failed:', formatErrorForLog({ error }));
20
21
  });
21
22
  }
22
23
  /**
@@ -100,13 +100,30 @@ export function useRuntimeDropUp({ width }) {
100
100
  const checked = menu?.querySelector(`${MENU_ITEMS}[aria-checked="true"]`);
101
101
  (checked ?? menu?.querySelector(MENU_ITEMS))?.focus();
102
102
  }, [open, position]);
103
+ // Escape is also handled here, on the menu and the trigger themselves: the document listener
104
+ // above never hears a keydown that something between them and the document stopped (demo
105
+ // practice run, 2026-11-02: Escape left the model picker open). preventDefault marks it consumed,
106
+ // so a host's own Escape (the admin dock closing) leaves this press to the menu.
107
+ const closeOnOwnEscape = useCallback((event) => {
108
+ if (event.key !== 'Escape')
109
+ return false;
110
+ event.preventDefault();
111
+ closeToTrigger();
112
+ return true;
113
+ }, [closeToTrigger]);
103
114
  const handleTriggerKeyDown = useCallback((event) => {
104
- if (open || (event.key !== 'ArrowUp' && event.key !== 'ArrowDown'))
115
+ if (open) {
116
+ closeOnOwnEscape(event);
117
+ return;
118
+ }
119
+ if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')
105
120
  return;
106
121
  event.preventDefault();
107
122
  setOpen(true);
108
- }, [open]);
123
+ }, [closeOnOwnEscape, open]);
109
124
  const handleMenuKeyDown = useCallback((event) => {
125
+ if (closeOnOwnEscape(event))
126
+ return;
110
127
  if (event.key === 'Tab') {
111
128
  setOpen(false);
112
129
  return;
@@ -123,7 +140,7 @@ export function useRuntimeDropUp({ width }) {
123
140
  return;
124
141
  event.preventDefault();
125
142
  items[next]?.focus();
126
- }, []);
143
+ }, [closeOnOwnEscape]);
127
144
  const choose = useCallback((apply) => {
128
145
  apply();
129
146
  closeToTrigger();
@@ -12,6 +12,16 @@ export declare function configuredPickerModel(agent: ChatPaneAgent): string | un
12
12
  export declare function pickerModelLabel(agent: ChatPaneAgent | undefined, requested?: string, t?: I18nAdapter['t']): string;
13
13
  export declare function pickerModelValue(agent: ChatPaneAgent, requested?: string): string;
14
14
  export declare function pickerCatalogNote(agent: ChatPaneAgent, t?: I18nAdapter['t']): string | undefined;
15
+ /**
16
+ * A catalog row's display name: a bare raw id ("claude-opus-5-5", which offline fallback lists carry)
17
+ * reads as "Claude Opus 5.5"; a human label is kept as written. Still exact: a date or context suffix
18
+ * stays, and a routed id ("anthropic/…") is left whole, since its prefix names a different model.
19
+ * @example friendlyModelLabel({ label: 'claude-haiku-4-5-20251001' }) // "Claude Haiku 4.5 20251001"
20
+ * @complexity O(n) in the label length. Pure.
21
+ */
22
+ export declare function friendlyModelLabel({ label }: {
23
+ label: string;
24
+ }, _options?: Record<string, never>): string;
15
25
  /**
16
26
  * The composer bar's compact model name: the catalog label (or a prettified raw id when the catalog
17
27
  * has no label), minus the leading vendor word the agent's icon already conveys, capped with "…".
@@ -35,6 +35,10 @@ export function pickerCatalogNote(agent, t = passthrough) {
35
35
  const catalog = agent.modelCatalog;
36
36
  if (catalog?.freshness === 'stale')
37
37
  return `${t('Cached')} · ${catalog.fetchedAt}`;
38
+ // While the host is still probing, the live list has not failed yet: the rows are the
39
+ // cached/fallback list the inventory contract says to show meanwhile, not an offline verdict.
40
+ if (agent.discoveryPending)
41
+ return undefined;
38
42
  if (catalog?.freshness === 'offline-fallback' || !catalog && agent.models)
39
43
  return t('Offline fallback');
40
44
  return undefined;
@@ -49,21 +53,36 @@ const BAR_VENDOR_WORDS = {
49
53
  // A bare catalog id ("claude-opus-5-5", "gpt-6-astra[1m]", "anthropic/claude-sonnet-5") rather than a display name.
50
54
  const RAW_MODEL_ID = /^(?:[a-z0-9._-]+\/)*[a-z0-9][a-z0-9._-]*(?:\[[^\]]*\])?$/;
51
55
  const BRAND_CASING = { gpt: 'GPT' };
52
- function prettifyRawModelId(id) {
56
+ function prettifyRawModelId(id, { keepDates = false } = {}) {
53
57
  // A routing prefix ("anthropic/") is the full name's job; the bar names only the model.
54
58
  // The caller proved RAW_MODEL_ID, so the first '[' can only start its trailing context suffix.
55
- const tokens = id.slice(id.lastIndexOf('/') + 1).split('[', 1)[0].split(/[-_]+/).filter((token) => token && !/^\d{8}$/.test(token));
59
+ const tokens = id.slice(id.lastIndexOf('/') + 1).split('[', 1)[0].split(/[-_]+/).filter((token) => token && (keepDates || !/^\d{8}$/.test(token)));
56
60
  const words = [];
57
61
  for (const token of tokens) {
58
62
  const previous = words[words.length - 1];
59
63
  // Dash-separated version digits read as one dotted version: "5-5" → "5.5".
60
- if (/^\d+$/.test(token) && previous && /^\d+(?:\.\d+)*$/.test(previous))
64
+ // A kept 8-digit date stays its own word, never a version part.
65
+ if (/^\d{1,7}$/.test(token) && previous && /^\d+(?:\.\d+)*$/.test(previous))
61
66
  words[words.length - 1] = `${previous}.${token}`;
62
67
  else
63
68
  words.push(BRAND_CASING[token] ?? token.charAt(0).toUpperCase() + token.slice(1));
64
69
  }
65
70
  return words;
66
71
  }
72
+ /**
73
+ * A catalog row's display name: a bare raw id ("claude-opus-5-5", which offline fallback lists carry)
74
+ * reads as "Claude Opus 5.5"; a human label is kept as written. Still exact: a date or context suffix
75
+ * stays, and a routed id ("anthropic/…") is left whole, since its prefix names a different model.
76
+ * @example friendlyModelLabel({ label: 'claude-haiku-4-5-20251001' }) // "Claude Haiku 4.5 20251001"
77
+ * @complexity O(n) in the label length. Pure.
78
+ */
79
+ export function friendlyModelLabel({ label }, _options = {}) {
80
+ if (!RAW_MODEL_ID.test(label) || label.includes('/'))
81
+ return label;
82
+ const suffix = label.indexOf('[');
83
+ const words = prettifyRawModelId(label, { keepDates: true }).join(' ');
84
+ return suffix === -1 ? words : `${words} ${label.slice(suffix)}`;
85
+ }
67
86
  /**
68
87
  * The composer bar's compact model name: the catalog label (or a prettified raw id when the catalog
69
88
  * has no label), minus the leading vendor word the agent's icon already conveys, capped with "…".