@jini-ai/chat 0.6.17 → 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 (40) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/core/events.d.ts +6 -0
  3. package/dist/core/run-events/entry.js +15 -2
  4. package/dist/core/surface-recovery.d.ts +19 -0
  5. package/dist/core/surface-recovery.js +26 -0
  6. package/dist/core/tool-events.d.ts +6 -1
  7. package/dist/core/tool-events.js +74 -27
  8. package/dist/core/turn-usage.d.ts +1 -1
  9. package/dist/core/turn-usage.js +5 -3
  10. package/dist/embed-widget/browser/frontend-session-bridge.d.ts +14 -1
  11. package/dist/embed-widget/browser/frontend-session-bridge.js +120 -47
  12. package/dist/react/components/Composer.js +2 -1
  13. package/dist/react/components/ConversationList.js +2 -1
  14. package/dist/react/components/ConversationTitleEditor.js +2 -1
  15. package/dist/react/components/ExtEventErrorBoundary.js +2 -1
  16. package/dist/react/components/McpUiSurfaceCard.js +12 -5
  17. package/dist/react/components/ToolCard.js +3 -2
  18. package/dist/react/components/ToolCard.rules.js +2 -1
  19. package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +5 -3
  20. package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.d.ts +15 -2
  21. package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.js +19 -3
  22. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +14 -0
  23. package/dist/react/features/chat-pane/hooks/useChatPaneFileDrop.hooks.js +3 -2
  24. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +20 -3
  25. package/dist/react/features/chat-pane/model-options.d.ts +10 -0
  26. package/dist/react/features/chat-pane/model-options.js +22 -3
  27. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  28. package/dist/react/features/chat-pane/styles.js +3 -1
  29. package/dist/react/features/chat-pane/surface-recovery-stream.d.ts +12 -0
  30. package/dist/react/features/chat-pane/surface-recovery-stream.js +47 -0
  31. package/dist/react/hooks/composer-draft-cache.d.ts +6 -6
  32. package/dist/react/hooks/composer-draft-cache.js +18 -17
  33. package/dist/react/hooks/useComposer.js +52 -4
  34. package/dist/react/hooks/useConversation.js +28 -20
  35. package/dist/react/hooks/useRunStream.js +2 -1
  36. package/dist/react/hooks/useSurfaceRecovery.d.ts +23 -0
  37. package/dist/react/hooks/useSurfaceRecovery.js +84 -0
  38. package/dist/react/styles/reference.css +4 -5
  39. package/dist/store/runtime/chat-expiry-sweep.js +3 -1
  40. package/package.json +12 -12
@@ -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) => {
@@ -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() {
@@ -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 resources = useMemo(() => latestResourcesByUri(events), [events]);
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 { displayCall, input, standalone } = useSurfaceQuestionCall({ events, call });
155
- const { expiresAtMs, toolCall } = surfaceInteraction({ resource: single, answered, onToolCall, onAnswered, onAnswerText, call: displayCall });
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, documentsForUri: events.length, expired: expiry?.expired === true });
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 = (events.length === 1 ? questionAnswerFromResult({ result: call?.result, input }) : undefined) ?? answerText;
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
@@ -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,
@@ -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
@@ -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)
@@ -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 "…".