@jini-ai/chat 0.3.9 → 0.3.10
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/dist/core/assistant-content.d.ts +29 -0
- package/dist/core/assistant-content.js +41 -0
- package/dist/core/compact-events.d.ts +23 -0
- package/dist/core/compact-events.js +23 -0
- package/dist/core/events.d.ts +2 -0
- package/dist/core/index.d.ts +4 -0
- package/dist/core/index.js +4 -0
- package/dist/core/run-activity.d.ts +88 -0
- package/dist/core/run-activity.js +153 -0
- package/dist/core/tool-output.d.ts +13 -0
- package/dist/core/tool-output.js +24 -0
- package/dist/react/components/Markdown.js +21 -7
- package/dist/react/components/McpUiSurfaceCard.d.ts +9 -1
- package/dist/react/components/McpUiSurfaceCard.js +43 -2
- package/dist/react/components/MessageRow.js +28 -7
- package/dist/react/components/ToolCard.js +6 -6
- package/dist/react/ext-event-renderer-registry.d.ts +39 -1
- package/dist/react/ext-event-renderer-registry.js +23 -4
- package/dist/react/features/chat-pane/components/ChatPane.js +5 -4
- package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.d.ts +15 -1
- package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +113 -14
- package/dist/react/features/chat-pane/styles.d.ts +1 -1
- package/dist/react/features/chat-pane/styles.js +7 -2
- package/dist/react/hooks/useConversation.js +13 -11
- package/dist/react/hooks/useExtEventGroups.d.ts +13 -1
- package/dist/react/hooks/useExtEventGroups.js +38 -9
- package/dist/react/hooks/useRunActivity.d.ts +12 -0
- package/dist/react/hooks/useRunActivity.js +44 -0
- package/dist/react/index.d.ts +3 -3
- package/dist/react/index.js +2 -2
- package/dist/react/message-blocks.d.ts +6 -1
- package/dist/react/message-blocks.js +14 -13
- package/package.json +4 -4
|
@@ -54,9 +54,10 @@ import { isTerminalRunStatus, splitOnQuestionForms, stripArtifact } from '../../
|
|
|
54
54
|
import { useToolTimeline } from '../hooks/useToolTimeline.js';
|
|
55
55
|
import { useExtEventGroups } from '../hooks/useExtEventGroups.js';
|
|
56
56
|
import { useCopyToClipboard } from '../hooks/useCopyToClipboard.js';
|
|
57
|
+
import { useRunActivity } from '../hooks/useRunActivity.js';
|
|
57
58
|
import { interleaveMessageBlocks } from '../message-blocks.js';
|
|
58
59
|
import { useT } from '../hooks/context.js';
|
|
59
|
-
import { getExtEventRenderer } from '../ext-event-renderer-registry.js';
|
|
60
|
+
import { extEventSlot, getExtEventRenderer } from '../ext-event-renderer-registry.js';
|
|
60
61
|
import { ExtEventErrorBoundary } from './ExtEventErrorBoundary.js';
|
|
61
62
|
import { AttachmentPreviewModal } from './AttachmentPreviewModal.js';
|
|
62
63
|
import { Icon } from './Icon.js';
|
|
@@ -138,7 +139,7 @@ function CopyMessageButton({ text, label }) {
|
|
|
138
139
|
export function MessageRow({ message, runStreaming = false, runSucceeded = false, questionFormInteractive = false, questionFormSubmittedAnswers, onQuestionFormSubmit, projectFileNames, onRequestOpenFile, renderAttachment, }) {
|
|
139
140
|
const t = useT();
|
|
140
141
|
const timeline = useToolTimeline(message.events, { runStreaming, runSucceeded });
|
|
141
|
-
const extGroups = useExtEventGroups(message.events);
|
|
142
|
+
const extGroups = useExtEventGroups(message.events, extEventSlot);
|
|
142
143
|
// Which attachment chip (if any) this row's preview modal is open for. Row-local, not
|
|
143
144
|
// module-level: two attachments in the same message can never be "open" at once, but two
|
|
144
145
|
// different messages opening their own attachments independently is fine and expected.
|
|
@@ -154,7 +155,9 @@ export function MessageRow({ message, runStreaming = false, runSucceeded = false
|
|
|
154
155
|
// event walk produces, and an artifact block could legitimately span a tool call. Rather than
|
|
155
156
|
// reason about partial overlaps, an artifact-bearing message keeps the flat layout — those
|
|
156
157
|
// messages are dominated by the artifact panel anyway, so the ordering matters least there.
|
|
157
|
-
const blocks = visibleContent === message.content
|
|
158
|
+
const blocks = visibleContent === message.content
|
|
159
|
+
? interleaveMessageBlocks(message.events, message.content, timeline.rows, (ev) => extEventSlot(ev.name, ev.data))
|
|
160
|
+
: null;
|
|
158
161
|
const pending = isPendingWithNoContent(message, visibleContent, timeline.rows.length);
|
|
159
162
|
const runInProgress = isRunInProgress(message.runStatus);
|
|
160
163
|
const renderToolCard = (row) => (_jsx(ToolCard, { use: row.use, result: row.result, runStreaming: runStreaming, runSucceeded: runSucceeded, ...(projectFileNames !== undefined ? { projectFileNames } : {}), ...(onRequestOpenFile !== undefined ? { onRequestOpenFile } : {}) }, row.id));
|
|
@@ -162,14 +165,21 @@ export function MessageRow({ message, runStreaming = false, runSucceeded = false
|
|
|
162
165
|
const renderer = getExtEventRenderer(group.name);
|
|
163
166
|
if (!renderer)
|
|
164
167
|
return null;
|
|
165
|
-
const node = renderer({
|
|
168
|
+
const node = renderer({
|
|
169
|
+
name: group.name,
|
|
170
|
+
events: group.events,
|
|
171
|
+
runStreaming,
|
|
172
|
+
runSucceeded,
|
|
173
|
+
runId: message.runId,
|
|
174
|
+
...(group.call === undefined ? {} : { call: group.call }),
|
|
175
|
+
});
|
|
166
176
|
if (!node)
|
|
167
177
|
return null;
|
|
168
178
|
return (
|
|
169
179
|
// `key` includes the event count so a group that failed on an earlier, shorter event list
|
|
170
180
|
// gets a fresh boundary instance (not the still-tripped one) once a new event actually
|
|
171
181
|
// arrives for it, instead of staying tombstoned for the message's whole lifetime.
|
|
172
|
-
_jsx(ExtEventErrorBoundary, { name: group.name, children: _jsx("div", { children: node }) }, `${group.
|
|
182
|
+
_jsx(ExtEventErrorBoundary, { name: group.name, children: _jsx("div", { children: node }) }, `${group.slot}:${group.events.length}`));
|
|
173
183
|
};
|
|
174
184
|
const renderTextSegments = (text, keyPrefix) => splitOnQuestionForms(text).map((segment, i) => segment.kind === 'text' ? (segment.text.trim() ? (_jsx("div", { className: "jini-message-content", children: _jsx(Markdown, { children: segment.text }) }, `${keyPrefix}-${i}`)) : null) : (_jsx(QuestionForm, { form: segment.form, interactive: questionFormInteractive, ...(questionFormSubmittedAnswers !== undefined ? { submittedAnswers: questionFormSubmittedAnswers } : {}), ...(onQuestionFormSubmit !== undefined ? { onSubmit: onQuestionFormSubmit } : {}) }, `${keyPrefix}-${i}`)));
|
|
175
185
|
return (_jsxs("div", { className: "jini-message jini-message-assistant", "data-message-id": message.id, "data-run-status": message.runStatus, "data-agent-element": `chat-message-${message.id}`, "data-agent-role": "region", "data-agent-label": "A reply from the assistant", children: [message.agentName ? _jsx("div", { className: "jini-message-agent", children: message.agentName }) : null, blocks
|
|
@@ -180,11 +190,22 @@ export function MessageRow({ message, runStreaming = false, runSucceeded = false
|
|
|
180
190
|
if (block.kind === 'tools') {
|
|
181
191
|
return (_jsx("div", { className: "jini-message-tools", children: block.rows.map(renderToolCard) }, block.key));
|
|
182
192
|
}
|
|
183
|
-
const group = extGroups.find((g) => g.
|
|
193
|
+
const group = extGroups.find((g) => g.slot === block.slot);
|
|
184
194
|
if (!group)
|
|
185
195
|
return null;
|
|
186
196
|
return (_jsx("div", { className: "jini-message-ext-events", children: renderExtGroup(group) }, block.key));
|
|
187
197
|
})
|
|
188
|
-
: (_jsxs(_Fragment, { children: [segments.map((segment, i) => segment.kind === 'text' ? (segment.text.trim() ? (_jsx("div", { className: "jini-message-content", children: _jsx(Markdown, { children: segment.text }) }, i)) : null) : (_jsx(QuestionForm, { form: segment.form, interactive: questionFormInteractive, ...(questionFormSubmittedAnswers !== undefined ? { submittedAnswers: questionFormSubmittedAnswers } : {}), ...(onQuestionFormSubmit !== undefined ? { onSubmit: onQuestionFormSubmit } : {}) }, i))), timeline.rows.length > 0 ? (_jsx("div", { className: "jini-message-tools", children: timeline.rows.map(renderToolCard) })) : null, extGroups.length > 0 ? _jsx("div", { className: "jini-message-ext-events", children: extGroups.map(renderExtGroup) }) : null] })), usageEvent ? _jsx(UsageSummary, { usage: usageEvent }) : null, message.runStatus === 'failed' ? _jsx("div", { className: "jini-message-error", children: t('This turn failed.') }) : null, !pending && !runInProgress ? (_jsx("div", { className: "jini-message-actions jini-message-actions--assistant", children: _jsx(CopyMessageButton, { text: visibleContent, label: t('Copy message') }) })) : null,
|
|
198
|
+
: (_jsxs(_Fragment, { children: [segments.map((segment, i) => segment.kind === 'text' ? (segment.text.trim() ? (_jsx("div", { className: "jini-message-content", children: _jsx(Markdown, { children: segment.text }) }, i)) : null) : (_jsx(QuestionForm, { form: segment.form, interactive: questionFormInteractive, ...(questionFormSubmittedAnswers !== undefined ? { submittedAnswers: questionFormSubmittedAnswers } : {}), ...(onQuestionFormSubmit !== undefined ? { onSubmit: onQuestionFormSubmit } : {}) }, i))), timeline.rows.length > 0 ? (_jsx("div", { className: "jini-message-tools", children: timeline.rows.map(renderToolCard) })) : null, extGroups.length > 0 ? _jsx("div", { className: "jini-message-ext-events", children: extGroups.map(renderExtGroup) }) : null] })), usageEvent ? _jsx(UsageSummary, { usage: usageEvent }) : null, message.runStatus === 'failed' ? _jsx("div", { className: "jini-message-error", children: t('This turn failed.') }) : null, !pending && !runInProgress ? (_jsx("div", { className: "jini-message-actions jini-message-actions--assistant", children: _jsx(CopyMessageButton, { text: visibleContent, label: t('Copy message') }) })) : null, _jsx(RunActivityLine, { events: message.events, active: pending || (runStreaming === true && runInProgress) })] }));
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The live "what is it doing" line under a running reply (see `core/run-activity.ts`). Replaces the
|
|
202
|
+
* old "Thinking…" placeholder, which vanished for good as soon as the first tool row appeared.
|
|
203
|
+
* Kept a separate component so its one-second tick re-renders only this line, not the whole row.
|
|
204
|
+
*/
|
|
205
|
+
function RunActivityLine({ events, active }) {
|
|
206
|
+
const label = useRunActivity(events, active);
|
|
207
|
+
if (label === null)
|
|
208
|
+
return null;
|
|
209
|
+
return (_jsx("div", { className: "jini-message-pending jini-run-activity", "aria-live": "off", children: label }));
|
|
189
210
|
}
|
|
190
211
|
//# sourceMappingURL=MessageRow.js.map
|
|
@@ -21,7 +21,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
21
21
|
* generic rendering for it.
|
|
22
22
|
*/
|
|
23
23
|
import { useState } from 'react';
|
|
24
|
-
import { isTodoWriteToolName, parseTodoWriteInput, toRenderProps } from '../../core/index.js';
|
|
24
|
+
import { formatToolOutputForDisplay, isTodoWriteToolName, parseTodoWriteInput, toRenderProps } from '../../core/index.js';
|
|
25
25
|
import { useT } from '../hooks/context.js';
|
|
26
26
|
import { getToolRenderer } from '../tool-renderer-registry.js';
|
|
27
27
|
import { Icon } from './Icon.js';
|
|
@@ -137,21 +137,21 @@ function DelegatedToolCard({ name, input, result, runStreaming, runSucceeded })
|
|
|
137
137
|
const args = EXECUTE_DELEGATED_TOOL_NAMES.has(name) ? wrapper.input : input;
|
|
138
138
|
const target = primaryTarget(args);
|
|
139
139
|
const isRunning = runStreaming && !result;
|
|
140
|
-
return (_jsxs("div", { className: "op-card op-jini-tool", children: [_jsxs("button", { type: "button", className: "op-card-head", onClick: () => setOpen((o) => !o), children: [_jsx(ResultBadge, { result: result, runStreaming: runStreaming, runSucceeded: runSucceeded }), _jsxs("span", { className: `op-title${isRunning ? ' shimmer-text' : ''}`, children: [t('Tool call'), " \u00B7 ", humanizeToolId(toolId)] }), target ? _jsx("span", { className: "op-meta", children: target }) : null, _jsx("span", { className: "op-expand-chev", "aria-hidden": true, children: _jsx(Icon, { name: open ? 'chevron-down' : 'chevron-right', size: 11 }) })] }), _jsx("div", { className: `accordion-collapsible${open ? ' open' : ''}`, children: _jsx("div", { className: "accordion-collapsible-inner", children: _jsxs("div", { className: "op-card-detail", children: [_jsx("pre", { className: "op-command", children: JSON.stringify(args ?? {}) }), result?.content ? _jsx("pre", { className: "op-output", children: truncate(result.content, 2000) }) : null, _jsx(ToolResultMedia, { result: result })] }) }) }), _jsx(FileErrorDetail, { result: result })] }));
|
|
140
|
+
return (_jsxs("div", { className: "op-card op-jini-tool", children: [_jsxs("button", { type: "button", className: "op-card-head", onClick: () => setOpen((o) => !o), children: [_jsx(ResultBadge, { result: result, runStreaming: runStreaming, runSucceeded: runSucceeded }), _jsxs("span", { className: `op-title${isRunning ? ' shimmer-text' : ''}`, children: [t('Tool call'), " \u00B7 ", humanizeToolId(toolId)] }), target ? _jsx("span", { className: "op-meta", children: target }) : null, _jsx("span", { className: "op-expand-chev", "aria-hidden": true, children: _jsx(Icon, { name: open ? 'chevron-down' : 'chevron-right', size: 11 }) })] }), _jsx("div", { className: `accordion-collapsible${open ? ' open' : ''}`, children: _jsx("div", { className: "accordion-collapsible-inner", children: _jsxs("div", { className: "op-card-detail", children: [_jsx("pre", { className: "op-command", children: JSON.stringify(args ?? {}) }), result?.content ? _jsx("pre", { className: "op-output", children: truncate(formatToolOutputForDisplay(result.content), 2000) }) : null, _jsx(ToolResultMedia, { result: result })] }) }) }), _jsx(FileErrorDetail, { result: result })] }));
|
|
141
141
|
}
|
|
142
142
|
function SearchToolsCard({ input, result, runStreaming, runSucceeded }) {
|
|
143
143
|
const t = useT();
|
|
144
144
|
const [open, setOpen] = useState(false);
|
|
145
145
|
const obj = (input ?? {});
|
|
146
146
|
const isRunning = runStreaming && !result;
|
|
147
|
-
return (_jsxs("div", { className: "op-card op-jini-tool", children: [_jsxs("button", { type: "button", className: "op-card-head", onClick: () => setOpen((o) => !o), children: [_jsx(ResultBadge, { result: result, runStreaming: runStreaming, runSucceeded: runSucceeded }), _jsx("span", { className: `op-title${isRunning ? ' shimmer-text' : ''}`, children: t('Search tools') }), obj.query ? _jsx("span", { className: "op-meta", children: obj.query }) : null, _jsx("span", { className: "op-expand-chev", "aria-hidden": true, children: _jsx(Icon, { name: open ? 'chevron-down' : 'chevron-right', size: 11 }) })] }), _jsx("div", { className: `accordion-collapsible${open ? ' open' : ''}`, children: _jsx("div", { className: "accordion-collapsible-inner", children: _jsx("div", { className: "op-card-detail", children: result?.content ? _jsx("pre", { className: "op-output", children: truncate(result.content, 2000) }) : null }) }) }), _jsx(FileErrorDetail, { result: result })] }));
|
|
147
|
+
return (_jsxs("div", { className: "op-card op-jini-tool", children: [_jsxs("button", { type: "button", className: "op-card-head", onClick: () => setOpen((o) => !o), children: [_jsx(ResultBadge, { result: result, runStreaming: runStreaming, runSucceeded: runSucceeded }), _jsx("span", { className: `op-title${isRunning ? ' shimmer-text' : ''}`, children: t('Search tools') }), obj.query ? _jsx("span", { className: "op-meta", children: obj.query }) : null, _jsx("span", { className: "op-expand-chev", "aria-hidden": true, children: _jsx(Icon, { name: open ? 'chevron-down' : 'chevron-right', size: 11 }) })] }), _jsx("div", { className: `accordion-collapsible${open ? ' open' : ''}`, children: _jsx("div", { className: "accordion-collapsible-inner", children: _jsx("div", { className: "op-card-detail", children: result?.content ? _jsx("pre", { className: "op-output", children: truncate(formatToolOutputForDisplay(result.content), 2000) }) : null }) }) }), _jsx(FileErrorDetail, { result: result })] }));
|
|
148
148
|
}
|
|
149
149
|
function DescribeToolCard({ input, result, runStreaming, runSucceeded }) {
|
|
150
150
|
const t = useT();
|
|
151
151
|
const [open, setOpen] = useState(false);
|
|
152
152
|
const obj = (input ?? {});
|
|
153
153
|
const isRunning = runStreaming && !result;
|
|
154
|
-
return (_jsxs("div", { className: "op-card op-jini-tool", children: [_jsxs("button", { type: "button", className: "op-card-head", onClick: () => setOpen((o) => !o), children: [_jsx(ResultBadge, { result: result, runStreaming: runStreaming, runSucceeded: runSucceeded }), _jsx("span", { className: `op-title${isRunning ? ' shimmer-text' : ''}`, children: t('Describe tool') }), obj.id ? _jsx("span", { className: "op-meta", children: obj.id }) : null, _jsx("span", { className: "op-expand-chev", "aria-hidden": true, children: _jsx(Icon, { name: open ? 'chevron-down' : 'chevron-right', size: 11 }) })] }), _jsx("div", { className: `accordion-collapsible${open ? ' open' : ''}`, children: _jsx("div", { className: "accordion-collapsible-inner", children: _jsx("div", { className: "op-card-detail", children: result?.content ? _jsx("pre", { className: "op-output", children: truncate(result.content, 2000) }) : null }) }) }), _jsx(FileErrorDetail, { result: result })] }));
|
|
154
|
+
return (_jsxs("div", { className: "op-card op-jini-tool", children: [_jsxs("button", { type: "button", className: "op-card-head", onClick: () => setOpen((o) => !o), children: [_jsx(ResultBadge, { result: result, runStreaming: runStreaming, runSucceeded: runSucceeded }), _jsx("span", { className: `op-title${isRunning ? ' shimmer-text' : ''}`, children: t('Describe tool') }), obj.id ? _jsx("span", { className: "op-meta", children: obj.id }) : null, _jsx("span", { className: "op-expand-chev", "aria-hidden": true, children: _jsx(Icon, { name: open ? 'chevron-down' : 'chevron-right', size: 11 }) })] }), _jsx("div", { className: `accordion-collapsible${open ? ' open' : ''}`, children: _jsx("div", { className: "accordion-collapsible-inner", children: _jsx("div", { className: "op-card-detail", children: result?.content ? _jsx("pre", { className: "op-output", children: truncate(formatToolOutputForDisplay(result.content), 2000) }) : null }) }) }), _jsx(FileErrorDetail, { result: result })] }));
|
|
155
155
|
}
|
|
156
156
|
function OpenInTabButton({ filePath, ctx }) {
|
|
157
157
|
const t = useT();
|
|
@@ -208,7 +208,7 @@ function BashCard({ input, result, runStreaming, runSucceeded }) {
|
|
|
208
208
|
const desc = obj.description;
|
|
209
209
|
const [open, setOpen] = useState(false);
|
|
210
210
|
const isRunning = runStreaming && !result;
|
|
211
|
-
return (_jsxs("div", { className: "op-card op-bash", children: [_jsxs("button", { type: "button", className: "op-card-head", onClick: () => setOpen((o) => !o), children: [_jsx(ResultBadge, { result: result, runStreaming: runStreaming, runSucceeded: runSucceeded }), _jsx("span", { className: `op-title${isRunning ? ' shimmer-text' : ''}`, children: t('Bash') }), desc ? _jsx("span", { className: "op-meta op-desc", children: desc }) : null, _jsx("span", { className: "op-expand-chev", "aria-hidden": true, children: _jsx(Icon, { name: open ? 'chevron-down' : 'chevron-right', size: 11 }) })] }), _jsx("div", { className: `accordion-collapsible${open ? ' open' : ''}`, children: _jsx("div", { className: "accordion-collapsible-inner", children: _jsxs("div", { className: "op-card-detail", children: [_jsx("pre", { className: "op-command", children: truncate(command, 400) }), result?.content ? _jsx("pre", { className: "op-output", children: truncate(result.content, 4000) }) : null] }) }) })] }));
|
|
211
|
+
return (_jsxs("div", { className: "op-card op-bash", children: [_jsxs("button", { type: "button", className: "op-card-head", onClick: () => setOpen((o) => !o), children: [_jsx(ResultBadge, { result: result, runStreaming: runStreaming, runSucceeded: runSucceeded }), _jsx("span", { className: `op-title${isRunning ? ' shimmer-text' : ''}`, children: t('Bash') }), desc ? _jsx("span", { className: "op-meta op-desc", children: desc }) : null, _jsx("span", { className: "op-expand-chev", "aria-hidden": true, children: _jsx(Icon, { name: open ? 'chevron-down' : 'chevron-right', size: 11 }) })] }), _jsx("div", { className: `accordion-collapsible${open ? ' open' : ''}`, children: _jsx("div", { className: "accordion-collapsible-inner", children: _jsxs("div", { className: "op-card-detail", children: [_jsx("pre", { className: "op-command", children: truncate(command, 400) }), result?.content ? _jsx("pre", { className: "op-output", children: truncate(formatToolOutputForDisplay(result.content), 4000) }) : null] }) }) })] }));
|
|
212
212
|
}
|
|
213
213
|
function GlobCard({ input, result, runStreaming, runSucceeded }) {
|
|
214
214
|
const t = useT();
|
|
@@ -268,7 +268,7 @@ function ToolResultMedia({ result }) {
|
|
|
268
268
|
function FileErrorDetail({ result }) {
|
|
269
269
|
if (!result?.isError || !result.content.trim())
|
|
270
270
|
return null;
|
|
271
|
-
return _jsx("pre", { className: "op-output", children: truncate(result.content, 1200) });
|
|
271
|
+
return _jsx("pre", { className: "op-output", children: truncate(formatToolOutputForDisplay(result.content), 1200) });
|
|
272
272
|
}
|
|
273
273
|
function describeInput(input) {
|
|
274
274
|
if (input == null)
|
|
@@ -19,6 +19,20 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import type { ReactNode } from 'react';
|
|
21
21
|
/** Render-prop payload for one `name` group of `kind: 'ext'` events belonging to a message. */
|
|
22
|
+
/**
|
|
23
|
+
* The tool call that was still open when a group's first ext event arrived: the call that emitted
|
|
24
|
+
* the surface. For a held-open question (a tool that shows a card and waits for the answer), the
|
|
25
|
+
* call's `result` is the exchange ending: answered, expired or dismissed. Absent `result` means
|
|
26
|
+
* the call is still waiting, or its run ended before it returned.
|
|
27
|
+
*/
|
|
28
|
+
export interface ExtEventCall {
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly input: unknown;
|
|
31
|
+
readonly result?: {
|
|
32
|
+
readonly content: string;
|
|
33
|
+
readonly isError: boolean;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
22
36
|
export interface ExtEventRenderProps {
|
|
23
37
|
name: string;
|
|
24
38
|
/** Every matching event's `data`, in arrival order. Never empty. */
|
|
@@ -28,6 +42,8 @@ export interface ExtEventRenderProps {
|
|
|
28
42
|
runSucceeded: boolean;
|
|
29
43
|
/** The owning message's `ChatMessage.runId`, if known — a renderer that needs to send something back to this specific run (e.g. an A2UI action) needs this; omitted (`undefined`) for a message with no run association. */
|
|
30
44
|
runId: string | undefined;
|
|
45
|
+
/** See {@link ExtEventCall}. Absent when the group's first event arrived outside any tool call. */
|
|
46
|
+
call?: ExtEventCall;
|
|
31
47
|
}
|
|
32
48
|
/**
|
|
33
49
|
* Ext-event render callback. Runs inline during `MessageRow`'s render — hook-free, like
|
|
@@ -40,12 +56,34 @@ export interface ExtEventRenderProps {
|
|
|
40
56
|
* name+JSON card).
|
|
41
57
|
*/
|
|
42
58
|
export type ExtEventRenderer = (props: ExtEventRenderProps) => ReactNode;
|
|
59
|
+
/**
|
|
60
|
+
* Names the independent surface one ext event belongs to, or `undefined` for "the name's shared slot".
|
|
61
|
+
*
|
|
62
|
+
* Without one, every event sharing a `name` folds into ONE render slot at the name's first
|
|
63
|
+
* occurrence — right for a stream that builds a single surface (A2UI's create → update sequence).
|
|
64
|
+
* It is wrong for a protocol whose events are separate surfaces: MCP-UI emits one resource per
|
|
65
|
+
* card, so a run's second card rendered inside the first card's slot, far above where it arrived,
|
|
66
|
+
* and a run blocked on it waited for an answer nobody could see (Tovu stuck-chat investigation,
|
|
67
|
+
* 2026-09-27). A slot key gives each surface its own slot at its own first occurrence; later events
|
|
68
|
+
* with the same key still update that slot in place.
|
|
69
|
+
*/
|
|
70
|
+
export type ExtEventSlotKey = (data: unknown) => string | undefined;
|
|
71
|
+
export interface ExtEventRendererOptions {
|
|
72
|
+
/** See {@link ExtEventSlotKey}. Omit for one shared slot per `name`. */
|
|
73
|
+
slotKey?: ExtEventSlotKey;
|
|
74
|
+
}
|
|
43
75
|
/**
|
|
44
76
|
* Register a renderer for an ext-event `name`. Returns an unregister handle so tests / hot-reloads
|
|
45
77
|
* can dispose cleanly. Re-registering the same name overwrites — last writer wins.
|
|
46
78
|
*/
|
|
47
|
-
export declare function registerExtEventRenderer(name: string, renderer: ExtEventRenderer): () => void;
|
|
79
|
+
export declare function registerExtEventRenderer(name: string, renderer: ExtEventRenderer, options?: ExtEventRendererOptions): () => void;
|
|
48
80
|
export declare function getExtEventRenderer(name: string): ExtEventRenderer | undefined;
|
|
81
|
+
/**
|
|
82
|
+
* The render slot an ext event belongs to: its `name`, or `name:key` when the name's renderer was
|
|
83
|
+
* registered with a {@link ExtEventSlotKey} that recognizes this event. A throwing key function
|
|
84
|
+
* falls back to the shared slot rather than taking the whole message down.
|
|
85
|
+
*/
|
|
86
|
+
export declare function extEventSlot(name: string, data: unknown): string;
|
|
49
87
|
/** Visible mainly for tests. */
|
|
50
88
|
export declare function clearExtEventRenderers(): void;
|
|
51
89
|
//# sourceMappingURL=ext-event-renderer-registry.d.ts.map
|
|
@@ -3,15 +3,34 @@ const renderers = new Map();
|
|
|
3
3
|
* Register a renderer for an ext-event `name`. Returns an unregister handle so tests / hot-reloads
|
|
4
4
|
* can dispose cleanly. Re-registering the same name overwrites — last writer wins.
|
|
5
5
|
*/
|
|
6
|
-
export function registerExtEventRenderer(name, renderer) {
|
|
7
|
-
|
|
6
|
+
export function registerExtEventRenderer(name, renderer, options = {}) {
|
|
7
|
+
const entry = { renderer, slotKey: options.slotKey };
|
|
8
|
+
renderers.set(name, entry);
|
|
8
9
|
return () => {
|
|
9
|
-
if (renderers.get(name) ===
|
|
10
|
+
if (renderers.get(name) === entry)
|
|
10
11
|
renderers.delete(name);
|
|
11
12
|
};
|
|
12
13
|
}
|
|
13
14
|
export function getExtEventRenderer(name) {
|
|
14
|
-
return renderers.get(name);
|
|
15
|
+
return renderers.get(name)?.renderer;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The render slot an ext event belongs to: its `name`, or `name:key` when the name's renderer was
|
|
19
|
+
* registered with a {@link ExtEventSlotKey} that recognizes this event. A throwing key function
|
|
20
|
+
* falls back to the shared slot rather than taking the whole message down.
|
|
21
|
+
*/
|
|
22
|
+
export function extEventSlot(name, data) {
|
|
23
|
+
const slotKey = renderers.get(name)?.slotKey;
|
|
24
|
+
if (!slotKey)
|
|
25
|
+
return name;
|
|
26
|
+
let key;
|
|
27
|
+
try {
|
|
28
|
+
key = slotKey(data);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
key = undefined;
|
|
32
|
+
}
|
|
33
|
+
return key === undefined || key === '' ? name : `${name}:${key}`;
|
|
15
34
|
}
|
|
16
35
|
/** Visible mainly for tests. */
|
|
17
36
|
export function clearExtEventRenderers() {
|
|
@@ -29,11 +29,12 @@ function chatPaneClassName(variant, className) {
|
|
|
29
29
|
}
|
|
30
30
|
function resolveRuntimeAccessView(runtimeAccess, injectedAgents, scanningAgents, daemonOnline, onRescanAgents, inventory) {
|
|
31
31
|
if (runtimeAccess === undefined) {
|
|
32
|
-
return { agents: injectedAgents, scanningAgents, daemonOnline, rescanAgents: onRescanAgents };
|
|
32
|
+
return { agents: injectedAgents, scanningAgents, connectingAgents: false, daemonOnline, rescanAgents: onRescanAgents };
|
|
33
33
|
}
|
|
34
34
|
return {
|
|
35
35
|
agents: inventory.agents,
|
|
36
36
|
scanningAgents: inventory.scanningAgents,
|
|
37
|
+
connectingAgents: inventory.connectingAgents,
|
|
37
38
|
daemonOnline: inventory.daemonOnline,
|
|
38
39
|
rescanAgents: () => void inventory.rescanAgents(),
|
|
39
40
|
};
|
|
@@ -72,8 +73,8 @@ function ChatPaneNoUsableCliMessage({ executionMode, apiModeAvailable, onExecuti
|
|
|
72
73
|
}
|
|
73
74
|
/** The pane's stack of mutually-independent error/status banners — each condition is its own
|
|
74
75
|
* source of truth, so they are rendered as siblings rather than folded into one derived state. */
|
|
75
|
-
function ChatPaneStatusMessages({ unavailable, scanningAgents, executionMode, apiModeAvailable, onExecutionModeChange, conversationError, attachmentError, dropReadError, workingDirectoryError, workingDirectoryPending, workingDirectoryInvalid, runtimeInventoryError, t, }) {
|
|
76
|
-
return (_jsxs(_Fragment, { children: [unavailable && scanningAgents ? (_jsx("div", { className: "jini-chat-pane__status", role: "status", children: t('Loading available CLIs') })) : unavailable ? (_jsx(ChatPaneNoUsableCliMessage, { executionMode: executionMode, apiModeAvailable: apiModeAvailable, onExecutionModeChange: onExecutionModeChange, t: t })) : null, conversationError ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: conversationError.message })) : null, attachmentError ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: attachmentError.message })) : null, dropReadError ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: dropReadError })) : null, workingDirectoryError ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: workingDirectoryError.message })) : null, workingDirectoryPending ? (_jsx("div", { className: "jini-chat-pane__status", role: "status", children: t('Checking working directory…') })) : workingDirectoryInvalid ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: t('Working directory is unavailable.') })) : null, runtimeInventoryError ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: runtimeInventoryError.message })) : null] }));
|
|
76
|
+
function ChatPaneStatusMessages({ unavailable, scanningAgents, connectingAgents, executionMode, apiModeAvailable, onExecutionModeChange, conversationError, attachmentError, dropReadError, workingDirectoryError, workingDirectoryPending, workingDirectoryInvalid, runtimeInventoryError, t, }) {
|
|
77
|
+
return (_jsxs(_Fragment, { children: [unavailable && connectingAgents ? (_jsx("div", { className: "jini-chat-pane__status", role: "status", children: t('Connecting to the assistant…') })) : unavailable && scanningAgents ? (_jsx("div", { className: "jini-chat-pane__status", role: "status", children: t('Loading available CLIs') })) : unavailable ? (_jsx(ChatPaneNoUsableCliMessage, { executionMode: executionMode, apiModeAvailable: apiModeAvailable, onExecutionModeChange: onExecutionModeChange, t: t })) : null, conversationError ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: conversationError.message })) : null, attachmentError ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: attachmentError.message })) : null, dropReadError ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: dropReadError })) : null, workingDirectoryError ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: workingDirectoryError.message })) : null, workingDirectoryPending ? (_jsx("div", { className: "jini-chat-pane__status", role: "status", children: t('Checking working directory…') })) : workingDirectoryInvalid ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: t('Working directory is unavailable.') })) : null, runtimeInventoryError && !connectingAgents ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: runtimeInventoryError.message })) : null] }));
|
|
77
78
|
}
|
|
78
79
|
function ChatPaneWorkingDirectoryBlock({ workingDirectoryAccess, workingDirectoryControlPlacement, pane, t, }) {
|
|
79
80
|
if (workingDirectoryAccess && workingDirectoryControlPlacement === 'below') {
|
|
@@ -261,6 +262,6 @@ export function ChatPane({ transport, agents: injectedAgents = EMPTY_AGENTS, run
|
|
|
261
262
|
footerAccessories: (_jsx(AgentRuntimePicker, { agents: runtimeView.agents, value: pane.selection, onChange: pane.setSelection, ...definedProps({ onRescan: runtimeView.rescanAgents }), scanning: runtimeView.scanningAgents, daemonOnline: runtimeView.daemonOnline, placement: runtimePickerPlacement, executionMode: executionMode, apiModeAvailable: apiModeAvailable, ...definedProps({ onExecutionModeChange, byokRuntime, onByokModelChange }) })),
|
|
262
263
|
});
|
|
263
264
|
const { rootRef, controlsRef } = useChatPaneControlsHeight();
|
|
264
|
-
return (_jsxs("section", { className: chatPaneClassName(variant, className), style: style, "data-activity": pane.activity, ref: rootRef, children: [_jsx("style", { "data-jini-chat-pane-styles": "true", children: CHAT_PANE_STYLES }), resolveChatPaneHeader(header, title, pane.reset, t), _jsxs("div", { className: "jini-chat-pane__body", children: [_jsx(MessageList, { messages: pane.conversation.messages, isStreaming: pane.conversation.isStreaming, scrollIntent: pane.conversation.scrollIntent, onScrolled: pane.conversation.acknowledgeScroll, pendingPrompt: pane.queuedPrompt === null ? null : { text: pane.queuedPrompt, onCancel: pane.cancelQueued }, ...(projectFileNames === undefined ? {} : { projectFileNames: new Set(projectFileNames) }) }), _jsxs("div", { className: "jini-chat-pane__controls", ref: controlsRef, children: [_jsx(ChatPaneSuggestionsRow, { suggestions: suggestions, onSelect: pane.composer.setDraft, t: t }), _jsx(ChatPaneStatusMessages, { unavailable: unavailable, scanningAgents: runtimeView.scanningAgents, executionMode: executionMode, apiModeAvailable: apiModeAvailable, onExecutionModeChange: onExecutionModeChange, conversationError: pane.conversation.error, attachmentError: pane.attachmentError, dropReadError: fileDrop.dropReadError, workingDirectoryError: pane.workingDirectoryError, workingDirectoryPending: pane.workingDirectoryPending, workingDirectoryInvalid: pane.workingDirectoryInvalid, runtimeInventoryError: inventory.runtimeInventoryError, t: t }), _jsx(ChatPaneComposerArea, { fileDrop: fileDrop, uploadAttachments: uploadAttachments, pane: pane, disabled: disabled, unavailable: unavailable, placeholder: composerPlaceholder, slots: slots, attachmentAccept: attachmentAccept, workingDirectoryAccess: workingDirectoryAccess, workingDirectoryControlPlacement: workingDirectoryControlPlacement, t: t })] })] }), footer] }));
|
|
265
|
+
return (_jsxs("section", { className: chatPaneClassName(variant, className), style: style, "data-activity": pane.activity, ref: rootRef, children: [_jsx("style", { "data-jini-chat-pane-styles": "true", children: CHAT_PANE_STYLES }), resolveChatPaneHeader(header, title, pane.reset, t), _jsxs("div", { className: "jini-chat-pane__body", children: [_jsx(MessageList, { messages: pane.conversation.messages, isStreaming: pane.conversation.isStreaming, scrollIntent: pane.conversation.scrollIntent, onScrolled: pane.conversation.acknowledgeScroll, pendingPrompt: pane.queuedPrompt === null ? null : { text: pane.queuedPrompt, onCancel: pane.cancelQueued }, ...(projectFileNames === undefined ? {} : { projectFileNames: new Set(projectFileNames) }) }), _jsxs("div", { className: "jini-chat-pane__controls", ref: controlsRef, children: [_jsx(ChatPaneSuggestionsRow, { suggestions: suggestions, onSelect: pane.composer.setDraft, t: t }), _jsx(ChatPaneStatusMessages, { unavailable: unavailable, scanningAgents: runtimeView.scanningAgents, connectingAgents: runtimeView.connectingAgents, executionMode: executionMode, apiModeAvailable: apiModeAvailable, onExecutionModeChange: onExecutionModeChange, conversationError: pane.conversation.error, attachmentError: pane.attachmentError, dropReadError: fileDrop.dropReadError, workingDirectoryError: pane.workingDirectoryError, workingDirectoryPending: pane.workingDirectoryPending, workingDirectoryInvalid: pane.workingDirectoryInvalid, runtimeInventoryError: inventory.runtimeInventoryError, t: t }), _jsx(ChatPaneComposerArea, { fileDrop: fileDrop, uploadAttachments: uploadAttachments, pane: pane, disabled: disabled, unavailable: unavailable, placeholder: composerPlaceholder, slots: slots, attachmentAccept: attachmentAccept, workingDirectoryAccess: workingDirectoryAccess, workingDirectoryControlPlacement: workingDirectoryControlPlacement, t: t })] })] }), footer] }));
|
|
265
266
|
}
|
|
266
267
|
//# sourceMappingURL=ChatPane.js.map
|
|
@@ -3,10 +3,18 @@ export interface UseChatPaneRuntimeInventoryOptions {
|
|
|
3
3
|
access?: ChatPaneRuntimeAccess;
|
|
4
4
|
initialAgents?: readonly ChatPaneAgent[];
|
|
5
5
|
pollIntervalMs?: number;
|
|
6
|
+
/** Backoff between retries of a failed inventory load; the last entry repeats. */
|
|
7
|
+
retryDelaysMs?: readonly number[];
|
|
6
8
|
}
|
|
7
9
|
export interface UseChatPaneRuntimeInventoryResult {
|
|
8
10
|
agents: readonly ChatPaneAgent[];
|
|
9
11
|
scanningAgents: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* `listAgents` has not answered successfully yet and its last attempt failed — the host is
|
|
14
|
+
* unreachable (typically a daemon restart), so a retry is pending. Not "no usable CLI": that is
|
|
15
|
+
* only known once a load RESOLVES.
|
|
16
|
+
*/
|
|
17
|
+
connectingAgents: boolean;
|
|
10
18
|
daemonOnline: boolean;
|
|
11
19
|
runtimeInventoryError: Error | null;
|
|
12
20
|
rescanAgents: () => Promise<void>;
|
|
@@ -18,8 +26,14 @@ export interface UseChatPaneRuntimeInventoryResult {
|
|
|
18
26
|
* Inventory and health are tracked as two independent operations: a slow `listAgents` must not
|
|
19
27
|
* suppress a health tick that resolved after it, and vice versa.
|
|
20
28
|
*
|
|
29
|
+
* A REJECTED `listAgents` is "no answer yet", never a final empty inventory (2026-09-28: the
|
|
30
|
+
* daemon restarting under an open pane left it on "No usable CLI is selected" until a hard
|
|
31
|
+
* reload). Until the first successful answer the hook retries on a backoff, and immediately when
|
|
32
|
+
* the daemon goes from offline to online, the window regains focus, or the tab becomes visible.
|
|
33
|
+
* A RESOLVED list — even an empty one — is a finished answer; hosts reject when theirs is not one.
|
|
34
|
+
*
|
|
21
35
|
* @complexity Time: O(n) per inventory response; space: O(n) for the snapshot.
|
|
22
36
|
* @overallScore 100/100
|
|
23
37
|
*/
|
|
24
|
-
export declare function useChatPaneRuntimeInventory({ access, initialAgents, pollIntervalMs, }: UseChatPaneRuntimeInventoryOptions): UseChatPaneRuntimeInventoryResult;
|
|
38
|
+
export declare function useChatPaneRuntimeInventory({ access, initialAgents, pollIntervalMs, retryDelaysMs, }: UseChatPaneRuntimeInventoryOptions): UseChatPaneRuntimeInventoryResult;
|
|
25
39
|
//# sourceMappingURL=useChatPaneRuntimeInventory.hooks.d.ts.map
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { useCallback, useEffect, useState } from 'react';
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { useLatestOperation } from '../../../hooks/useLatestOperation.js';
|
|
3
3
|
const EMPTY_AGENTS = [];
|
|
4
|
+
/** Waits between inventory retries while `listAgents` keeps failing: quick at first (a daemon
|
|
5
|
+
* restart takes seconds), then every 30s for as long as the pane stays mounted. */
|
|
6
|
+
const DEFAULT_RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 15_000, 30_000];
|
|
4
7
|
/**
|
|
5
8
|
* Owns agent inventory, explicit rescans, and daemon-health polling while the
|
|
6
9
|
* host supplies only the environment-specific I/O effects.
|
|
@@ -8,10 +11,16 @@ const EMPTY_AGENTS = [];
|
|
|
8
11
|
* Inventory and health are tracked as two independent operations: a slow `listAgents` must not
|
|
9
12
|
* suppress a health tick that resolved after it, and vice versa.
|
|
10
13
|
*
|
|
14
|
+
* A REJECTED `listAgents` is "no answer yet", never a final empty inventory (2026-09-28: the
|
|
15
|
+
* daemon restarting under an open pane left it on "No usable CLI is selected" until a hard
|
|
16
|
+
* reload). Until the first successful answer the hook retries on a backoff, and immediately when
|
|
17
|
+
* the daemon goes from offline to online, the window regains focus, or the tab becomes visible.
|
|
18
|
+
* A RESOLVED list — even an empty one — is a finished answer; hosts reject when theirs is not one.
|
|
19
|
+
*
|
|
11
20
|
* @complexity Time: O(n) per inventory response; space: O(n) for the snapshot.
|
|
12
21
|
* @overallScore 100/100
|
|
13
22
|
*/
|
|
14
|
-
export function useChatPaneRuntimeInventory({ access, initialAgents = EMPTY_AGENTS, pollIntervalMs = 5_000, }) {
|
|
23
|
+
export function useChatPaneRuntimeInventory({ access, initialAgents = EMPTY_AGENTS, pollIntervalMs = 5_000, retryDelaysMs = DEFAULT_RETRY_DELAYS_MS, }) {
|
|
15
24
|
const [agents, setAgents] = useState(initialAgents);
|
|
16
25
|
// Seeded from `access` at mount, not `false`: the load effect below only flips this to `true`
|
|
17
26
|
// once it runs, which is after the first paint. Starting at `false` when `access` is already
|
|
@@ -19,70 +28,160 @@ export function useChatPaneRuntimeInventory({ access, initialAgents = EMPTY_AGEN
|
|
|
19
28
|
// loading — indistinguishable from "detection finished, nothing usable" to any consumer keyed
|
|
20
29
|
// off this flag, which is exactly the frame `ChatPane`'s "No usable CLI" banner flashed on.
|
|
21
30
|
const [scanningAgents, setScanningAgents] = useState(() => access !== undefined);
|
|
31
|
+
const [connectingAgents, setConnectingAgents] = useState(false);
|
|
22
32
|
const [daemonOnline, setDaemonOnline] = useState(false);
|
|
23
33
|
const [runtimeInventoryError, setRuntimeInventoryError] = useState(null);
|
|
24
34
|
const inventory = useLatestOperation();
|
|
25
35
|
const health = useLatestOperation();
|
|
36
|
+
const retry = useInventoryRetry(retryDelaysMs);
|
|
26
37
|
useEffect(() => {
|
|
27
38
|
if (access !== undefined)
|
|
28
39
|
return;
|
|
29
40
|
inventory.supersede();
|
|
41
|
+
retry.reset();
|
|
30
42
|
setAgents(initialAgents);
|
|
31
43
|
setScanningAgents(false);
|
|
44
|
+
setConnectingAgents(false);
|
|
32
45
|
setRuntimeInventoryError(null);
|
|
33
|
-
}, [access, initialAgents, inventory]);
|
|
46
|
+
}, [access, initialAgents, inventory, retry]);
|
|
34
47
|
const loadAgents = useCallback(async (rescan) => {
|
|
35
48
|
if (!access)
|
|
36
49
|
return;
|
|
50
|
+
retry.cancel();
|
|
37
51
|
setScanningAgents(true);
|
|
38
52
|
setRuntimeInventoryError(null);
|
|
39
53
|
await inventory.run(async (token) => {
|
|
40
54
|
const nextAgents = await (rescan ? access.rescanAgents() : access.listAgents());
|
|
41
55
|
token.ensureCurrent();
|
|
56
|
+
retry.answered();
|
|
42
57
|
setAgents([...nextAgents]);
|
|
58
|
+
setConnectingAgents(false);
|
|
43
59
|
setScanningAgents(false);
|
|
44
60
|
}, (error) => {
|
|
45
|
-
// A failed
|
|
46
|
-
// last-good to keep
|
|
47
|
-
if (!
|
|
61
|
+
// A failed load after a good answer keeps that inventory on screen. Before one there is no
|
|
62
|
+
// last-good to keep: clear to empty, report "connecting", and try again on the backoff.
|
|
63
|
+
if (!retry.hasAnswer()) {
|
|
48
64
|
setAgents([]);
|
|
65
|
+
setConnectingAgents(true);
|
|
66
|
+
retry.schedule(() => void loadAgents(false));
|
|
67
|
+
}
|
|
49
68
|
setRuntimeInventoryError(error);
|
|
50
69
|
setScanningAgents(false);
|
|
51
70
|
});
|
|
52
|
-
}, [access, inventory]);
|
|
71
|
+
}, [access, inventory, retry]);
|
|
72
|
+
/** A fresh attempt now, with the backoff restarted — only while nothing has answered yet. */
|
|
73
|
+
const reloadIfUnanswered = useCallback(() => {
|
|
74
|
+
if (retry.hasAnswer())
|
|
75
|
+
return;
|
|
76
|
+
retry.restart();
|
|
77
|
+
void loadAgents(false);
|
|
78
|
+
}, [loadAgents, retry]);
|
|
53
79
|
const refreshStatus = useCallback(async () => {
|
|
54
80
|
// No `if (!access) return` guard here (unlike `loadAgents`, which is reachable through
|
|
55
81
|
// the publicly-exposed `rescanAgents` and so can be invoked with a stale closure by the
|
|
56
|
-
// host): `refreshStatus` is never returned from this hook, so its
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
// defined by the time either one runs.
|
|
82
|
+
// host): `refreshStatus` is never returned from this hook, so its call sites (the effects
|
|
83
|
+
// below) all sit behind their own `if (!access) return` — `access` is guaranteed defined by
|
|
84
|
+
// the time any one runs.
|
|
60
85
|
const runtimeAccess = access;
|
|
61
86
|
await health.run(async (token) => {
|
|
62
87
|
const online = await runtimeAccess.daemonOnline();
|
|
63
88
|
token.ensureCurrent();
|
|
64
89
|
setDaemonOnline(online);
|
|
65
|
-
|
|
66
|
-
|
|
90
|
+
if (retry.observeOnline(online))
|
|
91
|
+
reloadIfUnanswered();
|
|
92
|
+
}, () => {
|
|
93
|
+
retry.observeOnline(false);
|
|
94
|
+
setDaemonOnline(false);
|
|
95
|
+
});
|
|
96
|
+
}, [access, health, reloadIfUnanswered, retry]);
|
|
67
97
|
useEffect(() => {
|
|
68
98
|
if (!access)
|
|
69
99
|
return;
|
|
100
|
+
retry.reset();
|
|
70
101
|
void loadAgents(false);
|
|
71
102
|
void refreshStatus();
|
|
72
103
|
const timer = window.setInterval(() => void refreshStatus(), pollIntervalMs);
|
|
73
104
|
return () => {
|
|
74
105
|
window.clearInterval(timer);
|
|
106
|
+
retry.cancel();
|
|
75
107
|
inventory.supersede();
|
|
76
108
|
health.supersede();
|
|
77
109
|
};
|
|
78
|
-
}, [access, health, inventory, loadAgents, pollIntervalMs, refreshStatus]);
|
|
110
|
+
}, [access, health, inventory, loadAgents, pollIntervalMs, refreshStatus, retry]);
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (!access)
|
|
113
|
+
return;
|
|
114
|
+
const onWake = () => {
|
|
115
|
+
if (document.visibilityState === 'hidden')
|
|
116
|
+
return;
|
|
117
|
+
void refreshStatus();
|
|
118
|
+
reloadIfUnanswered();
|
|
119
|
+
};
|
|
120
|
+
window.addEventListener('focus', onWake);
|
|
121
|
+
document.addEventListener('visibilitychange', onWake);
|
|
122
|
+
return () => {
|
|
123
|
+
window.removeEventListener('focus', onWake);
|
|
124
|
+
document.removeEventListener('visibilitychange', onWake);
|
|
125
|
+
};
|
|
126
|
+
}, [access, refreshStatus, reloadIfUnanswered]);
|
|
79
127
|
const rescanAgents = useCallback(() => loadAgents(true), [loadAgents]);
|
|
80
128
|
return {
|
|
81
129
|
agents,
|
|
82
130
|
scanningAgents,
|
|
131
|
+
connectingAgents,
|
|
83
132
|
daemonOnline,
|
|
84
133
|
runtimeInventoryError,
|
|
85
134
|
rescanAgents,
|
|
86
135
|
};
|
|
87
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* The retry bookkeeping `useChatPaneRuntimeInventory` needs, in refs so none of it re-renders:
|
|
139
|
+
* whether an answer has arrived, the pending retry timer, the backoff step, and the last health
|
|
140
|
+
* result (`null` until the first one, so the first "online" report is not a reconnect).
|
|
141
|
+
*/
|
|
142
|
+
function useInventoryRetry(delaysMs) {
|
|
143
|
+
const answeredRef = useRef(false);
|
|
144
|
+
const timerRef = useRef(undefined);
|
|
145
|
+
const stepRef = useRef(0);
|
|
146
|
+
const lastOnlineRef = useRef(null);
|
|
147
|
+
const delaysRef = useRef(delaysMs);
|
|
148
|
+
delaysRef.current = delaysMs;
|
|
149
|
+
const [retry] = useState(() => {
|
|
150
|
+
const cancel = () => {
|
|
151
|
+
window.clearTimeout(timerRef.current);
|
|
152
|
+
timerRef.current = undefined;
|
|
153
|
+
};
|
|
154
|
+
const restart = () => {
|
|
155
|
+
cancel();
|
|
156
|
+
stepRef.current = 0;
|
|
157
|
+
};
|
|
158
|
+
return {
|
|
159
|
+
hasAnswer: () => answeredRef.current,
|
|
160
|
+
answered: () => {
|
|
161
|
+
answeredRef.current = true;
|
|
162
|
+
restart();
|
|
163
|
+
},
|
|
164
|
+
schedule: (attempt) => {
|
|
165
|
+
cancel();
|
|
166
|
+
const delays = delaysRef.current;
|
|
167
|
+
const delay = delays[Math.min(stepRef.current, delays.length - 1)] ?? 0;
|
|
168
|
+
stepRef.current += 1;
|
|
169
|
+
timerRef.current = window.setTimeout(attempt, delay);
|
|
170
|
+
},
|
|
171
|
+
cancel,
|
|
172
|
+
restart,
|
|
173
|
+
reset: () => {
|
|
174
|
+
restart();
|
|
175
|
+
answeredRef.current = false;
|
|
176
|
+
lastOnlineRef.current = null;
|
|
177
|
+
},
|
|
178
|
+
observeOnline: (online) => {
|
|
179
|
+
const reconnected = online && lastOnlineRef.current === false;
|
|
180
|
+
lastOnlineRef.current = online;
|
|
181
|
+
return reconnected;
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
});
|
|
185
|
+
return retry;
|
|
186
|
+
}
|
|
88
187
|
//# sourceMappingURL=useChatPaneRuntimeInventory.hooks.js.map
|