@jini-ai/chat 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -1
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/run-activity.d.ts +12 -20
- package/dist/core/run-activity.js +59 -7
- package/dist/core/surface-expiry.d.ts +42 -0
- package/dist/core/surface-expiry.js +42 -0
- package/dist/core/tool-events.d.ts +34 -0
- package/dist/core/tool-events.js +61 -0
- package/dist/core/tools.d.ts +1 -1
- package/dist/core/tools.js +1 -1
- package/dist/react/chat-pane.d.ts +2 -2
- package/dist/react/chat-pane.js +1 -1
- package/dist/react/components/AttachmentPreviewModal.hooks.d.ts +19 -2
- package/dist/react/components/AttachmentPreviewModal.hooks.js +53 -38
- package/dist/react/components/Markdown.js +37 -6
- package/dist/react/components/McpUiSurfaceCard.d.ts +7 -1
- package/dist/react/components/McpUiSurfaceCard.js +23 -38
- package/dist/react/components/MessageList.d.ts +11 -0
- package/dist/react/components/MessageList.hooks.d.ts +19 -0
- package/dist/react/components/MessageList.hooks.js +82 -0
- package/dist/react/components/MessageList.js +3 -71
- package/dist/react/components/MessageList.rules.d.ts +43 -0
- package/dist/react/components/MessageList.rules.js +38 -0
- package/dist/react/components/MessageRow.js +3 -1
- package/dist/react/components/ToolCard.js +10 -7
- package/dist/react/components/mcp-ui-surface-state.d.ts +27 -0
- package/dist/react/components/mcp-ui-surface-state.js +33 -0
- package/dist/react/ext-event-renderer-registry.d.ts +7 -0
- package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
- package/dist/react/features/chat-pane/components/ChatPane.js +6 -5
- package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +54 -0
- package/dist/react/features/chat-pane/create-typed-answer-poster.js +46 -0
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +17 -1
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +153 -7
- package/dist/react/features/chat-pane/index.d.ts +2 -1
- package/dist/react/features/chat-pane/index.js +1 -0
- package/dist/react/features/chat-pane/rules.d.ts +59 -1
- package/dist/react/features/chat-pane/rules.js +74 -0
- package/dist/react/features/chat-pane/types.d.ts +34 -0
- package/dist/react/hooks/composer-draft-cache.d.ts +28 -0
- package/dist/react/hooks/composer-draft-cache.js +56 -6
- package/dist/react/hooks/last-conversation-store.d.ts +58 -0
- package/dist/react/hooks/last-conversation-store.js +112 -0
- package/dist/react/hooks/useSurfaceExpiry.d.ts +20 -0
- package/dist/react/hooks/useSurfaceExpiry.js +35 -0
- package/dist/react/hooks/useToolTimeline.js +6 -3
- package/dist/react/index.d.ts +10 -2
- package/dist/react/index.js +7 -1
- package/package.json +7 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,31 @@
|
|
|
1
|
+
## 0.5.3 — 2026-10-06
|
|
2
|
+
|
|
3
|
+
- New `createLastConversationStore({ scope }, { storage? })` (react): remembers one conversation id per caller identity under a versioned localStorage key (`jini.chat.last-conversation.v1.<scope>`); corrupt or foreign entries read as nothing remembered. Selection stays host-owned.
|
|
4
|
+
- Restored staged attachments keep their upload batch: the draft cache records the batch id beside each conversation's attachment references, and `useChatPane` resumes that batch on mount and on a conversation switch, so a second attachment after a remount no longer fails the turn ("Attachments must belong to one batch").
|
|
5
|
+
- A display-only A2UI surface (`surfaceProperties.displayOnly`, e.g. a drawn chart) no longer reads as "Waiting for your answer above".
|
|
6
|
+
- A run held on an open form reads as waiting, not working: new `isAwaitingAnswer`, passed to ext event renderers as `awaitingAnswer`.
|
|
7
|
+
- One row per delegated tool call: `isDelegatedWrapperToolName` covers both delegated gateways (bare or `mcp__<server>__`-prefixed) and `foldDelegatedWrapperCalls` drops a wrapper call once its canonical call exists (`useToolTimeline`, `ToolCard`).
|
|
8
|
+
- Markdown: `[label](url)` links render (relative and parenthesized URLs, unsafe schemes kept as text, off-site links open in a new tab); intraword underscores never open or close emphasis.
|
|
9
|
+
- A late scroll event no longer unsticks the transcript from the bottom; only a move up does.
|
|
10
|
+
- Requires `@jini-ai/agentic` ^0.4.2 (`displayOnlySurfaceIdOf`).
|
|
11
|
+
|
|
12
|
+
## 0.5.2 — 2026-10-05
|
|
13
|
+
|
|
14
|
+
- `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`.
|
|
15
|
+
- 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.
|
|
16
|
+
|
|
17
|
+
- `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.
|
|
18
|
+
|
|
19
|
+
- `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.
|
|
20
|
+
|
|
21
|
+
- 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`).
|
|
22
|
+
- 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.
|
|
23
|
+
|
|
1
24
|
## 0.5.1 — 2026-10-04
|
|
2
25
|
|
|
3
26
|
- 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.
|
|
4
27
|
|
|
5
|
-
|
|
28
|
+
### Also shipped in 0.5.1 (was listed under Unreleased)
|
|
6
29
|
|
|
7
30
|
- Type store fixtures with the shared storage kernel and chat contracts, including host-owned tables;
|
|
8
31
|
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
|
/**
|
|
@@ -1,22 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module run-activity
|
|
3
|
-
*
|
|
4
|
-
* What a running turn is doing right now, as one short line ("Running Page Fill… 1 m 20 s").
|
|
5
|
-
*
|
|
6
|
-
* Why: once a turn's first tool row appeared, nothing on screen showed the run was still alive —
|
|
7
|
-
* the old "Thinking…" placeholder only showed while the message was completely empty. Model think
|
|
8
|
-
* time, long tool calls, held cards and API overload retries all looked identical: dead.
|
|
9
|
-
*
|
|
10
|
-
* Shape: {@link deriveRunActivity} reads the events the pane already has and returns the latest
|
|
11
|
-
* signal; {@link RUN_ACTIVITY_LABELS} is the ONE table that turns a signal into words. The clock
|
|
12
|
-
* (seconds since the signal began) is the caller's: the React hook ticks it once a second. Nothing
|
|
13
|
-
* here special-cases a particular tool.
|
|
14
|
-
*
|
|
15
|
-
* Status events this reads (a host translates its runtime's heartbeats into them):
|
|
16
|
-
* - `code: 'api_retry'`, `data: { attempt, maxAttempts, service? }` — the model API is overloaded.
|
|
17
|
-
* - `code: 'tool_progress'`, `data: { elapsedSeconds }` — a tool call is still running.
|
|
18
|
-
* - `code: 'thinking'` — the model is reasoning (keeps the idle timer honest).
|
|
19
|
-
*/
|
|
20
1
|
import type { AgentEvent } from './events.js';
|
|
21
2
|
/** The latest thing a running turn is doing. */
|
|
22
3
|
export type RunActivity = {
|
|
@@ -57,7 +38,8 @@ export declare function humanizeToolName({ name }: {
|
|
|
57
38
|
* The latest signal in a running turn's events.
|
|
58
39
|
*
|
|
59
40
|
* Precedence, newest wins: an API retry with nothing visible after it; a card that arrived while a
|
|
60
|
-
* tool call is still open (the tool is waiting on the person
|
|
41
|
+
* tool call is still open (the tool is waiting on the person; a display-only A2UI surface is not a
|
|
42
|
+
* card); the newest open tool call; else
|
|
61
43
|
* writing (last visible event is text) or thinking.
|
|
62
44
|
*
|
|
63
45
|
* @complexity O(n) in events, O(open tool calls) extra space.
|
|
@@ -65,6 +47,16 @@ export declare function humanizeToolName({ name }: {
|
|
|
65
47
|
export declare function deriveRunActivity({ events }: {
|
|
66
48
|
events: readonly AgentEvent[] | undefined;
|
|
67
49
|
}): RunActivityState;
|
|
50
|
+
/**
|
|
51
|
+
* Whether the run is waiting on the person: a card (form, confirm, sign-in) arrived while its tool
|
|
52
|
+
* call is still open. Hosts use it to hide "still working" notices, which read as a hung run while
|
|
53
|
+
* the run is really waiting for an answer.
|
|
54
|
+
*
|
|
55
|
+
* @complexity O(n) in events.
|
|
56
|
+
*/
|
|
57
|
+
export declare function isAwaitingAnswer({ events }: {
|
|
58
|
+
events: readonly AgentEvent[] | undefined;
|
|
59
|
+
}): boolean;
|
|
68
60
|
/** `4` → `"4 s"`, `80` → `"1 m 20 s"`, `120` → `"2 m 0 s"`. */
|
|
69
61
|
export declare function formatActivityClock({ totalSeconds }: {
|
|
70
62
|
totalSeconds: number;
|
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module run-activity
|
|
3
|
+
*
|
|
4
|
+
* What a running turn is doing right now, as one short line ("Running Page Fill… 1 m 20 s").
|
|
5
|
+
*
|
|
6
|
+
* Why: once a turn's first tool row appeared, nothing on screen showed the run was still alive —
|
|
7
|
+
* the old "Thinking…" placeholder only showed while the message was completely empty. Model think
|
|
8
|
+
* time, long tool calls, held cards and API overload retries all looked identical: dead.
|
|
9
|
+
*
|
|
10
|
+
* Shape: {@link deriveRunActivity} reads the events the pane already has and returns the latest
|
|
11
|
+
* signal; {@link RUN_ACTIVITY_LABELS} is the ONE table that turns a signal into words. The clock
|
|
12
|
+
* (seconds since the signal began) is the caller's: the React hook ticks it once a second. Nothing
|
|
13
|
+
* here special-cases a particular tool.
|
|
14
|
+
*
|
|
15
|
+
* Status events this reads (a host translates its runtime's heartbeats into them):
|
|
16
|
+
* - `code: 'api_retry'`, `data: { attempt, maxAttempts, service? }` — the model API is overloaded.
|
|
17
|
+
* - `code: 'tool_progress'`, `data: { elapsedSeconds }` — a tool call is still running.
|
|
18
|
+
* - `code: 'thinking'` — the model is reasoning (keeps the idle timer honest).
|
|
19
|
+
*/
|
|
20
|
+
import { a2uiSurfaceIdOf, displayOnlySurfaceIdOf } from '@jini-ai/agentic/a2ui';
|
|
21
|
+
import { isDelegatedWrapperToolName } from './tool-events.js';
|
|
1
22
|
/** Tool names whose only job is finding the right tool (MCP tool search / schema lookup). */
|
|
2
23
|
const DISCOVERY_TOOL_NAMES = new Set([
|
|
3
24
|
'ToolSearch',
|
|
@@ -6,10 +27,10 @@ const DISCOVERY_TOOL_NAMES = new Set([
|
|
|
6
27
|
'mcp__jini__search_tools',
|
|
7
28
|
'mcp__jini__describe_tool',
|
|
8
29
|
]);
|
|
9
|
-
/** Transport wrappers whose real tool id is in `input.toolId`. */
|
|
10
|
-
const WRAPPER_TOOL_NAMES = new Set(['execute_delegated_tool', 'mcp__jini__execute_delegated_tool']);
|
|
11
30
|
/** Ext events that are a card for the person (a form, a confirm, a sign-in). */
|
|
12
31
|
const CARD_EXT_NAMES = new Set(['mcp-ui', 'a2ui']);
|
|
32
|
+
/** The A2UI ext name: its surfaces may declare themselves display-only (`@jini-ai/agentic/a2ui`). */
|
|
33
|
+
const A2UI_EXT_NAME = 'a2ui';
|
|
13
34
|
/** After this long with nothing new on screen, a thinking/writing line says "Still working…". */
|
|
14
35
|
export const RUN_ACTIVITY_IDLE_MS = 20_000;
|
|
15
36
|
function isRecord(value) {
|
|
@@ -25,7 +46,8 @@ function stringField(data, key) {
|
|
|
25
46
|
}
|
|
26
47
|
/** `"page.fill"` → `"Page Fill"`, `"mcp__supabase__list_projects"` → `"List Projects"`. Display only. */
|
|
27
48
|
export function humanizeToolName({ name }, { input } = {}) {
|
|
28
|
-
|
|
49
|
+
// A transport wrapper's real tool id is in `input.toolId`.
|
|
50
|
+
const real = isDelegatedWrapperToolName({ name: name }) ? (stringField(input, 'toolId') ?? name) : name;
|
|
29
51
|
const bare = real.startsWith('mcp__') ? real.split('__').slice(2).join('__') || real : real;
|
|
30
52
|
return bare
|
|
31
53
|
.split(/[._\s]+/)
|
|
@@ -36,6 +58,23 @@ export function humanizeToolName({ name }, { input } = {}) {
|
|
|
36
58
|
function isVisible(event) {
|
|
37
59
|
return event.kind === 'text' ? event.text.length > 0 : event.kind === 'tool_use' || event.kind === 'tool_result' || event.kind === 'ext';
|
|
38
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Whether an ext event is a card the person must answer. A display-only A2UI surface shows
|
|
63
|
+
* something and asks nothing, so a tool holding its call open after drawing one (assistant_render_ui
|
|
64
|
+
* waits a few seconds to hear a browser refusal) must not read as "Waiting for your answer above"
|
|
65
|
+
* (demo V3, 2026-10-05).
|
|
66
|
+
*/
|
|
67
|
+
function isAnswerCard(scan, name, data) {
|
|
68
|
+
if (!CARD_EXT_NAMES.has(name))
|
|
69
|
+
return false;
|
|
70
|
+
if (name !== A2UI_EXT_NAME)
|
|
71
|
+
return true;
|
|
72
|
+
const displayOnlyId = displayOnlySurfaceIdOf({ message: data });
|
|
73
|
+
if (displayOnlyId !== undefined)
|
|
74
|
+
scan.displayOnlySurfaces.add(displayOnlyId);
|
|
75
|
+
const surfaceId = a2uiSurfaceIdOf({ message: data });
|
|
76
|
+
return surfaceId === undefined || !scan.displayOnlySurfaces.has(surfaceId);
|
|
77
|
+
}
|
|
39
78
|
function scanEvent(scan, event, index) {
|
|
40
79
|
if (isVisible(event)) {
|
|
41
80
|
scan.lastVisible = { index, kind: event.kind };
|
|
@@ -48,7 +87,7 @@ function scanEvent(scan, event, index) {
|
|
|
48
87
|
else if (event.kind === 'tool_result') {
|
|
49
88
|
scan.open = scan.open.filter((tool) => tool.id !== event.toolUseId);
|
|
50
89
|
}
|
|
51
|
-
else if (event.kind === 'ext' &&
|
|
90
|
+
else if (event.kind === 'ext' && isAnswerCard(scan, event.name, event.data)) {
|
|
52
91
|
scan.lastCardIndex = index;
|
|
53
92
|
}
|
|
54
93
|
else if (event.kind === 'status' && event.code === 'api_retry') {
|
|
@@ -64,13 +103,14 @@ function scanEvent(scan, event, index) {
|
|
|
64
103
|
* The latest signal in a running turn's events.
|
|
65
104
|
*
|
|
66
105
|
* Precedence, newest wins: an API retry with nothing visible after it; a card that arrived while a
|
|
67
|
-
* tool call is still open (the tool is waiting on the person
|
|
106
|
+
* tool call is still open (the tool is waiting on the person; a display-only A2UI surface is not a
|
|
107
|
+
* card); the newest open tool call; else
|
|
68
108
|
* writing (last visible event is text) or thinking.
|
|
69
109
|
*
|
|
70
110
|
* @complexity O(n) in events, O(open tool calls) extra space.
|
|
71
111
|
*/
|
|
72
112
|
export function deriveRunActivity({ events }) {
|
|
73
|
-
const scan = { open: [], lastVisible: null, lastCardIndex: -1, retry: null, progress: null, visibleCount: 0 };
|
|
113
|
+
const scan = { open: [], lastVisible: null, lastCardIndex: -1, retry: null, progress: null, visibleCount: 0, displayOnlySurfaces: new Set() };
|
|
74
114
|
(events ?? []).forEach((event, index) => scanEvent(scan, event, index));
|
|
75
115
|
const visibleCount = scan.visibleCount;
|
|
76
116
|
const lastVisibleIndex = scan.lastVisible?.index ?? -1;
|
|
@@ -114,6 +154,16 @@ export function deriveRunActivity({ events }) {
|
|
|
114
154
|
}
|
|
115
155
|
return { activity: { kind: 'thinking' }, key: `thinking:${lastVisibleIndex}`, visibleCount };
|
|
116
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* Whether the run is waiting on the person: a card (form, confirm, sign-in) arrived while its tool
|
|
159
|
+
* call is still open. Hosts use it to hide "still working" notices, which read as a hung run while
|
|
160
|
+
* the run is really waiting for an answer.
|
|
161
|
+
*
|
|
162
|
+
* @complexity O(n) in events.
|
|
163
|
+
*/
|
|
164
|
+
export function isAwaitingAnswer({ events }) {
|
|
165
|
+
return deriveRunActivity({ events }).activity.kind === 'awaiting-answer';
|
|
166
|
+
}
|
|
117
167
|
/** `4` → `"4 s"`, `80` → `"1 m 20 s"`, `120` → `"2 m 0 s"`. */
|
|
118
168
|
export function formatActivityClock({ totalSeconds }) {
|
|
119
169
|
const seconds = Math.max(0, Math.floor(totalSeconds));
|
|
@@ -133,7 +183,9 @@ export const RUN_ACTIVITY_LABELS = {
|
|
|
133
183
|
: t('Writing…'),
|
|
134
184
|
'finding-tool': (_a, clock, t) => t('Finding the right tool… {time}', { time: formatActivityClock({ totalSeconds: clock.seconds }) }),
|
|
135
185
|
'running-tool': (a, clock, t) => t('Running {tool}… {time}', { tool: a.tool, time: formatActivityClock({ totalSeconds: clock.seconds }) }),
|
|
136
|
-
|
|
186
|
+
// No clock: the run is waiting on the person, not working, and a ticking timer under an open
|
|
187
|
+
// form read as a hung run (demo dry-run, 2026-10-05).
|
|
188
|
+
'awaiting-answer': (_a, _clock, t) => t('Waiting for your answer above'),
|
|
137
189
|
retrying: (a, _clock, t) => {
|
|
138
190
|
const who = a.service ? t("{service}'s servers are busy", { service: a.service }) : t('The AI service is busy');
|
|
139
191
|
return a.attempt !== undefined && a.maxAttempts !== undefined
|
|
@@ -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
|
|
@@ -17,4 +17,38 @@ import type { AgentEvent } from './events.js';
|
|
|
17
17
|
export declare function dedupeToolUsesById({ events }: {
|
|
18
18
|
events: AgentEvent[] | undefined;
|
|
19
19
|
}): AgentEvent[];
|
|
20
|
+
/**
|
|
21
|
+
* Whether `name` is a delegated-tool transport wrapper — `execute_delegated_tool` or
|
|
22
|
+
* `execute_readonly_delegated_tool`, bare or as an MCP client names it (`mcp__<server>__…`). Its real
|
|
23
|
+
* tool id is in `input.toolId`. The one shared definition, so a renderer and the run-activity line
|
|
24
|
+
* cannot disagree about which names are wrappers (both once missed the read-only gateway).
|
|
25
|
+
*
|
|
26
|
+
* @complexity O(n) in the length of `name`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function isDelegatedWrapperToolName({ name }: {
|
|
29
|
+
name: string;
|
|
30
|
+
}): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Drop each delegated-tool wrapper call that the daemon also reported as its own canonical row,
|
|
33
|
+
* so one call shows as one row.
|
|
34
|
+
*
|
|
35
|
+
* WHY. A delegated call arrives twice: the vendor CLI's wrapper `tool_use`
|
|
36
|
+
* (`mcp__jini__execute_delegated_tool {toolId, input}`, with the CLI's `toolu_…` id) and the
|
|
37
|
+
* daemon bridge's canonical `tool_use` named after the real tool (`content_stats`, with a fresh
|
|
38
|
+
* UUID from `@jini-ai/mcp`). The ids differ, so {@link dedupeToolUsesById} cannot merge them, and a
|
|
39
|
+
* failed call showed two red rows. The canonical row is the one to keep: it carries the tool's own
|
|
40
|
+
* name, input and result.
|
|
41
|
+
*
|
|
42
|
+
* A wrapper folds onto the first LATER canonical `tool_use` whose name equals its `input.toolId`
|
|
43
|
+
* and that no earlier wrapper has claimed; its `tool_result` events go with it. A wrapper with no
|
|
44
|
+
* such canonical row (refused before the bridge, e.g. a read-only 403) stays, so its error is not
|
|
45
|
+
* hidden.
|
|
46
|
+
*
|
|
47
|
+
* @param events - The event list, normally already passed through {@link dedupeToolUsesById}.
|
|
48
|
+
* @returns A new array without the folded wrappers, or the original reference when none fold.
|
|
49
|
+
* @complexity O(n·w): n events, w wrapper calls (a handful per message).
|
|
50
|
+
*/
|
|
51
|
+
export declare function foldDelegatedWrapperCalls({ events }: {
|
|
52
|
+
events: AgentEvent[] | undefined;
|
|
53
|
+
}): AgentEvent[];
|
|
20
54
|
//# sourceMappingURL=tool-events.d.ts.map
|
package/dist/core/tool-events.js
CHANGED
|
@@ -33,4 +33,65 @@ export function dedupeToolUsesById({ events }) {
|
|
|
33
33
|
}
|
|
34
34
|
return deduped ?? events;
|
|
35
35
|
}
|
|
36
|
+
/** The two delegated-tool gateways `@jini-ai/mcp` hosts: the write gateway and its read-only companion. */
|
|
37
|
+
const DELEGATED_WRAPPER_PATTERN = /^(?:mcp__.+__)?execute_(?:readonly_)?delegated_tool$/;
|
|
38
|
+
/**
|
|
39
|
+
* Whether `name` is a delegated-tool transport wrapper — `execute_delegated_tool` or
|
|
40
|
+
* `execute_readonly_delegated_tool`, bare or as an MCP client names it (`mcp__<server>__…`). Its real
|
|
41
|
+
* tool id is in `input.toolId`. The one shared definition, so a renderer and the run-activity line
|
|
42
|
+
* cannot disagree about which names are wrappers (both once missed the read-only gateway).
|
|
43
|
+
*
|
|
44
|
+
* @complexity O(n) in the length of `name`.
|
|
45
|
+
*/
|
|
46
|
+
export function isDelegatedWrapperToolName({ name }) {
|
|
47
|
+
return DELEGATED_WRAPPER_PATTERN.test(name);
|
|
48
|
+
}
|
|
49
|
+
function wrapperToolId(event) {
|
|
50
|
+
if (event.kind !== 'tool_use' || !isDelegatedWrapperToolName({ name: event.name }))
|
|
51
|
+
return undefined;
|
|
52
|
+
const input = event.input;
|
|
53
|
+
const toolId = typeof input === 'object' && input !== null ? input.toolId : undefined;
|
|
54
|
+
return typeof toolId === 'string' && toolId ? toolId : undefined;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Drop each delegated-tool wrapper call that the daemon also reported as its own canonical row,
|
|
58
|
+
* so one call shows as one row.
|
|
59
|
+
*
|
|
60
|
+
* WHY. A delegated call arrives twice: the vendor CLI's wrapper `tool_use`
|
|
61
|
+
* (`mcp__jini__execute_delegated_tool {toolId, input}`, with the CLI's `toolu_…` id) and the
|
|
62
|
+
* daemon bridge's canonical `tool_use` named after the real tool (`content_stats`, with a fresh
|
|
63
|
+
* UUID from `@jini-ai/mcp`). The ids differ, so {@link dedupeToolUsesById} cannot merge them, and a
|
|
64
|
+
* failed call showed two red rows. The canonical row is the one to keep: it carries the tool's own
|
|
65
|
+
* name, input and result.
|
|
66
|
+
*
|
|
67
|
+
* A wrapper folds onto the first LATER canonical `tool_use` whose name equals its `input.toolId`
|
|
68
|
+
* and that no earlier wrapper has claimed; its `tool_result` events go with it. A wrapper with no
|
|
69
|
+
* such canonical row (refused before the bridge, e.g. a read-only 403) stays, so its error is not
|
|
70
|
+
* hidden.
|
|
71
|
+
*
|
|
72
|
+
* @param events - The event list, normally already passed through {@link dedupeToolUsesById}.
|
|
73
|
+
* @returns A new array without the folded wrappers, or the original reference when none fold.
|
|
74
|
+
* @complexity O(n·w): n events, w wrapper calls (a handful per message).
|
|
75
|
+
*/
|
|
76
|
+
export function foldDelegatedWrapperCalls({ events }) {
|
|
77
|
+
if (!events || events.length === 0)
|
|
78
|
+
return [];
|
|
79
|
+
const claimed = new Set();
|
|
80
|
+
const folded = new Set();
|
|
81
|
+
events.forEach((event, i) => {
|
|
82
|
+
const toolId = wrapperToolId(event);
|
|
83
|
+
// A replayed wrapper (same id) must not claim a second canonical row.
|
|
84
|
+
if (toolId === undefined || event.kind !== 'tool_use' || folded.has(event.id))
|
|
85
|
+
return;
|
|
86
|
+
const match = events.findIndex((other, j) => j > i && !claimed.has(j) && other.kind === 'tool_use' && other.name === toolId);
|
|
87
|
+
if (match === -1)
|
|
88
|
+
return;
|
|
89
|
+
claimed.add(match);
|
|
90
|
+
folded.add(event.id);
|
|
91
|
+
});
|
|
92
|
+
if (folded.size === 0)
|
|
93
|
+
return events;
|
|
94
|
+
return events.filter((event) => !((event.kind === 'tool_use' && folded.has(event.id)) ||
|
|
95
|
+
(event.kind === 'tool_result' && folded.has(event.toolUseId))));
|
|
96
|
+
}
|
|
36
97
|
//# sourceMappingURL=tool-events.js.map
|
package/dist/core/tools.d.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* archived provenance ledger.
|
|
11
11
|
*/
|
|
12
12
|
import type { ToolResultEvent, ToolResultMediaBlock, ToolUseEvent } from './events.js';
|
|
13
|
-
export { dedupeToolUsesById } from './tool-events.js';
|
|
13
|
+
export { dedupeToolUsesById, foldDelegatedWrapperCalls, isDelegatedWrapperToolName } from './tool-events.js';
|
|
14
14
|
/** The four-state tool-call lifecycle agreed across AG-UI / CopilotKit / LangGraph render props. */
|
|
15
15
|
export type ToolStatus = 'inProgress' | 'executing' | 'complete' | 'error';
|
|
16
16
|
/** The render-prop payload a tool-call UI needs, independent of any framework. */
|
package/dist/core/tools.js
CHANGED
|
@@ -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 {
|