@assistant-ui/core 0.2.6 → 0.2.8

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 (192) hide show
  1. package/dist/adapters/attachment.d.ts.map +1 -1
  2. package/dist/adapters/speech.d.ts.map +1 -1
  3. package/dist/adapters/speech.js.map +1 -1
  4. package/dist/index.d.ts +4 -1
  5. package/dist/index.js +8 -1
  6. package/dist/index.js.map +1 -0
  7. package/dist/internal/duplicate-detection.d.ts +5 -0
  8. package/dist/internal/duplicate-detection.d.ts.map +1 -0
  9. package/dist/internal/duplicate-detection.js +11 -0
  10. package/dist/internal/duplicate-detection.js.map +1 -0
  11. package/dist/internal.d.ts +2 -2
  12. package/dist/internal.js +2 -2
  13. package/dist/model-context/frame/host.d.ts.map +1 -1
  14. package/dist/model-context/frame/host.js.map +1 -1
  15. package/dist/model-context/frame/provider.d.ts.map +1 -1
  16. package/dist/model-context/frame/provider.js.map +1 -1
  17. package/dist/model-context/registry.d.ts.map +1 -1
  18. package/dist/model-context/tool.d.ts.map +1 -1
  19. package/dist/react/AssistantProvider.d.ts.map +1 -1
  20. package/dist/react/AssistantProvider.js.map +1 -1
  21. package/dist/react/client/Interactables.js.map +1 -1
  22. package/dist/react/client/Tools.d.ts.map +1 -1
  23. package/dist/react/client/Tools.js +26 -15
  24. package/dist/react/client/Tools.js.map +1 -1
  25. package/dist/react/index.d.ts +5 -4
  26. package/dist/react/index.js +2 -2
  27. package/dist/react/model-context/toolbox.d.ts +29 -2
  28. package/dist/react/model-context/toolbox.d.ts.map +1 -1
  29. package/dist/react/model-context/toolbox.js +18 -0
  30. package/dist/react/model-context/toolbox.js.map +1 -0
  31. package/dist/react/model-context/useAssistantTool.d.ts.map +1 -1
  32. package/dist/react/model-context/useAssistantTool.js +6 -3
  33. package/dist/react/model-context/useAssistantTool.js.map +1 -1
  34. package/dist/react/model-context/useAssistantToolUI.d.ts +6 -0
  35. package/dist/react/model-context/useAssistantToolUI.d.ts.map +1 -1
  36. package/dist/react/model-context/useAssistantToolUI.js +4 -2
  37. package/dist/react/model-context/useAssistantToolUI.js.map +1 -1
  38. package/dist/react/model-context/useInlineRender.js.map +1 -1
  39. package/dist/react/primitives/chainOfThought/ChainOfThoughtParts.js.map +1 -1
  40. package/dist/react/primitives/message/MessageGroupedParts.d.ts +49 -7
  41. package/dist/react/primitives/message/MessageGroupedParts.d.ts.map +1 -1
  42. package/dist/react/primitives/message/MessageGroupedParts.js +28 -3
  43. package/dist/react/primitives/message/MessageGroupedParts.js.map +1 -1
  44. package/dist/react/primitives/message/MessageParts.d.ts.map +1 -1
  45. package/dist/react/primitives/message/MessageParts.js +2 -7
  46. package/dist/react/primitives/message/MessageParts.js.map +1 -1
  47. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  48. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  49. package/dist/react/runtimes/RuntimeAdapterProvider.d.ts.map +1 -1
  50. package/dist/react/runtimes/RuntimeAdapterProvider.js +6 -5
  51. package/dist/react/runtimes/RuntimeAdapterProvider.js.map +1 -1
  52. package/dist/react/runtimes/cloud/CloudFileAttachmentAdapter.d.ts.map +1 -1
  53. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.d.ts.map +1 -1
  54. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js.map +1 -1
  55. package/dist/react/runtimes/external-message-converter.d.ts +1 -1
  56. package/dist/react/runtimes/external-message-converter.d.ts.map +1 -1
  57. package/dist/react/runtimes/external-message-converter.js +1 -0
  58. package/dist/react/runtimes/external-message-converter.js.map +1 -1
  59. package/dist/react/runtimes/useExternalStoreSharedOptions.d.ts +7 -0
  60. package/dist/react/runtimes/useExternalStoreSharedOptions.d.ts.map +1 -0
  61. package/dist/react/runtimes/useExternalStoreSharedOptions.js +21 -0
  62. package/dist/react/runtimes/useExternalStoreSharedOptions.js.map +1 -0
  63. package/dist/react/runtimes/useLocalRuntime.d.ts.map +1 -1
  64. package/dist/react/runtimes/useLocalRuntime.js.map +1 -1
  65. package/dist/react/runtimes/useRemoteThreadListRuntime.d.ts.map +1 -1
  66. package/dist/react/runtimes/useRemoteThreadListRuntime.js.map +1 -1
  67. package/dist/react/types/scopes/tools.d.ts +19 -2
  68. package/dist/react/types/scopes/tools.d.ts.map +1 -1
  69. package/dist/react/utils/groupParts.d.ts +32 -11
  70. package/dist/react/utils/groupParts.d.ts.map +1 -1
  71. package/dist/react/utils/groupParts.js +13 -6
  72. package/dist/react/utils/groupParts.js.map +1 -1
  73. package/dist/runtime/api/assistant-runtime.d.ts.map +1 -1
  74. package/dist/runtime/api/attachment-runtime.d.ts.map +1 -1
  75. package/dist/runtime/api/attachment-runtime.js.map +1 -1
  76. package/dist/runtime/api/composer-runtime.d.ts.map +1 -1
  77. package/dist/runtime/api/message-part-runtime.d.ts.map +1 -1
  78. package/dist/runtime/api/message-runtime.d.ts.map +1 -1
  79. package/dist/runtime/api/thread-list-item-runtime.d.ts.map +1 -1
  80. package/dist/runtime/api/thread-list-runtime.d.ts.map +1 -1
  81. package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
  82. package/dist/runtime/base/base-assistant-runtime-core.d.ts.map +1 -1
  83. package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
  84. package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
  85. package/dist/runtime/base/default-edit-composer-runtime-core.d.ts.map +1 -1
  86. package/dist/runtime/base/default-thread-composer-runtime-core.d.ts.map +1 -1
  87. package/dist/runtime/interfaces/thread-runtime-core.d.ts +8 -0
  88. package/dist/runtime/interfaces/thread-runtime-core.d.ts.map +1 -1
  89. package/dist/runtime/utils/message-repository.d.ts +9 -1
  90. package/dist/runtime/utils/message-repository.d.ts.map +1 -1
  91. package/dist/runtime/utils/message-repository.js +34 -14
  92. package/dist/runtime/utils/message-repository.js.map +1 -1
  93. package/dist/runtime/utils/thread-message-like.d.ts +1 -0
  94. package/dist/runtime/utils/thread-message-like.d.ts.map +1 -1
  95. package/dist/runtime/utils/thread-message-like.js +2 -1
  96. package/dist/runtime/utils/thread-message-like.js.map +1 -1
  97. package/dist/runtimes/external-store/external-store-adapter.d.ts +31 -0
  98. package/dist/runtimes/external-store/external-store-adapter.d.ts.map +1 -1
  99. package/dist/runtimes/external-store/external-store-shared-options.d.ts +8 -0
  100. package/dist/runtimes/external-store/external-store-shared-options.d.ts.map +1 -0
  101. package/dist/runtimes/external-store/external-store-shared-options.js +11 -0
  102. package/dist/runtimes/external-store/external-store-shared-options.js.map +1 -0
  103. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts.map +1 -1
  104. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.js.map +1 -1
  105. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +25 -2
  106. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  107. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +106 -26
  108. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  109. package/dist/runtimes/external-store/thread-message-converter.d.ts.map +1 -1
  110. package/dist/runtimes/local/local-thread-list-runtime-core.d.ts.map +1 -1
  111. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  112. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
  113. package/dist/runtimes/remote-thread-list/adapter/in-memory.d.ts.map +1 -1
  114. package/dist/runtimes/remote-thread-list/optimistic-state.d.ts.map +1 -1
  115. package/dist/runtimes/tool-invocations/ToolInvocationTracker.d.ts +168 -0
  116. package/dist/runtimes/tool-invocations/ToolInvocationTracker.d.ts.map +1 -0
  117. package/dist/runtimes/tool-invocations/ToolInvocationTracker.js +449 -0
  118. package/dist/runtimes/tool-invocations/ToolInvocationTracker.js.map +1 -0
  119. package/dist/subscribable/subscribable.d.ts.map +1 -1
  120. package/dist/subscribable/subscribable.js.map +1 -1
  121. package/dist/tests/remote-thread-list-test-helpers.d.ts.map +1 -1
  122. package/dist/types/message.d.ts +6 -0
  123. package/dist/types/message.d.ts.map +1 -1
  124. package/dist/types/message.js.map +1 -1
  125. package/dist/utils/composite-context-provider.d.ts.map +1 -1
  126. package/dist/utils/id.d.ts +1 -3
  127. package/dist/utils/id.d.ts.map +1 -1
  128. package/dist/utils/id.js +1 -4
  129. package/dist/utils/id.js.map +1 -1
  130. package/package.json +10 -10
  131. package/src/adapters/index.ts +1 -4
  132. package/src/adapters/speech.ts +0 -1
  133. package/src/index.ts +12 -0
  134. package/src/internal/duplicate-detection.ts +26 -0
  135. package/src/internal.ts +0 -2
  136. package/src/model-context/frame/host.ts +0 -1
  137. package/src/model-context/frame/provider.ts +0 -1
  138. package/src/react/AssistantProvider.tsx +2 -3
  139. package/src/react/client/Interactables.ts +0 -1
  140. package/src/react/client/Tools.ts +50 -25
  141. package/src/react/index.ts +9 -8
  142. package/src/react/model-context/toolbox.ts +46 -1
  143. package/src/react/model-context/useAssistantTool.ts +8 -3
  144. package/src/react/model-context/useAssistantToolUI.ts +9 -2
  145. package/src/react/model-context/useInlineRender.ts +0 -1
  146. package/src/react/primitives/chainOfThought/ChainOfThoughtParts.tsx +1 -2
  147. package/src/react/primitives/message/MessageAttachments.test.tsx +1 -1
  148. package/src/react/primitives/message/MessageGroupedParts.tsx +102 -13
  149. package/src/react/primitives/message/MessageParts.tsx +4 -7
  150. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +0 -3
  151. package/src/react/runtimes/RuntimeAdapterProvider.tsx +12 -7
  152. package/src/react/runtimes/cloud/useCloudThreadListAdapter.tsx +0 -3
  153. package/src/react/runtimes/external-message-converter.ts +5 -1
  154. package/src/react/runtimes/useExternalStoreSharedOptions.ts +23 -0
  155. package/src/react/runtimes/useLocalRuntime.ts +0 -10
  156. package/src/react/runtimes/useRemoteThreadListRuntime.ts +0 -6
  157. package/src/react/types/scopes/tools.ts +20 -1
  158. package/src/react/utils/groupParts.ts +49 -18
  159. package/src/runtime/api/attachment-runtime.ts +1 -2
  160. package/src/runtime/interfaces/thread-runtime-core.ts +8 -0
  161. package/src/runtime/internal.ts +1 -4
  162. package/src/runtime/utils/message-repository.ts +57 -16
  163. package/src/runtime/utils/thread-message-like.ts +2 -0
  164. package/src/runtimes/external-store/external-store-adapter.ts +33 -0
  165. package/src/runtimes/external-store/external-store-shared-options.ts +18 -0
  166. package/src/runtimes/external-store/external-store-thread-list-runtime-core.ts +1 -3
  167. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +179 -37
  168. package/src/runtimes/tool-invocations/EDGE_CASES.md +194 -0
  169. package/src/runtimes/tool-invocations/ToolInvocationTracker.test.ts +1054 -0
  170. package/src/runtimes/tool-invocations/ToolInvocationTracker.ts +782 -0
  171. package/src/subscribable/subscribable.ts +3 -3
  172. package/src/tests/MessageRepository.test.ts +83 -52
  173. package/src/tests/OptimisticState-delete-crash.test.ts +2 -0
  174. package/src/tests/OptimisticState-list-race.test.ts +2 -4
  175. package/src/tests/RemoteThreadListThreadListRuntimeCore-loadMore.test.ts +5 -5
  176. package/src/tests/auiV0Encode.test.ts +1 -1
  177. package/src/tests/composer-can-send.test.ts +8 -4
  178. package/src/tests/duplicate-detection.test.ts +34 -0
  179. package/src/tests/external-store-thread-list-runtime-core.test.ts +1 -1
  180. package/src/tests/external-store-thread-runtime-core.test.ts +112 -79
  181. package/src/tests/groupParts.test.ts +70 -0
  182. package/src/tests/no-unsafe-process-env.test.ts +1 -0
  183. package/src/tests/remote-thread-list-isLoading.test.ts +2 -5
  184. package/src/tests/thread-message-like.test.ts +4 -1
  185. package/src/types/index.ts +1 -4
  186. package/src/types/message.ts +6 -0
  187. package/src/utils/id.ts +0 -4
  188. package/dist/react/runtimes/useToolInvocations.d.ts +0 -53
  189. package/dist/react/runtimes/useToolInvocations.d.ts.map +0 -1
  190. package/dist/react/runtimes/useToolInvocations.js +0 -380
  191. package/dist/react/runtimes/useToolInvocations.js.map +0 -1
  192. package/src/react/runtimes/useToolInvocations.ts +0 -694
@@ -11,6 +11,7 @@ import type {
11
11
  import {
12
12
  buildGroupTree,
13
13
  GROUPBY_MEMO_KEY,
14
+ type GroupByContext,
14
15
  type GroupNode,
15
16
  } from "../../utils/groupParts";
16
17
  import { MessagePartChildren, type EnrichedPartState } from "./MessageParts";
@@ -28,13 +29,40 @@ export namespace MessagePrimitiveGroupedParts {
28
29
  readonly indices: readonly number[];
29
30
  };
30
31
 
32
+ /**
33
+ * Synthetic trailing slot for a streaming/loading affordance (a
34
+ * "thinking…" dot, etc.). Surfaced through the same `{ part }` channel
35
+ * as groups and leaf parts so a single `switch (part.type)` renders it
36
+ * via `case "indicator"`.
37
+ *
38
+ * It is only ever emitted while the message is running, so its presence
39
+ * alone means "render your loading UI here" — there's no `status` to
40
+ * branch on.
41
+ */
42
+ export type IndicatorPart = {
43
+ readonly type: "indicator";
44
+ };
45
+
46
+ /**
47
+ * When to emit the synthetic {@link IndicatorPart}. It is **only** emitted
48
+ * while the message is running (streaming); the mode further restricts
49
+ * which running states qualify:
50
+ * - `"never"` — never.
51
+ * - `"empty"` — only when the message has no parts yet.
52
+ * - `"no-text"` (default) — when the last part isn't `text`/`reasoning`
53
+ * (e.g. it ended on a tool call, so the assistant likely isn't done).
54
+ * - `"always"` — whenever the message is running, regardless of parts.
55
+ */
56
+ export type IndicatorMode = "never" | "empty" | "no-text" | "always";
57
+
31
58
  export type RenderInfo<TKey extends `group-${string}` = `group-${string}`> = {
32
59
  /**
33
60
  * Either a coalesced group ({@link GroupPart}, identified by a
34
- * `group-…` `type`) or a single enriched part. Use one switch over
35
- * `part.type` to handle both.
61
+ * `group-…` `type`), a single enriched leaf part, or the synthetic
62
+ * {@link IndicatorPart} (`type: "indicator"`). Use one switch over
63
+ * `part.type` to handle all three.
36
64
  */
37
- readonly part: GroupPart<TKey> | EnrichedPartState;
65
+ readonly part: GroupPart<TKey> | EnrichedPartState | IndicatorPart;
38
66
  /**
39
67
  * For group nodes: the recursively-rendered subtree (subgroups +
40
68
  * leaf parts). For leaf parts: a sentinel that throws when rendered
@@ -59,6 +87,9 @@ export namespace MessagePrimitiveGroupedParts {
59
87
  * the helper isn't expressive enough (e.g. branching on
60
88
  * `part.toolName` or part metadata).
61
89
  *
90
+ * The second argument is a {@link GroupByContext} carrying the tool-UI
91
+ * registry, for grouping that depends on it (e.g. standalone tool calls).
92
+ *
62
93
  * @example
63
94
  * ```tsx
64
95
  * import { groupPartByType } from "@assistant-ui/react";
@@ -71,12 +102,27 @@ export namespace MessagePrimitiveGroupedParts {
71
102
  * >
72
103
  * ```
73
104
  */
74
- readonly groupBy: (part: PartState) => readonly TKey[] | null;
105
+ readonly groupBy: (
106
+ part: PartState,
107
+ context: GroupByContext,
108
+ ) => readonly TKey[] | null;
75
109
 
76
110
  /**
77
- * Render function called once per group node and once per leaf part.
78
- * Switch on `part.type`: `"group-…"` cases wrap `children`; real
79
- * part types (`"text"`, `"tool-call"`, …) render the part directly.
111
+ * Controls emission of the synthetic {@link IndicatorPart} a
112
+ * trailing `{ part: { type: "indicator", status } }` render call you
113
+ * handle with `case "indicator"` to show loading/status UI.
114
+ *
115
+ * @default "no-text"
116
+ * @see IndicatorMode
117
+ */
118
+ readonly indicator?: IndicatorMode;
119
+
120
+ /**
121
+ * Render function called once per group node, once per leaf part, and
122
+ * (when the `indicator` condition is met) once for the trailing
123
+ * {@link IndicatorPart}. Switch on `part.type`: `"group-…"` cases wrap
124
+ * `children`; real part types (`"text"`, `"tool-call"`, …) render the
125
+ * part directly; `"indicator"` renders status/loading UI.
80
126
  *
81
127
  * Leaf parts receive the same {@link EnrichedPartState} that
82
128
  * `<MessagePrimitive.Parts>` would produce (`toolUI`, `addResult`,
@@ -88,6 +134,31 @@ export namespace MessagePrimitiveGroupedParts {
88
134
 
89
135
  const COMPLETE_STATUS: MessagePartStatus = Object.freeze({ type: "complete" });
90
136
 
137
+ const shouldShowIndicator = (
138
+ mode: MessagePrimitiveGroupedParts.IndicatorMode,
139
+ parts: readonly PartState[],
140
+ isRunning: boolean,
141
+ ): boolean => {
142
+ // The indicator is a streaming affordance — never show it on a settled
143
+ // message, whatever the mode.
144
+ if (!isRunning) return false;
145
+
146
+ switch (mode) {
147
+ case "never":
148
+ return false;
149
+ case "always":
150
+ return true;
151
+ case "empty":
152
+ return parts.length === 0;
153
+ case "no-text": {
154
+ const last = parts[parts.length - 1];
155
+ return (
156
+ last !== undefined && last.type !== "text" && last.type !== "reasoning"
157
+ );
158
+ }
159
+ }
160
+ };
161
+
91
162
  /**
92
163
  * `children` placeholder passed for leaf-part renders. Leaf parts have no
93
164
  * inner subtree; rendering this sentinel signals the consumer wrote
@@ -161,6 +232,7 @@ const renderNode = <TKey extends `group-${string}`>(
161
232
  * case "group-tool": return <ToolStack>{children}</ToolStack>;
162
233
  * case "text": return <MarkdownText />;
163
234
  * case "tool-call": return part.toolUI ?? <ToolFallback {...part} />;
235
+ * case "indicator": return <LoadingDots />;
164
236
  * default: return null;
165
237
  * }
166
238
  * }}
@@ -169,9 +241,17 @@ const renderNode = <TKey extends `group-${string}`>(
169
241
  */
170
242
  export const MessagePrimitiveGroupedParts = <TKey extends `group-${string}`>({
171
243
  groupBy,
244
+ indicator = "no-text",
172
245
  children,
173
246
  }: MessagePrimitiveGroupedParts.Props<TKey>): ReactNode => {
174
247
  const parts = useAuiState(useShallow((s) => s.message.parts));
248
+ // Handed to `groupBy` as its `context` argument (see GroupByContext).
249
+ const toolUIs = useAuiState((s) => s.tools.toolUIs);
250
+ // Subscribe to a boolean, not the status object: the tree only needs to
251
+ // re-render when running-ness flips, and `"never"` opts out entirely.
252
+ const isRunning = useAuiState((s) =>
253
+ indicator === "never" ? false : s.message.status?.type === "running",
254
+ );
175
255
 
176
256
  // Helpers like `groupPartByType` tag the function with `GROUPBY_MEMO_KEY`
177
257
  // (a stable string fingerprint of the helper config). When present,
@@ -182,13 +262,22 @@ export const MessagePrimitiveGroupedParts = <TKey extends `group-${string}`>({
182
262
  GROUPBY_MEMO_KEY
183
263
  ];
184
264
  const memoDep = memoKey ?? groupBy;
185
- // biome-ignore lint/correctness/useExhaustiveDependencies: groupBy is captured via memoDep — either as its identity (no memoKey) or as the helper's memoKey fingerprint. Listing groupBy directly would defeat the helper-tagged memo path.
186
- const tree = useMemo(
187
- () => buildGroupTree(parts.map((part) => groupBy(part) ?? [])),
188
- [parts, memoDep],
189
- );
265
+ const tree = useMemo(() => {
266
+ const context: GroupByContext = { toolUIs };
267
+ return buildGroupTree(parts.map((part) => groupBy(part, context) ?? []));
268
+ // oxlint-disable-next-line tap-hooks/exhaustive-deps -- groupBy is captured via memoDep (either its identity or the helper's memoKey fingerprint); listing it directly would defeat the helper-tagged memo path
269
+ }, [parts, memoDep, toolUIs]);
190
270
 
191
- return <>{tree.map((node) => renderNode(node, parts, children))}</>;
271
+ return (
272
+ <>
273
+ {tree.map((node) => renderNode(node, parts, children))}
274
+ {shouldShowIndicator(indicator, parts, isRunning) &&
275
+ children({
276
+ part: { type: "indicator" },
277
+ children: <PartChildrenSentinel />,
278
+ })}
279
+ </>
280
+ );
192
281
  };
193
282
 
194
283
  MessagePrimitiveGroupedParts.displayName = "MessagePrimitive.GroupedParts";
@@ -308,11 +308,9 @@ const ToolUIDisplay = ({
308
308
  }: {
309
309
  Fallback: ToolCallMessagePartComponent | undefined;
310
310
  } & ToolCallMessagePartProps) => {
311
- const Render = useAuiState((s) => {
312
- const Render = s.tools.tools[props.toolName] ?? Fallback;
313
- if (Array.isArray(Render)) return Render[0] ?? Fallback;
314
- return Render;
315
- });
311
+ const Render = useAuiState(
312
+ (s) => s.tools.toolUIs[props.toolName]?.[0]?.render ?? Fallback,
313
+ );
316
314
  if (!Render) return null;
317
315
  return <Render {...props} />;
318
316
  };
@@ -574,8 +572,7 @@ function resolveToolRender(
574
572
  toolsState: ToolsState,
575
573
  part: Extract<PartState, { type: "tool-call" }>,
576
574
  ): ToolCallMessagePartComponent | null {
577
- const entry = toolsState.tools[part.toolName];
578
- const named = Array.isArray(entry) ? (entry[0] ?? null) : (entry ?? null);
575
+ const named = toolsState.toolUIs[part.toolName]?.[0]?.render ?? null;
579
576
  if (named) return named;
580
577
  if (isMcpAppUri(part.mcp?.app?.resourceUri) && toolsState.mcpApp) {
581
578
  return toolsState.mcpApp.render;
@@ -62,7 +62,6 @@ export class RemoteThreadListThreadListRuntimeCore
62
62
  isLoading: true,
63
63
  };
64
64
  },
65
- // biome-ignore lint/suspicious/noThenProperty: OptimisticState reducer pattern
66
65
  then: (state, l) => {
67
66
  if (generation !== this._loadGeneration) return state;
68
67
  const fresh = classifyThreads(l.threads, {
@@ -120,7 +119,6 @@ export class RemoteThreadListThreadListRuntimeCore
120
119
  .optimisticUpdate({
121
120
  execute: () => adapter.list({ after: cursor }),
122
121
  loading: (state) => ({ ...state, isLoadingMore: true }),
123
- // biome-ignore lint/suspicious/noThenProperty: OptimisticState reducer pattern
124
122
  then: (state, l) => {
125
123
  if (generation !== this._loadGeneration) return state;
126
124
  if (adapter !== this._options.adapter) return state;
@@ -423,7 +421,6 @@ export class RemoteThreadListThreadListRuntimeCore
423
421
  },
424
422
  };
425
423
  },
426
- // biome-ignore lint/suspicious/noThenProperty: OptimisticState reducer pattern
427
424
  then: (state, { remoteId, externalId }) => {
428
425
  const data = getThreadData(state, threadId);
429
426
  if (!data) return state;
@@ -1,4 +1,10 @@
1
- import { createContext, type FC, type ReactNode, useContext } from "react";
1
+ import {
2
+ createContext,
3
+ type FC,
4
+ type ReactNode,
5
+ useContext,
6
+ useMemo,
7
+ } from "react";
2
8
  import type { ThreadHistoryAdapter } from "../../adapters/thread-history";
3
9
  import type { AttachmentAdapter } from "../../adapters/attachment";
4
10
  import type { ModelContextProvider } from "../../model-context/types";
@@ -23,13 +29,12 @@ export const RuntimeAdapterProvider: FC<RuntimeAdapterProvider.Props> = ({
23
29
  children,
24
30
  }) => {
25
31
  const context = useContext(RuntimeAdaptersContext);
32
+ const value = useMemo(
33
+ () => ({ ...context, ...adapters }),
34
+ [context, adapters],
35
+ );
26
36
  return (
27
- <RuntimeAdaptersContext.Provider
28
- value={{
29
- ...context,
30
- ...adapters,
31
- }}
32
- >
37
+ <RuntimeAdaptersContext.Provider value={value}>
33
38
  {children}
34
39
  </RuntimeAdaptersContext.Provider>
35
40
  );
@@ -43,20 +43,17 @@ export const useCloudThreadListAdapter = (
43
43
 
44
44
  const unstable_Provider = useCallback<FC<PropsWithChildren>>(
45
45
  function Provider({ children }) {
46
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
47
46
  const history = useAssistantCloudThreadHistoryAdapter({
48
47
  get current() {
49
48
  return adapterRef.current.cloud ?? autoCloud!;
50
49
  },
51
50
  });
52
51
  const cloudInstance = adapterRef.current.cloud ?? autoCloud!;
53
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
54
52
  const attachments = useMemo(
55
53
  () => new CloudFileAttachmentAdapter(cloudInstance),
56
54
  [cloudInstance],
57
55
  );
58
56
 
59
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
60
57
  const adapters = useMemo(
61
58
  () => ({
62
59
  history,
@@ -12,7 +12,7 @@ import {
12
12
  type ThreadMessageLike,
13
13
  } from "../../runtime/utils/thread-message-like";
14
14
  import { getAutoStatus, isAutoStatus } from "../../runtime/utils/auto-status";
15
- import type { ToolExecutionStatus } from "./useToolInvocations";
15
+ import type { ToolExecutionStatus } from "../../runtimes/tool-invocations/ToolInvocationTracker";
16
16
  import type { ReadonlyJSONValue } from "assistant-stream/utils";
17
17
  import { generateErrorMessageId } from "../../utils/id";
18
18
  import type {
@@ -198,6 +198,10 @@ const joinExternalMessages = (
198
198
  assistantMessage.metadata.submittedFeedback =
199
199
  output.metadata.submittedFeedback;
200
200
  }
201
+
202
+ if (output.metadata.isOptimistic) {
203
+ assistantMessage.metadata.isOptimistic = true;
204
+ }
201
205
  }
202
206
  // TODO keep this in sync
203
207
  }
@@ -0,0 +1,23 @@
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+ import type { ExternalStoreSharedOptions } from "../../runtimes/external-store/external-store-shared-options";
5
+
6
+ export const useExternalStoreSharedOptions = (
7
+ options: ExternalStoreSharedOptions,
8
+ ): ExternalStoreSharedOptions => {
9
+ const { isDisabled, isSendDisabled, unstable_capabilities, suggestions } =
10
+ options;
11
+ return useMemo(
12
+ () =>
13
+ ({
14
+ isDisabled,
15
+ isSendDisabled,
16
+ unstable_capabilities,
17
+ suggestions,
18
+ }) satisfies {
19
+ [K in keyof Required<ExternalStoreSharedOptions>]: ExternalStoreSharedOptions[K];
20
+ },
21
+ [isDisabled, isSendDisabled, unstable_capabilities, suggestions],
22
+ );
23
+ };
@@ -22,7 +22,6 @@ const useLocalThreadRuntime = (
22
22
  chatModel: ChatModelAdapter,
23
23
  { initialMessages, ...options }: LocalRuntimeOptions,
24
24
  ): AssistantRuntime => {
25
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
26
25
  const { modelContext, ...threadListAdapters } = useRuntimeAdapters() ?? {};
27
26
  const opt = {
28
27
  ...options,
@@ -33,41 +32,33 @@ const useLocalThreadRuntime = (
33
32
  },
34
33
  };
35
34
 
36
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
37
35
  const [runtime] = useState(() => new LocalRuntimeCore(opt, initialMessages));
38
36
 
39
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
40
37
  const threadIdRef = useRef<string | undefined>(undefined);
41
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
42
38
  threadIdRef.current = useAuiState((s) => s.threadListItem.remoteId);
43
39
 
44
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
45
40
  useEffect(() => {
46
41
  runtime.threads
47
42
  .getMainThreadRuntimeCore()
48
43
  .__internal_setGetThreadId(() => threadIdRef.current);
49
44
  }, [runtime]);
50
45
 
51
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
52
46
  useEffect(() => {
53
47
  return () => {
54
48
  runtime.threads.getMainThreadRuntimeCore().detach();
55
49
  };
56
50
  }, [runtime]);
57
51
 
58
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
59
52
  useEffect(() => {
60
53
  runtime.threads.getMainThreadRuntimeCore().__internal_setOptions(opt);
61
54
  runtime.threads.getMainThreadRuntimeCore().__internal_load();
62
55
  });
63
56
 
64
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
65
57
  useEffect(() => {
66
58
  if (!modelContext) return undefined;
67
59
  return runtime.registerModelContextProvider(modelContext);
68
60
  }, [modelContext, runtime]);
69
61
 
70
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
71
62
  return useMemo(() => new AssistantRuntimeImpl(runtime), [runtime]);
72
63
  };
73
64
 
@@ -102,7 +93,6 @@ export const useLocalRuntime = (
102
93
  const cloudAdapter = useCloudThreadListAdapter({ cloud });
103
94
  return useRemoteThreadListRuntime({
104
95
  runtimeHook: function RuntimeHook() {
105
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
106
96
  return useLocalThreadRuntime(chatModel, options);
107
97
  },
108
98
  adapter: cloudAdapter,
@@ -29,15 +29,12 @@ class RemoteThreadListRuntimeCore
29
29
  const useRemoteThreadListRuntimeImpl = (
30
30
  options: RemoteThreadListOptions,
31
31
  ): AssistantRuntime => {
32
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
33
32
  const [runtime] = useState(() => new RemoteThreadListRuntimeCore(options));
34
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
35
33
  useEffect(() => {
36
34
  runtime.threads.__internal_setOptions(options);
37
35
  runtime.threads.__internal_load();
38
36
  }, [runtime, options]);
39
37
 
40
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
41
38
  return useMemo(() => new AssistantRuntimeImpl(runtime), [runtime]);
42
39
  };
43
40
 
@@ -80,12 +77,9 @@ export const useRemoteThreadListRuntime = (
80
77
  return stableRuntimeHook();
81
78
  }
82
79
 
83
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
84
80
  const runtime = useRemoteThreadListRuntimeImpl(stableOptions);
85
81
 
86
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
87
82
  const prevThreadIdRef = useRef(options.threadId);
88
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
89
83
  useEffect(() => {
90
84
  if (options.threadId === prevThreadIdRef.current) return;
91
85
  prevThreadIdRef.current = options.threadId;
@@ -5,9 +5,27 @@ export type McpAppResourceOutput = {
5
5
  readonly render: ToolCallMessagePartComponent;
6
6
  };
7
7
 
8
+ /**
9
+ * A single tool-UI registration: the renderer plus its presentation options.
10
+ * Stored as a per-tool-name list so the name stays registered while any
11
+ * registration of it is mounted.
12
+ */
13
+ type ToolRegistration = {
14
+ readonly render: ToolCallMessagePartComponent;
15
+ /** Whether this UI renders standalone, outside the chain-of-thought trace. */
16
+ readonly standalone: boolean;
17
+ };
18
+
8
19
  export type ToolsState = {
9
- tools: Record<string, ToolCallMessagePartComponent[]>;
20
+ /** Registered tool UIs (renderer + presentation options) keyed by tool name. */
21
+ toolUIs: Record<string, readonly ToolRegistration[]>;
10
22
  mcpApp?: McpAppResourceOutput | undefined;
23
+ /**
24
+ * @deprecated Use {@link toolUIs} instead, whose entries carry the renderer
25
+ * alongside its presentation options. This component-only map is kept for
26
+ * back-compat and will be removed in v0.15.
27
+ */
28
+ tools: Record<string, ToolCallMessagePartComponent[]>;
11
29
  };
12
30
 
13
31
  export type ToolsMethods = {
@@ -15,6 +33,7 @@ export type ToolsMethods = {
15
33
  setToolUI(
16
34
  toolName: string,
17
35
  render: ToolCallMessagePartComponent,
36
+ options?: { standalone?: boolean },
18
37
  ): Unsubscribe;
19
38
  };
20
39
 
@@ -1,5 +1,17 @@
1
1
  import { isMcpAppUri } from "../../types/message";
2
2
  import type { PartState } from "../../store/scopes/part";
3
+ import type { ToolsState } from "../types/scopes/tools";
4
+
5
+ /**
6
+ * Registry context passed to a `groupBy` function as its second argument by
7
+ * `<MessagePrimitive.GroupedParts>`. Carries the live tool-UI registry so a
8
+ * `groupBy` can resolve registry-driven grouping (e.g. standalone tool calls)
9
+ * without the part itself having to carry that information.
10
+ */
11
+ export type GroupByContext = {
12
+ /** Tool UIs registered in the tool-UI registry, keyed by tool name. */
13
+ readonly toolUIs?: ToolsState["toolUIs"];
14
+ };
3
15
 
4
16
  /**
5
17
  * Hierarchical adjacent-coalescing grouping for message parts.
@@ -25,12 +37,20 @@ export const GROUPBY_MEMO_KEY: unique symbol = Symbol.for(
25
37
  );
26
38
 
27
39
  /**
28
- * Synthetic part-type key recognized by {@link groupPartByType}: a
29
- * tool-call whose `mcp.app.resourceUri` points at an assistant-ui MCP
30
- * app. Map this key to control how MCP-app tool calls are grouped —
31
- * separately from regular `"tool-call"` parts.
40
+ * Synthetic part-type keys recognized by {@link groupPartByType}, in
41
+ * addition to real {@link PartState} types:
42
+ *
43
+ * - `"standalone-tool-call"` — a tool-call whose UI should be presented on its
44
+ * own, outside the chain-of-thought grouping. Matches MCP-app tool calls plus
45
+ * any tool-call whose registered UI opts into standalone display (human
46
+ * tools, the built-in generative-UI tool, and tools that set
47
+ * `display: "standalone"`). Resolving the registry-driven cases reads the
48
+ * {@link GroupByContext} passed to the `groupBy` function. Takes precedence
49
+ * over the `"tool-call"` entry.
50
+ * - `"mcp-app"` — **deprecated**, kept for back-compat. Matches only MCP-app
51
+ * tool calls. Prefer `"standalone-tool-call"`, which is a superset.
32
52
  */
33
- type GroupPartType = PartState["type"] | "mcp-app";
53
+ type GroupPartType = PartState["type"] | "standalone-tool-call" | "mcp-app";
34
54
 
35
55
  /**
36
56
  * Build a `groupBy` from a `part.type → group-key path` lookup.
@@ -38,9 +58,12 @@ type GroupPartType = PartState["type"] | "mcp-app";
38
58
  * function carries a stable {@link GROUPBY_MEMO_KEY} fingerprint so
39
59
  * `<MessagePrimitive.GroupedParts>` can memoize its tree across renders.
40
60
  *
41
- * Special key `"mcp-app"` matches tool-call parts that point at an
42
- * assistant-ui MCP app resource (`ui://...`) and takes precedence over
43
- * the `"tool-call"` entry for those parts.
61
+ * The synthetic `"standalone-tool-call"` key matches tool calls that should
62
+ * render outside the chain-of-thought grouping. MCP-app calls are detected from
63
+ * the part alone; the registry-driven cases (human tools, the generative-UI
64
+ * tool, `display: "standalone"` opt-ins) are resolved from the
65
+ * {@link GroupByContext} that `<MessagePrimitive.GroupedParts>` passes to the
66
+ * `groupBy` function — the helper needs nothing threaded into it.
44
67
  *
45
68
  * @example
46
69
  * ```tsx
@@ -48,7 +71,7 @@ type GroupPartType = PartState["type"] | "mcp-app";
48
71
  * groupBy={groupPartByType({
49
72
  * reasoning: ["group-thought", "group-reasoning"],
50
73
  * "tool-call": ["group-thought", "group-tool"],
51
- * "mcp-app": [],
74
+ * "standalone-tool-call": [],
52
75
  * })}
53
76
  * >
54
77
  * {({ part, children }) => { ... }}
@@ -57,18 +80,26 @@ type GroupPartType = PartState["type"] | "mcp-app";
57
80
  */
58
81
  export const groupPartByType = <TKey extends `group-${string}`>(
59
82
  map: Partial<Readonly<Record<GroupPartType, readonly TKey[]>>>,
60
- ): ((part: PartState) => readonly TKey[]) => {
83
+ ): ((part: PartState, context?: GroupByContext) => readonly TKey[]) => {
61
84
  const lookup = map as Readonly<Record<string, readonly TKey[] | undefined>>;
62
- const fn = ((part) => {
63
- if (
64
- part.type === "tool-call" &&
65
- lookup["mcp-app"] !== undefined &&
66
- isMcpAppUri(part.mcp?.app?.resourceUri)
67
- ) {
68
- return lookup["mcp-app"]!;
85
+ const fn = ((part, context) => {
86
+ if (part.type === "tool-call") {
87
+ const isMcpApp = isMcpAppUri(part.mcp?.app?.resourceUri);
88
+ // Read the first registration's flag — the same one `resolveToolRender`
89
+ // renders — so grouping and rendering never disagree for a tool name.
90
+ const isStandalone =
91
+ isMcpApp ||
92
+ (context?.toolUIs?.[part.toolName]?.[0]?.standalone ?? false);
93
+ if (isStandalone && lookup["standalone-tool-call"] !== undefined) {
94
+ return lookup["standalone-tool-call"]!;
95
+ }
96
+ // TODO(v0.15): drop the deprecated "mcp-app" key (superseded by "standalone-tool-call").
97
+ if (isMcpApp && lookup["mcp-app"] !== undefined) {
98
+ return lookup["mcp-app"]!;
99
+ }
69
100
  }
70
101
  return lookup[part.type] ?? [];
71
- }) as ((part: PartState) => readonly TKey[]) & {
102
+ }) as ((part: PartState, context?: GroupByContext) => readonly TKey[]) & {
72
103
  [GROUPBY_MEMO_KEY]?: string;
73
104
  };
74
105
  // Sort keys so the fingerprint is insensitive to map insertion order —
@@ -42,8 +42,7 @@ export type AttachmentRuntime<
42
42
 
43
43
  export abstract class AttachmentRuntimeImpl<
44
44
  Source extends AttachmentRuntimeSource = AttachmentRuntimeSource,
45
- > implements AttachmentRuntime
46
- {
45
+ > implements AttachmentRuntime {
47
46
  public get path() {
48
47
  return this._core.path;
49
48
  }
@@ -1,3 +1,4 @@
1
+ import type { ToolModelContentPart } from "assistant-stream";
1
2
  import type { ReadonlyJSONValue } from "assistant-stream/utils";
2
3
  import type { ModelContext } from "../../model-context/types";
3
4
  import type { Unsubscribe } from "../../types/unsubscribe";
@@ -38,6 +39,13 @@ export type AddToolResultOptions = {
38
39
  result: ReadonlyJSONValue;
39
40
  isError: boolean;
40
41
  artifact?: ReadonlyJSONValue | undefined;
42
+ /**
43
+ * Optional model-content payload produced by the tool. Populated when a
44
+ * client-side `execute()` or `streamCall` returns a `ToolResponse` with
45
+ * `modelContent`. Forwarded through `adapter.onAddToolResult` so the
46
+ * adapter can include it when sending the result back to its backend.
47
+ */
48
+ modelContent?: readonly ToolModelContentPart[] | undefined;
41
49
  };
42
50
 
43
51
  export type ResumeToolCallOptions = {
@@ -18,10 +18,7 @@ export { DefaultEditComposerRuntimeCore } from "./base/default-edit-composer-run
18
18
  // Runtime Impl Classes
19
19
  export { AssistantRuntimeImpl } from "./api/assistant-runtime";
20
20
 
21
- export {
22
- getThreadState,
23
- ThreadRuntimeImpl,
24
- } from "./api/thread-runtime";
21
+ export { getThreadState, ThreadRuntimeImpl } from "./api/thread-runtime";
25
22
  export type {
26
23
  ThreadRuntimeCoreBinding,
27
24
  ThreadListItemRuntimeBinding,