@copilotkit/react-core 1.56.1 → 1.56.2-canary.test-welcome-screen
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-CSJw5BG8.cjs → copilotkit-By2G6-Zx.cjs} +250 -63
- package/dist/copilotkit-By2G6-Zx.cjs.map +1 -0
- package/dist/{copilotkit-CCbxm6JM.d.mts → copilotkit-DFaI4j2r.d.mts} +64 -18
- package/dist/copilotkit-DFaI4j2r.d.mts.map +1 -0
- package/dist/{copilotkit-BtP7w7cT.d.cts → copilotkit-Dg4r4Gi_.d.cts} +64 -18
- package/dist/copilotkit-Dg4r4Gi_.d.cts.map +1 -0
- package/dist/{copilotkit-Cj2ZIxVr.mjs → copilotkit-PzJlPKcU.mjs} +251 -64
- package/dist/copilotkit-PzJlPKcU.mjs.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +2 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +2 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.umd.js +31 -44
- package/dist/index.umd.js.map +1 -1
- package/dist/v2/index.cjs +1 -1
- package/dist/v2/index.css +1 -1
- package/dist/v2/index.d.cts +2 -2
- package/dist/v2/index.d.mts +2 -2
- package/dist/v2/index.mjs +1 -1
- package/dist/v2/index.umd.js +249 -66
- package/dist/v2/index.umd.js.map +1 -1
- package/package.json +6 -6
- package/src/components/copilot-provider/__tests__/v1-explicit-threadid-bridge.test.tsx +107 -0
- package/src/components/copilot-provider/copilotkit.tsx +6 -1
- package/src/context/__tests__/threads-context.test.tsx +116 -3
- package/src/context/threads-context.tsx +18 -1
- package/src/v2/components/chat/CopilotChat.tsx +91 -4
- package/src/v2/components/chat/CopilotChatInput.tsx +22 -0
- package/src/v2/components/chat/CopilotChatView.tsx +206 -11
- package/src/v2/components/chat/__tests__/CopilotChat.absentThreadConnect.test.tsx +66 -0
- package/src/v2/components/chat/__tests__/CopilotChat.welcomeGate.test.tsx +186 -0
- package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +300 -2
- package/src/v2/components/chat/__tests__/CopilotChatView.connectingGate.test.tsx +56 -0
- package/src/v2/components/chat/__tests__/CopilotChatView.pinToSend.test.tsx +94 -0
- package/src/v2/components/chat/__tests__/copilot-chat-throttle.test.tsx +0 -1
- package/src/v2/components/chat/__tests__/normalize-auto-scroll.test.ts +37 -0
- package/src/v2/components/chat/index.ts +2 -0
- package/src/v2/components/chat/last-user-message-context.ts +21 -0
- package/src/v2/components/chat/normalize-auto-scroll.ts +17 -0
- package/src/v2/components/license-warning-banner.tsx +20 -1
- package/src/v2/hooks/__tests__/use-agent-stability.test.tsx +6 -0
- package/src/v2/hooks/__tests__/use-agent-thread-isolation.test.tsx +6 -0
- package/src/v2/hooks/__tests__/use-agent-throttle.test.tsx +76 -50
- package/src/v2/hooks/__tests__/use-pin-to-send.test.tsx +219 -0
- package/src/v2/hooks/__tests__/use-threads.test.tsx +68 -0
- package/src/v2/hooks/use-agent.tsx +34 -77
- package/src/v2/hooks/use-pin-to-send.ts +94 -0
- package/src/v2/hooks/use-threads.tsx +55 -12
- package/src/v2/providers/CopilotChatConfigurationProvider.tsx +29 -1
- package/src/v2/providers/CopilotKitProvider.tsx +2 -11
- package/src/v2/providers/__tests__/CopilotChatConfigurationProvider.test.tsx +106 -0
- package/dist/copilotkit-BtP7w7cT.d.cts.map +0 -1
- package/dist/copilotkit-CCbxm6JM.d.mts.map +0 -1
- package/dist/copilotkit-CSJw5BG8.cjs.map +0 -1
- package/dist/copilotkit-Cj2ZIxVr.mjs.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@copilotkit/react-core",
|
|
3
|
-
"version": "1.56.
|
|
3
|
+
"version": "1.56.2-canary.test-welcome-screen",
|
|
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/
|
|
77
|
-
"@copilotkit/core": "1.56.
|
|
78
|
-
"@copilotkit/shared": "1.56.
|
|
79
|
-
"@copilotkit/web-inspector": "1.56.
|
|
80
|
-
"@copilotkit/
|
|
76
|
+
"@copilotkit/a2ui-renderer": "1.56.2-canary.test-welcome-screen",
|
|
77
|
+
"@copilotkit/core": "1.56.2-canary.test-welcome-screen",
|
|
78
|
+
"@copilotkit/shared": "1.56.2-canary.test-welcome-screen",
|
|
79
|
+
"@copilotkit/web-inspector": "1.56.2-canary.test-welcome-screen",
|
|
80
|
+
"@copilotkit/runtime-client-gql": "1.56.2-canary.test-welcome-screen"
|
|
81
81
|
},
|
|
82
82
|
"devDependencies": {
|
|
83
83
|
"@tailwindcss/cli": "^4.1.11",
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen, act } from "@testing-library/react";
|
|
3
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
4
|
+
import { CopilotKit } from "../copilotkit";
|
|
5
|
+
import { useCopilotContext } from "../../../context/copilot-context";
|
|
6
|
+
import { useCopilotChatConfiguration } from "../../../v2";
|
|
7
|
+
import type { CopilotKitProps } from "../copilotkit-props";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Probe that reads hasExplicitThreadId from the CopilotChatConfigurationProvider
|
|
11
|
+
* that the v1 <CopilotKit> bridge renders. This is the surface CopilotChat
|
|
12
|
+
* itself reads from to gate /connect and the welcome screen.
|
|
13
|
+
*/
|
|
14
|
+
function ExplicitProbe() {
|
|
15
|
+
const config = useCopilotChatConfiguration();
|
|
16
|
+
return (
|
|
17
|
+
<>
|
|
18
|
+
<div data-testid="explicit">{String(config?.hasExplicitThreadId)}</div>
|
|
19
|
+
<div data-testid="threadId">{config?.threadId ?? ""}</div>
|
|
20
|
+
</>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Exposes the v1 context's setThreadId so tests can drive the
|
|
26
|
+
* auto → explicit transition from outside React.
|
|
27
|
+
*/
|
|
28
|
+
function SetThreadIdButton({ nextId }: { nextId: string }) {
|
|
29
|
+
const { setThreadId } = useCopilotContext();
|
|
30
|
+
return (
|
|
31
|
+
<button data-testid="setThread" onClick={() => setThreadId(nextId)}>
|
|
32
|
+
set
|
|
33
|
+
</button>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// `agents__unsafe_dev_only` isn't declared on v1 CopilotKitProps but is
|
|
38
|
+
// forwarded via spread to the v2 provider underneath. Cast once here rather
|
|
39
|
+
// than every render.
|
|
40
|
+
type V1Props = CopilotKitProps & {
|
|
41
|
+
agents__unsafe_dev_only?: Record<string, unknown>;
|
|
42
|
+
};
|
|
43
|
+
const CopilotKitAny = CopilotKit as unknown as React.FC<V1Props>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Regression coverage for fix/welcome-not-showing-at-all at the v1 bridge
|
|
47
|
+
* boundary. The v1 <CopilotKit> wrapper pipes a ThreadsProvider-minted UUID
|
|
48
|
+
* through as `threadId`, but that UUID is NOT a caller choice — the bridge
|
|
49
|
+
* must mark it as non-explicit so downstream consumers don't treat it as a
|
|
50
|
+
* real backend thread. These tests verify the signal makes it all the way
|
|
51
|
+
* through to CopilotChatConfigurationProvider.
|
|
52
|
+
*/
|
|
53
|
+
describe("v1 <CopilotKit> bridge → hasExplicitThreadId", () => {
|
|
54
|
+
// Silence the in-dev/test "missing runtimeUrl" warning — we pass publicApiKey.
|
|
55
|
+
let warnSpy: ReturnType<typeof vi.spyOn>;
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
58
|
+
});
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
warnSpy.mockRestore();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("is false on mount when no threadId prop is supplied", () => {
|
|
64
|
+
render(
|
|
65
|
+
<CopilotKitAny publicApiKey="test-key">
|
|
66
|
+
<ExplicitProbe />
|
|
67
|
+
</CopilotKitAny>,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// ThreadsProvider auto-minted the UUID — it's not a caller-picked thread.
|
|
71
|
+
expect(screen.getByTestId("explicit").textContent).toBe("false");
|
|
72
|
+
// threadId still resolves to a value (mock-thread-id from setupTests),
|
|
73
|
+
// but downstream consumers must NOT treat it as real.
|
|
74
|
+
expect(screen.getByTestId("threadId").textContent).toBe("mock-thread-id");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("is true when threadId prop is supplied to <CopilotKit>", () => {
|
|
78
|
+
render(
|
|
79
|
+
<CopilotKitAny publicApiKey="test-key" threadId="caller-thread">
|
|
80
|
+
<ExplicitProbe />
|
|
81
|
+
</CopilotKitAny>,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
expect(screen.getByTestId("explicit").textContent).toBe("true");
|
|
85
|
+
expect(screen.getByTestId("threadId").textContent).toBe("caller-thread");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("flips from false to true after setThreadId() is called on the v1 context", () => {
|
|
89
|
+
render(
|
|
90
|
+
<CopilotKitAny publicApiKey="test-key">
|
|
91
|
+
<ExplicitProbe />
|
|
92
|
+
<SetThreadIdButton nextId="user-picked-thread" />
|
|
93
|
+
</CopilotKitAny>,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(screen.getByTestId("explicit").textContent).toBe("false");
|
|
97
|
+
|
|
98
|
+
act(() => {
|
|
99
|
+
screen.getByTestId("setThread").click();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(screen.getByTestId("threadId").textContent).toBe(
|
|
103
|
+
"user-picked-thread",
|
|
104
|
+
);
|
|
105
|
+
expect(screen.getByTestId("explicit").textContent).toBe("true");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -478,7 +478,11 @@ export function CopilotKitInternal(cpkProps: CopilotKitProps) {
|
|
|
478
478
|
}
|
|
479
479
|
}, [props.agent]);
|
|
480
480
|
|
|
481
|
-
const {
|
|
481
|
+
const {
|
|
482
|
+
threadId,
|
|
483
|
+
setThreadId: setInternalThreadId,
|
|
484
|
+
isThreadIdExplicit,
|
|
485
|
+
} = useThreads();
|
|
482
486
|
|
|
483
487
|
const setThreadId = useCallback(
|
|
484
488
|
(value: SetStateAction<string>) => {
|
|
@@ -757,6 +761,7 @@ export function CopilotKitInternal(cpkProps: CopilotKitProps) {
|
|
|
757
761
|
// isModalDefaultOpen={isModalDefaultOpen}
|
|
758
762
|
agentId={props.agent ?? "default"}
|
|
759
763
|
threadId={threadId}
|
|
764
|
+
hasExplicitThreadId={isThreadIdExplicit}
|
|
760
765
|
>
|
|
761
766
|
<CopilotContext.Provider value={copilotContextValue}>
|
|
762
767
|
<CopilotListeners />
|
|
@@ -1,10 +1,26 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { render, screen, act } from "@testing-library/react";
|
|
3
|
+
import { describe, it, expect } from "vitest";
|
|
3
4
|
import { ThreadsProvider, useThreads } from "../threads-context";
|
|
4
5
|
|
|
5
6
|
function ThreadIdViewer() {
|
|
6
|
-
const { threadId } = useThreads();
|
|
7
|
-
return
|
|
7
|
+
const { threadId, isThreadIdExplicit } = useThreads();
|
|
8
|
+
return (
|
|
9
|
+
<>
|
|
10
|
+
<div data-testid="threadId">{threadId}</div>
|
|
11
|
+
<div data-testid="isExplicit">{String(isThreadIdExplicit)}</div>
|
|
12
|
+
</>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Exposes setThreadId to the test so it can trigger the auto→explicit flip.
|
|
17
|
+
function ThreadIdController({ nextId }: { nextId: string }) {
|
|
18
|
+
const { setThreadId } = useThreads();
|
|
19
|
+
return (
|
|
20
|
+
<button data-testid="setThread" onClick={() => setThreadId(nextId)}>
|
|
21
|
+
set
|
|
22
|
+
</button>
|
|
23
|
+
);
|
|
8
24
|
}
|
|
9
25
|
|
|
10
26
|
describe("ThreadsProvider", () => {
|
|
@@ -25,4 +41,101 @@ describe("ThreadsProvider", () => {
|
|
|
25
41
|
|
|
26
42
|
expect(screen.getByTestId("threadId").textContent).toBe("customer-thread");
|
|
27
43
|
});
|
|
44
|
+
|
|
45
|
+
describe("isThreadIdExplicit", () => {
|
|
46
|
+
it("is false on first mount when no threadId prop is supplied", () => {
|
|
47
|
+
// Auto-minted UUID — the backend has never seen it, so downstream
|
|
48
|
+
// consumers (e.g. /connect) must NOT treat this as a real thread.
|
|
49
|
+
render(
|
|
50
|
+
<ThreadsProvider>
|
|
51
|
+
<ThreadIdViewer />
|
|
52
|
+
</ThreadsProvider>,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(screen.getByTestId("threadId").textContent).toBe("mock-thread-id");
|
|
56
|
+
expect(screen.getByTestId("isExplicit").textContent).toBe("false");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("is true when threadId prop is supplied on mount", () => {
|
|
60
|
+
render(
|
|
61
|
+
<ThreadsProvider threadId="customer-thread">
|
|
62
|
+
<ThreadIdViewer />
|
|
63
|
+
</ThreadsProvider>,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(screen.getByTestId("threadId").textContent).toBe(
|
|
67
|
+
"customer-thread",
|
|
68
|
+
);
|
|
69
|
+
expect(screen.getByTestId("isExplicit").textContent).toBe("true");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("flips from false to true after setThreadId() is called", () => {
|
|
73
|
+
render(
|
|
74
|
+
<ThreadsProvider>
|
|
75
|
+
<ThreadIdViewer />
|
|
76
|
+
<ThreadIdController nextId="user-picked-thread" />
|
|
77
|
+
</ThreadsProvider>,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
expect(screen.getByTestId("isExplicit").textContent).toBe("false");
|
|
81
|
+
|
|
82
|
+
act(() => {
|
|
83
|
+
screen.getByTestId("setThread").click();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(screen.getByTestId("threadId").textContent).toBe(
|
|
87
|
+
"user-picked-thread",
|
|
88
|
+
);
|
|
89
|
+
expect(screen.getByTestId("isExplicit").textContent).toBe("true");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("reverts to false when an explicit prop is removed and setThreadId was never called", () => {
|
|
93
|
+
// Current contract: explicitness via the `threadId` prop is prop-derived,
|
|
94
|
+
// so removing the prop returns the provider to its auto-minted state.
|
|
95
|
+
// Pinning this guards against an accidental "sticky explicit" regression.
|
|
96
|
+
const { rerender } = render(
|
|
97
|
+
<ThreadsProvider threadId="customer-thread">
|
|
98
|
+
<ThreadIdViewer />
|
|
99
|
+
</ThreadsProvider>,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
expect(screen.getByTestId("isExplicit").textContent).toBe("true");
|
|
103
|
+
|
|
104
|
+
rerender(
|
|
105
|
+
<ThreadsProvider>
|
|
106
|
+
<ThreadIdViewer />
|
|
107
|
+
</ThreadsProvider>,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
expect(screen.getByTestId("threadId").textContent).toBe("mock-thread-id");
|
|
111
|
+
expect(screen.getByTestId("isExplicit").textContent).toBe("false");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("stays true after prop is removed if setThreadId was called while prop was present", () => {
|
|
115
|
+
// Once the caller has touched setThreadId, explicitness is sticky —
|
|
116
|
+
// the internal "user picked a thread" flag outlives any prop churn.
|
|
117
|
+
const { rerender } = render(
|
|
118
|
+
<ThreadsProvider threadId="customer-thread">
|
|
119
|
+
<ThreadIdViewer />
|
|
120
|
+
<ThreadIdController nextId="user-picked-thread" />
|
|
121
|
+
</ThreadsProvider>,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
act(() => {
|
|
125
|
+
screen.getByTestId("setThread").click();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
rerender(
|
|
129
|
+
<ThreadsProvider>
|
|
130
|
+
<ThreadIdViewer />
|
|
131
|
+
<ThreadIdController nextId="user-picked-thread" />
|
|
132
|
+
</ThreadsProvider>,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(screen.getByTestId("threadId").textContent).toBe(
|
|
136
|
+
"user-picked-thread",
|
|
137
|
+
);
|
|
138
|
+
expect(screen.getByTestId("isExplicit").textContent).toBe("true");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
28
141
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React, {
|
|
2
2
|
createContext,
|
|
3
|
+
useCallback,
|
|
3
4
|
useContext,
|
|
4
5
|
useState,
|
|
5
6
|
ReactNode,
|
|
@@ -10,6 +11,12 @@ import { randomUUID } from "@copilotkit/shared";
|
|
|
10
11
|
export interface ThreadsContextValue {
|
|
11
12
|
threadId: string;
|
|
12
13
|
setThreadId: (value: SetStateAction<string>) => void;
|
|
14
|
+
// True when the current threadId was chosen by the caller — either via
|
|
15
|
+
// the `threadId` prop on <CopilotKit> / <ThreadsProvider>, or via a later
|
|
16
|
+
// setThreadId() call. False when the provider minted a UUID on first
|
|
17
|
+
// mount so downstream consumers don't have to treat that placeholder as
|
|
18
|
+
// a real backend thread.
|
|
19
|
+
isThreadIdExplicit: boolean;
|
|
13
20
|
}
|
|
14
21
|
|
|
15
22
|
const ThreadsContext = createContext<ThreadsContextValue | undefined>(
|
|
@@ -25,15 +32,25 @@ export function ThreadsProvider({
|
|
|
25
32
|
children,
|
|
26
33
|
threadId: explicitThreadId,
|
|
27
34
|
}: ThreadsProviderProps) {
|
|
28
|
-
const [internalThreadId,
|
|
35
|
+
const [internalThreadId, setInternalThreadId] = useState<string>(() =>
|
|
36
|
+
randomUUID(),
|
|
37
|
+
);
|
|
38
|
+
const [internalIsExplicit, setInternalIsExplicit] = useState<boolean>(false);
|
|
29
39
|
|
|
30
40
|
const threadId = explicitThreadId ?? internalThreadId;
|
|
41
|
+
const isThreadIdExplicit = explicitThreadId != null || internalIsExplicit;
|
|
42
|
+
|
|
43
|
+
const setThreadId = useCallback((value: SetStateAction<string>) => {
|
|
44
|
+
setInternalThreadId(value);
|
|
45
|
+
setInternalIsExplicit(true);
|
|
46
|
+
}, []);
|
|
31
47
|
|
|
32
48
|
return (
|
|
33
49
|
<ThreadsContext.Provider
|
|
34
50
|
value={{
|
|
35
51
|
threadId,
|
|
36
52
|
setThreadId,
|
|
53
|
+
isThreadIdExplicit,
|
|
37
54
|
}}
|
|
38
55
|
>
|
|
39
56
|
{children}
|
|
@@ -33,6 +33,10 @@ import {
|
|
|
33
33
|
transcribeAudio,
|
|
34
34
|
TranscriptionError,
|
|
35
35
|
} from "../../lib/transcription-client";
|
|
36
|
+
import {
|
|
37
|
+
LastUserMessageContext,
|
|
38
|
+
type LastUserMessageState,
|
|
39
|
+
} from "./last-user-message-context";
|
|
36
40
|
|
|
37
41
|
export type CopilotChatProps = Omit<
|
|
38
42
|
CopilotChatViewProps,
|
|
@@ -97,10 +101,20 @@ export function CopilotChat({
|
|
|
97
101
|
// Apply priority: props > existing config > defaults
|
|
98
102
|
const resolvedAgentId =
|
|
99
103
|
agentId ?? existingConfig?.agentId ?? DEFAULT_AGENT_ID;
|
|
104
|
+
const providedThreadId = threadId ?? existingConfig?.threadId;
|
|
100
105
|
const resolvedThreadId = useMemo(
|
|
101
|
-
() =>
|
|
102
|
-
[
|
|
106
|
+
() => providedThreadId ?? randomUUID(),
|
|
107
|
+
[providedThreadId],
|
|
103
108
|
);
|
|
109
|
+
// "Explicit" means a caller actually picked this thread — via the
|
|
110
|
+
// `threadId` prop on CopilotChat or a wrapping provider that marked its
|
|
111
|
+
// threadId as caller-chosen. An auto-minted UUID leaking down through a
|
|
112
|
+
// CopilotChatConfigurationProvider (e.g. from the v1 CopilotKit →
|
|
113
|
+
// ThreadsProvider chain) does NOT count; treating it as explicit is
|
|
114
|
+
// what made /connect fire against 404s and the welcome screen stay
|
|
115
|
+
// hidden for fresh empty chats.
|
|
116
|
+
const hasExplicitThreadId =
|
|
117
|
+
!!threadId || !!existingConfig?.hasExplicitThreadId;
|
|
104
118
|
|
|
105
119
|
const { agent } = useAgent({
|
|
106
120
|
agentId: resolvedAgentId,
|
|
@@ -191,7 +205,25 @@ export function CopilotChat({
|
|
|
191
205
|
...restProps
|
|
192
206
|
} = props;
|
|
193
207
|
|
|
208
|
+
// Tracks the last threadId for which connectAgent has completed (success or
|
|
209
|
+
// failure). When the user supplies a threadId, we're in "resume existing
|
|
210
|
+
// thread" mode — the welcome screen should be suppressed until the connect
|
|
211
|
+
// resolves, otherwise switching threads flashes the welcome screen while the
|
|
212
|
+
// new thread's messages are still en route.
|
|
213
|
+
const [lastConnectedThreadId, setLastConnectedThreadId] = useState<
|
|
214
|
+
string | null
|
|
215
|
+
>(null);
|
|
216
|
+
const isConnecting =
|
|
217
|
+
hasExplicitThreadId && lastConnectedThreadId !== resolvedThreadId;
|
|
218
|
+
|
|
194
219
|
useEffect(() => {
|
|
220
|
+
// When the caller hasn't picked a specific thread, resolvedThreadId is a
|
|
221
|
+
// UUID minted locally (either in this CopilotChat or in a wrapping
|
|
222
|
+
// ThreadsProvider). The backend has never seen it, so /connect would
|
|
223
|
+
// always 404 — skip the call. A real thread is only created once the
|
|
224
|
+
// user runs the agent for the first time.
|
|
225
|
+
if (!hasExplicitThreadId) return;
|
|
226
|
+
|
|
195
227
|
let detached = false;
|
|
196
228
|
|
|
197
229
|
// Create a fresh AbortController so we can cancel the HTTP request on cleanup.
|
|
@@ -212,6 +244,25 @@ export function CopilotChat({
|
|
|
212
244
|
// connectAgent already emits via the subscriber system, but catch
|
|
213
245
|
// here to prevent unhandled rejections from unexpected errors.
|
|
214
246
|
console.error("CopilotChat: connectAgent failed", error);
|
|
247
|
+
} finally {
|
|
248
|
+
// Whether the connect succeeded or failed, we're no longer in the
|
|
249
|
+
// transitional "connecting" state for this thread — unblock the
|
|
250
|
+
// welcome-screen-suppression so the view can settle.
|
|
251
|
+
//
|
|
252
|
+
// Defer one animation frame so any trailing React commits from the
|
|
253
|
+
// bootstrap replay (final assistant message content) paint before
|
|
254
|
+
// isConnecting flips off. Without this, suggestions + copy button
|
|
255
|
+
// can briefly appear against an incompletely-laid-out message tree
|
|
256
|
+
// and visibly snap once the last text chunk lands.
|
|
257
|
+
if (!detached) {
|
|
258
|
+
const raf =
|
|
259
|
+
typeof requestAnimationFrame === "function"
|
|
260
|
+
? requestAnimationFrame
|
|
261
|
+
: (cb: () => void) => setTimeout(cb, 16);
|
|
262
|
+
raf(() => {
|
|
263
|
+
if (!detached) setLastConnectedThreadId(resolvedThreadId);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
215
266
|
}
|
|
216
267
|
};
|
|
217
268
|
connect(agent);
|
|
@@ -229,7 +280,7 @@ export function CopilotChat({
|
|
|
229
280
|
};
|
|
230
281
|
// copilotkit is intentionally excluded — it is a stable ref that never changes.
|
|
231
282
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
232
|
-
}, [resolvedThreadId, agent, resolvedAgentId]);
|
|
283
|
+
}, [resolvedThreadId, agent, resolvedAgentId, hasExplicitThreadId]);
|
|
233
284
|
|
|
234
285
|
const onSubmitInput = useCallback(
|
|
235
286
|
async (value: string) => {
|
|
@@ -497,6 +548,37 @@ export function CopilotChat({
|
|
|
497
548
|
[messagesMemoKey],
|
|
498
549
|
);
|
|
499
550
|
|
|
551
|
+
// Compute the ID of the last user message for scroll-pinning logic.
|
|
552
|
+
const lastUserMessageId = useMemo(() => {
|
|
553
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
554
|
+
if (messages[i].role === "user") return messages[i].id;
|
|
555
|
+
}
|
|
556
|
+
return null;
|
|
557
|
+
}, [messages]);
|
|
558
|
+
|
|
559
|
+
// Track a nonce that increments each time a new user message ID appears.
|
|
560
|
+
// Using useState ensures the context value propagates correctly on the
|
|
561
|
+
// render that follows the state update (approach b from the design doc).
|
|
562
|
+
const [sendNonce, setSendNonce] = useState(0);
|
|
563
|
+
// Seed with the current value so restoring a thread with existing messages
|
|
564
|
+
// does not count as a new send. Only later-render id transitions bump.
|
|
565
|
+
const prevLastUserMessageIdRef = useRef<string | null>(lastUserMessageId);
|
|
566
|
+
|
|
567
|
+
useEffect(() => {
|
|
568
|
+
if (
|
|
569
|
+
lastUserMessageId &&
|
|
570
|
+
lastUserMessageId !== prevLastUserMessageIdRef.current
|
|
571
|
+
) {
|
|
572
|
+
setSendNonce((n) => n + 1);
|
|
573
|
+
prevLastUserMessageIdRef.current = lastUserMessageId;
|
|
574
|
+
}
|
|
575
|
+
}, [lastUserMessageId]);
|
|
576
|
+
|
|
577
|
+
const lastUserMessageState = useMemo<LastUserMessageState>(
|
|
578
|
+
() => ({ id: lastUserMessageId, sendNonce }),
|
|
579
|
+
[lastUserMessageId, sendNonce],
|
|
580
|
+
);
|
|
581
|
+
|
|
500
582
|
const finalProps: CopilotChatViewProps = {
|
|
501
583
|
...mergedProps,
|
|
502
584
|
messages,
|
|
@@ -521,6 +603,8 @@ export function CopilotChat({
|
|
|
521
603
|
onDragOver: handleDragOver,
|
|
522
604
|
onDragLeave: handleDragLeave,
|
|
523
605
|
onDrop: handleDrop,
|
|
606
|
+
isConnecting,
|
|
607
|
+
hasExplicitThreadId,
|
|
524
608
|
};
|
|
525
609
|
|
|
526
610
|
// Always create a provider with merged values
|
|
@@ -531,6 +615,7 @@ export function CopilotChat({
|
|
|
531
615
|
<CopilotChatConfigurationProvider
|
|
532
616
|
agentId={resolvedAgentId}
|
|
533
617
|
threadId={resolvedThreadId}
|
|
618
|
+
hasExplicitThreadId={hasExplicitThreadId}
|
|
534
619
|
labels={labels}
|
|
535
620
|
isModalDefaultOpen={isModalDefaultOpen}
|
|
536
621
|
>
|
|
@@ -564,7 +649,9 @@ export function CopilotChat({
|
|
|
564
649
|
{transcriptionError}
|
|
565
650
|
</div>
|
|
566
651
|
)}
|
|
567
|
-
{
|
|
652
|
+
<LastUserMessageContext.Provider value={lastUserMessageState}>
|
|
653
|
+
{RenderedChatView}
|
|
654
|
+
</LastUserMessageContext.Provider>
|
|
568
655
|
</div>
|
|
569
656
|
</CopilotChatConfigurationProvider>
|
|
570
657
|
);
|
|
@@ -87,6 +87,19 @@ type CopilotChatInputRestProps = {
|
|
|
87
87
|
containerRef?: React.Ref<HTMLDivElement>;
|
|
88
88
|
/** Whether to show the disclaimer. Default: true for absolute positioning, false for static */
|
|
89
89
|
showDisclaimer?: boolean;
|
|
90
|
+
/**
|
|
91
|
+
* Set to `true` when the input sits at the bottom of its container as a
|
|
92
|
+
* flex-last-child (visible position is driven by layout, not CSS
|
|
93
|
+
* positioning). Triggers reservation of bottom space for the fixed
|
|
94
|
+
* CopilotKit license banner via the
|
|
95
|
+
* `--copilotkit-license-banner-offset` CSS var so the two don't overlap.
|
|
96
|
+
*
|
|
97
|
+
* Not needed when `positioning === "absolute"`; that mode already pins the
|
|
98
|
+
* input to the bottom and picks up the same reservation automatically.
|
|
99
|
+
* Leave unset (default `false`) for inputs rendered mid-layout such as the
|
|
100
|
+
* welcome screen, where the banner offset would push the input off-center.
|
|
101
|
+
*/
|
|
102
|
+
bottomAnchored?: boolean;
|
|
90
103
|
} & Omit<React.HTMLAttributes<HTMLDivElement>, "onChange">;
|
|
91
104
|
|
|
92
105
|
type CopilotChatInputBaseProps = WithSlots<
|
|
@@ -130,6 +143,7 @@ export function CopilotChatInput({
|
|
|
130
143
|
keyboardHeight = 0,
|
|
131
144
|
containerRef,
|
|
132
145
|
showDisclaimer,
|
|
146
|
+
bottomAnchored = false,
|
|
133
147
|
textArea,
|
|
134
148
|
sendButton,
|
|
135
149
|
startTranscribeButton,
|
|
@@ -1097,6 +1111,14 @@ export function CopilotChatInput({
|
|
|
1097
1111
|
transform:
|
|
1098
1112
|
keyboardHeight > 0 ? `translateY(-${keyboardHeight}px)` : undefined,
|
|
1099
1113
|
transition: "transform 0.2s ease-out",
|
|
1114
|
+
// Reserve room when the fixed license banner is visible so it doesn't
|
|
1115
|
+
// overlap the input. Applied only for bottom-anchored inputs (either
|
|
1116
|
+
// `positioning === "absolute"`, or an explicitly-flagged flex-last-child
|
|
1117
|
+
// input in run state). The welcome-screen input sits mid-layout and
|
|
1118
|
+
// must stay still when the banner is present.
|
|
1119
|
+
...(positioning === "absolute" || bottomAnchored
|
|
1120
|
+
? { paddingBottom: "var(--copilotkit-license-banner-offset, 0px)" }
|
|
1121
|
+
: {}),
|
|
1100
1122
|
}}
|
|
1101
1123
|
{...props}
|
|
1102
1124
|
>
|