@hef2024/llmasaservice-ui 0.22.10 → 0.23.0

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.
@@ -19,7 +19,7 @@ import rehypeRaw from 'rehype-raw';
19
19
  import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
20
20
  import materialDark from 'react-syntax-highlighter/dist/esm/styles/prism/material-dark.js';
21
21
  import materialLight from 'react-syntax-highlighter/dist/esm/styles/prism/material-light.js';
22
- import { Button, ScrollArea, Tooltip } from './components/ui';
22
+ import { Button, ScrollArea, Tooltip, ThinkingBlock as ThinkingBlockComponent } from './components/ui';
23
23
  import ToolInfoModal from './components/ui/ToolInfoModal';
24
24
  import './AIChatPanel.css';
25
25
 
@@ -136,7 +136,7 @@ interface HistoryEntry {
136
136
  }
137
137
 
138
138
  interface ThinkingBlock {
139
- type: 'reasoning' | 'searching';
139
+ type: 'thinking' | 'reasoning' | 'searching';
140
140
  content: string;
141
141
  index: number;
142
142
  }
@@ -792,11 +792,17 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
792
792
  const [followOnQuestionsState, setFollowOnQuestionsState] = useState(followOnQuestions);
793
793
  const [thinkingBlocks, setThinkingBlocks] = useState<ThinkingBlock[]>([]);
794
794
  const [currentThinkingIndex, setCurrentThinkingIndex] = useState(0);
795
+ // NOTE: activeThinkingBlock is computed via useMemo, not useState - see below after processThinkingTags
796
+ // Track collapsed state per block (key: "block-{index}" or "active" for streaming block)
797
+ const [collapsedBlocks, setCollapsedBlocks] = useState<Set<string>>(new Set());
798
+ const hasAutoCollapsedRef = useRef(false); // Track if we've auto-collapsed for current response
799
+ const prevBlockCountRef = useRef(0); // Track previous block count to detect new blocks
795
800
  const [newConversationConfirm, setNewConversationConfirm] = useState(false);
796
801
  const [justReset, setJustReset] = useState(false);
797
802
  const [copiedCallId, setCopiedCallId] = useState<string | null>(null);
798
803
  const [feedbackCallId, setFeedbackCallId] = useState<{ callId: string; type: 'up' | 'down' } | null>(null);
799
804
  const [error, setError] = useState<{ message: string; code?: string } | null>(null);
805
+ const lastProcessedErrorRef = useRef<string | null>(null);
800
806
 
801
807
  // Email & Save state
802
808
  const [emailSent, setEmailSent] = useState(false);
@@ -1292,34 +1298,132 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1292
1298
  }, []);
1293
1299
 
1294
1300
  // Process thinking tags from response
1295
- const processThinkingTags = useCallback((text: string): { cleanedText: string; blocks: ThinkingBlock[] } => {
1296
- const blocks: ThinkingBlock[] = [];
1297
- let index = 0;
1301
+ const processThinkingTags = useCallback((text: string): {
1302
+ cleanedText: string;
1303
+ completedBlocks: ThinkingBlock[];
1304
+ activeBlock: { type: 'thinking' | 'reasoning' | 'searching'; content: string } | null;
1305
+ lastThinkingContent: string;
1306
+ } => {
1307
+ if (!text) {
1308
+ return {
1309
+ cleanedText: '',
1310
+ completedBlocks: [],
1311
+ activeBlock: null,
1312
+ lastThinkingContent: 'Thinking',
1313
+ };
1314
+ }
1315
+
1316
+ // Remove zero-width space characters from keepalive before processing
1317
+ const processedText = text.replace(/\u200B/g, '');
1318
+
1319
+ const allMatches: ThinkingBlock[] = [];
1298
1320
 
1299
- // Extract reasoning blocks
1300
- const reasoningRegex = /<reasoning>([\s\S]*?)<\/reasoning>/gi;
1321
+ // Extract complete thinking blocks
1322
+ const thinkingRegex = /<thinking>([\s\S]*?)<\/thinking>/gi;
1301
1323
  let match;
1302
- while ((match = reasoningRegex.exec(text)) !== null) {
1303
- blocks.push({ type: 'reasoning', content: match[1] ?? '', index: index++ });
1324
+ while ((match = thinkingRegex.exec(processedText)) !== null) {
1325
+ const content = match[1]?.trim();
1326
+ if (content) {
1327
+ allMatches.push({ content, index: match.index, type: 'thinking' });
1328
+ }
1304
1329
  }
1305
1330
 
1306
- // Extract searching blocks
1331
+ // Extract complete reasoning blocks
1332
+ const reasoningRegex = /<reasoning>([\s\S]*?)<\/reasoning>/gi;
1333
+ while ((match = reasoningRegex.exec(processedText)) !== null) {
1334
+ const content = match[1]?.trim();
1335
+ if (content) {
1336
+ allMatches.push({ content, index: match.index, type: 'reasoning' });
1337
+ }
1338
+ }
1339
+
1340
+ // Extract complete searching blocks
1307
1341
  const searchingRegex = /<searching>([\s\S]*?)<\/searching>/gi;
1308
- while ((match = searchingRegex.exec(text)) !== null) {
1309
- blocks.push({ type: 'searching', content: match[1] ?? '', index: index++ });
1342
+ while ((match = searchingRegex.exec(processedText)) !== null) {
1343
+ const content = match[1]?.trim();
1344
+ if (content) {
1345
+ allMatches.push({ content, index: match.index, type: 'searching' });
1346
+ }
1310
1347
  }
1311
1348
 
1312
1349
  // Sort by position in original text
1313
- blocks.sort((a, b) => a.index - b.index);
1350
+ const completedBlocks = allMatches.sort((a, b) => a.index - b.index);
1351
+
1352
+ // Check for incomplete (streaming) tags at the end of the text
1353
+ let activeBlock: { type: 'thinking' | 'reasoning' | 'searching'; content: string } | null = null;
1354
+ const tagTypes = ['thinking', 'reasoning', 'searching'] as const;
1355
+ let latestIncompletePos = -1;
1314
1356
 
1315
- // Clean the text
1316
- let cleanedText = text
1357
+ for (const tagType of tagTypes) {
1358
+ const openTag = `<${tagType}>`;
1359
+ const closeTag = `</${tagType}>`;
1360
+ const textLower = processedText.toLowerCase();
1361
+
1362
+ // Find the last occurrence of this opening tag
1363
+ const lastOpenIndex = textLower.lastIndexOf(openTag);
1364
+ if (lastOpenIndex === -1) continue;
1365
+
1366
+ // Check if there's a closing tag after this opening tag
1367
+ const afterOpen = processedText.slice(lastOpenIndex + openTag.length);
1368
+ const closeIndex = afterOpen.toLowerCase().indexOf(closeTag);
1369
+
1370
+ // If no closing tag found after the opening tag, this is incomplete
1371
+ if (closeIndex === -1 && lastOpenIndex > latestIncompletePos) {
1372
+ latestIncompletePos = lastOpenIndex;
1373
+ activeBlock = { type: tagType, content: afterOpen };
1374
+ }
1375
+ }
1376
+
1377
+ // Also check for partial opening tags (e.g., "<reas" before full "<reasoning>")
1378
+ if (!activeBlock) {
1379
+ const partialTagPatterns = [
1380
+ { pattern: /<think(?:i(?:n(?:g)?)?)?$/i, type: 'thinking' as const },
1381
+ { pattern: /<reas(?:o(?:n(?:i(?:n(?:g)?)?)?)?)?$/i, type: 'reasoning' as const },
1382
+ { pattern: /<sear(?:c(?:h(?:i(?:n(?:g)?)?)?)?)?$/i, type: 'searching' as const },
1383
+ ];
1384
+
1385
+ for (const { pattern, type } of partialTagPatterns) {
1386
+ if (pattern.test(processedText)) {
1387
+ activeBlock = { type, content: '' };
1388
+ break;
1389
+ }
1390
+ }
1391
+ }
1392
+
1393
+ // Clean the text by removing all thinking-related tags (complete and incomplete)
1394
+ let cleanedText = processedText
1395
+ .replace(/<thinking>[\s\S]*?<\/thinking>/gi, '')
1317
1396
  .replace(/<reasoning>[\s\S]*?<\/reasoning>/gi, '')
1318
1397
  .replace(/<searching>[\s\S]*?<\/searching>/gi, '')
1398
+ // Also remove partial opening tags
1399
+ .replace(/<think(?:i(?:n(?:g)?)?)?$/i, '')
1400
+ .replace(/<reas(?:o(?:n(?:i(?:n(?:g)?)?)?)?)?$/i, '')
1401
+ .replace(/<sear(?:c(?:h(?:i(?:n(?:g)?)?)?)?)?$/i, '')
1402
+ .replace(/<thinking>[\s\S]*$/i, '')
1403
+ .replace(/<reasoning>[\s\S]*$/i, '')
1404
+ .replace(/<searching>[\s\S]*$/i, '')
1319
1405
  .trim();
1320
-
1321
- return { cleanedText, blocks };
1322
- }, []);
1406
+
1407
+ // Get last thinking content for display
1408
+ let lastThinkingContent = 'Thinking';
1409
+ if (completedBlocks.length > 0) {
1410
+ const lastBlock = completedBlocks[completedBlocks.length - 1];
1411
+ if (lastBlock?.content) {
1412
+ lastThinkingContent = cleanContentForDisplay(lastBlock.content);
1413
+ }
1414
+ } else if (activeBlock?.content) {
1415
+ lastThinkingContent = cleanContentForDisplay(activeBlock.content);
1416
+ }
1417
+
1418
+ return { cleanedText, completedBlocks, activeBlock, lastThinkingContent };
1419
+ }, [cleanContentForDisplay]);
1420
+
1421
+ // Compute active thinking block directly from response during render (avoids state batching issues)
1422
+ const activeThinkingBlock = useMemo(() => {
1423
+ if (!response || justReset) return null;
1424
+ const { activeBlock } = processThinkingTags(response);
1425
+ return activeBlock;
1426
+ }, [response, justReset, processThinkingTags]);
1323
1427
 
1324
1428
  // Built-in action for agent suggestion cards
1325
1429
  // Pattern: [SUGGEST_AGENT:agent-id|Agent Name|Brief reason]
@@ -1515,11 +1619,16 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1515
1619
  // promptText is now required - comes from the isolated ChatInput component
1516
1620
  const continueChat = useCallback((promptText: string) => {
1517
1621
  // Clear thinking blocks for new response
1622
+ // Note: activeThinkingBlock is computed via useMemo from response
1518
1623
  setThinkingBlocks([]);
1519
1624
  setCurrentThinkingIndex(0);
1625
+ setCollapsedBlocks(new Set());
1626
+ hasAutoCollapsedRef.current = false;
1627
+ prevBlockCountRef.current = 0;
1520
1628
 
1521
1629
  // Clear any previous errors
1522
1630
  setError(null);
1631
+ lastProcessedErrorRef.current = null; // Allow new errors to be processed
1523
1632
 
1524
1633
  // Reset scroll tracking for new message - enable auto-scroll
1525
1634
  setUserHasScrolled(false);
@@ -1632,12 +1741,44 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1632
1741
  // Error callback - handle errors immediately
1633
1742
  console.log('[AIChatPanel] Error callback triggered:', errorMsg);
1634
1743
 
1744
+ // Check if this is a user-initiated abort
1745
+ const isAbortError = errorMsg.toLowerCase().includes('abort') ||
1746
+ errorMsg.toLowerCase().includes('canceled') ||
1747
+ errorMsg.toLowerCase().includes('cancelled');
1748
+
1749
+ if (isAbortError) {
1750
+ // User canceled the request - don't show error banner
1751
+ console.log('[AIChatPanel] Request was aborted by user');
1752
+ // Don't set error state - no red banner
1753
+
1754
+ // Update history to show cancellation
1755
+ if (promptKey) {
1756
+ setHistory((prev) => ({
1757
+ ...prev,
1758
+ [promptKey]: {
1759
+ content: 'Response canceled',
1760
+ callId: lastCallId || '',
1761
+ },
1762
+ }));
1763
+ }
1764
+ }
1635
1765
  // Detect 413 Content Too Large error
1636
- if (errorMsg.includes('413') || errorMsg.toLowerCase().includes('content too large')) {
1766
+ else if (errorMsg.includes('413') || errorMsg.toLowerCase().includes('content too large')) {
1637
1767
  setError({
1638
1768
  message: 'The context is too large to process. Please start a new conversation or reduce the amount of context.',
1639
1769
  code: '413',
1640
1770
  });
1771
+
1772
+ // Update history to show error
1773
+ if (promptKey) {
1774
+ setHistory((prev) => ({
1775
+ ...prev,
1776
+ [promptKey]: {
1777
+ content: `Error: ${errorMsg}`,
1778
+ callId: lastCallId || '',
1779
+ },
1780
+ }));
1781
+ }
1641
1782
  }
1642
1783
  // Detect other network errors
1643
1784
  else if (errorMsg.toLowerCase().includes('network error') || errorMsg.toLowerCase().includes('fetch')) {
@@ -1645,6 +1786,17 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1645
1786
  message: 'Network error. Please check your connection and try again.',
1646
1787
  code: 'NETWORK_ERROR',
1647
1788
  });
1789
+
1790
+ // Update history to show error
1791
+ if (promptKey) {
1792
+ setHistory((prev) => ({
1793
+ ...prev,
1794
+ [promptKey]: {
1795
+ content: `Error: ${errorMsg}`,
1796
+ callId: lastCallId || '',
1797
+ },
1798
+ }));
1799
+ }
1648
1800
  }
1649
1801
  // Generic error
1650
1802
  else {
@@ -1652,21 +1804,21 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1652
1804
  message: errorMsg,
1653
1805
  code: 'UNKNOWN_ERROR',
1654
1806
  });
1807
+
1808
+ // Update history to show error
1809
+ if (promptKey) {
1810
+ setHistory((prev) => ({
1811
+ ...prev,
1812
+ [promptKey]: {
1813
+ content: `Error: ${errorMsg}`,
1814
+ callId: lastCallId || '',
1815
+ },
1816
+ }));
1817
+ }
1655
1818
  }
1656
1819
 
1657
1820
  // Reset loading state
1658
1821
  setIsLoading(false);
1659
-
1660
- // Update history to show error
1661
- if (promptKey) {
1662
- setHistory((prev) => ({
1663
- ...prev,
1664
- [promptKey]: {
1665
- content: `Error: ${errorMsg}`,
1666
- callId: lastCallId || '',
1667
- },
1668
- }));
1669
- }
1670
1822
  }
1671
1823
  );
1672
1824
 
@@ -1737,6 +1889,10 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1737
1889
  setFollowOnQuestionsState(followOnQuestions);
1738
1890
  setThinkingBlocks([]);
1739
1891
  setCurrentThinkingIndex(0);
1892
+ setCollapsedBlocks(new Set());
1893
+ // Note: activeThinkingBlock is computed via useMemo from response
1894
+ hasAutoCollapsedRef.current = false;
1895
+ prevBlockCountRef.current = 0;
1740
1896
  setJustReset(true);
1741
1897
  setLastController(new AbortController());
1742
1898
  setUserHasScrolled(false);
@@ -1760,10 +1916,40 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1760
1916
  useEffect(() => {
1761
1917
  if (!response || !lastKey || justReset) return;
1762
1918
 
1763
- const { cleanedText, blocks } = processThinkingTags(response);
1919
+ const { cleanedText, completedBlocks } = processThinkingTags(response);
1764
1920
 
1765
1921
  // Update display state
1766
- setThinkingBlocks(blocks);
1922
+ // Note: activeThinkingBlock is computed via useMemo from response directly
1923
+ setThinkingBlocks(completedBlocks);
1924
+
1925
+ // When a new block appears, collapse all previous blocks
1926
+ if (completedBlocks.length > prevBlockCountRef.current) {
1927
+ setCollapsedBlocks(prev => {
1928
+ const next = new Set(prev);
1929
+ // Collapse all blocks except the newest one
1930
+ for (let i = 0; i < completedBlocks.length - 1; i++) {
1931
+ next.add(`block-${i}`);
1932
+ }
1933
+ return next;
1934
+ });
1935
+ prevBlockCountRef.current = completedBlocks.length;
1936
+ }
1937
+
1938
+ // Auto-collapse all thinking blocks when main content starts appearing
1939
+ const hasMainContent = cleanedText.trim().length > 0;
1940
+ const hasThinkingContent = completedBlocks.length > 0 || processThinkingTags(response).activeBlock !== null;
1941
+ if (hasMainContent && hasThinkingContent && !hasAutoCollapsedRef.current) {
1942
+ hasAutoCollapsedRef.current = true;
1943
+ setTimeout(() => {
1944
+ // Collapse all blocks including active
1945
+ setCollapsedBlocks(prev => {
1946
+ const next = new Set(prev);
1947
+ completedBlocks.forEach((_, index) => next.add(`block-${index}`));
1948
+ next.add('active');
1949
+ return next;
1950
+ });
1951
+ }, 500);
1952
+ }
1767
1953
 
1768
1954
  // Update history state with RAW content (actions applied at render time)
1769
1955
  setHistory((prev) => {
@@ -1959,17 +2145,49 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1959
2145
  // Monitor for errors from useLLM hook
1960
2146
  useEffect(() => {
1961
2147
  if (llmError && llmError.trim()) {
2148
+ // Skip if we've already processed this exact error
2149
+ if (lastProcessedErrorRef.current === llmError) {
2150
+ console.log('[AIChatPanel] Skipping duplicate error:', llmError);
2151
+ return;
2152
+ }
2153
+
1962
2154
  console.log('[AIChatPanel] Error detected:', llmError);
2155
+ lastProcessedErrorRef.current = llmError;
1963
2156
 
1964
2157
  // Parse error message to detect specific error types
1965
2158
  const errorMessage = llmError;
1966
2159
 
2160
+ // Check if this is a user-initiated abort
2161
+ const isAbortError = errorMessage.toLowerCase().includes('abort') ||
2162
+ errorMessage.toLowerCase().includes('canceled') ||
2163
+ errorMessage.toLowerCase().includes('cancelled');
2164
+
2165
+ if (isAbortError) {
2166
+ // User canceled the request - don't show error banner
2167
+ console.log('[AIChatPanel] Request was aborted by user (useEffect)');
2168
+ // Don't set error state - no red banner
2169
+
2170
+ // Don't update history here - the error callback in send() already handled it
2171
+ // with the correct promptKey. Updating here with lastKey can affect the wrong entry
2172
+ // if the user has already submitted a new prompt.
2173
+ }
1967
2174
  // Detect 413 Content Too Large error
1968
- if (errorMessage.includes('413') || errorMessage.toLowerCase().includes('content too large')) {
2175
+ else if (errorMessage.includes('413') || errorMessage.toLowerCase().includes('content too large')) {
1969
2176
  setError({
1970
2177
  message: 'The context is too large to process. Please start a new conversation or reduce the amount of context.',
1971
2178
  code: '413',
1972
2179
  });
2180
+
2181
+ // Update history to show error
2182
+ if (lastKey) {
2183
+ setHistory((prev) => ({
2184
+ ...prev,
2185
+ [lastKey]: {
2186
+ content: `Error: ${errorMessage}`,
2187
+ callId: lastCallId || '',
2188
+ },
2189
+ }));
2190
+ }
1973
2191
  }
1974
2192
  // Detect other network errors
1975
2193
  else if (errorMessage.toLowerCase().includes('network error') || errorMessage.toLowerCase().includes('fetch')) {
@@ -1977,6 +2195,17 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1977
2195
  message: 'Network error. Please check your connection and try again.',
1978
2196
  code: 'NETWORK_ERROR',
1979
2197
  });
2198
+
2199
+ // Update history to show error
2200
+ if (lastKey) {
2201
+ setHistory((prev) => ({
2202
+ ...prev,
2203
+ [lastKey]: {
2204
+ content: `Error: ${errorMessage}`,
2205
+ callId: lastCallId || '',
2206
+ },
2207
+ }));
2208
+ }
1980
2209
  }
1981
2210
  // Generic error
1982
2211
  else {
@@ -1984,21 +2213,21 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1984
2213
  message: errorMessage,
1985
2214
  code: 'UNKNOWN_ERROR',
1986
2215
  });
2216
+
2217
+ // Update history to show error
2218
+ if (lastKey) {
2219
+ setHistory((prev) => ({
2220
+ ...prev,
2221
+ [lastKey]: {
2222
+ content: `Error: ${errorMessage}`,
2223
+ callId: lastCallId || '',
2224
+ },
2225
+ }));
2226
+ }
1987
2227
  }
1988
2228
 
1989
2229
  // Reset loading state
1990
2230
  setIsLoading(false);
1991
-
1992
- // Update history to show error
1993
- if (lastKey) {
1994
- setHistory((prev) => ({
1995
- ...prev,
1996
- [lastKey]: {
1997
- content: `Error: ${errorMessage}`,
1998
- callId: lastCallId || '',
1999
- },
2000
- }));
2001
- }
2002
2231
  }
2003
2232
  }, [llmError, lastKey, lastCallId]);
2004
2233
 
@@ -2219,46 +2448,56 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2219
2448
  },
2220
2449
  }), [CodeBlock, AgentSuggestionCard]);
2221
2450
 
2222
- // Render thinking blocks
2223
- const renderThinkingBlocks = useCallback(() => {
2224
- if (thinkingBlocks.length === 0) return null;
2451
+ // Render thinking blocks with new collapsible design
2452
+ const renderThinkingBlocks = useCallback((isStreaming: boolean = false): React.ReactElement | null => {
2453
+ const hasActiveBlock = activeThinkingBlock !== null;
2454
+ const hasCompletedBlocks = thinkingBlocks.length > 0;
2225
2455
 
2226
- const currentBlock = thinkingBlocks[currentThinkingIndex];
2227
- if (!currentBlock) return null;
2456
+ if (!hasActiveBlock && !hasCompletedBlocks) return null;
2228
2457
 
2229
- const isReasoning = currentBlock.type === 'reasoning';
2230
- const icon = isReasoning ? <BrainIcon /> : <SearchIcon />;
2231
- const title = isReasoning ? 'Reasoning' : 'Searching';
2458
+ const handleToggleCollapse = (blockKey: string) => {
2459
+ setCollapsedBlocks(prev => {
2460
+ const next = new Set(prev);
2461
+ if (next.has(blockKey)) {
2462
+ next.delete(blockKey);
2463
+ } else {
2464
+ next.add(blockKey);
2465
+ }
2466
+ return next;
2467
+ });
2468
+ };
2232
2469
 
2233
2470
  return (
2234
- <div className="ai-chat-thinking">
2235
- <div className="ai-chat-thinking__header">
2236
- <span className="ai-chat-thinking__icon">{icon}</span>
2237
- <span className="ai-chat-thinking__title">{title}</span>
2238
- {thinkingBlocks.length > 1 && (
2239
- <span className="ai-chat-thinking__nav">
2240
- <button
2241
- onClick={() => setCurrentThinkingIndex(Math.max(0, currentThinkingIndex - 1))}
2242
- disabled={currentThinkingIndex === 0}
2243
- >
2244
-
2245
- </button>
2246
- <span>{currentThinkingIndex + 1} / {thinkingBlocks.length}</span>
2247
- <button
2248
- onClick={() => setCurrentThinkingIndex(Math.min(thinkingBlocks.length - 1, currentThinkingIndex + 1))}
2249
- disabled={currentThinkingIndex === thinkingBlocks.length - 1}
2250
- >
2251
-
2252
- </button>
2253
- </span>
2254
- )}
2255
- </div>
2256
- <div className="ai-chat-thinking__content">
2257
- {cleanContentForDisplay(currentBlock.content)}
2258
- </div>
2259
- </div>
2471
+ <>
2472
+ {/* Render completed blocks first */}
2473
+ {thinkingBlocks.map((block, index) => {
2474
+ const blockKey = `block-${index}`;
2475
+ return (
2476
+ <ThinkingBlockComponent
2477
+ key={blockKey}
2478
+ type={block.type}
2479
+ content={block.content}
2480
+ isStreaming={false}
2481
+ isCollapsed={collapsedBlocks.has(blockKey)}
2482
+ onToggleCollapse={() => handleToggleCollapse(blockKey)}
2483
+ />
2484
+ );
2485
+ })}
2486
+
2487
+ {/* Render active (streaming) block */}
2488
+ {activeThinkingBlock && (
2489
+ <ThinkingBlockComponent
2490
+ key="active-streaming"
2491
+ type={activeThinkingBlock.type}
2492
+ content={activeThinkingBlock.content}
2493
+ isStreaming={true}
2494
+ isCollapsed={collapsedBlocks.has('active')}
2495
+ onToggleCollapse={() => handleToggleCollapse('active')}
2496
+ />
2497
+ )}
2498
+ </>
2260
2499
  );
2261
- }, [thinkingBlocks, currentThinkingIndex, cleanContentForDisplay]);
2500
+ }, [thinkingBlocks, activeThinkingBlock, collapsedBlocks]);
2262
2501
 
2263
2502
  // ============================================================================
2264
2503
  // Render
@@ -2354,43 +2593,55 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2354
2593
  <div className="ai-chat-message__content">
2355
2594
  {/* Streaming state */}
2356
2595
  {isLastEntry && (isLoading || !idle) && !justReset ? (
2357
- <div className="ai-chat-streaming">
2358
- {thinkingBlocks.length > 0 && renderThinkingBlocks()}
2596
+ (() => {
2597
+ // During streaming, compute content directly from response (not from history which may be stale)
2598
+ const { cleanedText: streamingCleanedText } = processThinkingTags(response || '');
2599
+ const streamingContent = processActions(streamingCleanedText);
2600
+ const hasStreamingContent = streamingContent.trim().length > 0;
2359
2601
 
2360
- {processedContent ? (
2361
- markdownClass ? (
2362
- <div className={markdownClass}>
2363
- <ReactMarkdown
2364
- remarkPlugins={[remarkGfm]}
2365
- rehypePlugins={[rehypeRaw]}
2366
- components={markdownComponents}
2367
- >
2368
- {processedContent}
2369
- </ReactMarkdown>
2370
- </div>
2371
- ) : (
2372
- <ReactMarkdown
2373
- remarkPlugins={[remarkGfm]}
2374
- rehypePlugins={[rehypeRaw]}
2375
- components={markdownComponents}
2376
- >
2377
- {processedContent}
2378
- </ReactMarkdown>
2379
- )
2380
- ) : (
2381
- <div className="ai-chat-loading">
2382
- <span>Thinking</span>
2383
- <span className="ai-chat-loading__dots">
2384
- <span className="ai-chat-loading__dot" />
2385
- <span className="ai-chat-loading__dot" />
2386
- <span className="ai-chat-loading__dot" />
2387
- </span>
2602
+ return (
2603
+ <div className="ai-chat-streaming">
2604
+ {/* Show thinking blocks (both completed and active/streaming) */}
2605
+ {(thinkingBlocks.length > 0 || activeThinkingBlock) && renderThinkingBlocks(true)}
2606
+
2607
+ {/* Show streaming content or loading indicator */}
2608
+ {hasStreamingContent ? (
2609
+ markdownClass ? (
2610
+ <div className={markdownClass}>
2611
+ <ReactMarkdown
2612
+ remarkPlugins={[remarkGfm]}
2613
+ rehypePlugins={[rehypeRaw]}
2614
+ components={markdownComponents}
2615
+ >
2616
+ {streamingContent}
2617
+ </ReactMarkdown>
2618
+ </div>
2619
+ ) : (
2620
+ <ReactMarkdown
2621
+ remarkPlugins={[remarkGfm]}
2622
+ rehypePlugins={[rehypeRaw]}
2623
+ components={markdownComponents}
2624
+ >
2625
+ {streamingContent}
2626
+ </ReactMarkdown>
2627
+ )
2628
+ ) : (
2629
+ <div className="ai-chat-loading">
2630
+ <span>{thinkingBlocks.length > 0 || activeThinkingBlock ? 'Still thinking' : 'Thinking'}</span>
2631
+ <span className="ai-chat-loading__dots">
2632
+ <span className="ai-chat-loading__dot" />
2633
+ <span className="ai-chat-loading__dot" />
2634
+ <span className="ai-chat-loading__dot" />
2635
+ </span>
2636
+ </div>
2637
+ )}
2388
2638
  </div>
2389
- )}
2390
- </div>
2639
+ );
2640
+ })()
2391
2641
  ) : (
2392
2642
  <>
2393
- {isLastEntry && thinkingBlocks.length > 0 && renderThinkingBlocks()}
2643
+ {/* Show completed thinking blocks after streaming */}
2644
+ {isLastEntry && thinkingBlocks.length > 0 && renderThinkingBlocks(false)}
2394
2645
  {markdownClass ? (
2395
2646
  <div className={markdownClass}>
2396
2647
  <ReactMarkdown