@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.
- package/CHANGELOG.md +17 -1
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/surface-expiry.d.ts +42 -0
- package/dist/core/surface-expiry.js +42 -0
- 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/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 +76 -0
- package/dist/react/components/MessageList.js +3 -71
- package/dist/react/components/MessageList.rules.d.ts +25 -0
- package/dist/react/components/MessageList.rules.js +23 -0
- 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/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 +120 -4
- 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 +56 -1
- package/dist/react/features/chat-pane/rules.js +71 -0
- package/dist/react/features/chat-pane/types.d.ts +34 -0
- package/dist/react/hooks/useSurfaceExpiry.d.ts +20 -0
- package/dist/react/hooks/useSurfaceExpiry.js +35 -0
- package/dist/react/index.d.ts +4 -2
- package/dist/react/index.js +2 -1
- package/package.json +8 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
|
-
##
|
|
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.
|
package/dist/core/index.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/core/index.js
CHANGED
|
@@ -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
|
package/dist/react/chat-pane.js
CHANGED
|
@@ -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
|
|
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:
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,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
|