@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.
Files changed (33) hide show
  1. package/dist/core/assistant-content.d.ts +29 -0
  2. package/dist/core/assistant-content.js +41 -0
  3. package/dist/core/compact-events.d.ts +23 -0
  4. package/dist/core/compact-events.js +23 -0
  5. package/dist/core/events.d.ts +2 -0
  6. package/dist/core/index.d.ts +4 -0
  7. package/dist/core/index.js +4 -0
  8. package/dist/core/run-activity.d.ts +88 -0
  9. package/dist/core/run-activity.js +153 -0
  10. package/dist/core/tool-output.d.ts +13 -0
  11. package/dist/core/tool-output.js +24 -0
  12. package/dist/react/components/Markdown.js +21 -7
  13. package/dist/react/components/McpUiSurfaceCard.d.ts +9 -1
  14. package/dist/react/components/McpUiSurfaceCard.js +43 -2
  15. package/dist/react/components/MessageRow.js +28 -7
  16. package/dist/react/components/ToolCard.js +6 -6
  17. package/dist/react/ext-event-renderer-registry.d.ts +39 -1
  18. package/dist/react/ext-event-renderer-registry.js +23 -4
  19. package/dist/react/features/chat-pane/components/ChatPane.js +5 -4
  20. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.d.ts +15 -1
  21. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +113 -14
  22. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  23. package/dist/react/features/chat-pane/styles.js +7 -2
  24. package/dist/react/hooks/useConversation.js +13 -11
  25. package/dist/react/hooks/useExtEventGroups.d.ts +13 -1
  26. package/dist/react/hooks/useExtEventGroups.js +38 -9
  27. package/dist/react/hooks/useRunActivity.d.ts +12 -0
  28. package/dist/react/hooks/useRunActivity.js +44 -0
  29. package/dist/react/index.d.ts +3 -3
  30. package/dist/react/index.js +2 -2
  31. package/dist/react/message-blocks.d.ts +6 -1
  32. package/dist/react/message-blocks.js +14 -13
  33. 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 ? interleaveMessageBlocks(message.events, message.content, timeline.rows) : null;
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({ name: group.name, events: group.events, runStreaming, runSucceeded, runId: message.runId });
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.name}:${group.events.length}`));
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.name === block.name);
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, pending ? (_jsx("div", { className: "jini-message-pending", "aria-live": "polite", children: t('Thinking…') })) : 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
- renderers.set(name, renderer);
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) === renderer)
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 rescan keeps the last good inventory on screen; a failed initial load has no
46
- // last-good to keep, so it clears to empty rather than showing agents that may be gone.
47
- if (!rescan)
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 only two call sites
57
- // (the effect below, both the immediate call and the interval tick) are inside the SAME
58
- // closure as the effect's own `if (!access) return` at its top — `access` is guaranteed
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
- }, () => setDaemonOnline(false));
66
- }, [access, health]);
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