@hef2024/llmasaservice-ui 0.25.2 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -52,6 +52,41 @@ export interface BeforeSendPayload {
52
52
  messages: { role: string; content: string }[];
53
53
  }
54
54
 
55
+ export type CompactionAction = 'none' | 'compacted' | 'fallback_window';
56
+
57
+ export interface CompactionTokenUsage {
58
+ projectedBefore: number;
59
+ projectedAfter: number;
60
+ warnThreshold: number;
61
+ compactThreshold: number;
62
+ targetThreshold: number;
63
+ }
64
+
65
+ export interface CompactContextInput {
66
+ prompt: string;
67
+ conversationId: string | null;
68
+ agentId?: string | null;
69
+ service?: string | null;
70
+ messages: { role: string; content: string }[];
71
+ data: { key: string; data: string }[];
72
+ maxContextTokens: number;
73
+ totalContextTokens: number;
74
+ warnRatio: number;
75
+ compactRatio: number;
76
+ targetRatio: number;
77
+ preserveTurns: number;
78
+ projectedTokens: CompactionTokenUsage;
79
+ }
80
+
81
+ export interface CompactContextResult {
82
+ action: CompactionAction;
83
+ prompt: string;
84
+ messages: { role: string; content: string }[];
85
+ warning?: string | null;
86
+ tokenUsage?: Partial<CompactionTokenUsage> | null;
87
+ metadata?: Record<string, unknown> | null;
88
+ }
89
+
55
90
  export type TraceContextMode = 'standard' | 'full';
56
91
 
57
92
  export interface LocalToolExecutorContext {
@@ -169,6 +204,8 @@ export interface AIChatPanelProps {
169
204
 
170
205
  // Callback invoked before each send() call
171
206
  onBeforeSend?: (payload: BeforeSendPayload) => Promise<void> | void;
207
+ compactContext?: (input: CompactContextInput) => Promise<CompactContextResult> | CompactContextResult;
208
+ compactionPreserveTurns?: number;
172
209
 
173
210
  // UI Customization Props (from ChatPanel)
174
211
  cssUrl?: string;
@@ -193,6 +230,7 @@ export interface AIChatPanelProps {
193
230
  customerEmailCaptureMode?: "HIDE" | "OPTIONAL" | "REQUIRED";
194
231
  customerEmailCapturePlaceholder?: string;
195
232
  toolStatusLabelFormatter?: ToolStatusLabelFormatter;
233
+ composerAgentModeControl?: ComposerAgentModeControl;
196
234
  }
197
235
 
198
236
  /**
@@ -207,8 +245,122 @@ export interface ContextSection {
207
245
  rawData?: string;
208
246
  }
209
247
 
248
+ export interface ComposerAgentModeControl {
249
+ active?: boolean;
250
+ disabled?: boolean;
251
+ label?: string;
252
+ title?: string;
253
+ onActivate?: () => void;
254
+ }
255
+
210
256
  export type ContextDataFormat = 'json' | 'toon' | 'markdown' | 'text';
211
257
 
258
+ type UserInputOption = {
259
+ value: string;
260
+ label: string;
261
+ description?: string;
262
+ };
263
+
264
+ type UserInputQuestion = {
265
+ id: string;
266
+ header: string;
267
+ question: string;
268
+ required: boolean;
269
+ allowWriteIn: boolean;
270
+ writeInPlaceholder: string;
271
+ options: UserInputOption[];
272
+ };
273
+
274
+ type UserInputRequest = {
275
+ requestId: string;
276
+ title: string;
277
+ instructions: string;
278
+ submitLabel: string;
279
+ cancelLabel: string;
280
+ allowCancel: boolean;
281
+ questions: UserInputQuestion[];
282
+ };
283
+
284
+ type UserInputAnswer = {
285
+ id: string;
286
+ header: string;
287
+ question: string;
288
+ selectedOptionValue: string | null;
289
+ selectedOptionLabel: string | null;
290
+ writeIn: string | null;
291
+ answer: string;
292
+ };
293
+
294
+ type UserInputCompletionPayload = {
295
+ status: 'submitted' | 'cancelled';
296
+ requestId: string;
297
+ answers?: UserInputAnswer[];
298
+ cancelledBy?: 'user' | 'stop' | 'reset' | 'unmount';
299
+ };
300
+
301
+ const USER_INPUT_TOOL_NAMES = new Set(['request_user_input', 'ff_request_user_input']);
302
+ const MAX_USER_INPUT_QUESTIONS = 5;
303
+ const MAX_USER_INPUT_OPTIONS = 8;
304
+ const MAX_USER_INPUT_WRITE_IN_CHARS = 1000;
305
+ const DEFAULT_USER_INPUT_TITLE = 'Need your input';
306
+ const DEFAULT_USER_INPUT_INSTRUCTIONS = 'Answer these quick questions to continue.';
307
+
308
+ const DEFAULT_CONTEXT_WARN_RATIO = 0.7;
309
+ const DEFAULT_CONTEXT_COMPACT_RATIO = 0.9;
310
+ const DEFAULT_CONTEXT_TARGET_RATIO = 0.65;
311
+ const DEFAULT_COMPACTION_PRESERVE_TURNS = 8;
312
+
313
+ function toBoundedRatio(value: number, fallback: number): number {
314
+ if (!Number.isFinite(value)) return fallback;
315
+ if (value <= 0) return fallback;
316
+ if (value >= 1) return 1;
317
+ return value;
318
+ }
319
+
320
+ function estimateTokensFromText(text: string): number {
321
+ const normalized = typeof text === 'string' ? text : '';
322
+ if (!normalized) return 0;
323
+ return Math.ceil(normalized.length / 4);
324
+ }
325
+
326
+ function estimateTokensFromMessages(messages: Array<{ role: string; content: string }>): number {
327
+ if (!Array.isArray(messages) || messages.length === 0) return 0;
328
+ return messages.reduce((sum, message) => {
329
+ const role = typeof message?.role === 'string' ? message.role : '';
330
+ const content = typeof message?.content === 'string' ? message.content : '';
331
+ return sum + estimateTokensFromText(role) + estimateTokensFromText(content);
332
+ }, 0);
333
+ }
334
+
335
+ function estimateTokensFromData(data: Array<{ key: string; data: string }>): number {
336
+ if (!Array.isArray(data) || data.length === 0) return 0;
337
+ return data.reduce((sum, entry) => {
338
+ const key = typeof entry?.key === 'string' ? entry.key : '';
339
+ const value = typeof entry?.data === 'string' ? entry.data : '';
340
+ return sum + estimateTokensFromText(key) + estimateTokensFromText(value);
341
+ }, 0);
342
+ }
343
+
344
+ function formatTokenCount(tokens: number): string {
345
+ const safeTokens = Number.isFinite(tokens) ? Math.max(0, Math.floor(tokens)) : 0;
346
+ if (safeTokens >= 1000) {
347
+ return `${(safeTokens / 1000).toFixed(1)}K`;
348
+ }
349
+ return safeTokens.toString();
350
+ }
351
+
352
+ function buildFallbackWindowMessages(
353
+ messages: Array<{ role: string; content: string }>,
354
+ preserveTurns: number,
355
+ ): Array<{ role: string; content: string }> {
356
+ if (!Array.isArray(messages) || messages.length === 0) return [];
357
+ const keepMessageCount = Math.max(2, Math.floor(preserveTurns) * 2);
358
+ if (messages.length <= keepMessageCount) {
359
+ return messages;
360
+ }
361
+ return messages.slice(messages.length - keepMessageCount);
362
+ }
363
+
212
364
  type ThinkingBlock = ResponseArtifactBlock;
213
365
 
214
366
  interface ToolRequestMatch {
@@ -342,6 +494,158 @@ const shouldPreserveBoundaryDroppedStreamText = (
342
494
  const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
343
495
  !!value && typeof value === 'object' && !Array.isArray(value);
344
496
 
497
+ const toTrimmedString = (value: unknown): string =>
498
+ typeof value === 'string' ? value.trim() : '';
499
+
500
+ const toBooleanWithDefault = (value: unknown, fallback: boolean): boolean =>
501
+ typeof value === 'boolean' ? value : fallback;
502
+
503
+ const parseMaybeJsonString = (value: unknown): unknown => {
504
+ if (typeof value !== 'string') return value;
505
+ const trimmed = value.trim();
506
+ if (!trimmed) return value;
507
+ if (!(trimmed.startsWith('{') || trimmed.startsWith('['))) return value;
508
+ try {
509
+ return JSON.parse(trimmed);
510
+ } catch (_error) {
511
+ return value;
512
+ }
513
+ };
514
+
515
+ const toArrayFromUnknown = (value: unknown): unknown[] => {
516
+ const parsed = parseMaybeJsonString(value);
517
+ return Array.isArray(parsed) ? parsed : [];
518
+ };
519
+
520
+ const toSlugValue = (value: string, fallback: string): string => {
521
+ const normalized = value
522
+ .toLowerCase()
523
+ .replace(/[^a-z0-9]+/g, '-')
524
+ .replace(/^-+|-+$/g, '');
525
+ return normalized || fallback;
526
+ };
527
+
528
+ const normalizeUserInputOptionEntry = (
529
+ value: unknown,
530
+ optionIndex: number,
531
+ questionId: string,
532
+ ): UserInputOption | null => {
533
+ const normalizedValue = parseMaybeJsonString(value);
534
+ if (typeof normalizedValue === 'string') {
535
+ const label = normalizedValue.trim();
536
+ if (!label) return null;
537
+ return {
538
+ label,
539
+ value: toSlugValue(label, `${questionId}-option-${optionIndex + 1}`),
540
+ description: '',
541
+ };
542
+ }
543
+ if (!isObjectRecord(normalizedValue)) return null;
544
+ const label =
545
+ toTrimmedString(normalizedValue.label) ||
546
+ toTrimmedString(normalizedValue.title) ||
547
+ toTrimmedString(normalizedValue.value);
548
+ if (!label) return null;
549
+ const explicitValue = toTrimmedString(normalizedValue.value);
550
+ return {
551
+ label,
552
+ value: explicitValue || toSlugValue(label, `${questionId}-option-${optionIndex + 1}`),
553
+ description: toTrimmedString(normalizedValue.description),
554
+ };
555
+ };
556
+
557
+ const normalizeUserInputQuestionEntry = (
558
+ value: unknown,
559
+ questionIndex: number,
560
+ ): UserInputQuestion | null => {
561
+ const normalizedValue = parseMaybeJsonString(value);
562
+ if (!isObjectRecord(normalizedValue)) return null;
563
+ const fallbackId = `question_${questionIndex + 1}`;
564
+ const id = toSlugValue(
565
+ toTrimmedString(normalizedValue.id) || toTrimmedString(normalizedValue.key) || fallbackId,
566
+ fallbackId,
567
+ );
568
+ const header = toTrimmedString(normalizedValue.header) || `Question ${questionIndex + 1}`;
569
+ const question =
570
+ toTrimmedString(normalizedValue.question) ||
571
+ toTrimmedString(normalizedValue.prompt) ||
572
+ toTrimmedString(normalizedValue.text);
573
+ if (!question) return null;
574
+
575
+ const rawOptions = toArrayFromUnknown(normalizedValue.options);
576
+ const normalizedOptions = rawOptions
577
+ .map((entry, optionIndex) => normalizeUserInputOptionEntry(entry, optionIndex, id))
578
+ .filter((entry): entry is UserInputOption => Boolean(entry))
579
+ .slice(0, MAX_USER_INPUT_OPTIONS);
580
+ if (normalizedOptions.length === 0) {
581
+ return null;
582
+ }
583
+
584
+ const parsedWriteInConfig = parseMaybeJsonString(normalizedValue.writeIn);
585
+ const writeInConfig = isObjectRecord(parsedWriteInConfig) ? parsedWriteInConfig : null;
586
+ const allowWriteIn =
587
+ toBooleanWithDefault(normalizedValue.allowWriteIn, false) ||
588
+ toBooleanWithDefault(writeInConfig?.enabled, false);
589
+ const writeInPlaceholder =
590
+ toTrimmedString(normalizedValue.writeInPlaceholder) ||
591
+ toTrimmedString(writeInConfig?.placeholder) ||
592
+ 'Add details';
593
+
594
+ return {
595
+ id,
596
+ header,
597
+ question,
598
+ required: toBooleanWithDefault(normalizedValue.required, true),
599
+ allowWriteIn,
600
+ writeInPlaceholder,
601
+ options: normalizedOptions,
602
+ };
603
+ };
604
+
605
+ const normalizeUserInputRequest = (
606
+ args: Record<string, unknown>,
607
+ callId: string,
608
+ ): UserInputRequest => {
609
+ const rawQuestions = toArrayFromUnknown(args.questions);
610
+ const fallbackQuestion =
611
+ toTrimmedString(args.question) || toTrimmedString(args.prompt) || toTrimmedString(args.text);
612
+ const fallbackOptions = toArrayFromUnknown(args.options);
613
+ const baseQuestions =
614
+ rawQuestions.length > 0
615
+ ? rawQuestions
616
+ : fallbackQuestion
617
+ ? [
618
+ {
619
+ id: 'question_1',
620
+ header: 'Question 1',
621
+ question: fallbackQuestion,
622
+ options: fallbackOptions,
623
+ allowWriteIn: toBooleanWithDefault(args.allowWriteIn, false),
624
+ writeInPlaceholder: toTrimmedString(args.writeInPlaceholder),
625
+ },
626
+ ]
627
+ : [];
628
+
629
+ const questions = baseQuestions
630
+ .map((entry, index) => normalizeUserInputQuestionEntry(entry, index))
631
+ .filter((entry): entry is UserInputQuestion => Boolean(entry))
632
+ .slice(0, MAX_USER_INPUT_QUESTIONS);
633
+
634
+ if (questions.length === 0) {
635
+ throw new Error('request_user_input requires at least one valid question with options.');
636
+ }
637
+
638
+ return {
639
+ requestId: toTrimmedString(args.requestId) || toTrimmedString(args.id) || callId,
640
+ title: toTrimmedString(args.title) || DEFAULT_USER_INPUT_TITLE,
641
+ instructions: toTrimmedString(args.instructions) || DEFAULT_USER_INPUT_INSTRUCTIONS,
642
+ submitLabel: toTrimmedString(args.submitLabel) || 'Submit',
643
+ cancelLabel: toTrimmedString(args.cancelLabel) || 'Cancel',
644
+ allowCancel: toBooleanWithDefault(args.allowCancel, true),
645
+ questions,
646
+ };
647
+ };
648
+
345
649
  const stringifyToolArgs = (value: unknown): string => {
346
650
  if (typeof value === 'string') return value;
347
651
  try {
@@ -1408,6 +1712,8 @@ interface ChatInputProps {
1408
1712
  onSubmit: (text: string) => void;
1409
1713
  onQueueSubmit: (text: string) => void;
1410
1714
  onStop: () => void;
1715
+ userInputRequest?: UserInputRequest | null;
1716
+ onCompleteUserInputRequest?: (payload: UserInputCompletionPayload) => void;
1411
1717
  queuedPrompts?: string[];
1412
1718
  onClearQueuedPrompt: (index: number) => void;
1413
1719
  agentOptions: AgentOption[];
@@ -1424,6 +1730,7 @@ interface ChatInputProps {
1424
1730
  disabledSectionIds?: Set<string>;
1425
1731
  onToggleSection?: (sectionId: string, enabled: boolean) => void;
1426
1732
  onContextViewerToggle?: () => void;
1733
+ composerAgentModeControl?: ComposerAgentModeControl;
1427
1734
  }
1428
1735
 
1429
1736
  const ChatInput = React.memo<ChatInputProps>(({
@@ -1432,6 +1739,8 @@ const ChatInput = React.memo<ChatInputProps>(({
1432
1739
  onSubmit,
1433
1740
  onQueueSubmit,
1434
1741
  onStop,
1742
+ userInputRequest = null,
1743
+ onCompleteUserInputRequest,
1435
1744
  queuedPrompts = [],
1436
1745
  onClearQueuedPrompt,
1437
1746
  agentOptions,
@@ -1446,6 +1755,7 @@ const ChatInput = React.memo<ChatInputProps>(({
1446
1755
  enableContextDetailView = false,
1447
1756
  disabledSectionIds = new Set(),
1448
1757
  onToggleSection,
1758
+ composerAgentModeControl,
1449
1759
  }) => {
1450
1760
  const [inputValue, setInputValue] = useState('');
1451
1761
  const [dropdownOpen, setDropdownOpen] = useState(false);
@@ -1455,6 +1765,65 @@ const ChatInput = React.memo<ChatInputProps>(({
1455
1765
  const textareaRef = useRef<HTMLTextAreaElement | null>(null);
1456
1766
  const containerRef = useRef<HTMLDivElement | null>(null);
1457
1767
  const contextPopoverRef = useRef<HTMLDivElement | null>(null);
1768
+ const [userInputSelections, setUserInputSelections] = useState<Record<string, string>>({});
1769
+ const [userInputWriteIns, setUserInputWriteIns] = useState<Record<string, string>>({});
1770
+ const [userInputValidationError, setUserInputValidationError] = useState('');
1771
+ const [userInputQuestionIndex, setUserInputQuestionIndex] = useState(0);
1772
+ const [agentModeChipExpanded, setAgentModeChipExpanded] = useState(false);
1773
+ const isAgentModeActionVisible = composerAgentModeControl !== undefined;
1774
+ const isAgentModeActive = Boolean(composerAgentModeControl?.active);
1775
+ const isAgentModeActionDisabled = Boolean(composerAgentModeControl?.disabled);
1776
+ const agentModeLabel = String(composerAgentModeControl?.label || 'Agent').trim() || 'Agent';
1777
+ const agentModeTitle =
1778
+ String(composerAgentModeControl?.title || '').trim() ||
1779
+ (isAgentModeActive ? 'Agent Mode active for this session' : 'Enable Agent Mode');
1780
+
1781
+ useEffect(() => {
1782
+ if (isAgentModeActionVisible) return;
1783
+ setAgentModeChipExpanded(false);
1784
+ }, [isAgentModeActionVisible]);
1785
+
1786
+ useEffect(() => {
1787
+ if (!isAgentModeActive) return;
1788
+ setAgentModeChipExpanded(true);
1789
+ }, [isAgentModeActive]);
1790
+
1791
+ const handleAgentModePillClick = useCallback(() => {
1792
+ if (!isAgentModeActionVisible || isAgentModeActionDisabled) return;
1793
+ if (isAgentModeActive) {
1794
+ setAgentModeChipExpanded(true);
1795
+ return;
1796
+ }
1797
+ setAgentModeChipExpanded(true);
1798
+ if (typeof composerAgentModeControl?.onActivate === 'function') {
1799
+ composerAgentModeControl.onActivate();
1800
+ }
1801
+ }, [
1802
+ composerAgentModeControl,
1803
+ isAgentModeActionDisabled,
1804
+ isAgentModeActionVisible,
1805
+ isAgentModeActive,
1806
+ ]);
1807
+
1808
+ useEffect(() => {
1809
+ if (!userInputRequest) {
1810
+ setUserInputSelections({});
1811
+ setUserInputWriteIns({});
1812
+ setUserInputValidationError('');
1813
+ setUserInputQuestionIndex(0);
1814
+ return;
1815
+ }
1816
+ const nextSelections: Record<string, string> = {};
1817
+ const nextWriteIns: Record<string, string> = {};
1818
+ userInputRequest.questions.forEach((question) => {
1819
+ nextSelections[question.id] = '';
1820
+ nextWriteIns[question.id] = '';
1821
+ });
1822
+ setUserInputSelections(nextSelections);
1823
+ setUserInputWriteIns(nextWriteIns);
1824
+ setUserInputValidationError('');
1825
+ setUserInputQuestionIndex(0);
1826
+ }, [userInputRequest]);
1458
1827
 
1459
1828
  // Auto-resize textarea
1460
1829
  const autoResize = useCallback(() => {
@@ -1562,12 +1931,221 @@ const ChatInput = React.memo<ChatInputProps>(({
1562
1931
  const hasQueuedPrompts = normalizedQueuedPrompts.length > 0;
1563
1932
  const shouldQueueSubmission = isBusy || hasQueuedPrompts;
1564
1933
  const showStopAction = isBusy && !hasDraft;
1934
+ const composerLockedByUserInput = Boolean(userInputRequest);
1935
+ const totalUserInputQuestions = userInputRequest?.questions.length || 0;
1936
+ const boundedUserInputQuestionIndex =
1937
+ totalUserInputQuestions > 0
1938
+ ? Math.min(Math.max(userInputQuestionIndex, 0), totalUserInputQuestions - 1)
1939
+ : 0;
1940
+ const activeUserInputQuestion =
1941
+ totalUserInputQuestions > 0 ? userInputRequest?.questions[boundedUserInputQuestionIndex] || null : null;
1942
+ const isFirstUserInputQuestion = boundedUserInputQuestionIndex <= 0;
1943
+ const isLastUserInputQuestion = totalUserInputQuestions > 0 && boundedUserInputQuestionIndex >= totalUserInputQuestions - 1;
1944
+
1945
+ const getQuestionAnswerState = useCallback((question: UserInputQuestion) => {
1946
+ const selectedValue = toTrimmedString(userInputSelections[question.id]);
1947
+ const writeInRaw = toTrimmedString(userInputWriteIns[question.id]);
1948
+ const writeIn = writeInRaw.slice(0, MAX_USER_INPUT_WRITE_IN_CHARS);
1949
+ const selectedOption = question.options.find((option) => option.value === selectedValue) || null;
1950
+ const hasAnswer = Boolean(selectedOption) || (question.allowWriteIn && writeIn.length > 0);
1951
+ return {
1952
+ selectedOption,
1953
+ writeIn,
1954
+ hasAnswer,
1955
+ };
1956
+ }, [userInputSelections, userInputWriteIns]);
1957
+
1958
+ const validateUserInputQuestion = useCallback((question: UserInputQuestion): boolean => {
1959
+ const answerState = getQuestionAnswerState(question);
1960
+ if (question.required && !answerState.hasAnswer) {
1961
+ setUserInputValidationError(`Please answer "${question.header}".`);
1962
+ return false;
1963
+ }
1964
+ return true;
1965
+ }, [getQuestionAnswerState]);
1966
+
1967
+ const handleUserInputNext = useCallback(() => {
1968
+ if (!activeUserInputQuestion || isLastUserInputQuestion) return;
1969
+ if (!validateUserInputQuestion(activeUserInputQuestion)) return;
1970
+ setUserInputValidationError('');
1971
+ setUserInputQuestionIndex((prev) => Math.min(prev + 1, totalUserInputQuestions - 1));
1972
+ }, [activeUserInputQuestion, isLastUserInputQuestion, totalUserInputQuestions, validateUserInputQuestion]);
1973
+
1974
+ const handleUserInputBack = useCallback(() => {
1975
+ setUserInputValidationError('');
1976
+ setUserInputQuestionIndex((prev) => Math.max(prev - 1, 0));
1977
+ }, []);
1978
+
1979
+ const handleUserInputSubmit = useCallback(() => {
1980
+ if (!userInputRequest || typeof onCompleteUserInputRequest !== 'function') {
1981
+ return;
1982
+ }
1983
+
1984
+ const answers: UserInputAnswer[] = [];
1985
+ for (let questionIndex = 0; questionIndex < userInputRequest.questions.length; questionIndex += 1) {
1986
+ const question = userInputRequest.questions[questionIndex]!;
1987
+ const answerState = getQuestionAnswerState(question);
1988
+ if (question.required && !answerState.hasAnswer) {
1989
+ setUserInputQuestionIndex(questionIndex);
1990
+ setUserInputValidationError(`Please answer "${question.header}".`);
1991
+ return;
1992
+ }
1993
+
1994
+ answers.push({
1995
+ id: question.id,
1996
+ header: question.header,
1997
+ question: question.question,
1998
+ selectedOptionValue: answerState.selectedOption?.value || null,
1999
+ selectedOptionLabel: answerState.selectedOption?.label || null,
2000
+ writeIn: answerState.writeIn || null,
2001
+ answer: answerState.writeIn || answerState.selectedOption?.label || '',
2002
+ });
2003
+ }
2004
+
2005
+ setUserInputValidationError('');
2006
+ onCompleteUserInputRequest({
2007
+ status: 'submitted',
2008
+ requestId: userInputRequest.requestId,
2009
+ answers,
2010
+ });
2011
+ }, [getQuestionAnswerState, onCompleteUserInputRequest, userInputRequest]);
2012
+
2013
+ const handleUserInputCancel = useCallback(() => {
2014
+ if (!userInputRequest || typeof onCompleteUserInputRequest !== 'function') {
2015
+ return;
2016
+ }
2017
+ onCompleteUserInputRequest({
2018
+ status: 'cancelled',
2019
+ requestId: userInputRequest.requestId,
2020
+ cancelledBy: 'user',
2021
+ });
2022
+ }, [onCompleteUserInputRequest, userInputRequest]);
1565
2023
 
1566
2024
  return (
1567
2025
  <div
1568
2026
  className={`ai-chat-panel__input-container ${dropdownOpen ? 'ai-chat-panel__input-container--dropdown-open' : ''}`}
1569
2027
  ref={containerRef}
1570
2028
  >
2029
+ {userInputRequest && (
2030
+ <div className="ai-chat-user-input-popover" role="dialog" aria-live="polite" aria-label={userInputRequest.title}>
2031
+ <div className="ai-chat-user-input-popover__header">
2032
+ <div className="ai-chat-user-input-popover__title">{userInputRequest.title}</div>
2033
+ <div className="ai-chat-user-input-popover__header-meta">
2034
+ {totalUserInputQuestions > 1 && (
2035
+ <span className="ai-chat-user-input-popover__progress">
2036
+ {boundedUserInputQuestionIndex + 1} of {totalUserInputQuestions}
2037
+ </span>
2038
+ )}
2039
+ {userInputRequest.allowCancel && (
2040
+ <button
2041
+ type="button"
2042
+ className="ai-chat-user-input-popover__cancel-inline"
2043
+ onClick={handleUserInputCancel}
2044
+ >
2045
+ {userInputRequest.cancelLabel}
2046
+ </button>
2047
+ )}
2048
+ </div>
2049
+ </div>
2050
+ {userInputRequest.instructions && (
2051
+ <div className="ai-chat-user-input-popover__instructions">{userInputRequest.instructions}</div>
2052
+ )}
2053
+ {activeUserInputQuestion && (
2054
+ <div className="ai-chat-user-input-popover__question">
2055
+ <div className="ai-chat-user-input-popover__question-header">
2056
+ <span className="ai-chat-user-input-popover__question-index">{boundedUserInputQuestionIndex + 1}</span>
2057
+ <span className="ai-chat-user-input-popover__question-title">{activeUserInputQuestion.header}</span>
2058
+ </div>
2059
+ <div className="ai-chat-user-input-popover__question-text">{activeUserInputQuestion.question}</div>
2060
+ <div className="ai-chat-user-input-popover__options">
2061
+ {activeUserInputQuestion.options.map((option) => (
2062
+ <label key={`${activeUserInputQuestion.id}:${option.value}`} className="ai-chat-user-input-popover__option">
2063
+ <input
2064
+ type="radio"
2065
+ name={`user-input-${activeUserInputQuestion.id}`}
2066
+ value={option.value}
2067
+ checked={(userInputSelections[activeUserInputQuestion.id] || '') === option.value}
2068
+ onChange={() => {
2069
+ setUserInputValidationError('');
2070
+ setUserInputSelections((prev) => ({
2071
+ ...prev,
2072
+ [activeUserInputQuestion.id]: option.value,
2073
+ }));
2074
+ }}
2075
+ />
2076
+ <span className="ai-chat-user-input-popover__option-content">
2077
+ <span className="ai-chat-user-input-popover__option-label">{option.label}</span>
2078
+ {option.description ? (
2079
+ <span className="ai-chat-user-input-popover__option-description">{option.description}</span>
2080
+ ) : null}
2081
+ </span>
2082
+ </label>
2083
+ ))}
2084
+ </div>
2085
+ {activeUserInputQuestion.allowWriteIn && (
2086
+ <textarea
2087
+ className="ai-chat-user-input-popover__writein"
2088
+ placeholder={activeUserInputQuestion.writeInPlaceholder}
2089
+ value={userInputWriteIns[activeUserInputQuestion.id] || ''}
2090
+ onChange={(event) => {
2091
+ const nextValue = event.target.value.slice(0, MAX_USER_INPUT_WRITE_IN_CHARS);
2092
+ setUserInputValidationError('');
2093
+ setUserInputWriteIns((prev) => ({
2094
+ ...prev,
2095
+ [activeUserInputQuestion.id]: nextValue,
2096
+ }));
2097
+ }}
2098
+ rows={2}
2099
+ />
2100
+ )}
2101
+ </div>
2102
+ )}
2103
+ {userInputValidationError ? (
2104
+ <div className="ai-chat-user-input-popover__error">{userInputValidationError}</div>
2105
+ ) : null}
2106
+ <div className="ai-chat-user-input-popover__actions">
2107
+ <div className="ai-chat-user-input-popover__actions-left">
2108
+ {userInputRequest.allowCancel && (
2109
+ <button
2110
+ type="button"
2111
+ className="ai-chat-user-input-popover__button ai-chat-user-input-popover__button--secondary"
2112
+ onClick={handleUserInputCancel}
2113
+ >
2114
+ {userInputRequest.cancelLabel}
2115
+ </button>
2116
+ )}
2117
+ </div>
2118
+ <div className="ai-chat-user-input-popover__actions-right">
2119
+ {!isFirstUserInputQuestion && (
2120
+ <button
2121
+ type="button"
2122
+ className="ai-chat-user-input-popover__button ai-chat-user-input-popover__button--secondary"
2123
+ onClick={handleUserInputBack}
2124
+ >
2125
+ Back
2126
+ </button>
2127
+ )}
2128
+ {isLastUserInputQuestion ? (
2129
+ <button
2130
+ type="button"
2131
+ className="ai-chat-user-input-popover__button ai-chat-user-input-popover__button--primary"
2132
+ onClick={handleUserInputSubmit}
2133
+ >
2134
+ {userInputRequest.submitLabel}
2135
+ </button>
2136
+ ) : (
2137
+ <button
2138
+ type="button"
2139
+ className="ai-chat-user-input-popover__button ai-chat-user-input-popover__button--primary"
2140
+ onClick={handleUserInputNext}
2141
+ >
2142
+ Next
2143
+ </button>
2144
+ )}
2145
+ </div>
2146
+ </div>
2147
+ </div>
2148
+ )}
1571
2149
  {hasQueuedPrompts && (
1572
2150
  <div className="ai-chat-queued-prompts">
1573
2151
  {normalizedQueuedPrompts.map((queuedPrompt, index) => (
@@ -1603,8 +2181,9 @@ const ChatInput = React.memo<ChatInputProps>(({
1603
2181
  <textarea
1604
2182
  ref={textareaRef}
1605
2183
  className="ai-chat-input"
1606
- placeholder={placeholder}
2184
+ placeholder={composerLockedByUserInput ? 'Answer the question above to continue…' : placeholder}
1607
2185
  value={inputValue}
2186
+ disabled={composerLockedByUserInput}
1608
2187
  onChange={(e) => {
1609
2188
  setInputValue(e.target.value);
1610
2189
  setTimeout(autoResize, 0);
@@ -1621,62 +2200,83 @@ const ChatInput = React.memo<ChatInputProps>(({
1621
2200
 
1622
2201
  {/* Footer row with agent selector and send button */}
1623
2202
  <div className="ai-chat-panel__input-footer">
1624
- {agentOptions.length > 0 ? (
1625
- <div className="ai-chat-agent-selector">
2203
+ <div className="ai-chat-input-footer__left">
2204
+ {agentOptions.length > 0 ? (
2205
+ <div className="ai-chat-agent-selector">
2206
+ <button
2207
+ className="ai-chat-agent-selector__trigger"
2208
+ onClick={() => setDropdownOpen(!dropdownOpen)}
2209
+ disabled={agentsLoading}
2210
+ >
2211
+ {currentAgentAvatarUrl ? (
2212
+ <img
2213
+ src={currentAgentAvatarUrl}
2214
+ alt={currentAgentLabel || 'Agent'}
2215
+ className="ai-chat-agent-selector__avatar"
2216
+ />
2217
+ ) : (
2218
+ <AgentIcon />
2219
+ )}
2220
+ <span className="ai-chat-agent-selector__label">
2221
+ {agentsLoading ? 'Loading...' : currentAgentLabel || 'Select agent'}
2222
+ </span>
2223
+ {dropdownOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
2224
+ </button>
2225
+ </div>
2226
+ ) : null}
2227
+ {isAgentModeActionVisible && (
1626
2228
  <button
1627
- className="ai-chat-agent-selector__trigger"
1628
- onClick={() => setDropdownOpen(!dropdownOpen)}
1629
- disabled={agentsLoading}
2229
+ className={`ai-chat-agent-mode-trigger ${
2230
+ agentModeChipExpanded ? 'ai-chat-agent-mode-trigger--expanded' : ''
2231
+ } ${isAgentModeActive ? 'ai-chat-agent-mode-trigger--active' : ''}`}
2232
+ onClick={handleAgentModePillClick}
2233
+ type="button"
2234
+ title={agentModeTitle}
2235
+ aria-label={isAgentModeActive ? 'Agent active' : 'Enable Agent Mode'}
2236
+ aria-pressed={isAgentModeActive}
2237
+ disabled={isAgentModeActionDisabled}
1630
2238
  >
1631
- {currentAgentAvatarUrl ? (
1632
- <img
1633
- src={currentAgentAvatarUrl}
1634
- alt={currentAgentLabel || 'Agent'}
1635
- className="ai-chat-agent-selector__avatar"
1636
- />
1637
- ) : (
2239
+ <span className="ai-chat-agent-mode-trigger__icon" aria-hidden="true">
1638
2240
  <AgentIcon />
1639
- )}
1640
- <span className="ai-chat-agent-selector__label">
1641
- {agentsLoading ? 'Loading...' : currentAgentLabel || 'Select agent'}
1642
2241
  </span>
1643
- {dropdownOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
2242
+ <span className="ai-chat-agent-mode-trigger__label">{agentModeLabel}</span>
1644
2243
  </button>
1645
- </div>
1646
- ) : (
1647
- <div className="ai-chat-panel__input-footer-spacer" />
1648
- )}
2244
+ )}
2245
+ {agentOptions.length === 0 && !isAgentModeActionVisible ? (
2246
+ <div className="ai-chat-panel__input-footer-spacer" />
2247
+ ) : null}
2248
+ </div>
1649
2249
 
1650
- {/* Context viewer pill - Cursor style */}
1651
2250
  {contextSections.length > 0 && (
1652
2251
  <div className="ai-chat-context-pill-wrapper">
1653
- <button
1654
- className={`ai-chat-context-pill ${contextViewerOpen ? 'ai-chat-context-pill--active' : ''} ${isOverLimit ? 'ai-chat-context-pill--warning' : ''}`}
1655
- onClick={(e) => {
1656
- e.preventDefault();
1657
- e.stopPropagation();
1658
- console.log('[ContextViewer] Button clicked, current state:', contextViewerOpen);
1659
- setContextViewerOpen(!contextViewerOpen);
1660
- if (!contextViewerOpen) {
1661
- setContextViewMode('summary');
1662
- setExpandedSectionId(null);
1663
- } else {
1664
- setExpandedSectionId(null);
1665
- }
1666
- }}
1667
- type="button"
1668
- title="View context"
1669
- >
1670
- <span className="ai-chat-context-pill__label">context: {contextSections.length} {contextSections.length === 1 ? 'section' : 'sections'}</span>
1671
- </button>
2252
+ <div className="ai-chat-context-pill-anchor">
2253
+ <button
2254
+ className={`ai-chat-context-pill ${contextViewerOpen ? 'ai-chat-context-pill--active' : ''} ${isOverLimit ? 'ai-chat-context-pill--warning' : ''}`}
2255
+ onClick={(e) => {
2256
+ e.preventDefault();
2257
+ e.stopPropagation();
2258
+ console.log('[ContextViewer] Button clicked, current state:', contextViewerOpen);
2259
+ setContextViewerOpen(!contextViewerOpen);
2260
+ if (!contextViewerOpen) {
2261
+ setContextViewMode('summary');
2262
+ setExpandedSectionId(null);
2263
+ } else {
2264
+ setExpandedSectionId(null);
2265
+ }
2266
+ }}
2267
+ type="button"
2268
+ title="View context"
2269
+ >
2270
+ <span className="ai-chat-context-pill__label">context: {contextSections.length} {contextSections.length === 1 ? 'section' : 'sections'}</span>
2271
+ </button>
1672
2272
 
1673
- {/* Context popover - positioned relative to pill wrapper */}
1674
- {contextViewerOpen && (
1675
- <div
1676
- className={`ai-chat-context-popover ${contextViewMode === 'detail' ? 'ai-chat-context-popover--detail' : ''}`}
1677
- ref={contextPopoverRef}
1678
- onClick={(e) => e.stopPropagation()}
1679
- >
2273
+ {/* Context popover - positioned relative to context pill */}
2274
+ {contextViewerOpen && (
2275
+ <div
2276
+ className={`ai-chat-context-popover ${contextViewMode === 'detail' ? 'ai-chat-context-popover--detail' : ''}`}
2277
+ ref={contextPopoverRef}
2278
+ onClick={(e) => e.stopPropagation()}
2279
+ >
1680
2280
  {/* Summary view */}
1681
2281
  {contextViewMode === 'summary' && (
1682
2282
  <div className="ai-chat-context-popover__summary">
@@ -1834,16 +2434,27 @@ const ChatInput = React.memo<ChatInputProps>(({
1834
2434
  </div>
1835
2435
  </div>
1836
2436
  )}
1837
- </div>
1838
- )}
2437
+ </div>
2438
+ )}
2439
+ </div>
1839
2440
  </div>
1840
2441
  )}
1841
2442
 
1842
2443
  <button
1843
- className={`ai-chat-send-button ${!showStopAction && !hasDraft ? 'ai-chat-send-button--disabled' : ''} ${showStopAction ? 'ai-chat-send-button--stop' : ''}`}
2444
+ className={`ai-chat-send-button ${
2445
+ !showStopAction && (!hasDraft || composerLockedByUserInput) ? 'ai-chat-send-button--disabled' : ''
2446
+ } ${showStopAction ? 'ai-chat-send-button--stop' : ''}`}
1844
2447
  onClick={() => showStopAction ? onStop() : handleSubmit()}
1845
- disabled={!showStopAction && !hasDraft}
1846
- title={showStopAction ? 'Stop response' : shouldQueueSubmission ? 'Queue prompt' : 'Send prompt'}
2448
+ disabled={(!showStopAction && !hasDraft) || (!showStopAction && composerLockedByUserInput)}
2449
+ title={
2450
+ showStopAction
2451
+ ? 'Stop response'
2452
+ : composerLockedByUserInput
2453
+ ? 'Answer the question above to continue'
2454
+ : shouldQueueSubmission
2455
+ ? 'Queue prompt'
2456
+ : 'Send prompt'
2457
+ }
1847
2458
  >
1848
2459
  {showStopAction ? <StopIcon /> : <ArrowUpIcon />}
1849
2460
  </button>
@@ -1946,6 +2557,8 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1946
2557
  onToggleSection: propOnToggleSection,
1947
2558
  onConversationCreated,
1948
2559
  onBeforeSend,
2560
+ compactContext,
2561
+ compactionPreserveTurns = DEFAULT_COMPACTION_PRESERVE_TURNS,
1949
2562
  // UI Customization Props
1950
2563
  cssUrl,
1951
2564
  markdownClass,
@@ -1966,6 +2579,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1966
2579
  customerEmailCaptureMode = 'HIDE',
1967
2580
  customerEmailCapturePlaceholder = 'Please enter your email...',
1968
2581
  toolStatusLabelFormatter,
2582
+ composerAgentModeControl,
1969
2583
  }) => {
1970
2584
  // ============================================================================
1971
2585
  // API URL
@@ -2000,6 +2614,10 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2000
2614
  const [copiedCallId, setCopiedCallId] = useState<string | null>(null);
2001
2615
  const [feedbackCallId, setFeedbackCallId] = useState<{ callId: string; type: 'up' | 'down' } | null>(null);
2002
2616
  const [error, setError] = useState<{ message: string; code?: string } | null>(null);
2617
+ const [compactionNotice, setCompactionNotice] = useState<{
2618
+ level: 'info' | 'warning' | 'success';
2619
+ message: string;
2620
+ } | null>(null);
2003
2621
  const lastProcessedErrorRef = useRef<string | null>(null);
2004
2622
 
2005
2623
  // Email & Save state
@@ -2031,6 +2649,9 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2031
2649
  const [activeToolCalls, setActiveToolCalls] = useState<
2032
2650
  Array<{ toolName: string; callId: string; args?: Record<string, unknown> }>
2033
2651
  >([]);
2652
+ const [pendingUserInputRequest, setPendingUserInputRequest] = useState<UserInputRequest | null>(null);
2653
+ const pendingUserInputRequestRef = useRef<UserInputRequest | null>(null);
2654
+ const pendingUserInputResolverRef = useRef<((payload: UserInputCompletionPayload) => void) | null>(null);
2034
2655
  const normalizeToolName = useCallback((toolName: string): string => {
2035
2656
  return String(toolName ?? '').trim().toLowerCase();
2036
2657
  }, []);
@@ -2043,6 +2664,31 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2043
2664
  },
2044
2665
  [normalizeToolName],
2045
2666
  );
2667
+ const completePendingUserInputRequest = useCallback((payload: UserInputCompletionPayload) => {
2668
+ const resolver = pendingUserInputResolverRef.current;
2669
+ pendingUserInputResolverRef.current = null;
2670
+ pendingUserInputRequestRef.current = null;
2671
+ setPendingUserInputRequest(null);
2672
+ if (resolver) {
2673
+ resolver(payload);
2674
+ }
2675
+ }, []);
2676
+ useEffect(() => {
2677
+ pendingUserInputRequestRef.current = pendingUserInputRequest;
2678
+ }, [pendingUserInputRequest]);
2679
+ useEffect(() => {
2680
+ return () => {
2681
+ const resolver = pendingUserInputResolverRef.current;
2682
+ if (!resolver) return;
2683
+ pendingUserInputResolverRef.current = null;
2684
+ const pendingRequestId = pendingUserInputRequestRef.current?.requestId || 'request_user_input';
2685
+ resolver({
2686
+ status: 'cancelled',
2687
+ requestId: pendingRequestId,
2688
+ cancelledBy: 'unmount',
2689
+ });
2690
+ };
2691
+ }, []);
2046
2692
  const alwaysApprovedToolsStorageKey = useMemo(() => {
2047
2693
  const customerId =
2048
2694
  (customer as any)?.customer_id ||
@@ -2864,6 +3510,51 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2864
3510
  turnLockRef.current = false;
2865
3511
  }, [hasInFlightTurnWork]);
2866
3512
 
3513
+ const runRequestUserInputTool = useCallback(
3514
+ async (toolCall: {
3515
+ toolName: string;
3516
+ callId: string;
3517
+ args: Record<string, unknown>;
3518
+ }): Promise<Record<string, unknown>> => {
3519
+ const normalizedToolName = normalizeToolName(toolCall.toolName);
3520
+ if (!USER_INPUT_TOOL_NAMES.has(normalizedToolName)) {
3521
+ throw new Error(`Unsupported user input tool: ${toolCall.toolName}`);
3522
+ }
3523
+ if (pendingUserInputResolverRef.current || pendingUserInputRequestRef.current) {
3524
+ throw new Error('A user input request is already pending. Wait for completion before requesting another.');
3525
+ }
3526
+
3527
+ const request = normalizeUserInputRequest(
3528
+ isObjectRecord(toolCall.args) ? toolCall.args : {},
3529
+ toolCall.callId || toolCall.toolName,
3530
+ );
3531
+ pendingUserInputRequestRef.current = request;
3532
+ setPendingUserInputRequest(request);
3533
+
3534
+ const completion = await new Promise<UserInputCompletionPayload>((resolve) => {
3535
+ pendingUserInputResolverRef.current = resolve;
3536
+ });
3537
+
3538
+ if (completion.status === 'cancelled') {
3539
+ return {
3540
+ status: 'cancelled',
3541
+ requestId: request.requestId,
3542
+ cancelledBy: completion.cancelledBy || 'user',
3543
+ answers: [],
3544
+ };
3545
+ }
3546
+
3547
+ const answers = Array.isArray(completion.answers) ? completion.answers : [];
3548
+ return {
3549
+ status: 'ok',
3550
+ requestId: request.requestId,
3551
+ answers,
3552
+ answeredCount: answers.length,
3553
+ };
3554
+ },
3555
+ [normalizeToolName],
3556
+ );
3557
+
2867
3558
  const processGivenToolRequests = useCallback(
2868
3559
  async (requests: ToolRequestMatch[]) => {
2869
3560
  const dedupeToolRequests = (input: ToolRequestMatch[]): ToolRequestMatch[] => {
@@ -3223,6 +3914,33 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
3223
3914
  localToolExecutors && typeof localToolExecutors[toolCall.toolName] === 'function'
3224
3915
  ? localToolExecutors[toolCall.toolName]
3225
3916
  : null;
3917
+ const normalizedToolName = normalizeToolName(toolCall.toolName);
3918
+
3919
+ if (USER_INPUT_TOOL_NAMES.has(normalizedToolName)) {
3920
+ try {
3921
+ const requestResult = await runRequestUserInputTool({
3922
+ toolName: toolCall.toolName,
3923
+ callId: toolCall.callId,
3924
+ args: toolCall.args,
3925
+ });
3926
+ return {
3927
+ tool_call_id: toolCall.callId,
3928
+ tool_name: toolCall.toolName,
3929
+ result: JSON.stringify(requestResult),
3930
+ isError: false,
3931
+ };
3932
+ } catch (error) {
3933
+ return {
3934
+ tool_call_id: toolCall.callId,
3935
+ tool_name: toolCall.toolName,
3936
+ result:
3937
+ error instanceof Error
3938
+ ? error.message
3939
+ : `Unhandled error calling ${toolCall.toolName}`,
3940
+ isError: true,
3941
+ };
3942
+ }
3943
+ }
3226
3944
 
3227
3945
  if (localExecutor) {
3228
3946
  try {
@@ -3607,6 +4325,8 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
3607
4325
  idle,
3608
4326
  stop,
3609
4327
  lastController,
4328
+ normalizeToolName,
4329
+ runRequestUserInputTool,
3610
4330
  waitForStreamIdle,
3611
4331
  releaseTurnLockIfSettled,
3612
4332
  ],
@@ -4168,6 +4888,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
4168
4888
 
4169
4889
  // Clear any previous errors
4170
4890
  setError(null);
4891
+ setCompactionNotice(null);
4171
4892
 
4172
4893
  // Reset scroll tracking for new message - enable auto-scroll
4173
4894
  setUserHasScrolled(false);
@@ -4209,189 +4930,279 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
4209
4930
 
4210
4931
  // Now proceed with API calls in the background (conversation creation + LLM call)
4211
4932
  // Ensure conversation exists before sending (matches ChatPanel)
4212
- console.log('AIChatPanel.continueChat - about to call ensureConversation');
4213
- ensureConversation().then((convId) => {
4214
- console.log('AIChatPanel.continueChat - ensureConversation resolved with:', convId);
4215
- // Build messagesAndHistory from history (matches ChatPanel)
4216
- // IMPORTANT: Exclude the current prompt (promptKey) since it's new and we're sending it now
4217
- const historyForCall = latestHistoryRef.current || {};
4218
- const messagesAndHistory: { role: string; content: string }[] = [];
4219
- Object.entries(historyForCall).forEach(([historyPrompt, historyEntry]) => {
4220
- // Skip the current prompt we just added optimistically
4221
- if (historyPrompt === promptKey) return;
4222
- const promptForHistory = normalizeHistoryPromptForContext(historyPrompt);
4223
- const assistantContextContent = buildAssistantContextContent(historyPrompt, historyEntry);
4224
-
4225
- messagesAndHistory.push({ role: 'user', content: promptForHistory });
4226
- messagesAndHistory.push({ role: 'assistant', content: assistantContextContent });
4227
- });
4228
-
4229
- // Build the full prompt - only apply template for first message (matches ChatPanel)
4230
- // Check if this is the first message by seeing if messagesAndHistory is empty
4231
- let fullPromptToSend = promptToSend;
4232
- if (messagesAndHistory.length === 0 && promptTemplate) {
4233
- fullPromptToSend = promptTemplate.replace('{{prompt}}', fullPromptToSend);
4234
- }
4235
-
4236
- const newController = new AbortController();
4237
- setLastController(newController);
4933
+ void (async () => {
4934
+ try {
4935
+ console.log('AIChatPanel.continueChat - about to call ensureConversation');
4936
+ const convId = await ensureConversation();
4937
+ console.log('AIChatPanel.continueChat - ensureConversation resolved with:', convId);
4938
+
4939
+ // Build messagesAndHistory from history (matches ChatPanel)
4940
+ // IMPORTANT: Exclude the current prompt (promptKey) since it's new and we're sending it now
4941
+ const historyForCall = latestHistoryRef.current || {};
4942
+ const messagesAndHistory: { role: string; content: string }[] = [];
4943
+ Object.entries(historyForCall).forEach(([historyPrompt, historyEntry]) => {
4944
+ if (historyPrompt === promptKey) return;
4945
+ const promptForHistory = normalizeHistoryPromptForContext(historyPrompt);
4946
+ const assistantContextContent = buildAssistantContextContent(historyPrompt, historyEntry);
4947
+
4948
+ messagesAndHistory.push({ role: 'user', content: promptForHistory });
4949
+ messagesAndHistory.push({ role: 'assistant', content: assistantContextContent });
4950
+ });
4951
+
4952
+ let fullPromptToSend = promptToSend;
4953
+ if (messagesAndHistory.length === 0 && promptTemplate) {
4954
+ fullPromptToSend = promptTemplate.replace('{{prompt}}', fullPromptToSend);
4955
+ }
4956
+
4957
+ const newController = new AbortController();
4958
+ setLastController(newController);
4959
+
4960
+ if (onBeforeSend) {
4961
+ try {
4962
+ await Promise.resolve(
4963
+ onBeforeSend({
4964
+ prompt: promptToSend,
4965
+ conversationId: convId || null,
4966
+ agentId: agent,
4967
+ service,
4968
+ messages: messagesAndHistory,
4969
+ }),
4970
+ );
4971
+ } catch (error) {
4972
+ console.warn('[AIChatPanel] onBeforeSend callback failed:', error);
4973
+ }
4974
+ }
4975
+
4976
+ const warnRatio = toBoundedRatio(DEFAULT_CONTEXT_WARN_RATIO, DEFAULT_CONTEXT_WARN_RATIO);
4977
+ const compactRatio = toBoundedRatio(DEFAULT_CONTEXT_COMPACT_RATIO, DEFAULT_CONTEXT_COMPACT_RATIO);
4978
+ const targetRatio = toBoundedRatio(DEFAULT_CONTEXT_TARGET_RATIO, DEFAULT_CONTEXT_TARGET_RATIO);
4979
+ const maxTokens = Math.max(1, Number.isFinite(Number(maxContextTokens)) ? Math.floor(Number(maxContextTokens)) : 8000);
4980
+ const dataEntries = dataWithExtras();
4981
+ const projectedBefore =
4982
+ estimateTokensFromText(fullPromptToSend)
4983
+ + estimateTokensFromMessages(messagesAndHistory)
4984
+ + Math.max(Number(totalContextTokens) || 0, estimateTokensFromData(dataEntries));
4985
+ const warnThreshold = Math.floor(maxTokens * warnRatio);
4986
+ const compactThreshold = Math.floor(maxTokens * compactRatio);
4987
+ const targetThreshold = Math.floor(maxTokens * targetRatio);
4988
+ const preserveTurnsForCompaction =
4989
+ Number.isFinite(Number(compactionPreserveTurns)) && Number(compactionPreserveTurns) > 0
4990
+ ? Math.floor(Number(compactionPreserveTurns))
4991
+ : DEFAULT_COMPACTION_PRESERVE_TURNS;
4992
+ const canDropOlderTurns =
4993
+ buildFallbackWindowMessages(messagesAndHistory, preserveTurnsForCompaction).length
4994
+ < messagesAndHistory.length;
4995
+
4996
+ let sendPrompt = fullPromptToSend;
4997
+ let sendMessages = messagesAndHistory;
4998
+
4999
+ if (projectedBefore >= warnThreshold && projectedBefore < compactThreshold) {
5000
+ const nearLimitMessage = 'Context is approaching the configured limit. A later turn may auto-compact.';
5001
+ setCompactionNotice({
5002
+ level: 'warning',
5003
+ message: nearLimitMessage,
5004
+ });
5005
+ }
4238
5006
 
4239
- if (onBeforeSend) {
4240
- void Promise.resolve(
4241
- onBeforeSend({
4242
- prompt: promptToSend,
5007
+ if (compactContext && projectedBefore >= compactThreshold && canDropOlderTurns) {
5008
+ setCompactionNotice({
5009
+ level: 'info',
5010
+ message: 'Automatically compacting context',
5011
+ });
5012
+ const compactionInput: CompactContextInput = {
5013
+ prompt: fullPromptToSend,
4243
5014
  conversationId: convId || null,
4244
5015
  agentId: agent,
4245
5016
  service,
4246
5017
  messages: messagesAndHistory,
4247
- })
4248
- ).catch((error) => {
4249
- console.warn('[AIChatPanel] onBeforeSend callback failed:', error);
4250
- });
4251
- }
4252
-
4253
- // Pass data array to send() for template replacement (e.g., {{Context}})
4254
- // Pass service (group_id) and customer data just like ChatPanel does
4255
- // Use convId from ensureConversation (matches ChatPanel)
4256
- send(
4257
- fullPromptToSend,
4258
- messagesAndHistory,
4259
- [
4260
- ...dataWithExtras(),
4261
- { key: '--messages', data: messagesAndHistory.length.toString() },
4262
- ],
4263
- true, // stream
4264
- true, // includeHistory
4265
- service, // group_id from agent config
4266
- convId, // Use the conversation ID from ensureConversation
4267
- newController,
4268
- undefined, // onComplete
4269
- (errorMsg: string) => {
4270
- // Error callback - handle errors immediately
4271
- console.log('[AIChatPanel] Error callback triggered:', errorMsg);
4272
-
4273
- // Check if this is a user-initiated abort
4274
- const isAbortError = errorMsg.toLowerCase().includes('abort') ||
4275
- errorMsg.toLowerCase().includes('canceled') ||
4276
- errorMsg.toLowerCase().includes('cancelled');
4277
-
4278
- if (isAbortError) {
4279
- if (suppressAbortHistoryUpdateRef.current) {
4280
- setIsLoading(false);
4281
- releaseTurnLockIfSettled();
4282
- return;
4283
- }
4284
- // User canceled the request - don't show error banner
4285
- console.log('[AIChatPanel] Request was aborted by user');
4286
- // Don't set error state - no red banner
4287
-
4288
- // Update history to show cancellation
4289
- if (promptKey) {
4290
- setHistory((prev) => {
4291
- const existingEntry = prev[promptKey] || { content: '', callId: '' };
4292
- return {
4293
- ...prev,
4294
- [promptKey]: {
4295
- ...existingEntry,
4296
- content: 'Response canceled',
4297
- callId: lastCallId || existingEntry.callId || '',
4298
- },
4299
- };
4300
- });
5018
+ data: dataEntries,
5019
+ maxContextTokens: maxTokens,
5020
+ totalContextTokens: Number(totalContextTokens) || 0,
5021
+ warnRatio,
5022
+ compactRatio,
5023
+ targetRatio,
5024
+ preserveTurns: preserveTurnsForCompaction,
5025
+ projectedTokens: {
5026
+ projectedBefore,
5027
+ projectedAfter: projectedBefore,
5028
+ warnThreshold,
5029
+ compactThreshold,
5030
+ targetThreshold,
5031
+ },
5032
+ };
5033
+
5034
+ try {
5035
+ const compactResult = await Promise.resolve(compactContext(compactionInput));
5036
+ if (compactResult && typeof compactResult === 'object') {
5037
+ if (typeof compactResult.prompt === 'string' && compactResult.prompt.trim()) {
5038
+ sendPrompt = compactResult.prompt;
5039
+ }
5040
+ if (Array.isArray(compactResult.messages)) {
5041
+ sendMessages = compactResult.messages;
5042
+ }
5043
+
5044
+ if (compactResult.action === 'compacted') {
5045
+ const usageBefore = Number(compactResult.tokenUsage?.projectedBefore);
5046
+ const usageAfter = Number(compactResult.tokenUsage?.projectedAfter);
5047
+ const hasUsageNumbers = Number.isFinite(usageBefore) && Number.isFinite(usageAfter);
5048
+ const compactedMessage = hasUsageNumbers
5049
+ ? `Context compacted for this send (${formatTokenCount(usageBefore)} -> ${formatTokenCount(usageAfter)} tokens).`
5050
+ : 'Older turns were compacted to keep this thread within context limits.';
5051
+ setCompactionNotice({
5052
+ level: 'success',
5053
+ message: compactResult.warning || compactedMessage,
5054
+ });
5055
+ } else if (compactResult.action === 'fallback_window') {
5056
+ setCompactionNotice({
5057
+ level: 'warning',
5058
+ message: compactResult.warning || 'Compaction fallback applied. Sending only the most recent turns.',
5059
+ });
5060
+ } else {
5061
+ setCompactionNotice(null);
5062
+ }
4301
5063
  }
4302
- }
4303
- // Detect 413 Content Too Large error
4304
- else if (errorMsg.includes('413') || errorMsg.toLowerCase().includes('content too large')) {
4305
- setError({
4306
- message: 'The context is too large to process. Please start a new conversation or reduce the amount of context.',
4307
- code: '413',
5064
+ } catch (error) {
5065
+ console.warn('[AIChatPanel] compactContext failed, applying local fallback window', error);
5066
+ sendMessages = buildFallbackWindowMessages(messagesAndHistory, preserveTurnsForCompaction);
5067
+ setCompactionNotice({
5068
+ level: 'warning',
5069
+ message: 'Compaction service unavailable. Sending a reduced recent-window context.',
4308
5070
  });
4309
-
4310
- // Update history to show error
4311
- if (promptKey) {
4312
- setHistory((prev) => {
4313
- const existingEntry = prev[promptKey] || { content: '', callId: '' };
4314
- return {
4315
- ...prev,
4316
- [promptKey]: {
4317
- ...existingEntry,
4318
- content: `Error: ${errorMsg}`,
4319
- callId: lastCallId || existingEntry.callId || '',
4320
- },
4321
- };
5071
+ }
5072
+ }
5073
+
5074
+ send(
5075
+ sendPrompt,
5076
+ sendMessages,
5077
+ [
5078
+ ...dataEntries,
5079
+ { key: '--messages', data: sendMessages.length.toString() },
5080
+ ],
5081
+ true, // stream
5082
+ true, // includeHistory
5083
+ service, // group_id from agent config
5084
+ convId, // Use the conversation ID from ensureConversation
5085
+ newController,
5086
+ undefined, // onComplete
5087
+ (errorMsg: string) => {
5088
+ // Error callback - handle errors immediately
5089
+ console.log('[AIChatPanel] Error callback triggered:', errorMsg);
5090
+
5091
+ // Check if this is a user-initiated abort
5092
+ const isAbortError = errorMsg.toLowerCase().includes('abort')
5093
+ || errorMsg.toLowerCase().includes('canceled')
5094
+ || errorMsg.toLowerCase().includes('cancelled');
5095
+
5096
+ if (isAbortError) {
5097
+ if (suppressAbortHistoryUpdateRef.current) {
5098
+ setIsLoading(false);
5099
+ releaseTurnLockIfSettled();
5100
+ return;
5101
+ }
5102
+ // User canceled the request - don't show error banner
5103
+ console.log('[AIChatPanel] Request was aborted by user');
5104
+
5105
+ // Update history to show cancellation
5106
+ if (promptKey) {
5107
+ setHistory((prev) => {
5108
+ const existingEntry = prev[promptKey] || { content: '', callId: '' };
5109
+ return {
5110
+ ...prev,
5111
+ [promptKey]: {
5112
+ ...existingEntry,
5113
+ content: 'Response canceled',
5114
+ callId: lastCallId || existingEntry.callId || '',
5115
+ },
5116
+ };
5117
+ });
5118
+ }
5119
+ }
5120
+ // Detect 413 Content Too Large error
5121
+ else if (errorMsg.includes('413') || errorMsg.toLowerCase().includes('content too large')) {
5122
+ setError({
5123
+ message: 'The context is too large to process. Please start a new conversation or reduce the amount of context.',
5124
+ code: '413',
4322
5125
  });
5126
+
5127
+ if (promptKey) {
5128
+ setHistory((prev) => {
5129
+ const existingEntry = prev[promptKey] || { content: '', callId: '' };
5130
+ return {
5131
+ ...prev,
5132
+ [promptKey]: {
5133
+ ...existingEntry,
5134
+ content: `Error: ${errorMsg}`,
5135
+ callId: lastCallId || existingEntry.callId || '',
5136
+ },
5137
+ };
5138
+ });
5139
+ }
4323
5140
  }
4324
- }
4325
- // Detect other network errors
4326
- else if (errorMsg.toLowerCase().includes('network error') || errorMsg.toLowerCase().includes('fetch')) {
4327
- setError({
4328
- message: 'Network error. Please check your connection and try again.',
4329
- code: 'NETWORK_ERROR',
4330
- });
4331
-
4332
- // Update history to show error
4333
- if (promptKey) {
4334
- setHistory((prev) => {
4335
- const existingEntry = prev[promptKey] || { content: '', callId: '' };
4336
- return {
4337
- ...prev,
4338
- [promptKey]: {
4339
- ...existingEntry,
4340
- content: `Error: ${errorMsg}`,
4341
- callId: lastCallId || existingEntry.callId || '',
4342
- },
4343
- };
5141
+ // Detect other network errors
5142
+ else if (errorMsg.toLowerCase().includes('network error') || errorMsg.toLowerCase().includes('fetch')) {
5143
+ setError({
5144
+ message: 'Network error. Please check your connection and try again.',
5145
+ code: 'NETWORK_ERROR',
4344
5146
  });
5147
+
5148
+ if (promptKey) {
5149
+ setHistory((prev) => {
5150
+ const existingEntry = prev[promptKey] || { content: '', callId: '' };
5151
+ return {
5152
+ ...prev,
5153
+ [promptKey]: {
5154
+ ...existingEntry,
5155
+ content: `Error: ${errorMsg}`,
5156
+ callId: lastCallId || existingEntry.callId || '',
5157
+ },
5158
+ };
5159
+ });
5160
+ }
4345
5161
  }
4346
- }
4347
- // Generic error
4348
- else {
4349
- setError({
4350
- message: errorMsg,
4351
- code: 'UNKNOWN_ERROR',
4352
- });
4353
-
4354
- // Update history to show error
4355
- if (promptKey) {
4356
- setHistory((prev) => {
4357
- const existingEntry = prev[promptKey] || { content: '', callId: '' };
4358
- return {
4359
- ...prev,
4360
- [promptKey]: {
4361
- ...existingEntry,
4362
- content: `Error: ${errorMsg}`,
4363
- callId: lastCallId || existingEntry.callId || '',
4364
- },
4365
- };
5162
+ // Generic error
5163
+ else {
5164
+ setError({
5165
+ message: errorMsg,
5166
+ code: 'UNKNOWN_ERROR',
4366
5167
  });
5168
+
5169
+ if (promptKey) {
5170
+ setHistory((prev) => {
5171
+ const existingEntry = prev[promptKey] || { content: '', callId: '' };
5172
+ return {
5173
+ ...prev,
5174
+ [promptKey]: {
5175
+ ...existingEntry,
5176
+ content: `Error: ${errorMsg}`,
5177
+ callId: lastCallId || existingEntry.callId || '',
5178
+ },
5179
+ };
5180
+ });
5181
+ }
4367
5182
  }
4368
- }
4369
-
4370
- // Reset loading state
4371
- setIsLoading(false);
4372
- releaseTurnLockIfSettled();
5183
+
5184
+ setIsLoading(false);
5185
+ releaseTurnLockIfSettled();
5186
+ },
5187
+ );
5188
+
5189
+ setLastMessages(sendMessages);
5190
+
5191
+ if (convId && onConversationCreated) {
5192
+ setTimeout(() => {
5193
+ onConversationCreated(convId);
5194
+ }, 100);
4373
5195
  }
4374
- );
4375
-
4376
- setLastMessages(messagesAndHistory);
4377
-
4378
- // Notify parent of new conversation ID AFTER send() has started
4379
- // This prevents the component from being remounted before send() runs
4380
- if (convId && onConversationCreated) {
4381
- // Use setTimeout to ensure send() has fully started before triggering re-render
4382
- setTimeout(() => {
4383
- onConversationCreated(convId);
4384
- }, 100);
5196
+ } catch (error) {
5197
+ console.error('[AIChatPanel] Failed to send prompt:', error);
5198
+ setError({
5199
+ message: error instanceof Error ? error.message : 'Failed to send prompt',
5200
+ code: 'UNKNOWN_ERROR',
5201
+ });
5202
+ setIsLoading(false);
5203
+ releaseTurnLockIfSettled();
4385
5204
  }
4386
- }).catch((error) => {
4387
- console.error('[AIChatPanel] Failed to send prompt:', error);
4388
- setError({
4389
- message: error instanceof Error ? error.message : 'Failed to send prompt',
4390
- code: 'UNKNOWN_ERROR',
4391
- });
4392
- setIsLoading(false);
4393
- releaseTurnLockIfSettled();
4394
- });
5205
+ })();
4395
5206
  }, [
4396
5207
  clearFollowOnQuestionsNextPrompt,
4397
5208
  promptTemplate,
@@ -4406,6 +5217,10 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
4406
5217
  scrollToBottom,
4407
5218
  onConversationCreated,
4408
5219
  onBeforeSend,
5220
+ compactContext,
5221
+ compactionPreserveTurns,
5222
+ maxContextTokens,
5223
+ totalContextTokens,
4409
5224
  hasInFlightTurnWork,
4410
5225
  queuePromptForLater,
4411
5226
  releaseTurnLockIfSettled,
@@ -4431,8 +5246,15 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
4431
5246
 
4432
5247
  // Handle stop - stable callback for ChatInput
4433
5248
  const handleStop = useCallback(() => {
5249
+ if (pendingUserInputRequest) {
5250
+ completePendingUserInputRequest({
5251
+ status: 'cancelled',
5252
+ requestId: pendingUserInputRequest.requestId,
5253
+ cancelledBy: 'stop',
5254
+ });
5255
+ }
4434
5256
  stop(lastController);
4435
- }, [stop, lastController]);
5257
+ }, [completePendingUserInputRequest, lastController, pendingUserInputRequest, stop]);
4436
5258
 
4437
5259
  // Reset conversation
4438
5260
  const handleNewConversation = useCallback(() => {
@@ -4447,9 +5269,17 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
4447
5269
  if (!idle) {
4448
5270
  stop(lastController);
4449
5271
  }
5272
+ if (pendingUserInputRequest) {
5273
+ completePendingUserInputRequest({
5274
+ status: 'cancelled',
5275
+ requestId: pendingUserInputRequest.requestId,
5276
+ cancelledBy: 'reset',
5277
+ });
5278
+ }
4450
5279
 
4451
5280
  setResponse('');
4452
5281
  setHistory({});
5282
+ setCompactionNotice(null);
4453
5283
  latestHistoryRef.current = {}; // Keep ref in sync
4454
5284
  hasNotifiedCompletionRef.current = true; // Prevent stale notifications
4455
5285
  queuedPromptsRef.current = [];
@@ -4471,6 +5301,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
4471
5301
  setThinkingBlocks([]);
4472
5302
  setCurrentThinkingIndex(0);
4473
5303
  setCollapsedBlocks(new Set());
5304
+ setPendingUserInputRequest(null);
4474
5305
  // Note: activeThinkingBlock is computed via useMemo from response
4475
5306
  hasAutoCollapsedRef.current = false;
4476
5307
  prevBlockCountRef.current = 0;
@@ -4485,7 +5316,16 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
4485
5316
  setJustReset(false);
4486
5317
  responseAreaRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
4487
5318
  }, 100);
4488
- }, [newConversationConfirm, idle, stop, lastController, setResponse, followOnQuestions]);
5319
+ }, [
5320
+ completePendingUserInputRequest,
5321
+ followOnQuestions,
5322
+ idle,
5323
+ lastController,
5324
+ newConversationConfirm,
5325
+ pendingUserInputRequest,
5326
+ setResponse,
5327
+ stop,
5328
+ ]);
4489
5329
 
4490
5330
  // ============================================================================
4491
5331
  // Effects - CLEAN DESIGN: No overlapping effects, explicit triggers only
@@ -5539,6 +6379,19 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
5539
6379
  </button>
5540
6380
  </div>
5541
6381
  )}
6382
+
6383
+ {compactionNotice && compactionNotice.level !== 'info' && (
6384
+ <div className={`ai-chat-compaction-banner ai-chat-compaction-banner--${compactionNotice.level}`}>
6385
+ <div className="ai-chat-compaction-banner__message">{compactionNotice.message}</div>
6386
+ <button
6387
+ className="ai-chat-compaction-banner__close"
6388
+ onClick={() => setCompactionNotice(null)}
6389
+ aria-label="Dismiss context notice"
6390
+ >
6391
+ <CloseIcon />
6392
+ </button>
6393
+ </div>
6394
+ )}
5542
6395
 
5543
6396
  {/* Messages Area */}
5544
6397
  <ScrollArea className="ai-chat-panel__messages" ref={responseAreaRef}>
@@ -5919,6 +6772,12 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
5919
6772
  <div ref={bottomRef} />
5920
6773
  </ScrollArea>
5921
6774
 
6775
+ {compactionNotice?.level === 'info' && (
6776
+ <div className="ai-chat-compaction-inline-status" role="status" aria-live="polite">
6777
+ <span>{compactionNotice.message}</span>
6778
+ </div>
6779
+ )}
6780
+
5922
6781
  {/* Button Container - Save, Email, CTA */}
5923
6782
  {(showSaveButton || showEmailButton || showCallToAction) && (
5924
6783
  <div className="ai-chat-button-container">
@@ -6097,6 +6956,8 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
6097
6956
  onSubmit={continueChat}
6098
6957
  onQueueSubmit={handleQueuePrompt}
6099
6958
  onStop={handleStop}
6959
+ userInputRequest={pendingUserInputRequest}
6960
+ onCompleteUserInputRequest={completePendingUserInputRequest}
6100
6961
  queuedPrompts={queuedPrompts}
6101
6962
  onClearQueuedPrompt={handleClearQueuedPrompt}
6102
6963
  agentOptions={agentOptions}
@@ -6111,6 +6972,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
6111
6972
  enableContextDetailView={enableContextDetailView}
6112
6973
  disabledSectionIds={disabledSectionIds}
6113
6974
  onToggleSection={handleToggleSection}
6975
+ composerAgentModeControl={composerAgentModeControl}
6114
6976
  />
6115
6977
 
6116
6978
  {/* Footer */}