@copilotkit/react-core 1.55.0-next.9 → 1.55.1-next.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +46 -6
  2. package/dist/{copilotkit-DeOzjPsb.mjs → copilotkit-BY5S1-0P.mjs} +2402 -552
  3. package/dist/copilotkit-BY5S1-0P.mjs.map +1 -0
  4. package/dist/{copilotkit-BqcyhQjT.d.mts → copilotkit-BuhSUZHb.d.mts} +228 -17
  5. package/dist/copilotkit-BuhSUZHb.d.mts.map +1 -0
  6. package/dist/{copilotkit-BDNjFNmk.cjs → copilotkit-Bz5-ImDl.cjs} +2421 -541
  7. package/dist/copilotkit-Bz5-ImDl.cjs.map +1 -0
  8. package/dist/{copilotkit-l-IBF4Xp.d.cts → copilotkit-dwDWYpya.d.cts} +228 -17
  9. package/dist/copilotkit-dwDWYpya.d.cts.map +1 -0
  10. package/dist/index.cjs +1 -1
  11. package/dist/index.d.cts +1 -1
  12. package/dist/index.d.mts +1 -1
  13. package/dist/index.mjs +1 -1
  14. package/dist/index.umd.js +1400 -238
  15. package/dist/index.umd.js.map +1 -1
  16. package/dist/v2/index.cjs +13 -1
  17. package/dist/v2/index.css +1 -1
  18. package/dist/v2/index.d.cts +3 -3
  19. package/dist/v2/index.d.mts +3 -3
  20. package/dist/v2/index.mjs +3 -2
  21. package/dist/v2/index.umd.js +2442 -552
  22. package/dist/v2/index.umd.js.map +1 -1
  23. package/package.json +62 -54
  24. package/scripts/scope-preflight.mjs +1 -2
  25. package/src/components/CopilotListeners.tsx +41 -8
  26. package/src/components/copilot-provider/copilotkit-props.tsx +4 -2
  27. package/src/components/toast/toast-provider.tsx +269 -194
  28. package/src/v2/__tests__/A2UIMessageRenderer.test.tsx +86 -22
  29. package/src/v2/__tests__/utils/test-helpers.tsx +67 -0
  30. package/src/v2/a2ui/A2UICatalogContext.tsx +79 -0
  31. package/src/v2/a2ui/A2UIMessageRenderer.tsx +125 -37
  32. package/src/v2/a2ui/A2UIToolCallRenderer.tsx +290 -0
  33. package/src/v2/components/CopilotKitInspector.tsx +2 -0
  34. package/src/v2/components/OpenGenerativeUIRenderer.tsx +598 -0
  35. package/src/v2/components/__tests__/OpenGenerativeUIRenderer.test.tsx +665 -0
  36. package/src/v2/components/chat/CopilotChat.tsx +193 -50
  37. package/src/v2/components/chat/CopilotChatAssistantMessage.tsx +17 -2
  38. package/src/v2/components/chat/CopilotChatAttachmentQueue.tsx +481 -0
  39. package/src/v2/components/chat/CopilotChatAttachmentRenderer.tsx +139 -0
  40. package/src/v2/components/chat/CopilotChatInput.tsx +146 -77
  41. package/src/v2/components/chat/CopilotChatMessageView.tsx +253 -149
  42. package/src/v2/components/chat/CopilotChatSuggestionView.tsx +1 -0
  43. package/src/v2/components/chat/CopilotChatUserMessage.tsx +54 -0
  44. package/src/v2/components/chat/CopilotChatView.tsx +179 -66
  45. package/src/v2/components/chat/__tests__/CopilotChat.attachments.test.tsx +168 -0
  46. package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +63 -2
  47. package/src/v2/components/chat/__tests__/CopilotChatInput.test.tsx +544 -1
  48. package/src/v2/components/chat/__tests__/CopilotChatPerf.e2e.test.tsx +268 -0
  49. package/src/v2/components/chat/__tests__/CopilotChatPropsRerender.e2e.test.tsx +249 -0
  50. package/src/v2/components/chat/__tests__/MCPAppsActivityRenderer.e2e.test.tsx +43 -2
  51. package/src/v2/components/chat/__tests__/copilot-chat-throttle.test.tsx +138 -0
  52. package/src/v2/components/chat/index.ts +9 -0
  53. package/src/v2/components/chat/scroll-element-context.ts +13 -0
  54. package/src/v2/hooks/__tests__/use-agent-throttle.test.tsx +1003 -0
  55. package/src/v2/hooks/__tests__/use-attachments.test.tsx +169 -0
  56. package/src/v2/hooks/__tests__/use-threads.test.tsx +54 -0
  57. package/src/v2/hooks/index.ts +5 -0
  58. package/src/v2/hooks/use-agent.tsx +95 -10
  59. package/src/v2/hooks/use-attachments.tsx +269 -0
  60. package/src/v2/hooks/use-frontend-tool.tsx +5 -2
  61. package/src/v2/hooks/use-render-activity-message.tsx +9 -2
  62. package/src/v2/hooks/use-threads.tsx +35 -15
  63. package/src/v2/index.ts +5 -1
  64. package/src/v2/lib/__tests__/processPartialHtml.test.ts +112 -0
  65. package/src/v2/lib/__tests__/slots.test.ts +56 -0
  66. package/src/v2/lib/processPartialHtml.ts +45 -0
  67. package/src/v2/lib/slots.tsx +42 -1
  68. package/src/v2/providers/CopilotChatConfigurationProvider.tsx +9 -3
  69. package/src/v2/providers/CopilotKitProvider.tsx +268 -32
  70. package/src/v2/providers/SandboxFunctionsContext.ts +10 -0
  71. package/src/v2/providers/__tests__/CopilotKitProvider.sandboxFunctions.test.tsx +198 -0
  72. package/src/v2/providers/__tests__/CopilotKitProvider.test.tsx +71 -0
  73. package/src/v2/providers/index.ts +7 -0
  74. package/src/v2/styles/globals.css +2 -1
  75. package/src/v2/types/index.ts +1 -0
  76. package/src/v2/types/sandbox-function.ts +11 -0
  77. package/dist/copilotkit-BDNjFNmk.cjs.map +0 -1
  78. package/dist/copilotkit-BqcyhQjT.d.mts.map +0 -1
  79. package/dist/copilotkit-DeOzjPsb.mjs.map +0 -1
  80. package/dist/copilotkit-l-IBF4Xp.d.cts.map +0 -1
  81. 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 photos or files",
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
  });