@kenkaiiii/ggcoder 4.2.39 → 4.2.40

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.
@@ -22,6 +22,31 @@ function estimateTokens(msgs) {
22
22
  }
23
23
  return Math.round(chars / 4);
24
24
  }
25
+ /**
26
+ * Merge multiple UserContent items into a single one.
27
+ * Text-only items are joined with newlines. Mixed content (text + images)
28
+ * is flattened into a content array preserving all parts.
29
+ */
30
+ function mergeUserContent(items) {
31
+ if (items.length === 1)
32
+ return items[0];
33
+ const hasArrayContent = items.some((c) => Array.isArray(c));
34
+ if (!hasArrayContent) {
35
+ // All items are strings — join with newlines
36
+ return items.join("\n");
37
+ }
38
+ // Flatten into a single content array
39
+ const parts = [];
40
+ for (const item of items) {
41
+ if (typeof item === "string") {
42
+ parts.push({ type: "text", text: item });
43
+ }
44
+ else {
45
+ parts.push(...item);
46
+ }
47
+ }
48
+ return parts;
49
+ }
25
50
  export function useAgentLoop(messages, options, callbacks) {
26
51
  const onComplete = callbacks?.onComplete;
27
52
  const onTurnText = callbacks?.onTurnText;
@@ -33,6 +58,7 @@ export function useAgentLoop(messages, options, callbacks) {
33
58
  const onTurnEnd = callbacks?.onTurnEnd;
34
59
  const onDone = callbacks?.onDone;
35
60
  const onAborted = callbacks?.onAborted;
61
+ const onQueuedStart = callbacks?.onQueuedStart;
36
62
  const [isRunning, setIsRunning] = useState(false);
37
63
  const [streamingText, setStreamingText] = useState("");
38
64
  const [streamingThinking, setStreamingThinking] = useState("");
@@ -46,6 +72,8 @@ export function useAgentLoop(messages, options, callbacks) {
46
72
  const [isThinking, setIsThinking] = useState(false);
47
73
  const [streamedTokenEstimate, setStreamedTokenEstimate] = useState(0);
48
74
  const abortRef = useRef(null);
75
+ const queueRef = useRef([]);
76
+ const [queuedCount, setQueuedCount] = useState(0);
49
77
  const activeToolCallsRef = useRef([]);
50
78
  const textPendingRef = useRef("");
51
79
  const textVisibleRef = useRef("");
@@ -182,254 +210,299 @@ export function useAgentLoop(messages, options, callbacks) {
182
210
  setThinkingMs(0);
183
211
  setIsThinking(false);
184
212
  setStreamedTokenEstimate(0);
213
+ queueRef.current = [];
214
+ setQueuedCount(0);
215
+ }, []);
216
+ const queueMessage = useCallback((content) => {
217
+ queueRef.current.push(content);
218
+ setQueuedCount(queueRef.current.length);
219
+ }, []);
220
+ const clearQueue = useCallback(() => {
221
+ queueRef.current = [];
222
+ setQueuedCount(0);
185
223
  }, []);
186
224
  const run = useCallback(async (userContent) => {
187
- const ac = new AbortController();
188
- abortRef.current = ac;
189
- let wasAborted = false;
190
- // Reset state
191
- doneCalledRef.current = false;
192
- textPendingRef.current = "";
193
- textVisibleRef.current = "";
194
- thinkingBufferRef.current = "";
195
- thinkingPendingRef.current = "";
196
- thinkingVisibleRef.current = "";
197
- runStartRef.current = Date.now();
198
- toolsUsedRef.current = new Set();
199
- charCountRef.current = 0;
200
- realTokensAccumRef.current = 0;
201
- thinkingAccumRef.current = 0;
202
- thinkingStartRef.current = null;
203
- phaseRef.current = "waiting";
204
- setStreamingText("");
205
- setStreamingThinking("");
206
- setActiveToolCalls([]);
207
- setActivityPhase("waiting");
208
- setElapsedMs(0);
209
- setThinkingMs(0);
210
- setIsThinking(false);
211
- setStreamedTokenEstimate(0);
212
- setIsRunning(true);
213
- // Start elapsed timer (ticks every 1000ms — less frequent to reduce
214
- // Ink re-renders which cause live-area flickering and viewport snapping)
215
- if (elapsedTimerRef.current)
216
- clearInterval(elapsedTimerRef.current);
217
- const timerStart = Date.now();
218
- elapsedTimerRef.current = setInterval(() => {
219
- const now = Date.now();
220
- setElapsedMs(now - timerStart);
221
- // Update live thinking time if currently thinking
222
- if (thinkingStartRef.current !== null) {
223
- setThinkingMs(thinkingAccumRef.current + (now - thinkingStartRef.current));
224
- }
225
- // Update token estimate
226
- setStreamedTokenEstimate(realTokensAccumRef.current + Math.ceil(charCountRef.current / 4));
227
- }, 1000);
228
- /** Freeze thinking time if currently in thinking phase */
229
- const freezeThinking = () => {
230
- if (thinkingStartRef.current !== null) {
231
- thinkingAccumRef.current += Date.now() - thinkingStartRef.current;
232
- thinkingStartRef.current = null;
233
- setThinkingMs(thinkingAccumRef.current);
234
- setIsThinking(false);
235
- }
236
- };
237
- // Push user message
238
- const userMsg = { role: "user", content: userContent };
239
- messages.current.push(userMsg);
240
- const startIndex = messages.current.length;
241
- try {
242
- // Resolve fresh credentials (handles OAuth token refresh)
243
- let apiKey = options.apiKey;
244
- let accountId = options.accountId;
245
- if (options.resolveCredentials) {
246
- const creds = await options.resolveCredentials();
247
- apiKey = creds.apiKey;
248
- accountId = creds.accountId;
249
- }
250
- const generator = agentLoop(messages.current, {
251
- provider: options.provider,
252
- model: options.model,
253
- tools: options.tools,
254
- webSearch: options.webSearch,
255
- maxTokens: options.maxTokens,
256
- thinking: options.thinking,
257
- apiKey,
258
- baseUrl: options.baseUrl,
259
- accountId,
260
- signal: ac.signal,
261
- transformContext: options.transformContext,
262
- });
263
- for await (const event of generator) {
264
- switch (event.type) {
265
- case "text_delta":
266
- textPendingRef.current += event.text;
267
- charCountRef.current += event.text.length;
268
- startReveal();
269
- if (phaseRef.current !== "generating") {
225
+ /** Run a single user message through the agent loop. Returns true if aborted. */
226
+ const runSingle = async (content) => {
227
+ const ac = new AbortController();
228
+ abortRef.current = ac;
229
+ let wasAborted = false;
230
+ // Reset state
231
+ doneCalledRef.current = false;
232
+ textPendingRef.current = "";
233
+ textVisibleRef.current = "";
234
+ thinkingBufferRef.current = "";
235
+ thinkingPendingRef.current = "";
236
+ thinkingVisibleRef.current = "";
237
+ runStartRef.current = Date.now();
238
+ toolsUsedRef.current = new Set();
239
+ charCountRef.current = 0;
240
+ realTokensAccumRef.current = 0;
241
+ thinkingAccumRef.current = 0;
242
+ thinkingStartRef.current = null;
243
+ phaseRef.current = "waiting";
244
+ setStreamingText("");
245
+ setStreamingThinking("");
246
+ setActiveToolCalls([]);
247
+ setActivityPhase("waiting");
248
+ setElapsedMs(0);
249
+ setThinkingMs(0);
250
+ setIsThinking(false);
251
+ setStreamedTokenEstimate(0);
252
+ setIsRunning(true);
253
+ // Start elapsed timer (ticks every 1000ms — less frequent to reduce
254
+ // Ink re-renders which cause live-area flickering and viewport snapping)
255
+ if (elapsedTimerRef.current)
256
+ clearInterval(elapsedTimerRef.current);
257
+ const timerStart = Date.now();
258
+ elapsedTimerRef.current = setInterval(() => {
259
+ const now = Date.now();
260
+ setElapsedMs(now - timerStart);
261
+ // Update live thinking time if currently thinking
262
+ if (thinkingStartRef.current !== null) {
263
+ setThinkingMs(thinkingAccumRef.current + (now - thinkingStartRef.current));
264
+ }
265
+ // Update token estimate
266
+ setStreamedTokenEstimate(realTokensAccumRef.current + Math.ceil(charCountRef.current / 4));
267
+ }, 1000);
268
+ /** Freeze thinking time if currently in thinking phase */
269
+ const freezeThinking = () => {
270
+ if (thinkingStartRef.current !== null) {
271
+ thinkingAccumRef.current += Date.now() - thinkingStartRef.current;
272
+ thinkingStartRef.current = null;
273
+ setThinkingMs(thinkingAccumRef.current);
274
+ setIsThinking(false);
275
+ }
276
+ };
277
+ // Push user message
278
+ const userMsg = { role: "user", content: content };
279
+ messages.current.push(userMsg);
280
+ const startIndex = messages.current.length;
281
+ try {
282
+ // Resolve fresh credentials (handles OAuth token refresh)
283
+ let apiKey = options.apiKey;
284
+ let accountId = options.accountId;
285
+ if (options.resolveCredentials) {
286
+ const creds = await options.resolveCredentials();
287
+ apiKey = creds.apiKey;
288
+ accountId = creds.accountId;
289
+ }
290
+ const generator = agentLoop(messages.current, {
291
+ provider: options.provider,
292
+ model: options.model,
293
+ tools: options.tools,
294
+ webSearch: options.webSearch,
295
+ maxTokens: options.maxTokens,
296
+ thinking: options.thinking,
297
+ apiKey,
298
+ baseUrl: options.baseUrl,
299
+ accountId,
300
+ signal: ac.signal,
301
+ transformContext: options.transformContext,
302
+ // Drain queued messages as steering — injected between tool calls
303
+ // and before the agent would stop, so the LLM sees user guidance
304
+ // within the same run instead of waiting for a new one.
305
+ getSteeringMessages: () => {
306
+ if (queueRef.current.length === 0)
307
+ return null;
308
+ const batch = queueRef.current.splice(0);
309
+ setQueuedCount(0);
310
+ const merged = mergeUserContent(batch);
311
+ onQueuedStart?.(merged);
312
+ return [{ role: "user", content: merged }];
313
+ },
314
+ });
315
+ for await (const event of generator) {
316
+ switch (event.type) {
317
+ case "text_delta":
318
+ textPendingRef.current += event.text;
319
+ charCountRef.current += event.text.length;
320
+ startReveal();
321
+ if (phaseRef.current !== "generating") {
322
+ freezeThinking();
323
+ phaseRef.current = "generating";
324
+ setActivityPhase("generating");
325
+ }
326
+ break;
327
+ case "thinking_delta":
328
+ thinkingBufferRef.current += event.text;
329
+ thinkingPendingRef.current += event.text;
330
+ charCountRef.current += event.text.length;
331
+ startThinkingReveal();
332
+ if (phaseRef.current !== "thinking") {
333
+ thinkingStartRef.current = Date.now();
334
+ setIsThinking(true);
335
+ phaseRef.current = "thinking";
336
+ setActivityPhase("thinking");
337
+ }
338
+ break;
339
+ case "tool_call_start": {
270
340
  freezeThinking();
271
- phaseRef.current = "generating";
272
- setActivityPhase("generating");
341
+ if (phaseRef.current !== "tools") {
342
+ phaseRef.current = "tools";
343
+ setActivityPhase("tools");
344
+ }
345
+ const newTc = {
346
+ toolCallId: event.toolCallId,
347
+ name: event.name,
348
+ args: event.args,
349
+ startTime: Date.now(),
350
+ updates: [],
351
+ };
352
+ onToolStart?.(event.toolCallId, event.name, event.args);
353
+ toolsUsedRef.current.add(event.name);
354
+ activeToolCallsRef.current = [...activeToolCallsRef.current, newTc];
355
+ setActiveToolCalls(activeToolCallsRef.current);
356
+ break;
273
357
  }
274
- break;
275
- case "thinking_delta":
276
- thinkingBufferRef.current += event.text;
277
- thinkingPendingRef.current += event.text;
278
- charCountRef.current += event.text.length;
279
- startThinkingReveal();
280
- if (phaseRef.current !== "thinking") {
281
- thinkingStartRef.current = Date.now();
282
- setIsThinking(true);
283
- phaseRef.current = "thinking";
284
- setActivityPhase("thinking");
358
+ case "tool_call_update": {
359
+ onToolUpdate?.(event.toolCallId, event.update);
360
+ // Mutate the matching tool call in-place to avoid allocating
361
+ // a new array + new objects on every update event. Over a 5h
362
+ // session with thousands of tool calls this prevents significant
363
+ // GC pressure from spread-copy churn.
364
+ const target = activeToolCallsRef.current.find((tc) => tc.toolCallId === event.toolCallId);
365
+ if (target) {
366
+ if (target.updates.length >= 20) {
367
+ target.updates.shift();
368
+ }
369
+ target.updates.push(event.update);
370
+ }
371
+ // Spread once to create a new array reference for React state
372
+ setActiveToolCalls([...activeToolCallsRef.current]);
373
+ break;
285
374
  }
286
- break;
287
- case "tool_call_start": {
288
- freezeThinking();
289
- if (phaseRef.current !== "tools") {
290
- phaseRef.current = "tools";
291
- setActivityPhase("tools");
375
+ case "tool_call_end": {
376
+ const tc = activeToolCallsRef.current.find((t) => t.toolCallId === event.toolCallId);
377
+ const toolName = tc?.name ?? "unknown";
378
+ const durationMs = tc ? Date.now() - tc.startTime : 0;
379
+ onToolEnd?.(event.toolCallId, toolName, event.result, event.isError, durationMs, event.details);
380
+ activeToolCallsRef.current = activeToolCallsRef.current.filter((t) => t.toolCallId !== event.toolCallId);
381
+ setActiveToolCalls(activeToolCallsRef.current);
382
+ break;
292
383
  }
293
- const newTc = {
294
- toolCallId: event.toolCallId,
295
- name: event.name,
296
- args: event.args,
297
- startTime: Date.now(),
298
- updates: [],
299
- };
300
- onToolStart?.(event.toolCallId, event.name, event.args);
301
- toolsUsedRef.current.add(event.name);
302
- activeToolCallsRef.current = [...activeToolCallsRef.current, newTc];
303
- setActiveToolCalls(activeToolCallsRef.current);
304
- break;
305
- }
306
- case "tool_call_update": {
307
- onToolUpdate?.(event.toolCallId, event.update);
308
- // Mutate the matching tool call in-place to avoid allocating
309
- // a new array + new objects on every update event. Over a 5h
310
- // session with thousands of tool calls this prevents significant
311
- // GC pressure from spread-copy churn.
312
- const target = activeToolCallsRef.current.find((tc) => tc.toolCallId === event.toolCallId);
313
- if (target) {
314
- if (target.updates.length >= 20) {
315
- target.updates.shift();
384
+ case "server_tool_call":
385
+ onServerToolCall?.(event.id, event.name, event.input);
386
+ break;
387
+ case "server_tool_result":
388
+ onServerToolResult?.(event.toolUseId, event.resultType, event.data);
389
+ break;
390
+ case "steering_message":
391
+ // Steering message was injected — UI already notified via
392
+ // onQueuedStart inside getSteeringMessages callback.
393
+ break;
394
+ case "turn_end":
395
+ onTurnEnd?.(event.turn, event.stopReason, event.usage);
396
+ setCurrentTurn(event.turn);
397
+ setTotalTokens((prev) => ({
398
+ input: prev.input + event.usage.inputTokens,
399
+ output: prev.output + event.usage.outputTokens,
400
+ }));
401
+ // Latest turn's input tokens = current context window fill
402
+ // With prompt caching, input_tokens only counts non-cached tokens.
403
+ // Total context = input + cache_read + cache_write.
404
+ setContextUsed(event.usage.inputTokens +
405
+ (event.usage.cacheRead ?? 0) +
406
+ (event.usage.cacheWrite ?? 0));
407
+ // Replace char-based estimate with real output tokens
408
+ realTokensAccumRef.current += event.usage.outputTokens;
409
+ charCountRef.current = 0;
410
+ setStreamedTokenEstimate(realTokensAccumRef.current);
411
+ // Reset phase for next turn
412
+ phaseRef.current = "waiting";
413
+ setActivityPhase("waiting");
414
+ // Flush all pending text before completing turn
415
+ flushAllText();
416
+ if (textVisibleRef.current) {
417
+ onTurnText?.(textVisibleRef.current, thinkingBufferRef.current, thinkingAccumRef.current);
316
418
  }
317
- target.updates.push(event.update);
318
- }
319
- // Spread once to create a new array reference for React state
320
- setActiveToolCalls([...activeToolCallsRef.current]);
321
- break;
419
+ // Reset streaming buffers for next turn
420
+ textPendingRef.current = "";
421
+ textVisibleRef.current = "";
422
+ thinkingBufferRef.current = "";
423
+ thinkingPendingRef.current = "";
424
+ thinkingVisibleRef.current = "";
425
+ setStreamingText("");
426
+ setStreamingThinking("");
427
+ break;
428
+ case "agent_done":
429
+ flushAllText();
430
+ // Batch ALL completion state into a single render so Ink
431
+ // processes the live-area change atomically. Previously
432
+ // isRunning, activityPhase, and onDone landed in separate
433
+ // render batches, causing multiple live-area height changes
434
+ // that confused Ink's cursor math and clipped content.
435
+ setIsRunning(false);
436
+ phaseRef.current = "idle";
437
+ setActivityPhase("idle");
438
+ // Call onDone HERE (not in finally) so its state updates
439
+ // (doneStatus, flushing items to Static) are batched too.
440
+ onDone?.(Date.now() - runStartRef.current, [...toolsUsedRef.current]);
441
+ doneCalledRef.current = true;
442
+ break;
322
443
  }
323
- case "tool_call_end": {
324
- const tc = activeToolCallsRef.current.find((t) => t.toolCallId === event.toolCallId);
325
- const toolName = tc?.name ?? "unknown";
326
- const durationMs = tc ? Date.now() - tc.startTime : 0;
327
- onToolEnd?.(event.toolCallId, toolName, event.result, event.isError, durationMs, event.details);
328
- activeToolCallsRef.current = activeToolCallsRef.current.filter((t) => t.toolCallId !== event.toolCallId);
329
- setActiveToolCalls(activeToolCallsRef.current);
330
- break;
331
- }
332
- case "server_tool_call":
333
- onServerToolCall?.(event.id, event.name, event.input);
334
- break;
335
- case "server_tool_result":
336
- onServerToolResult?.(event.toolUseId, event.resultType, event.data);
337
- break;
338
- case "turn_end":
339
- onTurnEnd?.(event.turn, event.stopReason, event.usage);
340
- setCurrentTurn(event.turn);
341
- setTotalTokens((prev) => ({
342
- input: prev.input + event.usage.inputTokens,
343
- output: prev.output + event.usage.outputTokens,
344
- }));
345
- // Latest turn's input tokens = current context window fill
346
- // With prompt caching, input_tokens only counts non-cached tokens.
347
- // Total context = input + cache_read + cache_write.
348
- setContextUsed(event.usage.inputTokens +
349
- (event.usage.cacheRead ?? 0) +
350
- (event.usage.cacheWrite ?? 0));
351
- // Replace char-based estimate with real output tokens
352
- realTokensAccumRef.current += event.usage.outputTokens;
353
- charCountRef.current = 0;
354
- setStreamedTokenEstimate(realTokensAccumRef.current);
355
- // Reset phase for next turn
356
- phaseRef.current = "waiting";
357
- setActivityPhase("waiting");
358
- // Flush all pending text before completing turn
359
- flushAllText();
360
- if (textVisibleRef.current) {
361
- onTurnText?.(textVisibleRef.current, thinkingBufferRef.current, thinkingAccumRef.current);
362
- }
363
- // Reset streaming buffers for next turn
364
- textPendingRef.current = "";
365
- textVisibleRef.current = "";
366
- thinkingBufferRef.current = "";
367
- thinkingPendingRef.current = "";
368
- thinkingVisibleRef.current = "";
369
- setStreamingText("");
370
- setStreamingThinking("");
371
- break;
372
- case "agent_done":
373
- flushAllText();
374
- // Batch ALL completion state into a single render so Ink
375
- // processes the live-area change atomically. Previously
376
- // isRunning, activityPhase, and onDone landed in separate
377
- // render batches, causing multiple live-area height changes
378
- // that confused Ink's cursor math and clipped content.
379
- setIsRunning(false);
380
- phaseRef.current = "idle";
381
- setActivityPhase("idle");
382
- // Call onDone HERE (not in finally) so its state updates
383
- // (doneStatus, flushing items to Static) are batched too.
384
- onDone?.(Date.now() - runStartRef.current, [...toolsUsedRef.current]);
385
- doneCalledRef.current = true;
386
- break;
387
444
  }
388
445
  }
389
- }
390
- catch (err) {
391
- const isAbort = err instanceof Error && (err.name === "AbortError" || err.message.includes("aborted"));
392
- if (!isAbort) {
393
- throw err;
394
- }
395
- wasAborted = true;
396
- }
397
- finally {
398
- setIsRunning(false);
399
- abortRef.current = null;
400
- stopReveal();
401
- stopThinkingReveal();
402
- if (elapsedTimerRef.current) {
403
- clearInterval(elapsedTimerRef.current);
404
- elapsedTimerRef.current = null;
405
- }
406
- phaseRef.current = "idle";
407
- setActivityPhase("idle");
408
- if (wasAborted) {
409
- // Flush any visible streaming text so onAborted (which adds
410
- // "Request was stopped.") lands AFTER the agent's partial text
411
- // in liveItems — not above it.
412
- flushAllText();
413
- if (textVisibleRef.current) {
414
- onTurnText?.(textVisibleRef.current, thinkingBufferRef.current, thinkingAccumRef.current);
446
+ catch (err) {
447
+ const isAbort = err instanceof Error && (err.name === "AbortError" || err.message.includes("aborted"));
448
+ if (!isAbort) {
449
+ throw err;
415
450
  }
416
- textPendingRef.current = "";
417
- textVisibleRef.current = "";
418
- thinkingBufferRef.current = "";
419
- thinkingPendingRef.current = "";
420
- thinkingVisibleRef.current = "";
421
- setStreamingText("");
422
- setStreamingThinking("");
423
- onAborted?.();
451
+ wasAborted = true;
424
452
  }
425
- else if (!doneCalledRef.current) {
426
- // Safety fallback — normally agent_done calls onDone in-band
427
- const durationMs = Date.now() - runStartRef.current;
428
- onDone?.(durationMs, [...toolsUsedRef.current]);
453
+ finally {
454
+ setIsRunning(false);
455
+ abortRef.current = null;
456
+ stopReveal();
457
+ stopThinkingReveal();
458
+ if (elapsedTimerRef.current) {
459
+ clearInterval(elapsedTimerRef.current);
460
+ elapsedTimerRef.current = null;
461
+ }
462
+ phaseRef.current = "idle";
463
+ setActivityPhase("idle");
464
+ if (wasAborted) {
465
+ // Flush any visible streaming text so onAborted (which adds
466
+ // "Request was stopped.") lands AFTER the agent's partial text
467
+ // in liveItems — not above it.
468
+ flushAllText();
469
+ if (textVisibleRef.current) {
470
+ onTurnText?.(textVisibleRef.current, thinkingBufferRef.current, thinkingAccumRef.current);
471
+ }
472
+ textPendingRef.current = "";
473
+ textVisibleRef.current = "";
474
+ thinkingBufferRef.current = "";
475
+ thinkingPendingRef.current = "";
476
+ thinkingVisibleRef.current = "";
477
+ setStreamingText("");
478
+ setStreamingThinking("");
479
+ onAborted?.();
480
+ }
481
+ else if (!doneCalledRef.current) {
482
+ // Safety fallback — normally agent_done calls onDone in-band
483
+ const durationMs = Date.now() - runStartRef.current;
484
+ onDone?.(durationMs, [...toolsUsedRef.current]);
485
+ }
486
+ // Notify parent of new messages
487
+ const newMsgs = messages.current.slice(startIndex);
488
+ onComplete?.(newMsgs);
429
489
  }
430
- // Notify parent of new messages
431
- const newMsgs = messages.current.slice(startIndex);
432
- onComplete?.(newMsgs);
490
+ return wasAborted;
491
+ }; // end runSingle
492
+ // Run the initial message
493
+ const aborted = await runSingle(userContent);
494
+ // Drain the queue: process follow-up messages that arrived after agent_done.
495
+ // Most queued messages are consumed mid-run via getSteeringMessages, but
496
+ // messages that arrive after the agent finishes (no more tool calls to
497
+ // trigger steering) land here. Batch all remaining into a single run.
498
+ if (!aborted && queueRef.current.length > 0) {
499
+ const batch = queueRef.current.splice(0);
500
+ setQueuedCount(0);
501
+ const merged = mergeUserContent(batch);
502
+ // Let React process the onDone state updates before starting next run
503
+ await new Promise((r) => setTimeout(r, 100));
504
+ onQueuedStart?.(merged);
505
+ await runSingle(merged);
433
506
  }
434
507
  }, [
435
508
  messages,
@@ -444,6 +517,7 @@ export function useAgentLoop(messages, options, callbacks) {
444
517
  onTurnEnd,
445
518
  onDone,
446
519
  onAborted,
520
+ onQueuedStart,
447
521
  startReveal,
448
522
  stopReveal,
449
523
  startThinkingReveal,
@@ -466,6 +540,9 @@ export function useAgentLoop(messages, options, callbacks) {
466
540
  run,
467
541
  abort,
468
542
  reset,
543
+ queueMessage,
544
+ queuedCount,
545
+ clearQueue,
469
546
  isRunning,
470
547
  streamingText,
471
548
  streamingThinking,