@jini-ai/chat 0.6.5 → 0.6.7

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 (161) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/core/accepted-input.d.ts +54 -0
  3. package/dist/core/accepted-input.js +165 -0
  4. package/dist/core/agent-plugin-chat-state.d.ts +43 -0
  5. package/dist/core/agent-plugin-chat-state.js +48 -0
  6. package/dist/core/assistant-client/a2ui-action-poster.js +32 -28
  7. package/dist/core/assistant-client/messages.d.ts +1 -1
  8. package/dist/core/assistant-client/messages.js +2 -2
  9. package/dist/core/assistant-client/transport.d.ts +2 -2
  10. package/dist/core/assistant-client/transport.js +184 -97
  11. package/dist/core/assistant-content.d.ts +2 -1
  12. package/dist/core/assistant-content.js +9 -11
  13. package/dist/core/browser/page-actions.js +4 -2
  14. package/dist/core/composer-history.d.ts +16 -9
  15. package/dist/core/composer-history.js +28 -16
  16. package/dist/core/conversation-title.d.ts +3 -0
  17. package/dist/core/conversation-title.js +3 -0
  18. package/dist/core/durable-projection.js +20 -13
  19. package/dist/core/durable-runs/continuation.d.ts +4 -0
  20. package/dist/core/durable-runs/continuation.js +35 -6
  21. package/dist/core/durable-runs/ledger-contracts.d.ts +2 -1
  22. package/dist/core/durable-runs/policies.d.ts +1 -0
  23. package/dist/core/durable-runs/policies.js +1 -0
  24. package/dist/core/durable-runs/ports.d.ts +6 -2
  25. package/dist/core/index.d.ts +2 -0
  26. package/dist/core/index.js +2 -0
  27. package/dist/core/messages.d.ts +2 -0
  28. package/dist/core/partial-json.d.ts +6 -7
  29. package/dist/core/partial-json.js +81 -47
  30. package/dist/core/persistence/title.js +4 -2
  31. package/dist/core/question-answer.d.ts +23 -0
  32. package/dist/core/question-answer.js +66 -0
  33. package/dist/core/question-form/format.d.ts +6 -6
  34. package/dist/core/question-form/format.js +16 -17
  35. package/dist/core/question-form/normalize.d.ts +18 -0
  36. package/dist/core/question-form/normalize.js +112 -122
  37. package/dist/core/question-form/scan.js +141 -203
  38. package/dist/core/run-activity.js +62 -49
  39. package/dist/core/run-events/entry.d.ts +10 -1
  40. package/dist/core/run-events/entry.js +117 -87
  41. package/dist/core/todos.d.ts +1 -1
  42. package/dist/core/todos.js +60 -85
  43. package/dist/core/tool-events.js +14 -12
  44. package/dist/core/transcript.js +62 -16
  45. package/dist/core/turn-usage.d.ts +2 -1
  46. package/dist/core/turn-usage.js +56 -27
  47. package/dist/core/user-text-redaction.js +3 -0
  48. package/dist/core/util/manifest.js +77 -82
  49. package/dist/core/util/markdown-context.js +51 -53
  50. package/dist/core/util/parser.js +136 -144
  51. package/dist/core/util/pointer.js +16 -16
  52. package/dist/core/util/strip.js +57 -67
  53. package/dist/core/util/validate.js +15 -17
  54. package/dist/embed-widget/browser/session-store.js +21 -12
  55. package/dist/embed-widget/transports/fetch-sse.js +108 -95
  56. package/dist/embed-widget/vanilla/index.js +81 -70
  57. package/dist/react/agent-bridge/frontend-session-bridge.js +80 -52
  58. package/dist/react/components/AttachmentPreviewModal.js +25 -1
  59. package/dist/react/components/ChatSettingsDialog.d.ts +13 -0
  60. package/dist/react/components/ChatSettingsDialog.hooks.d.ts +115 -0
  61. package/dist/react/components/ChatSettingsDialog.hooks.js +40 -0
  62. package/dist/react/components/ChatSettingsDialog.js +72 -0
  63. package/dist/react/components/ChatSettingsSegmented.d.ts +18 -0
  64. package/dist/react/components/ChatSettingsSegmented.js +11 -0
  65. package/dist/react/components/Composer.d.ts +5 -1
  66. package/dist/react/components/Composer.js +126 -82
  67. package/dist/react/components/ConversationList.js +25 -39
  68. package/dist/react/components/ConversationTitleEditor.d.ts +27 -0
  69. package/dist/react/components/ConversationTitleEditor.js +54 -0
  70. package/dist/react/components/Icon.d.ts +1 -1
  71. package/dist/react/components/Icon.js +25 -45
  72. package/dist/react/components/Markdown.js +134 -123
  73. package/dist/react/components/McpUiSurfaceCard.d.ts +2 -2
  74. package/dist/react/components/McpUiSurfaceCard.js +30 -9
  75. package/dist/react/components/MessageList.d.ts +13 -13
  76. package/dist/react/components/MessageList.hooks.d.ts +6 -1
  77. package/dist/react/components/MessageList.hooks.js +19 -3
  78. package/dist/react/components/MessageList.js +34 -10
  79. package/dist/react/components/MessageRow.js +48 -32
  80. package/dist/react/components/MessageRow.usage.d.ts +5 -2
  81. package/dist/react/components/MessageRow.usage.js +20 -11
  82. package/dist/react/components/MidRunUserMessage.js +16 -3
  83. package/dist/react/components/QuestionForm.js +148 -76
  84. package/dist/react/components/QuestionsPanel.js +1 -1
  85. package/dist/react/components/TodoCard.js +12 -6
  86. package/dist/react/components/ToolCard.js +47 -40
  87. package/dist/react/components/composer-discovery.js +6 -1
  88. package/dist/react/components/mcp-ui-surface-state.d.ts +6 -11
  89. package/dist/react/components/mcp-ui-surface-state.js +23 -8
  90. package/dist/react/ext-event-renderer-registry.d.ts +8 -0
  91. package/dist/react/ext-event-renderer-registry.js +5 -2
  92. package/dist/react/features/chat-pane/components/AgentPluginUsage.d.ts +7 -0
  93. package/dist/react/features/chat-pane/components/AgentPluginUsage.js +45 -0
  94. package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +55 -19
  95. package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
  96. package/dist/react/features/chat-pane/components/ChatPane.js +192 -74
  97. package/dist/react/features/chat-pane/components/RuntimeDropUp.d.ts +29 -0
  98. package/dist/react/features/chat-pane/components/RuntimeDropUp.js +45 -0
  99. package/dist/react/features/chat-pane/create-daemon-attachment-uploader.js +80 -47
  100. package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.js +44 -40
  101. package/dist/react/features/chat-pane/create-mid-run-message-poster.d.ts +6 -6
  102. package/dist/react/features/chat-pane/create-mid-run-message-poster.js +8 -8
  103. package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +3 -3
  104. package/dist/react/features/chat-pane/create-typed-answer-poster.js +3 -3
  105. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.d.ts +6 -1
  106. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js +11 -5
  107. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +12 -7
  108. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +340 -149
  109. package/dist/react/features/chat-pane/hooks/useChatPaneControlsHeight.hooks.js +7 -6
  110. package/dist/react/features/chat-pane/hooks/useChatPaneNewTabLinks.hooks.js +28 -10
  111. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.d.ts +3 -3
  112. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +60 -12
  113. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.d.ts +49 -0
  114. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +143 -0
  115. package/dist/react/features/chat-pane/model-options.d.ts +18 -1
  116. package/dist/react/features/chat-pane/model-options.js +49 -20
  117. package/dist/react/features/chat-pane/rules.d.ts +8 -9
  118. package/dist/react/features/chat-pane/rules.js +84 -46
  119. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  120. package/dist/react/features/chat-pane/styles.js +156 -37
  121. package/dist/react/features/chat-pane/types.d.ts +17 -8
  122. package/dist/react/hooks/assistant-chats.js +12 -5
  123. package/dist/react/hooks/composer-draft-cache.js +116 -118
  124. package/dist/react/hooks/composer-history-storage.js +5 -5
  125. package/dist/react/hooks/fab-position.js +7 -4
  126. package/dist/react/hooks/useAnsweredCards.d.ts +21 -4
  127. package/dist/react/hooks/useAnsweredCards.js +38 -11
  128. package/dist/react/hooks/useArtifactStream.js +28 -22
  129. package/dist/react/hooks/useComposer.d.ts +20 -0
  130. package/dist/react/hooks/useComposer.js +22 -9
  131. package/dist/react/hooks/useComposerHistory.hooks.d.ts +6 -1
  132. package/dist/react/hooks/useComposerHistory.hooks.js +39 -19
  133. package/dist/react/hooks/useComposerHistoryKeys.hooks.js +24 -14
  134. package/dist/react/hooks/useConversation.d.ts +7 -0
  135. package/dist/react/hooks/useConversation.js +23 -10
  136. package/dist/react/hooks/useExtEventGroups.js +39 -39
  137. package/dist/react/hooks/useQuestionForms.js +21 -26
  138. package/dist/react/hooks/useRunStream.js +10 -2
  139. package/dist/react/index.d.ts +7 -1
  140. package/dist/react/index.js +9 -0
  141. package/dist/react/message-blocks.d.ts +6 -4
  142. package/dist/react/message-blocks.js +94 -72
  143. package/dist/react/slots.d.ts +2 -2
  144. package/dist/react/tool-call-display.js +19 -17
  145. package/dist/server/durable-runs/recover.d.ts +1 -1
  146. package/dist/server/durable-runs/recover.js +139 -76
  147. package/dist/server/run-finalizer/entry.js +25 -15
  148. package/dist/store/legacy/sqlite/conversations.d.ts +11 -11
  149. package/dist/store/legacy/sqlite/conversations.js +69 -98
  150. package/dist/store/legacy/sqlite/messages.d.ts +10 -2
  151. package/dist/store/legacy/sqlite/messages.js +113 -71
  152. package/dist/store/runtime/chat-history-store.js +9 -3
  153. package/dist/store/runtime/durable-run-store.d.ts +2 -2
  154. package/dist/store/runtime/durable-run-store.js +165 -117
  155. package/dist/store/runtime/history-tools.js +118 -82
  156. package/dist/store/runtime/run-ledger.d.ts +4 -1
  157. package/dist/store/runtime/run-ledger.js +56 -16
  158. package/dist/store/sql/cursor.js +38 -18
  159. package/dist/store/sql/store.d.ts +18 -0
  160. package/dist/store/sql/store.js +46 -15
  161. package/package.json +12 -10
@@ -31,6 +31,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
31
31
  import { useComposerHistory } from './useComposerHistory.hooks.js';
32
32
  import { readCachedAttachments, readCachedDraft, rememberCachedDraft, writeCachedAttachments, writeCachedDraft, } from './composer-draft-cache.js';
33
33
  import { cacheAttachmentPreviewSource } from './attachment-preview-cache.js';
34
+ /**
35
+ * The real window/document pair, or `null` when this runtime has no `window` — a non-DOM React
36
+ * renderer still runs effects, and subscribing there would throw.
37
+ * @complexity Time/space: O(1).
38
+ */
39
+ export function browserPageLifecycle() {
40
+ return typeof window === 'undefined' ? null : { window, document };
41
+ }
34
42
  const EMPTY_MENTION = { open: false, query: '', results: [] };
35
43
  /**
36
44
  * How long typing must pause before the draft reaches `localStorage` and the host's persistence
@@ -44,9 +52,12 @@ export const DRAFT_PERSIST_DEBOUNCE_MS = 800;
44
52
  * One pending durable draft write at a time, trailing-edge debounced. Flushes on unmount and when
45
53
  * the page is hidden (`pagehide`, or `visibilitychange` to hidden — the last event a mobile browser
46
54
  * reliably delivers before discarding the tab).
55
+ * @param pageLifecycle The page-exit event sources, captured at mount; `undefined` resolves the
56
+ * browser's own, `null` subscribes to nothing (unmount still flushes).
47
57
  * @complexity Time/space: O(1) per call.
48
58
  */
49
- function useDeferredDraftWrite() {
59
+ function useDeferredDraftWrite(pageLifecycle) {
60
+ const [lifecycle] = useState(() => (pageLifecycle === undefined ? browserPageLifecycle() : pageLifecycle));
50
61
  const pendingRef = useRef(null);
51
62
  const cancel = useCallback(() => {
52
63
  const pending = pendingRef.current;
@@ -73,20 +84,21 @@ function useDeferredDraftWrite() {
73
84
  pendingRef.current = { timer, write };
74
85
  }, []);
75
86
  useEffect(() => {
76
- if (typeof window === 'undefined')
87
+ if (lifecycle === null)
77
88
  return flush;
89
+ const { window: page, document: doc } = lifecycle;
78
90
  const onVisibilityChange = () => {
79
- if (document.visibilityState === 'hidden')
91
+ if (doc.visibilityState === 'hidden')
80
92
  flush();
81
93
  };
82
- window.addEventListener('pagehide', flush);
83
- document.addEventListener('visibilitychange', onVisibilityChange);
94
+ page.addEventListener('pagehide', flush);
95
+ doc.addEventListener('visibilitychange', onVisibilityChange);
84
96
  return () => {
85
- window.removeEventListener('pagehide', flush);
86
- document.removeEventListener('visibilitychange', onVisibilityChange);
97
+ page.removeEventListener('pagehide', flush);
98
+ doc.removeEventListener('visibilitychange', onVisibilityChange);
87
99
  flush();
88
100
  };
89
- }, [flush]);
101
+ }, [flush, lifecycle]);
90
102
  return useMemo(() => ({ schedule, flush, cancel }), [schedule, flush, cancel]);
91
103
  }
92
104
  export function useComposer(options = {}) {
@@ -95,7 +107,7 @@ export function useComposer(options = {}) {
95
107
  const [attachments, setAttachments] = useState([]);
96
108
  const [agent, setAgent] = useState(options.initialAgent);
97
109
  const [mention, setMention] = useState(EMPTY_MENTION);
98
- const deferredWrite = useDeferredDraftWrite();
110
+ const deferredWrite = useDeferredDraftWrite(options.pageLifecycle);
99
111
  const commitDraft = useCallback((next) => {
100
112
  setDraftState(next);
101
113
  rememberCachedDraft({ conversationId: conversationId, draft: next });
@@ -115,6 +127,7 @@ export function useComposer(options = {}) {
115
127
  const history = useComposerHistory({ setRecalledDraft: setDraftState }, {
116
128
  ...(options.historyMessages === undefined ? {} : { messages: options.historyMessages }),
117
129
  ...(options.historyScope === undefined ? {} : { scope: options.historyScope }),
130
+ ...(options.historyCap === undefined ? {} : { cap: options.historyCap }),
118
131
  ...(options.historyStorage === undefined ? {} : { storage: options.historyStorage }),
119
132
  ...(conversationId === undefined ? {} : { conversationId }),
120
133
  });
@@ -1,12 +1,17 @@
1
+ import { type AcceptedChatInput } from '../../core/accepted-input.js';
1
2
  import type { ChatMessage } from '../../core/messages.js';
2
3
  import { type ComposerHistoryKeyInput, type ComposerHistoryStoragePort } from '../../core/composer-history.js';
3
4
  export interface ComposerHistoryController {
5
+ record: (required: {
6
+ inputs: readonly AcceptedChatInput[];
7
+ }, optional?: {}) => void;
4
8
  navigate: (input: ComposerHistoryKeyInput, optional?: {}) => boolean;
5
9
  edit: (required: {}, optional?: {}) => void;
6
10
  }
7
11
  export declare function useComposerHistory({ setRecalledDraft }: {
8
12
  setRecalledDraft: (text: string) => void;
9
- }, { messages, scope, storage, conversationId }?: {
13
+ }, { messages, scope, storage, conversationId, cap }?: {
14
+ cap?: number;
10
15
  messages?: readonly ChatMessage[];
11
16
  scope?: string;
12
17
  storage?: ComposerHistoryStoragePort;
@@ -1,36 +1,42 @@
1
+ import { acceptedInputsFromMessages } from '../../core/accepted-input.js';
1
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
3
  import { composerHistoryKeyAction, createComposerHistoryState, mergeComposerHistory, normalizeComposerHistory, transitionComposerHistory, } from '../../core/composer-history.js';
3
4
  import { browserComposerHistoryStorage } from './composer-history-storage.js';
4
5
  const EMPTY_MESSAGES = [];
5
- export function useComposerHistory({ setRecalledDraft }, { messages = EMPTY_MESSAGES, scope = 'default', storage = browserComposerHistoryStorage, conversationId } = {}) {
6
+ export function useComposerHistory({ setRecalledDraft }, { messages = EMPTY_MESSAGES, scope = 'default', storage = browserComposerHistoryStorage, conversationId, cap = 100 } = {}) {
6
7
  const browseRef = useRef(createComposerHistoryState({}));
7
8
  const [recent, setRecent] = useState([]);
8
9
  const hydratedPortRef = useRef(null);
9
10
  const [hydratedScope, setHydratedScope] = useState(null);
10
11
  const pendingRef = useRef([]);
12
+ const pendingBaselineRef = useRef([]);
11
13
  const conversationRef = useRef(conversationId);
12
14
  const scopeRef = useRef(scope);
13
- const seenRef = useRef(new Set(messages.map((message) => message.id)));
15
+ const inputs = useMemo(() => acceptedInputsFromMessages({ messages }), [messages]);
16
+ const seenRef = useRef(new Set(inputs.flatMap(input => [input.id, ...(input.aliases ?? [])])));
14
17
  const writeQueueRef = useRef(Promise.resolve());
15
- const current = useMemo(() => messages.filter((message) => message.role === 'user').map((message) => message.content), [messages]);
16
- const entries = mergeComposerHistory({ recent, current });
18
+ const current = inputs.map(input => input.text);
19
+ const entries = pendingRef.current.length > 0 ? normalizeComposerHistory({ entries: recent }, { cap }) : mergeComposerHistory({ recent, current }, { cap });
17
20
  useEffect(() => {
18
21
  let cancelled = false;
19
22
  pendingRef.current = [];
23
+ pendingBaselineRef.current = [];
20
24
  hydratedPortRef.current = null;
21
25
  setRecent([]);
22
26
  setHydratedScope(null);
23
27
  browseRef.current = createComposerHistoryState({});
24
28
  // Read after this hook's outstanding writes so an injected async store cannot return stale
25
29
  // entries when a scope is revisited. A remounted hook's default storage writes synchronously.
26
- void writeQueueRef.current.then(() => storage.read({ scope }, {})).catch(() => []).then((loaded) => {
30
+ void writeQueueRef.current.then(() => storage.read({ scope }, cap === 100 ? {} : { cap })).catch(() => []).then((loaded) => {
27
31
  if (cancelled)
28
32
  return;
29
33
  // Bootstrap an empty recent store from the already-sent current transcript. This
30
34
  // makes opening a new chat useful immediately after installing recall; opening an
31
35
  // older transcript later does not reorder an established recent list.
32
36
  const seed = loaded.length > 0 ? loaded : current;
33
- setRecent(normalizeComposerHistory({ entries: [...seed, ...pendingRef.current] }));
37
+ const baseline = pendingRef.current.length > 0
38
+ ? mergeComposerHistory({ recent: seed, current: pendingBaselineRef.current }, { cap }) : seed;
39
+ setRecent(normalizeComposerHistory({ entries: [...baseline, ...pendingRef.current] }, { cap }));
34
40
  hydratedPortRef.current = storage;
35
41
  setHydratedScope(scope);
36
42
  });
@@ -42,31 +48,45 @@ export function useComposerHistory({ setRecalledDraft }, { messages = EMPTY_MESS
42
48
  scopeRef.current = scope;
43
49
  if (adopted)
44
50
  return;
51
+ pendingRef.current = [];
52
+ pendingBaselineRef.current = [];
45
53
  browseRef.current = createComposerHistoryState({});
46
54
  // A conversation's existing turns are available for navigation, but merely opening old
47
55
  // conversations must not reorder the cross-conversation recent list.
48
- seenRef.current = new Set(messages.map((message) => message.id));
56
+ seenRef.current = new Set(inputs.flatMap(input => [input.id, ...(input.aliases ?? [])]));
49
57
  // messages is deliberately absent: newly appended turns are handled below, not reseeded.
50
58
  }, [conversationId, scope]);
51
- useEffect(() => {
59
+ /** All acceptance paths, including replay observations, claim identities before writing recall. */
60
+ const record = useCallback(({ inputs: accepted }, _options = {}) => {
52
61
  const added = [];
53
- for (const message of messages) {
54
- if (!seenRef.current.has(message.id) && message.role === 'user')
55
- added.push(message.content);
56
- seenRef.current.add(message.id);
62
+ const newIds = new Set();
63
+ for (const input of accepted) {
64
+ const ids = [input.id, ...(input.aliases ?? [])];
65
+ const seen = ids.some(id => seenRef.current.has(id));
66
+ for (const id of ids)
67
+ seenRef.current.add(id);
68
+ if (!seen) {
69
+ added.push(input.text);
70
+ for (const id of ids)
71
+ newIds.add(id);
72
+ }
57
73
  }
58
74
  if (added.length === 0)
59
75
  return;
60
- pendingRef.current.push(...added);
61
- pendingRef.current = normalizeComposerHistory({ entries: pendingRef.current });
62
- setRecent((previous) => normalizeComposerHistory({ entries: [...previous, ...added] }));
63
- }, [messages]);
76
+ const firstAcceptance = pendingRef.current.length === 0;
77
+ pendingRef.current = normalizeComposerHistory({ entries: [...pendingRef.current, ...added] }, { cap });
78
+ const before = inputs.filter(input => !newIds.has(input.id)).map(input => input.text);
79
+ if (firstAcceptance)
80
+ pendingBaselineRef.current = before;
81
+ setRecent(previous => normalizeComposerHistory({ entries: [...(firstAcceptance ? mergeComposerHistory({ recent: previous, current: before }, { cap }) : previous), ...added] }, { cap }));
82
+ }, [cap, inputs]);
83
+ useEffect(() => { record({ inputs }); }, [inputs, record]);
64
84
  useEffect(() => {
65
85
  if (hydratedScope !== scope || hydratedPortRef.current !== storage)
66
86
  return;
67
87
  // Serialize writes; a slow earlier write must not overwrite a later prompt.
68
- writeQueueRef.current = writeQueueRef.current.then(() => storage.write({ scope, entries: recent }, {})).catch(() => { });
69
- }, [hydratedScope, recent, scope, storage]);
88
+ writeQueueRef.current = writeQueueRef.current.then(() => storage.write({ scope, entries: normalizeComposerHistory({ entries: recent }, { cap }) }, cap === 100 ? {} : { cap })).catch(() => { });
89
+ }, [hydratedScope, recent, scope, storage, cap]);
70
90
  const navigate = useCallback((input, _optional = {}) => {
71
91
  const action = composerHistoryKeyAction(input);
72
92
  if (action === null)
@@ -85,6 +105,6 @@ export function useComposerHistory({ setRecalledDraft }, { messages = EMPTY_MESS
85
105
  // the original browsing snapshot are never edited in place.
86
106
  browseRef.current = createComposerHistoryState({});
87
107
  }, []);
88
- return useMemo(() => ({ navigate, edit }), [navigate, edit]);
108
+ return useMemo(() => ({ navigate, edit, record }), [navigate, edit, record]);
89
109
  }
90
110
  //# sourceMappingURL=useComposerHistory.hooks.js.map
@@ -37,28 +37,38 @@ function visualLineBounds({ textarea }) {
37
37
  mirror.remove();
38
38
  }
39
39
  }
40
+ function placeCaret(textarea, { placement }) {
41
+ const offset = placement === 'start' ? 0 : textarea.value.length;
42
+ textarea.setSelectionRange(offset, offset);
43
+ }
44
+ function hasModifier(event) {
45
+ return event.shiftKey || event.ctrlKey || event.altKey || event.metaKey;
46
+ }
47
+ /** Keys another handler or an IME already owns, or that an open popup should get instead. */
48
+ function isClaimedElsewhere(event, { popupOpen }) {
49
+ return event.defaultPrevented || popupOpen || event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229;
50
+ }
51
+ function isHistoryKey(event, { popupOpen }) {
52
+ if (hasModifier(event) || isClaimedElsewhere(event, { popupOpen }))
53
+ return false;
54
+ return ['ArrowUp', 'ArrowDown', 'Escape'].includes(event.key);
55
+ }
40
56
  export function useComposerHistoryKeys({ composer, textareaRef }, { popupOpen = false } = {}) {
41
57
  const pendingCaretRef = useRef(null);
42
58
  // A duplicate history entry can equal the current value, so setDraft may bail out.
43
59
  // Still commit the caret request now rather than carrying it into the next real edit.
44
60
  const [, requestCaretCommit] = useReducer((revision) => revision + 1, 0);
45
61
  useLayoutEffect(() => {
46
- if (!pendingCaretRef.current)
47
- return;
48
62
  const placement = pendingCaretRef.current;
63
+ if (!placement)
64
+ return;
49
65
  pendingCaretRef.current = null;
50
66
  const textarea = textareaRef.current;
51
- if (textarea) {
52
- const offset = placement === 'start' ? 0 : textarea.value.length;
53
- textarea.setSelectionRange(offset, offset);
54
- }
67
+ if (textarea)
68
+ placeCaret(textarea, { placement });
55
69
  });
56
70
  return (event) => {
57
- if (event.shiftKey || event.ctrlKey || event.altKey || event.metaKey)
58
- return false;
59
- if (event.defaultPrevented || popupOpen || event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229)
60
- return false;
61
- if (!['ArrowUp', 'ArrowDown', 'Escape'].includes(event.key))
71
+ if (!isHistoryKey(event, { popupOpen }))
62
72
  return false;
63
73
  const textarea = event.currentTarget;
64
74
  const handled = composer.history?.navigate({
@@ -78,11 +88,11 @@ export function useComposerHistoryKeys({ composer, textareaRef }, { popupOpen =
78
88
  // that commit clamps it to the OLD draft's length and breaks multiline Down gating.
79
89
  // Up stays on the first line of even a multiline recall so repeated Up can browse
80
90
  // older entries. Down/Escape go to the end, including when restoring the draft.
81
- pendingCaretRef.current = event.key === 'ArrowUp' ? 'start' : 'end';
91
+ const placement = event.key === 'ArrowUp' ? 'start' : 'end';
92
+ pendingCaretRef.current = placement;
82
93
  requestCaretCommit();
83
94
  // Recalling an identical string may not cause a render; the existing value is safe.
84
- const offset = pendingCaretRef.current === 'start' ? 0 : textarea.value.length;
85
- textarea.setSelectionRange(offset, offset);
95
+ placeCaret(textarea, { placement });
86
96
  }
87
97
  return handled;
88
98
  };
@@ -1,3 +1,4 @@
1
+ import { type AcceptedChatInput } from '../../core/accepted-input.js';
1
2
  import { type UserTextRedactionOptions } from '../../core/user-text-redaction.js';
2
3
  import type { ChatAttachment, ChatMessage } from '../../core/index.js';
3
4
  import type { ChatTransport, RunContext } from '../../core/index.js';
@@ -18,6 +19,12 @@ export interface UseConversationOptions extends UserTextRedactionOptions {
18
19
  createMessageId?: () => string;
19
20
  }
20
21
  export interface SendMessageOptions {
22
+ /** Queue flushes reuse the input receipts recorded when they were queued. */
23
+ acceptedInputs?: AcceptedChatInput[];
24
+ /** Called after transport acceptance; rejection never consumes the draft or recall slot. */
25
+ onAccepted?: (input: {
26
+ inputs: AcceptedChatInput[];
27
+ }) => void;
21
28
  /** Non-secret signal from a composer that already sanitized the content before this boundary. */
22
29
  secretRedaction?: ChatMessage['secretRedaction'];
23
30
  attachments?: ChatAttachment[];
@@ -1,3 +1,4 @@
1
+ import { acceptedInputUserEvent, projectAcceptedInputEvents } from '../../core/accepted-input.js';
1
2
  import { redactUserHistory, redactUserMessage } from '../../core/user-text-redaction.js';
2
3
  import { preserveAssistantContent, mergeRunEvents } from '../../core/durable-projection.js';
3
4
  /**
@@ -22,6 +23,18 @@ function defaultCreateMessageId() {
22
23
  fallbackIdCounter += 1;
23
24
  return `msg-${Date.now()}-${fallbackIdCounter}`;
24
25
  }
26
+ function pendingUserMessage({ content, sendOptions, createMessageId }, options = {}) {
27
+ return redactUserMessage({ message: {
28
+ id: createMessageId(),
29
+ role: 'user',
30
+ inputPending: true,
31
+ ...(sendOptions.acceptedInputs ? { events: sendOptions.acceptedInputs.map(input => acceptedInputUserEvent({ input }, { receipt: 'accepted' })) } : {}),
32
+ content,
33
+ createdAt: Date.now(),
34
+ ...(sendOptions.secretRedaction ? { secretRedaction: sendOptions.secretRedaction } : {}),
35
+ ...(sendOptions.attachments ? { attachments: sendOptions.attachments } : {}),
36
+ } }, options).message;
37
+ }
25
38
  export function useConversation(options) {
26
39
  const { transport, conversationId = null, agentId, createMessageId = defaultCreateMessageId } = options;
27
40
  const [messages, setMessagesState] = useState(() => redactUserHistory({ history: options.initialMessages ?? [] }, { ...(options.redactUserText ? { redactUserText: options.redactUserText } : {}) }));
@@ -149,7 +162,7 @@ export function useConversation(options) {
149
162
  return {
150
163
  ...m,
151
164
  ...(nextRunId !== undefined ? { runId: nextRunId } : {}),
152
- events: mergeRunEvents({ saved: m.events, incoming: run.events }, {}),
165
+ events: projectAcceptedInputEvents({ events: mergeRunEvents({ saved: m.events, incoming: run.events }, {}) }),
153
166
  // Replayed completion prefixes cannot erase a saved interrupted segment.
154
167
  content: preserveAssistantContent({ saved: m.content, incoming: assistantContentFromEvents({ events: run.events }) }, {}),
155
168
  runStatus,
@@ -169,14 +182,7 @@ export function useConversation(options) {
169
182
  // eslint-disable-next-line react-hooks/exhaustive-deps
170
183
  }, [run.events, run.status, run.runId]);
171
184
  const sendMessage = useCallback(async (content, sendOptions = {}) => {
172
- const userMessage = redactUserMessage({ message: {
173
- id: createMessageId(),
174
- role: 'user',
175
- content,
176
- createdAt: Date.now(),
177
- ...(sendOptions.secretRedaction ? { secretRedaction: sendOptions.secretRedaction } : {}),
178
- ...(sendOptions.attachments ? { attachments: sendOptions.attachments } : {}),
179
- } }, options).message;
185
+ const userMessage = pendingUserMessage({ content, sendOptions, createMessageId }, options);
180
186
  const resolvedAgentId = sendOptions.agentId ?? agentId;
181
187
  const assistantMessage = {
182
188
  id: createMessageId(),
@@ -197,7 +203,7 @@ export function useConversation(options) {
197
203
  // (host stuck-chat investigation, 2026-09-27).
198
204
  setMessagesState((prev) => [...prev, userMessage, assistantMessage]);
199
205
  setScrollIntent(true);
200
- await run.start({
206
+ const accepted = await run.start({
201
207
  assistantMessageId: assistantMessage.id,
202
208
  history,
203
209
  ...(resolvedAgentId !== undefined ? { agentId: resolvedAgentId } : {}),
@@ -205,6 +211,13 @@ export function useConversation(options) {
205
211
  ...(sendOptions.attachments !== undefined ? { attachments: sendOptions.attachments } : {}),
206
212
  ...(sendOptions.context !== undefined ? { context: sendOptions.context } : {}),
207
213
  });
214
+ if (!accepted) {
215
+ setMessagesState(prev => prev.filter(message => message.id !== userMessage.id && message.id !== assistantMessage.id));
216
+ return;
217
+ }
218
+ const inputs = sendOptions.acceptedInputs ?? [{ id: userMessage.id, text: userMessage.content }];
219
+ setMessagesState(prev => prev.map(message => message.id === userMessage.id ? { ...message, inputPending: false } : message));
220
+ sendOptions.onAccepted?.({ inputs });
208
221
  }, [agentId, conversationId, createMessageId, run, options.redactUserText, options.onSecretRedacted]);
209
222
  const retry = useCallback(async (assistantMessageId) => {
210
223
  const idx = messagesRef.current.findIndex((m) => m.id === assistantMessageId);
@@ -11,51 +11,51 @@
11
11
  * chrome for them).
12
12
  */
13
13
  import { useMemo } from 'react';
14
+ function addExtEvent(state, { name, data }) {
15
+ const slot = state.slotOf(name, data);
16
+ let group = state.bySlot.get(slot);
17
+ if (!group) {
18
+ group = { name, slot, events: [] };
19
+ state.bySlot.set(slot, group);
20
+ const owner = [...state.openCalls].at(-1);
21
+ if (owner !== undefined)
22
+ state.ownerOf.set(group, { id: owner[0], ...owner[1] });
23
+ }
24
+ group.events.push(data);
25
+ }
26
+ function visitEvent(state, ev) {
27
+ if (ev.kind === 'tool_use') {
28
+ state.openCalls.set(ev.id, { name: ev.name, input: ev.input });
29
+ }
30
+ else if (ev.kind === 'tool_result') {
31
+ state.openCalls.delete(ev.toolUseId);
32
+ state.results.set(ev.toolUseId, { content: ev.content, isError: ev.isError });
33
+ }
34
+ else if (ev.kind === 'ext') {
35
+ addExtEvent(state, ev);
36
+ }
37
+ }
38
+ function attachCalls({ bySlot, ownerOf, results }) {
39
+ const order = [...bySlot.values()];
40
+ for (const group of order) {
41
+ const owner = ownerOf.get(group);
42
+ if (owner === undefined)
43
+ continue;
44
+ const result = results.get(owner.id);
45
+ group.call = { id: owner.id, name: owner.name, input: owner.input, ...(result === undefined ? {} : { result }) };
46
+ }
47
+ return order;
48
+ }
14
49
  /**
15
50
  * @param slotOf - Which render slot an event belongs to. Defaults to its `name`, the one-slot-per-name
16
51
  * grouping this hook always had; `MessageRow` passes the registry's `extEventSlot`.
17
52
  */
18
53
  export function useExtEventGroups({ events }, { slotOf = (name) => name } = {}) {
19
54
  return useMemo(() => {
20
- const order = [];
21
- const bySlot = new Map();
22
- // Calls still open at this point in the stream, in the order they opened; the newest open one
23
- // is the call an ext event arrived inside.
24
- const openCalls = new Map();
25
- const ownerOf = new Map();
26
- const results = new Map();
27
- for (const ev of events ?? []) {
28
- if (ev.kind === 'tool_use') {
29
- openCalls.set(ev.id, { name: ev.name, input: ev.input });
30
- continue;
31
- }
32
- if (ev.kind === 'tool_result') {
33
- openCalls.delete(ev.toolUseId);
34
- results.set(ev.toolUseId, { content: ev.content, isError: ev.isError });
35
- continue;
36
- }
37
- if (ev.kind !== 'ext')
38
- continue;
39
- const slot = slotOf(ev.name, ev.data);
40
- let group = bySlot.get(slot);
41
- if (!group) {
42
- group = { name: ev.name, slot, events: [] };
43
- bySlot.set(slot, group);
44
- order.push(group);
45
- const owner = [...openCalls].at(-1);
46
- if (owner !== undefined)
47
- ownerOf.set(group, { id: owner[0], ...owner[1] });
48
- }
49
- group.events.push(ev.data);
50
- }
51
- for (const group of order) {
52
- const owner = ownerOf.get(group);
53
- if (owner === undefined)
54
- continue;
55
- const result = results.get(owner.id);
56
- group.call = { name: owner.name, input: owner.input, ...(result === undefined ? {} : { result }) };
57
- }
58
- return order;
55
+ const state = { slotOf, bySlot: new Map(), openCalls: new Map(), ownerOf: new Map(), results: new Map() };
56
+ for (const ev of events ?? [])
57
+ visitEvent(state, ev);
58
+ return attachCalls(state);
59
59
  }, [events, slotOf]);
60
60
  }
61
61
  //# sourceMappingURL=useExtEventGroups.js.map
@@ -17,33 +17,28 @@
17
17
  */
18
18
  import { useCallback, useMemo } from 'react';
19
19
  import { findFirstQuestionForm, formOptionValueForLabel, formatFormAnswers } from '../../core/index.js';
20
- export function useQuestionForms({ messages }) {
21
- const forms = useMemo(() => {
22
- if (!messages || messages.length === 0)
23
- return [];
24
- const out = [];
25
- for (let i = 0; i < messages.length; i += 1) {
26
- const message = messages[i];
27
- if (!message || message.role !== 'assistant')
28
- continue;
29
- const found = findFirstQuestionForm({ input: message.content });
30
- if (!found)
31
- continue;
32
- const nextMessage = messages[i + 1];
33
- const submittedAnswers = nextMessage && nextMessage.role === 'user' ? parseSubmittedAnswers({ form: found.form, userMessageContent: nextMessage.content }) ?? undefined : undefined;
34
- const interactive = submittedAnswers === undefined && i === messages.length - 1;
35
- out.push({ form: found.form, raw: found.raw, messageId: message.id, interactive, submittedAnswers });
36
- }
37
- return out;
38
- }, [messages]);
39
- const activeForm = useMemo(() => {
40
- for (let i = forms.length - 1; i >= 0; i -= 1) {
41
- const form = forms[i];
42
- if (form?.interactive)
43
- return form;
44
- }
20
+ /**
21
+ * The question-form on `messages[index]`, or `null` when that message is not
22
+ * an assistant message or carries no form. Answers come from the message right
23
+ * after it, and only when that one is a user reply.
24
+ */
25
+ function parseFormAt(messages, { index }) {
26
+ // Only called with an index `flatMap` is visiting (it skips holes), so the
27
+ // element is always defined.
28
+ const message = messages[index];
29
+ if (message.role !== 'assistant')
45
30
  return null;
46
- }, [forms]);
31
+ const found = findFirstQuestionForm({ input: message.content });
32
+ if (!found)
33
+ return null;
34
+ const nextMessage = messages[index + 1];
35
+ const submittedAnswers = nextMessage?.role === 'user' ? parseSubmittedAnswers({ form: found.form, userMessageContent: nextMessage.content }) ?? undefined : undefined;
36
+ const interactive = submittedAnswers === undefined && index === messages.length - 1;
37
+ return { form: found.form, raw: found.raw, messageId: message.id, interactive, submittedAnswers };
38
+ }
39
+ export function useQuestionForms({ messages }) {
40
+ const forms = useMemo(() => (messages ?? []).flatMap((_, index, all) => parseFormAt(all, { index }) ?? []), [messages]);
41
+ const activeForm = useMemo(() => forms.findLast((form) => form.interactive) ?? null, [forms]);
47
42
  const buildSubmission = useCallback((form, answers) => formatFormAnswers({ form: form, answers: answers }), []);
48
43
  return { forms, activeForm, buildSubmission };
49
44
  }
@@ -51,6 +51,9 @@ const INITIAL_STATE = {
51
51
  };
52
52
  export function useRunStream({ transport }) {
53
53
  const [state, setState] = useState(INITIAL_STATE);
54
+ // Acceptance/checkpoints can precede React's next commit. Cancellation needs the live id,
55
+ // even when the Stop handler still belongs to the render that showed a pending start.
56
+ const runIdRef = useRef(null);
54
57
  const subscriptionAbortRef = useRef(null);
55
58
  const cancelAbortRef = useRef(null);
56
59
  // Bumped on every start/reattach/reset. A handler closes over the
@@ -81,6 +84,7 @@ export function useRunStream({ transport }) {
81
84
  onCheckpoint: (message) => {
82
85
  if (isStale())
83
86
  return;
87
+ runIdRef.current = message.runId ?? runIdRef.current;
84
88
  const statuses = { queued: 'streaming', running: 'streaming', succeeded: 'done', failed: 'error', canceled: 'canceled' };
85
89
  setState((prev) => ({ ...prev, runId: message.runId ?? prev.runId,
86
90
  status: message.runStatus ? statuses[message.runStatus] : prev.status,
@@ -115,6 +119,7 @@ export function useRunStream({ transport }) {
115
119
  const start = useCallback(async (input) => {
116
120
  teardownSubscription();
117
121
  const generation = ++generationRef.current;
122
+ runIdRef.current = null;
118
123
  const subscriptionAbort = new AbortController();
119
124
  const cancelAbort = new AbortController();
120
125
  subscriptionAbortRef.current = subscriptionAbort;
@@ -129,6 +134,7 @@ export function useRunStream({ transport }) {
129
134
  }
130
135
  return { runId };
131
136
  }
137
+ runIdRef.current = runId;
132
138
  setState((prev) => ({ ...prev, runId }));
133
139
  return { runId };
134
140
  }
@@ -145,6 +151,7 @@ export function useRunStream({ transport }) {
145
151
  const reattach = useCallback(async (runId, initialEvents = []) => {
146
152
  teardownSubscription();
147
153
  const generation = ++generationRef.current;
154
+ runIdRef.current = runId;
148
155
  const subscriptionAbort = new AbortController();
149
156
  const cancelAbort = new AbortController();
150
157
  subscriptionAbortRef.current = subscriptionAbort;
@@ -165,7 +172,7 @@ export function useRunStream({ transport }) {
165
172
  }, [makeHandlers, teardownSubscription]);
166
173
  const cancel = useCallback(() => {
167
174
  cancelAbortRef.current?.abort();
168
- const runId = state.runId;
175
+ const runId = runIdRef.current;
169
176
  // Recorded BEFORE the bump, so it names the generation currently in flight. When `runId` is
170
177
  // already known the `stopRun` below does the work and this entry is simply never consumed;
171
178
  // when it is not, this is the only thing that will ever stop that run.
@@ -176,9 +183,10 @@ export function useRunStream({ transport }) {
176
183
  setState((prev) => ({ ...prev, status: 'canceled' }));
177
184
  if (runId)
178
185
  runStopRunHostEffect('transport.stopRun (cancel)', () => transport.stopRun(runId));
179
- }, [state.runId, teardownSubscription, transport]);
186
+ }, [teardownSubscription, transport]);
180
187
  const reset = useCallback(() => {
181
188
  generationRef.current += 1;
189
+ runIdRef.current = null;
182
190
  teardownSubscription();
183
191
  setState(INITIAL_STATE);
184
192
  }, [teardownSubscription]);
@@ -3,6 +3,10 @@
3
3
  * chat/core vocabulary. Every export is explicit: adding or renaming a name here is a public API
4
4
  * review, so internal module exports cannot silently become public. Do not use export *.
5
5
  */
6
+ export { ChatSettingsDialog } from './components/ChatSettingsDialog.js';
7
+ export { ChatSettingsSegmented } from './components/ChatSettingsSegmented.js';
8
+ export { useChatSettingsDialog } from './components/ChatSettingsDialog.hooks.js';
9
+ export type { ChatPermissionsPort, ChatSettingsSection } from './components/ChatSettingsDialog.hooks.js';
6
10
  export type { ChatTransport, FeedbackChange, OnFeedback, ReattachRunOptions, RunContext, RunHandlers, StartRunInput, UserTextRedaction, } from '../core/index.js';
7
11
  export { RendererRegistry, } from './artifact-types.js';
8
12
  export type { ArtifactFile, ArtifactRenderContext, ArtifactRenderMatch, ArtifactRenderer, } from './artifact-types.js';
@@ -25,7 +29,7 @@ export type { RunStreamState, RunStreamStatus, StartRunOptions, UseRunStreamResu
25
29
  export { useConversation, } from './hooks/useConversation.js';
26
30
  export type { SendMessageOptions, UseConversationOptions, UseConversationResult, } from './hooks/useConversation.js';
27
31
  export { useComposer, } from './hooks/useComposer.js';
28
- export type { ComposerDraftPersistence, MentionPopoverState, UseComposerOptions, UseComposerResult, } from './hooks/useComposer.js';
32
+ export type { ComposerDraftPersistence, ComposerPageLifecycle, MentionPopoverState, UseComposerOptions, UseComposerResult, } from './hooks/useComposer.js';
29
33
  export { useToolTimeline, } from './hooks/useToolTimeline.js';
30
34
  export type { ToolTimelineRow, UseToolTimelineOptions, UseToolTimelineResult, } from './hooks/useToolTimeline.js';
31
35
  export { useExtEventGroups, } from './hooks/useExtEventGroups.js';
@@ -97,6 +101,8 @@ export type { TodoCardProps } from './components/TodoCard.js';
97
101
  * `leadingAccessory` slot; it needs no changes to `ChatPane` itself.
98
102
  */
99
103
  export { ConversationList } from './components/ConversationList.js';
104
+ export { ConversationTitle, ConversationTitleEditor } from './components/ConversationTitleEditor.js';
105
+ export type { ConversationTitleProps, ConversationTitleEditorProps } from './components/ConversationTitleEditor.js';
100
106
  export type { ConversationListProps, ConversationListItem } from './components/ConversationList.js';
101
107
  /**
102
108
  * The conversation an operator last had open, per caller identity — so a host's switcher lands
@@ -1,3 +1,11 @@
1
+ /**
2
+ * @jini-ai/chat/react — headless hooks, presentational components and slots over framework-free
3
+ * chat/core vocabulary. Every export is explicit: adding or renaming a name here is a public API
4
+ * review, so internal module exports cannot silently become public. Do not use export *.
5
+ */
6
+ export { ChatSettingsDialog } from './components/ChatSettingsDialog.js';
7
+ export { ChatSettingsSegmented } from './components/ChatSettingsSegmented.js';
8
+ export { useChatSettingsDialog } from './components/ChatSettingsDialog.hooks.js';
1
9
  export { RendererRegistry, } from './artifact-types.js';
2
10
  export { createToolRendererRegistry, clearToolRenderers, getToolRenderer, registerToolRenderer, } from './tool-renderer-registry.js';
3
11
  export { createExtEventRendererRegistry, clearExtEventRenderers, extEventSlot, getExtEventRenderer, registerExtEventRenderer, } from './ext-event-renderer-registry.js';
@@ -89,6 +97,7 @@ export { TodoCard } from './components/TodoCard.js';
89
97
  * `leadingAccessory` slot; it needs no changes to `ChatPane` itself.
90
98
  */
91
99
  export { ConversationList } from './components/ConversationList.js';
100
+ export { ConversationTitle, ConversationTitleEditor } from './components/ConversationTitleEditor.js';
92
101
  /**
93
102
  * The conversation an operator last had open, per caller identity — so a host's switcher lands
94
103
  * back on it after a reload instead of an empty new chat. See the module doc for the contract.
@@ -85,6 +85,10 @@ export type MessageBlock<Row> = {
85
85
  readonly text: string;
86
86
  readonly key: string;
87
87
  };
88
+ type SlotOf = (ev: {
89
+ readonly name: string;
90
+ readonly data: unknown;
91
+ }) => string;
88
92
  /**
89
93
  * Rebuilds an assistant message as ordered blocks, or returns `null` if that cannot be done
90
94
  * losslessly.
@@ -107,10 +111,8 @@ export declare function interleaveMessageBlocks<Row extends {
107
111
  content: string;
108
112
  rows: readonly Row[];
109
113
  }, { slotOf, includeThinking }?: {
110
- slotOf?: (ev: {
111
- readonly name: string;
112
- readonly data: unknown;
113
- }) => string;
114
+ slotOf?: SlotOf;
114
115
  includeThinking?: boolean;
115
116
  }): MessageBlock<Row>[] | null;
117
+ export {};
116
118
  //# sourceMappingURL=message-blocks.d.ts.map