@copilotkit/react-core 1.56.4-canary.1777629975 → 1.56.4-canary.1777659843

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@copilotkit/react-core",
3
- "version": "1.56.4-canary.1777629975",
3
+ "version": "1.56.4-canary.1777659843",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "ai",
@@ -73,11 +73,11 @@
73
73
  "untruncate-json": "^0.0.1",
74
74
  "use-stick-to-bottom": "^1.1.1",
75
75
  "zod-to-json-schema": "^3.24.5",
76
- "@copilotkit/a2ui-renderer": "1.56.4-canary.1777629975",
77
- "@copilotkit/runtime-client-gql": "1.56.4-canary.1777629975",
78
- "@copilotkit/web-inspector": "1.56.4-canary.1777629975",
79
- "@copilotkit/shared": "1.56.4-canary.1777629975",
80
- "@copilotkit/core": "1.56.4-canary.1777629975"
76
+ "@copilotkit/core": "1.56.4-canary.1777659843",
77
+ "@copilotkit/a2ui-renderer": "1.56.4-canary.1777659843",
78
+ "@copilotkit/runtime-client-gql": "1.56.4-canary.1777659843",
79
+ "@copilotkit/shared": "1.56.4-canary.1777659843",
80
+ "@copilotkit/web-inspector": "1.56.4-canary.1777659843"
81
81
  },
82
82
  "devDependencies": {
83
83
  "@tailwindcss/cli": "^4.1.11",
@@ -518,12 +518,9 @@ export namespace CopilotChatView {
518
518
  className,
519
519
  ...props
520
520
  }) => {
521
- const spacerRef = useRef<HTMLDivElement>(null);
522
-
523
521
  usePinToSend({
524
522
  scrollRef,
525
523
  contentRef,
526
- spacerRef,
527
524
  topOffset: 16,
528
525
  });
529
526
 
@@ -549,16 +546,11 @@ export namespace CopilotChatView {
549
546
  >
550
547
  <div
551
548
  ref={contentRef}
549
+ data-pin-to-send-content
552
550
  className="cpk:px-4 cpk:sm:px-0 cpk:[div[data-sidebar-chat]_&]:px-8 cpk:[div[data-popup-chat]_&]:px-6"
553
551
  >
554
552
  {children}
555
553
  </div>
556
- <div
557
- ref={spacerRef}
558
- data-pin-to-send-spacer
559
- aria-hidden="true"
560
- style={{ height: 0, flex: "0 0 auto" }}
561
- />
562
554
  </div>
563
555
  {BoundFeather}
564
556
  {/* Scroll to bottom button */}
@@ -35,7 +35,7 @@ async function waitForMount(screen: {
35
35
  }
36
36
 
37
37
  describe("CopilotChatView pin-to-send mode", () => {
38
- it("renders the pin-to-send spacer element when autoScroll='pin-to-send'", async () => {
38
+ it("renders the pin-to-send content container when autoScroll='pin-to-send'", async () => {
39
39
  const screen = render(
40
40
  <TestWrapper>
41
41
  <LastUserMessageContext.Provider value={{ id: null, sendNonce: 0 }}>
@@ -44,30 +44,36 @@ describe("CopilotChatView pin-to-send mode", () => {
44
44
  </TestWrapper>,
45
45
  );
46
46
  await waitForMount(screen);
47
- const spacer = screen.container.querySelector("[data-pin-to-send-spacer]");
48
- expect(spacer).not.toBeNull();
47
+ const content = screen.container.querySelector(
48
+ "[data-pin-to-send-content]",
49
+ );
50
+ expect(content).not.toBeNull();
49
51
  });
50
52
 
51
- it("does not render the spacer when autoScroll='pin-to-bottom'", async () => {
53
+ it("does not render the pin-to-send content marker when autoScroll='pin-to-bottom'", async () => {
52
54
  const screen = render(
53
55
  <TestWrapper>
54
56
  <CopilotChatView autoScroll="pin-to-bottom" messages={sampleMessages} />
55
57
  </TestWrapper>,
56
58
  );
57
59
  await waitForMount(screen);
58
- const spacer = screen.container.querySelector("[data-pin-to-send-spacer]");
59
- expect(spacer).toBeNull();
60
+ const content = screen.container.querySelector(
61
+ "[data-pin-to-send-content]",
62
+ );
63
+ expect(content).toBeNull();
60
64
  });
61
65
 
62
- it("does not render the spacer when autoScroll='none'", async () => {
66
+ it("does not render the pin-to-send content marker when autoScroll='none'", async () => {
63
67
  const screen = render(
64
68
  <TestWrapper>
65
69
  <CopilotChatView autoScroll="none" messages={sampleMessages} />
66
70
  </TestWrapper>,
67
71
  );
68
72
  await waitForMount(screen);
69
- const spacer = screen.container.querySelector("[data-pin-to-send-spacer]");
70
- expect(spacer).toBeNull();
73
+ const content = screen.container.querySelector(
74
+ "[data-pin-to-send-content]",
75
+ );
76
+ expect(content).toBeNull();
71
77
  });
72
78
 
73
79
  it("boolean true still maps to pin-to-bottom (back-compat)", async () => {
@@ -77,8 +83,10 @@ describe("CopilotChatView pin-to-send mode", () => {
77
83
  </TestWrapper>,
78
84
  );
79
85
  await waitForMount(screen);
80
- const spacer = screen.container.querySelector("[data-pin-to-send-spacer]");
81
- expect(spacer).toBeNull();
86
+ const content = screen.container.querySelector(
87
+ "[data-pin-to-send-content]",
88
+ );
89
+ expect(content).toBeNull();
82
90
  });
83
91
 
84
92
  it("boolean false still maps to none (back-compat)", async () => {
@@ -88,7 +96,9 @@ describe("CopilotChatView pin-to-send mode", () => {
88
96
  </TestWrapper>,
89
97
  );
90
98
  await waitForMount(screen);
91
- const spacer = screen.container.querySelector("[data-pin-to-send-spacer]");
92
- expect(spacer).toBeNull();
99
+ const content = screen.container.querySelector(
100
+ "[data-pin-to-send-content]",
101
+ );
102
+ expect(content).toBeNull();
93
103
  });
94
104
  });
@@ -33,9 +33,8 @@ function setHeight(el: HTMLElement, height: number) {
33
33
  function HarnessInner({ topOffset }: { topOffset: number }) {
34
34
  const scrollRef = useRef<HTMLDivElement>(null);
35
35
  const contentRef = useRef<HTMLDivElement>(null);
36
- const spacerRef = useRef<HTMLDivElement>(null);
37
36
 
38
- usePinToSend({ scrollRef, contentRef, spacerRef, topOffset });
37
+ usePinToSend({ scrollRef, contentRef, topOffset });
39
38
 
40
39
  return (
41
40
  <div ref={scrollRef} data-testid="scroll">
@@ -50,7 +49,6 @@ function HarnessInner({ topOffset }: { topOffset: number }) {
50
49
  user msg 2
51
50
  </div>
52
51
  </div>
53
- <div ref={spacerRef} data-testid="spacer" style={{ height: 0 }} />
54
52
  </div>
55
53
  );
56
54
  }
@@ -80,30 +78,42 @@ beforeEach(() => {
80
78
  });
81
79
 
82
80
  describe("usePinToSend", () => {
83
- it("sets spacer height to viewportHeight - userMessageHeight - topOffset on new send", async () => {
81
+ it("sets content min-height to userMsgPos + viewportHeight - topOffset on new send", async () => {
84
82
  const { rerender, getByTestId } = render(
85
83
  <Harness lastUserMessage={{ id: null, sendNonce: 0 }} />,
86
84
  );
87
85
 
88
86
  const scroll = getByTestId("scroll");
89
- const spacer = getByTestId("spacer");
87
+ const content = getByTestId("content");
90
88
  setHeight(scroll, 800);
91
89
 
92
90
  const userMsg = scroll.querySelector(
93
91
  '[data-message-id="m3"]',
94
92
  ) as HTMLElement;
95
- setHeight(userMsg, 40);
93
+ // Mock user msg at scroll offset 400, height 40, no top padding.
94
+ userMsg.getBoundingClientRect = () =>
95
+ ({
96
+ top: 400,
97
+ left: 0,
98
+ right: 100,
99
+ bottom: 440,
100
+ width: 100,
101
+ height: 40,
102
+ x: 0,
103
+ y: 400,
104
+ toJSON: () => ({}),
105
+ }) as DOMRect;
96
106
 
97
107
  act(() => {
98
108
  rerender(<Harness lastUserMessage={{ id: "m3", sendNonce: 1 }} />);
99
109
  });
100
110
 
101
- // viewport=800, userMsg=40, topOffset=16
102
- // spacer = max(0, 800 - 40 - 16) = 744
103
- expect(spacer.style.height).toBe("744px");
111
+ // userMsgPos=400, paddingTop=0, viewport=800, topOffset=16
112
+ // minHeight = 400 + 0 + 800 - 16 = 1184
113
+ expect(content.style.minHeight).toBe("1184px");
104
114
  });
105
115
 
106
- it("calls scrollTo with targetEl.offsetTop - topOffset on new send", async () => {
116
+ it("calls scrollTo with userMsgPos + paddingTop - topOffset on new send", async () => {
107
117
  const { rerender, getByTestId } = render(
108
118
  <Harness lastUserMessage={{ id: null, sendNonce: 0 }} />,
109
119
  );
@@ -115,7 +125,6 @@ describe("usePinToSend", () => {
115
125
  const userMsg = scroll.querySelector(
116
126
  '[data-message-id="m3"]',
117
127
  ) as HTMLElement;
118
- setHeight(userMsg, 40);
119
128
  // computeOffsetTop uses getBoundingClientRect; mock top=400 on userMsg and top=0 on scroll
120
129
  // so that elRect.top - stopRect.top + scrollEl.scrollTop = 400 - 0 + 0 = 400.
121
130
  userMsg.getBoundingClientRect = () =>
@@ -146,50 +155,36 @@ describe("usePinToSend", () => {
146
155
  });
147
156
  });
148
157
 
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");
158
+ it("clears content min-height on cleanup so the next send resets the floor", async () => {
159
+ const { rerender, unmount, getByTestId } = render(
160
+ <Harness lastUserMessage={{ id: null, sendNonce: 0 }} />,
161
+ );
162
+ const scroll = getByTestId("scroll");
163
+ const content = getByTestId("content");
164
+ setHeight(scroll, 800);
165
+ const userMsg = scroll.querySelector(
166
+ '[data-message-id="m3"]',
167
+ ) as HTMLElement;
168
+ userMsg.getBoundingClientRect = () =>
169
+ ({
170
+ top: 400,
171
+ left: 0,
172
+ right: 100,
173
+ bottom: 440,
174
+ width: 100,
175
+ height: 40,
176
+ x: 0,
177
+ y: 400,
178
+ toJSON: () => ({}),
179
+ }) as DOMRect;
179
180
 
180
- // Simulate content growing — spacer should shrink
181
- setHeight(content, 600);
182
- act(() => observed?.());
183
- expect(parseInt(spacer.style.height, 10)).toBeLessThan(744);
181
+ act(() => {
182
+ rerender(<Harness lastUserMessage={{ id: "m3", sendNonce: 1 }} />);
183
+ });
184
+ expect(content.style.minHeight).toBe("1184px");
184
185
 
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
- }
186
+ unmount();
187
+ expect(content.style.minHeight).toBe("");
193
188
  });
194
189
 
195
190
  it("cancels the scheduled rAF on unmount (cleanup)", async () => {
@@ -4,19 +4,22 @@ import { LastUserMessageContext } from "../components/chat/last-user-message-con
4
4
  export type UsePinToSendOptions = {
5
5
  scrollRef: React.RefObject<HTMLElement | null>;
6
6
  contentRef: React.RefObject<HTMLElement | null>;
7
- spacerRef: React.RefObject<HTMLElement | null>;
8
7
  topOffset?: number;
9
8
  };
10
9
 
10
+ // Apply a transient min-height to the scroll content so the newly sent user
11
+ // message can scroll to `topOffset` from the viewport top, then let natural
12
+ // content (the streaming assistant response, suggestions, etc.) take over as
13
+ // it grows past that floor. This mirrors the ChatGPT / Claude behavior:
14
+ // user message pinned to top, response streams in below, no extra scrollable
15
+ // whitespace tacked on after generation.
11
16
  export function usePinToSend({
12
17
  scrollRef,
13
18
  contentRef,
14
- spacerRef,
15
19
  topOffset = 16,
16
20
  }: UsePinToSendOptions): void {
17
21
  const { id, sendNonce } = useContext(LastUserMessageContext);
18
22
  const lastNonceRef = useRef<number>(-1);
19
- const currentSpacerHeightRef = useRef<number>(0);
20
23
 
21
24
  useEffect(() => {
22
25
  if (sendNonce === lastNonceRef.current) return;
@@ -25,8 +28,7 @@ export function usePinToSend({
25
28
  if (!id) return;
26
29
  const scrollEl = scrollRef.current;
27
30
  const contentEl = contentRef.current;
28
- const spacerEl = spacerRef.current;
29
- if (!scrollEl || !contentEl || !spacerEl) return;
31
+ if (!scrollEl || !contentEl) return;
30
32
 
31
33
  const escaped =
32
34
  typeof CSS !== "undefined" && CSS.escape
@@ -44,45 +46,34 @@ export function usePinToSend({
44
46
  // whatever was above the element too — including the previous message's
45
47
  // trailing copy button).
46
48
  const viewportHeight = scrollEl.clientHeight;
47
- const userMessageHeight = targetEl.getBoundingClientRect().height;
48
49
  const paddingTop = parseFloat(getComputedStyle(targetEl).paddingTop) || 0;
49
- const bubbleHeight = Math.max(0, userMessageHeight - paddingTop);
50
- const spacerHeight = Math.max(0, viewportHeight - bubbleHeight - topOffset);
50
+ const userMsgOffsetInScroll = computeOffsetTop(targetEl, scrollEl);
51
51
 
52
- spacerEl.style.height = `${spacerHeight}px`;
53
- currentSpacerHeightRef.current = spacerHeight;
52
+ // The minimum scrollHeight that lets the bubble land at `topOffset` from
53
+ // the viewport top is: `bubbleTop + viewportHeight - topOffset`, where
54
+ // `bubbleTop = userMsgOffsetInScroll + paddingTop`. We apply this as a
55
+ // min-height on the content element. Once the natural content (response,
56
+ // padding, suggestions) grows past this floor, the floor is irrelevant
57
+ // and the layout reflects only the actual content — no leftover spacer
58
+ // whitespace at the bottom after generation.
59
+ const requiredScrollHeight =
60
+ userMsgOffsetInScroll + paddingTop + viewportHeight - topOffset;
61
+ contentEl.style.minHeight = `${Math.max(0, requiredScrollHeight)}px`;
54
62
 
55
63
  const raf = requestAnimationFrame(() => {
56
64
  // Scroll so the BUBBLE is `topOffset` from the viewport top — the
57
65
  // padding above the bubble ends up scrolled off-screen.
58
- const targetTop =
59
- computeOffsetTop(targetEl, scrollEl) + paddingTop - topOffset;
66
+ const targetTop = userMsgOffsetInScroll + paddingTop - topOffset;
60
67
  scrollEl.scrollTo({ top: Math.max(0, targetTop), behavior: "smooth" });
61
68
  });
62
69
 
63
- // Shrink-only ResizeObserver: as the assistant response grows below the
64
- // anchored user message, collapse the spacer by the same amount so total
65
- // scrollable space below the bubble stays constant (and the bubble stays
66
- // pinned). Never grow the spacer after initial sizing.
67
- const ro = new ResizeObserver(() => {
68
- if (!contentEl || !spacerEl || !scrollEl) return;
69
- const contentHeight = contentEl.getBoundingClientRect().height;
70
- const targetOffsetWithinContent = computeOffsetTop(targetEl, contentEl);
71
- const consumedBelow =
72
- contentHeight - targetOffsetWithinContent - userMessageHeight;
73
- const remaining = Math.max(0, spacerHeight - consumedBelow);
74
- if (remaining < currentSpacerHeightRef.current) {
75
- spacerEl.style.height = `${remaining}px`;
76
- currentSpacerHeightRef.current = remaining;
77
- }
78
- });
79
- ro.observe(contentEl);
80
-
81
70
  return () => {
82
71
  cancelAnimationFrame(raf);
83
- ro.disconnect();
72
+ // Clear the floor on cleanup so a subsequent send (with a different
73
+ // user-message position) starts from a clean slate.
74
+ contentEl.style.minHeight = "";
84
75
  };
85
- }, [id, sendNonce, scrollRef, contentRef, spacerRef, topOffset]);
76
+ }, [id, sendNonce, scrollRef, contentRef, topOffset]);
86
77
  }
87
78
 
88
79
  // Compute the offset of el relative to stopAt, accounting for stopAt's current scrollTop.