@assistant-ui/core 0.2.7 → 0.2.9

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 (160) 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 +2 -1
  5. package/dist/index.js +2 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/internal/duplicate-detection.d.ts.map +1 -1
  8. package/dist/internal.d.ts +2 -2
  9. package/dist/internal.js +2 -2
  10. package/dist/model-context/frame/host.d.ts.map +1 -1
  11. package/dist/model-context/frame/host.js.map +1 -1
  12. package/dist/model-context/frame/provider.d.ts.map +1 -1
  13. package/dist/model-context/frame/provider.js.map +1 -1
  14. package/dist/model-context/registry.d.ts.map +1 -1
  15. package/dist/model-context/tool.d.ts.map +1 -1
  16. package/dist/react/client/Interactables.js.map +1 -1
  17. package/dist/react/client/Tools.d.ts.map +1 -1
  18. package/dist/react/client/Tools.js +26 -15
  19. package/dist/react/client/Tools.js.map +1 -1
  20. package/dist/react/index.d.ts +6 -3
  21. package/dist/react/index.js +4 -1
  22. package/dist/react/model-context/define-toolkit.d.ts +20 -0
  23. package/dist/react/model-context/define-toolkit.d.ts.map +1 -0
  24. package/dist/react/model-context/define-toolkit.js +21 -0
  25. package/dist/react/model-context/define-toolkit.js.map +1 -0
  26. package/dist/react/model-context/hitl.d.ts +19 -0
  27. package/dist/react/model-context/hitl.d.ts.map +1 -0
  28. package/dist/react/model-context/hitl.js +22 -0
  29. package/dist/react/model-context/hitl.js.map +1 -0
  30. package/dist/react/model-context/toolbox.d.ts +29 -2
  31. package/dist/react/model-context/toolbox.d.ts.map +1 -1
  32. package/dist/react/model-context/toolbox.js +18 -0
  33. package/dist/react/model-context/toolbox.js.map +1 -0
  34. package/dist/react/model-context/useAssistantTool.d.ts.map +1 -1
  35. package/dist/react/model-context/useAssistantTool.js +6 -3
  36. package/dist/react/model-context/useAssistantTool.js.map +1 -1
  37. package/dist/react/model-context/useAssistantToolUI.d.ts +6 -0
  38. package/dist/react/model-context/useAssistantToolUI.d.ts.map +1 -1
  39. package/dist/react/model-context/useAssistantToolUI.js +4 -2
  40. package/dist/react/model-context/useAssistantToolUI.js.map +1 -1
  41. package/dist/react/model-context/useInlineRender.js.map +1 -1
  42. package/dist/react/primitives/message/MessageGroupedParts.d.ts +49 -7
  43. package/dist/react/primitives/message/MessageGroupedParts.d.ts.map +1 -1
  44. package/dist/react/primitives/message/MessageGroupedParts.js +28 -3
  45. package/dist/react/primitives/message/MessageGroupedParts.js.map +1 -1
  46. package/dist/react/primitives/message/MessageParts.d.ts.map +1 -1
  47. package/dist/react/primitives/message/MessageParts.js +2 -7
  48. package/dist/react/primitives/message/MessageParts.js.map +1 -1
  49. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  50. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  51. package/dist/react/runtimes/RuntimeAdapterProvider.d.ts.map +1 -1
  52. package/dist/react/runtimes/RuntimeAdapterProvider.js +6 -5
  53. package/dist/react/runtimes/RuntimeAdapterProvider.js.map +1 -1
  54. package/dist/react/runtimes/cloud/CloudFileAttachmentAdapter.d.ts.map +1 -1
  55. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.d.ts.map +1 -1
  56. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js.map +1 -1
  57. package/dist/react/runtimes/external-message-converter.d.ts.map +1 -1
  58. package/dist/react/runtimes/external-message-converter.js +1 -0
  59. package/dist/react/runtimes/external-message-converter.js.map +1 -1
  60. package/dist/react/runtimes/useExternalStoreSharedOptions.d.ts +7 -0
  61. package/dist/react/runtimes/useExternalStoreSharedOptions.d.ts.map +1 -0
  62. package/dist/react/runtimes/useExternalStoreSharedOptions.js +21 -0
  63. package/dist/react/runtimes/useExternalStoreSharedOptions.js.map +1 -0
  64. package/dist/react/runtimes/useLocalRuntime.d.ts.map +1 -1
  65. package/dist/react/runtimes/useLocalRuntime.js.map +1 -1
  66. package/dist/react/runtimes/useRemoteThreadListRuntime.d.ts.map +1 -1
  67. package/dist/react/runtimes/useRemoteThreadListRuntime.js.map +1 -1
  68. package/dist/react/types/scopes/tools.d.ts +19 -2
  69. package/dist/react/types/scopes/tools.d.ts.map +1 -1
  70. package/dist/react/utils/groupParts.d.ts +32 -11
  71. package/dist/react/utils/groupParts.d.ts.map +1 -1
  72. package/dist/react/utils/groupParts.js +13 -6
  73. package/dist/react/utils/groupParts.js.map +1 -1
  74. package/dist/runtime/api/assistant-runtime.d.ts.map +1 -1
  75. package/dist/runtime/api/attachment-runtime.d.ts.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/utils/message-repository.d.ts +9 -1
  88. package/dist/runtime/utils/message-repository.d.ts.map +1 -1
  89. package/dist/runtime/utils/message-repository.js +34 -14
  90. package/dist/runtime/utils/message-repository.js.map +1 -1
  91. package/dist/runtime/utils/thread-message-like.d.ts +1 -0
  92. package/dist/runtime/utils/thread-message-like.d.ts.map +1 -1
  93. package/dist/runtime/utils/thread-message-like.js +2 -1
  94. package/dist/runtime/utils/thread-message-like.js.map +1 -1
  95. package/dist/runtimes/external-store/external-store-shared-options.d.ts +8 -0
  96. package/dist/runtimes/external-store/external-store-shared-options.d.ts.map +1 -0
  97. package/dist/runtimes/external-store/external-store-shared-options.js +11 -0
  98. package/dist/runtimes/external-store/external-store-shared-options.js.map +1 -0
  99. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts.map +1 -1
  100. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +0 -2
  101. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  102. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +12 -23
  103. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  104. package/dist/runtimes/external-store/thread-message-converter.d.ts.map +1 -1
  105. package/dist/runtimes/local/local-thread-list-runtime-core.d.ts.map +1 -1
  106. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  107. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
  108. package/dist/runtimes/remote-thread-list/adapter/in-memory.d.ts.map +1 -1
  109. package/dist/runtimes/remote-thread-list/optimistic-state.d.ts.map +1 -1
  110. package/dist/runtimes/tool-invocations/ToolInvocationTracker.d.ts.map +1 -1
  111. package/dist/runtimes/tool-invocations/ToolInvocationTracker.js.map +1 -1
  112. package/dist/subscribable/subscribable.d.ts.map +1 -1
  113. package/dist/tests/remote-thread-list-test-helpers.d.ts.map +1 -1
  114. package/dist/types/message.d.ts +6 -0
  115. package/dist/types/message.d.ts.map +1 -1
  116. package/dist/types/message.js.map +1 -1
  117. package/dist/utils/composite-context-provider.d.ts.map +1 -1
  118. package/dist/utils/id.d.ts +1 -3
  119. package/dist/utils/id.d.ts.map +1 -1
  120. package/dist/utils/id.js +1 -4
  121. package/dist/utils/id.js.map +1 -1
  122. package/package.json +10 -10
  123. package/src/adapters/speech.ts +0 -1
  124. package/src/index.ts +2 -0
  125. package/src/internal.ts +0 -2
  126. package/src/model-context/frame/host.ts +0 -1
  127. package/src/model-context/frame/provider.ts +0 -1
  128. package/src/react/client/Interactables.ts +0 -1
  129. package/src/react/client/Tools.ts +50 -25
  130. package/src/react/index.ts +10 -2
  131. package/src/react/model-context/define-toolkit.test.ts +13 -0
  132. package/src/react/model-context/define-toolkit.ts +23 -0
  133. package/src/react/model-context/hitl.ts +22 -0
  134. package/src/react/model-context/toolbox.ts +46 -1
  135. package/src/react/model-context/useAssistantTool.ts +8 -3
  136. package/src/react/model-context/useAssistantToolUI.ts +9 -2
  137. package/src/react/model-context/useInlineRender.ts +0 -1
  138. package/src/react/primitives/message/MessageGroupedParts.tsx +101 -12
  139. package/src/react/primitives/message/MessageParts.tsx +4 -7
  140. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +0 -3
  141. package/src/react/runtimes/RuntimeAdapterProvider.tsx +12 -7
  142. package/src/react/runtimes/cloud/useCloudThreadListAdapter.tsx +0 -3
  143. package/src/react/runtimes/external-message-converter.ts +4 -0
  144. package/src/react/runtimes/useExternalStoreSharedOptions.ts +23 -0
  145. package/src/react/runtimes/useLocalRuntime.ts +0 -10
  146. package/src/react/runtimes/useRemoteThreadListRuntime.ts +0 -6
  147. package/src/react/types/scopes/tools.ts +20 -1
  148. package/src/react/utils/groupParts.ts +49 -18
  149. package/src/runtime/utils/message-repository.ts +57 -16
  150. package/src/runtime/utils/thread-message-like.ts +2 -0
  151. package/src/runtimes/external-store/external-store-shared-options.ts +18 -0
  152. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +18 -33
  153. package/src/runtimes/tool-invocations/ToolInvocationTracker.ts +0 -1
  154. package/src/tests/MessageRepository.test.ts +83 -52
  155. package/src/tests/OptimisticState-list-race.test.ts +0 -4
  156. package/src/tests/external-store-thread-runtime-core.test.ts +105 -73
  157. package/src/tests/groupParts.test.ts +70 -0
  158. package/src/tests/remote-thread-list-isLoading.test.ts +0 -5
  159. package/src/types/message.ts +6 -0
  160. package/src/utils/id.ts +0 -4
@@ -0,0 +1,23 @@
1
+ import type { Toolkit, ToolkitDeclaration } from "./toolbox";
2
+
3
+ /**
4
+ * Authoring helper for a `"use generative"` toolkit. Accepts the permissive
5
+ * {@link ToolkitDeclaration} (a `backend` tool may carry its server `execute`)
6
+ * and types the result as the canonical {@link Toolkit}.
7
+ *
8
+ * It has **no runtime implementation**. A `"use generative"` compiler (e.g.
9
+ * `@assistant-ui/next` or `@assistant-ui/vite`) strips the `defineToolkit(...)`
10
+ * wrapper (and its import) per build, so a correctly compiled
11
+ * `export default defineToolkit({...})` never calls this. If it *does* run, the
12
+ * module was not compiled by a use-generative loader — e.g. `defineToolkit` used
13
+ * outside a `"use generative"` file — which would ship a backend `execute` to the
14
+ * client. So it throws instead of silently leaking.
15
+ */
16
+ export function defineToolkit(_declaration: ToolkitDeclaration): Toolkit {
17
+ throw new Error(
18
+ "[assistant-ui] defineToolkit() has no runtime implementation — it is " +
19
+ "stripped at build time by the use-generative compiler. Reaching it means " +
20
+ 'this module was not compiled (e.g. defineToolkit used outside a "use ' +
21
+ 'generative" file). Add the directive, or do not use defineToolkit here.',
22
+ );
23
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Marks a tool as **human-in-the-loop**: the agent pauses and the UI (`render`)
3
+ * supplies the result instead of code. Use it as the tool's `execute`:
4
+ *
5
+ * ```tsx
6
+ * confirm: { execute: hitl(), render: (props) => <Confirm {...props} /> }
7
+ * ```
8
+ *
9
+ * Like {@link defineToolkit}, it has **no runtime implementation**: a
10
+ * `"use generative"` compiler (e.g. `@assistant-ui/next` or `@assistant-ui/vite`)
11
+ * detects `execute: hitl()`, drops it, and stamps the tool `type: "human"`.
12
+ * Reaching it at runtime means the module wasn't compiled (used outside a
13
+ * `"use generative"` file), so it throws.
14
+ */
15
+ export function hitl(): never {
16
+ throw new Error(
17
+ "[assistant-ui] hitl() has no runtime implementation — it marks a " +
18
+ "human-in-the-loop tool and is stripped at build time by the " +
19
+ "use-generative compiler. Reaching it means this module was not compiled " +
20
+ '(e.g. hitl() used outside a "use generative" file).',
21
+ );
22
+ }
@@ -1,6 +1,22 @@
1
- import type { Tool } from "assistant-stream";
1
+ import type { Tool, ToolDeclaration } from "assistant-stream";
2
2
  import type { ToolCallMessagePartComponent } from "../types/MessagePartComponentTypes";
3
3
 
4
+ /**
5
+ * Resolves whether a tool's UI should be presented standalone (outside the
6
+ * chain-of-thought grouping), applying the type-based defaults.
7
+ *
8
+ * An explicit `display` wins. Otherwise `human` tools default to standalone
9
+ * (they prompt the user), and every other tool defaults to inline (a trace of
10
+ * what the model is doing). MCP-app tool calls are detected separately from
11
+ * the part itself and are not resolved here.
12
+ */
13
+ export const isStandaloneToolDisplay = (
14
+ tool: Pick<Tool<any, any>, "type" | "display">,
15
+ ): boolean => {
16
+ if (tool.display !== undefined) return tool.display === "standalone";
17
+ return tool.type === "human";
18
+ };
19
+
4
20
  type WithRender<T, TArgs extends Record<string, unknown>, TResult> = T extends {
5
21
  type: "frontend" | "human";
6
22
  }
@@ -43,6 +59,35 @@ export type ToolDefinition<
43
59
  */
44
60
  export type Toolkit = Record<string, ToolDefinition<any, any>>;
45
61
 
62
+ /**
63
+ * A tool as authored, before the build splits it: like {@link ToolDefinition}
64
+ * but it may declare `description`, `parameters`, and a server-side `execute`
65
+ * alongside its `render`. The `type` field is **not** authored — the
66
+ * `"use generative"` compiler infers it (`execute: hitl()` → human; `execute`
67
+ * with a `"use client"` directive → frontend; otherwise backend) and writes it
68
+ * back — so declaring it here is a type error.
69
+ */
70
+ export type ToolkitDeclarationDefinition<
71
+ TArgs extends Record<string, unknown>,
72
+ TResult,
73
+ > = WithRender<
74
+ Omit<ToolDeclaration<TArgs, TResult>, "type">,
75
+ TArgs,
76
+ TResult
77
+ > & {
78
+ type?: never;
79
+ };
80
+
81
+ /**
82
+ * The permissive, authoring-time counterpart to {@link Toolkit} — the input to
83
+ * {@link defineToolkit}. Backend entries may carry their server `execute` here;
84
+ * the canonical {@link Toolkit} keeps those fields `undefined`.
85
+ */
86
+ export type ToolkitDeclaration = Record<
87
+ string,
88
+ ToolkitDeclarationDefinition<any, any>
89
+ >;
90
+
46
91
  /** Configuration for the {@link Tools} resource. */
47
92
  export type ToolsConfig = {
48
93
  /** Tools to register with model context and, when provided, message renderers. */
@@ -2,6 +2,7 @@ import { useEffect } from "react";
2
2
  import { useAui } from "@assistant-ui/store";
3
3
  import type { ToolCallMessagePartComponent } from "../types/MessagePartComponentTypes";
4
4
  import type { AssistantToolProps as CoreAssistantToolProps } from "../..";
5
+ import { isStandaloneToolDisplay } from "./toolbox";
5
6
 
6
7
  /**
7
8
  * Props used to register a tool from React.
@@ -52,13 +53,17 @@ export const useAssistantTool = <
52
53
  ) => {
53
54
  const aui = useAui();
54
55
 
56
+ const standalone = isStandaloneToolDisplay(tool);
57
+
55
58
  useEffect(() => {
56
59
  if (!tool.render) return undefined;
57
- return aui.tools().setToolUI(tool.toolName, tool.render);
58
- }, [aui, tool.toolName, tool.render]);
60
+ return aui.tools().setToolUI(tool.toolName, tool.render, { standalone });
61
+ }, [aui, tool.toolName, tool.render, standalone]);
59
62
 
60
63
  useEffect(() => {
61
- const { toolName, render, ...rest } = tool;
64
+ // `render` and `display` are client-only presentation concerns and never
65
+ // reach the model.
66
+ const { toolName, render, display, ...rest } = tool;
62
67
  const context = {
63
68
  tools: {
64
69
  [toolName]: rest,
@@ -8,6 +8,12 @@ export type AssistantToolUIProps<TArgs, TResult> = {
8
8
  toolName: string;
9
9
  /** Component rendered for matching tool-call message parts. */
10
10
  render: ToolCallMessagePartComponent<TArgs, TResult>;
11
+ /**
12
+ * How the UI is presented relative to the chain-of-thought trace. Set
13
+ * `"standalone"` to surface it on its own (e.g. human-in-the-loop or
14
+ * generative UI for a backend/MCP tool). Defaults to `"inline"`.
15
+ */
16
+ display?: "standalone" | "inline";
11
17
  };
12
18
 
13
19
  /**
@@ -23,8 +29,9 @@ export const useAssistantToolUI = (
23
29
  tool: AssistantToolUIProps<any, any> | null,
24
30
  ) => {
25
31
  const aui = useAui();
32
+ const standalone = tool?.display === "standalone";
26
33
  useEffect(() => {
27
34
  if (!tool?.toolName || !tool?.render) return undefined;
28
- return aui.tools().setToolUI(tool.toolName, tool.render);
29
- }, [aui, tool?.toolName, tool?.render]);
35
+ return aui.tools().setToolUI(tool.toolName, tool.render, { standalone });
36
+ }, [aui, tool?.toolName, tool?.render, standalone]);
30
37
  };
@@ -17,7 +17,6 @@ export const useInlineRender = <TArgs, TResult>(
17
17
 
18
18
  return useCallback(
19
19
  function ToolUI(args) {
20
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
21
20
  const store = useToolUIStore();
22
21
  return store.toolUI(args);
23
22
  },
@@ -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
- const tree = useMemo(
186
- () => buildGroupTree(parts.map((part) => groupBy(part) ?? [])),
265
+ const tree = useMemo(() => {
266
+ const context: GroupByContext = { toolUIs };
267
+ return buildGroupTree(parts.map((part) => groupBy(part, context) ?? []));
187
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
188
- [parts, memoDep],
189
- );
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,
@@ -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