@jini-ai/chat 0.6.12 → 0.6.15-rc.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 +30 -0
- package/dist/contracts/chat-bubble-style.d.ts +3 -2
- package/dist/contracts/chat-bubble-style.js +3 -2
- package/dist/core/accepted-input.d.ts +20 -1
- package/dist/core/accepted-input.js +73 -19
- package/dist/core/ag-ui/entry.d.ts +2 -4
- package/dist/core/ag-ui/entry.js +11 -5
- package/dist/core/assistant-client/ag-ui-transport.js +6 -1
- package/dist/core/assistant-client/durable-run-subscription.d.ts +2 -1
- package/dist/core/assistant-client/durable-run-subscription.js +3 -1
- package/dist/core/assistant-client/transport.js +5 -1
- package/dist/core/assistant-content.d.ts +8 -1
- package/dist/core/assistant-content.js +17 -4
- package/dist/core/durable-runs/continuation.js +6 -2
- package/dist/core/events.d.ts +3 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/question-answer.d.ts +8 -0
- package/dist/core/question-answer.js +24 -7
- package/dist/core/run-activity.d.ts +1 -1
- package/dist/core/run-activity.js +46 -4
- package/dist/core/run-events/entry.js +5 -2
- package/dist/core/turn-usage.js +5 -2
- package/dist/embed-widget/vanilla/style.css +1 -1
- package/dist/react/components/CopyMessageButton.d.ts +18 -0
- package/dist/react/components/CopyMessageButton.js +22 -0
- package/dist/react/components/Markdown.js +24 -5
- package/dist/react/components/McpUiSurfaceCard.js +50 -17
- package/dist/react/components/MessageList.hooks.d.ts +2 -1
- package/dist/react/components/MessageList.hooks.js +14 -4
- package/dist/react/components/MessageList.js +5 -3
- package/dist/react/components/MessageRow.d.ts +3 -1
- package/dist/react/components/MessageRow.js +66 -38
- package/dist/react/components/MessageRow.usage.js +4 -1
- package/dist/react/components/MidRunUserMessage.d.ts +1 -1
- package/dist/react/components/MidRunUserMessage.js +11 -7
- package/dist/react/components/QuestionForm.js +22 -2
- package/dist/react/components/ToolCard.js +21 -8
- package/dist/react/components/mcp-ui-question-input.d.ts +20 -0
- package/dist/react/components/mcp-ui-question-input.js +72 -0
- package/dist/react/ext-event-renderer-registry.d.ts +2 -0
- package/dist/react/features/chat-pane/components/ChatPane.js +10 -8
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +6 -0
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +78 -20
- package/dist/react/features/chat-pane/hooks/useChatPaneAgentControl.hooks.js +16 -20
- package/dist/react/hooks/useAnsweredCards.js +36 -20
- package/dist/react/hooks/useConversation.d.ts +6 -0
- package/dist/react/hooks/useConversation.js +10 -3
- package/dist/react/index.d.ts +7 -0
- package/dist/react/index.js +4 -0
- package/dist/react/message-blocks.d.ts +6 -0
- package/dist/react/message-blocks.js +27 -1
- package/dist/react/tool-call-display.js +1 -2
- package/dist/server/durable-runs/accept.d.ts +4 -0
- package/dist/server/durable-runs/accept.js +8 -1
- package/dist/server/durable-runs/recover.js +5 -4
- package/package.json +15 -15
|
@@ -82,6 +82,12 @@ export interface UseChatPaneResult extends UseChatPaneWorkingDirectoryResult {
|
|
|
82
82
|
* silent no-op.
|
|
83
83
|
*/
|
|
84
84
|
sendPrompt: (prompt: string) => Promise<void>;
|
|
85
|
+
/**
|
|
86
|
+
* Sends the newest refused composer send again (see `conversation.sendError`). When that text is
|
|
87
|
+
* back in the composer it goes out from there like {@link send}; when something new was typed
|
|
88
|
+
* since, the refused text goes out on its own and the composer keeps the new typing.
|
|
89
|
+
*/
|
|
90
|
+
retrySend: () => Promise<void>;
|
|
85
91
|
/**
|
|
86
92
|
* The prompt waiting for the in-flight run to finish, or `null` when nothing is queued. Exposed
|
|
87
93
|
* so the pane can SHOW it — a queued turn that is invisible is indistinguishable from one that
|
|
@@ -20,15 +20,18 @@ function unconsumedQueuedInputs({ inputs, messages }) {
|
|
|
20
20
|
const receipt = acceptedInputEvent({ event });
|
|
21
21
|
if (receipt?.data.deliveryState !== 'consumed')
|
|
22
22
|
continue;
|
|
23
|
-
|
|
24
|
-
consumed.add(input.id);
|
|
25
|
-
for (const alias of input.aliases ?? [])
|
|
26
|
-
consumed.add(alias);
|
|
27
|
-
}
|
|
23
|
+
recordConsumedInputs({ receipt: receipt.data, consumed });
|
|
28
24
|
}
|
|
29
25
|
}
|
|
30
26
|
return inputs.filter(input => !consumed.has(input.liveMessageId ?? input.id));
|
|
31
27
|
}
|
|
28
|
+
function recordConsumedInputs({ receipt, consumed }) {
|
|
29
|
+
for (const input of [receipt, ...(receipt.inputs ?? [])]) {
|
|
30
|
+
consumed.add(input.id);
|
|
31
|
+
for (const alias of input.aliases ?? [])
|
|
32
|
+
consumed.add(alias);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
32
35
|
/** The conversation copies run events onto its messages one effect after streaming stops, so a
|
|
33
36
|
* consumed receipt can still be missing from the transcript when the send blocker clears. A
|
|
34
37
|
* live-accepted copy therefore waits until the run's own message reaches a terminal status. */
|
|
@@ -192,6 +195,7 @@ export function useChatPane(options) {
|
|
|
192
195
|
const queuedForLiveDeliveryRef = useRef(false);
|
|
193
196
|
const submittedComposerRef = useRef(null);
|
|
194
197
|
const startingPromptRef = useRef(false);
|
|
198
|
+
const refusedSendRef = useRef(null);
|
|
195
199
|
const [typedAnswerNotice, setTypedAnswerNotice] = useState(null);
|
|
196
200
|
// Shared delivery lock: a second Enter cannot submit the same answer or screenshot twice.
|
|
197
201
|
const deliveringTypedAnswerRef = useRef(false);
|
|
@@ -414,11 +418,14 @@ export function useChatPane(options) {
|
|
|
414
418
|
const last = messages.at(-1);
|
|
415
419
|
if (last?.role !== 'assistant')
|
|
416
420
|
return messages;
|
|
421
|
+
const { atEventCount, ...eventBubble } = bubble;
|
|
417
422
|
const event = acceptedInputUserEvent({ input: { id: inputs[0].id, text: bubble.text,
|
|
418
423
|
...(inputs[0].deliveryState ? { deliveryState: inputs[0].deliveryState } : {}) },
|
|
419
|
-
inputs, ...
|
|
424
|
+
inputs, ...eventBubble }, { receipt });
|
|
425
|
+
const events = last.events ?? [];
|
|
426
|
+
const position = atEventCount ?? events.length;
|
|
420
427
|
return [...messages.slice(0, -1), { ...last,
|
|
421
|
-
events: projectAcceptedInputEvents({ events: [...(
|
|
428
|
+
events: projectAcceptedInputEvents({ events: [...events.slice(0, position), event, ...events.slice(position)] }) }];
|
|
422
429
|
});
|
|
423
430
|
}, [composer, conversation.setMessages]);
|
|
424
431
|
/** Both accepted send paths consume the sent files and rotate the same upload batch. */
|
|
@@ -441,18 +448,35 @@ export function useChatPane(options) {
|
|
|
441
448
|
latestComposerRef.current.addAttachment(attachment);
|
|
442
449
|
}
|
|
443
450
|
}, []);
|
|
444
|
-
|
|
451
|
+
/** A refused composer send is kept for Retry and goes back only into a composer that is still
|
|
452
|
+
* empty: text typed (or files staged) while it was sending are never overwritten. */
|
|
453
|
+
const restoreRefusedDraft = useCallback(({ redaction, draft, attachments }, _options = {}) => {
|
|
454
|
+
refusedSendRef.current = { redaction, draft, attachments };
|
|
455
|
+
if (latestDraftRef.current.trim() !== '' || latestAttachmentsRef.current.length > 0)
|
|
456
|
+
return;
|
|
457
|
+
latestComposerRef.current.setDraft(draft);
|
|
458
|
+
for (const attachment of attachments)
|
|
459
|
+
latestComposerRef.current.addAttachment(attachment);
|
|
460
|
+
}, []);
|
|
461
|
+
const finishPromptSend = useCallback(({ accepted, inputs, redaction, submittedDraft, attachments, composerDraft }, _options = {}) => {
|
|
445
462
|
if (!accepted) {
|
|
446
|
-
|
|
463
|
+
if (composerDraft === undefined)
|
|
464
|
+
restoreQueuedDraft({ inputs, text: redaction.text, attachments });
|
|
465
|
+
else
|
|
466
|
+
restoreRefusedDraft({ redaction, draft: composerDraft, attachments });
|
|
447
467
|
return;
|
|
448
468
|
}
|
|
449
|
-
|
|
469
|
+
refusedSendRef.current = null;
|
|
470
|
+
// A composer send emptied the composer when it started; what is there now was typed since.
|
|
471
|
+
if (composerDraft === undefined && inputs === undefined && latestDraftRef.current === submittedDraft) {
|
|
450
472
|
latestComposerRef.current.setDraft('');
|
|
451
473
|
latestComposerRef.current.closeMention();
|
|
452
474
|
}
|
|
453
475
|
consumeDeliveredAttachments({ attachments });
|
|
454
|
-
}, [consumeDeliveredAttachments, restoreQueuedDraft]);
|
|
455
|
-
|
|
476
|
+
}, [consumeDeliveredAttachments, restoreQueuedDraft, restoreRefusedDraft]);
|
|
477
|
+
/** `composerDraft`: the composer text this send already took out of the composer, so the
|
|
478
|
+
* composer is never cleared again on acceptance and a refusal hands the text back. */
|
|
479
|
+
const sendGuardedPrompt = useCallback(async ({ redaction, inputs, attachments = composer.attachments, composerDraft }, _options = {}) => {
|
|
456
480
|
const trimmed = redaction.text.trim();
|
|
457
481
|
assertPromptSendable({ trimmed, sendBlocker });
|
|
458
482
|
if (startingPromptRef.current)
|
|
@@ -480,7 +504,7 @@ export function useChatPane(options) {
|
|
|
480
504
|
} }));
|
|
481
505
|
if (!mountedRef.current || generation !== typedAnswerGenerationRef.current)
|
|
482
506
|
return;
|
|
483
|
-
finishPromptSend({ accepted, inputs, redaction, submittedDraft, attachments });
|
|
507
|
+
finishPromptSend({ accepted, inputs, redaction, submittedDraft, attachments, composerDraft });
|
|
484
508
|
}
|
|
485
509
|
finally {
|
|
486
510
|
startingPromptRef.current = false;
|
|
@@ -490,6 +514,27 @@ export function useChatPane(options) {
|
|
|
490
514
|
const sendPrompt = useCallback(async (prompt) => {
|
|
491
515
|
await sendGuardedPrompt({ redaction: guard.redact({ text: prompt.trim() }, {}) });
|
|
492
516
|
}, [guard.redact, sendGuardedPrompt]);
|
|
517
|
+
// Owner, 2026-10-27: the sent text sat in the composer for seconds until the server accepted the
|
|
518
|
+
// run. The composer empties on send; the text waits here until the start is accepted or refused.
|
|
519
|
+
const sendComposerPrompt = useCallback(async (prompt) => {
|
|
520
|
+
if (sendBlocker !== null || startingPromptRef.current)
|
|
521
|
+
return;
|
|
522
|
+
const draft = latestDraftRef.current;
|
|
523
|
+
const attachments = latestAttachmentsRef.current;
|
|
524
|
+
latestComposerRef.current.setDraft('');
|
|
525
|
+
latestComposerRef.current.closeMention();
|
|
526
|
+
for (const attachment of attachments)
|
|
527
|
+
latestComposerRef.current.removeAttachment(attachment.path);
|
|
528
|
+
const redaction = guard.redact({ text: prompt.trim() }, {});
|
|
529
|
+
try {
|
|
530
|
+
await sendGuardedPrompt({ redaction, attachments, composerDraft: draft });
|
|
531
|
+
}
|
|
532
|
+
catch (error) {
|
|
533
|
+
// A host hook that throws (run context, activity) must not cost the typed text either.
|
|
534
|
+
restoreRefusedDraft({ redaction, draft, attachments });
|
|
535
|
+
throw error;
|
|
536
|
+
}
|
|
537
|
+
}, [guard.redact, restoreRefusedDraft, sendBlocker, sendGuardedPrompt]);
|
|
493
538
|
// MOVE: Every queue path retains per-message receipts; appendQueuedTurn owns their one-turn delivery.
|
|
494
539
|
// A turn queued against another conversation is not joined — it would never be flushed there anyway.
|
|
495
540
|
const enqueueTurn = useCallback(({ turn, inputs, attachments = composer.attachments }, _options = {}) => {
|
|
@@ -539,7 +584,7 @@ export function useChatPane(options) {
|
|
|
539
584
|
latestComposerRef.current.setDraft('');
|
|
540
585
|
}, []);
|
|
541
586
|
/** HTTP acceptance retains fallback ownership; only an actual consumption receipt removes it. */
|
|
542
|
-
const retainAcceptedMidRunInput = useCallback(({ outcome, inputs, turn, attachments, afterInputId }, _options = {}) => {
|
|
587
|
+
const retainAcceptedMidRunInput = useCallback(({ outcome, inputs, turn, attachments, afterInputId, atEventCount }, _options = {}) => {
|
|
543
588
|
const accepted = inputs ?? [{ id: createChatLocalId({}), text: turn.text, attachments,
|
|
544
589
|
...(turn.secretRedacted ? { secretRedaction: { secretRedacted: true, count: turn.count } } : {}) }];
|
|
545
590
|
const id = typeof outcome === 'object' ? outcome.id : accepted[0].id;
|
|
@@ -552,16 +597,16 @@ export function useChatPane(options) {
|
|
|
552
597
|
queuedConversationIdRef.current = options.conversationId;
|
|
553
598
|
setQueuedInputs(queued => [...queued, ...retained]);
|
|
554
599
|
recordAcceptedInput({ inputs: retained,
|
|
555
|
-
bubble: { text: turn.text, ...(afterInputId === undefined ? {} : { afterInputId }) } }, typeof outcome === 'object' ? { receipt: 'accepted' } : {});
|
|
600
|
+
bubble: { text: turn.text, atEventCount, ...(afterInputId === undefined ? {} : { afterInputId }) } }, typeof outcome === 'object' ? { receipt: 'accepted' } : {});
|
|
556
601
|
}, [options.conversationId, recordAcceptedInput]);
|
|
557
|
-
const finishMidRunSend = useCallback(({ outcome, inputs, turn, attachments, afterInputId, submittedDraft }, _options = {}) => {
|
|
602
|
+
const finishMidRunSend = useCallback(({ outcome, inputs, turn, attachments, afterInputId, submittedDraft, atEventCount }, _options = {}) => {
|
|
558
603
|
if (outcome === 'failed') {
|
|
559
604
|
restoreQueuedDraft({ inputs, text: turn.text, attachments });
|
|
560
605
|
setTypedAnswerNotice('failed');
|
|
561
606
|
return;
|
|
562
607
|
}
|
|
563
608
|
if (outcome === 'delivered' || typeof outcome === 'object') {
|
|
564
|
-
retainAcceptedMidRunInput({ outcome, inputs, turn, attachments, afterInputId });
|
|
609
|
+
retainAcceptedMidRunInput({ outcome, inputs, turn, attachments, afterInputId, atEventCount });
|
|
565
610
|
clearDeliveredDraft({ inputs, submittedDraft });
|
|
566
611
|
consumeDeliveredAttachments({ attachments });
|
|
567
612
|
return;
|
|
@@ -586,13 +631,14 @@ export function useChatPane(options) {
|
|
|
586
631
|
deliveringTypedAnswerRef.current = false;
|
|
587
632
|
if (!mountedRef.current || generation !== typedAnswerGenerationRef.current)
|
|
588
633
|
return;
|
|
589
|
-
|
|
634
|
+
// The reply may stream before HTTP 202 resolves. Anchor the bubble to send time, not response time.
|
|
635
|
+
finishMidRunSend({ outcome, inputs, turn, attachments, afterInputId, submittedDraft, atEventCount: priorEvents.length });
|
|
590
636
|
}, [composer, conversation, finishMidRunSend]);
|
|
591
637
|
// The ordinary next-turn path: into the running run, else queue behind it, else send now.
|
|
592
638
|
const sendOrdinaryTurn = useCallback(async (prompt) => {
|
|
593
639
|
if (!isChatPaneQueueableBlocker({ blocker: sendBlocker })) {
|
|
594
640
|
if (canSend)
|
|
595
|
-
await
|
|
641
|
+
await sendComposerPrompt(prompt);
|
|
596
642
|
return;
|
|
597
643
|
}
|
|
598
644
|
const turn = guard.redact({ text: prompt }, {});
|
|
@@ -626,7 +672,7 @@ export function useChatPane(options) {
|
|
|
626
672
|
queuedInputs,
|
|
627
673
|
sendBlocker,
|
|
628
674
|
sendIntoRunningTurn,
|
|
629
|
-
|
|
675
|
+
sendComposerPrompt,
|
|
630
676
|
guard.redact,
|
|
631
677
|
]);
|
|
632
678
|
const send = useCallback(async () => {
|
|
@@ -660,6 +706,16 @@ export function useChatPane(options) {
|
|
|
660
706
|
sendBlocker,
|
|
661
707
|
sendOrdinaryTurn,
|
|
662
708
|
]);
|
|
709
|
+
const retrySend = useCallback(async () => {
|
|
710
|
+
const refused = refusedSendRef.current;
|
|
711
|
+
if (refused === null || latestDraftRef.current === refused.draft) {
|
|
712
|
+
await send();
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
if (sendBlocker !== null || startingPromptRef.current)
|
|
716
|
+
return;
|
|
717
|
+
await sendGuardedPrompt({ redaction: refused.redaction, attachments: refused.attachments, composerDraft: refused.draft });
|
|
718
|
+
}, [send, sendBlocker, sendGuardedPrompt]);
|
|
663
719
|
const sendAsNewMessage = useCallback(async () => {
|
|
664
720
|
const prompt = composerPrompt(composer);
|
|
665
721
|
if (!prompt || composerDeliveryBlocked({ delivering: deliveringTypedAnswerRef.current, starting: startingPromptRef.current, composer, submitted: submittedComposerRef.current }))
|
|
@@ -752,6 +808,7 @@ export function useChatPane(options) {
|
|
|
752
808
|
queuedForLiveDeliveryRef.current = false;
|
|
753
809
|
queuedUploadOwnersRef.current.clear();
|
|
754
810
|
setQueuedInputs([]);
|
|
811
|
+
refusedSendRef.current = null;
|
|
755
812
|
}, [composer, conversation, options.initialMessages]);
|
|
756
813
|
return {
|
|
757
814
|
secretRedacted: guard.secretRedacted,
|
|
@@ -770,6 +827,7 @@ export function useChatPane(options) {
|
|
|
770
827
|
addAttachments,
|
|
771
828
|
send,
|
|
772
829
|
sendPrompt,
|
|
830
|
+
retrySend,
|
|
773
831
|
queuedPrompt: queuedTurn?.text ?? null,
|
|
774
832
|
// Accepted live messages already have queued bubbles in the transcript. Their private fallback
|
|
775
833
|
// copy must not create a second composer row whose Cancel button cannot revoke daemon input.
|
|
@@ -1,18 +1,7 @@
|
|
|
1
1
|
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { getAgentModelContext } from '@jini-ai/agentic/dom';
|
|
2
3
|
import { CHAT_PANE_AGENT_TOOLS } from '../agent-tools.js';
|
|
3
4
|
import { definedProps } from '../../../util/defined-props.js';
|
|
4
|
-
function getModelContext() {
|
|
5
|
-
const doc = globalThis.document;
|
|
6
|
-
if (isModelContext(doc?.modelContext))
|
|
7
|
-
return doc.modelContext;
|
|
8
|
-
const nav = globalThis.navigator;
|
|
9
|
-
if (isModelContext(nav?.modelContext))
|
|
10
|
-
return nav.modelContext;
|
|
11
|
-
return undefined;
|
|
12
|
-
}
|
|
13
|
-
function isModelContext(value) {
|
|
14
|
-
return typeof value === 'object' && value !== null && typeof value.registerTool === 'function';
|
|
15
|
-
}
|
|
16
5
|
function requireStringField(input, key) {
|
|
17
6
|
const value = input[key];
|
|
18
7
|
if (typeof value !== 'string' || value.length === 0)
|
|
@@ -206,26 +195,33 @@ export function useChatPaneAgentControl({ pane }, options = {}) {
|
|
|
206
195
|
const cleanups = [];
|
|
207
196
|
// Not registered unless the host opted in: see `webmcp`'s own doc for why this path cannot be
|
|
208
197
|
// gated and therefore must not be on by default.
|
|
209
|
-
|
|
198
|
+
// Feature-detect the draft surface through its shared owner; no `@mcp-b/*` dependency.
|
|
199
|
+
const modelContext = options.webmcp === true ? getAgentModelContext({
|
|
200
|
+
host: { candidates: () => [
|
|
201
|
+
globalThis.document?.modelContext,
|
|
202
|
+
globalThis.navigator?.modelContext,
|
|
203
|
+
] },
|
|
204
|
+
}) : undefined;
|
|
210
205
|
if (modelContext) {
|
|
211
206
|
const controller = new AbortController();
|
|
212
207
|
for (const tool of CHAT_PANE_AGENT_TOOLS) {
|
|
213
208
|
// `registerTool` is async per the draft IDL, and a polyfill may return undefined instead.
|
|
214
209
|
// Normalize both, then swallow: registration is best-effort, and a rejected one must not
|
|
215
210
|
// surface as an unhandled rejection from a React effect.
|
|
216
|
-
const registered = modelContext.registerTool({
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
211
|
+
const registered = modelContext.registerTool({ tool: {
|
|
212
|
+
name: tool.id,
|
|
213
|
+
description: tool.description,
|
|
214
|
+
inputSchema: tool.inputSchema,
|
|
215
|
+
execute: (args) => runChatPaneAction(paneRef.current, tool.id, args ?? {}),
|
|
216
|
+
} }, { signal: controller.signal });
|
|
222
217
|
void Promise.resolve(registered).catch(() => undefined);
|
|
223
218
|
}
|
|
224
219
|
cleanups.push(() => {
|
|
220
|
+
// Aborting the signal is the spec cleanup path; unregisterTool is optional for polyfills.
|
|
225
221
|
controller.abort();
|
|
226
222
|
if (modelContext.unregisterTool) {
|
|
227
223
|
for (const tool of CHAT_PANE_AGENT_TOOLS)
|
|
228
|
-
modelContext.unregisterTool(tool.id);
|
|
224
|
+
modelContext.unregisterTool({ name: tool.id });
|
|
229
225
|
}
|
|
230
226
|
});
|
|
231
227
|
}
|
|
@@ -8,7 +8,28 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { useCallback, useMemo, useState } from 'react';
|
|
10
10
|
import { isTerminalRunStatus } from '../../core/messages.js';
|
|
11
|
-
import { acceptedInputEvent } from '../../core/accepted-input.js';
|
|
11
|
+
import { acceptedInputEvent, recordedSurfaceAnswers } from '../../core/accepted-input.js';
|
|
12
|
+
/** Index durable receipts by their existing tool or standalone resource identity.
|
|
13
|
+
* @complexity O(e + g) time/space for events and groups; no mutation of caller data or I/O.
|
|
14
|
+
*/
|
|
15
|
+
function recordedCardAnswers({ events, groups }, _options = {}) {
|
|
16
|
+
const receipts = new Map();
|
|
17
|
+
const surfaces = recordedSurfaceAnswers({ events });
|
|
18
|
+
for (const event of events) {
|
|
19
|
+
const receipt = acceptedInputEvent({ event });
|
|
20
|
+
if (receipt?.data.toolUseId)
|
|
21
|
+
receipts.set(receipt.data.toolUseId, receipt.data.text);
|
|
22
|
+
}
|
|
23
|
+
return new Map(groups.flatMap(group => {
|
|
24
|
+
const latest = group.events.at(-1);
|
|
25
|
+
const receipt = group.call?.id === undefined ? undefined : receipts.get(group.call.id);
|
|
26
|
+
// Grouping binds the call to its FIRST document. Even a late result cannot answer its replacement.
|
|
27
|
+
// Tool receipts keep their optimistic bubble's position, so projected order is not causal.
|
|
28
|
+
const originalDocument = group.name !== 'mcp-ui' || group.events.length === 1;
|
|
29
|
+
const text = surfaces.get(latest) ?? (originalDocument ? receipt : undefined);
|
|
30
|
+
return text === undefined ? [] : [[group.slot, text]];
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
12
33
|
/**
|
|
13
34
|
* @param slotOf - Maps an ext event to its render slot (`extEventSlot`).
|
|
14
35
|
* @param events - Accepted-input-projected events, including persisted or optimistic answer receipts.
|
|
@@ -20,27 +41,22 @@ import { acceptedInputEvent } from '../../core/accepted-input.js';
|
|
|
20
41
|
*/
|
|
21
42
|
export function useAnsweredCards({ slotOf, events = [], groups = [], runStreaming, runStatus }) {
|
|
22
43
|
const [answered, setAnswered] = useState(() => new Map());
|
|
44
|
+
// MCP-UI replaces documents in one slot; other protocols retain their stream-wide answer state.
|
|
45
|
+
const documents = useMemo(() => new Map(groups.map(group => [group.slot,
|
|
46
|
+
group.name === 'mcp-ui' ? group.events.at(-1) : group.slot])), [groups]);
|
|
23
47
|
// MOVE: Match by tool identity so receipt replay hides only its own card, even for equal answers.
|
|
24
|
-
const recorded = useMemo(() => {
|
|
25
|
-
const receipts = new Map();
|
|
26
|
-
for (const event of events) {
|
|
27
|
-
const receipt = acceptedInputEvent({ event });
|
|
28
|
-
if (receipt?.data.toolUseId)
|
|
29
|
-
receipts.set(receipt.data.toolUseId, receipt.data.text);
|
|
30
|
-
}
|
|
31
|
-
const slots = new Map();
|
|
32
|
-
for (const group of groups) {
|
|
33
|
-
const text = group.call?.id === undefined ? undefined : receipts.get(group.call.id);
|
|
34
|
-
if (text !== undefined)
|
|
35
|
-
slots.set(group.slot, text);
|
|
36
|
-
}
|
|
37
|
-
return slots;
|
|
38
|
-
}, [events, groups]);
|
|
48
|
+
const recorded = useMemo(() => recordedCardAnswers({ events, groups }), [events, groups]);
|
|
39
49
|
const markAnswered = useCallback((slot, answerText) => {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
50
|
+
const document = documents.get(slot);
|
|
51
|
+
setAnswered(current => current.get(slot)?.document === document && current.has(slot)
|
|
52
|
+
? current : new Map(current).set(slot, { document, text: answerText }));
|
|
53
|
+
}, [documents]);
|
|
54
|
+
const liveAnswer = useCallback((slot) => {
|
|
55
|
+
const answer = answered.get(slot);
|
|
56
|
+
return answer?.document === documents.get(slot) ? answer : undefined;
|
|
57
|
+
}, [answered, documents]);
|
|
58
|
+
const isAnswered = useCallback((slot) => recorded.has(slot) || liveAnswer(slot) !== undefined, [liveAnswer, recorded]);
|
|
59
|
+
const answerText = useCallback((slot) => recorded.get(slot) ?? liveAnswer(slot)?.text, [liveAnswer, recorded]);
|
|
44
60
|
const isRecorded = useCallback((slot) => recorded.has(slot), [recorded]);
|
|
45
61
|
const isAnsweredCard = useCallback((card) => isAnswered(slotOf(card)), [isAnswered, slotOf]);
|
|
46
62
|
// MOVE: Finished runs must keep orphaned questions closed even when the host still reports streaming.
|
|
@@ -36,6 +36,12 @@ export interface UseConversationResult {
|
|
|
36
36
|
conversationId: string | null;
|
|
37
37
|
isStreaming: boolean;
|
|
38
38
|
error: Error | null;
|
|
39
|
+
/**
|
|
40
|
+
* Why the newest send never became a run (refused, unreachable, or the connection dropped before
|
|
41
|
+
* acceptance), or `null`. Its optimistic bubbles are already gone, so a host shows this beside the
|
|
42
|
+
* composer and offers to send the refused text again. Cleared by the next send.
|
|
43
|
+
*/
|
|
44
|
+
sendError: Error | null;
|
|
39
45
|
/** `true` when new content just arrived and nothing has told the hook the user already scrolled away — a `<MessageList>` reads this to decide whether to auto-scroll. */
|
|
40
46
|
scrollIntent: boolean;
|
|
41
47
|
sendMessage: (content: string, options?: SendMessageOptions) => Promise<void>;
|
|
@@ -39,6 +39,7 @@ export function useConversation(options) {
|
|
|
39
39
|
const { transport, conversationId = null, agentId, createMessageId = defaultCreateMessageId } = options;
|
|
40
40
|
const [messages, setMessagesState] = useState(() => redactUserHistory({ history: options.initialMessages ?? [] }, { ...(options.redactUserText ? { redactUserText: options.redactUserText } : {}) }));
|
|
41
41
|
const [scrollIntent, setScrollIntent] = useState(false);
|
|
42
|
+
const [sendRefused, setSendRefused] = useState(false);
|
|
42
43
|
const run = useRunStream({ transport });
|
|
43
44
|
// The assistant message id the currently-active run is writing into.
|
|
44
45
|
const activeAssistantIdRef = useRef(null);
|
|
@@ -159,12 +160,13 @@ export function useConversation(options) {
|
|
|
159
160
|
else if (run.status === 'canceled')
|
|
160
161
|
runStatus = 'canceled';
|
|
161
162
|
const nextRunId = run.runId ?? m.runId;
|
|
163
|
+
const events = projectAcceptedInputEvents({ events: mergeRunEvents({ saved: m.events, incoming: run.events }, {}) });
|
|
162
164
|
return {
|
|
163
165
|
...m,
|
|
164
166
|
...(nextRunId !== undefined ? { runId: nextRunId } : {}),
|
|
165
|
-
events
|
|
167
|
+
events,
|
|
166
168
|
// Replayed completion prefixes cannot erase a saved interrupted segment.
|
|
167
|
-
content: preserveAssistantContent({ saved: m.content, incoming: assistantContentFromEvents({ events
|
|
169
|
+
content: preserveAssistantContent({ saved: m.content, incoming: assistantContentFromEvents({ events }) }, {}),
|
|
168
170
|
runStatus,
|
|
169
171
|
...(isTerminalRunStatus({ status: runStatus }) ? { endedAt: Date.now() } : {}),
|
|
170
172
|
};
|
|
@@ -194,6 +196,7 @@ export function useConversation(options) {
|
|
|
194
196
|
startedAt: Date.now(),
|
|
195
197
|
};
|
|
196
198
|
activeAssistantIdRef.current = assistantMessage.id;
|
|
199
|
+
setSendRefused(false);
|
|
197
200
|
const history = redactUserHistory({ history: [...messagesRef.current, userMessage] }, options);
|
|
198
201
|
// Functional, never `[...history, assistantMessage]`: `messagesRef` is the last RENDER's
|
|
199
202
|
// snapshot, and a host that sends from an effect (`useChatPane`'s queued-prompt flush) runs in
|
|
@@ -212,7 +215,9 @@ export function useConversation(options) {
|
|
|
212
215
|
...(sendOptions.context !== undefined ? { context: sendOptions.context } : {}),
|
|
213
216
|
});
|
|
214
217
|
if (!accepted) {
|
|
218
|
+
// The bubbles go, but the reason must not: it was rendered only on the assistant bubble.
|
|
215
219
|
setMessagesState(prev => prev.filter(message => message.id !== userMessage.id && message.id !== assistantMessage.id));
|
|
220
|
+
setSendRefused(true);
|
|
216
221
|
return;
|
|
217
222
|
}
|
|
218
223
|
const inputs = sendOptions.acceptedInputs ?? [{ id: userMessage.id, text: userMessage.content }];
|
|
@@ -258,6 +263,8 @@ export function useConversation(options) {
|
|
|
258
263
|
conversationId,
|
|
259
264
|
isStreaming: run.isStreaming,
|
|
260
265
|
error: run.error,
|
|
266
|
+
// A Stop before acceptance also returns no run, but leaves no error: nothing to report.
|
|
267
|
+
sendError: sendRefused ? run.error : null,
|
|
261
268
|
scrollIntent,
|
|
262
269
|
sendMessage,
|
|
263
270
|
cancel,
|
|
@@ -265,6 +272,6 @@ export function useConversation(options) {
|
|
|
265
272
|
setMessages,
|
|
266
273
|
acknowledgeScroll,
|
|
267
274
|
suppressScroll,
|
|
268
|
-
}), [messages, conversationId, run.isStreaming, run.error, scrollIntent, sendMessage, cancel, retry, setMessages, acknowledgeScroll, suppressScroll]);
|
|
275
|
+
}), [messages, conversationId, run.isStreaming, run.error, sendRefused, scrollIntent, sendMessage, cancel, retry, setMessages, acknowledgeScroll, suppressScroll]);
|
|
269
276
|
}
|
|
270
277
|
//# sourceMappingURL=useConversation.js.map
|
package/dist/react/index.d.ts
CHANGED
|
@@ -168,4 +168,11 @@ export { createAssistantChatsClient } from "./hooks/assistant-chats.js";
|
|
|
168
168
|
export type { AssistantChatsPort, AssistantChatsClientPorts, AssistantChatsClient, UseAssistantChats } from "./hooks/assistant-chats-contracts.js";
|
|
169
169
|
export { useFabPosition } from "./hooks/fab-position.js";
|
|
170
170
|
export type { FabPositionInput, FabPositionResult } from "./hooks/fab-position.js";
|
|
171
|
+
export type { ChatMessage } from '../core/messages.js';
|
|
172
|
+
export { isTerminalRunStatus } from '../core/messages.js';
|
|
173
|
+
export { AGENT_PLUGIN_STATE_CHANGED, readAgentPluginChatState, writeAgentPluginChatState } from '../core/agent-plugin-chat-state.js';
|
|
174
|
+
export type { AgentPluginUsage, AgentPluginUsage as Usage } from '../core/agent-plugin-chat-state.js';
|
|
175
|
+
export { acceptedInputEvent, acceptedInputUserEvent, projectAcceptedInputEvents } from '../core/accepted-input.js';
|
|
176
|
+
export type { AcceptedChatInput } from '../core/accepted-input.js';
|
|
177
|
+
export { isAwaitingAnswer } from '../core/run-activity.js';
|
|
171
178
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/react/index.js
CHANGED
|
@@ -143,4 +143,8 @@ export { cacheAttachmentPreviewSource } from './hooks/attachment-preview-cache.j
|
|
|
143
143
|
export { readCachedAttachmentBatchId, writeCachedAttachmentBatchId } from './hooks/composer-draft-cache.js';
|
|
144
144
|
export { createAssistantChatsClient } from "./hooks/assistant-chats.js";
|
|
145
145
|
export { useFabPosition } from "./hooks/fab-position.js";
|
|
146
|
+
export { isTerminalRunStatus } from '../core/messages.js';
|
|
147
|
+
export { AGENT_PLUGIN_STATE_CHANGED, readAgentPluginChatState, writeAgentPluginChatState } from '../core/agent-plugin-chat-state.js';
|
|
148
|
+
export { acceptedInputEvent, acceptedInputUserEvent, projectAcceptedInputEvents } from '../core/accepted-input.js';
|
|
149
|
+
export { isAwaitingAnswer } from '../core/run-activity.js';
|
|
146
150
|
//# sourceMappingURL=index.js.map
|
|
@@ -85,6 +85,12 @@ export type MessageBlock<Row> = {
|
|
|
85
85
|
readonly text: string;
|
|
86
86
|
readonly key: string;
|
|
87
87
|
};
|
|
88
|
+
/** Split assistant sections around user receipts so each bubble can be an ordinary transcript row.
|
|
89
|
+
* @complexity O(b) time/space for b blocks; preserves every block and its order.
|
|
90
|
+
*/
|
|
91
|
+
export declare function splitMidRunBlocks<Row>({ blocks }: {
|
|
92
|
+
blocks: readonly MessageBlock<Row>[];
|
|
93
|
+
}, _options?: {}): MessageBlock<Row>[][];
|
|
88
94
|
type SlotOf = (ev: {
|
|
89
95
|
readonly name: string;
|
|
90
96
|
readonly data: unknown;
|
|
@@ -1,10 +1,32 @@
|
|
|
1
1
|
import { assistantContentFromEvents, isReplyBoundary, legacyAssistantContentFromEvents } from '../core/index.js';
|
|
2
|
+
import { isAssistantStepBoundary } from '../core/assistant-content.js';
|
|
3
|
+
import { MID_RUN_USER_MESSAGE_EVENT_NAME } from '../core/turn-usage.js';
|
|
4
|
+
/** Split assistant sections around user receipts so each bubble can be an ordinary transcript row.
|
|
5
|
+
* @complexity O(b) time/space for b blocks; preserves every block and its order.
|
|
6
|
+
*/
|
|
7
|
+
export function splitMidRunBlocks({ blocks }, _options = {}) {
|
|
8
|
+
const sections = [];
|
|
9
|
+
let assistant = [];
|
|
10
|
+
for (const block of blocks) {
|
|
11
|
+
if (block.kind === 'ext' && block.name === MID_RUN_USER_MESSAGE_EVENT_NAME) {
|
|
12
|
+
if (assistant.length > 0)
|
|
13
|
+
sections.push(assistant);
|
|
14
|
+
sections.push([block]);
|
|
15
|
+
assistant = [];
|
|
16
|
+
}
|
|
17
|
+
else
|
|
18
|
+
assistant.push(block);
|
|
19
|
+
}
|
|
20
|
+
if (assistant.length > 0)
|
|
21
|
+
sections.push(assistant);
|
|
22
|
+
return sections;
|
|
23
|
+
}
|
|
2
24
|
/** Whether there is anything besides plain text to place: a tool row, an ext event, or (when asked
|
|
3
25
|
* for) non-blank thinking. With none of these the flat path is identical. */
|
|
4
26
|
function hasSomethingToInterleave({ events, rows }, { includeThinking }) {
|
|
5
27
|
if (rows.length > 0)
|
|
6
28
|
return true;
|
|
7
|
-
return events.some((ev) => ev
|
|
29
|
+
return events.some((ev) => isAssistantStepBoundary({ event: ev }) || (includeThinking && ev.kind === 'thinking' && ev.text.trim() !== ''));
|
|
8
30
|
}
|
|
9
31
|
/** Whether `content` is demonstrably built from the text events. */
|
|
10
32
|
function contentMatchesEvents({ events, content }) {
|
|
@@ -71,6 +93,10 @@ function placeEvent({ state, ev }, { slotOf, includeThinking }) {
|
|
|
71
93
|
placeThinking({ state, text: ev.text });
|
|
72
94
|
return;
|
|
73
95
|
}
|
|
96
|
+
// Usage and step-start markers separate paragraphs even when no tool/card renders there.
|
|
97
|
+
// Tool and card events keep their own placement rules (a folded card or repeated tool id never splits).
|
|
98
|
+
if ((ev.kind === 'usage' || ev.kind === 'status') && isAssistantStepBoundary({ event: ev }))
|
|
99
|
+
flushText({ state });
|
|
74
100
|
// Emitted per boundary, before any slot dedupe below, so every reply gets its own end marker.
|
|
75
101
|
if (isReplyBoundary({ event: ev }))
|
|
76
102
|
placeReplyEnd({ state });
|
|
@@ -58,8 +58,7 @@ function hideToolCalls({ blocks, runFailed }) {
|
|
|
58
58
|
const rows = toolRowsForDisplay({ rows: block.rows, mode: 'hidden' }, { recovered, runFailed: failedReply });
|
|
59
59
|
if (rows.length > 0)
|
|
60
60
|
out.push({ ...block, rows });
|
|
61
|
-
|
|
62
|
-
recovered = true;
|
|
61
|
+
recovered = block.rows.some((row) => row.status === 'complete') || recovered;
|
|
63
62
|
}
|
|
64
63
|
return collapseToolCalls({ blocks: out.reverse() });
|
|
65
64
|
}
|
|
@@ -11,6 +11,10 @@ export interface RunAcceptancePorts {
|
|
|
11
11
|
run: DurableRun;
|
|
12
12
|
request: AcceptedRunRequest;
|
|
13
13
|
}, optional: {}) => Promise<void>;
|
|
14
|
+
/** Observes only a newly committed logical run; replays and recovery never invoke it. */
|
|
15
|
+
readonly onAccepted?: (required: {
|
|
16
|
+
run: DurableRun;
|
|
17
|
+
}, optional: {}) => void;
|
|
14
18
|
readonly attach: (required: DurableRun, optional: {}) => void;
|
|
15
19
|
}
|
|
16
20
|
export declare function createRunAcceptance(ports: RunAcceptancePorts, _optional?: {}): {
|
|
@@ -2,9 +2,16 @@ export function createRunAcceptance(ports, _optional = {}) {
|
|
|
2
2
|
return {
|
|
3
3
|
async accept(required, _options = {}) {
|
|
4
4
|
const request = { ...required.request, contextRef: ports.sanitizeContextRef({ contextRef: required.request.contextRef }, {}) };
|
|
5
|
-
const
|
|
5
|
+
const runId = ports.mintRunId();
|
|
6
|
+
const run = await ports.store.accept({ ...required, request, runId, now: ports.now() }, {});
|
|
6
7
|
if (!run)
|
|
7
8
|
return null;
|
|
9
|
+
if (run.runId === runId) {
|
|
10
|
+
try {
|
|
11
|
+
ports.onAccepted?.({ run }, {});
|
|
12
|
+
}
|
|
13
|
+
catch { /* An optional observer cannot prevent dispatch of a committed run. */ }
|
|
14
|
+
}
|
|
8
15
|
// The stub and accepted input are committed BEFORE dispatch. The daemon uses the stored
|
|
9
16
|
// attempt id as its idempotency key, so duplicate POSTs cannot start two executors.
|
|
10
17
|
if (run.message.runStatus === "queued" && run.recoveryCount === 0 && !run.cancelReason) {
|
|
@@ -4,8 +4,8 @@ import { continuationRequest } from "../../core/durable-runs/continuation.js";
|
|
|
4
4
|
import { isAwaitingAnswer } from "../../core/run-activity.js";
|
|
5
5
|
import { RECOVERY_LIMIT, RECOVERY_WINDOW_MS, RUN_STALENESS_MS, SAVED_WORK_NOTICE, AWAITING_ANSWER_NOTICE, EXHAUSTED_NOTICE } from "../../core/durable-runs/policies.js";
|
|
6
6
|
export { RECOVERY_LIMIT, RECOVERY_WINDOW_MS, RUN_STALENESS_MS, SAVED_WORK_NOTICE, AWAITING_ANSWER_NOTICE, EXHAUSTED_NOTICE } from "../../core/durable-runs/policies.js";
|
|
7
|
-
//
|
|
8
|
-
//
|
|
7
|
+
// Older saved run error frames hold only their message: the daemon's MCP_BRIDGE_UNAVAILABLE
|
|
8
|
+
// code was dropped, so the bridge failure is still matched by its text.
|
|
9
9
|
const MCP_BRIDGE_FAILURE = /The assistant's tools did not load \(MCP bridge "jini" status: [^\r\n]*/;
|
|
10
10
|
const STOPPED_NOTICE = "Stopped. Saved work is above.";
|
|
11
11
|
/** Index of the last event matching `match`, or -1.
|
|
@@ -33,10 +33,11 @@ function diagnosticLine({ line }, _optional = {}) {
|
|
|
33
33
|
}
|
|
34
34
|
return [line];
|
|
35
35
|
}
|
|
36
|
-
/** A saved raw event may hold several JSONL lines; filter each before failure classification.
|
|
36
|
+
/** A saved raw event may hold several JSONL lines; filter each before failure classification.
|
|
37
|
+
* A status's typed code counts too: a launch `*_UNSUPPORTED` code (any CLI) cannot heal on retry. */
|
|
37
38
|
function diagnosticLines({ event }, _optional = {}) {
|
|
38
39
|
if (event.kind === "status")
|
|
39
|
-
return [event.label, event.detail ?? ""];
|
|
40
|
+
return [event.code ?? "", event.label, event.detail ?? ""];
|
|
40
41
|
if (event.kind !== "raw")
|
|
41
42
|
return [];
|
|
42
43
|
return event.line.split(/\r?\n/).flatMap(line => diagnosticLine({ line }));
|