@jini-ai/chat 0.6.12 → 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.
Files changed (53) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/contracts/chat-bubble-style.d.ts +3 -2
  3. package/dist/contracts/chat-bubble-style.js +3 -2
  4. package/dist/core/accepted-input.d.ts +20 -1
  5. package/dist/core/accepted-input.js +65 -17
  6. package/dist/core/ag-ui/entry.d.ts +2 -4
  7. package/dist/core/ag-ui/entry.js +11 -5
  8. package/dist/core/assistant-client/ag-ui-transport.js +6 -1
  9. package/dist/core/assistant-client/durable-run-subscription.d.ts +2 -1
  10. package/dist/core/assistant-client/durable-run-subscription.js +3 -1
  11. package/dist/core/assistant-client/transport.js +5 -1
  12. package/dist/core/assistant-content.d.ts +8 -1
  13. package/dist/core/assistant-content.js +17 -4
  14. package/dist/core/durable-runs/continuation.js +6 -2
  15. package/dist/core/events.d.ts +3 -0
  16. package/dist/core/index.d.ts +1 -0
  17. package/dist/core/index.js +1 -0
  18. package/dist/core/question-answer.d.ts +8 -0
  19. package/dist/core/question-answer.js +16 -4
  20. package/dist/core/run-activity.d.ts +1 -1
  21. package/dist/core/run-activity.js +46 -4
  22. package/dist/core/run-events/entry.js +5 -2
  23. package/dist/embed-widget/vanilla/style.css +1 -1
  24. package/dist/react/components/CopyMessageButton.d.ts +18 -0
  25. package/dist/react/components/CopyMessageButton.js +22 -0
  26. package/dist/react/components/Markdown.js +24 -5
  27. package/dist/react/components/McpUiSurfaceCard.js +50 -17
  28. package/dist/react/components/MessageList.hooks.d.ts +2 -1
  29. package/dist/react/components/MessageList.hooks.js +14 -4
  30. package/dist/react/components/MessageList.js +5 -3
  31. package/dist/react/components/MessageRow.d.ts +3 -1
  32. package/dist/react/components/MessageRow.js +66 -38
  33. package/dist/react/components/MidRunUserMessage.d.ts +1 -1
  34. package/dist/react/components/MidRunUserMessage.js +11 -7
  35. package/dist/react/components/QuestionForm.js +22 -2
  36. package/dist/react/components/ToolCard.js +21 -8
  37. package/dist/react/components/mcp-ui-question-input.d.ts +20 -0
  38. package/dist/react/components/mcp-ui-question-input.js +72 -0
  39. package/dist/react/ext-event-renderer-registry.d.ts +2 -0
  40. package/dist/react/features/chat-pane/components/ChatPane.js +10 -3
  41. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +6 -0
  42. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +70 -15
  43. package/dist/react/hooks/useAnsweredCards.js +36 -20
  44. package/dist/react/hooks/useConversation.d.ts +6 -0
  45. package/dist/react/hooks/useConversation.js +10 -3
  46. package/dist/react/index.d.ts +7 -0
  47. package/dist/react/index.js +4 -0
  48. package/dist/react/message-blocks.d.ts +6 -0
  49. package/dist/react/message-blocks.js +27 -1
  50. package/dist/server/durable-runs/accept.d.ts +4 -0
  51. package/dist/server/durable-runs/accept.js +8 -1
  52. package/dist/server/durable-runs/recover.js +5 -4
  53. package/package.json +15 -15
@@ -192,6 +192,7 @@ export function useChatPane(options) {
192
192
  const queuedForLiveDeliveryRef = useRef(false);
193
193
  const submittedComposerRef = useRef(null);
194
194
  const startingPromptRef = useRef(false);
195
+ const refusedSendRef = useRef(null);
195
196
  const [typedAnswerNotice, setTypedAnswerNotice] = useState(null);
196
197
  // Shared delivery lock: a second Enter cannot submit the same answer or screenshot twice.
197
198
  const deliveringTypedAnswerRef = useRef(false);
@@ -414,11 +415,14 @@ export function useChatPane(options) {
414
415
  const last = messages.at(-1);
415
416
  if (last?.role !== 'assistant')
416
417
  return messages;
418
+ const { atEventCount, ...eventBubble } = bubble;
417
419
  const event = acceptedInputUserEvent({ input: { id: inputs[0].id, text: bubble.text,
418
420
  ...(inputs[0].deliveryState ? { deliveryState: inputs[0].deliveryState } : {}) },
419
- inputs, ...bubble }, { receipt });
421
+ inputs, ...eventBubble }, { receipt });
422
+ const events = last.events ?? [];
423
+ const position = atEventCount ?? events.length;
420
424
  return [...messages.slice(0, -1), { ...last,
421
- events: projectAcceptedInputEvents({ events: [...(last.events ?? []), event] }) }];
425
+ events: projectAcceptedInputEvents({ events: [...events.slice(0, position), event, ...events.slice(position)] }) }];
422
426
  });
423
427
  }, [composer, conversation.setMessages]);
424
428
  /** Both accepted send paths consume the sent files and rotate the same upload batch. */
@@ -441,18 +445,35 @@ export function useChatPane(options) {
441
445
  latestComposerRef.current.addAttachment(attachment);
442
446
  }
443
447
  }, []);
444
- const finishPromptSend = useCallback(({ accepted, inputs, redaction, submittedDraft, attachments }, _options = {}) => {
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 = {}) => {
445
459
  if (!accepted) {
446
- restoreQueuedDraft({ inputs, text: redaction.text, attachments });
460
+ if (composerDraft === undefined)
461
+ restoreQueuedDraft({ inputs, text: redaction.text, attachments });
462
+ else
463
+ restoreRefusedDraft({ redaction, draft: composerDraft, attachments });
447
464
  return;
448
465
  }
449
- if (inputs === undefined && latestDraftRef.current === submittedDraft) {
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) {
450
469
  latestComposerRef.current.setDraft('');
451
470
  latestComposerRef.current.closeMention();
452
471
  }
453
472
  consumeDeliveredAttachments({ attachments });
454
- }, [consumeDeliveredAttachments, restoreQueuedDraft]);
455
- const sendGuardedPrompt = useCallback(async ({ redaction, inputs, attachments = composer.attachments }, _options = {}) => {
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 = {}) => {
456
477
  const trimmed = redaction.text.trim();
457
478
  assertPromptSendable({ trimmed, sendBlocker });
458
479
  if (startingPromptRef.current)
@@ -480,7 +501,7 @@ export function useChatPane(options) {
480
501
  } }));
481
502
  if (!mountedRef.current || generation !== typedAnswerGenerationRef.current)
482
503
  return;
483
- finishPromptSend({ accepted, inputs, redaction, submittedDraft, attachments });
504
+ finishPromptSend({ accepted, inputs, redaction, submittedDraft, attachments, composerDraft });
484
505
  }
485
506
  finally {
486
507
  startingPromptRef.current = false;
@@ -490,6 +511,27 @@ export function useChatPane(options) {
490
511
  const sendPrompt = useCallback(async (prompt) => {
491
512
  await sendGuardedPrompt({ redaction: guard.redact({ text: prompt.trim() }, {}) });
492
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]);
493
535
  // MOVE: Every queue path retains per-message receipts; appendQueuedTurn owns their one-turn delivery.
494
536
  // A turn queued against another conversation is not joined — it would never be flushed there anyway.
495
537
  const enqueueTurn = useCallback(({ turn, inputs, attachments = composer.attachments }, _options = {}) => {
@@ -539,7 +581,7 @@ export function useChatPane(options) {
539
581
  latestComposerRef.current.setDraft('');
540
582
  }, []);
541
583
  /** HTTP acceptance retains fallback ownership; only an actual consumption receipt removes it. */
542
- const retainAcceptedMidRunInput = useCallback(({ outcome, inputs, turn, attachments, afterInputId }, _options = {}) => {
584
+ const retainAcceptedMidRunInput = useCallback(({ outcome, inputs, turn, attachments, afterInputId, atEventCount }, _options = {}) => {
543
585
  const accepted = inputs ?? [{ id: createChatLocalId({}), text: turn.text, attachments,
544
586
  ...(turn.secretRedacted ? { secretRedaction: { secretRedacted: true, count: turn.count } } : {}) }];
545
587
  const id = typeof outcome === 'object' ? outcome.id : accepted[0].id;
@@ -552,16 +594,16 @@ export function useChatPane(options) {
552
594
  queuedConversationIdRef.current = options.conversationId;
553
595
  setQueuedInputs(queued => [...queued, ...retained]);
554
596
  recordAcceptedInput({ inputs: retained,
555
- 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' } : {});
556
598
  }, [options.conversationId, recordAcceptedInput]);
557
- const finishMidRunSend = useCallback(({ outcome, inputs, turn, attachments, afterInputId, submittedDraft }, _options = {}) => {
599
+ const finishMidRunSend = useCallback(({ outcome, inputs, turn, attachments, afterInputId, submittedDraft, atEventCount }, _options = {}) => {
558
600
  if (outcome === 'failed') {
559
601
  restoreQueuedDraft({ inputs, text: turn.text, attachments });
560
602
  setTypedAnswerNotice('failed');
561
603
  return;
562
604
  }
563
605
  if (outcome === 'delivered' || typeof outcome === 'object') {
564
- retainAcceptedMidRunInput({ outcome, inputs, turn, attachments, afterInputId });
606
+ retainAcceptedMidRunInput({ outcome, inputs, turn, attachments, afterInputId, atEventCount });
565
607
  clearDeliveredDraft({ inputs, submittedDraft });
566
608
  consumeDeliveredAttachments({ attachments });
567
609
  return;
@@ -586,13 +628,14 @@ export function useChatPane(options) {
586
628
  deliveringTypedAnswerRef.current = false;
587
629
  if (!mountedRef.current || generation !== typedAnswerGenerationRef.current)
588
630
  return;
589
- finishMidRunSend({ outcome, inputs, turn, attachments, afterInputId, submittedDraft });
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 });
590
633
  }, [composer, conversation, finishMidRunSend]);
591
634
  // The ordinary next-turn path: into the running run, else queue behind it, else send now.
592
635
  const sendOrdinaryTurn = useCallback(async (prompt) => {
593
636
  if (!isChatPaneQueueableBlocker({ blocker: sendBlocker })) {
594
637
  if (canSend)
595
- await sendPrompt(prompt);
638
+ await sendComposerPrompt(prompt);
596
639
  return;
597
640
  }
598
641
  const turn = guard.redact({ text: prompt }, {});
@@ -626,7 +669,7 @@ export function useChatPane(options) {
626
669
  queuedInputs,
627
670
  sendBlocker,
628
671
  sendIntoRunningTurn,
629
- sendPrompt,
672
+ sendComposerPrompt,
630
673
  guard.redact,
631
674
  ]);
632
675
  const send = useCallback(async () => {
@@ -660,6 +703,16 @@ export function useChatPane(options) {
660
703
  sendBlocker,
661
704
  sendOrdinaryTurn,
662
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]);
663
716
  const sendAsNewMessage = useCallback(async () => {
664
717
  const prompt = composerPrompt(composer);
665
718
  if (!prompt || composerDeliveryBlocked({ delivering: deliveringTypedAnswerRef.current, starting: startingPromptRef.current, composer, submitted: submittedComposerRef.current }))
@@ -752,6 +805,7 @@ export function useChatPane(options) {
752
805
  queuedForLiveDeliveryRef.current = false;
753
806
  queuedUploadOwnersRef.current.clear();
754
807
  setQueuedInputs([]);
808
+ refusedSendRef.current = null;
755
809
  }, [composer, conversation, options.initialMessages]);
756
810
  return {
757
811
  secretRedacted: guard.secretRedacted,
@@ -770,6 +824,7 @@ export function useChatPane(options) {
770
824
  addAttachments,
771
825
  send,
772
826
  sendPrompt,
827
+ retrySend,
773
828
  queuedPrompt: queuedTurn?.text ?? null,
774
829
  // Accepted live messages already have queued bubbles in the transcript. Their private fallback
775
830
  // copy must not create a second composer row whose Cancel button cannot revoke daemon input.
@@ -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
- setAnswered((current) => (current.has(slot) ? current : new Map(current).set(slot, answerText)));
41
- }, []);
42
- const isAnswered = useCallback((slot) => recorded.has(slot) || answered.has(slot), [answered, recorded]);
43
- const answerText = useCallback((slot) => recorded.get(slot) ?? answered.get(slot), [answered, recorded]);
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: projectAcceptedInputEvents({ events: mergeRunEvents({ saved: m.events, incoming: run.events }, {}) }),
167
+ events,
166
168
  // Replayed completion prefixes cannot erase a saved interrupted segment.
167
- content: preserveAssistantContent({ saved: m.content, incoming: assistantContentFromEvents({ events: run.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
@@ -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
@@ -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.kind === 'ext' || (includeThinking && ev.kind === 'thinking' && ev.text.trim() !== ''));
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 run = await ports.store.accept({ ...required, request, runId: ports.mintRunId(), now: ports.now() }, {});
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
- // translateRunFrame saves a run error frame as a status label holding only its message: the
8
- // daemon's MCP_BRIDGE_UNAVAILABLE code is dropped, so the bridge failure is matched by its text.
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 }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/chat",
3
- "version": "0.6.12",
3
+ "version": "0.6.15-rc.1",
4
4
  "description": "Jini's chat/artifact frontend, consolidated into one package with two subpaths: framework-free chat vocabulary and pure parsers in `/core`, headless hooks + presentational components + slots in `/react`. Import only the layer you need.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -196,12 +196,12 @@
196
196
  "registry": "https://registry.npmjs.org"
197
197
  },
198
198
  "dependencies": {
199
- "@jini-ai/core": "^0.6.12"
199
+ "@jini-ai/core": "^0.6.15-rc.1"
200
200
  },
201
201
  "peerDependencies": {
202
202
  "react": "^18.3.0 || ^19.0.0",
203
203
  "react-dom": "^18.3.0 || ^19.0.0",
204
- "@jini-ai/db": "^0.6.10",
204
+ "@jini-ai/db": "^0.6.15-rc.1",
205
205
  "kysely": "^0.29.6",
206
206
  "better-sqlite3": "^13.0.0",
207
207
  "@electric-sql/pglite": "0.5.8",
@@ -213,11 +213,11 @@
213
213
  "@radix-ui/react-radio-group": "^1.4.7",
214
214
  "@radix-ui/react-select": "^2.3.7",
215
215
  "@radix-ui/react-slot": "^1.3.3",
216
- "@jini-ai/agent-runtime": "^0.6.10",
217
- "@jini-ai/agentic": "^0.6.10",
218
- "@jini-ai/protocol": "^0.6.10",
219
- "@jini-ai/ui": "^0.6.10",
220
- "@jini-ai/diagnostics": "^0.6.10"
216
+ "@jini-ai/agent-runtime": "^0.6.15-rc.1",
217
+ "@jini-ai/agentic": "^0.6.15-rc.1",
218
+ "@jini-ai/protocol": "^0.6.15-rc.1",
219
+ "@jini-ai/ui": "^0.6.15-rc.1",
220
+ "@jini-ai/diagnostics": "^0.6.15-rc.1"
221
221
  },
222
222
  "peerDependenciesMeta": {
223
223
  "react": {
@@ -303,13 +303,13 @@
303
303
  "pg": "8.23.0",
304
304
  "@ag-ui/core": "0.0.58",
305
305
  "fast-check": "4.3.0",
306
- "@jini-ai/db": "0.6.12",
307
- "@jini-ai/agentic": "^0.6.12",
308
- "@jini-ai/agent-runtime": "0.6.12",
309
- "@jini-ai/diagnostics": "^0.6.12",
310
- "@jini-ai/ui": "^0.6.12",
311
- "@jini-ai/core": "^0.6.12",
312
- "@jini-ai/protocol": "^0.6.12"
306
+ "@jini-ai/db": "0.6.15-rc.1",
307
+ "@jini-ai/ui": "^0.6.15-rc.1",
308
+ "@jini-ai/agentic": "^0.6.15-rc.1",
309
+ "@jini-ai/diagnostics": "^0.6.15-rc.1",
310
+ "@jini-ai/core": "^0.6.15-rc.1",
311
+ "@jini-ai/protocol": "^0.6.15-rc.1",
312
+ "@jini-ai/agent-runtime": "0.6.15-rc.1"
313
313
  },
314
314
  "typesVersions": {
315
315
  "*": {