@hef2024/llmasaservice-ui 0.23.2 → 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/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
@@ -102,6 +102,11 @@ const getDefaultTitle = (type: ThinkingBlockType): string => {
102
102
  /**
103
103
  * ThinkingBlock - A collapsible block for displaying thinking/reasoning/searching content
104
104
  * with streaming support. Content streams in naturally as it arrives.
105
+ *
106
+ * Features Cursor-style effects:
107
+ * - Spinning icon when streaming
108
+ * - Shimmer highlight effect on title text
109
+ * - Compact collapsed state with content preview
105
110
  */
106
111
  export const ThinkingBlock: React.FC<ThinkingBlockProps> = ({
107
112
  type,
@@ -113,10 +118,17 @@ export const ThinkingBlock: React.FC<ThinkingBlockProps> = ({
113
118
  }) => {
114
119
  const displayTitle = title || getDefaultTitle(type);
115
120
  const icon = getIcon(type);
121
+
122
+ // Create a truncated preview for collapsed state
123
+ const getPreview = (text: string, maxLength: number = 60): string => {
124
+ const cleaned = text.replace(/\s+/g, ' ').trim();
125
+ if (cleaned.length <= maxLength) return cleaned;
126
+ return cleaned.substring(0, maxLength).trim() + '...';
127
+ };
116
128
 
117
129
  return (
118
130
  <div
119
- className={`thinking-block thinking-block--${type} ${isCollapsed ? 'thinking-block--collapsed' : ''}`}
131
+ className={`thinking-block thinking-block--${type} ${isCollapsed ? 'thinking-block--collapsed' : ''} ${isStreaming ? 'thinking-block--streaming' : ''}`}
120
132
  >
121
133
  <button
122
134
  className="thinking-block__header"
@@ -125,13 +137,15 @@ export const ThinkingBlock: React.FC<ThinkingBlockProps> = ({
125
137
  aria-expanded={!isCollapsed}
126
138
  >
127
139
  <div className="thinking-block__header-left">
128
- {icon}
129
- <span className="thinking-block__title">{displayTitle}</span>
130
- {isStreaming && (
131
- <span className="thinking-block__streaming-indicator">
132
- <span className="thinking-block__streaming-dot" />
133
- <span className="thinking-block__streaming-dot" />
134
- <span className="thinking-block__streaming-dot" />
140
+ <span className={`thinking-block__icon-wrapper ${isStreaming ? 'thinking-block__icon-wrapper--spinning' : ''}`}>
141
+ {icon}
142
+ </span>
143
+ <span className={`thinking-block__title ${isStreaming ? 'thinking-block__title--shimmer' : ''}`}>
144
+ {displayTitle}
145
+ </span>
146
+ {isCollapsed && content && (
147
+ <span className="thinking-block__preview">
148
+ {getPreview(content)}
135
149
  </span>
136
150
  )}
137
151
  </div>