@jini-ai/chat 0.5.1 → 0.5.3
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 +24 -1
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/run-activity.d.ts +12 -20
- package/dist/core/run-activity.js +59 -7
- package/dist/core/surface-expiry.d.ts +42 -0
- package/dist/core/surface-expiry.js +42 -0
- package/dist/core/tool-events.d.ts +34 -0
- package/dist/core/tool-events.js +61 -0
- package/dist/core/tools.d.ts +1 -1
- package/dist/core/tools.js +1 -1
- package/dist/react/chat-pane.d.ts +2 -2
- package/dist/react/chat-pane.js +1 -1
- package/dist/react/components/AttachmentPreviewModal.hooks.d.ts +19 -2
- package/dist/react/components/AttachmentPreviewModal.hooks.js +53 -38
- package/dist/react/components/Markdown.js +37 -6
- package/dist/react/components/McpUiSurfaceCard.d.ts +7 -1
- package/dist/react/components/McpUiSurfaceCard.js +23 -38
- package/dist/react/components/MessageList.d.ts +11 -0
- package/dist/react/components/MessageList.hooks.d.ts +19 -0
- package/dist/react/components/MessageList.hooks.js +82 -0
- package/dist/react/components/MessageList.js +3 -71
- package/dist/react/components/MessageList.rules.d.ts +43 -0
- package/dist/react/components/MessageList.rules.js +38 -0
- package/dist/react/components/MessageRow.js +3 -1
- package/dist/react/components/ToolCard.js +10 -7
- package/dist/react/components/mcp-ui-surface-state.d.ts +27 -0
- package/dist/react/components/mcp-ui-surface-state.js +33 -0
- package/dist/react/ext-event-renderer-registry.d.ts +7 -0
- package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
- package/dist/react/features/chat-pane/components/ChatPane.js +6 -5
- package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +54 -0
- package/dist/react/features/chat-pane/create-typed-answer-poster.js +46 -0
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +17 -1
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +153 -7
- package/dist/react/features/chat-pane/index.d.ts +2 -1
- package/dist/react/features/chat-pane/index.js +1 -0
- package/dist/react/features/chat-pane/rules.d.ts +59 -1
- package/dist/react/features/chat-pane/rules.js +74 -0
- package/dist/react/features/chat-pane/types.d.ts +34 -0
- package/dist/react/hooks/composer-draft-cache.d.ts +28 -0
- package/dist/react/hooks/composer-draft-cache.js +56 -6
- package/dist/react/hooks/last-conversation-store.d.ts +58 -0
- package/dist/react/hooks/last-conversation-store.js +112 -0
- package/dist/react/hooks/useSurfaceExpiry.d.ts +20 -0
- package/dist/react/hooks/useSurfaceExpiry.js +35 -0
- package/dist/react/hooks/useToolTimeline.js +6 -3
- package/dist/react/index.d.ts +10 -2
- package/dist/react/index.js +7 -1
- package/package.json +7 -7
|
@@ -34,7 +34,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
34
34
|
* renderer covers:
|
|
35
35
|
* ATX headings (`#`…`###`), fenced code blocks, ordered/unordered lists,
|
|
36
36
|
* blockquotes, a horizontal rule, paragraphs, and inline `` `code` ``/
|
|
37
|
-
* `**bold**`/`*italic
|
|
37
|
+
* `**bold**`/`*italic*`/`[label](url)` links/bare autolinks — including inside table cells, via
|
|
38
38
|
* the same `renderInline` pass every other block uses. TODO(follow-up): port
|
|
39
39
|
* the code-comment directive syntax once a host needs it.
|
|
40
40
|
*/
|
|
@@ -370,21 +370,52 @@ function TableBlock(props) {
|
|
|
370
370
|
setExpanded(false);
|
|
371
371
|
}, children: [_jsx("button", { type: "button", className: "jini-md-table-modal-close", onClick: () => setExpanded(false), "aria-label": "Close", children: "\u00D7" }), _jsx("div", { className: "jini-md-table-modal-body", children: expanded ? renderTableElement(props) : null })] })] }));
|
|
372
372
|
}
|
|
373
|
-
// Inline pass: `code`, **bold**, *italic*/_italic_, bare http(s) autolinks.
|
|
373
|
+
// Inline pass: `code`, [label](url) links, **bold**, *italic*/_italic_, bare http(s) autolinks.
|
|
374
374
|
// Processes left-to-right with a single regex alternation so spans never
|
|
375
375
|
// nest incorrectly across kinds.
|
|
376
|
-
|
|
376
|
+
//
|
|
377
|
+
// `_italic_` follows CommonMark's word-boundary rule: an `_` with a letter,
|
|
378
|
+
// digit or `_` on its outer side never opens or closes emphasis, and an `_`
|
|
379
|
+
// between two word characters inside the span is content. The plain
|
|
380
|
+
// `_[^_]+_` this replaced matched intraword, so a tool-result name like
|
|
381
|
+
// `ANALYTICS_ROOT_KEY_SEED` rendered as `ANALYTICS<em>ROOT</em>KEY_SEED`.
|
|
382
|
+
// `*` stays permissive — CommonMark allows intraword `*` emphasis.
|
|
383
|
+
const UNDERSCORE_EM = String.raw `(?<![\p{L}\p{N}_])_(?![\s_])(?:[^_]|_(?=[\p{L}\p{N}]))+?(?<!\s)_(?![\p{L}\p{N}_])`;
|
|
384
|
+
// `[label](url)`: the URL may hold one level of balanced parentheses (`Foo_(bar)`), and the whole
|
|
385
|
+
// link may itself sit inside parentheses (`([edit](/admin/pages/x))`) — the outer `)` is left as text.
|
|
386
|
+
const MD_LINK = String.raw `\[[^\]\n]+\]\((?:[^()\s]|\([^()\s]*\))+\)`;
|
|
387
|
+
const MD_LINK_PARTS = /^\[([^\]\n]+)\]\((.+)\)$/u;
|
|
388
|
+
const INLINE_RE = new RegExp(String.raw `(\x60[^\x60]+\x60)|(${MD_LINK})|(\*\*[^*]+\*\*)|(\*[^*]+\*|${UNDERSCORE_EM})|(https?:\/\/[^\s)]+)`, 'gu');
|
|
389
|
+
// Only these targets become anchors; anything else (`javascript:`, `data:`, protocol-relative
|
|
390
|
+
// `//host`) stays literal text, since the text is untrusted model output.
|
|
391
|
+
const SAFE_LINK_HREF = /^(?:https?:\/\/|mailto:|\/(?!\/)|#)/iu;
|
|
392
|
+
// Off-site links open in a new tab; a same-origin path stays in the tab so the host's in-app link
|
|
393
|
+
// interceptor (an SPA router) can take it without a reload.
|
|
394
|
+
const OFFSITE_LINK_HREF = /^(?:https?:|mailto:)/iu;
|
|
395
|
+
function renderMarkdownLink(source, key) {
|
|
396
|
+
// `MD_LINK` already matched `source`, so these two groups always capture.
|
|
397
|
+
const [, label, href] = MD_LINK_PARTS.exec(source);
|
|
398
|
+
if (!SAFE_LINK_HREF.test(href))
|
|
399
|
+
return source;
|
|
400
|
+
const offsite = OFFSITE_LINK_HREF.test(href);
|
|
401
|
+
return (_jsx("a", { href: href, ...(offsite ? { target: '_blank', rel: 'noreferrer' } : {}), children: renderInline(label) }, key));
|
|
402
|
+
}
|
|
377
403
|
function renderInline(text) {
|
|
378
404
|
const nodes = [];
|
|
379
405
|
let lastIndex = 0;
|
|
380
406
|
let key = 0;
|
|
381
|
-
|
|
407
|
+
// A fresh copy per call, not the shared `g` regex: a link label recurses into this function
|
|
408
|
+
// mid-loop, and a shared `lastIndex` would be reset under the outer scan.
|
|
409
|
+
const inlineRe = new RegExp(INLINE_RE);
|
|
410
|
+
let match = inlineRe.exec(text);
|
|
382
411
|
while (match !== null) {
|
|
383
412
|
if (match.index > lastIndex)
|
|
384
413
|
nodes.push(text.slice(lastIndex, match.index));
|
|
385
|
-
const [whole, code, bold, italic, link] = match;
|
|
414
|
+
const [whole, code, mdLink, bold, italic, link] = match;
|
|
386
415
|
if (code)
|
|
387
416
|
nodes.push(_jsx("code", { children: code.slice(1, -1) }, key++));
|
|
417
|
+
else if (mdLink)
|
|
418
|
+
nodes.push(renderMarkdownLink(mdLink, key++));
|
|
388
419
|
else if (bold)
|
|
389
420
|
nodes.push(_jsx("strong", { children: bold.slice(2, -2) }, key++));
|
|
390
421
|
else if (italic)
|
|
@@ -395,7 +426,7 @@ function renderInline(text) {
|
|
|
395
426
|
// defined (if possibly empty) full-match string at index 0, so this
|
|
396
427
|
// fallback is unreachable (same dead-fallback reasoning as above).
|
|
397
428
|
lastIndex = match.index + whole.length;
|
|
398
|
-
match =
|
|
429
|
+
match = inlineRe.exec(text);
|
|
399
430
|
}
|
|
400
431
|
if (lastIndex < text.length)
|
|
401
432
|
nodes.push(text.slice(lastIndex));
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type McpUiToolCallHandler } from '@jini-ai/ui/mcp-ui';
|
|
2
2
|
import { type ExtEventRenderProps } from '../ext-event-renderer-registry.js';
|
|
3
|
+
import { type SurfaceExpiryClock } from '../hooks/useSurfaceExpiry.js';
|
|
3
4
|
/** The `kind: 'ext'` event name this renderer claims. */
|
|
4
5
|
export declare const MCP_UI_EXT_EVENT_NAME = "mcp-ui";
|
|
5
6
|
export interface McpUiSurfaceCardProps extends ExtEventRenderProps {
|
|
@@ -22,9 +23,14 @@ export interface McpUiSurfaceCardProps extends ExtEventRenderProps {
|
|
|
22
23
|
* a fixed-width sidebar should pass a cap sized to its own viewport.
|
|
23
24
|
*/
|
|
24
25
|
maxHeight?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Time source for a card's answer deadline (`MCP_UI_EXPIRES_AT_META_KEY`). Omit for the wall clock;
|
|
28
|
+
* a test injects a fake one to step through the countdown.
|
|
29
|
+
*/
|
|
30
|
+
expiryClock?: SurfaceExpiryClock;
|
|
25
31
|
}
|
|
26
32
|
/** Registered against `ext-event-renderer-registry.ts`'s `'mcp-ui'` name — see module doc. */
|
|
27
|
-
export declare function McpUiSurfaceCard({ events, sandboxProxyUrl, onToolCall, onOpenLink, maxHeight, call, runStreaming }: McpUiSurfaceCardProps): import("react").JSX.Element;
|
|
33
|
+
export declare function McpUiSurfaceCard({ events, sandboxProxyUrl, onToolCall, onOpenLink, maxHeight, call, runStreaming, expiryClock }: McpUiSurfaceCardProps): import("react").JSX.Element;
|
|
28
34
|
/**
|
|
29
35
|
* The ext-event slot key for MCP-UI: one slot per `ui://` URI. Each resource is its own card, so a
|
|
30
36
|
* run's second card renders where it arrived instead of inside the first card's slot, while a
|
|
@@ -62,9 +62,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
62
62
|
* against the real driver in `@jini-ai/agentic`.
|
|
63
63
|
*/
|
|
64
64
|
import { Fragment, useMemo } from 'react';
|
|
65
|
-
import { McpUiHost, parseUIResource, readActionPlan, readPreferredFrameSize, } from '@jini-ai/ui/mcp-ui';
|
|
65
|
+
import { McpUiHost, parseUIResource, readActionPlan, readExpiresAt, readPreferredFrameSize, } from '@jini-ai/ui/mcp-ui';
|
|
66
66
|
import { registerExtEventRenderer } from '../ext-event-renderer-registry.js';
|
|
67
67
|
import { useT } from '../hooks/context.js';
|
|
68
|
+
import { useSurfaceExpiry } from '../hooks/useSurfaceExpiry.js';
|
|
69
|
+
import { closedSurfaceLabel } from './mcp-ui-surface-state.js';
|
|
68
70
|
/** The `kind: 'ext'` event name this renderer claims. */
|
|
69
71
|
export const MCP_UI_EXT_EVENT_NAME = 'mcp-ui';
|
|
70
72
|
/** Collapses the event stream to the newest resource per `ui://` URI, in first-appearance order. */
|
|
@@ -112,39 +114,22 @@ function pendingMirrorLabel(t, plan) {
|
|
|
112
114
|
function PendingSurfaceMirror({ uri, plan, t }) {
|
|
113
115
|
return (_jsx("div", { "aria-hidden": "true", style: { display: 'none' }, "data-agent-element": pendingMirrorHandle(uri), "data-agent-role": "status", "data-agent-label": pendingMirrorLabel(t, plan) }));
|
|
114
116
|
}
|
|
115
|
-
/** Words in a finished call's result that mean nobody answered in time. */
|
|
116
|
-
const UNANSWERED_RESULT = /\b(expired|abandoned|did not respond|run ended)\b/i;
|
|
117
|
-
/**
|
|
118
|
-
* Why a card can no longer be answered, or `undefined` while it still can.
|
|
119
|
-
*
|
|
120
|
-
* A held-open question (the tool shows the card and waits) is over once its tool call returns, and
|
|
121
|
-
* dead once its run ends without that return. Its first document stays on screen either way, so
|
|
122
|
-
* without this an expired, answered or orphaned question still looked answerable (host stuck-chat
|
|
123
|
-
* investigation, 2026-09-27). Only a card still showing that FIRST document closes: a tool that sent
|
|
124
|
-
* a follow-up document for the same `ui://` URI (an outcome after the answer) keeps showing it.
|
|
125
|
-
*
|
|
126
|
-
* The labels are read from the call's result text, so they are a best guess for a producer this
|
|
127
|
-
* package has not seen; the card is closed either way, which is the property that matters.
|
|
128
|
-
*/
|
|
129
|
-
function closedLabel(t, call, runStreaming, documentsForUri) {
|
|
130
|
-
if (call === undefined || documentsForUri > 1)
|
|
131
|
-
return undefined;
|
|
132
|
-
if (call.result === undefined)
|
|
133
|
-
return runStreaming ? undefined : t('This question expired');
|
|
134
|
-
return UNANSWERED_RESULT.test(call.result.content) ? t('This question expired') : t('Answered');
|
|
135
|
-
}
|
|
136
117
|
/** A closed card: the question's title, when the call gave one, and why it is closed. */
|
|
137
118
|
function ClosedSurface({ call, label }) {
|
|
138
119
|
const title = call?.input?.title;
|
|
139
120
|
return (_jsx("div", { className: "mcpui-surface-card mcpui-surface-card-closed", role: "status", children: typeof title === 'string' && title.trim() !== '' ? `${title}: ${label}` : label }));
|
|
140
121
|
}
|
|
141
122
|
/** Registered against `ext-event-renderer-registry.ts`'s `'mcp-ui'` name — see module doc. */
|
|
142
|
-
export function McpUiSurfaceCard({ events, sandboxProxyUrl, onToolCall, onOpenLink, maxHeight, call, runStreaming }) {
|
|
123
|
+
export function McpUiSurfaceCard({ events, sandboxProxyUrl, onToolCall, onOpenLink, maxHeight, call, runStreaming, expiryClock }) {
|
|
143
124
|
const t = useT();
|
|
144
125
|
const resources = useMemo(() => latestResourcesByUri(events), [events]);
|
|
145
126
|
// One card per slot is the registered shape (`mcpUiSurfaceSlotKey`), so `call` belongs to exactly
|
|
146
127
|
// that card. A group holding several URIs has no single call to judge them by, and stays live.
|
|
147
|
-
const
|
|
128
|
+
const single = resources.length === 1 ? resources[0] : undefined;
|
|
129
|
+
const expiry = useSurfaceExpiry({ expiresAtMs: single === undefined ? undefined : readExpiresAt(single) }, expiryClock === undefined ? {} : { clock: expiryClock });
|
|
130
|
+
const closed = single === undefined
|
|
131
|
+
? undefined
|
|
132
|
+
: closedSurfaceLabel({ t, call, runStreaming, documentsForUri: events.length, expired: expiry?.expired === true });
|
|
148
133
|
if (closed !== undefined)
|
|
149
134
|
return _jsx(ClosedSurface, { call: call, label: closed });
|
|
150
135
|
if (resources.length === 0) {
|
|
@@ -153,20 +138,20 @@ export function McpUiSurfaceCard({ events, sandboxProxyUrl, onToolCall, onOpenLi
|
|
|
153
138
|
// looks like an ordinary empty turn.
|
|
154
139
|
return (_jsx("div", { className: "mcpui-surface-card mcpui-surface-card-empty", role: "status", children: t('This MCP-UI event carried no renderable resource.') }));
|
|
155
140
|
}
|
|
156
|
-
return (
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
141
|
+
return (_jsxs("div", { className: "mcpui-surface-card", children: [resources.map((resource) => {
|
|
142
|
+
const preferred = readPreferredFrameSize(resource);
|
|
143
|
+
const height = preferred === undefined ? undefined : Number.parseInt(preferred[1], 10);
|
|
144
|
+
const plan = readActionPlan(resource);
|
|
145
|
+
return (_jsxs(Fragment, { children: [_jsx(McpUiHost, { title: resource.resource.uri, html: resource.resource.text, sandboxProxyUrl: sandboxProxyUrl, toolName: resource.resource.uri,
|
|
146
|
+
// The whole document, not its LENGTH. `useMcpUiHost` already defaults `sessionKey` to
|
|
147
|
+
// `html` — exact by construction — and this override replaced that with a digest so
|
|
148
|
+
// coarse that any two documents of equal size under one URI collided: a genuine update
|
|
149
|
+
// (a re-rendered dialog with the same-length body, a swapped confirmation token) reused
|
|
150
|
+
// the previous iframe session, so the handshake never re-ran and the frame kept serving
|
|
151
|
+
// the OLD document's state. The URI stays in the key because it is real identity; the
|
|
152
|
+
// length was never a substitute for content.
|
|
153
|
+
sessionKey: `${resource.resource.uri}:${resource.resource.text}`, ...(onToolCall === undefined ? {} : { onToolCall }), ...(onOpenLink === undefined ? {} : { onOpenLink }), ...(height === undefined || Number.isNaN(height) ? {} : { initialHeight: height }), ...(maxHeight === undefined ? {} : { maxHeight }) }), plan !== undefined && _jsx(PendingSurfaceMirror, { uri: resource.resource.uri, plan: plan, t: t })] }, resource.resource.uri));
|
|
154
|
+
}), expiry?.expired === false && (_jsx("p", { className: "mcpui-surface-expiry", role: "timer", children: t('Expires in {time}', { time: expiry.remaining }) }))] }));
|
|
170
155
|
}
|
|
171
156
|
/**
|
|
172
157
|
* The ext-event slot key for MCP-UI: one slot per `ui://` URI. Each resource is its own card, so a
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module MessageList
|
|
3
|
+
*
|
|
4
|
+
* Renders a conversation's messages via `<MessageRow>`, and — matching
|
|
5
|
+
* `useConversation`'s `scrollIntent` flag (see
|
|
6
|
+
* `ADS-memory/reports/jini-port/recon/r4b-webui-design.md` §4: "message array, optimistic
|
|
7
|
+
* user message, scroll-intent flag") — auto-scrolls to the newest content
|
|
8
|
+
* whenever `scrollIntent` is true, calling `onScrolled` once it has. The
|
|
9
|
+
* scroll-anchoring state lives in `useMessageListAutoScroll`
|
|
10
|
+
* (`MessageList.hooks.ts`); see that file for why it is presentational.
|
|
11
|
+
*/
|
|
1
12
|
import type { ChatMessage } from '../../core/index.js';
|
|
2
13
|
import { type MessageRowProps } from './MessageRow.js';
|
|
3
14
|
export interface MessageListProps extends Pick<MessageRowProps, 'projectFileNames' | 'onRequestOpenFile' | 'renderAttachment'> {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file `MessageList`'s scroll-anchoring state, split out of the component per this package's
|
|
3
|
+
* hooks-file convention (see `AttachmentPreviewModal.hooks.ts`). Pure DOM scroll-anchoring via a ref
|
|
4
|
+
* lives here (presentational), not in the headless `useConversation` hook, per that hook's own
|
|
5
|
+
* "transport-agnostic hooks never touch DOM" contract.
|
|
6
|
+
*/
|
|
7
|
+
import { type RefObject, type UIEvent } from 'react';
|
|
8
|
+
import type { ChatMessage } from '../../core/index.js';
|
|
9
|
+
export interface MessageListAutoScroll {
|
|
10
|
+
containerRef: RefObject<HTMLDivElement | null>;
|
|
11
|
+
onScroll: (event: UIEvent<HTMLElement>) => void;
|
|
12
|
+
}
|
|
13
|
+
export declare function useMessageListAutoScroll({ messages, scrollIntent }: {
|
|
14
|
+
messages: ChatMessage[];
|
|
15
|
+
scrollIntent: boolean;
|
|
16
|
+
}, { onScrolled }?: {
|
|
17
|
+
onScrolled?: () => void;
|
|
18
|
+
}): MessageListAutoScroll;
|
|
19
|
+
//# sourceMappingURL=MessageList.hooks.d.ts.map
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file `MessageList`'s scroll-anchoring state, split out of the component per this package's
|
|
3
|
+
* hooks-file convention (see `AttachmentPreviewModal.hooks.ts`). Pure DOM scroll-anchoring via a ref
|
|
4
|
+
* lives here (presentational), not in the headless `useConversation` hook, per that hook's own
|
|
5
|
+
* "transport-agnostic hooks never touch DOM" contract.
|
|
6
|
+
*/
|
|
7
|
+
import { useEffect, useRef } from 'react';
|
|
8
|
+
import { nextStickToBottom, startsNewTurn } from './MessageList.rules.js';
|
|
9
|
+
export function useMessageListAutoScroll({ messages, scrollIntent }, { onScrolled } = {}) {
|
|
10
|
+
const containerRef = useRef(null);
|
|
11
|
+
// Whether the transcript was scrolled to its bottom as of the last user scroll or programmatic
|
|
12
|
+
// scroll-to-bottom — read by the ResizeObserver effect below, which only ever sees the DOM AFTER a
|
|
13
|
+
// resize already happened and so cannot recompute "was this at the bottom" from current values.
|
|
14
|
+
// Starts true: a freshly mounted pane opens on the latest turn, same as scrollIntent's own default.
|
|
15
|
+
const stickToBottomRef = useRef(true);
|
|
16
|
+
const previousMessagesRef = useRef(messages);
|
|
17
|
+
// `scrollTop` at the previous `scroll` event — `nextStickToBottom` needs the direction of travel.
|
|
18
|
+
// Only scroll events write it (a browser fires one for programmatic scrolls too). Starts at
|
|
19
|
+
// Infinity, "no position seen yet", so a first event away from the bottom unsticks as it always did.
|
|
20
|
+
const lastScrollTopRef = useRef(Number.POSITIVE_INFINITY);
|
|
21
|
+
// Runs BEFORE the scroll effect below (effects run in declaration order), so a send re-sticks in
|
|
22
|
+
// time for that same commit's scroll. The user's own send (or retry) is an explicit "take me to
|
|
23
|
+
// the latest turn": without this, a user who had scrolled up to read history and then sent stayed
|
|
24
|
+
// parked up there, because the stick gate below — correct for streamed chatter — also swallowed
|
|
25
|
+
// their own send (owner-reported "Chat auto-scroll still sticks", todos audit 2026-10-04).
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (startsNewTurn({ previous: previousMessagesRef.current, next: messages })) {
|
|
28
|
+
stickToBottomRef.current = true;
|
|
29
|
+
}
|
|
30
|
+
previousMessagesRef.current = messages;
|
|
31
|
+
}, [messages]);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!scrollIntent)
|
|
34
|
+
return;
|
|
35
|
+
const el = containerRef.current;
|
|
36
|
+
// Gated on `stickToBottomRef`, same as the ResizeObserver effect below: `scrollIntent` turns
|
|
37
|
+
// true on EVERY streamed event (tool-call chatter included, not just a finished reply — see
|
|
38
|
+
// `useConversation.applyRunToAssistantMessage`), so scrolling unconditionally here yanked a user
|
|
39
|
+
// who had deliberately scrolled up to read history back to the bottom on the very next chunk
|
|
40
|
+
// (owner-reported, 2026-08-30). `stickToBottomRef` already reflects the user's own last scroll
|
|
41
|
+
// gesture via the `onScroll` handler below, captured BEFORE this content change, so it is safe
|
|
42
|
+
// to trust here.
|
|
43
|
+
if (el && stickToBottomRef.current) {
|
|
44
|
+
el.scrollTop = el.scrollHeight;
|
|
45
|
+
}
|
|
46
|
+
onScrolled?.();
|
|
47
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
48
|
+
}, [scrollIntent, messages]);
|
|
49
|
+
// Re-sticks to the bottom when a message ROW grows after mount with no accompanying `messages`
|
|
50
|
+
// change — the exact case an embedded MCP-UI/A2UI surface hits: it mounts at an initial guessed
|
|
51
|
+
// height (`preferredFrameSize` or `DEFAULT_INITIAL_HEIGHT`), then reports its real, often much
|
|
52
|
+
// taller content height asynchronously via `ui/notifications/size-changed`, entirely inside that
|
|
53
|
+
// surface's own React state (`McpUiHost`). The effect above never re-runs for that, so without this
|
|
54
|
+
// a surface which grows after the one-shot scroll leaves its own action buttons — or, once it
|
|
55
|
+
// resolves, the assistant's next reply — below the stale "bottom" with nothing to bring them back
|
|
56
|
+
// into view. Observes each direct child (one per message row) rather than the scroll container
|
|
57
|
+
// itself: the container's own box is held fixed by its flex/overflow layout, so only its CONTENT
|
|
58
|
+
// grows, which `ResizeObserver` only reports for the element whose box is actually changing.
|
|
59
|
+
// Re-created per `messages` change so a newly mounted row is observed too. Gated on
|
|
60
|
+
// `stickToBottomRef` so a human who scrolled up to read history is never yanked back down by an
|
|
61
|
+
// unrelated row resizing elsewhere in the transcript.
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const el = containerRef.current;
|
|
64
|
+
if (!el || typeof ResizeObserver !== 'function')
|
|
65
|
+
return;
|
|
66
|
+
const observer = new ResizeObserver(() => {
|
|
67
|
+
if (!stickToBottomRef.current)
|
|
68
|
+
return;
|
|
69
|
+
el.scrollTop = el.scrollHeight;
|
|
70
|
+
});
|
|
71
|
+
for (const child of Array.from(el.children))
|
|
72
|
+
observer.observe(child);
|
|
73
|
+
return () => observer.disconnect();
|
|
74
|
+
}, [messages]);
|
|
75
|
+
const onScroll = (event) => {
|
|
76
|
+
const el = event.currentTarget;
|
|
77
|
+
stickToBottomRef.current = nextStickToBottom(el, { wasSticking: stickToBottomRef.current, previousScrollTop: lastScrollTopRef.current });
|
|
78
|
+
lastScrollTopRef.current = el.scrollTop;
|
|
79
|
+
};
|
|
80
|
+
return { containerRef, onScroll };
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=MessageList.hooks.js.map
|
|
@@ -1,80 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
/**
|
|
3
|
-
* @module MessageList
|
|
4
|
-
*
|
|
5
|
-
* Renders a conversation's messages via `<MessageRow>`, and — matching
|
|
6
|
-
* `useConversation`'s `scrollIntent` flag (see
|
|
7
|
-
* `ADS-memory/reports/jini-port/recon/r4b-webui-design.md` §4: "message array, optimistic
|
|
8
|
-
* user message, scroll-intent flag") — auto-scrolls to the newest content
|
|
9
|
-
* whenever `scrollIntent` is true, calling `onScrolled` once it has. Pure
|
|
10
|
-
* DOM scroll-anchoring via a ref lives here (a presentational component),
|
|
11
|
-
* not in the headless `useConversation` hook, per that hook's own
|
|
12
|
-
* "transport-agnostic hooks never touch DOM" contract.
|
|
13
|
-
*/
|
|
14
|
-
import { useEffect, useRef } from 'react';
|
|
15
2
|
import { isTerminalRunStatus } from '../../core/index.js';
|
|
16
3
|
import { useT } from '../hooks/context.js';
|
|
4
|
+
import { useMessageListAutoScroll } from './MessageList.hooks.js';
|
|
17
5
|
import { MessageRow } from './MessageRow.js';
|
|
18
|
-
/** Slack, in px, for treating "close enough to the bottom" as "at the bottom" — a fractional
|
|
19
|
-
* scrollHeight/clientHeight rounding difference should not by itself stop the sticky-follow below. */
|
|
20
|
-
const AT_BOTTOM_THRESHOLD_PX = 4;
|
|
21
|
-
function isScrolledToBottom(el) {
|
|
22
|
-
return el.scrollHeight - el.scrollTop - el.clientHeight <= AT_BOTTOM_THRESHOLD_PX;
|
|
23
|
-
}
|
|
24
6
|
export function MessageList({ messages, isStreaming = false, scrollIntent = false, onScrolled, activeQuestionFormMessageId = null, questionFormSubmittedAnswersByMessageId, onQuestionFormSubmit, projectFileNames, onRequestOpenFile, renderAttachment, pendingPrompt = null, }) {
|
|
25
7
|
const t = useT();
|
|
26
|
-
const containerRef =
|
|
27
|
-
|
|
28
|
-
// scroll-to-bottom — read by the ResizeObserver effect below, which only ever sees the DOM AFTER a
|
|
29
|
-
// resize already happened and so cannot recompute "was this at the bottom" from current values.
|
|
30
|
-
// Starts true: a freshly mounted pane opens on the latest turn, same as scrollIntent's own default.
|
|
31
|
-
const stickToBottomRef = useRef(true);
|
|
32
|
-
useEffect(() => {
|
|
33
|
-
if (!scrollIntent)
|
|
34
|
-
return;
|
|
35
|
-
const el = containerRef.current;
|
|
36
|
-
// Gated on `stickToBottomRef`, same as the ResizeObserver effect below: `scrollIntent` turns
|
|
37
|
-
// true on EVERY streamed event (tool-call chatter included, not just a finished reply — see
|
|
38
|
-
// `useConversation.applyRunToAssistantMessage`), so scrolling unconditionally here yanked a user
|
|
39
|
-
// who had deliberately scrolled up to read history back to the bottom on the very next chunk
|
|
40
|
-
// (owner-reported, 2026-08-30). `stickToBottomRef` already reflects the user's own last scroll
|
|
41
|
-
// gesture via the `onScroll` handler below, captured BEFORE this content change, so it is safe
|
|
42
|
-
// to trust here.
|
|
43
|
-
if (el && stickToBottomRef.current) {
|
|
44
|
-
el.scrollTop = el.scrollHeight;
|
|
45
|
-
}
|
|
46
|
-
onScrolled?.();
|
|
47
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
48
|
-
}, [scrollIntent, messages]);
|
|
49
|
-
// Re-sticks to the bottom when a message ROW grows after mount with no accompanying `messages`
|
|
50
|
-
// change — the exact case an embedded MCP-UI/A2UI surface hits: it mounts at an initial guessed
|
|
51
|
-
// height (`preferredFrameSize` or `DEFAULT_INITIAL_HEIGHT`), then reports its real, often much
|
|
52
|
-
// taller content height asynchronously via `ui/notifications/size-changed`, entirely inside that
|
|
53
|
-
// surface's own React state (`McpUiHost`). The effect above never re-runs for that, so without this
|
|
54
|
-
// a surface which grows after the one-shot scroll leaves its own action buttons — or, once it
|
|
55
|
-
// resolves, the assistant's next reply — below the stale "bottom" with nothing to bring them back
|
|
56
|
-
// into view. Observes each direct child (one per message row) rather than the scroll container
|
|
57
|
-
// itself: the container's own box is held fixed by its flex/overflow layout, so only its CONTENT
|
|
58
|
-
// grows, which `ResizeObserver` only reports for the element whose box is actually changing.
|
|
59
|
-
// Re-created per `messages` change so a newly mounted row is observed too. Gated on
|
|
60
|
-
// `stickToBottomRef` so a human who scrolled up to read history is never yanked back down by an
|
|
61
|
-
// unrelated row resizing elsewhere in the transcript.
|
|
62
|
-
useEffect(() => {
|
|
63
|
-
const el = containerRef.current;
|
|
64
|
-
if (!el || typeof ResizeObserver !== 'function')
|
|
65
|
-
return;
|
|
66
|
-
const observer = new ResizeObserver(() => {
|
|
67
|
-
if (!stickToBottomRef.current)
|
|
68
|
-
return;
|
|
69
|
-
el.scrollTop = el.scrollHeight;
|
|
70
|
-
});
|
|
71
|
-
for (const child of Array.from(el.children))
|
|
72
|
-
observer.observe(child);
|
|
73
|
-
return () => observer.disconnect();
|
|
74
|
-
}, [messages]);
|
|
75
|
-
return (_jsxs("div", { className: "jini-message-list", ref: containerRef, onScroll: (event) => {
|
|
76
|
-
stickToBottomRef.current = isScrolledToBottom(event.currentTarget);
|
|
77
|
-
}, "data-agent-element": "chat-transcript", "data-agent-role": "list", "data-agent-label": "The conversation transcript, oldest message first", children: [messages.map((message) => {
|
|
8
|
+
const { containerRef, onScroll } = useMessageListAutoScroll({ messages, scrollIntent }, onScrolled !== undefined ? { onScrolled } : {});
|
|
9
|
+
return (_jsxs("div", { className: "jini-message-list", ref: containerRef, onScroll: onScroll, "data-agent-element": "chat-transcript", "data-agent-role": "list", "data-agent-label": "The conversation transcript, oldest message first", children: [messages.map((message) => {
|
|
78
10
|
const isLast = message.id === messages[messages.length - 1]?.id;
|
|
79
11
|
const runStreaming = isLast && isStreaming && !isTerminalRunStatus({ status: message.runStatus });
|
|
80
12
|
return (_jsx(MessageRow, { message: message, runStreaming: runStreaming, runSucceeded: message.runStatus === 'succeeded', questionFormInteractive: message.id === activeQuestionFormMessageId, ...(questionFormSubmittedAnswersByMessageId?.[message.id] !== undefined ? { questionFormSubmittedAnswers: questionFormSubmittedAnswersByMessageId[message.id] } : {}), ...(onQuestionFormSubmit !== undefined ? { onQuestionFormSubmit: (text, answers) => onQuestionFormSubmit(message.id, text, answers) } : {}), ...(projectFileNames !== undefined ? { projectFileNames } : {}), ...(onRequestOpenFile !== undefined ? { onRequestOpenFile } : {}), ...(renderAttachment !== undefined ? { renderAttachment } : {}) }, message.id));
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Pure scroll rules for `MessageList` (no DOM, no React), split out so the component file
|
|
3
|
+
* holds no logic.
|
|
4
|
+
*/
|
|
5
|
+
import type { ChatMessage } from '../../core/index.js';
|
|
6
|
+
/** Slack, in px, for treating "close enough to the bottom" as "at the bottom" — a fractional
|
|
7
|
+
* scrollHeight/clientHeight rounding difference should not by itself stop the sticky-follow. */
|
|
8
|
+
export declare const AT_BOTTOM_THRESHOLD_PX = 4;
|
|
9
|
+
export declare function isScrolledToBottom(el: {
|
|
10
|
+
scrollHeight: number;
|
|
11
|
+
scrollTop: number;
|
|
12
|
+
clientHeight: number;
|
|
13
|
+
}): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* The sticky-follow flag after a `scroll` event.
|
|
16
|
+
*
|
|
17
|
+
* At the bottom always re-sticks. Otherwise only a move UP (the position went down since the last
|
|
18
|
+
* event) unsticks. A `scroll` event is dispatched a frame after the scroll that caused it, so the
|
|
19
|
+
* event for an auto-scroll can arrive after more streamed rows already grew the content: the
|
|
20
|
+
* position is unchanged but no longer at the bottom. Reading that as "the user scrolled away" is
|
|
21
|
+
* what left a tool-heavy run's final reply below the fold (demo dry-run, 2026-10-05). Content growth
|
|
22
|
+
* never lowers `scrollTop`; a user scrolling up always does.
|
|
23
|
+
*/
|
|
24
|
+
export declare function nextStickToBottom(el: {
|
|
25
|
+
scrollHeight: number;
|
|
26
|
+
scrollTop: number;
|
|
27
|
+
clientHeight: number;
|
|
28
|
+
}, { wasSticking, previousScrollTop }: {
|
|
29
|
+
wasSticking: boolean;
|
|
30
|
+
previousScrollTop: number;
|
|
31
|
+
}): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Whether `next` starts a turn the user themself asked for, relative to `previous`: a user message
|
|
34
|
+
* that was not there before (a send — `useConversation.sendMessage` appends it optimistically), or
|
|
35
|
+
* an assistant message that newly entered `runStatus: 'queued'` (a retry, which resets the existing
|
|
36
|
+
* assistant message in place rather than appending one). Streamed chatter on an already-running
|
|
37
|
+
* turn is neither, so it never counts.
|
|
38
|
+
*/
|
|
39
|
+
export declare function startsNewTurn({ previous, next }: {
|
|
40
|
+
previous: readonly ChatMessage[];
|
|
41
|
+
next: readonly ChatMessage[];
|
|
42
|
+
}): boolean;
|
|
43
|
+
//# sourceMappingURL=MessageList.rules.d.ts.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Slack, in px, for treating "close enough to the bottom" as "at the bottom" — a fractional
|
|
2
|
+
* scrollHeight/clientHeight rounding difference should not by itself stop the sticky-follow. */
|
|
3
|
+
export const AT_BOTTOM_THRESHOLD_PX = 4;
|
|
4
|
+
export function isScrolledToBottom(el) {
|
|
5
|
+
return el.scrollHeight - el.scrollTop - el.clientHeight <= AT_BOTTOM_THRESHOLD_PX;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The sticky-follow flag after a `scroll` event.
|
|
9
|
+
*
|
|
10
|
+
* At the bottom always re-sticks. Otherwise only a move UP (the position went down since the last
|
|
11
|
+
* event) unsticks. A `scroll` event is dispatched a frame after the scroll that caused it, so the
|
|
12
|
+
* event for an auto-scroll can arrive after more streamed rows already grew the content: the
|
|
13
|
+
* position is unchanged but no longer at the bottom. Reading that as "the user scrolled away" is
|
|
14
|
+
* what left a tool-heavy run's final reply below the fold (demo dry-run, 2026-10-05). Content growth
|
|
15
|
+
* never lowers `scrollTop`; a user scrolling up always does.
|
|
16
|
+
*/
|
|
17
|
+
export function nextStickToBottom(el, { wasSticking, previousScrollTop }) {
|
|
18
|
+
if (isScrolledToBottom(el))
|
|
19
|
+
return true;
|
|
20
|
+
return el.scrollTop < previousScrollTop ? false : wasSticking;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Whether `next` starts a turn the user themself asked for, relative to `previous`: a user message
|
|
24
|
+
* that was not there before (a send — `useConversation.sendMessage` appends it optimistically), or
|
|
25
|
+
* an assistant message that newly entered `runStatus: 'queued'` (a retry, which resets the existing
|
|
26
|
+
* assistant message in place rather than appending one). Streamed chatter on an already-running
|
|
27
|
+
* turn is neither, so it never counts.
|
|
28
|
+
*/
|
|
29
|
+
export function startsNewTurn({ previous, next }) {
|
|
30
|
+
const previousById = new Map(previous.map((message) => [message.id, message]));
|
|
31
|
+
return next.some((message) => {
|
|
32
|
+
const before = previousById.get(message.id);
|
|
33
|
+
if (message.role === 'user')
|
|
34
|
+
return before === undefined;
|
|
35
|
+
return message.runStatus === 'queued' && before?.runStatus !== 'queued';
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=MessageList.rules.js.map
|
|
@@ -50,7 +50,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
50
50
|
* chunk stopped arriving.
|
|
51
51
|
*/
|
|
52
52
|
import React, { useState } from 'react';
|
|
53
|
-
import { isTerminalRunStatus, splitOnQuestionForms, stripArtifact } from '../../core/index.js';
|
|
53
|
+
import { isAwaitingAnswer, isTerminalRunStatus, splitOnQuestionForms, stripArtifact } from '../../core/index.js';
|
|
54
54
|
import { useToolTimeline } from '../hooks/useToolTimeline.js';
|
|
55
55
|
import { useExtEventGroups } from '../hooks/useExtEventGroups.js';
|
|
56
56
|
import { useCopyToClipboard } from '../hooks/useCopyToClipboard.js';
|
|
@@ -160,6 +160,7 @@ export function MessageRow({ message, runStreaming = false, runSucceeded = false
|
|
|
160
160
|
: null;
|
|
161
161
|
const pending = isPendingWithNoContent(message, visibleContent, timeline.rows.length);
|
|
162
162
|
const runInProgress = isRunInProgress(message.runStatus);
|
|
163
|
+
const awaitingAnswer = runStreaming && runInProgress && isAwaitingAnswer({ events: message.events });
|
|
163
164
|
const renderToolCard = (row) => (_jsx(ToolCard, { use: row.use, result: row.result, runStreaming: runStreaming, runSucceeded: runSucceeded, ...(projectFileNames !== undefined ? { projectFileNames } : {}), ...(onRequestOpenFile !== undefined ? { onRequestOpenFile } : {}) }, row.id));
|
|
164
165
|
const renderExtGroup = (group) => {
|
|
165
166
|
const renderer = getExtEventRenderer({ name: group.name });
|
|
@@ -170,6 +171,7 @@ export function MessageRow({ message, runStreaming = false, runSucceeded = false
|
|
|
170
171
|
events: group.events,
|
|
171
172
|
runStreaming,
|
|
172
173
|
runSucceeded,
|
|
174
|
+
awaitingAnswer,
|
|
173
175
|
runId: message.runId,
|
|
174
176
|
...(group.call === undefined ? {} : { call: group.call }),
|
|
175
177
|
});
|
|
@@ -21,14 +21,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
21
21
|
* generic rendering for it.
|
|
22
22
|
*/
|
|
23
23
|
import { useState } from 'react';
|
|
24
|
-
import { formatToolOutputForDisplay, isTodoWriteToolName, parseTodoWriteInput, toRenderProps } from '../../core/index.js';
|
|
24
|
+
import { formatToolOutputForDisplay, isDelegatedWrapperToolName, isTodoWriteToolName, parseTodoWriteInput, toRenderProps } from '../../core/index.js';
|
|
25
25
|
import { useT } from '../hooks/context.js';
|
|
26
26
|
import { getToolRenderer } from '../tool-renderer-registry.js';
|
|
27
27
|
import { Icon } from './Icon.js';
|
|
28
28
|
import { TodoCard } from './TodoCard.js';
|
|
29
29
|
/** A Jini registry tool id: dotted, lowercase, snake_case segments — `page.fill`, `daemon.db.vacuum`, `chat.send_message`. Never matches an agent-vendor tool name (`Bash`, `Write`, `mcp__jini__execute_delegated_tool`), which is the point — this is how a canonical delegated-tool event is told apart from everything else. */
|
|
30
30
|
const JINI_TOOL_ID_PATTERN = /^[a-z][a-z_]*(\.[a-z][a-z_]*)+$/;
|
|
31
|
-
const EXECUTE_DELEGATED_TOOL_NAMES = new Set(['execute_delegated_tool', 'mcp__jini__execute_delegated_tool']);
|
|
32
31
|
const SEARCH_TOOLS_NAMES = new Set(['search_tools', 'mcp__jini__search_tools']);
|
|
33
32
|
const DESCRIBE_TOOL_NAMES = new Set(['describe_tool', 'mcp__jini__describe_tool']);
|
|
34
33
|
export function ToolCard({ use, result, runStreaming, runSucceeded, projectFileNames, onRequestOpenFile }) {
|
|
@@ -64,8 +63,12 @@ function renderToolCardBody(name, use, result, isStreaming, isSucceeded, fileCtx
|
|
|
64
63
|
// action twice. Suppressed only on a *successful* result: a wrapper-level failure (a genuine
|
|
65
64
|
// transport error, not a tool-execution failure — those come back as a normal 'completed' result
|
|
66
65
|
// with status:'failed' inside it) has no matching canonical row to fall back on, so it must stay
|
|
67
|
-
// visible rather than disappear silently.
|
|
68
|
-
|
|
66
|
+
// visible rather than disappear silently. (A wrapper that failed AFTER the bridge emitted its
|
|
67
|
+
// canonical row — e.g. a 400 on a refused input — never reaches here when rendered through
|
|
68
|
+
// `useToolTimeline`: `foldDelegatedWrapperCalls` drops it in favour of that canonical row. This
|
|
69
|
+
// check still covers a host that renders `<ToolCard>` straight from raw events.)
|
|
70
|
+
// Covers the read-only gateway (`execute_readonly_delegated_tool`) too.
|
|
71
|
+
if (isDelegatedWrapperToolName({ name: name }) && result && !result.isError)
|
|
69
72
|
return null;
|
|
70
73
|
if (isTodoWriteToolName({ name: name }))
|
|
71
74
|
return _jsx(TodoCard, { todos: parseTodoWriteInput({ input: use.input }), runStreaming: isStreaming });
|
|
@@ -85,7 +88,7 @@ function renderToolCardBody(name, use, result, isStreaming, isSucceeded, fileCtx
|
|
|
85
88
|
return _jsx(WebFetchCard, { input: use.input, result: result, runStreaming: isStreaming, runSucceeded: isSucceeded });
|
|
86
89
|
if (name === 'WebSearch' || name === 'web_search')
|
|
87
90
|
return _jsx(WebSearchCard, { input: use.input, result: result, runStreaming: isStreaming, runSucceeded: isSucceeded });
|
|
88
|
-
if (JINI_TOOL_ID_PATTERN.test(name) ||
|
|
91
|
+
if (JINI_TOOL_ID_PATTERN.test(name) || isDelegatedWrapperToolName({ name: name })) {
|
|
89
92
|
// The unsuppressed (failed-wrapper) case falls through here too: `name` is still
|
|
90
93
|
// `execute_delegated_tool`, so `jiniToolIdFromInput` recovers the real id from
|
|
91
94
|
// `{toolId, input}` instead of `DelegatedToolCard` receiving its own wrapper name as the title.
|
|
@@ -133,8 +136,8 @@ function DelegatedToolCard({ name, input, result, runStreaming, runSucceeded })
|
|
|
133
136
|
// Wrapper form (only reached on a failed wrapper call, per ToolCard's suppression comment):
|
|
134
137
|
// the real id and args are nested under {toolId, input}, not `input` itself.
|
|
135
138
|
const wrapper = (input ?? {});
|
|
136
|
-
const toolId =
|
|
137
|
-
const args =
|
|
139
|
+
const toolId = isDelegatedWrapperToolName({ name: name }) ? (wrapper.toolId ?? name) : name;
|
|
140
|
+
const args = isDelegatedWrapperToolName({ name: name }) ? wrapper.input : input;
|
|
138
141
|
const target = primaryTarget(args);
|
|
139
142
|
const isRunning = runStreaming && !result;
|
|
140
143
|
return (_jsxs("div", { className: "op-card op-jini-tool", children: [_jsxs("button", { type: "button", className: "op-card-head", onClick: () => setOpen((o) => !o), children: [_jsx(ResultBadge, { result: result, runStreaming: runStreaming, runSucceeded: runSucceeded }), _jsxs("span", { className: `op-title${isRunning ? ' shimmer-text' : ''}`, children: [t('Tool call'), " \u00B7 ", humanizeToolId(toolId)] }), target ? _jsx("span", { className: "op-meta", children: target }) : null, _jsx("span", { className: "op-expand-chev", "aria-hidden": true, children: _jsx(Icon, { name: open ? 'chevron-down' : 'chevron-right', size: 11 }) })] }), _jsx("div", { className: `accordion-collapsible${open ? ' open' : ''}`, children: _jsx("div", { className: "accordion-collapsible-inner", children: _jsxs("div", { className: "op-card-detail", children: [_jsx("pre", { className: "op-command", children: JSON.stringify(args ?? {}) }), result?.content ? _jsx("pre", { className: "op-output", children: truncate(formatToolOutputForDisplay({ text: result.content }), 2000) }) : null, _jsx(ToolResultMedia, { result: result })] }) }) }), _jsx(FileErrorDetail, { result: result })] }));
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module components/mcp-ui-surface-state
|
|
3
|
+
*
|
|
4
|
+
* Whether an MCP-UI card in a chat transcript is closed, and with which label. Kept out of
|
|
5
|
+
* `McpUiSurfaceCard.tsx` so the component only renders; moved here from that file 2026-10-04 when
|
|
6
|
+
* the card's own deadline (`useSurfaceExpiry`) became a second reason to close it.
|
|
7
|
+
*/
|
|
8
|
+
import type { ExtEventCall } from '../ext-event-renderer-registry.js';
|
|
9
|
+
import type { useT } from '../hooks/context.js';
|
|
10
|
+
type Translate = ReturnType<typeof useT>;
|
|
11
|
+
/**
|
|
12
|
+
* Why a card can no longer be answered, or `undefined` while it still can: {@link settledLabel}'s
|
|
13
|
+
* answer first, so a card answered in time still reads "Answered", then the card's own deadline
|
|
14
|
+
* (`useSurfaceExpiry`). The deadline closes the card the moment it passes rather than when the
|
|
15
|
+
* server's expired result arrives, so a person never clicks a card nobody is waiting on.
|
|
16
|
+
*
|
|
17
|
+
* @complexity O(r) in the call result's length (one regex test).
|
|
18
|
+
*/
|
|
19
|
+
export declare function closedSurfaceLabel({ t, call, runStreaming, documentsForUri, expired }: {
|
|
20
|
+
t: Translate;
|
|
21
|
+
call: ExtEventCall | undefined;
|
|
22
|
+
runStreaming: boolean;
|
|
23
|
+
documentsForUri: number;
|
|
24
|
+
expired: boolean;
|
|
25
|
+
}): string | undefined;
|
|
26
|
+
export {};
|
|
27
|
+
//# sourceMappingURL=mcp-ui-surface-state.d.ts.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Words in a finished call's result that mean nobody answered in time. */
|
|
2
|
+
const UNANSWERED_RESULT = /\b(expired|abandoned|did not respond|run ended)\b/i;
|
|
3
|
+
/**
|
|
4
|
+
* Why a card can no longer be answered, or `undefined` while it still can.
|
|
5
|
+
*
|
|
6
|
+
* A held-open question (the tool shows the card and waits) is over once its tool call returns, and
|
|
7
|
+
* dead once its run ends without that return. Its first document stays on screen either way, so
|
|
8
|
+
* without this an expired, answered or orphaned question still looked answerable (host stuck-chat
|
|
9
|
+
* investigation, 2026-09-27). Only a card still showing that FIRST document closes: a tool that sent
|
|
10
|
+
* a follow-up document for the same `ui://` URI (an outcome after the answer) keeps showing it.
|
|
11
|
+
*
|
|
12
|
+
* The labels are read from the call's result text, so they are a best guess for a producer this
|
|
13
|
+
* package has not seen; the card is closed either way, which is the property that matters.
|
|
14
|
+
*/
|
|
15
|
+
function settledLabel(t, call, runStreaming, documentsForUri) {
|
|
16
|
+
if (call === undefined || documentsForUri > 1)
|
|
17
|
+
return undefined;
|
|
18
|
+
if (call.result === undefined)
|
|
19
|
+
return runStreaming ? undefined : t('This question expired');
|
|
20
|
+
return UNANSWERED_RESULT.test(call.result.content) ? t('This question expired') : t('Answered');
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Why a card can no longer be answered, or `undefined` while it still can: {@link settledLabel}'s
|
|
24
|
+
* answer first, so a card answered in time still reads "Answered", then the card's own deadline
|
|
25
|
+
* (`useSurfaceExpiry`). The deadline closes the card the moment it passes rather than when the
|
|
26
|
+
* server's expired result arrives, so a person never clicks a card nobody is waiting on.
|
|
27
|
+
*
|
|
28
|
+
* @complexity O(r) in the call result's length (one regex test).
|
|
29
|
+
*/
|
|
30
|
+
export function closedSurfaceLabel({ t, call, runStreaming, documentsForUri, expired }) {
|
|
31
|
+
return settledLabel(t, call, runStreaming, documentsForUri) ?? (expired ? t('This question expired') : undefined);
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=mcp-ui-surface-state.js.map
|