@hef2024/llmasaservice-ui 0.25.0 → 0.25.2

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.
@@ -2141,6 +2141,10 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2141
2141
  const queuedToolRequestsRef = useRef<ToolRequestMatch[] | null>(null);
2142
2142
  const suppressAbortHistoryUpdateRef = useRef<boolean>(false);
2143
2143
  const toolReplaySummariesByKeyRef = useRef<Record<string, ToolReplaySummaryEntry[]>>({});
2144
+ const continuationDispatchPendingRef = useRef<boolean>(false);
2145
+ const successfulMutationSignaturesRef = useRef<Set<string>>(new Set());
2146
+ const queuedDrainTimerRef = useRef<number | null>(null);
2147
+ const queuedDrainScheduledRef = useRef<boolean>(false);
2144
2148
 
2145
2149
  // Sync new entries from initialHistory into local history state
2146
2150
  // This allows parent components to inject messages (e.g., page-based agent suggestions)
@@ -2798,6 +2802,8 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2798
2802
  }, [customerEmailCaptureMode, emailInputSet]);
2799
2803
 
2800
2804
  const pendingToolRequestsRef = useRef<ToolRequestMatch[]>(pendingToolRequests);
2805
+ const activeToolCallsRef = useRef(activeToolCalls);
2806
+ const turnLockRef = useRef<boolean>(false);
2801
2807
  const streamIdleRef = useRef(idle);
2802
2808
  streamIdleRef.current = idle;
2803
2809
 
@@ -2814,6 +2820,50 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2814
2820
  pendingToolRequestsRef.current = pendingToolRequests;
2815
2821
  }, [pendingToolRequests]);
2816
2822
 
2823
+ useEffect(() => {
2824
+ activeToolCallsRef.current = activeToolCalls;
2825
+ }, [activeToolCalls]);
2826
+
2827
+ const hasUnresolvedToolRequestsInLatestResponse = useCallback((): boolean => {
2828
+ const latestResponse = String(responseRef.current || '');
2829
+ if (!latestResponse.trim()) return false;
2830
+ const latestRequests = extractToolRequestMatchesFromText(latestResponse);
2831
+ if (latestRequests.length === 0) return false;
2832
+
2833
+ return latestRequests.some((request) => {
2834
+ const signature = getToolCallSignature(request.toolName, request.callId);
2835
+ if (!signature) return false;
2836
+ if (handledToolCallSignaturesRef.current.has(signature)) return false;
2837
+ if (inFlightToolCallSignaturesRef.current.has(signature)) return false;
2838
+ return true;
2839
+ });
2840
+ }, [getToolCallSignature]);
2841
+
2842
+ const hasInFlightTurnWork = useCallback((): boolean => {
2843
+ return (
2844
+ toolRequestProcessingRef.current ||
2845
+ continuationDispatchPendingRef.current ||
2846
+ (queuedToolRequestsRef.current || []).length > 0 ||
2847
+ (pendingToolRequestsRef.current || []).length > 0 ||
2848
+ (activeToolCallsRef.current || []).length > 0 ||
2849
+ !streamIdleRef.current ||
2850
+ hasUnresolvedToolRequestsInLatestResponse()
2851
+ );
2852
+ }, [hasUnresolvedToolRequestsInLatestResponse]);
2853
+
2854
+ const queuePromptForLater = useCallback((promptText: string) => {
2855
+ const normalizedPrompt = String(promptText || '').trim();
2856
+ if (!normalizedPrompt) return;
2857
+ const nextQueuedPrompts = [...queuedPromptsRef.current, normalizedPrompt];
2858
+ queuedPromptsRef.current = nextQueuedPrompts;
2859
+ setQueuedPrompts(nextQueuedPrompts);
2860
+ }, []);
2861
+
2862
+ const releaseTurnLockIfSettled = useCallback(() => {
2863
+ if (hasInFlightTurnWork()) return;
2864
+ turnLockRef.current = false;
2865
+ }, [hasInFlightTurnWork]);
2866
+
2817
2867
  const processGivenToolRequests = useCallback(
2818
2868
  async (requests: ToolRequestMatch[]) => {
2819
2869
  const dedupeToolRequests = (input: ToolRequestMatch[]): ToolRequestMatch[] => {
@@ -2840,6 +2890,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2840
2890
  return;
2841
2891
  }
2842
2892
  toolRequestProcessingRef.current = true;
2893
+ turnLockRef.current = true;
2843
2894
 
2844
2895
  try {
2845
2896
  let requestsToProcess = requests;
@@ -2931,6 +2982,61 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2931
2982
  }
2932
2983
  }
2933
2984
 
2985
+ const normalizeToolArgsForSignature = (value: unknown): unknown => {
2986
+ if (Array.isArray(value)) {
2987
+ return value.map((item) => normalizeToolArgsForSignature(item));
2988
+ }
2989
+ if (value && typeof value === 'object') {
2990
+ const normalizedObject: Record<string, unknown> = {};
2991
+ Object.keys(value as Record<string, unknown>)
2992
+ .sort()
2993
+ .forEach((key) => {
2994
+ normalizedObject[key] = normalizeToolArgsForSignature(
2995
+ (value as Record<string, unknown>)[key],
2996
+ );
2997
+ });
2998
+ return normalizedObject;
2999
+ }
3000
+ return value ?? null;
3001
+ };
3002
+
3003
+ const getSemanticMutationSignature = (
3004
+ toolName: string,
3005
+ args: Record<string, unknown>,
3006
+ ): string => {
3007
+ const normalizedName = String(toolName || '').trim().toLowerCase();
3008
+ const normalizedArgs = normalizeToolArgsForSignature(args);
3009
+ return `${normalizedName}::${JSON.stringify(normalizedArgs)}`;
3010
+ };
3011
+
3012
+ const isLikelyMutatingCall = (
3013
+ toolName: string,
3014
+ args: Record<string, unknown>,
3015
+ ): boolean => {
3016
+ const normalizedName = String(toolName || '').trim().toLowerCase();
3017
+ const action = String((args as Record<string, unknown>)?.action || '').trim().toLowerCase();
3018
+ const mutatingActions = new Set([
3019
+ 'add',
3020
+ 'create',
3021
+ 'materialize_from_sourcing_result',
3022
+ 'materialize',
3023
+ 'update',
3024
+ 'delete',
3025
+ 'remove',
3026
+ 'cancel',
3027
+ 'complete',
3028
+ 'move',
3029
+ ]);
3030
+
3031
+ if (action && mutatingActions.has(action)) return true;
3032
+ return (
3033
+ normalizedName.includes('pipeline') ||
3034
+ normalizedName.includes('work_item') ||
3035
+ normalizedName.includes('candidate_pipeline') ||
3036
+ normalizedName.includes('cron')
3037
+ );
3038
+ };
3039
+
2934
3040
  const parsedToolCalls = await Promise.all(
2935
3041
  requestsToProcess.map(async (req, index) => {
2936
3042
  let parsedToolCall: any = null;
@@ -2990,6 +3096,8 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2990
3096
 
2991
3097
  const callSignature = getToolCallSignature(toolName, callId);
2992
3098
  if (!callSignature) return null;
3099
+ const semanticMutationSignature = getSemanticMutationSignature(toolName, args);
3100
+ const likelyMutating = isLikelyMutatingCall(toolName, args);
2993
3101
 
2994
3102
  return {
2995
3103
  req,
@@ -2998,6 +3106,8 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2998
3106
  args,
2999
3107
  serviceTag,
3000
3108
  callSignature,
3109
+ semanticMutationSignature,
3110
+ likelyMutating,
3001
3111
  };
3002
3112
  }),
3003
3113
  );
@@ -3009,9 +3119,12 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
3009
3119
  args: Record<string, unknown>;
3010
3120
  serviceTag: string;
3011
3121
  callSignature: string;
3122
+ semanticMutationSignature: string;
3123
+ likelyMutating: boolean;
3012
3124
  }>;
3013
3125
 
3014
3126
  const seenCallSignatures = new Set<string>();
3127
+ const seenMutationSemanticsInBatch = new Set<string>();
3015
3128
  const callsToRun: Array<{
3016
3129
  req: ToolRequestMatch;
3017
3130
  toolName: string;
@@ -3019,6 +3132,18 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
3019
3132
  args: Record<string, unknown>;
3020
3133
  serviceTag: string;
3021
3134
  callSignature: string;
3135
+ semanticMutationSignature: string;
3136
+ likelyMutating: boolean;
3137
+ }> = [];
3138
+ const suppressedMutationDuplicates: Array<{
3139
+ req: ToolRequestMatch;
3140
+ toolName: string;
3141
+ callId: string;
3142
+ args: Record<string, unknown>;
3143
+ serviceTag: string;
3144
+ callSignature: string;
3145
+ semanticMutationSignature: string;
3146
+ likelyMutating: boolean;
3022
3147
  }> = [];
3023
3148
 
3024
3149
  toolCallBatch.forEach((toolCall) => {
@@ -3027,11 +3152,25 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
3027
3152
 
3028
3153
  if (handledToolCallSignaturesRef.current.has(toolCall.callSignature)) return;
3029
3154
  if (inFlightToolCallSignaturesRef.current.has(toolCall.callSignature)) return;
3155
+ if (toolCall.likelyMutating && seenMutationSemanticsInBatch.has(toolCall.semanticMutationSignature)) {
3156
+ suppressedMutationDuplicates.push(toolCall);
3157
+ return;
3158
+ }
3159
+ if (
3160
+ toolCall.likelyMutating &&
3161
+ successfulMutationSignaturesRef.current.has(toolCall.semanticMutationSignature)
3162
+ ) {
3163
+ suppressedMutationDuplicates.push(toolCall);
3164
+ return;
3165
+ }
3166
+ if (toolCall.likelyMutating) {
3167
+ seenMutationSemanticsInBatch.add(toolCall.semanticMutationSignature);
3168
+ }
3030
3169
 
3031
3170
  callsToRun.push(toolCall);
3032
3171
  });
3033
3172
 
3034
- if (callsToRun.length === 0) {
3173
+ if (callsToRun.length === 0 && suppressedMutationDuplicates.length === 0) {
3035
3174
  setPendingToolRequests((prev) =>
3036
3175
  prev.filter((request) => {
3037
3176
  const signature = getToolCallSignature(request.toolName, request.callId);
@@ -3044,11 +3183,14 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
3044
3183
  return;
3045
3184
  }
3046
3185
 
3047
- const callsToRunSignatures = new Set(callsToRun.map((toolCall) => toolCall.callSignature));
3186
+ const handledBatchSignatures = new Set([
3187
+ ...callsToRun.map((toolCall) => toolCall.callSignature),
3188
+ ...suppressedMutationDuplicates.map((toolCall) => toolCall.callSignature),
3189
+ ]);
3048
3190
  setPendingToolRequests((prev) =>
3049
3191
  prev.filter((request) => {
3050
3192
  const signature = getToolCallSignature(request.toolName, request.callId);
3051
- return !signature || !callsToRunSignatures.has(signature);
3193
+ return !signature || !handledBatchSignatures.has(signature);
3052
3194
  }),
3053
3195
  );
3054
3196
  callsToRun.forEach((toolCall) => {
@@ -3063,7 +3205,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
3063
3205
  })),
3064
3206
  );
3065
3207
 
3066
- const finalToolCalls = callsToRun.map((toolCall) => ({
3208
+ const finalToolCalls = [...callsToRun, ...suppressedMutationDuplicates].map((toolCall) => ({
3067
3209
  id: toolCall.callId,
3068
3210
  type: 'tool_use',
3069
3211
  name: toolCall.toolName,
@@ -3233,12 +3375,32 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
3233
3375
  }),
3234
3376
  );
3235
3377
 
3236
- finalToolResponses = toolResponses.filter(Boolean) as any[];
3378
+ const executedResponses = toolResponses.filter(Boolean) as any[];
3379
+ const suppressedResponses = suppressedMutationDuplicates.map((toolCall) => ({
3380
+ tool_call_id: toolCall.callId,
3381
+ tool_name: toolCall.toolName,
3382
+ result:
3383
+ 'Duplicate mutating call suppressed: this exact action already succeeded earlier in the same turn.',
3384
+ isError: false,
3385
+ }));
3386
+ finalToolResponses = [...executedResponses, ...suppressedResponses];
3387
+
3388
+ callsToRun.forEach((toolCall) => {
3389
+ const matchedResponse = executedResponses.find(
3390
+ (response) => response?.tool_call_id === toolCall.callId,
3391
+ );
3392
+ if (!matchedResponse?.isError && toolCall.likelyMutating) {
3393
+ successfulMutationSignaturesRef.current.add(toolCall.semanticMutationSignature);
3394
+ }
3395
+ });
3237
3396
  } finally {
3238
3397
  callsToRun.forEach((toolCall) => {
3239
3398
  inFlightToolCallSignaturesRef.current.delete(toolCall.callSignature);
3240
3399
  handledToolCallSignaturesRef.current.add(toolCall.callSignature);
3241
3400
  });
3401
+ suppressedMutationDuplicates.forEach((toolCall) => {
3402
+ handledToolCallSignaturesRef.current.add(toolCall.callSignature);
3403
+ });
3242
3404
  }
3243
3405
 
3244
3406
  // Keep the running state visible during execution; clear it only after completion.
@@ -3369,6 +3531,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
3369
3531
  if (!streamIdleRef.current) {
3370
3532
  setActiveToolCalls([]);
3371
3533
  setIsLoading(false);
3534
+ continuationDispatchPendingRef.current = false;
3372
3535
  setError({
3373
3536
  message: 'Timed out waiting for the previous stream to settle before tool continuation.',
3374
3537
  code: 'TOOL_CONTINUATION_WAIT_TIMEOUT',
@@ -3388,6 +3551,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
3388
3551
  } else {
3389
3552
  activeStreamAppendBaseRef.current = null;
3390
3553
  }
3554
+ continuationDispatchPendingRef.current = true;
3391
3555
  send(
3392
3556
  '',
3393
3557
  newMessages,
@@ -3405,12 +3569,14 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
3405
3569
  newController,
3406
3570
  undefined,
3407
3571
  (errorMsg: string) => {
3572
+ continuationDispatchPendingRef.current = false;
3408
3573
  setActiveToolCalls([]);
3409
3574
  setIsLoading(false);
3410
3575
  setError({
3411
3576
  message: errorMsg,
3412
3577
  code: 'TOOL_ERROR',
3413
3578
  });
3579
+ releaseTurnLockIfSettled();
3414
3580
  },
3415
3581
  );
3416
3582
  } finally {
@@ -3421,6 +3587,8 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
3421
3587
  queueMicrotask(() => {
3422
3588
  void processGivenToolRequests(queued);
3423
3589
  });
3590
+ } else {
3591
+ releaseTurnLockIfSettled();
3424
3592
  }
3425
3593
  }
3426
3594
  },
@@ -3440,6 +3608,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
3440
3608
  stop,
3441
3609
  lastController,
3442
3610
  waitForStreamIdle,
3611
+ releaseTurnLockIfSettled,
3443
3612
  ],
3444
3613
  );
3445
3614
 
@@ -3469,6 +3638,8 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
3469
3638
 
3470
3639
  useEffect(() => {
3471
3640
  if (pendingToolRequests.length === 0) return;
3641
+ if (!idle) return;
3642
+ if (continuationDispatchPendingRef.current) return;
3472
3643
 
3473
3644
  const configuredAutoApproveTools = Array.isArray(autoApproveTools)
3474
3645
  ? new Set(
@@ -3503,6 +3674,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
3503
3674
  }
3504
3675
  }, [
3505
3676
  autoApproveTools,
3677
+ idle,
3506
3678
  pendingToolRequests,
3507
3679
  sessionApprovedTools,
3508
3680
  alwaysApprovedTools,
@@ -3955,11 +4127,24 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
3955
4127
  // Continue chat (send message) - matches ChatPanel behavior exactly
3956
4128
  // promptText is now required - comes from the isolated ChatInput component
3957
4129
  const continueChat = useCallback((promptText: string) => {
4130
+ const promptToSend = String(promptText || '').trim();
4131
+ if (!promptToSend) return;
4132
+
4133
+ // Hard serialization guard: never start a second send while the current turn
4134
+ // is still settling (including tool/continuation phases).
4135
+ if (turnLockRef.current || hasInFlightTurnWork()) {
4136
+ queuePromptForLater(promptToSend);
4137
+ return;
4138
+ }
4139
+ turnLockRef.current = true;
4140
+
3958
4141
  handledToolCallSignaturesRef.current = new Set();
3959
4142
  inFlightToolCallSignaturesRef.current = new Set();
3960
4143
  toolContinuationCountRef.current = 0;
4144
+ successfulMutationSignaturesRef.current = new Set();
3961
4145
  activeStreamAppendBaseRef.current = null;
3962
4146
  toolReplaySummariesByKeyRef.current = {};
4147
+ continuationDispatchPendingRef.current = false;
3963
4148
  setPendingToolRequests([]);
3964
4149
  setActiveToolCalls([]);
3965
4150
 
@@ -3993,33 +4178,16 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
3993
4178
  // when the history update effect runs
3994
4179
  setResponse('');
3995
4180
 
3996
- // Handle stop if not idle (matches ChatPanel)
3997
- if (!idle) {
3998
- stop(lastController);
3999
- setHistory((prevHistory) => ({
4000
- ...prevHistory,
4001
- [lastKey ?? '']: {
4002
- content: processThinkingTags(response).cleanedText + '\n\n(response cancelled)',
4003
- callId: lastCallId || '',
4004
- },
4005
- }));
4006
- return;
4007
- }
4008
-
4009
4181
  if (clearFollowOnQuestionsNextPrompt) {
4010
4182
  setFollowOnQuestionsState([]);
4011
4183
  }
4012
-
4013
- const promptToSend = promptText;
4014
-
4015
- if (!promptToSend || !promptToSend.trim()) return;
4016
-
4184
+
4017
4185
  setIsLoading(true);
4018
4186
 
4019
4187
  // === OPTIMISTIC UPDATE: Show prompt immediately in UI ===
4020
4188
  // Generate unique key using ISO timestamp prefix + prompt
4021
4189
  const timestamp = new Date().toISOString();
4022
- const promptKey = `${timestamp}:${promptToSend.trim()}`;
4190
+ const promptKey = `${timestamp}:${promptToSend}`;
4023
4191
 
4024
4192
  // Add prompt to history IMMEDIATELY - this makes it appear in the UI right away
4025
4193
  setHistory((prevHistory) => ({
@@ -4029,7 +4197,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
4029
4197
  toolReplaySummariesByKeyRef.current[promptKey] = [];
4030
4198
 
4031
4199
  // Store the key for later use
4032
- setLastPrompt(promptToSend.trim());
4200
+ setLastPrompt(promptToSend);
4033
4201
  setLastKey(promptKey);
4034
4202
 
4035
4203
  // Scroll to bottom immediately to show the new prompt
@@ -4060,7 +4228,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
4060
4228
 
4061
4229
  // Build the full prompt - only apply template for first message (matches ChatPanel)
4062
4230
  // Check if this is the first message by seeing if messagesAndHistory is empty
4063
- let fullPromptToSend = promptToSend.trim();
4231
+ let fullPromptToSend = promptToSend;
4064
4232
  if (messagesAndHistory.length === 0 && promptTemplate) {
4065
4233
  fullPromptToSend = promptTemplate.replace('{{prompt}}', fullPromptToSend);
4066
4234
  }
@@ -4071,7 +4239,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
4071
4239
  if (onBeforeSend) {
4072
4240
  void Promise.resolve(
4073
4241
  onBeforeSend({
4074
- prompt: promptToSend.trim(),
4242
+ prompt: promptToSend,
4075
4243
  conversationId: convId || null,
4076
4244
  agentId: agent,
4077
4245
  service,
@@ -4110,6 +4278,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
4110
4278
  if (isAbortError) {
4111
4279
  if (suppressAbortHistoryUpdateRef.current) {
4112
4280
  setIsLoading(false);
4281
+ releaseTurnLockIfSettled();
4113
4282
  return;
4114
4283
  }
4115
4284
  // User canceled the request - don't show error banner
@@ -4200,6 +4369,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
4200
4369
 
4201
4370
  // Reset loading state
4202
4371
  setIsLoading(false);
4372
+ releaseTurnLockIfSettled();
4203
4373
  }
4204
4374
  );
4205
4375
 
@@ -4213,15 +4383,16 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
4213
4383
  onConversationCreated(convId);
4214
4384
  }, 100);
4215
4385
  }
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();
4216
4394
  });
4217
4395
  }, [
4218
- idle,
4219
- stop,
4220
- lastController,
4221
- lastKey,
4222
- response,
4223
- lastCallId,
4224
- processThinkingTags,
4225
4396
  clearFollowOnQuestionsNextPrompt,
4226
4397
  promptTemplate,
4227
4398
  send,
@@ -4235,18 +4406,17 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
4235
4406
  scrollToBottom,
4236
4407
  onConversationCreated,
4237
4408
  onBeforeSend,
4409
+ hasInFlightTurnWork,
4410
+ queuePromptForLater,
4411
+ releaseTurnLockIfSettled,
4238
4412
  getThinkingBlockCollapseKey,
4239
4413
  getThinkingBlockRenderKey,
4240
4414
  setResponse,
4241
4415
  ]);
4242
4416
 
4243
4417
  const handleQueuePrompt = useCallback((promptText: string) => {
4244
- const normalizedPrompt = String(promptText || '').trim();
4245
- if (!normalizedPrompt) return;
4246
- const nextQueuedPrompts = [...queuedPromptsRef.current, normalizedPrompt];
4247
- queuedPromptsRef.current = nextQueuedPrompts;
4248
- setQueuedPrompts(nextQueuedPrompts);
4249
- }, []);
4418
+ queuePromptForLater(promptText);
4419
+ }, [queuePromptForLater]);
4250
4420
 
4251
4421
  const handleClearQueuedPrompt = useCallback((index: number) => {
4252
4422
  const nextQueuedPrompts = queuedPromptsRef.current.filter((_, queueIndex) => queueIndex !== index);
@@ -4264,20 +4434,6 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
4264
4434
  stop(lastController);
4265
4435
  }, [stop, lastController]);
4266
4436
 
4267
- useEffect(() => {
4268
- const nextQueuedPrompt = String(queuedPromptsRef.current[0] || '').trim();
4269
- if (!nextQueuedPrompt) return;
4270
- if (!idle || isLoading) return;
4271
- if (pendingToolRequests.length > 0 || activeToolCalls.length > 0) return;
4272
- if (toolRequestProcessingRef.current) return;
4273
- if ((queuedToolRequestsRef.current || []).length > 0) return;
4274
-
4275
- const remainingQueuedPrompts = queuedPromptsRef.current.slice(1);
4276
- queuedPromptsRef.current = remainingQueuedPrompts;
4277
- setQueuedPrompts(remainingQueuedPrompts);
4278
- continueChat(nextQueuedPrompt);
4279
- }, [activeToolCalls.length, continueChat, idle, isLoading, pendingToolRequests.length, queuedPrompts.length]);
4280
-
4281
4437
  // Reset conversation
4282
4438
  const handleNewConversation = useCallback(() => {
4283
4439
  if (!newConversationConfirm) {
@@ -4323,6 +4479,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
4323
4479
  setUserHasScrolled(false);
4324
4480
  setError(null); // Clear any errors
4325
4481
  setActiveToolCalls([]);
4482
+ turnLockRef.current = false;
4326
4483
 
4327
4484
  setTimeout(() => {
4328
4485
  setJustReset(false);
@@ -4354,6 +4511,9 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
4354
4511
  return true;
4355
4512
  });
4356
4513
 
4514
+ // Keep ref synchronized immediately so completion/drain guards evaluate
4515
+ // against the same response pass, not the previous render.
4516
+ pendingToolRequestsRef.current = unseenToolRequests;
4357
4517
  setPendingToolRequests((prev) => {
4358
4518
  if (areToolRequestListsEqual(prev, unseenToolRequests)) {
4359
4519
  return prev;
@@ -4483,11 +4643,14 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
4483
4643
  responseCompleteCallbackRef.current(currentLastCallId, currentLastPrompt || '', entry.content);
4484
4644
  }
4485
4645
  }
4646
+
4647
+ releaseTurnLockIfSettled();
4486
4648
  }
4487
4649
 
4488
4650
  // Reset notification flag when starting a new stream
4489
4651
  if (!isNowIdle && hasNotifiedCompletionRef.current) {
4490
4652
  hasNotifiedCompletionRef.current = false;
4653
+ continuationDispatchPendingRef.current = false;
4491
4654
  // Reset response length tracking for new stream
4492
4655
  prevResponseLengthRef.current = 0;
4493
4656
  const currentLastKey = lastKeyRef.current;
@@ -4504,7 +4667,59 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
4504
4667
  // Keep thinking UI state across follow-on streams triggered by tool calls.
4505
4668
  // New user prompts already reset this state explicitly before send().
4506
4669
  }
4507
- }, [idle]); // ONLY depends on idle - no history, no callbacks in deps
4670
+ }, [idle, releaseTurnLockIfSettled]); // ONLY depends on idle transition + lock-release guard
4671
+
4672
+ // Reconcile lock state on idle renders where tool markers/cards are cleared without
4673
+ // another idle transition (e.g., follow-up response replaces a prior tool-only payload).
4674
+ useEffect(() => {
4675
+ if (!idle) return;
4676
+ releaseTurnLockIfSettled();
4677
+ }, [idle, response, pendingToolRequests.length, activeToolCalls.length, releaseTurnLockIfSettled]);
4678
+
4679
+ // Drain queued prompts only after response history has been reconciled.
4680
+ // This effect intentionally sits after the response-processing/completion effects above
4681
+ // so queued sends inherit the latest finalized assistant turn context.
4682
+ useEffect(() => {
4683
+ const nextQueuedPrompt = String(queuedPromptsRef.current[0] || '').trim();
4684
+ if (!nextQueuedPrompt) return;
4685
+ if (!idle || isLoading) return;
4686
+ if (pendingToolRequests.length > 0 || activeToolCalls.length > 0) return;
4687
+ if (turnLockRef.current || hasInFlightTurnWork()) return;
4688
+ if (queuedDrainScheduledRef.current) return;
4689
+
4690
+ queuedDrainScheduledRef.current = true;
4691
+ queuedDrainTimerRef.current = window.setTimeout(() => {
4692
+ queuedDrainScheduledRef.current = false;
4693
+ queuedDrainTimerRef.current = null;
4694
+
4695
+ const queuedPrompt = String(queuedPromptsRef.current[0] || '').trim();
4696
+ if (!queuedPrompt) return;
4697
+ if (turnLockRef.current || hasInFlightTurnWork()) return;
4698
+
4699
+ const remainingQueuedPrompts = queuedPromptsRef.current.slice(1);
4700
+ queuedPromptsRef.current = remainingQueuedPrompts;
4701
+ setQueuedPrompts(remainingQueuedPrompts);
4702
+ continueChat(queuedPrompt);
4703
+ }, 0);
4704
+ }, [
4705
+ continueChat,
4706
+ hasInFlightTurnWork,
4707
+ idle,
4708
+ isLoading,
4709
+ pendingToolRequests.length,
4710
+ activeToolCalls.length,
4711
+ queuedPrompts.length,
4712
+ ]);
4713
+
4714
+ useEffect(() => {
4715
+ return () => {
4716
+ if (queuedDrainTimerRef.current !== null) {
4717
+ window.clearTimeout(queuedDrainTimerRef.current);
4718
+ queuedDrainTimerRef.current = null;
4719
+ }
4720
+ queuedDrainScheduledRef.current = false;
4721
+ };
4722
+ }, []);
4508
4723
 
4509
4724
  // Keep refs in sync
4510
4725
  useEffect(() => {