@jini-ai/chat 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/dist/core/index.d.ts +1 -0
  3. package/dist/core/index.js +1 -0
  4. package/dist/core/run-activity.d.ts +12 -20
  5. package/dist/core/run-activity.js +59 -7
  6. package/dist/core/surface-expiry.d.ts +42 -0
  7. package/dist/core/surface-expiry.js +42 -0
  8. package/dist/core/tool-events.d.ts +34 -0
  9. package/dist/core/tool-events.js +61 -0
  10. package/dist/core/tools.d.ts +1 -1
  11. package/dist/core/tools.js +1 -1
  12. package/dist/react/chat-pane.d.ts +2 -2
  13. package/dist/react/chat-pane.js +1 -1
  14. package/dist/react/components/AttachmentPreviewModal.hooks.d.ts +19 -2
  15. package/dist/react/components/AttachmentPreviewModal.hooks.js +53 -38
  16. package/dist/react/components/Markdown.js +37 -6
  17. package/dist/react/components/McpUiSurfaceCard.d.ts +7 -1
  18. package/dist/react/components/McpUiSurfaceCard.js +23 -38
  19. package/dist/react/components/MessageList.d.ts +11 -0
  20. package/dist/react/components/MessageList.hooks.d.ts +19 -0
  21. package/dist/react/components/MessageList.hooks.js +82 -0
  22. package/dist/react/components/MessageList.js +3 -71
  23. package/dist/react/components/MessageList.rules.d.ts +43 -0
  24. package/dist/react/components/MessageList.rules.js +38 -0
  25. package/dist/react/components/MessageRow.js +3 -1
  26. package/dist/react/components/ToolCard.js +10 -7
  27. package/dist/react/components/mcp-ui-surface-state.d.ts +27 -0
  28. package/dist/react/components/mcp-ui-surface-state.js +33 -0
  29. package/dist/react/ext-event-renderer-registry.d.ts +7 -0
  30. package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
  31. package/dist/react/features/chat-pane/components/ChatPane.js +6 -5
  32. package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +54 -0
  33. package/dist/react/features/chat-pane/create-typed-answer-poster.js +46 -0
  34. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +17 -1
  35. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +153 -7
  36. package/dist/react/features/chat-pane/index.d.ts +2 -1
  37. package/dist/react/features/chat-pane/index.js +1 -0
  38. package/dist/react/features/chat-pane/rules.d.ts +59 -1
  39. package/dist/react/features/chat-pane/rules.js +74 -0
  40. package/dist/react/features/chat-pane/types.d.ts +34 -0
  41. package/dist/react/hooks/composer-draft-cache.d.ts +28 -0
  42. package/dist/react/hooks/composer-draft-cache.js +56 -6
  43. package/dist/react/hooks/last-conversation-store.d.ts +58 -0
  44. package/dist/react/hooks/last-conversation-store.js +112 -0
  45. package/dist/react/hooks/useSurfaceExpiry.d.ts +20 -0
  46. package/dist/react/hooks/useSurfaceExpiry.js +35 -0
  47. package/dist/react/hooks/useToolTimeline.js +6 -3
  48. package/dist/react/index.d.ts +10 -2
  49. package/dist/react/index.js +7 -1
  50. package/package.json +7 -7
@@ -40,6 +40,13 @@ export interface ExtEventRenderProps {
40
40
  /** Whether the owning run is still streaming — more events with this `name` may still arrive. */
41
41
  runStreaming: boolean;
42
42
  runSucceeded: boolean;
43
+ /**
44
+ * Whether the owning run is waiting on the person — a card (form, confirm, sign-in) sits on a
45
+ * still-open tool call (`isAwaitingAnswer` in `core/run-activity.ts`). A "still working" notice
46
+ * should hide while this is true. Always `false` once the run stops streaming. Optional so a host
47
+ * that builds these props itself (tests, wrappers) keeps compiling; `MessageRow` always sets it.
48
+ */
49
+ awaitingAnswer?: boolean;
43
50
  /** 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. */
44
51
  runId: string | undefined;
45
52
  /** See {@link ExtEventCall}. Absent when the group's first event arrived outside any tool call. */
@@ -6,5 +6,5 @@ import type { ChatPaneProps } from '../types.js';
6
6
  * @complexity Time/space: O(n) in rendered messages, agents, and suggestions.
7
7
  * @overallScore 100/100
8
8
  */
9
- export declare function ChatPane({ transport, agents: injectedAgents, runtimeAccess, runtimeStatusPollMs, title, variant, initialMessages, conversationId, initialSelection, selection: controlledSelection, onSelectionChange, runContext, agentControl, onActivityChange, onMessagesChange, onRescanAgents, scanningAgents, daemonOnline, executionMode, apiModeAvailable, onExecutionModeChange, byokRuntime, onByokModelChange, initialDraft, validateAttachments, composerHandle, placeholder, placeholders, suggestions, workingDirectory, initialWorkingDirectory, onChangeWorkingDirectory, workingDirectoryAccess, workingDirectoryControlPlacement, projectFileNames, uploadAttachments, attachmentAccept, disabled, runtimePickerPlacement, composerSlots, header, leadingAccessory, footer, className, style, }: ChatPaneProps): import("react").JSX.Element;
9
+ export declare function ChatPane({ transport, agents: injectedAgents, runtimeAccess, runtimeStatusPollMs, title, variant, initialMessages, conversationId, initialSelection, selection: controlledSelection, onSelectionChange, runContext, agentControl, onActivityChange, onMessagesChange, onRescanAgents, scanningAgents, daemonOnline, executionMode, apiModeAvailable, onExecutionModeChange, byokRuntime, onByokModelChange, initialDraft, validateAttachments, deliverTypedAnswer, composerHandle, placeholder, placeholders, suggestions, workingDirectory, initialWorkingDirectory, onChangeWorkingDirectory, workingDirectoryAccess, workingDirectoryControlPlacement, projectFileNames, uploadAttachments, attachmentAccept, disabled, runtimePickerPlacement, composerSlots, header, leadingAccessory, footer, className, style, }: ChatPaneProps): import("react").JSX.Element;
10
10
  //# sourceMappingURL=ChatPane.d.ts.map
@@ -6,7 +6,7 @@ import { appendComposerDiscovery } from '../../../components/composer-discovery.
6
6
  import { MessageList } from '../../../components/MessageList.js';
7
7
  import { useT } from '../../../hooks/context.js';
8
8
  import { definedProps } from '../../../util/defined-props.js';
9
- import { isChatPaneApiModeConfigured } from '../rules.js';
9
+ import { describeTypedAnswerNotice, isChatPaneApiModeConfigured } from '../rules.js';
10
10
  import { useChatPane } from '../hooks/useChatPane.hooks.js';
11
11
  import { useChatPaneAgentControl } from '../hooks/useChatPaneAgentControl.hooks.js';
12
12
  import { useChatPaneComposerPlaceholder } from '../hooks/useChatPaneComposerPlaceholder.hooks.js';
@@ -73,8 +73,8 @@ function ChatPaneNoUsableCliMessage({ executionMode, apiModeAvailable, onExecuti
73
73
  }
74
74
  /** The pane's stack of mutually-independent error/status banners — each condition is its own
75
75
  * source of truth, so they are rendered as siblings rather than folded into one derived state. */
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] }));
76
+ function ChatPaneStatusMessages({ unavailable, scanningAgents, connectingAgents, executionMode, apiModeAvailable, onExecutionModeChange, conversationError, attachmentError, dropReadError, workingDirectoryError, workingDirectoryPending, workingDirectoryInvalid, runtimeInventoryError, typedAnswerNotice, onSendAsNewMessage, 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, typedAnswerNotice ? (_jsxs("div", { className: "jini-chat-pane__error", role: "alert", children: [t(describeTypedAnswerNotice({ notice: typedAnswerNotice })), typedAnswerNotice === 'not-pending' ? (_jsxs(_Fragment, { children: [' ', _jsx("button", { type: "button", className: "jini-chat-pane__error-action", onClick: onSendAsNewMessage, children: t('Send as a new message') })] })) : null] })) : null] }));
78
78
  }
79
79
  function ChatPaneWorkingDirectoryBlock({ workingDirectoryAccess, workingDirectoryControlPlacement, pane, t, }) {
80
80
  if (workingDirectoryAccess && workingDirectoryControlPlacement === 'below') {
@@ -178,7 +178,7 @@ function ChatPaneComposerArea({ fileDrop, uploadAttachments, pane, disabled, una
178
178
  * @complexity Time/space: O(n) in rendered messages, agents, and suggestions.
179
179
  * @overallScore 100/100
180
180
  */
181
- export function ChatPane({ transport, agents: injectedAgents = EMPTY_AGENTS, runtimeAccess, runtimeStatusPollMs = 5_000, title, variant = 'workspace', initialMessages, conversationId, initialSelection, selection: controlledSelection, onSelectionChange, runContext, agentControl, onActivityChange, onMessagesChange, onRescanAgents, scanningAgents = false, daemonOnline = true, executionMode = 'local', apiModeAvailable = false, onExecutionModeChange, byokRuntime, onByokModelChange, initialDraft, validateAttachments, composerHandle, placeholder, placeholders, suggestions = [], workingDirectory, initialWorkingDirectory, onChangeWorkingDirectory, workingDirectoryAccess, workingDirectoryControlPlacement = 'below', projectFileNames, uploadAttachments, attachmentAccept, disabled = false, runtimePickerPlacement = 'up', composerSlots, header, leadingAccessory, footer, className, style, }) {
181
+ export function ChatPane({ transport, agents: injectedAgents = EMPTY_AGENTS, runtimeAccess, runtimeStatusPollMs = 5_000, title, variant = 'workspace', initialMessages, conversationId, initialSelection, selection: controlledSelection, onSelectionChange, runContext, agentControl, onActivityChange, onMessagesChange, onRescanAgents, scanningAgents = false, daemonOnline = true, executionMode = 'local', apiModeAvailable = false, onExecutionModeChange, byokRuntime, onByokModelChange, initialDraft, validateAttachments, deliverTypedAnswer, composerHandle, placeholder, placeholders, suggestions = [], workingDirectory, initialWorkingDirectory, onChangeWorkingDirectory, workingDirectoryAccess, workingDirectoryControlPlacement = 'below', projectFileNames, uploadAttachments, attachmentAccept, disabled = false, runtimePickerPlacement = 'up', composerSlots, header, leadingAccessory, footer, className, style, }) {
182
182
  const t = useT();
183
183
  const composerPlaceholder = useChatPaneComposerPlaceholder({ placeholder: placeholder, placeholders: placeholders });
184
184
  const inventory = useChatPaneRuntimeInventory(definedProps({ source: {
@@ -210,6 +210,7 @@ export function ChatPane({ transport, agents: injectedAgents = EMPTY_AGENTS, run
210
210
  onChangeWorkingDirectory,
211
211
  workingDirectoryAccess,
212
212
  apiModeConfigured,
213
+ deliverTypedAnswer,
213
214
  } }));
214
215
  // Mirrors `Composer.tsx`'s own `draftRef`: `composerHandle.insertText` (below) is called from
215
216
  // OUTSIDE any render, so it cannot close over `pane.composer.draft` directly — that would freeze
@@ -262,6 +263,6 @@ export function ChatPane({ transport, agents: injectedAgents = EMPTY_AGENTS, run
262
263
  footerAccessories: (_jsx(AgentRuntimePicker, { agents: runtimeView.agents, value: pane.selection, onChange: pane.setSelection, ...definedProps({ source: { onRescan: runtimeView.rescanAgents } }), scanning: runtimeView.scanningAgents, daemonOnline: runtimeView.daemonOnline, placement: runtimePickerPlacement, executionMode: executionMode, apiModeAvailable: apiModeAvailable, ...definedProps({ source: { onExecutionModeChange, byokRuntime, onByokModelChange } }) })),
263
264
  } });
264
265
  const { rootRef, controlsRef } = useChatPaneControlsHeight();
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] }));
266
+ 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, typedAnswerNotice: pane.typedAnswerNotice, onSendAsNewMessage: () => void pane.sendAsNewMessage(), 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] }));
266
267
  }
267
268
  //# sourceMappingURL=ChatPane.js.map
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @module create-typed-answer-poster
3
+ *
4
+ * The client half of a typed answer: a ready-made {@link DeliverTypedAnswer} that posts text the
5
+ * human typed into the composer to the MCP-UI tool-call route, for the question a running agent is
6
+ * holding open. Pass it as `ChatPane`'s `deliverTypedAnswer`.
7
+ *
8
+ * ```tsx
9
+ * <ChatPane deliverTypedAnswer={createTypedAnswerPoster({ baseUrl: '', fetch, toolName: 'ask_choice' })} />
10
+ * ```
11
+ *
12
+ * Sibling of `create-mcp-ui-tool-caller.ts`, and the same wire shape: `{toolName, params}` to the
13
+ * same route. The difference is what the params carry and what the caller needs back.
14
+ *
15
+ * ## Why the body names no exchange
16
+ *
17
+ * A human typing into the composer has never seen an exchange id; the only client-side copy lives
18
+ * inside the surface's own (model-influenced, sandboxed) HTML. So the body carries only the tool and
19
+ * the text under {@link TYPED_ANSWER_PARAM}, and the server resolves which open exchange — for that
20
+ * tool and the request's own authenticated principal — the answer is for (`@jini-ai/daemon`'s
21
+ * `findTypedAnswerTarget`).
22
+ *
23
+ * ## Why it resolves an outcome instead of throwing
24
+ *
25
+ * The pane must tell three cases apart and act differently on each, never by queueing the text as a
26
+ * new run: 202 delivered it; 409 means nothing is waiting any more (answered, expired, or never
27
+ * open); anything else is a failure the human can retry. A rejection would collapse the last two.
28
+ */
29
+ import type { DeliverTypedAnswer } from './types.js';
30
+ export interface CreateTypedAnswerPosterOptions {
31
+ /** Path appended to `baseUrl`. Defaults to `/api/mcp-ui/tool-calls`, as for `createMcpUiToolCaller`. */
32
+ readonly path?: string;
33
+ /** Abandon a post after this many ms (reported as `'failed'`). Defaults to 30s. */
34
+ readonly timeoutMs?: number;
35
+ /** Extra headers per request (e.g. a CSRF token). `content-type` is always set and cannot be overridden. */
36
+ readonly headers?: Readonly<Record<string, string>>;
37
+ }
38
+ /**
39
+ * Builds a {@link DeliverTypedAnswer} that POSTs `{toolName, params: {__typedAnswer: text}}`.
40
+ *
41
+ * @param deps.baseUrl - Origin to call; `''` for same-origin.
42
+ * @param deps.fetch - The fetch to use (the browser's, in production).
43
+ * @param deps.toolName - The host's question tool id — the server only routes typed answers to a
44
+ * tool that opted in, so this package cannot pick it.
45
+ * @returns Resolves to the outcome; never rejects. Carries `toolName`, which also tells the pane
46
+ * which tool's open card is a question awaiting typed text.
47
+ * @complexity O(1) — one request per call.
48
+ */
49
+ export declare function createTypedAnswerPoster({ baseUrl, fetch, toolName }: {
50
+ baseUrl: string;
51
+ fetch: typeof globalThis.fetch;
52
+ toolName: string;
53
+ }, options?: CreateTypedAnswerPosterOptions): DeliverTypedAnswer;
54
+ //# sourceMappingURL=create-typed-answer-poster.d.ts.map
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The params key a typed answer travels under. Spelled here rather than imported because this
3
+ * browser package cannot depend on `@jini-ai/daemon`, which owns the canonical
4
+ * `SURFACE_TYPED_ANSWER_PARAM`; `@jini-ai/mcp`'s ask-choice tool keeps its own copy for the same
5
+ * reason. All three must agree.
6
+ */
7
+ const TYPED_ANSWER_PARAM = '__typedAnswer';
8
+ const DEFAULT_PATH = '/api/mcp-ui/tool-calls';
9
+ const DEFAULT_TIMEOUT_MS = 30_000;
10
+ /** Maps the route's status onto an outcome. Only 202 is a delivery; a 200 would be a fresh execution. */
11
+ function deliveryFor(status) {
12
+ if (status === 202)
13
+ return 'delivered';
14
+ return status === 409 ? 'not-pending' : 'failed';
15
+ }
16
+ /**
17
+ * Builds a {@link DeliverTypedAnswer} that POSTs `{toolName, params: {__typedAnswer: text}}`.
18
+ *
19
+ * @param deps.baseUrl - Origin to call; `''` for same-origin.
20
+ * @param deps.fetch - The fetch to use (the browser's, in production).
21
+ * @param deps.toolName - The host's question tool id — the server only routes typed answers to a
22
+ * tool that opted in, so this package cannot pick it.
23
+ * @returns Resolves to the outcome; never rejects. Carries `toolName`, which also tells the pane
24
+ * which tool's open card is a question awaiting typed text.
25
+ * @complexity O(1) — one request per call.
26
+ */
27
+ export function createTypedAnswerPoster({ baseUrl, fetch, toolName }, options = {}) {
28
+ const endpoint = `${baseUrl.replace(/\/$/u, '')}${options.path ?? DEFAULT_PATH}`;
29
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
30
+ const deliver = async ({ text }) => {
31
+ const controller = new AbortController();
32
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
33
+ const outcome = await fetch(endpoint, {
34
+ method: 'POST',
35
+ // Same-origin cookie session: the human's own credentials authorize the answer.
36
+ credentials: 'same-origin',
37
+ headers: { ...options.headers, 'content-type': 'application/json' },
38
+ body: JSON.stringify({ toolName, params: { [TYPED_ANSWER_PARAM]: text } }),
39
+ signal: controller.signal,
40
+ }).then((response) => deliveryFor(response.status), () => 'failed');
41
+ clearTimeout(timer);
42
+ return outcome;
43
+ };
44
+ return Object.assign(deliver, { toolName });
45
+ }
46
+ //# sourceMappingURL=create-typed-answer-poster.js.map
@@ -1,5 +1,5 @@
1
1
  import { type ChatPaneSendBlocker } from '../rules.js';
2
- import type { ChatPaneActivity, ChatPaneAgent, ChatPaneAttachmentUploadOptions, ChatPaneAgentSelection, ChatPaneRunContext, ChatPaneWorkingDirectoryAccess } from '../types.js';
2
+ import type { ChatPaneActivity, ChatPaneAgent, ChatPaneAttachmentUploadOptions, ChatPaneAgentSelection, ChatPaneRunContext, ChatPaneWorkingDirectoryAccess, DeliverTypedAnswer, TypedAnswerNotice } from '../types.js';
3
3
  import type { ChatAttachment, ChatMessage } from '@jini-ai/chat';
4
4
  import type { ChatTransport } from '@jini-ai/chat';
5
5
  import { type UseComposerResult } from '../../../hooks/useComposer.js';
@@ -30,6 +30,8 @@ export interface UseChatPaneOptions {
30
30
  * behavior: no selected agent always blocks sending.
31
31
  */
32
32
  apiModeConfigured?: boolean;
33
+ /** See `ChatPaneProps.deliverTypedAnswer`. Omitted keeps today's queue-while-streaming behavior. */
34
+ deliverTypedAnswer?: DeliverTypedAnswer;
33
35
  }
34
36
  export interface UseChatPaneResult extends UseChatPaneWorkingDirectoryResult {
35
37
  conversation: UseConversationResult;
@@ -76,6 +78,20 @@ export interface UseChatPaneResult extends UseChatPaneWorkingDirectoryResult {
76
78
  queuedPrompt: string | null;
77
79
  /** Drops the queued prompt without ever sending it. */
78
80
  cancelQueued: () => void;
81
+ /**
82
+ * Why the last typed answer was NOT sent, or `null`. Set when `deliverTypedAnswer` reports the
83
+ * question no longer waiting or could not be reached, and when {@link send} holds back an answer
84
+ * whose question has since closed; the draft stays in the composer. Cleared by the next send and
85
+ * by `reset()`.
86
+ */
87
+ typedAnswerNotice: TypedAnswerNotice | null;
88
+ /**
89
+ * Sends the composer draft as an ordinary next turn (queued behind a running run, as {@link send}
90
+ * does) even though it was typed as an answer to a question that has since closed. The one way
91
+ * past {@link send}'s hold on such a draft other than clearing it: the human chose a new, possibly
92
+ * paid, run on purpose.
93
+ */
94
+ sendAsNewMessage: () => Promise<void>;
79
95
  /**
80
96
  * Cancels the run in flight and sends the composer draft as soon as it stops — the modifier-key
81
97
  * counterpart to {@link send}, which queues behind the run instead of ending it. Implemented as
@@ -1,7 +1,8 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
- import { describeChatPaneSendBlocker, findChatPaneSendBlocker, isChatPaneQueueableBlocker, resolveChatPaneSelection, } from '../rules.js';
2
+ import { describeChatPaneSendBlocker, findAwaitedTypedAnswerId, findChatPaneSendBlocker, isChatPaneQueueableBlocker, isTypedAnswerTurn, resolveChatPaneSelection, } from '../rules.js';
3
3
  import { definedProps } from '../../../util/defined-props.js';
4
4
  import { cacheAttachmentPreviewSource } from '../../../hooks/attachment-preview-cache.js';
5
+ import { readCachedAttachmentBatchId, writeCachedAttachmentBatchId, } from '../../../hooks/composer-draft-cache.js';
5
6
  import { useComposer } from '../../../hooks/useComposer.js';
6
7
  import { useConversation, } from '../../../hooks/useConversation.js';
7
8
  import { useChatPaneWorkingDirectory, } from './useChatPaneWorkingDirectory.hooks.js';
@@ -18,6 +19,20 @@ function createAttachmentBatchId() {
18
19
  return globalThis.crypto?.randomUUID?.()
19
20
  ?? `chat-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
20
21
  }
22
+ /**
23
+ * Hands one typed answer to the host. A throw counts as `'failed'`: the host could not say whether
24
+ * anything was waiting, and the one outcome that must never follow is queueing the text as a new run.
25
+ *
26
+ * @complexity O(1) plus the host's own request.
27
+ */
28
+ async function deliverTypedAnswerSafely({ deliver, text }) {
29
+ try {
30
+ return await deliver({ text });
31
+ }
32
+ catch {
33
+ return 'failed';
34
+ }
35
+ }
21
36
  /** Module scope so each branch costs 1 cognitive point instead of 2 (nested one level inside
22
37
  * `useChatPane` where it used to live as a ternary chain). */
23
38
  function resolveChatPaneActivity(selectedAgent, conversation) {
@@ -61,13 +76,33 @@ export function useChatPane(options) {
61
76
  const [activeUploadCount, setActiveUploadCount] = useState(0);
62
77
  const [attachmentError, setAttachmentError] = useState(null);
63
78
  const [queuedPrompt, setQueuedPrompt] = useState(null);
79
+ const [typedAnswerNotice, setTypedAnswerNotice] = useState(null);
80
+ // Set across the await of one typed-answer delivery so a second Enter cannot post the same text
81
+ // twice — the second post would find the question already answered and wrongly report it closed.
82
+ const deliveringTypedAnswerRef = useRef(false);
83
+ // The question (tool-use id) the current draft was typed for, or null. Set while a non-empty draft
84
+ // sits in the composer as a question waits, and kept after that question closes (answered
85
+ // elsewhere, expired, 409), so `send` cannot slip the draft into the queue as a new paid run.
86
+ // Released only by an empty draft, a delivery, `reset()`, or `sendAsNewMessage`.
87
+ const heldAnswerQuestionIdRef = useRef(null);
88
+ // The question the last delivery answered: typing more while its card is still drawn open (its
89
+ // tool_result has not streamed in yet) is a follow-up, not another answer to hold.
90
+ const answeredQuestionIdRef = useRef(null);
91
+ // Bumped by `reset()` and a conversation switch; a delivery that settles under a different value
92
+ // belongs to a composer that no longer exists and must neither clear the draft nor set a notice.
93
+ const typedAnswerGenerationRef = useRef(0);
64
94
  // The conversation `queuedPrompt` was queued against. Compared with `options.conversationId` at
65
95
  // flush time so a prompt queued behind a streaming run in one conversation can never be posted
66
96
  // into a different one the caller switched to before that run finished.
67
97
  const queuedConversationIdRef = useRef(undefined);
68
98
  const mountedRef = useRef(true);
69
99
  const attachmentGenerationRef = useRef(0);
70
- const attachmentBatchIdRef = useRef(createAttachmentBatchId());
100
+ // Every attachment staged for one turn must share one upload batch (the daemon refuses a turn
101
+ // spanning two). So a mount that will restore cached attachments starts in THEIR batch rather
102
+ // than a new one; see `readCachedAttachmentBatchId`.
103
+ const [initialAttachmentBatchId] = useState(() => readCachedAttachmentBatchId({ conversationId: options.conversationId }) ?? createAttachmentBatchId());
104
+ const attachmentBatchIdRef = useRef(initialAttachmentBatchId);
105
+ const batchConversationIdRef = useRef(options.conversationId);
71
106
  const activeUploadsRef = useRef(new Set());
72
107
  const [internalSelection, setInternalSelection] = useState(options.initialSelection ?? { agentId: '' });
73
108
  const workingDirectoryState = useChatPaneWorkingDirectory(definedProps({ source: {
@@ -150,10 +185,51 @@ export function useChatPane(options) {
150
185
  ...(options.apiModeConfigured === undefined ? {} : { apiModeConfigured: options.apiModeConfigured }),
151
186
  });
152
187
  const canSend = sendBlocker === null && composer.canSubmit;
188
+ // Read after `await` in `answerWaitingQuestion`, where the render-time `composer.draft` is stale.
189
+ const latestDraftRef = useRef(composer.draft);
190
+ latestDraftRef.current = composer.draft;
191
+ const awaitedQuestionId = options.deliverTypedAnswer === undefined
192
+ ? null
193
+ : findAwaitedTypedAnswerId({ messages: conversation.messages, toolName: options.deliverTypedAnswer.toolName });
194
+ const hasDraft = composer.draft.trim() !== '';
195
+ useEffect(() => {
196
+ if (!hasDraft) {
197
+ heldAnswerQuestionIdRef.current = null;
198
+ return;
199
+ }
200
+ if (awaitedQuestionId !== null && awaitedQuestionId !== answeredQuestionIdRef.current) {
201
+ heldAnswerQuestionIdRef.current = awaitedQuestionId;
202
+ }
203
+ }, [awaitedQuestionId, hasDraft]);
204
+ useEffect(() => {
205
+ typedAnswerGenerationRef.current += 1;
206
+ heldAnswerQuestionIdRef.current = null;
207
+ }, [options.conversationId]);
208
+ // A conversation switch without a remount: adopt the batch of the attachments the composer is
209
+ // about to restore for the new conversation, else start a fresh one so two conversations never
210
+ // share a batch directory (the agent is granted the whole directory, not just the claimed files).
211
+ // An id arriving where there was none is the same conversation finally getting a key — the
212
+ // composer keeps what is staged (see `useComposer`), so the batch stays too.
213
+ useEffect(() => {
214
+ const previousConversationId = batchConversationIdRef.current;
215
+ if (options.conversationId === previousConversationId)
216
+ return;
217
+ batchConversationIdRef.current = options.conversationId;
218
+ const restoredBatchId = readCachedAttachmentBatchId({ conversationId: options.conversationId });
219
+ if (restoredBatchId !== null) {
220
+ attachmentBatchIdRef.current = restoredBatchId;
221
+ return;
222
+ }
223
+ if (previousConversationId == null)
224
+ return;
225
+ attachmentBatchIdRef.current = createAttachmentBatchId();
226
+ }, [options.conversationId]);
153
227
  const addAttachments = useCallback(async (files) => {
154
228
  if (files.length === 0 || options.uploadAttachments === undefined)
155
229
  return;
156
230
  const generation = attachmentGenerationRef.current;
231
+ const batchId = attachmentBatchIdRef.current;
232
+ const conversationId = options.conversationId;
157
233
  const controller = new AbortController();
158
234
  activeUploadsRef.current.add(controller);
159
235
  setActiveUploadCount(activeUploadsRef.current.size);
@@ -162,7 +238,7 @@ export function useChatPane(options) {
162
238
  await uploadAttachmentBatch(files, {
163
239
  upload: options.uploadAttachments,
164
240
  signal: controller.signal,
165
- batchId: attachmentBatchIdRef.current,
241
+ batchId,
166
242
  stillWanted: () => mountedRef.current
167
243
  && !controller.signal.aborted
168
244
  && generation === attachmentGenerationRef.current,
@@ -172,6 +248,9 @@ export function useChatPane(options) {
172
248
  // `attachment-preview-cache.ts`'s module doc for why this is the only place that copy can
173
249
  // ever be captured.
174
250
  onAttachment: (attachment, file) => {
251
+ // Recorded before staging so the cache entry that persists this attachment carries its
252
+ // batch, which is what lets a later mount restore the batch along with the attachment.
253
+ writeCachedAttachmentBatchId({ conversationId, batchId });
175
254
  composer.addAttachment(attachment);
176
255
  if (file)
177
256
  cacheAttachmentPreviewSource({ path: attachment.path, file });
@@ -184,7 +263,7 @@ export function useChatPane(options) {
184
263
  setActiveUploadCount(activeUploadsRef.current.size);
185
264
  }
186
265
  }
187
- }, [composer, options.uploadAttachments]);
266
+ }, [composer, options.conversationId, options.uploadAttachments]);
188
267
  const sendPrompt = useCallback(async (prompt) => {
189
268
  const trimmed = prompt.trim();
190
269
  if (!trimmed)
@@ -220,10 +299,30 @@ export function useChatPane(options) {
220
299
  sendBlocker,
221
300
  workingDirectoryState.workingDirectory,
222
301
  ]);
223
- const send = useCallback(async () => {
224
- const prompt = composerPrompt(composer);
225
- if (!prompt)
302
+ const answerWaitingQuestion = useCallback(async ({ deliver, text }) => {
303
+ const generation = typedAnswerGenerationRef.current;
304
+ const submittedDraft = latestDraftRef.current;
305
+ const questionId = awaitedQuestionId;
306
+ deliveringTypedAnswerRef.current = true;
307
+ const outcome = await deliverTypedAnswerSafely({ deliver, text });
308
+ deliveringTypedAnswerRef.current = false;
309
+ if (!mountedRef.current || generation !== typedAnswerGenerationRef.current)
226
310
  return;
311
+ // Anything but a delivery keeps the draft and says why — never the queue below, whose flush
312
+ // would post the answer as a brand-new (paid) run once this one ends.
313
+ if (outcome !== 'delivered') {
314
+ setTypedAnswerNotice(outcome);
315
+ return;
316
+ }
317
+ answeredQuestionIdRef.current = questionId;
318
+ heldAnswerQuestionIdRef.current = null;
319
+ // The textarea stays editable during delivery: clear only the text that went out, never a
320
+ // correction or next message typed while the host answered.
321
+ if (latestDraftRef.current === submittedDraft)
322
+ composer.setDraft('');
323
+ }, [awaitedQuestionId, composer]);
324
+ // The ordinary next-turn path: queue behind a running run, else send now.
325
+ const sendOrdinaryTurn = useCallback(async (prompt) => {
227
326
  // Queue instead of no-op'ing. `setDraft('')` and NOT `composer.reset()`: reset also discards
228
327
  // staged attachments, which this turn still needs when it finally goes out.
229
328
  if (isChatPaneQueueableBlocker({ blocker: sendBlocker })) {
@@ -236,6 +335,48 @@ export function useChatPane(options) {
236
335
  return;
237
336
  await sendPrompt(prompt);
238
337
  }, [canSend, composer, options.conversationId, sendBlocker, sendPrompt]);
338
+ const send = useCallback(async () => {
339
+ const prompt = composerPrompt(composer);
340
+ if (!prompt || deliveringTypedAnswerRef.current)
341
+ return;
342
+ setTypedAnswerNotice(null);
343
+ const deliver = options.deliverTypedAnswer;
344
+ if (deliver !== undefined && isTypedAnswerTurn({
345
+ blocker: sendBlocker,
346
+ attachmentCount: composer.attachments.length,
347
+ messages: conversation.messages,
348
+ toolName: deliver.toolName,
349
+ })) {
350
+ await answerWaitingQuestion({ deliver, text: prompt });
351
+ return;
352
+ }
353
+ // Typed for a question that has since closed: the human meant an answer, so going on to the
354
+ // queue would start a paid run they never asked for. Attachments mark a new turn (a typed answer
355
+ // carries text only), the same rule `isTypedAnswerTurn` applies.
356
+ if (heldAnswerQuestionIdRef.current !== null && composer.attachments.length === 0) {
357
+ setTypedAnswerNotice('not-pending');
358
+ return;
359
+ }
360
+ await sendOrdinaryTurn(prompt);
361
+ }, [
362
+ answerWaitingQuestion,
363
+ composer,
364
+ conversation.messages,
365
+ options.deliverTypedAnswer,
366
+ sendBlocker,
367
+ sendOrdinaryTurn,
368
+ ]);
369
+ const sendAsNewMessage = useCallback(async () => {
370
+ const prompt = composerPrompt(composer);
371
+ if (!prompt || deliveringTypedAnswerRef.current)
372
+ return;
373
+ setTypedAnswerNotice(null);
374
+ // Mark the question answered too, so the still-non-empty draft is not re-held by the effect
375
+ // while that question's card is drawn open.
376
+ answeredQuestionIdRef.current = heldAnswerQuestionIdRef.current ?? answeredQuestionIdRef.current;
377
+ heldAnswerQuestionIdRef.current = null;
378
+ await sendOrdinaryTurn(prompt);
379
+ }, [composer, sendOrdinaryTurn]);
239
380
  const interruptSend = useCallback(() => {
240
381
  const prompt = composerPrompt(composer);
241
382
  if (!prompt)
@@ -277,6 +418,9 @@ export function useChatPane(options) {
277
418
  conversation.setMessages(options.initialMessages ?? []);
278
419
  composer.reset();
279
420
  setAttachmentError(null);
421
+ setTypedAnswerNotice(null);
422
+ typedAnswerGenerationRef.current += 1;
423
+ heldAnswerQuestionIdRef.current = null;
280
424
  // Without this, a prompt queued behind a streaming run survives `reset()` and — once
281
425
  // `conversation.cancel()` above clears the streaming blocker — the flush effect fires it into
282
426
  // the just-reset conversation instead of discarding it like the rest of this turn's state.
@@ -301,6 +445,8 @@ export function useChatPane(options) {
301
445
  sendPrompt,
302
446
  queuedPrompt,
303
447
  cancelQueued,
448
+ typedAnswerNotice,
449
+ sendAsNewMessage,
304
450
  interruptSend,
305
451
  reset,
306
452
  };
@@ -1,8 +1,9 @@
1
- export type { AgentRuntimePickerProps, ByokRuntimeSummary, ChatPaneActivity, ChatPaneAgent, ChatPaneAgentBridgeAccess, ChatPaneAgentControlOptions, ChatPaneAgentOption, ChatPaneAgentSelection, ChatPaneAgentToolAction, ChatPaneAttachmentUploadOptions, ChatPaneComposerHandle, ChatPaneProps, ChatPaneRunContext, ChatPaneRunContextInput, ChatPaneRuntimeAccess, ChatPaneVariant, ChatPaneWorkingDirectoryAccess, RuntimePickerPlacement, } from './types.js';
1
+ export type { AgentRuntimePickerProps, ByokRuntimeSummary, ChatPaneActivity, ChatPaneAgent, ChatPaneAgentBridgeAccess, ChatPaneAgentControlOptions, ChatPaneAgentOption, ChatPaneAgentSelection, ChatPaneAgentToolAction, ChatPaneAttachmentUploadOptions, ChatPaneComposerHandle, ChatPaneProps, ChatPaneRunContext, ChatPaneRunContextInput, ChatPaneRuntimeAccess, ChatPaneVariant, ChatPaneWorkingDirectoryAccess, DeliverTypedAnswer, RuntimePickerPlacement, TypedAnswerDelivery, TypedAnswerNotice, } from './types.js';
2
2
  export { CHAT_PANE_AGENT_TOOLS, type ChatPaneAgentToolDef, type ChatPaneAgentToolInputSchema, type ChatPaneAgentToolRisk, } from './agent-tools.js';
3
3
  export { defaultChatPaneSelection, orderChatPaneAgents, resolveChatPaneSelection, } from './rules.js';
4
4
  export { createDaemonAttachmentUploader, type CreateDaemonAttachmentUploaderOptions, } from './create-daemon-attachment-uploader.js';
5
5
  export { createMcpUiToolCaller, type CreateMcpUiToolCallerOptions, type McpUiToolCallRequest, } from './create-mcp-ui-tool-caller.js';
6
+ export { createTypedAnswerPoster, type CreateTypedAnswerPosterOptions, } from './create-typed-answer-poster.js';
6
7
  export { useChatPane, type UseChatPaneOptions, type UseChatPaneResult, } from './hooks/useChatPane.hooks.js';
7
8
  export { useChatPaneAgentControl, type UseChatPaneAgentControlOptions, } from './hooks/useChatPaneAgentControl.hooks.js';
8
9
  export { useChatPaneWorkingDirectory, type UseChatPaneWorkingDirectoryOptions, type UseChatPaneWorkingDirectoryResult, } from './hooks/useChatPaneWorkingDirectory.hooks.js';
@@ -2,6 +2,7 @@ export { CHAT_PANE_AGENT_TOOLS, } from './agent-tools.js';
2
2
  export { defaultChatPaneSelection, orderChatPaneAgents, resolveChatPaneSelection, } from './rules.js';
3
3
  export { createDaemonAttachmentUploader, } from './create-daemon-attachment-uploader.js';
4
4
  export { createMcpUiToolCaller, } from './create-mcp-ui-tool-caller.js';
5
+ export { createTypedAnswerPoster, } from './create-typed-answer-poster.js';
5
6
  export { useChatPane, } from './hooks/useChatPane.hooks.js';
6
7
  export { useChatPaneAgentControl, } from './hooks/useChatPaneAgentControl.hooks.js';
7
8
  export { useChatPaneWorkingDirectory, } from './hooks/useChatPaneWorkingDirectory.hooks.js';
@@ -1,4 +1,5 @@
1
- import type { ByokRuntimeSummary, ChatPaneAgent, ChatPaneAgentSelection } from './types.js';
1
+ import type { ChatMessage } from '@jini-ai/chat';
2
+ import type { ByokRuntimeSummary, ChatPaneAgent, ChatPaneAgentSelection, TypedAnswerNotice } from './types.js';
2
3
  export declare function defaultChatPaneSelection({ agent }: {
3
4
  agent: ChatPaneAgent;
4
5
  }): ChatPaneAgentSelection;
@@ -71,6 +72,63 @@ export declare function describeChatPaneSendBlocker(blocker: ChatPaneSendBlocker
71
72
  export declare function isChatPaneQueueableBlocker({ blocker }: {
72
73
  blocker: ChatPaneSendBlocker | null;
73
74
  }): boolean;
75
+ /**
76
+ * Whether the run in flight is holding a question open for the human: the newest message is the
77
+ * assistant's, and it carries an interactive surface (`kind: 'ext'`, e.g. an MCP-UI card) that
78
+ * arrived inside a still-open call to the host's question tool (`toolName`). That is the one moment
79
+ * text typed into the composer is an ANSWER rather than a next turn — the tool is parked waiting on
80
+ * it, and queueing the text behind the run would only deliver it after the question had expired, as
81
+ * a new paid run.
82
+ *
83
+ * Only the question tool counts. Other tools park on cards too — a delete confirm, an approval —
84
+ * but they take no typed answer: the server routes typed text only to the question tool, so treating
85
+ * those as waiting would refuse the text with "no longer waiting" and drop it instead of queueing.
86
+ * A call is the question tool when ANY `tool_use` for its id names it, since the same call's
87
+ * `tool_use` can arrive more than once (the CLI re-emits it). A vendor's wrapper row
88
+ * (`execute_delegated_tool`) and the daemon's canonical row do NOT share an id — the wrapper carries
89
+ * the CLI's `toolu_…` id and the canonical row a fresh UUID from `@jini-ai/mcp`. The canonical row
90
+ * opens after its wrapper, so it is the newest open call when the surface arrives, and that is
91
+ * what attributes the surface to the question tool.
92
+ *
93
+ * A surface is attributed to the newest call still open when it arrived — the same rule
94
+ * `useExtEventGroups` uses to give a card its `call` — so an older call returning does not settle
95
+ * it. Once its call returns (answered, expired, abandoned), typing is a next turn again.
96
+ *
97
+ * @complexity O(e·c): e events in the newest message, c calls open at once (one or two in practice).
98
+ */
99
+ export declare function isAwaitingTypedAnswer({ messages, toolName }: {
100
+ messages: readonly ChatMessage[];
101
+ toolName: string;
102
+ }): boolean;
103
+ /**
104
+ * The tool-use id of the question {@link isAwaitingTypedAnswer} sees waiting, or `null` — the newest
105
+ * one when several are open. The id lets the pane tell one question from the next: a draft typed
106
+ * for a question stays tied to THAT question after it closes (see `useChatPane`'s held answer).
107
+ *
108
+ * @complexity O(e·c), as {@link isAwaitingTypedAnswer}.
109
+ */
110
+ export declare function findAwaitedTypedAnswerId({ messages, toolName }: {
111
+ messages: readonly ChatMessage[];
112
+ toolName: string;
113
+ }): string | null;
114
+ /**
115
+ * Whether a composer send is an answer to a waiting question rather than a next turn: a run is
116
+ * streaming ({@link isChatPaneQueueableBlocker}), the turn has no attachments (a typed answer
117
+ * carries text only), and that run is holding the `toolName` question open
118
+ * ({@link isAwaitingTypedAnswer}).
119
+ *
120
+ * @complexity O(e·c), from {@link isAwaitingTypedAnswer}.
121
+ */
122
+ export declare function isTypedAnswerTurn({ blocker, attachmentCount, messages, toolName }: {
123
+ blocker: ChatPaneSendBlocker | null;
124
+ attachmentCount: number;
125
+ messages: readonly ChatMessage[];
126
+ toolName: string;
127
+ }): boolean;
128
+ /** The English copy (and i18n key) for why a typed answer was not sent. */
129
+ export declare function describeTypedAnswerNotice({ notice }: {
130
+ notice: TypedAnswerNotice;
131
+ }): string;
74
132
  export declare function orderChatPaneAgents({ agents }: {
75
133
  agents: readonly ChatPaneAgent[];
76
134
  }): ChatPaneAgent[];