@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/dist/core/index.d.ts +1 -0
  3. package/dist/core/index.js +1 -0
  4. package/dist/core/run-activity.d.ts +12 -20
  5. package/dist/core/run-activity.js +59 -7
  6. package/dist/core/surface-expiry.d.ts +42 -0
  7. package/dist/core/surface-expiry.js +42 -0
  8. package/dist/core/tool-events.d.ts +34 -0
  9. package/dist/core/tool-events.js +61 -0
  10. package/dist/core/tools.d.ts +1 -1
  11. package/dist/core/tools.js +1 -1
  12. package/dist/react/chat-pane.d.ts +2 -2
  13. package/dist/react/chat-pane.js +1 -1
  14. package/dist/react/components/AttachmentPreviewModal.hooks.d.ts +19 -2
  15. package/dist/react/components/AttachmentPreviewModal.hooks.js +53 -38
  16. package/dist/react/components/Markdown.js +37 -6
  17. package/dist/react/components/McpUiSurfaceCard.d.ts +7 -1
  18. package/dist/react/components/McpUiSurfaceCard.js +23 -38
  19. package/dist/react/components/MessageList.d.ts +11 -0
  20. package/dist/react/components/MessageList.hooks.d.ts +19 -0
  21. package/dist/react/components/MessageList.hooks.js +82 -0
  22. package/dist/react/components/MessageList.js +3 -71
  23. package/dist/react/components/MessageList.rules.d.ts +43 -0
  24. package/dist/react/components/MessageList.rules.js +38 -0
  25. package/dist/react/components/MessageRow.js +3 -1
  26. package/dist/react/components/ToolCard.js +10 -7
  27. package/dist/react/components/mcp-ui-surface-state.d.ts +27 -0
  28. package/dist/react/components/mcp-ui-surface-state.js +33 -0
  29. package/dist/react/ext-event-renderer-registry.d.ts +7 -0
  30. package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
  31. package/dist/react/features/chat-pane/components/ChatPane.js +6 -5
  32. package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +54 -0
  33. package/dist/react/features/chat-pane/create-typed-answer-poster.js +46 -0
  34. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +17 -1
  35. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +153 -7
  36. package/dist/react/features/chat-pane/index.d.ts +2 -1
  37. package/dist/react/features/chat-pane/index.js +1 -0
  38. package/dist/react/features/chat-pane/rules.d.ts +59 -1
  39. package/dist/react/features/chat-pane/rules.js +74 -0
  40. package/dist/react/features/chat-pane/types.d.ts +34 -0
  41. package/dist/react/hooks/composer-draft-cache.d.ts +28 -0
  42. package/dist/react/hooks/composer-draft-cache.js +56 -6
  43. package/dist/react/hooks/last-conversation-store.d.ts +58 -0
  44. package/dist/react/hooks/last-conversation-store.js +112 -0
  45. package/dist/react/hooks/useSurfaceExpiry.d.ts +20 -0
  46. package/dist/react/hooks/useSurfaceExpiry.js +35 -0
  47. package/dist/react/hooks/useToolTimeline.js +6 -3
  48. package/dist/react/index.d.ts +10 -2
  49. package/dist/react/index.js +7 -1
  50. 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*`/bare autolinks — including inside table cells, via
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
- const INLINE_RE = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*|_[^_]+_)|(https?:\/\/[^\s)]+)/g;
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
- let match = INLINE_RE.exec(text);
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 = INLINE_RE.exec(text);
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 closed = resources.length === 1 ? closedLabel(t, call, runStreaming, events.length) : undefined;
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 (_jsx("div", { className: "mcpui-surface-card", children: resources.map((resource) => {
157
- const preferred = readPreferredFrameSize(resource);
158
- const height = preferred === undefined ? undefined : Number.parseInt(preferred[1], 10);
159
- const plan = readActionPlan(resource);
160
- return (_jsxs(Fragment, { children: [_jsx(McpUiHost, { title: resource.resource.uri, html: resource.resource.text, sandboxProxyUrl: sandboxProxyUrl, toolName: resource.resource.uri,
161
- // The whole document, not its LENGTH. `useMcpUiHost` already defaults `sessionKey` to
162
- // `html` — exact by construction — and this override replaced that with a digest so
163
- // coarse that any two documents of equal size under one URI collided: a genuine update
164
- // (a re-rendered dialog with the same-length body, a swapped confirmation token) reused
165
- // the previous iframe session, so the handshake never re-ran and the frame kept serving
166
- // the OLD document's state. The URI stays in the key because it is real identity; the
167
- // length was never a substitute for content.
168
- 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));
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 = useRef(null);
27
- // Whether the transcript was scrolled to its bottom as of the last user scroll or programmatic
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
- if (EXECUTE_DELEGATED_TOOL_NAMES.has(name) && result && !result.isError)
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) || EXECUTE_DELEGATED_TOOL_NAMES.has(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 = EXECUTE_DELEGATED_TOOL_NAMES.has(name) ? (wrapper.toolId ?? name) : name;
137
- const args = EXECUTE_DELEGATED_TOOL_NAMES.has(name) ? wrapper.input : input;
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