@jini-ai/chat 0.6.17 → 0.6.21

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 (75) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/core/accepted-input.d.ts +10 -2
  3. package/dist/core/accepted-input.js +87 -33
  4. package/dist/core/assistant-client/durable-run-subscription.js +6 -0
  5. package/dist/core/assistant-client/entry.d.ts +1 -1
  6. package/dist/core/assistant-client/entry.js +1 -1
  7. package/dist/core/assistant-client/messages.d.ts +21 -0
  8. package/dist/core/assistant-client/messages.js +31 -0
  9. package/dist/core/checkpoint-interval.d.ts +25 -0
  10. package/dist/core/checkpoint-interval.js +38 -0
  11. package/dist/core/durable-runs/ledger-contracts.d.ts +27 -1
  12. package/dist/core/durable-runs/ports.d.ts +25 -1
  13. package/dist/core/events.d.ts +6 -0
  14. package/dist/core/index.d.ts +1 -0
  15. package/dist/core/index.js +1 -0
  16. package/dist/core/run-events/entry.js +15 -2
  17. package/dist/core/surface-recovery.d.ts +19 -0
  18. package/dist/core/surface-recovery.js +26 -0
  19. package/dist/core/tool-events.d.ts +11 -3
  20. package/dist/core/tool-events.js +75 -43
  21. package/dist/core/turn-usage.d.ts +1 -1
  22. package/dist/core/turn-usage.js +5 -3
  23. package/dist/embed-widget/browser/frontend-session-bridge.d.ts +14 -1
  24. package/dist/embed-widget/browser/frontend-session-bridge.js +120 -47
  25. package/dist/react/components/Composer.d.ts +3 -3
  26. package/dist/react/components/Composer.js +5 -4
  27. package/dist/react/components/ConversationList.js +2 -1
  28. package/dist/react/components/ConversationTitleEditor.js +2 -1
  29. package/dist/react/components/ExtEventErrorBoundary.js +2 -1
  30. package/dist/react/components/McpUiSurfaceCard.js +12 -5
  31. package/dist/react/components/MessageList.js +2 -1
  32. package/dist/react/components/MidRunUserMessage.js +1 -1
  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 -1
  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.d.ts +5 -4
  40. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +17 -9
  41. package/dist/react/features/chat-pane/hooks/useChatPaneFileDrop.hooks.js +3 -2
  42. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +20 -3
  43. package/dist/react/features/chat-pane/model-options.d.ts +10 -0
  44. package/dist/react/features/chat-pane/model-options.js +22 -3
  45. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  46. package/dist/react/features/chat-pane/styles.js +3 -1
  47. package/dist/react/features/chat-pane/surface-recovery-stream.d.ts +12 -0
  48. package/dist/react/features/chat-pane/surface-recovery-stream.js +47 -0
  49. package/dist/react/hooks/composer-draft-cache.d.ts +6 -6
  50. package/dist/react/hooks/composer-draft-cache.js +18 -17
  51. package/dist/react/hooks/useComposer.js +52 -4
  52. package/dist/react/hooks/useConversation.js +41 -22
  53. package/dist/react/hooks/useRunStream.js +2 -1
  54. package/dist/react/hooks/useSurfaceRecovery.d.ts +23 -0
  55. package/dist/react/hooks/useSurfaceRecovery.js +84 -0
  56. package/dist/react/styles/reference.css +4 -5
  57. package/dist/server/durable-runs/recover.js +3 -3
  58. package/dist/store/ports.d.ts +17 -3
  59. package/dist/store/runtime/chat-expiry-sweep.js +3 -1
  60. package/dist/store/runtime/chat-history-store.js +6 -0
  61. package/dist/store/runtime/checkpoint-delta.d.ts +35 -0
  62. package/dist/store/runtime/checkpoint-delta.js +75 -0
  63. package/dist/store/runtime/durable-run-store.js +92 -25
  64. package/dist/store/runtime/events-json.d.ts +17 -0
  65. package/dist/store/runtime/events-json.js +19 -0
  66. package/dist/store/runtime/run-guarded-chat-store.js +8 -3
  67. package/dist/store/runtime/run-ledger.d.ts +2 -2
  68. package/dist/store/runtime/run-ledger.js +120 -45
  69. package/dist/store/sql/read-version.d.ts +15 -0
  70. package/dist/store/sql/read-version.js +21 -0
  71. package/dist/store/sql/store.d.ts +9 -0
  72. package/dist/store/sql/store.js +89 -4
  73. package/dist/store/sqlite/schema.d.ts +2 -2
  74. package/dist/store/sqlite/schema.js +4 -0
  75. package/package.json +12 -12
@@ -49,7 +49,7 @@ function MidRunInput({ input, queued, transcriptRow }) {
49
49
  const t = useT();
50
50
  const attachments = input.attachments ?? [];
51
51
  const gallery = useAttachmentGallery({ attachments });
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
+ 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-chat-pane__status 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 })] }));
53
53
  }
54
54
  /**
55
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
- * `mcp__tovu__theme_list_files`) still reads as a label and never as its raw name and JSON args.
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
- const nested = Object.values(obj).filter((v) => v != null && typeof v === 'object' && !Array.isArray(v));
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). */
@@ -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,
@@ -225,7 +225,7 @@ function ChatPaneComposerArea({ fileDrop, uploadAttachments, pane, disabled, una
225
225
  // rather than refusing it, so the operator can keep writing while the agent thinks.
226
226
  disabled: disabled || unavailable,
227
227
  // The one blocker `send()` handles itself. Every other refusal still greys out the button.
228
- sendDisabled: !pane.canSend && pane.sendBlocker !== 'streaming', ...definedProps({ source: { sendDisabledReason, sendDisabledReasonId: sendDisabledReason ? sendDisabledReasonId : undefined } }), running: pane.conversation.isStreaming, onCancel: pane.conversation.cancel, onInterrupt: pane.interruptSend, ...definedProps({ source: { placeholder } }), slots: { ...slots, footerAccessories: _jsxs(_Fragment, { children: [sendDisabledReason ? _jsx("small", { id: sendDisabledReasonId, className: "jini-composer-send-blocker", children: sendDisabledReason }) : null, slots.footerAccessories] }) }, ...resolveComposerAttachmentPicker(uploadAttachments, pane, attachmentAccept), ...resolveComposerWorkingDirectory(workingDirectoryAccess, workingDirectoryControlPlacement, pane) }), _jsx(ChatPaneWorkingDirectoryBlock, { workingDirectoryAccess: workingDirectoryAccess, workingDirectoryControlPlacement: workingDirectoryControlPlacement, pane: pane, t: t })] }));
228
+ sendDisabled: !pane.canSend && pane.sendBlocker !== 'streaming', ...definedProps({ source: { sendDisabledReason, sendDisabledReasonId: sendDisabledReason ? sendDisabledReasonId : undefined } }), running: pane.conversation.isStreaming, onCancel: pane.conversation.cancel, ...definedProps({ source: { placeholder } }), slots: { ...slots, footerAccessories: _jsxs(_Fragment, { children: [sendDisabledReason ? _jsx("small", { id: sendDisabledReasonId, className: "jini-composer-send-blocker", children: sendDisabledReason }) : null, slots.footerAccessories] }) }, ...resolveComposerAttachmentPicker(uploadAttachments, pane, attachmentAccept), ...resolveComposerWorkingDirectory(workingDirectoryAccess, workingDirectoryControlPlacement, pane) }), _jsx(ChatPaneWorkingDirectoryBlock, { workingDirectoryAccess: workingDirectoryAccess, workingDirectoryControlPlacement: workingDirectoryControlPlacement, pane: pane, t: t })] }));
229
229
  }
230
230
  const EMPTY_SUGGESTIONS = [];
231
231
  const CHAT_PANE_DEFAULTS = {
@@ -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
@@ -115,10 +115,11 @@ export interface UseChatPaneResult extends UseChatPaneWorkingDirectoryResult {
115
115
  */
116
116
  sendAsNewMessage: () => Promise<void>;
117
117
  /**
118
- * Cancels the run in flight and sends the composer draft as soon as it stops — the modifier-key
119
- * counterpart to {@link send}, which queues behind the run instead of ending it. Implemented as
120
- * queue-then-cancel rather than cancel-then-send so both paths share one flush, and so a cancel
121
- * that never lands cannot strand the prompt.
118
+ * @deprecated Use send and let Composer apply sendDisabled to keyboard shortcuts.
119
+ * Legacy name for the modifier-key send shortcut; uses {@link send} and keeps the run going.
120
+ * Previously queue-then-cancel rather than cancel-then-send kept both paths on one flush so a
121
+ * cancel that never landed could not strand the prompt. Sharing send now also preserves that
122
+ * queue ownership, while a capable run reads the input after its current step.
122
123
  */
123
124
  interruptSend: () => void;
124
125
  reset: () => void;
@@ -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)
@@ -742,15 +756,9 @@ export function useChatPane(options) {
742
756
  guard.clearNotice();
743
757
  await sendOrdinaryTurn(prompt);
744
758
  }, [composer, guard.clearNotice, sendOrdinaryTurn]);
745
- const interruptSend = useCallback(() => {
746
- const prompt = composerPrompt(composer);
747
- if (!prompt || composerDeliveryBlocked({ delivering: deliveringTypedAnswerRef.current, starting: startingPromptRef.current, composer, submitted: submittedComposerRef.current }))
748
- return;
749
- guard.clearNotice();
750
- enqueueTurn({ turn: guard.redact({ text: prompt }, {}) });
751
- composer.setDraft('');
752
- conversation.cancel();
753
- }, [composer, conversation, enqueueTurn, guard.clearNotice, guard.redact]);
759
+ // A send shortcut must honor the same mid-run delivery contract as the Send button.
760
+ // Unsupported agents wait behind the current reply; only the explicit Stop action cancels it.
761
+ const interruptSend = useCallback(() => { void send(); }, [send]);
754
762
  /** Abort only uploads owned by the removed inputs; late completions must not refill the composer. */
755
763
  const discardQueuedUploads = useCallback(({ id }, _options = {}) => {
756
764
  for (const [controller, owner] of queuedUploadOwnersRef.current) {
@@ -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 "…".