@hef2024/llmasaservice-ui 0.24.2 → 0.24.4

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.
@@ -21,6 +21,12 @@ import materialDark from 'react-syntax-highlighter/dist/esm/styles/prism/materia
21
21
  import materialLight from 'react-syntax-highlighter/dist/esm/styles/prism/material-light.js';
22
22
  import { Button, ScrollArea, Tooltip, ThinkingBlock as ThinkingBlockComponent } from './components/ui';
23
23
  import ToolInfoModal from './components/ui/ToolInfoModal';
24
+ import {
25
+ MCPAuthPhase,
26
+ MCPAuthHeaderResolver,
27
+ normalizeMcpHeaders,
28
+ } from './mcpAuth';
29
+ import { parseToolArguments } from './toolArgsParser';
24
30
  import './AIChatPanel.css';
25
31
 
26
32
  // ============================================================================
@@ -38,6 +44,28 @@ export interface AgentOption {
38
44
  avatarUrl?: string;
39
45
  }
40
46
 
47
+ export interface BeforeSendPayload {
48
+ prompt: string;
49
+ conversationId: string | null;
50
+ agentId?: string | null;
51
+ service?: string | null;
52
+ messages: { role: string; content: string }[];
53
+ }
54
+
55
+ export type TraceContextMode = 'standard' | 'full';
56
+
57
+ export interface LocalToolExecutorContext {
58
+ toolName: string;
59
+ callId: string;
60
+ serviceTag: string;
61
+ mcpTool: Record<string, unknown> | null;
62
+ }
63
+
64
+ export type LocalToolExecutor = (
65
+ args: Record<string, unknown>,
66
+ context: LocalToolExecutorContext,
67
+ ) => Promise<unknown> | unknown;
68
+
41
69
  export interface AIChatPanelProps {
42
70
  project_id: string;
43
71
  initialPrompt?: string;
@@ -74,7 +102,11 @@ export interface AIChatPanelProps {
74
102
  initialHistory?: Record<string, { content: string; callId: string }>;
75
103
  hideRagContextInPrompt?: boolean;
76
104
  createConversationOnFirstChat?: boolean;
105
+ autoApproveTools?: boolean | string[];
77
106
  mcpServers?: any[];
107
+ resolveMcpAuthHeaders?: MCPAuthHeaderResolver;
108
+ localToolExecutors?: Record<string, LocalToolExecutor>;
109
+ traceContextMode?: TraceContextMode;
78
110
  progressiveActions?: boolean;
79
111
 
80
112
  // Agent selector props (Cursor-style at bottom of input)
@@ -94,6 +126,9 @@ export interface AIChatPanelProps {
94
126
  // Callback when a new conversation is created via API
95
127
  onConversationCreated?: (conversationId: string) => void;
96
128
 
129
+ // Callback invoked before each send() call
130
+ onBeforeSend?: (payload: BeforeSendPayload) => Promise<void> | void;
131
+
97
132
  // UI Customization Props (from ChatPanel)
98
133
  cssUrl?: string;
99
134
  markdownClass?: string;
@@ -143,8 +178,957 @@ interface ThinkingBlock {
143
178
  type: 'thinking' | 'reasoning' | 'searching';
144
179
  content: string;
145
180
  index: number;
181
+ signature: string;
182
+ }
183
+
184
+ interface ToolRequestMatch {
185
+ match: string;
186
+ groups: string[];
187
+ toolName: string;
188
+ callId: string;
189
+ serviceTag: string;
190
+ start: number;
191
+ end: number;
192
+ }
193
+
194
+ type ToolCallStatus = 'pending' | 'running' | 'completed' | 'error';
195
+
196
+ interface ToolCallStatusRow {
197
+ signature: string;
198
+ toolName: string;
199
+ callId: string;
200
+ status: ToolCallStatus;
201
+ statusLabel: string;
202
+ }
203
+
204
+ interface ToolReplaySummaryEntry {
205
+ toolName: string;
206
+ callId: string;
207
+ status: 'ok' | 'error';
208
+ argsText: string;
209
+ resultText: string;
210
+ }
211
+
212
+ interface InlineToolMarker {
213
+ toolName: string;
214
+ callId: string;
215
+ }
216
+
217
+ interface InlineThinkingMarker {
218
+ type: 'thinking' | 'reasoning' | 'searching';
219
+ signature: string;
146
220
  }
147
221
 
222
+ const areToolRequestListsEqual = (a: ToolRequestMatch[], b: ToolRequestMatch[]): boolean => {
223
+ if (a.length !== b.length) return false;
224
+ for (let index = 0; index < a.length; index += 1) {
225
+ const left = a[index];
226
+ const right = b[index];
227
+ if (!left || !right) return false;
228
+ if (left.callId !== right.callId) return false;
229
+ if (left.toolName !== right.toolName) return false;
230
+ if (left.serviceTag !== right.serviceTag) return false;
231
+ if (left.match !== right.match) return false;
232
+ if (left.start !== right.start || left.end !== right.end) return false;
233
+ }
234
+ return true;
235
+ };
236
+
237
+ const mergeContinuationResponseText = (baseText: string, continuationText: string): string => {
238
+ const base = typeof baseText === 'string' ? baseText : '';
239
+ const continuation = typeof continuationText === 'string' ? continuationText : '';
240
+ if (!base) return continuation;
241
+ if (!continuation) return base;
242
+
243
+ if (base.includes(continuation)) {
244
+ return base;
245
+ }
246
+
247
+ const maxOverlap = Math.min(base.length, continuation.length);
248
+ for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
249
+ if (base.slice(-overlap) === continuation.slice(0, overlap)) {
250
+ return `${base}${continuation.slice(overlap)}`;
251
+ }
252
+ }
253
+
254
+ return `${base}\n\n${continuation}`;
255
+ };
256
+
257
+ const INLINE_TOOL_MARKER_PREFIX = '[[AI_TOOL_CALL:';
258
+ const INLINE_TOOL_MARKER_SUFFIX = ']]';
259
+ const INLINE_TOOL_MARKER_REGEX = /\[\[AI_TOOL_CALL:([^|\]]+)\|([^\]]+)\]\]/g;
260
+ const INLINE_THINKING_MARKER_PREFIX = '[[AI_THINK_BLOCK:';
261
+ const INLINE_THINKING_MARKER_SUFFIX = ']]';
262
+ const INLINE_THINKING_MARKER_REGEX = /\[\[AI_THINK_BLOCK:([^|\]]+)\|([^\]]+)\]\]/g;
263
+ const MAX_TOOL_CONTINUATIONS_PER_TURN = 20;
264
+ const MAX_TOOL_REPLAY_PAYLOAD_CHARS = 750_000;
265
+ const MAX_TRACE_SUMMARY_CHARS = 1_800;
266
+ const MAX_TRACE_REASONING_BLOCKS = 4;
267
+ const MAX_TRACE_TOOL_LINES = 6;
268
+ const MAX_TRACE_ITEM_CHARS = 220;
269
+ const MAX_TRACE_LINE_CHARS = 420;
270
+
271
+ const hasInlineRuntimeMarkers = (value: string): boolean => {
272
+ const source = typeof value === 'string' ? value : '';
273
+ return source.includes(INLINE_TOOL_MARKER_PREFIX) || source.includes(INLINE_THINKING_MARKER_PREFIX);
274
+ };
275
+
276
+ const toNonEmptyLines = (value: string): string[] =>
277
+ String(value || '')
278
+ .split('\n')
279
+ .map((line) => line.trim())
280
+ .filter((line) => line.length > 0);
281
+
282
+ const isBoundarySubsetByLines = (existingLines: string[], incomingLines: string[]): boolean => {
283
+ if (incomingLines.length === 0 || existingLines.length === 0) return false;
284
+ if (incomingLines.length >= existingLines.length) return false;
285
+
286
+ const prefixMatch = incomingLines.every((line, index) => existingLines[index] === line);
287
+ if (prefixMatch) return true;
288
+
289
+ const suffixStart = existingLines.length - incomingLines.length;
290
+ return incomingLines.every((line, index) => existingLines[suffixStart + index] === line);
291
+ };
292
+
293
+ const shouldPreserveBoundaryDroppedStreamText = (
294
+ existingContent: string,
295
+ incomingContent: string,
296
+ ): boolean => {
297
+ const existing = String(existingContent || '').trim();
298
+ const incoming = String(incomingContent || '').trim();
299
+ if (!existing) return false;
300
+ if (!incoming) return true;
301
+ if (incoming === existing) return false;
302
+ if (!hasInlineRuntimeMarkers(existing)) return false;
303
+
304
+ if (existing.includes(incoming)) {
305
+ return true;
306
+ }
307
+
308
+ const existingLines = toNonEmptyLines(existing);
309
+ const incomingLines = toNonEmptyLines(incoming);
310
+ return isBoundarySubsetByLines(existingLines, incomingLines);
311
+ };
312
+
313
+ const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
314
+ !!value && typeof value === 'object' && !Array.isArray(value);
315
+
316
+ const stringifyToolArgs = (value: unknown): string => {
317
+ if (typeof value === 'string') return value;
318
+ try {
319
+ return JSON.stringify(value ?? {});
320
+ } catch (_error) {
321
+ return '{}';
322
+ }
323
+ };
324
+
325
+ const truncateTraceText = (value: string, maxChars: number): string => {
326
+ const text = String(value || '').trim();
327
+ if (!text) return '';
328
+ if (text.length <= maxChars) return text;
329
+ return `${text.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
330
+ };
331
+
332
+ const normalizeTraceText = (value: unknown): string => {
333
+ if (value === null || value === undefined) return '';
334
+ const raw =
335
+ typeof value === 'string'
336
+ ? value
337
+ : (() => {
338
+ try {
339
+ return JSON.stringify(value);
340
+ } catch (_error) {
341
+ return String(value);
342
+ }
343
+ })();
344
+ return String(raw || '')
345
+ .replace(/\s+/g, ' ')
346
+ .trim();
347
+ };
348
+
349
+ const toTraceObjectArray = (value: unknown): Record<string, unknown>[] => {
350
+ if (!Array.isArray(value)) return [];
351
+ return value.filter((item): item is Record<string, unknown> => isObjectRecord(item));
352
+ };
353
+
354
+ const getTraceStatusLabel = (response: Record<string, unknown> | undefined): 'ok' | 'error' | 'pending' => {
355
+ if (!response) return 'pending';
356
+ if (response.isError === true || response.error === true) return 'error';
357
+ return 'ok';
358
+ };
359
+
360
+ const buildCompactTraceSummary = ({
361
+ reasoningBlocks,
362
+ toolCalls,
363
+ toolResponses,
364
+ }: {
365
+ reasoningBlocks: ThinkingBlock[];
366
+ toolCalls?: unknown[];
367
+ toolResponses?: unknown[];
368
+ }): string => {
369
+ const sections: string[] = [];
370
+
371
+ const normalizedReasoning = (Array.isArray(reasoningBlocks) ? reasoningBlocks : [])
372
+ .filter((block) => !!block && typeof block.content === 'string' && block.content.trim().length > 0)
373
+ .slice(-MAX_TRACE_REASONING_BLOCKS)
374
+ .map((block) => {
375
+ const content = truncateTraceText(normalizeTraceText(block.content), MAX_TRACE_ITEM_CHARS);
376
+ if (!content) return '';
377
+ const type = typeof block.type === 'string' ? block.type : 'thinking';
378
+ return `- ${type}: ${content}`;
379
+ })
380
+ .filter(Boolean);
381
+
382
+ if (normalizedReasoning.length > 0) {
383
+ sections.push(['reasoning:', ...normalizedReasoning].join('\n'));
384
+ }
385
+
386
+ const calls = toTraceObjectArray(toolCalls).slice(-MAX_TRACE_TOOL_LINES);
387
+ const responses = toTraceObjectArray(toolResponses);
388
+ if (calls.length > 0) {
389
+ const responsesByCallId = new Map<string, Record<string, unknown>>();
390
+ responses.forEach((response) => {
391
+ const key = typeof response.tool_call_id === 'string' ? response.tool_call_id.trim() : '';
392
+ if (!key || responsesByCallId.has(key)) return;
393
+ responsesByCallId.set(key, response);
394
+ });
395
+
396
+ const responseOffset = Math.max(0, responses.length - calls.length);
397
+ const toolLines = calls
398
+ .map((call, index) => {
399
+ const toolName =
400
+ typeof call.name === 'string'
401
+ ? call.name.trim()
402
+ : typeof call.tool_name === 'string'
403
+ ? call.tool_name.trim()
404
+ : 'tool';
405
+ const callId =
406
+ typeof call.id === 'string'
407
+ ? call.id.trim()
408
+ : typeof call.tool_call_id === 'string'
409
+ ? call.tool_call_id.trim()
410
+ : '';
411
+ const response =
412
+ (callId ? responsesByCallId.get(callId) : undefined) || responses[responseOffset + index] || responses[index];
413
+ const status = getTraceStatusLabel(response);
414
+
415
+ const rawArgs = call.input ?? call.args ?? call.arguments ?? {};
416
+ const parsedArgs = parseToolArguments(rawArgs);
417
+ const normalizedArgs = truncateTraceText(
418
+ normalizeTraceText(parsedArgs ?? (isObjectRecord(rawArgs) ? rawArgs : rawArgs || {})),
419
+ MAX_TRACE_ITEM_CHARS,
420
+ );
421
+
422
+ const normalizedResult = truncateTraceText(
423
+ normalizeTraceText(response?.result ?? response?.content ?? response?.error ?? ''),
424
+ MAX_TRACE_ITEM_CHARS,
425
+ );
426
+
427
+ const base = callId ? `- ${toolName} (${callId}) ${status}` : `- ${toolName} ${status}`;
428
+ const withArgs = normalizedArgs ? `${base} args=${normalizedArgs}` : base;
429
+ const withResult = normalizedResult ? `${withArgs} result=${normalizedResult}` : withArgs;
430
+ return truncateTraceText(withResult, MAX_TRACE_LINE_CHARS);
431
+ })
432
+ .filter(Boolean);
433
+
434
+ if (toolLines.length > 0) {
435
+ sections.push(['tools:', ...toolLines].join('\n'));
436
+ }
437
+ }
438
+
439
+ if (sections.length === 0) return '';
440
+ return truncateTraceText(['TRACE SUMMARY (compact)', ...sections].join('\n'), MAX_TRACE_SUMMARY_CHARS);
441
+ };
442
+
443
+ const findPreviousNonWhitespaceChar = (text: string, startIndex: number): string | null => {
444
+ for (let index = startIndex; index >= 0; index -= 1) {
445
+ const char = text[index];
446
+ if (typeof char !== 'string') continue;
447
+ if (!/\s/.test(char)) {
448
+ return char;
449
+ }
450
+ }
451
+ return null;
452
+ };
453
+
454
+ const findNextNonWhitespaceChar = (text: string, startIndex: number): string | null => {
455
+ for (let index = startIndex; index < text.length; index += 1) {
456
+ const char = text[index];
457
+ if (typeof char !== 'string') continue;
458
+ if (!/\s/.test(char)) {
459
+ return char;
460
+ }
461
+ }
462
+ return null;
463
+ };
464
+
465
+ const isStandaloneToolObjectSegment = (text: string, openIndex: number, closeIndex: number): boolean => {
466
+ const lineStart = text.lastIndexOf('\n', openIndex - 1) + 1;
467
+ const lineEndRaw = text.indexOf('\n', closeIndex + 1);
468
+ const lineEnd = lineEndRaw === -1 ? text.length : lineEndRaw;
469
+
470
+ const linePrefix = text.slice(lineStart, openIndex);
471
+ const lineSuffix = text.slice(closeIndex + 1, lineEnd);
472
+ const startsLine = linePrefix.trim().length === 0;
473
+ const endsLine = lineSuffix.trim().length === 0;
474
+
475
+ const prevChar = findPreviousNonWhitespaceChar(text, openIndex - 1);
476
+ const nextChar = findNextNonWhitespaceChar(text, closeIndex + 1);
477
+ const prevDelimited = prevChar !== null && ['{', '}', '[', ']', ','].includes(prevChar);
478
+ const nextDelimited = nextChar !== null && ['{', '}', '[', ']', ','].includes(nextChar);
479
+
480
+ // Guard against replay-summary lines such as "Args: {...}" / "Result: {...}".
481
+ const prefixWindow = text.slice(Math.max(0, openIndex - 64), openIndex);
482
+ const hasLabelPrefix = /(?:^|[\r\n])\s*[A-Za-z][A-Za-z0-9 _-]{0,30}:\s*$/.test(prefixWindow);
483
+ if (hasLabelPrefix) return false;
484
+
485
+ return (startsLine || prevDelimited) && (endsLine || nextDelimited);
486
+ };
487
+
488
+ type ParsedJsonSegment = {
489
+ start: number;
490
+ end: number;
491
+ raw: string;
492
+ value: Record<string, unknown>;
493
+ };
494
+
495
+ const extractTopLevelJsonObjectSegments = (source: string): ParsedJsonSegment[] => {
496
+ const text = typeof source === 'string' ? source : '';
497
+ if (!text) return [];
498
+
499
+ const segments: ParsedJsonSegment[] = [];
500
+ let cursor = 0;
501
+
502
+ while (cursor < text.length) {
503
+ const openIndex = text.indexOf('{', cursor);
504
+ if (openIndex === -1) break;
505
+
506
+ let depth = 0;
507
+ let inString = false;
508
+ let escaped = false;
509
+ let closeIndex = -1;
510
+
511
+ for (let index = openIndex; index < text.length; index += 1) {
512
+ const char = text[index];
513
+
514
+ if (inString) {
515
+ if (escaped) {
516
+ escaped = false;
517
+ continue;
518
+ }
519
+ if (char === '\\') {
520
+ escaped = true;
521
+ continue;
522
+ }
523
+ if (char === '"') {
524
+ inString = false;
525
+ }
526
+ continue;
527
+ }
528
+
529
+ if (char === '"') {
530
+ inString = true;
531
+ continue;
532
+ }
533
+
534
+ if (char === '{') {
535
+ depth += 1;
536
+ continue;
537
+ }
538
+
539
+ if (char === '}') {
540
+ depth -= 1;
541
+ if (depth === 0) {
542
+ closeIndex = index;
543
+ break;
544
+ }
545
+ if (depth < 0) {
546
+ break;
547
+ }
548
+ }
549
+ }
550
+
551
+ if (closeIndex === -1) {
552
+ cursor = openIndex + 1;
553
+ continue;
554
+ }
555
+
556
+ const raw = text.slice(openIndex, closeIndex + 1);
557
+ try {
558
+ const value = JSON.parse(raw);
559
+ if (isObjectRecord(value) && isStandaloneToolObjectSegment(text, openIndex, closeIndex)) {
560
+ segments.push({
561
+ start: openIndex,
562
+ end: closeIndex + 1,
563
+ raw,
564
+ value,
565
+ });
566
+ }
567
+ } catch (_error) {
568
+ // Ignore parse failures for non-tool JSON snippets.
569
+ }
570
+
571
+ cursor = closeIndex + 1;
572
+ }
573
+
574
+ return segments;
575
+ };
576
+
577
+ const resolveToolRequestFromSegment = (
578
+ segment: ParsedJsonSegment,
579
+ fallbackIndex: number,
580
+ ): ToolRequestMatch | null => {
581
+ const record = segment.value;
582
+ const serviceTag = typeof record.service === 'string' ? record.service : '';
583
+ const rawId = typeof record.id === 'string' ? record.id.trim() : '';
584
+
585
+ if (record.type === 'tool_use' && typeof record.name === 'string') {
586
+ const toolName = String(record.name).trim();
587
+ if (!toolName) return null;
588
+ const callId = rawId || `${toolName}-${fallbackIndex + 1}`;
589
+ return {
590
+ match: segment.raw,
591
+ groups: [callId, toolName, stringifyToolArgs(record.input ?? {}), serviceTag],
592
+ toolName,
593
+ callId,
594
+ serviceTag,
595
+ start: segment.start,
596
+ end: segment.end,
597
+ };
598
+ }
599
+
600
+ if (record.type === 'function' && isObjectRecord(record.function)) {
601
+ const functionRecord = record.function;
602
+ const toolName = typeof functionRecord.name === 'string' ? functionRecord.name.trim() : '';
603
+ if (!toolName) return null;
604
+ const callId = rawId || `${toolName}-${fallbackIndex + 1}`;
605
+ return {
606
+ match: segment.raw,
607
+ groups: [callId, toolName, stringifyToolArgs(functionRecord.arguments ?? {}), serviceTag],
608
+ toolName,
609
+ callId,
610
+ serviceTag,
611
+ start: segment.start,
612
+ end: segment.end,
613
+ };
614
+ }
615
+
616
+ if (isObjectRecord(record.functionCall) && typeof record.functionCall.name === 'string') {
617
+ const functionCall = record.functionCall;
618
+ const toolName = String(functionCall.name).trim();
619
+ if (!toolName) return null;
620
+ const callId = rawId || `${toolName}-${fallbackIndex + 1}`;
621
+ return {
622
+ match: segment.raw,
623
+ groups: [callId, toolName, stringifyToolArgs(functionCall.args ?? {}), serviceTag],
624
+ toolName,
625
+ callId,
626
+ serviceTag,
627
+ start: segment.start,
628
+ end: segment.end,
629
+ };
630
+ }
631
+
632
+ return null;
633
+ };
634
+
635
+ const extractBalancedObjectCandidate = (input: string): string | null => {
636
+ const text = typeof input === 'string' ? input : '';
637
+ const start = text.indexOf('{');
638
+ if (start === -1) return null;
639
+
640
+ let depth = 0;
641
+ let inString = false;
642
+ let escaped = false;
643
+
644
+ for (let index = start; index < text.length; index += 1) {
645
+ const char = text[index];
646
+
647
+ if (inString) {
648
+ if (escaped) {
649
+ escaped = false;
650
+ continue;
651
+ }
652
+ if (char === '\\') {
653
+ escaped = true;
654
+ continue;
655
+ }
656
+ if (char === '"') {
657
+ inString = false;
658
+ }
659
+ continue;
660
+ }
661
+
662
+ if (char === '"') {
663
+ inString = true;
664
+ continue;
665
+ }
666
+ if (char === '{') {
667
+ depth += 1;
668
+ continue;
669
+ }
670
+ if (char === '}') {
671
+ depth -= 1;
672
+ if (depth === 0) {
673
+ return text.slice(start, index + 1);
674
+ }
675
+ }
676
+ }
677
+
678
+ return null;
679
+ };
680
+
681
+ const extractLineLevelFallbackToolRequests = (
682
+ source: string,
683
+ existing: ToolRequestMatch[],
684
+ ): ToolRequestMatch[] => {
685
+ const text = typeof source === 'string' ? source : '';
686
+ if (!text) return [];
687
+
688
+ const takenRanges = existing.map((request) => ({
689
+ start: request.start,
690
+ end: request.end,
691
+ }));
692
+
693
+ const overlapsExisting = (start: number, end: number): boolean =>
694
+ takenRanges.some((range) => start < range.end && end > range.start);
695
+
696
+ const requests: ToolRequestMatch[] = [];
697
+ let offset = 0;
698
+ const lines = text.split('\n');
699
+
700
+ const normalizeStandaloneToolJsonLine = (line: string): string | null => {
701
+ const trimmed = line.trim();
702
+ if (!trimmed) return null;
703
+
704
+ let normalized = trimmed;
705
+ const first = normalized[0];
706
+ const last = normalized[normalized.length - 1];
707
+ const hasSymmetricQuote =
708
+ normalized.length >= 2 &&
709
+ ((first === "'" && last === "'") || (first === '"' && last === '"') || (first === '`' && last === '`'));
710
+ if (hasSymmetricQuote) {
711
+ const inner = normalized.slice(1, -1).trim();
712
+ if (first === '"') {
713
+ try {
714
+ const decoded = JSON.parse(normalized);
715
+ if (typeof decoded === 'string') {
716
+ normalized = decoded.trim();
717
+ } else {
718
+ normalized = inner;
719
+ }
720
+ } catch (_error) {
721
+ normalized = inner;
722
+ }
723
+ } else {
724
+ normalized = inner;
725
+ }
726
+ }
727
+
728
+ const extractedCandidate = extractBalancedObjectCandidate(normalized);
729
+ if (extractedCandidate) {
730
+ normalized = extractedCandidate.trim();
731
+ }
732
+
733
+ if (!normalized.startsWith('{')) return null;
734
+ if (!normalized.includes('"type"')) return null;
735
+ return normalized;
736
+ };
737
+
738
+ const extractBalancedObjectAfterField = (input: string, fieldName: string): string | null => {
739
+ const fieldRegex = new RegExp(`"${fieldName}"\\s*:\\s*`, 'i');
740
+ const fieldMatch = fieldRegex.exec(input);
741
+ if (!fieldMatch) return null;
742
+
743
+ const startIndex = input.indexOf('{', fieldMatch.index + fieldMatch[0].length);
744
+ if (startIndex === -1) return null;
745
+
746
+ let depth = 0;
747
+ let inString = false;
748
+ let escaped = false;
749
+
750
+ for (let index = startIndex; index < input.length; index += 1) {
751
+ const char = input[index];
752
+
753
+ if (inString) {
754
+ if (escaped) {
755
+ escaped = false;
756
+ continue;
757
+ }
758
+ if (char === '\\') {
759
+ escaped = true;
760
+ continue;
761
+ }
762
+ if (char === '"') {
763
+ inString = false;
764
+ }
765
+ continue;
766
+ }
767
+
768
+ if (char === '"') {
769
+ inString = true;
770
+ continue;
771
+ }
772
+ if (char === '{') {
773
+ depth += 1;
774
+ continue;
775
+ }
776
+ if (char === '}') {
777
+ depth -= 1;
778
+ if (depth === 0) {
779
+ return input.slice(startIndex, index + 1);
780
+ }
781
+ }
782
+ }
783
+
784
+ return null;
785
+ };
786
+
787
+ lines.forEach((line, index) => {
788
+ const lineStart = offset;
789
+ const lineEnd = lineStart + line.length;
790
+ offset = lineEnd + (index < lines.length - 1 ? 1 : 0);
791
+
792
+ const normalized = normalizeStandaloneToolJsonLine(line);
793
+ if (!normalized) return;
794
+ if (!(normalized.includes('"tool_use"') || normalized.includes('"function"'))) return;
795
+ if (/(^|[\r\n])\s*[A-Za-z][A-Za-z0-9 _-]{0,30}:\s*\{/.test(line)) return;
796
+ if (overlapsExisting(lineStart, lineEnd)) return;
797
+
798
+ // Prefer strict JSON parsing after line-level normalization to handle
799
+ // quoted/escaped raw tool payload lines without brittle regex capture.
800
+ try {
801
+ const parsedLine = JSON.parse(normalized);
802
+ if (isObjectRecord(parsedLine)) {
803
+ const type = typeof parsedLine.type === 'string' ? parsedLine.type.trim().toLowerCase() : '';
804
+ if (type === 'tool_use') {
805
+ const toolName = typeof parsedLine.name === 'string' ? parsedLine.name.trim() : '';
806
+ if (!toolName) return;
807
+ const callId =
808
+ typeof parsedLine.id === 'string' && parsedLine.id.trim().length > 0
809
+ ? parsedLine.id.trim()
810
+ : `tool-call-${index + 1}`;
811
+ const serviceTag = typeof parsedLine.service === 'string' ? parsedLine.service : '';
812
+ const parsedArgs = parseToolArguments(parsedLine.input ?? {});
813
+ if (!parsedArgs) return;
814
+
815
+ requests.push({
816
+ match: line,
817
+ groups: [callId, toolName, stringifyToolArgs(parsedArgs), serviceTag],
818
+ toolName,
819
+ callId,
820
+ serviceTag,
821
+ start: lineStart,
822
+ end: lineEnd,
823
+ });
824
+ return;
825
+ }
826
+
827
+ if (type === 'function' && isObjectRecord(parsedLine.function)) {
828
+ const functionRecord = parsedLine.function;
829
+ const toolName = typeof functionRecord.name === 'string' ? functionRecord.name.trim() : '';
830
+ if (!toolName) return;
831
+ const callId =
832
+ typeof parsedLine.id === 'string' && parsedLine.id.trim().length > 0
833
+ ? parsedLine.id.trim()
834
+ : `tool-call-${index + 1}`;
835
+ const serviceTag = typeof parsedLine.service === 'string' ? parsedLine.service : '';
836
+ const parsedArgs = parseToolArguments(functionRecord.arguments ?? {});
837
+ if (!parsedArgs) return;
838
+
839
+ requests.push({
840
+ match: line,
841
+ groups: [callId, toolName, stringifyToolArgs(parsedArgs), serviceTag],
842
+ toolName,
843
+ callId,
844
+ serviceTag,
845
+ start: lineStart,
846
+ end: lineEnd,
847
+ });
848
+ return;
849
+ }
850
+ }
851
+ } catch (_error) {
852
+ // Fall through to permissive regex extraction for malformed-yet-recoverable payloads.
853
+ }
854
+
855
+ const typeMatch = normalized.match(/"type"\s*:\s*"([^"]+)"/i);
856
+ const type = typeMatch?.[1]?.trim().toLowerCase();
857
+ if (type !== 'tool_use' && type !== 'function') return;
858
+
859
+ const idMatch = normalized.match(/"id"\s*:\s*"([^"]+)"/i);
860
+ const callId = idMatch?.[1]?.trim() || `tool-call-${index + 1}`;
861
+
862
+ let toolName = '';
863
+ let argsRaw = '{}';
864
+ if (type === 'tool_use') {
865
+ const nameMatch = normalized.match(/"name"\s*:\s*"([^"]+)"/i);
866
+ toolName = nameMatch?.[1]?.trim() || '';
867
+ const inputObject = extractBalancedObjectAfterField(normalized, 'input');
868
+ if (inputObject) {
869
+ argsRaw = inputObject;
870
+ }
871
+ } else {
872
+ const fnNameMatch = normalized.match(/"function"\s*:\s*\{[\s\S]*?"name"\s*:\s*"([^"]+)"/i);
873
+ toolName = fnNameMatch?.[1]?.trim() || '';
874
+
875
+ const argumentsObject = extractBalancedObjectAfterField(normalized, 'arguments');
876
+ if (argumentsObject) {
877
+ argsRaw = argumentsObject;
878
+ } else {
879
+ const argsStringMatch = normalized.match(/"arguments"\s*:\s*"([\s\S]*?)"(?:\s*,|\s*})/i);
880
+ if (argsStringMatch?.[1]) {
881
+ const rawArgs = argsStringMatch[1];
882
+ try {
883
+ argsRaw = JSON.parse(`"${rawArgs}"`);
884
+ } catch (_error) {
885
+ argsRaw = rawArgs;
886
+ }
887
+ }
888
+ }
889
+ }
890
+
891
+ if (!toolName) return;
892
+ const parsedArgs = parseToolArguments(argsRaw);
893
+ if (!parsedArgs) return;
894
+
895
+ requests.push({
896
+ match: line,
897
+ groups: [callId, toolName, stringifyToolArgs(parsedArgs), ''],
898
+ toolName,
899
+ callId,
900
+ serviceTag: '',
901
+ start: lineStart,
902
+ end: lineEnd,
903
+ });
904
+ });
905
+
906
+ return requests;
907
+ };
908
+
909
+ const stripStandaloneRawToolJsonLines = (source: string): string => {
910
+ const text = typeof source === 'string' ? source : '';
911
+ if (!text) return text;
912
+
913
+ const normalizeStandaloneToolJsonLine = (line: string): string | null => {
914
+ const trimmed = line.trim();
915
+ if (!trimmed) return null;
916
+
917
+ let normalized = trimmed;
918
+ const first = normalized[0];
919
+ const last = normalized[normalized.length - 1];
920
+ const hasSymmetricQuote =
921
+ normalized.length >= 2 &&
922
+ ((first === "'" && last === "'") || (first === '"' && last === '"') || (first === '`' && last === '`'));
923
+ if (hasSymmetricQuote) {
924
+ const inner = normalized.slice(1, -1).trim();
925
+ if (first === '"') {
926
+ try {
927
+ const decoded = JSON.parse(normalized);
928
+ if (typeof decoded === 'string') {
929
+ normalized = decoded.trim();
930
+ } else {
931
+ normalized = inner;
932
+ }
933
+ } catch (_error) {
934
+ normalized = inner;
935
+ }
936
+ } else {
937
+ normalized = inner;
938
+ }
939
+ }
940
+
941
+ const extractedCandidate = extractBalancedObjectCandidate(normalized);
942
+ if (extractedCandidate) {
943
+ normalized = extractedCandidate.trim();
944
+ }
945
+
946
+ if (!normalized.startsWith('{') || !normalized.endsWith('}')) return null;
947
+ return normalized;
948
+ };
949
+
950
+ const lines = text.split('\n');
951
+ let removedAny = false;
952
+ const kept = lines.filter((line) => {
953
+ const normalized = normalizeStandaloneToolJsonLine(line);
954
+ if (!normalized) return true;
955
+
956
+ const hasToolType = /"type"\s*:\s*"(tool_use|function)"/i.test(normalized);
957
+ if (!hasToolType) return true;
958
+
959
+ const hasName = /"name"\s*:\s*"[^"]+"/i.test(normalized);
960
+ const hasFunctionName = /"function"\s*:\s*\{[\s\S]*?"name"\s*:\s*"[^"]+"/i.test(normalized);
961
+ if (!hasName && !hasFunctionName) return true;
962
+
963
+ removedAny = true;
964
+ return false;
965
+ });
966
+
967
+ if (!removedAny) return text;
968
+ return kept.join('\n').replace(/\n{3,}/g, '\n\n');
969
+ };
970
+
971
+ const extractToolRequestMatchesFromText = (rawResponse: string): ToolRequestMatch[] => {
972
+ const requests: ToolRequestMatch[] = [];
973
+ const segments = extractTopLevelJsonObjectSegments(rawResponse);
974
+ segments.forEach((segment, segmentIndex) => {
975
+ const parsed = resolveToolRequestFromSegment(segment, segmentIndex);
976
+ if (!parsed) return;
977
+ requests.push(parsed);
978
+ });
979
+
980
+ const fallbackRequests = extractLineLevelFallbackToolRequests(rawResponse, requests);
981
+ fallbackRequests.forEach((request) => {
982
+ requests.push(request);
983
+ });
984
+
985
+ if (requests.length === 0) return [];
986
+ requests.sort((a, b) => a.start - b.start);
987
+
988
+ const seenSignatures = new Set<string>();
989
+ const deduped = requests.filter((request) => {
990
+ const signature = `${String(request.toolName || '').trim().toLowerCase()}::${String(
991
+ request.callId || '',
992
+ ).trim().toLowerCase()}::${request.start}`;
993
+ if (seenSignatures.has(signature)) return false;
994
+ seenSignatures.add(signature);
995
+ return true;
996
+ });
997
+
998
+ deduped.sort((a, b) => a.start - b.start);
999
+ return deduped;
1000
+ };
1001
+
1002
+ const hashInlineMarkerValue = (value: string): string => {
1003
+ let hash = 5381;
1004
+ const input = String(value || '');
1005
+ for (let index = 0; index < input.length; index += 1) {
1006
+ hash = ((hash << 5) + hash) ^ input.charCodeAt(index);
1007
+ }
1008
+ return (hash >>> 0).toString(36);
1009
+ };
1010
+
1011
+ const getThinkingBlockSignature = (
1012
+ type: 'thinking' | 'reasoning' | 'searching',
1013
+ content: string,
1014
+ ): string => {
1015
+ const normalizedContent = String(content || '').trim();
1016
+ return `${type}-${hashInlineMarkerValue(normalizedContent)}-${normalizedContent.length}`;
1017
+ };
1018
+
1019
+ const buildThinkingBlockMarker = (
1020
+ type: 'thinking' | 'reasoning' | 'searching',
1021
+ signature: string,
1022
+ ): string => {
1023
+ const normalizedType = String(type || 'thinking').trim() as 'thinking' | 'reasoning' | 'searching';
1024
+ const normalizedSignature = String(signature || '').trim() || `${normalizedType}-block`;
1025
+ return `${INLINE_THINKING_MARKER_PREFIX}${encodeURIComponent(normalizedType)}|${encodeURIComponent(
1026
+ normalizedSignature,
1027
+ )}${INLINE_THINKING_MARKER_SUFFIX}`;
1028
+ };
1029
+
1030
+ const buildInlineToolMarker = (toolName: string, callId: string): string => {
1031
+ const normalizedToolName = String(toolName || '').trim() || 'tool';
1032
+ const normalizedCallId = String(callId || '').trim() || `${normalizedToolName}-call`;
1033
+ return `${INLINE_TOOL_MARKER_PREFIX}${encodeURIComponent(normalizedToolName)}|${encodeURIComponent(
1034
+ normalizedCallId,
1035
+ )}${INLINE_TOOL_MARKER_SUFFIX}`;
1036
+ };
1037
+
1038
+ const parseInlineToolMarkers = (text: string): { parts: string[]; markers: InlineToolMarker[] } => {
1039
+ const source = typeof text === 'string' ? text : '';
1040
+ if (!source) return { parts: [''], markers: [] };
1041
+
1042
+ const parts: string[] = [];
1043
+ const markers: InlineToolMarker[] = [];
1044
+ let lastIndex = 0;
1045
+ INLINE_TOOL_MARKER_REGEX.lastIndex = 0;
1046
+ let match: RegExpExecArray | null;
1047
+
1048
+ while ((match = INLINE_TOOL_MARKER_REGEX.exec(source)) !== null) {
1049
+ parts.push(source.slice(lastIndex, match.index));
1050
+
1051
+ const encodedToolName = match[1] || '';
1052
+ const encodedCallId = match[2] || '';
1053
+ let toolName = encodedToolName;
1054
+ let callId = encodedCallId;
1055
+
1056
+ try {
1057
+ toolName = decodeURIComponent(encodedToolName);
1058
+ } catch (_error) {
1059
+ toolName = encodedToolName;
1060
+ }
1061
+
1062
+ try {
1063
+ callId = decodeURIComponent(encodedCallId);
1064
+ } catch (_error) {
1065
+ callId = encodedCallId;
1066
+ }
1067
+
1068
+ markers.push({
1069
+ toolName: String(toolName || '').trim() || 'tool',
1070
+ callId: String(callId || '').trim() || 'call',
1071
+ });
1072
+
1073
+ lastIndex = match.index + match[0].length;
1074
+ }
1075
+
1076
+ parts.push(source.slice(lastIndex));
1077
+ return { parts, markers };
1078
+ };
1079
+
1080
+ const parseInlineThinkingMarkers = (
1081
+ text: string,
1082
+ ): { parts: string[]; markers: InlineThinkingMarker[] } => {
1083
+ const source = typeof text === 'string' ? text : '';
1084
+ if (!source) return { parts: [''], markers: [] };
1085
+
1086
+ const parts: string[] = [];
1087
+ const markers: InlineThinkingMarker[] = [];
1088
+ let lastIndex = 0;
1089
+ INLINE_THINKING_MARKER_REGEX.lastIndex = 0;
1090
+ let match: RegExpExecArray | null;
1091
+
1092
+ while ((match = INLINE_THINKING_MARKER_REGEX.exec(source)) !== null) {
1093
+ parts.push(source.slice(lastIndex, match.index));
1094
+
1095
+ const encodedType = match[1] || '';
1096
+ const encodedSignature = match[2] || '';
1097
+ let rawType = encodedType;
1098
+ let signature = encodedSignature;
1099
+
1100
+ try {
1101
+ rawType = decodeURIComponent(encodedType);
1102
+ } catch (_error) {
1103
+ rawType = encodedType;
1104
+ }
1105
+
1106
+ try {
1107
+ signature = decodeURIComponent(encodedSignature);
1108
+ } catch (_error) {
1109
+ signature = encodedSignature;
1110
+ }
1111
+
1112
+ const normalizedType = String(rawType || '').trim().toLowerCase();
1113
+ const type: 'thinking' | 'reasoning' | 'searching' =
1114
+ normalizedType === 'reasoning'
1115
+ ? 'reasoning'
1116
+ : normalizedType === 'searching'
1117
+ ? 'searching'
1118
+ : 'thinking';
1119
+
1120
+ markers.push({
1121
+ type,
1122
+ signature: String(signature || '').trim(),
1123
+ });
1124
+
1125
+ lastIndex = match.index + match[0].length;
1126
+ }
1127
+
1128
+ parts.push(source.slice(lastIndex));
1129
+ return { parts, markers };
1130
+ };
1131
+
148
1132
  // ============================================================================
149
1133
  // Icons
150
1134
  // ============================================================================
@@ -218,6 +1202,12 @@ const AgentIcon = () => (
218
1202
  </svg>
219
1203
  );
220
1204
 
1205
+ const ToolIcon = () => (
1206
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
1207
+ <path d="M14.7 6.3a4 4 0 0 0-5.4 5.4l-6 6a2 2 0 0 0 2.8 2.8l6-6a4 4 0 0 0 5.4-5.4l-2.1 2.1-3.3-3.3 2.6-1.6Z" />
1208
+ </svg>
1209
+ );
1210
+
221
1211
  const CheckIcon = () => (
222
1212
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
223
1213
  <polyline points="20 6 9 17 4 12" />
@@ -287,7 +1277,7 @@ const CloseIcon = () => (
287
1277
 
288
1278
  interface ChatInputProps {
289
1279
  placeholder: string;
290
- idle: boolean;
1280
+ isBusy: boolean;
291
1281
  onSubmit: (text: string) => void;
292
1282
  onStop: () => void;
293
1283
  agentOptions: AgentOption[];
@@ -308,7 +1298,7 @@ interface ChatInputProps {
308
1298
 
309
1299
  const ChatInput = React.memo<ChatInputProps>(({
310
1300
  placeholder,
311
- idle,
1301
+ isBusy,
312
1302
  onSubmit,
313
1303
  onStop,
314
1304
  agentOptions,
@@ -345,7 +1335,7 @@ const ChatInput = React.memo<ChatInputProps>(({
345
1335
  // Handle submit
346
1336
  const handleSubmit = useCallback(() => {
347
1337
  const trimmed = inputValue.trim();
348
- if (trimmed && idle) {
1338
+ if (trimmed && !isBusy) {
349
1339
  onSubmit(trimmed);
350
1340
  setInputValue('');
351
1341
  // Reset textarea height
@@ -353,7 +1343,7 @@ const ChatInput = React.memo<ChatInputProps>(({
353
1343
  textareaRef.current.style.height = 'auto';
354
1344
  }
355
1345
  }
356
- }, [inputValue, idle, onSubmit]);
1346
+ }, [inputValue, isBusy, onSubmit]);
357
1347
 
358
1348
  // Close dropdown on outside click
359
1349
  useEffect(() => {
@@ -531,9 +1521,9 @@ const ChatInput = React.memo<ChatInputProps>(({
531
1521
  </div>
532
1522
  </div>
533
1523
  <div className="ai-chat-context-popover__sections">
534
- {contextSections.map((section) => (
1524
+ {contextSections.map((section, index) => (
535
1525
  <div
536
- key={section.id}
1526
+ key={`${section.id}-${index}`}
537
1527
  className={`ai-chat-context-popover__section-item ${enableContextDetailView ? 'ai-chat-context-popover__section-item--clickable' : ''}`}
538
1528
  onClick={() => {
539
1529
  if (enableContextDetailView) {
@@ -608,12 +1598,12 @@ const ChatInput = React.memo<ChatInputProps>(({
608
1598
  </div>
609
1599
  </div>
610
1600
  <div className="ai-chat-context-popover__detail-sections">
611
- {contextSections.map((section) => {
1601
+ {contextSections.map((section, index) => {
612
1602
  const isRawSection = hasRawData(section);
613
1603
  const isEnabled = !disabledSectionIds.has(section.id);
614
1604
  return (
615
1605
  <details
616
- key={section.id}
1606
+ key={`${section.id}-${index}`}
617
1607
  className={`ai-chat-context-popover__detail-section ${!isEnabled ? 'ai-chat-context-popover__detail-section--disabled' : ''}`}
618
1608
  open={expandedSectionId === section.id}
619
1609
  >
@@ -661,11 +1651,11 @@ const ChatInput = React.memo<ChatInputProps>(({
661
1651
  )}
662
1652
 
663
1653
  <button
664
- className={`ai-chat-send-button ${idle && !inputValue.trim() ? 'ai-chat-send-button--disabled' : ''} ${!idle ? 'ai-chat-send-button--stop' : ''}`}
665
- onClick={() => idle ? handleSubmit() : onStop()}
666
- disabled={idle && !inputValue.trim()}
1654
+ className={`ai-chat-send-button ${!isBusy && !inputValue.trim() ? 'ai-chat-send-button--disabled' : ''} ${isBusy ? 'ai-chat-send-button--stop' : ''}`}
1655
+ onClick={() => isBusy ? onStop() : handleSubmit()}
1656
+ disabled={!isBusy && !inputValue.trim()}
667
1657
  >
668
- {idle ? <ArrowUpIcon /> : <StopIcon />}
1658
+ {isBusy ? <StopIcon /> : <ArrowUpIcon />}
669
1659
  </button>
670
1660
  </div>
671
1661
 
@@ -748,7 +1738,11 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
748
1738
  initialHistory = {},
749
1739
  hideRagContextInPrompt = true,
750
1740
  createConversationOnFirstChat = true,
1741
+ autoApproveTools = false,
751
1742
  mcpServers = [],
1743
+ resolveMcpAuthHeaders,
1744
+ localToolExecutors,
1745
+ traceContextMode = 'standard',
752
1746
  progressiveActions = true,
753
1747
  agentOptions = [],
754
1748
  currentAgentId,
@@ -761,6 +1755,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
761
1755
  disabledSectionIds: propDisabledSectionIds,
762
1756
  onToggleSection: propOnToggleSection,
763
1757
  onConversationCreated,
1758
+ onBeforeSend,
764
1759
  // UI Customization Props
765
1760
  cssUrl,
766
1761
  markdownClass,
@@ -801,10 +1796,12 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
801
1796
  const [thinkingBlocks, setThinkingBlocks] = useState<ThinkingBlock[]>([]);
802
1797
  const [currentThinkingIndex, setCurrentThinkingIndex] = useState(0);
803
1798
  // NOTE: activeThinkingBlock is computed via useMemo, not useState - see below after processThinkingTags
804
- // Track collapsed state per block (key: "block-{index}" or "active" for streaming block)
1799
+ // Track collapsed state per block (key: "{entryKey}::block-{index}" or "{entryKey}::active")
805
1800
  const [collapsedBlocks, setCollapsedBlocks] = useState<Set<string>>(new Set());
806
1801
  const hasAutoCollapsedRef = useRef(false); // Track if we've auto-collapsed for current response
807
1802
  const prevBlockCountRef = useRef(0); // Track previous block count to detect new blocks
1803
+ const thinkingBlocksByKeyRef = useRef<Record<string, ThinkingBlock[]>>({});
1804
+ const [thinkingBlocksByKey, setThinkingBlocksByKey] = useState<Record<string, ThinkingBlock[]>>({});
808
1805
  const [newConversationConfirm, setNewConversationConfirm] = useState(false);
809
1806
  const [justReset, setJustReset] = useState(false);
810
1807
  const [copiedCallId, setCopiedCallId] = useState<string | null>(null);
@@ -831,15 +1828,80 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
831
1828
  const [emailClickedButNoEmail, setEmailClickedButNoEmail] = useState(false);
832
1829
 
833
1830
  // Tool Approval state (for MCP tools)
834
- const [pendingToolRequests, setPendingToolRequests] = useState<any[]>([]);
1831
+ const [pendingToolRequests, setPendingToolRequests] = useState<ToolRequestMatch[]>([]);
835
1832
  const [sessionApprovedTools, setSessionApprovedTools] = useState<string[]>([]);
836
1833
  const [alwaysApprovedTools, setAlwaysApprovedTools] = useState<string[]>([]);
1834
+ const [toolList, setToolList] = useState<any[]>([]);
1835
+ const [toolsLoading, setToolsLoading] = useState(false);
1836
+ const [toolsFetchError, setToolsFetchError] = useState(false);
1837
+ const [resolvedMcpServers, setResolvedMcpServers] = useState<any[]>(mcpServers || []);
1838
+ const [activeToolCalls, setActiveToolCalls] = useState<Array<{ toolName: string; callId: string }>>([]);
1839
+ const normalizeToolName = useCallback((toolName: string): string => {
1840
+ return String(toolName ?? '').trim().toLowerCase();
1841
+ }, []);
1842
+ const getToolCallSignature = useCallback(
1843
+ (toolName: string, callId: string): string => {
1844
+ const normalizedToolName = normalizeToolName(toolName);
1845
+ const normalizedCallId = String(callId ?? '').trim();
1846
+ if (!normalizedToolName || !normalizedCallId) return '';
1847
+ return `${normalizedToolName}::${normalizedCallId}`;
1848
+ },
1849
+ [normalizeToolName],
1850
+ );
1851
+ const alwaysApprovedToolsStorageKey = useMemo(() => {
1852
+ const customerId =
1853
+ (customer as any)?.customer_id ||
1854
+ (customer as any)?.id ||
1855
+ (customer as any)?.customer_user_email ||
1856
+ 'anonymous';
1857
+ const agentIdForScope = currentAgentId || agent || 'default';
1858
+ return `llmasaservice-ui:always-approved-tools:${project_id}:${customerId}:${agentIdForScope}`;
1859
+ }, [project_id, customer, currentAgentId, agent]);
837
1860
 
838
1861
  // Context section toggle state (disabled sections)
839
1862
  // Use internal state only if prop is not provided
840
1863
  const [internalDisabledSectionIds, setInternalDisabledSectionIds] = useState<Set<string>>(new Set());
841
1864
  const disabledSectionIds = propDisabledSectionIds ?? internalDisabledSectionIds;
842
1865
 
1866
+ useEffect(() => {
1867
+ if (typeof window === 'undefined') return;
1868
+ try {
1869
+ const stored = window.localStorage.getItem(alwaysApprovedToolsStorageKey);
1870
+ if (!stored) return;
1871
+ const parsed = JSON.parse(stored);
1872
+ if (!Array.isArray(parsed)) return;
1873
+
1874
+ const normalized = Array.from(
1875
+ new Set(
1876
+ parsed
1877
+ .map((value) => normalizeToolName(String(value)))
1878
+ .filter((value) => value.length > 0),
1879
+ ),
1880
+ );
1881
+ setAlwaysApprovedTools(normalized);
1882
+ } catch (error) {
1883
+ console.warn('[AIChatPanel] Failed to load always-approved tools from localStorage:', error);
1884
+ }
1885
+ }, [alwaysApprovedToolsStorageKey, normalizeToolName]);
1886
+
1887
+ useEffect(() => {
1888
+ if (typeof window === 'undefined') return;
1889
+ try {
1890
+ if (alwaysApprovedTools.length === 0) {
1891
+ window.localStorage.removeItem(alwaysApprovedToolsStorageKey);
1892
+ return;
1893
+ }
1894
+ window.localStorage.setItem(
1895
+ alwaysApprovedToolsStorageKey,
1896
+ JSON.stringify(
1897
+ Array.from(new Set(alwaysApprovedTools.map((value) => normalizeToolName(value)).filter(Boolean))),
1898
+ ),
1899
+ );
1900
+ } catch (error) {
1901
+ console.warn('[AIChatPanel] Failed to persist always-approved tools to localStorage:', error);
1902
+ }
1903
+ }, [alwaysApprovedToolsStorageKey, alwaysApprovedTools, normalizeToolName]);
1904
+
843
1905
  // Email capture mode effect - like ChatPanel
844
1906
  useEffect(() => {
845
1907
  setShowEmailPanel(customerEmailCaptureMode !== 'HIDE');
@@ -875,6 +1937,14 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
875
1937
  const initialPromptSentRef = useRef<boolean>(false);
876
1938
  // Track the last followOnPrompt to detect changes (for auto-submit trigger)
877
1939
  const lastFollowOnPromptRef = useRef<string>('');
1940
+ const handledToolCallSignaturesRef = useRef<Set<string>>(new Set());
1941
+ const inFlightToolCallSignaturesRef = useRef<Set<string>>(new Set());
1942
+ const toolContinuationCountRef = useRef<number>(0);
1943
+ const activeStreamAppendBaseRef = useRef<{ key: string; base: string } | null>(null);
1944
+ const toolRequestProcessingRef = useRef<boolean>(false);
1945
+ const queuedToolRequestsRef = useRef<ToolRequestMatch[] | null>(null);
1946
+ const suppressAbortHistoryUpdateRef = useRef<boolean>(false);
1947
+ const toolReplaySummariesByKeyRef = useRef<Record<string, ToolReplaySummaryEntry[]>>({});
878
1948
 
879
1949
  // Sync new entries from initialHistory into local history state
880
1950
  // This allows parent components to inject messages (e.g., page-based agent suggestions)
@@ -903,16 +1973,202 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
903
1973
  });
904
1974
  }, [initialHistory]);
905
1975
 
1976
+ // Keep latest history ref synchronized so send() can always build context from
1977
+ // the freshest state, even when callbacks execute between render commits.
1978
+ useEffect(() => {
1979
+ latestHistoryRef.current = history;
1980
+ }, [history]);
1981
+
906
1982
  // ============================================================================
907
1983
  // useLLM Hook
908
1984
  // ============================================================================
1985
+ useEffect(() => {
1986
+ let cancelled = false;
1987
+
1988
+ const resolveServers = async () => {
1989
+ if (!mcpServers || mcpServers.length === 0) {
1990
+ if (!cancelled) {
1991
+ setResolvedMcpServers((prev) => (prev.length === 0 ? prev : []));
1992
+ }
1993
+ return;
1994
+ }
1995
+
1996
+ if (!resolveMcpAuthHeaders) {
1997
+ if (!cancelled) {
1998
+ setResolvedMcpServers((prev) => {
1999
+ const hasSameServers =
2000
+ prev.length === mcpServers.length &&
2001
+ prev.every((server, index) => server === mcpServers[index]);
2002
+ return hasSameServers ? prev : mcpServers;
2003
+ });
2004
+ }
2005
+ return;
2006
+ }
2007
+
2008
+ try {
2009
+ const enriched = await Promise.all(
2010
+ mcpServers.map(async (server) => {
2011
+ const resolved = await resolveMcpAuthHeaders({
2012
+ phase: 'list',
2013
+ mcpServer: (server || {}) as Record<string, unknown>,
2014
+ projectId: project_id,
2015
+ customer: customer as LLMAsAServiceCustomer | undefined,
2016
+ });
2017
+ return {
2018
+ ...(server || {}),
2019
+ headers: {
2020
+ ...(typeof server?.headers === 'object' && server?.headers ? server.headers : {}),
2021
+ ...normalizeMcpHeaders(
2022
+ resolved as Record<string, unknown> | null | undefined
2023
+ ),
2024
+ },
2025
+ };
2026
+ })
2027
+ );
2028
+ if (!cancelled) setResolvedMcpServers(enriched);
2029
+ } catch (error) {
2030
+ console.error('[AIChatPanel] Failed to resolve MCP auth headers:', error);
2031
+ if (!cancelled) setResolvedMcpServers(mcpServers);
2032
+ }
2033
+ };
2034
+
2035
+ void resolveServers();
2036
+
2037
+ return () => {
2038
+ cancelled = true;
2039
+ };
2040
+ }, [mcpServers, resolveMcpAuthHeaders, project_id, customer]);
2041
+
2042
+ const buildMcpRequestHeaders = useCallback(
2043
+ async ({
2044
+ phase,
2045
+ mcpServer,
2046
+ toolName,
2047
+ toolArgs,
2048
+ }: {
2049
+ phase: MCPAuthPhase;
2050
+ mcpServer: Record<string, unknown>;
2051
+ toolName?: string;
2052
+ toolArgs?: unknown;
2053
+ }): Promise<Record<string, string>> => {
2054
+ const merged: Record<string, string> = {};
2055
+ const packScopeIntoAccessToken = (headers: Record<string, string>): Record<string, string> => {
2056
+ const accessToken = typeof headers['x-mcp-access-token'] === 'string' ? headers['x-mcp-access-token'].trim() : '';
2057
+ const scopeToken = typeof headers['x-client-id'] === 'string' ? headers['x-client-id'].trim() : '';
2058
+ if (!accessToken || !scopeToken) return headers;
2059
+
2060
+ return {
2061
+ ...headers,
2062
+ 'x-mcp-access-token': `${accessToken}::scope::${scopeToken}`,
2063
+ };
2064
+ };
2065
+
2066
+ const baseAccessToken =
2067
+ typeof mcpServer.accessToken === 'string' ? mcpServer.accessToken.trim() : '';
2068
+ if (baseAccessToken) {
2069
+ merged['x-mcp-access-token'] = baseAccessToken;
2070
+ }
2071
+ if (project_id) {
2072
+ merged['x-project-id'] = project_id;
2073
+ }
2074
+
2075
+ if (!resolveMcpAuthHeaders) return merged;
2076
+
2077
+ try {
2078
+ const resolved = await resolveMcpAuthHeaders({
2079
+ phase,
2080
+ mcpServer,
2081
+ projectId: project_id,
2082
+ customer: customer as LLMAsAServiceCustomer | undefined,
2083
+ toolName,
2084
+ toolArgs,
2085
+ });
2086
+ return packScopeIntoAccessToken({
2087
+ ...merged,
2088
+ ...normalizeMcpHeaders(
2089
+ resolved as Record<string, unknown> | null | undefined
2090
+ ),
2091
+ });
2092
+ } catch (error) {
2093
+ console.error(
2094
+ `Failed to resolve MCP auth headers for ${phase} request:`,
2095
+ error
2096
+ );
2097
+ return merged;
2098
+ }
2099
+ },
2100
+ [project_id, customer, resolveMcpAuthHeaders]
2101
+ );
2102
+
2103
+ useEffect(() => {
2104
+ const fetchAndSetTools = async () => {
2105
+ if (!resolvedMcpServers || resolvedMcpServers.length === 0) {
2106
+ setToolList((prev) => (prev.length === 0 ? prev : []));
2107
+ setToolsLoading((prev) => (prev ? false : prev));
2108
+ setToolsFetchError((prev) => (prev ? false : prev));
2109
+ return;
2110
+ }
2111
+
2112
+ setToolsLoading(true);
2113
+ setToolsFetchError(false);
2114
+
2115
+ try {
2116
+ const fetchPromises = (resolvedMcpServers ?? []).map(async (m: any) => {
2117
+ const urlToFetch = `${publicAPIUrl}/tools/${encodeURIComponent(m.url)}`;
2118
+
2119
+ const requestHeaders = await buildMcpRequestHeaders({
2120
+ phase: 'list',
2121
+ mcpServer: m,
2122
+ });
2123
+
2124
+ const response = await fetch(urlToFetch, {
2125
+ headers: requestHeaders,
2126
+ });
2127
+ if (!response.ok) {
2128
+ const errorBody = await response.text();
2129
+ throw new Error(
2130
+ `HTTP ${response.status}: ${response.statusText} ${errorBody}`
2131
+ );
2132
+ }
2133
+
2134
+ const toolsFromServer = await response.json();
2135
+ if (!Array.isArray(toolsFromServer)) return [];
2136
+
2137
+ return toolsFromServer.map((tool) => ({
2138
+ ...tool,
2139
+ url: m.url,
2140
+ accessToken: m.accessToken || '',
2141
+ headers: requestHeaders,
2142
+ }));
2143
+ });
2144
+
2145
+ const results = await Promise.all(fetchPromises);
2146
+ const allTools = results.flat();
2147
+ setToolList(allTools);
2148
+ setToolsFetchError(false);
2149
+ } catch (error) {
2150
+ console.error('[AIChatPanel] Failed to load MCP tools:', error);
2151
+ setToolList([]);
2152
+ setToolsFetchError(true);
2153
+ } finally {
2154
+ setToolsLoading(false);
2155
+ }
2156
+ };
2157
+
2158
+ void fetchAndSetTools();
2159
+ }, [resolvedMcpServers, publicAPIUrl, buildMcpRequestHeaders]);
2160
+
909
2161
  const llmResult = useLLM({
910
2162
  project_id,
911
2163
  customer: customer as LLMAsAServiceCustomer | undefined,
912
2164
  ...(url && { url }),
913
2165
  ...(service && { group_id: service }),
914
2166
  ...(agent && { agent }),
915
- ...(mcpServers && mcpServers.length > 0 && { mcp_servers: mcpServers }),
2167
+ tools: toolList.map((item) => ({
2168
+ name: item.name,
2169
+ description: item.description,
2170
+ parameters: item.parameters,
2171
+ })) as [],
916
2172
  });
917
2173
 
918
2174
  const {
@@ -925,11 +2181,6 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
925
2181
  error: llmError,
926
2182
  } = llmResult;
927
2183
 
928
- // Tool-related properties (may not exist on all versions of useLLM)
929
- const toolList = (llmResult as any).toolList || [];
930
- const toolsLoading = (llmResult as any).toolsLoading || false;
931
- const toolsFetchError = (llmResult as any).toolsFetchError || null;
932
-
933
2184
  // Refs to track latest values for cleanup and callbacks (must be after useLLM)
934
2185
  const historyCallbackRef = useRef(historyChangedCallback);
935
2186
  const responseCompleteCallbackRef = useRef(responseCompleteCallback);
@@ -985,23 +2236,34 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
985
2236
  // Ensure a conversation exists before sending the first message
986
2237
  // This creates a conversation on the server and returns the conversation ID
987
2238
  const ensureConversation = useCallback(() => {
2239
+ const normalizedConversationId =
2240
+ typeof currentConversation === 'string' ? currentConversation.trim() : '';
2241
+
988
2242
  console.log('ensureConversation - called with:', {
989
- currentConversation,
2243
+ currentConversation: normalizedConversationId || null,
990
2244
  createConversationOnFirstChat,
991
2245
  project_id,
992
2246
  publicAPIUrl,
993
2247
  });
994
- if (
995
- (!currentConversation || currentConversation === '') &&
996
- createConversationOnFirstChat
997
- ) {
998
- // Guard: Don't create conversation without a project_id
999
- if (!project_id) {
1000
- console.error('ensureConversation - Cannot create conversation without project_id');
1001
- return Promise.resolve('');
1002
- }
1003
-
1004
- const requestBody = {
2248
+
2249
+ // Existing conversation supplied by caller: use it directly.
2250
+ if (normalizedConversationId) {
2251
+ console.log('ensureConversation - using existing conversation:', normalizedConversationId);
2252
+ return Promise.resolve(normalizedConversationId);
2253
+ }
2254
+
2255
+ if (!createConversationOnFirstChat) {
2256
+ return Promise.resolve('');
2257
+ }
2258
+
2259
+ // Guard: Don't create/ensure conversations without a project_id
2260
+ if (!project_id) {
2261
+ console.error('ensureConversation - Cannot create conversation without project_id');
2262
+ return Promise.resolve('');
2263
+ }
2264
+
2265
+ const createConversation = () => {
2266
+ const requestBody: Record<string, unknown> = {
1005
2267
  project_id: project_id,
1006
2268
  agentId: agent,
1007
2269
  customerId: customer?.customer_id ?? null,
@@ -1009,9 +2271,10 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1009
2271
  timezone: browserInfo?.userTimezone,
1010
2272
  language: browserInfo?.userLanguage,
1011
2273
  };
2274
+
1012
2275
  console.log('ensureConversation - Creating conversation with:', requestBody);
1013
2276
  console.log('ensureConversation - API URL:', `${publicAPIUrl}/conversations`);
1014
-
2277
+
1015
2278
  return fetch(`${publicAPIUrl}/conversations`, {
1016
2279
  method: 'POST',
1017
2280
  headers: {
@@ -1030,13 +2293,21 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1030
2293
  })
1031
2294
  .then((newConvo) => {
1032
2295
  console.log('ensureConversation - API response:', newConvo);
1033
- if (newConvo?.id) {
1034
- console.log('ensureConversation - New conversation ID:', newConvo.id);
1035
- setCurrentConversation(newConvo.id);
2296
+ const createdId =
2297
+ (typeof newConvo?.id === 'string' && newConvo.id.trim()) ||
2298
+ (typeof newConvo?.conversationId === 'string' && newConvo.conversationId.trim()) ||
2299
+ (typeof newConvo?.conversation_id === 'string' && newConvo.conversation_id.trim()) ||
2300
+ (typeof newConvo?.conversation?.id === 'string' && newConvo.conversation.id.trim()) ||
2301
+ '';
2302
+
2303
+ if (createdId) {
2304
+ console.log('ensureConversation - New conversation ID:', createdId);
2305
+ setCurrentConversation(createdId);
1036
2306
  // NOTE: Don't call onConversationCreated here - it causes a re-render
1037
2307
  // before send() is called. The caller should notify after send() starts.
1038
- return newConvo.id;
2308
+ return createdId;
1039
2309
  }
2310
+
1040
2311
  console.warn('ensureConversation - No ID in response');
1041
2312
  return '';
1042
2313
  })
@@ -1044,10 +2315,9 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1044
2315
  console.error('Error creating new conversation', error);
1045
2316
  return '';
1046
2317
  });
1047
- }
1048
- // If a currentConversation exists, return it in a resolved Promise.
1049
- console.log('ensureConversation - using existing conversation:', currentConversation);
1050
- return Promise.resolve(currentConversation);
2318
+ };
2319
+
2320
+ return createConversation();
1051
2321
  }, [currentConversation, createConversationOnFirstChat, publicAPIUrl, project_id, agent, customer, browserInfo]);
1052
2322
 
1053
2323
  // Data with extras (matches ChatPanel's dataWithExtras)
@@ -1267,25 +2537,717 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1267
2537
  return false;
1268
2538
  }, [customerEmailCaptureMode, emailInputSet]);
1269
2539
 
1270
- // Handle tool approval for MCP tools
1271
- const handleToolApproval = useCallback((toolName: string, scope: 'once' | 'session' | 'always') => {
1272
- if (scope === 'session' || scope === 'always') {
1273
- setSessionApprovedTools((prev) => Array.from(new Set([...prev, toolName])));
1274
- }
1275
- if (scope === 'always') {
1276
- setAlwaysApprovedTools((prev) => Array.from(new Set([...prev, toolName])));
2540
+ const pendingToolRequestsRef = useRef<ToolRequestMatch[]>(pendingToolRequests);
2541
+ const streamIdleRef = useRef(idle);
2542
+ streamIdleRef.current = idle;
2543
+
2544
+ const waitForStreamIdle = useCallback(async (timeoutMs: number = 2500) => {
2545
+ const startedAt = Date.now();
2546
+ while (!streamIdleRef.current && Date.now() - startedAt < timeoutMs) {
2547
+ await new Promise<void>((resolve) => {
2548
+ setTimeout(resolve, 25);
2549
+ });
1277
2550
  }
2551
+ }, []);
2552
+
2553
+ useEffect(() => {
2554
+ pendingToolRequestsRef.current = pendingToolRequests;
2555
+ }, [pendingToolRequests]);
2556
+
2557
+ const processGivenToolRequests = useCallback(
2558
+ async (requests: ToolRequestMatch[]) => {
2559
+ const dedupeToolRequests = (input: ToolRequestMatch[]): ToolRequestMatch[] => {
2560
+ const seen = new Set<string>();
2561
+ const deduped: ToolRequestMatch[] = [];
2562
+ for (const request of Array.isArray(input) ? input : []) {
2563
+ if (!request) continue;
2564
+ const signature = getToolCallSignature(request.toolName, request.callId) || request.match;
2565
+ if (!signature || seen.has(signature)) continue;
2566
+ seen.add(signature);
2567
+ deduped.push(request);
2568
+ }
2569
+ return deduped;
2570
+ };
2571
+
2572
+ if (toolRequestProcessingRef.current) {
2573
+ const queued = dedupeToolRequests([
2574
+ ...(queuedToolRequestsRef.current || []),
2575
+ ...(Array.isArray(requests) ? requests : []),
2576
+ ]);
2577
+ if (queued.length > 0) {
2578
+ queuedToolRequestsRef.current = queued;
2579
+ }
2580
+ return;
2581
+ }
2582
+ toolRequestProcessingRef.current = true;
2583
+
2584
+ try {
2585
+ let requestsToProcess = requests;
2586
+ if (!requestsToProcess || requestsToProcess.length === 0) {
2587
+ requestsToProcess = pendingToolRequestsRef.current || [];
2588
+ }
2589
+ if (!requestsToProcess || requestsToProcess.length === 0) return;
2590
+
2591
+ setIsLoading(true);
2592
+
2593
+ const userPrompt = lastPromptRef.current || lastKeyRef.current || '';
2594
+ const lastPromptKey = lastKeyRef.current;
2595
+ const assistantSeedText =
2596
+ lastPromptKey && latestHistoryRef.current[lastPromptKey]?.content
2597
+ ? latestHistoryRef.current[lastPromptKey]?.content
2598
+ : '';
2599
+ const historyForContinuation = latestHistoryRef.current || {};
2600
+ const newMessages: any[] = [];
2601
+
2602
+ Object.entries(historyForContinuation).forEach(([historyPrompt, historyEntry]) => {
2603
+ let promptForHistory = String(historyPrompt || '');
2604
+ const isoTimestampRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z:/;
2605
+ if (isoTimestampRegex.test(promptForHistory)) {
2606
+ const colonIndex = promptForHistory.indexOf(':', 19);
2607
+ promptForHistory = promptForHistory.substring(colonIndex + 1);
2608
+ } else if (/^\d+:/.test(promptForHistory)) {
2609
+ const colonIndex = promptForHistory.indexOf(':');
2610
+ promptForHistory = promptForHistory.substring(colonIndex + 1);
2611
+ }
2612
+
2613
+ const typedHistoryEntry = (historyEntry || { content: '', callId: '' }) as HistoryEntry;
2614
+ const assistantBaseContent =
2615
+ typeof typedHistoryEntry.content === 'string' ? typedHistoryEntry.content : '';
2616
+ let assistantContextContent = assistantBaseContent;
2617
+ if (traceContextMode === 'full') {
2618
+ const traceSummary = buildCompactTraceSummary({
2619
+ reasoningBlocks: thinkingBlocksByKeyRef.current[historyPrompt] || [],
2620
+ toolCalls: typedHistoryEntry.toolCalls,
2621
+ toolResponses: typedHistoryEntry.toolResponses,
2622
+ });
2623
+ if (traceSummary) {
2624
+ assistantContextContent = assistantBaseContent
2625
+ ? `${assistantBaseContent}\n\n${traceSummary}`
2626
+ : traceSummary;
2627
+ }
2628
+ }
2629
+
2630
+ newMessages.push({
2631
+ role: 'user',
2632
+ content: [
2633
+ {
2634
+ type: 'text',
2635
+ text: promptForHistory,
2636
+ },
2637
+ ],
2638
+ });
2639
+ newMessages.push({
2640
+ role: 'assistant',
2641
+ content: [
2642
+ {
2643
+ type: 'text',
2644
+ text: assistantContextContent,
2645
+ },
2646
+ ],
2647
+ });
2648
+ });
2649
+
2650
+ // Safety fallback if no history was materialized.
2651
+ if (newMessages.length === 0 && userPrompt.trim().length > 0) {
2652
+ newMessages.push({
2653
+ role: 'user',
2654
+ content: [
2655
+ {
2656
+ type: 'text',
2657
+ text: userPrompt,
2658
+ },
2659
+ ],
2660
+ });
2661
+ if (assistantSeedText.trim().length > 0) {
2662
+ newMessages.push({
2663
+ role: 'assistant',
2664
+ content: [
2665
+ {
2666
+ type: 'text',
2667
+ text: assistantSeedText,
2668
+ },
2669
+ ],
2670
+ });
2671
+ }
2672
+ }
2673
+
2674
+ const parsedToolCalls = await Promise.all(
2675
+ requestsToProcess.map(async (req, index) => {
2676
+ let parsedToolCall: any = null;
2677
+ try {
2678
+ parsedToolCall = JSON.parse(req.match);
2679
+ } catch (error) {
2680
+ console.error('[AIChatPanel] Failed to parse tool call payload:', error);
2681
+ }
2682
+
2683
+ const toolName =
2684
+ req.groups[1] ||
2685
+ req.toolName ||
2686
+ (typeof parsedToolCall?.name === 'string' ? parsedToolCall.name : '') ||
2687
+ (typeof parsedToolCall?.function?.name === 'string' ? parsedToolCall.function.name : '');
2688
+ if (!toolName) return null;
2689
+
2690
+ const rawCallId =
2691
+ req.callId ||
2692
+ req.groups[0] ||
2693
+ parsedToolCall?.id ||
2694
+ parsedToolCall?.tool_call_id ||
2695
+ `${toolName}-${index + 1}`;
2696
+ const callId =
2697
+ typeof rawCallId === 'string' &&
2698
+ rawCallId.trim().length > 0 &&
2699
+ rawCallId !== 'functionCall'
2700
+ ? rawCallId
2701
+ : `${toolName}-${index + 1}`;
2702
+
2703
+ let args: Record<string, unknown> = {};
2704
+ const rawArgs =
2705
+ req.groups[2] ??
2706
+ parsedToolCall?.input ??
2707
+ parsedToolCall?.args ??
2708
+ parsedToolCall?.function?.arguments ??
2709
+ '{}';
2710
+
2711
+ const parsedArgs = parseToolArguments(rawArgs);
2712
+ if (!parsedArgs) {
2713
+ console.error('[AIChatPanel] Failed to parse tool arguments', {
2714
+ toolName,
2715
+ callId,
2716
+ rawArgsPreview:
2717
+ typeof rawArgs === 'string'
2718
+ ? rawArgs.slice(0, 500)
2719
+ : JSON.stringify(rawArgs).slice(0, 500),
2720
+ });
2721
+ return null;
2722
+ }
2723
+ args = parsedArgs;
2724
+
2725
+ const serviceTag =
2726
+ (typeof req.serviceTag === 'string' && req.serviceTag) ||
2727
+ (typeof req.groups[3] === 'string' && req.groups[3]) ||
2728
+ (typeof parsedToolCall?.service === 'string' && parsedToolCall.service) ||
2729
+ '';
2730
+
2731
+ const callSignature = getToolCallSignature(toolName, callId);
2732
+ if (!callSignature) return null;
2733
+
2734
+ return {
2735
+ req,
2736
+ toolName,
2737
+ callId,
2738
+ args,
2739
+ serviceTag,
2740
+ callSignature,
2741
+ };
2742
+ }),
2743
+ );
2744
+
2745
+ const toolCallBatch = parsedToolCalls.filter(Boolean) as Array<{
2746
+ req: ToolRequestMatch;
2747
+ toolName: string;
2748
+ callId: string;
2749
+ args: Record<string, unknown>;
2750
+ serviceTag: string;
2751
+ callSignature: string;
2752
+ }>;
2753
+
2754
+ const seenCallSignatures = new Set<string>();
2755
+ const callsToRun: Array<{
2756
+ req: ToolRequestMatch;
2757
+ toolName: string;
2758
+ callId: string;
2759
+ args: Record<string, unknown>;
2760
+ serviceTag: string;
2761
+ callSignature: string;
2762
+ }> = [];
2763
+
2764
+ toolCallBatch.forEach((toolCall) => {
2765
+ if (seenCallSignatures.has(toolCall.callSignature)) return;
2766
+ seenCallSignatures.add(toolCall.callSignature);
2767
+
2768
+ if (handledToolCallSignaturesRef.current.has(toolCall.callSignature)) return;
2769
+ if (inFlightToolCallSignaturesRef.current.has(toolCall.callSignature)) return;
2770
+
2771
+ callsToRun.push(toolCall);
2772
+ });
2773
+
2774
+ if (callsToRun.length === 0) {
2775
+ setPendingToolRequests((prev) =>
2776
+ prev.filter((request) => {
2777
+ const signature = getToolCallSignature(request.toolName, request.callId);
2778
+ if (!signature) return true;
2779
+ return !seenCallSignatures.has(signature);
2780
+ }),
2781
+ );
2782
+ setActiveToolCalls([]);
2783
+ setIsLoading(false);
2784
+ return;
2785
+ }
2786
+
2787
+ const callsToRunSignatures = new Set(callsToRun.map((toolCall) => toolCall.callSignature));
2788
+ setPendingToolRequests((prev) =>
2789
+ prev.filter((request) => {
2790
+ const signature = getToolCallSignature(request.toolName, request.callId);
2791
+ return !signature || !callsToRunSignatures.has(signature);
2792
+ }),
2793
+ );
2794
+ callsToRun.forEach((toolCall) => {
2795
+ inFlightToolCallSignaturesRef.current.add(toolCall.callSignature);
2796
+ });
2797
+
2798
+ setActiveToolCalls(
2799
+ callsToRun.map((toolCall) => ({
2800
+ toolName: toolCall.toolName,
2801
+ callId: toolCall.callId,
2802
+ })),
2803
+ );
2804
+
2805
+ const finalToolCalls = callsToRun.map((toolCall) => ({
2806
+ id: toolCall.callId,
2807
+ type: 'tool_use',
2808
+ name: toolCall.toolName,
2809
+ input: toolCall.args,
2810
+ service: toolCall.serviceTag,
2811
+ }));
2812
+
2813
+ let finalToolResponses: any[] = [];
2814
+ try {
2815
+ const toolResponses = await Promise.all(
2816
+ callsToRun.map(async (toolCall) => {
2817
+ const mcpTool = (toolList.find((tool) => tool.name === toolCall.toolName) ||
2818
+ null) as Record<string, unknown> | null;
2819
+ const localExecutor =
2820
+ localToolExecutors && typeof localToolExecutors[toolCall.toolName] === 'function'
2821
+ ? localToolExecutors[toolCall.toolName]
2822
+ : null;
2823
+
2824
+ if (localExecutor) {
2825
+ try {
2826
+ const localResult = await localExecutor(toolCall.args, {
2827
+ toolName: toolCall.toolName,
2828
+ callId: toolCall.callId,
2829
+ serviceTag: toolCall.serviceTag,
2830
+ mcpTool,
2831
+ });
2832
+
2833
+ if (localResult && typeof localResult === 'object' && !Array.isArray(localResult)) {
2834
+ const objectResult = localResult as Record<string, unknown>;
2835
+ const isError = objectResult.isError === true || objectResult.error === true;
2836
+ const maybeResult = Object.prototype.hasOwnProperty.call(objectResult, 'result')
2837
+ ? objectResult.result
2838
+ : localResult;
2839
+ const textResult =
2840
+ typeof maybeResult === 'string' ? maybeResult : JSON.stringify(maybeResult ?? {});
2841
+ return {
2842
+ tool_call_id: toolCall.callId,
2843
+ tool_name: toolCall.toolName,
2844
+ result: textResult || '',
2845
+ isError,
2846
+ };
2847
+ }
2848
+
2849
+ return {
2850
+ tool_call_id: toolCall.callId,
2851
+ tool_name: toolCall.toolName,
2852
+ result:
2853
+ typeof localResult === 'string'
2854
+ ? localResult
2855
+ : JSON.stringify(localResult ?? {}),
2856
+ isError: false,
2857
+ };
2858
+ } catch (error) {
2859
+ return {
2860
+ tool_call_id: toolCall.callId,
2861
+ tool_name: toolCall.toolName,
2862
+ result: error instanceof Error ? error.message : `Unhandled error calling ${toolCall.toolName}`,
2863
+ isError: true,
2864
+ };
2865
+ }
2866
+ }
2867
+
2868
+ if (!mcpTool) {
2869
+ console.error(`[AIChatPanel] Tool ${toolCall.toolName} not found in tool list`);
2870
+ return {
2871
+ tool_call_id: toolCall.callId,
2872
+ tool_name: toolCall.toolName,
2873
+ result: `Tool ${toolCall.toolName} not found in current tool list.`,
2874
+ isError: true,
2875
+ };
2876
+ }
2877
+ const toolUrl = typeof mcpTool.url === 'string' ? mcpTool.url : '';
2878
+ if (!toolUrl) {
2879
+ return {
2880
+ tool_call_id: toolCall.callId,
2881
+ tool_name: toolCall.toolName,
2882
+ result: `Tool ${toolCall.toolName} is missing url metadata.`,
2883
+ isError: true,
2884
+ };
2885
+ }
2886
+
2887
+ const body = {
2888
+ tool: toolCall.toolName,
2889
+ args: toolCall.args,
2890
+ };
2891
+
2892
+ try {
2893
+ const result = await fetch(
2894
+ `${publicAPIUrl}/tools/${encodeURIComponent(toolUrl)}`,
2895
+ {
2896
+ method: 'POST',
2897
+ headers: {
2898
+ 'Content-Type': 'application/json',
2899
+ ...(await buildMcpRequestHeaders({
2900
+ phase: 'call',
2901
+ mcpServer: mcpTool as Record<string, unknown>,
2902
+ toolName: toolCall.toolName,
2903
+ toolArgs: toolCall.args,
2904
+ })),
2905
+ },
2906
+ body: JSON.stringify(body),
2907
+ },
2908
+ );
2909
+
2910
+ if (!result.ok) {
2911
+ const errorBody = await result.text();
2912
+ console.error(
2913
+ `[AIChatPanel] Tool call failed ${toolCall.toolName}: ${result.status} ${result.statusText} ${errorBody}`,
2914
+ );
2915
+ return {
2916
+ tool_call_id: toolCall.callId,
2917
+ tool_name: toolCall.toolName,
2918
+ result: `HTTP ${result.status} ${result.statusText}: ${errorBody || 'Tool call failed'}`,
2919
+ isError: true,
2920
+ };
2921
+ }
2922
+
2923
+ let resultData: any = null;
2924
+ try {
2925
+ resultData = await result.json();
2926
+ } catch (error) {
2927
+ console.error('[AIChatPanel] Failed parsing tool call JSON response:', error);
2928
+ return {
2929
+ tool_call_id: toolCall.callId,
2930
+ tool_name: toolCall.toolName,
2931
+ result: 'Tool returned a non-JSON response.',
2932
+ isError: true,
2933
+ };
2934
+ }
2935
+
2936
+ const textResult =
2937
+ resultData?.content?.[0]?.text ??
2938
+ (resultData?.result ? JSON.stringify(resultData.result) : JSON.stringify(resultData));
2939
+ const inferredError =
2940
+ resultData?.isError === true ||
2941
+ resultData?.error === true ||
2942
+ typeof resultData?.error === 'string' ||
2943
+ resultData?.status === 'error' ||
2944
+ resultData?.result?.isError === true ||
2945
+ resultData?.result?.error === true ||
2946
+ typeof resultData?.result?.error === 'string';
2947
+ const normalizedResultText =
2948
+ typeof textResult === 'string' && textResult.trim().length > 0
2949
+ ? textResult
2950
+ : inferredError && typeof resultData?.error === 'string'
2951
+ ? resultData.error
2952
+ : inferredError && typeof resultData?.result?.error === 'string'
2953
+ ? resultData.result.error
2954
+ : '';
2955
+
2956
+ return {
2957
+ tool_call_id: toolCall.callId,
2958
+ tool_name: toolCall.toolName,
2959
+ result: normalizedResultText,
2960
+ isError: inferredError,
2961
+ };
2962
+ } catch (error) {
2963
+ console.error(`[AIChatPanel] Error calling tool ${toolCall.toolName}:`, error);
2964
+ return {
2965
+ tool_call_id: toolCall.callId,
2966
+ tool_name: toolCall.toolName,
2967
+ result:
2968
+ error instanceof Error ? error.message : `Unhandled error calling ${toolCall.toolName}`,
2969
+ isError: true,
2970
+ };
2971
+ }
2972
+ }),
2973
+ );
2974
+
2975
+ finalToolResponses = toolResponses.filter(Boolean) as any[];
2976
+ } finally {
2977
+ callsToRun.forEach((toolCall) => {
2978
+ inFlightToolCallSignaturesRef.current.delete(toolCall.callSignature);
2979
+ handledToolCallSignaturesRef.current.add(toolCall.callSignature);
2980
+ });
2981
+ }
2982
+
2983
+ // Keep the running state visible during execution; clear it only after completion.
2984
+ setActiveToolCalls([]);
2985
+
2986
+ const currentLastKey = lastKeyRef.current;
2987
+ if (currentLastKey) {
2988
+ setHistory((prev) => {
2989
+ const existingEntry = prev[currentLastKey] || { content: '', callId: '' };
2990
+ return {
2991
+ ...prev,
2992
+ [currentLastKey]: {
2993
+ ...existingEntry,
2994
+ toolCalls: [...((existingEntry as any).toolCalls || []), ...finalToolCalls],
2995
+ toolResponses: [...((existingEntry as any).toolResponses || []), ...finalToolResponses],
2996
+ },
2997
+ };
2998
+ });
2999
+ }
3000
+
3001
+ const toReplayText = (value: unknown): string => {
3002
+ const raw =
3003
+ typeof value === 'string'
3004
+ ? value
3005
+ : (() => {
3006
+ try {
3007
+ return JSON.stringify(value);
3008
+ } catch (_error) {
3009
+ return String(value ?? '');
3010
+ }
3011
+ })();
3012
+ return String(raw ?? '');
3013
+ };
3014
+
3015
+ const replayEntryKey = lastKeyRef.current || '';
3016
+ const previousReplayEntries = replayEntryKey
3017
+ ? toolReplaySummariesByKeyRef.current[replayEntryKey] || []
3018
+ : [];
3019
+
3020
+ const currentReplayEntries: ToolReplaySummaryEntry[] = callsToRun.map((toolCall, index) => {
3021
+ const matchedResponse =
3022
+ finalToolResponses.find((response) => response?.tool_call_id === toolCall.callId) ||
3023
+ finalToolResponses[index];
3024
+ return {
3025
+ toolName: toolCall.toolName,
3026
+ callId: toolCall.callId,
3027
+ status: matchedResponse?.isError ? 'error' : 'ok',
3028
+ argsText: toReplayText(toolCall.args),
3029
+ resultText: toReplayText(matchedResponse?.result ?? 'No result returned'),
3030
+ };
3031
+ });
3032
+
3033
+ const replayEntries = [...previousReplayEntries, ...currentReplayEntries];
3034
+ if (replayEntryKey) {
3035
+ toolReplaySummariesByKeyRef.current[replayEntryKey] = replayEntries;
3036
+ }
3037
+
3038
+ const replayLines = replayEntries.map((entry) =>
3039
+ [
3040
+ `Tool: ${entry.toolName}`,
3041
+ `Call ID: ${entry.callId}`,
3042
+ `Status: ${entry.status}`,
3043
+ `Args: ${entry.argsText}`,
3044
+ `Result: ${entry.resultText}`,
3045
+ ].join('\n'),
3046
+ );
3047
+
3048
+ const originalRequest =
3049
+ (typeof lastPromptRef.current === 'string' && lastPromptRef.current.trim()) ||
3050
+ (typeof userPrompt === 'string' && userPrompt.trim()) ||
3051
+ '';
3052
+
3053
+ const continuationPromptText = [
3054
+ originalRequest ? `Original request: ${originalRequest}` : '',
3055
+ 'Tool execution summary for the previous request:',
3056
+ ...replayLines,
3057
+ 'Continue the same assistant response from exactly where you paused using these tool results.',
3058
+ 'Treat successful mutating tool results above as already completed actions. Do not repeat those same mutating tool calls unless the user explicitly asks to retry.',
3059
+ 'If you include meta tags, use only <thinking>, <reasoning>, <searching> for internal process.',
3060
+ 'Put the final user-facing answer outside all meta tags.',
3061
+ ]
3062
+ .filter(Boolean)
3063
+ .join('\n\n');
3064
+
3065
+ if (continuationPromptText.length > MAX_TOOL_REPLAY_PAYLOAD_CHARS) {
3066
+ setActiveToolCalls([]);
3067
+ setIsLoading(false);
3068
+ setError({
3069
+ message: 'Tool result payload is too large to continue safely in a single turn. Narrow the query or fetch steps in chunks.',
3070
+ code: 'TOOL_REPLAY_TOO_LARGE',
3071
+ });
3072
+ return;
3073
+ }
3074
+
3075
+ newMessages.push({
3076
+ role: 'user',
3077
+ content: [
3078
+ {
3079
+ type: 'text',
3080
+ text: continuationPromptText,
3081
+ },
3082
+ ],
3083
+ });
3084
+
3085
+ if (toolContinuationCountRef.current >= MAX_TOOL_CONTINUATIONS_PER_TURN) {
3086
+ setActiveToolCalls([]);
3087
+ setIsLoading(false);
3088
+ setError({
3089
+ message: 'Tool continuation limit reached for this response. Please refine the prompt and retry.',
3090
+ code: 'TOOL_CONTINUATION_LIMIT',
3091
+ });
3092
+ return;
3093
+ }
3094
+ toolContinuationCountRef.current += 1;
3095
+
3096
+ if (!streamIdleRef.current) {
3097
+ await waitForStreamIdle(10_000);
3098
+ }
3099
+ if (!streamIdleRef.current) {
3100
+ suppressAbortHistoryUpdateRef.current = true;
3101
+ try {
3102
+ stop(lastController);
3103
+ await waitForStreamIdle(3_000);
3104
+ } finally {
3105
+ suppressAbortHistoryUpdateRef.current = false;
3106
+ }
3107
+ }
3108
+ if (!streamIdleRef.current) {
3109
+ setActiveToolCalls([]);
3110
+ setIsLoading(false);
3111
+ setError({
3112
+ message: 'Timed out waiting for the previous stream to settle before tool continuation.',
3113
+ code: 'TOOL_CONTINUATION_WAIT_TIMEOUT',
3114
+ });
3115
+ return;
3116
+ }
3117
+
3118
+ const newController = new AbortController();
3119
+ setLastController(newController);
3120
+ const continuationKey = lastKeyRef.current;
3121
+ if (continuationKey) {
3122
+ const continuationBase = latestHistoryRef.current[continuationKey]?.content || '';
3123
+ activeStreamAppendBaseRef.current =
3124
+ continuationBase.trim().length > 0
3125
+ ? { key: continuationKey, base: continuationBase }
3126
+ : null;
3127
+ } else {
3128
+ activeStreamAppendBaseRef.current = null;
3129
+ }
3130
+ send(
3131
+ '',
3132
+ newMessages,
3133
+ [
3134
+ ...dataWithExtras(),
3135
+ {
3136
+ key: '--messages',
3137
+ data: newMessages.length.toString(),
3138
+ },
3139
+ ],
3140
+ true,
3141
+ true,
3142
+ service,
3143
+ currentConversation,
3144
+ newController,
3145
+ undefined,
3146
+ (errorMsg: string) => {
3147
+ setActiveToolCalls([]);
3148
+ setIsLoading(false);
3149
+ setError({
3150
+ message: errorMsg,
3151
+ code: 'TOOL_ERROR',
3152
+ });
3153
+ },
3154
+ );
3155
+ } finally {
3156
+ toolRequestProcessingRef.current = false;
3157
+ const queued = queuedToolRequestsRef.current;
3158
+ if (queued && queued.length > 0) {
3159
+ queuedToolRequestsRef.current = null;
3160
+ queueMicrotask(() => {
3161
+ void processGivenToolRequests(queued);
3162
+ });
3163
+ }
3164
+ }
3165
+ },
3166
+ [
3167
+ toolList,
3168
+ localToolExecutors,
3169
+ publicAPIUrl,
3170
+ buildMcpRequestHeaders,
3171
+ dataWithExtras,
3172
+ send,
3173
+ service,
3174
+ currentConversation,
3175
+ setActiveToolCalls,
3176
+ getToolCallSignature,
3177
+ traceContextMode,
3178
+ idle,
3179
+ stop,
3180
+ lastController,
3181
+ waitForStreamIdle,
3182
+ ],
3183
+ );
3184
+
3185
+ // Handle tool approval for MCP tools
3186
+ const handleToolApproval = useCallback(
3187
+ (toolName: string, scope: 'once' | 'session' | 'always') => {
3188
+ const normalizedToolName = normalizeToolName(toolName);
3189
+ if (!normalizedToolName) return;
3190
+
3191
+ if (scope === 'session' || scope === 'always') {
3192
+ setSessionApprovedTools((prev) => Array.from(new Set([...prev, normalizedToolName])));
3193
+ }
3194
+ if (scope === 'always') {
3195
+ setAlwaysApprovedTools((prev) => Array.from(new Set([...prev, normalizedToolName])));
3196
+ }
1278
3197
 
1279
- // Remove approved tool from pending list
1280
- setPendingToolRequests((prev) => prev.filter((r) => r.toolName !== toolName));
1281
-
1282
- console.log(`[AIChatPanel] Tool "${toolName}" approved with scope: ${scope}`);
1283
- }, []);
3198
+ const requestsToRun = (pendingToolRequestsRef.current || []).filter(
3199
+ (r) => normalizeToolName(r.toolName) === normalizedToolName
3200
+ );
3201
+ void processGivenToolRequests(requestsToRun);
3202
+ setPendingToolRequests((prev) =>
3203
+ prev.filter((r) => normalizeToolName(r.toolName) !== normalizedToolName),
3204
+ );
3205
+ },
3206
+ [processGivenToolRequests, normalizeToolName]
3207
+ );
1284
3208
 
1285
- // Get unique tool names from pending requests
1286
- const getUniqueToolNames = useCallback(() => {
1287
- return Array.from(new Set(pendingToolRequests.map((r) => r.toolName)));
1288
- }, [pendingToolRequests]);
3209
+ useEffect(() => {
3210
+ if (pendingToolRequests.length === 0) return;
3211
+
3212
+ const configuredAutoApproveTools = Array.isArray(autoApproveTools)
3213
+ ? new Set(
3214
+ autoApproveTools
3215
+ .map((toolName) => normalizeToolName(String(toolName)))
3216
+ .filter(Boolean)
3217
+ )
3218
+ : null;
3219
+
3220
+ const toAuto = pendingToolRequests.filter(
3221
+ (r) => {
3222
+ const normalized = normalizeToolName(r.toolName);
3223
+ if (!normalized) return false;
3224
+ if (autoApproveTools === true) return true;
3225
+ if (configuredAutoApproveTools?.has(normalized)) return true;
3226
+ return sessionApprovedTools.includes(normalized) || alwaysApprovedTools.includes(normalized);
3227
+ }
3228
+ ) as ToolRequestMatch[];
3229
+ if (toAuto.length > 0) {
3230
+ void processGivenToolRequests(toAuto);
3231
+ setPendingToolRequests((prev) =>
3232
+ prev.filter(
3233
+ (r) => {
3234
+ const normalized = normalizeToolName(r.toolName);
3235
+ if (!normalized) return true;
3236
+ if (autoApproveTools === true) return false;
3237
+ if (configuredAutoApproveTools?.has(normalized)) return false;
3238
+ return !sessionApprovedTools.includes(normalized) && !alwaysApprovedTools.includes(normalized);
3239
+ }
3240
+ )
3241
+ );
3242
+ }
3243
+ }, [
3244
+ autoApproveTools,
3245
+ pendingToolRequests,
3246
+ sessionApprovedTools,
3247
+ alwaysApprovedTools,
3248
+ processGivenToolRequests,
3249
+ normalizeToolName,
3250
+ ]);
1289
3251
 
1290
3252
  // ============================================================================
1291
3253
  // Callbacks
@@ -1324,38 +3286,31 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1324
3286
  // Remove zero-width space characters from keepalive before processing
1325
3287
  const processedText = text.replace(/\u200B/g, '');
1326
3288
 
1327
- const allMatches: ThinkingBlock[] = [];
1328
-
1329
- // Extract complete thinking blocks
1330
- const thinkingRegex = /<thinking>([\s\S]*?)<\/thinking>/gi;
1331
- let match;
1332
- while ((match = thinkingRegex.exec(processedText)) !== null) {
1333
- const content = match[1]?.trim();
1334
- if (content) {
1335
- allMatches.push({ content, index: match.index, type: 'thinking' });
1336
- }
1337
- }
1338
-
1339
- // Extract complete reasoning blocks
1340
- const reasoningRegex = /<reasoning>([\s\S]*?)<\/reasoning>/gi;
1341
- while ((match = reasoningRegex.exec(processedText)) !== null) {
1342
- const content = match[1]?.trim();
1343
- if (content) {
1344
- allMatches.push({ content, index: match.index, type: 'reasoning' });
1345
- }
1346
- }
1347
-
1348
- // Extract complete searching blocks
1349
- const searchingRegex = /<searching>([\s\S]*?)<\/searching>/gi;
1350
- while ((match = searchingRegex.exec(processedText)) !== null) {
1351
- const content = match[1]?.trim();
1352
- if (content) {
1353
- allMatches.push({ content, index: match.index, type: 'searching' });
1354
- }
1355
- }
1356
-
1357
- // Sort by position in original text
1358
- const completedBlocks = allMatches.sort((a, b) => a.index - b.index);
3289
+ const completedBlocks: ThinkingBlock[] = [];
3290
+ const textWithCompleteMarkers = processedText.replace(
3291
+ /<(thinking|reasoning|searching)>([\s\S]*?)<\/\1>/gi,
3292
+ (_fullMatch, rawType, rawContent, offset) => {
3293
+ const normalizedType = String(rawType || '').trim().toLowerCase();
3294
+ const type: 'thinking' | 'reasoning' | 'searching' =
3295
+ normalizedType === 'reasoning'
3296
+ ? 'reasoning'
3297
+ : normalizedType === 'searching'
3298
+ ? 'searching'
3299
+ : 'thinking';
3300
+ const content = String(rawContent || '').trim();
3301
+ if (!content) return '\n\n';
3302
+
3303
+ const signature = getThinkingBlockSignature(type, content);
3304
+ completedBlocks.push({
3305
+ type,
3306
+ content,
3307
+ index: Number(offset || 0),
3308
+ signature,
3309
+ });
3310
+
3311
+ return `\n\n${buildThinkingBlockMarker(type, signature)}\n\n`;
3312
+ },
3313
+ );
1359
3314
 
1360
3315
  // Check for incomplete (streaming) tags at the end of the text
1361
3316
  let activeBlock: { type: 'thinking' | 'reasoning' | 'searching'; content: string } | null = null;
@@ -1399,10 +3354,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1399
3354
  }
1400
3355
 
1401
3356
  // Clean the text by removing all thinking-related tags (complete and incomplete)
1402
- let cleanedText = processedText
1403
- .replace(/<thinking>[\s\S]*?<\/thinking>/gi, '')
1404
- .replace(/<reasoning>[\s\S]*?<\/reasoning>/gi, '')
1405
- .replace(/<searching>[\s\S]*?<\/searching>/gi, '')
3357
+ let cleanedText = textWithCompleteMarkers
1406
3358
  // Also remove partial opening tags
1407
3359
  .replace(/<think(?:i(?:n(?:g)?)?)?$/i, '')
1408
3360
  .replace(/<reas(?:o(?:n(?:i(?:n(?:g)?)?)?)?)?$/i, '')
@@ -1426,12 +3378,44 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1426
3378
  return { cleanedText, completedBlocks, activeBlock, lastThinkingContent };
1427
3379
  }, [cleanContentForDisplay]);
1428
3380
 
1429
- // Compute active thinking block directly from response during render (avoids state batching issues)
1430
- const activeThinkingBlock = useMemo(() => {
1431
- if (!response || justReset) return null;
1432
- const { activeBlock } = processThinkingTags(response);
1433
- return activeBlock;
1434
- }, [response, justReset, processThinkingTags]);
3381
+ const mergeThinkingBlocks = useCallback(
3382
+ (existing: ThinkingBlock[], incoming: ThinkingBlock[]): ThinkingBlock[] => {
3383
+ if (incoming.length === 0) return existing;
3384
+ if (existing.length === 0) return incoming;
3385
+
3386
+ // During a single stream, incoming blocks are usually a superset prefix of existing blocks.
3387
+ // In that case, replace to keep indexes/content fully aligned with latest parse.
3388
+ const incomingSupersetPrefix =
3389
+ incoming.length >= existing.length &&
3390
+ existing.every((block, index) => {
3391
+ const next = incoming[index];
3392
+ return !!next && next.type === block.type && next.content === block.content;
3393
+ });
3394
+ if (incomingSupersetPrefix) {
3395
+ return incoming;
3396
+ }
3397
+
3398
+ // For follow-on streams (e.g., tool continuations), append only unseen blocks.
3399
+ const merged = [...existing];
3400
+ const seen = new Set(existing.map((block) => block.signature || `${block.type}::${block.content}`));
3401
+ for (const block of incoming) {
3402
+ const signature = block.signature || `${block.type}::${block.content}`;
3403
+ if (seen.has(signature)) continue;
3404
+ seen.add(signature);
3405
+ merged.push(block);
3406
+ }
3407
+ return merged;
3408
+ },
3409
+ [],
3410
+ );
3411
+
3412
+ const getThinkingBlockCollapseKey = useCallback((entryKey: string, blockKey: string): string => {
3413
+ return `${entryKey}::${blockKey}`;
3414
+ }, []);
3415
+
3416
+ const getThinkingBlockRenderKey = useCallback((block: ThinkingBlock, fallbackIndex: number): string => {
3417
+ return String(block?.signature || '').trim() || `block-${fallbackIndex}`;
3418
+ }, []);
1435
3419
 
1436
3420
  // Built-in action for agent suggestion cards
1437
3421
  // Pattern: [SUGGEST_AGENT:agent-id|Agent Name|Brief reason]
@@ -1440,6 +3424,37 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1440
3424
  markdown: '<agent-suggestion data-agent-id="$1" data-agent-name="$2" data-reason="$3"></agent-suggestion>',
1441
3425
  };
1442
3426
 
3427
+ const extractToolRequests = useCallback((rawResponse: string): ToolRequestMatch[] => {
3428
+ return extractToolRequestMatchesFromText(rawResponse);
3429
+ }, []);
3430
+
3431
+ const formatToolRequestsForDisplay = useCallback((rawResponse: string): string => {
3432
+ if (!rawResponse) return rawResponse;
3433
+ const requests = extractToolRequestMatchesFromText(rawResponse);
3434
+ if (requests.length === 0) return stripStandaloneRawToolJsonLines(rawResponse);
3435
+
3436
+ const output: string[] = [];
3437
+ let cursor = 0;
3438
+ requests.forEach((request) => {
3439
+ const start = Math.max(0, Math.min(rawResponse.length, request.start));
3440
+ const end = Math.max(start, Math.min(rawResponse.length, request.end));
3441
+ output.push(rawResponse.slice(cursor, start));
3442
+ output.push(`\n\n${buildInlineToolMarker(request.toolName, request.callId)}\n\n`);
3443
+ cursor = end;
3444
+ });
3445
+ output.push(rawResponse.slice(cursor));
3446
+ return stripStandaloneRawToolJsonLines(output.join(''));
3447
+ }, []);
3448
+
3449
+ // Compute active thinking block from the raw response during render.
3450
+ // Using raw response preserves streaming reasoning/searching blocks even when
3451
+ // tool-call payload cleanup temporarily mutates the display text.
3452
+ const activeThinkingBlock = useMemo(() => {
3453
+ if (!response || justReset) return null;
3454
+ const { activeBlock } = processThinkingTags(response);
3455
+ return activeBlock;
3456
+ }, [response, justReset, processThinkingTags]);
3457
+
1443
3458
  // Process actions in content
1444
3459
  const processActions = useCallback((content: string): string => {
1445
3460
  // Combine built-in actions with user-provided actions
@@ -1485,6 +3500,42 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1485
3500
  return displayPrompt;
1486
3501
  }, [hideRagContextInPrompt]);
1487
3502
 
3503
+ const normalizeHistoryPromptForContext = useCallback((historyPrompt: string): string => {
3504
+ let promptForHistory = String(historyPrompt || '');
3505
+ const isoTimestampRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z:/;
3506
+ if (isoTimestampRegex.test(promptForHistory)) {
3507
+ const colonIndex = promptForHistory.indexOf(':', 19);
3508
+ promptForHistory = promptForHistory.substring(colonIndex + 1);
3509
+ } else if (/^\d+:/.test(promptForHistory)) {
3510
+ const colonIndex = promptForHistory.indexOf(':');
3511
+ promptForHistory = promptForHistory.substring(colonIndex + 1);
3512
+ }
3513
+ return promptForHistory;
3514
+ }, []);
3515
+
3516
+ const buildAssistantContextContent = useCallback(
3517
+ (historyPrompt: string, historyEntry: HistoryEntry): string => {
3518
+ const assistantBaseContent =
3519
+ typeof historyEntry?.content === 'string' ? historyEntry.content : '';
3520
+ if (traceContextMode !== 'full') {
3521
+ return assistantBaseContent;
3522
+ }
3523
+
3524
+ const traceSummary = buildCompactTraceSummary({
3525
+ reasoningBlocks: thinkingBlocksByKeyRef.current[historyPrompt] || [],
3526
+ toolCalls: historyEntry?.toolCalls,
3527
+ toolResponses: historyEntry?.toolResponses,
3528
+ });
3529
+ if (!traceSummary) {
3530
+ return assistantBaseContent;
3531
+ }
3532
+ return assistantBaseContent
3533
+ ? `${assistantBaseContent}\n\n${traceSummary}`
3534
+ : traceSummary;
3535
+ },
3536
+ [traceContextMode],
3537
+ );
3538
+
1488
3539
  // Built-in interaction tracking - reports to LLMAsAService API
1489
3540
  const interactionClicked = useCallback(async (
1490
3541
  callId: string,
@@ -1626,11 +3677,29 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1626
3677
  // Continue chat (send message) - matches ChatPanel behavior exactly
1627
3678
  // promptText is now required - comes from the isolated ChatInput component
1628
3679
  const continueChat = useCallback((promptText: string) => {
3680
+ handledToolCallSignaturesRef.current = new Set();
3681
+ inFlightToolCallSignaturesRef.current = new Set();
3682
+ toolContinuationCountRef.current = 0;
3683
+ activeStreamAppendBaseRef.current = null;
3684
+ toolReplaySummariesByKeyRef.current = {};
3685
+ setPendingToolRequests([]);
3686
+ setActiveToolCalls([]);
3687
+
3688
+ // Preserve completed blocks from prior prompts and collapse them when a new prompt starts.
3689
+ setCollapsedBlocks((prev) => {
3690
+ const next = new Set(prev);
3691
+ Object.entries(thinkingBlocksByKeyRef.current).forEach(([entryKey, blocks]) => {
3692
+ blocks.forEach((block, index) => {
3693
+ next.add(getThinkingBlockCollapseKey(entryKey, getThinkingBlockRenderKey(block, index)));
3694
+ });
3695
+ });
3696
+ return next;
3697
+ });
3698
+
1629
3699
  // Clear thinking blocks for new response
1630
3700
  // Note: activeThinkingBlock is computed via useMemo from response
1631
3701
  setThinkingBlocks([]);
1632
3702
  setCurrentThinkingIndex(0);
1633
- setCollapsedBlocks(new Set());
1634
3703
  hasAutoCollapsedRef.current = false;
1635
3704
  prevBlockCountRef.current = 0;
1636
3705
 
@@ -1680,6 +3749,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1680
3749
  ...prevHistory,
1681
3750
  [promptKey]: { content: '', callId: '' },
1682
3751
  }));
3752
+ toolReplaySummariesByKeyRef.current[promptKey] = [];
1683
3753
 
1684
3754
  // Store the key for later use
1685
3755
  setLastPrompt(promptToSend.trim());
@@ -1699,24 +3769,16 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1699
3769
  console.log('AIChatPanel.continueChat - ensureConversation resolved with:', convId);
1700
3770
  // Build messagesAndHistory from history (matches ChatPanel)
1701
3771
  // IMPORTANT: Exclude the current prompt (promptKey) since it's new and we're sending it now
3772
+ const historyForCall = latestHistoryRef.current || {};
1702
3773
  const messagesAndHistory: { role: string; content: string }[] = [];
1703
- Object.entries(history).forEach(([historyPrompt, historyEntry]) => {
3774
+ Object.entries(historyForCall).forEach(([historyPrompt, historyEntry]) => {
1704
3775
  // Skip the current prompt we just added optimistically
1705
3776
  if (historyPrompt === promptKey) return;
1706
-
1707
- // Strip timestamp prefix from prompt before using it (matches ChatPanel)
1708
- let promptForHistory = historyPrompt;
1709
- const isoTimestampRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z:/;
1710
- if (isoTimestampRegex.test(historyPrompt)) {
1711
- const colonIndex = historyPrompt.indexOf(':', 19);
1712
- promptForHistory = historyPrompt.substring(colonIndex + 1);
1713
- } else if (/^\d+:/.test(historyPrompt)) {
1714
- const colonIndex = historyPrompt.indexOf(':');
1715
- promptForHistory = historyPrompt.substring(colonIndex + 1);
1716
- }
1717
-
3777
+ const promptForHistory = normalizeHistoryPromptForContext(historyPrompt);
3778
+ const assistantContextContent = buildAssistantContextContent(historyPrompt, historyEntry);
3779
+
1718
3780
  messagesAndHistory.push({ role: 'user', content: promptForHistory });
1719
- messagesAndHistory.push({ role: 'assistant', content: historyEntry.content });
3781
+ messagesAndHistory.push({ role: 'assistant', content: assistantContextContent });
1720
3782
  });
1721
3783
 
1722
3784
  // Build the full prompt - only apply template for first message (matches ChatPanel)
@@ -1728,6 +3790,20 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1728
3790
 
1729
3791
  const newController = new AbortController();
1730
3792
  setLastController(newController);
3793
+
3794
+ if (onBeforeSend) {
3795
+ void Promise.resolve(
3796
+ onBeforeSend({
3797
+ prompt: promptToSend.trim(),
3798
+ conversationId: convId || null,
3799
+ agentId: agent,
3800
+ service,
3801
+ messages: messagesAndHistory,
3802
+ })
3803
+ ).catch((error) => {
3804
+ console.warn('[AIChatPanel] onBeforeSend callback failed:', error);
3805
+ });
3806
+ }
1731
3807
 
1732
3808
  // Pass data array to send() for template replacement (e.g., {{Context}})
1733
3809
  // Pass service (group_id) and customer data just like ChatPanel does
@@ -1755,19 +3831,27 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1755
3831
  errorMsg.toLowerCase().includes('cancelled');
1756
3832
 
1757
3833
  if (isAbortError) {
3834
+ if (suppressAbortHistoryUpdateRef.current) {
3835
+ setIsLoading(false);
3836
+ return;
3837
+ }
1758
3838
  // User canceled the request - don't show error banner
1759
3839
  console.log('[AIChatPanel] Request was aborted by user');
1760
3840
  // Don't set error state - no red banner
1761
3841
 
1762
3842
  // Update history to show cancellation
1763
3843
  if (promptKey) {
1764
- setHistory((prev) => ({
1765
- ...prev,
1766
- [promptKey]: {
1767
- content: 'Response canceled',
1768
- callId: lastCallId || '',
1769
- },
1770
- }));
3844
+ setHistory((prev) => {
3845
+ const existingEntry = prev[promptKey] || { content: '', callId: '' };
3846
+ return {
3847
+ ...prev,
3848
+ [promptKey]: {
3849
+ ...existingEntry,
3850
+ content: 'Response canceled',
3851
+ callId: lastCallId || existingEntry.callId || '',
3852
+ },
3853
+ };
3854
+ });
1771
3855
  }
1772
3856
  }
1773
3857
  // Detect 413 Content Too Large error
@@ -1779,13 +3863,17 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1779
3863
 
1780
3864
  // Update history to show error
1781
3865
  if (promptKey) {
1782
- setHistory((prev) => ({
1783
- ...prev,
1784
- [promptKey]: {
1785
- content: `Error: ${errorMsg}`,
1786
- callId: lastCallId || '',
1787
- },
1788
- }));
3866
+ setHistory((prev) => {
3867
+ const existingEntry = prev[promptKey] || { content: '', callId: '' };
3868
+ return {
3869
+ ...prev,
3870
+ [promptKey]: {
3871
+ ...existingEntry,
3872
+ content: `Error: ${errorMsg}`,
3873
+ callId: lastCallId || existingEntry.callId || '',
3874
+ },
3875
+ };
3876
+ });
1789
3877
  }
1790
3878
  }
1791
3879
  // Detect other network errors
@@ -1797,13 +3885,17 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1797
3885
 
1798
3886
  // Update history to show error
1799
3887
  if (promptKey) {
1800
- setHistory((prev) => ({
1801
- ...prev,
1802
- [promptKey]: {
1803
- content: `Error: ${errorMsg}`,
1804
- callId: lastCallId || '',
1805
- },
1806
- }));
3888
+ setHistory((prev) => {
3889
+ const existingEntry = prev[promptKey] || { content: '', callId: '' };
3890
+ return {
3891
+ ...prev,
3892
+ [promptKey]: {
3893
+ ...existingEntry,
3894
+ content: `Error: ${errorMsg}`,
3895
+ callId: lastCallId || existingEntry.callId || '',
3896
+ },
3897
+ };
3898
+ });
1807
3899
  }
1808
3900
  }
1809
3901
  // Generic error
@@ -1815,13 +3907,17 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1815
3907
 
1816
3908
  // Update history to show error
1817
3909
  if (promptKey) {
1818
- setHistory((prev) => ({
1819
- ...prev,
1820
- [promptKey]: {
1821
- content: `Error: ${errorMsg}`,
1822
- callId: lastCallId || '',
1823
- },
1824
- }));
3910
+ setHistory((prev) => {
3911
+ const existingEntry = prev[promptKey] || { content: '', callId: '' };
3912
+ return {
3913
+ ...prev,
3914
+ [promptKey]: {
3915
+ ...existingEntry,
3916
+ content: `Error: ${errorMsg}`,
3917
+ callId: lastCallId || existingEntry.callId || '',
3918
+ },
3919
+ };
3920
+ });
1825
3921
  }
1826
3922
  }
1827
3923
 
@@ -1850,14 +3946,20 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1850
3946
  lastCallId,
1851
3947
  processThinkingTags,
1852
3948
  clearFollowOnQuestionsNextPrompt,
1853
- history,
1854
3949
  promptTemplate,
1855
3950
  send,
1856
3951
  service,
3952
+ agent,
1857
3953
  ensureConversation,
3954
+ normalizeHistoryPromptForContext,
3955
+ buildAssistantContextContent,
3956
+ traceContextMode,
1858
3957
  dataWithExtras,
1859
3958
  scrollToBottom,
1860
3959
  onConversationCreated,
3960
+ onBeforeSend,
3961
+ getThinkingBlockCollapseKey,
3962
+ getThinkingBlockRenderKey,
1861
3963
  setResponse,
1862
3964
  ]);
1863
3965
 
@@ -1894,6 +3996,14 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1894
3996
  setLastKey(null);
1895
3997
  setIsLoading(false);
1896
3998
  setCurrentConversation(null);
3999
+ thinkingBlocksByKeyRef.current = {};
4000
+ setThinkingBlocksByKey({});
4001
+ handledToolCallSignaturesRef.current = new Set();
4002
+ inFlightToolCallSignaturesRef.current = new Set();
4003
+ toolContinuationCountRef.current = 0;
4004
+ activeStreamAppendBaseRef.current = null;
4005
+ toolReplaySummariesByKeyRef.current = {};
4006
+ setPendingToolRequests([]);
1897
4007
  setFollowOnQuestionsState(followOnQuestions);
1898
4008
  setThinkingBlocks([]);
1899
4009
  setCurrentThinkingIndex(0);
@@ -1905,6 +4015,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1905
4015
  setLastController(new AbortController());
1906
4016
  setUserHasScrolled(false);
1907
4017
  setError(null); // Clear any errors
4018
+ setActiveToolCalls([]);
1908
4019
 
1909
4020
  setTimeout(() => {
1910
4021
  setJustReset(false);
@@ -1923,54 +4034,110 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1923
4034
  // actions will re-process the history correctly.
1924
4035
  useEffect(() => {
1925
4036
  if (!response || !lastKey || justReset) return;
1926
-
1927
- const { cleanedText, completedBlocks } = processThinkingTags(response);
4037
+
4038
+ const extractedToolRequests = extractToolRequests(response);
4039
+ const seenToolCallSignatures = new Set<string>();
4040
+ const unseenToolRequests = extractedToolRequests.filter((request) => {
4041
+ const callSignature = getToolCallSignature(request.toolName, request.callId);
4042
+ if (!callSignature) return false;
4043
+ if (seenToolCallSignatures.has(callSignature)) return false;
4044
+ seenToolCallSignatures.add(callSignature);
4045
+ if (handledToolCallSignaturesRef.current.has(callSignature)) return false;
4046
+ if (inFlightToolCallSignaturesRef.current.has(callSignature)) return false;
4047
+ return true;
4048
+ });
4049
+
4050
+ setPendingToolRequests((prev) => {
4051
+ if (areToolRequestListsEqual(prev, unseenToolRequests)) {
4052
+ return prev;
4053
+ }
4054
+ return unseenToolRequests;
4055
+ });
4056
+
4057
+ const responseWithInlineToolLabels = formatToolRequestsForDisplay(response);
4058
+ const { cleanedText: parsedCleanedText, completedBlocks } = processThinkingTags(responseWithInlineToolLabels);
4059
+ const cleanedText = parsedCleanedText.trim();
4060
+ const existingBlocks = thinkingBlocksByKeyRef.current[lastKey] || [];
4061
+ const mergedBlocks = mergeThinkingBlocks(existingBlocks, completedBlocks);
4062
+ thinkingBlocksByKeyRef.current[lastKey] = mergedBlocks;
4063
+ setThinkingBlocksByKey((prev) => {
4064
+ const existing = prev[lastKey];
4065
+ const isSame =
4066
+ !!existing &&
4067
+ existing.length === mergedBlocks.length &&
4068
+ existing.every(
4069
+ (block, index) =>
4070
+ block.type === mergedBlocks[index]?.type &&
4071
+ block.content === mergedBlocks[index]?.content,
4072
+ );
4073
+
4074
+ if (isSame) return prev;
4075
+ return {
4076
+ ...prev,
4077
+ [lastKey]: mergedBlocks,
4078
+ };
4079
+ });
1928
4080
 
1929
4081
  // Update display state
1930
4082
  // Note: activeThinkingBlock is computed via useMemo from response directly
1931
- setThinkingBlocks(completedBlocks);
4083
+ setThinkingBlocks(mergedBlocks);
4084
+ setCurrentThinkingIndex(Math.max(0, mergedBlocks.length - 1));
1932
4085
 
1933
4086
  // When a new block appears, collapse all previous blocks
1934
- if (completedBlocks.length > prevBlockCountRef.current) {
4087
+ if (mergedBlocks.length > prevBlockCountRef.current) {
1935
4088
  setCollapsedBlocks(prev => {
1936
4089
  const next = new Set(prev);
1937
4090
  // Collapse all blocks except the newest one
1938
- for (let i = 0; i < completedBlocks.length - 1; i++) {
1939
- next.add(`block-${i}`);
4091
+ for (let i = 0; i < mergedBlocks.length - 1; i++) {
4092
+ const block = mergedBlocks[i];
4093
+ if (!block) continue;
4094
+ next.add(getThinkingBlockCollapseKey(lastKey, getThinkingBlockRenderKey(block, i)));
1940
4095
  }
1941
4096
  return next;
1942
4097
  });
1943
- prevBlockCountRef.current = completedBlocks.length;
4098
+ prevBlockCountRef.current = mergedBlocks.length;
1944
4099
  }
1945
4100
 
1946
- // Auto-collapse all thinking blocks when main content starts appearing
1947
- const hasMainContent = cleanedText.trim().length > 0;
1948
- const hasThinkingContent = completedBlocks.length > 0 || processThinkingTags(response).activeBlock !== null;
1949
- if (hasMainContent && hasThinkingContent && !hasAutoCollapsedRef.current) {
1950
- hasAutoCollapsedRef.current = true;
1951
- setTimeout(() => {
1952
- // Collapse all blocks including active
1953
- setCollapsedBlocks(prev => {
1954
- const next = new Set(prev);
1955
- completedBlocks.forEach((_, index) => next.add(`block-${index}`));
1956
- next.add('active');
1957
- return next;
1958
- });
1959
- }, 500);
1960
- }
4101
+ // Keep reasoning blocks visible; users can collapse manually if needed.
4102
+ // Auto-collapse here made blocks appear and then seem to disappear.
1961
4103
 
1962
4104
  // Update history state with RAW content (actions applied at render time)
1963
4105
  setHistory((prev) => {
1964
4106
  const newHistory = { ...prev };
4107
+ const existingEntry = newHistory[lastKey] || { content: '', callId: '' };
4108
+ const appendBase = activeStreamAppendBaseRef.current;
4109
+ const mergedContinuationContent =
4110
+ appendBase && appendBase.key === lastKey
4111
+ ? mergeContinuationResponseText(appendBase.base, cleanedText)
4112
+ : cleanedText;
4113
+ const existingContent = typeof existingEntry.content === 'string' ? existingEntry.content : '';
4114
+ const nextContent =
4115
+ appendBase && appendBase.key === lastKey && existingContent.length > mergedContinuationContent.length
4116
+ ? existingContent
4117
+ : shouldPreserveBoundaryDroppedStreamText(existingContent, mergedContinuationContent)
4118
+ ? existingContent
4119
+ : mergedContinuationContent;
1965
4120
  newHistory[lastKey] = {
1966
- content: cleanedText, // Store raw content, not processed
1967
- callId: lastCallId || '',
4121
+ ...existingEntry,
4122
+ content: nextContent, // Store raw content without tool JSON or thinking tags
4123
+ callId: lastCallId || existingEntry.callId || '',
1968
4124
  };
1969
4125
  // Keep ref in sync for callbacks (this doesn't trigger re-renders)
1970
4126
  latestHistoryRef.current = newHistory;
1971
4127
  return newHistory;
1972
4128
  });
1973
- }, [response, lastKey, lastCallId, processThinkingTags, justReset]);
4129
+ }, [
4130
+ response,
4131
+ lastKey,
4132
+ lastCallId,
4133
+ processThinkingTags,
4134
+ justReset,
4135
+ extractToolRequests,
4136
+ getToolCallSignature,
4137
+ getThinkingBlockCollapseKey,
4138
+ getThinkingBlockRenderKey,
4139
+ formatToolRequestsForDisplay,
4140
+ ]);
1974
4141
 
1975
4142
  // Effect 2: Handle response completion - SINGLE POINT for all completion logic
1976
4143
  // Triggers ONLY when idle transitions from false → true
@@ -2013,6 +4180,19 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2013
4180
  hasNotifiedCompletionRef.current = false;
2014
4181
  // Reset response length tracking for new stream
2015
4182
  prevResponseLengthRef.current = 0;
4183
+ const currentLastKey = lastKeyRef.current;
4184
+ const existingContent = currentLastKey
4185
+ ? latestHistoryRef.current[currentLastKey]?.content || ''
4186
+ : '';
4187
+ activeStreamAppendBaseRef.current =
4188
+ currentLastKey && existingContent.trim().length > 0
4189
+ ? {
4190
+ key: currentLastKey,
4191
+ base: existingContent,
4192
+ }
4193
+ : null;
4194
+ // Keep thinking UI state across follow-on streams triggered by tool calls.
4195
+ // New user prompts already reset this state explicitly before send().
2016
4196
  }
2017
4197
  }, [idle]); // ONLY depends on idle - no history, no callbacks in deps
2018
4198
 
@@ -2052,10 +4232,6 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2052
4232
  }
2053
4233
  }, [response, idle]); // ONLY response and idle - no other dependencies!
2054
4234
 
2055
- // Ref to track idle state for scroll handler (avoids stale closure)
2056
- const idleRef = useRef(idle);
2057
- idleRef.current = idle;
2058
-
2059
4235
  // Detect user scroll intent via wheel event (fires before scroll position changes)
2060
4236
  useEffect(() => {
2061
4237
  const scrollArea = responseAreaRef.current;
@@ -2068,7 +4244,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2068
4244
  // Wheel event detects user intent immediately (before scroll position changes)
2069
4245
  const handleWheel = (e: WheelEvent) => {
2070
4246
  // Skip if not streaming
2071
- if (idleRef.current) return;
4247
+ if (streamIdleRef.current) return;
2072
4248
 
2073
4249
  // deltaY < 0 means scrolling UP (toward top of document)
2074
4250
  if (e.deltaY < 0 && !userHasScrolledRef.current) {
@@ -2079,7 +4255,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2079
4255
  // Scroll event for detecting when user returns to bottom
2080
4256
  const handleScroll = () => {
2081
4257
  // Skip if not streaming or user hasn't scrolled up
2082
- if (idleRef.current || !userHasScrolledRef.current) return;
4258
+ if (streamIdleRef.current || !userHasScrolledRef.current) return;
2083
4259
 
2084
4260
  const scrollHeight = scrollElement.scrollHeight;
2085
4261
  const currentScrollTop = scrollElement.scrollTop;
@@ -2102,7 +4278,15 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2102
4278
 
2103
4279
  // Update follow-on questions from props
2104
4280
  useEffect(() => {
2105
- setFollowOnQuestionsState(followOnQuestions);
4281
+ setFollowOnQuestionsState((prev) => {
4282
+ if (
4283
+ prev.length === followOnQuestions.length &&
4284
+ prev.every((question, index) => question === followOnQuestions[index])
4285
+ ) {
4286
+ return prev;
4287
+ }
4288
+ return followOnQuestions;
4289
+ });
2106
4290
  }, [followOnQuestions]);
2107
4291
 
2108
4292
  // Notify loading state changes
@@ -2129,9 +4313,11 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2129
4313
 
2130
4314
  // If there's a response in progress, make sure it's saved
2131
4315
  if (currentLastKey && currentResponse) {
4316
+ const existingEntry = currentHistory[currentLastKey] || { content: '', callId: '' };
2132
4317
  currentHistory[currentLastKey] = {
4318
+ ...existingEntry,
2133
4319
  content: currentResponse + '\n\n(response interrupted)',
2134
- callId: currentLastCallId || '',
4320
+ callId: currentLastCallId || existingEntry.callId || '',
2135
4321
  };
2136
4322
  }
2137
4323
 
@@ -2198,13 +4384,17 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2198
4384
 
2199
4385
  // Update history to show error
2200
4386
  if (lastKey) {
2201
- setHistory((prev) => ({
2202
- ...prev,
2203
- [lastKey]: {
2204
- content: `Error: ${errorMessage}`,
2205
- callId: lastCallId || '',
2206
- },
2207
- }));
4387
+ setHistory((prev) => {
4388
+ const existingEntry = prev[lastKey] || { content: '', callId: '' };
4389
+ return {
4390
+ ...prev,
4391
+ [lastKey]: {
4392
+ ...existingEntry,
4393
+ content: `Error: ${errorMessage}`,
4394
+ callId: lastCallId || existingEntry.callId || '',
4395
+ },
4396
+ };
4397
+ });
2208
4398
  }
2209
4399
  }
2210
4400
  // Detect other network errors
@@ -2216,13 +4406,17 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2216
4406
 
2217
4407
  // Update history to show error
2218
4408
  if (lastKey) {
2219
- setHistory((prev) => ({
2220
- ...prev,
2221
- [lastKey]: {
2222
- content: `Error: ${errorMessage}`,
2223
- callId: lastCallId || '',
2224
- },
2225
- }));
4409
+ setHistory((prev) => {
4410
+ const existingEntry = prev[lastKey] || { content: '', callId: '' };
4411
+ return {
4412
+ ...prev,
4413
+ [lastKey]: {
4414
+ ...existingEntry,
4415
+ content: `Error: ${errorMessage}`,
4416
+ callId: lastCallId || existingEntry.callId || '',
4417
+ },
4418
+ };
4419
+ });
2226
4420
  }
2227
4421
  }
2228
4422
  // Generic error
@@ -2234,13 +4428,17 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2234
4428
 
2235
4429
  // Update history to show error
2236
4430
  if (lastKey) {
2237
- setHistory((prev) => ({
2238
- ...prev,
2239
- [lastKey]: {
2240
- content: `Error: ${errorMessage}`,
2241
- callId: lastCallId || '',
2242
- },
2243
- }));
4431
+ setHistory((prev) => {
4432
+ const existingEntry = prev[lastKey] || { content: '', callId: '' };
4433
+ return {
4434
+ ...prev,
4435
+ [lastKey]: {
4436
+ ...existingEntry,
4437
+ content: `Error: ${errorMessage}`,
4438
+ callId: lastCallId || existingEntry.callId || '',
4439
+ },
4440
+ };
4441
+ });
2244
4442
  }
2245
4443
  }
2246
4444
 
@@ -2466,62 +4664,271 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2466
4664
  },
2467
4665
  }), [CodeBlock, AgentSuggestionCard]);
2468
4666
 
2469
- // Render thinking blocks with new collapsible design
2470
- const renderThinkingBlocks = useCallback((isStreaming: boolean = false): React.ReactElement | null => {
2471
- const hasActiveBlock = activeThinkingBlock !== null;
2472
- const hasCompletedBlocks = thinkingBlocks.length > 0;
2473
-
2474
- if (!hasActiveBlock && !hasCompletedBlocks) return null;
2475
-
2476
- const handleToggleCollapse = (blockKey: string) => {
2477
- setCollapsedBlocks(prev => {
2478
- const next = new Set(prev);
2479
- if (next.has(blockKey)) {
2480
- next.delete(blockKey);
2481
- } else {
2482
- next.add(blockKey);
2483
- }
2484
- return next;
2485
- });
2486
- };
2487
-
4667
+ const toggleThinkingBlockCollapsed = useCallback((entryKey: string, blockKey: string) => {
4668
+ const collapseKey = getThinkingBlockCollapseKey(entryKey, blockKey);
4669
+ setCollapsedBlocks((prev) => {
4670
+ const next = new Set(prev);
4671
+ if (next.has(collapseKey)) {
4672
+ next.delete(collapseKey);
4673
+ } else {
4674
+ next.add(collapseKey);
4675
+ }
4676
+ return next;
4677
+ });
4678
+ }, [getThinkingBlockCollapseKey]);
4679
+
4680
+ const renderThinkingBlockCard = (
4681
+ entryKey: string,
4682
+ block: { type: 'thinking' | 'reasoning' | 'searching'; content: string },
4683
+ blockKey: string,
4684
+ renderKey: string,
4685
+ isStreaming: boolean,
4686
+ ): React.ReactNode => {
4687
+ const collapseKey = getThinkingBlockCollapseKey(entryKey, blockKey);
2488
4688
  return (
2489
- <>
2490
- {/* Render completed blocks first */}
2491
- {thinkingBlocks.map((block, index) => {
2492
- const blockKey = `block-${index}`;
2493
- return (
2494
- <ThinkingBlockComponent
2495
- key={blockKey}
2496
- type={block.type}
2497
- content={block.content}
2498
- isStreaming={false}
2499
- isCollapsed={collapsedBlocks.has(blockKey)}
2500
- onToggleCollapse={() => handleToggleCollapse(blockKey)}
2501
- />
2502
- );
2503
- })}
2504
-
2505
- {/* Render active (streaming) block */}
2506
- {activeThinkingBlock && (
2507
- <ThinkingBlockComponent
2508
- key="active-streaming"
2509
- type={activeThinkingBlock.type}
2510
- content={activeThinkingBlock.content}
2511
- isStreaming={true}
2512
- isCollapsed={collapsedBlocks.has('active')}
2513
- onToggleCollapse={() => handleToggleCollapse('active')}
2514
- />
2515
- )}
2516
- </>
4689
+ <ThinkingBlockComponent
4690
+ key={renderKey}
4691
+ type={block.type}
4692
+ content={block.content}
4693
+ isStreaming={isStreaming}
4694
+ isCollapsed={collapsedBlocks.has(collapseKey)}
4695
+ onToggleCollapse={() => toggleThinkingBlockCollapsed(entryKey, blockKey)}
4696
+ />
4697
+ );
4698
+ };
4699
+
4700
+ const renderActiveThinkingBlock = (
4701
+ entryKey: string,
4702
+ activeBlock: { type: 'thinking' | 'reasoning' | 'searching'; content: string } | null,
4703
+ keyPrefix: string,
4704
+ ): React.ReactNode => {
4705
+ if (!activeBlock) return null;
4706
+ return (
4707
+ <div className="ai-chat-inline-thinking-events">
4708
+ {renderThinkingBlockCard(entryKey, activeBlock, 'active', `${keyPrefix}-active`, true)}
4709
+ </div>
2517
4710
  );
2518
- }, [thinkingBlocks, activeThinkingBlock, collapsedBlocks]);
4711
+ };
2519
4712
 
2520
4713
  // ============================================================================
2521
4714
  // Render
2522
4715
  // ============================================================================
2523
4716
 
2524
4717
  const panelClasses = ['ai-chat-panel', theme === 'dark' ? 'dark-theme' : ''].filter(Boolean).join(' ');
4718
+ const getToolStatusRank = (status: ToolCallStatus): number => {
4719
+ switch (status) {
4720
+ case 'error':
4721
+ return 4;
4722
+ case 'completed':
4723
+ return 3;
4724
+ case 'running':
4725
+ return 2;
4726
+ case 'pending':
4727
+ default:
4728
+ return 1;
4729
+ }
4730
+ };
4731
+
4732
+ const formatToolCallId = (callId: string): string => {
4733
+ const normalized = String(callId || '').trim();
4734
+ if (normalized.length <= 22) return normalized;
4735
+ return `${normalized.slice(0, 10)}...${normalized.slice(-8)}`;
4736
+ };
4737
+
4738
+ const renderMarkdownContent = (content: string, key: string): React.ReactNode => {
4739
+ if (!content || !content.trim()) return null;
4740
+
4741
+ if (markdownClass) {
4742
+ return (
4743
+ <div key={key} className={markdownClass}>
4744
+ <ReactMarkdown
4745
+ remarkPlugins={[remarkGfm]}
4746
+ rehypePlugins={[rehypeRaw]}
4747
+ components={markdownComponents}
4748
+ >
4749
+ {content}
4750
+ </ReactMarkdown>
4751
+ </div>
4752
+ );
4753
+ }
4754
+
4755
+ return (
4756
+ <ReactMarkdown
4757
+ key={key}
4758
+ remarkPlugins={[remarkGfm]}
4759
+ rehypePlugins={[rehypeRaw]}
4760
+ components={markdownComponents}
4761
+ >
4762
+ {content}
4763
+ </ReactMarkdown>
4764
+ );
4765
+ };
4766
+
4767
+ const renderToolStatusRow = (toolStatusRow: ToolCallStatusRow, key: string): React.ReactNode => (
4768
+ <div
4769
+ key={key}
4770
+ className={`ai-chat-tool-status-row ai-chat-tool-status-row--${toolStatusRow.status}`}
4771
+ >
4772
+ <div className="ai-chat-tool-status-row__main">
4773
+ <ToolIcon />
4774
+ <span className="ai-chat-tool-status-row__label">{toolStatusRow.statusLabel}</span>
4775
+ <span className="ai-chat-tool-status-row__call-id">
4776
+ {formatToolCallId(toolStatusRow.callId)}
4777
+ </span>
4778
+ </div>
4779
+ {toolStatusRow.status === 'pending' && (
4780
+ <div className="ai-chat-tool-status-row__actions">
4781
+ <Button
4782
+ size="sm"
4783
+ variant="ghost"
4784
+ className="ai-chat-tool-status-row__button"
4785
+ onClick={() => handleToolApproval(toolStatusRow.toolName, 'once')}
4786
+ >
4787
+ once
4788
+ </Button>
4789
+ <Button
4790
+ size="sm"
4791
+ variant="ghost"
4792
+ className="ai-chat-tool-status-row__button"
4793
+ onClick={() => handleToolApproval(toolStatusRow.toolName, 'session')}
4794
+ >
4795
+ session
4796
+ </Button>
4797
+ <Button
4798
+ size="sm"
4799
+ variant="ghost"
4800
+ className="ai-chat-tool-status-row__button"
4801
+ onClick={() => handleToolApproval(toolStatusRow.toolName, 'always')}
4802
+ >
4803
+ always
4804
+ </Button>
4805
+ </div>
4806
+ )}
4807
+ </div>
4808
+ );
4809
+
4810
+ const renderContentWithInlineToolCards = (
4811
+ content: string,
4812
+ toolStatusRows: ToolCallStatusRow[],
4813
+ thinkingBlocksForEntry: ThinkingBlock[],
4814
+ entryKey: string,
4815
+ keyPrefix: string,
4816
+ ): React.ReactNode => {
4817
+ const { parts, markers } = parseInlineToolMarkers(content);
4818
+ const thinkingBlocksBySignature = new Map<string, ThinkingBlock>();
4819
+ thinkingBlocksForEntry.forEach((block, index) => {
4820
+ const signature = String(block?.signature || '').trim() || getThinkingBlockRenderKey(block, index);
4821
+ if (!thinkingBlocksBySignature.has(signature)) {
4822
+ thinkingBlocksBySignature.set(signature, {
4823
+ ...block,
4824
+ signature,
4825
+ });
4826
+ }
4827
+ });
4828
+
4829
+ const pendingBySignature = new Map<string, ToolCallStatusRow>();
4830
+ const pendingByCallId = new Map<string, ToolCallStatusRow>();
4831
+ toolStatusRows.forEach((row) => {
4832
+ if (!pendingBySignature.has(row.signature)) {
4833
+ pendingBySignature.set(row.signature, row);
4834
+ }
4835
+ if (!pendingByCallId.has(row.callId)) {
4836
+ pendingByCallId.set(row.callId, row);
4837
+ }
4838
+ });
4839
+
4840
+ const nodes: React.ReactNode[] = [];
4841
+
4842
+ parts.forEach((part, partIndex) => {
4843
+ const { parts: thinkingParts, markers: thinkingMarkers } = parseInlineThinkingMarkers(part);
4844
+ thinkingParts.forEach((thinkingPart, thinkingIndex) => {
4845
+ const markdownNode = renderMarkdownContent(
4846
+ thinkingPart,
4847
+ `${keyPrefix}-md-${partIndex}-${thinkingIndex}`,
4848
+ );
4849
+ if (markdownNode) {
4850
+ nodes.push(markdownNode);
4851
+ }
4852
+
4853
+ const thinkingMarker = thinkingMarkers[thinkingIndex];
4854
+ if (!thinkingMarker) return;
4855
+ const matchedBlock = thinkingBlocksBySignature.get(thinkingMarker.signature);
4856
+ if (!matchedBlock) return;
4857
+
4858
+ const blockKey = getThinkingBlockRenderKey(matchedBlock, thinkingIndex);
4859
+ nodes.push(
4860
+ <div
4861
+ key={`${keyPrefix}-thinking-${partIndex}-${thinkingIndex}-${blockKey}`}
4862
+ className="ai-chat-inline-thinking-events"
4863
+ >
4864
+ {renderThinkingBlockCard(
4865
+ entryKey,
4866
+ matchedBlock,
4867
+ blockKey,
4868
+ `${keyPrefix}-thinking-card-${partIndex}-${thinkingIndex}-${blockKey}`,
4869
+ false,
4870
+ )}
4871
+ </div>,
4872
+ );
4873
+ });
4874
+
4875
+ const marker = markers[partIndex];
4876
+ if (!marker) return;
4877
+
4878
+ const markerSignature = getToolCallSignature(marker.toolName, marker.callId);
4879
+ const matchedRow =
4880
+ (markerSignature ? pendingBySignature.get(markerSignature) : undefined) ||
4881
+ pendingByCallId.get(marker.callId);
4882
+ const fallbackSignature =
4883
+ markerSignature ||
4884
+ getToolCallSignature(marker.toolName, `${marker.callId}-${partIndex + 1}`) ||
4885
+ `${marker.toolName}::${marker.callId}::${partIndex}`;
4886
+ const rowToRender: ToolCallStatusRow = matchedRow || {
4887
+ signature: fallbackSignature,
4888
+ toolName: marker.toolName,
4889
+ callId: marker.callId,
4890
+ status: 'running',
4891
+ statusLabel: `tool call ${marker.toolName} running`,
4892
+ };
4893
+
4894
+ if (matchedRow) {
4895
+ pendingBySignature.delete(matchedRow.signature);
4896
+ const pendingByCallIdMatch = pendingByCallId.get(matchedRow.callId);
4897
+ if (pendingByCallIdMatch?.signature === matchedRow.signature) {
4898
+ pendingByCallId.delete(matchedRow.callId);
4899
+ }
4900
+ }
4901
+
4902
+ nodes.push(
4903
+ <div
4904
+ key={`${keyPrefix}-inline-${rowToRender.signature}-${partIndex}`}
4905
+ className="ai-chat-inline-tool-events"
4906
+ role="status"
4907
+ aria-live="polite"
4908
+ >
4909
+ {renderToolStatusRow(rowToRender, `${keyPrefix}-row-${rowToRender.signature}-${partIndex}`)}
4910
+ </div>,
4911
+ );
4912
+ });
4913
+
4914
+ const unmatchedRows = Array.from(pendingBySignature.values());
4915
+ if (unmatchedRows.length > 0) {
4916
+ nodes.push(
4917
+ <div
4918
+ key={`${keyPrefix}-tail`}
4919
+ className="ai-chat-inline-tool-events ai-chat-inline-tool-events--tail"
4920
+ role="status"
4921
+ aria-live="polite"
4922
+ >
4923
+ {unmatchedRows.map((row, rowIndex) =>
4924
+ renderToolStatusRow(row, `${keyPrefix}-tail-${row.signature}-${rowIndex}`),
4925
+ )}
4926
+ </div>,
4927
+ );
4928
+ }
4929
+
4930
+ return <>{nodes}</>;
4931
+ };
2525
4932
 
2526
4933
  return (
2527
4934
  <div
@@ -2588,15 +4995,101 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2588
4995
  {/* History */}
2589
4996
  {Object.entries(history).map(([prompt, entry], index, entries) => {
2590
4997
  const isLastEntry = index === entries.length - 1;
4998
+ const isStreamingEntry = isLastEntry && (isLoading || !idle) && !justReset;
4999
+ const continuationAppendBase =
5000
+ isStreamingEntry &&
5001
+ activeStreamAppendBaseRef.current &&
5002
+ activeStreamAppendBaseRef.current.key === prompt &&
5003
+ activeStreamAppendBaseRef.current.base.trim().length > 0
5004
+ ? activeStreamAppendBaseRef.current
5005
+ : null;
5006
+ const isContinuationStreamingEntry = !!continuationAppendBase;
2591
5007
  // Check if this is a system message (injected by page context, etc.)
2592
5008
  const isSystemMessage = prompt.startsWith('__system__:');
2593
5009
  // Process thinking tags first, then apply actions at render time
2594
5010
  // This ensures actions are always applied with current props (e.g., after agent switch)
2595
5011
  const { cleanedText } = processThinkingTags(entry.content);
2596
5012
  const processedContent = processActions(cleanedText);
5013
+ const entryThinkingBlocks = thinkingBlocksByKey[prompt] || [];
5014
+ const statusBySignature = new Map<string, ToolCallStatusRow>();
5015
+ const upsertToolStatus = (row: ToolCallStatusRow) => {
5016
+ const existing = statusBySignature.get(row.signature);
5017
+ if (!existing || getToolStatusRank(row.status) >= getToolStatusRank(existing.status)) {
5018
+ statusBySignature.set(row.signature, row);
5019
+ }
5020
+ };
5021
+
5022
+ const entryToolCalls = Array.isArray((entry as any).toolCalls) ? ((entry as any).toolCalls as any[]) : [];
5023
+ const entryToolResponses = Array.isArray((entry as any).toolResponses)
5024
+ ? ((entry as any).toolResponses as any[])
5025
+ : [];
5026
+
5027
+ entryToolCalls.forEach((toolCall, toolIndex) => {
5028
+ const toolName = String((toolCall as any)?.name || '').trim() || 'tool';
5029
+ const callId = String((toolCall as any)?.id || '').trim() || `${toolName}-${toolIndex + 1}`;
5030
+ const signature =
5031
+ getToolCallSignature(toolName, callId) ||
5032
+ `completed-${prompt}-${toolIndex}-${toolName}-${callId}`;
5033
+ const matchedResponse =
5034
+ entryToolResponses.find((response) => String((response as any)?.tool_call_id || '').trim() === callId) ||
5035
+ entryToolResponses[toolIndex];
5036
+ const isError = Boolean((matchedResponse as any)?.isError);
5037
+ upsertToolStatus({
5038
+ signature,
5039
+ toolName,
5040
+ callId,
5041
+ status: isError ? 'error' : 'completed',
5042
+ statusLabel: isError ? `tool call ${toolName} errored` : `tool call ${toolName} completed`,
5043
+ });
5044
+ });
5045
+
5046
+ if (isLastEntry && !justReset) {
5047
+ pendingToolRequests.forEach((request, requestIndex) => {
5048
+ const toolName = String(request?.toolName || '').trim() || 'tool';
5049
+ const callId = String(request?.callId || '').trim() || `${toolName}-pending-${requestIndex + 1}`;
5050
+ const signature =
5051
+ getToolCallSignature(toolName, callId) ||
5052
+ `pending-${prompt}-${requestIndex}-${toolName}-${callId}`;
5053
+ upsertToolStatus({
5054
+ signature,
5055
+ toolName,
5056
+ callId,
5057
+ status: 'pending',
5058
+ statusLabel: `tool call ${toolName} awaiting approval`,
5059
+ });
5060
+ });
5061
+
5062
+ activeToolCalls.forEach((activeToolCall, activeIndex) => {
5063
+ const toolName = String(activeToolCall?.toolName || '').trim() || 'tool';
5064
+ const callId =
5065
+ String(activeToolCall?.callId || '').trim() || `${toolName}-running-${activeIndex + 1}`;
5066
+ const signature =
5067
+ getToolCallSignature(toolName, callId) ||
5068
+ `running-${prompt}-${activeIndex}-${toolName}-${callId}`;
5069
+ upsertToolStatus({
5070
+ signature,
5071
+ toolName,
5072
+ callId,
5073
+ status: 'running',
5074
+ statusLabel: `tool call ${toolName} running`,
5075
+ });
5076
+ });
5077
+ }
5078
+
5079
+ const entryToolStatusRows = Array.from(statusBySignature.values());
5080
+ const hasInFlightToolStatus = entryToolStatusRows.some(
5081
+ (row) => row.status === 'pending' || row.status === 'running',
5082
+ );
5083
+ const isActivePromptEntry = !!lastKey && prompt === lastKey;
5084
+ const shouldShowBusyGapThinkingFallback =
5085
+ isActivePromptEntry &&
5086
+ !isStreamingEntry &&
5087
+ (isLoading || !idle) &&
5088
+ !activeThinkingBlock &&
5089
+ !hasInFlightToolStatus;
2597
5090
 
2598
5091
  return (
2599
- <div key={index} className="ai-chat-entry">
5092
+ <div key={prompt} className="ai-chat-entry">
2600
5093
  {/* User Message - hidden for initial prompt or system messages */}
2601
5094
  {!(hideInitialPrompt && index === 0) && !isSystemMessage && (
2602
5095
  <div className="ai-chat-message ai-chat-message--user">
@@ -2610,42 +5103,113 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2610
5103
  <div className="ai-chat-message ai-chat-message--assistant">
2611
5104
  <div className="ai-chat-message__content">
2612
5105
  {/* Streaming state */}
2613
- {isLastEntry && (isLoading || !idle) && !justReset ? (
5106
+ {isStreamingEntry ? (
2614
5107
  (() => {
5108
+ if (isContinuationStreamingEntry) {
5109
+ const continuationResponseWithInlineToolLabels = formatToolRequestsForDisplay(response || '');
5110
+ const { cleanedText: continuationCleanedText } = processThinkingTags(
5111
+ continuationResponseWithInlineToolLabels,
5112
+ );
5113
+ const appendBase = continuationAppendBase || activeStreamAppendBaseRef.current;
5114
+ const continuationMergedText =
5115
+ appendBase && appendBase.key === prompt
5116
+ ? mergeContinuationResponseText(appendBase.base, continuationCleanedText.trim())
5117
+ : continuationCleanedText;
5118
+ const continuationDisplaySource =
5119
+ continuationMergedText.trim().length > 0
5120
+ ? continuationMergedText
5121
+ : cleanedText;
5122
+ const continuationDisplayContent = processActions(continuationDisplaySource);
5123
+ const hasVisibleContinuationContent =
5124
+ continuationDisplayContent.trim().length > 0;
5125
+ const hasFreshContinuationContent = continuationCleanedText.trim().length > 0;
5126
+ const showThinkingFallback =
5127
+ !activeThinkingBlock && !hasFreshContinuationContent && !hasInFlightToolStatus;
5128
+
5129
+ return (
5130
+ <div className="ai-chat-streaming">
5131
+ {renderActiveThinkingBlock(prompt, activeThinkingBlock, `${prompt}-continuation`)}
5132
+ {hasVisibleContinuationContent ? (
5133
+ <>
5134
+ {renderContentWithInlineToolCards(
5135
+ continuationDisplayContent,
5136
+ entryToolStatusRows,
5137
+ entryThinkingBlocks,
5138
+ prompt,
5139
+ `${prompt}-continuation`,
5140
+ )}
5141
+ {showThinkingFallback && (
5142
+ <div className="ai-chat-loading ai-chat-loading--inline">
5143
+ <span>Thinking</span>
5144
+ <span className="ai-chat-loading__dots">
5145
+ <span className="ai-chat-loading__dot" />
5146
+ <span className="ai-chat-loading__dot" />
5147
+ <span className="ai-chat-loading__dot" />
5148
+ </span>
5149
+ </div>
5150
+ )}
5151
+ </>
5152
+ ) : (
5153
+ <div className="ai-chat-loading">
5154
+ <span>Continuing response</span>
5155
+ <span className="ai-chat-loading__dots">
5156
+ <span className="ai-chat-loading__dot" />
5157
+ <span className="ai-chat-loading__dot" />
5158
+ <span className="ai-chat-loading__dot" />
5159
+ </span>
5160
+ </div>
5161
+ )}
5162
+ </div>
5163
+ );
5164
+ }
5165
+
2615
5166
  // During streaming, compute content directly from response (not from history which may be stale)
2616
- const { cleanedText: streamingCleanedText } = processThinkingTags(response || '');
2617
- const streamingContent = processActions(streamingCleanedText);
5167
+ const streamingResponseWithInlineToolLabels = formatToolRequestsForDisplay(response || '');
5168
+ const { cleanedText: streamingCleanedText } = processThinkingTags(streamingResponseWithInlineToolLabels);
5169
+ const appendBase = activeStreamAppendBaseRef.current;
5170
+ const streamingMergedText =
5171
+ appendBase && appendBase.key === prompt
5172
+ ? mergeContinuationResponseText(appendBase.base, streamingCleanedText.trim())
5173
+ : streamingCleanedText;
5174
+ const streamingContent = processActions(streamingMergedText);
2618
5175
  const hasStreamingContent = streamingContent.trim().length > 0;
5176
+ const hasFreshStreamingContent = streamingCleanedText.trim().length > 0;
5177
+ const fallbackHistoryContent = processedContent.trim();
5178
+ const streamingDisplayContent = hasStreamingContent
5179
+ ? streamingContent
5180
+ : fallbackHistoryContent;
5181
+ const hasDisplayContent = streamingDisplayContent.trim().length > 0;
5182
+ const showThinkingFallback =
5183
+ !activeThinkingBlock && !hasFreshStreamingContent && !hasInFlightToolStatus;
2619
5184
 
2620
5185
  return (
2621
5186
  <div className="ai-chat-streaming">
2622
- {/* Show thinking blocks (both completed and active/streaming) */}
2623
- {(thinkingBlocks.length > 0 || activeThinkingBlock) && renderThinkingBlocks(true)}
5187
+ {renderActiveThinkingBlock(prompt, activeThinkingBlock, `${prompt}-streaming`)}
2624
5188
 
2625
5189
  {/* Show streaming content or loading indicator */}
2626
- {hasStreamingContent ? (
2627
- markdownClass ? (
2628
- <div className={markdownClass}>
2629
- <ReactMarkdown
2630
- remarkPlugins={[remarkGfm]}
2631
- rehypePlugins={[rehypeRaw]}
2632
- components={markdownComponents}
2633
- >
2634
- {streamingContent}
2635
- </ReactMarkdown>
2636
- </div>
2637
- ) : (
2638
- <ReactMarkdown
2639
- remarkPlugins={[remarkGfm]}
2640
- rehypePlugins={[rehypeRaw]}
2641
- components={markdownComponents}
2642
- >
2643
- {streamingContent}
2644
- </ReactMarkdown>
2645
- )
5190
+ {hasDisplayContent ? (
5191
+ <>
5192
+ {renderContentWithInlineToolCards(
5193
+ streamingDisplayContent,
5194
+ entryToolStatusRows,
5195
+ entryThinkingBlocks,
5196
+ prompt,
5197
+ `${prompt}-streaming`,
5198
+ )}
5199
+ {showThinkingFallback && (
5200
+ <div className="ai-chat-loading ai-chat-loading--inline">
5201
+ <span>Thinking</span>
5202
+ <span className="ai-chat-loading__dots">
5203
+ <span className="ai-chat-loading__dot" />
5204
+ <span className="ai-chat-loading__dot" />
5205
+ <span className="ai-chat-loading__dot" />
5206
+ </span>
5207
+ </div>
5208
+ )}
5209
+ </>
2646
5210
  ) : (
2647
5211
  <div className="ai-chat-loading">
2648
- <span>{thinkingBlocks.length > 0 || activeThinkingBlock ? 'Still thinking' : 'Thinking'}</span>
5212
+ <span>{activeThinkingBlock ? 'Still thinking' : 'Thinking'}</span>
2649
5213
  <span className="ai-chat-loading__dots">
2650
5214
  <span className="ai-chat-loading__dot" />
2651
5215
  <span className="ai-chat-loading__dot" />
@@ -2658,26 +5222,22 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2658
5222
  })()
2659
5223
  ) : (
2660
5224
  <>
2661
- {/* Show completed thinking blocks after streaming */}
2662
- {isLastEntry && thinkingBlocks.length > 0 && renderThinkingBlocks(false)}
2663
- {markdownClass ? (
2664
- <div className={markdownClass}>
2665
- <ReactMarkdown
2666
- remarkPlugins={[remarkGfm]}
2667
- rehypePlugins={[rehypeRaw]}
2668
- components={markdownComponents}
2669
- >
2670
- {processedContent}
2671
- </ReactMarkdown>
5225
+ {renderContentWithInlineToolCards(
5226
+ processedContent,
5227
+ entryToolStatusRows,
5228
+ entryThinkingBlocks,
5229
+ prompt,
5230
+ `${prompt}-final`,
5231
+ )}
5232
+ {shouldShowBusyGapThinkingFallback && (
5233
+ <div className="ai-chat-loading ai-chat-loading--inline" aria-live="polite">
5234
+ <span>Thinking</span>
5235
+ <span className="ai-chat-loading__dots">
5236
+ <span className="ai-chat-loading__dot" />
5237
+ <span className="ai-chat-loading__dot" />
5238
+ <span className="ai-chat-loading__dot" />
5239
+ </span>
2672
5240
  </div>
2673
- ) : (
2674
- <ReactMarkdown
2675
- remarkPlugins={[remarkGfm]}
2676
- rehypePlugins={[rehypeRaw]}
2677
- components={markdownComponents}
2678
- >
2679
- {processedContent}
2680
- </ReactMarkdown>
2681
5241
  )}
2682
5242
  </>
2683
5243
  )}
@@ -2731,26 +5291,23 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2731
5291
  )}
2732
5292
  </button>
2733
5293
 
2734
- {/* Tool Info Button - show if entry has tool data */}
2735
- {(entry.toolCalls || entry.toolResponses) && (
2736
- <button
2737
- className="ai-chat-action-button"
2738
- onClick={() => {
2739
- setToolInfoData({
2740
- calls: entry.toolCalls || [],
2741
- responses: entry.toolResponses || [],
2742
- });
2743
- setIsToolInfoModalOpen(true);
2744
- }}
2745
- title="View tool information"
2746
- >
2747
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
2748
- <circle cx="12" cy="12" r="10" />
2749
- <line x1="12" x2="12" y1="16" y2="12" />
2750
- <line x1="12" x2="12.01" y1="8" y2="8" />
2751
- </svg>
2752
- </button>
2753
- )}
5294
+ <button
5295
+ className="ai-chat-action-button"
5296
+ onClick={() => {
5297
+ setToolInfoData({
5298
+ calls: entry.toolCalls || [],
5299
+ responses: entry.toolResponses || [],
5300
+ });
5301
+ setIsToolInfoModalOpen(true);
5302
+ }}
5303
+ title="View tool information"
5304
+ >
5305
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
5306
+ <circle cx="12" cy="12" r="10" />
5307
+ <line x1="12" x2="12" y1="16" y2="12" />
5308
+ <line x1="12" x2="12.01" y1="8" y2="8" />
5309
+ </svg>
5310
+ </button>
2754
5311
  </div>
2755
5312
  )}
2756
5313
  </div>
@@ -2777,50 +5334,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2777
5334
 
2778
5335
  <div ref={bottomRef} />
2779
5336
  </ScrollArea>
2780
-
2781
- {/* Tool Approval Panel */}
2782
- {pendingToolRequests.length > 0 && (
2783
- <div className="ai-chat-approve-tools-panel">
2784
- <div className="ai-chat-approve-tools-header">
2785
- Tool Approval Required
2786
- </div>
2787
- <div className="ai-chat-approve-tools-description">
2788
- The AI wants to use the following tools:
2789
- </div>
2790
- {getUniqueToolNames().map((toolName) => (
2791
- <div key={toolName} className="ai-chat-approve-tool-item">
2792
- <div className="ai-chat-approve-tool-name">{toolName}</div>
2793
- <div className="ai-chat-approve-tools-buttons">
2794
- <Button
2795
- size="sm"
2796
- variant="outline"
2797
- className="ai-chat-approve-tools-button"
2798
- onClick={() => handleToolApproval(toolName, 'once')}
2799
- >
2800
- Once
2801
- </Button>
2802
- <Button
2803
- size="sm"
2804
- variant="outline"
2805
- className="ai-chat-approve-tools-button"
2806
- onClick={() => handleToolApproval(toolName, 'session')}
2807
- >
2808
- This Session
2809
- </Button>
2810
- <Button
2811
- size="sm"
2812
- variant="default"
2813
- className="ai-chat-approve-tools-button"
2814
- onClick={() => handleToolApproval(toolName, 'always')}
2815
- >
2816
- Always
2817
- </Button>
2818
- </div>
2819
- </div>
2820
- ))}
2821
- </div>
2822
- )}
2823
-
5337
+
2824
5338
  {/* Button Container - Save, Email, CTA */}
2825
5339
  {(showSaveButton || showEmailButton || showCallToAction) && (
2826
5340
  <div className="ai-chat-button-container">
@@ -2991,11 +5505,11 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2991
5505
  </div>
2992
5506
  </>
2993
5507
  )}
2994
-
5508
+
2995
5509
  {/* Input Area - Isolated component for performance */}
2996
5510
  <ChatInput
2997
5511
  placeholder={placeholder}
2998
- idle={idle}
5512
+ isBusy={isLoading || !idle}
2999
5513
  onSubmit={continueChat}
3000
5514
  onStop={handleStop}
3001
5515
  agentOptions={agentOptions}
@@ -3056,4 +5570,3 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
3056
5570
 
3057
5571
  // Memoize to prevent re-renders when parent state changes but our props don't
3058
5572
  export default React.memo(AIChatPanel);
3059
-