@hef2024/llmasaservice-ui 0.22.11 → 0.23.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
@@ -18,6 +18,7 @@ import materialDark from "react-syntax-highlighter/dist/esm/styles/prism/materia
18
18
  import materialLight from "react-syntax-highlighter/dist/esm/styles/prism/material-light.js";
19
19
  import EmailModal from "./EmailModal";
20
20
  import ToolInfoModal from "./ToolInfoModal";
21
+ import { ThinkingBlock as ThinkingBlockComponent } from './components/ui';
21
22
 
22
23
  export interface ChatPanelProps {
23
24
  project_id: string;
@@ -218,12 +219,19 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
218
219
 
219
220
  // State for tracking thinking content and navigation
220
221
  const [thinkingBlocks, setThinkingBlocks] = useState<
221
- Array<{ type: "reasoning" | "searching"; content: string; index: number }>
222
+ Array<{ type: "thinking" | "reasoning" | "searching"; content: string; index: number; isStreaming?: boolean }>
222
223
  >([]);
223
224
  const [currentThinkingIndex, setCurrentThinkingIndex] = useState(0);
225
+ // NOTE: activeThinkingBlock is now computed via useMemo, not useState - see below after processThinkingTags
226
+ // Track collapsed state per block (key: "block-{index}" or "active" for streaming block)
227
+ const [collapsedBlocks, setCollapsedBlocks] = useState<Set<string>>(new Set());
228
+ const hasAutoCollapsedRef = useRef(false); // Track if we've auto-collapsed for current response
229
+ const prevBlockCountRef = useRef(0); // Track previous block count to detect new blocks
224
230
 
225
231
  // State for error handling
226
232
  const [error, setError] = useState<{ message: string; code?: string } | null>(null);
233
+ // Ref to track last processed error to prevent re-showing same error from useLLM
234
+ const lastProcessedErrorRef = useRef<string | null>(null);
227
235
 
228
236
  // State for tracking user-resized textarea height
229
237
  const [userResizedHeight, setUserResizedHeight] = useState<number | null>(null);
@@ -310,6 +318,7 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
310
318
  // Memoized regex patterns to avoid recreation on every render
311
319
  const THINKING_PATTERNS = useMemo(
312
320
  () => ({
321
+ thinking: /<thinking>([\s\S]*?)<\/thinking>/gi,
313
322
  reasoning: /<reasoning>([\s\S]*?)<\/reasoning>/gi,
314
323
  searching: /<searching>([\s\S]*?)<\/searching>/gi,
315
324
  }),
@@ -317,6 +326,10 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
317
326
  );
318
327
 
319
328
  // Memoized regex instances for better performance
329
+ const thinkingRegex = useMemo(
330
+ () => new RegExp(THINKING_PATTERNS.thinking.source, "gi"),
331
+ [THINKING_PATTERNS.thinking.source]
332
+ );
320
333
  const reasoningRegex = useMemo(
321
334
  () => new RegExp(THINKING_PATTERNS.reasoning.source, "gi"),
322
335
  [THINKING_PATTERNS.reasoning.source]
@@ -343,23 +356,26 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
343
356
  return cleaned || "Thinking";
344
357
  }, []);
345
358
 
346
- // Optimized function to extract thinking blocks in order
359
+ // Optimized function to extract thinking blocks in order - handles both complete and incomplete (streaming) tags
347
360
  const processThinkingTags = useCallback(
348
361
  (
349
362
  text: string
350
363
  ): {
351
364
  cleanedText: string;
352
- thinkingBlocks: Array<{
353
- type: "reasoning" | "searching";
365
+ completedBlocks: Array<{
366
+ type: "thinking" | "reasoning" | "searching";
354
367
  content: string;
355
368
  index: number;
369
+ isStreaming?: boolean;
356
370
  }>;
371
+ activeBlock: { type: "thinking" | "reasoning" | "searching"; content: string } | null;
357
372
  lastThinkingContent: string;
358
373
  } => {
359
374
  if (!text) {
360
375
  return {
361
376
  cleanedText: "",
362
- thinkingBlocks: [],
377
+ completedBlocks: [],
378
+ activeBlock: null,
363
379
  lastThinkingContent: "Thinking",
364
380
  };
365
381
  }
@@ -371,14 +387,30 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
371
387
  const allMatches: Array<{
372
388
  content: string;
373
389
  index: number;
374
- type: "reasoning" | "searching";
390
+ type: "thinking" | "reasoning" | "searching";
391
+ isStreaming?: boolean;
375
392
  }> = [];
376
393
 
377
394
  // Reset regex state for fresh matching
395
+ thinkingRegex.lastIndex = 0;
378
396
  reasoningRegex.lastIndex = 0;
379
397
  searchingRegex.lastIndex = 0;
380
398
 
381
- // Process reasoning blocks
399
+ // Process complete thinking blocks (Claude-style)
400
+ let thinkingMatch;
401
+ while ((thinkingMatch = thinkingRegex.exec(processedText)) !== null) {
402
+ const content = thinkingMatch[1]?.trim();
403
+ if (content) {
404
+ allMatches.push({
405
+ content,
406
+ index: thinkingMatch.index,
407
+ type: "thinking",
408
+ isStreaming: false,
409
+ });
410
+ }
411
+ }
412
+
413
+ // Process complete reasoning blocks
382
414
  let reasoningMatch;
383
415
  while ((reasoningMatch = reasoningRegex.exec(processedText)) !== null) {
384
416
  const content = reasoningMatch[1]?.trim();
@@ -387,11 +419,12 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
387
419
  content,
388
420
  index: reasoningMatch.index,
389
421
  type: "reasoning",
422
+ isStreaming: false,
390
423
  });
391
424
  }
392
425
  }
393
426
 
394
- // Process searching blocks
427
+ // Process complete searching blocks
395
428
  let searchingMatch;
396
429
  while ((searchingMatch = searchingRegex.exec(processedText)) !== null) {
397
430
  const content = searchingMatch[1]?.trim();
@@ -400,127 +433,105 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
400
433
  content,
401
434
  index: searchingMatch.index,
402
435
  type: "searching",
436
+ isStreaming: false,
403
437
  });
404
438
  }
405
439
  }
406
440
 
407
441
  // Sort by index to preserve original order
408
- const thinkingBlocks = allMatches.sort((a, b) => a.index - b.index);
442
+ const completedBlocks = allMatches.sort((a, b) => a.index - b.index);
409
443
 
410
- // Clean the text by removing thinking tags
444
+ // Check for incomplete (streaming) tags at the end of the text
445
+ // We need to find the LAST opening tag that doesn't have a corresponding closing tag
446
+ let activeBlock: { type: "thinking" | "reasoning" | "searching"; content: string } | null = null;
447
+ const tagTypes = ['thinking', 'reasoning', 'searching'] as const;
448
+ let latestIncompletePos = -1;
449
+
450
+ for (const tagType of tagTypes) {
451
+ const openTag = `<${tagType}>`;
452
+ const closeTag = `</${tagType}>`;
453
+ const textLower = processedText.toLowerCase();
454
+
455
+ // Find the last occurrence of this opening tag
456
+ const lastOpenIndex = textLower.lastIndexOf(openTag);
457
+ if (lastOpenIndex === -1) continue;
458
+
459
+ // Check if there's a closing tag after this opening tag
460
+ const afterOpen = processedText.slice(lastOpenIndex + openTag.length);
461
+ const closeIndex = afterOpen.toLowerCase().indexOf(closeTag);
462
+
463
+ // If no closing tag found after the opening tag, this is incomplete
464
+ if (closeIndex === -1 && lastOpenIndex > latestIncompletePos) {
465
+ latestIncompletePos = lastOpenIndex;
466
+ activeBlock = {
467
+ type: tagType,
468
+ content: afterOpen,
469
+ };
470
+ }
471
+ }
472
+
473
+ // Also check for partial opening tags (e.g., "<reas" before full "<reasoning>")
474
+ // This helps show the thinking indicator earlier during streaming
475
+ if (!activeBlock) {
476
+ const partialTagPatterns = [
477
+ { pattern: /<think(?:i(?:n(?:g)?)?)?$/i, type: 'thinking' as const },
478
+ { pattern: /<reas(?:o(?:n(?:i(?:n(?:g)?)?)?)?)?$/i, type: 'reasoning' as const },
479
+ { pattern: /<sear(?:c(?:h(?:i(?:n(?:g)?)?)?)?)?$/i, type: 'searching' as const },
480
+ ];
481
+
482
+ for (const { pattern, type } of partialTagPatterns) {
483
+ if (pattern.test(processedText)) {
484
+ activeBlock = { type, content: '' };
485
+ break;
486
+ }
487
+ }
488
+ }
489
+
490
+ // Clean the text by removing all thinking-related tags (complete and incomplete)
491
+ // Incomplete tags (opening without closing) are also removed so they don't show as raw text
411
492
  let cleanedText = processedText
493
+ .replace(THINKING_PATTERNS.thinking, "")
412
494
  .replace(THINKING_PATTERNS.reasoning, "")
413
495
  .replace(THINKING_PATTERNS.searching, "")
496
+ // Also remove partial opening tags
497
+ .replace(/<think(?:i(?:n(?:g)?)?)?$/i, "")
498
+ .replace(/<reas(?:o(?:n(?:i(?:n(?:g)?)?)?)?)?$/i, "")
499
+ .replace(/<sear(?:c(?:h(?:i(?:n(?:g)?)?)?)?)?$/i, "")
500
+ .replace(/<thinking>[\s\S]*$/i, "")
501
+ .replace(/<reasoning>[\s\S]*$/i, "")
502
+ .replace(/<searching>[\s\S]*$/i, "")
414
503
  .trim();
415
504
 
416
505
  // Get last thinking content
417
506
  let lastThinkingContent = "Thinking";
418
- if (thinkingBlocks.length > 0) {
419
- const lastBlock = thinkingBlocks[thinkingBlocks.length - 1];
507
+ if (completedBlocks.length > 0) {
508
+ const lastBlock = completedBlocks[completedBlocks.length - 1];
420
509
  if (lastBlock?.content) {
421
510
  lastThinkingContent = cleanContentForDisplay(lastBlock.content);
422
511
  }
512
+ } else if (activeBlock?.content) {
513
+ lastThinkingContent = cleanContentForDisplay(activeBlock.content);
423
514
  }
424
515
 
425
516
  return {
426
517
  cleanedText,
427
- thinkingBlocks,
518
+ completedBlocks,
519
+ activeBlock,
428
520
  lastThinkingContent,
429
521
  };
430
522
  },
431
523
  [
524
+ THINKING_PATTERNS.thinking,
432
525
  THINKING_PATTERNS.reasoning,
433
526
  THINKING_PATTERNS.searching,
527
+ thinkingRegex,
434
528
  reasoningRegex,
435
529
  searchingRegex,
436
530
  cleanContentForDisplay,
437
531
  ]
438
532
  );
439
533
 
440
- // Memoized render function for thinking blocks with navigation
441
- const renderThinkingBlocks = useCallback((): React.ReactElement | null => {
442
- if (thinkingBlocks.length === 0) return null;
443
-
444
- const currentBlock = thinkingBlocks[currentThinkingIndex];
445
- if (!currentBlock) return null;
446
-
447
- const icon = currentBlock.type === "reasoning" ? "🤔" : "🔍";
448
- const baseTitle =
449
- currentBlock.type === "reasoning" ? "Reasoning" : "Searching";
450
-
451
- // Extract title from **[title]** at the beginning of content and strip formatting
452
- const extractTitleAndContent = (
453
- text: string
454
- ): { displayTitle: string; content: string } => {
455
- // Handle potential whitespace at the beginning and be more flexible with the pattern
456
- const trimmedText = text.trim();
457
- const titleMatch = trimmedText.match(/^\*\*\[(.*?)\]\*\*/);
458
- if (titleMatch) {
459
- const extractedTitle = titleMatch[1];
460
- // Remove the title pattern and any following whitespace/newlines
461
- const remainingContent = trimmedText
462
- .replace(/^\*\*\[.*?\]\*\*\s*\n?/, "")
463
- .replace(/\*\*(.*?)\*\*/g, "$1")
464
- .trim();
465
- return {
466
- displayTitle: `${baseTitle}: ${extractedTitle}`,
467
- content: remainingContent,
468
- };
469
- }
470
- // If no title found, just strip bold formatting
471
- return {
472
- displayTitle: baseTitle,
473
- content: trimmedText.replace(/\*\*(.*?)\*\*/g, "$1"),
474
- };
475
- };
476
-
477
- const { displayTitle, content } = extractTitleAndContent(
478
- currentBlock.content
479
- );
480
-
481
- return (
482
- <div className="thinking-block-container">
483
- <div className={`thinking-section ${currentBlock.type}-section`}>
484
- <div className="thinking-header">
485
- {icon} {displayTitle}
486
- {thinkingBlocks.length > 1 && (
487
- <div className="thinking-navigation">
488
- <button
489
- onClick={() =>
490
- setCurrentThinkingIndex(
491
- Math.max(0, currentThinkingIndex - 1)
492
- )
493
- }
494
- disabled={currentThinkingIndex === 0}
495
- className="thinking-nav-btn"
496
- >
497
-
498
- </button>
499
- <span className="thinking-counter">
500
- {currentThinkingIndex + 1} / {thinkingBlocks.length}
501
- </span>
502
- <button
503
- onClick={() =>
504
- setCurrentThinkingIndex(
505
- Math.min(
506
- thinkingBlocks.length - 1,
507
- currentThinkingIndex + 1
508
- )
509
- )
510
- }
511
- disabled={currentThinkingIndex === thinkingBlocks.length - 1}
512
- className="thinking-nav-btn"
513
- >
514
-
515
- </button>
516
- </div>
517
- )}
518
- </div>
519
- <div className="thinking-content">{content}</div>
520
- </div>
521
- </div>
522
- );
523
- }, [thinkingBlocks, currentThinkingIndex]);
534
+ // NOTE: activeThinkingBlock and renderThinkingBlocks are defined after useLLM hook (needs response)
524
535
 
525
536
  const getBrowserInfo = () => {
526
537
  try {
@@ -700,10 +711,66 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
700
711
 
701
712
  const { send, response, idle, stop, lastCallId, setResponse, error: llmError } = llmResult;
702
713
 
714
+ // Compute active thinking block directly from response during render (avoids state batching issues)
715
+ const activeThinkingBlock = useMemo(() => {
716
+ if (!response || justReset) return null;
717
+ const { activeBlock } = processThinkingTags(response);
718
+ return activeBlock;
719
+ }, [response, justReset, processThinkingTags]);
720
+
721
+ // Render thinking blocks using new collapsible ThinkingBlock component
722
+ const renderThinkingBlocks = useCallback((isStreaming: boolean = false): React.ReactElement | null => {
723
+ const hasActiveBlock = activeThinkingBlock !== null;
724
+ const hasCompletedBlocks = thinkingBlocks.length > 0;
725
+
726
+ if (!hasActiveBlock && !hasCompletedBlocks) return null;
727
+
728
+ const handleToggleCollapse = (blockKey: string) => {
729
+ setCollapsedBlocks(prev => {
730
+ const next = new Set(prev);
731
+ if (next.has(blockKey)) {
732
+ next.delete(blockKey);
733
+ } else {
734
+ next.add(blockKey);
735
+ }
736
+ return next;
737
+ });
738
+ };
739
+
740
+ return (
741
+ <>
742
+ {/* Render completed blocks first */}
743
+ {thinkingBlocks.map((block, index) => {
744
+ const blockKey = `block-${index}`;
745
+ return (
746
+ <ThinkingBlockComponent
747
+ key={blockKey}
748
+ type={block.type}
749
+ content={block.content}
750
+ isStreaming={false}
751
+ isCollapsed={collapsedBlocks.has(blockKey)}
752
+ onToggleCollapse={() => handleToggleCollapse(blockKey)}
753
+ />
754
+ );
755
+ })}
756
+
757
+ {/* Render active (streaming) block */}
758
+ {activeThinkingBlock && (
759
+ <ThinkingBlockComponent
760
+ key="active-streaming"
761
+ type={activeThinkingBlock.type}
762
+ content={activeThinkingBlock.content}
763
+ isStreaming={true}
764
+ isCollapsed={collapsedBlocks.has('active')}
765
+ onToggleCollapse={() => handleToggleCollapse('active')}
766
+ />
767
+ )}
768
+ </>
769
+ );
770
+ }, [thinkingBlocks, activeThinkingBlock, collapsedBlocks]);
771
+
703
772
  // Error handler function - reusable for both error state and callbacks
704
773
  const handleError = useCallback((errorMessage: string, historyKey?: string) => {
705
- console.log('[ChatPanel] Error detected:', errorMessage);
706
-
707
774
  // Detect 413 Content Too Large error
708
775
  if (errorMessage.includes('413') || errorMessage.toLowerCase().includes('content too large')) {
709
776
  setError({
@@ -745,6 +812,16 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
745
812
  // Monitor for errors from useLLM hook (for errors not caught by callback)
746
813
  useEffect(() => {
747
814
  if (llmError && llmError.trim()) {
815
+ // Skip if we've already processed this exact error
816
+ // This prevents re-showing the same error when a new call starts but llmError
817
+ // hasn't been cleared yet by the useLLM hook
818
+ if (lastProcessedErrorRef.current === llmError) {
819
+ console.log('[ChatPanel] Skipping duplicate error:', llmError);
820
+ return;
821
+ }
822
+
823
+ console.log('[ChatPanel] Error detected:', llmError);
824
+ lastProcessedErrorRef.current = llmError;
748
825
  handleError(llmError);
749
826
  }
750
827
  }, [llmError, handleError]);
@@ -1568,13 +1645,45 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
1568
1645
  }
1569
1646
 
1570
1647
  // Step 3: Process thinking tags on the response without tool JSON
1571
- const { cleanedText, thinkingBlocks: newThinkingBlocks } =
1648
+ const { cleanedText, completedBlocks, activeBlock } =
1572
1649
  processThinkingTags(responseWithoutTools);
1573
1650
 
1574
- // Replace the blocks entirely (don't append) to avoid duplicates during streaming
1575
- setThinkingBlocks(newThinkingBlocks);
1651
+ // Update completed thinking blocks
1652
+ setThinkingBlocks(completedBlocks);
1576
1653
  // Always show the latest (last) thinking block
1577
- setCurrentThinkingIndex(Math.max(0, newThinkingBlocks.length - 1));
1654
+ setCurrentThinkingIndex(Math.max(0, completedBlocks.length - 1));
1655
+
1656
+ // Note: activeThinkingBlock is now computed via useMemo from response directly
1657
+
1658
+ // When a new block appears, collapse all previous blocks
1659
+ if (completedBlocks.length > prevBlockCountRef.current) {
1660
+ setCollapsedBlocks(prev => {
1661
+ const next = new Set(prev);
1662
+ // Collapse all blocks except the newest one
1663
+ for (let i = 0; i < completedBlocks.length - 1; i++) {
1664
+ next.add(`block-${i}`);
1665
+ }
1666
+ return next;
1667
+ });
1668
+ prevBlockCountRef.current = completedBlocks.length;
1669
+ }
1670
+
1671
+ // Auto-collapse all thinking blocks when main content starts appearing
1672
+ const hasThinkingContent = completedBlocks.length > 0 || activeBlock;
1673
+ const hasMainContent = cleanedText.trim().length > 0;
1674
+
1675
+ if (hasMainContent && hasThinkingContent && !hasAutoCollapsedRef.current) {
1676
+ hasAutoCollapsedRef.current = true;
1677
+ setTimeout(() => {
1678
+ // Collapse all blocks including active
1679
+ setCollapsedBlocks(prev => {
1680
+ const next = new Set(prev);
1681
+ completedBlocks.forEach((_, index) => next.add(`block-${index}`));
1682
+ next.add('active');
1683
+ return next;
1684
+ });
1685
+ }, 500);
1686
+ }
1578
1687
 
1579
1688
  // Step 4: Process other non-tool actions on the cleaned response with two-phase option
1580
1689
  const { processedContent: newResponse, buttonAttachments } = processActionsOnContent(
@@ -1631,6 +1740,8 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
1631
1740
  if (!idle) return; // only finalize at end
1632
1741
  if (!lastKey) return;
1633
1742
  if (finalizedForCallRef.current === lastCallId) return; // already finalized
1743
+
1744
+ // Note: activeThinkingBlock is computed via useMemo from response
1634
1745
 
1635
1746
  // Replace data-pending buttons in the stored history content for the lastKey
1636
1747
  setHistory((prev) => {
@@ -1673,6 +1784,9 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
1673
1784
  finalizedForCallRef.current = lastCallId;
1674
1785
  }, [idle, progressiveActions, lastCallId, lastKey]);
1675
1786
 
1787
+ // Note: activeThinkingBlock is computed via useMemo from response
1788
+ // It will automatically become null when response no longer has incomplete tags
1789
+
1676
1790
  // More reliable button attachment with retry mechanism and MutationObserver
1677
1791
  const attachButtonHandlers = useCallback(
1678
1792
  (
@@ -1979,6 +2093,10 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
1979
2093
  // Clear thinking blocks for new response
1980
2094
  setThinkingBlocks([]);
1981
2095
  setCurrentThinkingIndex(0);
2096
+ // Note: activeThinkingBlock is computed via useMemo from response
2097
+ setCollapsedBlocks(new Set());
2098
+ hasAutoCollapsedRef.current = false;
2099
+ prevBlockCountRef.current = 0;
1982
2100
 
1983
2101
  ensureConversation().then((convId) => {
1984
2102
  if (lastController) stop(lastController);
@@ -2211,6 +2329,10 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
2211
2329
  // Clear thinking blocks for new response
2212
2330
  setThinkingBlocks([]);
2213
2331
  setCurrentThinkingIndex(0);
2332
+ // Note: activeThinkingBlock is computed via useMemo from response
2333
+ setCollapsedBlocks(new Set());
2334
+ hasAutoCollapsedRef.current = false;
2335
+ prevBlockCountRef.current = 0;
2214
2336
 
2215
2337
  // Clear any previous errors
2216
2338
  setError(null);
@@ -2843,103 +2965,51 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
2843
2965
  {index === Object.keys(history).length - 1 &&
2844
2966
  (isLoading || !idle) &&
2845
2967
  !justReset ? (
2846
- <div className="streaming-response">
2847
- {/* Display current thinking block or thinking message */}
2848
- {(() => {
2849
- const { cleanedText } = processThinkingTags(
2850
- response || ""
2851
- );
2852
-
2853
- // If we have thinking blocks, show the current one
2854
- if (thinkingBlocks.length > 0) {
2855
- const isOnLastBlock =
2856
- currentThinkingIndex === thinkingBlocks.length - 1;
2857
- const hasMainContent =
2858
- cleanedText && cleanedText.trim().length > 0;
2859
- const shouldShowLoading =
2860
- isOnLastBlock && !hasMainContent;
2861
-
2862
- return (
2863
- <div>
2864
- {renderThinkingBlocks()}
2865
- {/* Show animated thinking if we're showing the last block and no main content yet */}
2866
- {shouldShowLoading && (
2867
- <div className="loading-text">
2868
- Thinking...&nbsp;
2869
- <div className="dot"></div>
2870
- <div className="dot"></div>
2871
- <div className="dot"></div>
2872
- </div>
2873
- )}
2874
- </div>
2875
- );
2876
- }
2877
-
2878
- // If no thinking blocks yet but no main content, show generic thinking
2879
- if (!cleanedText || cleanedText.length === 0) {
2880
- return (
2968
+ (() => {
2969
+ // During streaming, compute content directly from response (not from history which may be stale)
2970
+ const { cleanedText: streamingCleanedText } = processThinkingTags(response || '');
2971
+ const hasStreamingContent = streamingCleanedText.trim().length > 0;
2972
+
2973
+ return (
2974
+ <div className="streaming-response">
2975
+ {/* Display thinking blocks (completed and streaming) */}
2976
+ {(thinkingBlocks.length > 0 || activeThinkingBlock) && renderThinkingBlocks(true)}
2977
+
2978
+ {/* Display streaming content or loading indicator */}
2979
+ {hasStreamingContent ? (
2980
+ (() => {
2981
+ const content = (
2982
+ <ReactMarkdown
2983
+ remarkPlugins={[remarkGfm]}
2984
+ rehypePlugins={[rehypeRaw]}
2985
+ components={{ /*a: CustomLink,*/ code: CodeBlock }}
2986
+ >
2987
+ {streamingCleanedText}
2988
+ </ReactMarkdown>
2989
+ );
2990
+ return markdownClass ? (
2991
+ <div className={markdownClass}>{content}</div>
2992
+ ) : (
2993
+ content
2994
+ );
2995
+ })()
2996
+ ) : (
2881
2997
  <div className="loading-text">
2882
- Thinking...&nbsp;
2998
+ {thinkingBlocks.length > 0 || activeThinkingBlock ? 'Still thinking' : 'Thinking'}...&nbsp;
2883
2999
  <div className="dot"></div>
2884
3000
  <div className="dot"></div>
2885
3001
  <div className="dot"></div>
2886
3002
  </div>
2887
- );
2888
- }
2889
-
2890
- return null;
2891
- })()}
2892
-
2893
- {/* Display the main content (processed with actions) */}
2894
- {(() => {
2895
- // Get the processed content that includes action buttons from history
2896
- // During streaming, use the most recent history entry if it exists
2897
- if (lastKey && history[lastKey] && history[lastKey].content) {
2898
- const content = (
2899
- <ReactMarkdown
2900
- remarkPlugins={[remarkGfm]}
2901
- rehypePlugins={[rehypeRaw]}
2902
- components={{ /*a: CustomLink,*/ code: CodeBlock }}
2903
- >
2904
- {history[lastKey].content}
2905
- </ReactMarkdown>
2906
- );
2907
- return markdownClass ? (
2908
- <div className={markdownClass}>{content}</div>
2909
- ) : (
2910
- content
2911
- );
2912
- }
2913
-
2914
- // Fallback to cleaned text if no processed history exists yet
2915
- const { cleanedText } = processThinkingTags(
2916
- response || ""
2917
- );
2918
- if (cleanedText && cleanedText.length > 0) {
2919
- const content = (
2920
- <ReactMarkdown
2921
- remarkPlugins={[remarkGfm]}
2922
- rehypePlugins={[rehypeRaw]}
2923
- components={{ /*a: CustomLink,*/ code: CodeBlock }}
2924
- >
2925
- {cleanedText}
2926
- </ReactMarkdown>
2927
- );
2928
- return markdownClass ? (
2929
- <div className={markdownClass}>{content}</div>
2930
- ) : (
2931
- content
2932
- );
2933
- }
2934
- return null;
2935
- })()}
2936
- </div>
3003
+ )}
3004
+ </div>
3005
+ );
3006
+ })()
2937
3007
  ) : (
2938
3008
  <div>
2939
3009
  {/* For completed responses, show stored thinking blocks if this is the last entry */}
2940
3010
  {isLastEntry &&
2941
3011
  thinkingBlocks.length > 0 &&
2942
- renderThinkingBlocks()}
3012
+ renderThinkingBlocks(false)}
2943
3013
 
2944
3014
  {/* Show the main content (cleaned of thinking tags) */}
2945
3015
  {markdownClass ? (
@@ -3287,6 +3357,10 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
3287
3357
  setSessionApprovedTools([]);
3288
3358
  setThinkingBlocks([]);
3289
3359
  setCurrentThinkingIndex(0);
3360
+ // Note: activeThinkingBlock is computed via useMemo from response
3361
+ setCollapsedBlocks(new Set());
3362
+ hasAutoCollapsedRef.current = false;
3363
+ prevBlockCountRef.current = 0;
3290
3364
  setPendingButtonAttachments([]);
3291
3365
  setPendingToolRequests([]);
3292
3366
  setIsEmailModalOpen(false);