@hef2024/llmasaservice-ui 0.24.0 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -847,6 +847,11 @@ var ChatPanel = ({
847
847
  }, []);
848
848
  const [iframeUrl, setIframeUrl] = (0, import_react11.useState)(null);
849
849
  const responseAreaRef = (0, import_react11.useRef)(null);
850
+ const [userHasScrolled, setUserHasScrolled] = (0, import_react11.useState)(false);
851
+ const lastScrollTopRef = (0, import_react11.useRef)(0);
852
+ const prevResponseLengthRef = (0, import_react11.useRef)(0);
853
+ const userHasScrolledRef = (0, import_react11.useRef)(false);
854
+ const idleRef = (0, import_react11.useRef)(true);
850
855
  const THINKING_PATTERNS = (0, import_react11.useMemo)(
851
856
  () => ({
852
857
  thinking: /<thinking>([\s\S]*?)<\/thinking>/gi,
@@ -2179,32 +2184,67 @@ var ChatPanel = ({
2179
2184
  }
2180
2185
  }
2181
2186
  }, [initialPrompt]);
2187
+ (0, import_react11.useEffect)(() => {
2188
+ userHasScrolledRef.current = userHasScrolled;
2189
+ }, [userHasScrolled]);
2190
+ (0, import_react11.useEffect)(() => {
2191
+ idleRef.current = idle;
2192
+ }, [idle]);
2182
2193
  (0, import_react11.useEffect)(() => {
2183
2194
  var _a2;
2184
- if (scrollToEnd) {
2195
+ if (idle) return;
2196
+ const currentResponseLength = response.length;
2197
+ const responseGotLonger = currentResponseLength > prevResponseLengthRef.current;
2198
+ prevResponseLengthRef.current = currentResponseLength;
2199
+ const shouldAutoScroll = scrollToEnd || !userHasScrolledRef.current;
2200
+ if (shouldAutoScroll && response && responseGotLonger) {
2185
2201
  if (window.top !== window.self) {
2186
2202
  const responseArea = responseAreaRef.current;
2187
- responseArea.scrollTo({
2188
- top: responseArea.scrollHeight,
2189
- behavior: "smooth"
2190
- });
2203
+ if (responseArea) {
2204
+ responseArea.scrollTo({
2205
+ top: responseArea.scrollHeight,
2206
+ behavior: "auto"
2207
+ });
2208
+ }
2191
2209
  } else {
2192
- (_a2 = bottomRef.current) == null ? void 0 : _a2.scrollIntoView({ behavior: "smooth", block: "end" });
2210
+ (_a2 = bottomRef.current) == null ? void 0 : _a2.scrollIntoView({ behavior: "auto", block: "end" });
2193
2211
  }
2194
- } else {
2195
- const responseArea = responseAreaRef.current;
2196
- if (responseArea) {
2197
- setHasScroll(hasVerticalScrollbar(responseArea));
2198
- const handleScroll = () => {
2199
- const isScrolledToBottom = responseArea.scrollHeight - responseArea.scrollTop === responseArea.clientHeight;
2200
- setIsAtBottom(isScrolledToBottom);
2201
- };
2202
- handleScroll();
2203
- responseArea.addEventListener("scroll", handleScroll);
2204
- return () => responseArea.removeEventListener("scroll", handleScroll);
2212
+ }
2213
+ }, [response, idle, scrollToEnd]);
2214
+ (0, import_react11.useEffect)(() => {
2215
+ const responseArea = responseAreaRef.current;
2216
+ if (!responseArea) return;
2217
+ const handleWheel = (e) => {
2218
+ if (idleRef.current) return;
2219
+ if (e.deltaY < 0 && !userHasScrolledRef.current) {
2220
+ setUserHasScrolled(true);
2221
+ }
2222
+ };
2223
+ const handleScroll = () => {
2224
+ setHasScroll(hasVerticalScrollbar(responseArea));
2225
+ const isScrolledToBottom = responseArea.scrollHeight - responseArea.scrollTop - responseArea.clientHeight < 5;
2226
+ setIsAtBottom(isScrolledToBottom);
2227
+ if (!idleRef.current && userHasScrolledRef.current) {
2228
+ const isNearBottom = responseArea.scrollHeight - responseArea.scrollTop - responseArea.clientHeight < 50;
2229
+ if (isNearBottom) {
2230
+ setUserHasScrolled(false);
2231
+ }
2205
2232
  }
2233
+ };
2234
+ handleScroll();
2235
+ responseArea.addEventListener("wheel", handleWheel, { passive: true });
2236
+ responseArea.addEventListener("scroll", handleScroll, { passive: true });
2237
+ return () => {
2238
+ responseArea.removeEventListener("wheel", handleWheel);
2239
+ responseArea.removeEventListener("scroll", handleScroll);
2240
+ };
2241
+ }, []);
2242
+ (0, import_react11.useEffect)(() => {
2243
+ const responseArea = responseAreaRef.current;
2244
+ if (responseArea) {
2245
+ setHasScroll(hasVerticalScrollbar(responseArea));
2206
2246
  }
2207
- }, [response, history]);
2247
+ }, [history]);
2208
2248
  (0, import_react11.useEffect)(() => {
2209
2249
  if (historyCallbackRef.current) {
2210
2250
  historyCallbackRef.current(history);
@@ -2327,6 +2367,8 @@ var ChatPanel = ({
2327
2367
  hasAutoCollapsedRef.current = false;
2328
2368
  prevBlockCountRef.current = 0;
2329
2369
  setError(null);
2370
+ setUserHasScrolled(false);
2371
+ prevResponseLengthRef.current = 0;
2330
2372
  setResponse("");
2331
2373
  if (emailInput && isEmailAddress(emailInput) && !emailInputSet) {
2332
2374
  const newId = (currentCustomer == null ? void 0 : currentCustomer.customer_id) && currentCustomer.customer_id !== "" && currentCustomer.customer_id !== (currentCustomer == null ? void 0 : currentCustomer.customer_user_email) ? currentCustomer.customer_id : emailInput;
@@ -4660,7 +4702,7 @@ var AIChatPanel = ({
4660
4702
  cancelAnimationFrame(scrollRAFRef.current);
4661
4703
  }
4662
4704
  if (!force && responseAreaRef.current) {
4663
- const scrollViewport = responseAreaRef.current.querySelector("[data-radix-scroll-area-viewport]");
4705
+ const scrollViewport = responseAreaRef.current.querySelector(".ai-scroll-area-viewport");
4664
4706
  const scrollElement = scrollViewport || responseAreaRef.current;
4665
4707
  const scrollTop = scrollElement.scrollTop;
4666
4708
  const scrollHeight = scrollElement.scrollHeight;
@@ -4963,7 +5005,7 @@ var AIChatPanel = ({
4963
5005
  prevResponseLengthRef.current = currentResponseLength;
4964
5006
  const shouldAutoScroll = scrollToEndRef.current || !userHasScrolledRef.current;
4965
5007
  if (shouldAutoScroll && response && responseGotLonger) {
4966
- scrollToBottom(false);
5008
+ scrollToBottom(true);
4967
5009
  }
4968
5010
  }, [response, idle]);
4969
5011
  const idleRef = (0, import_react14.useRef)(idle);
@@ -4971,21 +5013,30 @@ var AIChatPanel = ({
4971
5013
  (0, import_react14.useEffect)(() => {
4972
5014
  const scrollArea = responseAreaRef.current;
4973
5015
  if (!scrollArea) return;
4974
- const scrollViewport = scrollArea.querySelector("[data-radix-scroll-area-viewport]");
5016
+ const scrollViewport = scrollArea.querySelector(".ai-scroll-area-viewport");
4975
5017
  const scrollElement = scrollViewport || scrollArea;
5018
+ const handleWheel = (e) => {
5019
+ if (idleRef.current) return;
5020
+ if (e.deltaY < 0 && !userHasScrolledRef.current) {
5021
+ setUserHasScrolled(true);
5022
+ }
5023
+ };
4976
5024
  const handleScroll = () => {
4977
- if (idleRef.current || userHasScrolledRef.current) return;
4978
- const currentScrollTop = scrollElement.scrollTop;
5025
+ if (idleRef.current || !userHasScrolledRef.current) return;
4979
5026
  const scrollHeight = scrollElement.scrollHeight;
5027
+ const currentScrollTop = scrollElement.scrollTop;
4980
5028
  const clientHeight = scrollElement.clientHeight;
4981
- const isNearBottom = scrollHeight - currentScrollTop - clientHeight < 100;
4982
- if (currentScrollTop < lastScrollTopRef.current && !isNearBottom) {
4983
- setUserHasScrolled(true);
5029
+ const isNearBottom = scrollHeight - currentScrollTop - clientHeight < 50;
5030
+ if (isNearBottom) {
5031
+ setUserHasScrolled(false);
4984
5032
  }
4985
- lastScrollTopRef.current = currentScrollTop;
4986
5033
  };
5034
+ scrollElement.addEventListener("wheel", handleWheel, { passive: true });
4987
5035
  scrollElement.addEventListener("scroll", handleScroll, { passive: true });
4988
- return () => scrollElement.removeEventListener("scroll", handleScroll);
5036
+ return () => {
5037
+ scrollElement.removeEventListener("wheel", handleWheel);
5038
+ scrollElement.removeEventListener("scroll", handleScroll);
5039
+ };
4989
5040
  }, []);
4990
5041
  (0, import_react14.useEffect)(() => {
4991
5042
  setFollowOnQuestionsState(followOnQuestions);
package/dist/index.mjs CHANGED
@@ -806,6 +806,11 @@ var ChatPanel = ({
806
806
  }, []);
807
807
  const [iframeUrl, setIframeUrl] = useState5(null);
808
808
  const responseAreaRef = useRef5(null);
809
+ const [userHasScrolled, setUserHasScrolled] = useState5(false);
810
+ const lastScrollTopRef = useRef5(0);
811
+ const prevResponseLengthRef = useRef5(0);
812
+ const userHasScrolledRef = useRef5(false);
813
+ const idleRef = useRef5(true);
809
814
  const THINKING_PATTERNS = useMemo(
810
815
  () => ({
811
816
  thinking: /<thinking>([\s\S]*?)<\/thinking>/gi,
@@ -2138,32 +2143,67 @@ var ChatPanel = ({
2138
2143
  }
2139
2144
  }
2140
2145
  }, [initialPrompt]);
2146
+ useEffect6(() => {
2147
+ userHasScrolledRef.current = userHasScrolled;
2148
+ }, [userHasScrolled]);
2149
+ useEffect6(() => {
2150
+ idleRef.current = idle;
2151
+ }, [idle]);
2141
2152
  useEffect6(() => {
2142
2153
  var _a2;
2143
- if (scrollToEnd) {
2154
+ if (idle) return;
2155
+ const currentResponseLength = response.length;
2156
+ const responseGotLonger = currentResponseLength > prevResponseLengthRef.current;
2157
+ prevResponseLengthRef.current = currentResponseLength;
2158
+ const shouldAutoScroll = scrollToEnd || !userHasScrolledRef.current;
2159
+ if (shouldAutoScroll && response && responseGotLonger) {
2144
2160
  if (window.top !== window.self) {
2145
2161
  const responseArea = responseAreaRef.current;
2146
- responseArea.scrollTo({
2147
- top: responseArea.scrollHeight,
2148
- behavior: "smooth"
2149
- });
2162
+ if (responseArea) {
2163
+ responseArea.scrollTo({
2164
+ top: responseArea.scrollHeight,
2165
+ behavior: "auto"
2166
+ });
2167
+ }
2150
2168
  } else {
2151
- (_a2 = bottomRef.current) == null ? void 0 : _a2.scrollIntoView({ behavior: "smooth", block: "end" });
2169
+ (_a2 = bottomRef.current) == null ? void 0 : _a2.scrollIntoView({ behavior: "auto", block: "end" });
2152
2170
  }
2153
- } else {
2154
- const responseArea = responseAreaRef.current;
2155
- if (responseArea) {
2156
- setHasScroll(hasVerticalScrollbar(responseArea));
2157
- const handleScroll = () => {
2158
- const isScrolledToBottom = responseArea.scrollHeight - responseArea.scrollTop === responseArea.clientHeight;
2159
- setIsAtBottom(isScrolledToBottom);
2160
- };
2161
- handleScroll();
2162
- responseArea.addEventListener("scroll", handleScroll);
2163
- return () => responseArea.removeEventListener("scroll", handleScroll);
2171
+ }
2172
+ }, [response, idle, scrollToEnd]);
2173
+ useEffect6(() => {
2174
+ const responseArea = responseAreaRef.current;
2175
+ if (!responseArea) return;
2176
+ const handleWheel = (e) => {
2177
+ if (idleRef.current) return;
2178
+ if (e.deltaY < 0 && !userHasScrolledRef.current) {
2179
+ setUserHasScrolled(true);
2180
+ }
2181
+ };
2182
+ const handleScroll = () => {
2183
+ setHasScroll(hasVerticalScrollbar(responseArea));
2184
+ const isScrolledToBottom = responseArea.scrollHeight - responseArea.scrollTop - responseArea.clientHeight < 5;
2185
+ setIsAtBottom(isScrolledToBottom);
2186
+ if (!idleRef.current && userHasScrolledRef.current) {
2187
+ const isNearBottom = responseArea.scrollHeight - responseArea.scrollTop - responseArea.clientHeight < 50;
2188
+ if (isNearBottom) {
2189
+ setUserHasScrolled(false);
2190
+ }
2164
2191
  }
2192
+ };
2193
+ handleScroll();
2194
+ responseArea.addEventListener("wheel", handleWheel, { passive: true });
2195
+ responseArea.addEventListener("scroll", handleScroll, { passive: true });
2196
+ return () => {
2197
+ responseArea.removeEventListener("wheel", handleWheel);
2198
+ responseArea.removeEventListener("scroll", handleScroll);
2199
+ };
2200
+ }, []);
2201
+ useEffect6(() => {
2202
+ const responseArea = responseAreaRef.current;
2203
+ if (responseArea) {
2204
+ setHasScroll(hasVerticalScrollbar(responseArea));
2165
2205
  }
2166
- }, [response, history]);
2206
+ }, [history]);
2167
2207
  useEffect6(() => {
2168
2208
  if (historyCallbackRef.current) {
2169
2209
  historyCallbackRef.current(history);
@@ -2286,6 +2326,8 @@ var ChatPanel = ({
2286
2326
  hasAutoCollapsedRef.current = false;
2287
2327
  prevBlockCountRef.current = 0;
2288
2328
  setError(null);
2329
+ setUserHasScrolled(false);
2330
+ prevResponseLengthRef.current = 0;
2289
2331
  setResponse("");
2290
2332
  if (emailInput && isEmailAddress(emailInput) && !emailInputSet) {
2291
2333
  const newId = (currentCustomer == null ? void 0 : currentCustomer.customer_id) && currentCustomer.customer_id !== "" && currentCustomer.customer_id !== (currentCustomer == null ? void 0 : currentCustomer.customer_user_email) ? currentCustomer.customer_id : emailInput;
@@ -4625,7 +4667,7 @@ var AIChatPanel = ({
4625
4667
  cancelAnimationFrame(scrollRAFRef.current);
4626
4668
  }
4627
4669
  if (!force && responseAreaRef.current) {
4628
- const scrollViewport = responseAreaRef.current.querySelector("[data-radix-scroll-area-viewport]");
4670
+ const scrollViewport = responseAreaRef.current.querySelector(".ai-scroll-area-viewport");
4629
4671
  const scrollElement = scrollViewport || responseAreaRef.current;
4630
4672
  const scrollTop = scrollElement.scrollTop;
4631
4673
  const scrollHeight = scrollElement.scrollHeight;
@@ -4928,7 +4970,7 @@ var AIChatPanel = ({
4928
4970
  prevResponseLengthRef.current = currentResponseLength;
4929
4971
  const shouldAutoScroll = scrollToEndRef.current || !userHasScrolledRef.current;
4930
4972
  if (shouldAutoScroll && response && responseGotLonger) {
4931
- scrollToBottom(false);
4973
+ scrollToBottom(true);
4932
4974
  }
4933
4975
  }, [response, idle]);
4934
4976
  const idleRef = useRef6(idle);
@@ -4936,21 +4978,30 @@ var AIChatPanel = ({
4936
4978
  useEffect8(() => {
4937
4979
  const scrollArea = responseAreaRef.current;
4938
4980
  if (!scrollArea) return;
4939
- const scrollViewport = scrollArea.querySelector("[data-radix-scroll-area-viewport]");
4981
+ const scrollViewport = scrollArea.querySelector(".ai-scroll-area-viewport");
4940
4982
  const scrollElement = scrollViewport || scrollArea;
4983
+ const handleWheel = (e) => {
4984
+ if (idleRef.current) return;
4985
+ if (e.deltaY < 0 && !userHasScrolledRef.current) {
4986
+ setUserHasScrolled(true);
4987
+ }
4988
+ };
4941
4989
  const handleScroll = () => {
4942
- if (idleRef.current || userHasScrolledRef.current) return;
4943
- const currentScrollTop = scrollElement.scrollTop;
4990
+ if (idleRef.current || !userHasScrolledRef.current) return;
4944
4991
  const scrollHeight = scrollElement.scrollHeight;
4992
+ const currentScrollTop = scrollElement.scrollTop;
4945
4993
  const clientHeight = scrollElement.clientHeight;
4946
- const isNearBottom = scrollHeight - currentScrollTop - clientHeight < 100;
4947
- if (currentScrollTop < lastScrollTopRef.current && !isNearBottom) {
4948
- setUserHasScrolled(true);
4994
+ const isNearBottom = scrollHeight - currentScrollTop - clientHeight < 50;
4995
+ if (isNearBottom) {
4996
+ setUserHasScrolled(false);
4949
4997
  }
4950
- lastScrollTopRef.current = currentScrollTop;
4951
4998
  };
4999
+ scrollElement.addEventListener("wheel", handleWheel, { passive: true });
4952
5000
  scrollElement.addEventListener("scroll", handleScroll, { passive: true });
4953
- return () => scrollElement.removeEventListener("scroll", handleScroll);
5001
+ return () => {
5002
+ scrollElement.removeEventListener("wheel", handleWheel);
5003
+ scrollElement.removeEventListener("scroll", handleScroll);
5004
+ };
4954
5005
  }, []);
4955
5006
  useEffect8(() => {
4956
5007
  setFollowOnQuestionsState(followOnQuestions);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hef2024/llmasaservice-ui",
3
- "version": "0.24.0",
3
+ "version": "0.24.1",
4
4
  "description": "Prebuilt UI components for LLMAsAService.io",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -1585,7 +1585,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1585
1585
 
1586
1586
  // Check if we should scroll - only if user is near bottom or force is true
1587
1587
  if (!force && responseAreaRef.current) {
1588
- const scrollViewport = responseAreaRef.current.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement;
1588
+ const scrollViewport = responseAreaRef.current.querySelector('.ai-scroll-area-viewport') as HTMLElement;
1589
1589
  const scrollElement = scrollViewport || responseAreaRef.current;
1590
1590
 
1591
1591
  const scrollTop = scrollElement.scrollTop;
@@ -2038,8 +2038,9 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2038
2038
  // Only scroll if response actually grew - use refs to avoid unnecessary effect runs
2039
2039
  const shouldAutoScroll = scrollToEndRef.current || !userHasScrolledRef.current;
2040
2040
  if (shouldAutoScroll && response && responseGotLonger) {
2041
- // Use non-forced scroll - will only scroll if near bottom
2042
- scrollToBottom(false);
2041
+ // Use forced scroll since userHasScrolled is our gatekeeper for user intent
2042
+ // The isNearBottom check in scrollToBottom is for layout changes, not streaming
2043
+ scrollToBottom(true);
2043
2044
  }
2044
2045
  }, [response, idle]); // ONLY response and idle - no other dependencies!
2045
2046
 
@@ -2047,39 +2048,48 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2047
2048
  const idleRef = useRef(idle);
2048
2049
  idleRef.current = idle;
2049
2050
 
2050
- // Detect user scroll to disable auto-scroll
2051
+ // Detect user scroll intent via wheel event (fires before scroll position changes)
2051
2052
  useEffect(() => {
2052
2053
  const scrollArea = responseAreaRef.current;
2053
2054
  if (!scrollArea) return;
2054
2055
 
2055
2056
  // Get the actual scrollable element (ScrollArea wraps a viewport)
2056
- const scrollViewport = scrollArea.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement;
2057
+ const scrollViewport = scrollArea.querySelector('.ai-scroll-area-viewport') as HTMLElement;
2057
2058
  const scrollElement = scrollViewport || scrollArea;
2058
2059
 
2060
+ // Wheel event detects user intent immediately (before scroll position changes)
2061
+ const handleWheel = (e: WheelEvent) => {
2062
+ // Skip if not streaming
2063
+ if (idleRef.current) return;
2064
+
2065
+ // deltaY < 0 means scrolling UP (toward top of document)
2066
+ if (e.deltaY < 0 && !userHasScrolledRef.current) {
2067
+ setUserHasScrolled(true);
2068
+ }
2069
+ };
2070
+
2071
+ // Scroll event for detecting when user returns to bottom
2059
2072
  const handleScroll = () => {
2060
- // Skip if not streaming or already marked as user-scrolled
2061
- if (idleRef.current || userHasScrolledRef.current) return;
2073
+ // Skip if not streaming or user hasn't scrolled up
2074
+ if (idleRef.current || !userHasScrolledRef.current) return;
2062
2075
 
2063
- const currentScrollTop = scrollElement.scrollTop;
2064
2076
  const scrollHeight = scrollElement.scrollHeight;
2077
+ const currentScrollTop = scrollElement.scrollTop;
2065
2078
  const clientHeight = scrollElement.clientHeight;
2066
2079
 
2067
- // If user scrolled UP (away from bottom), disable auto-scroll
2068
- // We consider "near bottom" as within 100px to account for smooth scrolling
2069
- const isNearBottom = scrollHeight - currentScrollTop - clientHeight < 100;
2070
-
2071
- // User scrolled up if:
2072
- // 1. They scrolled upward (currentScrollTop < lastScrollTopRef.current)
2073
- // 2. AND they're not near the bottom
2074
- if (currentScrollTop < lastScrollTopRef.current && !isNearBottom) {
2075
- setUserHasScrolled(true);
2080
+ // Re-enable auto-scroll when user scrolls back near bottom
2081
+ const isNearBottom = scrollHeight - currentScrollTop - clientHeight < 50;
2082
+ if (isNearBottom) {
2083
+ setUserHasScrolled(false);
2076
2084
  }
2077
-
2078
- lastScrollTopRef.current = currentScrollTop;
2079
2085
  };
2080
2086
 
2087
+ scrollElement.addEventListener('wheel', handleWheel, { passive: true });
2081
2088
  scrollElement.addEventListener('scroll', handleScroll, { passive: true });
2082
- return () => scrollElement.removeEventListener('scroll', handleScroll);
2089
+ return () => {
2090
+ scrollElement.removeEventListener('wheel', handleWheel);
2091
+ scrollElement.removeEventListener('scroll', handleScroll);
2092
+ };
2083
2093
  }, []); // Empty deps - handler uses refs to get current values
2084
2094
 
2085
2095
  // Update follow-on questions from props
package/src/ChatPanel.tsx CHANGED
@@ -315,6 +315,13 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
315
315
  const [iframeUrl, setIframeUrl] = useState<string | null>(null);
316
316
  const responseAreaRef = useRef(null);
317
317
 
318
+ // Auto-scroll state - tracks if user has manually scrolled during streaming
319
+ const [userHasScrolled, setUserHasScrolled] = useState(false);
320
+ const lastScrollTopRef = useRef<number>(0);
321
+ const prevResponseLengthRef = useRef<number>(0);
322
+ const userHasScrolledRef = useRef<boolean>(false);
323
+ const idleRef = useRef<boolean>(true);
324
+
318
325
  // Memoized regex patterns to avoid recreation on every render
319
326
  const THINKING_PATTERNS = useMemo(
320
327
  () => ({
@@ -2134,34 +2141,91 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
2134
2141
  }
2135
2142
  }, [initialPrompt]);
2136
2143
 
2144
+ // Keep refs in sync with state
2145
+ useEffect(() => {
2146
+ userHasScrolledRef.current = userHasScrolled;
2147
+ }, [userHasScrolled]);
2148
+
2149
+ useEffect(() => {
2150
+ idleRef.current = idle;
2151
+ }, [idle]);
2152
+
2153
+ // Auto-scroll to bottom during streaming (when user hasn't scrolled up)
2137
2154
  useEffect(() => {
2138
- if (scrollToEnd) {
2155
+ // Skip if idle (not streaming)
2156
+ if (idle) return;
2157
+
2158
+ const currentResponseLength = response.length;
2159
+ const responseGotLonger = currentResponseLength > prevResponseLengthRef.current;
2160
+ prevResponseLengthRef.current = currentResponseLength;
2161
+
2162
+ // Only auto-scroll if user hasn't manually scrolled up (or scrollToEnd prop forces it)
2163
+ const shouldAutoScroll = scrollToEnd || !userHasScrolledRef.current;
2164
+ if (shouldAutoScroll && response && responseGotLonger) {
2139
2165
  if (window.top !== window.self) {
2140
2166
  const responseArea = responseAreaRef.current as any;
2141
- responseArea.scrollTo({
2142
- top: responseArea.scrollHeight,
2143
- behavior: "smooth",
2144
- });
2167
+ if (responseArea) {
2168
+ responseArea.scrollTo({
2169
+ top: responseArea.scrollHeight,
2170
+ behavior: "auto",
2171
+ });
2172
+ }
2145
2173
  } else {
2146
- // If the ChatPanel is not within an iframe
2147
- bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
2174
+ bottomRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
2148
2175
  }
2149
- } else {
2150
- const responseArea = responseAreaRef.current as any;
2151
- if (responseArea) {
2152
- setHasScroll(hasVerticalScrollbar(responseArea));
2153
- const handleScroll = () => {
2154
- const isScrolledToBottom =
2155
- responseArea.scrollHeight - responseArea.scrollTop ===
2156
- responseArea.clientHeight;
2157
- setIsAtBottom(isScrolledToBottom);
2158
- };
2159
- handleScroll();
2160
- responseArea.addEventListener("scroll", handleScroll);
2161
- return () => responseArea.removeEventListener("scroll", handleScroll);
2176
+ }
2177
+ }, [response, idle, scrollToEnd]);
2178
+
2179
+ // Detect user scroll intent via wheel event (fires before scroll position changes)
2180
+ useEffect(() => {
2181
+ const responseArea = responseAreaRef.current as any;
2182
+ if (!responseArea) return;
2183
+
2184
+ // Wheel event detects user intent immediately (before scroll position changes)
2185
+ const handleWheel = (e: WheelEvent) => {
2186
+ // Skip if not streaming
2187
+ if (idleRef.current) return;
2188
+
2189
+ // deltaY < 0 means scrolling UP (toward top of document)
2190
+ if (e.deltaY < 0 && !userHasScrolledRef.current) {
2191
+ setUserHasScrolled(true);
2192
+ }
2193
+ };
2194
+
2195
+ // Scroll event for hasScroll/isAtBottom UI and detecting return to bottom
2196
+ const handleScroll = () => {
2197
+ // Update hasScroll and isAtBottom states
2198
+ setHasScroll(hasVerticalScrollbar(responseArea));
2199
+ const isScrolledToBottom =
2200
+ responseArea.scrollHeight - responseArea.scrollTop - responseArea.clientHeight < 5;
2201
+ setIsAtBottom(isScrolledToBottom);
2202
+
2203
+ // Re-enable auto-scroll when user scrolls back near bottom (during streaming)
2204
+ if (!idleRef.current && userHasScrolledRef.current) {
2205
+ const isNearBottom =
2206
+ responseArea.scrollHeight - responseArea.scrollTop - responseArea.clientHeight < 50;
2207
+ if (isNearBottom) {
2208
+ setUserHasScrolled(false);
2209
+ }
2162
2210
  }
2211
+ };
2212
+
2213
+ handleScroll(); // Initial check
2214
+ responseArea.addEventListener("wheel", handleWheel, { passive: true });
2215
+ responseArea.addEventListener("scroll", handleScroll, { passive: true });
2216
+ return () => {
2217
+ responseArea.removeEventListener("wheel", handleWheel);
2218
+ responseArea.removeEventListener("scroll", handleScroll);
2219
+ };
2220
+ }, []); // Empty deps - uses refs
2221
+
2222
+ // Update hasScroll when history changes
2223
+ useEffect(() => {
2224
+ const responseArea = responseAreaRef.current as any;
2225
+ if (responseArea) {
2226
+ setHasScroll(hasVerticalScrollbar(responseArea));
2163
2227
  }
2164
- }, [response, history]);
2228
+ }, [history]);
2165
2229
 
2166
2230
  // Use ref to avoid infinite loops from unstable callback references
2167
2231
  useEffect(() => {
@@ -2337,6 +2401,10 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
2337
2401
  // Clear any previous errors
2338
2402
  setError(null);
2339
2403
 
2404
+ // Reset scroll tracking for new message - enable auto-scroll
2405
+ setUserHasScrolled(false);
2406
+ prevResponseLengthRef.current = 0;
2407
+
2340
2408
  // IMPORTANT: Clear the response BEFORE setting new lastKey
2341
2409
  // This prevents the old response from being written to the new history entry
2342
2410
  // when the history update effect runs