@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.
- package/dist/{copilotkit-BebqQrYT.mjs → copilotkit-BBYbekCa.mjs} +265 -76
- package/dist/copilotkit-BBYbekCa.mjs.map +1 -0
- package/dist/{copilotkit-Cvb6WpAX.cjs → copilotkit-D5JT2Pu3.cjs} +264 -75
- package/dist/copilotkit-D5JT2Pu3.cjs.map +1 -0
- package/dist/{copilotkit-f2Uq0RwG.d.mts → copilotkit-DArT2Iuw.d.mts} +71 -18
- package/dist/copilotkit-DArT2Iuw.d.mts.map +1 -0
- package/dist/{copilotkit-Dv8zU8_U.d.cts → copilotkit-KEc28l8G.d.cts} +71 -18
- package/dist/copilotkit-KEc28l8G.d.cts.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.umd.js +30 -46
- package/dist/index.umd.js.map +1 -1
- package/dist/v2/index.cjs +1 -1
- package/dist/v2/index.css +1 -1
- package/dist/v2/index.d.cts +2 -2
- package/dist/v2/index.d.mts +2 -2
- package/dist/v2/index.mjs +1 -1
- package/dist/v2/index.umd.js +264 -79
- package/dist/v2/index.umd.js.map +1 -1
- package/package.json +6 -6
- package/src/components/CopilotListeners.tsx +15 -4
- package/src/components/__tests__/CopilotListeners.test.tsx +38 -0
- package/src/v2/components/chat/CopilotChat.tsx +80 -4
- package/src/v2/components/chat/CopilotChatAssistantMessage.tsx +4 -4
- package/src/v2/components/chat/CopilotChatInput.tsx +43 -2
- package/src/v2/components/chat/CopilotChatView.tsx +206 -11
- package/src/v2/components/chat/__tests__/CopilotChat.absentThreadConnect.test.tsx +66 -0
- package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +300 -2
- package/src/v2/components/chat/__tests__/CopilotChatAssistantMessage.thumbs.test.tsx +72 -0
- package/src/v2/components/chat/__tests__/CopilotChatInput.test.tsx +38 -0
- package/src/v2/components/chat/__tests__/CopilotChatView.connectingGate.test.tsx +56 -0
- package/src/v2/components/chat/__tests__/CopilotChatView.pinToSend.test.tsx +94 -0
- package/src/v2/components/chat/__tests__/copilot-chat-throttle.test.tsx +0 -1
- package/src/v2/components/chat/__tests__/normalize-auto-scroll.test.ts +37 -0
- package/src/v2/components/chat/index.ts +2 -0
- package/src/v2/components/chat/last-user-message-context.ts +21 -0
- package/src/v2/components/chat/normalize-auto-scroll.ts +17 -0
- package/src/v2/components/license-warning-banner.tsx +20 -1
- package/src/v2/components/ui/button.tsx +12 -11
- package/src/v2/hooks/__tests__/use-agent-stability.test.tsx +6 -0
- package/src/v2/hooks/__tests__/use-agent-thread-isolation.test.tsx +6 -0
- package/src/v2/hooks/__tests__/use-agent-throttle.test.tsx +76 -50
- package/src/v2/hooks/__tests__/use-pin-to-send.test.tsx +219 -0
- package/src/v2/hooks/__tests__/use-render-custom-messages.test.tsx +55 -0
- package/src/v2/hooks/__tests__/use-threads.test.tsx +68 -0
- package/src/v2/hooks/use-agent.tsx +34 -77
- package/src/v2/hooks/use-pin-to-send.ts +94 -0
- package/src/v2/hooks/use-render-custom-messages.tsx +1 -1
- package/src/v2/hooks/use-render-tool-call.tsx +3 -0
- package/src/v2/hooks/use-render-tool.tsx +3 -0
- package/src/v2/hooks/use-threads.tsx +55 -12
- package/src/v2/providers/CopilotKitProvider.tsx +2 -11
- package/src/v2/types/defineToolCallRenderer.ts +3 -0
- package/src/v2/types/react-tool-call-renderer.ts +3 -0
- package/dist/copilotkit-BebqQrYT.mjs.map +0 -1
- package/dist/copilotkit-Cvb6WpAX.cjs.map +0 -1
- package/dist/copilotkit-Dv8zU8_U.d.cts.map +0 -1
- package/dist/copilotkit-f2Uq0RwG.d.mts.map +0 -1
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import React from "react";
|
|
1
|
+
import React, { useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
// Total reserved vertical space for the fixed license banner: banner height
|
|
4
|
+
// (~36px) + bottom offset (8px) + visual gap above the chat input (~8px).
|
|
5
|
+
const LICENSE_BANNER_OFFSET_PX = 52;
|
|
6
|
+
const LICENSE_BANNER_OFFSET_VAR = "--copilotkit-license-banner-offset";
|
|
2
7
|
|
|
3
8
|
interface LicenseWarningBannerProps {
|
|
4
9
|
type:
|
|
@@ -72,6 +77,20 @@ function BannerShell({
|
|
|
72
77
|
actionUrl: string;
|
|
73
78
|
onDismiss?: () => void;
|
|
74
79
|
}) {
|
|
80
|
+
// Publish the banner's reserved bottom offset so the chat input can lift
|
|
81
|
+
// itself above it via padding-bottom: var(--copilotkit-license-banner-offset).
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (typeof document === "undefined") return;
|
|
84
|
+
const root = document.documentElement;
|
|
85
|
+
root.style.setProperty(
|
|
86
|
+
LICENSE_BANNER_OFFSET_VAR,
|
|
87
|
+
`${LICENSE_BANNER_OFFSET_PX}px`,
|
|
88
|
+
);
|
|
89
|
+
return () => {
|
|
90
|
+
root.style.removeProperty(LICENSE_BANNER_OFFSET_VAR);
|
|
91
|
+
};
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
75
94
|
return (
|
|
76
95
|
<div style={{ ...BANNER_STYLES.base, ...getSeverityStyle(severity) }}>
|
|
77
96
|
<span>{message}</span>
|
|
@@ -99,25 +99,26 @@ const buttonVariants = cva(
|
|
|
99
99
|
},
|
|
100
100
|
);
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
102
|
+
const Button = React.forwardRef<
|
|
103
|
+
HTMLButtonElement,
|
|
104
|
+
React.ComponentProps<"button"> &
|
|
105
|
+
VariantProps<typeof buttonVariants> & {
|
|
106
|
+
asChild?: boolean;
|
|
107
|
+
}
|
|
108
|
+
>(function Button(
|
|
109
|
+
{ className, variant, size, asChild = false, ...props },
|
|
110
|
+
ref,
|
|
111
|
+
) {
|
|
112
112
|
const Comp = asChild ? Slot : "button";
|
|
113
113
|
|
|
114
114
|
return (
|
|
115
115
|
<Comp
|
|
116
|
+
ref={ref}
|
|
116
117
|
data-slot="button"
|
|
117
118
|
className={cn(buttonVariants({ variant, size, className }))}
|
|
118
119
|
{...props}
|
|
119
120
|
/>
|
|
120
121
|
);
|
|
121
|
-
}
|
|
122
|
+
});
|
|
122
123
|
|
|
123
124
|
export { Button, buttonVariants };
|
|
@@ -29,6 +29,10 @@ describe("useAgent stability during runtime connection", () => {
|
|
|
29
29
|
runtimeTransport: string;
|
|
30
30
|
headers: Record<string, string>;
|
|
31
31
|
agents: Record<string, AbstractAgent>;
|
|
32
|
+
subscribeToAgentWithOptions: (
|
|
33
|
+
agent: AbstractAgent,
|
|
34
|
+
subscriber: any,
|
|
35
|
+
) => { unsubscribe: () => void };
|
|
32
36
|
};
|
|
33
37
|
|
|
34
38
|
beforeEach(() => {
|
|
@@ -40,6 +44,8 @@ describe("useAgent stability during runtime connection", () => {
|
|
|
40
44
|
runtimeTransport: "rest",
|
|
41
45
|
headers: {},
|
|
42
46
|
agents: {},
|
|
47
|
+
subscribeToAgentWithOptions: (agent, subscriber) =>
|
|
48
|
+
agent.subscribe(subscriber),
|
|
43
49
|
};
|
|
44
50
|
|
|
45
51
|
mockUseCopilotKit.mockReturnValue({
|
|
@@ -47,6 +47,10 @@ describe("useAgent thread isolation", () => {
|
|
|
47
47
|
runtimeTransport: string;
|
|
48
48
|
headers: Record<string, string>;
|
|
49
49
|
agents: Record<string, AbstractAgent>;
|
|
50
|
+
subscribeToAgentWithOptions: (
|
|
51
|
+
agent: AbstractAgent,
|
|
52
|
+
subscriber: any,
|
|
53
|
+
) => { unsubscribe: () => void };
|
|
50
54
|
};
|
|
51
55
|
|
|
52
56
|
let registeredAgent: CloneableAgent;
|
|
@@ -64,6 +68,8 @@ describe("useAgent thread isolation", () => {
|
|
|
64
68
|
runtimeTransport: "rest",
|
|
65
69
|
headers: {},
|
|
66
70
|
agents: { "my-agent": registeredAgent },
|
|
71
|
+
subscribeToAgentWithOptions: (agent, subscriber) =>
|
|
72
|
+
agent.subscribe(subscriber),
|
|
67
73
|
};
|
|
68
74
|
|
|
69
75
|
mockUseCopilotKit.mockReturnValue({
|
|
@@ -4,7 +4,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
4
4
|
import { useAgent, UseAgentUpdate } from "../use-agent";
|
|
5
5
|
import { useCopilotKit } from "../../providers/CopilotKitProvider";
|
|
6
6
|
import { MockStepwiseAgent } from "../../__tests__/utils/test-helpers";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
CopilotKitCore,
|
|
9
|
+
CopilotKitCoreRuntimeConnectionStatus,
|
|
10
|
+
} from "@copilotkit/core";
|
|
8
11
|
import type { Message } from "@ag-ui/core";
|
|
9
12
|
import type { RunAgentInput } from "@ag-ui/client";
|
|
10
13
|
|
|
@@ -126,11 +129,19 @@ function createTestComponent(
|
|
|
126
129
|
};
|
|
127
130
|
}
|
|
128
131
|
|
|
129
|
-
/** Factory for the mock return value of useCopilotKit
|
|
132
|
+
/** Factory for the mock return value of useCopilotKit.
|
|
133
|
+
* Uses a real CopilotKitCore instance so subscribeToAgentWithOptions (with its throttle
|
|
134
|
+
* logic) is exercised end-to-end rather than mocked. */
|
|
130
135
|
function createMockContext(
|
|
131
136
|
agent: MockStepwiseAgent,
|
|
132
137
|
overrides: { defaultThrottleMs?: number } = {},
|
|
133
138
|
) {
|
|
139
|
+
const core = new CopilotKitCore({
|
|
140
|
+
runtimeUrl: "http://localhost:3000/api/copilot",
|
|
141
|
+
});
|
|
142
|
+
if (overrides.defaultThrottleMs !== undefined) {
|
|
143
|
+
core.setDefaultThrottleMs(overrides.defaultThrottleMs);
|
|
144
|
+
}
|
|
134
145
|
return {
|
|
135
146
|
copilotkit: {
|
|
136
147
|
getAgent: () => agent,
|
|
@@ -139,7 +150,8 @@ function createMockContext(
|
|
|
139
150
|
runtimeTransport: "rest",
|
|
140
151
|
headers: {},
|
|
141
152
|
agents: { [String(agent.agentId)]: agent },
|
|
142
|
-
defaultThrottleMs:
|
|
153
|
+
defaultThrottleMs: core.defaultThrottleMs,
|
|
154
|
+
subscribeToAgentWithOptions: core.subscribeToAgentWithOptions.bind(core),
|
|
143
155
|
},
|
|
144
156
|
executingToolCallIds: new Set(),
|
|
145
157
|
};
|
|
@@ -313,7 +325,7 @@ describe("useAgent throttleMs", () => {
|
|
|
313
325
|
expect(screen.getByTestId("count").textContent).toBe("3");
|
|
314
326
|
});
|
|
315
327
|
|
|
316
|
-
it("with throttleMs, onStateChanged
|
|
328
|
+
it("with throttleMs, onStateChanged is also throttled (shared window)", async () => {
|
|
317
329
|
const TestComponent = createTestComponent({
|
|
318
330
|
updates: [
|
|
319
331
|
UseAgentUpdate.OnMessagesChanged,
|
|
@@ -324,19 +336,29 @@ describe("useAgent throttleMs", () => {
|
|
|
324
336
|
|
|
325
337
|
render(<TestComponent />);
|
|
326
338
|
|
|
327
|
-
// Fire onMessagesChanged to start the throttle window
|
|
339
|
+
// Fire onMessagesChanged to start the throttle window (leading edge)
|
|
328
340
|
act(() => {
|
|
329
341
|
mockAgent.messages = [userMsg("1", "a")];
|
|
330
342
|
notifyMessagesChanged(mockAgent);
|
|
331
343
|
});
|
|
344
|
+
expect(screen.getByTestId("count").textContent).toBe("1");
|
|
332
345
|
|
|
333
|
-
// Fire onStateChanged 10ms later —
|
|
334
|
-
|
|
346
|
+
// Fire onStateChanged 10ms later — should be deferred (within throttle window)
|
|
347
|
+
act(() => {
|
|
335
348
|
vi.advanceTimersByTime(10);
|
|
336
349
|
mockAgent.state = { count: 42 };
|
|
337
350
|
notifyStateChanged(mockAgent);
|
|
338
351
|
});
|
|
339
352
|
|
|
353
|
+
// State update is pending, not yet rendered
|
|
354
|
+
expect(screen.getByTestId("state").textContent).toBe("{}");
|
|
355
|
+
|
|
356
|
+
// Trailing edge fires after the window — await so microtask from
|
|
357
|
+
// batchedForceUpdate flushes and triggers the React re-render.
|
|
358
|
+
await act(async () => {
|
|
359
|
+
vi.advanceTimersByTime(100);
|
|
360
|
+
});
|
|
361
|
+
|
|
340
362
|
expect(screen.getByTestId("state").textContent).toBe('{"count":42}');
|
|
341
363
|
});
|
|
342
364
|
|
|
@@ -372,7 +394,7 @@ describe("useAgent throttleMs", () => {
|
|
|
372
394
|
expect(renderCount.current).toBe(countBeforeUnmount);
|
|
373
395
|
});
|
|
374
396
|
|
|
375
|
-
it("with throttleMs and
|
|
397
|
+
it("with throttleMs and only OnStateChanged subscribed, first state fires on leading edge", async () => {
|
|
376
398
|
const TestComponent = createTestComponent({
|
|
377
399
|
updates: [UseAgentUpdate.OnStateChanged],
|
|
378
400
|
throttleMs: 100,
|
|
@@ -380,7 +402,8 @@ describe("useAgent throttleMs", () => {
|
|
|
380
402
|
|
|
381
403
|
render(<TestComponent />);
|
|
382
404
|
|
|
383
|
-
//
|
|
405
|
+
// First onStateChanged fires immediately (leading edge) — await so
|
|
406
|
+
// microtask from batchedForceUpdate flushes.
|
|
384
407
|
await act(async () => {
|
|
385
408
|
mockAgent.state = { value: "test" };
|
|
386
409
|
notifyStateChanged(mockAgent);
|
|
@@ -388,15 +411,13 @@ describe("useAgent throttleMs", () => {
|
|
|
388
411
|
|
|
389
412
|
expect(screen.getByTestId("state").textContent).toBe('{"value":"test"}');
|
|
390
413
|
|
|
391
|
-
// No onMessagesChanged subscription should exist
|
|
414
|
+
// No onMessagesChanged subscription should exist — messages notification
|
|
415
|
+
// does nothing because the handler was never registered.
|
|
392
416
|
act(() => {
|
|
393
417
|
mockAgent.messages = [userMsg("1", "a")];
|
|
394
418
|
notifyMessagesChanged(mockAgent);
|
|
395
419
|
});
|
|
396
420
|
|
|
397
|
-
// onMessagesChanged was sent but no handler is subscribed, so no
|
|
398
|
-
// re-render is triggered. We verify by checking state still shows the
|
|
399
|
-
// last rendered value.
|
|
400
421
|
expect(screen.getByTestId("state").textContent).toBe('{"value":"test"}');
|
|
401
422
|
});
|
|
402
423
|
|
|
@@ -418,6 +439,7 @@ describe("useAgent throttleMs", () => {
|
|
|
418
439
|
expect.stringContaining(
|
|
419
440
|
"throttleMs must be a non-negative finite number",
|
|
420
441
|
),
|
|
442
|
+
expect.any(Error),
|
|
421
443
|
);
|
|
422
444
|
|
|
423
445
|
// Should behave as unthrottled — every notification fires immediately
|
|
@@ -864,23 +886,23 @@ describe("useAgent defaultThrottleMs from provider", () => {
|
|
|
864
886
|
])(
|
|
865
887
|
"with invalid provider defaultThrottleMs ($label), falls back to unthrottled and warns",
|
|
866
888
|
({ value }) => {
|
|
889
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
890
|
+
|
|
867
891
|
mockUseCopilotKit.mockReturnValue(
|
|
868
892
|
createMockContext(mockAgent, { defaultThrottleMs: value }),
|
|
869
893
|
);
|
|
870
894
|
|
|
871
|
-
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
872
895
|
const TestComponent = createTestComponent({ throttleMs: undefined });
|
|
873
896
|
|
|
874
897
|
render(<TestComponent />);
|
|
875
898
|
|
|
876
|
-
|
|
877
|
-
expect.stringContaining("provider-level defaultThrottleMs"),
|
|
878
|
-
);
|
|
899
|
+
// The core setter rejects invalid values and logs an error
|
|
879
900
|
expect(errorSpy).toHaveBeenCalledWith(
|
|
880
901
|
expect.stringContaining("must be a non-negative finite number"),
|
|
902
|
+
expect.any(Error),
|
|
881
903
|
);
|
|
882
904
|
|
|
883
|
-
// Should behave as unthrottled
|
|
905
|
+
// Should behave as unthrottled (setter rejected the value)
|
|
884
906
|
act(() => {
|
|
885
907
|
mockAgent.messages = [userMsg("1", "a")];
|
|
886
908
|
notifyMessagesChanged(mockAgent);
|
|
@@ -963,41 +985,45 @@ describe("useAgent defaultThrottleMs from provider", () => {
|
|
|
963
985
|
});
|
|
964
986
|
});
|
|
965
987
|
|
|
966
|
-
describe("CopilotKitCore.setDefaultThrottleMs
|
|
988
|
+
describe("CopilotKitCore.setDefaultThrottleMs", () => {
|
|
989
|
+
it("stores valid values", () => {
|
|
990
|
+
const core = new CopilotKitCore({});
|
|
991
|
+
core.setDefaultThrottleMs(100);
|
|
992
|
+
expect(core.defaultThrottleMs).toBe(100);
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
it("stores 0", () => {
|
|
996
|
+
const core = new CopilotKitCore({});
|
|
997
|
+
core.setDefaultThrottleMs(100);
|
|
998
|
+
core.setDefaultThrottleMs(0);
|
|
999
|
+
expect(core.defaultThrottleMs).toBe(0);
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
it("stores undefined", () => {
|
|
1003
|
+
const core = new CopilotKitCore({});
|
|
1004
|
+
core.setDefaultThrottleMs(100);
|
|
1005
|
+
core.setDefaultThrottleMs(undefined);
|
|
1006
|
+
expect(core.defaultThrottleMs).toBeUndefined();
|
|
1007
|
+
});
|
|
1008
|
+
|
|
967
1009
|
it.each([
|
|
968
1010
|
{ label: "NaN", value: NaN },
|
|
969
1011
|
{ label: "Infinity", value: Infinity },
|
|
970
1012
|
{ label: "-1", value: -1 },
|
|
971
1013
|
{ label: "-Infinity", value: -Infinity },
|
|
972
|
-
])(
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
render(<TestComponent />);
|
|
988
|
-
|
|
989
|
-
// Should behave as unthrottled (no provider default in effect)
|
|
990
|
-
act(() => {
|
|
991
|
-
mockAgent.messages = [userMsg("1", "a")];
|
|
992
|
-
notifyMessagesChanged(mockAgent);
|
|
993
|
-
});
|
|
994
|
-
expect(screen.getByTestId("count").textContent).toBe("1");
|
|
995
|
-
|
|
996
|
-
act(() => {
|
|
997
|
-
mockAgent.messages = [userMsg("1", "a"), assistantMsg("2", "b")];
|
|
998
|
-
notifyMessagesChanged(mockAgent);
|
|
999
|
-
});
|
|
1000
|
-
expect(screen.getByTestId("count").textContent).toBe("2");
|
|
1001
|
-
vi.useRealTimers();
|
|
1002
|
-
});
|
|
1014
|
+
])(
|
|
1015
|
+
"rejects invalid value ($label) and preserves previous value",
|
|
1016
|
+
({ value }) => {
|
|
1017
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
1018
|
+
const core = new CopilotKitCore({});
|
|
1019
|
+
core.setDefaultThrottleMs(200);
|
|
1020
|
+
core.setDefaultThrottleMs(value);
|
|
1021
|
+
expect(core.defaultThrottleMs).toBe(200);
|
|
1022
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
1023
|
+
expect.stringContaining("must be a non-negative finite number"),
|
|
1024
|
+
expect.any(Error),
|
|
1025
|
+
);
|
|
1026
|
+
errorSpy.mockRestore();
|
|
1027
|
+
},
|
|
1028
|
+
);
|
|
1003
1029
|
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { render, act } from "@testing-library/react";
|
|
3
|
+
import React, { useRef } from "react";
|
|
4
|
+
import { usePinToSend } from "../use-pin-to-send";
|
|
5
|
+
import { LastUserMessageContext } from "../../components/chat/last-user-message-context";
|
|
6
|
+
|
|
7
|
+
// Small harness that wires the hook up against an in-memory DOM.
|
|
8
|
+
// Height mocks are applied via Object.defineProperty because jsdom doesn't run layout.
|
|
9
|
+
function setHeight(el: HTMLElement, height: number) {
|
|
10
|
+
Object.defineProperty(el, "clientHeight", {
|
|
11
|
+
configurable: true,
|
|
12
|
+
value: height,
|
|
13
|
+
});
|
|
14
|
+
Object.defineProperty(el, "offsetHeight", {
|
|
15
|
+
configurable: true,
|
|
16
|
+
value: height,
|
|
17
|
+
});
|
|
18
|
+
el.getBoundingClientRect = () =>
|
|
19
|
+
({
|
|
20
|
+
top: 0,
|
|
21
|
+
left: 0,
|
|
22
|
+
right: 0,
|
|
23
|
+
bottom: height,
|
|
24
|
+
width: 100,
|
|
25
|
+
height,
|
|
26
|
+
x: 0,
|
|
27
|
+
y: 0,
|
|
28
|
+
toJSON: () => ({}),
|
|
29
|
+
}) as DOMRect;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Inner component so the hook is mounted inside the Provider and can read context.
|
|
33
|
+
function HarnessInner({ topOffset }: { topOffset: number }) {
|
|
34
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
35
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
36
|
+
const spacerRef = useRef<HTMLDivElement>(null);
|
|
37
|
+
|
|
38
|
+
usePinToSend({ scrollRef, contentRef, spacerRef, topOffset });
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div ref={scrollRef} data-testid="scroll">
|
|
42
|
+
<div ref={contentRef} data-testid="content">
|
|
43
|
+
<div data-message-id="m1" data-role="user">
|
|
44
|
+
user msg 1
|
|
45
|
+
</div>
|
|
46
|
+
<div data-message-id="m2" data-role="assistant">
|
|
47
|
+
asst msg 1
|
|
48
|
+
</div>
|
|
49
|
+
<div data-message-id="m3" data-role="user">
|
|
50
|
+
user msg 2
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
<div ref={spacerRef} data-testid="spacer" style={{ height: 0 }} />
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function Harness({
|
|
59
|
+
lastUserMessage,
|
|
60
|
+
topOffset = 16,
|
|
61
|
+
}: {
|
|
62
|
+
lastUserMessage: { id: string | null; sendNonce: number };
|
|
63
|
+
topOffset?: number;
|
|
64
|
+
}) {
|
|
65
|
+
return (
|
|
66
|
+
<LastUserMessageContext.Provider value={lastUserMessage}>
|
|
67
|
+
<HarnessInner topOffset={topOffset} />
|
|
68
|
+
</LastUserMessageContext.Provider>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
HTMLElement.prototype.scrollTo = vi.fn();
|
|
74
|
+
// jsdom does not run rAF callbacks — stub it to fire synchronously so scroll assertions work.
|
|
75
|
+
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
|
76
|
+
cb(0);
|
|
77
|
+
return 0;
|
|
78
|
+
});
|
|
79
|
+
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("usePinToSend", () => {
|
|
83
|
+
it("sets spacer height to viewportHeight - userMessageHeight - topOffset on new send", async () => {
|
|
84
|
+
const { rerender, getByTestId } = render(
|
|
85
|
+
<Harness lastUserMessage={{ id: null, sendNonce: 0 }} />,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const scroll = getByTestId("scroll");
|
|
89
|
+
const spacer = getByTestId("spacer");
|
|
90
|
+
setHeight(scroll, 800);
|
|
91
|
+
|
|
92
|
+
const userMsg = scroll.querySelector(
|
|
93
|
+
'[data-message-id="m3"]',
|
|
94
|
+
) as HTMLElement;
|
|
95
|
+
setHeight(userMsg, 40);
|
|
96
|
+
|
|
97
|
+
act(() => {
|
|
98
|
+
rerender(<Harness lastUserMessage={{ id: "m3", sendNonce: 1 }} />);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// viewport=800, userMsg=40, topOffset=16
|
|
102
|
+
// spacer = max(0, 800 - 40 - 16) = 744
|
|
103
|
+
expect(spacer.style.height).toBe("744px");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("calls scrollTo with targetEl.offsetTop - topOffset on new send", async () => {
|
|
107
|
+
const { rerender, getByTestId } = render(
|
|
108
|
+
<Harness lastUserMessage={{ id: null, sendNonce: 0 }} />,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const scroll = getByTestId("scroll");
|
|
112
|
+
setHeight(scroll, 800);
|
|
113
|
+
const scrollTo = scroll.scrollTo as unknown as ReturnType<typeof vi.fn>;
|
|
114
|
+
|
|
115
|
+
const userMsg = scroll.querySelector(
|
|
116
|
+
'[data-message-id="m3"]',
|
|
117
|
+
) as HTMLElement;
|
|
118
|
+
setHeight(userMsg, 40);
|
|
119
|
+
// computeOffsetTop uses getBoundingClientRect; mock top=400 on userMsg and top=0 on scroll
|
|
120
|
+
// so that elRect.top - stopRect.top + scrollEl.scrollTop = 400 - 0 + 0 = 400.
|
|
121
|
+
userMsg.getBoundingClientRect = () =>
|
|
122
|
+
({
|
|
123
|
+
top: 400,
|
|
124
|
+
left: 0,
|
|
125
|
+
right: 100,
|
|
126
|
+
bottom: 440,
|
|
127
|
+
width: 100,
|
|
128
|
+
height: 40,
|
|
129
|
+
x: 0,
|
|
130
|
+
y: 400,
|
|
131
|
+
toJSON: () => ({}),
|
|
132
|
+
}) as DOMRect;
|
|
133
|
+
|
|
134
|
+
act(() => {
|
|
135
|
+
rerender(<Harness lastUserMessage={{ id: "m3", sendNonce: 1 }} />);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Allow rAF to fire
|
|
139
|
+
await act(async () => {
|
|
140
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(scrollTo).toHaveBeenCalledWith({
|
|
144
|
+
top: 400 - 16,
|
|
145
|
+
behavior: "smooth",
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("shrinks spacer as content height grows (does not grow it)", async () => {
|
|
150
|
+
let observed: (() => void) | null = null;
|
|
151
|
+
const ROStub = vi.fn().mockImplementation((cb: () => void) => {
|
|
152
|
+
observed = cb;
|
|
153
|
+
return { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() };
|
|
154
|
+
});
|
|
155
|
+
const prevRO = global.ResizeObserver;
|
|
156
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
157
|
+
global.ResizeObserver = ROStub as any;
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const { rerender, getByTestId } = render(
|
|
161
|
+
<Harness lastUserMessage={{ id: null, sendNonce: 0 }} />,
|
|
162
|
+
);
|
|
163
|
+
const scroll = getByTestId("scroll");
|
|
164
|
+
const content = getByTestId("content");
|
|
165
|
+
const spacer = getByTestId("spacer");
|
|
166
|
+
setHeight(scroll, 800);
|
|
167
|
+
const userMsg = scroll.querySelector(
|
|
168
|
+
'[data-message-id="m3"]',
|
|
169
|
+
) as HTMLElement;
|
|
170
|
+
setHeight(userMsg, 40);
|
|
171
|
+
setHeight(content, 200);
|
|
172
|
+
|
|
173
|
+
act(() => {
|
|
174
|
+
rerender(<Harness lastUserMessage={{ id: "m3", sendNonce: 1 }} />);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Initial: 800 - 40 - 16 = 744
|
|
178
|
+
expect(spacer.style.height).toBe("744px");
|
|
179
|
+
|
|
180
|
+
// Simulate content growing — spacer should shrink
|
|
181
|
+
setHeight(content, 600);
|
|
182
|
+
act(() => observed?.());
|
|
183
|
+
expect(parseInt(spacer.style.height, 10)).toBeLessThan(744);
|
|
184
|
+
|
|
185
|
+
// Simulate content shrinking — spacer should NOT grow back
|
|
186
|
+
setHeight(content, 100);
|
|
187
|
+
const shrunkHeight = spacer.style.height;
|
|
188
|
+
act(() => observed?.());
|
|
189
|
+
expect(spacer.style.height).toBe(shrunkHeight);
|
|
190
|
+
} finally {
|
|
191
|
+
global.ResizeObserver = prevRO;
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("cancels the scheduled rAF on unmount (cleanup)", async () => {
|
|
196
|
+
// Use a real rAF handle so we can assert the cancel was issued with it.
|
|
197
|
+
const cancelSpy = vi.spyOn(global, "cancelAnimationFrame");
|
|
198
|
+
try {
|
|
199
|
+
const { rerender, unmount, getByTestId } = render(
|
|
200
|
+
<Harness lastUserMessage={{ id: null, sendNonce: 0 }} />,
|
|
201
|
+
);
|
|
202
|
+
const scroll = getByTestId("scroll");
|
|
203
|
+
setHeight(scroll, 800);
|
|
204
|
+
const userMsg = scroll.querySelector(
|
|
205
|
+
'[data-message-id="m3"]',
|
|
206
|
+
) as HTMLElement;
|
|
207
|
+
setHeight(userMsg, 40);
|
|
208
|
+
|
|
209
|
+
act(() => {
|
|
210
|
+
rerender(<Harness lastUserMessage={{ id: "m3", sendNonce: 1 }} />);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
unmount();
|
|
214
|
+
expect(cancelSpy).toHaveBeenCalled();
|
|
215
|
+
} finally {
|
|
216
|
+
cancelSpy.mockRestore();
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { describe, it, expect, vi } from "vitest";
|
|
3
|
+
import { renderHook } from "@testing-library/react";
|
|
4
|
+
import { useRenderCustomMessages } from "../use-render-custom-messages";
|
|
5
|
+
import { CopilotKitProvider } from "../../providers/CopilotKitProvider";
|
|
6
|
+
import { CopilotChatConfigurationProvider } from "../../providers/CopilotChatConfigurationProvider";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Regression test for #3497: useRenderCustomMessages throws "Agent not found"
|
|
10
|
+
* when the agent is undefined during the connecting state.
|
|
11
|
+
*
|
|
12
|
+
* During initial connection, the agent may not yet be registered in the
|
|
13
|
+
* CopilotKit registry. The hook should return null gracefully instead of
|
|
14
|
+
* throwing an error.
|
|
15
|
+
*/
|
|
16
|
+
describe("useRenderCustomMessages (#3497)", () => {
|
|
17
|
+
it("returns null instead of throwing when agent is not found", () => {
|
|
18
|
+
// Render the hook inside a CopilotKitProvider with an agentId that
|
|
19
|
+
// does NOT exist in the registry (simulating connecting state).
|
|
20
|
+
// The hook should not throw.
|
|
21
|
+
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
22
|
+
<CopilotKitProvider
|
|
23
|
+
renderCustomMessages={[
|
|
24
|
+
{
|
|
25
|
+
agentId: "nonexistent-agent",
|
|
26
|
+
render: () => <div>Custom</div>,
|
|
27
|
+
},
|
|
28
|
+
]}
|
|
29
|
+
>
|
|
30
|
+
<CopilotChatConfigurationProvider
|
|
31
|
+
agentId="nonexistent-agent"
|
|
32
|
+
threadId="test-thread"
|
|
33
|
+
>
|
|
34
|
+
{children}
|
|
35
|
+
</CopilotChatConfigurationProvider>
|
|
36
|
+
</CopilotKitProvider>
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const { result } = renderHook(() => useRenderCustomMessages(), { wrapper });
|
|
40
|
+
|
|
41
|
+
// The hook should return a function (the render function), not throw
|
|
42
|
+
// When called, it should handle missing agent gracefully
|
|
43
|
+
if (typeof result.current === "function") {
|
|
44
|
+
const output = result.current({
|
|
45
|
+
message: { id: "msg-1", role: "assistant", content: "test" },
|
|
46
|
+
position: "after",
|
|
47
|
+
});
|
|
48
|
+
// Should return null since agent isn't found
|
|
49
|
+
expect(output).toBeNull();
|
|
50
|
+
} else {
|
|
51
|
+
// If result.current is null, that's also acceptable
|
|
52
|
+
expect(result.current).toBeNull();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|