@assistant-ui/react 0.11.47 → 0.11.49

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 (86) hide show
  1. package/dist/client/types/Thread.d.ts +4 -0
  2. package/dist/client/types/Thread.d.ts.map +1 -1
  3. package/dist/context/react/index.d.ts +1 -1
  4. package/dist/context/react/index.d.ts.map +1 -1
  5. package/dist/context/react/index.js.map +1 -1
  6. package/dist/context/stores/ThreadViewport.js +1 -1
  7. package/dist/context/stores/ThreadViewport.js.map +1 -1
  8. package/dist/legacy-runtime/client/ThreadRuntimeClient.d.ts.map +1 -1
  9. package/dist/legacy-runtime/client/ThreadRuntimeClient.js +1 -0
  10. package/dist/legacy-runtime/client/ThreadRuntimeClient.js.map +1 -1
  11. package/dist/legacy-runtime/runtime/subscribable/SKIP_UPDATE.js +1 -1
  12. package/dist/legacy-runtime/runtime/subscribable/SKIP_UPDATE.js.map +1 -1
  13. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js +2 -2
  14. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
  15. package/dist/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.d.ts +1 -4
  16. package/dist/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.d.ts.map +1 -1
  17. package/dist/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.js +34 -12
  18. package/dist/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.js.map +1 -1
  19. package/dist/legacy-runtime/runtime-cores/external-store/auto-status.js +1 -1
  20. package/dist/legacy-runtime/runtime-cores/external-store/auto-status.js.map +1 -1
  21. package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.js +2 -2
  22. package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.js.map +1 -1
  23. package/dist/model-context/registry/ModelContextRegistry.js +3 -3
  24. package/dist/model-context/registry/ModelContextRegistry.js.map +1 -1
  25. package/dist/primitives/actionBar/ActionBarExportMarkdown.d.ts +17 -0
  26. package/dist/primitives/actionBar/ActionBarExportMarkdown.d.ts.map +1 -0
  27. package/dist/primitives/actionBar/ActionBarExportMarkdown.js +54 -0
  28. package/dist/primitives/actionBar/ActionBarExportMarkdown.js.map +1 -0
  29. package/dist/primitives/actionBar/index.d.ts +1 -0
  30. package/dist/primitives/actionBar/index.d.ts.map +1 -1
  31. package/dist/primitives/actionBar/index.js +2 -0
  32. package/dist/primitives/actionBar/index.js.map +1 -1
  33. package/dist/primitives/assistant/AssistantIf.d.ts +12 -0
  34. package/dist/primitives/assistant/AssistantIf.d.ts.map +1 -0
  35. package/dist/primitives/assistant/AssistantIf.js +16 -0
  36. package/dist/primitives/assistant/AssistantIf.js.map +1 -0
  37. package/dist/primitives/composer/ComposerIf.d.ts +3 -0
  38. package/dist/primitives/composer/ComposerIf.d.ts.map +1 -1
  39. package/dist/primitives/composer/ComposerIf.js.map +1 -1
  40. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  41. package/dist/primitives/composer/ComposerInput.js +1 -0
  42. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  43. package/dist/primitives/index.d.ts +1 -0
  44. package/dist/primitives/index.d.ts.map +1 -1
  45. package/dist/primitives/index.js +2 -0
  46. package/dist/primitives/index.js.map +1 -1
  47. package/dist/primitives/message/MessageIf.d.ts +3 -0
  48. package/dist/primitives/message/MessageIf.d.ts.map +1 -1
  49. package/dist/primitives/message/MessageIf.js.map +1 -1
  50. package/dist/primitives/message/MessageParts.js +2 -2
  51. package/dist/primitives/message/MessageParts.js.map +1 -1
  52. package/dist/primitives/thread/ThreadIf.d.ts +3 -0
  53. package/dist/primitives/thread/ThreadIf.d.ts.map +1 -1
  54. package/dist/primitives/thread/ThreadIf.js +2 -3
  55. package/dist/primitives/thread/ThreadIf.js.map +1 -1
  56. package/dist/primitives/thread/ThreadViewport.d.ts +36 -0
  57. package/dist/primitives/thread/ThreadViewport.d.ts.map +1 -1
  58. package/dist/primitives/thread/ThreadViewport.js +21 -12
  59. package/dist/primitives/thread/ThreadViewport.js.map +1 -1
  60. package/dist/primitives/thread/ThreadViewportSlack.d.ts.map +1 -1
  61. package/dist/primitives/thread/ThreadViewportSlack.js +4 -1
  62. package/dist/primitives/thread/ThreadViewportSlack.js.map +1 -1
  63. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts +20 -2
  64. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  65. package/dist/primitives/thread/useThreadViewportAutoScroll.js +21 -2
  66. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  67. package/dist/tests/setup.js +44 -42
  68. package/dist/tests/setup.js.map +1 -1
  69. package/package.json +7 -7
  70. package/src/client/types/Thread.ts +5 -0
  71. package/src/context/react/index.ts +1 -0
  72. package/src/legacy-runtime/client/ThreadRuntimeClient.ts +1 -0
  73. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.tsx +1 -1
  74. package/src/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.ts +40 -17
  75. package/src/primitives/actionBar/ActionBarExportMarkdown.tsx +70 -0
  76. package/src/primitives/actionBar/index.ts +1 -0
  77. package/src/primitives/assistant/AssistantIf.tsx +25 -0
  78. package/src/primitives/composer/ComposerIf.tsx +3 -0
  79. package/src/primitives/composer/ComposerInput.tsx +3 -0
  80. package/src/primitives/index.ts +2 -0
  81. package/src/primitives/message/MessageIf.tsx +3 -0
  82. package/src/primitives/message/MessageParts.tsx +2 -2
  83. package/src/primitives/thread/ThreadIf.tsx +5 -3
  84. package/src/primitives/thread/ThreadViewport.tsx +49 -18
  85. package/src/primitives/thread/ThreadViewportSlack.tsx +4 -1
  86. package/src/primitives/thread/useThreadViewportAutoScroll.tsx +48 -2
package/package.json CHANGED
@@ -28,7 +28,7 @@
28
28
  "conversational-ui",
29
29
  "conversational-ai"
30
30
  ],
31
- "version": "0.11.47",
31
+ "version": "0.11.49",
32
32
  "license": "MIT",
33
33
  "type": "module",
34
34
  "exports": {
@@ -48,8 +48,8 @@
48
48
  ],
49
49
  "sideEffects": false,
50
50
  "dependencies": {
51
- "assistant-cloud": "^0.1.9",
52
- "@assistant-ui/tap": "^0.3.1",
51
+ "assistant-cloud": "^0.1.10",
52
+ "@assistant-ui/tap": "^0.3.2",
53
53
  "@radix-ui/primitive": "^1.1.3",
54
54
  "@radix-ui/react-compose-refs": "^1.1.2",
55
55
  "@radix-ui/react-context": "^1.1.3",
@@ -59,11 +59,11 @@
59
59
  "@radix-ui/react-use-callback-ref": "^1.1.1",
60
60
  "@radix-ui/react-use-escape-keydown": "^1.1.1",
61
61
  "@standard-schema/spec": "^1.0.0",
62
- "assistant-stream": "^0.2.42",
62
+ "assistant-stream": "^0.2.44",
63
63
  "nanoid": "5.1.6",
64
64
  "react-textarea-autosize": "^8.5.9",
65
65
  "zod": "^4.1.13",
66
- "zustand": "^5.0.8"
66
+ "zustand": "^5.0.9"
67
67
  },
68
68
  "peerDependencies": {
69
69
  "@types/react": "*",
@@ -84,8 +84,8 @@
84
84
  "@stryker-mutator/vitest-runner": "^9.4.0",
85
85
  "@types/json-schema": "^7.0.15",
86
86
  "@types/node": "^24.10.1",
87
- "tsx": "^4.20.6",
88
- "vitest": "^4.0.14",
87
+ "tsx": "^4.21.0",
88
+ "vitest": "^4.0.15",
89
89
  "@assistant-ui/x-buildutils": "0.0.1"
90
90
  },
91
91
  "publishConfig": {
@@ -19,6 +19,11 @@ import { CreateResumeRunConfig } from "../../legacy-runtime/runtime/ThreadRuntim
19
19
  import { ModelContext } from "../../model-context";
20
20
 
21
21
  export type ThreadClientState = {
22
+ /**
23
+ * Whether the thread is empty. A thread is considered empty when it has no messages and is not loading.
24
+ */
25
+ readonly isEmpty: boolean;
26
+
22
27
  /**
23
28
  * Whether the thread is disabled. Disabled threads cannot receive new messages.
24
29
  */
@@ -6,6 +6,7 @@ export {
6
6
  useAssistantApi,
7
7
  useExtendedAssistantApi,
8
8
  type AssistantApi,
9
+ type AssistantState,
9
10
  } from "./AssistantApiContext";
10
11
  export { useAssistantState } from "./hooks/useAssistantState";
11
12
  export { useAssistantEvent } from "./hooks/useAssistantEvent";
@@ -97,6 +97,7 @@ export const ThreadClient = resource(
97
97
 
98
98
  const state = tapMemo<ThreadClientState>(() => {
99
99
  return {
100
+ isEmpty: messages.state.length === 0 && !runtimeState.isLoading,
100
101
  isDisabled: runtimeState.isDisabled,
101
102
  isLoading: runtimeState.isLoading,
102
103
  isRunning: runtimeState.isRunning,
@@ -274,7 +274,7 @@ const useAssistantTransportThreadRuntime = <T,>(
274
274
  },
275
275
  onCancel: async () => {
276
276
  runManager.cancel();
277
- toolInvocations.abort();
277
+ await toolInvocations.abort();
278
278
  },
279
279
  onResume: async () => {
280
280
  if (!options.resumeApi)
@@ -39,8 +39,7 @@ type UseToolInvocationsParams = {
39
39
 
40
40
  export type ToolExecutionStatus =
41
41
  | { type: "executing" }
42
- | { type: "interrupt"; payload: { type: "human"; payload: unknown } }
43
- | { type: "cancelled"; reason: string };
42
+ | { type: "interrupt"; payload: { type: "human"; payload: unknown } };
44
43
 
45
44
  export function useToolInvocations({
46
45
  state,
@@ -71,6 +70,9 @@ export function useToolInvocations({
71
70
  >(new Map());
72
71
 
73
72
  const acRef = useRef<AbortController>(new AbortController());
73
+ const executingCountRef = useRef(0);
74
+ const settledResolversRef = useRef<Array<() => void>>([]);
75
+
74
76
  const [controller] = useState(() => {
75
77
  const [stream, controller] = createAssistantStreamController();
76
78
  const transform = unstable_toolResultStream(
@@ -96,6 +98,28 @@ export function useToolInvocations({
96
98
  }));
97
99
  });
98
100
  },
101
+ {
102
+ onExecutionStart: (toolCallId: string) => {
103
+ executingCountRef.current++;
104
+ setToolStatuses((prev) => ({
105
+ ...prev,
106
+ [toolCallId]: { type: "executing" },
107
+ }));
108
+ },
109
+ onExecutionEnd: (toolCallId: string) => {
110
+ executingCountRef.current--;
111
+ setToolStatuses((prev) => {
112
+ const next = { ...prev };
113
+ delete next[toolCallId];
114
+ return next;
115
+ });
116
+ // Resolve any waiting abort promises when all tools have settled
117
+ if (executingCountRef.current === 0) {
118
+ settledResolversRef.current.forEach((resolve) => resolve());
119
+ settledResolversRef.current = [];
120
+ }
121
+ },
122
+ },
99
123
  );
100
124
  stream
101
125
  .pipeThrough(transform)
@@ -116,13 +140,6 @@ export function useToolInvocations({
116
140
  isError: chunk.isError,
117
141
  ...(chunk.artifact && { artifact: chunk.artifact }),
118
142
  });
119
-
120
- // Clear status when result is set
121
- setToolStatuses((prev) => {
122
- const next = { ...prev };
123
- delete next[chunk.meta.toolCallId];
124
- return next;
125
- });
126
143
  }
127
144
  },
128
145
  }),
@@ -234,20 +251,27 @@ export function useToolInvocations({
234
251
  }
235
252
  }, [state, controller, onResult]);
236
253
 
237
- const abort = () => {
254
+ const abort = (): Promise<void> => {
238
255
  humanInputRef.current.forEach(({ reject }) => {
239
256
  reject(new Error("Tool execution aborted"));
240
257
  });
241
258
  humanInputRef.current.clear();
242
- setToolStatuses({});
243
259
 
244
260
  acRef.current.abort();
245
261
  acRef.current = new AbortController();
262
+
263
+ // Return a promise that resolves when all executing tools have settled
264
+ if (executingCountRef.current === 0) {
265
+ return Promise.resolve();
266
+ }
267
+ return new Promise<void>((resolve) => {
268
+ settledResolversRef.current.push(resolve);
269
+ });
246
270
  };
247
271
 
248
272
  return {
249
273
  reset: () => {
250
- abort();
274
+ void abort();
251
275
  isInitialState.current = true;
252
276
  },
253
277
  abort,
@@ -255,11 +279,10 @@ export function useToolInvocations({
255
279
  const handlers = humanInputRef.current.get(toolCallId);
256
280
  if (handlers) {
257
281
  humanInputRef.current.delete(toolCallId);
258
- setToolStatuses((prev) => {
259
- const next = { ...prev };
260
- delete next[toolCallId];
261
- return next;
262
- });
282
+ setToolStatuses((prev) => ({
283
+ ...prev,
284
+ [toolCallId]: { type: "executing" },
285
+ }));
263
286
  handlers.resolve(payload);
264
287
  } else {
265
288
  throw new Error(
@@ -0,0 +1,70 @@
1
+ "use client";
2
+
3
+ import { forwardRef, useCallback } from "react";
4
+ import { ActionButtonProps } from "../../utils/createActionButton";
5
+ import { composeEventHandlers } from "@radix-ui/primitive";
6
+ import { Primitive } from "@radix-ui/react-primitive";
7
+ import { useAssistantState, useAssistantApi } from "../../context";
8
+
9
+ const useActionBarExportMarkdown = ({
10
+ filename,
11
+ onExport,
12
+ }: {
13
+ filename?: string | undefined;
14
+ onExport?: ((content: string) => void | Promise<void>) | undefined;
15
+ } = {}) => {
16
+ const api = useAssistantApi();
17
+ const hasExportableContent = useAssistantState(({ message }) => {
18
+ return (
19
+ (message.role !== "assistant" || message.status?.type !== "running") &&
20
+ message.parts.some((c) => c.type === "text" && c.text.length > 0)
21
+ );
22
+ });
23
+
24
+ const callback = useCallback(async () => {
25
+ const content = api.message().getCopyText();
26
+ if (!content) return;
27
+
28
+ if (onExport) {
29
+ await onExport(content);
30
+ return;
31
+ }
32
+
33
+ const blob = new Blob([content], { type: "text/markdown" });
34
+ const url = URL.createObjectURL(blob);
35
+ const a = document.createElement("a");
36
+ a.href = url;
37
+ a.download = filename ?? `message-${Date.now()}.md`;
38
+ a.click();
39
+ URL.revokeObjectURL(url);
40
+ }, [api, filename, onExport]);
41
+
42
+ if (!hasExportableContent) return null;
43
+ return callback;
44
+ };
45
+
46
+ export namespace ActionBarPrimitiveExportMarkdown {
47
+ export type Element = HTMLButtonElement;
48
+ export type Props = ActionButtonProps<typeof useActionBarExportMarkdown>;
49
+ }
50
+
51
+ export const ActionBarPrimitiveExportMarkdown = forwardRef<
52
+ ActionBarPrimitiveExportMarkdown.Element,
53
+ ActionBarPrimitiveExportMarkdown.Props
54
+ >(({ filename, onExport, onClick, disabled, ...props }, forwardedRef) => {
55
+ const callback = useActionBarExportMarkdown({ filename, onExport });
56
+ return (
57
+ <Primitive.button
58
+ type="button"
59
+ {...props}
60
+ ref={forwardedRef}
61
+ disabled={disabled || !callback}
62
+ onClick={composeEventHandlers(onClick, () => {
63
+ callback?.();
64
+ })}
65
+ />
66
+ );
67
+ });
68
+
69
+ ActionBarPrimitiveExportMarkdown.displayName =
70
+ "ActionBarPrimitive.ExportMarkdown";
@@ -6,3 +6,4 @@ export { ActionBarPrimitiveSpeak as Speak } from "./ActionBarSpeak";
6
6
  export { ActionBarPrimitiveStopSpeaking as StopSpeaking } from "./ActionBarStopSpeaking";
7
7
  export { ActionBarPrimitiveFeedbackPositive as FeedbackPositive } from "./ActionBarFeedbackPositive";
8
8
  export { ActionBarPrimitiveFeedbackNegative as FeedbackNegative } from "./ActionBarFeedbackNegative";
9
+ export { ActionBarPrimitiveExportMarkdown as ExportMarkdown } from "./ActionBarExportMarkdown";
@@ -0,0 +1,25 @@
1
+ "use client";
2
+
3
+ import type { FC, PropsWithChildren } from "react";
4
+ import { useAssistantState } from "../../context";
5
+ import type { AssistantState } from "../../context/react/AssistantApiContext";
6
+
7
+ type UseAssistantIfProps = {
8
+ condition: AssistantIf.Condition;
9
+ };
10
+
11
+ const useAssistantIf = (props: UseAssistantIfProps) => {
12
+ return useAssistantState(props.condition);
13
+ };
14
+
15
+ export namespace AssistantIf {
16
+ export type Props = PropsWithChildren<UseAssistantIfProps>;
17
+ export type Condition = (state: AssistantState) => boolean;
18
+ }
19
+
20
+ export const AssistantIf: FC<AssistantIf.Props> = ({ children, condition }) => {
21
+ const result = useAssistantIf({ condition });
22
+ return result ? children : null;
23
+ };
24
+
25
+ AssistantIf.displayName = "AssistantIf";
@@ -23,6 +23,9 @@ export namespace ComposerPrimitiveIf {
23
23
  export type Props = PropsWithChildren<UseComposerIfProps>;
24
24
  }
25
25
 
26
+ /**
27
+ * @deprecated Use `<AssistantIf condition={({ composer }) => ...} />` instead.
28
+ */
26
29
  export const ComposerPrimitiveIf: FC<ComposerPrimitiveIf.Props> = ({
27
30
  children,
28
31
  ...query
@@ -114,6 +114,9 @@ export const ComposerPrimitiveInput = forwardRef<
114
114
  useEscapeKeydown((e) => {
115
115
  if (!cancelOnEscape) return;
116
116
 
117
+ // Only handle ESC if it originated from within this input
118
+ if (!textareaRef.current?.contains(e.target as Node)) return;
119
+
117
120
  const composer = api.composer();
118
121
  if (composer.getState().canCancel) {
119
122
  composer.cancel();
@@ -10,6 +10,8 @@ export * as ThreadPrimitive from "./thread";
10
10
  export * as ThreadListPrimitive from "./threadList";
11
11
  export * as ThreadListItemPrimitive from "./threadListItem";
12
12
 
13
+ export { AssistantIf } from "./assistant/AssistantIf";
14
+
13
15
  export { useMessagePartText } from "./messagePart/useMessagePartText";
14
16
  export { useMessagePartReasoning } from "./messagePart/useMessagePartReasoning";
15
17
  export { useMessagePartSource } from "./messagePart/useMessagePartSource";
@@ -77,6 +77,9 @@ export namespace MessagePrimitiveIf {
77
77
  export type Props = PropsWithChildren<UseMessageIfProps>;
78
78
  }
79
79
 
80
+ /**
81
+ * @deprecated Use `<AssistantIf condition={({ message }) => ...} />` instead.
82
+ */
80
83
  export const MessagePrimitiveIf: FC<MessagePrimitiveIf.Props> = ({
81
84
  children,
82
85
  ...query
@@ -478,7 +478,7 @@ export const MessagePrimitiveParts: FC<MessagePrimitiveParts.Props> = ({
478
478
  );
479
479
  } else if (range.type === "toolGroup") {
480
480
  const ToolGroupComponent =
481
- components!.ToolGroup ?? defaultComponents.ToolGroup;
481
+ components?.ToolGroup ?? defaultComponents.ToolGroup;
482
482
  return (
483
483
  <ToolGroupComponent
484
484
  key={`tool-${range.startIndex}`}
@@ -500,7 +500,7 @@ export const MessagePrimitiveParts: FC<MessagePrimitiveParts.Props> = ({
500
500
  } else {
501
501
  // reasoningGroup
502
502
  const ReasoningGroupComponent =
503
- components!.ReasoningGroup ?? defaultComponents.ReasoningGroup;
503
+ components?.ReasoningGroup ?? defaultComponents.ReasoningGroup;
504
504
  return (
505
505
  <ReasoningGroupComponent
506
506
  key={`reasoning-${range.startIndex}`}
@@ -14,9 +14,8 @@ type UseThreadIfProps = RequireAtLeastOne<ThreadIfFilters>;
14
14
 
15
15
  const useThreadIf = (props: UseThreadIfProps) => {
16
16
  return useAssistantState(({ thread }) => {
17
- const isEmpty = thread.messages.length === 0 && !thread.isLoading;
18
- if (props.empty === true && !isEmpty) return false;
19
- if (props.empty === false && isEmpty) return false;
17
+ if (props.empty === true && !thread.isEmpty) return false;
18
+ if (props.empty === false && thread.isEmpty) return false;
20
19
 
21
20
  if (props.running === true && !thread.isRunning) return false;
22
21
  if (props.running === false && thread.isRunning) return false;
@@ -31,6 +30,9 @@ export namespace ThreadPrimitiveIf {
31
30
  export type Props = PropsWithChildren<UseThreadIfProps>;
32
31
  }
33
32
 
33
+ /**
34
+ * @deprecated Use `<AssistantIf condition={({ thread }) => ...} />` instead.
35
+ */
34
36
  export const ThreadPrimitiveIf: FC<ThreadPrimitiveIf.Props> = ({
35
37
  children,
36
38
  ...query
@@ -30,36 +30,67 @@ export namespace ThreadPrimitiveViewport {
30
30
  * - "top": New user messages anchor at the top of the viewport for a focused reading experience.
31
31
  */
32
32
  turnAnchor?: "top" | "bottom" | undefined;
33
+
34
+ /**
35
+ * Whether to scroll to bottom when a new run starts.
36
+ *
37
+ * Defaults to true.
38
+ */
39
+ scrollToBottomOnRunStart?: boolean | undefined;
40
+
41
+ /**
42
+ * Whether to scroll to bottom when thread history is first loaded.
43
+ *
44
+ * Defaults to true.
45
+ */
46
+ scrollToBottomOnInitialize?: boolean | undefined;
47
+
48
+ /**
49
+ * Whether to scroll to bottom when switching to a different thread.
50
+ *
51
+ * Defaults to true.
52
+ */
53
+ scrollToBottomOnThreadSwitch?: boolean | undefined;
33
54
  };
34
55
  }
35
56
 
36
57
  const useViewportSizeRef = () => {
37
58
  const register = useThreadViewport((s) => s.registerViewport);
38
- const getHeight = useCallback(
39
- (el: HTMLElement) =>
40
- el.clientHeight - parseFloat(getComputedStyle(el).paddingTop),
41
- [],
42
- );
43
-
59
+ const getHeight = useCallback((el: HTMLElement) => el.clientHeight, []);
44
60
  return useSizeHandle(register, getHeight);
45
61
  };
46
62
 
47
63
  const ThreadPrimitiveViewportScrollable = forwardRef<
48
64
  ThreadPrimitiveViewport.Element,
49
65
  ThreadPrimitiveViewport.Props
50
- >(({ autoScroll, children, ...rest }, forwardedRef) => {
51
- const autoScrollRef = useThreadViewportAutoScroll<HTMLDivElement>({
52
- autoScroll,
53
- });
54
- const viewportSizeRef = useViewportSizeRef();
55
- const ref = useComposedRefs(forwardedRef, autoScrollRef, viewportSizeRef);
66
+ >(
67
+ (
68
+ {
69
+ autoScroll,
70
+ scrollToBottomOnRunStart,
71
+ scrollToBottomOnInitialize,
72
+ scrollToBottomOnThreadSwitch,
73
+ children,
74
+ ...rest
75
+ },
76
+ forwardedRef,
77
+ ) => {
78
+ const autoScrollRef = useThreadViewportAutoScroll<HTMLDivElement>({
79
+ autoScroll,
80
+ scrollToBottomOnRunStart,
81
+ scrollToBottomOnInitialize,
82
+ scrollToBottomOnThreadSwitch,
83
+ });
84
+ const viewportSizeRef = useViewportSizeRef();
85
+ const ref = useComposedRefs(forwardedRef, autoScrollRef, viewportSizeRef);
56
86
 
57
- return (
58
- <Primitive.div {...rest} ref={ref}>
59
- {children}
60
- </Primitive.div>
61
- );
62
- });
87
+ return (
88
+ <Primitive.div {...rest} ref={ref}>
89
+ {children}
90
+ </Primitive.div>
91
+ );
92
+ },
93
+ );
63
94
 
64
95
  ThreadPrimitiveViewportScrollable.displayName =
65
96
  "ThreadPrimitive.ViewportScrollable";
@@ -57,7 +57,10 @@ export const ThreadPrimitiveViewportSlack: FC<ThreadViewportSlackProps> = ({
57
57
  fillClampThreshold = "10em",
58
58
  fillClampOffset = "6em",
59
59
  }) => {
60
- const isLast = useAssistantState(({ message }) => message.isLast);
60
+ const isLast = useAssistantState(
61
+ // only add slack if the message is the last message and we already have at least 3 messages
62
+ ({ message }) => message.isLast && message.index >= 2,
63
+ );
61
64
  const threadViewportStore = useThreadViewportStore({ optional: true });
62
65
  const isNested = useContext(SlackNestingContext);
63
66
 
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useComposedRefs } from "@radix-ui/react-compose-refs";
4
- import { RefCallback, useCallback, useRef } from "react";
4
+ import { useCallback, useRef, type RefCallback } from "react";
5
5
  import { useAssistantEvent } from "../../context";
6
6
  import { useOnResizeContent } from "../../utils/hooks/useOnResizeContent";
7
7
  import { useOnScrollToBottom } from "../../utils/hooks/useOnScrollToBottom";
@@ -18,11 +18,35 @@ export namespace useThreadViewportAutoScroll {
18
18
  * Default false if `turnAnchor` is "top", otherwise defaults to true.
19
19
  */
20
20
  autoScroll?: boolean | undefined;
21
+
22
+ /**
23
+ * Whether to scroll to bottom when a new run starts.
24
+ *
25
+ * Defaults to true.
26
+ */
27
+ scrollToBottomOnRunStart?: boolean | undefined;
28
+
29
+ /**
30
+ * Whether to scroll to bottom when thread history is first loaded.
31
+ *
32
+ * Defaults to true.
33
+ */
34
+ scrollToBottomOnInitialize?: boolean | undefined;
35
+
36
+ /**
37
+ * Whether to scroll to bottom when switching to a different thread.
38
+ *
39
+ * Defaults to true.
40
+ */
41
+ scrollToBottomOnThreadSwitch?: boolean | undefined;
21
42
  };
22
43
  }
23
44
 
24
45
  export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
25
46
  autoScroll,
47
+ scrollToBottomOnRunStart = true,
48
+ scrollToBottomOnInitialize = true,
49
+ scrollToBottomOnThreadSwitch = true,
26
50
  }: useThreadViewportAutoScroll.Options): RefCallback<TElement> => {
27
51
  const divRef = useRef<TElement>(null);
28
52
 
@@ -62,7 +86,10 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
62
86
  scrollingToBottomBehaviorRef.current = null;
63
87
  }
64
88
 
65
- if (newIsAtBottom !== isAtBottom) {
89
+ const shouldUpdate =
90
+ newIsAtBottom || scrollingToBottomBehaviorRef.current === null;
91
+
92
+ if (shouldUpdate && newIsAtBottom !== isAtBottom) {
66
93
  writableStore(threadViewportStore).setState({
67
94
  isAtBottom: newIsAtBottom,
68
95
  });
@@ -96,12 +123,31 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
96
123
 
97
124
  // autoscroll on run start
98
125
  useAssistantEvent("thread.run-start", () => {
126
+ if (!scrollToBottomOnRunStart) return;
99
127
  scrollingToBottomBehaviorRef.current = "auto";
100
128
  requestAnimationFrame(() => {
101
129
  scrollToBottom("auto");
102
130
  });
103
131
  });
104
132
 
133
+ // scroll to bottom instantly when thread history is first loaded
134
+ useAssistantEvent("thread.initialize", () => {
135
+ if (!scrollToBottomOnInitialize) return;
136
+ scrollingToBottomBehaviorRef.current = "instant";
137
+ requestAnimationFrame(() => {
138
+ scrollToBottom("instant");
139
+ });
140
+ });
141
+
142
+ // scroll to bottom instantly when switching threads
143
+ useAssistantEvent("thread-list-item.switched-to", () => {
144
+ if (!scrollToBottomOnThreadSwitch) return;
145
+ scrollingToBottomBehaviorRef.current = "instant";
146
+ requestAnimationFrame(() => {
147
+ scrollToBottom("instant");
148
+ });
149
+ });
150
+
105
151
  const autoScrollRef = useComposedRefs<TElement>(resizeRef, scrollRef, divRef);
106
152
  return autoScrollRef as RefCallback<TElement>;
107
153
  };