@jini-ai/chat 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -1
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/surface-expiry.d.ts +42 -0
- package/dist/core/surface-expiry.js +42 -0
- package/dist/react/chat-pane.d.ts +2 -2
- package/dist/react/chat-pane.js +1 -1
- package/dist/react/components/AttachmentPreviewModal.hooks.d.ts +19 -2
- package/dist/react/components/AttachmentPreviewModal.hooks.js +53 -38
- package/dist/react/components/McpUiSurfaceCard.d.ts +7 -1
- package/dist/react/components/McpUiSurfaceCard.js +23 -38
- package/dist/react/components/MessageList.d.ts +11 -0
- package/dist/react/components/MessageList.hooks.d.ts +19 -0
- package/dist/react/components/MessageList.hooks.js +76 -0
- package/dist/react/components/MessageList.js +3 -71
- package/dist/react/components/MessageList.rules.d.ts +25 -0
- package/dist/react/components/MessageList.rules.js +23 -0
- package/dist/react/components/mcp-ui-surface-state.d.ts +27 -0
- package/dist/react/components/mcp-ui-surface-state.js +33 -0
- package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
- package/dist/react/features/chat-pane/components/ChatPane.js +6 -5
- package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +54 -0
- package/dist/react/features/chat-pane/create-typed-answer-poster.js +46 -0
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +17 -1
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +120 -4
- package/dist/react/features/chat-pane/index.d.ts +2 -1
- package/dist/react/features/chat-pane/index.js +1 -0
- package/dist/react/features/chat-pane/rules.d.ts +56 -1
- package/dist/react/features/chat-pane/rules.js +71 -0
- package/dist/react/features/chat-pane/types.d.ts +34 -0
- package/dist/react/hooks/useSurfaceExpiry.d.ts +20 -0
- package/dist/react/hooks/useSurfaceExpiry.js +35 -0
- package/dist/react/index.d.ts +4 -2
- package/dist/react/index.js +2 -1
- package/package.json +8 -8
|
@@ -1,80 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
/**
|
|
3
|
-
* @module MessageList
|
|
4
|
-
*
|
|
5
|
-
* Renders a conversation's messages via `<MessageRow>`, and — matching
|
|
6
|
-
* `useConversation`'s `scrollIntent` flag (see
|
|
7
|
-
* `ADS-memory/reports/jini-port/recon/r4b-webui-design.md` §4: "message array, optimistic
|
|
8
|
-
* user message, scroll-intent flag") — auto-scrolls to the newest content
|
|
9
|
-
* whenever `scrollIntent` is true, calling `onScrolled` once it has. Pure
|
|
10
|
-
* DOM scroll-anchoring via a ref lives here (a presentational component),
|
|
11
|
-
* not in the headless `useConversation` hook, per that hook's own
|
|
12
|
-
* "transport-agnostic hooks never touch DOM" contract.
|
|
13
|
-
*/
|
|
14
|
-
import { useEffect, useRef } from 'react';
|
|
15
2
|
import { isTerminalRunStatus } from '../../core/index.js';
|
|
16
3
|
import { useT } from '../hooks/context.js';
|
|
4
|
+
import { useMessageListAutoScroll } from './MessageList.hooks.js';
|
|
17
5
|
import { MessageRow } from './MessageRow.js';
|
|
18
|
-
/** Slack, in px, for treating "close enough to the bottom" as "at the bottom" — a fractional
|
|
19
|
-
* scrollHeight/clientHeight rounding difference should not by itself stop the sticky-follow below. */
|
|
20
|
-
const AT_BOTTOM_THRESHOLD_PX = 4;
|
|
21
|
-
function isScrolledToBottom(el) {
|
|
22
|
-
return el.scrollHeight - el.scrollTop - el.clientHeight <= AT_BOTTOM_THRESHOLD_PX;
|
|
23
|
-
}
|
|
24
6
|
export function MessageList({ messages, isStreaming = false, scrollIntent = false, onScrolled, activeQuestionFormMessageId = null, questionFormSubmittedAnswersByMessageId, onQuestionFormSubmit, projectFileNames, onRequestOpenFile, renderAttachment, pendingPrompt = null, }) {
|
|
25
7
|
const t = useT();
|
|
26
|
-
const containerRef =
|
|
27
|
-
|
|
28
|
-
// scroll-to-bottom — read by the ResizeObserver effect below, which only ever sees the DOM AFTER a
|
|
29
|
-
// resize already happened and so cannot recompute "was this at the bottom" from current values.
|
|
30
|
-
// Starts true: a freshly mounted pane opens on the latest turn, same as scrollIntent's own default.
|
|
31
|
-
const stickToBottomRef = useRef(true);
|
|
32
|
-
useEffect(() => {
|
|
33
|
-
if (!scrollIntent)
|
|
34
|
-
return;
|
|
35
|
-
const el = containerRef.current;
|
|
36
|
-
// Gated on `stickToBottomRef`, same as the ResizeObserver effect below: `scrollIntent` turns
|
|
37
|
-
// true on EVERY streamed event (tool-call chatter included, not just a finished reply — see
|
|
38
|
-
// `useConversation.applyRunToAssistantMessage`), so scrolling unconditionally here yanked a user
|
|
39
|
-
// who had deliberately scrolled up to read history back to the bottom on the very next chunk
|
|
40
|
-
// (owner-reported, 2026-08-30). `stickToBottomRef` already reflects the user's own last scroll
|
|
41
|
-
// gesture via the `onScroll` handler below, captured BEFORE this content change, so it is safe
|
|
42
|
-
// to trust here.
|
|
43
|
-
if (el && stickToBottomRef.current) {
|
|
44
|
-
el.scrollTop = el.scrollHeight;
|
|
45
|
-
}
|
|
46
|
-
onScrolled?.();
|
|
47
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
48
|
-
}, [scrollIntent, messages]);
|
|
49
|
-
// Re-sticks to the bottom when a message ROW grows after mount with no accompanying `messages`
|
|
50
|
-
// change — the exact case an embedded MCP-UI/A2UI surface hits: it mounts at an initial guessed
|
|
51
|
-
// height (`preferredFrameSize` or `DEFAULT_INITIAL_HEIGHT`), then reports its real, often much
|
|
52
|
-
// taller content height asynchronously via `ui/notifications/size-changed`, entirely inside that
|
|
53
|
-
// surface's own React state (`McpUiHost`). The effect above never re-runs for that, so without this
|
|
54
|
-
// a surface which grows after the one-shot scroll leaves its own action buttons — or, once it
|
|
55
|
-
// resolves, the assistant's next reply — below the stale "bottom" with nothing to bring them back
|
|
56
|
-
// into view. Observes each direct child (one per message row) rather than the scroll container
|
|
57
|
-
// itself: the container's own box is held fixed by its flex/overflow layout, so only its CONTENT
|
|
58
|
-
// grows, which `ResizeObserver` only reports for the element whose box is actually changing.
|
|
59
|
-
// Re-created per `messages` change so a newly mounted row is observed too. Gated on
|
|
60
|
-
// `stickToBottomRef` so a human who scrolled up to read history is never yanked back down by an
|
|
61
|
-
// unrelated row resizing elsewhere in the transcript.
|
|
62
|
-
useEffect(() => {
|
|
63
|
-
const el = containerRef.current;
|
|
64
|
-
if (!el || typeof ResizeObserver !== 'function')
|
|
65
|
-
return;
|
|
66
|
-
const observer = new ResizeObserver(() => {
|
|
67
|
-
if (!stickToBottomRef.current)
|
|
68
|
-
return;
|
|
69
|
-
el.scrollTop = el.scrollHeight;
|
|
70
|
-
});
|
|
71
|
-
for (const child of Array.from(el.children))
|
|
72
|
-
observer.observe(child);
|
|
73
|
-
return () => observer.disconnect();
|
|
74
|
-
}, [messages]);
|
|
75
|
-
return (_jsxs("div", { className: "jini-message-list", ref: containerRef, onScroll: (event) => {
|
|
76
|
-
stickToBottomRef.current = isScrolledToBottom(event.currentTarget);
|
|
77
|
-
}, "data-agent-element": "chat-transcript", "data-agent-role": "list", "data-agent-label": "The conversation transcript, oldest message first", children: [messages.map((message) => {
|
|
8
|
+
const { containerRef, onScroll } = useMessageListAutoScroll({ messages, scrollIntent }, onScrolled !== undefined ? { onScrolled } : {});
|
|
9
|
+
return (_jsxs("div", { className: "jini-message-list", ref: containerRef, onScroll: onScroll, "data-agent-element": "chat-transcript", "data-agent-role": "list", "data-agent-label": "The conversation transcript, oldest message first", children: [messages.map((message) => {
|
|
78
10
|
const isLast = message.id === messages[messages.length - 1]?.id;
|
|
79
11
|
const runStreaming = isLast && isStreaming && !isTerminalRunStatus({ status: message.runStatus });
|
|
80
12
|
return (_jsx(MessageRow, { message: message, runStreaming: runStreaming, runSucceeded: message.runStatus === 'succeeded', questionFormInteractive: message.id === activeQuestionFormMessageId, ...(questionFormSubmittedAnswersByMessageId?.[message.id] !== undefined ? { questionFormSubmittedAnswers: questionFormSubmittedAnswersByMessageId[message.id] } : {}), ...(onQuestionFormSubmit !== undefined ? { onQuestionFormSubmit: (text, answers) => onQuestionFormSubmit(message.id, text, answers) } : {}), ...(projectFileNames !== undefined ? { projectFileNames } : {}), ...(onRequestOpenFile !== undefined ? { onRequestOpenFile } : {}), ...(renderAttachment !== undefined ? { renderAttachment } : {}) }, message.id));
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Pure scroll rules for `MessageList` (no DOM, no React), split out so the component file
|
|
3
|
+
* holds no logic.
|
|
4
|
+
*/
|
|
5
|
+
import type { ChatMessage } from '../../core/index.js';
|
|
6
|
+
/** Slack, in px, for treating "close enough to the bottom" as "at the bottom" — a fractional
|
|
7
|
+
* scrollHeight/clientHeight rounding difference should not by itself stop the sticky-follow. */
|
|
8
|
+
export declare const AT_BOTTOM_THRESHOLD_PX = 4;
|
|
9
|
+
export declare function isScrolledToBottom(el: {
|
|
10
|
+
scrollHeight: number;
|
|
11
|
+
scrollTop: number;
|
|
12
|
+
clientHeight: number;
|
|
13
|
+
}): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Whether `next` starts a turn the user themself asked for, relative to `previous`: a user message
|
|
16
|
+
* that was not there before (a send — `useConversation.sendMessage` appends it optimistically), or
|
|
17
|
+
* an assistant message that newly entered `runStatus: 'queued'` (a retry, which resets the existing
|
|
18
|
+
* assistant message in place rather than appending one). Streamed chatter on an already-running
|
|
19
|
+
* turn is neither, so it never counts.
|
|
20
|
+
*/
|
|
21
|
+
export declare function startsNewTurn({ previous, next }: {
|
|
22
|
+
previous: readonly ChatMessage[];
|
|
23
|
+
next: readonly ChatMessage[];
|
|
24
|
+
}): boolean;
|
|
25
|
+
//# sourceMappingURL=MessageList.rules.d.ts.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Slack, in px, for treating "close enough to the bottom" as "at the bottom" — a fractional
|
|
2
|
+
* scrollHeight/clientHeight rounding difference should not by itself stop the sticky-follow. */
|
|
3
|
+
export const AT_BOTTOM_THRESHOLD_PX = 4;
|
|
4
|
+
export function isScrolledToBottom(el) {
|
|
5
|
+
return el.scrollHeight - el.scrollTop - el.clientHeight <= AT_BOTTOM_THRESHOLD_PX;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Whether `next` starts a turn the user themself asked for, relative to `previous`: a user message
|
|
9
|
+
* that was not there before (a send — `useConversation.sendMessage` appends it optimistically), or
|
|
10
|
+
* an assistant message that newly entered `runStatus: 'queued'` (a retry, which resets the existing
|
|
11
|
+
* assistant message in place rather than appending one). Streamed chatter on an already-running
|
|
12
|
+
* turn is neither, so it never counts.
|
|
13
|
+
*/
|
|
14
|
+
export function startsNewTurn({ previous, next }) {
|
|
15
|
+
const previousById = new Map(previous.map((message) => [message.id, message]));
|
|
16
|
+
return next.some((message) => {
|
|
17
|
+
const before = previousById.get(message.id);
|
|
18
|
+
if (message.role === 'user')
|
|
19
|
+
return before === undefined;
|
|
20
|
+
return message.runStatus === 'queued' && before?.runStatus !== 'queued';
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=MessageList.rules.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module components/mcp-ui-surface-state
|
|
3
|
+
*
|
|
4
|
+
* Whether an MCP-UI card in a chat transcript is closed, and with which label. Kept out of
|
|
5
|
+
* `McpUiSurfaceCard.tsx` so the component only renders; moved here from that file 2026-10-04 when
|
|
6
|
+
* the card's own deadline (`useSurfaceExpiry`) became a second reason to close it.
|
|
7
|
+
*/
|
|
8
|
+
import type { ExtEventCall } from '../ext-event-renderer-registry.js';
|
|
9
|
+
import type { useT } from '../hooks/context.js';
|
|
10
|
+
type Translate = ReturnType<typeof useT>;
|
|
11
|
+
/**
|
|
12
|
+
* Why a card can no longer be answered, or `undefined` while it still can: {@link settledLabel}'s
|
|
13
|
+
* answer first, so a card answered in time still reads "Answered", then the card's own deadline
|
|
14
|
+
* (`useSurfaceExpiry`). The deadline closes the card the moment it passes rather than when the
|
|
15
|
+
* server's expired result arrives, so a person never clicks a card nobody is waiting on.
|
|
16
|
+
*
|
|
17
|
+
* @complexity O(r) in the call result's length (one regex test).
|
|
18
|
+
*/
|
|
19
|
+
export declare function closedSurfaceLabel({ t, call, runStreaming, documentsForUri, expired }: {
|
|
20
|
+
t: Translate;
|
|
21
|
+
call: ExtEventCall | undefined;
|
|
22
|
+
runStreaming: boolean;
|
|
23
|
+
documentsForUri: number;
|
|
24
|
+
expired: boolean;
|
|
25
|
+
}): string | undefined;
|
|
26
|
+
export {};
|
|
27
|
+
//# sourceMappingURL=mcp-ui-surface-state.d.ts.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Words in a finished call's result that mean nobody answered in time. */
|
|
2
|
+
const UNANSWERED_RESULT = /\b(expired|abandoned|did not respond|run ended)\b/i;
|
|
3
|
+
/**
|
|
4
|
+
* Why a card can no longer be answered, or `undefined` while it still can.
|
|
5
|
+
*
|
|
6
|
+
* A held-open question (the tool shows the card and waits) is over once its tool call returns, and
|
|
7
|
+
* dead once its run ends without that return. Its first document stays on screen either way, so
|
|
8
|
+
* without this an expired, answered or orphaned question still looked answerable (host stuck-chat
|
|
9
|
+
* investigation, 2026-09-27). Only a card still showing that FIRST document closes: a tool that sent
|
|
10
|
+
* a follow-up document for the same `ui://` URI (an outcome after the answer) keeps showing it.
|
|
11
|
+
*
|
|
12
|
+
* The labels are read from the call's result text, so they are a best guess for a producer this
|
|
13
|
+
* package has not seen; the card is closed either way, which is the property that matters.
|
|
14
|
+
*/
|
|
15
|
+
function settledLabel(t, call, runStreaming, documentsForUri) {
|
|
16
|
+
if (call === undefined || documentsForUri > 1)
|
|
17
|
+
return undefined;
|
|
18
|
+
if (call.result === undefined)
|
|
19
|
+
return runStreaming ? undefined : t('This question expired');
|
|
20
|
+
return UNANSWERED_RESULT.test(call.result.content) ? t('This question expired') : t('Answered');
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Why a card can no longer be answered, or `undefined` while it still can: {@link settledLabel}'s
|
|
24
|
+
* answer first, so a card answered in time still reads "Answered", then the card's own deadline
|
|
25
|
+
* (`useSurfaceExpiry`). The deadline closes the card the moment it passes rather than when the
|
|
26
|
+
* server's expired result arrives, so a person never clicks a card nobody is waiting on.
|
|
27
|
+
*
|
|
28
|
+
* @complexity O(r) in the call result's length (one regex test).
|
|
29
|
+
*/
|
|
30
|
+
export function closedSurfaceLabel({ t, call, runStreaming, documentsForUri, expired }) {
|
|
31
|
+
return settledLabel(t, call, runStreaming, documentsForUri) ?? (expired ? t('This question expired') : undefined);
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=mcp-ui-surface-state.js.map
|
|
@@ -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,5 +1,5 @@
|
|
|
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
5
|
import { useComposer } from '../../../hooks/useComposer.js';
|
|
@@ -18,6 +18,20 @@ function createAttachmentBatchId() {
|
|
|
18
18
|
return globalThis.crypto?.randomUUID?.()
|
|
19
19
|
?? `chat-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Hands one typed answer to the host. A throw counts as `'failed'`: the host could not say whether
|
|
23
|
+
* anything was waiting, and the one outcome that must never follow is queueing the text as a new run.
|
|
24
|
+
*
|
|
25
|
+
* @complexity O(1) plus the host's own request.
|
|
26
|
+
*/
|
|
27
|
+
async function deliverTypedAnswerSafely({ deliver, text }) {
|
|
28
|
+
try {
|
|
29
|
+
return await deliver({ text });
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return 'failed';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
21
35
|
/** Module scope so each branch costs 1 cognitive point instead of 2 (nested one level inside
|
|
22
36
|
* `useChatPane` where it used to live as a ternary chain). */
|
|
23
37
|
function resolveChatPaneActivity(selectedAgent, conversation) {
|
|
@@ -61,6 +75,21 @@ export function useChatPane(options) {
|
|
|
61
75
|
const [activeUploadCount, setActiveUploadCount] = useState(0);
|
|
62
76
|
const [attachmentError, setAttachmentError] = useState(null);
|
|
63
77
|
const [queuedPrompt, setQueuedPrompt] = useState(null);
|
|
78
|
+
const [typedAnswerNotice, setTypedAnswerNotice] = useState(null);
|
|
79
|
+
// Set across the await of one typed-answer delivery so a second Enter cannot post the same text
|
|
80
|
+
// twice — the second post would find the question already answered and wrongly report it closed.
|
|
81
|
+
const deliveringTypedAnswerRef = useRef(false);
|
|
82
|
+
// The question (tool-use id) the current draft was typed for, or null. Set while a non-empty draft
|
|
83
|
+
// sits in the composer as a question waits, and kept after that question closes (answered
|
|
84
|
+
// elsewhere, expired, 409), so `send` cannot slip the draft into the queue as a new paid run.
|
|
85
|
+
// Released only by an empty draft, a delivery, `reset()`, or `sendAsNewMessage`.
|
|
86
|
+
const heldAnswerQuestionIdRef = useRef(null);
|
|
87
|
+
// The question the last delivery answered: typing more while its card is still drawn open (its
|
|
88
|
+
// tool_result has not streamed in yet) is a follow-up, not another answer to hold.
|
|
89
|
+
const answeredQuestionIdRef = useRef(null);
|
|
90
|
+
// Bumped by `reset()` and a conversation switch; a delivery that settles under a different value
|
|
91
|
+
// belongs to a composer that no longer exists and must neither clear the draft nor set a notice.
|
|
92
|
+
const typedAnswerGenerationRef = useRef(0);
|
|
64
93
|
// The conversation `queuedPrompt` was queued against. Compared with `options.conversationId` at
|
|
65
94
|
// flush time so a prompt queued behind a streaming run in one conversation can never be posted
|
|
66
95
|
// into a different one the caller switched to before that run finished.
|
|
@@ -150,6 +179,26 @@ export function useChatPane(options) {
|
|
|
150
179
|
...(options.apiModeConfigured === undefined ? {} : { apiModeConfigured: options.apiModeConfigured }),
|
|
151
180
|
});
|
|
152
181
|
const canSend = sendBlocker === null && composer.canSubmit;
|
|
182
|
+
// Read after `await` in `answerWaitingQuestion`, where the render-time `composer.draft` is stale.
|
|
183
|
+
const latestDraftRef = useRef(composer.draft);
|
|
184
|
+
latestDraftRef.current = composer.draft;
|
|
185
|
+
const awaitedQuestionId = options.deliverTypedAnswer === undefined
|
|
186
|
+
? null
|
|
187
|
+
: findAwaitedTypedAnswerId({ messages: conversation.messages, toolName: options.deliverTypedAnswer.toolName });
|
|
188
|
+
const hasDraft = composer.draft.trim() !== '';
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
if (!hasDraft) {
|
|
191
|
+
heldAnswerQuestionIdRef.current = null;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (awaitedQuestionId !== null && awaitedQuestionId !== answeredQuestionIdRef.current) {
|
|
195
|
+
heldAnswerQuestionIdRef.current = awaitedQuestionId;
|
|
196
|
+
}
|
|
197
|
+
}, [awaitedQuestionId, hasDraft]);
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
typedAnswerGenerationRef.current += 1;
|
|
200
|
+
heldAnswerQuestionIdRef.current = null;
|
|
201
|
+
}, [options.conversationId]);
|
|
153
202
|
const addAttachments = useCallback(async (files) => {
|
|
154
203
|
if (files.length === 0 || options.uploadAttachments === undefined)
|
|
155
204
|
return;
|
|
@@ -220,10 +269,30 @@ export function useChatPane(options) {
|
|
|
220
269
|
sendBlocker,
|
|
221
270
|
workingDirectoryState.workingDirectory,
|
|
222
271
|
]);
|
|
223
|
-
const
|
|
224
|
-
const
|
|
225
|
-
|
|
272
|
+
const answerWaitingQuestion = useCallback(async ({ deliver, text }) => {
|
|
273
|
+
const generation = typedAnswerGenerationRef.current;
|
|
274
|
+
const submittedDraft = latestDraftRef.current;
|
|
275
|
+
const questionId = awaitedQuestionId;
|
|
276
|
+
deliveringTypedAnswerRef.current = true;
|
|
277
|
+
const outcome = await deliverTypedAnswerSafely({ deliver, text });
|
|
278
|
+
deliveringTypedAnswerRef.current = false;
|
|
279
|
+
if (!mountedRef.current || generation !== typedAnswerGenerationRef.current)
|
|
226
280
|
return;
|
|
281
|
+
// Anything but a delivery keeps the draft and says why — never the queue below, whose flush
|
|
282
|
+
// would post the answer as a brand-new (paid) run once this one ends.
|
|
283
|
+
if (outcome !== 'delivered') {
|
|
284
|
+
setTypedAnswerNotice(outcome);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
answeredQuestionIdRef.current = questionId;
|
|
288
|
+
heldAnswerQuestionIdRef.current = null;
|
|
289
|
+
// The textarea stays editable during delivery: clear only the text that went out, never a
|
|
290
|
+
// correction or next message typed while the host answered.
|
|
291
|
+
if (latestDraftRef.current === submittedDraft)
|
|
292
|
+
composer.setDraft('');
|
|
293
|
+
}, [awaitedQuestionId, composer]);
|
|
294
|
+
// The ordinary next-turn path: queue behind a running run, else send now.
|
|
295
|
+
const sendOrdinaryTurn = useCallback(async (prompt) => {
|
|
227
296
|
// Queue instead of no-op'ing. `setDraft('')` and NOT `composer.reset()`: reset also discards
|
|
228
297
|
// staged attachments, which this turn still needs when it finally goes out.
|
|
229
298
|
if (isChatPaneQueueableBlocker({ blocker: sendBlocker })) {
|
|
@@ -236,6 +305,48 @@ export function useChatPane(options) {
|
|
|
236
305
|
return;
|
|
237
306
|
await sendPrompt(prompt);
|
|
238
307
|
}, [canSend, composer, options.conversationId, sendBlocker, sendPrompt]);
|
|
308
|
+
const send = useCallback(async () => {
|
|
309
|
+
const prompt = composerPrompt(composer);
|
|
310
|
+
if (!prompt || deliveringTypedAnswerRef.current)
|
|
311
|
+
return;
|
|
312
|
+
setTypedAnswerNotice(null);
|
|
313
|
+
const deliver = options.deliverTypedAnswer;
|
|
314
|
+
if (deliver !== undefined && isTypedAnswerTurn({
|
|
315
|
+
blocker: sendBlocker,
|
|
316
|
+
attachmentCount: composer.attachments.length,
|
|
317
|
+
messages: conversation.messages,
|
|
318
|
+
toolName: deliver.toolName,
|
|
319
|
+
})) {
|
|
320
|
+
await answerWaitingQuestion({ deliver, text: prompt });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
// Typed for a question that has since closed: the human meant an answer, so going on to the
|
|
324
|
+
// queue would start a paid run they never asked for. Attachments mark a new turn (a typed answer
|
|
325
|
+
// carries text only), the same rule `isTypedAnswerTurn` applies.
|
|
326
|
+
if (heldAnswerQuestionIdRef.current !== null && composer.attachments.length === 0) {
|
|
327
|
+
setTypedAnswerNotice('not-pending');
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
await sendOrdinaryTurn(prompt);
|
|
331
|
+
}, [
|
|
332
|
+
answerWaitingQuestion,
|
|
333
|
+
composer,
|
|
334
|
+
conversation.messages,
|
|
335
|
+
options.deliverTypedAnswer,
|
|
336
|
+
sendBlocker,
|
|
337
|
+
sendOrdinaryTurn,
|
|
338
|
+
]);
|
|
339
|
+
const sendAsNewMessage = useCallback(async () => {
|
|
340
|
+
const prompt = composerPrompt(composer);
|
|
341
|
+
if (!prompt || deliveringTypedAnswerRef.current)
|
|
342
|
+
return;
|
|
343
|
+
setTypedAnswerNotice(null);
|
|
344
|
+
// Mark the question answered too, so the still-non-empty draft is not re-held by the effect
|
|
345
|
+
// while that question's card is drawn open.
|
|
346
|
+
answeredQuestionIdRef.current = heldAnswerQuestionIdRef.current ?? answeredQuestionIdRef.current;
|
|
347
|
+
heldAnswerQuestionIdRef.current = null;
|
|
348
|
+
await sendOrdinaryTurn(prompt);
|
|
349
|
+
}, [composer, sendOrdinaryTurn]);
|
|
239
350
|
const interruptSend = useCallback(() => {
|
|
240
351
|
const prompt = composerPrompt(composer);
|
|
241
352
|
if (!prompt)
|
|
@@ -277,6 +388,9 @@ export function useChatPane(options) {
|
|
|
277
388
|
conversation.setMessages(options.initialMessages ?? []);
|
|
278
389
|
composer.reset();
|
|
279
390
|
setAttachmentError(null);
|
|
391
|
+
setTypedAnswerNotice(null);
|
|
392
|
+
typedAnswerGenerationRef.current += 1;
|
|
393
|
+
heldAnswerQuestionIdRef.current = null;
|
|
280
394
|
// Without this, a prompt queued behind a streaming run survives `reset()` and — once
|
|
281
395
|
// `conversation.cancel()` above clears the streaming blocker — the flush effect fires it into
|
|
282
396
|
// the just-reset conversation instead of discarding it like the rest of this turn's state.
|
|
@@ -301,6 +415,8 @@ export function useChatPane(options) {
|
|
|
301
415
|
sendPrompt,
|
|
302
416
|
queuedPrompt,
|
|
303
417
|
cancelQueued,
|
|
418
|
+
typedAnswerNotice,
|
|
419
|
+
sendAsNewMessage,
|
|
304
420
|
interruptSend,
|
|
305
421
|
reset,
|
|
306
422
|
};
|
|
@@ -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';
|