Skip to content

Commit 804b2e2

Browse files
authored
feat: useRecordUserAction hook + runtime wiring (CPK-7587) (CopilotKit#4839)
## Summary Three-tier wiring on the CopilotKit side, mirroring `useThreads`, to surface user UI signals into CopilotKit Intelligence's self-learning loop. Companion change in `CopilotKit/Intelligence` (PR CopilotKit#192) lands the connector + schema. - **Runtime client** — `CopilotKitIntelligence.recordUserAction(...)` hits the idempotent platform endpoint `${apiUrl}/connector/user-actions/record/:clientEventId`. Auth via the deployment-level Intel API key (Bearer); the Intel key never reaches the browser. - **Runtime handler** — `handleRecordUserAction` resolves the Intel user via `resolveIntelligenceUser`, forwards to the platform client, returns `{ id, duplicate }`. - **Fetch router** — `POST /user-actions` wired in (`user-actions/record` `RouteInfo` variant + dispatch case). - **React hook** — `useRecordUserAction()` and `useRecordUserActionInCurrentThread()` in `@copilotkit/react-core/v2`. Auto-generates a UUID `clientEventId` per call so retries are idempotent by default. Throws when `runtimeUrl` is absent. Linear: CPK-7587 ## Test plan - [ ] CI green on this PR - [ ] Companion Intelligence PR CopilotKit#192 merged or coordinated 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents 7d537fa + 4d32433 commit 804b2e2

37 files changed

Lines changed: 3831 additions & 746 deletions
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import React, { useEffect, useState } from "react";
2+
import type { Meta, StoryObj } from "@storybook/react";
3+
import { IntelligenceIndicatorView } from "@copilotkit/react-core/v2";
4+
import type { IntelligenceIndicatorViewProps } from "@copilotkit/react-core/v2";
5+
6+
/**
7+
* `IntelligenceIndicatorView` is the presentational face of the
8+
* "CopilotKit Intelligence" indicator — the default rendered by the
9+
* `intelligenceIndicator` slot on `CopilotChat`. Single-element
10+
* three-stage design: glassmorphism chrome around a 270° arc that
11+
* morphs to a checkmark on `status="finished"`, after which the
12+
* chrome fades and the label settles to a neutral gray with a slight
13+
* italic-like slant.
14+
*
15+
* The orchestration brain (`IntelligenceIndicator`) is provider- and
16+
* agent-driven, so it's covered by unit/e2e tests rather than stories.
17+
*/
18+
19+
const mockMessage = {
20+
id: "story-message",
21+
role: "assistant" as const,
22+
content: "",
23+
};
24+
25+
const meta = {
26+
title: "UI/IntelligenceIndicator",
27+
component: IntelligenceIndicatorView,
28+
decorators: [
29+
(Story) => (
30+
<div
31+
style={{
32+
display: "flex",
33+
justifyContent: "center",
34+
alignItems: "center",
35+
minHeight: "240px",
36+
padding: "32px",
37+
}}
38+
>
39+
<Story />
40+
</div>
41+
),
42+
],
43+
args: {
44+
message: mockMessage,
45+
label: "CopilotKit Intelligence",
46+
status: "in-progress",
47+
},
48+
} satisfies Meta<typeof IntelligenceIndicatorView>;
49+
50+
export default meta;
51+
type Story = StoryObj<typeof meta>;
52+
53+
/** Active phase: glassmorphism pill with a spinning ring. */
54+
export const InProgress: Story = {
55+
args: { status: "in-progress" },
56+
};
57+
58+
/**
59+
* Persistent finished phase: compact icon + text tag. This is what
60+
* sits quietly in chat history per turn — one tag per agent turn that
61+
* used Intelligence, kept forever in scroll-back.
62+
*/
63+
export const Finished: Story = {
64+
args: { status: "finished" },
65+
};
66+
67+
/** Props-tier slot override demo: a custom label passed through the slot. */
68+
export const WithCustomLabel: Story = {
69+
args: { status: "finished", label: "Recalling memory" },
70+
};
71+
72+
/**
73+
* Animated timeline: enter as `in-progress`, then flip to `finished`
74+
* after ~1.5s. The face's built-in opacity cross-fade swaps the
75+
* spinner pill out for the persistent tag. Click "Restart" to replay.
76+
*/
77+
export const Playground: Story = {
78+
render: (args) => {
79+
const [tick, setTick] = useState(0);
80+
const [status, setStatus] = useState<"in-progress" | "finished">(
81+
"in-progress",
82+
);
83+
useEffect(() => {
84+
setStatus("in-progress");
85+
const t = setTimeout(() => setStatus("finished"), 1500);
86+
return () => clearTimeout(t);
87+
}, [tick]);
88+
return (
89+
<div
90+
style={{
91+
display: "flex",
92+
flexDirection: "column",
93+
gap: "16px",
94+
alignItems: "center",
95+
}}
96+
>
97+
<IntelligenceIndicatorView {...args} status={status} />
98+
<button
99+
onClick={() => setTick((n) => n + 1)}
100+
style={{
101+
padding: "6px 14px",
102+
borderRadius: 6,
103+
border: "1px solid #d4d4d8",
104+
background: "#fafafa",
105+
cursor: "pointer",
106+
}}
107+
>
108+
Restart
109+
</button>
110+
</div>
111+
);
112+
},
113+
};
114+
115+
/**
116+
* A fully custom face — what a slot consumer would render when they
117+
* pass `<CopilotChat intelligenceIndicator={MyCustomIndicator} />`. It
118+
* receives the same `{ status, label, message }` props the brain hands
119+
* the default face, and decides its own look from `status`.
120+
*/
121+
const CustomFace: React.FC<IntelligenceIndicatorViewProps> = ({
122+
status,
123+
label,
124+
}) => {
125+
const isFinished = status === "finished";
126+
return (
127+
<span
128+
style={{
129+
display: "inline-flex",
130+
alignItems: "center",
131+
gap: 8,
132+
padding: "6px 14px",
133+
borderRadius: 999,
134+
background: isFinished ? "#dcfce7" : "#fef3c7",
135+
color: isFinished ? "#166534" : "#92400e",
136+
fontSize: 13,
137+
fontWeight: 600,
138+
transition: "background 360ms ease-out, color 360ms ease-out",
139+
}}
140+
>
141+
<span>{isFinished ? "✅" : "⏳"}</span>
142+
<span>{isFinished ? `${label} — done` : `${label}…`}</span>
143+
</span>
144+
);
145+
};
146+
147+
export const FullyCustomFace: Story = {
148+
render: (args) => {
149+
const [tick, setTick] = useState(0);
150+
const [status, setStatus] = useState<"in-progress" | "finished">(
151+
"in-progress",
152+
);
153+
useEffect(() => {
154+
setStatus("in-progress");
155+
const t = setTimeout(() => setStatus("finished"), 1500);
156+
return () => clearTimeout(t);
157+
}, [tick]);
158+
return (
159+
<div
160+
style={{
161+
display: "flex",
162+
flexDirection: "column",
163+
gap: "16px",
164+
alignItems: "center",
165+
}}
166+
>
167+
<CustomFace {...args} status={status} />
168+
<button
169+
onClick={() => setTick((n) => n + 1)}
170+
style={{
171+
padding: "6px 14px",
172+
borderRadius: 6,
173+
border: "1px solid #d4d4d8",
174+
background: "#fafafa",
175+
cursor: "pointer",
176+
}}
177+
>
178+
Restart
179+
</button>
180+
</div>
181+
);
182+
},
183+
};

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
}
9191
},
9292
"overrides": {
93+
"@ag-ui/mcp-middleware>@ag-ui/client": "0.0.53",
9394
"streamdown>react": "^19.0.0",
9495
"@types/react": "19.1.8",
9596
"@types/react-dom": "^19.0.2",

packages/react-core/src/v2/components/chat/CopilotChatMessageView.tsx

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ import { twMerge } from "tailwind-merge";
2525
import { useRenderActivityMessage, useRenderCustomMessages } from "../../hooks";
2626
import { useCopilotKit } from "../../providers/CopilotKitProvider";
2727
import { useCopilotChatConfiguration } from "../../providers/CopilotChatConfigurationProvider";
28-
import { IntelligenceIndicator } from "../intelligence-indicator";
28+
import {
29+
IntelligenceIndicator,
30+
getIntelligenceTurnAnchors,
31+
} from "../intelligence-indicator";
32+
import type { IntelligenceIndicatorView } from "../intelligence-indicator";
2933
import { DEFAULT_AGENT_ID } from "@copilotkit/shared";
3034

3135
/**
@@ -353,6 +357,7 @@ export type CopilotChatMessageViewProps = Omit<
353357
userMessage: typeof CopilotChatUserMessage;
354358
reasoningMessage: typeof CopilotChatReasoningMessage;
355359
cursor: typeof CopilotChatMessageView.Cursor;
360+
intelligenceIndicator: typeof IntelligenceIndicatorView;
356361
},
357362
{
358363
isRunning?: boolean;
@@ -380,6 +385,7 @@ export function CopilotChatMessageView({
380385
userMessage,
381386
reasoningMessage,
382387
cursor,
388+
intelligenceIndicator,
383389
isRunning = false,
384390
children,
385391
className,
@@ -534,6 +540,17 @@ export function CopilotChatMessageView({
534540
// eslint-disable-next-line react-hooks/exhaustive-deps
535541
}, [shouldVirtualize, firstMessageId]);
536542

543+
// Map each Intelligence-using turn's anchor message → stable turn id. One
544+
// indicator is emitted per turn (keyed by the turn id) at its anchor, so it
545+
// moves with the anchor without remounting. `getIntelligenceTurnAnchors`
546+
// only yields anchors for turns that invoked the knowledge-base tool, so
547+
// non-Intelligence turns naturally produce an empty map (and the indicator
548+
// itself also hard-gates on intelligence mode).
549+
const intelligenceTurnAnchors = useMemo(
550+
() => getIntelligenceTurnAnchors(deduplicatedMessages),
551+
[deduplicatedMessages],
552+
);
553+
537554
// ---------------------------------------------------------------------------
538555
// Per-message rendering helper (shared by flat and virtual paths)
539556
// ---------------------------------------------------------------------------
@@ -606,19 +623,19 @@ export function CopilotChatMessageView({
606623
);
607624
}
608625

609-
// Auto-mount the IntelligenceIndicator on assistant message slots
610-
// when the runtime is in intelligence mode. The component self-gates
611-
// further (latest matching-assistant slot, pending tool-call grace
612-
// window) so only one pill renders at a time — mounting only for
613-
// assistant messages avoids the per-slot `useAgent` subscription
614-
// and four effects on user/reasoning/activity slots that would just
615-
// return null at the role gate anyway.
616-
if (copilotkit.intelligence !== undefined && message.role === "assistant") {
626+
// Auto-mount the IntelligenceIndicator once per Intelligence-using turn,
627+
// at that turn's anchor message (its first bash-using assistant), keyed by
628+
// the stable turn id. Keying by turn (not message) means the indicator
629+
// moves with the anchor across a hand-off without remounting, and past
630+
// turns keep their own indicator.
631+
const intelligenceTurnId = intelligenceTurnAnchors.get(message.id);
632+
if (intelligenceTurnId !== undefined) {
617633
elements.push(
618634
<IntelligenceIndicator
619-
key={`${message.id}-intelligence`}
635+
key={`intelligence-${intelligenceTurnId}`}
620636
message={message}
621637
agentId={config?.agentId ?? DEFAULT_AGENT_ID}
638+
intelligenceIndicator={intelligenceIndicator}
622639
/>,
623640
);
624641
}

packages/react-core/src/v2/components/chat/CopilotChatView.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ScrollElementContext } from "./scroll-element-context";
99
import type { WithSlots, SlotValue } from "../../lib/slots";
1010
import { renderSlot } from "../../lib/slots";
1111
import CopilotChatMessageView from "./CopilotChatMessageView";
12+
import type { IntelligenceIndicatorView } from "../intelligence-indicator";
1213
import type {
1314
CopilotChatInputProps,
1415
CopilotChatInputMode,
@@ -108,6 +109,12 @@ export type CopilotChatViewProps = WithSlots<
108109
* ```
109110
*/
110111
disclaimer?: SlotValue<React.FC<React.HTMLAttributes<HTMLDivElement>>>;
112+
/**
113+
* Slot for the "Using CopilotKit Intelligence" indicator. Pass-through
114+
* to `CopilotChatMessageView`'s `intelligenceIndicator` slot — accepts a
115+
* className string, a props object, or a replacement component.
116+
*/
117+
intelligenceIndicator?: SlotValue<typeof IntelligenceIndicatorView>;
111118
} & React.HTMLAttributes<HTMLDivElement>
112119
>;
113120

@@ -163,6 +170,8 @@ export function CopilotChatView({
163170
hasExplicitThreadId = false,
164171
// Deprecated — forwarded to input slot
165172
disclaimer,
173+
// Pass-through to CopilotChatMessageView's intelligenceIndicator slot
174+
intelligenceIndicator,
166175
children,
167176
className,
168177
...props
@@ -239,6 +248,7 @@ export function CopilotChatView({
239248
const BoundMessageView = renderSlot(messageView, CopilotChatMessageView, {
240249
messages,
241250
isRunning,
251+
intelligenceIndicator,
242252
});
243253

244254
const BoundInput = renderSlot(input, CopilotChatInput, {

0 commit comments

Comments
 (0)