@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.
- package/CHANGELOG.md +9 -0
- package/dist/core/events.d.ts +6 -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/turn-usage.d.ts +1 -1
- package/dist/core/turn-usage.js +5 -3
- 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 +2 -1
- package/dist/react/components/ConversationList.js +2 -1
- package/dist/react/components/ConversationTitleEditor.js +2 -1
- package/dist/react/components/ExtEventErrorBoundary.js +2 -1
- package/dist/react/components/McpUiSurfaceCard.js +12 -5
- 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/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 +14 -0
- 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 +3 -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/hooks/composer-draft-cache.d.ts +6 -6
- package/dist/react/hooks/composer-draft-cache.js +18 -17
- package/dist/react/hooks/useComposer.js +52 -4
- package/dist/react/hooks/useConversation.js +28 -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 +4 -5
- package/dist/store/runtime/chat-expiry-sweep.js +3 -1
- 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
|
|
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
|
|
@@ -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). */
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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):
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 "…".
|