@copilotkit/react-core 1.55.3 → 1.56.1
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-dwDWYpya.d.cts → copilotkit-BtP7w7cT.d.cts} +56 -10
- package/dist/copilotkit-BtP7w7cT.d.cts.map +1 -0
- package/dist/{copilotkit-BuhSUZHb.d.mts → copilotkit-CCbxm6JM.d.mts} +56 -10
- package/dist/copilotkit-CCbxm6JM.d.mts.map +1 -0
- package/dist/{copilotkit-Dgdpbqjt.cjs → copilotkit-CSJw5BG8.cjs} +129 -58
- package/dist/copilotkit-CSJw5BG8.cjs.map +1 -0
- package/dist/{copilotkit-Cd-NrDyp.mjs → copilotkit-Cj2ZIxVr.mjs} +125 -60
- package/dist/copilotkit-Cj2ZIxVr.mjs.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 +55 -23
- package/dist/index.umd.js.map +1 -1
- package/dist/v2/index.cjs +2 -1
- package/dist/v2/index.d.cts +2 -2
- package/dist/v2/index.d.mts +2 -2
- package/dist/v2/index.mjs +2 -2
- package/dist/v2/index.umd.js +124 -59
- 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/components/copilot-provider/__tests__/error-visibility-prod.test.tsx +70 -0
- package/src/components/copilot-provider/copilot-messages.tsx +39 -24
- package/src/components/copilot-provider/copilotkit-props.tsx +26 -6
- package/src/components/copilot-provider/copilotkit.tsx +4 -1
- package/src/v2/components/chat/CopilotChatAssistantMessage.tsx +22 -19
- package/src/v2/components/chat/CopilotChatInput.tsx +21 -2
- package/src/v2/components/chat/CopilotChatReasoningMessage.tsx +17 -4
- package/src/v2/components/chat/CopilotChatUserMessage.tsx +13 -10
- package/src/v2/components/chat/__tests__/CopilotChat.e2e.test.tsx +131 -5
- package/src/v2/components/chat/__tests__/CopilotChatAssistantMessage.test.tsx +1 -1
- package/src/v2/components/chat/__tests__/CopilotChatAssistantMessage.thumbs.test.tsx +72 -0
- package/src/v2/components/chat/__tests__/CopilotChatCopyButton.clipboard.test.tsx +241 -0
- package/src/v2/components/chat/__tests__/CopilotChatInput.test.tsx +38 -0
- package/src/v2/components/ui/button.tsx +12 -11
- package/src/v2/hooks/__tests__/use-agent-throttle.test.tsx +10 -10
- package/src/v2/hooks/__tests__/use-capabilities.test.tsx +76 -0
- package/src/v2/hooks/__tests__/use-render-custom-messages.test.tsx +55 -0
- package/src/v2/hooks/index.ts +1 -0
- package/src/v2/hooks/use-agent.tsx +23 -4
- package/src/v2/hooks/use-capabilities.tsx +25 -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/providers/CopilotKitProvider.tsx +15 -2
- package/src/v2/types/defineToolCallRenderer.ts +3 -0
- package/src/v2/types/react-tool-call-renderer.ts +3 -0
- package/dist/copilotkit-BuhSUZHb.d.mts.map +0 -1
- package/dist/copilotkit-Cd-NrDyp.mjs.map +0 -1
- package/dist/copilotkit-Dgdpbqjt.cjs.map +0 -1
- package/dist/copilotkit-dwDWYpya.d.cts.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useEffect } from "react";
|
|
2
|
-
import { screen, fireEvent, waitFor } from "@testing-library/react";
|
|
2
|
+
import { screen, fireEvent, waitFor, act } from "@testing-library/react";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { defineToolCallRenderer, ReactToolCallRenderer } from "../../../types";
|
|
5
5
|
import {
|
|
@@ -1060,6 +1060,128 @@ describe("CopilotChat E2E - Chat Basics and Streaming Patterns", () => {
|
|
|
1060
1060
|
agent.complete();
|
|
1061
1061
|
});
|
|
1062
1062
|
|
|
1063
|
+
it("should not auto-collapse when user manually toggled during streaming", async () => {
|
|
1064
|
+
const agent = new MockStepwiseAgent();
|
|
1065
|
+
renderWithCopilotKit({ agent });
|
|
1066
|
+
|
|
1067
|
+
const input = await screen.findByRole("textbox");
|
|
1068
|
+
fireEvent.change(input, { target: { value: "User toggle test" } });
|
|
1069
|
+
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
|
1070
|
+
|
|
1071
|
+
await waitFor(() => {
|
|
1072
|
+
expect(screen.getByText("User toggle test")).toBeDefined();
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
const reasoningId = testId("reasoning");
|
|
1076
|
+
const textId = testId("text");
|
|
1077
|
+
|
|
1078
|
+
// Start streaming reasoning — panel should auto-open
|
|
1079
|
+
agent.emit(runStartedEvent());
|
|
1080
|
+
agent.emit(reasoningStartEvent(reasoningId));
|
|
1081
|
+
agent.emit(reasoningMessageStartEvent(reasoningId));
|
|
1082
|
+
agent.emit(
|
|
1083
|
+
reasoningMessageContentEvent(reasoningId, "Deep analysis in progress"),
|
|
1084
|
+
);
|
|
1085
|
+
|
|
1086
|
+
await waitFor(() => {
|
|
1087
|
+
expect(screen.getByText("Thinking…")).toBeDefined();
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
// Panel should be open (aria-expanded="true") while streaming
|
|
1091
|
+
await waitFor(() => {
|
|
1092
|
+
const header = screen.getByText("Thinking…");
|
|
1093
|
+
const button = header.closest("button");
|
|
1094
|
+
expect(button?.getAttribute("aria-expanded")).toBe("true");
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
// User manually collapses during streaming — this sets userToggledRef
|
|
1098
|
+
const header = screen.getByText("Thinking…");
|
|
1099
|
+
const button = header.closest("button");
|
|
1100
|
+
act(() => {
|
|
1101
|
+
if (button) {
|
|
1102
|
+
fireEvent.click(button);
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
// Should now be collapsed by user action
|
|
1107
|
+
await waitFor(() => {
|
|
1108
|
+
const btn = screen.getByText("Thinking…").closest("button");
|
|
1109
|
+
expect(btn?.getAttribute("aria-expanded")).toBe("false");
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
// Now streaming ends — because userToggledRef is true, the panel
|
|
1113
|
+
// should stay in whatever state the user set (collapsed).
|
|
1114
|
+
agent.emit(reasoningMessageEndEvent(reasoningId));
|
|
1115
|
+
agent.emit(reasoningEndEvent(reasoningId));
|
|
1116
|
+
agent.emit(textChunkEvent(textId, "Done."));
|
|
1117
|
+
agent.emit(runFinishedEvent());
|
|
1118
|
+
agent.complete();
|
|
1119
|
+
|
|
1120
|
+
// Panel should remain collapsed (not flash open then closed)
|
|
1121
|
+
await waitFor(() => {
|
|
1122
|
+
const btn = screen.getByText(/Thought for/).closest("button");
|
|
1123
|
+
expect(btn?.getAttribute("aria-expanded")).toBe("false");
|
|
1124
|
+
});
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
it("should keep panel open when user re-expands during streaming", async () => {
|
|
1128
|
+
const agent = new MockStepwiseAgent();
|
|
1129
|
+
renderWithCopilotKit({ agent });
|
|
1130
|
+
|
|
1131
|
+
const input = await screen.findByRole("textbox");
|
|
1132
|
+
fireEvent.change(input, {
|
|
1133
|
+
target: { value: "Re-expand toggle test" },
|
|
1134
|
+
});
|
|
1135
|
+
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
|
1136
|
+
|
|
1137
|
+
await waitFor(() => {
|
|
1138
|
+
expect(screen.getByText("Re-expand toggle test")).toBeDefined();
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
const reasoningId = testId("reasoning");
|
|
1142
|
+
const textId = testId("text");
|
|
1143
|
+
|
|
1144
|
+
// Start streaming reasoning — panel auto-opens
|
|
1145
|
+
agent.emit(runStartedEvent());
|
|
1146
|
+
agent.emit(reasoningStartEvent(reasoningId));
|
|
1147
|
+
agent.emit(reasoningMessageStartEvent(reasoningId));
|
|
1148
|
+
agent.emit(reasoningMessageContentEvent(reasoningId, "Thinking hard"));
|
|
1149
|
+
|
|
1150
|
+
await waitFor(() => {
|
|
1151
|
+
const btn = screen.getByText("Thinking…").closest("button");
|
|
1152
|
+
expect(btn?.getAttribute("aria-expanded")).toBe("true");
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
// User collapses, then re-expands (both set userToggledRef = true)
|
|
1156
|
+
const headerEl = screen.getByText("Thinking…");
|
|
1157
|
+
const btn = headerEl.closest("button");
|
|
1158
|
+
act(() => {
|
|
1159
|
+
if (btn) {
|
|
1160
|
+
fireEvent.click(btn); // collapse
|
|
1161
|
+
fireEvent.click(btn); // re-expand
|
|
1162
|
+
}
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
await waitFor(() => {
|
|
1166
|
+
const b = screen.getByText("Thinking…").closest("button");
|
|
1167
|
+
expect(b?.getAttribute("aria-expanded")).toBe("true");
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
// Streaming ends — because userToggledRef is true, panel should
|
|
1171
|
+
// stay in the user's chosen state (open).
|
|
1172
|
+
agent.emit(reasoningMessageEndEvent(reasoningId));
|
|
1173
|
+
agent.emit(reasoningEndEvent(reasoningId));
|
|
1174
|
+
agent.emit(textChunkEvent(textId, "All done."));
|
|
1175
|
+
agent.emit(runFinishedEvent());
|
|
1176
|
+
agent.complete();
|
|
1177
|
+
|
|
1178
|
+
// Panel should remain open (not auto-collapse)
|
|
1179
|
+
await waitFor(() => {
|
|
1180
|
+
const b = screen.getByText(/Thought for/).closest("button");
|
|
1181
|
+
expect(b?.getAttribute("aria-expanded")).toBe("true");
|
|
1182
|
+
});
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1063
1185
|
it("should expand and collapse reasoning content on click", async () => {
|
|
1064
1186
|
const agent = new MockStepwiseAgent();
|
|
1065
1187
|
renderWithCopilotKit({ agent });
|
|
@@ -1094,12 +1216,16 @@ describe("CopilotChat E2E - Chat Basics and Streaming Patterns", () => {
|
|
|
1094
1216
|
expect(button?.getAttribute("aria-expanded")).toBe("false");
|
|
1095
1217
|
});
|
|
1096
1218
|
|
|
1097
|
-
// Click to expand
|
|
1219
|
+
// Click to expand — wrap in act() so React 18 flushes the state
|
|
1220
|
+
// update synchronously instead of deferring it through the scheduler,
|
|
1221
|
+
// which can race with waitFor polling on slow CI runners.
|
|
1098
1222
|
const header = screen.getByText(/Thought for/);
|
|
1099
1223
|
const button = header.closest("button");
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1224
|
+
act(() => {
|
|
1225
|
+
if (button) {
|
|
1226
|
+
fireEvent.click(button);
|
|
1227
|
+
}
|
|
1228
|
+
});
|
|
1103
1229
|
|
|
1104
1230
|
// Should now be expanded
|
|
1105
1231
|
await waitFor(() => {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
3
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
4
|
+
import { AssistantMessage } from "@ag-ui/core";
|
|
5
|
+
import { CopilotChatAssistantMessage } from "../CopilotChatAssistantMessage";
|
|
6
|
+
import { CopilotChatConfigurationProvider } from "../../../providers/CopilotChatConfigurationProvider";
|
|
7
|
+
import { CopilotKitProvider } from "../../../providers/CopilotKitProvider";
|
|
8
|
+
|
|
9
|
+
const TEST_THREAD_ID = "test-thread";
|
|
10
|
+
|
|
11
|
+
const renderWithProvider = (component: React.ReactElement) => {
|
|
12
|
+
return render(
|
|
13
|
+
<CopilotKitProvider>
|
|
14
|
+
<CopilotChatConfigurationProvider threadId={TEST_THREAD_ID}>
|
|
15
|
+
{component}
|
|
16
|
+
</CopilotChatConfigurationProvider>
|
|
17
|
+
</CopilotKitProvider>,
|
|
18
|
+
);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe("CopilotChatAssistantMessage thumbs callbacks (#3457)", () => {
|
|
22
|
+
const message: AssistantMessage = {
|
|
23
|
+
id: "msg-1",
|
|
24
|
+
role: "assistant",
|
|
25
|
+
content: "Hello from the assistant",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
it("onThumbsUp receives AssistantMessage, not SyntheticEvent", () => {
|
|
29
|
+
const onThumbsUp = vi.fn();
|
|
30
|
+
|
|
31
|
+
renderWithProvider(
|
|
32
|
+
<CopilotChatAssistantMessage message={message} onThumbsUp={onThumbsUp} />,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const thumbsUpButton = screen.getByRole("button", {
|
|
36
|
+
name: /good response/i,
|
|
37
|
+
});
|
|
38
|
+
fireEvent.click(thumbsUpButton);
|
|
39
|
+
|
|
40
|
+
expect(onThumbsUp).toHaveBeenCalledTimes(1);
|
|
41
|
+
const arg = onThumbsUp.mock.calls[0][0];
|
|
42
|
+
// Should receive AssistantMessage
|
|
43
|
+
expect(arg).toHaveProperty("id", "msg-1");
|
|
44
|
+
expect(arg).toHaveProperty("role", "assistant");
|
|
45
|
+
expect(arg).toHaveProperty("content", "Hello from the assistant");
|
|
46
|
+
// Should NOT receive a SyntheticEvent (which has nativeEvent, target, etc.)
|
|
47
|
+
expect(arg).not.toHaveProperty("nativeEvent");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("onThumbsDown receives AssistantMessage, not SyntheticEvent", () => {
|
|
51
|
+
const onThumbsDown = vi.fn();
|
|
52
|
+
|
|
53
|
+
renderWithProvider(
|
|
54
|
+
<CopilotChatAssistantMessage
|
|
55
|
+
message={message}
|
|
56
|
+
onThumbsDown={onThumbsDown}
|
|
57
|
+
/>,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const thumbsDownButton = screen.getByRole("button", {
|
|
61
|
+
name: /bad response/i,
|
|
62
|
+
});
|
|
63
|
+
fireEvent.click(thumbsDownButton);
|
|
64
|
+
|
|
65
|
+
expect(onThumbsDown).toHaveBeenCalledTimes(1);
|
|
66
|
+
const arg = onThumbsDown.mock.calls[0][0];
|
|
67
|
+
expect(arg).toHaveProperty("id", "msg-1");
|
|
68
|
+
expect(arg).toHaveProperty("role", "assistant");
|
|
69
|
+
expect(arg).toHaveProperty("content", "Hello from the assistant");
|
|
70
|
+
expect(arg).not.toHaveProperty("nativeEvent");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
3
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
4
|
+
import { CopilotChatAssistantMessage } from "../CopilotChatAssistantMessage";
|
|
5
|
+
import { CopilotChatUserMessage } from "../CopilotChatUserMessage";
|
|
6
|
+
import { CopilotKitProvider } from "../../../providers/CopilotKitProvider";
|
|
7
|
+
import { CopilotChatConfigurationProvider } from "../../../providers/CopilotChatConfigurationProvider";
|
|
8
|
+
import { AssistantMessage, UserMessage } from "@ag-ui/core";
|
|
9
|
+
|
|
10
|
+
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|
11
|
+
<CopilotKitProvider>
|
|
12
|
+
<CopilotChatConfigurationProvider threadId="test-thread">
|
|
13
|
+
{children}
|
|
14
|
+
</CopilotChatConfigurationProvider>
|
|
15
|
+
</CopilotKitProvider>
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
const createAssistantMessage = (content: string): AssistantMessage => ({
|
|
19
|
+
id: "msg-assistant-1",
|
|
20
|
+
role: "assistant",
|
|
21
|
+
content,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const createUserMessage = (content: string): UserMessage => ({
|
|
25
|
+
id: "msg-user-1",
|
|
26
|
+
role: "user",
|
|
27
|
+
content,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("CopyButton clipboard behavior", () => {
|
|
31
|
+
let originalClipboard: Clipboard;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
originalClipboard = navigator.clipboard;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
Object.defineProperty(navigator, "clipboard", {
|
|
39
|
+
value: originalClipboard,
|
|
40
|
+
writable: true,
|
|
41
|
+
configurable: true,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("AssistantMessage CopyButton", () => {
|
|
46
|
+
it("shows copied state only after successful clipboard write", async () => {
|
|
47
|
+
const writeTextMock = vi.fn().mockResolvedValue(undefined);
|
|
48
|
+
Object.defineProperty(navigator, "clipboard", {
|
|
49
|
+
value: { writeText: writeTextMock },
|
|
50
|
+
writable: true,
|
|
51
|
+
configurable: true,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const message = createAssistantMessage("Hello assistant");
|
|
55
|
+
render(
|
|
56
|
+
<TestWrapper>
|
|
57
|
+
<CopilotChatAssistantMessage message={message} />
|
|
58
|
+
</TestWrapper>,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const copyButton = screen.getByTestId("copilot-copy-button");
|
|
62
|
+
fireEvent.click(copyButton);
|
|
63
|
+
|
|
64
|
+
await waitFor(() => {
|
|
65
|
+
expect(writeTextMock).toHaveBeenCalledWith("Hello assistant");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// After successful write, should show check icon
|
|
69
|
+
await waitFor(() => {
|
|
70
|
+
const checkIcon = copyButton.querySelector(".lucide-check");
|
|
71
|
+
expect(checkIcon).not.toBeNull();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("does NOT show copied state when clipboard API is unavailable", async () => {
|
|
76
|
+
Object.defineProperty(navigator, "clipboard", {
|
|
77
|
+
value: undefined,
|
|
78
|
+
writable: true,
|
|
79
|
+
configurable: true,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const message = createAssistantMessage("Hello assistant");
|
|
83
|
+
render(
|
|
84
|
+
<TestWrapper>
|
|
85
|
+
<CopilotChatAssistantMessage message={message} />
|
|
86
|
+
</TestWrapper>,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const copyButton = screen.getByTestId("copilot-copy-button");
|
|
90
|
+
fireEvent.click(copyButton);
|
|
91
|
+
|
|
92
|
+
// Wait a tick for any async handlers
|
|
93
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
94
|
+
|
|
95
|
+
// Should still show copy icon (not check icon)
|
|
96
|
+
const checkIcon = copyButton.querySelector(".lucide-check");
|
|
97
|
+
expect(checkIcon).toBeNull();
|
|
98
|
+
const copyIcon = copyButton.querySelector(".lucide-copy");
|
|
99
|
+
expect(copyIcon).not.toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("logs error when clipboard write rejects", async () => {
|
|
103
|
+
const writeTextMock = vi
|
|
104
|
+
.fn()
|
|
105
|
+
.mockRejectedValue(new Error("Permission denied"));
|
|
106
|
+
Object.defineProperty(navigator, "clipboard", {
|
|
107
|
+
value: { writeText: writeTextMock },
|
|
108
|
+
writable: true,
|
|
109
|
+
configurable: true,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const consoleSpy = vi
|
|
113
|
+
.spyOn(console, "error")
|
|
114
|
+
.mockImplementation(() => {});
|
|
115
|
+
|
|
116
|
+
const message = createAssistantMessage("Hello assistant");
|
|
117
|
+
render(
|
|
118
|
+
<TestWrapper>
|
|
119
|
+
<CopilotChatAssistantMessage message={message} />
|
|
120
|
+
</TestWrapper>,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const copyButton = screen.getByTestId("copilot-copy-button");
|
|
124
|
+
fireEvent.click(copyButton);
|
|
125
|
+
|
|
126
|
+
await waitFor(() => {
|
|
127
|
+
expect(writeTextMock).toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await waitFor(() => {
|
|
131
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
132
|
+
"Failed to copy to clipboard:",
|
|
133
|
+
expect.any(Error),
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Should NOT show copied state when write failed
|
|
138
|
+
const checkIcon = copyButton.querySelector(".lucide-check");
|
|
139
|
+
expect(checkIcon).toBeNull();
|
|
140
|
+
|
|
141
|
+
consoleSpy.mockRestore();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("UserMessage CopyButton", () => {
|
|
146
|
+
it("shows copied state only after successful clipboard write", async () => {
|
|
147
|
+
const writeTextMock = vi.fn().mockResolvedValue(undefined);
|
|
148
|
+
Object.defineProperty(navigator, "clipboard", {
|
|
149
|
+
value: { writeText: writeTextMock },
|
|
150
|
+
writable: true,
|
|
151
|
+
configurable: true,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const message = createUserMessage("Hello user");
|
|
155
|
+
render(
|
|
156
|
+
<TestWrapper>
|
|
157
|
+
<CopilotChatUserMessage message={message} />
|
|
158
|
+
</TestWrapper>,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const copyButton = screen.getByTestId("copilot-user-copy-button");
|
|
162
|
+
fireEvent.click(copyButton);
|
|
163
|
+
|
|
164
|
+
await waitFor(() => {
|
|
165
|
+
expect(writeTextMock).toHaveBeenCalledWith("Hello user");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await waitFor(() => {
|
|
169
|
+
const checkIcon = copyButton.querySelector(".lucide-check");
|
|
170
|
+
expect(checkIcon).not.toBeNull();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("does NOT show copied state when clipboard API is unavailable", async () => {
|
|
175
|
+
Object.defineProperty(navigator, "clipboard", {
|
|
176
|
+
value: undefined,
|
|
177
|
+
writable: true,
|
|
178
|
+
configurable: true,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const message = createUserMessage("Hello user");
|
|
182
|
+
render(
|
|
183
|
+
<TestWrapper>
|
|
184
|
+
<CopilotChatUserMessage message={message} />
|
|
185
|
+
</TestWrapper>,
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const copyButton = screen.getByTestId("copilot-user-copy-button");
|
|
189
|
+
fireEvent.click(copyButton);
|
|
190
|
+
|
|
191
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
192
|
+
|
|
193
|
+
const checkIcon = copyButton.querySelector(".lucide-check");
|
|
194
|
+
expect(checkIcon).toBeNull();
|
|
195
|
+
const copyIcon = copyButton.querySelector(".lucide-copy");
|
|
196
|
+
expect(copyIcon).not.toBeNull();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("logs error when clipboard write rejects", async () => {
|
|
200
|
+
const writeTextMock = vi
|
|
201
|
+
.fn()
|
|
202
|
+
.mockRejectedValue(new Error("Permission denied"));
|
|
203
|
+
Object.defineProperty(navigator, "clipboard", {
|
|
204
|
+
value: { writeText: writeTextMock },
|
|
205
|
+
writable: true,
|
|
206
|
+
configurable: true,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const consoleSpy = vi
|
|
210
|
+
.spyOn(console, "error")
|
|
211
|
+
.mockImplementation(() => {});
|
|
212
|
+
|
|
213
|
+
const message = createUserMessage("Hello user");
|
|
214
|
+
render(
|
|
215
|
+
<TestWrapper>
|
|
216
|
+
<CopilotChatUserMessage message={message} />
|
|
217
|
+
</TestWrapper>,
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const copyButton = screen.getByTestId("copilot-user-copy-button");
|
|
221
|
+
fireEvent.click(copyButton);
|
|
222
|
+
|
|
223
|
+
await waitFor(() => {
|
|
224
|
+
expect(writeTextMock).toHaveBeenCalled();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await waitFor(() => {
|
|
228
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
229
|
+
"Failed to copy to clipboard:",
|
|
230
|
+
expect.any(Error),
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Should NOT show copied state when write failed
|
|
235
|
+
const checkIcon = copyButton.querySelector(".lucide-check");
|
|
236
|
+
expect(checkIcon).toBeNull();
|
|
237
|
+
|
|
238
|
+
consoleSpy.mockRestore();
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -985,6 +985,44 @@ describe("CopilotChatInput", () => {
|
|
|
985
985
|
expect((input as HTMLTextAreaElement).value).toBe("test message");
|
|
986
986
|
expect(mockOnSubmitMessage).toHaveBeenCalledWith("test message");
|
|
987
987
|
});
|
|
988
|
+
|
|
989
|
+
it("calls onChange with empty string after submission in controlled mode", () => {
|
|
990
|
+
const mockOnChange = vi.fn();
|
|
991
|
+
const mockOnSubmitMessage = vi.fn();
|
|
992
|
+
|
|
993
|
+
const { container } = renderWithProvider(
|
|
994
|
+
<CopilotChatInput
|
|
995
|
+
value="test message"
|
|
996
|
+
onChange={mockOnChange}
|
|
997
|
+
onSubmitMessage={mockOnSubmitMessage}
|
|
998
|
+
/>,
|
|
999
|
+
);
|
|
1000
|
+
|
|
1001
|
+
const sendButton = getSendButton(container);
|
|
1002
|
+
fireEvent.click(sendButton!);
|
|
1003
|
+
|
|
1004
|
+
expect(mockOnSubmitMessage).toHaveBeenCalledWith("test message");
|
|
1005
|
+
expect(mockOnChange).toHaveBeenCalledWith("");
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
it("calls onChange with empty string after Enter submission in controlled mode", () => {
|
|
1009
|
+
const mockOnChange = vi.fn();
|
|
1010
|
+
const mockOnSubmitMessage = vi.fn();
|
|
1011
|
+
|
|
1012
|
+
renderWithProvider(
|
|
1013
|
+
<CopilotChatInput
|
|
1014
|
+
value="hello world"
|
|
1015
|
+
onChange={mockOnChange}
|
|
1016
|
+
onSubmitMessage={mockOnSubmitMessage}
|
|
1017
|
+
/>,
|
|
1018
|
+
);
|
|
1019
|
+
|
|
1020
|
+
const input = screen.getByRole("textbox");
|
|
1021
|
+
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
|
1022
|
+
|
|
1023
|
+
expect(mockOnSubmitMessage).toHaveBeenCalledWith("hello world");
|
|
1024
|
+
expect(mockOnChange).toHaveBeenCalledWith("");
|
|
1025
|
+
});
|
|
988
1026
|
});
|
|
989
1027
|
|
|
990
1028
|
describe("Container dimension cache", () => {
|
|
@@ -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 };
|
|
@@ -313,7 +313,7 @@ describe("useAgent throttleMs", () => {
|
|
|
313
313
|
expect(screen.getByTestId("count").textContent).toBe("3");
|
|
314
314
|
});
|
|
315
315
|
|
|
316
|
-
it("with throttleMs, onStateChanged still fires immediately", () => {
|
|
316
|
+
it("with throttleMs, onStateChanged still fires immediately", async () => {
|
|
317
317
|
const TestComponent = createTestComponent({
|
|
318
318
|
updates: [
|
|
319
319
|
UseAgentUpdate.OnMessagesChanged,
|
|
@@ -330,8 +330,8 @@ describe("useAgent throttleMs", () => {
|
|
|
330
330
|
notifyMessagesChanged(mockAgent);
|
|
331
331
|
});
|
|
332
332
|
|
|
333
|
-
// Fire onStateChanged 10ms later —
|
|
334
|
-
act(() => {
|
|
333
|
+
// Fire onStateChanged 10ms later — fires via microtask batch (not synchronously)
|
|
334
|
+
await act(async () => {
|
|
335
335
|
vi.advanceTimersByTime(10);
|
|
336
336
|
mockAgent.state = { count: 42 };
|
|
337
337
|
notifyStateChanged(mockAgent);
|
|
@@ -372,7 +372,7 @@ describe("useAgent throttleMs", () => {
|
|
|
372
372
|
expect(renderCount.current).toBe(countBeforeUnmount);
|
|
373
373
|
});
|
|
374
374
|
|
|
375
|
-
it("with throttleMs and updates excluding OnMessagesChanged, throttle is a no-op", () => {
|
|
375
|
+
it("with throttleMs and updates excluding OnMessagesChanged, throttle is a no-op", async () => {
|
|
376
376
|
const TestComponent = createTestComponent({
|
|
377
377
|
updates: [UseAgentUpdate.OnStateChanged],
|
|
378
378
|
throttleMs: 100,
|
|
@@ -380,8 +380,8 @@ describe("useAgent throttleMs", () => {
|
|
|
380
380
|
|
|
381
381
|
render(<TestComponent />);
|
|
382
382
|
|
|
383
|
-
// Only onStateChanged is subscribed —
|
|
384
|
-
act(() => {
|
|
383
|
+
// Only onStateChanged is subscribed — fires via microtask batch
|
|
384
|
+
await act(async () => {
|
|
385
385
|
mockAgent.state = { value: "test" };
|
|
386
386
|
notifyStateChanged(mockAgent);
|
|
387
387
|
});
|
|
@@ -649,7 +649,7 @@ describe("useAgent throttleMs", () => {
|
|
|
649
649
|
expect(renderCount.current).toBe(rendersAfterMount + 1);
|
|
650
650
|
});
|
|
651
651
|
|
|
652
|
-
it("with throttleMs, onRunInitialized still fires immediately during throttle window", () => {
|
|
652
|
+
it("with throttleMs, onRunInitialized still fires immediately during throttle window", async () => {
|
|
653
653
|
const renderCount = { current: 0 };
|
|
654
654
|
const TestComponent = createTestComponent({
|
|
655
655
|
updates: [
|
|
@@ -670,13 +670,13 @@ describe("useAgent throttleMs", () => {
|
|
|
670
670
|
});
|
|
671
671
|
expect(renderCount.current).toBe(rendersAfterMount + 1);
|
|
672
672
|
|
|
673
|
-
// Fire onRunInitialized 10ms later —
|
|
674
|
-
act(() => {
|
|
673
|
+
// Fire onRunInitialized 10ms later — fires via microtask batch
|
|
674
|
+
await act(async () => {
|
|
675
675
|
vi.advanceTimersByTime(10);
|
|
676
676
|
notifyRunInitialized(mockAgent);
|
|
677
677
|
});
|
|
678
678
|
|
|
679
|
-
// Run status notification
|
|
679
|
+
// Run status notification fires via microtask batch
|
|
680
680
|
expect(renderCount.current).toBe(rendersAfterMount + 2);
|
|
681
681
|
});
|
|
682
682
|
|