@copilotkit/react-core 1.56.0 → 1.56.2-canary.pin-to-send

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.
Files changed (60) hide show
  1. package/dist/{copilotkit-BebqQrYT.mjs → copilotkit-BBYbekCa.mjs} +265 -76
  2. package/dist/copilotkit-BBYbekCa.mjs.map +1 -0
  3. package/dist/{copilotkit-Cvb6WpAX.cjs → copilotkit-D5JT2Pu3.cjs} +264 -75
  4. package/dist/copilotkit-D5JT2Pu3.cjs.map +1 -0
  5. package/dist/{copilotkit-f2Uq0RwG.d.mts → copilotkit-DArT2Iuw.d.mts} +71 -18
  6. package/dist/copilotkit-DArT2Iuw.d.mts.map +1 -0
  7. package/dist/{copilotkit-Dv8zU8_U.d.cts → copilotkit-KEc28l8G.d.cts} +71 -18
  8. package/dist/copilotkit-KEc28l8G.d.cts.map +1 -0
  9. package/dist/index.cjs +1 -1
  10. package/dist/index.d.cts +1 -1
  11. package/dist/index.d.mts +1 -1
  12. package/dist/index.mjs +1 -1
  13. package/dist/index.umd.js +30 -46
  14. package/dist/index.umd.js.map +1 -1
  15. package/dist/v2/index.cjs +1 -1
  16. package/dist/v2/index.css +1 -1
  17. package/dist/v2/index.d.cts +2 -2
  18. package/dist/v2/index.d.mts +2 -2
  19. package/dist/v2/index.mjs +1 -1
  20. package/dist/v2/index.umd.js +264 -79
  21. package/dist/v2/index.umd.js.map +1 -1
  22. package/package.json +6 -6
  23. package/src/components/CopilotListeners.tsx +15 -4
  24. package/src/components/__tests__/CopilotListeners.test.tsx +38 -0
  25. package/src/v2/components/chat/CopilotChat.tsx +80 -4
  26. package/src/v2/components/chat/CopilotChatAssistantMessage.tsx +4 -4
  27. package/src/v2/components/chat/CopilotChatInput.tsx +43 -2
  28. package/src/v2/components/chat/CopilotChatView.tsx +206 -11
  29. package/src/v2/components/chat/__tests__/CopilotChat.absentThreadConnect.test.tsx +66 -0
  30. package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +300 -2
  31. package/src/v2/components/chat/__tests__/CopilotChatAssistantMessage.thumbs.test.tsx +72 -0
  32. package/src/v2/components/chat/__tests__/CopilotChatInput.test.tsx +38 -0
  33. package/src/v2/components/chat/__tests__/CopilotChatView.connectingGate.test.tsx +56 -0
  34. package/src/v2/components/chat/__tests__/CopilotChatView.pinToSend.test.tsx +94 -0
  35. package/src/v2/components/chat/__tests__/copilot-chat-throttle.test.tsx +0 -1
  36. package/src/v2/components/chat/__tests__/normalize-auto-scroll.test.ts +37 -0
  37. package/src/v2/components/chat/index.ts +2 -0
  38. package/src/v2/components/chat/last-user-message-context.ts +21 -0
  39. package/src/v2/components/chat/normalize-auto-scroll.ts +17 -0
  40. package/src/v2/components/license-warning-banner.tsx +20 -1
  41. package/src/v2/components/ui/button.tsx +12 -11
  42. package/src/v2/hooks/__tests__/use-agent-stability.test.tsx +6 -0
  43. package/src/v2/hooks/__tests__/use-agent-thread-isolation.test.tsx +6 -0
  44. package/src/v2/hooks/__tests__/use-agent-throttle.test.tsx +76 -50
  45. package/src/v2/hooks/__tests__/use-pin-to-send.test.tsx +219 -0
  46. package/src/v2/hooks/__tests__/use-render-custom-messages.test.tsx +55 -0
  47. package/src/v2/hooks/__tests__/use-threads.test.tsx +68 -0
  48. package/src/v2/hooks/use-agent.tsx +34 -77
  49. package/src/v2/hooks/use-pin-to-send.ts +94 -0
  50. package/src/v2/hooks/use-render-custom-messages.tsx +1 -1
  51. package/src/v2/hooks/use-render-tool-call.tsx +3 -0
  52. package/src/v2/hooks/use-render-tool.tsx +3 -0
  53. package/src/v2/hooks/use-threads.tsx +55 -12
  54. package/src/v2/providers/CopilotKitProvider.tsx +2 -11
  55. package/src/v2/types/defineToolCallRenderer.ts +3 -0
  56. package/src/v2/types/react-tool-call-renderer.ts +3 -0
  57. package/dist/copilotkit-BebqQrYT.mjs.map +0 -1
  58. package/dist/copilotkit-Cvb6WpAX.cjs.map +0 -1
  59. package/dist/copilotkit-Dv8zU8_U.d.cts.map +0 -1
  60. package/dist/copilotkit-f2Uq0RwG.d.mts.map +0 -1
@@ -2,6 +2,7 @@ import React from "react";
2
2
  import { act, renderHook, waitFor } from "@testing-library/react";
3
3
  import { beforeEach, describe, expect, it, vi } from "vitest";
4
4
  import { useCopilotKit } from "../../providers/CopilotKitProvider";
5
+ import { CopilotKitCoreRuntimeConnectionStatus } from "@copilotkit/core";
5
6
 
6
7
  vi.mock("../../providers/CopilotKitProvider", () => ({
7
8
  useCopilotKit: vi.fn(),
@@ -148,6 +149,7 @@ function setupCopilotKit(runtimeUrl = "http://localhost:4000") {
148
149
  mockUseCopilotKit.mockReturnValue({
149
150
  copilotkit: {
150
151
  runtimeUrl,
152
+ runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Connected,
151
153
  headers: { Authorization: "Bearer test-token" },
152
154
  intelligence: {
153
155
  wsUrl: "ws://localhost:4000/client",
@@ -484,4 +486,70 @@ describe("useThreads", () => {
484
486
  expect(channel.left).toBe(true);
485
487
  expect(socket.disconnected).toBe(true);
486
488
  });
489
+
490
+ it("waits for runtimeConnectionStatus=Connected before fetching /threads", async () => {
491
+ // Start in Connecting — hook should hold off on dispatching any request
492
+ // so the initial list fetch includes wsUrl and avoids a redundant second
493
+ // call once /info resolves.
494
+ mockUseCopilotKit.mockReturnValue({
495
+ copilotkit: {
496
+ runtimeUrl: "http://localhost:4000",
497
+ runtimeConnectionStatus:
498
+ CopilotKitCoreRuntimeConnectionStatus.Connecting,
499
+ headers: { Authorization: "Bearer test-token" },
500
+ intelligence: undefined,
501
+ },
502
+ });
503
+
504
+ fetchMock
505
+ .mockReturnValueOnce(
506
+ jsonResponse({ threads: sampleThreads, joinCode: "jc-1" }),
507
+ )
508
+ .mockReturnValueOnce(jsonResponse({ joinToken: "jt-1" }));
509
+
510
+ const { result, rerender } = renderHook(() => useThreads(defaultInput));
511
+
512
+ // Give effects a tick to settle; no fetch should occur while Connecting.
513
+ await new Promise((resolve) => setTimeout(resolve, 20));
514
+ expect(fetchMock).not.toHaveBeenCalled();
515
+
516
+ // While waiting for Connected, the hook must surface isLoading=true so
517
+ // consumers don't render an empty-state flash before the first fetch
518
+ // is even dispatched. The store's own isLoading is false at this
519
+ // point (no contextChanged action yet), so the hook synthesizes it.
520
+ expect(result.current.isLoading).toBe(true);
521
+ expect(result.current.threads).toEqual([]);
522
+
523
+ // Flip to Connected with wsUrl populated, re-render. The effect now
524
+ // dispatches exactly one list fetch (+ one subscribe after it lands).
525
+ mockUseCopilotKit.mockReturnValue({
526
+ copilotkit: {
527
+ runtimeUrl: "http://localhost:4000",
528
+ runtimeConnectionStatus:
529
+ CopilotKitCoreRuntimeConnectionStatus.Connected,
530
+ headers: { Authorization: "Bearer test-token" },
531
+ intelligence: { wsUrl: "ws://localhost:4000/client" },
532
+ },
533
+ });
534
+
535
+ rerender();
536
+
537
+ await waitFor(() => {
538
+ expect(fetchMock).toHaveBeenCalledWith(
539
+ expect.stringContaining("/threads?agentId=agent-1"),
540
+ expect.objectContaining({ method: "GET" }),
541
+ );
542
+ });
543
+
544
+ // Exactly the expected pair — no speculative list call before Connected.
545
+ const listCalls = fetchMock.mock.calls.filter(
546
+ ([url]) => typeof url === "string" && /\/threads\?agentId=/.test(url),
547
+ );
548
+ expect(listCalls).toHaveLength(1);
549
+
550
+ // After the fetch settles, isLoading returns to false.
551
+ await waitFor(() => {
552
+ expect(result.current.isLoading).toBe(false);
553
+ });
554
+ });
487
555
  });
@@ -6,6 +6,7 @@ import { AbstractAgent, HttpAgent } from "@ag-ui/client";
6
6
  import {
7
7
  ProxiedCopilotRuntimeAgent,
8
8
  CopilotKitCoreRuntimeConnectionStatus,
9
+ type SubscribeToAgentSubscriber,
9
10
  } from "@copilotkit/core";
10
11
 
11
12
  export enum UseAgentUpdate {
@@ -25,23 +26,26 @@ export interface UseAgentProps {
25
26
  threadId?: string;
26
27
  updates?: UseAgentUpdate[];
27
28
  /**
28
- * Throttle interval (in milliseconds) for React re-renders triggered by
29
- * `OnMessagesChanged` notifications. Useful to reduce re-render frequency
30
- * during high-frequency message updates such as streaming.
29
+ * Throttle interval (in milliseconds) for re-renders triggered by
30
+ * `onMessagesChanged` and `onStateChanged` notifications. Useful to reduce
31
+ * re-render frequency during high-frequency streaming updates.
31
32
  *
32
- * Uses leading+trailing: first update fires immediately, subsequent updates
33
- * within the window are coalesced, and a trailing timer ensures the most
34
- * recent update fires after the window expires. The trailing edge restarts
35
- * the throttle window, so no two renders occur within `throttleMs` of each
36
- * other. Cleanup on unmount cancels any pending trailing timer.
33
+ * Uses a leading+trailing pattern with a shared window — first update
34
+ * fires immediately, subsequent updates within the window are coalesced,
35
+ * and a trailing timer ensures the most recent update fires after the
36
+ * window expires. See `CopilotKitCore.subscribeToAgentWithOptions` in `@copilotkit/core`
37
+ * for details.
37
38
  *
38
- * Must be a non-negative finite number. Negative or non-finite values fall
39
- * back to unthrottled behavior with a `console.error`. Only affects
40
- * `OnMessagesChanged` updates `OnStateChanged` and `OnRunStatusChanged`
41
- * always fire immediately. If `updates` does not include
42
- * `OnMessagesChanged`, this property has no effect.
39
+ * Resolved as: `throttleMs ?? provider defaultThrottleMs ?? 0`.
40
+ * Passing `throttleMs={0}` explicitly disables throttling even when the
41
+ * provider specifies a non-zero `defaultThrottleMs`.
43
42
  *
44
- * Default: `0` (no throttle).
43
+ * Run lifecycle callbacks (`onRunInitialized`, `onRunFinalized`,
44
+ * `onRunFailed`, `onRunErrorEvent`) always fire immediately.
45
+ *
46
+ * @default undefined
47
+ * When unset, inherits from the provider's `defaultThrottleMs`;
48
+ * if that is also unset, the effective value is `0` (no throttle).
45
49
  */
46
50
  throttleMs?: number;
47
51
  }
@@ -123,6 +127,9 @@ export function useAgent({
123
127
  agentId ??= DEFAULT_AGENT_ID;
124
128
 
125
129
  const { copilotkit } = useCopilotKit();
130
+ // Read the provider-level default so it appears in the effect's dep array.
131
+ // subscribeToAgentWithOptions reads it from the core instance, but React needs the dep
132
+ // to know when to re-subscribe.
126
133
  const providerThrottleMs = copilotkit.defaultThrottleMs;
127
134
  // Fall back to the enclosing CopilotChatConfigurationProvider's threadId so
128
135
  // that useAgent() called without explicit threadId (e.g. inside a custom
@@ -131,23 +138,6 @@ export function useAgent({
131
138
  const chatConfig = useCopilotChatConfiguration();
132
139
  threadId ??= chatConfig?.threadId;
133
140
 
134
- const effectiveThrottleMs = useMemo(() => {
135
- const resolved = throttleMs ?? providerThrottleMs ?? 0;
136
- if (!Number.isFinite(resolved) || resolved < 0) {
137
- // When both throttleMs and providerThrottleMs are undefined, resolved
138
- // is 0 which passes validation — so one of them must be defined here.
139
- const source =
140
- throttleMs !== undefined
141
- ? "hook-level throttleMs"
142
- : "provider-level defaultThrottleMs";
143
- console.error(
144
- `useAgent: ${source} must be a non-negative finite number, got ${resolved}. Falling back to unthrottled.`,
145
- );
146
- return 0;
147
- }
148
- return resolved;
149
- }, [throttleMs, providerThrottleMs]);
150
-
151
141
  const [, forceUpdate] = useReducer((x) => x + 1, 0);
152
142
 
153
143
  const updateFlags = useMemo(
@@ -277,9 +267,8 @@ export function useAgent({
277
267
  useEffect(() => {
278
268
  if (updateFlags.length === 0) return;
279
269
 
280
- const handlers: Parameters<AbstractAgent["subscribe"]>[0] = {};
281
- let timerId: ReturnType<typeof setTimeout> | null = null;
282
270
  let active = true;
271
+ const handlers: SubscribeToAgentSubscriber = {};
283
272
 
284
273
  // Microtask-batched forceUpdate: coalesces multiple synchronous
285
274
  // notifications (e.g. OnStateChanged + OnRunStatusChanged firing in the
@@ -301,45 +290,7 @@ export function useAgent({
301
290
  };
302
291
 
303
292
  if (updateFlags.includes(UseAgentUpdate.OnMessagesChanged)) {
304
- const ms = effectiveThrottleMs;
305
- if (ms > 0) {
306
- // Throttled onMessagesChanged: leading+trailing pattern.
307
- // First notification fires immediately, subsequent ones within the
308
- // window are coalesced. Trailing timer fires after the window to
309
- // ensure the final state is rendered.
310
- let throttleActive = false;
311
- // Tracks whether a notification arrived during the throttle window,
312
- // so the trailing timer knows whether a re-render is needed.
313
- let pending = false;
314
-
315
- const throttledNotify = () => {
316
- if (!active) return;
317
- if (!throttleActive) {
318
- // Leading edge — fire immediately and start the throttle window
319
- throttleActive = true;
320
- pending = false;
321
- forceUpdate();
322
- timerId = setTimeout(function trailingEdge() {
323
- timerId = null;
324
- if (active && pending) {
325
- // Trailing edge — fire and restart the window
326
- pending = false;
327
- forceUpdate();
328
- timerId = setTimeout(trailingEdge, ms);
329
- } else {
330
- // No pending notifications — end the window
331
- throttleActive = false;
332
- }
333
- }, ms);
334
- } else {
335
- pending = true;
336
- }
337
- };
338
-
339
- handlers.onMessagesChanged = throttledNotify;
340
- } else {
341
- handlers.onMessagesChanged = forceUpdate;
342
- }
293
+ handlers.onMessagesChanged = forceUpdate;
343
294
  }
344
295
 
345
296
  if (updateFlags.includes(UseAgentUpdate.OnStateChanged)) {
@@ -350,18 +301,24 @@ export function useAgent({
350
301
  handlers.onRunInitialized = batchedForceUpdate;
351
302
  handlers.onRunFinalized = batchedForceUpdate;
352
303
  handlers.onRunFailed = batchedForceUpdate;
304
+ // Protocol-level RUN_ERROR event (distinct from onRunFailed which
305
+ // handles local exceptions like network errors).
306
+ handlers.onRunErrorEvent = batchedForceUpdate;
353
307
  }
354
308
 
355
- const subscription = agent.subscribe(handlers);
309
+ const subscription = copilotkit.subscribeToAgentWithOptions(
310
+ agent,
311
+ handlers,
312
+ {
313
+ throttleMs,
314
+ },
315
+ );
356
316
  return () => {
357
317
  active = false;
358
- if (timerId !== null) {
359
- clearTimeout(timerId);
360
- }
361
318
  subscription.unsubscribe();
362
319
  };
363
320
  // eslint-disable-next-line react-hooks/exhaustive-deps
364
- }, [agent, forceUpdate, effectiveThrottleMs, updateFlags]);
321
+ }, [agent, forceUpdate, throttleMs, providerThrottleMs, updateFlags]);
365
322
 
366
323
  // Keep HttpAgent headers fresh without mutating inside useMemo, which is
367
324
  // unsafe in concurrent mode (React may invoke useMemo multiple times and
@@ -0,0 +1,94 @@
1
+ import { useContext, useEffect, useRef } from "react";
2
+ import { LastUserMessageContext } from "../components/chat/last-user-message-context";
3
+
4
+ export type UsePinToSendOptions = {
5
+ scrollRef: React.RefObject<HTMLElement | null>;
6
+ contentRef: React.RefObject<HTMLElement | null>;
7
+ spacerRef: React.RefObject<HTMLElement | null>;
8
+ topOffset?: number;
9
+ };
10
+
11
+ export function usePinToSend({
12
+ scrollRef,
13
+ contentRef,
14
+ spacerRef,
15
+ topOffset = 16,
16
+ }: UsePinToSendOptions): void {
17
+ const { id, sendNonce } = useContext(LastUserMessageContext);
18
+ const lastNonceRef = useRef<number>(-1);
19
+ const currentSpacerHeightRef = useRef<number>(0);
20
+
21
+ useEffect(() => {
22
+ if (sendNonce === lastNonceRef.current) return;
23
+ lastNonceRef.current = sendNonce;
24
+
25
+ if (!id) return;
26
+ const scrollEl = scrollRef.current;
27
+ const contentEl = contentRef.current;
28
+ const spacerEl = spacerRef.current;
29
+ if (!scrollEl || !contentEl || !spacerEl) return;
30
+
31
+ const escaped =
32
+ typeof CSS !== "undefined" && CSS.escape
33
+ ? CSS.escape(id)
34
+ : id.replace(/[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~]/g, "\\$&");
35
+ const targetEl = contentEl.querySelector<HTMLElement>(
36
+ `[data-message-id="${escaped}"]`,
37
+ );
38
+ if (!targetEl) return;
39
+
40
+ // The target message's element has a top padding (e.g. `pt-10`) that
41
+ // creates breathing room above the visible bubble. When we "anchor at
42
+ // the top", we mean anchor the *bubble*, not the element's padded box.
43
+ // So we scroll past the padding (it goes above the viewport, hiding
44
+ // whatever was above the element too — including the previous message's
45
+ // trailing copy button).
46
+ const viewportHeight = scrollEl.clientHeight;
47
+ const userMessageHeight = targetEl.getBoundingClientRect().height;
48
+ const paddingTop = parseFloat(getComputedStyle(targetEl).paddingTop) || 0;
49
+ const bubbleHeight = Math.max(0, userMessageHeight - paddingTop);
50
+ const spacerHeight = Math.max(0, viewportHeight - bubbleHeight - topOffset);
51
+
52
+ spacerEl.style.height = `${spacerHeight}px`;
53
+ currentSpacerHeightRef.current = spacerHeight;
54
+
55
+ const raf = requestAnimationFrame(() => {
56
+ // Scroll so the BUBBLE is `topOffset` from the viewport top — the
57
+ // padding above the bubble ends up scrolled off-screen.
58
+ const targetTop =
59
+ computeOffsetTop(targetEl, scrollEl) + paddingTop - topOffset;
60
+ scrollEl.scrollTo({ top: Math.max(0, targetTop), behavior: "smooth" });
61
+ });
62
+
63
+ // Shrink-only ResizeObserver: as the assistant response grows below the
64
+ // anchored user message, collapse the spacer by the same amount so total
65
+ // scrollable space below the bubble stays constant (and the bubble stays
66
+ // pinned). Never grow the spacer after initial sizing.
67
+ const ro = new ResizeObserver(() => {
68
+ if (!contentEl || !spacerEl || !scrollEl) return;
69
+ const contentHeight = contentEl.getBoundingClientRect().height;
70
+ const targetOffsetWithinContent = computeOffsetTop(targetEl, contentEl);
71
+ const consumedBelow =
72
+ contentHeight - targetOffsetWithinContent - userMessageHeight;
73
+ const remaining = Math.max(0, spacerHeight - consumedBelow);
74
+ if (remaining < currentSpacerHeightRef.current) {
75
+ spacerEl.style.height = `${remaining}px`;
76
+ currentSpacerHeightRef.current = remaining;
77
+ }
78
+ });
79
+ ro.observe(contentEl);
80
+
81
+ return () => {
82
+ cancelAnimationFrame(raf);
83
+ ro.disconnect();
84
+ };
85
+ }, [id, sendNonce, scrollRef, contentRef, spacerRef, topOffset]);
86
+ }
87
+
88
+ // Compute the offset of el relative to stopAt, accounting for stopAt's current scrollTop.
89
+ // Uses getBoundingClientRect so it works regardless of CSS positioning (including position:static).
90
+ function computeOffsetTop(el: HTMLElement, stopAt: HTMLElement): number {
91
+ const elRect = el.getBoundingClientRect();
92
+ const stopRect = stopAt.getBoundingClientRect();
93
+ return elRect.top - stopRect.top + stopAt.scrollTop;
94
+ }
@@ -45,7 +45,7 @@ export function useRenderCustomMessages() {
45
45
  const registryAgent = copilotkit.getAgent(agentId);
46
46
  const agent = getThreadClone(registryAgent, threadId) ?? registryAgent;
47
47
  if (!agent) {
48
- throw new Error("Agent not found");
48
+ return null;
49
49
  }
50
50
 
51
51
  const messagesIdsInRun = resolvedRunId
@@ -47,6 +47,7 @@ const ToolCallRenderer = React.memo(
47
47
  return (
48
48
  <RenderComponent
49
49
  name={toolName}
50
+ toolCallId={toolCall.id}
50
51
  args={args}
51
52
  status={ToolCallStatus.Complete}
52
53
  result={toolMessage.content}
@@ -56,6 +57,7 @@ const ToolCallRenderer = React.memo(
56
57
  return (
57
58
  <RenderComponent
58
59
  name={toolName}
60
+ toolCallId={toolCall.id}
59
61
  args={args}
60
62
  status={ToolCallStatus.Executing}
61
63
  result={undefined}
@@ -65,6 +67,7 @@ const ToolCallRenderer = React.memo(
65
67
  return (
66
68
  <RenderComponent
67
69
  name={toolName}
70
+ toolCallId={toolCall.id}
68
71
  args={args}
69
72
  status={ToolCallStatus.InProgress}
70
73
  result={undefined}
@@ -7,6 +7,7 @@ const EMPTY_DEPS: ReadonlyArray<unknown> = [];
7
7
 
8
8
  export interface RenderToolInProgressProps<S extends StandardSchemaV1> {
9
9
  name: string;
10
+ toolCallId: string;
10
11
  parameters: Partial<InferSchemaOutput<S>>;
11
12
  status: "inProgress";
12
13
  result: undefined;
@@ -14,6 +15,7 @@ export interface RenderToolInProgressProps<S extends StandardSchemaV1> {
14
15
 
15
16
  export interface RenderToolExecutingProps<S extends StandardSchemaV1> {
16
17
  name: string;
18
+ toolCallId: string;
17
19
  parameters: InferSchemaOutput<S>;
18
20
  status: "executing";
19
21
  result: undefined;
@@ -21,6 +23,7 @@ export interface RenderToolExecutingProps<S extends StandardSchemaV1> {
21
23
 
22
24
  export interface RenderToolCompleteProps<S extends StandardSchemaV1> {
23
25
  name: string;
26
+ toolCallId: string;
24
27
  parameters: InferSchemaOutput<S>;
25
28
  status: "complete";
26
29
  result: string;
@@ -1,5 +1,6 @@
1
1
  import { useCopilotKit } from "../providers/CopilotKitProvider";
2
2
  import {
3
+ CopilotKitCoreRuntimeConnectionStatus,
3
4
  ɵcreateThreadStore,
4
5
  ɵselectThreads,
5
6
  ɵselectThreadsError,
@@ -30,6 +31,13 @@ export interface Thread {
30
31
  archived: boolean;
31
32
  createdAt: string;
32
33
  updatedAt: string;
34
+ /**
35
+ * ISO-8601 timestamp of the most recent agent run on this thread. Absent
36
+ * when the thread has never been run. Prefer this over `updatedAt` for
37
+ * user-facing "last activity" displays — it is not bumped by metadata-only
38
+ * actions like rename or archive.
39
+ */
40
+ lastRunAt?: string;
33
41
  }
34
42
 
35
43
  /**
@@ -179,13 +187,14 @@ export function useThreads({
179
187
  const threads: Thread[] = useMemo(
180
188
  () =>
181
189
  coreThreads.map(
182
- ({ id, agentId, name, archived, createdAt, updatedAt }) => ({
190
+ ({ id, agentId, name, archived, createdAt, updatedAt, lastRunAt }) => ({
183
191
  id,
184
192
  agentId,
185
193
  name,
186
194
  archived,
187
195
  createdAt,
188
196
  updatedAt,
197
+ ...(lastRunAt !== undefined ? { lastRunAt } : {}),
189
198
  }),
190
199
  ),
191
200
  [coreThreads],
@@ -211,7 +220,18 @@ export function useThreads({
211
220
 
212
221
  return new Error("Runtime URL is not configured");
213
222
  }, [copilotkit.runtimeUrl]);
214
- const isLoading = runtimeError ? false : storeIsLoading;
223
+
224
+ // Tracks whether we've dispatched the first real context to the store.
225
+ // The store itself starts with `isLoading: false`, so before we dispatch
226
+ // consumers would otherwise see an empty, non-loading state (empty-list
227
+ // flash). While runtimeUrl is set and we haven't dispatched yet, we
228
+ // synthesize `isLoading: true` so the UI keeps its loading indicator until
229
+ // the first fetch is in flight (at which point the store's own
230
+ // isLoading takes over).
231
+ const [hasDispatchedContext, setHasDispatchedContext] = useState(false);
232
+ const preConnectLoading = !!copilotkit.runtimeUrl && !hasDispatchedContext;
233
+
234
+ const isLoading = runtimeError ? false : preConnectLoading || storeIsLoading;
215
235
  const error = runtimeError ?? storeError;
216
236
 
217
237
  useEffect(() => {
@@ -221,22 +241,45 @@ export function useThreads({
221
241
  };
222
242
  }, [store]);
223
243
 
244
+ // Defer setting the context until the runtime reports Connected. Before
245
+ // `/info` resolves we don't know `intelligence.wsUrl`, so dispatching the
246
+ // context early would issue a list fetch with `wsUrl: undefined`, then a
247
+ // second list fetch (and a `/threads/subscribe`) once the flag lands.
248
+ // Waiting lets the hook issue just one `/threads?…` + one `/threads/subscribe`.
249
+ //
250
+ // When `runtimeUrl` is absent we dispatch `null` to clear the store. For
251
+ // transient states (Disconnected/Connecting/Error with a URL still set) we
252
+ // leave the previously-dispatched context in place — any in-flight
253
+ // realtime subscription or cached thread list stays usable while the
254
+ // runtime recovers, and we don't re-trigger a fetch storm on transitions.
255
+ const runtimeStatus = copilotkit.runtimeConnectionStatus;
224
256
  useEffect(() => {
225
- const context: ɵThreadRuntimeContext | null = copilotkit.runtimeUrl
226
- ? {
227
- runtimeUrl: copilotkit.runtimeUrl,
228
- headers: { ...copilotkit.headers },
229
- wsUrl: copilotkit.intelligence?.wsUrl,
230
- agentId,
231
- includeArchived,
232
- limit,
233
- }
234
- : null;
257
+ if (!copilotkit.runtimeUrl) {
258
+ store.setContext(null);
259
+ return;
260
+ }
261
+
262
+ // Wait for /info to land so we can include `wsUrl` in the initial
263
+ // context and avoid a redundant second list fetch.
264
+ if (runtimeStatus !== CopilotKitCoreRuntimeConnectionStatus.Connected) {
265
+ return;
266
+ }
267
+
268
+ const context: ɵThreadRuntimeContext = {
269
+ runtimeUrl: copilotkit.runtimeUrl,
270
+ headers: { ...copilotkit.headers },
271
+ wsUrl: copilotkit.intelligence?.wsUrl,
272
+ agentId,
273
+ includeArchived,
274
+ limit,
275
+ };
235
276
 
236
277
  store.setContext(context);
278
+ setHasDispatchedContext(true);
237
279
  }, [
238
280
  store,
239
281
  copilotkit.runtimeUrl,
282
+ runtimeStatus,
240
283
  headersKey,
241
284
  copilotkit.intelligence?.wsUrl,
242
285
  agentId,
@@ -720,19 +720,10 @@ export const CopilotKitProvider: React.FC<CopilotKitProviderProps> = ({
720
720
  }, []);
721
721
 
722
722
  // Sync defaultThrottleMs to the core instance on prop changes.
723
- // Initial value is set synchronously during instance creation (below the
724
- // ref init) so child hooks see the correct value on their first render.
723
+ // Initial value is set synchronously during instance creation (inside the
724
+ // ref-init block above) so child hooks see the correct value on first render.
725
725
  // This effect handles subsequent updates when the prop changes.
726
726
  useEffect(() => {
727
- if (
728
- defaultThrottleMs !== undefined &&
729
- (!Number.isFinite(defaultThrottleMs) || defaultThrottleMs < 0)
730
- ) {
731
- console.error(
732
- `CopilotKitProvider: defaultThrottleMs must be a non-negative finite number, got ${defaultThrottleMs}. ` +
733
- `useAgent hooks without an explicit throttleMs will fall back to unthrottled.`,
734
- );
735
- }
736
727
  copilotkit.setDefaultThrottleMs(defaultThrottleMs);
737
728
  }, [copilotkit, defaultThrottleMs]);
738
729
 
@@ -14,18 +14,21 @@ import { ToolCallStatus } from "@copilotkit/core";
14
14
  type RenderProps<T> =
15
15
  | {
16
16
  name: string;
17
+ toolCallId: string;
17
18
  args: Partial<T>;
18
19
  status: ToolCallStatus.InProgress;
19
20
  result: undefined;
20
21
  }
21
22
  | {
22
23
  name: string;
24
+ toolCallId: string;
23
25
  args: T;
24
26
  status: ToolCallStatus.Executing;
25
27
  result: undefined;
26
28
  }
27
29
  | {
28
30
  name: string;
31
+ toolCallId: string;
29
32
  args: T;
30
33
  status: ToolCallStatus.Complete;
31
34
  result: string;
@@ -12,18 +12,21 @@ export interface ReactToolCallRenderer<T = unknown> {
12
12
  render: React.ComponentType<
13
13
  | {
14
14
  name: string;
15
+ toolCallId: string;
15
16
  args: Partial<T>;
16
17
  status: ToolCallStatus.InProgress;
17
18
  result: undefined;
18
19
  }
19
20
  | {
20
21
  name: string;
22
+ toolCallId: string;
21
23
  args: T;
22
24
  status: ToolCallStatus.Executing;
23
25
  result: undefined;
24
26
  }
25
27
  | {
26
28
  name: string;
29
+ toolCallId: string;
27
30
  args: T;
28
31
  status: ToolCallStatus.Complete;
29
32
  result: string;