@jini-ai/chat 0.6.11 → 0.6.15-rc.1
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 +37 -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 +65 -17
- 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 +16 -4
- package/dist/core/run-activity.d.ts +1 -1
- package/dist/core/run-activity.js +50 -4
- package/dist/core/run-events/entry.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/MidRunUserMessage.d.ts +1 -1
- package/dist/react/components/MidRunUserMessage.js +14 -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 -3
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +6 -0
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +89 -25
- package/dist/react/features/chat-pane/rules.d.ts +5 -2
- package/dist/react/features/chat-pane/rules.js +8 -2
- 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/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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { acceptedInputEvent, acceptedInputUserEvent, projectAcceptedInputEvents } from '../../../../core/accepted-input.js';
|
|
2
2
|
import { useUserTextGuard } from '../../../hooks/useUserTextGuard.js';
|
|
3
|
+
import { isAwaitingAnswer } from '../../../../core/run-activity.js';
|
|
3
4
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
5
|
import { appendQueuedTurn, describeChatPaneSendBlocker, findAwaitedTypedAnswerId, findChatPaneSendBlocker, findMidRunMessageRunId, isChatPaneQueueableBlocker, resolveChatPaneSelection, } from '../rules.js';
|
|
5
6
|
import { definedProps } from '../../../util/defined-props.js';
|
|
@@ -108,11 +109,17 @@ function resolveChatPaneActivity(selectedAgent, conversation) {
|
|
|
108
109
|
return 'streaming';
|
|
109
110
|
return 'ready';
|
|
110
111
|
}
|
|
112
|
+
/** Hold new input until its uploads or human gate settle; the existing queue owns the retry.
|
|
113
|
+
* @complexity O(e) in the newest message's events, with no I/O.
|
|
114
|
+
*/
|
|
115
|
+
function waitingForLiveInput({ uploadPending, messages }, _options = {}) {
|
|
116
|
+
return uploadPending || isAwaitingAnswer({ events: messages.at(-1)?.events });
|
|
117
|
+
}
|
|
111
118
|
/**
|
|
112
119
|
* The host delivery and live run a send can go INTO instead of queueing behind, or `null` when the
|
|
113
120
|
* host offers no mid-run delivery or no run can take it ({@link findMidRunMessageRunId}).
|
|
114
121
|
*
|
|
115
|
-
* @complexity O(
|
|
122
|
+
* @complexity O(e) in the newest message's events.
|
|
116
123
|
*/
|
|
117
124
|
function findMidRunTarget({ deliver, blocker, attachmentCount, messages }) {
|
|
118
125
|
if (deliver === undefined)
|
|
@@ -181,10 +188,11 @@ export function useChatPane(options) {
|
|
|
181
188
|
const [storedQueuedInputs, setQueuedInputs] = useState([]);
|
|
182
189
|
// MOVE: Uploads belong to the input that submitted them, even if a later message is queued first.
|
|
183
190
|
const queuedUploadOwnersRef = useRef(new Map());
|
|
184
|
-
// A send
|
|
185
|
-
const
|
|
191
|
+
// A send held for uploads or a human card resumes live; a refused delivery waits for run end.
|
|
192
|
+
const queuedForLiveDeliveryRef = useRef(false);
|
|
186
193
|
const submittedComposerRef = useRef(null);
|
|
187
194
|
const startingPromptRef = useRef(false);
|
|
195
|
+
const refusedSendRef = useRef(null);
|
|
188
196
|
const [typedAnswerNotice, setTypedAnswerNotice] = useState(null);
|
|
189
197
|
// Shared delivery lock: a second Enter cannot submit the same answer or screenshot twice.
|
|
190
198
|
const deliveringTypedAnswerRef = useRef(false);
|
|
@@ -407,11 +415,14 @@ export function useChatPane(options) {
|
|
|
407
415
|
const last = messages.at(-1);
|
|
408
416
|
if (last?.role !== 'assistant')
|
|
409
417
|
return messages;
|
|
418
|
+
const { atEventCount, ...eventBubble } = bubble;
|
|
410
419
|
const event = acceptedInputUserEvent({ input: { id: inputs[0].id, text: bubble.text,
|
|
411
420
|
...(inputs[0].deliveryState ? { deliveryState: inputs[0].deliveryState } : {}) },
|
|
412
|
-
inputs, ...
|
|
421
|
+
inputs, ...eventBubble }, { receipt });
|
|
422
|
+
const events = last.events ?? [];
|
|
423
|
+
const position = atEventCount ?? events.length;
|
|
413
424
|
return [...messages.slice(0, -1), { ...last,
|
|
414
|
-
events: projectAcceptedInputEvents({ events: [...(
|
|
425
|
+
events: projectAcceptedInputEvents({ events: [...events.slice(0, position), event, ...events.slice(position)] }) }];
|
|
415
426
|
});
|
|
416
427
|
}, [composer, conversation.setMessages]);
|
|
417
428
|
/** Both accepted send paths consume the sent files and rotate the same upload batch. */
|
|
@@ -434,18 +445,35 @@ export function useChatPane(options) {
|
|
|
434
445
|
latestComposerRef.current.addAttachment(attachment);
|
|
435
446
|
}
|
|
436
447
|
}, []);
|
|
437
|
-
|
|
448
|
+
/** A refused composer send is kept for Retry and goes back only into a composer that is still
|
|
449
|
+
* empty: text typed (or files staged) while it was sending are never overwritten. */
|
|
450
|
+
const restoreRefusedDraft = useCallback(({ redaction, draft, attachments }, _options = {}) => {
|
|
451
|
+
refusedSendRef.current = { redaction, draft, attachments };
|
|
452
|
+
if (latestDraftRef.current.trim() !== '' || latestAttachmentsRef.current.length > 0)
|
|
453
|
+
return;
|
|
454
|
+
latestComposerRef.current.setDraft(draft);
|
|
455
|
+
for (const attachment of attachments)
|
|
456
|
+
latestComposerRef.current.addAttachment(attachment);
|
|
457
|
+
}, []);
|
|
458
|
+
const finishPromptSend = useCallback(({ accepted, inputs, redaction, submittedDraft, attachments, composerDraft }, _options = {}) => {
|
|
438
459
|
if (!accepted) {
|
|
439
|
-
|
|
460
|
+
if (composerDraft === undefined)
|
|
461
|
+
restoreQueuedDraft({ inputs, text: redaction.text, attachments });
|
|
462
|
+
else
|
|
463
|
+
restoreRefusedDraft({ redaction, draft: composerDraft, attachments });
|
|
440
464
|
return;
|
|
441
465
|
}
|
|
442
|
-
|
|
466
|
+
refusedSendRef.current = null;
|
|
467
|
+
// A composer send emptied the composer when it started; what is there now was typed since.
|
|
468
|
+
if (composerDraft === undefined && inputs === undefined && latestDraftRef.current === submittedDraft) {
|
|
443
469
|
latestComposerRef.current.setDraft('');
|
|
444
470
|
latestComposerRef.current.closeMention();
|
|
445
471
|
}
|
|
446
472
|
consumeDeliveredAttachments({ attachments });
|
|
447
|
-
}, [consumeDeliveredAttachments, restoreQueuedDraft]);
|
|
448
|
-
|
|
473
|
+
}, [consumeDeliveredAttachments, restoreQueuedDraft, restoreRefusedDraft]);
|
|
474
|
+
/** `composerDraft`: the composer text this send already took out of the composer, so the
|
|
475
|
+
* composer is never cleared again on acceptance and a refusal hands the text back. */
|
|
476
|
+
const sendGuardedPrompt = useCallback(async ({ redaction, inputs, attachments = composer.attachments, composerDraft }, _options = {}) => {
|
|
449
477
|
const trimmed = redaction.text.trim();
|
|
450
478
|
assertPromptSendable({ trimmed, sendBlocker });
|
|
451
479
|
if (startingPromptRef.current)
|
|
@@ -473,7 +501,7 @@ export function useChatPane(options) {
|
|
|
473
501
|
} }));
|
|
474
502
|
if (!mountedRef.current || generation !== typedAnswerGenerationRef.current)
|
|
475
503
|
return;
|
|
476
|
-
finishPromptSend({ accepted, inputs, redaction, submittedDraft, attachments });
|
|
504
|
+
finishPromptSend({ accepted, inputs, redaction, submittedDraft, attachments, composerDraft });
|
|
477
505
|
}
|
|
478
506
|
finally {
|
|
479
507
|
startingPromptRef.current = false;
|
|
@@ -483,6 +511,27 @@ export function useChatPane(options) {
|
|
|
483
511
|
const sendPrompt = useCallback(async (prompt) => {
|
|
484
512
|
await sendGuardedPrompt({ redaction: guard.redact({ text: prompt.trim() }, {}) });
|
|
485
513
|
}, [guard.redact, sendGuardedPrompt]);
|
|
514
|
+
// Owner, 2026-10-27: the sent text sat in the composer for seconds until the server accepted the
|
|
515
|
+
// run. The composer empties on send; the text waits here until the start is accepted or refused.
|
|
516
|
+
const sendComposerPrompt = useCallback(async (prompt) => {
|
|
517
|
+
if (sendBlocker !== null || startingPromptRef.current)
|
|
518
|
+
return;
|
|
519
|
+
const draft = latestDraftRef.current;
|
|
520
|
+
const attachments = latestAttachmentsRef.current;
|
|
521
|
+
latestComposerRef.current.setDraft('');
|
|
522
|
+
latestComposerRef.current.closeMention();
|
|
523
|
+
for (const attachment of attachments)
|
|
524
|
+
latestComposerRef.current.removeAttachment(attachment.path);
|
|
525
|
+
const redaction = guard.redact({ text: prompt.trim() }, {});
|
|
526
|
+
try {
|
|
527
|
+
await sendGuardedPrompt({ redaction, attachments, composerDraft: draft });
|
|
528
|
+
}
|
|
529
|
+
catch (error) {
|
|
530
|
+
// A host hook that throws (run context, activity) must not cost the typed text either.
|
|
531
|
+
restoreRefusedDraft({ redaction, draft, attachments });
|
|
532
|
+
throw error;
|
|
533
|
+
}
|
|
534
|
+
}, [guard.redact, restoreRefusedDraft, sendBlocker, sendGuardedPrompt]);
|
|
486
535
|
// MOVE: Every queue path retains per-message receipts; appendQueuedTurn owns their one-turn delivery.
|
|
487
536
|
// A turn queued against another conversation is not joined — it would never be flushed there anyway.
|
|
488
537
|
const enqueueTurn = useCallback(({ turn, inputs, attachments = composer.attachments }, _options = {}) => {
|
|
@@ -532,7 +581,7 @@ export function useChatPane(options) {
|
|
|
532
581
|
latestComposerRef.current.setDraft('');
|
|
533
582
|
}, []);
|
|
534
583
|
/** HTTP acceptance retains fallback ownership; only an actual consumption receipt removes it. */
|
|
535
|
-
const retainAcceptedMidRunInput = useCallback(({ outcome, inputs, turn, attachments, afterInputId }, _options = {}) => {
|
|
584
|
+
const retainAcceptedMidRunInput = useCallback(({ outcome, inputs, turn, attachments, afterInputId, atEventCount }, _options = {}) => {
|
|
536
585
|
const accepted = inputs ?? [{ id: createChatLocalId({}), text: turn.text, attachments,
|
|
537
586
|
...(turn.secretRedacted ? { secretRedaction: { secretRedacted: true, count: turn.count } } : {}) }];
|
|
538
587
|
const id = typeof outcome === 'object' ? outcome.id : accepted[0].id;
|
|
@@ -545,16 +594,16 @@ export function useChatPane(options) {
|
|
|
545
594
|
queuedConversationIdRef.current = options.conversationId;
|
|
546
595
|
setQueuedInputs(queued => [...queued, ...retained]);
|
|
547
596
|
recordAcceptedInput({ inputs: retained,
|
|
548
|
-
bubble: { text: turn.text, ...(afterInputId === undefined ? {} : { afterInputId }) } }, typeof outcome === 'object' ? { receipt: 'accepted' } : {});
|
|
597
|
+
bubble: { text: turn.text, atEventCount, ...(afterInputId === undefined ? {} : { afterInputId }) } }, typeof outcome === 'object' ? { receipt: 'accepted' } : {});
|
|
549
598
|
}, [options.conversationId, recordAcceptedInput]);
|
|
550
|
-
const finishMidRunSend = useCallback(({ outcome, inputs, turn, attachments, afterInputId, submittedDraft }, _options = {}) => {
|
|
599
|
+
const finishMidRunSend = useCallback(({ outcome, inputs, turn, attachments, afterInputId, submittedDraft, atEventCount }, _options = {}) => {
|
|
551
600
|
if (outcome === 'failed') {
|
|
552
601
|
restoreQueuedDraft({ inputs, text: turn.text, attachments });
|
|
553
602
|
setTypedAnswerNotice('failed');
|
|
554
603
|
return;
|
|
555
604
|
}
|
|
556
605
|
if (outcome === 'delivered' || typeof outcome === 'object') {
|
|
557
|
-
retainAcceptedMidRunInput({ outcome, inputs, turn, attachments, afterInputId });
|
|
606
|
+
retainAcceptedMidRunInput({ outcome, inputs, turn, attachments, afterInputId, atEventCount });
|
|
558
607
|
clearDeliveredDraft({ inputs, submittedDraft });
|
|
559
608
|
consumeDeliveredAttachments({ attachments });
|
|
560
609
|
return;
|
|
@@ -579,19 +628,22 @@ export function useChatPane(options) {
|
|
|
579
628
|
deliveringTypedAnswerRef.current = false;
|
|
580
629
|
if (!mountedRef.current || generation !== typedAnswerGenerationRef.current)
|
|
581
630
|
return;
|
|
582
|
-
|
|
631
|
+
// The reply may stream before HTTP 202 resolves. Anchor the bubble to send time, not response time.
|
|
632
|
+
finishMidRunSend({ outcome, inputs, turn, attachments, afterInputId, submittedDraft, atEventCount: priorEvents.length });
|
|
583
633
|
}, [composer, conversation, finishMidRunSend]);
|
|
584
634
|
// The ordinary next-turn path: into the running run, else queue behind it, else send now.
|
|
585
635
|
const sendOrdinaryTurn = useCallback(async (prompt) => {
|
|
586
636
|
if (!isChatPaneQueueableBlocker({ blocker: sendBlocker })) {
|
|
587
637
|
if (canSend)
|
|
588
|
-
await
|
|
638
|
+
await sendComposerPrompt(prompt);
|
|
589
639
|
return;
|
|
590
640
|
}
|
|
591
641
|
const turn = guard.redact({ text: prompt }, {});
|
|
592
642
|
const uploadPending = activeUploadsRef.current.size > 0 || attachmentError !== null;
|
|
593
|
-
|
|
594
|
-
|
|
643
|
+
// Retry live delivery after the human gate settles, using the same queue as pending uploads.
|
|
644
|
+
if (waitingForLiveInput({ uploadPending, messages: conversation.messages })) {
|
|
645
|
+
queuedForLiveDeliveryRef.current = queuedTurn === null || queuedForLiveDeliveryRef.current;
|
|
646
|
+
}
|
|
595
647
|
// MOVE: The existing queue owns its files; a later steer must join it to preserve send order.
|
|
596
648
|
const target = uploadPending || queuedInputs.some(input => input.liveMessageId === undefined) ? null : findMidRunTarget({
|
|
597
649
|
deliver: options.deliverMidRunMessage,
|
|
@@ -617,7 +669,7 @@ export function useChatPane(options) {
|
|
|
617
669
|
queuedInputs,
|
|
618
670
|
sendBlocker,
|
|
619
671
|
sendIntoRunningTurn,
|
|
620
|
-
|
|
672
|
+
sendComposerPrompt,
|
|
621
673
|
guard.redact,
|
|
622
674
|
]);
|
|
623
675
|
const send = useCallback(async () => {
|
|
@@ -651,6 +703,16 @@ export function useChatPane(options) {
|
|
|
651
703
|
sendBlocker,
|
|
652
704
|
sendOrdinaryTurn,
|
|
653
705
|
]);
|
|
706
|
+
const retrySend = useCallback(async () => {
|
|
707
|
+
const refused = refusedSendRef.current;
|
|
708
|
+
if (refused === null || latestDraftRef.current === refused.draft) {
|
|
709
|
+
await send();
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
if (sendBlocker !== null || startingPromptRef.current)
|
|
713
|
+
return;
|
|
714
|
+
await sendGuardedPrompt({ redaction: refused.redaction, attachments: refused.attachments, composerDraft: refused.draft });
|
|
715
|
+
}, [send, sendBlocker, sendGuardedPrompt]);
|
|
654
716
|
const sendAsNewMessage = useCallback(async () => {
|
|
655
717
|
const prompt = composerPrompt(composer);
|
|
656
718
|
if (!prompt || composerDeliveryBlocked({ delivering: deliveringTypedAnswerRef.current, starting: startingPromptRef.current, composer, submitted: submittedComposerRef.current }))
|
|
@@ -679,7 +741,7 @@ export function useChatPane(options) {
|
|
|
679
741
|
}, []);
|
|
680
742
|
const cancelQueued = useCallback(() => {
|
|
681
743
|
submittedComposerRef.current = null;
|
|
682
|
-
|
|
744
|
+
queuedForLiveDeliveryRef.current = false;
|
|
683
745
|
queuedConversationIdRef.current = undefined;
|
|
684
746
|
discardQueuedUploads({});
|
|
685
747
|
setQueuedInputs([]);
|
|
@@ -693,7 +755,7 @@ export function useChatPane(options) {
|
|
|
693
755
|
// clear blocker. Clear the slot before awaiting so re-renders cannot send it twice.
|
|
694
756
|
useEffect(() => {
|
|
695
757
|
if (queuedTurn === null) {
|
|
696
|
-
|
|
758
|
+
queuedForLiveDeliveryRef.current = false;
|
|
697
759
|
return;
|
|
698
760
|
}
|
|
699
761
|
if (queuedConversationIdRef.current !== options.conversationId) {
|
|
@@ -705,13 +767,13 @@ export function useChatPane(options) {
|
|
|
705
767
|
}
|
|
706
768
|
if (activeUploadCount > 0 || attachmentError !== null)
|
|
707
769
|
return;
|
|
708
|
-
const target =
|
|
770
|
+
const target = queuedForLiveDeliveryRef.current ? findMidRunTarget({
|
|
709
771
|
deliver: options.deliverMidRunMessage,
|
|
710
772
|
blocker: sendBlocker, attachmentCount: queuedInputs.flatMap(input => input.attachments).length, messages: conversation.messages,
|
|
711
773
|
}) : null;
|
|
712
774
|
if (target === null && (sendBlocker !== null || awaitingRunReceipts({ inputs: queuedInputs, messages: conversation.messages })))
|
|
713
775
|
return;
|
|
714
|
-
|
|
776
|
+
queuedForLiveDeliveryRef.current = false;
|
|
715
777
|
const inputs = queuedInputs;
|
|
716
778
|
const attachments = inputs.flatMap(input => input.attachments);
|
|
717
779
|
setQueuedInputs([]);
|
|
@@ -740,9 +802,10 @@ export function useChatPane(options) {
|
|
|
740
802
|
// `conversation.cancel()` above clears the streaming blocker — the flush effect fires it into
|
|
741
803
|
// the just-reset conversation instead of discarding it like the rest of this turn's state.
|
|
742
804
|
queuedConversationIdRef.current = undefined;
|
|
743
|
-
|
|
805
|
+
queuedForLiveDeliveryRef.current = false;
|
|
744
806
|
queuedUploadOwnersRef.current.clear();
|
|
745
807
|
setQueuedInputs([]);
|
|
808
|
+
refusedSendRef.current = null;
|
|
746
809
|
}, [composer, conversation, options.initialMessages]);
|
|
747
810
|
return {
|
|
748
811
|
secretRedacted: guard.secretRedacted,
|
|
@@ -761,6 +824,7 @@ export function useChatPane(options) {
|
|
|
761
824
|
addAttachments,
|
|
762
825
|
send,
|
|
763
826
|
sendPrompt,
|
|
827
|
+
retrySend,
|
|
764
828
|
queuedPrompt: queuedTurn?.text ?? null,
|
|
765
829
|
// Accepted live messages already have queued bubbles in the transcript. Their private fallback
|
|
766
830
|
// copy must not create a second composer row whose Cancel button cannot revoke daemon input.
|
|
@@ -146,13 +146,16 @@ export declare function isTypedAnswerTurn({ blocker, attachmentCount, messages,
|
|
|
146
146
|
* turn with a known `runId`.
|
|
147
147
|
* Attachment support is decided by the host's delivery, never by dropping them here.
|
|
148
148
|
*
|
|
149
|
-
*
|
|
149
|
+
* A native live-input transport can reject a pending human gate when given another user turn.
|
|
150
|
+
* Keep ordinary text in the pane's queue until the card's tool settles; typed question answers
|
|
151
|
+
* have their own delivery path and never pass this guard.
|
|
152
|
+
* @complexity O(e) in the newest message's events.
|
|
150
153
|
*/
|
|
151
154
|
export declare function findMidRunMessageRunId({ blocker, messages }: {
|
|
152
155
|
blocker: ChatPaneSendBlocker | null;
|
|
153
156
|
attachmentCount: number;
|
|
154
157
|
messages: readonly ChatMessage[];
|
|
155
|
-
}): string | null;
|
|
158
|
+
}, _options?: {}): string | null;
|
|
156
159
|
/**
|
|
157
160
|
* MOVE: Display is per-message; delivery is ONE queued turn. A second message behind the same run
|
|
158
161
|
* is appended to that delivery (a blank line apart) rather than replacing it:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { configuredPickerModel, pickerModelOptions, isConcretePickerModel } from './model-options.js';
|
|
2
2
|
import { isTerminalRunStatus } from '../../../core/messages.js';
|
|
3
3
|
import { acceptedInputEvent } from '../../../core/accepted-input.js';
|
|
4
|
+
import { isAwaitingAnswer } from '../../../core/run-activity.js';
|
|
4
5
|
function preferredOptionId(options) {
|
|
5
6
|
return options?.find((option) => option.id === 'default')?.id ?? options?.[0]?.id;
|
|
6
7
|
}
|
|
@@ -234,12 +235,17 @@ export function isTypedAnswerTurn({ blocker, attachmentCount, messages, toolName
|
|
|
234
235
|
* turn with a known `runId`.
|
|
235
236
|
* Attachment support is decided by the host's delivery, never by dropping them here.
|
|
236
237
|
*
|
|
237
|
-
*
|
|
238
|
+
* A native live-input transport can reject a pending human gate when given another user turn.
|
|
239
|
+
* Keep ordinary text in the pane's queue until the card's tool settles; typed question answers
|
|
240
|
+
* have their own delivery path and never pass this guard.
|
|
241
|
+
* @complexity O(e) in the newest message's events.
|
|
238
242
|
*/
|
|
239
|
-
export function findMidRunMessageRunId({ blocker, messages }) {
|
|
243
|
+
export function findMidRunMessageRunId({ blocker, messages }, _options = {}) {
|
|
240
244
|
if (!isChatPaneQueueableBlocker({ blocker }))
|
|
241
245
|
return null;
|
|
242
246
|
const last = messages.at(-1);
|
|
247
|
+
if (isAwaitingAnswer({ events: last?.events }))
|
|
248
|
+
return null;
|
|
243
249
|
return last?.role === 'assistant' && last.runId ? last.runId : null;
|
|
244
250
|
}
|
|
245
251
|
/**
|
|
@@ -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 });
|
|
@@ -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 }));
|