@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.
@@ -1,9 +1,15 @@
1
1
  import { LLMAsAServiceCustomer } from 'llmasaservice-client';
2
2
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import './AIAgentPanel.css';
4
- import AIChatPanel, { AgentOption } from './AIChatPanel';
4
+ import AIChatPanel, {
5
+ AgentOption,
6
+ BeforeSendPayload,
7
+ LocalToolExecutor,
8
+ TraceContextMode,
9
+ } from './AIChatPanel';
5
10
  import { Button, Dialog, DialogFooter, Input, ScrollArea, Tooltip } from './components/ui';
6
11
  import { useAgentRegistry } from './hooks/useAgentRegistry';
12
+ import { MCPAuthHeaderResolver } from './mcpAuth';
7
13
 
8
14
  /**
9
15
  * Context section for agent awareness
@@ -58,6 +64,7 @@ export interface ActiveConversation {
58
64
  stableKey: string; // Stable key for React component - never changes
59
65
  agentId: string;
60
66
  history: Record<string, { content: string; callId: string }>;
67
+ transcriptLoaded: boolean; // Prevent re-fetch loops for conversations with empty history
61
68
  isLoading: boolean;
62
69
  title: string;
63
70
  conversationInitialPrompt?: string; // Per-conversation initial prompt for programmatic start
@@ -126,6 +133,7 @@ export interface AIAgentPanelProps {
126
133
  // Callbacks
127
134
  onAgentSwitch?: (fromAgent: string, toAgent: string) => void;
128
135
  onConversationChange?: (conversationId: string) => void;
136
+ onBeforeSend?: (payload: BeforeSendPayload) => Promise<void> | void;
129
137
  historyChangedCallback?: (history: Record<string, { content: string; callId: string }>) => void;
130
138
  responseCompleteCallback?: (callId: string, prompt: string, response: string) => void;
131
139
  thumbsUpClick?: (callId: string) => void;
@@ -176,6 +184,10 @@ export interface AIAgentPanelProps {
176
184
  callToActionEmailSubject?: string;
177
185
  customerEmailCaptureMode?: "HIDE" | "OPTIONAL" | "REQUIRED";
178
186
  customerEmailCapturePlaceholder?: string;
187
+ resolveMcpAuthHeaders?: MCPAuthHeaderResolver;
188
+ localToolExecutors?: Record<string, LocalToolExecutor>;
189
+ traceContextMode?: TraceContextMode;
190
+ autoApproveTools?: boolean | string[];
179
191
  }
180
192
 
181
193
  // Icons
@@ -259,16 +271,22 @@ const normalizeConversationListPayload = (payload: any): APIConversationSummary[
259
271
  if (!payload) return [];
260
272
 
261
273
  const conversations = Array.isArray(payload) ? payload : payload.conversations || [];
262
-
263
- return conversations.map((conv: any) => {
274
+
275
+ const normalized = conversations.map((conv: any, index: number) => {
264
276
  // Get title, but filter out generic "Conversation" from API
265
277
  let title = conv.title && conv.title !== 'Conversation' ? conv.title : '';
266
278
  if (!title) {
267
279
  title = conv.summary || extractTitleFromConversation(conv);
268
280
  }
281
+
282
+ const resolvedConversationId =
283
+ conv.conversationId ||
284
+ conv.id ||
285
+ conv.conversation_id ||
286
+ `conversation-${index}-${Date.now()}`;
269
287
 
270
288
  return {
271
- conversationId: conv.conversationId || conv.id || conv.conversation_id,
289
+ conversationId: resolvedConversationId,
272
290
  title,
273
291
  summary: conv.summary,
274
292
  createdAt: conv.createdAt || conv.created_at || conv.timestamp || new Date().toISOString(),
@@ -277,6 +295,25 @@ const normalizeConversationListPayload = (payload: any): APIConversationSummary[
277
295
  messageCount: conv.messageCount || conv.message_count,
278
296
  };
279
297
  });
298
+
299
+ // Dedupe by conversationId in case upstream returns duplicate rows.
300
+ // Keep the most recently updated entry for each ID.
301
+ const dedupedById = new Map<string, APIConversationSummary>();
302
+ for (const conv of normalized) {
303
+ const existing = dedupedById.get(conv.conversationId);
304
+ if (!existing) {
305
+ dedupedById.set(conv.conversationId, conv);
306
+ continue;
307
+ }
308
+
309
+ const existingUpdated = new Date(existing.updatedAt).getTime();
310
+ const incomingUpdated = new Date(conv.updatedAt).getTime();
311
+ if (incomingUpdated >= existingUpdated) {
312
+ dedupedById.set(conv.conversationId, conv);
313
+ }
314
+ }
315
+
316
+ return Array.from(dedupedById.values());
280
317
  };
281
318
 
282
319
  // Extract title from conversation data
@@ -376,6 +413,333 @@ const groupConversationsByTime = (conversations: APIConversationSummary[], showA
376
413
  .map(([label, conversations]) => ({ label, conversations, count: conversations.length }));
377
414
  };
378
415
 
416
+ interface TranscriptMessage {
417
+ role: string;
418
+ content: string;
419
+ }
420
+
421
+ interface TranscriptTurn {
422
+ prompt: string;
423
+ response: string;
424
+ callId: string;
425
+ timestampMs: number;
426
+ }
427
+
428
+ const toRecord = (value: unknown): Record<string, unknown> | null => {
429
+ if (typeof value === 'object' && value !== null) {
430
+ return value as Record<string, unknown>;
431
+ }
432
+ return null;
433
+ };
434
+
435
+ const toStringValue = (value: unknown): string => {
436
+ if (typeof value === 'string') return value;
437
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
438
+ return '';
439
+ };
440
+
441
+ const normalizeContentText = (value: unknown): string => {
442
+ if (typeof value === 'string') return value;
443
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
444
+ if (Array.isArray(value)) {
445
+ return value
446
+ .map(item => normalizeContentText(item))
447
+ .filter(Boolean)
448
+ .join('\n')
449
+ .trim();
450
+ }
451
+
452
+ const record = toRecord(value);
453
+ if (!record) return '';
454
+
455
+ const directText = toStringValue(record.text);
456
+ if (directText) return directText;
457
+
458
+ const directValue = toStringValue(record.value);
459
+ if (directValue) return directValue;
460
+
461
+ const directContent = toStringValue(record.content);
462
+ if (directContent) return directContent;
463
+
464
+ if (Array.isArray(record.content)) {
465
+ return normalizeContentText(record.content);
466
+ }
467
+ if (Array.isArray(record.parts)) {
468
+ return normalizeContentText(record.parts);
469
+ }
470
+ if (record.content && typeof record.content === 'object') {
471
+ return normalizeContentText(record.content);
472
+ }
473
+
474
+ return '';
475
+ };
476
+
477
+ const parseCallMessages = (rawMessages: unknown): TranscriptMessage[] => {
478
+ let parsed: unknown = rawMessages;
479
+ if (typeof rawMessages === 'string') {
480
+ try {
481
+ parsed = JSON.parse(rawMessages);
482
+ } catch {
483
+ return [];
484
+ }
485
+ }
486
+
487
+ const parsedRecord = toRecord(parsed);
488
+ if (parsedRecord && Array.isArray(parsedRecord.messages)) {
489
+ parsed = parsedRecord.messages;
490
+ }
491
+
492
+ if (!Array.isArray(parsed)) {
493
+ return [];
494
+ }
495
+
496
+ const normalized: TranscriptMessage[] = [];
497
+ for (const message of parsed) {
498
+ const messageRecord = toRecord(message);
499
+ if (!messageRecord) continue;
500
+
501
+ const role = toStringValue(messageRecord.role).toLowerCase().trim();
502
+ if (!role) continue;
503
+
504
+ const content = normalizeContentText(
505
+ messageRecord.content ?? messageRecord.text ?? messageRecord.message
506
+ ).trim();
507
+ if (!content) continue;
508
+
509
+ normalized.push({ role, content });
510
+ }
511
+
512
+ return normalized;
513
+ };
514
+
515
+ const shouldSkipTranscriptMessage = (message: TranscriptMessage): boolean => {
516
+ return message.role === 'system' || (message.role === 'user' && message.content.startsWith('__system__:'));
517
+ };
518
+
519
+ const parseTimestampMs = (value: unknown): number | null => {
520
+ const timestamp = toStringValue(value).trim();
521
+ if (!timestamp) return null;
522
+ const parsed = Date.parse(timestamp);
523
+ return Number.isFinite(parsed) ? parsed : null;
524
+ };
525
+
526
+ const getCallTimestampMs = (call: Record<string, unknown>, fallbackIndex: number): number => {
527
+ const timestampCandidates = [
528
+ call.createdAt,
529
+ call.created_at,
530
+ call.timestamp,
531
+ call.updatedAt,
532
+ call.updated_at,
533
+ ];
534
+
535
+ for (const candidate of timestampCandidates) {
536
+ const parsed = parseTimestampMs(candidate);
537
+ if (parsed !== null) return parsed;
538
+ }
539
+
540
+ // Deterministic fallback to preserve ordering when timestamps are absent
541
+ return fallbackIndex;
542
+ };
543
+
544
+ const getCallId = (call: Record<string, unknown>): string => {
545
+ const id = toStringValue(call.id);
546
+ if (id) return id;
547
+ return toStringValue(call.callId);
548
+ };
549
+
550
+ const normalizeCallsPayload = (payload: unknown): Record<string, unknown>[] => {
551
+ const payloadRecord = toRecord(payload);
552
+ const calls = Array.isArray(payload)
553
+ ? payload
554
+ : payloadRecord && Array.isArray(payloadRecord.calls)
555
+ ? (payloadRecord.calls as unknown[])
556
+ : [];
557
+
558
+ return calls
559
+ .map(call => toRecord(call))
560
+ .filter((call): call is Record<string, unknown> => call !== null);
561
+ };
562
+
563
+ const turnsEqual = (left: TranscriptTurn, right: TranscriptTurn): boolean => {
564
+ return left.prompt === right.prompt && left.response === right.response;
565
+ };
566
+
567
+ const getPrefixMatchLength = (existing: TranscriptTurn[], incoming: TranscriptTurn[]): number => {
568
+ const max = Math.min(existing.length, incoming.length);
569
+ let matched = 0;
570
+ while (matched < max && turnsEqual(existing[matched]!, incoming[matched]!)) {
571
+ matched += 1;
572
+ }
573
+ return matched;
574
+ };
575
+
576
+ const getSuffixPrefixOverlap = (existing: TranscriptTurn[], incoming: TranscriptTurn[]): number => {
577
+ const max = Math.min(existing.length, incoming.length);
578
+ for (let overlap = max; overlap > 0; overlap -= 1) {
579
+ let matches = true;
580
+ for (let idx = 0; idx < overlap; idx += 1) {
581
+ const left = existing[existing.length - overlap + idx]!;
582
+ const right = incoming[idx]!;
583
+ if (!turnsEqual(left, right)) {
584
+ matches = false;
585
+ break;
586
+ }
587
+ }
588
+ if (matches) return overlap;
589
+ }
590
+ return 0;
591
+ };
592
+
593
+ const mergeTranscriptTurns = (existing: TranscriptTurn[], incoming: TranscriptTurn[]): TranscriptTurn[] => {
594
+ if (incoming.length === 0) return existing;
595
+ if (existing.length === 0) return [...incoming];
596
+
597
+ const prefixMatchLength = getPrefixMatchLength(existing, incoming);
598
+ if (prefixMatchLength > 0) {
599
+ return [...existing, ...incoming.slice(prefixMatchLength)];
600
+ }
601
+
602
+ const overlap = getSuffixPrefixOverlap(existing, incoming);
603
+ if (overlap > 0) {
604
+ return [...existing, ...incoming.slice(overlap)];
605
+ }
606
+
607
+ return [...existing, ...incoming];
608
+ };
609
+
610
+ const buildTurnsFromMessages = (
611
+ messages: TranscriptMessage[],
612
+ fallbackResponse: string,
613
+ callId: string,
614
+ timestampMs: number,
615
+ ): TranscriptTurn[] => {
616
+ const turns: TranscriptTurn[] = [];
617
+
618
+ for (let index = 0; index < messages.length; index += 1) {
619
+ const message = messages[index];
620
+ if (!message || message.role !== 'user') continue;
621
+
622
+ const prompt = message.content.trim();
623
+ if (!prompt) continue;
624
+
625
+ let response = '';
626
+ let reachedNextUser = false;
627
+ for (let lookAhead = index + 1; lookAhead < messages.length; lookAhead += 1) {
628
+ const nextMessage = messages[lookAhead];
629
+ if (!nextMessage) continue;
630
+ if (nextMessage.role === 'assistant') {
631
+ response = nextMessage.content.trim();
632
+ index = lookAhead;
633
+ break;
634
+ }
635
+ if (nextMessage.role === 'user') {
636
+ reachedNextUser = true;
637
+ break;
638
+ }
639
+ }
640
+
641
+ // Only pair fallback response with the final unresolved user message for this call.
642
+ if (!response && !reachedNextUser) {
643
+ response = fallbackResponse;
644
+ }
645
+
646
+ if (!response) continue;
647
+
648
+ turns.push({
649
+ prompt,
650
+ response,
651
+ callId,
652
+ timestampMs,
653
+ });
654
+ }
655
+
656
+ return turns;
657
+ };
658
+
659
+ const buildTranscriptTurnsFromCalls = (calls: Record<string, unknown>[]): TranscriptTurn[] => {
660
+ const orderedCalls = calls
661
+ .map((call, index) => ({
662
+ call,
663
+ index,
664
+ timestampMs: getCallTimestampMs(call, index),
665
+ }))
666
+ .sort((left, right) => {
667
+ if (left.timestampMs === right.timestampMs) return left.index - right.index;
668
+ return left.timestampMs - right.timestampMs;
669
+ });
670
+
671
+ let mergedTurns: TranscriptTurn[] = [];
672
+
673
+ for (const orderedCall of orderedCalls) {
674
+ const { call, timestampMs } = orderedCall;
675
+ const callId = getCallId(call);
676
+ const fallbackResponse = normalizeContentText(call.response).trim();
677
+
678
+ const parsedMessages = parseCallMessages(call.messages).filter(
679
+ message => !shouldSkipTranscriptMessage(message)
680
+ );
681
+
682
+ let callTurns = buildTurnsFromMessages(
683
+ parsedMessages,
684
+ fallbackResponse,
685
+ callId,
686
+ timestampMs,
687
+ );
688
+
689
+ if (callTurns.length === 0) {
690
+ const fallbackPrompt = normalizeContentText(call.prompt).trim();
691
+ if (fallbackPrompt && fallbackResponse) {
692
+ callTurns = [
693
+ {
694
+ prompt: fallbackPrompt,
695
+ response: fallbackResponse,
696
+ callId,
697
+ timestampMs,
698
+ },
699
+ ];
700
+ }
701
+ }
702
+
703
+ mergedTurns = mergeTranscriptTurns(mergedTurns, callTurns);
704
+ }
705
+
706
+ return mergedTurns;
707
+ };
708
+
709
+ const buildHistoryFromTranscriptTurns = (
710
+ turns: TranscriptTurn[],
711
+ ): Record<string, { content: string; callId: string }> => {
712
+ const history: Record<string, { content: string; callId: string }> = {};
713
+ const keyUsageByPrompt = new Map<string, number>();
714
+
715
+ turns.forEach((turn, index) => {
716
+ const prompt = turn.prompt.trim();
717
+ const response = turn.response.trim();
718
+ if (!prompt || !response) return;
719
+
720
+ const baseTimestampMs = Number.isFinite(turn.timestampMs) ? turn.timestampMs : index;
721
+ const keySeed = `${baseTimestampMs}:${prompt}`;
722
+ const keyUsageCount = keyUsageByPrompt.get(keySeed) ?? 0;
723
+ keyUsageByPrompt.set(keySeed, keyUsageCount + 1);
724
+
725
+ const uniqueTimestamp = new Date(baseTimestampMs + keyUsageCount).toISOString();
726
+ const historyKey = `${uniqueTimestamp}:${prompt}`;
727
+ history[historyKey] = {
728
+ content: response,
729
+ callId: turn.callId,
730
+ };
731
+ });
732
+
733
+ return history;
734
+ };
735
+
736
+ const truncatePromptForTitle = (prompt: string): string => {
737
+ if (prompt.length > 60) {
738
+ return `${prompt.slice(0, 57)}...`;
739
+ }
740
+ return prompt;
741
+ };
742
+
379
743
  // Empty arrays/objects to prevent recreating on every render
380
744
  const EMPTY_ARRAY: never[] = [];
381
745
  const EMPTY_HISTORY: Record<string, { content: string; callId: string }> = {};
@@ -416,6 +780,7 @@ interface ChatPanelWrapperProps {
416
780
  onToggleSection: (sectionId: string, enabled: boolean) => void;
417
781
  // Conversation creation callback
418
782
  onConversationCreated: (tempId: string, realId: string) => void;
783
+ onBeforeSend?: (payload: BeforeSendPayload) => Promise<void> | void;
419
784
  // Per-conversation initial prompt
420
785
  conversationInitialPrompt?: string;
421
786
  // New props from ChatPanel port
@@ -434,6 +799,10 @@ interface ChatPanelWrapperProps {
434
799
  callToActionEmailSubject?: string;
435
800
  customerEmailCaptureMode?: "HIDE" | "OPTIONAL" | "REQUIRED";
436
801
  customerEmailCapturePlaceholder?: string;
802
+ resolveMcpAuthHeaders?: MCPAuthHeaderResolver;
803
+ localToolExecutors?: Record<string, LocalToolExecutor>;
804
+ traceContextMode?: TraceContextMode;
805
+ autoApproveTools?: boolean | string[];
437
806
  }
438
807
 
439
808
  // Remove React.memo temporarily to debug - ChatPanelWrapper needs to re-render when agentId changes
@@ -468,6 +837,7 @@ const ChatPanelWrapper = (({
468
837
  disabledSectionIds,
469
838
  onToggleSection,
470
839
  onConversationCreated,
840
+ onBeforeSend,
471
841
  conversationInitialPrompt,
472
842
  // New props from ChatPanel port
473
843
  cssUrl,
@@ -485,6 +855,10 @@ const ChatPanelWrapper = (({
485
855
  callToActionEmailSubject,
486
856
  customerEmailCaptureMode,
487
857
  customerEmailCapturePlaceholder,
858
+ resolveMcpAuthHeaders,
859
+ localToolExecutors,
860
+ traceContextMode,
861
+ autoApproveTools,
488
862
  }) => {
489
863
  const convAgentProfile = getAgent(activeConv.agentId);
490
864
  const convAgentMetadata = convAgentProfile?.metadata;
@@ -513,6 +887,18 @@ const ChatPanelWrapper = (({
513
887
  },
514
888
  [onConversationCreated, activeConv.conversationId]
515
889
  );
890
+
891
+ const beforeSendCallback = useCallback(
892
+ (payload: BeforeSendPayload) => {
893
+ if (!onBeforeSend) return;
894
+ return onBeforeSend({
895
+ ...payload,
896
+ conversationId: payload.conversationId || activeConv.conversationId,
897
+ agentId: payload.agentId || activeConv.agentId,
898
+ });
899
+ },
900
+ [onBeforeSend, activeConv.conversationId, activeConv.agentId]
901
+ );
516
902
 
517
903
  // Compute follow-on questions - MUST update when agent switches
518
904
  // Don't use useMemo - compute fresh every render to ensure it always reflects current agent
@@ -595,6 +981,7 @@ const ChatPanelWrapper = (({
595
981
  disabledSectionIds={disabledSectionIds}
596
982
  onToggleSection={onToggleSection}
597
983
  onConversationCreated={conversationCreatedCallback}
984
+ onBeforeSend={beforeSendCallback}
598
985
  cssUrl={cssUrl}
599
986
  markdownClass={markdownClass}
600
987
  width={width}
@@ -610,6 +997,10 @@ const ChatPanelWrapper = (({
610
997
  callToActionEmailSubject={callToActionEmailSubject ?? convAgentMetadata.displayCallToActionEmailSubject ?? 'Agent CTA submitted'}
611
998
  customerEmailCaptureMode={customerEmailCaptureMode ?? convAgentMetadata?.customerEmailCaptureMode ?? 'HIDE'}
612
999
  customerEmailCapturePlaceholder={customerEmailCapturePlaceholder ?? convAgentMetadata?.customerEmailCapturePlaceholder ?? 'Please enter your email...'}
1000
+ resolveMcpAuthHeaders={resolveMcpAuthHeaders}
1001
+ localToolExecutors={localToolExecutors}
1002
+ traceContextMode={traceContextMode}
1003
+ autoApproveTools={autoApproveTools}
613
1004
  />
614
1005
  </div>
615
1006
  );
@@ -668,6 +1059,7 @@ const AIAgentPanel = React.forwardRef<AIAgentPanelHandle, AIAgentPanelProps>(({
668
1059
  enableContextDetailView = false,
669
1060
  onAgentSwitch,
670
1061
  onConversationChange,
1062
+ onBeforeSend,
671
1063
  historyChangedCallback,
672
1064
  responseCompleteCallback,
673
1065
  thumbsUpClick,
@@ -700,6 +1092,10 @@ const AIAgentPanel = React.forwardRef<AIAgentPanelHandle, AIAgentPanelProps>(({
700
1092
  callToActionEmailSubject,
701
1093
  customerEmailCaptureMode,
702
1094
  customerEmailCapturePlaceholder,
1095
+ resolveMcpAuthHeaders,
1096
+ localToolExecutors,
1097
+ traceContextMode = 'standard',
1098
+ autoApproveTools,
703
1099
  }, ref) => {
704
1100
  // Dev mode warnings for prop conflicts
705
1101
  useEffect(() => {
@@ -731,13 +1127,8 @@ const AIAgentPanel = React.forwardRef<AIAgentPanelHandle, AIAgentPanelProps>(({
731
1127
 
732
1128
  // Use controlled value if provided, otherwise use internal state
733
1129
  const isCollapsed = isControlled ? controlledIsCollapsed : uncontrolledIsCollapsed;
734
- const [isHistoryCollapsed, setIsHistoryCollapsed] = useState(() => {
735
- if (typeof window !== 'undefined') {
736
- const saved = localStorage.getItem('ai-agent-panel-history-collapsed');
737
- return saved === 'true';
738
- }
739
- return false;
740
- });
1130
+ // Keep history closed by default; users can explicitly open it per session.
1131
+ const [isHistoryCollapsed, setIsHistoryCollapsed] = useState(true);
741
1132
  const [panelWidth, setPanelWidth] = useState(() => {
742
1133
  if (typeof window !== 'undefined') {
743
1134
  const savedWidth = localStorage.getItem('ai-agent-panel-width');
@@ -877,9 +1268,17 @@ const AIAgentPanel = React.forwardRef<AIAgentPanelHandle, AIAgentPanelProps>(({
877
1268
  const [conversationsError, setConversationsError] = useState<string | null>(null);
878
1269
  const [searchQuery, setSearchQuery] = useState('');
879
1270
 
1271
+ const controlledConversationId =
1272
+ typeof conversation === 'string' && conversation.trim() !== ''
1273
+ ? conversation.trim()
1274
+ : null;
1275
+ const isConversationControlled = controlledConversationId !== null;
1276
+
880
1277
  // Multi-conversation state
881
1278
  const [activeConversations, setActiveConversations] = useState<Map<string, ActiveConversation>>(new Map());
882
- const [currentConversationId, setCurrentConversationId] = useState<string | null>(conversation || null);
1279
+ const [currentConversationId, setCurrentConversationId] = useState<string | null>(
1280
+ controlledConversationId
1281
+ );
883
1282
 
884
1283
  // Store first prompts for conversations (conversationId -> firstPrompt)
885
1284
  const [conversationFirstPrompts, setConversationFirstPrompts] = useState<Record<string, string>>({});
@@ -911,6 +1310,7 @@ const AIAgentPanel = React.forwardRef<AIAgentPanelHandle, AIAgentPanelProps>(({
911
1310
 
912
1311
  // Ref to track if fetch is in progress (prevents duplicate calls)
913
1312
  const fetchInProgressRef = useRef(false);
1313
+ const loadingTranscriptIdsRef = useRef<Set<string>>(new Set());
914
1314
 
915
1315
  // Ref to track the last agent we fetched for
916
1316
  const lastFetchedAgentRef = useRef<string | null>(null);
@@ -932,32 +1332,62 @@ const AIAgentPanel = React.forwardRef<AIAgentPanelHandle, AIAgentPanelProps>(({
932
1332
  const currentConversationIdRef = useRef<string | null>(currentConversationId);
933
1333
  currentConversationIdRef.current = currentConversationId;
934
1334
 
935
- // Imperative handle for programmatic control
936
- React.useImperativeHandle(ref, () => ({
937
- startNewConversation: (prompt: string, agent?: string) => {
938
- const targetAgent = agent || currentAgentId;
1335
+ // Centralized conversation selection flow.
1336
+ // In controlled mode, we only update local selection for prop-driven updates (notify=false).
1337
+ const commitConversationSelection = useCallback(
1338
+ (conversationId: string | null, notifyChange: boolean) => {
1339
+ const shouldSyncLocalState =
1340
+ !isConversationControlled ||
1341
+ !notifyChange ||
1342
+ controlledConversationId === conversationId;
1343
+
1344
+ if (shouldSyncLocalState) {
1345
+ setCurrentConversationId(conversationId);
1346
+ }
1347
+
1348
+ if (notifyChange && conversationId && onConversationChange) {
1349
+ const shouldNotifyParent =
1350
+ !isConversationControlled || controlledConversationId !== conversationId;
1351
+ if (shouldNotifyParent) {
1352
+ onConversationChange(conversationId);
1353
+ }
1354
+ }
1355
+ },
1356
+ [controlledConversationId, isConversationControlled, onConversationChange]
1357
+ );
1358
+
1359
+ const createDraftConversation = useCallback(
1360
+ (agentId: string, conversationInitialPrompt?: string): string => {
939
1361
  const tempId = `new-${Date.now()}`;
940
-
1362
+
941
1363
  setActiveConversations(prev => {
942
1364
  const next = new Map(prev);
943
1365
  next.set(tempId, {
944
1366
  conversationId: tempId,
945
1367
  stableKey: tempId,
946
- agentId: targetAgent,
1368
+ agentId,
947
1369
  history: {},
1370
+ transcriptLoaded: true,
948
1371
  isLoading: false,
949
1372
  title: 'New conversation',
950
- conversationInitialPrompt: prompt,
1373
+ conversationInitialPrompt,
951
1374
  });
952
1375
  return next;
953
1376
  });
954
-
955
- setCurrentConversationId(tempId);
956
- if (onConversationChange) {
957
- onConversationChange(tempId);
958
- }
1377
+
1378
+ return tempId;
1379
+ },
1380
+ []
1381
+ );
1382
+
1383
+ // Imperative handle for programmatic control
1384
+ React.useImperativeHandle(ref, () => ({
1385
+ startNewConversation: (prompt: string, agent?: string) => {
1386
+ const targetAgent = agent || currentAgentId;
1387
+ const tempId = createDraftConversation(targetAgent, prompt);
1388
+ commitConversationSelection(tempId, true);
959
1389
  }
960
- }), [currentAgentId, onConversationChange]);
1390
+ }), [commitConversationSelection, createDraftConversation, currentAgentId]);
961
1391
 
962
1392
  // Context change notification state
963
1393
  const [showContextNotification, setShowContextNotification] = useState(false);
@@ -1162,15 +1592,25 @@ const AIAgentPanel = React.forwardRef<AIAgentPanelHandle, AIAgentPanelProps>(({
1162
1592
 
1163
1593
  // Helper to strip context/template data from a prompt - keeps only the user's actual message
1164
1594
  // Load a specific conversation's transcript (or switch to it if already active)
1165
- const loadConversationTranscript = useCallback(async (conversationId: string, agentIdForConversation?: string, title?: string) => {
1595
+ const loadConversationTranscript = useCallback(async (
1596
+ conversationId: string,
1597
+ agentIdForConversation?: string,
1598
+ title?: string,
1599
+ notifyConversationChange: boolean = true,
1600
+ ) => {
1166
1601
  // Check if conversation is already in activeConversations (use ref to avoid dependency)
1167
1602
  const existingActive = activeConversationsRef.current.get(conversationId);
1168
- if (existingActive) {
1603
+ if (
1604
+ existingActive &&
1605
+ (existingActive.transcriptLoaded || Object.keys(existingActive.history || {}).length > 0)
1606
+ ) {
1169
1607
  // Just switch to it, no API call needed
1170
- setCurrentConversationId(conversationId);
1171
- if (onConversationChange) {
1172
- onConversationChange(conversationId);
1173
- }
1608
+ commitConversationSelection(conversationId, notifyConversationChange);
1609
+ return;
1610
+ }
1611
+
1612
+ if (loadingTranscriptIdsRef.current.has(conversationId)) {
1613
+ commitConversationSelection(conversationId, notifyConversationChange);
1174
1614
  return;
1175
1615
  }
1176
1616
 
@@ -1184,6 +1624,9 @@ const AIAgentPanel = React.forwardRef<AIAgentPanelHandle, AIAgentPanelProps>(({
1184
1624
  return;
1185
1625
  }
1186
1626
 
1627
+ loadingTranscriptIdsRef.current.add(conversationId);
1628
+ setConversationsError(null);
1629
+
1187
1630
  // Set loading state
1188
1631
  setLoadingConversationId(conversationId);
1189
1632
 
@@ -1200,83 +1643,41 @@ const AIAgentPanel = React.forwardRef<AIAgentPanelHandle, AIAgentPanelProps>(({
1200
1643
  });
1201
1644
 
1202
1645
  if (!response.ok) {
1646
+ if (response.status === 404) {
1647
+ // Allow externally supplied conversation/session IDs that have not yet produced calls.
1648
+ const conversationTitle =
1649
+ title || conversationFirstPrompts[conversationId] || 'New conversation';
1650
+ setActiveConversations(prev => {
1651
+ const next = new Map(prev);
1652
+ next.set(conversationId, {
1653
+ conversationId,
1654
+ stableKey: conversationId,
1655
+ agentId: agentIdToUse,
1656
+ history: {},
1657
+ transcriptLoaded: true,
1658
+ isLoading: false,
1659
+ title: conversationTitle,
1660
+ });
1661
+ return next;
1662
+ });
1663
+ commitConversationSelection(conversationId, notifyConversationChange);
1664
+ return;
1665
+ }
1203
1666
  throw new Error(`Failed to load conversation (${response.status})`);
1204
1667
  }
1205
1668
 
1206
1669
  const payload = await response.json();
1207
1670
  console.log('loadConversationTranscript - API response:', payload);
1208
-
1209
- // The /calls endpoint returns an array of calls
1210
- // Use the messages property from the LAST call, which contains the full conversation history
1211
- const history: Record<string, { content: string; callId: string }> = {};
1212
- let firstPrompt: string | null = null;
1213
-
1214
- if (Array.isArray(payload) && payload.length > 0) {
1215
- // Get the last call - it contains the full conversation history in the messages property
1216
- const lastCall = payload[payload.length - 1];
1217
- const callId = lastCall.id || '';
1218
- const timestamp = lastCall.createdAt || lastCall.created_at || new Date().toISOString();
1219
-
1220
- if (lastCall.messages) {
1221
- try {
1222
- // Parse the messages JSON string
1223
- const messages = JSON.parse(lastCall.messages) as { role: string; content: string }[];
1224
- console.log('loadConversationTranscript - parsed messages:', messages);
1225
-
1226
- // Filter out system messages and special __system__: user messages
1227
- const relevantMessages = messages.filter(msg =>
1228
- msg.role !== 'system' &&
1229
- !(msg.role === 'user' && msg.content.startsWith('__system__:'))
1230
- );
1231
- console.log('loadConversationTranscript - filtered messages:', relevantMessages);
1232
-
1233
- // Pair user/assistant messages to build history
1234
- // Note: The messages array contains the INPUT to the LLM (history + new prompt)
1235
- // The LAST user message needs to be paired with the call's response field
1236
- for (let i = 0; i < relevantMessages.length; i++) {
1237
- const msg = relevantMessages[i];
1238
-
1239
- if (!msg) continue;
1240
-
1241
- if (msg.role === 'user') {
1242
- // Extract first prompt for conversation title
1243
- if (!firstPrompt) {
1244
- firstPrompt = msg.content.length > 60 ? msg.content.slice(0, 57) + '...' : msg.content;
1245
- }
1246
-
1247
- // Look for the next assistant message in the array
1248
- const nextMsg = relevantMessages[i + 1];
1249
-
1250
- if (nextMsg && nextMsg.role === 'assistant') {
1251
- // This is a historical turn - pair the user message with the assistant message from the array
1252
- const historyKey = `${timestamp}:${msg.content}`;
1253
- history[historyKey] = {
1254
- content: nextMsg.content,
1255
- callId: callId,
1256
- };
1257
-
1258
- // Skip the assistant message we just processed
1259
- i++;
1260
- } else {
1261
- // This is the LAST user message - pair it with the response field
1262
- if (lastCall.response) {
1263
- const historyKey = `${timestamp}:${msg.content}`;
1264
- history[historyKey] = {
1265
- content: lastCall.response,
1266
- callId: callId,
1267
- };
1268
- }
1269
- }
1270
- }
1271
- }
1272
- } catch (err) {
1273
- console.error('loadConversationTranscript - failed to parse messages property:', err);
1274
- }
1275
- }
1276
- }
1277
1671
 
1278
- console.log('loadConversationTranscript - created', Object.keys(history).length, 'history entries');
1279
- console.log('loadConversationTranscript - parsed history:', history);
1672
+ const calls = normalizeCallsPayload(payload);
1673
+ const transcriptTurns = buildTranscriptTurnsFromCalls(calls);
1674
+ const history = buildHistoryFromTranscriptTurns(transcriptTurns);
1675
+ const firstPrompt = transcriptTurns.length > 0
1676
+ ? truncatePromptForTitle(transcriptTurns[0]!.prompt)
1677
+ : null;
1678
+
1679
+ console.log('loadConversationTranscript - parsed calls:', calls.length);
1680
+ console.log('loadConversationTranscript - created history entries:', Object.keys(history).length);
1280
1681
 
1281
1682
  // Update first prompt in state if we found one
1282
1683
  if (firstPrompt) {
@@ -1295,28 +1696,106 @@ const AIAgentPanel = React.forwardRef<AIAgentPanelHandle, AIAgentPanelProps>(({
1295
1696
  stableKey: conversationId, // Use real ID as stable key when loading from API
1296
1697
  agentId: agentIdToUse,
1297
1698
  history,
1699
+ transcriptLoaded: true,
1298
1700
  isLoading: false,
1299
1701
  title: conversationTitle,
1300
1702
  });
1301
1703
  return next;
1302
1704
  });
1303
1705
 
1304
- setCurrentConversationId(conversationId);
1305
-
1306
- if (onConversationChange) {
1307
- onConversationChange(conversationId);
1308
- }
1309
-
1310
- // Clear loading state on success
1311
- setLoadingConversationId(null);
1706
+ commitConversationSelection(conversationId, notifyConversationChange);
1312
1707
  } catch (error: any) {
1313
1708
  console.error('Failed to load conversation:', error);
1314
1709
  setConversationsError(error.message || 'Failed to load conversation');
1315
-
1316
- // Clear loading state on error
1317
- setLoadingConversationId(null);
1710
+ } finally {
1711
+ loadingTranscriptIdsRef.current.delete(conversationId);
1712
+ setLoadingConversationId(prev => (prev === conversationId ? null : prev));
1713
+ }
1714
+ }, [apiKey, commitConversationSelection, conversationFirstPrompts, currentAgentId, getAgent]);
1715
+
1716
+ // Controlled conversation mode: keep panel selection in sync with external conversation prop.
1717
+ useEffect(() => {
1718
+ if (!isConversationControlled) return;
1719
+
1720
+ const targetConversationId = controlledConversationId;
1721
+ if (!targetConversationId) {
1722
+ if (currentConversationIdRef.current !== null) {
1723
+ setCurrentConversationId(null);
1724
+ }
1725
+ return;
1726
+ }
1727
+
1728
+ if (targetConversationId.startsWith('new-')) {
1729
+ setActiveConversations(prev => {
1730
+ if (prev.has(targetConversationId)) {
1731
+ return prev;
1732
+ }
1733
+ const next = new Map(prev);
1734
+ next.set(targetConversationId, {
1735
+ conversationId: targetConversationId,
1736
+ stableKey: targetConversationId,
1737
+ agentId: currentAgentId,
1738
+ history: {},
1739
+ transcriptLoaded: true,
1740
+ isLoading: false,
1741
+ title: 'New conversation',
1742
+ });
1743
+ return next;
1744
+ });
1745
+
1746
+ if (currentConversationIdRef.current !== targetConversationId) {
1747
+ setCurrentConversationId(targetConversationId);
1748
+ }
1749
+ return;
1318
1750
  }
1319
- }, [apiKey, currentAgentId, getAgent, onConversationChange]);
1751
+
1752
+ if (loadingConversationId === targetConversationId) {
1753
+ return;
1754
+ }
1755
+
1756
+ const existingActive = activeConversationsRef.current.get(targetConversationId);
1757
+ if (
1758
+ existingActive &&
1759
+ existingActive.transcriptLoaded &&
1760
+ currentConversationIdRef.current === targetConversationId
1761
+ ) {
1762
+ return;
1763
+ }
1764
+
1765
+ const apiConversation = apiConversations.find(
1766
+ conv => conv.conversationId === targetConversationId
1767
+ );
1768
+ const targetAgentId =
1769
+ apiConversation?.agentId || existingActive?.agentId || currentAgentId;
1770
+
1771
+ if (!targetAgentId) {
1772
+ return;
1773
+ }
1774
+
1775
+ if (targetAgentId !== currentAgentId) {
1776
+ setCurrentAgentId(targetAgentId);
1777
+ }
1778
+
1779
+ const targetTitle =
1780
+ conversationFirstPrompts[targetConversationId] ||
1781
+ apiConversation?.title ||
1782
+ existingActive?.title;
1783
+
1784
+ void loadConversationTranscript(
1785
+ targetConversationId,
1786
+ targetAgentId,
1787
+ targetTitle,
1788
+ false,
1789
+ );
1790
+ }, [
1791
+ apiConversations,
1792
+ controlledConversationId,
1793
+ conversationFirstPrompts,
1794
+ currentAgentId,
1795
+ isConversationControlled,
1796
+ loadConversationTranscript,
1797
+ loadingConversationId,
1798
+ ]);
1320
1799
 
1321
1800
 
1322
1801
  // Refresh conversations callback
@@ -1345,35 +1824,21 @@ const AIAgentPanel = React.forwardRef<AIAgentPanelHandle, AIAgentPanelProps>(({
1345
1824
 
1346
1825
  // Start new conversation
1347
1826
  const handleNewConversation = useCallback(() => {
1348
- // Generate a temporary ID for the new conversation
1349
- const tempId = `new-${Date.now()}`;
1350
-
1351
- // Add to active conversations with empty history
1352
- setActiveConversations(prev => {
1353
- const next = new Map(prev);
1354
- next.set(tempId, {
1355
- conversationId: tempId,
1356
- stableKey: tempId, // Stable key never changes even when conversationId updates
1357
- agentId: currentAgentId,
1358
- history: {},
1359
- isLoading: false,
1360
- title: 'New conversation',
1361
- });
1362
- return next;
1363
- });
1364
-
1365
- setCurrentConversationId(tempId);
1366
- }, [currentAgentId]);
1827
+ const tempId = createDraftConversation(currentAgentId);
1828
+ commitConversationSelection(tempId, true);
1829
+ }, [commitConversationSelection, createDraftConversation, currentAgentId]);
1367
1830
 
1368
1831
  // Auto-start a new conversation when none exist and agent is ready
1369
1832
  useEffect(() => {
1833
+ if (isConversationControlled) return;
1834
+
1370
1835
  const agentProfile = getAgent(currentAgentId);
1371
1836
  const isAgentReady = agentProfile?.metadata?.projectId;
1372
1837
 
1373
1838
  if (isAgentReady && !agentsLoading && activeConversations.size === 0) {
1374
1839
  handleNewConversation();
1375
1840
  }
1376
- }, [currentAgentId, agentsLoading, activeConversations.size, getAgent, handleNewConversation]);
1841
+ }, [currentAgentId, agentsLoading, activeConversations.size, getAgent, handleNewConversation, isConversationControlled]);
1377
1842
 
1378
1843
  // Close an active conversation
1379
1844
  const handleCloseConversation = useCallback((conversationId: string, e?: React.MouseEvent) => {
@@ -1391,9 +1856,13 @@ const AIAgentPanel = React.forwardRef<AIAgentPanelHandle, AIAgentPanelProps>(({
1391
1856
  if (currentConversationIdRef.current === conversationId) {
1392
1857
  const remaining = Array.from(activeConversationsRef.current.keys()).filter(id => id !== conversationId);
1393
1858
  const nextId = remaining.length > 0 ? remaining[0] ?? null : null;
1394
- setCurrentConversationId(nextId);
1859
+ if (nextId) {
1860
+ commitConversationSelection(nextId, true);
1861
+ } else if (!isConversationControlled) {
1862
+ setCurrentConversationId(null);
1863
+ }
1395
1864
  }
1396
- }, []);
1865
+ }, [commitConversationSelection, isConversationControlled]);
1397
1866
 
1398
1867
  // Select conversation
1399
1868
  const handleSelectConversation = useCallback((conversationId: string) => {
@@ -1830,6 +2299,7 @@ const AIAgentPanel = React.forwardRef<AIAgentPanelHandle, AIAgentPanelProps>(({
1830
2299
  next.set(targetConversationId, {
1831
2300
  ...existing,
1832
2301
  history,
2302
+ transcriptLoaded: true,
1833
2303
  title,
1834
2304
  });
1835
2305
  return next;
@@ -1941,18 +2411,21 @@ const AIAgentPanel = React.forwardRef<AIAgentPanelHandle, AIAgentPanelProps>(({
1941
2411
 
1942
2412
  // Update currentConversationId if it was the temp one
1943
2413
  if (currentConversationIdRef.current === tempId) {
1944
- setCurrentConversationId(realId);
1945
- if (onConversationChange) {
1946
- onConversationChange(realId);
1947
- }
2414
+ commitConversationSelection(realId, true);
1948
2415
  }
1949
- }, [onConversationChange]);
2416
+ }, [commitConversationSelection]);
1950
2417
 
1951
2418
  // Toggle collapse - only if collapsible is enabled
1952
2419
  const toggleCollapse = useCallback(() => {
1953
2420
  if (!collapsible) return;
1954
2421
 
1955
2422
  const newValue = !isCollapsed;
2423
+
2424
+ // Expanding the main panel should not implicitly reopen conversation history.
2425
+ if (!newValue) {
2426
+ setIsHistoryCollapsed(true);
2427
+ setShowSearch(false);
2428
+ }
1956
2429
 
1957
2430
  // Update internal state if uncontrolled
1958
2431
  if (!isControlled) {
@@ -1965,11 +2438,7 @@ const AIAgentPanel = React.forwardRef<AIAgentPanelHandle, AIAgentPanelProps>(({
1965
2438
 
1966
2439
  // Toggle history collapse
1967
2440
  const toggleHistoryCollapse = useCallback(() => {
1968
- setIsHistoryCollapsed((prev) => {
1969
- const next = !prev;
1970
- localStorage.setItem('ai-agent-panel-history-collapsed', String(next));
1971
- return next;
1972
- });
2441
+ setIsHistoryCollapsed((prev) => !prev);
1973
2442
  }, []);
1974
2443
 
1975
2444
  // Panel classes
@@ -2055,38 +2524,23 @@ const AIAgentPanel = React.forwardRef<AIAgentPanelHandle, AIAgentPanelProps>(({
2055
2524
  variant={agent.id === currentAgentId ? 'secondary' : 'ghost'}
2056
2525
  size="icon"
2057
2526
  title={agent.name}
2058
- onClick={() => {
2059
- // Expand panel in controlled or uncontrolled mode
2060
- if (!isControlled) {
2061
- setUncontrolledIsCollapsed(false);
2062
- }
2063
- onCollapsedChange?.(false);
2064
- if (hasActiveConversation && activeConvForAgent) {
2065
- // Switch to the existing conversation
2066
- setCurrentConversationId(activeConvForAgent.conversationId);
2067
- setCurrentAgentId(agent.id);
2068
- if (onConversationChange) {
2069
- onConversationChange(activeConvForAgent.conversationId);
2527
+ onClick={() => {
2528
+ // Expand panel in controlled or uncontrolled mode
2529
+ if (!isControlled) {
2530
+ setUncontrolledIsCollapsed(false);
2070
2531
  }
2071
- } else {
2072
- // Start a new conversation with this agent
2073
- const tempId = `new-${Date.now()}`;
2074
- setActiveConversations(prev => {
2075
- const next = new Map(prev);
2076
- next.set(tempId, {
2077
- conversationId: tempId,
2078
- stableKey: tempId, // Stable key never changes
2079
- agentId: agent.id,
2080
- history: {},
2081
- isLoading: false,
2082
- title: 'New conversation',
2083
- });
2084
- return next;
2085
- });
2086
- setCurrentConversationId(tempId);
2087
- setCurrentAgentId(agent.id);
2088
- }
2089
- }}
2532
+ onCollapsedChange?.(false);
2533
+ if (hasActiveConversation && activeConvForAgent) {
2534
+ // Switch to the existing conversation
2535
+ setCurrentAgentId(agent.id);
2536
+ commitConversationSelection(activeConvForAgent.conversationId, true);
2537
+ } else {
2538
+ // Start a new conversation with this agent
2539
+ const tempId = createDraftConversation(agent.id);
2540
+ commitConversationSelection(tempId, true);
2541
+ setCurrentAgentId(agent.id);
2542
+ }
2543
+ }}
2090
2544
  className={`ai-agent-panel__agent-icon ${hasActiveConversation ? 'ai-agent-panel__agent-icon--active' : ''}`}
2091
2545
  >
2092
2546
  {agent.avatarUrl ? (
@@ -2184,27 +2638,12 @@ const AIAgentPanel = React.forwardRef<AIAgentPanelHandle, AIAgentPanelProps>(({
2184
2638
  onClick={() => {
2185
2639
  if (hasActiveConversation && activeConvForAgent) {
2186
2640
  // Switch to the existing conversation
2187
- setCurrentConversationId(activeConvForAgent.conversationId);
2188
2641
  setCurrentAgentId(agent.id);
2189
- if (onConversationChange) {
2190
- onConversationChange(activeConvForAgent.conversationId);
2191
- }
2642
+ commitConversationSelection(activeConvForAgent.conversationId, true);
2192
2643
  } else {
2193
2644
  // Start a new conversation with this agent
2194
- const tempId = `new-${Date.now()}`;
2195
- setActiveConversations(prev => {
2196
- const next = new Map(prev);
2197
- next.set(tempId, {
2198
- conversationId: tempId,
2199
- stableKey: tempId, // Stable key never changes
2200
- agentId: agent.id,
2201
- history: {},
2202
- isLoading: false,
2203
- title: 'New conversation',
2204
- });
2205
- return next;
2206
- });
2207
- setCurrentConversationId(tempId);
2645
+ const tempId = createDraftConversation(agent.id);
2646
+ commitConversationSelection(tempId, true);
2208
2647
  setCurrentAgentId(agent.id);
2209
2648
  }
2210
2649
  }}
@@ -2292,10 +2731,7 @@ const AIAgentPanel = React.forwardRef<AIAgentPanelHandle, AIAgentPanelProps>(({
2292
2731
  : ''
2293
2732
  }`}
2294
2733
  onClick={() => {
2295
- setCurrentConversationId(activeConv.conversationId);
2296
- if (onConversationChange) {
2297
- onConversationChange(activeConv.conversationId);
2298
- }
2734
+ commitConversationSelection(activeConv.conversationId, true);
2299
2735
  }}
2300
2736
  >
2301
2737
  <div className="ai-agent-panel__conversation-content">
@@ -2353,12 +2789,12 @@ const AIAgentPanel = React.forwardRef<AIAgentPanelHandle, AIAgentPanelProps>(({
2353
2789
  {expandedSections[group.label] ? '▼' : '▶'}
2354
2790
  </span>
2355
2791
  </div>
2356
- {expandedSections[group.label] && group.conversations.length > 0 && group.conversations.map((conv) => {
2792
+ {expandedSections[group.label] && group.conversations.length > 0 && group.conversations.map((conv, convIndex) => {
2357
2793
  // Check if this conversation is already active
2358
2794
  const isActive = activeConversations.has(conv.conversationId);
2359
2795
  return (
2360
2796
  <div
2361
- key={conv.conversationId}
2797
+ key={`${group.label}-${conv.conversationId}-${convIndex}`}
2362
2798
  className={`ai-agent-panel__conversation ${
2363
2799
  currentConversationId === conv.conversationId
2364
2800
  ? 'ai-agent-panel__conversation--current'
@@ -2411,7 +2847,7 @@ const AIAgentPanel = React.forwardRef<AIAgentPanelHandle, AIAgentPanelProps>(({
2411
2847
  {/* Chat panels - one per active conversation, shown/hidden via CSS */}
2412
2848
  {activeConversationsList.map((activeConv) => (
2413
2849
  <ChatPanelWrapper
2414
- key={`${activeConv.stableKey}-${activeConv.agentId}`}
2850
+ key={activeConv.stableKey}
2415
2851
  activeConv={activeConv}
2416
2852
  currentConversationId={currentConversationId}
2417
2853
  getAgent={getAgent}
@@ -2442,6 +2878,7 @@ const AIAgentPanel = React.forwardRef<AIAgentPanelHandle, AIAgentPanelProps>(({
2442
2878
  disabledSectionIds={currentDisabledSections}
2443
2879
  onToggleSection={handleContextSectionToggle}
2444
2880
  onConversationCreated={handleConversationCreated}
2881
+ onBeforeSend={onBeforeSend}
2445
2882
  conversationInitialPrompt={activeConv.conversationInitialPrompt}
2446
2883
  cssUrl={cssUrl}
2447
2884
  markdownClass={markdownClass}
@@ -2455,10 +2892,14 @@ const AIAgentPanel = React.forwardRef<AIAgentPanelHandle, AIAgentPanelProps>(({
2455
2892
  showCallToAction={showCallToAction}
2456
2893
  callToActionButtonText={callToActionButtonText}
2457
2894
  callToActionEmailAddress={callToActionEmailAddress}
2458
- callToActionEmailSubject={callToActionEmailSubject}
2459
- customerEmailCaptureMode={customerEmailCaptureMode}
2460
- customerEmailCapturePlaceholder={customerEmailCapturePlaceholder}
2461
- />
2895
+ callToActionEmailSubject={callToActionEmailSubject}
2896
+ customerEmailCaptureMode={customerEmailCaptureMode}
2897
+ customerEmailCapturePlaceholder={customerEmailCapturePlaceholder}
2898
+ resolveMcpAuthHeaders={resolveMcpAuthHeaders}
2899
+ localToolExecutors={localToolExecutors}
2900
+ traceContextMode={traceContextMode}
2901
+ autoApproveTools={autoApproveTools}
2902
+ />
2462
2903
  ))}
2463
2904
 
2464
2905
  {/* Conversation loading overlay */}
@@ -2520,4 +2961,3 @@ const AIAgentPanel = React.forwardRef<AIAgentPanelHandle, AIAgentPanelProps>(({
2520
2961
  AIAgentPanel.displayName = 'AIAgentPanel';
2521
2962
 
2522
2963
  export default AIAgentPanel;
2523
-