@jini-ai/chat 0.5.0 → 0.5.2

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 (35) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/dist/core/index.d.ts +1 -0
  3. package/dist/core/index.js +1 -0
  4. package/dist/core/surface-expiry.d.ts +42 -0
  5. package/dist/core/surface-expiry.js +42 -0
  6. package/dist/react/chat-pane.d.ts +2 -2
  7. package/dist/react/chat-pane.js +1 -1
  8. package/dist/react/components/AttachmentPreviewModal.hooks.d.ts +19 -2
  9. package/dist/react/components/AttachmentPreviewModal.hooks.js +53 -38
  10. package/dist/react/components/McpUiSurfaceCard.d.ts +7 -1
  11. package/dist/react/components/McpUiSurfaceCard.js +23 -38
  12. package/dist/react/components/MessageList.d.ts +11 -0
  13. package/dist/react/components/MessageList.hooks.d.ts +19 -0
  14. package/dist/react/components/MessageList.hooks.js +76 -0
  15. package/dist/react/components/MessageList.js +3 -71
  16. package/dist/react/components/MessageList.rules.d.ts +25 -0
  17. package/dist/react/components/MessageList.rules.js +23 -0
  18. package/dist/react/components/mcp-ui-surface-state.d.ts +27 -0
  19. package/dist/react/components/mcp-ui-surface-state.js +33 -0
  20. package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
  21. package/dist/react/features/chat-pane/components/ChatPane.js +6 -5
  22. package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +54 -0
  23. package/dist/react/features/chat-pane/create-typed-answer-poster.js +46 -0
  24. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +17 -1
  25. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +120 -4
  26. package/dist/react/features/chat-pane/index.d.ts +2 -1
  27. package/dist/react/features/chat-pane/index.js +1 -0
  28. package/dist/react/features/chat-pane/rules.d.ts +56 -1
  29. package/dist/react/features/chat-pane/rules.js +71 -0
  30. package/dist/react/features/chat-pane/types.d.ts +34 -0
  31. package/dist/react/hooks/useSurfaceExpiry.d.ts +20 -0
  32. package/dist/react/hooks/useSurfaceExpiry.js +35 -0
  33. package/dist/react/index.d.ts +4 -2
  34. package/dist/react/index.js +2 -1
  35. package/package.json +8 -8
package/CHANGELOG.md CHANGED
@@ -1,4 +1,20 @@
1
- ## Unreleased
1
+ ## 0.5.2 — 2026-10-05
2
+
3
+ - `useChatPane` holds a draft typed for a question that has since closed (answered elsewhere, expired, or refused with `'not-pending'`): `send` shows the `'not-pending'` notice instead of queueing it as a new paid run. Only clearing the draft or the new `sendAsNewMessage()` (the notice's "Send as a new message" button in `ChatPane`) lets it out as an ordinary turn. New pure `findAwaitedTypedAnswerId`.
4
+ - A delivered typed answer clears the composer only if its text is unchanged, so a correction typed while the host answered survives; a delivery settling after `reset()`, a conversation switch or unmount changes neither the draft nor the notice.
5
+
6
+ - `ChatPane`/`useChatPane` accept `deliverTypedAnswer`: text typed while the running agent holds a question card open (a surface whose tool call has not returned) goes to the host instead of the queue. `'delivered'` clears the draft; `'not-pending'`/`'failed'` (or a throw) keep the draft, show a notice (`typedAnswerNotice`) and never queue it, so a closed question can no longer turn an answer into a second paid run. New `createTypedAnswerPoster` posts `{toolName, params: {__typedAnswer}}` to the MCP-UI tool-call route (202 delivered, 409 not-pending). Omitting the prop keeps today's queue-while-streaming behavior.
7
+
8
+ - `McpUiSurfaceCard` counts down a card's answer deadline (`MCP_UI_EXPIRES_AT_META_KEY`) under the live frame ("Expires in {time}", `role="timer"`) and closes it as "This question expired" when the deadline passes; a card answered in time still reads "Answered". New `useSurfaceExpiry` hook with an injectable `SurfaceExpiryClock` (card prop `expiryClock`), and pure `describeSurfaceExpiry`/`formatRemainingTime` in core.
9
+
10
+ - A user's own send or retry re-sticks the transcript to the bottom even when they had scrolled up to read history (`MessageList`; scroll rules move to `useMessageListAutoScroll`).
11
+ - The `@jini-ai/db` peer range is `^0.2.0 || ^0.3.0`; chat uses only db's kernel and store entries, which 0.3.0 leaves unchanged.
12
+
13
+ ## 0.5.1 — 2026-10-04
14
+
15
+ - Internal `@jini-ai/*` dependencies are caret ranges (`workspace:^`) instead of exact pins, so a host on `@jini-ai/agentic` 0.4.1 or on a newer patch resolves a single copy without an override.
16
+
17
+ ### Also shipped in 0.5.1 (was listed under Unreleased)
2
18
 
3
19
  - Type store fixtures with the shared storage kernel and chat contracts, including host-owned tables;
4
20
  align construction, reopening, paging and message fixtures with current API shapes.
@@ -24,6 +24,7 @@ export * from './question-form.js';
24
24
  export * from './util/index.js';
25
25
  export * from './transcript.js';
26
26
  export * from './run-activity.js';
27
+ export * from './surface-expiry.js';
27
28
  export * from './assistant-content.js';
28
29
  export * from './compact-events.js';
29
30
  /**
@@ -24,6 +24,7 @@ export * from './question-form.js';
24
24
  export * from './util/index.js';
25
25
  export * from './transcript.js';
26
26
  export * from './run-activity.js';
27
+ export * from './surface-expiry.js';
27
28
  export * from './assistant-content.js';
28
29
  export * from './compact-events.js';
29
30
  /**
@@ -0,0 +1,42 @@
1
+ /**
2
+ * @module surface-expiry
3
+ *
4
+ * The answer deadline of a pending MCP-UI card (`MCP_UI_EXPIRES_AT_META_KEY` in `@jini-ai/ui`):
5
+ * whether it has passed, and the time left as the card shows it. Pure — the caller supplies "now",
6
+ * so the boundary (just before / at / after the deadline) is tested without a real clock.
7
+ */
8
+ /** How often a live countdown re-reads the clock. Matches the label's whole-second resolution. */
9
+ export declare const SURFACE_EXPIRY_TICK_MS = 1000;
10
+ /** A card still answerable, with the time left, or one whose deadline has passed. */
11
+ export type SurfaceExpiry = {
12
+ readonly expired: true;
13
+ } | {
14
+ readonly expired: false;
15
+ readonly remainingMs: number;
16
+ readonly remaining: string;
17
+ };
18
+ /**
19
+ * Describes a card's deadline at one instant.
20
+ *
21
+ * Expired AT the deadline, not after it: the server gives up when its timer fires, so a click at
22
+ * that instant would already find nothing waiting.
23
+ *
24
+ * @param input.expiresAtMs - The deadline, epoch milliseconds.
25
+ * @param input.nowMs - The current time, same clock.
26
+ * @complexity O(1).
27
+ */
28
+ export declare function describeSurfaceExpiry({ expiresAtMs, nowMs }: {
29
+ expiresAtMs: number;
30
+ nowMs: number;
31
+ }): SurfaceExpiry;
32
+ /**
33
+ * Formats a positive duration as `m:ss`, or `h:mm:ss` from an hour up.
34
+ *
35
+ * Rounds UP to the whole second, so a card with any time left never reads `0:00` while it can still
36
+ * be answered; it reads `0:01` until the instant it expires.
37
+ *
38
+ * @param remainingMs - Milliseconds left, greater than zero.
39
+ * @complexity O(1).
40
+ */
41
+ export declare function formatRemainingTime(remainingMs: number): string;
42
+ //# sourceMappingURL=surface-expiry.d.ts.map
@@ -0,0 +1,42 @@
1
+ /**
2
+ * @module surface-expiry
3
+ *
4
+ * The answer deadline of a pending MCP-UI card (`MCP_UI_EXPIRES_AT_META_KEY` in `@jini-ai/ui`):
5
+ * whether it has passed, and the time left as the card shows it. Pure — the caller supplies "now",
6
+ * so the boundary (just before / at / after the deadline) is tested without a real clock.
7
+ */
8
+ /** How often a live countdown re-reads the clock. Matches the label's whole-second resolution. */
9
+ export const SURFACE_EXPIRY_TICK_MS = 1_000;
10
+ /**
11
+ * Describes a card's deadline at one instant.
12
+ *
13
+ * Expired AT the deadline, not after it: the server gives up when its timer fires, so a click at
14
+ * that instant would already find nothing waiting.
15
+ *
16
+ * @param input.expiresAtMs - The deadline, epoch milliseconds.
17
+ * @param input.nowMs - The current time, same clock.
18
+ * @complexity O(1).
19
+ */
20
+ export function describeSurfaceExpiry({ expiresAtMs, nowMs }) {
21
+ const remainingMs = expiresAtMs - nowMs;
22
+ if (remainingMs <= 0)
23
+ return { expired: true };
24
+ return { expired: false, remainingMs, remaining: formatRemainingTime(remainingMs) };
25
+ }
26
+ /**
27
+ * Formats a positive duration as `m:ss`, or `h:mm:ss` from an hour up.
28
+ *
29
+ * Rounds UP to the whole second, so a card with any time left never reads `0:00` while it can still
30
+ * be answered; it reads `0:01` until the instant it expires.
31
+ *
32
+ * @param remainingMs - Milliseconds left, greater than zero.
33
+ * @complexity O(1).
34
+ */
35
+ export function formatRemainingTime(remainingMs) {
36
+ const totalSeconds = Math.ceil(remainingMs / 1_000);
37
+ const hours = Math.floor(totalSeconds / 3_600);
38
+ const minutes = Math.floor((totalSeconds % 3_600) / 60);
39
+ const seconds = String(totalSeconds % 60).padStart(2, '0');
40
+ return hours > 0 ? `${hours}:${String(minutes).padStart(2, '0')}:${seconds}` : `${minutes}:${seconds}`;
41
+ }
42
+ //# sourceMappingURL=surface-expiry.js.map
@@ -16,6 +16,6 @@
16
16
  * `@deprecated`, so every existing `import { ChatPane } from '@jini-ai/chat/react'` keeps working
17
17
  * unchanged. New code should import from this subpath directly.
18
18
  */
19
- export { AgentRuntimePicker, CHAT_PANE_AGENT_TOOLS, ChatPane, createDaemonAttachmentUploader, createMcpUiToolCaller, defaultChatPaneSelection, orderChatPaneAgents, resolveChatPaneSelection, useChatPane, useChatPaneAgentControl, useChatPaneRuntimeInventory, useChatPaneWorkingDirectory, } from './features/chat-pane/index.js';
20
- export type { AgentRuntimePickerProps, ByokRuntimeSummary, ChatPaneActivity, ChatPaneAgent, ChatPaneAgentBridgeAccess, ChatPaneAgentControlOptions, ChatPaneAgentOption, ChatPaneAgentSelection, ChatPaneAgentToolAction, ChatPaneAgentToolDef, ChatPaneAgentToolInputSchema, ChatPaneAgentToolRisk, ChatPaneAttachmentUploadOptions, ChatPaneComposerHandle, ChatPaneProps, ChatPaneRunContext, ChatPaneRunContextInput, ChatPaneRuntimeAccess, ChatPaneVariant, ChatPaneWorkingDirectoryAccess, CreateDaemonAttachmentUploaderOptions, CreateMcpUiToolCallerOptions, McpUiToolCallRequest, RuntimePickerPlacement, UseChatPaneAgentControlOptions, UseChatPaneOptions, UseChatPaneResult, UseChatPaneRuntimeInventoryOptions, UseChatPaneRuntimeInventoryResult, UseChatPaneWorkingDirectoryOptions, UseChatPaneWorkingDirectoryResult, } from './features/chat-pane/index.js';
19
+ export { AgentRuntimePicker, CHAT_PANE_AGENT_TOOLS, ChatPane, createDaemonAttachmentUploader, createMcpUiToolCaller, createTypedAnswerPoster, defaultChatPaneSelection, orderChatPaneAgents, resolveChatPaneSelection, useChatPane, useChatPaneAgentControl, useChatPaneRuntimeInventory, useChatPaneWorkingDirectory, } from './features/chat-pane/index.js';
20
+ export type { AgentRuntimePickerProps, ByokRuntimeSummary, ChatPaneActivity, ChatPaneAgent, ChatPaneAgentBridgeAccess, ChatPaneAgentControlOptions, ChatPaneAgentOption, ChatPaneAgentSelection, ChatPaneAgentToolAction, ChatPaneAgentToolDef, ChatPaneAgentToolInputSchema, ChatPaneAgentToolRisk, ChatPaneAttachmentUploadOptions, ChatPaneComposerHandle, ChatPaneProps, ChatPaneRunContext, ChatPaneRunContextInput, ChatPaneRuntimeAccess, ChatPaneVariant, ChatPaneWorkingDirectoryAccess, CreateDaemonAttachmentUploaderOptions, CreateMcpUiToolCallerOptions, CreateTypedAnswerPosterOptions, DeliverTypedAnswer, McpUiToolCallRequest, RuntimePickerPlacement, TypedAnswerDelivery, TypedAnswerNotice, UseChatPaneAgentControlOptions, UseChatPaneOptions, UseChatPaneResult, UseChatPaneRuntimeInventoryOptions, UseChatPaneRuntimeInventoryResult, UseChatPaneWorkingDirectoryOptions, UseChatPaneWorkingDirectoryResult, } from './features/chat-pane/index.js';
21
21
  //# sourceMappingURL=chat-pane.d.ts.map
@@ -16,5 +16,5 @@
16
16
  * `@deprecated`, so every existing `import { ChatPane } from '@jini-ai/chat/react'` keeps working
17
17
  * unchanged. New code should import from this subpath directly.
18
18
  */
19
- export { AgentRuntimePicker, CHAT_PANE_AGENT_TOOLS, ChatPane, createDaemonAttachmentUploader, createMcpUiToolCaller, defaultChatPaneSelection, orderChatPaneAgents, resolveChatPaneSelection, useChatPane, useChatPaneAgentControl, useChatPaneRuntimeInventory, useChatPaneWorkingDirectory, } from './features/chat-pane/index.js';
19
+ export { AgentRuntimePicker, CHAT_PANE_AGENT_TOOLS, ChatPane, createDaemonAttachmentUploader, createMcpUiToolCaller, createTypedAnswerPoster, defaultChatPaneSelection, orderChatPaneAgents, resolveChatPaneSelection, useChatPane, useChatPaneAgentControl, useChatPaneRuntimeInventory, useChatPaneWorkingDirectory, } from './features/chat-pane/index.js';
20
20
  //# sourceMappingURL=chat-pane.js.map
@@ -36,7 +36,7 @@
36
36
  * fallback that `onError` can retract - never a second, competing source of truth for a format the
37
37
  * server already classified.
38
38
  */
39
- import { type MouseEvent, type RefObject, type SyntheticEvent } from 'react';
39
+ import { type MouseEvent, type RefCallback, type SyntheticEvent } from 'react';
40
40
  import type { ChatAttachment } from '../../core/index.js';
41
41
  export type AttachmentPreviewStatus = 'image' | 'text' | 'unsupported';
42
42
  /** Hard cap on rendered text-preview characters, so a large attached log/CSV cannot hang the tab painting one giant `<pre>`. */
@@ -45,12 +45,25 @@ export declare const MAX_TEXT_PREVIEW_CHARS = 200000;
45
45
  export declare function looksLikeImageAttachment({ attachment }: {
46
46
  attachment: Pick<ChatAttachment, 'kind' | 'name'>;
47
47
  }): boolean;
48
+ /**
49
+ * Reads `file` as text via `FileReader`, not the newer `Blob.prototype.text()` promise API: both
50
+ * are supported in every real evergreen browser, but jsdom (this package's test environment,
51
+ * verified directly - `File`/`Blob` here has no `.text`/`.arrayBuffer` at all) implements
52
+ * `FileReader.readAsText` while leaving `.text()` unimplemented. Choosing the form the test
53
+ * environment can actually exercise avoids a test-only monkeypatch of a browser primitive whose
54
+ * production behavior isn't in question - only its jsdom coverage was.
55
+ */
56
+ export declare function readFileAsText({ file }: {
57
+ file: File;
58
+ }, { createReader }?: {
59
+ createReader?: () => FileReader;
60
+ }): Promise<string>;
48
61
  export interface AttachmentTextPreview {
49
62
  content: string;
50
63
  truncated: boolean;
51
64
  }
52
65
  export interface AttachmentPreviewController {
53
- dialogRef: RefObject<HTMLDialogElement | null>;
66
+ dialogRef: RefCallback<HTMLDialogElement>;
54
67
  status: AttachmentPreviewStatus;
55
68
  imageUrl: string | undefined;
56
69
  onImageError: () => void;
@@ -71,5 +84,9 @@ export interface AttachmentPreviewController {
71
84
  export declare function useAttachmentPreviewModal({ attachment, onClose }: {
72
85
  attachment: ChatAttachment;
73
86
  onClose: () => void;
87
+ }, { readText }?: {
88
+ readText?: (required: {
89
+ file: File;
90
+ }) => Promise<string>;
74
91
  }): AttachmentPreviewController;
75
92
  //# sourceMappingURL=AttachmentPreviewModal.hooks.d.ts.map
@@ -36,7 +36,7 @@
36
36
  * fallback that `onError` can retract - never a second, competing source of truth for a format the
37
37
  * server already classified.
38
38
  */
39
- import { useEffect, useRef, useState } from 'react';
39
+ import { useEffect, useState } from 'react';
40
40
  import { getAttachmentPreviewSource } from '../hooks/attachment-preview-cache.js';
41
41
  import { formatAttachmentSize } from './AttachmentTray.js';
42
42
  /** Extensions attempted as an `<img>` even when `attachment.kind` says otherwise — see module doc. */
@@ -64,9 +64,9 @@ function looksLikeTextAttachment(name) {
64
64
  * environment can actually exercise avoids a test-only monkeypatch of a browser primitive whose
65
65
  * production behavior isn't in question - only its jsdom coverage was.
66
66
  */
67
- function readFileAsText(file) {
67
+ export function readFileAsText({ file }, { createReader = () => new FileReader() } = {}) {
68
68
  return new Promise((resolve, reject) => {
69
- const reader = new FileReader();
69
+ const reader = createReader();
70
70
  reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '');
71
71
  reader.onerror = () => reject(reader.error ?? new Error('Failed to read attachment as text'));
72
72
  reader.readAsText(file);
@@ -81,6 +81,47 @@ function readFileAsText(file) {
81
81
  function canCreateObjectUrl() {
82
82
  return typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function';
83
83
  }
84
+ /**
85
+ * Opens `dialog` as a modal and returns the matching close, which also restores focus to whatever
86
+ * was focused when it opened — see this module's doc on why the restore is explicit rather than left
87
+ * to the browser's own (jsdom-absent) default.
88
+ */
89
+ function openModalDialog(dialog) {
90
+ const trigger = document.activeElement;
91
+ if (typeof dialog.showModal === 'function') {
92
+ if (!dialog.open)
93
+ dialog.showModal();
94
+ }
95
+ else {
96
+ dialog.setAttribute('open', '');
97
+ }
98
+ return () => {
99
+ if (typeof dialog.close === 'function') {
100
+ if (dialog.open)
101
+ dialog.close();
102
+ }
103
+ else {
104
+ dialog.removeAttribute('open');
105
+ }
106
+ if (trigger instanceof HTMLElement)
107
+ trigger.focus();
108
+ };
109
+ }
110
+ /**
111
+ * The `<dialog>`'s callback ref: open on attach, close + restore focus on detach. A callback ref
112
+ * rather than an effect reading a ref object, so the element is always in hand — there is no
113
+ * "not attached" case to guard. React 18 and 19 both call it with the element on mount and `null`
114
+ * on unmount, and attach always precedes detach, so `close` is set by the time it is called.
115
+ */
116
+ function createDialogRef() {
117
+ let close;
118
+ return (dialog) => {
119
+ if (dialog)
120
+ close = openModalDialog(dialog);
121
+ else
122
+ close();
123
+ };
124
+ }
84
125
  /**
85
126
  * Drives one `AttachmentPreviewModal` instance for `attachment`. The caller conditionally mounts
86
127
  * this (one open attachment at a time per `MessageRow`), so mount = open and unmount = close; there
@@ -89,9 +130,12 @@ function canCreateObjectUrl() {
89
130
  * read on mount (bounded in practice by the upload route's own 20 MB per-attachment cap, and by
90
131
  * {@link MAX_TEXT_PREVIEW_CHARS} for what actually renders).
91
132
  */
92
- export function useAttachmentPreviewModal({ attachment, onClose }) {
93
- const dialogRef = useRef(null);
94
- const triggerRef = useRef(null);
133
+ export function useAttachmentPreviewModal({ attachment, onClose }, { readText = readFileAsText } = {}) {
134
+ // Lazy state, not `useCallback`: a new ref identity would make React detach and re-attach it,
135
+ // closing and reopening the dialog. The caller mounts a fresh instance per opened attachment (never
136
+ // reuses one instance across two different open attachments), so there is no "attachment changed
137
+ // while still open" transition for the ref to react to.
138
+ const [dialogRef] = useState(createDialogRef);
95
139
  const [imageFailed, setImageFailed] = useState(false);
96
140
  const [imageUrl, setImageUrl] = useState(undefined);
97
141
  const [text, setText] = useState(undefined);
@@ -120,7 +164,7 @@ export function useAttachmentPreviewModal({ attachment, onClose }) {
120
164
  }
121
165
  let canceled = false;
122
166
  setTextLoading(true);
123
- readFileAsText(cachedFile)
167
+ readText({ file: cachedFile })
124
168
  .then((full) => {
125
169
  if (canceled)
126
170
  return;
@@ -142,36 +186,6 @@ export function useAttachmentPreviewModal({ attachment, onClose }) {
142
186
  };
143
187
  // eslint-disable-next-line react-hooks/exhaustive-deps
144
188
  }, [attemptText, cachedFile]);
145
- // Open on mount, close + restore focus on unmount — see this module's doc on why the restore is
146
- // explicit rather than left to the browser's own (jsdom-absent) default.
147
- useEffect(() => {
148
- const dialog = dialogRef.current;
149
- if (!dialog)
150
- return undefined;
151
- triggerRef.current = document.activeElement;
152
- if (typeof dialog.showModal === 'function') {
153
- if (!dialog.open)
154
- dialog.showModal();
155
- }
156
- else {
157
- dialog.setAttribute('open', '');
158
- }
159
- return () => {
160
- if (typeof dialog.close === 'function') {
161
- if (dialog.open)
162
- dialog.close();
163
- }
164
- else {
165
- dialog.removeAttribute('open');
166
- }
167
- if (triggerRef.current instanceof HTMLElement)
168
- triggerRef.current.focus();
169
- };
170
- // Mount/unmount only: the caller mounts a fresh instance per opened attachment (never reuses one
171
- // instance across two different open attachments), so there is no "attachment changed while
172
- // still open" transition for this effect to react to.
173
- // eslint-disable-next-line react-hooks/exhaustive-deps
174
- }, []);
175
189
  function handleNativeCancel(event) {
176
190
  // Always prevented, same reasoning as `ConfirmDialog.hooks.tsx`: the effect above is the single
177
191
  // source of truth for open/closed, so Escape still closes — it just does so through `onClose`
@@ -182,7 +196,8 @@ export function useAttachmentPreviewModal({ attachment, onClose }) {
182
196
  function handleBackdropClick(event) {
183
197
  // A `<dialog>` element's own box is sized to its content; a click landing on the `<dialog>`
184
198
  // element itself (rather than a descendant) is a click on the backdrop area outside that box.
185
- if (event.target === dialogRef.current)
199
+ // The handler is bound on the `<dialog>`, so `currentTarget` is that element.
200
+ if (event.target === event.currentTarget)
186
201
  onClose();
187
202
  }
188
203
  return {
@@ -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,76 @@
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 { isScrolledToBottom, 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
+ // Runs BEFORE the scroll effect below (effects run in declaration order), so a send re-sticks in
18
+ // time for that same commit's scroll. The user's own send (or retry) is an explicit "take me to
19
+ // the latest turn": without this, a user who had scrolled up to read history and then sent stayed
20
+ // parked up there, because the stick gate below — correct for streamed chatter — also swallowed
21
+ // their own send (owner-reported "Chat auto-scroll still sticks", todos audit 2026-10-04).
22
+ useEffect(() => {
23
+ if (startsNewTurn({ previous: previousMessagesRef.current, next: messages })) {
24
+ stickToBottomRef.current = true;
25
+ }
26
+ previousMessagesRef.current = messages;
27
+ }, [messages]);
28
+ useEffect(() => {
29
+ if (!scrollIntent)
30
+ return;
31
+ const el = containerRef.current;
32
+ // Gated on `stickToBottomRef`, same as the ResizeObserver effect below: `scrollIntent` turns
33
+ // true on EVERY streamed event (tool-call chatter included, not just a finished reply — see
34
+ // `useConversation.applyRunToAssistantMessage`), so scrolling unconditionally here yanked a user
35
+ // who had deliberately scrolled up to read history back to the bottom on the very next chunk
36
+ // (owner-reported, 2026-08-30). `stickToBottomRef` already reflects the user's own last scroll
37
+ // gesture via the `onScroll` handler below, captured BEFORE this content change, so it is safe
38
+ // to trust here.
39
+ if (el && stickToBottomRef.current) {
40
+ el.scrollTop = el.scrollHeight;
41
+ }
42
+ onScrolled?.();
43
+ // eslint-disable-next-line react-hooks/exhaustive-deps
44
+ }, [scrollIntent, messages]);
45
+ // Re-sticks to the bottom when a message ROW grows after mount with no accompanying `messages`
46
+ // change — the exact case an embedded MCP-UI/A2UI surface hits: it mounts at an initial guessed
47
+ // height (`preferredFrameSize` or `DEFAULT_INITIAL_HEIGHT`), then reports its real, often much
48
+ // taller content height asynchronously via `ui/notifications/size-changed`, entirely inside that
49
+ // surface's own React state (`McpUiHost`). The effect above never re-runs for that, so without this
50
+ // a surface which grows after the one-shot scroll leaves its own action buttons — or, once it
51
+ // resolves, the assistant's next reply — below the stale "bottom" with nothing to bring them back
52
+ // into view. Observes each direct child (one per message row) rather than the scroll container
53
+ // itself: the container's own box is held fixed by its flex/overflow layout, so only its CONTENT
54
+ // grows, which `ResizeObserver` only reports for the element whose box is actually changing.
55
+ // Re-created per `messages` change so a newly mounted row is observed too. Gated on
56
+ // `stickToBottomRef` so a human who scrolled up to read history is never yanked back down by an
57
+ // unrelated row resizing elsewhere in the transcript.
58
+ useEffect(() => {
59
+ const el = containerRef.current;
60
+ if (!el || typeof ResizeObserver !== 'function')
61
+ return;
62
+ const observer = new ResizeObserver(() => {
63
+ if (!stickToBottomRef.current)
64
+ return;
65
+ el.scrollTop = el.scrollHeight;
66
+ });
67
+ for (const child of Array.from(el.children))
68
+ observer.observe(child);
69
+ return () => observer.disconnect();
70
+ }, [messages]);
71
+ const onScroll = (event) => {
72
+ stickToBottomRef.current = isScrolledToBottom(event.currentTarget);
73
+ };
74
+ return { containerRef, onScroll };
75
+ }
76
+ //# sourceMappingURL=MessageList.hooks.js.map