@copilotkit/react-core 1.56.2-canary.pin-to-send → 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.
Files changed (35) hide show
  1. package/dist/{copilotkit-D5JT2Pu3.cjs → copilotkit-By2G6-Zx.cjs} +22 -9
  2. package/dist/copilotkit-By2G6-Zx.cjs.map +1 -0
  3. package/dist/{copilotkit-DArT2Iuw.d.mts → copilotkit-DFaI4j2r.d.mts} +3 -1
  4. package/dist/copilotkit-DFaI4j2r.d.mts.map +1 -0
  5. package/dist/{copilotkit-KEc28l8G.d.cts → copilotkit-Dg4r4Gi_.d.cts} +3 -1
  6. package/dist/copilotkit-Dg4r4Gi_.d.cts.map +1 -0
  7. package/dist/{copilotkit-BBYbekCa.mjs → copilotkit-PzJlPKcU.mjs} +22 -9
  8. package/dist/copilotkit-PzJlPKcU.mjs.map +1 -0
  9. package/dist/index.cjs +1 -1
  10. package/dist/index.d.cts +2 -1
  11. package/dist/index.d.cts.map +1 -1
  12. package/dist/index.d.mts +2 -1
  13. package/dist/index.d.mts.map +1 -1
  14. package/dist/index.mjs +1 -1
  15. package/dist/index.umd.js +15 -4
  16. package/dist/index.umd.js.map +1 -1
  17. package/dist/v2/index.cjs +1 -1
  18. package/dist/v2/index.d.cts +1 -1
  19. package/dist/v2/index.d.mts +1 -1
  20. package/dist/v2/index.mjs +1 -1
  21. package/dist/v2/index.umd.js +21 -8
  22. package/dist/v2/index.umd.js.map +1 -1
  23. package/package.json +6 -6
  24. package/src/components/copilot-provider/__tests__/v1-explicit-threadid-bridge.test.tsx +107 -0
  25. package/src/components/copilot-provider/copilotkit.tsx +6 -1
  26. package/src/context/__tests__/threads-context.test.tsx +116 -3
  27. package/src/context/threads-context.tsx +18 -1
  28. package/src/v2/components/chat/CopilotChat.tsx +19 -8
  29. package/src/v2/components/chat/__tests__/CopilotChat.welcomeGate.test.tsx +186 -0
  30. package/src/v2/providers/CopilotChatConfigurationProvider.tsx +29 -1
  31. package/src/v2/providers/__tests__/CopilotChatConfigurationProvider.test.tsx +106 -0
  32. package/dist/copilotkit-BBYbekCa.mjs.map +0 -1
  33. package/dist/copilotkit-D5JT2Pu3.cjs.map +0 -1
  34. package/dist/copilotkit-DArT2Iuw.d.mts.map +0 -1
  35. package/dist/copilotkit-KEc28l8G.d.cts.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@copilotkit/react-core",
3
- "version": "1.56.2-canary.pin-to-send",
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/core": "1.56.2-canary.pin-to-send",
77
- "@copilotkit/shared": "1.56.2-canary.pin-to-send",
78
- "@copilotkit/runtime-client-gql": "1.56.2-canary.pin-to-send",
79
- "@copilotkit/web-inspector": "1.56.2-canary.pin-to-send",
80
- "@copilotkit/a2ui-renderer": "1.56.2-canary.pin-to-send"
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 { threadId, setThreadId: setInternalThreadId } = useThreads();
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 <div data-testid="threadId">{threadId}</div>;
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, setThreadId] = useState<string>(() => randomUUID());
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}
@@ -106,6 +106,15 @@ export function CopilotChat({
106
106
  () => providedThreadId ?? randomUUID(),
107
107
  [providedThreadId],
108
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;
109
118
 
110
119
  const { agent } = useAgent({
111
120
  agentId: resolvedAgentId,
@@ -205,14 +214,15 @@ export function CopilotChat({
205
214
  string | null
206
215
  >(null);
207
216
  const isConnecting =
208
- !!providedThreadId && lastConnectedThreadId !== resolvedThreadId;
217
+ hasExplicitThreadId && lastConnectedThreadId !== resolvedThreadId;
209
218
 
210
219
  useEffect(() => {
211
- // When no threadId was supplied by the caller, resolvedThreadId is a UUID
212
- // minted in this browser tab. The backend has never seen it, so /connect
213
- // would always 404. Skip the call a real thread is only created once
214
- // the user runs the agent for the first time.
215
- if (!providedThreadId) return;
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;
216
226
 
217
227
  let detached = false;
218
228
 
@@ -270,7 +280,7 @@ export function CopilotChat({
270
280
  };
271
281
  // copilotkit is intentionally excluded — it is a stable ref that never changes.
272
282
  // eslint-disable-next-line react-hooks/exhaustive-deps
273
- }, [resolvedThreadId, agent, resolvedAgentId, providedThreadId]);
283
+ }, [resolvedThreadId, agent, resolvedAgentId, hasExplicitThreadId]);
274
284
 
275
285
  const onSubmitInput = useCallback(
276
286
  async (value: string) => {
@@ -594,7 +604,7 @@ export function CopilotChat({
594
604
  onDragLeave: handleDragLeave,
595
605
  onDrop: handleDrop,
596
606
  isConnecting,
597
- hasExplicitThreadId: !!providedThreadId,
607
+ hasExplicitThreadId,
598
608
  };
599
609
 
600
610
  // Always create a provider with merged values
@@ -605,6 +615,7 @@ export function CopilotChat({
605
615
  <CopilotChatConfigurationProvider
606
616
  agentId={resolvedAgentId}
607
617
  threadId={resolvedThreadId}
618
+ hasExplicitThreadId={hasExplicitThreadId}
608
619
  labels={labels}
609
620
  isModalDefaultOpen={isModalDefaultOpen}
610
621
  >
@@ -0,0 +1,186 @@
1
+ import React from "react";
2
+ import { render, screen, waitFor } from "@testing-library/react";
3
+ import { describe, it, expect, beforeEach } from "vitest";
4
+ import { CopilotKitProvider } from "../../../providers/CopilotKitProvider";
5
+ import { CopilotChatConfigurationProvider } from "../../../providers/CopilotChatConfigurationProvider";
6
+ import { CopilotChat } from "../CopilotChat";
7
+ import { MockStepwiseAgent } from "../../../__tests__/utils/test-helpers";
8
+ import { DEFAULT_AGENT_ID } from "@copilotkit/shared";
9
+
10
+ /**
11
+ * Mock agent that records every connectAgent() invocation and resolves
12
+ * immediately with an empty run result. Tracking lives on the class so
13
+ * per-thread clones (from useAgent's WeakMap) share the counter.
14
+ */
15
+ class TrackingAgent extends MockStepwiseAgent {
16
+ static connectCalls: Array<{
17
+ threadId: string | undefined;
18
+ agentId: string | undefined;
19
+ }> = [];
20
+
21
+ static reset() {
22
+ TrackingAgent.connectCalls = [];
23
+ }
24
+
25
+ async connectAgent(
26
+ _params: unknown,
27
+ _subscriber: unknown,
28
+ ): Promise<{ result: unknown; newMessages: [] }> {
29
+ TrackingAgent.connectCalls.push({
30
+ threadId: this.threadId,
31
+ agentId: this.agentId,
32
+ });
33
+ return { result: undefined, newMessages: [] };
34
+ }
35
+ }
36
+
37
+ function renderWithKit(ui: React.ReactNode, agent: TrackingAgent) {
38
+ return render(
39
+ <CopilotKitProvider agents__unsafe_dev_only={{ [DEFAULT_AGENT_ID]: agent }}>
40
+ <div style={{ height: 400 }}>{ui}</div>
41
+ </CopilotKitProvider>,
42
+ );
43
+ }
44
+
45
+ /**
46
+ * Regression coverage for fix/welcome-not-showing-at-all.
47
+ *
48
+ * The underlying bug: the v1 <CopilotKit> wrapper pipes a ThreadsProvider-
49
+ * minted UUID through to CopilotChatConfigurationProvider as `threadId`.
50
+ * CopilotChat previously treated any non-empty providedThreadId as "caller
51
+ * supplied a real backend thread" and (a) fired /connect (→ 404 for an
52
+ * auto-minted UUID) and (b) suppressed the welcome screen forever. The
53
+ * fix threads an `hasExplicitThreadId` signal through the provider chain;
54
+ * these tests pin the contract that /connect and welcome-screen gating
55
+ * now follow that signal rather than `!!threadId`.
56
+ */
57
+ describe("CopilotChat welcome / connect integration", () => {
58
+ beforeEach(() => {
59
+ TrackingAgent.reset();
60
+ });
61
+
62
+ describe("v1 bridge scenario (config provider marks threadId as non-explicit)", () => {
63
+ it("does not call connectAgent and shows the welcome screen", async () => {
64
+ const agent = new TrackingAgent();
65
+ agent.agentId = DEFAULT_AGENT_ID;
66
+
67
+ renderWithKit(
68
+ <CopilotChatConfigurationProvider
69
+ threadId="auto-minted-uuid"
70
+ hasExplicitThreadId={false}
71
+ >
72
+ <CopilotChat />
73
+ </CopilotChatConfigurationProvider>,
74
+ agent,
75
+ );
76
+
77
+ // Give the connect-effect a chance to misfire.
78
+ await new Promise((r) => setTimeout(r, 50));
79
+
80
+ expect(TrackingAgent.connectCalls).toHaveLength(0);
81
+ expect(screen.getByTestId("copilot-welcome-screen")).toBeDefined();
82
+ });
83
+ });
84
+
85
+ describe("plain CopilotChat (no threadId anywhere)", () => {
86
+ it("does not call connectAgent and shows the welcome screen", async () => {
87
+ const agent = new TrackingAgent();
88
+ agent.agentId = DEFAULT_AGENT_ID;
89
+
90
+ renderWithKit(<CopilotChat />, agent);
91
+
92
+ await new Promise((r) => setTimeout(r, 50));
93
+
94
+ expect(TrackingAgent.connectCalls).toHaveLength(0);
95
+ expect(screen.getByTestId("copilot-welcome-screen")).toBeDefined();
96
+ });
97
+ });
98
+
99
+ describe("explicit threadId via CopilotChat prop", () => {
100
+ it("calls connectAgent with that threadId and suppresses the welcome screen", async () => {
101
+ const agent = new TrackingAgent();
102
+ agent.agentId = DEFAULT_AGENT_ID;
103
+
104
+ renderWithKit(<CopilotChat threadId="real-thread" />, agent);
105
+
106
+ await waitFor(() => {
107
+ expect(TrackingAgent.connectCalls.length).toBeGreaterThan(0);
108
+ });
109
+
110
+ // The per-thread clone carries threadId; agentId is the default.
111
+ expect(
112
+ TrackingAgent.connectCalls.some((c) => c.threadId === "real-thread"),
113
+ ).toBe(true);
114
+
115
+ // Welcome screen is suppressed even after connect resolves, because the
116
+ // thread was caller-picked (hasExplicitThreadId=true).
117
+ expect(screen.queryByTestId("copilot-welcome-screen")).toBeNull();
118
+ });
119
+ });
120
+
121
+ describe("explicit threadId via wrapping CopilotChatConfigurationProvider", () => {
122
+ it("inherits explicitness from the provider and connects", async () => {
123
+ const agent = new TrackingAgent();
124
+ agent.agentId = DEFAULT_AGENT_ID;
125
+
126
+ renderWithKit(
127
+ <CopilotChatConfigurationProvider threadId="from-config">
128
+ <CopilotChat />
129
+ </CopilotChatConfigurationProvider>,
130
+ agent,
131
+ );
132
+
133
+ await waitFor(() => {
134
+ expect(TrackingAgent.connectCalls.length).toBeGreaterThan(0);
135
+ });
136
+
137
+ expect(
138
+ TrackingAgent.connectCalls.some((c) => c.threadId === "from-config"),
139
+ ).toBe(true);
140
+ expect(screen.queryByTestId("copilot-welcome-screen")).toBeNull();
141
+ });
142
+ });
143
+
144
+ describe("thread switch between two explicit threads", () => {
145
+ it("keeps the welcome screen hidden across the switch", async () => {
146
+ const agent = new TrackingAgent();
147
+ agent.agentId = DEFAULT_AGENT_ID;
148
+
149
+ const { rerender } = renderWithKit(
150
+ <CopilotChat threadId="thread-a" />,
151
+ agent,
152
+ );
153
+
154
+ await waitFor(() => {
155
+ expect(
156
+ TrackingAgent.connectCalls.some((c) => c.threadId === "thread-a"),
157
+ ).toBe(true);
158
+ });
159
+ // After thread-a's connect resolves, welcome must still be hidden
160
+ // because the thread is caller-picked.
161
+ expect(screen.queryByTestId("copilot-welcome-screen")).toBeNull();
162
+
163
+ rerender(
164
+ <CopilotKitProvider
165
+ agents__unsafe_dev_only={{ [DEFAULT_AGENT_ID]: agent }}
166
+ >
167
+ <div style={{ height: 400 }}>
168
+ <CopilotChat threadId="thread-b" />
169
+ </div>
170
+ </CopilotKitProvider>,
171
+ );
172
+
173
+ // During the switch (lastConnected="thread-a" !== "thread-b") isConnecting
174
+ // is true — welcome must not flash.
175
+ expect(screen.queryByTestId("copilot-welcome-screen")).toBeNull();
176
+
177
+ await waitFor(() => {
178
+ expect(
179
+ TrackingAgent.connectCalls.some((c) => c.threadId === "thread-b"),
180
+ ).toBe(true);
181
+ });
182
+ // And after thread-b's connect resolves.
183
+ expect(screen.queryByTestId("copilot-welcome-screen")).toBeNull();
184
+ });
185
+ });
186
+ });
@@ -45,6 +45,11 @@ export interface CopilotChatConfigurationValue {
45
45
  threadId: string;
46
46
  isModalOpen: boolean;
47
47
  setModalOpen: (open: boolean) => void;
48
+ // True when the current threadId was chosen by the caller rather than
49
+ // silently minted inside the provider chain. Consumers that only make
50
+ // sense against a real backend thread (e.g. /connect, suppressing the
51
+ // welcome screen on switch) gate on this instead of `!!threadId`.
52
+ hasExplicitThreadId: boolean;
48
53
  }
49
54
 
50
55
  // Create the configuration context
@@ -57,13 +62,26 @@ export interface CopilotChatConfigurationProviderProps {
57
62
  labels?: Partial<CopilotChatLabels>;
58
63
  agentId?: string;
59
64
  threadId?: string;
65
+ // Lets internal wrappers (e.g. the v1 CopilotKit bridge, which pipes a
66
+ // ThreadsProvider-minted UUID through as `threadId`) declare that the
67
+ // threadId they are supplying is NOT a caller choice. When omitted, the
68
+ // provider infers explicitness from whether the `threadId` prop itself
69
+ // was supplied.
70
+ hasExplicitThreadId?: boolean;
60
71
  isModalDefaultOpen?: boolean;
61
72
  }
62
73
 
63
74
  // Provider component
64
75
  export const CopilotChatConfigurationProvider: React.FC<
65
76
  CopilotChatConfigurationProviderProps
66
- > = ({ children, labels, agentId, threadId, isModalDefaultOpen }) => {
77
+ > = ({
78
+ children,
79
+ labels,
80
+ agentId,
81
+ threadId,
82
+ hasExplicitThreadId,
83
+ isModalDefaultOpen,
84
+ }) => {
67
85
  const parentConfig = useContext(CopilotChatConfiguration);
68
86
 
69
87
  // Stabilize labels references so that inline objects (new reference on every
@@ -92,6 +110,14 @@ export const CopilotChatConfigurationProvider: React.FC<
92
110
  return randomUUID();
93
111
  }, [threadId, parentConfig?.threadId]);
94
112
 
113
+ // If a caller passed `hasExplicitThreadId`, trust it verbatim (lets the v1
114
+ // bridge mark an auto-minted UUID as non-explicit). Otherwise infer: a
115
+ // threadId supplied as a prop here is by definition a caller choice.
116
+ const ownHasExplicitThreadId =
117
+ hasExplicitThreadId !== undefined ? hasExplicitThreadId : !!threadId;
118
+ const resolvedHasExplicitThreadId =
119
+ ownHasExplicitThreadId || !!parentConfig?.hasExplicitThreadId;
120
+
95
121
  const resolvedDefaultOpen = isModalDefaultOpen ?? true;
96
122
 
97
123
  const [internalModalOpen, setInternalModalOpen] =
@@ -141,6 +167,7 @@ export const CopilotChatConfigurationProvider: React.FC<
141
167
  labels: mergedLabels,
142
168
  agentId: resolvedAgentId,
143
169
  threadId: resolvedThreadId,
170
+ hasExplicitThreadId: resolvedHasExplicitThreadId,
144
171
  isModalOpen: resolvedIsModalOpen,
145
172
  setModalOpen: resolvedSetModalOpen,
146
173
  }),
@@ -148,6 +175,7 @@ export const CopilotChatConfigurationProvider: React.FC<
148
175
  mergedLabels,
149
176
  resolvedAgentId,
150
177
  resolvedThreadId,
178
+ resolvedHasExplicitThreadId,
151
179
  resolvedIsModalOpen,
152
180
  resolvedSetModalOpen,
153
181
  ],