@copilotkit/react-core 1.56.1 → 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-Cj2ZIxVr.mjs → copilotkit-BBYbekCa.mjs} +234 -60
- package/dist/copilotkit-BBYbekCa.mjs.map +1 -0
- package/dist/{copilotkit-CSJw5BG8.cjs → copilotkit-D5JT2Pu3.cjs} +233 -59
- package/dist/copilotkit-D5JT2Pu3.cjs.map +1 -0
- package/dist/{copilotkit-CCbxm6JM.d.mts → copilotkit-DArT2Iuw.d.mts} +62 -18
- package/dist/copilotkit-DArT2Iuw.d.mts.map +1 -0
- package/dist/{copilotkit-BtP7w7cT.d.cts → copilotkit-KEc28l8G.d.cts} +62 -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 +16 -40
- 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 +232 -62
- package/dist/v2/index.umd.js.map +1 -1
- package/package.json +6 -6
- package/src/v2/components/chat/CopilotChat.tsx +80 -4
- package/src/v2/components/chat/CopilotChatInput.tsx +22 -0
- 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__/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/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-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-threads.tsx +55 -12
- package/src/v2/providers/CopilotKitProvider.tsx +2 -11
- package/dist/copilotkit-BtP7w7cT.d.cts.map +0 -1
- package/dist/copilotkit-CCbxm6JM.d.mts.map +0 -1
- package/dist/copilotkit-CSJw5BG8.cjs.map +0 -1
- package/dist/copilotkit-Cj2ZIxVr.mjs.map +0 -1
|
@@ -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
|
+
});
|
|
@@ -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
|
});
|