@copilotkit/react-core 1.55.0-next.9 → 1.55.0
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/CHANGELOG.md +36 -6
- package/dist/{copilotkit-DeOzjPsb.mjs → copilotkit-BY5S1-0P.mjs} +2402 -552
- package/dist/copilotkit-BY5S1-0P.mjs.map +1 -0
- package/dist/{copilotkit-BqcyhQjT.d.mts → copilotkit-BuhSUZHb.d.mts} +228 -17
- package/dist/copilotkit-BuhSUZHb.d.mts.map +1 -0
- package/dist/{copilotkit-BDNjFNmk.cjs → copilotkit-Bz5-ImDl.cjs} +2421 -541
- package/dist/copilotkit-Bz5-ImDl.cjs.map +1 -0
- package/dist/{copilotkit-l-IBF4Xp.d.cts → copilotkit-dwDWYpya.d.cts} +228 -17
- package/dist/copilotkit-dwDWYpya.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 +1400 -238
- package/dist/index.umd.js.map +1 -1
- package/dist/v2/index.cjs +13 -1
- package/dist/v2/index.css +1 -1
- package/dist/v2/index.d.cts +3 -3
- package/dist/v2/index.d.mts +3 -3
- package/dist/v2/index.mjs +3 -2
- package/dist/v2/index.umd.js +2442 -552
- package/dist/v2/index.umd.js.map +1 -1
- package/package.json +62 -54
- package/scripts/scope-preflight.mjs +1 -2
- package/src/components/CopilotListeners.tsx +41 -8
- package/src/components/copilot-provider/copilotkit-props.tsx +4 -2
- package/src/components/toast/toast-provider.tsx +269 -194
- package/src/v2/__tests__/A2UIMessageRenderer.test.tsx +86 -22
- package/src/v2/__tests__/utils/test-helpers.tsx +67 -0
- package/src/v2/a2ui/A2UICatalogContext.tsx +79 -0
- package/src/v2/a2ui/A2UIMessageRenderer.tsx +125 -37
- package/src/v2/a2ui/A2UIToolCallRenderer.tsx +290 -0
- package/src/v2/components/CopilotKitInspector.tsx +2 -0
- package/src/v2/components/OpenGenerativeUIRenderer.tsx +598 -0
- package/src/v2/components/__tests__/OpenGenerativeUIRenderer.test.tsx +665 -0
- package/src/v2/components/chat/CopilotChat.tsx +193 -50
- package/src/v2/components/chat/CopilotChatAssistantMessage.tsx +17 -2
- package/src/v2/components/chat/CopilotChatAttachmentQueue.tsx +481 -0
- package/src/v2/components/chat/CopilotChatAttachmentRenderer.tsx +139 -0
- package/src/v2/components/chat/CopilotChatInput.tsx +146 -77
- package/src/v2/components/chat/CopilotChatMessageView.tsx +253 -149
- package/src/v2/components/chat/CopilotChatSuggestionView.tsx +1 -0
- package/src/v2/components/chat/CopilotChatUserMessage.tsx +54 -0
- package/src/v2/components/chat/CopilotChatView.tsx +179 -66
- package/src/v2/components/chat/__tests__/CopilotChat.attachments.test.tsx +168 -0
- package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +63 -2
- package/src/v2/components/chat/__tests__/CopilotChatInput.test.tsx +544 -1
- package/src/v2/components/chat/__tests__/CopilotChatPerf.e2e.test.tsx +268 -0
- package/src/v2/components/chat/__tests__/CopilotChatPropsRerender.e2e.test.tsx +249 -0
- package/src/v2/components/chat/__tests__/MCPAppsActivityRenderer.e2e.test.tsx +43 -2
- package/src/v2/components/chat/__tests__/copilot-chat-throttle.test.tsx +138 -0
- package/src/v2/components/chat/index.ts +9 -0
- package/src/v2/components/chat/scroll-element-context.ts +13 -0
- package/src/v2/hooks/__tests__/use-agent-throttle.test.tsx +1003 -0
- package/src/v2/hooks/__tests__/use-attachments.test.tsx +169 -0
- package/src/v2/hooks/__tests__/use-threads.test.tsx +54 -0
- package/src/v2/hooks/index.ts +5 -0
- package/src/v2/hooks/use-agent.tsx +95 -10
- package/src/v2/hooks/use-attachments.tsx +269 -0
- package/src/v2/hooks/use-frontend-tool.tsx +5 -2
- package/src/v2/hooks/use-render-activity-message.tsx +9 -2
- package/src/v2/hooks/use-threads.tsx +35 -15
- package/src/v2/index.ts +5 -1
- package/src/v2/lib/__tests__/processPartialHtml.test.ts +112 -0
- package/src/v2/lib/__tests__/slots.test.ts +56 -0
- package/src/v2/lib/processPartialHtml.ts +45 -0
- package/src/v2/lib/slots.tsx +42 -1
- package/src/v2/providers/CopilotChatConfigurationProvider.tsx +9 -3
- package/src/v2/providers/CopilotKitProvider.tsx +268 -32
- package/src/v2/providers/SandboxFunctionsContext.ts +10 -0
- package/src/v2/providers/__tests__/CopilotKitProvider.sandboxFunctions.test.tsx +198 -0
- package/src/v2/providers/__tests__/CopilotKitProvider.test.tsx +71 -0
- package/src/v2/providers/index.ts +7 -0
- package/src/v2/styles/globals.css +2 -1
- package/src/v2/types/index.ts +1 -0
- package/src/v2/types/sandbox-function.ts +11 -0
- package/dist/copilotkit-BDNjFNmk.cjs.map +0 -1
- package/dist/copilotkit-BqcyhQjT.d.mts.map +0 -1
- package/dist/copilotkit-DeOzjPsb.mjs.map +0 -1
- package/dist/copilotkit-l-IBF4Xp.d.cts.map +0 -1
- package/src/v2/components/__tests__/license-warning-banner.test.tsx +0 -46
|
@@ -35,6 +35,9 @@ const getAddMenuButton = (container: HTMLElement) =>
|
|
|
35
35
|
.querySelector("svg.lucide-plus")
|
|
36
36
|
?.closest("button") as HTMLButtonElement | null;
|
|
37
37
|
|
|
38
|
+
const getLayoutGrid = (textarea: HTMLElement) =>
|
|
39
|
+
textarea.closest("[data-layout]") as HTMLElement;
|
|
40
|
+
|
|
38
41
|
const mockLayoutMetrics = (
|
|
39
42
|
container: HTMLElement,
|
|
40
43
|
options?: { gridWidth?: number; addWidth?: number; actionsWidth?: number },
|
|
@@ -742,7 +745,7 @@ describe("CopilotChatInput", () => {
|
|
|
742
745
|
});
|
|
743
746
|
|
|
744
747
|
const menuItem = await screen.findByRole("menuitem", {
|
|
745
|
-
name: "Add
|
|
748
|
+
name: "Add attachments",
|
|
746
749
|
});
|
|
747
750
|
fireEvent.click(menuItem);
|
|
748
751
|
|
|
@@ -983,4 +986,544 @@ describe("CopilotChatInput", () => {
|
|
|
983
986
|
expect(mockOnSubmitMessage).toHaveBeenCalledWith("test message");
|
|
984
987
|
});
|
|
985
988
|
});
|
|
989
|
+
|
|
990
|
+
describe("Container dimension cache", () => {
|
|
991
|
+
const OriginalResizeObserver = globalThis.ResizeObserver;
|
|
992
|
+
|
|
993
|
+
class MockResizeObserver {
|
|
994
|
+
static instances: MockResizeObserver[] = [];
|
|
995
|
+
callback: (entries: Array<{ target: Element }>) => void;
|
|
996
|
+
observedTargets = new Set<Element>();
|
|
997
|
+
|
|
998
|
+
constructor(cb: (entries: Array<{ target: Element }>) => void) {
|
|
999
|
+
this.callback = cb;
|
|
1000
|
+
MockResizeObserver.instances.push(this);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
observe = vi.fn((target: Element) => {
|
|
1004
|
+
this.observedTargets.add(target);
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
unobserve = vi.fn((target: Element) => {
|
|
1008
|
+
this.observedTargets.delete(target);
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
disconnect = vi.fn(() => {
|
|
1012
|
+
this.observedTargets.clear();
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const DEFAULT_LAYOUT_OPTIONS = {
|
|
1017
|
+
gridWidth: 640,
|
|
1018
|
+
addWidth: 48,
|
|
1019
|
+
actionsWidth: 96,
|
|
1020
|
+
gridPadding: 16,
|
|
1021
|
+
columnGap: 8,
|
|
1022
|
+
textareaPadding: 20,
|
|
1023
|
+
} as const;
|
|
1024
|
+
|
|
1025
|
+
/** Trigger all observers with all their observed targets. */
|
|
1026
|
+
const triggerAllResizeObservers = () => {
|
|
1027
|
+
for (const instance of MockResizeObserver.instances) {
|
|
1028
|
+
const entries = [...instance.observedTargets].map((target) => ({
|
|
1029
|
+
target,
|
|
1030
|
+
}));
|
|
1031
|
+
if (entries.length > 0) {
|
|
1032
|
+
instance.callback(entries);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
/** Trigger observers that watch the given targets, with only those targets as entries. */
|
|
1038
|
+
const triggerResizeForTargets = (...targets: Element[]) => {
|
|
1039
|
+
for (const instance of MockResizeObserver.instances) {
|
|
1040
|
+
const matching = targets.filter((t) => instance.observedTargets.has(t));
|
|
1041
|
+
if (matching.length > 0) {
|
|
1042
|
+
instance.callback(matching.map((t) => ({ target: t })));
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
};
|
|
1046
|
+
|
|
1047
|
+
beforeEach(() => {
|
|
1048
|
+
MockResizeObserver.instances = [];
|
|
1049
|
+
// Double cast required: MockResizeObserver's callback signature uses a
|
|
1050
|
+
// simplified `{ target: Element }` entry instead of the full
|
|
1051
|
+
// ResizeObserverEntry (which includes contentRect, borderBoxSize, etc.)
|
|
1052
|
+
// that the real ResizeObserverCallback demands.
|
|
1053
|
+
globalThis.ResizeObserver =
|
|
1054
|
+
MockResizeObserver as unknown as typeof ResizeObserver;
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
afterEach(() => {
|
|
1058
|
+
vi.restoreAllMocks();
|
|
1059
|
+
globalThis.ResizeObserver = OriginalResizeObserver;
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Extends mockLayoutMetrics with getComputedStyle mocks so that
|
|
1064
|
+
* updateContainerCache can compute real compactWidth and font values,
|
|
1065
|
+
* exercising the canvas-based text measurement path.
|
|
1066
|
+
*/
|
|
1067
|
+
const mockLayoutMetricsWithComputedStyle = (
|
|
1068
|
+
container: HTMLElement,
|
|
1069
|
+
options?: {
|
|
1070
|
+
gridWidth?: number;
|
|
1071
|
+
addWidth?: number;
|
|
1072
|
+
actionsWidth?: number;
|
|
1073
|
+
gridPadding?: number;
|
|
1074
|
+
columnGap?: number;
|
|
1075
|
+
textareaPadding?: number;
|
|
1076
|
+
font?: string;
|
|
1077
|
+
},
|
|
1078
|
+
) => {
|
|
1079
|
+
mockLayoutMetrics(container, {
|
|
1080
|
+
gridWidth: options?.gridWidth,
|
|
1081
|
+
addWidth: options?.addWidth,
|
|
1082
|
+
actionsWidth: options?.actionsWidth,
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
const {
|
|
1086
|
+
gridPadding = 16,
|
|
1087
|
+
columnGap = 8,
|
|
1088
|
+
textareaPadding = 20,
|
|
1089
|
+
font = "16px sans-serif",
|
|
1090
|
+
} = options ?? {};
|
|
1091
|
+
|
|
1092
|
+
const grid = container.querySelector(
|
|
1093
|
+
"div.cpk\\:grid",
|
|
1094
|
+
) as HTMLElement | null;
|
|
1095
|
+
const textarea = container.querySelector(
|
|
1096
|
+
"textarea",
|
|
1097
|
+
) as HTMLElement | null;
|
|
1098
|
+
|
|
1099
|
+
const originalGetComputedStyle = window.getComputedStyle;
|
|
1100
|
+
// Cast needed: spreading a CSSStyleDeclaration loses the indexed-property
|
|
1101
|
+
// accessors and prototype methods, so the result doesn't satisfy the full
|
|
1102
|
+
// CSSStyleDeclaration interface. Only the properties the SUT reads matter.
|
|
1103
|
+
vi.spyOn(window, "getComputedStyle").mockImplementation((el) => {
|
|
1104
|
+
if (el === grid) {
|
|
1105
|
+
return {
|
|
1106
|
+
...originalGetComputedStyle(el),
|
|
1107
|
+
paddingLeft: `${gridPadding}px`,
|
|
1108
|
+
paddingRight: `${gridPadding}px`,
|
|
1109
|
+
columnGap: `${columnGap}px`,
|
|
1110
|
+
} as CSSStyleDeclaration;
|
|
1111
|
+
}
|
|
1112
|
+
if (el === textarea) {
|
|
1113
|
+
return {
|
|
1114
|
+
...originalGetComputedStyle(el),
|
|
1115
|
+
paddingLeft: `${textareaPadding}px`,
|
|
1116
|
+
paddingRight: `${textareaPadding}px`,
|
|
1117
|
+
paddingTop: "12px",
|
|
1118
|
+
paddingBottom: "12px",
|
|
1119
|
+
font,
|
|
1120
|
+
fontStyle: "normal",
|
|
1121
|
+
fontVariant: "normal",
|
|
1122
|
+
fontWeight: "400",
|
|
1123
|
+
fontSize: "16px",
|
|
1124
|
+
lineHeight: "24px",
|
|
1125
|
+
fontFamily: "sans-serif",
|
|
1126
|
+
} as CSSStyleDeclaration;
|
|
1127
|
+
}
|
|
1128
|
+
return originalGetComputedStyle(el);
|
|
1129
|
+
});
|
|
1130
|
+
};
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* Mocks canvas measureText to return a deterministic width per character.
|
|
1134
|
+
*/
|
|
1135
|
+
const mockCanvasMeasureText = (charWidth: number) => {
|
|
1136
|
+
const originalGetContext = HTMLCanvasElement.prototype.getContext;
|
|
1137
|
+
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(
|
|
1138
|
+
// Double cast required: getContext has 6+ overloaded signatures
|
|
1139
|
+
// (one per context type) and vitest's mockImplementation cannot
|
|
1140
|
+
// unify them into a single callable type.
|
|
1141
|
+
function (
|
|
1142
|
+
this: HTMLCanvasElement,
|
|
1143
|
+
contextId: string,
|
|
1144
|
+
...args: unknown[]
|
|
1145
|
+
) {
|
|
1146
|
+
if (contextId === "2d") {
|
|
1147
|
+
const ctx = originalGetContext.call(
|
|
1148
|
+
this,
|
|
1149
|
+
"2d",
|
|
1150
|
+
...(args as [unknown]),
|
|
1151
|
+
) as CanvasRenderingContext2D | null;
|
|
1152
|
+
if (ctx) {
|
|
1153
|
+
// Cast needed: TextMetrics has many readonly properties
|
|
1154
|
+
// (actualBoundingBoxAscent, etc.) that are irrelevant to the
|
|
1155
|
+
// SUT — only `width` is read.
|
|
1156
|
+
ctx.measureText = (text: string) =>
|
|
1157
|
+
({ width: text.length * charWidth }) as TextMetrics;
|
|
1158
|
+
}
|
|
1159
|
+
return ctx;
|
|
1160
|
+
}
|
|
1161
|
+
return originalGetContext.call(
|
|
1162
|
+
this,
|
|
1163
|
+
contextId,
|
|
1164
|
+
...(args as [unknown]),
|
|
1165
|
+
);
|
|
1166
|
+
} as unknown as typeof HTMLCanvasElement.prototype.getContext,
|
|
1167
|
+
);
|
|
1168
|
+
};
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* Sets up all DOM mocks and invalidates the stale cache from the initial
|
|
1172
|
+
* render by triggering the ResizeObserver callback.
|
|
1173
|
+
*/
|
|
1174
|
+
const setupMocksAndInvalidateCache = (
|
|
1175
|
+
container: HTMLElement,
|
|
1176
|
+
options?: Parameters<typeof mockLayoutMetricsWithComputedStyle>[1],
|
|
1177
|
+
charWidth = 10,
|
|
1178
|
+
) => {
|
|
1179
|
+
mockLayoutMetricsWithComputedStyle(container, options);
|
|
1180
|
+
mockCanvasMeasureText(charWidth);
|
|
1181
|
+
// Invalidate the stale cache populated during the initial render
|
|
1182
|
+
// so the next evaluateLayout call re-measures with our mocked values.
|
|
1183
|
+
triggerAllResizeObservers();
|
|
1184
|
+
};
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* Render CopilotChatInput, set up layout mocks, type text, and wait for
|
|
1188
|
+
* the layout to settle. Returns the resulting data-layout attribute value.
|
|
1189
|
+
*/
|
|
1190
|
+
const renderTypeAndExpectLayout = async (
|
|
1191
|
+
text: string,
|
|
1192
|
+
options?: Parameters<typeof mockLayoutMetricsWithComputedStyle>[1],
|
|
1193
|
+
charWidth = 10,
|
|
1194
|
+
) => {
|
|
1195
|
+
const { container } = renderWithProvider(
|
|
1196
|
+
<CopilotChatInput onSubmitMessage={mockOnSubmitMessage} />,
|
|
1197
|
+
);
|
|
1198
|
+
setupMocksAndInvalidateCache(container, options, charWidth);
|
|
1199
|
+
|
|
1200
|
+
const textarea = screen.getByRole("textbox");
|
|
1201
|
+
fireEvent.change(textarea, { target: { value: text } });
|
|
1202
|
+
|
|
1203
|
+
const grid = await waitFor(() => {
|
|
1204
|
+
const g = getLayoutGrid(textarea);
|
|
1205
|
+
expect(g).not.toBeNull();
|
|
1206
|
+
return g;
|
|
1207
|
+
});
|
|
1208
|
+
return {
|
|
1209
|
+
container,
|
|
1210
|
+
textarea,
|
|
1211
|
+
grid,
|
|
1212
|
+
layout: grid.getAttribute("data-layout"),
|
|
1213
|
+
};
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1216
|
+
it("expands layout via canvas text measurement when a single long line exceeds compact width", async () => {
|
|
1217
|
+
// gridWidth=640, gridPadding=16 each side, columnGap=8, addWidth=48, actionsWidth=96
|
|
1218
|
+
// compactWidth = (640 - 32) - 48 - 96 - 16 = 448
|
|
1219
|
+
// compactInnerWidth = 448 - 20 - 20 = 408
|
|
1220
|
+
// With charWidth=10, text of length 50 = width 500, which > 408 → expand
|
|
1221
|
+
const { layout } = await renderTypeAndExpectLayout(
|
|
1222
|
+
"a".repeat(50),
|
|
1223
|
+
DEFAULT_LAYOUT_OPTIONS,
|
|
1224
|
+
);
|
|
1225
|
+
expect(layout).toBe("expanded");
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
it("stays compact when single-line text fits within the cached compact width", async () => {
|
|
1229
|
+
// Width = 10 * 10 = 100, well within compactInnerWidth of 408
|
|
1230
|
+
const { layout } = await renderTypeAndExpectLayout(
|
|
1231
|
+
"a".repeat(10),
|
|
1232
|
+
DEFAULT_LAYOUT_OPTIONS,
|
|
1233
|
+
);
|
|
1234
|
+
expect(layout).toBe("compact");
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
it("re-evaluates layout correctly after container resize invalidates the cache", async () => {
|
|
1238
|
+
// Phase 1: wide container — 30 chars fit in compact
|
|
1239
|
+
const { container, textarea } = await renderTypeAndExpectLayout(
|
|
1240
|
+
"a".repeat(30),
|
|
1241
|
+
DEFAULT_LAYOUT_OPTIONS,
|
|
1242
|
+
);
|
|
1243
|
+
expect(getLayoutGrid(textarea).getAttribute("data-layout")).toBe(
|
|
1244
|
+
"compact",
|
|
1245
|
+
);
|
|
1246
|
+
|
|
1247
|
+
// Phase 2: simulate a container resize to a much narrower width.
|
|
1248
|
+
// compactWidth = (300 - 32) - 48 - 96 - 16 = 108
|
|
1249
|
+
// compactInnerWidth = 108 - 20 - 20 = 68
|
|
1250
|
+
// Text width = 30 * 10 = 300 > 68 → should expand
|
|
1251
|
+
vi.restoreAllMocks();
|
|
1252
|
+
mockLayoutMetricsWithComputedStyle(container, {
|
|
1253
|
+
...DEFAULT_LAYOUT_OPTIONS,
|
|
1254
|
+
gridWidth: 300,
|
|
1255
|
+
});
|
|
1256
|
+
mockCanvasMeasureText(10);
|
|
1257
|
+
|
|
1258
|
+
// Trigger resize to invalidate the cache with the new narrow dimensions
|
|
1259
|
+
triggerAllResizeObservers();
|
|
1260
|
+
|
|
1261
|
+
// Trigger re-evaluation
|
|
1262
|
+
fireEvent.change(textarea, {
|
|
1263
|
+
target: { value: "a".repeat(30) + " " },
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
await waitFor(() => {
|
|
1267
|
+
expect(getLayoutGrid(textarea).getAttribute("data-layout")).toBe(
|
|
1268
|
+
"expanded",
|
|
1269
|
+
);
|
|
1270
|
+
});
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
it("stays compact when textarea font cannot be resolved (empty font fallback)", async () => {
|
|
1274
|
+
const { container } = renderWithProvider(
|
|
1275
|
+
<CopilotChatInput onSubmitMessage={mockOnSubmitMessage} />,
|
|
1276
|
+
);
|
|
1277
|
+
|
|
1278
|
+
mockLayoutMetrics(container, {
|
|
1279
|
+
gridWidth: DEFAULT_LAYOUT_OPTIONS.gridWidth,
|
|
1280
|
+
addWidth: DEFAULT_LAYOUT_OPTIONS.addWidth,
|
|
1281
|
+
actionsWidth: DEFAULT_LAYOUT_OPTIONS.actionsWidth,
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
const originalGetComputedStyle = window.getComputedStyle;
|
|
1285
|
+
// Cast needed: see comment in mockLayoutMetricsWithComputedStyle above.
|
|
1286
|
+
vi.spyOn(window, "getComputedStyle").mockImplementation((el) => {
|
|
1287
|
+
return {
|
|
1288
|
+
...originalGetComputedStyle(el),
|
|
1289
|
+
font: "",
|
|
1290
|
+
fontStyle: "",
|
|
1291
|
+
fontVariant: "",
|
|
1292
|
+
fontWeight: "",
|
|
1293
|
+
fontSize: "",
|
|
1294
|
+
lineHeight: "",
|
|
1295
|
+
fontFamily: "",
|
|
1296
|
+
paddingLeft: "16px",
|
|
1297
|
+
paddingRight: "16px",
|
|
1298
|
+
columnGap: "8px",
|
|
1299
|
+
} as CSSStyleDeclaration;
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
// Invalidate cache so it tries to rebuild with the empty font mock
|
|
1303
|
+
triggerAllResizeObservers();
|
|
1304
|
+
|
|
1305
|
+
const textarea = screen.getByRole("textbox");
|
|
1306
|
+
|
|
1307
|
+
// Single-line text (under 50-char mock wrap threshold) that would exceed
|
|
1308
|
+
// compact width via canvas measurement, but font is empty → skipped → compact
|
|
1309
|
+
fireEvent.change(textarea, {
|
|
1310
|
+
target: { value: "a".repeat(45) },
|
|
1311
|
+
});
|
|
1312
|
+
|
|
1313
|
+
await waitFor(() => {
|
|
1314
|
+
expect(getLayoutGrid(textarea).getAttribute("data-layout")).toBe(
|
|
1315
|
+
"compact",
|
|
1316
|
+
);
|
|
1317
|
+
});
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
it("stays compact when canvas.getContext returns null (no text-width measurement)", async () => {
|
|
1321
|
+
const { container } = renderWithProvider(
|
|
1322
|
+
<CopilotChatInput onSubmitMessage={mockOnSubmitMessage} />,
|
|
1323
|
+
);
|
|
1324
|
+
|
|
1325
|
+
setupMocksAndInvalidateCache(container, DEFAULT_LAYOUT_OPTIONS);
|
|
1326
|
+
|
|
1327
|
+
// Override getContext to return null for "2d"
|
|
1328
|
+
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
|
1329
|
+
|
|
1330
|
+
// Invalidate cache again so the next evaluateLayout uses the null context
|
|
1331
|
+
triggerAllResizeObservers();
|
|
1332
|
+
|
|
1333
|
+
const textarea = screen.getByRole("textbox");
|
|
1334
|
+
|
|
1335
|
+
// Single-line text (under 50-char mock wrap threshold) that would exceed
|
|
1336
|
+
// compact width via canvas, but getContext is null → stays compact
|
|
1337
|
+
fireEvent.change(textarea, {
|
|
1338
|
+
target: { value: "a".repeat(45) },
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
await waitFor(() => {
|
|
1342
|
+
expect(getLayoutGrid(textarea).getAttribute("data-layout")).toBe(
|
|
1343
|
+
"compact",
|
|
1344
|
+
);
|
|
1345
|
+
});
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* Render, set up all mocks, populate the cache with a short keystroke,
|
|
1350
|
+
* and wait for compact layout. Returns container, textarea, and grid.
|
|
1351
|
+
*/
|
|
1352
|
+
const renderAndWarmCache = async () => {
|
|
1353
|
+
const { container } = renderWithProvider(
|
|
1354
|
+
<CopilotChatInput onSubmitMessage={mockOnSubmitMessage} />,
|
|
1355
|
+
);
|
|
1356
|
+
setupMocksAndInvalidateCache(container, DEFAULT_LAYOUT_OPTIONS);
|
|
1357
|
+
|
|
1358
|
+
const textarea = screen.getByRole("textbox");
|
|
1359
|
+
fireEvent.change(textarea, { target: { value: "a" } });
|
|
1360
|
+
|
|
1361
|
+
await waitFor(() => {
|
|
1362
|
+
expect(getLayoutGrid(textarea).getAttribute("data-layout")).toBe(
|
|
1363
|
+
"compact",
|
|
1364
|
+
);
|
|
1365
|
+
});
|
|
1366
|
+
|
|
1367
|
+
return {
|
|
1368
|
+
container,
|
|
1369
|
+
textarea,
|
|
1370
|
+
grid: container.querySelector("div.cpk\\:grid") as HTMLElement,
|
|
1371
|
+
};
|
|
1372
|
+
};
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* Install a counting spy on an element's getBoundingClientRect,
|
|
1376
|
+
* delegating to the original implementation. Returns the spy.
|
|
1377
|
+
*/
|
|
1378
|
+
const installBoundingRectSpy = (element: HTMLElement) => {
|
|
1379
|
+
const spy = vi.fn(element.getBoundingClientRect.bind(element));
|
|
1380
|
+
Object.defineProperty(element, "getBoundingClientRect", {
|
|
1381
|
+
value: spy,
|
|
1382
|
+
configurable: true,
|
|
1383
|
+
});
|
|
1384
|
+
return spy;
|
|
1385
|
+
};
|
|
1386
|
+
|
|
1387
|
+
it("does not re-read container dimensions on keystroke when cache is warm", async () => {
|
|
1388
|
+
const { textarea, grid } = await renderAndWarmCache();
|
|
1389
|
+
|
|
1390
|
+
const addRectSpy = installBoundingRectSpy(
|
|
1391
|
+
grid.children[0] as HTMLElement,
|
|
1392
|
+
);
|
|
1393
|
+
const actionsRectSpy = installBoundingRectSpy(
|
|
1394
|
+
grid.children[2] as HTMLElement,
|
|
1395
|
+
);
|
|
1396
|
+
|
|
1397
|
+
// Type more — should NOT call getBoundingClientRect since cache is warm
|
|
1398
|
+
fireEvent.change(textarea, { target: { value: "ab" } });
|
|
1399
|
+
fireEvent.change(textarea, { target: { value: "abc" } });
|
|
1400
|
+
|
|
1401
|
+
await waitFor(() => {
|
|
1402
|
+
expect(getLayoutGrid(textarea).getAttribute("data-layout")).toBe(
|
|
1403
|
+
"compact",
|
|
1404
|
+
);
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
expect(addRectSpy).not.toHaveBeenCalled();
|
|
1408
|
+
expect(actionsRectSpy).not.toHaveBeenCalled();
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
it("does not invalidate cache when only the textarea resizes", async () => {
|
|
1412
|
+
const { textarea, grid } = await renderAndWarmCache();
|
|
1413
|
+
|
|
1414
|
+
const addRectSpy = installBoundingRectSpy(
|
|
1415
|
+
grid.children[0] as HTMLElement,
|
|
1416
|
+
);
|
|
1417
|
+
|
|
1418
|
+
// Trigger textarea-only resize — should NOT invalidate cache
|
|
1419
|
+
triggerResizeForTargets(textarea);
|
|
1420
|
+
fireEvent.change(textarea, { target: { value: "ab" } });
|
|
1421
|
+
|
|
1422
|
+
await waitFor(() => {
|
|
1423
|
+
expect(getLayoutGrid(textarea).getAttribute("data-layout")).toBe(
|
|
1424
|
+
"compact",
|
|
1425
|
+
);
|
|
1426
|
+
});
|
|
1427
|
+
|
|
1428
|
+
expect(addRectSpy).not.toHaveBeenCalled();
|
|
1429
|
+
});
|
|
1430
|
+
|
|
1431
|
+
it("invalidates cache when container targets resize", async () => {
|
|
1432
|
+
const { textarea, grid } = await renderAndWarmCache();
|
|
1433
|
+
|
|
1434
|
+
const addRectSpy = installBoundingRectSpy(
|
|
1435
|
+
grid.children[0] as HTMLElement,
|
|
1436
|
+
);
|
|
1437
|
+
|
|
1438
|
+
// Trigger container resize — SHOULD invalidate cache
|
|
1439
|
+
triggerResizeForTargets(grid);
|
|
1440
|
+
fireEvent.change(textarea, { target: { value: "ab" } });
|
|
1441
|
+
|
|
1442
|
+
await waitFor(() => {
|
|
1443
|
+
expect(getLayoutGrid(textarea).getAttribute("data-layout")).toBe(
|
|
1444
|
+
"compact",
|
|
1445
|
+
);
|
|
1446
|
+
});
|
|
1447
|
+
|
|
1448
|
+
// Cache was invalidated, so updateContainerCache called getBoundingClientRect
|
|
1449
|
+
expect(addRectSpy).toHaveBeenCalled();
|
|
1450
|
+
});
|
|
1451
|
+
|
|
1452
|
+
it("keeps cache warm during layout toggle (ignoreResizeRef path)", async () => {
|
|
1453
|
+
const { textarea, grid } = await renderAndWarmCache();
|
|
1454
|
+
|
|
1455
|
+
// Trigger expansion — ignoreResizeRef set to true by updateLayout
|
|
1456
|
+
fireEvent.change(textarea, { target: { value: "line1\nline2" } });
|
|
1457
|
+
await waitFor(() => {
|
|
1458
|
+
expect(getLayoutGrid(textarea).getAttribute("data-layout")).toBe(
|
|
1459
|
+
"expanded",
|
|
1460
|
+
);
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
// Simulate observer firing from the layout toggle.
|
|
1464
|
+
// Self-inflicted resize: ignoreResizeRef is consumed, cache stays warm.
|
|
1465
|
+
triggerAllResizeObservers();
|
|
1466
|
+
|
|
1467
|
+
// Go back to short text — cache should still be warm from before the toggle.
|
|
1468
|
+
const addRectSpy = installBoundingRectSpy(
|
|
1469
|
+
grid.children[0] as HTMLElement,
|
|
1470
|
+
);
|
|
1471
|
+
|
|
1472
|
+
fireEvent.change(textarea, { target: { value: "short" } });
|
|
1473
|
+
await waitFor(() => {
|
|
1474
|
+
expect(getLayoutGrid(textarea).getAttribute("data-layout")).toBe(
|
|
1475
|
+
"compact",
|
|
1476
|
+
);
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
// Cache was NOT invalidated — no getBoundingClientRect needed
|
|
1480
|
+
expect(addRectSpy).not.toHaveBeenCalled();
|
|
1481
|
+
});
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
describe("Scroll behavior", () => {
|
|
1485
|
+
it("does not call scrollIntoView when the textarea receives focus", async () => {
|
|
1486
|
+
const scrollIntoViewMock = vi.fn();
|
|
1487
|
+
HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
|
|
1488
|
+
|
|
1489
|
+
renderWithProvider(
|
|
1490
|
+
<CopilotChatInput autoFocus onSubmitMessage={mockOnSubmitMessage} />,
|
|
1491
|
+
);
|
|
1492
|
+
|
|
1493
|
+
const textarea = screen.getByRole("textbox");
|
|
1494
|
+
|
|
1495
|
+
// Trigger focus explicitly (autoFocus also triggers it, but let's be explicit)
|
|
1496
|
+
fireEvent.focus(textarea);
|
|
1497
|
+
|
|
1498
|
+
// Wait long enough for the 300ms setTimeout inside the focus handler
|
|
1499
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
1500
|
+
|
|
1501
|
+
expect(scrollIntoViewMock).not.toHaveBeenCalled();
|
|
1502
|
+
|
|
1503
|
+
// Clean up
|
|
1504
|
+
delete (HTMLElement.prototype as any).scrollIntoView;
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
it("does not auto-focus the textarea by default", () => {
|
|
1508
|
+
const focusSpy = vi.spyOn(HTMLElement.prototype, "focus");
|
|
1509
|
+
|
|
1510
|
+
renderWithProvider(
|
|
1511
|
+
<CopilotChatInput onSubmitMessage={mockOnSubmitMessage} />,
|
|
1512
|
+
);
|
|
1513
|
+
|
|
1514
|
+
expect(focusSpy).not.toHaveBeenCalled();
|
|
1515
|
+
focusSpy.mockRestore();
|
|
1516
|
+
});
|
|
1517
|
+
|
|
1518
|
+
it("auto-focuses with preventScroll when autoFocus is true", () => {
|
|
1519
|
+
const focusSpy = vi.spyOn(HTMLElement.prototype, "focus");
|
|
1520
|
+
|
|
1521
|
+
renderWithProvider(
|
|
1522
|
+
<CopilotChatInput autoFocus onSubmitMessage={mockOnSubmitMessage} />,
|
|
1523
|
+
);
|
|
1524
|
+
|
|
1525
|
+
expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true });
|
|
1526
|
+
focusSpy.mockRestore();
|
|
1527
|
+
});
|
|
1528
|
+
});
|
|
986
1529
|
});
|